diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 02cd4f7e..f8b2ff88 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,8 +87,8 @@ HWP 파일이 한컴과 다르게 렌더링되면 알려주세요: ### PR 전 체크리스트 ```bash -cargo test # 783+ 테스트 통과 -cargo clippy -- -D warnings # 린트 경고 0건 +cargo test # 793+ 테스트 통과 +cargo clippy --all-targets --all-features # native-skia까지 확인하려면 fontconfig/freetype 개발 패키지가 필요할 수 있음 ``` 두 명령이 모두 통과하는지 확인한 후 PR을 생성해주세요. @@ -122,6 +122,51 @@ cargo run --bin rhwp -- dump-pages sample.hwp -p 3 cargo run --bin rhwp -- dump sample.hwp -s 0 -p 45 ``` +`export-svg`의 기본 출력 폴더는 `output/`입니다. 예를 들어 `sample.hwp`를 한 페이지 문서로 내보내면 `output/sample.svg`, 여러 페이지면 `output/sample_001.svg`처럼 저장됩니다. `-o`를 사용하면 다른 폴더로 보낼 수 있습니다. + +### 렌더러 비교 가이드 + +현재 렌더러 경로는 아래처럼 나뉩니다. + +- **Legacy SVG**: 기본 `cargo run --bin rhwp -- export-svg sample.hwp` +- **Layer SVG**: `RHWP_RENDER_PATH=layer-svg cargo run --bin rhwp -- export-svg sample.hwp` +- **Native Skia PNG**: 테스트 경로에서 `render_page_png_native()`로 검증되며, 현재 별도 `export-png` CLI는 없습니다 +- **Browser Canvas2D / CanvasKit**: `rhwp-studio`에서 기본은 Canvas2D, `http://localhost:7700/?renderer=canvaskit`로 CanvasKit 비교 + - CanvasKit 래스터 모드: `?canvaskitMode=compat`(기본, Canvas2D 유사도 우선) 또는 `?canvaskitMode=default`(CanvasKit 기본 동작) + +SVG를 직접 비교하려면 보통 아래처럼 두 번 내보냅니다. + +```bash +cargo run --bin rhwp -- export-svg sample.hwp -o output/legacy +RHWP_RENDER_PATH=layer-svg cargo run --bin rhwp -- export-svg sample.hwp -o output/layer +``` + +자동 회귀 테스트는 다음 명령을 사용합니다. + +```bash +cargo test layer_svg --lib +RUSTFLAGS='-L native=target/native-libs' cargo test skia --lib --features native-skia + +cd rhwp-studio +npm run e2e # 기본: host Chrome CDP 모드, CanvasKit compat/default 둘 다 실행 +node e2e/text-flow.test.mjs --mode=headless && node e2e/canvaskit-render.test.mjs --mode=headless && RHWP_CANVASKIT_MODE=default node e2e/canvaskit-render.test.mjs --mode=headless +``` + +WSL/CI처럼 호스트 Chrome CDP가 없는 환경에서는 `npm run e2e` 대신 `--mode=headless` 명령을 사용하세요. + +비교 아티팩트는 아래 위치에 남습니다. + +- `output/layer-svg-diff/` — legacy SVG vs layer SVG +- `output/skia-diff/` — layer SVG vs native Skia PNG +- `output/e2e/` 및 `rhwp-studio/e2e/screenshots/` — 브라우저 Canvas2D vs CanvasKit + +- `layer-svg` 비교는 현재 exact match 기준입니다. 한 픽셀이라도 diff가 생기면 테스트가 실패합니다. +- `native-skia` / `CanvasKit` 비교는 exact diff를 계속 저장하고, 별도로 채널 차이가 `8` 이하인 픽셀을 무시한 tolerant diff를 계산합니다. +- 테스트 통과 여부는 tolerant diff 기준으로 판단합니다. 현재 기준은 `native-skia`는 tolerant diff pixel `64` 이하, `CanvasKit`은 tolerant diff ratio `0.25%` 이하입니다. +- tolerant 결과는 최종 허용 예산까지 반영한 값입니다. 즉 통과한 케이스는 tolerant diff가 `0`으로 보고되고, tolerant diff 아티팩트도 남기지 않습니다. exact diff 아티팩트는 참고용으로 계속 생성될 수 있습니다. +- `CanvasKit` e2e는 기본적으로 전체 페이지를 비교합니다. `eq-01`도 다시 전체 페이지 회귀에 포함됩니다. +- 추가로 `equation`처럼 특정 op 자체를 분리해서 추적하고 싶은 기능 회귀는 해당 `layer op` bbox만 잘라서 비교합니다. + 디버그 오버레이는 문단/표에 라벨을 표시합니다: - 문단: `s{섹션}:pi={인덱스} y={좌표}` - 표: `s{섹션}:pi={인덱스} ci={컨트롤} {행}x{열} y={좌표}` diff --git a/Cargo.toml b/Cargo.toml index 31550c92..f44c6f0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,14 +38,17 @@ console_error_panic_hook = { version = "0.1", optional = true } [features] default = ["console_error_panic_hook"] +native-skia = ["dep:skia-safe"] # PDF 내보내기 (네이티브 전용, Task #21) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] svg2pdf = "0.13" usvg = "0.45" +resvg = "0.45" pdf-writer = "0.12" subsetter = "0.2" ttf-parser = "0.25" +skia-safe = { version = "0.93.1", optional = true, default-features = false, features = ["binary-cache", "embed-icudtl", "pdf"] } [target.'cfg(target_arch = "wasm32")'.dependencies] web-sys = { version = "0.3", features = [ diff --git a/README.md b/README.md index 14a407cf..1962f039 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,9 @@ rhwp는 Rust + WebAssembly 기반의 오픈소스 HWP/HWPX 뷰어/에디터입 - HWP 5.0 / HWPX 파서, 문단·표·수식·이미지·차트 렌더링 - 페이지네이션 (다단 분할, 표 행 분할), 머리말/꼬리말/바탕쪽/각주 -- SVG 내보내기 (CLI) + Canvas 렌더링 (WASM/Web) +- 레이어 기반 SVG/Canvas2D/CanvasKit/native Skia 렌더링 경로 - 웹 에디터 + hwpctl 호환 API (30 Actions, Field API) -- 783+ 테스트 +- 793+ 테스트 ### v1.0.0 — 조판 엔진 @@ -123,10 +123,24 @@ rhwp는 Rust + WebAssembly 기반의 오픈소스 HWP/HWPX 뷰어/에디터입 - vpos-based paragraph position correction ### Output (출력) -- SVG export (CLI) -- Canvas rendering (WASM/Web) +- SVG export (CLI, legacy + layer replay) +- Canvas rendering (WASM/Web, Canvas2D + CanvasKit) +- Native Skia PNG rendering (feature-gated) - Debug overlay (paragraph/table boundaries + indices + y-coordinates) +### Multi-Renderer Backends (멀티 렌더러 백엔드) +- `PageLayerTree` 페인트 IR를 공유하고, 백엔드별로 replay만 다르게 수행합니다. +- **Legacy SVG**: 기본 `rhwp export-svg sample.hwp` +- **Layered SVG**: `RHWP_RENDER_PATH=layer-svg rhwp export-svg sample.hwp` +- **Native Skia**: non-wasm 타깃에서 `native-skia` feature로 PNG 렌더링 +- **Browser Canvas2D / CanvasKit**: `rhwp-studio` 기본값은 Canvas2D, `?renderer=canvaskit`로 CanvasKit 선택 + +### Renderer Regression Tests (렌더러 회귀 테스트) +- `cargo test layer_svg --lib` +- `cargo test --features native-skia skia --lib` +- `cd rhwp-studio && npm run e2e` +- diff artifact는 `output/layer-svg-diff`, `output/skia-diff`, `rhwp-studio/output/e2e`에 남습니다. + ### Web Editor (웹 에디터) - Text editing (insert, delete, undo/redo) - Character/paragraph formatting dialogs @@ -200,7 +214,8 @@ document.getElementById('viewer').innerHTML = doc.renderPageSvg(0); ```bash cargo build # Development build cargo build --release # Release build -cargo test # Run tests (755+ tests) +cargo test # Run tests (793+ tests) +cargo clippy --all-targets --all-features # native-skia까지 보려면 fontconfig/freetype 개발 패키지가 필요할 수 있음 ``` ### WASM Build @@ -224,6 +239,8 @@ npx vite --host 0.0.0.0 --port 7700 Open `http://localhost:7700` in your browser. +브라우저 렌더러를 바꿔 비교하려면 `http://localhost:7700/?renderer=canvas2d` 또는 `http://localhost:7700/?renderer=canvaskit`를 사용하세요. + ## CLI Usage ### SVG Export diff --git a/README_EN.md b/README_EN.md index 0688aee5..af52369f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -117,10 +117,24 @@ document.getElementById('viewer').innerHTML = doc.renderPageSvg(0); - vpos-based paragraph position correction ### Output -- SVG export (CLI) -- Canvas rendering (WASM/Web) +- SVG export (CLI, legacy + layer replay) +- Canvas rendering (WASM/Web, Canvas2D + CanvasKit) +- Native Skia PNG rendering (feature-gated) - Debug overlay (paragraph/table boundaries + indices + y-coordinates) +### Multi-Renderer Backends +- rhwp now shares a `PageLayerTree` paint IR and replays it through different backends. +- **Legacy SVG**: default `rhwp export-svg sample.hwp` +- **Layered SVG**: `RHWP_RENDER_PATH=layer-svg rhwp export-svg sample.hwp` +- **Native Skia**: PNG rendering on non-wasm targets behind the `native-skia` feature +- **Browser Canvas2D / CanvasKit**: `rhwp-studio` defaults to Canvas2D, and `?renderer=canvaskit` switches to CanvasKit + +### Renderer Regression Tests +- `cargo test layer_svg --lib` +- `cargo test --features native-skia skia --lib` +- `cd rhwp-studio && npm run e2e` +- diff artifacts are written to `output/layer-svg-diff`, `output/skia-diff`, and `rhwp-studio/output/e2e`. + ### Web Editor - Text editing (insert, delete, undo/redo) - Character/paragraph formatting dialogs @@ -145,7 +159,8 @@ document.getElementById('viewer').innerHTML = doc.renderPageSvg(0); ```bash cargo build # Development build cargo build --release # Release build -cargo test # Run tests (783+ tests) +cargo test # Run tests (793+ tests) +cargo clippy --all-targets --all-features # native-skia may require fontconfig/freetype development libraries ``` ### WASM Build @@ -167,6 +182,8 @@ npx vite --port 7700 Open `http://localhost:7700` in your browser. +To compare browser backends directly, open `http://localhost:7700/?renderer=canvas2d` or `http://localhost:7700/?renderer=canvaskit`. + ## CLI Usage ### SVG Export @@ -277,7 +294,7 @@ The `mydocs/` directory (724 files, English translations in `mydocs/eng/`) conta Most AI coding demos show simple tasks. This project demonstrates AI pair programming **at production scale**: - **100K+ lines of Rust** — parser, renderer, pagination, editor -- **783+ tests** with zero clippy warnings +- **793+ tests** with zero clippy warnings - **Reverse engineering** a proprietary binary format - **Sub-pixel layout accuracy** matching commercial software - **Full CI/CD pipeline** — from commit to npm publish to GitHub Pages @@ -292,7 +309,7 @@ The `mydocs/` directory is not documentation about the code — it's documentati |--|-------------|-------------| | **Human role** | Accept AI output | Direct, review, decide | | **Planning** | None — "just build it" | Written plan → approval → execution | -| **Quality gate** | Hope it works | 783 tests + Clippy + CI + code review | +| **Quality gate** | Hope it works | 793 tests + Clippy + CI + code review | | **Debugging** | Ask AI to fix AI's bugs | Human diagnoses, AI implements fix | | **Architecture** | Emergent (accidental) | Deliberate (CQRS, dependency direction) | | **Documentation** | None | 724 files of process records | diff --git a/mydocs/eng/tech/layered_renderer_architecture.md b/mydocs/eng/tech/layered_renderer_architecture.md new file mode 100644 index 00000000..25edf938 --- /dev/null +++ b/mydocs/eng/tech/layered_renderer_architecture.md @@ -0,0 +1,312 @@ +# RHWP Layered Renderer Architecture + +## 1. Purpose + +This document explains the current multi-renderer architecture of rhwp from the implementation point of view. +It focuses on the following topics. + +- The role split between `PageRenderTree` and `PageLayerTree` +- The current relationship between legacy SVG, layered SVG, browser Canvas2D, browser CanvasKit, and native Skia +- The meaning of CanvasKit `compat` and `default` modes +- Screenshot-diff-based parity strategy +- The touch points required when adding a new backend or a new paint op + +For higher-level historical design context, see [rendering_engine_design.md](./rendering_engine_design.md). + +## 2. Current rendering paths at a glance + +rhwp is no longer best described as “one render tree consumed directly by every backend”. +The current system uses two representation layers. + +```text +Document / Section / Paragraph / Control + -> compose / paginate / layout + -> PageRenderTree + -> LayerBuilder + -> PageLayerTree + -> backend replay +``` + +The concrete paths are currently split as follows. + +| Path | Input | Main files | Current role | +|---|---|---|---| +| Legacy SVG | `PageRenderTree` | `src/document_core/queries/rendering.rs`, `src/renderer/svg.rs` | Existing reference path, structural baseline | +| Layered SVG | `PageLayerTree` | `src/paint/*`, `src/renderer/svg_layer.rs` | Layered replay validation path | +| Browser Canvas2D | `PageRenderTree` | `src/wasm_api.rs`, `src/renderer/web_canvas.rs`, `rhwp-studio/src/view/page-renderer.ts` | Current web baseline renderer | +| Browser CanvasKit | `PageLayerTree` | `src/wasm_api.rs`, `rhwp-studio/src/view/canvaskit-renderer.ts` | Layered browser backend | +| Native Skia | `PageLayerTree` | `src/renderer/skia/renderer.rs` | Layered raster backend | + +Two points matter here. + +1. The web baseline, Canvas2D, still uses the established WASM Canvas rendering path. +2. New backends, namely layered SVG, CanvasKit, and native Skia, all consume `PageLayerTree`. + +So the current structure is not “all backends already use the same path”. +It is closer to “keep the proven baseline path, while converging new backends on the layered path”. + +## 3. Why `PageLayerTree` exists + +`PageRenderTree` is appropriate as a layout result, but too semantic to serve as a replay IR for all backends. +It still contains document-level concepts such as `Header`, `Footer`, `Table`, `TextLine`, `TextBox`, and `Group`. +That forces each backend to reinterpret semantic containers on its own. + +This causes several problems. + +- Adding a new raster or vector backend requires re-implementing semantic-container traversal. +- It becomes difficult to keep composition order, clipping, and transforms aligned across SVG, Canvas, and Skia. +- Browser CanvasKit and native Skia cannot share a common replay input. + +`PageLayerTree` was introduced to solve this. + +- It lowers semantic containers into visual layers. +- It keeps only the information a backend needs for replay. +- It makes clip, group, and leaf paint ops explicit. +- It lets backends focus on replay instead of layout. + +The entry point for this layer is `src/paint/mod.rs`, and +`LayerBuilder` in `src/paint/builder.rs` performs the `PageRenderTree -> PageLayerTree` conversion. + +## 4. The role split: `PageRenderTree` vs `PageLayerTree` + +| Item | `PageRenderTree` | `PageLayerTree` | +|---|---|---| +| Main purpose | Represent layout result | Serve as backend replay input | +| Included information | Document meaning plus layout result | Visual composition information | +| Node character | Semantic containers plus leaves | Group / clip / leaf op | +| Typical types | `RenderNodeType::Table`, `TextLine`, `Group` | `LayerNodeKind::Group`, `ClipRect`, `Leaf` | +| Consumers | Legacy SVG, debug/query layers | Layered SVG, native Skia, CanvasKit | +| Need for backend reinterpretation | High | Low | + +In practical terms: + +- `PageRenderTree` explains how the document was laid out. +- `PageLayerTree` explains in what order and in what way the result should be painted. + +## 5. Core rules of the layered path + +### 5.1 Layout must happen only once + +Backends must not recalculate line breaking, paragraph composition, table placement, or inner-shape layout. +That responsibility belongs to the compose, paginate, and layout stages. + +### 5.2 Backends only replay + +Backends should only handle: + +- paint-op replay +- clip application +- transform application +- raster or vector output +- browser-specific fallback or compatibility handling + +### 5.3 Semantic information should be retained only as needed + +Metadata such as `GroupKind`, `ClipKind`, and `CacheHint` is acceptable, +but `PageLayerTree` should not force a backend to interpret document semantics again. + +### 5.4 Shape children must be preserved + +Even when a shape itself becomes a leaf paint op, its image fill, text box content, or grouped child nodes must not disappear from the layer tree. + +This is an important invariant of the layered path. +The recent `group-drawing-02` parity issue reaffirmed that lowering a shape leaf must not silently drop its children. + +## 6. Current backend behavior + +### 6.1 Legacy SVG + +- Entry: `DocumentCore::render_page_svg_legacy_native()` +- Implementation: `src/renderer/svg.rs` +- Input: `PageRenderTree` + +This path is not removed. +It remains as an existing export path and as a structural comparison baseline against the layered path. +Unless `RHWP_RENDER_PATH=layer-svg` is set, SVG export still uses this path by default. + +### 6.2 Layered SVG + +- Entry: `DocumentCore::render_page_svg_layer_native()` +- Implementation: `src/renderer/svg_layer.rs` +- Input: `PageLayerTree` + +`SvgLayerRenderer` reconstructs a temporary render-tree-shaped structure from the layer tree and reuses the existing SVG leaf logic. +In other words, the input is layered, but the mature SVG output logic is still intentionally reused. + +### 6.3 Browser Canvas2D + +- Entry: `PageRenderer.renderPage()` calling `this.wasm.renderPageToCanvas(...)` +- Implementation: `src/renderer/web_canvas.rs` +- Input: `PageRenderTree` + +This is currently the browser baseline. +CanvasKit parity tests compare screenshots against this path. + +The important point is that Canvas2D has not yet moved to the layered path. +So CanvasKit parity is not a pure sibling test where two backends replay the same tree. +It is a validation that the new layered backend looks sufficiently close to the established baseline. + +### 6.4 Browser CanvasKit + +- Entry: `PageRenderer.renderPage()` calling `this.wasm.getPageLayerTree(...)` +- Implementation: `rhwp-studio/src/view/canvaskit-renderer.ts` +- Input: `PageLayerTree` + +CanvasKit is a browser-side replay renderer. +The Rust core performs layout and layer-tree export, and TypeScript replays that data with the CanvasKit API. + +This path exists to provide: + +- a Skia-like 2D drawing model in the browser +- an experimental Skia-family renderer on the web +- a foundation for future native/backend extensibility + +### 6.5 Native Skia + +- Entry: `DocumentCore::render_page_png_native()` +- Implementation: `src/renderer/skia/renderer.rs` +- Feature flag: `native-skia` +- Input: `PageLayerTree` + +Native Skia is the layered raster backend for non-wasm targets. +At the moment it is primarily a test and validation path. +There is not yet a general-purpose end-user `export-png` CLI. + +## 7. CanvasKit render modes + +CanvasKit currently exposes two modes. + +| Mode | Meaning | Default | +|---|---|---| +| `default` | Prefer native CanvasKit behavior | No | +| `compat` | Prefer visual similarity to Canvas2D | Yes | + +`rhwp-studio/src/view/render-backend.ts` resolves this from query parameters and local storage. + +`compat` is the default for several reasons. + +- The current browser baseline is still Canvas2D. +- Text rasterization, font fallback, and glyph positioning can differ significantly between CanvasKit and Canvas2D. +- Switching the renderer should not immediately make the document look obviously different to end users. + +At the moment, compat mode especially uses Canvas2D-based overlay or fallback behavior for text-like operations to absorb pure CanvasKit raster differences. +This logic is a browser-side compatibility layer. +It is not intended to rewrite the Rust layout core itself. + +## 8. Parity and diff strategy + +Renderer parity is not managed with a strict “exact diff must always be zero” rule. +Different engines naturally produce small differences in anti-aliasing, subpixel coverage, and font rasterization. + +The current validation strategy is as follows. + +### 8.1 Legacy SVG vs layered SVG + +- Purpose: structural migration validation +- Rule: primarily exact-match oriented +- Artifacts: `output/layer-svg-diff/` + +### 8.2 Layered SVG vs native Skia PNG + +- Purpose: layered raster backend validation +- Rule: tolerant diff pixel budget +- Artifacts: `output/skia-diff/` + +### 8.3 Browser Canvas2D vs CanvasKit + +- Purpose: browser parity validation +- Test file: `rhwp-studio/e2e/canvaskit-render.test.mjs` +- Rule: + - exact diffs are always recorded + - tolerant diff ignores pixels whose per-pixel channel delta is `8` or less + - final pass/fail uses tolerant diff ratio `<= 0.25%` +- Artifacts: + - `output/e2e/` + - `rhwp-studio/e2e/screenshots/` + +The intent is: + +- keep exact diffs for inspection and trend tracking +- use tolerant diff as the actual acceptance gate for renderer-engine-only differences +- distinguish visible structural mismatches from minor raster differences + +## 9. What the tests are protecting + +The current parity tests are not just cosmetic screenshot checks. +They protect important invariants of the layered architecture. + +They should catch problems such as: + +- dropping shape children while lowering shape leaves +- changing draw order while flattening group or clip hierarchy +- losing text fallback in CanvasKit and breaking Hangul or equations +- regressions in samples that are especially sensitive to backend differences, such as equations, crop, fields, and grouped drawings + +So the screenshot tests are effectively contract tests for how closely the new layered path must track the existing baseline. + +## 10. How to add a new backend + +When adding a new backend, the usual order is: + +1. Decide whether the backend will consume `PageLayerTree`. +2. Define the replay strategy for `LayerNodeKind::Group`, `ClipRect`, and `Leaf`. +3. Map each required `PaintOp` to backend draw calls. +4. If the backend is browser-side, update `paint/json.rs` and TypeScript layer types as well. +5. Add parity tests against an existing baseline. + +The main touch points are usually these files. + +| Purpose | Main files | +|---|---| +| Layer-tree generation | `src/paint/builder.rs` | +| Layer-tree JSON export | `src/paint/json.rs` | +| Layered SVG replay | `src/renderer/svg_layer.rs` | +| Native Skia replay | `src/renderer/skia/renderer.rs` | +| WASM export | `src/document_core/queries/rendering.rs`, `src/wasm_api.rs` | +| Browser CanvasKit replay | `rhwp-studio/src/view/canvaskit-renderer.ts` | +| Browser parity tests | `rhwp-studio/e2e/canvaskit-render.test.mjs` | + +## 11. Checklist for adding a new paint op + +Adding a new `PaintOp` usually requires all of the following. + +1. Add the type in `src/paint/paint_op.rs` +2. Convert the corresponding render node in `src/paint/builder.rs` +3. Extend serialization in `src/paint/json.rs` +4. Update replay in `src/renderer/svg_layer.rs` +5. Update replay in `src/renderer/skia/renderer.rs` +6. If used in the browser, update TypeScript layer types and CanvasKit replay +7. Add a parity test sample + +If any one of these is omitted, asymmetric failures such as “visible in one backend but missing in another” become very likely. + +## 12. Important caveats when reading the current architecture + +### 12.1 Canvas2D and CanvasKit are not fully symmetric yet + +Canvas2D is still the legacy browser path. +CanvasKit is the layered browser path. +They are not two implementations of the exact same replay contract yet. + +### 12.2 The layered path has not completely replaced the semantic tree + +Legacy SVG and several query or debug capabilities still depend on `PageRenderTree`. +So the current stage is not “semantic tree removed”. +It is “layered replay introduced in parallel”. + +### 12.3 Compat code is an intentional transition buffer + +Compat mode is best understood as a transition layer that keeps the existing baseline stable while the new backend is being introduced. + +However, that code should stay in the browser layer, primarily inside `rhwp-studio`, and should not leak back into the Rust layout core. + +## 13. Summary + +The current layered renderer architecture in rhwp can be summarized as follows. + +- Layout produces `PageRenderTree`. +- The visual replay IR is `PageLayerTree`. +- Layered SVG, native Skia, and CanvasKit all share `PageLayerTree`. +- Browser Canvas2D is still kept as the baseline path. +- Therefore parity tests currently validate how closely the new layered backends match the established baseline. +- The most important rule when adding a new backend is: do not re-layout, replay the layer tree. diff --git a/mydocs/eng/tech/rendering_engine_design.md b/mydocs/eng/tech/rendering_engine_design.md index 5ea0cd28..65eebdb7 100644 --- a/mydocs/eng/tech/rendering_engine_design.md +++ b/mydocs/eng/tech/rendering_engine_design.md @@ -1,5 +1,10 @@ # RHWP Rendering Engine Architecture Design +> This document explains the high-level rendering architecture and its historical design direction. +> The currently implemented `PageRenderTree -> PageLayerTree -> backend replay` pipeline and +> the CanvasKit/native Skia parity strategy are described in detail in +> [layered_renderer_architecture.md](./layered_renderer_architecture.md). + ## 1. Final Rendering Backend Selection ### Comparative Evaluation diff --git a/mydocs/tech/layered_renderer_architecture.md b/mydocs/tech/layered_renderer_architecture.md new file mode 100644 index 00000000..512e3fde --- /dev/null +++ b/mydocs/tech/layered_renderer_architecture.md @@ -0,0 +1,311 @@ +# RHWP Layered Renderer Architecture + +## 1. 목적 + +이 문서는 현재 rhwp의 멀티 렌더러 구조를 구현 기준으로 설명한다. +특히 다음 내용을 정리한다. + +- `PageRenderTree`와 `PageLayerTree`의 역할 차이 +- legacy SVG, layer SVG, browser Canvas2D, browser CanvasKit, native Skia의 현재 관계 +- CanvasKit compat/default mode의 의미 +- 스크린샷 diff 기반 parity 테스트 전략 +- 새 백엔드나 새 paint op를 추가할 때 수정해야 하는 지점 + +상위 수준의 역사적 설계 배경은 [rendering_engine_design.md](./rendering_engine_design.md)를 본다. + +## 2. 현재 렌더링 경로 한눈에 보기 + +rhwp는 더 이상 “하나의 render tree를 각 백엔드가 직접 해석”하는 구조만으로 설명되지 않는다. +현재는 두 단계의 표현을 사용한다. + +```text +Document / Section / Paragraph / Control + -> compose / paginate / layout + -> PageRenderTree + -> LayerBuilder + -> PageLayerTree + -> backend replay +``` + +실제 경로는 백엔드마다 다음과 같이 나뉜다. + +| 경로 | 입력 | 주요 구현 파일 | 현재 역할 | +|---|---|---|---| +| Legacy SVG | `PageRenderTree` | `src/document_core/queries/rendering.rs`, `src/renderer/svg.rs` | 기존 기준 경로, 구조 비교 baseline | +| Layer SVG | `PageLayerTree` | `src/paint/*`, `src/renderer/svg_layer.rs` | layered replay 검증 경로 | +| Browser Canvas2D | `PageRenderTree` | `src/wasm_api.rs`, `src/renderer/web_canvas.rs`, `rhwp-studio/src/view/page-renderer.ts` | 현재 웹 baseline 렌더러 | +| Browser CanvasKit | `PageLayerTree` | `src/wasm_api.rs`, `rhwp-studio/src/view/canvaskit-renderer.ts` | layered browser backend | +| Native Skia | `PageLayerTree` | `src/renderer/skia/renderer.rs` | layered raster backend | + +핵심 포인트는 다음 두 가지다. + +1. 웹 baseline인 Canvas2D는 아직 기존 WASM Canvas 렌더링 경로를 사용한다. +2. 새 backend인 layer SVG, CanvasKit, native Skia는 모두 `PageLayerTree`를 소비한다. + +즉 현재 구조는 “모든 백엔드가 같은 path를 쓴다”가 아니라, “기존 baseline은 유지하고 새 backend는 layered path로 수렴한다”에 가깝다. + +## 3. 왜 `PageLayerTree`가 필요했는가 + +`PageRenderTree`는 레이아웃 결과를 표현하기에는 적절하지만, 백엔드 replay용 IR로는 너무 semantic하다. +예를 들어 `Header`, `Footer`, `Table`, `TextLine`, `TextBox`, `Group` 같은 문서적 개념이 포함되어 있고, +backend마다 이를 다시 해석해야 한다. + +이 구조는 다음 문제를 만든다. + +- 새 raster/vector backend를 추가할 때 semantic container 해석을 다시 구현해야 한다. +- SVG/Canvas/Skia 간 합성 순서와 clip/transform 처리를 동일하게 맞추기 어렵다. +- browser CanvasKit과 native Skia가 같은 입력을 소비하지 못한다. + +`PageLayerTree`는 이 문제를 해결하기 위해 도입되었다. + +- semantic container를 시각 레이어 단위로 내린다. +- backend가 실제로 필요한 정보만 남긴다. +- clip, group, leaf paint op를 명시적으로 표현한다. +- backend는 layout이 아니라 replay에만 집중한다. + +`src/paint/mod.rs`가 이 계층의 진입점이고, `src/paint/builder.rs`의 `LayerBuilder`가 +`PageRenderTree -> PageLayerTree` 변환을 담당한다. + +## 4. `PageRenderTree`와 `PageLayerTree`의 역할 + +| 항목 | `PageRenderTree` | `PageLayerTree` | +|---|---|---| +| 주 목적 | 레이아웃 결과 표현 | backend replay 입력 | +| 포함 정보 | 문서 의미 + 레이아웃 결과 | 시각 합성 정보 | +| 노드 성격 | semantic container + leaf | group / clip / leaf op | +| 대표 타입 | `RenderNodeType::Table`, `TextLine`, `Group` | `LayerNodeKind::Group`, `ClipRect`, `Leaf` | +| 소비자 | legacy SVG, 디버그/쿼리 계층 | layer SVG, native Skia, CanvasKit | +| backend 재해석 필요성 | 높음 | 낮음 | + +실무적으로는 아래처럼 생각하면 된다. + +- `PageRenderTree`는 “문서가 어떻게 조판되었는가”를 설명한다. +- `PageLayerTree`는 “그 결과를 어떤 순서로 어떻게 그릴 것인가”를 설명한다. + +## 5. Layered path의 핵심 규칙 + +### 5.1 layout은 한 번만 한다 + +backend가 텍스트 줄바꿈, 문단 조판, 표 배치, 도형 내부 레이아웃을 다시 계산하면 안 된다. +그 책임은 `compose / paginate / layout` 단계에 있다. + +### 5.2 backend는 replay만 한다 + +backend는 다음만 담당해야 한다. + +- paint op 순서 재생 +- clip 적용 +- transform 적용 +- raster/vector 출력 +- browser-specific fallback 또는 compat 처리 + +### 5.3 semantic 정보는 필요한 만큼만 남긴다 + +`GroupKind`, `ClipKind`, `CacheHint` 같은 메타데이터는 남기되, +문서 의미를 backend가 다시 해석해야 할 정도로 semantic 정보를 넣지는 않는다. + +### 5.4 shape 자식은 보존되어야 한다 + +shape 자체가 leaf paint op를 가지더라도, 그 안의 이미지 채우기, 글상자 텍스트, 그룹 내부 자식은 +layer tree에서 사라지면 안 된다. + +이 점은 layered path에서 중요한 invariant다. +최근 `group-drawing-02` parity 이슈도 shape leaf를 내리면서 자식을 버리면 안 된다는 점을 다시 확인한 사례다. + +## 6. 백엔드별 현재 동작 + +### 6.1 Legacy SVG + +- 진입점: `DocumentCore::render_page_svg_legacy_native()` +- 구현: `src/renderer/svg.rs` +- 입력: `PageRenderTree` + +이 경로는 완전히 제거된 것이 아니라, layered path와의 구조 비교 기준으로 계속 남아 있다. +`RHWP_RENDER_PATH=layer-svg`를 지정하지 않으면 기본적으로 이 경로가 SVG 내보내기에 사용된다. + +### 6.2 Layer SVG + +- 진입점: `DocumentCore::render_page_svg_layer_native()` +- 구현: `src/renderer/svg_layer.rs` +- 입력: `PageLayerTree` + +`SvgLayerRenderer`는 layer tree를 다시 temporary render tree 형태로 조립해 +기존 SVG leaf 로직을 재사용한다. +즉 layer tree 기반이지만, SVG 출력 품질을 맞추기 위해 기존 SVG renderer를 완전히 버리지는 않았다. + +### 6.3 Browser Canvas2D + +- 진입점: `PageRenderer.renderPage()`에서 `this.wasm.renderPageToCanvas(...)` +- 구현: `src/renderer/web_canvas.rs` +- 입력: `PageRenderTree` + +현재 브라우저에서의 baseline이다. +CanvasKit parity 테스트도 이 경로의 스크린샷을 기준으로 비교한다. + +중요한 점은 Canvas2D가 아직 layered path로 전환되지 않았다는 것이다. +즉 CanvasKit parity는 “같은 tree를 두 backend가 replay하는 exact sibling test”가 아니라, +“새 layered backend가 기존 baseline과 얼마나 가깝게 보이는가”를 검증하는 테스트다. + +### 6.4 Browser CanvasKit + +- 진입점: `PageRenderer.renderPage()`에서 `this.wasm.getPageLayerTree(...)` +- 구현: `rhwp-studio/src/view/canvaskit-renderer.ts` +- 입력: `PageLayerTree` + +CanvasKit은 browser-side replay renderer다. +Rust core가 layout과 layer tree export를 담당하고, TypeScript가 CanvasKit API 호출로 이를 그린다. + +이 경로를 둔 이유는 다음과 같다. + +- native Skia와 유사한 2D drawing model 확보 +- 브라우저에서 Skia 계열 renderer 실험 +- 추후 native/backend 확장성 확보 + +### 6.5 Native Skia + +- 진입점: `DocumentCore::render_page_png_native()` +- 구현: `src/renderer/skia/renderer.rs` +- feature: `native-skia` +- 입력: `PageLayerTree` + +native Skia는 non-wasm 타깃에서 layered raster backend 역할을 한다. +현재는 테스트/검증용 경로가 중심이며, 별도의 일반 사용자용 `export-png` CLI는 아직 없다. + +## 7. CanvasKit render mode + +CanvasKit에는 현재 두 가지 모드가 있다. + +| 모드 | 의미 | 기본값 | +|---|---|---| +| `default` | CanvasKit 기본 동작 우선 | 아님 | +| `compat` | Canvas2D와의 시각적 유사도 우선 | 기본 | + +`rhwp-studio/src/view/render-backend.ts`에서 query param과 localStorage를 통해 이 값을 결정한다. + +`compat`가 기본인 이유는 다음과 같다. + +- 브라우저 baseline은 아직 Canvas2D다. +- 텍스트 rasterization, font fallback, glyph positioning에서 CanvasKit과 Canvas2D는 그대로는 많이 다를 수 있다. +- 사용자가 backend를 바꿨을 때 “렌더링이 달라 보인다”는 인상을 최소화해야 한다. + +현재 compat mode는 특히 텍스트 계열에서 Canvas2D overlay/fallback을 사용해 +CanvasKit의 순수 raster 차이를 흡수한다. +이 로직은 browser-specific compatibility layer이며, Rust core의 layout 자체를 바꾸는 것은 아니다. + +## 8. Parity와 diff 전략 + +렌더러 parity는 “무조건 exact diff 0”만으로 관리하지 않는다. +backend 특성상 anti-aliasing, subpixel coverage, font rasterization 차이가 생길 수 있기 때문이다. + +현재 검증 전략은 다음과 같다. + +### 8.1 Legacy SVG vs Layer SVG + +- 성격: 구조 전환 검증 +- 기준: exact match 중심 +- 산출물: `output/layer-svg-diff/` + +### 8.2 Layer SVG vs Native Skia PNG + +- 성격: layered raster backend 검증 +- 기준: tolerant diff pixel budget 사용 +- 산출물: `output/skia-diff/` + +### 8.3 Browser Canvas2D vs CanvasKit + +- 성격: browser parity 검증 +- 테스트 파일: `rhwp-studio/e2e/canvaskit-render.test.mjs` +- 기준: + - exact diff는 항상 기록 + - tolerant diff는 채널 차이 `8` 이하 픽셀을 무시 + - 최종 pass/fail은 tolerant diff ratio `0.25%` 이하 +- 산출물: + - `output/e2e/` + - `rhwp-studio/e2e/screenshots/` + +이 전략의 의도는 다음과 같다. + +- exact diff는 계속 남겨서 변화량을 추적한다. +- 하지만 통과 기준은 renderer 엔진 차이만 허용하는 tolerant 값으로 잡는다. +- 즉 “눈에 띄는 구조 차이”와 “작은 raster 차이”를 구분한다. + +## 9. 테스트가 보호해야 하는 것 + +현재 parity 테스트는 단순 픽셀 비교가 아니라 layered architecture의 invariant를 지키는 장치다. + +특히 다음 문제를 잡아내야 한다. + +- shape leaf를 만들면서 shape 자식을 누락하는 경우 +- group/clip 계층이 flatten되면서 draw order가 바뀌는 경우 +- CanvasKit에서 텍스트 fallback이 빠져 한글/수식이 깨지는 경우 +- equation, crop, field, group drawing처럼 backend 차이가 잘 드러나는 샘플 회귀 + +즉 스크린샷 테스트는 “예쁘게 보이는지” 이상의 의미를 가진다. +현재 layered path가 기존 baseline을 어느 정도 유지하는지 보여주는 계약 테스트다. + +## 10. 새 backend를 추가할 때의 작업 순서 + +새 backend를 붙일 때는 보통 아래 순서를 따른다. + +1. `PageLayerTree`를 입력으로 받을지 먼저 결정한다. +2. `LayerNodeKind::Group / ClipRect / Leaf` replay 전략을 정의한다. +3. 필요한 `PaintOp`를 backend별 draw call로 매핑한다. +4. browser backend면 `paint/json.rs`와 TS 타입까지 같이 맞춘다. +5. 기존 baseline과의 parity 테스트를 추가한다. + +수정 지점은 대체로 다음 파일들이다. + +| 목적 | 주요 파일 | +|---|---| +| layer tree 생성 | `src/paint/builder.rs` | +| layer tree JSON export | `src/paint/json.rs` | +| layered SVG replay | `src/renderer/svg_layer.rs` | +| native Skia replay | `src/renderer/skia/renderer.rs` | +| wasm export | `src/document_core/queries/rendering.rs`, `src/wasm_api.rs` | +| browser CanvasKit replay | `rhwp-studio/src/view/canvaskit-renderer.ts` | +| browser parity test | `rhwp-studio/e2e/canvaskit-render.test.mjs` | + +## 11. 새 paint op를 추가할 때의 체크리스트 + +새 `PaintOp`를 추가하면 보통 아래를 같이 확인해야 한다. + +1. `src/paint/paint_op.rs`에 타입 추가 +2. `src/paint/builder.rs`에서 해당 render node를 layer op로 변환 +3. `src/paint/json.rs` 직렬화 추가 +4. `src/renderer/svg_layer.rs` replay 경로 반영 +5. `src/renderer/skia/renderer.rs` replay 경로 반영 +6. browser에서 쓰면 TS layer type + CanvasKit replay 반영 +7. parity test 샘플 추가 + +이 중 하나라도 빠지면 “특정 backend에서만 안 보임” 같은 비대칭 문제가 쉽게 생긴다. + +## 12. 현재 구조를 해석할 때 주의할 점 + +### 12.1 Canvas2D와 CanvasKit은 아직 완전히 대칭이 아니다 + +Canvas2D는 legacy browser path이고, CanvasKit은 layered browser path다. +둘은 같은 renderer 구현체가 아니다. + +### 12.2 Layered path가 semantic tree를 완전히 대체한 것은 아니다 + +legacy SVG와 여러 query/debug 기능은 여전히 `PageRenderTree`에 기대고 있다. +따라서 현재는 “semantic tree 제거”가 아니라 “layered replay를 병행 도입”한 단계다. + +### 12.3 compat 코드는 backend 불일치를 숨기는 완충층이다 + +compat mode는 아키텍처 오염이라기보다, +현재 baseline을 보존하면서 새 backend를 도입하기 위한 전환 비용으로 보는 편이 맞다. + +다만 이 코드는 가능한 한 `rhwp-studio` 쪽 browser layer에만 머물러야 하고, +Rust layout core로 역류하면 안 된다. + +## 13. 요약 + +현재 rhwp의 layered renderer 구조는 다음으로 요약할 수 있다. + +- layout 결과는 `PageRenderTree`로 만들어진다. +- backend replay용 시각 IR은 `PageLayerTree`다. +- layer SVG, native Skia, CanvasKit은 `PageLayerTree`를 공유한다. +- browser Canvas2D는 아직 baseline으로 유지된다. +- 따라서 현재 parity 테스트는 “새 layered backend가 기존 baseline과 얼마나 가까운가”를 검증한다. +- 새 backend를 추가할 때 가장 중요한 원칙은 “layout을 다시 하지 말고 layer tree를 replay하라”이다. diff --git a/mydocs/tech/rendering_engine_design.md b/mydocs/tech/rendering_engine_design.md index 8506bd4e..2cfacd4d 100644 --- a/mydocs/tech/rendering_engine_design.md +++ b/mydocs/tech/rendering_engine_design.md @@ -1,5 +1,10 @@ # RHWP 렌더링 엔진 아키텍처 설계서 +> 이 문서는 렌더링 엔진의 상위 구조와 역사적 설계 방향을 설명한다. +> 현재 구현된 `PageRenderTree -> PageLayerTree -> backend replay` 구조와 +> CanvasKit/native Skia parity 전략은 +> [layered_renderer_architecture.md](./layered_renderer_architecture.md)에서 자세히 다룬다. + ## 1. 렌더링 백엔드 최종 선정 ### 비교 평가 diff --git a/rhwp-studio/e2e/canvaskit-render.test.mjs b/rhwp-studio/e2e/canvaskit-render.test.mjs new file mode 100644 index 00000000..9d92b535 --- /dev/null +++ b/rhwp-studio/e2e/canvaskit-render.test.mjs @@ -0,0 +1,124 @@ +import { + assert, + comparePngBuffers, + cropPngBuffer, + createNewDocument, + getLayerOpBBoxes, + loadApp, + loadHwpFile, + runTest, + screenshotCanvas, + setTestCase, +} from './helpers.mjs'; + +const FULL_PAGE_CASES = [ + { name: 'blank-new-document', setup: (page) => createNewDocument(page) }, + { name: 'lseg-01-basic', setup: (page) => loadHwpFile(page, 'lseg-01-basic.hwp') }, + { name: 'eq-01', setup: (page) => loadHwpFile(page, 'eq-01.hwp') }, + { name: 'hwp-table-test', setup: (page) => loadHwpFile(page, 'hwp_table_test.hwp') }, + { name: 'pic-crop-01', setup: (page) => loadHwpFile(page, 'pic-crop-01.hwp') }, + { name: 'field-01', setup: (page) => loadHwpFile(page, 'field-01.hwp') }, + { name: 'shape-group-02', setup: (page) => loadHwpFile(page, 'shape-group-02.hwp') }, + { name: 'group-drawing-02', setup: (page) => loadHwpFile(page, 'group-drawing-02.hwp') }, +]; +const CANVASKIT_MODE = process.env.RHWP_CANVASKIT_MODE === 'default' ? 'default' : 'compat'; +const TOLERANT_DIFF = { + ignoreChannelDelta: 8, + maxDiffRatio: 0.0025, +}; +const FEATURE_CASES = [ + { + name: 'eq-01', + setup: (page) => loadHwpFile(page, 'eq-01.hwp'), + opType: 'equation', + margin: 4, + }, +]; + +async function renderScenario(page, backend, caseInfo) { + const search = backend === 'canvaskit' + ? `?renderer=${backend}&canvaskitMode=${CANVASKIT_MODE}` + : `?renderer=${backend}`; + await loadApp(page, search); + await caseInfo.setup(page); + + const activeBackend = await page.evaluate(() => window.__renderBackend ?? window.__canvasView?.getRenderBackend?.()); + assert(activeBackend === backend || (backend === 'canvas2d' && activeBackend === 'canvas'), `${caseInfo.name} backend=${backend}`); + + if (backend === 'canvaskit') { + const layerSummary = await page.evaluate(() => { + const tree = window.__wasm?.getPageLayerTree?.(0); + if (!tree) return null; + const root = tree.root; + const opCount = root.kind === 'leaf' ? root.ops.length : root.kind === 'group' ? root.children.length : 1; + return { + kind: root.kind, + opCount, + mode: window.__canvaskitRenderMode, + }; + }); + assert(!!layerSummary && layerSummary.opCount > 0, `${caseInfo.name} layer tree exported`); + assert(layerSummary?.mode === CANVASKIT_MODE, `${caseInfo.name} canvaskitMode=${CANVASKIT_MODE}`); + } + + const screenshotName = backend === 'canvaskit' + ? `${caseInfo.name}-${backend}-${CANVASKIT_MODE}` + : `${caseInfo.name}-${backend}`; + return screenshotCanvas(page, screenshotName); +} + +runTest('CanvasKit 렌더 비교', async ({ page }) => { + for (const caseInfo of FULL_PAGE_CASES) { + setTestCase(caseInfo.name); + console.log(`\n[${caseInfo.name}] Canvas2D baseline 렌더...`); + const baseline = await renderScenario(page, 'canvas2d', caseInfo); + + console.log(`[${caseInfo.name}] CanvasKit 렌더...`); + const canvaskit = await renderScenario(page, 'canvaskit', caseInfo); + + const diff = await comparePngBuffers(baseline.buffer, canvaskit.buffer, { + diffName: `${caseInfo.name}-${CANVASKIT_MODE}`, + ignoreChannelDelta: TOLERANT_DIFF.ignoreChannelDelta, + maxDiffRatio: TOLERANT_DIFF.maxDiffRatio, + }); + + assert( + diff.passed, + `${caseInfo.name} screenshot exact=${diff.exactDiffPixels} (${diff.exactDiffRatio.toFixed(4)}), tolerant=${diff.tolerantDiffPixels} (${diff.tolerantDiffRatio.toFixed(4)}), raw_tolerant=${diff.rawTolerantDiffPixels} (${diff.rawTolerantDiffRatio.toFixed(4)}), ignored_channel_delta<=${diff.ignoreChannelDelta}, max_channel_delta=${diff.maxChannelDelta}`, + ); + } + + for (const caseInfo of FEATURE_CASES) { + setTestCase(`${caseInfo.name}-feature`); + console.log(`\n[${caseInfo.name}] Canvas2D baseline 기능 렌더...`); + const baseline = await renderScenario(page, 'canvas2d', caseInfo); + + console.log(`[${caseInfo.name}] CanvasKit 기능 렌더...`); + const canvaskit = await renderScenario(page, 'canvaskit', caseInfo); + + const boxes = await getLayerOpBBoxes(page, caseInfo.opType); + assert(boxes.length > 0, `${caseInfo.name} ${caseInfo.opType} bbox exported`); + + for (const [index, box] of boxes.entries()) { + const bbox = { + x: box.x - caseInfo.margin, + y: box.y - caseInfo.margin, + width: box.width + caseInfo.margin * 2, + height: box.height + caseInfo.margin * 2, + }; + const diff = await comparePngBuffers( + cropPngBuffer(baseline.buffer, bbox), + cropPngBuffer(canvaskit.buffer, bbox), + { + diffName: `${caseInfo.name}-${caseInfo.opType}-${index}-${CANVASKIT_MODE}`, + ignoreChannelDelta: TOLERANT_DIFF.ignoreChannelDelta, + maxDiffRatio: TOLERANT_DIFF.maxDiffRatio, + }, + ); + assert( + diff.passed, + `${caseInfo.name} ${caseInfo.opType}[${index}] exact=${diff.exactDiffPixels} (${diff.exactDiffRatio.toFixed(4)}), tolerant=${diff.tolerantDiffPixels} (${diff.tolerantDiffRatio.toFixed(4)}), raw_tolerant=${diff.rawTolerantDiffPixels} (${diff.rawTolerantDiffRatio.toFixed(4)}), ignored_channel_delta<=${diff.ignoreChannelDelta}, max_channel_delta=${diff.maxChannelDelta}`, + ); + } + } +}, { skipLoadApp: true }); diff --git a/rhwp-studio/e2e/helpers.mjs b/rhwp-studio/e2e/helpers.mjs index 1e91f4eb..3c338b1b 100644 --- a/rhwp-studio/e2e/helpers.mjs +++ b/rhwp-studio/e2e/helpers.mjs @@ -10,12 +10,37 @@ * node e2e/text-flow.test.mjs --mode=headless # headless Chrome */ import puppeteer from 'puppeteer-core'; +import pixelmatch from 'pixelmatch'; +import { PNG } from 'pngjs'; +import { existsSync, readFileSync, readdirSync } from 'fs'; +import { fileURLToPath } from 'url'; +import os from 'os'; +import path from 'path'; import { TestReporter } from './report-generator.mjs'; -const CHROME_PATH = '/home/edward/.cache/puppeteer/chrome/linux-146.0.7680.31/chrome-linux64/chrome'; const CHROME_CDP = process.env.CHROME_CDP || 'http://172.21.192.1:19222'; const VITE_URL = process.env.VITE_URL || 'http://localhost:7700'; const REPORT_DIR = '../output/e2e'; +const RHWP_ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url)), '..'); + +function resolveChromePath() { + const envPath = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH; + if (envPath && existsSync(envPath)) return envPath; + + const cacheRoot = path.join(os.homedir(), '.cache', 'puppeteer', 'chrome'); + if (!existsSync(cacheRoot)) return envPath || ''; + + const entries = readdirSync(cacheRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(cacheRoot, entry.name, 'chrome-linux64', 'chrome')) + .filter((candidate) => existsSync(candidate)) + .sort() + .reverse(); + + return entries[0] || envPath || ''; +} + +const CHROME_PATH = resolveChromePath(); /** CLI 인수에서 --mode=host|headless 파싱 */ function parseMode() { @@ -42,6 +67,9 @@ export function setTestCase(name) { /** Chrome 브라우저에 연결하거나 시작하고 반환 */ export async function launchBrowser() { if (MODE === 'headless') { + if (!CHROME_PATH) { + throw new Error('headless Chrome executable을 찾지 못했습니다. CHROME_PATH를 지정하거나 Puppeteer browser를 설치하세요.'); + } console.log(' [browser] headless Chrome 실행'); return await puppeteer.launch({ headless: true, @@ -113,9 +141,9 @@ export async function closeBrowser(browser) { const CANVAS_SELECTOR = '#scroll-container canvas'; /** Vite dev server에서 앱을 로드하고 WASM 초기화 완료 대기 */ -export async function loadApp(page) { - await page.goto(VITE_URL, { waitUntil: 'networkidle0', timeout: 30000 }); - await page.waitForFunction(() => !!window.__wasm, { timeout: 15000 }); +export async function loadApp(page, search = '') { + await page.goto(`${VITE_URL}${search}`, { waitUntil: 'networkidle0', timeout: 30000 }); + await page.waitForFunction(() => !!window.__wasm && !!window.__canvasView, { timeout: 15000 }); await page.evaluate(() => new Promise(r => setTimeout(r, 500))); } @@ -131,21 +159,24 @@ export async function createNewDocument(page) { await page.evaluate(() => new Promise(r => setTimeout(r, 1000))); } -/** HWP 파일을 fetch하여 문서 로드 + 캔버스 대기 */ +/** repo 샘플 HWP 파일을 Node에서 읽어 브라우저에 주입하고 문서를 로드한다 */ export async function loadHwpFile(page, filename) { - const result = await page.evaluate(async (fname) => { + const samplePath = path.join(RHWP_ROOT, 'samples', filename); + if (!existsSync(samplePath)) { + throw new Error(`샘플 파일이 없습니다: ${samplePath}`); + } + + const bytes = [...readFileSync(samplePath)]; + const result = await page.evaluate(async ({ fname, sampleBytes }) => { try { - const resp = await fetch(`/samples/${fname}`); - if (!resp.ok) return { error: `HTTP ${resp.status}` }; - const buf = await resp.arrayBuffer(); - const docInfo = window.__wasm?.loadDocument(new Uint8Array(buf), fname); + const docInfo = window.__wasm?.loadDocument(new Uint8Array(sampleBytes), fname); if (!docInfo) return { error: 'loadDocument returned null' }; window.__canvasView?.loadDocument?.(); return { pageCount: docInfo.pageCount }; } catch (e) { return { error: e.message || String(e) }; } - }, filename); + }, { fname: filename, sampleBytes: bytes }); if (result.error) throw new Error(`파일 로드 실패 (${filename}): ${result.error}`); await page.waitForSelector(CANVAS_SELECTOR, { timeout: 10000 }); await page.evaluate(() => new Promise(r => setTimeout(r, 1500))); @@ -236,6 +267,164 @@ export async function screenshot(page, name) { return path; } +/** 편집 영역의 첫 번째 페이지 캔버스만 캡처한다 */ +export async function screenshotCanvas(page, name) { + const dir = 'e2e/screenshots'; + const { mkdirSync, existsSync } = await import('fs'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const path = `${dir}/${name}.png`; + const canvas = await page.$(CANVAS_SELECTOR); + if (!canvas) throw new Error('편집 영역 캔버스를 찾을 수 없습니다'); + const buffer = await canvas.screenshot({ path }); + console.log(` Canvas Screenshot: ${path}`); + _lastScreenshot = `${name}.png`; + if (_reporter) { + const results = _reporter.results; + if (results.length > 0 && !results[results.length - 1].screenshot) { + results[results.length - 1].screenshot = `${name}.png`; + } + } + return { path, buffer }; +} + +/** layer tree에서 특정 op type의 bbox 목록을 수집한다 */ +export async function getLayerOpBBoxes(page, opType) { + return await page.evaluate((targetType) => { + const tree = window.__wasm?.getPageLayerTree?.(0); + const boxes = []; + const walk = (node) => { + if (!node) return; + if (node.kind === 'leaf') { + for (const op of node.ops) { + if (op.type === targetType) boxes.push(op.bbox); + } + return; + } + if (node.kind === 'clipRect') { + walk(node.child); + return; + } + if (node.kind === 'group') { + for (const child of node.children) walk(child); + } + }; + walk(tree?.root); + return boxes; + }, opType); +} + +/** PNG 버퍼를 bbox 영역으로 잘라 새 PNG 버퍼를 반환한다 */ +export function cropPngBuffer(buffer, bbox) { + const image = PNG.sync.read(buffer); + const x = Math.max(0, Math.floor(bbox.x)); + const y = Math.max(0, Math.floor(bbox.y)); + const width = Math.min(image.width - x, Math.ceil(bbox.width)); + const height = Math.min(image.height - y, Math.ceil(bbox.height)); + const out = new PNG({ width, height }); + PNG.bitblt(image, out, x, y, width, height, 0, 0); + return PNG.sync.write(out); +} + +/** 두 PNG 버퍼를 exact/tolerant 기준으로 비교하고 diff 아티팩트를 저장한다 */ +export async function comparePngBuffers(expectedBuffer, actualBuffer, { + diffName, + threshold = 0, + ignoreChannelDelta = 0, + maxDiffPixels = null, + maxDiffRatio = null, +} = {}) { + const expected = PNG.sync.read(expectedBuffer); + const actual = PNG.sync.read(actualBuffer); + + if (expected.width !== actual.width || expected.height !== actual.height) { + throw new Error(`이미지 크기 불일치: ${expected.width}x${expected.height} vs ${actual.width}x${actual.height}`); + } + + const exactDiff = new PNG({ width: expected.width, height: expected.height }); + const exactDiffPixels = pixelmatch( + expected.data, + actual.data, + exactDiff.data, + expected.width, + expected.height, + { threshold, includeAA: true }, + ); + + const tolerantDiff = new PNG({ width: expected.width, height: expected.height }); + let tolerantDiffPixels = 0; + let totalChannelDelta = 0; + let maxChannelDelta = 0; + + for (let i = 0; i < expected.data.length; i += 4) { + let pixelMaxDelta = 0; + + for (let channel = 0; channel < 4; channel++) { + const delta = Math.abs(expected.data[i + channel] - actual.data[i + channel]); + totalChannelDelta += delta; + if (delta > pixelMaxDelta) pixelMaxDelta = delta; + if (delta > maxChannelDelta) maxChannelDelta = delta; + } + + if (pixelMaxDelta > ignoreChannelDelta) { + tolerantDiffPixels++; + tolerantDiff.data[i] = Math.max(pixelMaxDelta, 32); + tolerantDiff.data[i + 1] = 0; + tolerantDiff.data[i + 2] = 0; + tolerantDiff.data[i + 3] = 255; + } + } + + const totalPixels = expected.width * expected.height; + const exactDiffRatio = totalPixels > 0 ? exactDiffPixels / totalPixels : 0; + const rawTolerantDiffRatio = totalPixels > 0 ? tolerantDiffPixels / totalPixels : 0; + const meanAbsChannelDelta = totalPixels > 0 ? totalChannelDelta / (totalPixels * 4) : 0; + const hasPixelBudget = maxDiffPixels != null; + const hasRatioBudget = maxDiffRatio != null; + const passed = hasPixelBudget || hasRatioBudget + ? (!hasPixelBudget || tolerantDiffPixels <= maxDiffPixels) + && (!hasRatioBudget || rawTolerantDiffRatio <= maxDiffRatio) + : tolerantDiffPixels === 0; + const budgetedTolerantDiffPixels = passed ? 0 : tolerantDiffPixels; + const budgetedTolerantDiffRatio = passed ? 0 : rawTolerantDiffRatio; + + let exactDiffPath = null; + let tolerantDiffPath = null; + if (diffName && (exactDiffPixels > 0 || tolerantDiffPixels > 0)) { + const { mkdirSync, existsSync, writeFileSync } = await import('fs'); + const outputDir = '../output/e2e/canvaskit-diff'; + if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }); + if (exactDiffPixels > 0) { + exactDiffPath = `${outputDir}/${diffName}.png`; + writeFileSync(exactDiffPath, PNG.sync.write(exactDiff)); + console.log(` Exact Diff Artifact: ${exactDiffPath}`); + } + if (!passed && tolerantDiffPixels > 0) { + tolerantDiffPath = `${outputDir}/${diffName}-tolerant.png`; + writeFileSync(tolerantDiffPath, PNG.sync.write(tolerantDiff)); + console.log(` Tolerant Diff Artifact: ${tolerantDiffPath}`); + } + } + + return { + passed, + diffPixels: budgetedTolerantDiffPixels, + diffRatio: budgetedTolerantDiffRatio, + exactDiffPixels, + exactDiffRatio, + tolerantDiffPixels: budgetedTolerantDiffPixels, + tolerantDiffRatio: budgetedTolerantDiffRatio, + rawTolerantDiffPixels: tolerantDiffPixels, + rawTolerantDiffRatio, + width: expected.width, + height: expected.height, + ignoreChannelDelta, + meanAbsChannelDelta, + maxChannelDelta, + exactDiffPath, + tolerantDiffPath, + }; +} + /** WASM bridge를 통해 페이지 수 조회 */ export async function getPageCount(page) { return await page.evaluate(() => window.__wasm?.pageCount ?? 0); diff --git a/rhwp-studio/package-lock.json b/rhwp-studio/package-lock.json index 8440c6f6..2e287027 100644 --- a/rhwp-studio/package-lock.json +++ b/rhwp-studio/package-lock.json @@ -1,13 +1,18 @@ { "name": "rhwp-studio", - "version": "0.7.1", + "version": "0.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rhwp-studio", - "version": "0.7.1", + "version": "0.7.2", "license": "MIT", + "dependencies": { + "canvaskit-wasm": "^0.41.1", + "pixelmatch": "^7.1.0", + "pngjs": "^7.0.0" + }, "devDependencies": { "@types/chrome": "^0.1.40", "puppeteer-core": "^24.40.0", @@ -431,6 +436,12 @@ "@types/node": "*" } }, + "node_modules/@webgpu/types": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz", + "integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==", + "license": "BSD-3-Clause" + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -595,6 +606,15 @@ "node": "*" } }, + "node_modules/canvaskit-wasm": { + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.41.1.tgz", + "integrity": "sha512-gO2rNMIE0lOQ8MaarM4qNeq2zZAEknAObKP2XAKidMSAY8P5sURGdFkrx1TSdHuBFqTf5w35JqjuUzjibqkD5g==", + "license": "BSD-3-Clause", + "dependencies": { + "@webgpu/types": "0.1.21" + } + }, "node_modules/chromium-bidi": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", @@ -1307,6 +1327,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/postcss": { "version": "8.5.9", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", diff --git a/rhwp-studio/package.json b/rhwp-studio/package.json index 074b6d14..dea8c3c5 100644 --- a/rhwp-studio/package.json +++ b/rhwp-studio/package.json @@ -7,12 +7,17 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "e2e": "node e2e/text-flow.test.mjs" + "e2e": "node e2e/text-flow.test.mjs && node e2e/canvaskit-render.test.mjs && RHWP_CANVASKIT_MODE=default node e2e/canvaskit-render.test.mjs" }, "devDependencies": { "@types/chrome": "^0.1.40", "puppeteer-core": "^24.40.0", "typescript": "^6.0.2", "vite": "^8.0.8" + }, + "dependencies": { + "canvaskit-wasm": "^0.41.1", + "pixelmatch": "^7.1.0", + "pngjs": "^7.0.0" } } diff --git a/rhwp-studio/src/core/font-loader.ts b/rhwp-studio/src/core/font-loader.ts index cdbac234..95a39d85 100644 --- a/rhwp-studio/src/core/font-loader.ts +++ b/rhwp-studio/src/core/font-loader.ts @@ -11,24 +11,35 @@ interface FontEntry { file: string; /** woff2(기본) 또는 woff — CDN woff 파일용 */ format?: 'woff2' | 'woff'; + /** CSS/FontFace font-weight descriptor */ + weight?: string; } -// 함초롬체 CDN (눈누 jsdelivr — 비상업적 사용 허용, 한컴 라이선스) -const CDN_HAMCHOB_R = 'https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_2104@1.0/HANBatang.woff'; -const CDN_HAMCHOB_B = 'https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_2104@1.0/HANBatangB.woff'; -const CDN_HAMCHOD_R = 'https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_four@1.0/HCRDotum.woff'; +// 함초롬 aliases는 번들된 로컬 서체로 안정적으로 치환한다. +const HAMCHOROM_BATANG_REGULAR = 'fonts/NotoSerifKR-Regular.woff2'; +const HAMCHOROM_BATANG_BOLD = 'fonts/NotoSerifKR-Bold.woff2'; +const HAMCHOROM_DOTUM_REGULAR = 'fonts/NotoSansKR-Regular.woff2'; +const HAMCHOROM_DOTUM_BOLD = 'fonts/NotoSansKR-Bold.woff2'; // 한컴 webhwp CSS(@font-face) 매핑 기준 + HWP 문서에서 사용하는 별칭 const FONT_LIST: FontEntry[] = [ - // === 함초롬/함초롱/한컴 폰트 (CDN 참조) === - { name: '함초롬돋움', file: CDN_HAMCHOD_R, format: 'woff' }, - { name: '함초롬바탕', file: CDN_HAMCHOB_R, format: 'woff' }, - { name: '함초롱돋움', file: CDN_HAMCHOD_R, format: 'woff' }, - { name: '함초롱바탕', file: CDN_HAMCHOB_R, format: 'woff' }, - { name: '한컴돋움', file: CDN_HAMCHOD_R, format: 'woff' }, - { name: '한컴바탕', file: CDN_HAMCHOB_R, format: 'woff' }, - { name: '새돋움', file: CDN_HAMCHOD_R, format: 'woff' }, - { name: '새바탕', file: CDN_HAMCHOB_R, format: 'woff' }, + // === 함초롬/함초롱/한컴 폰트 aliases → 번들 서체 === + { name: '함초롬돋움', file: HAMCHOROM_DOTUM_REGULAR, weight: '400' }, + { name: '함초롬돋움', file: HAMCHOROM_DOTUM_BOLD, weight: '700' }, + { name: '함초롬바탕', file: HAMCHOROM_BATANG_REGULAR, weight: '400' }, + { name: '함초롬바탕', file: HAMCHOROM_BATANG_BOLD, weight: '700' }, + { name: '함초롱돋움', file: HAMCHOROM_DOTUM_REGULAR, weight: '400' }, + { name: '함초롱돋움', file: HAMCHOROM_DOTUM_BOLD, weight: '700' }, + { name: '함초롱바탕', file: HAMCHOROM_BATANG_REGULAR, weight: '400' }, + { name: '함초롱바탕', file: HAMCHOROM_BATANG_BOLD, weight: '700' }, + { name: '한컴돋움', file: HAMCHOROM_DOTUM_REGULAR, weight: '400' }, + { name: '한컴돋움', file: HAMCHOROM_DOTUM_BOLD, weight: '700' }, + { name: '한컴바탕', file: HAMCHOROM_BATANG_REGULAR, weight: '400' }, + { name: '한컴바탕', file: HAMCHOROM_BATANG_BOLD, weight: '700' }, + { name: '새돋움', file: HAMCHOROM_DOTUM_REGULAR, weight: '400' }, + { name: '새돋움', file: HAMCHOROM_DOTUM_BOLD, weight: '700' }, + { name: '새바탕', file: HAMCHOROM_BATANG_REGULAR, weight: '400' }, + { name: '새바탕', file: HAMCHOROM_BATANG_BOLD, weight: '700' }, // === 한컴 HY 폰트 → 오픈소스 대체 === { name: 'HY헤드라인M', file: 'fonts/NotoSansKR-Bold.woff2' }, { name: 'HYHeadLine M', file: 'fonts/NotoSansKR-Bold.woff2' }, @@ -44,26 +55,40 @@ const FONT_LIST: FontEntry[] = [ { name: 'HY중고딕', file: 'fonts/NotoSansKR-Regular.woff2' }, { name: '양재튼튼체B', file: 'fonts/NotoSansKR-Bold.woff2' }, // === 한글 시스템 폰트 → 오픈소스 대체 (OS 폰트 없을 때 폴백) === - { name: 'Malgun Gothic', file: 'fonts/Pretendard-Regular.woff2' }, - { name: '맑은 고딕', file: 'fonts/Pretendard-Regular.woff2' }, + { name: 'Malgun Gothic', file: 'fonts/NotoSansKR-Regular.woff2', weight: '400' }, + { name: 'Malgun Gothic', file: 'fonts/NotoSansKR-Bold.woff2', weight: '700' }, + { name: '맑은 고딕', file: 'fonts/NotoSansKR-Regular.woff2', weight: '400' }, + { name: '맑은 고딕', file: 'fonts/NotoSansKR-Bold.woff2', weight: '700' }, + { name: 'Apple SD Gothic Neo', file: 'fonts/NotoSansKR-Regular.woff2', weight: '400' }, + { name: 'Apple SD Gothic Neo', file: 'fonts/NotoSansKR-Bold.woff2', weight: '700' }, { name: '돋움', file: 'fonts/NotoSansKR-Regular.woff2' }, { name: '돋움체', file: 'fonts/NotoSansKR-Regular.woff2' }, { name: '굴림', file: 'fonts/NotoSansKR-Regular.woff2' }, + { name: 'GulimChe', file: 'fonts/D2Coding-Regular.woff2' }, { name: '굴림체', file: 'fonts/D2Coding-Regular.woff2' }, { name: '새굴림', file: 'fonts/NotoSansKR-Regular.woff2' }, + { name: 'Batang', file: 'fonts/NotoSerifKR-Regular.woff2' }, { name: '바탕', file: 'fonts/NotoSerifKR-Regular.woff2' }, { name: '바탕체', file: 'fonts/D2Coding-Regular.woff2' }, + { name: 'AppleMyungjo', file: 'fonts/NotoSerifKR-Regular.woff2' }, { name: '궁서', file: 'fonts/GowunBatang-Regular.woff2' }, { name: '궁서체', file: 'fonts/GowunBatang-Regular.woff2' }, { name: '새궁서', file: 'fonts/GowunBatang-Regular.woff2' }, // === 나눔 폰트 (OFL, 로컬) === + { name: 'NanumGothic', file: 'fonts/NanumGothic-Regular.woff2' }, { name: '나눔고딕', file: 'fonts/NanumGothic-Regular.woff2' }, + { name: 'NanumMyeongjo', file: 'fonts/NanumMyeongjo-Regular.woff2' }, { name: '나눔명조', file: 'fonts/NanumMyeongjo-Regular.woff2' }, + { name: 'NanumGothicCoding', file: 'fonts/NanumGothicCoding-Regular.woff2' }, { name: '나눔고딕코딩', file: 'fonts/NanumGothicCoding-Regular.woff2' }, // === 영문 폰트 → OS 폴백 (번들 제거) === { name: 'Palatino Linotype', file: 'fonts/NotoSerifKR-Regular.woff2' }, // === Noto (OFL, 로컬) === - { name: 'Noto Sans KR', file: 'fonts/NotoSansKR-Regular.woff2' }, + { name: 'Noto Sans CJK KR', file: 'fonts/NotoSansKR-Regular.woff2', weight: '400' }, + { name: 'Noto Sans CJK KR', file: 'fonts/NotoSansKR-Bold.woff2', weight: '700' }, + { name: 'Noto Sans KR', file: 'fonts/NotoSansKR-Regular.woff2', weight: '400' }, + { name: 'Noto Sans KR', file: 'fonts/NotoSansKR-Bold.woff2', weight: '700' }, + { name: 'Noto Serif CJK KR', file: 'fonts/NotoSerifKR-Regular.woff2' }, { name: 'Noto Serif KR', file: 'fonts/NotoSerifKR-Regular.woff2' }, // === Pretendard === { name: 'Pretendard', file: 'fonts/Pretendard-Regular.woff2' }, @@ -122,7 +147,9 @@ const OS_FONT_CANDIDATES = [ // macOS / iOS 'Apple SD Gothic Neo', 'AppleMyungjo', 'AppleGothic', // Android - 'Noto Sans KR', 'Noto Serif KR', + 'Noto Sans KR', 'Noto Serif KR', 'Noto Sans CJK KR', 'Noto Serif CJK KR', + // Linux/Open source + 'NanumGothic', 'NanumMyeongjo', 'NanumGothicCoding', ]; const detectedOSFonts = new Set(); @@ -167,7 +194,7 @@ export async function loadWebFonts( const style = document.createElement('style'); style.textContent = FONT_LIST.map(f => { const fmt = f.format ?? 'woff2'; - return `@font-face { font-family: "${f.name}"; src: url("${f.file}") format("${fmt}"); font-display: swap; }`; + return `@font-face { font-family: "${f.name}"; src: url("${f.file}") format("${fmt}"); font-display: swap; font-weight: ${f.weight ?? '400'}; }`; }).join('\n'); document.head.appendChild(style); fontFaceRegistered = true; @@ -187,8 +214,9 @@ export async function loadWebFonts( const seenFiles = new Set(); const uniqueToLoad: FontEntry[] = []; for (const f of toLoad) { - if (!seenFiles.has(f.file) && !loadedFiles.has(f.file)) { - seenFiles.add(f.file); + const loadKey = `${f.file}::${f.weight ?? '400'}`; + if (!seenFiles.has(loadKey) && !loadedFiles.has(loadKey)) { + seenFiles.add(loadKey); uniqueToLoad.push(f); } } @@ -199,12 +227,13 @@ export async function loadWebFonts( console.log(`[FontLoader] 웹폰트 로드 시작: ${total}개 woff2 (이미 로드됨: ${loadedFiles.size}개)`); // 같은 woff2 파일에 매핑된 모든 이름도 함께 등록 - const fileToNames = new Map(); + const fileToFaces = new Map>(); for (const f of toLoad) { - if (!loadedFiles.has(f.file)) { - const names = fileToNames.get(f.file) ?? []; - names.push(f.name); - fileToNames.set(f.file, names); + const loadKey = `${f.file}::${f.weight ?? '400'}`; + if (!loadedFiles.has(loadKey)) { + const faces = fileToFaces.get(loadKey) ?? []; + faces.push({ name: f.name, weight: f.weight ?? '400' }); + fileToFaces.set(loadKey, faces); } } @@ -216,14 +245,15 @@ export async function loadWebFonts( const batch = uniqueToLoad.slice(i, i + BATCH); await Promise.all(batch.map(async (f) => { try { - const names = fileToNames.get(f.file) ?? [f.name]; + const loadKey = `${f.file}::${f.weight ?? '400'}`; + const faces = fileToFaces.get(loadKey) ?? [{ name: f.name, weight: f.weight ?? '400' }]; const fmt = f.format ?? 'woff2'; - for (const name of names) { - const face = new FontFace(name, `url(${f.file}) format('${fmt}')`); + for (const { name, weight } of faces) { + const face = new FontFace(name, `url(${f.file}) format('${fmt}')`, { weight }); const result = await face.load(); document.fonts.add(result); } - loadedFiles.add(f.file); + loadedFiles.add(loadKey); loaded++; } catch { failed++; diff --git a/rhwp-studio/src/core/font-substitution.ts b/rhwp-studio/src/core/font-substitution.ts index b9bf18ca..d2a22be2 100644 --- a/rhwp-studio/src/core/font-substitution.ts +++ b/rhwp-studio/src/core/font-substitution.ts @@ -257,12 +257,12 @@ export function fontFamilyWithFallback(fontName: string): string { const lower = fontName.toLowerCase(); // Monospace 판별 if (/굴림체|바탕체|gulimche|batangche|coding|courier/i.test(fontName)) { - return `"${fontName}", "GulimChe", "D2Coding", "Noto Sans Mono", monospace`; + return `"${fontName}", "GulimChe", "D2Coding", "NanumGothicCoding", "나눔고딕코딩", "Noto Sans Mono", monospace`; } // Serif 판별 if (/[바탕명조궁서]|hymjre|times|palatino|georgia|batang|gungsuh/i.test(fontName)) { - return `"${fontName}", "Batang", "AppleMyungjo", "Noto Serif KR", serif`; + return `"${fontName}", "Batang", "AppleMyungjo", "Noto Serif KR", "Noto Serif CJK KR", "NanumMyeongjo", "나눔명조", serif`; } // Sans-serif (기본) - return `"${fontName}", "Malgun Gothic", "Apple SD Gothic Neo", "Noto Sans KR", "Pretendard", sans-serif`; + return `"${fontName}", "Malgun Gothic", "Apple SD Gothic Neo", "Noto Sans KR", "Noto Sans CJK KR", "NanumGothic", "나눔고딕", "Pretendard", sans-serif`; } diff --git a/rhwp-studio/src/core/types.ts b/rhwp-studio/src/core/types.ts index 57e3fca9..db816a8d 100644 --- a/rhwp-studio/src/core/types.ts +++ b/rhwp-studio/src/core/types.ts @@ -30,6 +30,312 @@ export interface PageInfo { columns?: { x: number; width: number }[]; } +export interface LayerBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface PageLayerTree { + pageWidth: number; + pageHeight: number; + root: LayerNode; +} + +export type LayerNode = LayerGroupNode | LayerClipNode | LayerLeafNode; + +export interface LayerGroupNode { + bounds: LayerBounds; + kind: 'group'; + sourceNodeId?: number; + children: LayerNode[]; +} + +export interface LayerClipNode { + bounds: LayerBounds; + kind: 'clipRect'; + sourceNodeId?: number; + clip: LayerBounds; + child: LayerNode; +} + +export interface LayerLeafNode { + bounds: LayerBounds; + kind: 'leaf'; + sourceNodeId?: number; + ops: LayerPaintOp[]; +} + +export type LayerPaintOp = + | LayerPageBackgroundOp + | LayerTextRunOp + | LayerFootnoteMarkerOp + | LayerLineOp + | LayerRectangleOp + | LayerEllipseOp + | LayerPathOp + | LayerImageOp + | LayerEquationOp + | LayerFormObjectOp; + +export interface LayerTextStyle { + fontFamily: string; + fontSize: number; + color: string; + bold: boolean; + italic: boolean; + ratio: number; + underline: 'none' | 'bottom' | 'top'; + underlineShape: number; + strikethrough: boolean; + strikeShape: number; + outlineType: number; + shadowType: number; + shadowColor: string; + shadowOffsetX: number; + shadowOffsetY: number; + emboss: boolean; + engrave: boolean; + emphasisDot: number; + underlineColor: string; + strikeColor: string; + shadeColor: string; +} + +export interface LayerTabLeader { + startX: number; + endX: number; + fillType: number; +} + +export interface LayerShapeShadow { + shadowType: number; + color: string; + offsetX: number; + offsetY: number; + alpha: number; +} + +export interface LayerPatternFill { + patternType: number; + patternColor: string; + backgroundColor: string; +} + +export interface LayerShapeStyle { + fillColor: string | null; + strokeColor: string | null; + strokeWidth: number; + strokeDash: 'solid' | 'dash' | 'dot' | 'dashDot' | 'dashDotDot'; + opacity: number; + pattern?: LayerPatternFill; + shadow?: LayerShapeShadow; +} + +export interface LayerLineStyle { + color: string; + width: number; + dash: 'solid' | 'dash' | 'dot' | 'dashDot' | 'dashDotDot'; + lineType: 'single' | 'double' | 'thinThickDouble' | 'thickThinDouble' | 'thinThickThinTriple'; + startArrow: string; + endArrow: string; + startArrowSize: number; + endArrowSize: number; + shadow?: LayerShapeShadow; +} + +export interface LayerTransform { + rotation: number; + horzFlip: boolean; + vertFlip: boolean; +} + +export interface LayerGradient { + gradientType: number; + angle: number; + centerX: number; + centerY: number; + colors: string[]; + positions: number[]; +} + +export type LayerPathCommand = + | { type: 'moveTo'; x: number; y: number } + | { type: 'lineTo'; x: number; y: number } + | { type: 'curveTo'; x1: number; y1: number; x2: number; y2: number; x3: number; y3: number } + | { type: 'arcTo'; rx: number; ry: number; rotation: number; largeArc: boolean; sweep: boolean; x: number; y: number } + | { type: 'closePath' }; + +export interface LayerPageBackgroundOp { + type: 'pageBackground'; + bbox: LayerBounds; + backgroundColor?: string; + borderColor?: string; + borderWidth: number; + gradient?: LayerGradient; + image?: { + fillMode: string; + base64: string; + }; +} + +export interface LayerTextRunOp { + type: 'textRun'; + bbox: LayerBounds; + text: string; + baseline: number; + rotation: number; + isVertical: boolean; + style: LayerTextStyle; + positions: number[]; + tabLeaders?: LayerTabLeader[]; +} + +export interface LayerFootnoteMarkerOp { + type: 'footnoteMarker'; + bbox: LayerBounds; + text: string; + fontFamily: string; + fontSize: number; + color: string; +} + +export interface LayerLineOp { + type: 'line'; + bbox: LayerBounds; + x1: number; + y1: number; + x2: number; + y2: number; + style: LayerLineStyle; + transform: LayerTransform; +} + +export interface LayerRectangleOp { + type: 'rectangle'; + bbox: LayerBounds; + cornerRadius: number; + style: LayerShapeStyle; + gradient?: LayerGradient; + transform: LayerTransform; +} + +export interface LayerEllipseOp { + type: 'ellipse'; + bbox: LayerBounds; + style: LayerShapeStyle; + gradient?: LayerGradient; + transform: LayerTransform; +} + +export interface LayerPathOp { + type: 'path'; + bbox: LayerBounds; + commands: LayerPathCommand[]; + style: LayerShapeStyle; + gradient?: LayerGradient; + connectorEndpoints?: { + x1: number; + y1: number; + x2: number; + y2: number; + }; + lineStyle?: LayerLineStyle; + transform: LayerTransform; +} + +export interface LayerImageOp { + type: 'image'; + bbox: LayerBounds; + base64?: string; + fillMode?: string; + originalSize?: { + width: number; + height: number; + }; + crop?: { + left: number; + top: number; + right: number; + bottom: number; + }; + transform: LayerTransform; +} + +export type LayerEquationMatrixStyle = 'plain' | 'paren' | 'bracket' | 'vert'; +export type LayerEquationDecoration = + | 'hat' + | 'check' + | 'tilde' + | 'acute' + | 'grave' + | 'dot' + | 'dDot' + | 'bar' + | 'vec' + | 'dyad' + | 'under' + | 'arch' + | 'underline' + | 'overline' + | 'strikeThrough'; +export type LayerEquationFontStyle = 'roman' | 'italic' | 'bold'; + +export interface LayerEquationLayoutBox { + x: number; + y: number; + width: number; + height: number; + baseline: number; + kind: LayerEquationLayoutKind; +} + +export type LayerEquationLayoutKind = + | { type: 'row'; children: LayerEquationLayoutBox[] } + | { type: 'text'; text: string } + | { type: 'number'; text: string } + | { type: 'symbol'; text: string } + | { type: 'mathSymbol'; text: string } + | { type: 'function'; name: string } + | { type: 'fraction'; numer: LayerEquationLayoutBox; denom: LayerEquationLayoutBox } + | { type: 'sqrt'; body: LayerEquationLayoutBox; index?: LayerEquationLayoutBox } + | { type: 'superscript'; base: LayerEquationLayoutBox; sup: LayerEquationLayoutBox } + | { type: 'subscript'; base: LayerEquationLayoutBox; sub: LayerEquationLayoutBox } + | { type: 'subSup'; base: LayerEquationLayoutBox; sub: LayerEquationLayoutBox; sup: LayerEquationLayoutBox } + | { type: 'bigOp'; symbol: string; sub?: LayerEquationLayoutBox; sup?: LayerEquationLayoutBox } + | { type: 'limit'; isUpper: boolean; sub?: LayerEquationLayoutBox } + | { type: 'matrix'; style: LayerEquationMatrixStyle; cells: LayerEquationLayoutBox[][] } + | { type: 'rel'; arrow: LayerEquationLayoutBox; over: LayerEquationLayoutBox; under?: LayerEquationLayoutBox } + | { type: 'eqAlign'; rows: Array<{ left: LayerEquationLayoutBox; right: LayerEquationLayoutBox }> } + | { type: 'paren'; left: string; right: string; body: LayerEquationLayoutBox } + | { type: 'decoration'; decoration: LayerEquationDecoration; body: LayerEquationLayoutBox } + | { type: 'fontStyle'; fontStyle: LayerEquationFontStyle; body: LayerEquationLayoutBox } + | { type: 'space'; width: number } + | { type: 'newline' } + | { type: 'empty' }; + +export interface LayerEquationOp { + type: 'equation'; + bbox: LayerBounds; + color: string; + fontSize: number; + svgContent: string; + layoutBox: LayerEquationLayoutBox; +} + +export interface LayerFormObjectOp { + type: 'formObject'; + bbox: LayerBounds; + formType: string; + caption: string; + text: string; + foreColor: string; + backColor: string; + value: number; + enabled: boolean; +} + /** WASM getPageDef() 반환 타입 — HWPUNIT 원본값 */ export interface PageDef { width: number; diff --git a/rhwp-studio/src/core/wasm-bridge.ts b/rhwp-studio/src/core/wasm-bridge.ts index d76da8d4..8308af8c 100644 --- a/rhwp-studio/src/core/wasm-bridge.ts +++ b/rhwp-studio/src/core/wasm-bridge.ts @@ -1,5 +1,5 @@ import init, { HwpDocument, version } from '@wasm/rhwp.js'; -import type { DocumentInfo, PageInfo, PageDef, SectionDef, CursorRect, HitTestResult, LineInfo, TableDimensions, CellInfo, CellBbox, CellProperties, TableProperties, DocumentPosition, MoveVerticalResult, SelectionRect, CharProperties, ParaProperties, CellPathEntry, NavContextEntry, FieldInfoResult, BookmarkInfo } from './types'; +import type { DocumentInfo, PageInfo, PageDef, SectionDef, CursorRect, HitTestResult, LineInfo, TableDimensions, CellInfo, CellBbox, CellProperties, TableProperties, DocumentPosition, MoveVerticalResult, SelectionRect, CharProperties, ParaProperties, CellPathEntry, NavContextEntry, FieldInfoResult, BookmarkInfo, PageLayerTree } from './types'; import { resolveFont, fontFamilyWithFallback } from './font-substitution'; import { REGISTERED_FONTS } from './font-loader'; @@ -145,6 +145,11 @@ export class WasmBridge { return this.doc.renderPageSvg(pageNum); } + getPageLayerTree(pageNum: number): PageLayerTree { + if (!this.doc) throw new Error('문서가 로드되지 않았습니다'); + return JSON.parse((this.doc as any).getPageLayerTree(pageNum)); + } + getCursorRect(sec: number, para: number, charOffset: number): CursorRect { if (!this.doc) throw new Error('문서가 로드되지 않았습니다'); return JSON.parse(this.doc.getCursorRect(sec, para, charOffset)); diff --git a/rhwp-studio/src/main.ts b/rhwp-studio/src/main.ts index 8d5c11ee..d3518c4f 100644 --- a/rhwp-studio/src/main.ts +++ b/rhwp-studio/src/main.ts @@ -23,6 +23,13 @@ import { CellSelectionRenderer } from '@/engine/cell-selection-renderer'; import { TableObjectRenderer } from '@/engine/table-object-renderer'; import { TableResizeRenderer } from '@/engine/table-resize-renderer'; import { Ruler } from '@/view/ruler'; +import { CanvasKitLayerRenderer } from '@/view/canvaskit-renderer'; +import { + persistCanvasKitRenderMode, + persistRenderBackend, + resolveCanvasKitRenderMode, + resolveRenderBackend, +} from '@/view/render-backend'; const wasm = new WasmBridge(); const eventBus = new EventBus(); @@ -91,10 +98,26 @@ async function initialize(): Promise { await loadWebFonts([]); // CSS @font-face 등록 + CRITICAL 폰트만 로드 msg.textContent = 'WASM 로딩 중...'; await wasm.initialize(); + const requestedBackend = resolveRenderBackend(window.location.search); + const canvaskitMode = resolveCanvasKitRenderMode(window.location.search); + let renderBackend = requestedBackend; + let canvaskitRenderer: CanvasKitLayerRenderer | null = null; + + if (renderBackend === 'canvaskit') { + msg.textContent = 'CanvasKit 로딩 중...'; + try { + canvaskitRenderer = await CanvasKitLayerRenderer.create(canvaskitMode); + } catch (error) { + console.error('[main] CanvasKit 초기화 실패, Canvas2D로 폴백합니다:', error); + renderBackend = 'canvas2d'; + } + } + persistRenderBackend(renderBackend); + persistCanvasKitRenderMode(canvaskitMode); msg.textContent = 'HWP 파일을 선택해주세요.'; const container = document.getElementById('scroll-container')!; - canvasView = new CanvasView(container, wasm, eventBus); + canvasView = new CanvasView(container, wasm, eventBus, renderBackend, canvaskitRenderer); // 눈금자 초기화 ruler = new Ruler( @@ -182,6 +205,8 @@ async function initialize(): Promise { if (import.meta.env.DEV) { (window as any).__inputHandler = inputHandler; (window as any).__canvasView = canvasView; + (window as any).__renderBackend = renderBackend; + (window as any).__canvaskitRenderMode = canvaskitMode; } } catch (error) { msg.textContent = `WASM 초기화 실패: ${error}`; diff --git a/rhwp-studio/src/view/canvas-view.ts b/rhwp-studio/src/view/canvas-view.ts index 9a55aaf8..3f28c39a 100644 --- a/rhwp-studio/src/view/canvas-view.ts +++ b/rhwp-studio/src/view/canvas-view.ts @@ -6,6 +6,8 @@ import { CanvasPool } from './canvas-pool'; import { PageRenderer } from './page-renderer'; import { ViewportManager } from './viewport-manager'; import { CoordinateSystem } from './coordinate-system'; +import { CanvasKitLayerRenderer } from './canvaskit-renderer'; +import type { RenderBackend } from './render-backend'; export class CanvasView { private virtualScroll: VirtualScroll; @@ -23,10 +25,12 @@ export class CanvasView { private container: HTMLElement, private wasm: WasmBridge, private eventBus: EventBus, + renderBackend: RenderBackend, + canvaskitRenderer: CanvasKitLayerRenderer | null, ) { this.virtualScroll = new VirtualScroll(); this.canvasPool = new CanvasPool(); - this.pageRenderer = new PageRenderer(wasm); + this.pageRenderer = new PageRenderer(wasm, renderBackend, canvaskitRenderer); this.viewportManager = new ViewportManager(eventBus); this.coordinateSystem = new CoordinateSystem(this.virtualScroll); @@ -137,15 +141,18 @@ export class CanvasView { // iOS WebKit Canvas 최대 크기 제한 (64MP = 67,108,864 pixels) // 물리 크기 = pageSize × zoom × dpr 가 제한을 초과하면 dpr을 낮춘다 const pageInfo = this.pages[pageIdx]; + if (!pageInfo) { + console.error(`[CanvasView] 페이지 ${pageIdx} 정보가 없습니다`); + this.canvasPool.release(pageIdx); + return; + } const MAX_CANVAS_PIXELS = 67108864; let dpr = rawDpr; - if (pageInfo) { - const physW = pageInfo.width * zoom * dpr; - const physH = pageInfo.height * zoom * dpr; - if (physW * physH > MAX_CANVAS_PIXELS) { - dpr = Math.sqrt(MAX_CANVAS_PIXELS / (pageInfo.width * zoom * pageInfo.height * zoom)); - dpr = Math.max(1, Math.floor(dpr)); // 최소 1, 정수로 내림 - } + const physW = pageInfo.width * zoom * dpr; + const physH = pageInfo.height * zoom * dpr; + if (physW * physH > MAX_CANVAS_PIXELS) { + dpr = Math.sqrt(MAX_CANVAS_PIXELS / (pageInfo.width * zoom * pageInfo.height * zoom)); + dpr = Math.max(1, Math.floor(dpr)); // 최소 1, 정수로 내림 } const renderScale = zoom * dpr; @@ -166,7 +173,7 @@ export class CanvasView { // WASM이 Canvas 크기를 자동 설정한다 (물리 픽셀 = 페이지크기 × zoom × DPR) try { - this.pageRenderer.renderPage(pageIdx, canvas, renderScale); + this.pageRenderer.renderPage(pageIdx, pageInfo, canvas, renderScale); } catch (e) { console.error(`[CanvasView] 페이지 ${pageIdx} 렌더링 실패:`, e); this.canvasPool.release(pageIdx); @@ -279,6 +286,10 @@ export class CanvasView { return this.viewportManager; } + getRenderBackend(): RenderBackend { + return this.pageRenderer.getBackend(); + } + getCoordinateSystem(): CoordinateSystem { return this.coordinateSystem; } diff --git a/rhwp-studio/src/view/canvaskit-renderer.ts b/rhwp-studio/src/view/canvaskit-renderer.ts new file mode 100644 index 00000000..21cd8a26 --- /dev/null +++ b/rhwp-studio/src/view/canvaskit-renderer.ts @@ -0,0 +1,2377 @@ +import CanvasKitInit from 'canvaskit-wasm'; +import type { CanvasKit, Font, Image, Paint, Shader, Surface, Typeface, TypefaceFontProvider } from 'canvaskit-wasm'; +import canvaskitWasmUrl from 'canvaskit-wasm/bin/canvaskit.wasm?url'; + +import { fontFamilyWithFallback, resolveFont } from '@/core/font-substitution'; +import type { CanvasKitRenderMode } from '@/view/render-backend'; +import type { + LayerBounds, + LayerClipNode, + LayerEllipseOp, + LayerEquationLayoutBox, + LayerFootnoteMarkerOp, + LayerFormObjectOp, + LayerGradient, + LayerImageOp, + LayerLeafNode, + LayerLineOp, + LayerLineStyle, + LayerNode, + LayerPageBackgroundOp, + LayerPaintOp, + LayerPathCommand, + LayerPathOp, + LayerPatternFill, + LayerRectangleOp, + LayerShapeShadow, + LayerTabLeader, + LayerTextRunOp, + PageLayerTree, +} from '@/core/types'; + +const FONT_SANS_REGULAR_URL = new URL('../../../web/fonts/NotoSansKR-Regular.woff2', import.meta.url).href; +const FONT_SANS_BOLD_URL = new URL('../../../web/fonts/NotoSansKR-Bold.woff2', import.meta.url).href; +const FONT_SERIF_REGULAR_URL = new URL('../../../web/fonts/NotoSerifKR-Regular.woff2', import.meta.url).href; +const FONT_SERIF_BOLD_URL = new URL('../../../web/fonts/NotoSerifKR-Bold.woff2', import.meta.url).href; +const FONT_MONO_REGULAR_URL = new URL('../../../web/fonts/D2Coding-Regular.woff2', import.meta.url).href; +const FONT_HAMCHOROM_DOTUM_URL = new URL('../../../web/fonts/NotoSansKR-Regular.woff2', import.meta.url).href; +const FONT_HAMCHOROM_DOTUM_BOLD_URL = new URL('../../../web/fonts/NotoSansKR-Bold.woff2', import.meta.url).href; +const FONT_HAMCHOROM_BATANG_URL = new URL('../../../web/fonts/NotoSerifKR-Regular.woff2', import.meta.url).href; +const FONT_HAMCHOROM_BATANG_BOLD_URL = new URL('../../../web/fonts/NotoSerifKR-Bold.woff2', import.meta.url).href; + +const HAMCHOROM_DOTUM_FAMILY = 'HCR Dotum'; +const HAMCHOROM_BATANG_FAMILY = 'HCR Batang'; +const HAMCHOROM_DOTUM_ALIASES = new Set([ + '함초롬돋움', + '함초롱돋움', + '한컴돋움', + '새돋움', + HAMCHOROM_DOTUM_FAMILY, +]); +const HAMCHOROM_BATANG_ALIASES = new Set([ + '함초롬바탕', + '함초롱바탕', + '한컴바탕', + '새바탕', + HAMCHOROM_BATANG_FAMILY, +]); + +const SANS_ALIASES = [ + 'Noto Sans KR', + 'Noto Sans CJK KR', + 'NanumGothic', + '나눔고딕', + '맑은 고딕', + 'Malgun Gothic', + 'Apple SD Gothic Neo', + 'Pretendard', + '돋움', + '돋움체', + '굴림', + '새굴림', + 'HY중고딕', + 'HY그래픽', + 'HY그래픽M', + 'HYHeadLine M', + 'HYHeadLine Medium', + 'HY헤드라인M', + 'SpoqaHanSans', +]; + +const SERIF_ALIASES = [ + 'Noto Serif KR', + 'Noto Serif CJK KR', + 'NanumMyeongjo', + '나눔명조', + '바탕', + 'AppleMyungjo', + '궁서', + '새궁서', + 'HY신명조', + 'HY견명조', + 'Batang', +]; + +const MONO_ALIASES = [ + 'D2Coding', + 'NanumGothicCoding', + '나눔고딕코딩', + '굴림체', + 'GulimChe', + '바탕체', + 'Noto Sans Mono', +]; + +export class CanvasKitLayerRenderer { + private readonly imageCache = new Map(); + private readonly patternImageCache = new Map(); + private readonly fontAliases = new Set(); + + private constructor( + private readonly canvasKit: CanvasKit, + private readonly fontProvider: TypefaceFontProvider, + private readonly renderMode: CanvasKitRenderMode, + ) {} + + static async create(renderMode: CanvasKitRenderMode = 'compat'): Promise { + const canvasKit = await CanvasKitInit({ + locateFile: (file) => file === 'canvaskit.wasm' ? canvaskitWasmUrl : file, + }); + const fontProvider = canvasKit.TypefaceFontProvider.Make(); + const renderer = new CanvasKitLayerRenderer(canvasKit, fontProvider, renderMode); + await renderer.registerFonts(); + return renderer; + } + + renderPage( + tree: PageLayerTree, + targetCanvas: HTMLCanvasElement, + scale: number, + ): void { + const surface = this.canvasKit.MakeSWCanvasSurface(targetCanvas); + if (!surface) { + throw new Error('CanvasKit surface 생성 실패'); + } + + try { + const canvas = surface.getCanvas(); + canvas.clear(this.canvasKit.TRANSPARENT); + canvas.save(); + canvas.scale(scale, scale); + this.renderNode(canvas, tree.root); + canvas.restore(); + surface.flush(); + this.renderFallbackOverlays(tree.root, targetCanvas, scale); + } finally { + surface.delete(); + } + } + + private async registerFonts(): Promise { + const fontFiles = new Map(); + + const loadFontFile = async (url: string): Promise => { + const cached = fontFiles.get(url); + if (cached) return cached; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`CanvasKit font fetch failed: ${response.status} ${url}`); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + fontFiles.set(url, bytes); + return bytes; + }; + + const registerAliases = async (aliases: string[], regularUrl: string, boldUrl?: string): Promise => { + const regularBytes = await loadFontFile(regularUrl); + const boldBytes = boldUrl ? await loadFontFile(boldUrl) : null; + + for (const alias of aliases) { + this.fontProvider.registerFont(regularBytes, alias); + this.fontAliases.add(alias); + if (boldBytes) { + this.fontProvider.registerFont(boldBytes, alias); + } + } + }; + + await registerAliases([HAMCHOROM_DOTUM_FAMILY], FONT_HAMCHOROM_DOTUM_URL, FONT_HAMCHOROM_DOTUM_BOLD_URL); + await registerAliases([HAMCHOROM_BATANG_FAMILY], FONT_HAMCHOROM_BATANG_URL, FONT_HAMCHOROM_BATANG_BOLD_URL); + await registerAliases(SANS_ALIASES, FONT_SANS_REGULAR_URL, FONT_SANS_BOLD_URL); + await registerAliases(SERIF_ALIASES, FONT_SERIF_REGULAR_URL, FONT_SERIF_BOLD_URL); + await registerAliases(MONO_ALIASES, FONT_MONO_REGULAR_URL); + } + + private renderNode( + canvas: ReturnType, + node: LayerNode, + ): void { + switch (node.kind) { + case 'group': + for (const child of node.children) { + this.renderNode(canvas, child); + } + break; + case 'clipRect': + this.renderClipNode(canvas, node); + break; + case 'leaf': + this.renderLeafNode(canvas, node); + break; + } + } + + private renderClipNode( + canvas: ReturnType, + node: LayerClipNode, + ): void { + canvas.save(); + canvas.clipRect( + this.canvasKit.XYWHRect(node.clip.x, node.clip.y, node.clip.width, node.clip.height), + this.canvasKit.ClipOp.Intersect, + true, + ); + this.renderNode(canvas, node.child); + canvas.restore(); + } + + private renderLeafNode( + canvas: ReturnType, + node: LayerLeafNode, + ): void { + for (const op of node.ops) { + this.renderOp(canvas, op); + } + } + + private renderOp( + canvas: ReturnType, + op: LayerPaintOp, + ): void { + switch (op.type) { + case 'pageBackground': + this.renderPageBackground(canvas, op); + return; + case 'textRun': + if (this.shouldOverlayTextRun(op)) { + return; + } + this.renderTextRun(canvas, op); + return; + case 'footnoteMarker': + if (this.shouldOverlayFootnoteMarker(op)) { + return; + } + this.renderFootnoteMarker(canvas, op); + return; + case 'line': + this.renderLine(canvas, op); + return; + case 'rectangle': + this.renderRectangle(canvas, op); + return; + case 'ellipse': + this.renderEllipse(canvas, op); + return; + case 'path': + this.renderPath(canvas, op); + return; + case 'image': + this.renderImage(canvas, op); + return; + case 'equation': + return; + case 'formObject': + this.renderFormObject(canvas, op); + return; + } + } + + private shouldOverlayTextRun(op: LayerTextRunOp): boolean { + return true; + } + + private shouldOverlayFootnoteMarker(op: LayerFootnoteMarkerOp): boolean { + return true; + } + + private renderPageBackground(canvas: ReturnType, op: LayerPageBackgroundOp): void { + const fill = this.makeShapeFillPaint(op.bbox, op.backgroundColor ?? null, 1, op.gradient); + if (fill) { + canvas.drawRect(this.toRect(op.bbox), fill.paint); + fill.shader?.delete(); + fill.paint.delete(); + } + + if (op.image?.base64) { + this.drawEncodedImage(canvas, op.image.base64, op.bbox, op.image.fillMode); + } + + if (op.borderColor && op.borderWidth > 0) { + const paint = this.makePaint(op.borderColor, 'stroke'); + paint.setStrokeWidth(op.borderWidth); + canvas.drawRect(this.toRect(op.bbox), paint); + paint.delete(); + } + } + + private renderTextRun(canvas: ReturnType, op: LayerTextRunOp): void { + const ratio = typeof op.style.ratio === 'number' && op.style.ratio > 0 ? op.style.ratio : 1; + const outlineType = op.style.outlineType ?? 0; + const shadowType = op.style.shadowType ?? 0; + const shadowColor = typeof op.style.shadowColor === 'string' ? op.style.shadowColor : op.style.color; + const shadowOffsetX = typeof op.style.shadowOffsetX === 'number' ? op.style.shadowOffsetX : 0; + const shadowOffsetY = typeof op.style.shadowOffsetY === 'number' ? op.style.shadowOffsetY : 0; + const emboss = !!op.style.emboss; + const engrave = !!op.style.engrave; + const emphasisDot = op.style.emphasisDot ?? 0; + const shadeColor = (typeof op.style.shadeColor === 'string' ? op.style.shadeColor : '#ffffff').toLowerCase(); + const primaryObjects = this.makeTextObjects( + op.style.fontFamily, + op.style.fontSize, + op.style.bold, + op.style.italic, + op.style.color, + ratio, + ); + const clusters = splitIntoClusters(op.text); + const textObjectsByFamily = new Map(); + textObjectsByFamily.set(op.style.fontFamily, primaryObjects); + const fallbackFamilies = [ + op.style.fontFamily, + 'Noto Sans KR', + 'Noto Sans CJK KR', + 'NanumGothic', + 'D2Coding', + 'NanumGothicCoding', + 'Noto Serif KR', + 'Noto Serif CJK KR', + ].filter((family, index, all) => all.indexOf(family) === index); + const clusterFonts: Font[] = []; + for (const cluster of clusters) { + let selectedFont = primaryObjects.font; + const primaryGlyphs = primaryObjects.font.getGlyphIDs(cluster.text); + if (primaryGlyphs?.some((glyphId) => glyphId === 0)) { + for (const family of fallbackFamilies) { + let candidate = textObjectsByFamily.get(family); + if (!candidate) { + candidate = this.makeTextObjects( + family, + op.style.fontSize, + op.style.bold, + op.style.italic, + op.style.color, + ratio, + ); + textObjectsByFamily.set(family, candidate); + } + const candidateGlyphs = candidate.font.getGlyphIDs(cluster.text); + if (candidateGlyphs && candidateGlyphs.every((glyphId) => glyphId !== 0)) { + selectedFont = candidate.font; + break; + } + } + } + clusterFonts.push(selectedFont); + } + const drawClusters = (originX: number, originY: number) => { + const textWidth = op.positions.at(-1) ?? 0; + if (textWidth > 0 && shadeColor !== '#ffffff') { + const shadePaint = this.makePaint(shadeColor, 'fill'); + canvas.drawRect( + this.canvasKit.XYWHRect(originX, originY - op.style.fontSize, textWidth, op.style.fontSize * 1.2), + shadePaint, + ); + shadePaint.delete(); + } + + const drawPass = (dx: number, dy: number, fillPaint: Paint, strokePaint?: Paint) => { + for (const [index, cluster] of clusters.entries()) { + if (cluster.text === ' ' || cluster.text === '\t' || cluster.text === '\u2007') { + continue; + } + const x = originX + op.positions[cluster.start] + dx; + const y = originY + dy; + canvas.drawText(cluster.text, x, y, fillPaint, clusterFonts[index]); + if (strokePaint) { + canvas.drawText(cluster.text, x, y, strokePaint, clusterFonts[index]); + } + } + }; + + if (emboss || engrave) { + const offset = Math.max(op.style.fontSize / 20, 1); + const firstPaint = this.makePaint(emboss ? '#ffffff' : '#808080', 'fill'); + const secondPaint = this.makePaint(emboss ? '#808080' : '#ffffff', 'fill'); + drawPass(-offset, -offset, firstPaint); + drawPass(offset, offset, secondPaint); + drawPass(0, 0, primaryObjects.paint); + firstPaint.delete(); + secondPaint.delete(); + } else { + if (shadowType > 0) { + const shadowPaint = this.makePaint(shadowColor, 'fill'); + drawPass(shadowOffsetX, shadowOffsetY, shadowPaint); + shadowPaint.delete(); + } + + if (outlineType > 0) { + const fillPaint = this.makePaint('#ffffff', 'fill'); + const strokePaint = this.makePaint(op.style.color, 'stroke'); + strokePaint.setStrokeWidth(Math.max(op.style.fontSize / 25, 0.5)); + drawPass(0, 0, fillPaint, strokePaint); + fillPaint.delete(); + strokePaint.delete(); + } else { + drawPass(0, 0, primaryObjects.paint); + } + } + + if (emphasisDot > 0) { + const dotChar = + emphasisDot === 1 ? '●' + : emphasisDot === 2 ? '○' + : emphasisDot === 3 ? 'ˇ' + : emphasisDot === 4 ? '˜' + : emphasisDot === 5 ? '・' + : emphasisDot === 6 ? '˸' + : ''; + if (dotChar) { + const dotSize = op.style.fontSize * 0.3; + const dotY = originY - op.style.fontSize * 1.05; + const dotObjects = this.makeTextObjects('Noto Sans KR', dotSize, false, false, op.style.color); + for (const position of op.positions.slice(0, -1)) { + const dotX = originX + position + (op.style.fontSize * ratio * 0.5); + canvas.drawText(dotChar, dotX, dotY, dotObjects.paint, dotObjects.font); + } + dotObjects.paint.delete(); + dotObjects.font.delete(); + dotObjects.typeface.delete(); + } + } + + if (op.tabLeaders?.length) { + this.drawTabLeaders(canvas, op.tabLeaders, originX, originY, op.style.color); + } + + if (op.style.underline !== 'none') { + const underlinePaint = this.makePaint(op.style.underlineColor || op.style.color, 'stroke'); + underlinePaint.setStrokeWidth(1); + const y = op.style.underline === 'top' ? originY - op.style.fontSize + 1 : originY + 2; + canvas.drawLine(originX, y, originX + textWidth, y, underlinePaint); + underlinePaint.delete(); + } + if (op.style.strikethrough) { + const strikePaint = this.makePaint(op.style.strikeColor || op.style.color, 'stroke'); + strikePaint.setStrokeWidth(1); + const y = originY - op.style.fontSize * 0.3; + canvas.drawLine(originX, y, originX + textWidth, y, strikePaint); + strikePaint.delete(); + } + }; + + if (op.rotation !== 0) { + const cx = op.bbox.x + op.bbox.width / 2; + const cy = op.bbox.y + op.bbox.height / 2; + canvas.save(); + canvas.translate(cx, cy); + canvas.rotate(op.rotation, 0, 0); + drawClusters(-op.bbox.width / 2, -op.bbox.height / 2 + op.baseline); + canvas.restore(); + } else { + drawClusters(op.bbox.x, op.bbox.y + op.baseline); + } + + for (const { paint, font, typeface } of textObjectsByFamily.values()) { + paint.delete(); + font.delete(); + typeface.delete(); + } + } + + private renderFootnoteMarker(canvas: ReturnType, op: Extract): void { + const { font, paint, typeface } = this.makeTextObjects(op.fontFamily, op.fontSize, false, false, op.color); + canvas.drawText(op.text, op.bbox.x, op.bbox.y + op.bbox.height * 0.4, paint, font); + paint.delete(); + font.delete(); + typeface.delete(); + } + + private renderLine(canvas: ReturnType, op: LayerLineOp): void { + this.withTransform(canvas, op.bbox, op.transform, () => { + const width = Math.max(op.style.width, 0.5); + const dx = op.x2 - op.x1; + const dy = op.y2 - op.y1; + const lineLength = Math.hypot(dx, dy); + let lineX1 = op.x1; + let lineY1 = op.y1; + let lineX2 = op.x2; + let lineY2 = op.y2; + + if (lineLength > 0) { + const unitX = dx / lineLength; + const unitY = dy / lineLength; + if (op.style.startArrow !== 'none') { + const [arrowWidth, arrowHeight] = calculateArrowDimensions(width, lineLength, op.style.startArrowSize); + drawArrowHead( + this.canvasKit, + canvas, + op.x1, + op.y1, + -unitX, + -unitY, + arrowWidth, + arrowHeight, + op.style.startArrow, + op.style.color, + width, + ); + lineX1 += unitX * arrowWidth; + lineY1 += unitY * arrowWidth; + } + if (op.style.endArrow !== 'none') { + const [arrowWidth, arrowHeight] = calculateArrowDimensions(width, lineLength, op.style.endArrowSize); + drawArrowHead( + this.canvasKit, + canvas, + op.x2, + op.y2, + unitX, + unitY, + arrowWidth, + arrowHeight, + op.style.endArrow, + op.style.color, + width, + ); + lineX2 -= unitX * arrowWidth; + lineY2 -= unitY * arrowWidth; + } + } + + const drawSegment = (strokeWidth: number, offsetRatio: number) => { + const paint = this.makeLinePaint(op.style.color, strokeWidth, op.style.dash); + if (strokeWidth < 0.5) { + paint.setStrokeWidth(strokeWidth); + } + let offsetX = 0; + let offsetY = 0; + if (lineLength > 0 && offsetRatio !== 0) { + const normalX = -dy / lineLength; + const normalY = dx / lineLength; + offsetX = normalX * width * offsetRatio; + offsetY = normalY * width * offsetRatio; + } + if (op.style.shadow) { + this.drawShadow( + canvas, + op.style.shadow, + 'stroke', + op.style.shadow.color, + strokeWidth, + (shadowPaint) => canvas.drawLine(lineX1 + offsetX, lineY1 + offsetY, lineX2 + offsetX, lineY2 + offsetY, shadowPaint), + ); + } + canvas.drawLine(lineX1 + offsetX, lineY1 + offsetY, lineX2 + offsetX, lineY2 + offsetY, paint); + paint.delete(); + }; + + switch (op.style.lineType) { + case 'double': + drawSegment(width * 0.3, -0.35); + drawSegment(width * 0.3, 0.35); + break; + case 'thickThinDouble': + drawSegment(width * 0.4, -0.30); + drawSegment(width * 0.2, 0.40); + break; + case 'thinThickDouble': + drawSegment(width * 0.2, -0.40); + drawSegment(width * 0.4, 0.30); + break; + case 'thinThickThinTriple': + drawSegment(width * 0.15, -0.425); + drawSegment(width * 0.30, 0); + drawSegment(width * 0.15, 0.425); + break; + default: + drawSegment(width, 0); + } + }); + } + + private renderRectangle(canvas: ReturnType, op: LayerRectangleOp): void { + this.withTransform(canvas, op.bbox, op.transform, () => { + const fill = this.makeShapeFillPaint(op.bbox, op.style.fillColor, op.style.opacity, op.gradient, op.style.pattern); + const strokePaint = op.style.strokeColor ? this.makeLinePaint(op.style.strokeColor, op.style.strokeWidth, op.style.strokeDash, op.style.opacity) : null; + const rect = this.toRect(op.bbox); + const drawRect = (paint: Paint) => { + if (op.cornerRadius > 0) { + canvas.drawRRect(this.canvasKit.RRectXY(rect, op.cornerRadius, op.cornerRadius), paint); + return; + } + canvas.drawRect(rect, paint); + }; + + if (op.style.shadow) { + this.drawShadow( + canvas, + op.style.shadow, + fill ? 'fill' : 'stroke', + op.style.shadow.color, + op.style.strokeWidth, + drawRect, + ); + } + + if (fill) { + drawRect(fill.paint); + fill.shader?.delete(); + fill.paint.delete(); + } + if (strokePaint) { + drawRect(strokePaint); + strokePaint.delete(); + } + }); + } + + private renderEllipse(canvas: ReturnType, op: LayerEllipseOp): void { + this.withTransform(canvas, op.bbox, op.transform, () => { + const fill = this.makeShapeFillPaint(op.bbox, op.style.fillColor, op.style.opacity, op.gradient, op.style.pattern); + const strokePaint = op.style.strokeColor ? this.makeLinePaint(op.style.strokeColor, op.style.strokeWidth, op.style.strokeDash, op.style.opacity) : null; + const oval = this.toRect(op.bbox); + const drawOval = (paint: Paint) => canvas.drawOval(oval, paint); + + if (op.style.shadow) { + this.drawShadow( + canvas, + op.style.shadow, + fill ? 'fill' : 'stroke', + op.style.shadow.color, + op.style.strokeWidth, + drawOval, + ); + } + + if (fill) { + drawOval(fill.paint); + fill.shader?.delete(); + fill.paint.delete(); + } + if (strokePaint) { + drawOval(strokePaint); + strokePaint.delete(); + } + }); + } + + private renderPath(canvas: ReturnType, op: LayerPathOp): void { + this.withTransform(canvas, op.bbox, op.transform, () => { + const path = this.makePath(op.commands); + const pathBounds = computePathPaintBounds(op.commands, op.bbox); + const fill = this.makeShapeFillPaint(pathBounds, op.style.fillColor, op.style.opacity, op.gradient, op.style.pattern); + const strokePaint = op.style.strokeColor ? this.makeLinePaint(op.style.strokeColor, op.style.strokeWidth, op.style.strokeDash, op.style.opacity) : null; + const drawPath = (paint: Paint) => canvas.drawPath(path, paint); + + if (op.style.shadow) { + this.drawShadow( + canvas, + op.style.shadow, + fill ? 'fill' : 'stroke', + op.style.shadow.color, + op.style.strokeWidth, + drawPath, + ); + } + + if (fill) { + drawPath(fill.paint); + fill.shader?.delete(); + fill.paint.delete(); + } + if (strokePaint) { + drawPath(strokePaint); + strokePaint.delete(); + } + if (op.lineStyle && op.connectorEndpoints) { + const { x1, y1, x2, y2 } = op.connectorEndpoints; + const connectorLength = Math.max(Math.hypot(x2 - x1, y2 - y1), 1); + + if (op.lineStyle.startArrow !== 'none') { + let directionX = x1 - x2; + let directionY = y1 - y2; + for (const command of op.commands.slice(1)) { + if (command.type === 'lineTo') { + if (Math.abs(x1 - command.x) > 0.5 || Math.abs(y1 - command.y) > 0.5) { + directionX = x1 - command.x; + directionY = y1 - command.y; + break; + } + continue; + } + if (command.type === 'curveTo') { + if (Math.abs(x1 - command.x1) > 0.5 || Math.abs(y1 - command.y1) > 0.5) { + directionX = x1 - command.x1; + directionY = y1 - command.y1; + break; + } + } + } + const directionLength = Math.max(Math.hypot(directionX, directionY), 0.001); + const [arrowWidth, arrowHeight] = calculateArrowDimensions(op.lineStyle.width, connectorLength, op.lineStyle.startArrowSize); + drawArrowHead( + this.canvasKit, + canvas, + x1, + y1, + directionX / directionLength, + directionY / directionLength, + arrowWidth, + arrowHeight, + op.lineStyle.startArrow, + op.lineStyle.color, + op.lineStyle.width, + ); + } + + if (op.lineStyle.endArrow !== 'none') { + const points: Array<[number, number]> = []; + for (const command of op.commands) { + if (command.type === 'moveTo' || command.type === 'lineTo') { + points.push([command.x, command.y]); + continue; + } + if (command.type === 'curveTo') { + points.push([command.x2, command.y2]); + points.push([command.x3, command.y3]); + } + } + let directionX = x2 - x1; + let directionY = y2 - y1; + for (let index = points.length - 1; index >= 0; index -= 1) { + const [pointX, pointY] = points[index]; + const candidateX = x2 - pointX; + const candidateY = y2 - pointY; + if (Math.abs(candidateX) > 0.5 || Math.abs(candidateY) > 0.5) { + directionX = candidateX; + directionY = candidateY; + break; + } + } + const directionLength = Math.max(Math.hypot(directionX, directionY), 0.001); + const [arrowWidth, arrowHeight] = calculateArrowDimensions(op.lineStyle.width, connectorLength, op.lineStyle.endArrowSize); + drawArrowHead( + this.canvasKit, + canvas, + x2, + y2, + directionX / directionLength, + directionY / directionLength, + arrowWidth, + arrowHeight, + op.lineStyle.endArrow, + op.lineStyle.color, + op.lineStyle.width, + ); + } + } + path.delete(); + }); + } + + private renderImage(canvas: ReturnType, op: LayerImageOp): void { + this.withTransform(canvas, op.bbox, op.transform, () => { + if (!op.base64) return; + this.drawEncodedImage(canvas, op.base64, op.bbox, op.fillMode, op.originalSize, op.crop); + }); + } + + private renderFormObject( + canvas: ReturnType, + op: LayerFormObjectOp, + ): void { + const { x, y, width: w, height: h } = op.bbox; + + switch (op.formType) { + case 'pushButton': { + const fillPaint = this.makePaint('#d0d0d0', 'fill'); + const strokePaint = this.makeLinePaint('#a0a0a0', 0.5, 'solid'); + canvas.drawRect(this.toRect(op.bbox), fillPaint); + canvas.drawRect(this.toRect(op.bbox), strokePaint); + fillPaint.delete(); + strokePaint.delete(); + + if (op.caption) { + const fontSize = Math.min(Math.max(h * 0.55, 7), 12); + const family = this.resolveCanvasKitFontFamily('Noto Sans KR'); + const { font, paint, typeface } = this.makeTextObjects(family, fontSize, false, false, '#808080'); + const cssFont = `${fontSize}px "${family}"`; + const textWidth = (globalThis as any).measureTextWidth?.(cssFont, op.caption) ?? op.caption.length * fontSize * 0.55; + canvas.drawText(op.caption, x + w / 2 - textWidth / 2, y + h / 2 + fontSize * 0.35, paint, font); + paint.delete(); + font.delete(); + typeface.delete(); + } + return; + } + case 'checkBox': { + const boxSize = Math.min(h * 0.7, 13); + const boxY = y + (h - boxSize) / 2; + const boxX = x + 2; + const fillPaint = this.makePaint('#ffffff', 'fill'); + const strokePaint = this.makeLinePaint('#606060', 0.8, 'solid'); + canvas.drawRect(this.canvasKit.XYWHRect(boxX, boxY, boxSize, boxSize), fillPaint); + canvas.drawRect(this.canvasKit.XYWHRect(boxX, boxY, boxSize, boxSize), strokePaint); + fillPaint.delete(); + strokePaint.delete(); + + if (op.value !== 0) { + const path = new this.canvasKit.PathBuilder(); + path.moveTo(boxX + boxSize * 0.2, boxY + boxSize * 0.55); + path.lineTo(boxX + boxSize * 0.45, boxY + boxSize * 0.8); + path.lineTo(boxX + boxSize * 0.85, boxY + boxSize * 0.2); + const markPaint = this.makeLinePaint('#000000', 1.5, 'solid'); + const checkPath = path.detach(); + canvas.drawPath(checkPath, markPaint); + markPaint.delete(); + checkPath.delete(); + path.delete(); + } + + if (op.caption) { + const fontSize = Math.min(Math.max(h * 0.55, 7), 12); + const { font, paint, typeface } = this.makeTextObjects('Noto Sans KR', fontSize, false, false, op.foreColor); + canvas.drawText(op.caption, boxX + boxSize + 3, y + h / 2 + fontSize * 0.35, paint, font); + paint.delete(); + font.delete(); + typeface.delete(); + } + return; + } + case 'radioButton': { + const r = Math.min(h * 0.3, 6.5); + const cx = x + 2 + r; + const cy = y + h / 2; + const fillPaint = this.makePaint('#ffffff', 'fill'); + const strokePaint = this.makeLinePaint('#606060', 0.8, 'solid'); + canvas.drawCircle(cx, cy, r, fillPaint); + canvas.drawCircle(cx, cy, r, strokePaint); + fillPaint.delete(); + strokePaint.delete(); + + if (op.value !== 0) { + const dotPaint = this.makePaint('#000000', 'fill'); + canvas.drawCircle(cx, cy, r * 0.5, dotPaint); + dotPaint.delete(); + } + + if (op.caption) { + const fontSize = Math.min(Math.max(h * 0.55, 7), 12); + const { font, paint, typeface } = this.makeTextObjects('Noto Sans KR', fontSize, false, false, op.foreColor); + canvas.drawText(op.caption, cx + r + 3, y + h / 2 + fontSize * 0.35, paint, font); + paint.delete(); + font.delete(); + typeface.delete(); + } + return; + } + case 'comboBox': { + const btnW = Math.min(h * 0.8, 16); + const fillPaint = this.makePaint('#ffffff', 'fill'); + const strokePaint = this.makeLinePaint('#a0a0a0', 0.8, 'solid'); + canvas.drawRect(this.toRect(op.bbox), fillPaint); + canvas.drawRect(this.toRect(op.bbox), strokePaint); + fillPaint.delete(); + strokePaint.delete(); + + const buttonRect = this.canvasKit.XYWHRect(x + w - btnW, y, btnW, h); + const buttonFill = this.makePaint('#e0e0e0', 'fill'); + const buttonStroke = this.makeLinePaint('#a0a0a0', 0.5, 'solid'); + canvas.drawRect(buttonRect, buttonFill); + canvas.drawRect(buttonRect, buttonStroke); + buttonFill.delete(); + buttonStroke.delete(); + + const arrowCx = x + w - btnW / 2; + const arrowCy = y + h / 2; + const arrowSize = Math.min(h * 0.2, 4); + const arrowPath = new this.canvasKit.PathBuilder(); + arrowPath.moveTo(arrowCx - arrowSize, arrowCy - arrowSize * 0.5); + arrowPath.lineTo(arrowCx + arrowSize, arrowCy - arrowSize * 0.5); + arrowPath.lineTo(arrowCx, arrowCy + arrowSize * 0.5); + arrowPath.close(); + const arrowPaint = this.makePaint('#404040', 'fill'); + const arrowShape = arrowPath.detach(); + canvas.drawPath(arrowShape, arrowPaint); + arrowPaint.delete(); + arrowShape.delete(); + arrowPath.delete(); + + if (op.text) { + const fontSize = Math.min(Math.max(h * 0.55, 7), 12); + const { font, paint, typeface } = this.makeTextObjects('Noto Sans KR', fontSize, false, false, op.foreColor); + canvas.drawText(op.text, x + 3, y + h / 2 + fontSize * 0.35, paint, font); + paint.delete(); + font.delete(); + typeface.delete(); + } + return; + } + case 'edit': { + const fillPaint = this.makePaint('#ffffff', 'fill'); + const strokePaint = this.makeLinePaint('#a0a0a0', 0.8, 'solid'); + canvas.drawRect(this.toRect(op.bbox), fillPaint); + canvas.drawRect(this.toRect(op.bbox), strokePaint); + fillPaint.delete(); + strokePaint.delete(); + + if (op.text) { + const fontSize = Math.min(Math.max(h * 0.55, 7), 12); + const { font, paint, typeface } = this.makeTextObjects('Noto Sans KR', fontSize, false, false, op.foreColor); + canvas.drawText(op.text, x + 3, y + h / 2 + fontSize * 0.35, paint, font); + paint.delete(); + font.delete(); + typeface.delete(); + } + } + } + } + + private renderFallbackOverlays(node: LayerNode, targetCanvas: HTMLCanvasElement, scale: number): void { + const ctx = targetCanvas.getContext('2d'); + if (!ctx) { + return; + } + ctx.save(); + ctx.setTransform(scale, 0, 0, scale, 0, 0); + this.renderFallbackOverlayNode(ctx, node); + ctx.restore(); + } + + private renderFallbackOverlayNode(ctx: CanvasRenderingContext2D, node: LayerNode): void { + if (node.kind === 'group') { + for (const child of node.children) { + this.renderFallbackOverlayNode(ctx, child); + } + return; + } + if (node.kind === 'clipRect') { + ctx.save(); + ctx.beginPath(); + ctx.rect(node.clip.x, node.clip.y, node.clip.width, node.clip.height); + ctx.clip(); + this.renderFallbackOverlayNode(ctx, node.child); + ctx.restore(); + return; + } + for (const op of node.ops) { + if (op.type === 'equation') { + renderEquationLayoutBox(ctx, op.layoutBox, op.bbox.x, op.bbox.y, op.color, op.fontSize, false, false); + continue; + } + if (op.type === 'textRun' && this.shouldOverlayTextRun(op)) { + this.renderTextRunOverlay(ctx, op); + continue; + } + if (op.type === 'footnoteMarker' && this.shouldOverlayFootnoteMarker(op)) { + this.renderFootnoteMarkerOverlay(ctx, op); + } + } + } + + private renderTextRunOverlay(ctx: CanvasRenderingContext2D, op: LayerTextRunOp): void { + const ratio = typeof op.style.ratio === 'number' && op.style.ratio > 0 ? op.style.ratio : 1; + const hasRatio = Math.abs(ratio - 1) > 0.01; + const outlineType = op.style.outlineType ?? 0; + const shadowType = op.style.shadowType ?? 0; + const shadowColor = typeof op.style.shadowColor === 'string' ? op.style.shadowColor : op.style.color; + const shadowOffsetX = typeof op.style.shadowOffsetX === 'number' ? op.style.shadowOffsetX : 0; + const shadowOffsetY = typeof op.style.shadowOffsetY === 'number' ? op.style.shadowOffsetY : 0; + const emboss = !!op.style.emboss; + const engrave = !!op.style.engrave; + const emphasisDot = op.style.emphasisDot ?? 0; + const shadeColor = (typeof op.style.shadeColor === 'string' ? op.style.shadeColor : '#ffffff').toLowerCase(); + const fontSize = op.style.fontSize || 12; + const clusters = splitIntoClusters(op.text); + const drawClusters = (originX: number, originY: number) => { + const textWidth = op.positions.at(-1) ?? 0; + if (textWidth > 0 && shadeColor !== '#ffffff') { + ctx.save(); + ctx.fillStyle = shadeColor; + ctx.fillRect(originX, originY - fontSize, textWidth, fontSize * 1.2); + ctx.restore(); + } + + const drawPass = (dx: number, dy: number, fillColor: string, strokeColor?: string, lineWidth = 0) => { + ctx.save(); + ctx.fillStyle = fillColor; + if (strokeColor) { + ctx.strokeStyle = strokeColor; + ctx.lineWidth = lineWidth; + ctx.lineJoin = 'round'; + } + for (const cluster of clusters) { + if (cluster.text === ' ' || cluster.text === '\t' || cluster.text === '\u2007') { + continue; + } + if (startsWithInvalidControl(cluster.text)) { + continue; + } + const x = originX + op.positions[cluster.start] + dx; + const y = originY + dy; + if (isHalfwidthScaledCluster(cluster.text) && !hasRatio) { + ctx.save(); + ctx.translate(x, y); + ctx.scale(0.5, 1); + ctx.fillText(cluster.text, 0, 0); + if (strokeColor) { + ctx.strokeText(cluster.text, 0, 0); + } + ctx.restore(); + continue; + } + if (hasRatio) { + ctx.save(); + ctx.translate(x, y); + ctx.scale(ratio, 1); + ctx.fillText(cluster.text, 0, 0); + if (strokeColor) { + ctx.strokeText(cluster.text, 0, 0); + } + ctx.restore(); + continue; + } + ctx.fillText(cluster.text, x, y); + if (strokeColor) { + ctx.strokeText(cluster.text, x, y); + } + } + ctx.restore(); + }; + + if (emboss || engrave) { + const offset = Math.max(fontSize / 20, 1); + drawPass(-offset, -offset, emboss ? '#ffffff' : '#808080'); + drawPass(offset, offset, emboss ? '#808080' : '#ffffff'); + drawPass(0, 0, op.style.color); + } else { + if (shadowType > 0) { + drawPass(shadowOffsetX, shadowOffsetY, shadowColor); + } + if (outlineType > 0) { + drawPass(0, 0, '#ffffff', op.style.color, Math.max(fontSize / 25, 0.5)); + } else { + drawPass(0, 0, op.style.color); + } + } + + if (emphasisDot > 0) { + const dotChar = + emphasisDot === 1 ? '●' + : emphasisDot === 2 ? '○' + : emphasisDot === 3 ? 'ˇ' + : emphasisDot === 4 ? '˜' + : emphasisDot === 5 ? '・' + : emphasisDot === 6 ? '˸' + : ''; + if (dotChar) { + ctx.save(); + this.setCanvasTextFont(ctx, 'Noto Sans KR', fontSize * 0.3, false, false); + ctx.fillStyle = op.style.color; + const dotY = originY - fontSize * 1.05; + for (const position of op.positions.slice(0, -1)) { + const dotX = originX + position + (fontSize * ratio * 0.5); + ctx.fillText(dotChar, dotX, dotY); + } + ctx.restore(); + } + } + + if (op.tabLeaders?.length) { + this.drawTabLeadersOverlay(ctx, op.tabLeaders, originX, originY, op.style.color); + } + + if (op.style.underline !== 'none') { + ctx.save(); + ctx.strokeStyle = op.style.underlineColor || op.style.color; + ctx.lineWidth = 1; + const y = op.style.underline === 'top' ? originY - fontSize + 1 : originY + 2; + ctx.beginPath(); + ctx.moveTo(originX, y); + ctx.lineTo(originX + textWidth, y); + ctx.stroke(); + ctx.restore(); + } + + if (op.style.strikethrough) { + ctx.save(); + ctx.strokeStyle = op.style.strikeColor || op.style.color; + ctx.lineWidth = 1; + const y = originY - fontSize * 0.3; + ctx.beginPath(); + ctx.moveTo(originX, y); + ctx.lineTo(originX + textWidth, y); + ctx.stroke(); + ctx.restore(); + } + }; + + ctx.save(); + this.setCanvasTextFont(ctx, op.style.fontFamily, fontSize, op.style.bold, op.style.italic); + ctx.textBaseline = 'alphabetic'; + if (op.rotation !== 0) { + const cx = op.bbox.x + op.bbox.width / 2; + const cy = op.bbox.y + op.bbox.height / 2; + ctx.translate(cx, cy); + ctx.rotate((op.rotation * Math.PI) / 180); + drawClusters(-op.bbox.width / 2, -op.bbox.height / 2 + op.baseline); + } else { + drawClusters(op.bbox.x, op.bbox.y + op.baseline); + } + ctx.restore(); + } + + private renderFootnoteMarkerOverlay(ctx: CanvasRenderingContext2D, op: LayerFootnoteMarkerOp): void { + ctx.save(); + this.setCanvasTextFont(ctx, op.fontFamily, op.fontSize, false, false); + ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = op.color; + ctx.fillText(op.text, op.bbox.x, op.bbox.y + op.bbox.height * 0.4); + ctx.restore(); + } + + private drawTabLeadersOverlay(ctx: CanvasRenderingContext2D, leaders: LayerTabLeader[], originX: number, baselineY: number, color: string): void { + for (const leader of leaders) { + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.setLineDash( + leader.fillType === 2 ? [4, 2] + : leader.fillType === 3 ? [1.5, 2.5] + : [], + ); + const y = baselineY + 1; + ctx.beginPath(); + ctx.moveTo(originX + leader.startX, y); + ctx.lineTo(originX + leader.endX, y); + ctx.stroke(); + ctx.restore(); + } + } + + private setCanvasTextFont( + ctx: CanvasRenderingContext2D, + fontFamily: string, + fontSize: number, + bold: boolean, + italic: boolean, + ): void { + ctx.font = buildCanvasTextFont(fontFamily, fontSize, bold, italic); + } + + private drawEncodedImage( + canvas: ReturnType, + base64: string, + bbox: LayerBounds, + fillMode = 'fitToSize', + originalSize?: { width: number; height: number }, + crop?: { left: number; top: number; right: number; bottom: number }, + ): void { + const image = this.getImage(base64); + if (!image) return; + const drawImageRect = (srcRect: ReturnType, dstRect: ReturnType) => { + const paint = new this.canvasKit.Paint(); + canvas.drawImageRectOptions( + image, + srcRect, + dstRect, + this.canvasKit.FilterMode.Linear, + this.canvasKit.MipmapMode.None, + paint, + ); + paint.delete(); + }; + + if (fillMode === 'fitToSize' || fillMode === 'none') { + if (crop) { + const imgW = image.width(); + const imgH = image.height(); + const scaleX = crop.right / imgW; + const srcX = crop.left / scaleX; + const srcY = crop.top / scaleX; + const srcW = (crop.right - crop.left) / scaleX; + const srcH = (crop.bottom - crop.top) / scaleX; + const isCropped = srcX > 0.5 || srcY > 0.5 || Math.abs(srcW - imgW) > 1 || Math.abs(srcH - imgH) > 1; + if (isCropped) { + drawImageRect( + this.canvasKit.XYWHRect(srcX, srcY, srcW, srcH), + this.toRect(bbox), + ); + return; + } + } + drawImageRect( + this.canvasKit.XYWHRect(0, 0, image.width(), image.height()), + this.toRect(bbox), + ); + return; + } + + const imageWidth = originalSize?.width ?? image.width(); + const imageHeight = originalSize?.height ?? image.height(); + const { x, y } = this.resolveImagePlacement(fillMode, bbox, imageWidth, imageHeight); + + canvas.save(); + canvas.clipRect(this.toRect(bbox), this.canvasKit.ClipOp.Intersect, true); + + if (fillMode === 'tileAll' || fillMode === 'tileHorzTop' || fillMode === 'tileHorzBottom' || fillMode === 'tileVertLeft' || fillMode === 'tileVertRight') { + if (fillMode === 'tileAll') { + for (let ty = bbox.y; ty < bbox.y + bbox.height; ty += imageHeight) { + for (let tx = bbox.x; tx < bbox.x + bbox.width; tx += imageWidth) { + drawImageRect( + this.canvasKit.XYWHRect(0, 0, image.width(), image.height()), + this.canvasKit.XYWHRect(tx, ty, imageWidth, imageHeight), + ); + } + } + } else if (fillMode === 'tileHorzTop' || fillMode === 'tileHorzBottom') { + const ty = fillMode === 'tileHorzTop' ? bbox.y : bbox.y + bbox.height - imageHeight; + for (let tx = bbox.x; tx < bbox.x + bbox.width; tx += imageWidth) { + drawImageRect( + this.canvasKit.XYWHRect(0, 0, image.width(), image.height()), + this.canvasKit.XYWHRect(tx, ty, imageWidth, imageHeight), + ); + } + } else { + const tx = fillMode === 'tileVertLeft' ? bbox.x : bbox.x + bbox.width - imageWidth; + for (let ty = bbox.y; ty < bbox.y + bbox.height; ty += imageHeight) { + drawImageRect( + this.canvasKit.XYWHRect(0, 0, image.width(), image.height()), + this.canvasKit.XYWHRect(tx, ty, imageWidth, imageHeight), + ); + } + } + } else { + drawImageRect( + this.canvasKit.XYWHRect(0, 0, image.width(), image.height()), + this.canvasKit.XYWHRect(x, y, imageWidth, imageHeight), + ); + } + + canvas.restore(); + } + + private resolveImagePlacement(fillMode: string, bbox: LayerBounds, imageWidth: number, imageHeight: number): { x: number; y: number } { + switch (fillMode) { + case 'leftTop': + return { x: bbox.x, y: bbox.y }; + case 'centerTop': + return { x: bbox.x + (bbox.width - imageWidth) / 2, y: bbox.y }; + case 'rightTop': + return { x: bbox.x + bbox.width - imageWidth, y: bbox.y }; + case 'leftCenter': + return { x: bbox.x, y: bbox.y + (bbox.height - imageHeight) / 2 }; + case 'center': + return { x: bbox.x + (bbox.width - imageWidth) / 2, y: bbox.y + (bbox.height - imageHeight) / 2 }; + case 'rightCenter': + return { x: bbox.x + bbox.width - imageWidth, y: bbox.y + (bbox.height - imageHeight) / 2 }; + case 'leftBottom': + return { x: bbox.x, y: bbox.y + bbox.height - imageHeight }; + case 'centerBottom': + return { x: bbox.x + (bbox.width - imageWidth) / 2, y: bbox.y + bbox.height - imageHeight }; + case 'rightBottom': + return { x: bbox.x + bbox.width - imageWidth, y: bbox.y + bbox.height - imageHeight }; + default: + return { x: bbox.x, y: bbox.y }; + } + } + + private drawTabLeaders(canvas: ReturnType, leaders: LayerTabLeader[], originX: number, baselineY: number, color: string): void { + for (const leader of leaders) { + const dash = leader.fillType === 2 ? 'dash' : leader.fillType === 3 ? 'dot' : 'solid'; + const paint = this.makeLinePaint(color, 1, dash); + const y = baselineY + 1; + canvas.drawLine(originX + leader.startX, y, originX + leader.endX, y, paint); + paint.delete(); + } + } + + private makePath(commands: LayerPathCommand[]) { + const builder = new this.canvasKit.PathBuilder(); + for (const command of commands) { + switch (command.type) { + case 'moveTo': + builder.moveTo(command.x, command.y); + break; + case 'lineTo': + builder.lineTo(command.x, command.y); + break; + case 'curveTo': + builder.cubicTo(command.x1, command.y1, command.x2, command.y2, command.x3, command.y3); + break; + case 'arcTo': + builder.arcToRotated(command.rx, command.ry, command.rotation, !command.largeArc, !command.sweep, command.x, command.y); + break; + case 'closePath': + builder.close(); + break; + } + } + const path = builder.detach(); + builder.delete(); + return path; + } + + private makeTextObjects(fontFamily: string, fontSize: number, bold: boolean, italic: boolean, color: string, scaleX = 1): { typeface: Typeface; font: Font; paint: Paint } { + const family = this.resolveCanvasKitFontFamily(fontFamily); + const typeface = this.fontProvider.matchFamilyStyle(family, { + weight: this.canvasKit.FontWeight.Normal, + slant: this.canvasKit.FontSlant.Upright, + }); + const font = new this.canvasKit.Font(typeface, fontSize || 12); + font.setEmbolden(bold); + font.setScaleX(scaleX > 0 ? scaleX : 1); + font.setSkewX(italic ? -0.25 : 0); + if (this.renderMode === 'compat') { + font.setSubpixel(true); + if (fontSize >= 48 && bold && !italic) { + font.setEdging(this.canvasKit.FontEdging.SubpixelAntiAlias); + font.setHinting(this.canvasKit.FontHinting.Slight); + } + } + const paint = this.makePaint(color, 'fill'); + return { typeface, font, paint }; + } + + private resolveCanvasKitFontFamily(fontFamily: string): string { + const resolved = resolveFont(fontFamily, 0, 0); + if (HAMCHOROM_DOTUM_ALIASES.has(resolved) || HAMCHOROM_DOTUM_ALIASES.has(fontFamily)) { + return HAMCHOROM_DOTUM_FAMILY; + } + if (HAMCHOROM_BATANG_ALIASES.has(resolved) || HAMCHOROM_BATANG_ALIASES.has(fontFamily)) { + return HAMCHOROM_BATANG_FAMILY; + } + if (this.fontAliases.has(resolved)) return resolved; + if (this.fontAliases.has(fontFamily)) return fontFamily; + + const lower = resolved.toLowerCase(); + if (/gulimche|batangche|coding|courier/.test(lower) || /굴림체|바탕체/.test(resolved)) { + return 'D2Coding'; + } + if (/batang|gungsuh|serif|times/.test(lower) || /바탕|명조|궁서/.test(resolved)) { + return 'Noto Serif KR'; + } + return 'Noto Sans KR'; + } + + private makePaint(color: string, style: 'fill' | 'stroke', opacity = 1): Paint { + const paint = new this.canvasKit.Paint(); + paint.setAntiAlias(true); + paint.setStyle(style === 'fill' ? this.canvasKit.PaintStyle.Fill : this.canvasKit.PaintStyle.Stroke); + const rgba = [...this.canvasKit.parseColorString(color)] as number[]; + rgba[3] = (rgba[3] ?? 1) * opacity; + paint.setColor(rgba as any); + return paint; + } + + private makeLinePaint(color: string, width: number, dash: string, opacity = 1): Paint { + const paint = this.makePaint(color, 'stroke', opacity); + const strokeWidth = Math.max(width, 0.5); + paint.setStrokeWidth(strokeWidth); + + if (dash !== 'solid') { + const stroke = Math.max(width, 0.5); + const intervals = + dash === 'dash' ? [stroke * 4, stroke * 2] + : dash === 'dot' ? [stroke * 1.5, stroke * 2.5] + : dash === 'dashDot' ? [stroke * 4, stroke * 2, stroke * 1.5, stroke * 2] + : [stroke * 4, stroke * 2, stroke * 1.5, stroke * 2, stroke * 1.5, stroke * 2]; + const effect = this.canvasKit.PathEffect.MakeDash(intervals, 0); + paint.setPathEffect(effect); + effect.delete(); + } + + return paint; + } + + private makeShapeFillPaint( + bounds: LayerBounds, + fillColor: string | null | undefined, + opacity: number, + gradient?: LayerGradient, + pattern?: LayerPatternFill, + ): { paint: Paint; shader: Shader | null } | null { + const shader = gradient ? this.makeGradientShader(gradient, bounds) : pattern ? this.makePatternShader(pattern) : null; + if (!shader && !fillColor) { + return null; + } + + const paint = this.makePaint(fillColor ?? '#ffffff', 'fill', opacity); + if (shader) { + paint.setShader(shader); + paint.setAlphaf(opacity); + } + return { paint, shader }; + } + + private makeGradientShader(gradient: LayerGradient, bounds: LayerBounds): Shader | null { + if (gradient.colors.length < 2) { + return null; + } + + const colors = gradient.colors.map((color) => this.canvasKit.parseColorString(color)); + const positions = gradient.positions.length > 0 ? gradient.positions : null; + if (gradient.gradientType === 2 || gradient.gradientType === 3 || gradient.gradientType === 4) { + const cx = bounds.x + bounds.width * (gradient.centerX / 100); + const cy = bounds.y + bounds.height * (gradient.centerY / 100); + const radius = Math.max(bounds.width, bounds.height) / 2; + return this.canvasKit.Shader.MakeRadialGradient( + [cx, cy], + radius, + colors, + positions, + this.canvasKit.TileMode.Clamp, + ); + } + + const [x0, y0, x1, y1] = angleToCanvasCoords(gradient.angle, bounds.x, bounds.y, bounds.width, bounds.height); + return this.canvasKit.Shader.MakeLinearGradient( + [x0, y0], + [x1, y1], + colors, + positions, + this.canvasKit.TileMode.Clamp, + ); + } + + private makePatternShader(pattern: LayerPatternFill): Shader | null { + const image = this.getPatternImage(pattern); + return image + ? image.makeShaderOptions( + this.canvasKit.TileMode.Repeat, + this.canvasKit.TileMode.Repeat, + this.canvasKit.FilterMode.Nearest, + this.canvasKit.MipmapMode.None, + ) + : null; + } + + private getPatternImage(pattern: LayerPatternFill): Image | null { + const cacheKey = `${pattern.patternType}:${pattern.patternColor}:${pattern.backgroundColor}`; + if (this.patternImageCache.has(cacheKey)) { + return this.patternImageCache.get(cacheKey) ?? null; + } + + const bytes = rasterizePatternTileToPngBytes(pattern); + const image = bytes ? this.canvasKit.MakeImageFromEncoded(bytes) : null; + this.patternImageCache.set(cacheKey, image); + return image; + } + + private drawShadow( + canvas: ReturnType, + shadow: LayerShapeShadow | undefined, + style: 'fill' | 'stroke', + color: string, + strokeWidth: number, + draw: (paint: Paint) => void, + ): void { + if (!shadow) { + return; + } + + const opacity = shadow.alpha > 0 ? 1 - (shadow.alpha / 255) : 1; + const paint = this.makePaint(color, style, opacity); + if (style === 'stroke') { + paint.setStrokeWidth(Math.max(strokeWidth, 0.5)); + } + const blur = this.canvasKit.MaskFilter.MakeBlur(this.canvasKit.BlurStyle.Normal, 1, false); + paint.setMaskFilter(blur); + blur.delete(); + + canvas.save(); + canvas.translate(shadow.offsetX, shadow.offsetY); + draw(paint); + canvas.restore(); + paint.delete(); + } + + private withTransform( + canvas: ReturnType, + bbox: LayerBounds, + transform: { rotation: number; horzFlip: boolean; vertFlip: boolean }, + draw: () => void, + ): void { + if (!transform.rotation && !transform.horzFlip && !transform.vertFlip) { + draw(); + return; + } + + const cx = bbox.x + bbox.width / 2; + const cy = bbox.y + bbox.height / 2; + + canvas.save(); + if (transform.horzFlip) { + canvas.translate(cx * 2, 0); + canvas.scale(-1, 1); + } + if (transform.vertFlip) { + canvas.translate(0, cy * 2); + canvas.scale(1, -1); + } + if (transform.rotation) { + canvas.rotate(transform.rotation, cx, cy); + } + draw(); + canvas.restore(); + } + + private drawCanvasKitImage( + canvas: ReturnType, + image: Image, + bbox: LayerBounds, + ): void { + const paint = new this.canvasKit.Paint(); + canvas.drawImageRectOptions( + image, + this.canvasKit.XYWHRect(0, 0, image.width(), image.height()), + this.toRect(bbox), + this.canvasKit.FilterMode.Linear, + this.canvasKit.MipmapMode.None, + paint, + ); + paint.delete(); + } + + private getImage(base64: string): Image | null { + const cached = this.imageCache.get(base64); + if (cached) return cached; + + const bytes = decodeBase64(base64); + const image = this.canvasKit.MakeImageFromEncoded(bytes); + if (!image) return null; + this.imageCache.set(base64, image); + return image; + } + + private toRect(bounds: LayerBounds) { + return this.canvasKit.XYWHRect(bounds.x, bounds.y, bounds.width, bounds.height); + } +} + +function decodeBase64(base64: string): Uint8Array { + const binary = window.atob(base64); + const bytes = new Uint8Array(binary.length); + for (let idx = 0; idx < binary.length; idx += 1) { + bytes[idx] = binary.charCodeAt(idx); + } + return bytes; +} + +function buildCanvasTextFont(fontFamily: string, fontSize: number, bold: boolean, italic: boolean): string { + const resolved = resolveFont(fontFamily, 0, 0); + const baseFamily = resolved || fontFamily; + return `${italic ? 'italic ' : ''}${bold ? 'bold ' : ''}${(fontSize || 12).toFixed(3)}px ${fontFamilyWithFallback(baseFamily)}`; +} + +function startsWithInvalidControl(text: string): boolean { + if (!text) { + return false; + } + const code = text.codePointAt(0) ?? 0; + return code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d; +} + +function isHalfwidthScaledCluster(text: string): boolean { + const code = text.codePointAt(0) ?? 0; + return (code >= 0x2018 && code <= 0x2027) || code === 0x00b7; +} + +function angleToCanvasCoords(angle: number, x: number, y: number, width: number, height: number): [number, number, number, number] { + const normalized = ((angle % 360) + 360) % 360; + switch (normalized) { + case 0: + return [x, y, x, y + height]; + case 45: + return [x, y, x + width, y + height]; + case 90: + return [x, y, x + width, y]; + case 135: + return [x, y + height, x + width, y]; + case 180: + return [x, y + height, x, y]; + case 225: + return [x + width, y + height, x, y]; + case 270: + return [x + width, y, x, y]; + case 315: + return [x + width, y, x, y + height]; + default: { + const radians = normalized * (Math.PI / 180); + const sin = Math.sin(radians); + const cos = Math.cos(radians); + const centerX = x + width / 2; + const centerY = y + height / 2; + return [ + centerX - sin * width / 2, + centerY - cos * height / 2, + centerX + sin * width / 2, + centerY + cos * height / 2, + ]; + } + } +} + +function rasterizePatternTileToPngBytes(pattern: LayerPatternFill): Uint8Array | null { + const canvas = document.createElement('canvas'); + canvas.width = 6; + canvas.height = 6; + const ctx = canvas.getContext('2d'); + if (!ctx) { + return null; + } + + ctx.fillStyle = pattern.backgroundColor; + ctx.fillRect(0, 0, 6, 6); + ctx.strokeStyle = pattern.patternColor; + ctx.lineWidth = 1; + + switch (pattern.patternType) { + case 0: + ctx.beginPath(); + ctx.moveTo(0, 3); + ctx.lineTo(6, 3); + ctx.stroke(); + break; + case 1: + ctx.beginPath(); + ctx.moveTo(3, 0); + ctx.lineTo(3, 6); + ctx.stroke(); + break; + case 2: + ctx.beginPath(); + ctx.moveTo(6, 0); + ctx.lineTo(0, 6); + ctx.stroke(); + break; + case 3: + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(6, 6); + ctx.stroke(); + break; + case 4: + ctx.beginPath(); + ctx.moveTo(3, 0); + ctx.lineTo(3, 6); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, 3); + ctx.lineTo(6, 3); + ctx.stroke(); + break; + case 5: + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(6, 6); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(6, 0); + ctx.lineTo(0, 6); + ctx.stroke(); + break; + default: + break; + } + + const dataUrl = canvas.toDataURL('image/png'); + const [, encoded = ''] = dataUrl.split(','); + return decodeBase64(encoded); +} + +function computePathPaintBounds(commands: LayerPathCommand[], fallback: LayerBounds): LayerBounds { + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + + const record = (x: number, y: number) => { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + }; + + for (const command of commands) { + switch (command.type) { + case 'moveTo': + case 'lineTo': + record(command.x, command.y); + break; + case 'curveTo': + record(command.x1, command.y1); + record(command.x2, command.y2); + record(command.x3, command.y3); + break; + case 'arcTo': + record(command.x, command.y); + break; + case 'closePath': + break; + } + } + + if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) { + return fallback; + } + + return { + x: minX, + y: minY, + width: Math.max(maxX - minX, 1), + height: Math.max(maxY - minY, 1), + }; +} + +function calculateArrowDimensions(strokeWidth: number, lineLength: number, arrowSize: number): [number, number] { + const widthLevel = Math.floor(arrowSize / 3); + const lengthLevel = arrowSize % 3; + const widthMultiplier = widthLevel === 0 ? 1.5 : widthLevel === 1 ? 2.5 : 3.5; + const lengthMultiplier = lengthLevel === 0 ? 1 : lengthLevel === 1 ? 1.5 : 2; + const arrowHeight = Math.max(strokeWidth * widthMultiplier, 3); + const arrowWidth = Math.min(arrowHeight * lengthMultiplier, lineLength * 0.3); + return [arrowWidth, arrowHeight]; +} + +function drawArrowHead( + canvasKit: CanvasKit, + canvas: ReturnType, + tipX: number, + tipY: number, + directionX: number, + directionY: number, + arrowWidth: number, + arrowHeight: number, + arrowStyle: string, + color: string, + strokeWidth: number, +): void { + if (arrowStyle === 'none') { + return; + } + + const alongX = -directionX; + const alongY = -directionY; + const perpX = directionY; + const perpY = -directionX; + const halfHeight = arrowHeight / 2; + const toWorld = (along: number, perp: number): [number, number] => [ + tipX + along * alongX + perp * perpX, + tipY + along * alongY + perp * perpY, + ]; + + const builder = new canvasKit.PathBuilder(); + const fillPaint = new canvasKit.Paint(); + fillPaint.setAntiAlias(true); + fillPaint.setStyle(canvasKit.PaintStyle.Fill); + fillPaint.setColor(canvasKit.parseColorString(color)); + + const strokePaint = new canvasKit.Paint(); + strokePaint.setAntiAlias(true); + strokePaint.setStyle(canvasKit.PaintStyle.Stroke); + strokePaint.setColor(canvasKit.parseColorString(color)); + strokePaint.setStrokeWidth(Math.max(strokeWidth * 0.3, 0.5)); + + if (arrowStyle === 'arrow' || arrowStyle === 'concaveArrow') { + const [baseX1, baseY1] = toWorld(arrowWidth, -halfHeight); + const [baseX2, baseY2] = toWorld(arrowWidth, halfHeight); + builder.moveTo(tipX, tipY); + builder.lineTo(baseX1, baseY1); + if (arrowStyle === 'concaveArrow') { + const [centerX, centerY] = toWorld(arrowWidth - arrowWidth * 0.3, 0); + builder.lineTo(centerX, centerY); + } + builder.lineTo(baseX2, baseY2); + builder.close(); + const path = builder.detach(); + canvas.drawPath(path, fillPaint); + path.delete(); + builder.delete(); + fillPaint.delete(); + strokePaint.delete(); + return; + } + + if (arrowStyle === 'diamond' || arrowStyle === 'openDiamond') { + const halfWidth = arrowWidth / 2; + const [point1X, point1Y] = toWorld(0, 0); + const [point2X, point2Y] = toWorld(halfWidth, -halfHeight); + const [point3X, point3Y] = toWorld(arrowWidth, 0); + const [point4X, point4Y] = toWorld(halfWidth, halfHeight); + builder.moveTo(point1X, point1Y); + builder.lineTo(point2X, point2Y); + builder.lineTo(point3X, point3Y); + builder.lineTo(point4X, point4Y); + builder.close(); + const path = builder.detach(); + if (arrowStyle === 'diamond') { + canvas.drawPath(path, fillPaint); + } else { + const whiteFill = new canvasKit.Paint(); + whiteFill.setAntiAlias(true); + whiteFill.setStyle(canvasKit.PaintStyle.Fill); + whiteFill.setColor(canvasKit.parseColorString('white')); + canvas.drawPath(path, whiteFill); + canvas.drawPath(path, strokePaint); + whiteFill.delete(); + } + path.delete(); + builder.delete(); + fillPaint.delete(); + strokePaint.delete(); + return; + } + + if (arrowStyle === 'circle' || arrowStyle === 'openCircle') { + const halfWidth = arrowWidth / 2; + const [centerX, centerY] = toWorld(halfWidth, 0); + const radiusX = halfWidth * 0.8; + const radiusY = halfHeight * 0.8; + if (arrowStyle === 'circle') { + canvas.drawOval(canvasKit.LTRBRect(centerX - radiusX, centerY - radiusY, centerX + radiusX, centerY + radiusY), fillPaint); + } else { + const whiteFill = new canvasKit.Paint(); + whiteFill.setAntiAlias(true); + whiteFill.setStyle(canvasKit.PaintStyle.Fill); + whiteFill.setColor(canvasKit.parseColorString('white')); + const oval = canvasKit.LTRBRect(centerX - radiusX, centerY - radiusY, centerX + radiusX, centerY + radiusY); + canvas.drawOval(oval, whiteFill); + canvas.drawOval(oval, strokePaint); + whiteFill.delete(); + } + builder.delete(); + fillPaint.delete(); + strokePaint.delete(); + return; + } + + if (arrowStyle === 'square' || arrowStyle === 'openSquare') { + const [point1X, point1Y] = toWorld(0, -halfHeight); + const [point2X, point2Y] = toWorld(arrowWidth, -halfHeight); + const [point3X, point3Y] = toWorld(arrowWidth, halfHeight); + const [point4X, point4Y] = toWorld(0, halfHeight); + builder.moveTo(point1X, point1Y); + builder.lineTo(point2X, point2Y); + builder.lineTo(point3X, point3Y); + builder.lineTo(point4X, point4Y); + builder.close(); + const path = builder.detach(); + if (arrowStyle === 'square') { + canvas.drawPath(path, fillPaint); + } else { + const whiteFill = new canvasKit.Paint(); + whiteFill.setAntiAlias(true); + whiteFill.setStyle(canvasKit.PaintStyle.Fill); + whiteFill.setColor(canvasKit.parseColorString('white')); + canvas.drawPath(path, whiteFill); + canvas.drawPath(path, strokePaint); + whiteFill.delete(); + } + path.delete(); + } + + builder.delete(); + fillPaint.delete(); + strokePaint.delete(); +} + +const EQUATION_SCRIPT_SCALE = 0.7; +const EQUATION_BIG_OP_SCALE = 1.5; + +function renderEquationLayoutBox( + ctx: CanvasRenderingContext2D, + layout: LayerEquationLayoutBox, + parentX: number, + parentY: number, + color: string, + fontSize: number, + italic: boolean, + bold: boolean, +): void { + const x = parentX + layout.x; + const y = parentY + layout.y; + + switch (layout.kind.type) { + case 'row': + for (const child of layout.kind.children) { + renderEquationLayoutBox(ctx, child, x, y, color, fontSize, italic, bold); + } + return; + case 'text': { + const size = equationFontSizeFromBox(layout, fontSize); + setEquationFont(ctx, size, true, bold); + ctx.fillStyle = color; + ctx.fillText(layout.kind.text, x, y + layout.baseline); + return; + } + case 'number': { + const size = equationFontSizeFromBox(layout, fontSize); + setEquationFont(ctx, size, false, bold); + ctx.fillStyle = color; + ctx.fillText(layout.kind.text, x, y + layout.baseline); + return; + } + case 'symbol': { + const size = equationFontSizeFromBox(layout, fontSize); + setEquationFont(ctx, size, false, false); + ctx.fillStyle = color; + ctx.save(); + ctx.textAlign = 'center'; + ctx.fillText(layout.kind.text, x + layout.width / 2, y + layout.baseline); + ctx.restore(); + return; + } + case 'mathSymbol': { + const size = equationFontSizeFromBox(layout, fontSize); + setEquationFont(ctx, size, false, false); + ctx.fillStyle = color; + ctx.fillText(layout.kind.text, x, y + layout.baseline); + return; + } + case 'function': { + const size = equationFontSizeFromBox(layout, fontSize); + setEquationFont(ctx, size, false, false); + ctx.fillStyle = color; + ctx.fillText(layout.kind.name, x, y + layout.baseline); + return; + } + case 'fraction': + renderEquationLayoutBox(ctx, layout.kind.numer, x, y, color, fontSize, italic, bold); + ctx.strokeStyle = color; + ctx.lineWidth = fontSize * 0.04; + ctx.beginPath(); + ctx.moveTo(x + fontSize * 0.05, y + layout.baseline); + ctx.lineTo(x + layout.width - fontSize * 0.05, y + layout.baseline); + ctx.stroke(); + renderEquationLayoutBox(ctx, layout.kind.denom, x, y, color, fontSize, italic, bold); + return; + case 'sqrt': { + const bodyLeft = x + layout.kind.body.x - fontSize * 0.1; + const signHeight = layout.height; + const midX = bodyLeft - fontSize * 0.15; + const midY = y + signHeight; + const startX = midX - fontSize * 0.3; + const startY = y + signHeight * 0.6; + const tickX = startX - fontSize * 0.1; + const tickY = startY - fontSize * 0.05; + + ctx.strokeStyle = color; + ctx.lineWidth = fontSize * 0.04; + ctx.beginPath(); + ctx.moveTo(tickX, tickY); + ctx.lineTo(startX, startY); + ctx.lineTo(midX, midY); + ctx.lineTo(bodyLeft, y); + ctx.lineTo(x + layout.width, y); + ctx.stroke(); + + if (layout.kind.index) { + renderEquationLayoutBox( + ctx, + layout.kind.index, + x, + y, + color, + fontSize * EQUATION_SCRIPT_SCALE, + false, + false, + ); + } + renderEquationLayoutBox(ctx, layout.kind.body, x, y, color, fontSize, italic, bold); + return; + } + case 'superscript': + renderEquationLayoutBox(ctx, layout.kind.base, x, y, color, fontSize, italic, bold); + renderEquationLayoutBox( + ctx, + layout.kind.sup, + x, + y, + color, + fontSize * EQUATION_SCRIPT_SCALE, + italic, + bold, + ); + return; + case 'subscript': + renderEquationLayoutBox(ctx, layout.kind.base, x, y, color, fontSize, italic, bold); + renderEquationLayoutBox( + ctx, + layout.kind.sub, + x, + y, + color, + fontSize * EQUATION_SCRIPT_SCALE, + italic, + bold, + ); + return; + case 'subSup': + renderEquationLayoutBox(ctx, layout.kind.base, x, y, color, fontSize, italic, bold); + renderEquationLayoutBox( + ctx, + layout.kind.sub, + x, + y, + color, + fontSize * EQUATION_SCRIPT_SCALE, + italic, + bold, + ); + renderEquationLayoutBox( + ctx, + layout.kind.sup, + x, + y, + color, + fontSize * EQUATION_SCRIPT_SCALE, + italic, + bold, + ); + return; + case 'bigOp': { + const opFontSize = fontSize * EQUATION_BIG_OP_SCALE; + const supHeight = layout.kind.sup ? layout.kind.sup.height + fontSize * 0.05 : 0; + const opX = x + (layout.width - estimateEquationOperatorWidth(layout.kind.symbol, opFontSize)) / 2; + const opY = y + supHeight + opFontSize * 0.8; + setEquationFont(ctx, opFontSize, false, false); + ctx.fillStyle = color; + ctx.fillText(layout.kind.symbol, opX, opY); + if (layout.kind.sup) { + renderEquationLayoutBox( + ctx, + layout.kind.sup, + x, + y, + color, + fontSize * EQUATION_SCRIPT_SCALE, + false, + false, + ); + } + if (layout.kind.sub) { + renderEquationLayoutBox( + ctx, + layout.kind.sub, + x, + y, + color, + fontSize * EQUATION_SCRIPT_SCALE, + false, + false, + ); + } + return; + } + case 'limit': { + const name = layout.kind.isUpper ? 'Lim' : 'lim'; + const size = equationFontSizeFromBox(layout, fontSize); + setEquationFont(ctx, size, false, false); + ctx.fillStyle = color; + ctx.fillText(name, x, y + size * 0.8); + if (layout.kind.sub) { + renderEquationLayoutBox( + ctx, + layout.kind.sub, + x, + y, + color, + fontSize * EQUATION_SCRIPT_SCALE, + false, + false, + ); + } + return; + } + case 'matrix': { + const brackets = layout.kind.style === 'paren' ? ['(', ')'] + : layout.kind.style === 'bracket' ? ['[', ']'] + : layout.kind.style === 'vert' ? ['|', '|'] + : ['', '']; + if (brackets[0]) { + drawEquationStretchBracket(ctx, brackets[0], x, y, fontSize * 0.3, layout.height, color, fontSize); + drawEquationStretchBracket(ctx, brackets[1], x + layout.width - fontSize * 0.3, y, fontSize * 0.3, layout.height, color, fontSize); + } + for (const row of layout.kind.cells) { + for (const cell of row) { + renderEquationLayoutBox(ctx, cell, x, y, color, fontSize, italic, bold); + } + } + return; + } + case 'rel': + renderEquationLayoutBox(ctx, layout.kind.over, x, y, color, fontSize, italic, bold); + renderEquationLayoutBox(ctx, layout.kind.arrow, x, y, color, fontSize, italic, bold); + if (layout.kind.under) { + renderEquationLayoutBox(ctx, layout.kind.under, x, y, color, fontSize, italic, bold); + } + return; + case 'eqAlign': + for (const row of layout.kind.rows) { + renderEquationLayoutBox(ctx, row.left, x, y, color, fontSize, italic, bold); + renderEquationLayoutBox(ctx, row.right, x, y, color, fontSize, italic, bold); + } + return; + case 'paren': + if (layout.kind.left) { + drawEquationStretchBracket(ctx, layout.kind.left, x, y, fontSize * 0.3, layout.height, color, fontSize); + } + renderEquationLayoutBox(ctx, layout.kind.body, x, y, color, fontSize, italic, bold); + if (layout.kind.right) { + drawEquationStretchBracket( + ctx, + layout.kind.right, + x + layout.width - fontSize * 0.3, + y, + fontSize * 0.3, + layout.height, + color, + fontSize, + ); + } + return; + case 'decoration': + renderEquationLayoutBox(ctx, layout.kind.body, x, y, color, fontSize, italic, bold); + drawEquationDecoration( + ctx, + layout.kind.decoration, + x + layout.kind.body.x + layout.kind.body.width / 2, + y + fontSize * 0.05, + layout.kind.body.width, + color, + fontSize, + ); + return; + case 'fontStyle': { + const nextItalic = layout.kind.fontStyle === 'roman' ? false : layout.kind.fontStyle === 'italic' ? true : italic; + const nextBold = layout.kind.fontStyle === 'roman' ? false : layout.kind.fontStyle === 'bold' ? true : bold; + renderEquationLayoutBox(ctx, layout.kind.body, x, y, color, fontSize, nextItalic, nextBold); + return; + } + case 'space': + case 'newline': + case 'empty': + return; + } +} + +function equationFontSizeFromBox(layout: LayerEquationLayoutBox, baseFontSize: number): number { + return layout.height > 0 ? layout.height : baseFontSize; +} + +function estimateEquationOperatorWidth(text: string, fontSize: number): number { + return Array.from(text).length * fontSize * 0.6; +} + +function setEquationFont( + ctx: CanvasRenderingContext2D, + size: number, + italic: boolean, + bold: boolean, +): void { + const style = italic ? 'italic ' : ''; + const weight = bold ? 'bold ' : ''; + ctx.font = `${style}${weight}${size.toFixed(1)}px 'Latin Modern Math', 'STIX Two Math', 'Cambria Math', 'Pretendard', serif`; +} + +function drawEquationStretchBracket( + ctx: CanvasRenderingContext2D, + bracket: string, + x: number, + y: number, + width: number, + height: number, + color: string, + fontSize: number, +): void { + const midX = x + width / 2; + ctx.strokeStyle = color; + ctx.lineWidth = fontSize * 0.04; + + switch (bracket) { + case '(': + ctx.beginPath(); + ctx.moveTo(midX + width * 0.2, y); + ctx.quadraticCurveTo(x, y + height / 2, midX + width * 0.2, y + height); + ctx.stroke(); + return; + case ')': + ctx.beginPath(); + ctx.moveTo(midX - width * 0.2, y); + ctx.quadraticCurveTo(x + width, y + height / 2, midX - width * 0.2, y + height); + ctx.stroke(); + return; + case '[': + ctx.beginPath(); + ctx.moveTo(midX + width * 0.2, y); + ctx.lineTo(midX - width * 0.2, y); + ctx.lineTo(midX - width * 0.2, y + height); + ctx.lineTo(midX + width * 0.2, y + height); + ctx.stroke(); + return; + case ']': + ctx.beginPath(); + ctx.moveTo(midX - width * 0.2, y); + ctx.lineTo(midX + width * 0.2, y); + ctx.lineTo(midX + width * 0.2, y + height); + ctx.lineTo(midX - width * 0.2, y + height); + ctx.stroke(); + return; + case '{': { + const quarterHeight = height / 4; + ctx.beginPath(); + ctx.moveTo(midX + width * 0.2, y); + ctx.quadraticCurveTo(midX - width * 0.1, y, midX - width * 0.1, y + quarterHeight); + ctx.quadraticCurveTo(midX - width * 0.1, y + quarterHeight * 2, midX - width * 0.3, y + quarterHeight * 2); + ctx.quadraticCurveTo(midX - width * 0.1, y + quarterHeight * 2, midX - width * 0.1, y + quarterHeight * 3); + ctx.quadraticCurveTo(midX - width * 0.1, y + height, midX + width * 0.2, y + height); + ctx.stroke(); + return; + } + case '}': { + const quarterHeight = height / 4; + ctx.beginPath(); + ctx.moveTo(midX - width * 0.2, y); + ctx.quadraticCurveTo(midX + width * 0.1, y, midX + width * 0.1, y + quarterHeight); + ctx.quadraticCurveTo(midX + width * 0.1, y + quarterHeight * 2, midX + width * 0.3, y + quarterHeight * 2); + ctx.quadraticCurveTo(midX + width * 0.1, y + quarterHeight * 2, midX + width * 0.1, y + quarterHeight * 3); + ctx.quadraticCurveTo(midX + width * 0.1, y + height, midX - width * 0.2, y + height); + ctx.stroke(); + return; + } + case '|': + ctx.beginPath(); + ctx.moveTo(midX, y); + ctx.lineTo(midX, y + height); + ctx.stroke(); + return; + default: + setEquationFont(ctx, height, false, false); + ctx.fillStyle = color; + ctx.save(); + ctx.textAlign = 'center'; + ctx.fillText(bracket, midX, y + height * 0.7); + ctx.restore(); + } +} + +function drawEquationDecoration( + ctx: CanvasRenderingContext2D, + decoration: string, + midX: number, + y: number, + width: number, + color: string, + fontSize: number, +): void { + const strokeWidth = fontSize * 0.03; + const halfWidth = width / 2; + ctx.strokeStyle = color; + ctx.lineWidth = strokeWidth; + + switch (decoration) { + case 'hat': + ctx.beginPath(); + ctx.moveTo(midX - halfWidth * 0.6, y + fontSize * 0.15); + ctx.lineTo(midX, y); + ctx.lineTo(midX + halfWidth * 0.6, y + fontSize * 0.15); + ctx.stroke(); + return; + case 'bar': + case 'overline': + ctx.beginPath(); + ctx.moveTo(midX - halfWidth, y + fontSize * 0.05); + ctx.lineTo(midX + halfWidth, y + fontSize * 0.05); + ctx.stroke(); + return; + case 'vec': { + const arrowY = y + fontSize * 0.05; + ctx.beginPath(); + ctx.moveTo(midX - halfWidth, arrowY); + ctx.lineTo(midX + halfWidth, arrowY); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(midX + halfWidth - fontSize * 0.1, arrowY - fontSize * 0.06); + ctx.lineTo(midX + halfWidth, arrowY); + ctx.lineTo(midX + halfWidth - fontSize * 0.1, arrowY + fontSize * 0.06); + ctx.stroke(); + return; + } + case 'tilde': { + const tildeY = y + fontSize * 0.08; + ctx.beginPath(); + ctx.moveTo(midX - halfWidth * 0.6, tildeY); + ctx.quadraticCurveTo(midX - halfWidth * 0.2, tildeY - fontSize * 0.08, midX, tildeY); + ctx.quadraticCurveTo(midX + halfWidth * 0.2, tildeY + fontSize * 0.08, midX + halfWidth * 0.6, tildeY); + ctx.stroke(); + return; + } + case 'dot': + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(midX, y + fontSize * 0.06, fontSize * 0.03, 0, Math.PI * 2); + ctx.fill(); + return; + case 'dDot': { + const gap = fontSize * 0.1; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(midX - gap, y + fontSize * 0.06, fontSize * 0.03, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(midX + gap, y + fontSize * 0.06, fontSize * 0.03, 0, Math.PI * 2); + ctx.fill(); + return; + } + case 'underline': + case 'under': { + const underlineY = y + fontSize * 1.1; + ctx.beginPath(); + ctx.moveTo(midX - halfWidth, underlineY); + ctx.lineTo(midX + halfWidth, underlineY); + ctx.stroke(); + return; + } + default: + ctx.beginPath(); + ctx.moveTo(midX - halfWidth * 0.5, y + fontSize * 0.1); + ctx.lineTo(midX + halfWidth * 0.5, y + fontSize * 0.1); + ctx.stroke(); + } +} + +function splitIntoClusters(text: string): Array<{ start: number; text: string }> { + const chars = Array.from(text); + const clusters: Array<{ start: number; text: string }> = []; + + let idx = 0; + while (idx < chars.length) { + if (isHangulChoseong(chars[idx])) { + const start = idx; + let cluster = chars[idx]; + idx += 1; + if (idx < chars.length && isHangulJungseong(chars[idx])) { + cluster += chars[idx]; + idx += 1; + if (idx < chars.length && isHangulJongseong(chars[idx])) { + cluster += chars[idx]; + idx += 1; + } + } + clusters.push({ start, text: cluster }); + continue; + } + + clusters.push({ start: idx, text: chars[idx] }); + idx += 1; + } + + return clusters; +} + +function isHangulChoseong(char: string): boolean { + const code = char.codePointAt(0) ?? 0; + return (code >= 0x1100 && code <= 0x115f) || (code >= 0xa960 && code <= 0xa97f); +} + +function isHangulJungseong(char: string): boolean { + const code = char.codePointAt(0) ?? 0; + return (code >= 0x1160 && code <= 0x11a7) || (code >= 0xd7b0 && code <= 0xd7c6); +} + +function isHangulJongseong(char: string): boolean { + const code = char.codePointAt(0) ?? 0; + return (code >= 0x11a8 && code <= 0x11ff) || (code >= 0xd7cb && code <= 0xd7fb); +} diff --git a/rhwp-studio/src/view/page-renderer.ts b/rhwp-studio/src/view/page-renderer.ts index c5dd76ff..eda3f284 100644 --- a/rhwp-studio/src/view/page-renderer.ts +++ b/rhwp-studio/src/view/page-renderer.ts @@ -1,20 +1,53 @@ import { WasmBridge } from '@/core/wasm-bridge'; +import type { PageInfo } from '@/core/types'; +import { CanvasKitLayerRenderer } from './canvaskit-renderer'; +import { clampRenderScale, type RenderBackend } from './render-backend'; export class PageRenderer { private reRenderTimers = new Map[]>(); - constructor(private wasm: WasmBridge) {} + constructor( + private wasm: WasmBridge, + private backend: RenderBackend, + private canvaskitRenderer: CanvasKitLayerRenderer | null, + ) {} /** 페이지를 Canvas에 렌더링한다 (scale = zoom × DPR) */ - renderPage(pageIdx: number, canvas: HTMLCanvasElement, scale: number): void { - this.wasm.renderPageToCanvas(pageIdx, canvas, scale); - this.drawMarginGuides(pageIdx, canvas, scale); - this.scheduleReRender(pageIdx, canvas, scale); + renderPage(pageIdx: number, pageInfo: PageInfo, canvas: HTMLCanvasElement, scale: number): void { + const appliedScale = this.renderContent(pageIdx, pageInfo, canvas, scale); + this.drawMarginGuides(pageInfo, canvas, appliedScale); + this.scheduleReRender(pageIdx, pageInfo, canvas, scale); + } + + getBackend(): RenderBackend { + return this.backend; + } + + private renderContent( + pageIdx: number, + pageInfo: PageInfo, + canvas: HTMLCanvasElement, + scale: number, + ): number { + if (this.backend !== 'canvaskit') { + this.wasm.renderPageToCanvas(pageIdx, canvas, scale); + return scale; + } + + if (!this.canvaskitRenderer) { + throw new Error('CanvasKit renderer가 초기화되지 않았습니다'); + } + + const appliedScale = clampRenderScale(pageInfo, scale); + + canvas.width = Math.max(1, Math.floor(pageInfo.width * appliedScale)); + canvas.height = Math.max(1, Math.floor(pageInfo.height * appliedScale)); + this.canvaskitRenderer.renderPage(this.wasm.getPageLayerTree(pageIdx), canvas, appliedScale); + return appliedScale; } /** 편집 용지 여백 가이드라인을 캔버스에 그린다 (4모서리 L자 표시) */ - private drawMarginGuides(pageIdx: number, canvas: HTMLCanvasElement, scale: number): void { - const pageInfo = this.wasm.getPageInfo(pageIdx); + private drawMarginGuides(pageInfo: PageInfo, canvas: HTMLCanvasElement, scale: number): void { const ctx = canvas.getContext('2d'); if (!ctx) return; @@ -63,7 +96,12 @@ export class PageRenderer { * 아직 디코딩되지 않았을 수 있으므로 점진적 재렌더링한다. * 200ms, 600ms 두 번 재시도하여 대부분의 이미지 로드를 커버한다. */ - private scheduleReRender(pageIdx: number, canvas: HTMLCanvasElement, scale: number): void { + private scheduleReRender( + pageIdx: number, + pageInfo: PageInfo, + canvas: HTMLCanvasElement, + scale: number, + ): void { this.cancelReRender(pageIdx); const delays = [200, 600]; @@ -72,8 +110,8 @@ export class PageRenderer { for (const delay of delays) { const timer = setTimeout(() => { if (canvas.parentElement) { - this.wasm.renderPageToCanvas(pageIdx, canvas, scale); - this.drawMarginGuides(pageIdx, canvas, scale); + const appliedScale = this.renderContent(pageIdx, pageInfo, canvas, scale); + this.drawMarginGuides(pageInfo, canvas, appliedScale); } }, delay); timers.push(timer); diff --git a/rhwp-studio/src/view/render-backend.ts b/rhwp-studio/src/view/render-backend.ts new file mode 100644 index 00000000..8185a514 --- /dev/null +++ b/rhwp-studio/src/view/render-backend.ts @@ -0,0 +1,64 @@ +import type { PageInfo } from '@/core/types'; + +export type RenderBackend = 'canvas2d' | 'canvaskit'; +export type CanvasKitRenderMode = 'default' | 'compat'; + +const STORAGE_KEY = 'rhwp-render-backend'; +const CANVASKIT_MODE_STORAGE_KEY = 'rhwp-canvaskit-render-mode'; + +export function resolveRenderBackend(search: string): RenderBackend { + const params = new URLSearchParams(search); + const requested = params.get('renderer'); + + if (requested === 'canvaskit') return 'canvaskit'; + if (requested === 'canvas' || requested === 'canvas2d') return 'canvas2d'; + + try { + return window.localStorage.getItem(STORAGE_KEY) === 'canvaskit' ? 'canvaskit' : 'canvas2d'; + } catch { + return 'canvas2d'; + } +} + +export function persistRenderBackend(backend: RenderBackend): void { + try { + window.localStorage.setItem(STORAGE_KEY, backend); + } catch { + // private mode / disabled storage: 무시하고 query-param 선택만 사용한다. + } +} + +export function resolveCanvasKitRenderMode(search: string): CanvasKitRenderMode { + const params = new URLSearchParams(search); + const requested = params.get('canvaskitMode'); + + if (requested === 'default') return 'default'; + if (requested === 'compat') return 'compat'; + + try { + return window.localStorage.getItem(CANVASKIT_MODE_STORAGE_KEY) === 'default' + ? 'default' + : 'compat'; + } catch { + return 'compat'; + } +} + +export function persistCanvasKitRenderMode(mode: CanvasKitRenderMode): void { + try { + window.localStorage.setItem(CANVASKIT_MODE_STORAGE_KEY, mode); + } catch { + // private mode / disabled storage: 무시하고 query-param 선택만 사용한다. + } +} + +export function clampRenderScale(pageInfo: Pick, requestedScale: number): number { + let scale = requestedScale <= 0 || Number.isNaN(requestedScale) ? 1.0 : Math.min(Math.max(requestedScale, 0.25), 12.0); + const maxDim = 16384; + + if (pageInfo.width * scale > maxDim || pageInfo.height * scale > maxDim) { + scale = Math.min(maxDim / pageInfo.width, maxDim / pageInfo.height, scale); + } + + return scale; +} diff --git a/src/document_core/commands/clipboard.rs b/src/document_core/commands/clipboard.rs index 1103f157..95666f46 100644 --- a/src/document_core/commands/clipboard.rs +++ b/src/document_core/commands/clipboard.rs @@ -1,15 +1,14 @@ //! 내부 클립보드 + HTML 내보내기 관련 native 메서드 -use crate::model::control::Control; -use crate::model::paragraph::Paragraph; -use crate::document_core::{DocumentCore, ClipboardData}; -use crate::error::HwpError; -use crate::model::event::DocumentEvent; use super::super::helpers::{ - color_ref_to_css, clipboard_escape_html, clipboard_color_to_css, - border_line_type_to_u8_val, detect_clipboard_image_mime, - utf16_pos_to_char_idx, get_textbox_from_shape, + border_line_type_to_u8_val, clipboard_color_to_css, clipboard_escape_html, color_ref_to_css, + detect_clipboard_image_mime, get_textbox_from_shape, utf16_pos_to_char_idx, }; +use crate::document_core::{ClipboardData, DocumentCore}; +use crate::error::HwpError; +use crate::model::control::Control; +use crate::model::event::DocumentEvent; +use crate::model::paragraph::Paragraph; impl DocumentCore { pub fn has_internal_clipboard_native(&self) -> bool { @@ -18,7 +17,8 @@ impl DocumentCore { /// 내부 클립보드의 플레인 텍스트를 반환한다. pub fn get_clipboard_text_native(&self) -> String { - self.clipboard.as_ref() + self.clipboard + .as_ref() .map(|c| c.plain_text.clone()) .unwrap_or_default() } @@ -43,18 +43,23 @@ impl DocumentCore { // 인덱스 범위 검증 if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과", section_idx + "구역 인덱스 {} 범위 초과", + section_idx ))); } let section = &self.document.sections[section_idx]; if start_para_idx >= section.paragraphs.len() || end_para_idx >= section.paragraphs.len() { return Err(HwpError::RenderError(format!( "문단 인덱스 범위 초과 (start={}, end={}, total={})", - start_para_idx, end_para_idx, section.paragraphs.len() + start_para_idx, + end_para_idx, + section.paragraphs.len() ))); } if start_para_idx > end_para_idx { - return Err(HwpError::RenderError("시작 위치가 끝 위치보다 뒤에 있음".to_string())); + return Err(HwpError::RenderError( + "시작 위치가 끝 위치보다 뒤에 있음".to_string(), + )); } let mut clip_paragraphs = Vec::new(); @@ -99,13 +104,13 @@ impl DocumentCore { // 구조적 컨트롤(SectionDef, ColumnDef 등) 제거 — 텍스트 복사에 불필요 for para in &mut clip_paragraphs { - para.controls.retain(|ctrl| !matches!(ctrl, - Control::SectionDef(_) | Control::ColumnDef(_) - )); + para.controls + .retain(|ctrl| !matches!(ctrl, Control::SectionDef(_) | Control::ColumnDef(_))); } // 플레인 텍스트 추출 - let plain_text: String = clip_paragraphs.iter() + let plain_text: String = clip_paragraphs + .iter() .map(|p| p.text.as_str()) .collect::>() .join("\n"); @@ -117,7 +122,10 @@ impl DocumentCore { plain_text: plain_text.clone(), }); - Ok(super::super::helpers::json_ok_with(&format!("\"text\":\"{}\"", escaped))) + Ok(super::super::helpers::json_ok_with(&format!( + "\"text\":\"{}\"", + escaped + ))) } /// 표 셀 내부 선택 영역을 내부 클립보드에 복사한다. @@ -134,21 +142,30 @@ impl DocumentCore { ) -> Result { // 셀 문단 리스트 접근 let cell_paragraphs = { - let section = self.document.sections.get(section_idx) - .ok_or_else(|| HwpError::RenderError(format!("구역 {} 범위 초과", section_idx)))?; - let para = section.paragraphs.get(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 {} 범위 초과", parent_para_idx)))?; + let section = + self.document.sections.get(section_idx).ok_or_else(|| { + HwpError::RenderError(format!("구역 {} 범위 초과", section_idx)) + })?; + let para = section.paragraphs.get(parent_para_idx).ok_or_else(|| { + HwpError::RenderError(format!("문단 {} 범위 초과", parent_para_idx)) + })?; let table = match para.controls.get(control_idx) { Some(Control::Table(t)) => t, _ => return Err(HwpError::RenderError("표가 아님".to_string())), }; - let cell = table.cells.get(cell_idx) + let cell = table + .cells + .get(cell_idx) .ok_or_else(|| HwpError::RenderError(format!("셀 {} 범위 초과", cell_idx)))?; &cell.paragraphs }; - if start_cell_para_idx >= cell_paragraphs.len() || end_cell_para_idx >= cell_paragraphs.len() { - return Err(HwpError::RenderError("셀 문단 인덱스 범위 초과".to_string())); + if start_cell_para_idx >= cell_paragraphs.len() + || end_cell_para_idx >= cell_paragraphs.len() + { + return Err(HwpError::RenderError( + "셀 문단 인덱스 범위 초과".to_string(), + )); } let mut clip_paragraphs = Vec::new(); @@ -182,7 +199,8 @@ impl DocumentCore { clip_paragraphs.push(last); } - let plain_text: String = clip_paragraphs.iter() + let plain_text: String = clip_paragraphs + .iter() .map(|p| p.text.as_str()) .collect::>() .join("\n"); @@ -194,7 +212,10 @@ impl DocumentCore { plain_text: plain_text.clone(), }); - Ok(super::super::helpers::json_ok_with(&format!("\"text\":\"{}\"", escaped))) + Ok(super::super::helpers::json_ok_with(&format!( + "\"text\":\"{}\"", + escaped + ))) } /// 컨트롤 객체(표, 이미지, 도형)를 내부 클립보드에 복사한다. @@ -204,11 +225,18 @@ impl DocumentCore { para_idx: usize, control_idx: usize, ) -> Result { - let section = self.document.sections.get(section_idx) + let section = self + .document + .sections + .get(section_idx) .ok_or_else(|| HwpError::RenderError(format!("구역 {} 범위 초과", section_idx)))?; - let para = section.paragraphs.get(para_idx) + let para = section + .paragraphs + .get(para_idx) .ok_or_else(|| HwpError::RenderError(format!("문단 {} 범위 초과", para_idx)))?; - let control = para.controls.get(control_idx) + let control = para + .controls + .get(control_idx) .ok_or_else(|| HwpError::RenderError(format!("컨트롤 {} 범위 초과", control_idx)))?; // 컨트롤을 포함하는 단일 문단 생성 @@ -264,9 +292,7 @@ impl DocumentCore { para_shape_id: para.para_shape_id, style_id: para.style_id, controls: vec![control.clone()], - ctrl_data_records: vec![ - para.ctrl_data_records.get(control_idx).cloned().flatten(), - ], + ctrl_data_records: vec![para.ctrl_data_records.get(control_idx).cloned().flatten()], has_para_text: true, ..Default::default() }; @@ -283,7 +309,10 @@ impl DocumentCore { plain_text: plain_text.clone(), }); - Ok(super::super::helpers::json_ok_with(&format!("\"text\":\"{}\"", plain_text))) + Ok(super::super::helpers::json_ok_with(&format!( + "\"text\":\"{}\"", + plain_text + ))) } /// 내부 클립보드의 내용을 캐럿 위치에 붙여넣는다 (본문 문단). @@ -300,10 +329,16 @@ impl DocumentCore { // 인덱스 검증 if section_idx >= self.document.sections.len() { - return Err(HwpError::RenderError(format!("구역 {} 범위 초과", section_idx))); + return Err(HwpError::RenderError(format!( + "구역 {} 범위 초과", + section_idx + ))); } if para_idx >= self.document.sections[section_idx].paragraphs.len() { - return Err(HwpError::RenderError(format!("문단 {} 범위 초과", para_idx))); + return Err(HwpError::RenderError(format!( + "문단 {} 범위 초과", + para_idx + ))); } self.document.sections[section_idx].raw_stream = None; @@ -323,8 +358,12 @@ impl DocumentCore { // 클립보드의 글자 모양 적용 self.apply_clipboard_char_shapes( - section_idx, para_idx, char_offset, - &clip_char_shapes, &clip_char_offsets, new_chars, + section_idx, + para_idx, + char_offset, + &clip_char_shapes, + &clip_char_offsets, + new_chars, ); self.reflow_paragraph(section_idx, para_idx); @@ -332,33 +371,37 @@ impl DocumentCore { self.paginate_if_needed(); let new_offset = char_offset + new_chars; - self.event_log.push(DocumentEvent::ContentPasted { section: section_idx, para: para_idx }); + self.event_log.push(DocumentEvent::ContentPasted { + section: section_idx, + para: para_idx, + }); return Ok(super::super::helpers::json_ok_with(&format!( - "\"paraIdx\":{},\"charOffset\":{}", para_idx, new_offset + "\"paraIdx\":{},\"charOffset\":{}", + para_idx, new_offset ))); } // 다중 문단 또는 컨트롤 포함 붙여넣기 // 1. 현재 문단을 캐럿 위치에서 분할 - let right_half = self.document.sections[section_idx].paragraphs[para_idx] - .split_at(char_offset); + let right_half = + self.document.sections[section_idx].paragraphs[para_idx].split_at(char_offset); // 2. 왼쪽 절반에 첫 번째 클립보드 문단 병합 - self.document.sections[section_idx].paragraphs[para_idx] - .merge_from(&clip_paras[0]); + self.document.sections[section_idx].paragraphs[para_idx].merge_from(&clip_paras[0]); // 3. 나머지 클립보드 문단 삽입 let mut insert_idx = para_idx + 1; for i in 1..clip_count { - self.document.sections[section_idx].paragraphs + self.document.sections[section_idx] + .paragraphs .insert(insert_idx, clip_paras[i].clone()); insert_idx += 1; } // 4. 마지막 삽입된 문단에 오른쪽 절반 병합 let last_para_idx = insert_idx - 1; - let merge_point = self.document.sections[section_idx].paragraphs[last_para_idx] - .merge_from(&right_half); + let merge_point = + self.document.sections[section_idx].paragraphs[last_para_idx].merge_from(&right_half); // 5. 영향받는 모든 문단 리플로우 for i in para_idx..=last_para_idx { @@ -372,9 +415,13 @@ impl DocumentCore { } self.paginate_if_needed(); - self.event_log.push(DocumentEvent::ContentPasted { section: section_idx, para: para_idx }); + self.event_log.push(DocumentEvent::ContentPasted { + section: section_idx, + para: para_idx, + }); Ok(super::super::helpers::json_ok_with(&format!( - "\"paraIdx\":{},\"charOffset\":{}", last_para_idx, merge_point + "\"paraIdx\":{},\"charOffset\":{}", + last_para_idx, merge_point ))) } @@ -395,14 +442,18 @@ impl DocumentCore { // 셀 접근 검증 let cell_para_count = { - let section = self.document.sections.get(section_idx) - .ok_or_else(|| HwpError::RenderError(format!("구역 {} 범위 초과", section_idx)))?; - let para = section.paragraphs.get(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 {} 범위 초과", parent_para_idx)))?; + let section = + self.document.sections.get(section_idx).ok_or_else(|| { + HwpError::RenderError(format!("구역 {} 범위 초과", section_idx)) + })?; + let para = section.paragraphs.get(parent_para_idx).ok_or_else(|| { + HwpError::RenderError(format!("문단 {} 범위 초과", parent_para_idx)) + })?; match para.controls.get(control_idx) { Some(Control::Table(t)) => { - let cell = t.cells.get(cell_idx) - .ok_or_else(|| HwpError::RenderError(format!("셀 {} 범위 초과", cell_idx)))?; + let cell = t.cells.get(cell_idx).ok_or_else(|| { + HwpError::RenderError(format!("셀 {} 범위 초과", cell_idx)) + })?; cell.paragraphs.len() } Some(Control::Shape(s)) => { @@ -411,7 +462,9 @@ impl DocumentCore { tb.paragraphs.len() } Some(Control::Picture(p)) => { - let cap = p.caption.as_ref() + let cap = p + .caption + .as_ref() .ok_or_else(|| HwpError::RenderError("캡션 없음".to_string()))?; cap.paragraphs.len() } @@ -419,7 +472,10 @@ impl DocumentCore { } }; if cell_para_idx >= cell_para_count { - return Err(HwpError::RenderError(format!("셀 문단 {} 범위 초과", cell_para_idx))); + return Err(HwpError::RenderError(format!( + "셀 문단 {} 범위 초과", + cell_para_idx + ))); } self.document.sections[section_idx].raw_stream = None; @@ -433,7 +489,9 @@ impl DocumentCore { match &mut para.controls[control_idx] { Control::Table(t) => &mut t.cells[cell_idx].paragraphs, Control::Shape(s) => { - &mut super::super::helpers::get_textbox_from_shape_mut(s).unwrap().paragraphs + &mut super::super::helpers::get_textbox_from_shape_mut(s) + .unwrap() + .paragraphs } Control::Picture(p) => &mut p.caption.as_mut().unwrap().paragraphs, _ => unreachable!(), @@ -451,26 +509,43 @@ impl DocumentCore { let clip_char_shapes = clip_paras[0].char_shapes.clone(); let clip_char_offsets = clip_paras[0].char_offsets.clone(); Self::apply_clipboard_char_shapes_to_para( - &mut cell_paras[cell_para_idx], char_offset, - &clip_char_shapes, &clip_char_offsets, new_chars, + &mut cell_paras[cell_para_idx], + char_offset, + &clip_char_shapes, + &clip_char_offsets, + new_chars, ); // 셀 리플로우 let _ = cell_paras; - self.reflow_cell_paragraph(section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx); + self.reflow_cell_paragraph( + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ); // 부모 컨트롤 dirty 마킹 + 재페이지네이션 - match self.document.sections[section_idx] - .paragraphs[parent_para_idx].controls.get_mut(control_idx) { - Some(Control::Table(t)) => { t.dirty = true; } + match self.document.sections[section_idx].paragraphs[parent_para_idx] + .controls + .get_mut(control_idx) + { + Some(Control::Table(t)) => { + t.dirty = true; + } _ => {} } self.mark_section_dirty(section_idx); self.paginate_if_needed(); let new_offset = char_offset + new_chars; - self.event_log.push(DocumentEvent::ContentPasted { section: section_idx, para: parent_para_idx }); + self.event_log.push(DocumentEvent::ContentPasted { + section: section_idx, + para: parent_para_idx, + }); return Ok(super::super::helpers::json_ok_with(&format!( - "\"cellParaIdx\":{},\"charOffset\":{}", cell_para_idx, new_offset + "\"cellParaIdx\":{},\"charOffset\":{}", + cell_para_idx, new_offset ))); } @@ -493,17 +568,25 @@ impl DocumentCore { self.reflow_cell_paragraph(section_idx, parent_para_idx, control_idx, cell_idx, i); } // 부모 컨트롤 dirty 마킹 + 재페이지네이션 - match self.document.sections[section_idx] - .paragraphs[parent_para_idx].controls.get_mut(control_idx) { - Some(Control::Table(t)) => { t.dirty = true; } + match self.document.sections[section_idx].paragraphs[parent_para_idx] + .controls + .get_mut(control_idx) + { + Some(Control::Table(t)) => { + t.dirty = true; + } _ => {} } self.mark_section_dirty(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::ContentPasted { section: section_idx, para: parent_para_idx }); + self.event_log.push(DocumentEvent::ContentPasted { + section: section_idx, + para: parent_para_idx, + }); Ok(super::super::helpers::json_ok_with(&format!( - "\"cellParaIdx\":{},\"charOffset\":{}", last_para_idx, merge_point + "\"cellParaIdx\":{},\"charOffset\":{}", + last_para_idx, merge_point ))) } @@ -519,7 +602,10 @@ impl DocumentCore { ) { Self::apply_clipboard_char_shapes_to_para( &mut self.document.sections[section_idx].paragraphs[para_idx], - insert_offset, clip_char_shapes, clip_char_offsets, inserted_chars, + insert_offset, + clip_char_shapes, + clip_char_offsets, + inserted_chars, ); } @@ -539,12 +625,14 @@ impl DocumentCore { let cs = &clip_char_shapes[i]; // UTF-16 위치를 char 인덱스로 변환 - let start_char_idx = clip_char_offsets.iter() + let start_char_idx = clip_char_offsets + .iter() .position(|&off| off >= cs.start_pos) .unwrap_or(0); let end_char_idx = if i + 1 < clip_char_shapes.len() { - clip_char_offsets.iter() + clip_char_offsets + .iter() .position(|&off| off >= clip_char_shapes[i + 1].start_pos) .unwrap_or(inserted_chars) } else { @@ -564,12 +652,21 @@ impl DocumentCore { /// 내부 클립보드에 붙여넣기 가능한 개체 컨트롤(표/그림/도형)이 포함되어 있는지 확인한다. /// SectionDef, ColumnDef 등 구조적 컨트롤은 개체가 아니므로 제외한다. pub fn clipboard_has_control_native(&self) -> bool { - self.clipboard.as_ref() - .map(|c| c.paragraphs.first().map(|p| { - p.controls.iter().any(|ctrl| matches!(ctrl, - Control::Table(_) | Control::Picture(_) | Control::Shape(_) - )) - }).unwrap_or(false)) + self.clipboard + .as_ref() + .map(|c| { + c.paragraphs + .first() + .map(|p| { + p.controls.iter().any(|ctrl| { + matches!( + ctrl, + Control::Table(_) | Control::Picture(_) | Control::Shape(_) + ) + }) + }) + .unwrap_or(false) + }) .unwrap_or(false) } @@ -585,34 +682,42 @@ impl DocumentCore { ) -> Result { // 클립보드에서 컨트롤 문단 확인 let clip_para = match &self.clipboard { - Some(c) => { - match c.paragraphs.first() { - Some(p) if !p.controls.is_empty() => p.clone(), - _ => return Ok("{\"ok\":false,\"error\":\"no control in clipboard\"}".to_string()), - } - } + Some(c) => match c.paragraphs.first() { + Some(p) if !p.controls.is_empty() => p.clone(), + _ => return Ok("{\"ok\":false,\"error\":\"no control in clipboard\"}".to_string()), + }, None => return Ok("{\"ok\":false,\"error\":\"clipboard empty\"}".to_string()), }; // 인덱스 검증 if section_idx >= self.document.sections.len() { - return Err(HwpError::RenderError(format!("구역 {} 범위 초과", section_idx))); + return Err(HwpError::RenderError(format!( + "구역 {} 범위 초과", + section_idx + ))); } if para_idx >= self.document.sections[section_idx].paragraphs.len() { - return Err(HwpError::RenderError(format!("문단 {} 범위 초과", para_idx))); + return Err(HwpError::RenderError(format!( + "문단 {} 범위 초과", + para_idx + ))); } self.document.sections[section_idx].raw_stream = None; // 커서 위치 문단의 속성 상속 (빈 문단 생성용) let current_para = &self.document.sections[section_idx].paragraphs[para_idx]; - let default_char_shape_id: u32 = current_para.char_shapes.first() - .map(|cs| cs.char_shape_id).unwrap_or(0); + let default_char_shape_id: u32 = current_para + .char_shapes + .first() + .map(|cs| cs.char_shape_id) + .unwrap_or(0); let default_para_shape_id: u16 = current_para.para_shape_id; // 편집 영역 폭 let pd = &self.document.sections[section_idx].section_def.page_def; - let content_width = (pd.width as i32 - pd.margin_left as i32 - pd.margin_right as i32).max(7200) as u32; + let content_width = + (pd.width as i32 - pd.margin_left as i32 - pd.margin_right as i32).max(7200) as u32; // 삽입 위치 결정 (create_shape_control_native 패턴) let para = &self.document.sections[section_idx].paragraphs[para_idx]; @@ -623,17 +728,25 @@ impl DocumentCore { self.document.sections[section_idx].paragraphs[para_idx] = clip_para; insert_para_idx = para_idx; } else if char_offset == 0 && para.controls.is_empty() { - self.document.sections[section_idx].paragraphs.insert(para_idx, clip_para); + self.document.sections[section_idx] + .paragraphs + .insert(para_idx, clip_para); insert_para_idx = para_idx; } else { if char_offset > 0 && !para.text.is_empty() { - let new_para = self.document.sections[section_idx].paragraphs[para_idx] - .split_at(char_offset); - self.document.sections[section_idx].paragraphs.insert(para_idx + 1, new_para); - self.document.sections[section_idx].paragraphs.insert(para_idx + 1, clip_para); + let new_para = + self.document.sections[section_idx].paragraphs[para_idx].split_at(char_offset); + self.document.sections[section_idx] + .paragraphs + .insert(para_idx + 1, new_para); + self.document.sections[section_idx] + .paragraphs + .insert(para_idx + 1, clip_para); insert_para_idx = para_idx + 1; } else { - self.document.sections[section_idx].paragraphs.insert(para_idx + 1, clip_para); + self.document.sections[section_idx] + .paragraphs + .insert(para_idx + 1, clip_para); insert_para_idx = para_idx + 1; } } @@ -644,13 +757,15 @@ impl DocumentCore { // (insert_picture_native 패턴: line_height=pic.height, segment_width=content_width) { let inserted = &mut self.document.sections[section_idx].paragraphs[insert_para_idx]; - let ctrl_height = inserted.controls.first().map(|ctrl| { - match ctrl { + let ctrl_height = inserted + .controls + .first() + .map(|ctrl| match ctrl { Control::Picture(pic) => pic.common.height as i32, Control::Shape(shape) => shape.common().height as i32, _ => 0, - } - }).unwrap_or(0); + }) + .unwrap_or(0); if let Some(ls) = inserted.line_segs.first_mut() { ls.segment_width = content_width as i32; if ctrl_height > 0 { @@ -691,15 +806,21 @@ impl DocumentCore { raw_header_extra: empty_raw, ..Default::default() }; - self.document.sections[section_idx].paragraphs.insert(insert_para_idx + 1, empty_para); + self.document.sections[section_idx] + .paragraphs + .insert(insert_para_idx + 1, empty_para); // 리플로우 + 페이지네이션 self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::ContentPasted { section: section_idx, para: insert_para_idx }); + self.event_log.push(DocumentEvent::ContentPasted { + section: section_idx, + para: insert_para_idx, + }); Ok(super::super::helpers::json_ok_with(&format!( - "\"paraIdx\":{},\"controlIdx\":0", insert_para_idx + "\"paraIdx\":{},\"controlIdx\":0", + insert_para_idx ))) } @@ -714,7 +835,10 @@ impl DocumentCore { end_para_idx: usize, end_char_offset: usize, ) -> Result { - let section = self.document.sections.get(section_idx) + let section = self + .document + .sections + .get(section_idx) .ok_or_else(|| HwpError::RenderError(format!("구역 {} 범위 초과", section_idx)))?; if start_para_idx >= section.paragraphs.len() || end_para_idx >= section.paragraphs.len() { @@ -725,8 +849,16 @@ impl DocumentCore { for pi in start_para_idx..=end_para_idx { let para = §ion.paragraphs[pi]; - let start = if pi == start_para_idx { Some(start_char_offset) } else { None }; - let end = if pi == end_para_idx { Some(end_char_offset) } else { None }; + let start = if pi == start_para_idx { + Some(start_char_offset) + } else { + None + }; + let end = if pi == end_para_idx { + Some(end_char_offset) + } else { + None + }; html.push_str(&self.paragraph_to_html(para, start, end)); } @@ -746,24 +878,41 @@ impl DocumentCore { end_cell_para_idx: usize, end_char_offset: usize, ) -> Result { - let section = self.document.sections.get(section_idx) + let section = self + .document + .sections + .get(section_idx) .ok_or_else(|| HwpError::RenderError(format!("구역 {} 범위 초과", section_idx)))?; - let para = section.paragraphs.get(parent_para_idx) + let para = section + .paragraphs + .get(parent_para_idx) .ok_or_else(|| HwpError::RenderError(format!("문단 {} 범위 초과", parent_para_idx)))?; let table = match para.controls.get(control_idx) { Some(Control::Table(t)) => t, _ => return Err(HwpError::RenderError("표가 아님".to_string())), }; - let cell = table.cells.get(cell_idx) + let cell = table + .cells + .get(cell_idx) .ok_or_else(|| HwpError::RenderError(format!("셀 {} 범위 초과", cell_idx)))?; let mut html = String::from("\n\n"); for pi in start_cell_para_idx..=end_cell_para_idx { - if pi >= cell.paragraphs.len() { break; } + if pi >= cell.paragraphs.len() { + break; + } let cpara = &cell.paragraphs[pi]; - let start = if pi == start_cell_para_idx { Some(start_char_offset) } else { None }; - let end = if pi == end_cell_para_idx { Some(end_char_offset) } else { None }; + let start = if pi == start_cell_para_idx { + Some(start_char_offset) + } else { + None + }; + let end = if pi == end_cell_para_idx { + Some(end_char_offset) + } else { + None + }; html.push_str(&self.paragraph_to_html(cpara, start, end)); } @@ -778,11 +927,18 @@ impl DocumentCore { para_idx: usize, control_idx: usize, ) -> Result { - let section = self.document.sections.get(section_idx) + let section = self + .document + .sections + .get(section_idx) .ok_or_else(|| HwpError::RenderError(format!("구역 {} 범위 초과", section_idx)))?; - let para = section.paragraphs.get(para_idx) + let para = section + .paragraphs + .get(para_idx) .ok_or_else(|| HwpError::RenderError(format!("문단 {} 범위 초과", para_idx)))?; - let control = para.controls.get(control_idx) + let control = para + .controls + .get(control_idx) .ok_or_else(|| HwpError::RenderError(format!("컨트롤 {} 범위 초과", control_idx)))?; let mut html = String::from("\n\n"); @@ -801,7 +957,9 @@ impl DocumentCore { let chars: Vec = para.text.chars().collect(); let start_idx = start_offset.unwrap_or(0).min(chars.len()); let end_idx = end_offset.unwrap_or(chars.len()).min(chars.len()); - if start_idx >= end_idx { return String::new(); } + if start_idx >= end_idx { + return String::new(); + } // 문단 스타일 CSS let para_css = self.para_style_to_css(para.para_shape_id); @@ -811,16 +969,20 @@ impl DocumentCore { let style_ranges = self.get_char_style_ranges(para, start_idx, end_idx); for (range_start, range_end, char_shape_id) in &style_ranges { - let segment: String = chars[*range_start..*range_end].iter() + let segment: String = chars[*range_start..*range_end] + .iter() .filter(|c| !c.is_control() || **c == '\t') .collect(); - if segment.is_empty() { continue; } + if segment.is_empty() { + continue; + } let css = self.char_style_to_css(*char_shape_id); html.push_str(&format!( "{}", - css, clipboard_escape_html(&segment) + css, + clipboard_escape_html(&segment) )); } @@ -865,7 +1027,9 @@ impl DocumentCore { // 시작점 이전에 스타일이 없으면 첫 CharShapeRef의 스타일 사용 if ranges.is_empty() && !boundaries.is_empty() { - let last_before = boundaries.iter().rev() + let last_before = boundaries + .iter() + .rev() .find(|(idx, _)| *idx <= start_idx) .map(|(_, id)| *id) .unwrap_or(boundaries[0].1); @@ -889,13 +1053,15 @@ impl DocumentCore { if !cs.font_family.is_empty() { fonts.push(&cs.font_family); } - if cs.font_families.len() > 1 && !cs.font_families[1].is_empty() + if cs.font_families.len() > 1 + && !cs.font_families[1].is_empty() && cs.font_families[1] != cs.font_family { fonts.push(&cs.font_families[1]); } if !fonts.is_empty() { - let font_list: Vec = fonts.iter() + let font_list: Vec = fonts + .iter() .map(|f| format!("'{}'", clipboard_escape_html(f))) .collect(); css.push_str(&format!("font-family:{};", font_list.join(","))); @@ -908,8 +1074,12 @@ impl DocumentCore { } // font-weight / font-style - if cs.bold { css.push_str("font-weight:bold;"); } - if cs.italic { css.push_str("font-style:italic;"); } + if cs.bold { + css.push_str("font-weight:bold;"); + } + if cs.italic { + css.push_str("font-style:italic;"); + } // color let color = clipboard_color_to_css(cs.text_color); @@ -994,16 +1164,15 @@ impl DocumentCore { use crate::renderer::style_resolver::ResolvedBorderStyle; let mut html = String::from( - "\n" + "
\n", ); // 행별로 그룹화 let max_row = table.cells.iter().map(|c| c.row).max().unwrap_or(0); for row in 0..=max_row { html.push_str("\n"); - let mut row_cells: Vec<&crate::model::table::Cell> = table.cells.iter() - .filter(|c| c.row == row) - .collect(); + let mut row_cells: Vec<&crate::model::table::Cell> = + table.cells.iter().filter(|c| c.row == row).collect(); row_cells.sort_by_key(|c| c.col); for cell in &row_cells { @@ -1056,7 +1225,10 @@ impl DocumentCore { // 배경색 if let Some(fill_color) = bs.fill_color { if fill_color != 0xFFFFFF && fill_color != 0 { - css.push_str(&format!("background-color:{};", clipboard_color_to_css(fill_color))); + css.push_str(&format!( + "background-color:{};", + clipboard_color_to_css(fill_color) + )); } } @@ -1067,10 +1239,7 @@ impl DocumentCore { if bl.width > 0 { let color = clipboard_color_to_css(bl.color); let px = (bl.width as f64).max(1.0); - css.push_str(&format!( - "border-{}:{:.1}px solid {};", - side, px, color - )); + css.push_str(&format!("border-{}:{:.1}px solid {};", side, px, color)); } } } @@ -1080,11 +1249,15 @@ impl DocumentCore { use base64::Engine; let bin_data_id = pic.image_attr.bin_data_id; - if bin_data_id == 0 { return String::new(); } + if bin_data_id == 0 { + return String::new(); + } // 이미지 데이터 찾기 (bin_data_id는 1-indexed 순번) let image_data = if bin_data_id > 0 { - self.document.bin_data_content.get((bin_data_id - 1) as usize) + self.document + .bin_data_content + .get((bin_data_id - 1) as usize) } else { None }; @@ -1114,25 +1287,43 @@ impl DocumentCore { para_idx: usize, control_idx: usize, ) -> Result, HwpError> { - let section = self.document.sections.get(section_idx) + let section = self + .document + .sections + .get(section_idx) .ok_or_else(|| HwpError::RenderError(format!("구역 {} 범위 초과", section_idx)))?; - let para = section.paragraphs.get(para_idx) + let para = section + .paragraphs + .get(para_idx) .ok_or_else(|| HwpError::RenderError(format!("문단 {} 범위 초과", para_idx)))?; - let control = para.controls.get(control_idx) + let control = para + .controls + .get(control_idx) .ok_or_else(|| HwpError::RenderError(format!("컨트롤 {} 범위 초과", control_idx)))?; let pic = match control { Control::Picture(p) => p, - _ => return Err(HwpError::RenderError("Picture 컨트롤이 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "Picture 컨트롤이 아닙니다".to_string(), + )) + } }; let bin_data_id = pic.image_attr.bin_data_id; if bin_data_id == 0 { - return Err(HwpError::RenderError("이미지 데이터 없음 (bin_data_id=0)".to_string())); + return Err(HwpError::RenderError( + "이미지 데이터 없음 (bin_data_id=0)".to_string(), + )); } - let bdc = self.document.bin_data_content.get((bin_data_id - 1) as usize) - .ok_or_else(|| HwpError::RenderError(format!("바이너리 데이터 {} 범위 초과", bin_data_id)))?; + let bdc = self + .document + .bin_data_content + .get((bin_data_id - 1) as usize) + .ok_or_else(|| { + HwpError::RenderError(format!("바이너리 데이터 {} 범위 초과", bin_data_id)) + })?; Ok(bdc.data.clone()) } @@ -1144,29 +1335,46 @@ impl DocumentCore { para_idx: usize, control_idx: usize, ) -> Result { - let section = self.document.sections.get(section_idx) + let section = self + .document + .sections + .get(section_idx) .ok_or_else(|| HwpError::RenderError(format!("구역 {} 범위 초과", section_idx)))?; - let para = section.paragraphs.get(para_idx) + let para = section + .paragraphs + .get(para_idx) .ok_or_else(|| HwpError::RenderError(format!("문단 {} 범위 초과", para_idx)))?; - let control = para.controls.get(control_idx) + let control = para + .controls + .get(control_idx) .ok_or_else(|| HwpError::RenderError(format!("컨트롤 {} 범위 초과", control_idx)))?; let pic = match control { Control::Picture(p) => p, - _ => return Err(HwpError::RenderError("Picture 컨트롤이 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "Picture 컨트롤이 아닙니다".to_string(), + )) + } }; let bin_data_id = pic.image_attr.bin_data_id; if bin_data_id == 0 { - return Err(HwpError::RenderError("이미지 데이터 없음 (bin_data_id=0)".to_string())); + return Err(HwpError::RenderError( + "이미지 데이터 없음 (bin_data_id=0)".to_string(), + )); } - let bdc = self.document.bin_data_content.get((bin_data_id - 1) as usize) - .ok_or_else(|| HwpError::RenderError(format!("바이너리 데이터 {} 범위 초과", bin_data_id)))?; + let bdc = self + .document + .bin_data_content + .get((bin_data_id - 1) as usize) + .ok_or_else(|| { + HwpError::RenderError(format!("바이너리 데이터 {} 범위 초과", bin_data_id)) + })?; Ok(detect_clipboard_image_mime(&bdc.data).to_string()) } // === 클립보드 HTML 붙여넣기 === - } diff --git a/src/document_core/commands/document.rs b/src/document_core/commands/document.rs index e3579914..71d19589 100644 --- a/src/document_core/commands/document.rs +++ b/src/document_core/commands/document.rs @@ -1,15 +1,15 @@ //! 문서 생성/로딩/저장/설정 관련 native 메서드 -use std::cell::RefCell; -use std::collections::HashMap; +use crate::document_core::{DocumentCore, DEFAULT_FALLBACK_FONT}; +use crate::error::HwpError; use crate::model::document::Document; -use crate::renderer::style_resolver::{resolve_styles, ResolvedStyleSet}; use crate::renderer::composer::{compose_section, reflow_line_segs}; use crate::renderer::layout::LayoutEngine; use crate::renderer::page_layout::PageLayoutInfo; +use crate::renderer::style_resolver::{resolve_styles, ResolvedStyleSet}; use crate::renderer::DEFAULT_DPI; -use crate::document_core::{DocumentCore, DEFAULT_FALLBACK_FONT}; -use crate::error::HwpError; +use std::cell::RefCell; +use std::collections::HashMap; impl DocumentCore { pub fn from_bytes(data: &[u8]) -> Result { @@ -75,18 +75,16 @@ impl DocumentCore { /// 설정되어 줄바꿈·문단 높이 계산이 불가능하다. 이 함수는 문서 로드 직후 /// CharPr/ParaPr 기반으로 올바른 line_height/line_spacing을 계산한다. /// 본문 문단뿐 아니라 표 셀 내부 문단도 처리한다. - fn reflow_zero_height_paragraphs( - document: &mut Document, - styles: &ResolvedStyleSet, - dpi: f64, - ) { + fn reflow_zero_height_paragraphs(document: &mut Document, styles: &ResolvedStyleSet, dpi: f64) { use crate::model::control::Control; for section in &mut document.sections { let page_def = §ion.section_def.page_def; let column_def = Self::find_initial_column_def(§ion.paragraphs); let layout = PageLayoutInfo::from_page_def(page_def, &column_def, dpi); - let col_width = layout.column_areas.first() + let col_width = layout + .column_areas + .first() .map(|a| a.width) .unwrap_or(layout.body_area.width); @@ -107,7 +105,10 @@ impl DocumentCore { let mut max_tac_h: i32 = 0; for ctrl in para.controls.iter() { if let Control::Table(t) = ctrl { - if t.common.treat_as_char && t.raw_ctrl_data.is_empty() && t.common.height > 0 { + if t.common.treat_as_char + && t.raw_ctrl_data.is_empty() + && t.common.height > 0 + { max_tac_h = max_tac_h.max(t.common.height as i32); } } @@ -145,28 +146,44 @@ impl DocumentCore { for para in section.paragraphs.iter() { for ctrl in ¶.controls { match ctrl { - Control::Table(t) if t.common.treat_as_char && t.raw_ctrl_data.is_empty() && t.common.height > 0 => { + Control::Table(t) + if t.common.treat_as_char + && t.raw_ctrl_data.is_empty() + && t.common.height > 0 => + { need_vpos_recalc = true; break; } // 비-TAC TopAndBottom Picture/Table: LINE_SEG에 개체 높이 미포함 - Control::Picture(p) if !p.common.treat_as_char - && matches!(p.common.text_wrap, crate::model::shape::TextWrap::TopAndBottom) - && p.common.height > 0 => { + Control::Picture(p) + if !p.common.treat_as_char + && matches!( + p.common.text_wrap, + crate::model::shape::TextWrap::TopAndBottom + ) + && p.common.height > 0 => + { need_vpos_recalc = true; break; } - Control::Table(t) if !t.common.treat_as_char - && matches!(t.common.text_wrap, crate::model::shape::TextWrap::TopAndBottom) - && t.common.height > 0 - && t.raw_ctrl_data.is_empty() => { + Control::Table(t) + if !t.common.treat_as_char + && matches!( + t.common.text_wrap, + crate::model::shape::TextWrap::TopAndBottom + ) + && t.common.height > 0 + && t.raw_ctrl_data.is_empty() => + { need_vpos_recalc = true; break; } _ => {} } } - if need_vpos_recalc { break; } + if need_vpos_recalc { + break; + } } if need_vpos_recalc { let mut running_vpos: i32 = 0; @@ -190,21 +207,46 @@ impl DocumentCore { } // 비-TAC TopAndBottom Picture/Table: 개체 높이를 vpos에 반영 for ctrl in para.controls.iter() { - let (obj_height, obj_v_offset, obj_margin_top, obj_margin_bottom) = match ctrl { - Control::Picture(p) if !p.common.treat_as_char - && matches!(p.common.text_wrap, crate::model::shape::TextWrap::TopAndBottom) - && p.common.height > 0 => - (p.common.height as i32, p.common.vertical_offset as i32, 0, 0), - Control::Table(t) if !t.common.treat_as_char - && matches!(t.common.text_wrap, crate::model::shape::TextWrap::TopAndBottom) - && t.common.height > 0 - && t.raw_ctrl_data.is_empty() => - (t.common.height as i32, t.common.vertical_offset as i32, - t.outer_margin_top as i32, t.outer_margin_bottom as i32), - _ => continue, - }; - let obj_total = obj_height + obj_v_offset + obj_margin_top + obj_margin_bottom; - let seg_lh_total: i32 = para.line_segs.iter() + let (obj_height, obj_v_offset, obj_margin_top, obj_margin_bottom) = + match ctrl { + Control::Picture(p) + if !p.common.treat_as_char + && matches!( + p.common.text_wrap, + crate::model::shape::TextWrap::TopAndBottom + ) + && p.common.height > 0 => + { + ( + p.common.height as i32, + p.common.vertical_offset as i32, + 0, + 0, + ) + } + Control::Table(t) + if !t.common.treat_as_char + && matches!( + t.common.text_wrap, + crate::model::shape::TextWrap::TopAndBottom + ) + && t.common.height > 0 + && t.raw_ctrl_data.is_empty() => + { + ( + t.common.height as i32, + t.common.vertical_offset as i32, + t.outer_margin_top as i32, + t.outer_margin_bottom as i32, + ) + } + _ => continue, + }; + let obj_total = + obj_height + obj_v_offset + obj_margin_top + obj_margin_bottom; + let seg_lh_total: i32 = para + .line_segs + .iter() .map(|s| s.line_height + s.line_spacing) .sum(); if obj_total > seg_lh_total { @@ -231,7 +273,11 @@ impl DocumentCore { .map_err(|e| HwpError::InvalidFile(e.to_string()))?; let styles = resolve_styles(&document.doc_info, self.dpi); - let composed = document.sections.iter().map(|s| compose_section(s)).collect(); + let composed = document + .sections + .iter() + .map(|s| compose_section(s)) + .collect(); let sec_count = document.sections.len(); self.document = document; @@ -274,7 +320,10 @@ impl DocumentCore { pub fn set_document(&mut self, doc: Document) { self.document = doc; self.styles = resolve_styles(&self.document.doc_info, self.dpi); - self.composed = self.document.sections.iter() + self.composed = self + .document + .sections + .iter() .map(|s| compose_section(s)) .collect(); self.mark_all_sections_dirty(); @@ -317,13 +366,19 @@ impl DocumentCore { /// 지정 ID의 스냅샷으로 Document를 복원한다. /// 스타일 재해소 + 문단 구성 + 페이지네이션까지 수행. pub fn restore_snapshot_native(&mut self, id: u32) -> Result { - let idx = self.snapshot_store.iter().position(|(sid, _)| *sid == id) + let idx = self + .snapshot_store + .iter() + .position(|(sid, _)| *sid == id) .ok_or_else(|| HwpError::RenderError(format!("스냅샷 {} 없음", id)))?; let (_, doc) = self.snapshot_store[idx].clone(); self.document = doc; // 캐시 전체 재구성 self.styles = resolve_styles(&self.document.doc_info, self.dpi); - self.composed = self.document.sections.iter() + self.composed = self + .document + .sections + .iter() .map(|s| compose_section(s)) .collect(); self.mark_all_sections_dirty(); @@ -350,11 +405,17 @@ impl DocumentCore { use crate::renderer::composer::estimate_composed_line_width; use crate::renderer::hwpunit_to_px; - let section = self.document.sections.get(section_idx) - .ok_or_else(|| HwpError::InvalidFile(format!("section {} not found", section_idx)))?; - let para = section.paragraphs.get(para_idx) + let section = + self.document.sections.get(section_idx).ok_or_else(|| { + HwpError::InvalidFile(format!("section {} not found", section_idx)) + })?; + let para = section + .paragraphs + .get(para_idx) .ok_or_else(|| HwpError::InvalidFile(format!("para {} not found", para_idx)))?; - let composed = self.composed.get(section_idx) + let composed = self + .composed + .get(section_idx) .and_then(|s| s.get(para_idx)) .ok_or_else(|| HwpError::InvalidFile("composed paragraph not found".into()))?; @@ -375,7 +436,9 @@ impl DocumentCore { let mut runs_json = Vec::new(); for run in &composed_line.runs { let ts = crate::renderer::layout::resolved_to_text_style( - &self.styles, run.char_style_id, run.lang_index, + &self.styles, + run.char_style_id, + run.lang_index, ); let run_width = crate::renderer::layout::estimate_text_width(&run.text, &ts); runs_json.push(format!( @@ -387,9 +450,7 @@ impl DocumentCore { )); } - let line_text: String = composed_line.runs.iter() - .map(|r| r.text.as_str()) - .collect(); + let line_text: String = composed_line.runs.iter().map(|r| r.text.as_str()).collect(); lines_json.push(format!( r#"{{"line_index":{},"text":"{}","runs":[{}],"our_width_px":{:.2},"stored_segment_width_hwpunit":{},"stored_width_px":{:.2},"error_px":{:.2},"error_hwpunit":{}}}"#, @@ -426,15 +487,22 @@ impl DocumentCore { // 삭제 대상 field_range 인덱스와 삭제할 문자 범위 수집 let mut removals: Vec<(usize, usize, usize)> = Vec::new(); // (fr_idx, start, end) for (fri, fr) in para.field_ranges.iter().enumerate() { - if fr.start_char_idx >= fr.end_char_idx { continue; } + if fr.start_char_idx >= fr.end_char_idx { + continue; + } if let Some(Control::Field(f)) = para.controls.get(fr.control_idx) { - if f.field_type != FieldType::ClickHere { continue; } - if f.properties & (1 << 15) != 0 { continue; } // 이미 수정된 상태 - // 필드 값이 안내문과 동일한지 확인 + if f.field_type != FieldType::ClickHere { + continue; + } + if f.properties & (1 << 15) != 0 { + continue; + } // 이미 수정된 상태 + // 필드 값이 안내문과 동일한지 확인 if let Some(guide) = f.guide_text() { let chars: Vec = para.text.chars().collect(); if fr.end_char_idx <= chars.len() { - let field_val: String = chars[fr.start_char_idx..fr.end_char_idx].iter().collect(); + let field_val: String = + chars[fr.start_char_idx..fr.end_char_idx].iter().collect(); // trailing 공백 제거 후 비교 (한컴이 안내문 뒤에 공백을 추가하는 경우) if field_val.trim_end() == guide || field_val == guide { removals.push((fri, fr.start_char_idx, fr.end_char_idx)); @@ -452,7 +520,9 @@ impl DocumentCore { para.field_ranges[fri].end_char_idx = start; // 이후 field_ranges의 char_idx 조정 for i in 0..para.field_ranges.len() { - if i == fri { continue; } + if i == fri { + continue; + } let other = &mut para.field_ranges[i]; if other.start_char_idx >= end { other.start_char_idx -= removed_len; diff --git a/src/document_core/commands/footnote_ops.rs b/src/document_core/commands/footnote_ops.rs index 4eff892b..1fdbf913 100644 --- a/src/document_core/commands/footnote_ops.rs +++ b/src/document_core/commands/footnote_ops.rs @@ -1,11 +1,11 @@ //! 각주 내용 편집 관련 native 메서드 -use crate::model::control::Control; -use crate::model::paragraph::Paragraph; -use crate::renderer::composer::reflow_line_segs; use crate::document_core::DocumentCore; use crate::error::HwpError; +use crate::model::control::Control; use crate::model::event::DocumentEvent; +use crate::model::paragraph::Paragraph; +use crate::renderer::composer::reflow_line_segs; impl DocumentCore { /// 각주 컨트롤 내부 문단의 가변 참조를 얻는다. @@ -16,30 +16,30 @@ impl DocumentCore { control_idx: usize, fn_para_idx: usize, ) -> Result<&mut Paragraph, HwpError> { - let section = self.document.sections.get_mut(section_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과", section_idx - )))?; - let para = section.paragraphs.get_mut(para_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "문단 인덱스 {} 범위 초과", para_idx - )))?; - let ctrl = para.controls.get_mut(control_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "컨트롤 인덱스 {} 범위 초과", control_idx - )))?; + let section = self.document.sections.get_mut(section_idx).ok_or_else(|| { + HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)) + })?; + let para = section + .paragraphs + .get_mut(para_idx) + .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", para_idx)))?; + let ctrl = para.controls.get_mut(control_idx).ok_or_else(|| { + HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)) + })?; match ctrl { Control::Footnote(f) => { let len = f.paragraphs.len(); if fn_para_idx >= len { return Err(HwpError::RenderError(format!( - "각주 문단 인덱스 {} 범위 초과 (총 {}개)", fn_para_idx, len + "각주 문단 인덱스 {} 범위 초과 (총 {}개)", + fn_para_idx, len ))); } Ok(&mut f.paragraphs[fn_para_idx]) } _ => Err(HwpError::RenderError(format!( - "컨트롤 {}은 각주가 아닙니다", control_idx + "컨트롤 {}은 각주가 아닙니다", + control_idx ))), } } @@ -75,14 +75,18 @@ impl DocumentCore { let available_width = { let section = &self.document.sections[section_idx]; let page_def = §ion.section_def.page_def; - let text_width = page_def.width as i32 - - page_def.margin_left as i32 - - page_def.margin_right as i32; + let text_width = + page_def.width as i32 - page_def.margin_left as i32 - page_def.margin_right as i32; hwpunit_to_px(text_width, self.dpi) }; // 문단 여백 적용 - let para_shape_id = match self.get_footnote_paragraph_ref(section_idx, para_idx, control_idx, fn_para_idx) { + let para_shape_id = match self.get_footnote_paragraph_ref( + section_idx, + para_idx, + control_idx, + fn_para_idx, + ) { Some(p) => p.para_shape_id, None => return, }; @@ -109,22 +113,22 @@ impl DocumentCore { para_idx: usize, control_idx: usize, ) -> Result { - let section = self.document.sections.get(section_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과", section_idx - )))?; - let para = section.paragraphs.get(para_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "문단 인덱스 {} 범위 초과", para_idx - )))?; - let ctrl = para.controls.get(control_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "컨트롤 인덱스 {} 범위 초과", control_idx - )))?; + let section = self.document.sections.get(section_idx).ok_or_else(|| { + HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)) + })?; + let para = section + .paragraphs + .get(para_idx) + .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", para_idx)))?; + let ctrl = para.controls.get(control_idx).ok_or_else(|| { + HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)) + })?; match ctrl { Control::Footnote(f) => { let para_count = f.paragraphs.len(); - let texts: Vec = f.paragraphs.iter() + let texts: Vec = f + .paragraphs + .iter() .map(|p| p.text.replace('\\', "\\\\").replace('"', "\\\"")) .collect(); let total_len: usize = f.paragraphs.iter().map(|p| p.text.chars().count()).sum(); @@ -137,7 +141,8 @@ impl DocumentCore { )) } _ => Err(HwpError::RenderError(format!( - "컨트롤 {}은 각주가 아닙니다", control_idx + "컨트롤 {}은 각주가 아닙니다", + control_idx ))), } } @@ -153,7 +158,8 @@ impl DocumentCore { text: &str, ) -> Result { let new_chars_count = text.chars().count(); - let fn_para = self.get_footnote_paragraph_mut(section_idx, para_idx, control_idx, fn_para_idx)?; + let fn_para = + self.get_footnote_paragraph_mut(section_idx, para_idx, control_idx, fn_para_idx)?; fn_para.insert_text_at(char_offset, text); self.reflow_footnote_paragraph(section_idx, para_idx, control_idx, fn_para_idx); @@ -164,9 +170,15 @@ impl DocumentCore { let new_offset = char_offset + new_chars_count; self.event_log.push(DocumentEvent::TextInserted { - section: section_idx, para: para_idx, offset: char_offset, len: new_chars_count, + section: section_idx, + para: para_idx, + offset: char_offset, + len: new_chars_count, }); - Ok(super::super::helpers::json_ok_with(&format!("\"charOffset\":{}", new_offset))) + Ok(super::super::helpers::json_ok_with(&format!( + "\"charOffset\":{}", + new_offset + ))) } /// 각주 내 텍스트 삭제 @@ -179,7 +191,8 @@ impl DocumentCore { char_offset: usize, count: usize, ) -> Result { - let fn_para = self.get_footnote_paragraph_mut(section_idx, para_idx, control_idx, fn_para_idx)?; + let fn_para = + self.get_footnote_paragraph_mut(section_idx, para_idx, control_idx, fn_para_idx)?; fn_para.delete_text_at(char_offset, count); self.reflow_footnote_paragraph(section_idx, para_idx, control_idx, fn_para_idx); @@ -189,9 +202,15 @@ impl DocumentCore { self.paginate_if_needed(); self.event_log.push(DocumentEvent::TextDeleted { - section: section_idx, para: para_idx, offset: char_offset, count, + section: section_idx, + para: para_idx, + offset: char_offset, + count, }); - Ok(super::super::helpers::json_ok_with(&format!("\"charOffset\":{}", char_offset))) + Ok(super::super::helpers::json_ok_with(&format!( + "\"charOffset\":{}", + char_offset + ))) } /// 각주 내 문단 분할 (Enter 키) @@ -205,29 +224,38 @@ impl DocumentCore { ) -> Result { // 문단 분할 let new_para = { - let section = self.document.sections.get_mut(section_idx) - .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))?; - let para = section.paragraphs.get_mut(para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", para_idx)))?; - let ctrl = para.controls.get_mut(control_idx) - .ok_or_else(|| HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)))?; + let section = self.document.sections.get_mut(section_idx).ok_or_else(|| { + HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)) + })?; + let para = section.paragraphs.get_mut(para_idx).ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", para_idx)) + })?; + let ctrl = para.controls.get_mut(control_idx).ok_or_else(|| { + HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)) + })?; match ctrl { Control::Footnote(f) => { if fn_para_idx >= f.paragraphs.len() { return Err(HwpError::RenderError(format!( - "각주 문단 인덱스 {} 범위 초과", fn_para_idx + "각주 문단 인덱스 {} 범위 초과", + fn_para_idx ))); } f.paragraphs[fn_para_idx].split_at(char_offset) } - _ => return Err(HwpError::RenderError("컨트롤이 각주가 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "컨트롤이 각주가 아닙니다".to_string(), + )) + } } }; // 새 문단 삽입 let new_para_idx = fn_para_idx + 1; { - let ctrl = &mut self.document.sections[section_idx].paragraphs[para_idx].controls[control_idx]; + let ctrl = + &mut self.document.sections[section_idx].paragraphs[para_idx].controls[control_idx]; if let Control::Footnote(f) = ctrl { f.paragraphs.insert(new_para_idx, new_para); } @@ -242,11 +270,14 @@ impl DocumentCore { self.paginate_if_needed(); self.event_log.push(DocumentEvent::ParagraphSplit { - section: section_idx, para: para_idx, offset: char_offset, + section: section_idx, + para: para_idx, + offset: char_offset, }); - Ok(super::super::helpers::json_ok_with( - &format!("\"fnParaIndex\":{},\"charOffset\":0", new_para_idx) - )) + Ok(super::super::helpers::json_ok_with(&format!( + "\"fnParaIndex\":{},\"charOffset\":0", + new_para_idx + ))) } /// 각주 내 문단 병합 (Backspace at start) @@ -258,29 +289,39 @@ impl DocumentCore { fn_para_idx: usize, ) -> Result { if fn_para_idx == 0 { - return Err(HwpError::RenderError("첫 번째 문단은 이전 문단과 병합할 수 없습니다".to_string())); + return Err(HwpError::RenderError( + "첫 번째 문단은 이전 문단과 병합할 수 없습니다".to_string(), + )); } let merge_offset; { - let section = self.document.sections.get_mut(section_idx) - .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))?; - let para = section.paragraphs.get_mut(para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", para_idx)))?; - let ctrl = para.controls.get_mut(control_idx) - .ok_or_else(|| HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)))?; + let section = self.document.sections.get_mut(section_idx).ok_or_else(|| { + HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)) + })?; + let para = section.paragraphs.get_mut(para_idx).ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", para_idx)) + })?; + let ctrl = para.controls.get_mut(control_idx).ok_or_else(|| { + HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)) + })?; match ctrl { Control::Footnote(f) => { if fn_para_idx >= f.paragraphs.len() { return Err(HwpError::RenderError(format!( - "각주 문단 인덱스 {} 범위 초과", fn_para_idx + "각주 문단 인덱스 {} 범위 초과", + fn_para_idx ))); } merge_offset = f.paragraphs[fn_para_idx - 1].text.chars().count(); let removed = f.paragraphs.remove(fn_para_idx); f.paragraphs[fn_para_idx - 1].merge_from(&removed); } - _ => return Err(HwpError::RenderError("컨트롤이 각주가 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "컨트롤이 각주가 아닙니다".to_string(), + )) + } } } @@ -292,10 +333,12 @@ impl DocumentCore { self.paginate_if_needed(); self.event_log.push(DocumentEvent::ParagraphMerged { - section: section_idx, para: para_idx, + section: section_idx, + para: para_idx, }); - Ok(super::super::helpers::json_ok_with( - &format!("\"fnParaIndex\":{},\"charOffset\":{}", prev_idx, merge_offset) - )) + Ok(super::super::helpers::json_ok_with(&format!( + "\"fnParaIndex\":{},\"charOffset\":{}", + prev_idx, merge_offset + ))) } } diff --git a/src/document_core/commands/formatting.rs b/src/document_core/commands/formatting.rs index 15843997..b72a0c64 100644 --- a/src/document_core/commands/formatting.rs +++ b/src/document_core/commands/formatting.rs @@ -1,18 +1,17 @@ //! 글자모양/문단모양 조회·적용 관련 native 메서드 -use crate::model::control::Control; -use crate::model::paragraph::Paragraph; +use super::super::helpers::{ + border_line_type_to_u8_val, build_tab_def_from_json, color_ref_to_css, json_has_border_keys, + json_has_tab_keys, parse_char_shape_mods, parse_json_i16_array, parse_para_shape_mods, +}; use crate::document_core::DocumentCore; use crate::error::HwpError; +use crate::model::control::Control; use crate::model::event::DocumentEvent; -use super::super::helpers::{ - color_ref_to_css, parse_char_shape_mods, parse_para_shape_mods, - json_has_border_keys, json_has_tab_keys, build_tab_def_from_json, - parse_json_i16_array, border_line_type_to_u8_val, -}; -use crate::renderer::style_resolver::resolve_styles; +use crate::model::paragraph::Paragraph; use crate::renderer::composer::reflow_line_segs; use crate::renderer::page_layout::PageLayoutInfo; +use crate::renderer::style_resolver::resolve_styles; impl DocumentCore { pub fn get_char_properties_at_native( @@ -21,9 +20,14 @@ impl DocumentCore { para_idx: usize, char_offset: usize, ) -> Result { - let section = self.document.sections.get(sec_idx) + let section = self + .document + .sections + .get(sec_idx) .ok_or_else(|| HwpError::RenderError(format!("구역 {} 범위 초과", sec_idx)))?; - let para = section.paragraphs.get(para_idx) + let para = section + .paragraphs + .get(para_idx) .ok_or_else(|| HwpError::RenderError(format!("문단 {} 범위 초과", para_idx)))?; Ok(self.build_char_properties_json(para, char_offset)) } @@ -38,7 +42,14 @@ impl DocumentCore { cell_para_idx: usize, char_offset: usize, ) -> Result { - let para = self.get_cell_paragraph_ref(sec_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx) + let para = self + .get_cell_paragraph_ref( + sec_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ) .ok_or_else(|| HwpError::RenderError("셀 문단을 찾을 수 없음".to_string()))?; Ok(self.build_char_properties_json(para, char_offset)) } @@ -51,9 +62,14 @@ impl DocumentCore { ) -> Result { use crate::model::control::Control; use crate::model::style::HeadType; - let section = self.document.sections.get(sec_idx) + let section = self + .document + .sections + .get(sec_idx) .ok_or_else(|| HwpError::RenderError(format!("구역 {} 범위 초과", sec_idx)))?; - let para = section.paragraphs.get(para_idx) + let para = section + .paragraphs + .get(para_idx) .ok_or_else(|| HwpError::RenderError(format!("문단 {} 범위 초과", para_idx)))?; let mut json = self.build_para_properties_json(para.para_shape_id, sec_idx); @@ -64,7 +80,11 @@ impl DocumentCore { let cur_nid = ps.map(|s| s.numbering_id).unwrap_or(0); // NewNumber 컨트롤 체크 let new_number = para.controls.iter().find_map(|c| { - if let Control::NewNumber(nn) = c { Some(nn.number) } else { None } + if let Control::NewNumber(nn) = c { + Some(nn.number) + } else { + None + } }); let (mode, start_num) = if let Some(num) = new_number { (2, num as u32) // 새 번호 목록 시작 (NewNumber 컨트롤) @@ -76,7 +96,9 @@ impl DocumentCore { let pp = §ion.paragraphs[pi]; let pps = self.styles.para_styles.get(pp.para_shape_id as usize); let pht = pps.map(|s| s.head_type).unwrap_or(HeadType::None); - if pht == HeadType::None { continue; } + if pht == HeadType::None { + continue; + } let pnid = pps.map(|s| s.numbering_id).unwrap_or(0); if prev_nid.is_none() { prev_nid = Some(pnid); @@ -93,7 +115,10 @@ impl DocumentCore { } }; json.pop(); // 마지막 '}' 제거 - json.push_str(&format!(",\"numberingRestartMode\":{},\"numberingStartNum\":{}}}", mode, start_num)); + json.push_str(&format!( + ",\"numberingRestartMode\":{},\"numberingStartNum\":{}}}", + mode, start_num + )); } Ok(json) @@ -108,13 +133,24 @@ impl DocumentCore { cell_idx: usize, cell_para_idx: usize, ) -> Result { - let para = self.get_cell_paragraph_ref(sec_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx) + let para = self + .get_cell_paragraph_ref( + sec_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ) .ok_or_else(|| HwpError::RenderError("셀 문단을 찾을 수 없음".to_string()))?; Ok(self.build_para_properties_json(para.para_shape_id, sec_idx)) } /// 글자 속성 JSON 생성 헬퍼 - pub(crate) fn build_char_properties_json(&self, para: &crate::model::paragraph::Paragraph, char_offset: usize) -> String { + pub(crate) fn build_char_properties_json( + &self, + para: &crate::model::paragraph::Paragraph, + char_offset: usize, + ) -> String { let char_shape_id = para.char_shape_id_at(char_offset).unwrap_or(0); let style = self.styles.char_styles.get(char_shape_id as usize); @@ -124,12 +160,15 @@ impl DocumentCore { use crate::renderer::style_resolver::detect_lang_category; // 캐럿 위치 문자의 언어 카테고리를 판별하여 해당 폰트 반환 - let lang_index = para.text.chars() + let lang_index = para + .text + .chars() .nth(char_offset) .map(|ch| detect_lang_category(ch)) .unwrap_or(0); let font_family_raw = cs.font_family_for_lang(lang_index); - let font_family = crate::renderer::style_resolver::primary_font_name(&font_family_raw); + let font_family = + crate::renderer::style_resolver::primary_font_name(&font_family_raw); let escaped_font = super::super::helpers::json_escape(font_family); let underline = !matches!(cs.underline, UnderlineType::None); @@ -140,35 +179,104 @@ impl DocumentCore { }; // raw CharShape에서 추가 속성 읽기 - let raw_cs = self.document.doc_info.char_shapes.get(char_shape_id as usize); + let raw_cs = self + .document + .doc_info + .char_shapes + .get(char_shape_id as usize); let base_size = raw_cs.map(|s| s.base_size).unwrap_or(1000); // 언어별 글꼴 이름 배열 (원본 폰트명만, 폴백 제외) - let font_families: Vec = (0..7usize).map(|i| { - let name = cs.font_family_for_lang(i); - let primary = crate::renderer::style_resolver::primary_font_name(&name); - super::super::helpers::json_escape(primary) - }).collect(); - let font_families_json = format!("[{}]", - font_families.iter().map(|f| format!("\"{}\"", f)).collect::>().join(",")); + let font_families: Vec = (0..7usize) + .map(|i| { + let name = cs.font_family_for_lang(i); + let primary = crate::renderer::style_resolver::primary_font_name(&name); + super::super::helpers::json_escape(primary) + }) + .collect(); + let font_families_json = format!( + "[{}]", + font_families + .iter() + .map(|f| format!("\"{}\"", f)) + .collect::>() + .join(",") + ); // 언어별 수치 배열 let (ratios, spacings, relative_sizes, char_offsets) = match raw_cs { Some(s) => (s.ratios, s.spacings, s.relative_sizes, s.char_offsets), None => ([100u8; 7], [0i8; 7], [100u8; 7], [0i8; 7]), }; - let ratios_json = format!("[{}]", ratios.iter().map(|v| v.to_string()).collect::>().join(",")); - let spacings_json = format!("[{}]", spacings.iter().map(|v| v.to_string()).collect::>().join(",")); - let relative_sizes_json = format!("[{}]", relative_sizes.iter().map(|v| v.to_string()).collect::>().join(",")); - let char_offsets_json = format!("[{}]", char_offsets.iter().map(|v| v.to_string()).collect::>().join(",")); - - let (shadow_type, shadow_color, shadow_offset_x, shadow_offset_y, - outline_type, subscript, superscript, shade_color, - emboss, engrave, emphasis_dot, underline_shape, strike_shape, kerning) = match raw_cs { - Some(s) => (s.shadow_type, s.shadow_color, s.shadow_offset_x, s.shadow_offset_y, - s.outline_type, s.subscript, s.superscript, s.shade_color, - s.emboss, s.engrave, s.emphasis_dot, s.underline_shape, s.strike_shape, s.kerning), - None => (0, 0xB2B2B2, 0i8, 0i8, 0, false, false, 0xFFFFFF, false, false, 0, 0, 0, false), + let ratios_json = format!( + "[{}]", + ratios + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(",") + ); + let spacings_json = format!( + "[{}]", + spacings + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(",") + ); + let relative_sizes_json = format!( + "[{}]", + relative_sizes + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(",") + ); + let char_offsets_json = format!( + "[{}]", + char_offsets + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(",") + ); + + let ( + shadow_type, + shadow_color, + shadow_offset_x, + shadow_offset_y, + outline_type, + subscript, + superscript, + shade_color, + emboss, + engrave, + emphasis_dot, + underline_shape, + strike_shape, + kerning, + ) = match raw_cs { + Some(s) => ( + s.shadow_type, + s.shadow_color, + s.shadow_offset_x, + s.shadow_offset_y, + s.outline_type, + s.subscript, + s.superscript, + s.shade_color, + s.emboss, + s.engrave, + s.emphasis_dot, + s.underline_shape, + s.strike_shape, + s.kerning, + ), + None => ( + 0, 0xB2B2B2, 0i8, 0i8, 0, false, false, 0xFFFFFF, false, false, 0, 0, 0, + false, + ), }; // 글자 테두리/배경 정보 @@ -244,7 +352,8 @@ impl DocumentCore { use crate::model::style::UnderlineType; // 한글(0) 언어를 기본으로 사용 let font_family_raw = cs.font_family_for_lang(0); - let font_family = crate::renderer::style_resolver::primary_font_name(&font_family_raw); + let font_family = + crate::renderer::style_resolver::primary_font_name(&font_family_raw); let escaped_font = super::super::helpers::json_escape(font_family); let underline = !matches!(cs.underline, UnderlineType::None); let underline_type_str = match cs.underline { @@ -252,30 +361,99 @@ impl DocumentCore { UnderlineType::Bottom => "Bottom", UnderlineType::Top => "Top", }; - let raw_cs = self.document.doc_info.char_shapes.get(char_shape_id as usize); + let raw_cs = self + .document + .doc_info + .char_shapes + .get(char_shape_id as usize); let base_size = raw_cs.map(|s| s.base_size).unwrap_or(1000); - let font_families: Vec = (0..7usize).map(|i| { - let name = cs.font_family_for_lang(i); - let primary = crate::renderer::style_resolver::primary_font_name(&name); - super::super::helpers::json_escape(primary) - }).collect(); - let font_families_json = format!("[{}]", - font_families.iter().map(|f| format!("\"{}\"", f)).collect::>().join(",")); + let font_families: Vec = (0..7usize) + .map(|i| { + let name = cs.font_family_for_lang(i); + let primary = crate::renderer::style_resolver::primary_font_name(&name); + super::super::helpers::json_escape(primary) + }) + .collect(); + let font_families_json = format!( + "[{}]", + font_families + .iter() + .map(|f| format!("\"{}\"", f)) + .collect::>() + .join(",") + ); let (ratios, spacings, relative_sizes, char_offsets) = match raw_cs { Some(s) => (s.ratios, s.spacings, s.relative_sizes, s.char_offsets), None => ([100u8; 7], [0i8; 7], [100u8; 7], [0i8; 7]), }; - let ratios_json = format!("[{}]", ratios.iter().map(|v| v.to_string()).collect::>().join(",")); - let spacings_json = format!("[{}]", spacings.iter().map(|v| v.to_string()).collect::>().join(",")); - let relative_sizes_json = format!("[{}]", relative_sizes.iter().map(|v| v.to_string()).collect::>().join(",")); - let char_offsets_json = format!("[{}]", char_offsets.iter().map(|v| v.to_string()).collect::>().join(",")); - let (shadow_type, shadow_color, shadow_offset_x, shadow_offset_y, - outline_type, subscript, superscript, shade_color, - emboss, engrave, emphasis_dot, underline_shape, strike_shape, kerning) = match raw_cs { - Some(s) => (s.shadow_type, s.shadow_color, s.shadow_offset_x, s.shadow_offset_y, - s.outline_type, s.subscript, s.superscript, s.shade_color, - s.emboss, s.engrave, s.emphasis_dot, s.underline_shape, s.strike_shape, s.kerning), - None => (0, 0xB2B2B2, 0i8, 0i8, 0, false, false, 0xFFFFFF, false, false, 0, 0, 0, false), + let ratios_json = format!( + "[{}]", + ratios + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(",") + ); + let spacings_json = format!( + "[{}]", + spacings + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(",") + ); + let relative_sizes_json = format!( + "[{}]", + relative_sizes + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(",") + ); + let char_offsets_json = format!( + "[{}]", + char_offsets + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(",") + ); + let ( + shadow_type, + shadow_color, + shadow_offset_x, + shadow_offset_y, + outline_type, + subscript, + superscript, + shade_color, + emboss, + engrave, + emphasis_dot, + underline_shape, + strike_shape, + kerning, + ) = match raw_cs { + Some(s) => ( + s.shadow_type, + s.shadow_color, + s.shadow_offset_x, + s.shadow_offset_y, + s.outline_type, + s.subscript, + s.superscript, + s.shade_color, + s.emboss, + s.engrave, + s.emphasis_dot, + s.underline_shape, + s.strike_shape, + s.kerning, + ), + None => ( + 0, 0xB2B2B2, 0i8, 0i8, 0, false, false, 0xFFFFFF, false, false, 0, 0, 0, + false, + ), }; let border_fill_json = self.build_char_border_fill_json(raw_cs); format!( @@ -341,7 +519,10 @@ impl DocumentCore { } /// 글자 테두리/배경 JSON 헬퍼 — CharShape의 border_fill_id를 참조하여 BorderFill 정보를 JSON 문자열로 반환 - pub(crate) fn build_char_border_fill_json(&self, raw_cs: Option<&crate::model::style::CharShape>) -> String { + pub(crate) fn build_char_border_fill_json( + &self, + raw_cs: Option<&crate::model::style::CharShape>, + ) -> String { let bf_id = raw_cs.map(|s| s.border_fill_id).unwrap_or(0); if bf_id == 0 { return concat!( @@ -353,7 +534,11 @@ impl DocumentCore { "\"fillType\":\"none\",\"fillColor\":\"#ffffff\",\"patternColor\":\"#000000\",\"patternType\":0" ).to_string(); } - let bf = self.document.doc_info.border_fills.get((bf_id - 1) as usize); + let bf = self + .document + .doc_info + .border_fills + .get((bf_id - 1) as usize); match bf { Some(bf) => { use crate::model::style::FillType; @@ -396,21 +581,37 @@ impl DocumentCore { /// 문단 속성 JSON 생성 헬퍼 pub(crate) fn build_para_properties_json(&self, para_shape_id: u16, sec_idx: usize) -> String { - use crate::model::style::{Alignment, HeadType, FillType}; + use crate::model::style::{Alignment, FillType, HeadType}; let ps = self.styles.para_styles.get(para_shape_id as usize); // 탭 정의 조회 - let raw_ps = self.document.doc_info.para_shapes.get(para_shape_id as usize); + let raw_ps = self + .document + .doc_info + .para_shapes + .get(para_shape_id as usize); let tab_def_id = raw_ps.map(|p| p.tab_def_id).unwrap_or(0); let tab_def = self.document.doc_info.tab_defs.get(tab_def_id as usize); let tab_auto_left = tab_def.map(|td| td.auto_tab_left).unwrap_or(false); let tab_auto_right = tab_def.map(|td| td.auto_tab_right).unwrap_or(false); - let tab_stops_json = tab_def.map(|td| { - td.tabs.iter().map(|t| - format!("{{\"position\":{},\"type\":{},\"fill\":{}}}", t.position, t.tab_type, t.fill_type) - ).collect::>().join(",") - }).unwrap_or_default(); - let default_tab_spacing = self.document.sections.get(sec_idx) + let tab_stops_json = tab_def + .map(|td| { + td.tabs + .iter() + .map(|t| { + format!( + "{{\"position\":{},\"type\":{},\"fill\":{}}}", + t.position, t.tab_type, t.fill_type + ) + }) + .collect::>() + .join(",") + }) + .unwrap_or_default(); + let default_tab_spacing = self + .document + .sections + .get(sec_idx) .map(|s| s.section_def.default_tab_spacing) .unwrap_or(4000); @@ -418,22 +619,34 @@ impl DocumentCore { let bf_id = raw_ps.map(|p| p.border_fill_id).unwrap_or(0); let border_spacing = raw_ps.map(|p| p.border_spacing).unwrap_or([0; 4]); let border_fill_json = if bf_id > 0 { - if let Some(bf) = self.document.doc_info.border_fills.get((bf_id - 1) as usize) { + if let Some(bf) = self + .document + .doc_info + .border_fills + .get((bf_id - 1) as usize) + { let dir_names = ["Left", "Right", "Top", "Bottom"]; - let borders: Vec = bf.borders.iter().enumerate().map(|(i, b)| { - format!( - "\"border{}\":{{\"type\":{},\"width\":{},\"color\":\"{}\"}}", - dir_names[i], - border_line_type_to_u8_val(b.line_type), - b.width, - color_ref_to_css(b.color), - ) - }).collect(); + let borders: Vec = bf + .borders + .iter() + .enumerate() + .map(|(i, b)| { + format!( + "\"border{}\":{{\"type\":{},\"width\":{},\"color\":\"{}\"}}", + dir_names[i], + border_line_type_to_u8_val(b.line_type), + b.width, + color_ref_to_css(b.color), + ) + }) + .collect(); let (fill_type_str, fill_color, pat_color, pat_type) = match &bf.fill.solid { - Some(sf) if bf.fill.fill_type == FillType::Solid => { - ("solid", color_ref_to_css(sf.background_color), - color_ref_to_css(sf.pattern_color), sf.pattern_type) - } + Some(sf) if bf.fill.fill_type == FillType::Solid => ( + "solid", + color_ref_to_css(sf.background_color), + color_ref_to_css(sf.pattern_color), + sf.pattern_type, + ), _ => ("none", "#ffffff".to_string(), "#000000".to_string(), 0), }; format!( @@ -495,7 +708,9 @@ impl DocumentCore { // verticalAlign: attr1 bits 20-21 (autoSpacing과 충돌 시 0) let vertical_align = if !auto_space_kr_en && !auto_space_kr_num { (a1 >> 20) & 0x03 - } else { 0 }; + } else { + 0 + }; let english_break_unit = (a1 >> 5) & 0x03; let korean_break_unit = (a1 >> 7) & 0x01; format!( @@ -598,9 +813,13 @@ impl DocumentCore { /// 특정 언어 카테고리에서 글꼴 이름으로 ID를 찾거나, 없으면 해당 카테고리에만 등록한다. pub fn find_or_create_font_id_for_lang(&mut self, lang: usize, name: &str) -> i32 { - if lang >= 7 { return -1; } + if lang >= 7 { + return -1; + } let font_faces = &self.document.doc_info.font_faces; - if font_faces.len() <= lang { return -1; } + if font_faces.len() <= lang { + return -1; + } // 해당 언어 카테고리에서 검색 for (idx, font) in font_faces[lang].iter().enumerate() { @@ -661,7 +880,10 @@ impl DocumentCore { return Err(HwpError::RenderError(format!("구역 {} 범위 초과", sec_idx))); } if para_idx >= self.document.sections[sec_idx].paragraphs.len() { - return Err(HwpError::RenderError(format!("문단 {} 범위 초과", para_idx))); + return Err(HwpError::RenderError(format!( + "문단 {} 범위 초과", + para_idx + ))); } let mut mods = parse_char_shape_mods(props_json); @@ -679,7 +901,9 @@ impl DocumentCore { let page_def = §ion.section_def.page_def; let column_def = DocumentCore::find_initial_column_def(§ion.paragraphs); let layout = PageLayoutInfo::from_page_def(page_def, &column_def, self.dpi); - let col_width = layout.column_areas.first() + let col_width = layout + .column_areas + .first() .map(|a| a.width) .unwrap_or(layout.body_area.width); let para_shape_id = self.document.sections[sec_idx].paragraphs[para_idx].para_shape_id; @@ -688,16 +912,25 @@ impl DocumentCore { let margin_right = para_style.map(|s| s.margin_right).unwrap_or(0.0); let available_width = (col_width - margin_left - margin_right).max(1.0); // 원본 LineSeg 무효화 → reflow가 max_font_size에서 새로 계산 - self.document.sections[sec_idx].paragraphs[para_idx].line_segs.clear(); + self.document.sections[sec_idx].paragraphs[para_idx] + .line_segs + .clear(); reflow_line_segs( &mut self.document.sections[sec_idx].paragraphs[para_idx], - available_width, &styles, self.dpi, + available_width, + &styles, + self.dpi, ); } self.document.sections[sec_idx].raw_stream = None; self.rebuild_section(sec_idx); - self.event_log.push(DocumentEvent::CharFormatChanged { section: sec_idx, para: para_idx, start: start_offset, end: end_offset }); + self.event_log.push(DocumentEvent::CharFormatChanged { + section: sec_idx, + para: para_idx, + start: start_offset, + end: end_offset, + }); Ok("{\"ok\":true}".to_string()) } @@ -721,13 +954,26 @@ impl DocumentCore { // 셀 내 문단의 기존 char_shape_id를 기반으로 새 ID 생성 { - let para = self.get_cell_paragraph_ref(sec_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx) + let para = self + .get_cell_paragraph_ref( + sec_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ) .ok_or_else(|| HwpError::RenderError("셀 문단을 찾을 수 없음".to_string()))?; let base_id = para.char_shape_id_at(start_offset).unwrap_or(0); let new_id = self.document.find_or_create_char_shape(base_id, &mods); // 셀 문단에 범위 적용 - let cell_para = self.get_cell_paragraph_mut(sec_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx)?; + let cell_para = self.get_cell_paragraph_mut( + sec_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + )?; cell_para.apply_char_shape_range(start_offset, end_offset, new_id); } @@ -739,10 +985,18 @@ impl DocumentCore { let page_def = §ion.section_def.page_def; let column_def = DocumentCore::find_initial_column_def(§ion.paragraphs); let layout = PageLayoutInfo::from_page_def(page_def, &column_def, dpi); - let col_width = layout.column_areas.first() + let col_width = layout + .column_areas + .first() .map(|a| a.width) .unwrap_or(layout.body_area.width); - let cell_para = self.get_cell_paragraph_mut(sec_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx)?; + let cell_para = self.get_cell_paragraph_mut( + sec_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + )?; let para_shape_id = cell_para.para_shape_id; let para_style = styles.para_styles.get(para_shape_id as usize); let margin_left = para_style.map(|s| s.margin_left).unwrap_or(0.0); @@ -752,8 +1006,8 @@ impl DocumentCore { reflow_line_segs(cell_para, available_width, &styles, dpi); // 표 dirty 마킹 — 셀 높이 재계산 필요 - if let Control::Table(ref mut t) = self.document.sections[sec_idx] - .paragraphs[parent_para_idx].controls[control_idx] + if let Control::Table(ref mut t) = + self.document.sections[sec_idx].paragraphs[parent_para_idx].controls[control_idx] { t.dirty = true; } @@ -761,7 +1015,12 @@ impl DocumentCore { self.document.sections[sec_idx].raw_stream = None; self.rebuild_section(sec_idx); - self.event_log.push(DocumentEvent::CharFormatChanged { section: sec_idx, para: parent_para_idx, start: start_offset, end: end_offset }); + self.event_log.push(DocumentEvent::CharFormatChanged { + section: sec_idx, + para: parent_para_idx, + start: start_offset, + end: end_offset, + }); Ok("{\"ok\":true}".to_string()) } @@ -776,7 +1035,10 @@ impl DocumentCore { return Err(HwpError::RenderError(format!("구역 {} 범위 초과", sec_idx))); } if para_idx >= self.document.sections[sec_idx].paragraphs.len() { - return Err(HwpError::RenderError(format!("문단 {} 범위 초과", para_idx))); + return Err(HwpError::RenderError(format!( + "문단 {} 범위 초과", + para_idx + ))); } let mut mods = parse_para_shape_mods(props_json); @@ -784,11 +1046,18 @@ impl DocumentCore { // 탭 설정 변경 처리: TabDef 생성 → tab_def_id 세팅 if json_has_tab_keys(props_json) { let base_id = self.document.sections[sec_idx].paragraphs[para_idx].para_shape_id; - let base_tab_def_id = self.document.doc_info.para_shapes + let base_tab_def_id = self + .document + .doc_info + .para_shapes .get(base_id as usize) .map(|ps| ps.tab_def_id) .unwrap_or(0); - let new_td = build_tab_def_from_json(props_json, base_tab_def_id, &self.document.doc_info.tab_defs); + let new_td = build_tab_def_from_json( + props_json, + base_tab_def_id, + &self.document.doc_info.tab_defs, + ); let new_tab_id = self.document.find_or_create_tab_def(new_td); mods.tab_def_id = Some(new_tab_id); } @@ -813,7 +1082,9 @@ impl DocumentCore { let page_def = §ion.section_def.page_def; let column_def = DocumentCore::find_initial_column_def(§ion.paragraphs); let layout = PageLayoutInfo::from_page_def(page_def, &column_def, self.dpi); - let col_width = layout.column_areas.first() + let col_width = layout + .column_areas + .first() .map(|a| a.width) .unwrap_or(layout.body_area.width); let para_style = styles.para_styles.get(new_id as usize); @@ -830,7 +1101,10 @@ impl DocumentCore { self.document.sections[sec_idx].raw_stream = None; self.rebuild_section(sec_idx); - self.event_log.push(DocumentEvent::ParaFormatChanged { section: sec_idx, para: para_idx }); + self.event_log.push(DocumentEvent::ParaFormatChanged { + section: sec_idx, + para: para_idx, + }); Ok("{\"ok\":true}".to_string()) } @@ -848,13 +1122,27 @@ impl DocumentCore { // 탭 설정 변경 처리: TabDef 생성 → tab_def_id 세팅 if json_has_tab_keys(props_json) { - let para = self.get_cell_paragraph_ref(sec_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx) + let para = self + .get_cell_paragraph_ref( + sec_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ) .ok_or_else(|| HwpError::RenderError("셀 문단을 찾을 수 없음".to_string()))?; - let base_tab_def_id = self.document.doc_info.para_shapes + let base_tab_def_id = self + .document + .doc_info + .para_shapes .get(para.para_shape_id as usize) .map(|ps| ps.tab_def_id) .unwrap_or(0); - let new_td = build_tab_def_from_json(props_json, base_tab_def_id, &self.document.doc_info.tab_defs); + let new_td = build_tab_def_from_json( + props_json, + base_tab_def_id, + &self.document.doc_info.tab_defs, + ); let new_tab_id = self.document.find_or_create_tab_def(new_td); mods.tab_def_id = Some(new_tab_id); } @@ -870,12 +1158,25 @@ impl DocumentCore { let new_id; { - let para = self.get_cell_paragraph_ref(sec_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx) + let para = self + .get_cell_paragraph_ref( + sec_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ) .ok_or_else(|| HwpError::RenderError("셀 문단을 찾을 수 없음".to_string()))?; let base_id = para.para_shape_id; new_id = self.document.find_or_create_para_shape(base_id, &mods); - let cell_para = self.get_cell_paragraph_mut(sec_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx)?; + let cell_para = self.get_cell_paragraph_mut( + sec_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + )?; cell_para.para_shape_id = new_id; } @@ -887,22 +1188,30 @@ impl DocumentCore { let page_def = §ion.section_def.page_def; let column_def = DocumentCore::find_initial_column_def(§ion.paragraphs); let layout = PageLayoutInfo::from_page_def(page_def, &column_def, dpi); - let col_width = layout.column_areas.first() + let col_width = layout + .column_areas + .first() .map(|a| a.width) .unwrap_or(layout.body_area.width); let para_style = styles.para_styles.get(new_id as usize); let margin_left = para_style.map(|s| s.margin_left).unwrap_or(0.0); let margin_right = para_style.map(|s| s.margin_right).unwrap_or(0.0); let available_width = (col_width - margin_left - margin_right).max(1.0); - let cell_para = self.get_cell_paragraph_mut(sec_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx)?; + let cell_para = self.get_cell_paragraph_mut( + sec_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + )?; reflow_line_segs(cell_para, available_width, &styles, dpi); } // 표 dirty 마킹 — measure_section_incremental이 셀 높이를 재계산하도록 { use crate::model::control::Control; - if let Control::Table(ref mut t) = self.document.sections[sec_idx] - .paragraphs[parent_para_idx].controls[control_idx] + if let Control::Table(ref mut t) = + self.document.sections[sec_idx].paragraphs[parent_para_idx].controls[control_idx] { t.dirty = true; } @@ -910,7 +1219,10 @@ impl DocumentCore { self.document.sections[sec_idx].raw_stream = None; self.rebuild_section(sec_idx); - self.event_log.push(DocumentEvent::ParaFormatChanged { section: sec_idx, para: parent_para_idx }); + self.event_log.push(DocumentEvent::ParaFormatChanged { + section: sec_idx, + para: parent_para_idx, + }); Ok("{\"ok\":true}".to_string()) } @@ -941,7 +1253,10 @@ impl DocumentCore { /// 문서의 ParaShape 풀에서 동일 numbering_id·head_type이면서 target level인 것을 찾는다. fn find_para_shape_with_nid_and_level( - &self, nid: u16, head_type: crate::model::style::HeadType, level: u8, + &self, + nid: u16, + head_type: crate::model::style::HeadType, + level: u8, ) -> Option { for (i, ps) in self.document.doc_info.para_shapes.iter().enumerate() { if ps.numbering_id == nid && ps.head_type == head_type && ps.para_level == level { @@ -973,26 +1288,35 @@ impl DocumentCore { fn resolve_style_para_shape_id(&mut self, style_id: usize, current_psid: u16) -> u16 { use crate::model::style::HeadType; - let current_ps = self.document.doc_info.para_shapes.get(current_psid as usize).cloned(); - let current_head = current_ps.as_ref().map(|ps| ps.head_type).unwrap_or(HeadType::None); + let current_ps = self + .document + .doc_info + .para_shapes + .get(current_psid as usize) + .cloned(); + let current_head = current_ps + .as_ref() + .map(|ps| ps.head_type) + .unwrap_or(HeadType::None); let current_nid = current_ps.as_ref().map(|ps| ps.numbering_id).unwrap_or(0); // ── 현재 문단이 번호/개요를 가지고 있는 경우 ── // numbering_id와 head_type을 보존하고 para_level만 변경 if current_head != HeadType::None { // 대상 스타일의 개요 수준 결정 - let target_level = self.parse_outline_level_from_style(style_id) - .or_else(|| { - // 스타일 이름에서 못 찾으면 참조 문단에서 추출 - self.find_reference_para_shape_for_style(style_id) - .and_then(|psid| self.document.doc_info.para_shapes.get(psid as usize)) - .filter(|ps| ps.head_type != HeadType::None) - .map(|ps| ps.para_level) - }); + let target_level = self.parse_outline_level_from_style(style_id).or_else(|| { + // 스타일 이름에서 못 찾으면 참조 문단에서 추출 + self.find_reference_para_shape_for_style(style_id) + .and_then(|psid| self.document.doc_info.para_shapes.get(psid as usize)) + .filter(|ps| ps.head_type != HeadType::None) + .map(|ps| ps.para_level) + }); if let Some(level) = target_level { // 같은 numbering_id·head_type에서 target level인 ParaShape 검색 - if let Some(found) = self.find_para_shape_with_nid_and_level(current_nid, current_head, level) { + if let Some(found) = + self.find_para_shape_with_nid_and_level(current_nid, current_head, level) + { return found; } @@ -1047,35 +1371,53 @@ impl DocumentCore { para_idx: usize, style_id: usize, ) -> Result { - let style = self.document.doc_info.styles.get(style_id) + let style = self + .document + .doc_info + .styles + .get(style_id) .ok_or_else(|| HwpError::RenderError(format!("스타일 {} 범위 초과", style_id)))?; let new_char_shape_id = style.char_shape_id as u32; // 현재 문단의 para_shape_id를 먼저 읽어서 번호 문맥 보존 - let current_psid = self.document.sections.get(sec_idx) + let current_psid = self + .document + .sections + .get(sec_idx) .and_then(|s| s.paragraphs.get(para_idx)) .map(|p| p.para_shape_id) - .ok_or_else(|| HwpError::RenderError(format!("문단 {}/{} 범위 초과", sec_idx, para_idx)))?; + .ok_or_else(|| { + HwpError::RenderError(format!("문단 {}/{} 범위 초과", sec_idx, para_idx)) + })?; let new_para_shape_id = self.resolve_style_para_shape_id(style_id, current_psid); - let para = self.document.sections.get_mut(sec_idx) + let para = self + .document + .sections + .get_mut(sec_idx) .and_then(|s| s.paragraphs.get_mut(para_idx)) - .ok_or_else(|| HwpError::RenderError(format!("문단 {}/{} 범위 초과", sec_idx, para_idx)))?; + .ok_or_else(|| { + HwpError::RenderError(format!("문단 {}/{} 범위 초과", sec_idx, para_idx)) + })?; para.style_id = style_id as u8; para.para_shape_id = new_para_shape_id; // char_shape: 모든 로컬 오버라이드를 제거하고 스타일 CharShape 단일 항목으로 통일 para.char_shapes.clear(); - para.char_shapes.push(crate::model::paragraph::CharShapeRef { - start_pos: 0, - char_shape_id: new_char_shape_id, - }); + para.char_shapes + .push(crate::model::paragraph::CharShapeRef { + start_pos: 0, + char_shape_id: new_char_shape_id, + }); self.document.sections[sec_idx].raw_stream = None; self.rebuild_section(sec_idx); - self.event_log.push(DocumentEvent::ParaFormatChanged { section: sec_idx, para: para_idx }); + self.event_log.push(DocumentEvent::ParaFormatChanged { + section: sec_idx, + para: para_idx, + }); Ok("{\"ok\":true}".to_string()) } @@ -1089,32 +1431,54 @@ impl DocumentCore { cell_para_idx: usize, style_id: usize, ) -> Result { - let style = self.document.doc_info.styles.get(style_id) + let style = self + .document + .doc_info + .styles + .get(style_id) .ok_or_else(|| HwpError::RenderError(format!("스타일 {} 범위 초과", style_id)))?; let new_char_shape_id = style.char_shape_id as u32; // 현재 셀 문단의 para_shape_id를 먼저 읽어서 번호 문맥 보존 - let current_psid = self.get_cell_paragraph_ref(sec_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx) + let current_psid = self + .get_cell_paragraph_ref( + sec_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ) .map(|p| p.para_shape_id) .ok_or_else(|| HwpError::RenderError("셀 문단을 찾을 수 없음".to_string()))?; let new_para_shape_id = self.resolve_style_para_shape_id(style_id, current_psid); { - let cell_para = self.get_cell_paragraph_mut(sec_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx)?; + let cell_para = self.get_cell_paragraph_mut( + sec_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + )?; cell_para.style_id = style_id as u8; cell_para.para_shape_id = new_para_shape_id; // 모든 로컬 오버라이드를 제거하고 스타일 CharShape 단일 항목으로 통일 cell_para.char_shapes.clear(); - cell_para.char_shapes.push(crate::model::paragraph::CharShapeRef { - start_pos: 0, - char_shape_id: new_char_shape_id, - }); + cell_para + .char_shapes + .push(crate::model::paragraph::CharShapeRef { + start_pos: 0, + char_shape_id: new_char_shape_id, + }); } self.document.sections[sec_idx].raw_stream = None; self.rebuild_section(sec_idx); - self.event_log.push(DocumentEvent::ParaFormatChanged { section: sec_idx, para: parent_para_idx }); + self.event_log.push(DocumentEvent::ParaFormatChanged { + section: sec_idx, + para: parent_para_idx, + }); Ok("{\"ok\":true}".to_string()) } @@ -1131,8 +1495,11 @@ impl DocumentCore { .char_shape_id_at(start_offset) .unwrap_or(0); let new_id = self.document.find_or_create_char_shape(base_id, mods); - self.document.sections[sec_idx].paragraphs[para_idx] - .apply_char_shape_range(start_offset, end_offset, new_id); + self.document.sections[sec_idx].paragraphs[para_idx].apply_char_shape_range( + start_offset, + end_offset, + new_id, + ); } /// 문단 번호 시작 방식을 설정한다. @@ -1148,10 +1515,14 @@ impl DocumentCore { use crate::model::paragraph::NumberingRestart; if section_idx >= self.document.sections.len() { - return Err(crate::error::HwpError::RenderError("구역 범위 초과".to_string())); + return Err(crate::error::HwpError::RenderError( + "구역 범위 초과".to_string(), + )); } if para_idx >= self.document.sections[section_idx].paragraphs.len() { - return Err(crate::error::HwpError::RenderError("문단 범위 초과".to_string())); + return Err(crate::error::HwpError::RenderError( + "문단 범위 초과".to_string(), + )); } let restart = match mode { @@ -1186,19 +1557,30 @@ impl DocumentCore { use crate::model::control::{Control, PageHide}; if section_idx >= self.document.sections.len() { - return Err(crate::error::HwpError::RenderError("구역 범위 초과".to_string())); + return Err(crate::error::HwpError::RenderError( + "구역 범위 초과".to_string(), + )); } if para_idx >= self.document.sections[section_idx].paragraphs.len() { - return Err(crate::error::HwpError::RenderError("문단 범위 초과".to_string())); + return Err(crate::error::HwpError::RenderError( + "문단 범위 초과".to_string(), + )); } - let all_false = !hide_header && !hide_footer && !hide_master_page - && !hide_border && !hide_fill && !hide_page_num; + let all_false = !hide_header + && !hide_footer + && !hide_master_page + && !hide_border + && !hide_fill + && !hide_page_num; let para = &mut self.document.sections[section_idx].paragraphs[para_idx]; // 기존 PageHide 컨트롤 찾기 - let existing_idx = para.controls.iter().position(|c| matches!(c, Control::PageHide(_))); + let existing_idx = para + .controls + .iter() + .position(|c| matches!(c, Control::PageHide(_))); if all_false { // 모두 false → 기존 PageHide 제거 @@ -1210,8 +1592,12 @@ impl DocumentCore { } } else { let ph = PageHide { - hide_header, hide_footer, hide_master_page, - hide_border, hide_fill, hide_page_num, + hide_header, + hide_footer, + hide_master_page, + hide_border, + hide_fill, + hide_page_num, }; if let Some(idx) = existing_idx { // 기존 컨트롤 갱신 @@ -1238,9 +1624,14 @@ impl DocumentCore { ) -> Result { use crate::model::control::Control; - let section = self.document.sections.get(section_idx) + let section = self + .document + .sections + .get(section_idx) .ok_or_else(|| crate::error::HwpError::RenderError("구역 범위 초과".to_string()))?; - let para = section.paragraphs.get(para_idx) + let para = section + .paragraphs + .get(para_idx) .ok_or_else(|| crate::error::HwpError::RenderError("문단 범위 초과".to_string()))?; for ctrl in ¶.controls { diff --git a/src/document_core/commands/header_footer_ops.rs b/src/document_core/commands/header_footer_ops.rs index ca8bb56e..d35c76f3 100644 --- a/src/document_core/commands/header_footer_ops.rs +++ b/src/document_core/commands/header_footer_ops.rs @@ -1,13 +1,16 @@ //! 머리말/꼬리말 생성·조회·텍스트 편집 관련 native 메서드 -use crate::model::control::Control; -use crate::model::header_footer::{Header, Footer, HeaderFooterApply}; -use crate::model::paragraph::Paragraph; -use crate::renderer::composer::reflow_line_segs; +use crate::document_core::helpers::{ + build_tab_def_from_json, json_has_border_keys, json_has_tab_keys, parse_json_i16_array, + parse_para_shape_mods, +}; use crate::document_core::DocumentCore; -use crate::document_core::helpers::{parse_para_shape_mods, json_has_border_keys, json_has_tab_keys, build_tab_def_from_json, parse_json_i16_array}; use crate::error::HwpError; +use crate::model::control::Control; use crate::model::event::DocumentEvent; +use crate::model::header_footer::{Footer, Header, HeaderFooterApply}; +use crate::model::paragraph::Paragraph; +use crate::renderer::composer::reflow_line_segs; /// applyTo u8 값 → HeaderFooterApply 변환 fn apply_from_u8(v: u8) -> HeaderFooterApply { @@ -74,7 +77,9 @@ impl DocumentCore { ) -> Result { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과 (총 {}개)", section_idx, self.document.sections.len() + "구역 인덱스 {} 범위 초과 (총 {}개)", + section_idx, + self.document.sections.len() ))); } let apply = apply_from_u8(apply_to); @@ -86,7 +91,11 @@ impl DocumentCore { Control::Footer(f) => (&f.paragraphs, f.apply_to), _ => unreachable!(), }; - let text: String = paragraphs.iter().map(|p| p.text.clone()).collect::>().join("\n"); + let text: String = paragraphs + .iter() + .map(|p| p.text.clone()) + .collect::>() + .join("\n"); let kind = if is_header { "header" } else { "footer" }; let label = apply_label(at); Ok(format!( @@ -111,14 +120,21 @@ impl DocumentCore { ) -> Result { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과 (총 {}개)", section_idx, self.document.sections.len() + "구역 인덱스 {} 범위 초과 (총 {}개)", + section_idx, + self.document.sections.len() ))); } let apply = apply_from_u8(apply_to); - if self.find_header_footer_control(section_idx, is_header, apply).is_some() { + if self + .find_header_footer_control(section_idx, is_header, apply) + .is_some() + { let kind = if is_header { "머리말" } else { "꼬리말" }; return Err(HwpError::RenderError(format!( - "이미 {}({})이 존재합니다", kind, apply_label(apply) + "이미 {}({})이 존재합니다", + kind, + apply_label(apply) ))); } @@ -157,7 +173,8 @@ impl DocumentCore { self.paginate_if_needed(); // 생성된 컨트롤 위치 반환 - let (pi, ci) = self.find_header_footer_control(section_idx, is_header, apply) + let (pi, ci) = self + .find_header_footer_control(section_idx, is_header, apply) .expect("방금 생성한 컨트롤을 찾을 수 없음"); let kind = if is_header { "header" } else { "footer" }; @@ -177,10 +194,15 @@ impl DocumentCore { hf_para_idx: usize, ) -> Result<&mut Paragraph, HwpError> { let apply = apply_from_u8(apply_to); - let (pi, ci) = self.find_header_footer_control(section_idx, is_header, apply) + let (pi, ci) = self + .find_header_footer_control(section_idx, is_header, apply) .ok_or_else(|| { let kind = if is_header { "머리말" } else { "꼬리말" }; - HwpError::RenderError(format!("{}({})이 존재하지 않습니다", kind, apply_label(apply))) + HwpError::RenderError(format!( + "{}({})이 존재하지 않습니다", + kind, + apply_label(apply) + )) })?; let ctrl = &mut self.document.sections[section_idx].paragraphs[pi].controls[ci]; @@ -188,7 +210,9 @@ impl DocumentCore { Control::Header(h) => { if hf_para_idx >= h.paragraphs.len() { return Err(HwpError::RenderError(format!( - "머리말 문단 인덱스 {} 범위 초과 (총 {}개)", hf_para_idx, h.paragraphs.len() + "머리말 문단 인덱스 {} 범위 초과 (총 {}개)", + hf_para_idx, + h.paragraphs.len() ))); } Ok(&mut h.paragraphs[hf_para_idx]) @@ -196,12 +220,16 @@ impl DocumentCore { Control::Footer(f) => { if hf_para_idx >= f.paragraphs.len() { return Err(HwpError::RenderError(format!( - "꼬리말 문단 인덱스 {} 범위 초과 (총 {}개)", hf_para_idx, f.paragraphs.len() + "꼬리말 문단 인덱스 {} 범위 초과 (총 {}개)", + hf_para_idx, + f.paragraphs.len() ))); } Ok(&mut f.paragraphs[hf_para_idx]) } - _ => Err(HwpError::RenderError("컨트롤이 머리말/꼬리말이 아닙니다".to_string())), + _ => Err(HwpError::RenderError( + "컨트롤이 머리말/꼬리말이 아닙니다".to_string(), + )), } } @@ -235,7 +263,9 @@ impl DocumentCore { ) -> Result { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과 (총 {}개)", section_idx, self.document.sections.len() + "구역 인덱스 {} 범위 초과 (총 {}개)", + section_idx, + self.document.sections.len() ))); } @@ -253,9 +283,15 @@ impl DocumentCore { let new_offset = char_offset + new_chars_count; self.event_log.push(DocumentEvent::TextInserted { - section: section_idx, para: 0, offset: char_offset, len: new_chars_count, + section: section_idx, + para: 0, + offset: char_offset, + len: new_chars_count, }); - Ok(super::super::helpers::json_ok_with(&format!("\"charOffset\":{}", new_offset))) + Ok(super::super::helpers::json_ok_with(&format!( + "\"charOffset\":{}", + new_offset + ))) } /// 머리말/꼬리말 내 텍스트 삭제 @@ -270,7 +306,9 @@ impl DocumentCore { ) -> Result { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과 (총 {}개)", section_idx, self.document.sections.len() + "구역 인덱스 {} 범위 초과 (총 {}개)", + section_idx, + self.document.sections.len() ))); } @@ -286,9 +324,15 @@ impl DocumentCore { self.paginate_if_needed(); self.event_log.push(DocumentEvent::TextDeleted { - section: section_idx, para: 0, offset: char_offset, count, + section: section_idx, + para: 0, + offset: char_offset, + count, }); - Ok(super::super::helpers::json_ok_with(&format!("\"charOffset\":{}", char_offset))) + Ok(super::super::helpers::json_ok_with(&format!( + "\"charOffset\":{}", + char_offset + ))) } /// 머리말/꼬리말 내 문단 분할 (Enter 키) @@ -302,12 +346,14 @@ impl DocumentCore { ) -> Result { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과", section_idx + "구역 인덱스 {} 범위 초과", + section_idx ))); } let apply = apply_from_u8(apply_to); - let (pi, ci) = self.find_header_footer_control(section_idx, is_header, apply) + let (pi, ci) = self + .find_header_footer_control(section_idx, is_header, apply) .ok_or_else(|| { let kind = if is_header { "머리말" } else { "꼬리말" }; HwpError::RenderError(format!("{}이 존재하지 않습니다", kind)) @@ -323,7 +369,8 @@ impl DocumentCore { }; if hf_para_idx >= paragraphs.len() { return Err(HwpError::RenderError(format!( - "문단 인덱스 {} 범위 초과", hf_para_idx + "문단 인덱스 {} 범위 초과", + hf_para_idx ))); } paragraphs[hf_para_idx].split_at(char_offset) @@ -350,11 +397,14 @@ impl DocumentCore { self.paginate_if_needed(); self.event_log.push(DocumentEvent::ParagraphSplit { - section: section_idx, para: hf_para_idx, offset: char_offset, + section: section_idx, + para: hf_para_idx, + offset: char_offset, }); - Ok(super::super::helpers::json_ok_with( - &format!("\"hfParaIndex\":{},\"charOffset\":0", new_para_idx) - )) + Ok(super::super::helpers::json_ok_with(&format!( + "\"hfParaIndex\":{},\"charOffset\":0", + new_para_idx + ))) } /// 머리말/꼬리말 내 문단 병합 (Backspace at start) @@ -367,15 +417,19 @@ impl DocumentCore { ) -> Result { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과", section_idx + "구역 인덱스 {} 범위 초과", + section_idx ))); } if hf_para_idx == 0 { - return Err(HwpError::RenderError("첫 번째 문단은 이전 문단과 병합할 수 없습니다".to_string())); + return Err(HwpError::RenderError( + "첫 번째 문단은 이전 문단과 병합할 수 없습니다".to_string(), + )); } let apply = apply_from_u8(apply_to); - let (pi, ci) = self.find_header_footer_control(section_idx, is_header, apply) + let (pi, ci) = self + .find_header_footer_control(section_idx, is_header, apply) .ok_or_else(|| { let kind = if is_header { "머리말" } else { "꼬리말" }; HwpError::RenderError(format!("{}이 존재하지 않습니다", kind)) @@ -392,7 +446,8 @@ impl DocumentCore { }; if hf_para_idx >= paragraphs.len() { return Err(HwpError::RenderError(format!( - "문단 인덱스 {} 범위 초과", hf_para_idx + "문단 인덱스 {} 범위 초과", + hf_para_idx ))); } merge_offset = paragraphs[hf_para_idx - 1].text.chars().count(); @@ -408,11 +463,13 @@ impl DocumentCore { self.paginate_if_needed(); self.event_log.push(DocumentEvent::ParagraphMerged { - section: section_idx, para: hf_para_idx, + section: section_idx, + para: hf_para_idx, }); - Ok(super::super::helpers::json_ok_with( - &format!("\"hfParaIndex\":{},\"charOffset\":{}", prev_idx, merge_offset) - )) + Ok(super::super::helpers::json_ok_with(&format!( + "\"hfParaIndex\":{},\"charOffset\":{}", + prev_idx, merge_offset + ))) } /// 머리말/꼬리말 문단의 정보를 반환한다 (문단 수, 현재 문단 텍스트 길이 등). @@ -425,11 +482,13 @@ impl DocumentCore { ) -> Result { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과", section_idx + "구역 인덱스 {} 범위 초과", + section_idx ))); } let apply = apply_from_u8(apply_to); - let (pi, ci) = self.find_header_footer_control(section_idx, is_header, apply) + let (pi, ci) = self + .find_header_footer_control(section_idx, is_header, apply) .ok_or_else(|| { let kind = if is_header { "머리말" } else { "꼬리말" }; HwpError::RenderError(format!("{}이 존재하지 않습니다", kind)) @@ -464,20 +523,31 @@ impl DocumentCore { ) -> Result { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과 (총 {}개)", section_idx, self.document.sections.len() + "구역 인덱스 {} 범위 초과 (총 {}개)", + section_idx, + self.document.sections.len() ))); } let apply = apply_from_u8(apply_to); - let (pi, ci) = self.find_header_footer_control(section_idx, is_header, apply) + let (pi, ci) = self + .find_header_footer_control(section_idx, is_header, apply) .ok_or_else(|| { let kind = if is_header { "머리말" } else { "꼬리말" }; - HwpError::RenderError(format!("{}({})이 존재하지 않습니다", kind, apply_label(apply))) + HwpError::RenderError(format!( + "{}({})이 존재하지 않습니다", + kind, + apply_label(apply) + )) })?; - self.document.sections[section_idx].paragraphs[pi].controls.remove(ci); + self.document.sections[section_idx].paragraphs[pi] + .controls + .remove(ci); // 컨트롤 1개 = UTF-16 8 code units → char_count 갱신 self.document.sections[section_idx].paragraphs[pi].char_count = - self.document.sections[section_idx].paragraphs[pi].char_count.saturating_sub(8); + self.document.sections[section_idx].paragraphs[pi] + .char_count + .saturating_sub(8); self.document.sections[section_idx].raw_stream = None; self.mark_section_dirty(section_idx); self.paginate_if_needed(); @@ -512,7 +582,10 @@ impl DocumentCore { let label = apply_label(apply); let at = apply_to_u8(apply); - if si == current_section_idx && is_header == current_is_header && apply == current_apply { + if si == current_section_idx + && is_header == current_is_header + && apply == current_apply + { current_index = items.len() as i32; } @@ -526,7 +599,8 @@ impl DocumentCore { Ok(format!( "{{\"ok\":true,\"items\":[{}],\"currentIndex\":{}}}", - items.join(","), current_index + items.join(","), + current_index )) } @@ -550,7 +624,11 @@ impl DocumentCore { // 현재 페이지의 머리말/꼬리말 참조 (동일 컨트롤 스킵용) let current_ref = if let Ok((pc, _, _)) = self.find_page(current_page) { - if is_header { pc.active_header.clone() } else { pc.active_footer.clone() } + if is_header { + pc.active_header.clone() + } else { + pc.active_footer.clone() + } } else { None }; @@ -559,13 +637,19 @@ impl DocumentCore { while page >= 0 && page < total as i64 { let p = page as u32; if let Ok((pc, _, _)) = self.find_page(p) { - let hf_ref = if is_header { &pc.active_header } else { &pc.active_footer }; + let hf_ref = if is_header { + &pc.active_header + } else { + &pc.active_footer + }; if let Some(hf) = hf_ref { // 다른 컨트롤이거나, 같은 컨트롤이라도 다른 페이지이면 이동 대상 let is_different_control = match ¤t_ref { - Some(cr) => cr.para_index != hf.para_index - || cr.control_index != hf.control_index - || cr.source_section_index != hf.source_section_index, + Some(cr) => { + cr.para_index != hf.para_index + || cr.control_index != hf.control_index + || cr.source_section_index != hf.source_section_index + } None => true, }; // 같은 컨트롤이라도 페이지가 달라지면 이동 @@ -582,9 +666,15 @@ impl DocumentCore { Control::Footer(f) => apply_to_u8(f.apply_to), _ => 0, } - } else { 0 } - } else { 0 } - } else { 0 }; + } else { + 0 + } + } else { + 0 + } + } else { + 0 + }; return Ok(format!( "{{\"ok\":true,\"pageIndex\":{},\"sectionIdx\":{},\"isHeader\":{},\"applyTo\":{}}}", @@ -609,7 +699,8 @@ impl DocumentCore { let total = self.page_count(); if page_num >= total { return Err(HwpError::RenderError(format!( - "페이지 인덱스 {} 범위 초과 (총 {}개)", page_num, total + "페이지 인덱스 {} 범위 초과 (총 {}개)", + page_num, total ))); } let key = (page_num, is_header); @@ -647,17 +738,17 @@ impl DocumentCore { let available_width = { let section = &self.document.sections[section_idx]; let page_def = §ion.section_def.page_def; - let text_width = page_def.width as i32 - - page_def.margin_left as i32 - - page_def.margin_right as i32; + let text_width = + page_def.width as i32 - page_def.margin_left as i32 - page_def.margin_right as i32; hwpunit_to_px(text_width, self.dpi) }; // 문단 여백 적용 - let para_shape_id = match self.get_hf_paragraph_ref(section_idx, is_header, apply_to, hf_para_idx) { - Some(p) => p.para_shape_id, - None => return, - }; + let para_shape_id = + match self.get_hf_paragraph_ref(section_idx, is_header, apply_to, hf_para_idx) { + Some(p) => p.para_shape_id, + None => return, + }; let para_style = self.styles.para_styles.get(para_shape_id as usize); let margin_left = para_style.map(|s| s.margin_left).unwrap_or(0.0); let margin_right = para_style.map(|s| s.margin_right).unwrap_or(0.0); @@ -686,8 +777,11 @@ impl DocumentCore { apply_to: u8, hf_para_idx: usize, ) -> Result { - let para = self.get_hf_paragraph_ref(section_idx, is_header, apply_to, hf_para_idx) - .ok_or_else(|| HwpError::RenderError("머리말/꼬리말 문단을 찾을 수 없음".to_string()))?; + let para = self + .get_hf_paragraph_ref(section_idx, is_header, apply_to, hf_para_idx) + .ok_or_else(|| { + HwpError::RenderError("머리말/꼬리말 문단을 찾을 수 없음".to_string()) + })?; Ok(self.build_para_properties_json(para.para_shape_id, section_idx)) } @@ -702,8 +796,11 @@ impl DocumentCore { ) -> Result { // 현재 para_shape_id 조회 let base_id = { - let para = self.get_hf_paragraph_ref(section_idx, is_header, apply_to, hf_para_idx) - .ok_or_else(|| HwpError::RenderError("머리말/꼬리말 문단을 찾을 수 없음".to_string()))?; + let para = self + .get_hf_paragraph_ref(section_idx, is_header, apply_to, hf_para_idx) + .ok_or_else(|| { + HwpError::RenderError("머리말/꼬리말 문단을 찾을 수 없음".to_string()) + })?; para.para_shape_id }; @@ -711,11 +808,18 @@ impl DocumentCore { // 탭 설정 변경 처리 if json_has_tab_keys(props_json) { - let base_tab_def_id = self.document.doc_info.para_shapes + let base_tab_def_id = self + .document + .doc_info + .para_shapes .get(base_id as usize) .map(|ps| ps.tab_def_id) .unwrap_or(0); - let new_td = build_tab_def_from_json(props_json, base_tab_def_id, &self.document.doc_info.tab_defs); + let new_td = build_tab_def_from_json( + props_json, + base_tab_def_id, + &self.document.doc_info.tab_defs, + ); let new_tab_id = self.document.find_or_create_tab_def(new_td); mods.tab_def_id = Some(new_tab_id); } @@ -744,7 +848,10 @@ impl DocumentCore { self.document.sections[section_idx].raw_stream = None; self.rebuild_section(section_idx); - self.event_log.push(DocumentEvent::ParaFormatChanged { section: section_idx, para: 0 }); + self.event_log.push(DocumentEvent::ParaFormatChanged { + section: section_idx, + para: 0, + }); Ok("{\"ok\":true}".to_string()) } @@ -760,10 +867,15 @@ impl DocumentCore { field_type: u8, ) -> Result { let marker = match field_type { - 1 => "\u{0015}", // 현재 쪽번호 - 2 => "\u{0016}", // 총 쪽수 - 3 => "\u{0017}", // 파일 이름 - _ => return Err(HwpError::RenderError(format!("알 수 없는 필드 타입: {}", field_type))), + 1 => "\u{0015}", // 현재 쪽번호 + 2 => "\u{0016}", // 총 쪽수 + 3 => "\u{0017}", // 파일 이름 + _ => { + return Err(HwpError::RenderError(format!( + "알 수 없는 필드 타입: {}", + field_type + ))) + } }; let hf_para = self.get_hf_paragraph_mut(section_idx, is_header, apply_to, hf_para_idx)?; @@ -777,9 +889,15 @@ impl DocumentCore { let new_offset = char_offset + 1; self.event_log.push(DocumentEvent::TextInserted { - section: section_idx, para: 0, offset: char_offset, len: 1, + section: section_idx, + para: 0, + offset: char_offset, + len: 1, }); - Ok(super::super::helpers::json_ok_with(&format!("\"charOffset\":{}", new_offset))) + Ok(super::super::helpers::json_ok_with(&format!( + "\"charOffset\":{}", + new_offset + ))) } /// 머리말/꼬리말 마당(템플릿)을 적용한다. @@ -801,19 +919,24 @@ impl DocumentCore { ) -> Result { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과", section_idx + "구역 인덱스 {} 범위 초과", + section_idx ))); } if template_id > 10 { return Err(HwpError::RenderError(format!( - "알 수 없는 템플릿 ID: {}", template_id + "알 수 없는 템플릿 ID: {}", + template_id ))); } let apply = apply_from_u8(apply_to); // 1) 기존 HF가 있으면 삭제 - if self.find_header_footer_control(section_idx, is_header, apply).is_some() { + if self + .find_header_footer_control(section_idx, is_header, apply) + .is_some() + { self.delete_header_footer_native(section_idx, is_header, apply_to)?; } @@ -828,21 +951,27 @@ impl DocumentCore { } // 3) 배치/스타일 결정 - let layout = if template_id <= 5 { template_id } else { template_id - 5 }; + let layout = if template_id <= 5 { + template_id + } else { + template_id - 5 + }; let styled = template_id > 5; // bold + underline // 4) 텍스트 내용 결정 let text = match layout { - 1 => "\u{0015}".to_string(), // 왼쪽 쪽번호 - 2 => "\u{0015}".to_string(), // 가운데 쪽번호 - 3 => "\u{0015}".to_string(), // 오른쪽 쪽번호 - 4 => "\u{0015}\t\u{0017}".to_string(), // 쪽번호(왼) + 탭 + 파일이름(오) - 5 => "\u{0017}\t\u{0015}".to_string(), // 파일이름(왼) + 탭 + 쪽번호(오) + 1 => "\u{0015}".to_string(), // 왼쪽 쪽번호 + 2 => "\u{0015}".to_string(), // 가운데 쪽번호 + 3 => "\u{0015}".to_string(), // 오른쪽 쪽번호 + 4 => "\u{0015}\t\u{0017}".to_string(), // 쪽번호(왼) + 탭 + 파일이름(오) + 5 => "\u{0017}\t\u{0015}".to_string(), // 파일이름(왼) + 탭 + 쪽번호(오) _ => String::new(), }; // 5) 정렬 결정 - use crate::model::style::{Alignment, ParaShapeMods, CharShapeMods, UnderlineType, TabDef, TabItem}; + use crate::model::style::{ + Alignment, CharShapeMods, ParaShapeMods, TabDef, TabItem, UnderlineType, + }; let alignment = match layout { 1 => Alignment::Left, @@ -857,14 +986,18 @@ impl DocumentCore { let hf_para = self.get_hf_paragraph_mut(section_idx, is_header, apply_to, 0)?; hf_para.text = text; // char_offsets 재계산 - hf_para.char_offsets = hf_para.text.char_indices() + hf_para.char_offsets = hf_para + .text + .char_indices() .map(|(byte_idx, _)| byte_idx as u32) .collect(); } // 7) 문단 정렬 적용 let base_para_id = { - let para = self.get_hf_paragraph_ref(section_idx, is_header, apply_to, 0).unwrap(); + let para = self + .get_hf_paragraph_ref(section_idx, is_header, apply_to, 0) + .unwrap(); para.para_shape_id }; let mut para_mods = ParaShapeMods::default(); @@ -874,9 +1007,8 @@ impl DocumentCore { if layout == 4 || layout == 5 { let section = &self.document.sections[section_idx]; let page_def = §ion.section_def.page_def; - let text_width = page_def.width as i32 - - page_def.margin_left as i32 - - page_def.margin_right as i32; + let text_width = + page_def.width as i32 - page_def.margin_left as i32 - page_def.margin_right as i32; let new_td = TabDef { raw_data: None, @@ -893,7 +1025,9 @@ impl DocumentCore { para_mods.tab_def_id = Some(tab_id); } - let new_para_id = self.document.find_or_create_para_shape(base_para_id, ¶_mods); + let new_para_id = self + .document + .find_or_create_para_shape(base_para_id, ¶_mods); { let hf_para = self.get_hf_paragraph_mut(section_idx, is_header, apply_to, 0)?; hf_para.para_shape_id = new_para_id; @@ -902,13 +1036,20 @@ impl DocumentCore { // 9) 볼드+밑줄 스타일 적용 if styled { let base_char_id = { - let para = self.get_hf_paragraph_ref(section_idx, is_header, apply_to, 0).unwrap(); - para.char_shapes.first().map(|cs| cs.char_shape_id).unwrap_or(0) + let para = self + .get_hf_paragraph_ref(section_idx, is_header, apply_to, 0) + .unwrap(); + para.char_shapes + .first() + .map(|cs| cs.char_shape_id) + .unwrap_or(0) }; let mut char_mods = CharShapeMods::default(); char_mods.bold = Some(true); char_mods.underline_type = Some(UnderlineType::Bottom); - let new_char_id = self.document.find_or_create_char_shape(base_char_id, &char_mods); + let new_char_id = self + .document + .find_or_create_char_shape(base_char_id, &char_mods); let hf_para = self.get_hf_paragraph_mut(section_idx, is_header, apply_to, 0)?; // 전체 텍스트에 새 CharShape 적용 @@ -1001,7 +1142,9 @@ mod tests { let mut core = make_test_core(); core.create_header_footer_native(0, true, 0).unwrap(); - let result = core.insert_text_in_header_footer_native(0, true, 0, 0, 0, "Hello").unwrap(); + let result = core + .insert_text_in_header_footer_native(0, true, 0, 0, 0, "Hello") + .unwrap(); assert!(result.contains("\"charOffset\":5")); let result = core.get_header_footer_native(0, true, 0).unwrap(); @@ -1012,9 +1155,12 @@ mod tests { fn test_delete_text_in_header() { let mut core = make_test_core(); core.create_header_footer_native(0, true, 0).unwrap(); - core.insert_text_in_header_footer_native(0, true, 0, 0, 0, "Hello World").unwrap(); + core.insert_text_in_header_footer_native(0, true, 0, 0, 0, "Hello World") + .unwrap(); - let result = core.delete_text_in_header_footer_native(0, true, 0, 0, 5, 6).unwrap(); + let result = core + .delete_text_in_header_footer_native(0, true, 0, 0, 5, 6) + .unwrap(); assert!(result.contains("\"charOffset\":5")); let result = core.get_header_footer_native(0, true, 0).unwrap(); @@ -1025,24 +1171,33 @@ mod tests { fn test_split_merge_paragraph_in_header() { let mut core = make_test_core(); core.create_header_footer_native(0, true, 0).unwrap(); - core.insert_text_in_header_footer_native(0, true, 0, 0, 0, "HelloWorld").unwrap(); + core.insert_text_in_header_footer_native(0, true, 0, 0, 0, "HelloWorld") + .unwrap(); // 문단 분할 - let result = core.split_paragraph_in_header_footer_native(0, true, 0, 0, 5).unwrap(); + let result = core + .split_paragraph_in_header_footer_native(0, true, 0, 0, 5) + .unwrap(); assert!(result.contains("\"hfParaIndex\":1")); assert!(result.contains("\"charOffset\":0")); // 문단 수 확인 - let result = core.get_header_footer_para_info_native(0, true, 0, 0).unwrap(); + let result = core + .get_header_footer_para_info_native(0, true, 0, 0) + .unwrap(); assert!(result.contains("\"paraCount\":2")); // 문단 병합 - let result = core.merge_paragraph_in_header_footer_native(0, true, 0, 1).unwrap(); + let result = core + .merge_paragraph_in_header_footer_native(0, true, 0, 1) + .unwrap(); assert!(result.contains("\"hfParaIndex\":0")); assert!(result.contains("\"charOffset\":5")); // 문단 수 확인 - let result = core.get_header_footer_para_info_native(0, true, 0, 0).unwrap(); + let result = core + .get_header_footer_para_info_native(0, true, 0, 0) + .unwrap(); assert!(result.contains("\"paraCount\":1")); } @@ -1050,7 +1205,8 @@ mod tests { fn test_delete_header_footer() { let mut core = make_test_core(); core.create_header_footer_native(0, true, 0).unwrap(); - core.insert_text_in_header_footer_native(0, true, 0, 0, 0, "머리말 텍스트").unwrap(); + core.insert_text_in_header_footer_native(0, true, 0, 0, 0, "머리말 텍스트") + .unwrap(); let result = core.delete_header_footer_native(0, true, 0).unwrap(); assert!(result.contains("\"ok\":true")); @@ -1118,19 +1274,41 @@ mod tests { for si in 0..num_sections { let section = &core.document.sections[si]; - eprintln!("\n--- 구역 {} (문단 {}개) ---", si, section.paragraphs.len()); + eprintln!( + "\n--- 구역 {} (문단 {}개) ---", + si, + section.paragraphs.len() + ); for (pi, para) in section.paragraphs.iter().enumerate() { for (ci, ctrl) in para.controls.iter().enumerate() { match ctrl { Control::Header(h) => { - eprintln!(" 머리말: para[{}].ctrl[{}] apply_to={:?} 문단수={} 텍스트=[{}]", - pi, ci, h.apply_to, h.paragraphs.len(), - h.paragraphs.iter().map(|p| p.text.clone()).collect::>().join("|")); + eprintln!( + " 머리말: para[{}].ctrl[{}] apply_to={:?} 문단수={} 텍스트=[{}]", + pi, + ci, + h.apply_to, + h.paragraphs.len(), + h.paragraphs + .iter() + .map(|p| p.text.clone()) + .collect::>() + .join("|") + ); } Control::Footer(f) => { - eprintln!(" 꼬리말: para[{}].ctrl[{}] apply_to={:?} 문단수={} 텍스트=[{}]", - pi, ci, f.apply_to, f.paragraphs.len(), - f.paragraphs.iter().map(|p| p.text.clone()).collect::>().join("|")); + eprintln!( + " 꼬리말: para[{}].ctrl[{}] apply_to={:?} 문단수={} 텍스트=[{}]", + pi, + ci, + f.apply_to, + f.paragraphs.len(), + f.paragraphs + .iter() + .map(|p| p.text.clone()) + .collect::>() + .join("|") + ); } _ => {} } @@ -1143,12 +1321,18 @@ mod tests { for page_num in 0..core.page_count() { if let Ok((pc, _, _)) = core.find_page(page_num) { let hdr = if let Some(ref h) = pc.active_header { - format!("sec={} para={} ctrl={}", h.source_section_index, h.para_index, h.control_index) + format!( + "sec={} para={} ctrl={}", + h.source_section_index, h.para_index, h.control_index + ) } else { "없음".to_string() }; let ftr = if let Some(ref f) = pc.active_footer { - format!("sec={} para={} ctrl={}", f.source_section_index, f.para_index, f.control_index) + format!( + "sec={} para={} ctrl={}", + f.source_section_index, f.para_index, f.control_index + ) } else { "없음".to_string() }; @@ -1159,9 +1343,14 @@ mod tests { // 편집 시나리오: 7페이지(인덱스6), 8페이지(인덱스7) 머리말 구조 확인 for apply_to_val in [0u8, 1, 2] { for sec_idx in 0..num_sections { - let result = core.get_header_footer_native(sec_idx, true, apply_to_val).unwrap(); + let result = core + .get_header_footer_native(sec_idx, true, apply_to_val) + .unwrap(); if result.contains("\"exists\":true") { - eprintln!(" get_header_footer(sec={}, is_header=true, apply_to={}) => {}", sec_idx, apply_to_val, result); + eprintln!( + " get_header_footer(sec={}, is_header=true, apply_to={}) => {}", + sec_idx, apply_to_val, result + ); } } } @@ -1171,11 +1360,17 @@ mod tests { for page in [6u32, 7] { // hitTestHeaderFooter 호출 (x=100, y=20 — 머리말 영역 가정) let hf_hit = core.hit_test_header_footer_native(page, 100.0, 20.0); - eprintln!(" 페이지 {} hitTestHeaderFooter(100,20) => {:?}", page, hf_hit); + eprintln!( + " 페이지 {} hitTestHeaderFooter(100,20) => {:?}", + page, hf_hit + ); // 더 넓은 영역으로도 시도 (y=50) let hf_hit2 = core.hit_test_header_footer_native(page, 200.0, 50.0); - eprintln!(" 페이지 {} hitTestHeaderFooter(200,50) => {:?}", page, hf_hit2); + eprintln!( + " 페이지 {} hitTestHeaderFooter(200,50) => {:?}", + page, hf_hit2 + ); } // 실제 페이지 정보에서 active_header의 apply_to 직접 확인 @@ -1194,9 +1389,14 @@ mod tests { Control::Footer(f) => apply_to_u8(f.apply_to), _ => 255, }; - eprintln!(" 페이지 {} → sec={}, para={}, ctrl={}, apply_to={}", page, sec, pi, ci, apply_to); + eprintln!( + " 페이지 {} → sec={}, para={}, ctrl={}, apply_to={}", + page, sec, pi, ci, apply_to + ); // 이 apply_to로 텍스트 삽입 가능한지 확인 - let result = core.insert_text_in_header_footer_native(sec, true, apply_to, 0, 0, "T"); + let result = core.insert_text_in_header_footer_native( + sec, true, apply_to, 0, 0, "T", + ); eprintln!(" insert_text => {:?}", result); } } @@ -1211,10 +1411,23 @@ mod tests { eprintln!("\n--- 텍스트 삽입 테스트 ---"); for apply_to_val in [0u8, 1, 2] { for sec_idx in 0..num_sections { - let result = core.insert_text_in_header_footer_native(sec_idx, true, apply_to_val, 0, 0, "X"); + let result = core.insert_text_in_header_footer_native( + sec_idx, + true, + apply_to_val, + 0, + 0, + "X", + ); match result { - Ok(r) => eprintln!(" insert(sec={}, apply_to={}) => OK: {}", sec_idx, apply_to_val, r), - Err(e) => eprintln!(" insert(sec={}, apply_to={}) => ERR: {}", sec_idx, apply_to_val, e), + Ok(r) => eprintln!( + " insert(sec={}, apply_to={}) => OK: {}", + sec_idx, apply_to_val, r + ), + Err(e) => eprintln!( + " insert(sec={}, apply_to={}) => ERR: {}", + sec_idx, apply_to_val, e + ), } } } @@ -1238,23 +1451,33 @@ mod tests { let para = &core.document.sections[0].paragraphs[0]; assert_eq!(para.controls.len(), controls_before + 1); // char_count는 컨트롤 1개(8 UTF-16 code units) 만큼 증가해야 함 - assert!(para.char_count >= (para.controls.len() as u32) * 8, + assert!( + para.char_count >= (para.controls.len() as u32) * 8, "char_count({})가 컨트롤 UTF-16 크기({})보다 작음", - para.char_count, para.controls.len() * 8); + para.char_count, + para.controls.len() * 8 + ); // 본문 문단 0에 텍스트 삽입 core.insert_text_native(0, 0, 0, "가나다").unwrap(); // composed 데이터에 삽입된 텍스트가 포함되어야 함 - let all_text: String = core.composed[0][0].lines.iter() + let all_text: String = core.composed[0][0] + .lines + .iter() .flat_map(|l| l.runs.iter().map(|r| r.text.as_str())) .collect(); - assert!(all_text.contains("가나다"), - "HF 컨트롤 추가 후 본문 텍스트가 composed에 누락됨: {:?}", all_text); + assert!( + all_text.contains("가나다"), + "HF 컨트롤 추가 후 본문 텍스트가 composed에 누락됨: {:?}", + all_text + ); // 렌더 트리에도 포함되어야 함 let tree = core.build_page_tree(0).unwrap(); - assert!(format!("{:?}", tree).contains("가나다"), - "렌더 트리에 본문 텍스트 없음"); + assert!( + format!("{:?}", tree).contains("가나다"), + "렌더 트리에 본문 텍스트 없음" + ); } } diff --git a/src/document_core/commands/html_import.rs b/src/document_core/commands/html_import.rs index e6afee5a..02008c11 100644 --- a/src/document_core/commands/html_import.rs +++ b/src/document_core/commands/html_import.rs @@ -1,11 +1,11 @@ //! HTML 붙여넣기 + HTML 파싱 관련 native 메서드 -use crate::model::control::Control; -use crate::model::paragraph::Paragraph; +use super::super::helpers::*; use crate::document_core::DocumentCore; use crate::error::HwpError; +use crate::model::control::Control; use crate::model::event::DocumentEvent; -use super::super::helpers::*; +use crate::model::paragraph::Paragraph; use crate::renderer::style_resolver::resolve_styles; impl DocumentCore { @@ -17,10 +17,16 @@ impl DocumentCore { html: &str, ) -> Result { if section_idx >= self.document.sections.len() { - return Err(HwpError::RenderError(format!("구역 {} 범위 초과", section_idx))); + return Err(HwpError::RenderError(format!( + "구역 {} 범위 초과", + section_idx + ))); } if para_idx >= self.document.sections[section_idx].paragraphs.len() { - return Err(HwpError::RenderError(format!("문단 {} 범위 초과", para_idx))); + return Err(HwpError::RenderError(format!( + "문단 {} 범위 초과", + para_idx + ))); } // HTML 파싱 → 문단 목록 생성 @@ -44,8 +50,12 @@ impl DocumentCore { .insert_text_at(char_offset, &clip_text); self.apply_clipboard_char_shapes( - section_idx, para_idx, char_offset, - &clip_char_shapes, &clip_char_offsets, new_chars, + section_idx, + para_idx, + char_offset, + &clip_char_shapes, + &clip_char_offsets, + new_chars, ); self.reflow_paragraph(section_idx, para_idx); @@ -53,7 +63,10 @@ impl DocumentCore { self.paginate_if_needed(); let new_offset = char_offset + new_chars; - self.event_log.push(DocumentEvent::HtmlImported { section: section_idx, para: para_idx }); + self.event_log.push(DocumentEvent::HtmlImported { + section: section_idx, + para: para_idx, + }); return Ok(format!( "{{\"ok\":true,\"paraIdx\":{},\"charOffset\":{}}}", para_idx, new_offset @@ -65,18 +78,21 @@ impl DocumentCore { if has_controls { // 컨트롤 포함 문단은 merge 불가 → 직접 삽입 - let right_half = self.document.sections[section_idx].paragraphs[para_idx] - .split_at(char_offset); + let right_half = + self.document.sections[section_idx].paragraphs[para_idx].split_at(char_offset); // 현재 문단 (왼쪽 반)이 비어있으면 첫 번째 파싱 문단으로 대체 - let left_empty = self.document.sections[section_idx].paragraphs[para_idx].text.is_empty(); + let left_empty = self.document.sections[section_idx].paragraphs[para_idx] + .text + .is_empty(); let mut insert_idx = if left_empty { // 빈 왼쪽 문단을 첫 번째 파싱 문단으로 대체 self.document.sections[section_idx].paragraphs[para_idx] = parsed_paras[0].clone(); let idx = para_idx + 1; for i in 1..clip_count { - self.document.sections[section_idx].paragraphs + self.document.sections[section_idx] + .paragraphs .insert(idx + i - 1, parsed_paras[i].clone()); } para_idx + clip_count @@ -84,7 +100,8 @@ impl DocumentCore { // 왼쪽 문단에 텍스트 → 파싱 문단들을 그 뒤에 삽입 let idx = para_idx + 1; for i in 0..clip_count { - self.document.sections[section_idx].paragraphs + self.document.sections[section_idx] + .paragraphs .insert(idx + i, parsed_paras[i].clone()); } para_idx + 1 + clip_count @@ -94,7 +111,8 @@ impl DocumentCore { let last_para_idx; let merge_point; if !right_half.text.is_empty() { - self.document.sections[section_idx].paragraphs + self.document.sections[section_idx] + .paragraphs .insert(insert_idx, right_half); last_para_idx = insert_idx; merge_point = 0; @@ -116,7 +134,10 @@ impl DocumentCore { } self.paginate_if_needed(); - self.event_log.push(DocumentEvent::HtmlImported { section: section_idx, para: para_idx }); + self.event_log.push(DocumentEvent::HtmlImported { + section: section_idx, + para: para_idx, + }); return Ok(format!( "{{\"ok\":true,\"paraIdx\":{},\"charOffset\":{}}}", last_para_idx, merge_point @@ -124,22 +145,22 @@ impl DocumentCore { } // 다중 문단 삽입 (컨트롤 없는 텍스트만) - let right_half = self.document.sections[section_idx].paragraphs[para_idx] - .split_at(char_offset); + let right_half = + self.document.sections[section_idx].paragraphs[para_idx].split_at(char_offset); - self.document.sections[section_idx].paragraphs[para_idx] - .merge_from(&parsed_paras[0]); + self.document.sections[section_idx].paragraphs[para_idx].merge_from(&parsed_paras[0]); let mut insert_idx = para_idx + 1; for i in 1..clip_count { - self.document.sections[section_idx].paragraphs + self.document.sections[section_idx] + .paragraphs .insert(insert_idx, parsed_paras[i].clone()); insert_idx += 1; } let last_para_idx = insert_idx - 1; - let merge_point = self.document.sections[section_idx].paragraphs[last_para_idx] - .merge_from(&right_half); + let merge_point = + self.document.sections[section_idx].paragraphs[last_para_idx].merge_from(&right_half); for i in para_idx..=last_para_idx { self.reflow_paragraph(section_idx, i); @@ -152,7 +173,10 @@ impl DocumentCore { } self.paginate_if_needed(); - self.event_log.push(DocumentEvent::HtmlImported { section: section_idx, para: para_idx }); + self.event_log.push(DocumentEvent::HtmlImported { + section: section_idx, + para: para_idx, + }); Ok(format!( "{{\"ok\":true,\"paraIdx\":{},\"charOffset\":{}}}", last_para_idx, merge_point @@ -172,20 +196,28 @@ impl DocumentCore { ) -> Result { // 셀 접근 검증 let cell_para_count = { - let section = self.document.sections.get(section_idx) - .ok_or_else(|| HwpError::RenderError(format!("구역 {} 범위 초과", section_idx)))?; - let para = section.paragraphs.get(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 {} 범위 초과", parent_para_idx)))?; + let section = + self.document.sections.get(section_idx).ok_or_else(|| { + HwpError::RenderError(format!("구역 {} 범위 초과", section_idx)) + })?; + let para = section.paragraphs.get(parent_para_idx).ok_or_else(|| { + HwpError::RenderError(format!("문단 {} 범위 초과", parent_para_idx)) + })?; let table = match para.controls.get(control_idx) { Some(Control::Table(t)) => t, _ => return Err(HwpError::RenderError("표가 아님".to_string())), }; - let cell = table.cells.get(cell_idx) + let cell = table + .cells + .get(cell_idx) .ok_or_else(|| HwpError::RenderError(format!("셀 {} 범위 초과", cell_idx)))?; cell.paragraphs.len() }; if cell_para_idx >= cell_para_count { - return Err(HwpError::RenderError(format!("셀 문단 {} 범위 초과", cell_para_idx))); + return Err(HwpError::RenderError(format!( + "셀 문단 {} 범위 초과", + cell_para_idx + ))); } let parsed_paras = self.parse_html_to_paragraphs(html); @@ -198,32 +230,47 @@ impl DocumentCore { let clip_count = parsed_paras.len(); // 셀 내부에는 Table Control 중첩 불가 → 컨트롤 포함 문단은 텍스트만 추출 - let parsed_paras: Vec = parsed_paras.into_iter().map(|mut p| { - if !p.controls.is_empty() { - // 컨트롤 문단은 텍스트로 대체 - let text = if p.text.is_empty() || p.text == "\u{0002}" { - // Table/Picture 등 컨트롤 → 셀 텍스트 추출 - match p.controls.first() { - Some(Control::Table(tbl)) => { - tbl.cells.iter() - .map(|c| c.paragraphs.iter().map(|cp| cp.text.clone()).collect::>().join(" ")) + let parsed_paras: Vec = parsed_paras + .into_iter() + .map(|mut p| { + if !p.controls.is_empty() { + // 컨트롤 문단은 텍스트로 대체 + let text = if p.text.is_empty() || p.text == "\u{0002}" { + // Table/Picture 등 컨트롤 → 셀 텍스트 추출 + match p.controls.first() { + Some(Control::Table(tbl)) => tbl + .cells + .iter() + .map(|c| { + c.paragraphs + .iter() + .map(|cp| cp.text.clone()) + .collect::>() + .join(" ") + }) .collect::>() - .join("\t") - }, - _ => String::new(), - } - } else { - p.text.clone() - }; - p.controls.clear(); - p.text = text; - p.char_count = p.text.encode_utf16().count() as u32; - p.char_offsets = p.text.chars() - .scan(0u32, |acc, c| { let off = *acc; *acc += c.len_utf16() as u32; Some(off) }) - .collect(); - } - p - }).collect(); + .join("\t"), + _ => String::new(), + } + } else { + p.text.clone() + }; + p.controls.clear(); + p.text = text; + p.char_count = p.text.encode_utf16().count() as u32; + p.char_offsets = p + .text + .chars() + .scan(0u32, |acc, c| { + let off = *acc; + *acc += c.len_utf16() as u32; + Some(off) + }) + .collect(); + } + p + }) + .collect(); let clip_count = parsed_paras.len(); let cell_paras = { @@ -245,22 +292,37 @@ impl DocumentCore { let clip_char_shapes = parsed_paras[0].char_shapes.clone(); let clip_char_offsets = parsed_paras[0].char_offsets.clone(); Self::apply_clipboard_char_shapes_to_para( - &mut cell_paras[cell_para_idx], char_offset, - &clip_char_shapes, &clip_char_offsets, new_chars, + &mut cell_paras[cell_para_idx], + char_offset, + &clip_char_shapes, + &clip_char_offsets, + new_chars, ); let _ = cell_paras; - self.reflow_cell_paragraph(section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx); + self.reflow_cell_paragraph( + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ); // 부모 표 dirty 마킹 + 재페이지네이션 (셀 편집 → composed 불변) - if let Some(Control::Table(t)) = self.document.sections[section_idx] - .paragraphs[parent_para_idx].controls.get_mut(control_idx) { + if let Some(Control::Table(t)) = self.document.sections[section_idx].paragraphs + [parent_para_idx] + .controls + .get_mut(control_idx) + { t.dirty = true; } self.mark_section_dirty(section_idx); self.paginate_if_needed(); let new_offset = char_offset + new_chars; - self.event_log.push(DocumentEvent::HtmlImported { section: section_idx, para: parent_para_idx }); + self.event_log.push(DocumentEvent::HtmlImported { + section: section_idx, + para: parent_para_idx, + }); return Ok(format!( "{{\"ok\":true,\"cellParaIdx\":{},\"charOffset\":{}}}", cell_para_idx, new_offset @@ -285,14 +347,20 @@ impl DocumentCore { self.reflow_cell_paragraph(section_idx, parent_para_idx, control_idx, cell_idx, i); } // 부모 표 dirty 마킹 + 재페이지네이션 (셀 편집 → composed 불변) - if let Some(Control::Table(t)) = self.document.sections[section_idx] - .paragraphs[parent_para_idx].controls.get_mut(control_idx) { + if let Some(Control::Table(t)) = self.document.sections[section_idx].paragraphs + [parent_para_idx] + .controls + .get_mut(control_idx) + { t.dirty = true; } self.mark_section_dirty(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::HtmlImported { section: section_idx, para: parent_para_idx }); + self.event_log.push(DocumentEvent::HtmlImported { + section: section_idx, + para: parent_para_idx, + }); Ok(format!( "{{\"ok\":true,\"cellParaIdx\":{},\"charOffset\":{}}}", last_para_idx, merge_point @@ -343,7 +411,9 @@ impl DocumentCore { // 태그 시작 let tag_start = pos; let tag_end = find_char(&chars, pos, '>'); - if tag_end >= len { break; } + if tag_end >= len { + break; + } let tag_str: String = chars[tag_start..=tag_end].iter().collect(); let tag_lower = tag_str.to_lowercase(); @@ -410,7 +480,8 @@ impl DocumentCore { // div 내부의 콘텐츠를 재귀적으로 처리 let div_content_start = tag_end + 1; let div_end = find_closing_tag_chars(&chars, pos, "div"); - let div_inner: String = chars[div_content_start..div_end.min(len)].iter().collect(); + let div_inner: String = + chars[div_content_start..div_end.min(len)].iter().collect(); let div_inner = if let Some(idx) = div_inner.rfind("") { &div_inner[..idx] } else { @@ -441,7 +512,8 @@ impl DocumentCore { if tag_lower.starts_with("... 인라인 콘텐츠 let span_end = find_closing_tag_chars(&chars, pos, "span"); - let span_full: String = chars[tag_start..span_end.min(len)].iter().collect(); + let span_full: String = + chars[tag_start..span_end.min(len)].iter().collect(); let span_full = if let Some(idx) = span_full.rfind("") { &span_full[..idx] } else { @@ -476,8 +548,14 @@ impl DocumentCore { let mut para = Paragraph::default(); para.text = plain; para.char_count = para.text.encode_utf16().count() as u32; - para.char_offsets = para.text.chars() - .scan(0u32, |acc, c| { let off = *acc; *acc += c.len_utf16() as u32; Some(off) }) + para.char_offsets = para + .text + .chars() + .scan(0u32, |acc, c| { + let off = *acc; + *acc += c.len_utf16() as u32; + Some(off) + }) .collect(); paragraphs.push(para); } @@ -491,12 +569,20 @@ impl DocumentCore { let decoded = decode_html_entities(text); for line in decoded.split('\n') { let trimmed = line.trim(); - if trimmed.is_empty() { continue; } + if trimmed.is_empty() { + continue; + } let mut para = Paragraph::default(); para.text = trimmed.to_string(); para.char_count = para.text.encode_utf16().count() as u32; - para.char_offsets = para.text.chars() - .scan(0u32, |acc, c| { let off = *acc; *acc += c.len_utf16() as u32; Some(off) }) + para.char_offsets = para + .text + .chars() + .scan(0u32, |acc, c| { + let off = *acc; + *acc += c.len_utf16() as u32; + Some(off) + }) .collect(); paragraphs.push(para); } @@ -520,7 +606,9 @@ impl DocumentCore { while pos < len { if chars[pos] == '<' { let tag_end = find_char(&chars, pos, '>'); - if tag_end >= len { break; } + if tag_end >= len { + break; + } let tag_str: String = chars[pos..=tag_end].iter().collect(); let tag_lower = tag_str.to_lowercase(); @@ -533,7 +621,9 @@ impl DocumentCore { let close_chars: Vec = "".chars().collect(); let mut found = None; for i in inner_start..len.saturating_sub(close_chars.len() - 1) { - let slice: String = chars[i..i + close_chars.len().min(len - i)].iter().collect(); + let slice: String = chars[i..i + close_chars.len().min(len - i)] + .iter() + .collect(); if slice.to_lowercase() == "" { found = Some(i); break; @@ -547,7 +637,10 @@ impl DocumentCore { if !inner_text.is_empty() { let css = parse_inline_style(&tag_str); let char_shape_id = self.css_to_char_shape_id( - &css, inherited_bold, inherited_italic, inherited_underline, + &css, + inherited_bold, + inherited_italic, + inherited_underline, ); let start = full_text.chars().count(); full_text.push_str(&inner_text); @@ -601,14 +694,28 @@ impl DocumentCore { if !decoded.is_empty() { if inherited_bold || inherited_italic || inherited_underline { let css_parts: Vec = [ - if inherited_bold { Some("font-weight:bold".to_string()) } else { None }, - if inherited_italic { Some("font-style:italic".to_string()) } else { None }, - if inherited_underline { Some("text-decoration:underline".to_string()) } else { None }, - ].into_iter().flatten().collect(); + if inherited_bold { + Some("font-weight:bold".to_string()) + } else { + None + }, + if inherited_italic { + Some("font-style:italic".to_string()) + } else { + None + }, + if inherited_underline { + Some("text-decoration:underline".to_string()) + } else { + None + }, + ] + .into_iter() + .flatten() + .collect(); let fake_css = css_parts.join(";"); - let char_shape_id = self.css_to_char_shape_id( - &fake_css, false, false, false, - ); + let char_shape_id = + self.css_to_char_shape_id(&fake_css, false, false, false); let start = full_text.chars().count(); full_text.push_str(&decoded); let end = full_text.chars().count(); @@ -623,20 +730,30 @@ impl DocumentCore { para.text = full_text; para.char_count = para.text.encode_utf16().count() as u32; - para.char_offsets = para.text.chars() - .scan(0u32, |acc, c| { let off = *acc; *acc += c.len_utf16() as u32; Some(off) }) + para.char_offsets = para + .text + .chars() + .scan(0u32, |acc, c| { + let off = *acc; + *acc += c.len_utf16() as u32; + Some(off) + }) .collect(); // 스타일 범위를 CharShapeRef로 변환 for (start, _end, char_shape_id) in &style_runs { // char index → UTF-16 위치 - let utf16_pos: u32 = para.text.chars().take(*start) + let utf16_pos: u32 = para + .text + .chars() + .take(*start) .map(|c| c.len_utf16() as u32) .sum(); - para.char_shapes.push(crate::model::paragraph::CharShapeRef { - start_pos: utf16_pos, - char_shape_id: *char_shape_id, - }); + para.char_shapes + .push(crate::model::paragraph::CharShapeRef { + start_pos: utf16_pos, + char_shape_id: *char_shape_id, + }); } } @@ -651,8 +768,13 @@ impl DocumentCore { use crate::model::style::{CharShape, UnderlineType}; // 기본 CharShape를 기반으로 수정 - let base_id = if !self.document.doc_info.char_shapes.is_empty() { 0u32 } else { - self.document.doc_info.char_shapes.push(CharShape::default()); + let base_id = if !self.document.doc_info.char_shapes.is_empty() { + 0u32 + } else { + self.document + .doc_info + .char_shapes + .push(CharShape::default()); 0 }; let mut cs = self.document.doc_info.char_shapes[base_id as usize].clone(); @@ -662,7 +784,10 @@ impl DocumentCore { // font-family if let Some(font_name) = parse_css_value(&css_lower, "font-family") { - let clean_name = font_name.trim_matches(|c: char| c == '\'' || c == '"').trim().to_string(); + let clean_name = font_name + .trim_matches(|c: char| c == '\'' || c == '"') + .trim() + .to_string(); if !clean_name.is_empty() { if let Some(font_id) = self.find_font_id(&clean_name) { cs.font_ids = [font_id; 7]; @@ -704,7 +829,11 @@ impl DocumentCore { || css_lower.contains("text-decoration:underline") || css_lower.contains("text-decoration: underline") || css_lower.contains("text-decoration-line:underline"); - cs.underline_type = if has_underline { UnderlineType::Bottom } else { UnderlineType::None }; + cs.underline_type = if has_underline { + UnderlineType::Bottom + } else { + UnderlineType::None + }; let has_strikethrough = css_lower.contains("text-decoration:line-through") || css_lower.contains("text-decoration: line-through") @@ -736,7 +865,10 @@ impl DocumentCore { } let base_id: u16 = 0; - let mut ps = self.document.doc_info.para_shapes + let mut ps = self + .document + .doc_info + .para_shapes .get(base_id as usize) .cloned() .unwrap_or_default(); @@ -790,7 +922,10 @@ impl DocumentCore { let name_lower = name.to_lowercase(); // 한글 폰트 (인덱스 0)를 먼저, 영어 폰트 (인덱스 1)를 다음으로 검색 for lang_idx in 0..self.document.doc_info.font_faces.len() { - for (font_idx, font) in self.document.doc_info.font_faces[lang_idx].iter().enumerate() { + for (font_idx, font) in self.document.doc_info.font_faces[lang_idx] + .iter() + .enumerate() + { if font.name.to_lowercase() == name_lower { return Some(font_idx as u16); } @@ -798,5 +933,4 @@ impl DocumentCore { } None } - } diff --git a/src/document_core/commands/mod.rs b/src/document_core/commands/mod.rs index 5a505f8c..016561f1 100644 --- a/src/document_core/commands/mod.rs +++ b/src/document_core/commands/mod.rs @@ -1,9 +1,9 @@ +mod clipboard; mod document; -mod text_editing; -mod table_ops; -mod object_ops; +mod footnote_ops; mod formatting; -mod clipboard; -mod html_import; mod header_footer_ops; -mod footnote_ops; +mod html_import; +mod object_ops; +mod table_ops; +mod text_editing; diff --git a/src/document_core/commands/object_ops.rs b/src/document_core/commands/object_ops.rs index 77a17d18..373d935e 100644 --- a/src/document_core/commands/object_ops.rs +++ b/src/document_core/commands/object_ops.rs @@ -1,12 +1,12 @@ //! 그림 속성/삽입/삭제 + 표 생성 + 셀 bbox 관련 native 메서드 -use crate::model::control::Control; -use crate::model::shape::ShapeObject; -use crate::model::paragraph::Paragraph; +use super::super::helpers::get_textbox_from_shape; use crate::document_core::DocumentCore; use crate::error::HwpError; +use crate::model::control::Control; use crate::model::event::DocumentEvent; -use super::super::helpers::get_textbox_from_shape; +use crate::model::paragraph::Paragraph; +use crate::model::shape::ShapeObject; impl DocumentCore { pub fn get_picture_properties_native( @@ -15,16 +15,23 @@ impl DocumentCore { parent_para_idx: usize, control_idx: usize, ) -> Result { - let section = self.document.sections.get(section_idx) - .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))?; - let para = section.paragraphs.get(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)))?; - let ctrl = para.controls.get(control_idx) - .ok_or_else(|| HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)))?; + let section = self.document.sections.get(section_idx).ok_or_else(|| { + HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)) + })?; + let para = section.paragraphs.get(parent_para_idx).ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)) + })?; + let ctrl = para.controls.get(control_idx).ok_or_else(|| { + HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)) + })?; let pic = match ctrl { crate::model::control::Control::Picture(p) => p, - _ => return Err(HwpError::RenderError("지정된 컨트롤이 그림이 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "지정된 컨트롤이 그림이 아닙니다".to_string(), + )) + } }; let c = &pic.common; @@ -145,23 +152,36 @@ impl DocumentCore { props_json: &str, ) -> Result { // JSON 파싱 (serde_json 사용 대신 수동 파싱 — 기존 패턴) - let section = self.document.sections.get_mut(section_idx) - .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))?; - let para = section.paragraphs.get_mut(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)))?; - let ctrl = para.controls.get_mut(control_idx) - .ok_or_else(|| HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)))?; + let section = self.document.sections.get_mut(section_idx).ok_or_else(|| { + HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)) + })?; + let para = section.paragraphs.get_mut(parent_para_idx).ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)) + })?; + let ctrl = para.controls.get_mut(control_idx).ok_or_else(|| { + HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)) + })?; let pic = match ctrl { crate::model::control::Control::Picture(p) => p, - _ => return Err(HwpError::RenderError("지정된 컨트롤이 그림이 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "지정된 컨트롤이 그림이 아닙니다".to_string(), + )) + } }; - use super::super::helpers::{json_u32, json_i32, json_i16, json_bool, json_str}; + use super::super::helpers::{json_bool, json_i16, json_i32, json_str, json_u32}; // 크기 변경 - if let Some(w) = json_u32(props_json, "width") { pic.common.width = w; pic.shape_attr.current_width = w; } - if let Some(h) = json_u32(props_json, "height") { pic.common.height = h; pic.shape_attr.current_height = h; } + if let Some(w) = json_u32(props_json, "width") { + pic.common.width = w; + pic.shape_attr.current_width = w; + } + if let Some(h) = json_u32(props_json, "height") { + pic.common.height = h; + pic.shape_attr.current_height = h; + } // 위치 속성 if let Some(tac) = json_bool(props_json, "treatAsChar") { @@ -217,12 +237,20 @@ impl DocumentCore { _ => pic.common.text_wrap, }; } - if let Some(v) = json_u32(props_json, "vertOffset") { pic.common.vertical_offset = v; } - if let Some(v) = json_u32(props_json, "horzOffset") { pic.common.horizontal_offset = v; } + if let Some(v) = json_u32(props_json, "vertOffset") { + pic.common.vertical_offset = v; + } + if let Some(v) = json_u32(props_json, "horzOffset") { + pic.common.horizontal_offset = v; + } // 이미지 속성 - if let Some(v) = json_i32(props_json, "brightness") { pic.image_attr.brightness = v as i8; } - if let Some(v) = json_i32(props_json, "contrast") { pic.image_attr.contrast = v as i8; } + if let Some(v) = json_i32(props_json, "brightness") { + pic.image_attr.brightness = v as i8; + } + if let Some(v) = json_i32(props_json, "contrast") { + pic.image_attr.contrast = v as i8; + } if let Some(v) = json_str(props_json, "effect") { pic.image_attr.effect = match v.as_str() { "GrayScale" => crate::model::image::ImageEffect::GrayScale, @@ -233,37 +261,75 @@ impl DocumentCore { } // 회전/대칭 - if let Some(v) = json_i16(props_json, "rotationAngle") { pic.shape_attr.rotation_angle = v; } + if let Some(v) = json_i16(props_json, "rotationAngle") { + pic.shape_attr.rotation_angle = v; + } if let Some(v) = json_bool(props_json, "horzFlip") { pic.shape_attr.horz_flip = v; - if v { pic.shape_attr.flip |= 0x01; } else { pic.shape_attr.flip &= !0x01; } + if v { + pic.shape_attr.flip |= 0x01; + } else { + pic.shape_attr.flip &= !0x01; + } } if let Some(v) = json_bool(props_json, "vertFlip") { pic.shape_attr.vert_flip = v; - if v { pic.shape_attr.flip |= 0x02; } else { pic.shape_attr.flip &= !0x02; } + if v { + pic.shape_attr.flip |= 0x02; + } else { + pic.shape_attr.flip &= !0x02; + } } // 자르기 - if let Some(v) = json_i32(props_json, "cropLeft") { pic.crop.left = v; } - if let Some(v) = json_i32(props_json, "cropTop") { pic.crop.top = v; } - if let Some(v) = json_i32(props_json, "cropRight") { pic.crop.right = v; } - if let Some(v) = json_i32(props_json, "cropBottom") { pic.crop.bottom = v; } + if let Some(v) = json_i32(props_json, "cropLeft") { + pic.crop.left = v; + } + if let Some(v) = json_i32(props_json, "cropTop") { + pic.crop.top = v; + } + if let Some(v) = json_i32(props_json, "cropRight") { + pic.crop.right = v; + } + if let Some(v) = json_i32(props_json, "cropBottom") { + pic.crop.bottom = v; + } // 안쪽 여백 (그림 여백) - if let Some(v) = json_i16(props_json, "paddingLeft") { pic.padding.left = v; } - if let Some(v) = json_i16(props_json, "paddingTop") { pic.padding.top = v; } - if let Some(v) = json_i16(props_json, "paddingRight") { pic.padding.right = v; } - if let Some(v) = json_i16(props_json, "paddingBottom") { pic.padding.bottom = v; } + if let Some(v) = json_i16(props_json, "paddingLeft") { + pic.padding.left = v; + } + if let Some(v) = json_i16(props_json, "paddingTop") { + pic.padding.top = v; + } + if let Some(v) = json_i16(props_json, "paddingRight") { + pic.padding.right = v; + } + if let Some(v) = json_i16(props_json, "paddingBottom") { + pic.padding.bottom = v; + } // 바깥 여백 - if let Some(v) = json_i16(props_json, "outerMarginLeft") { pic.common.margin.left = v; } - if let Some(v) = json_i16(props_json, "outerMarginTop") { pic.common.margin.top = v; } - if let Some(v) = json_i16(props_json, "outerMarginRight") { pic.common.margin.right = v; } - if let Some(v) = json_i16(props_json, "outerMarginBottom") { pic.common.margin.bottom = v; } + if let Some(v) = json_i16(props_json, "outerMarginLeft") { + pic.common.margin.left = v; + } + if let Some(v) = json_i16(props_json, "outerMarginTop") { + pic.common.margin.top = v; + } + if let Some(v) = json_i16(props_json, "outerMarginRight") { + pic.common.margin.right = v; + } + if let Some(v) = json_i16(props_json, "outerMarginBottom") { + pic.common.margin.bottom = v; + } // 테두리 - if let Some(v) = json_u32(props_json, "borderColor") { pic.border_color = v; } - if let Some(v) = json_i32(props_json, "borderWidth") { pic.border_width = v; } + if let Some(v) = json_u32(props_json, "borderColor") { + pic.border_color = v; + } + if let Some(v) = json_i32(props_json, "borderWidth") { + pic.border_width = v; + } // description if let Some(v) = json_str(props_json, "description") { @@ -283,13 +349,15 @@ impl DocumentCore { number_type: crate::model::control::AutoNumberType::Picture, ..Default::default() }; - cap.paragraphs.push(crate::model::paragraph::Paragraph::default()); + cap.paragraphs + .push(crate::model::paragraph::Paragraph::default()); // 캡션 텍스트 최대 폭 = 개체 폭 cap.max_width = pic.common.width; pic.caption = Some(cap); caption_created = true; // 번호 할당을 위해 컨트롤을 임시로 캡션에 추가 - pic.caption.as_mut().unwrap().paragraphs[0].controls + pic.caption.as_mut().unwrap().paragraphs[0] + .controls .push(crate::model::control::Control::AutoNumber(an)); // attr bit 29: 캡션 존재 플래그 (한컴 호환성) pic.common.attr |= 1 << 29; @@ -310,9 +378,15 @@ impl DocumentCore { _ => crate::model::shape::CaptionVertAlign::Top, }; } - if let Some(v) = json_u32(props_json, "captionWidth") { cap.width = v; } - if let Some(v) = json_i16(props_json, "captionSpacing") { cap.spacing = v; } - if let Some(v) = json_bool(props_json, "captionIncludeMargin") { cap.include_margin = v; } + if let Some(v) = json_u32(props_json, "captionWidth") { + cap.width = v; + } + if let Some(v) = json_i16(props_json, "captionSpacing") { + cap.spacing = v; + } + if let Some(v) = json_bool(props_json, "captionIncludeMargin") { + cap.include_margin = v; + } } else { // 캡션 제거 — 현재는 None 처리하지 않음 (캡션에 텍스트가 있을 수 있으므로) } @@ -323,15 +397,16 @@ impl DocumentCore { // AutoNumber가 번호를 렌더링하므로 텍스트에 번호를 넣지 않는다. if caption_created { crate::parser::assign_auto_numbers(&mut self.document); - let pic_mut = match &mut self.document.sections[section_idx] - .paragraphs[parent_para_idx].controls[control_idx] { + let pic_mut = match &mut self.document.sections[section_idx].paragraphs[parent_para_idx] + .controls[control_idx] + { crate::model::control::Control::Picture(p) => p, _ => unreachable!(), }; let para = &mut pic_mut.caption.as_mut().unwrap().paragraphs[0]; // "그림 " (3글자) + [AutoNumber 8 code units] + " " (1글자) para.text = "그림 ".to_string(); // 그림 + space + space (AutoNumber 사이) - // char_offsets: 그(0) 림(1) space(2) space(11=3+8, AutoNumber 뒤) + // char_offsets: 그(0) 림(1) space(2) space(11=3+8, AutoNumber 뒤) para.char_offsets = vec![0, 1, 2, 11]; // char_count = 텍스트 4 code units + AutoNumber 8 + 끝마커 1 = 13 para.char_count = 13; @@ -343,17 +418,24 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::PictureResized { section: section_idx, para: parent_para_idx, ctrl: control_idx }); + self.event_log.push(DocumentEvent::PictureResized { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + }); if caption_created { - let char_offset = match &self.document.sections[section_idx] - .paragraphs[parent_para_idx].controls[control_idx] { - crate::model::control::Control::Picture(p) => { - p.caption.as_ref().map_or(0, |c| - c.paragraphs.first().map_or(0, |p| p.text.chars().count())) - } + let char_offset = match &self.document.sections[section_idx].paragraphs[parent_para_idx] + .controls[control_idx] + { + crate::model::control::Control::Picture(p) => p.caption.as_ref().map_or(0, |c| { + c.paragraphs.first().map_or(0, |p| p.text.chars().count()) + }), _ => 0, }; - Ok(format!("{{\"ok\":true,\"captionCharOffset\":{}}}", char_offset)) + Ok(format!( + "{{\"ok\":true,\"captionCharOffset\":{}}}", + char_offset + )) } else { Ok("{\"ok\":true}".to_string()) } @@ -368,25 +450,31 @@ impl DocumentCore { ) -> Result { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과", section_idx + "구역 인덱스 {} 범위 초과", + section_idx ))); } let section = &mut self.document.sections[section_idx]; if parent_para_idx >= section.paragraphs.len() { return Err(HwpError::RenderError(format!( - "부모 문단 인덱스 {} 범위 초과", parent_para_idx + "부모 문단 인덱스 {} 범위 초과", + parent_para_idx ))); } let para = &mut section.paragraphs[parent_para_idx]; if control_idx >= para.controls.len() { return Err(HwpError::RenderError(format!( - "컨트롤 인덱스 {} 범위 초과", control_idx + "컨트롤 인덱스 {} 범위 초과", + control_idx ))); } // 그림 컨트롤인지 확인 - if !matches!(¶.controls[control_idx], crate::model::control::Control::Picture(_)) { + if !matches!( + ¶.controls[control_idx], + crate::model::control::Control::Picture(_) + ) { return Err(HwpError::RenderError( - "지정된 컨트롤이 그림이 아닙니다".to_string() + "지정된 컨트롤이 그림이 아닙니다".to_string(), )); } @@ -396,7 +484,11 @@ impl DocumentCore { let mut prev_end: u32 = 0; let mut gap_start: Option = None; 'outer: for i in 0..text_chars.len() { - let offset = if i < para.char_offsets.len() { para.char_offsets[i] } else { prev_end }; + let offset = if i < para.char_offsets.len() { + para.char_offsets[i] + } else { + prev_end + }; while prev_end + 8 <= offset && ci < para.controls.len() { if ci == control_idx { gap_start = Some(prev_end); @@ -405,9 +497,13 @@ impl DocumentCore { ci += 1; prev_end += 8; } - let char_size: u32 = if text_chars[i] == '\t' { 8 } - else if text_chars[i].len_utf16() == 2 { 2 } - else { 1 }; + let char_size: u32 = if text_chars[i] == '\t' { + 8 + } else if text_chars[i].len_utf16() == 2 { + 2 + } else { + 1 + }; prev_end = offset + char_size; } if gap_start.is_none() { @@ -449,7 +545,11 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::PictureDeleted { section: section_idx, para: parent_para_idx, ctrl: control_idx }); + self.event_log.push(DocumentEvent::PictureDeleted { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + }); Ok("{\"ok\":true}".to_string()) } @@ -463,13 +563,16 @@ impl DocumentCore { dpi: f64, ) { // 남은 컨트롤 중 가장 큰 높이 계산 - let max_remaining_ctrl_height = para.controls.iter().map(|ctrl| { - match ctrl { + let max_remaining_ctrl_height = para + .controls + .iter() + .map(|ctrl| match ctrl { Control::Picture(pic) => pic.common.height as i32, Control::Shape(shape) => shape.common().height as i32, _ => 0, - } - }).max().unwrap_or(0); + }) + .max() + .unwrap_or(0); if max_remaining_ctrl_height > 0 { // 아직 컨트롤이 남아있으면 가장 큰 컨트롤 높이로 설정 @@ -488,9 +591,7 @@ impl DocumentCore { } } else { // 텍스트가 있으면 reflow_line_segs로 재계산 - let seg_width = para.line_segs.first() - .map(|s| s.segment_width) - .unwrap_or(0); + let seg_width = para.line_segs.first().map(|s| s.segment_width).unwrap_or(0); let available_width_px = crate::renderer::hwpunit_to_px(seg_width, dpi); crate::renderer::composer::reflow_line_segs(para, available_width_px, styles, dpi); } @@ -511,36 +612,47 @@ impl DocumentCore { row_count: u16, col_count: u16, ) -> Result { - use crate::model::table::{Table, Cell, TablePageBreak}; use crate::model::paragraph::{CharShapeRef, LineSeg}; use crate::model::style::{BorderFill, BorderLine, BorderLineType, DiagonalLine, Fill}; + use crate::model::table::{Cell, Table, TablePageBreak}; // 유효성 검사 if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과 (총 {}개)", section_idx, self.document.sections.len() + "구역 인덱스 {} 범위 초과 (총 {}개)", + section_idx, + self.document.sections.len() ))); } if para_idx >= self.document.sections[section_idx].paragraphs.len() { return Err(HwpError::RenderError(format!( - "문단 인덱스 {} 범위 초과", para_idx + "문단 인덱스 {} 범위 초과", + para_idx ))); } if row_count == 0 || col_count == 0 || col_count > 256 { return Err(HwpError::RenderError(format!( - "행/열 수 범위 오류 (행={}, 열={}, 열은 1~256)", row_count, col_count + "행/열 수 범위 오류 (행={}, 열={}, 열은 1~256)", + row_count, col_count ))); } // --- 1. 편집 영역 폭 계산 --- let pd = &self.document.sections[section_idx].section_def.page_def; let outer_margin_lr: i32 = 283 * 2; // outer_margin left + right (~2mm) - let content_width = (pd.width as i32 - pd.margin_left as i32 - pd.margin_right as i32 - outer_margin_lr).max(7200) as u32; + let content_width = + (pd.width as i32 - pd.margin_left as i32 - pd.margin_right as i32 - outer_margin_lr) + .max(7200) as u32; // --- 2. 한컴 기본값 기반 셀 생성 (blank_h_saved.hwp 참조) --- let col_width = content_width / col_count as u32; // 한컴 기본: 셀 패딩 L=510 R=510 T=141 B=141 - let cell_pad = crate::model::Padding { left: 510, right: 510, top: 141, bottom: 141 }; + let cell_pad = crate::model::Padding { + left: 510, + right: 510, + top: 141, + bottom: 141, + }; // 한컴 기본: 셀 높이 = top + bottom padding (빈 셀 최소 높이) let cell_height: u32 = (cell_pad.top + cell_pad.bottom) as u32; // 한컴 기본: 행 렌더링 높이 = padding_top + line_height(1000) + padding_bottom @@ -551,18 +663,28 @@ impl DocumentCore { // BorderFill: 실선 테두리가 있는 기존 항목 재사용, 없으면 새로 생성 let cell_border_fill_id = { let existing = self.document.doc_info.border_fills.iter().position(|bf| { - bf.borders.iter().all(|b| b.line_type == BorderLineType::Solid && b.width >= 1) + bf.borders + .iter() + .all(|b| b.line_type == BorderLineType::Solid && b.width >= 1) }); if let Some(idx) = existing { (idx + 1) as u16 // 1-based } else { // 실선 BorderFill이 없으면 새로 생성 - let solid_border = BorderLine { line_type: BorderLineType::Solid, width: 1, color: 0 }; + let solid_border = BorderLine { + line_type: BorderLineType::Solid, + width: 1, + color: 0, + }; let new_bf = BorderFill { raw_data: None, attr: 0, borders: [solid_border, solid_border, solid_border, solid_border], - diagonal: DiagonalLine { diagonal_type: 1, width: 0, color: 0 }, + diagonal: DiagonalLine { + diagonal_type: 1, + width: 0, + color: 0, + }, fill: Fill::default(), }; self.document.doc_info.border_fills.push(new_bf); @@ -573,7 +695,9 @@ impl DocumentCore { // 커서 위치 문단의 속성을 기본값으로 상속 (한컴 동작 일치) let current_para = &self.document.sections[section_idx].paragraphs[para_idx]; - let default_char_shape_id: u32 = current_para.char_shapes.first() + let default_char_shape_id: u32 = current_para + .char_shapes + .first() .map(|cs| cs.char_shape_id) .unwrap_or(0); let default_para_shape_id: u16 = current_para.para_shape_id; @@ -585,7 +709,7 @@ impl DocumentCore { let mut cell = Cell::new_empty(c, r, col_width, cell_height, cell_border_fill_id); cell.padding = cell_pad; cell.vertical_align = crate::model::table::VerticalAlign::Center; // 한컴 기본값 - // 셀 문단 보정: char_count_msb, raw_header_extra, para/char shape + // 셀 문단 보정: char_count_msb, raw_header_extra, para/char shape for cp in &mut cell.paragraphs { cp.char_count_msb = true; cp.para_shape_id = default_para_shape_id; @@ -615,9 +739,7 @@ impl DocumentCore { } // --- 3. Table 구조체 조립 (한컴 기본 속성값) --- - let row_sizes: Vec = (0..row_count) - .map(|_| col_count as i16) - .collect(); + let row_sizes: Vec = (0..row_count).map(|_| col_count as i16).collect(); // raw_ctrl_data: CommonObjAttr 바이너리 (파서 호환) // 바이트 레이아웃: flags(4) + v_offset(4) + h_offset(4) + width(4) + height(4) @@ -628,24 +750,26 @@ impl DocumentCore { let flags: u32 = (2 << 3) | (3 << 8) | (4 << 15) | (2 << 18) | (1 << 21); let outer_margin: i16 = 283; // ~1mm let mut raw_ctrl_data = vec![0u8; 38]; - raw_ctrl_data[0..4].copy_from_slice(&flags.to_le_bytes()); // offset 0: flags - // offset 4-7: vertical_offset = 0 - // offset 8-11: horizontal_offset = 0 + raw_ctrl_data[0..4].copy_from_slice(&flags.to_le_bytes()); // offset 0: flags + // offset 4-7: vertical_offset = 0 + // offset 8-11: horizontal_offset = 0 raw_ctrl_data[12..16].copy_from_slice(&total_width.to_le_bytes()); // offset 12: width - raw_ctrl_data[16..20].copy_from_slice(&total_height.to_le_bytes());// offset 16: height - // offset 20-23: z_order = 0 - raw_ctrl_data[24..26].copy_from_slice(&outer_margin.to_le_bytes());// offset 24: margin_left - raw_ctrl_data[26..28].copy_from_slice(&outer_margin.to_le_bytes());// offset 26: margin_right - raw_ctrl_data[28..30].copy_from_slice(&outer_margin.to_le_bytes());// offset 28: margin_top - raw_ctrl_data[30..32].copy_from_slice(&outer_margin.to_le_bytes());// offset 30: margin_bottom - // offset 32-35: instance_id (해시 기반, 비-0 필수) + raw_ctrl_data[16..20].copy_from_slice(&total_height.to_le_bytes()); // offset 16: height + // offset 20-23: z_order = 0 + raw_ctrl_data[24..26].copy_from_slice(&outer_margin.to_le_bytes()); // offset 24: margin_left + raw_ctrl_data[26..28].copy_from_slice(&outer_margin.to_le_bytes()); // offset 26: margin_right + raw_ctrl_data[28..30].copy_from_slice(&outer_margin.to_le_bytes()); // offset 28: margin_top + raw_ctrl_data[30..32].copy_from_slice(&outer_margin.to_le_bytes()); // offset 30: margin_bottom + // offset 32-35: instance_id (해시 기반, 비-0 필수) let instance_id: u32 = { let mut h: u32 = 0x7c150000; h = h.wrapping_add(row_count as u32 * 0x1000); h = h.wrapping_add(col_count as u32 * 0x100); h = h.wrapping_add(total_width); h = h.wrapping_add(total_height.wrapping_mul(0x1b)); - if h == 0 { h = 0x7c154b69; } + if h == 0 { + h = 0x7c154b69; + } h }; raw_ctrl_data[32..36].copy_from_slice(&instance_id.to_le_bytes()); @@ -655,7 +779,12 @@ impl DocumentCore { row_count, col_count, cell_spacing: 0, - padding: crate::model::Padding { left: 510, right: 510, top: 141, bottom: 141 }, + padding: crate::model::Padding { + left: 510, + right: 510, + top: 141, + bottom: 141, + }, row_sizes, border_fill_id: cell_border_fill_id, // 한컴: 표와 셀이 같은 BorderFill 사용 zones: Vec::new(), @@ -736,20 +865,28 @@ impl DocumentCore { insert_para_idx = para_idx; } else if char_offset == 0 && para.controls.is_empty() { // 문단 맨 앞이면 바로 앞에 삽입 - self.document.sections[section_idx].paragraphs.insert(para_idx, table_para); + self.document.sections[section_idx] + .paragraphs + .insert(para_idx, table_para); insert_para_idx = para_idx; } else { // 문단 중간이면 분할 후 삽입 if char_offset > 0 && !para.text.is_empty() { - let new_para = self.document.sections[section_idx].paragraphs[para_idx] - .split_at(char_offset); - self.document.sections[section_idx].paragraphs.insert(para_idx + 1, new_para); + let new_para = + self.document.sections[section_idx].paragraphs[para_idx].split_at(char_offset); + self.document.sections[section_idx] + .paragraphs + .insert(para_idx + 1, new_para); // 표 문단은 분할된 뒤에 삽입 - self.document.sections[section_idx].paragraphs.insert(para_idx + 1, table_para); + self.document.sections[section_idx] + .paragraphs + .insert(para_idx + 1, table_para); insert_para_idx = para_idx + 1; } else { // char_offset == 0이지만 컨트롤이 있는 경우 → 뒤에 삽입 - self.document.sections[section_idx].paragraphs.insert(para_idx + 1, table_para); + self.document.sections[section_idx] + .paragraphs + .insert(para_idx + 1, table_para); insert_para_idx = para_idx + 1; } } @@ -783,14 +920,23 @@ impl DocumentCore { raw_header_extra: empty_raw_header_extra, ..Default::default() }; - self.document.sections[section_idx].paragraphs.insert(insert_para_idx + 1, empty_para); + self.document.sections[section_idx] + .paragraphs + .insert(insert_para_idx + 1, empty_para); // --- 6. 스타일 갱신 + 리플로우 + 페이지네이션 --- // 새 BorderFill 추가 시 styles.border_styles 갱신이 필요하므로 rebuild_section 사용 self.rebuild_section(section_idx); - self.event_log.push(DocumentEvent::TableRowInserted { section: section_idx, para: insert_para_idx, ctrl: 0 }); - Ok(super::super::helpers::json_ok_with(&format!("\"paraIdx\":{},\"controlIdx\":0", insert_para_idx))) + self.event_log.push(DocumentEvent::TableRowInserted { + section: section_idx, + para: insert_para_idx, + ctrl: 0, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"paraIdx\":{},\"controlIdx\":0", + insert_para_idx + ))) } /// 커서 위치에 표를 삽입한다 (확장, JSON 옵션). @@ -810,25 +956,37 @@ impl DocumentCore { treat_as_char: bool, col_widths_hu: Option<&[u32]>, ) -> Result { - use crate::model::table::{Table, Cell, TablePageBreak}; use crate::model::paragraph::{CharShapeRef, LineSeg}; use crate::model::style::{BorderFill, BorderLine, BorderLineType, DiagonalLine, Fill}; + use crate::model::table::{Cell, Table, TablePageBreak}; if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과", section_idx))); + "구역 인덱스 {} 범위 초과", + section_idx + ))); } if para_idx >= self.document.sections[section_idx].paragraphs.len() { return Err(HwpError::RenderError(format!( - "문단 인덱스 {} 범위 초과", para_idx))); + "문단 인덱스 {} 범위 초과", + para_idx + ))); } if row_count == 0 || col_count == 0 || col_count > 256 { return Err(HwpError::RenderError(format!( - "행/열 수 범위 오류 (행={}, 열={})", row_count, col_count))); + "행/열 수 범위 오류 (행={}, 열={})", + row_count, col_count + ))); } if !treat_as_char { - return self.create_table_native(section_idx, para_idx, char_offset, row_count, col_count); + return self.create_table_native( + section_idx, + para_idx, + char_offset, + row_count, + col_count, + ); } // ── 인라인 TAC 표 생성 ── @@ -836,7 +994,9 @@ impl DocumentCore { let pd = &self.document.sections[section_idx].section_def.page_def; let outer_margin: i16 = 283; let outer_margin_lr = (outer_margin * 2) as i32; - let content_width = (pd.width as i32 - pd.margin_left as i32 - pd.margin_right as i32 - outer_margin_lr).max(7200) as u32; + let content_width = + (pd.width as i32 - pd.margin_left as i32 - pd.margin_right as i32 - outer_margin_lr) + .max(7200) as u32; // 열 폭 결정 let col_ws: Vec = if let Some(widths) = col_widths_hu { @@ -852,7 +1012,12 @@ impl DocumentCore { }; let total_width: u32 = col_ws.iter().sum(); - let cell_pad = crate::model::Padding { left: 510, right: 510, top: 141, bottom: 141 }; + let cell_pad = crate::model::Padding { + left: 510, + right: 510, + top: 141, + bottom: 141, + }; let cell_height: u32 = (cell_pad.top + cell_pad.bottom) as u32; let rendered_row_height: u32 = cell_pad.top as u32 + 1000 + cell_pad.bottom as u32; let total_height = rendered_row_height * row_count as u32; @@ -860,16 +1025,27 @@ impl DocumentCore { // BorderFill let cell_border_fill_id = { let existing = self.document.doc_info.border_fills.iter().position(|bf| { - bf.borders.iter().all(|b| b.line_type == BorderLineType::Solid && b.width >= 1) + bf.borders + .iter() + .all(|b| b.line_type == BorderLineType::Solid && b.width >= 1) }); if let Some(idx) = existing { (idx + 1) as u16 } else { - let solid_border = BorderLine { line_type: BorderLineType::Solid, width: 1, color: 0 }; + let solid_border = BorderLine { + line_type: BorderLineType::Solid, + width: 1, + color: 0, + }; let new_bf = BorderFill { - raw_data: None, attr: 0, + raw_data: None, + attr: 0, borders: [solid_border, solid_border, solid_border, solid_border], - diagonal: DiagonalLine { diagonal_type: 1, width: 0, color: 0 }, + diagonal: DiagonalLine { + diagonal_type: 1, + width: 0, + color: 0, + }, fill: Fill::default(), }; self.document.doc_info.border_fills.push(new_bf); @@ -879,8 +1055,11 @@ impl DocumentCore { }; let current_para = &self.document.sections[section_idx].paragraphs[para_idx]; - let default_char_shape_id: u32 = current_para.char_shapes.first() - .map(|cs| cs.char_shape_id).unwrap_or(0); + let default_char_shape_id: u32 = current_para + .char_shapes + .first() + .map(|cs| cs.char_shape_id) + .unwrap_or(0); let default_para_shape_id: u16 = current_para.para_shape_id; // 셀 생성 @@ -902,9 +1081,13 @@ impl DocumentCore { } let seg_w = (col_w as i32) - 141 - 141; cp.line_segs = vec![LineSeg { - text_start: 0, line_height: 1000, text_height: 1000, - baseline_distance: 850, line_spacing: 600, - segment_width: seg_w, tag: 0x00060000, + text_start: 0, + line_height: 1000, + text_height: 1000, + baseline_distance: 850, + line_spacing: 600, + segment_width: seg_w, + tag: 0x00060000, ..Default::default() }]; } @@ -936,20 +1119,27 @@ impl DocumentCore { h = h.wrapping_add(row_count as u32 * 0x1000); h = h.wrapping_add(col_count as u32 * 0x100); h = h.wrapping_add(total_width); - if h == 0 { h = 0x7c164b69; } + if h == 0 { + h = 0x7c164b69; + } h }; raw_ctrl_data[32..36].copy_from_slice(&instance_id.to_le_bytes()); let mut table = Table { attr: 0x04000006, - row_count, col_count, cell_spacing: 0, + row_count, + col_count, + cell_spacing: 0, padding: cell_pad, row_sizes, border_fill_id: cell_border_fill_id, - zones: Vec::new(), cells, cell_grid: Vec::new(), + zones: Vec::new(), + cells, + cell_grid: Vec::new(), page_break: TablePageBreak::RowBreak, - repeat_header: false, caption: None, + repeat_header: false, + caption: None, common: crate::model::shape::CommonObjAttr { treat_as_char: true, text_wrap: crate::model::shape::TextWrap::TopAndBottom, @@ -987,8 +1177,12 @@ impl DocumentCore { para.char_offsets[char_offset] } else if !para.char_offsets.is_empty() { let last_idx = para.char_offsets.len() - 1; - let last_char_len = para.text.chars().nth(last_idx) - .map(|c| c.len_utf16() as u32).unwrap_or(1); + let last_char_len = para + .text + .chars() + .nth(last_idx) + .map(|c| c.len_utf16() as u32) + .unwrap_or(1); para.char_offsets[last_idx] + last_char_len } else { 0 @@ -1018,11 +1212,15 @@ impl DocumentCore { self.rebuild_section(section_idx); self.event_log.push(DocumentEvent::TableRowInserted { - section: section_idx, para: para_idx, ctrl: ctrl_idx, + section: section_idx, + para: para_idx, + ctrl: ctrl_idx, }); // 표 바로 뒤의 논리적 오프셋 계산 let logical_after = super::super::helpers::text_to_logical_offset( - &self.document.sections[section_idx].paragraphs[para_idx], char_offset) + 1; + &self.document.sections[section_idx].paragraphs[para_idx], + char_offset, + ) + 1; Ok(super::super::helpers::json_ok_with(&format!( "\"paraIdx\":{},\"controlIdx\":{},\"logicalOffset\":{}", para_idx, ctrl_idx, logical_after @@ -1043,23 +1241,30 @@ impl DocumentCore { extension: &str, description: &str, ) -> Result { - use crate::model::image::{Picture, ImageAttr, ImageEffect, CropInfo}; - use crate::model::shape::{CommonObjAttr, ShapeComponentAttr, VertRelTo, HorzRelTo}; - use crate::model::bin_data::{BinData, BinDataType, BinDataCompression, BinDataStatus, BinDataContent}; + use crate::model::bin_data::{ + BinData, BinDataCompression, BinDataContent, BinDataStatus, BinDataType, + }; + use crate::model::image::{CropInfo, ImageAttr, ImageEffect, Picture}; use crate::model::paragraph::{CharShapeRef, LineSeg}; + use crate::model::shape::{CommonObjAttr, HorzRelTo, ShapeComponentAttr, VertRelTo}; // 유효성 검사 if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과 (총 {}개)", section_idx, self.document.sections.len() + "구역 인덱스 {} 범위 초과 (총 {}개)", + section_idx, + self.document.sections.len() ))); } if para_idx >= self.document.sections[section_idx].paragraphs.len() { return Err(HwpError::RenderError(format!( - "문단 인덱스 {} 범위 초과", para_idx + "문단 인덱스 {} 범위 초과", + para_idx ))); } if image_data.is_empty() { - return Err(HwpError::RenderError("이미지 데이터가 비어 있습니다".to_string())); + return Err(HwpError::RenderError( + "이미지 데이터가 비어 있습니다".to_string(), + )); } // --- 1. BinDataContent 추가 --- @@ -1145,13 +1350,16 @@ impl DocumentCore { // --- 4. 그림 포함 문단 생성 + 삽입 (createTable 패턴) --- let current_para = &self.document.sections[section_idx].paragraphs[para_idx]; - let default_char_shape_id: u32 = current_para.char_shapes.first() + let default_char_shape_id: u32 = current_para + .char_shapes + .first() .map(|cs| cs.char_shape_id) .unwrap_or(0); let default_para_shape_id: u16 = current_para.para_shape_id; let pd = &self.document.sections[section_idx].section_def.page_def; - let content_width = (pd.width as i32 - pd.margin_left as i32 - pd.margin_right as i32).max(7200) as u32; + let content_width = + (pd.width as i32 - pd.margin_left as i32 - pd.margin_right as i32).max(7200) as u32; let mut pic_raw_header_extra = vec![0u8; 10]; pic_raw_header_extra[0..2].copy_from_slice(&1u16.to_le_bytes()); // n_char_shapes=1 @@ -1197,17 +1405,25 @@ impl DocumentCore { self.document.sections[section_idx].paragraphs[para_idx] = pic_para; insert_para_idx = para_idx; } else if char_offset == 0 && para.controls.is_empty() { - self.document.sections[section_idx].paragraphs.insert(para_idx, pic_para); + self.document.sections[section_idx] + .paragraphs + .insert(para_idx, pic_para); insert_para_idx = para_idx; } else { if char_offset > 0 && !para.text.is_empty() { - let new_para = self.document.sections[section_idx].paragraphs[para_idx] - .split_at(char_offset); - self.document.sections[section_idx].paragraphs.insert(para_idx + 1, new_para); - self.document.sections[section_idx].paragraphs.insert(para_idx + 1, pic_para); + let new_para = + self.document.sections[section_idx].paragraphs[para_idx].split_at(char_offset); + self.document.sections[section_idx] + .paragraphs + .insert(para_idx + 1, new_para); + self.document.sections[section_idx] + .paragraphs + .insert(para_idx + 1, pic_para); insert_para_idx = para_idx + 1; } else { - self.document.sections[section_idx].paragraphs.insert(para_idx + 1, pic_para); + self.document.sections[section_idx] + .paragraphs + .insert(para_idx + 1, pic_para); insert_para_idx = para_idx + 1; } } @@ -1241,14 +1457,22 @@ impl DocumentCore { raw_header_extra: empty_raw_header_extra, ..Default::default() }; - self.document.sections[section_idx].paragraphs.insert(insert_para_idx + 1, empty_para); + self.document.sections[section_idx] + .paragraphs + .insert(insert_para_idx + 1, empty_para); // --- 5. 리플로우 + 페이지네이션 --- self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::PictureInserted { section: section_idx, para: insert_para_idx }); - Ok(super::super::helpers::json_ok_with(&format!("\"paraIdx\":{},\"controlIdx\":0", insert_para_idx))) + self.event_log.push(DocumentEvent::PictureInserted { + section: section_idx, + para: insert_para_idx, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"paraIdx\":{},\"controlIdx\":0", + insert_para_idx + ))) } /// 표의 모든 셀 bbox를 반환한다 (네이티브). @@ -1275,7 +1499,9 @@ impl DocumentCore { // 렌더 트리에서 해당 표 노드를 찾아 셀 bbox를 수집 fn find_table_cells( node: &RenderNode, - sec: usize, ppi: usize, ci: usize, + sec: usize, + ppi: usize, + ci: usize, page_idx: usize, result: &mut Vec, ) -> bool { @@ -1315,7 +1541,14 @@ impl DocumentCore { let mut found = false; for page_num in start..total_pages { let tree = self.build_page_tree_cached(page_num as u32)?; - if find_table_cells(&tree.root, section_idx, parent_para_idx, control_idx, page_num, &mut cells) { + if find_table_cells( + &tree.root, + section_idx, + parent_para_idx, + control_idx, + page_num, + &mut cells, + ) { found = true; } else if found { break; @@ -1326,12 +1559,26 @@ impl DocumentCore { if !found && start > 0 { for page_num in (0..start).rev() { let tree = self.build_page_tree_cached(page_num as u32)?; - if find_table_cells(&tree.root, section_idx, parent_para_idx, control_idx, page_num, &mut cells) { + if find_table_cells( + &tree.root, + section_idx, + parent_para_idx, + control_idx, + page_num, + &mut cells, + ) { found = true; // 이 페이지에서 찾음 — hint까지 다시 정방향 탐색하여 누락된 페이지 수집 for fwd in (page_num + 1)..=start { let tree2 = self.build_page_tree_cached(fwd as u32)?; - if !find_table_cells(&tree2.root, section_idx, parent_para_idx, control_idx, fwd, &mut cells) { + if !find_table_cells( + &tree2.root, + section_idx, + parent_para_idx, + control_idx, + fwd, + &mut cells, + ) { break; } } @@ -1387,23 +1634,42 @@ impl DocumentCore { \"horzRelTo\":\"{}\",\"horzAlign\":\"{}\",\ \"vertOffset\":{},\"horzOffset\":{},\ \"textWrap\":\"{}\",\"zOrder\":{},\"instanceId\":{},\"description\":\"{}\"", - c.width, c.height, c.treat_as_char, - vert_rel, vert_align, - horz_rel, horz_align, - c.vertical_offset, c.horizontal_offset, - text_wrap, c.z_order, c.instance_id, desc_escaped, + c.width, + c.height, + c.treat_as_char, + vert_rel, + vert_align, + horz_rel, + horz_align, + c.vertical_offset, + c.horizontal_offset, + text_wrap, + c.z_order, + c.instance_id, + desc_escaped, ) } /// JSON → CommonObjAttr 필드 업데이트 (Shape/Picture 공용) - fn apply_common_obj_attr_from_json(c: &mut crate::model::shape::CommonObjAttr, props_json: &str) { - use super::super::helpers::{json_u32, json_bool, json_str}; + fn apply_common_obj_attr_from_json( + c: &mut crate::model::shape::CommonObjAttr, + props_json: &str, + ) { + use super::super::helpers::{json_bool, json_str, json_u32}; - if let Some(w) = json_u32(props_json, "width") { c.width = w; } - if let Some(h) = json_u32(props_json, "height") { c.height = h; } + if let Some(w) = json_u32(props_json, "width") { + c.width = w; + } + if let Some(h) = json_u32(props_json, "height") { + c.height = h; + } if let Some(tac) = json_bool(props_json, "treatAsChar") { c.treat_as_char = tac; - if tac { c.attr |= 0x01; } else { c.attr &= !0x01; } + if tac { + c.attr |= 0x01; + } else { + c.attr &= !0x01; + } } if let Some(v) = json_str(props_json, "vertRelTo") { c.vert_rel_to = match v.as_str() { @@ -1449,9 +1715,15 @@ impl DocumentCore { _ => c.text_wrap, }; } - if let Some(v) = json_u32(props_json, "vertOffset") { c.vertical_offset = v; } - if let Some(v) = json_u32(props_json, "horzOffset") { c.horizontal_offset = v; } - if let Some(v) = json_str(props_json, "description") { c.description = v; } + if let Some(v) = json_u32(props_json, "vertOffset") { + c.vertical_offset = v; + } + if let Some(v) = json_u32(props_json, "horzOffset") { + c.horizontal_offset = v; + } + if let Some(v) = json_str(props_json, "description") { + c.description = v; + } } /// 글상자(Shape) 속성 조회 (네이티브). @@ -1461,16 +1733,23 @@ impl DocumentCore { parent_para_idx: usize, control_idx: usize, ) -> Result { - let section = self.document.sections.get(section_idx) - .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))?; - let para = section.paragraphs.get(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)))?; - let ctrl = para.controls.get(control_idx) - .ok_or_else(|| HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)))?; + let section = self.document.sections.get(section_idx).ok_or_else(|| { + HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)) + })?; + let para = section.paragraphs.get(parent_para_idx).ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)) + })?; + let ctrl = para.controls.get(control_idx).ok_or_else(|| { + HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)) + })?; let shape = match ctrl { Control::Shape(s) => s.as_ref(), - _ => return Err(HwpError::RenderError("지정된 컨트롤이 Shape이 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "지정된 컨트롤이 Shape이 아닙니다".to_string(), + )) + } }; let c = shape.common(); @@ -1504,12 +1783,12 @@ impl DocumentCore { }; // borderAttr 비트필드 분해 let bl = &d.border_line; - let line_type = bl.attr & 0x3F; // bits 0-5: 선 종류 (0~17) - let line_end_shape = (bl.attr >> 6) & 0x0F; // bits 6-9: 끝 모양 - let arrow_start = (bl.attr >> 10) & 0x3F; // bits 10-15: 화살표 시작 모양 - let arrow_end = (bl.attr >> 16) & 0x3F; // bits 16-21: 화살표 끝 모양 - let arrow_start_size = (bl.attr >> 22) & 0x0F; // bits 22-25: 화살표 시작 크기 - let arrow_end_size = (bl.attr >> 26) & 0x0F; // bits 26-29: 화살표 끝 크기 + let line_type = bl.attr & 0x3F; // bits 0-5: 선 종류 (0~17) + let line_end_shape = (bl.attr >> 6) & 0x0F; // bits 6-9: 끝 모양 + let arrow_start = (bl.attr >> 10) & 0x3F; // bits 10-15: 화살표 시작 모양 + let arrow_end = (bl.attr >> 16) & 0x3F; // bits 16-21: 화살표 끝 모양 + let arrow_start_size = (bl.attr >> 22) & 0x0F; // bits 22-25: 화살표 시작 크기 + let arrow_end_size = (bl.attr >> 26) & 0x0F; // bits 26-29: 화살표 끝 크기 let mut extra = format!( ",\"borderColor\":{},\"borderWidth\":{},\"borderAttr\":{},\"borderOutlineStyle\":{}\ @@ -1558,13 +1837,20 @@ impl DocumentCore { let connector_json = if let crate::model::shape::ShapeObject::Line(ref line) = shape { if let Some(ref conn) = line.connector { // type=2 제어점의 평균 좌표 (꺽임 모서리 / 곡선 중간점) - let ctrl2_pts: Vec<&crate::model::shape::ConnectorControlPoint> = - conn.control_points.iter().filter(|cp| cp.point_type == 2).collect(); + let ctrl2_pts: Vec<&crate::model::shape::ConnectorControlPoint> = conn + .control_points + .iter() + .filter(|cp| cp.point_type == 2) + .collect(); if !ctrl2_pts.is_empty() { - let avg_x: i32 = ctrl2_pts.iter().map(|p| p.x).sum::() / ctrl2_pts.len() as i32; - let avg_y: i32 = ctrl2_pts.iter().map(|p| p.y).sum::() / ctrl2_pts.len() as i32; - format!(",\"connectorType\":{},\"connectorMidX\":{},\"connectorMidY\":{}", - conn.link_type as u32, avg_x, avg_y) + let avg_x: i32 = + ctrl2_pts.iter().map(|p| p.x).sum::() / ctrl2_pts.len() as i32; + let avg_y: i32 = + ctrl2_pts.iter().map(|p| p.y).sum::() / ctrl2_pts.len() as i32; + format!( + ",\"connectorType\":{},\"connectorMidX\":{},\"connectorMidY\":{}", + conn.link_type as u32, avg_x, avg_y + ) } else { format!(",\"connectorType\":{}", conn.link_type as u32) } @@ -1575,7 +1861,10 @@ impl DocumentCore { String::new() }; - Ok(format!("{{{}{}{}{}{}}}", common_json, tb_json, extra_json, round_json, connector_json)) + Ok(format!( + "{{{}{}{}{}{}}}", + common_json, tb_json, extra_json, round_json, connector_json + )) } /// 글상자(Shape) 속성 변경 (네이티브). @@ -1588,16 +1877,23 @@ impl DocumentCore { ) -> Result { use super::super::helpers::{json_bool, json_i32, json_str}; - let section = self.document.sections.get_mut(section_idx) - .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))?; - let para = section.paragraphs.get_mut(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)))?; - let ctrl = para.controls.get_mut(control_idx) - .ok_or_else(|| HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)))?; + let section = self.document.sections.get_mut(section_idx).ok_or_else(|| { + HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)) + })?; + let para = section.paragraphs.get_mut(parent_para_idx).ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)) + })?; + let ctrl = para.controls.get_mut(control_idx).ok_or_else(|| { + HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)) + })?; let shape = match ctrl { Control::Shape(s) => s.as_mut(), - _ => return Err(HwpError::RenderError("지정된 컨트롤이 Shape이 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "지정된 컨트롤이 Shape이 아닙니다".to_string(), + )) + } }; // CommonObjAttr 업데이트 @@ -1624,16 +1920,28 @@ impl DocumentCore { // 대칭(flip) if let Some(v) = json_bool(props_json, "horzFlip") { d.shape_attr.horz_flip = v; - if v { d.shape_attr.flip |= 1; } else { d.shape_attr.flip &= !1; } + if v { + d.shape_attr.flip |= 1; + } else { + d.shape_attr.flip &= !1; + } } if let Some(v) = json_bool(props_json, "vertFlip") { d.shape_attr.vert_flip = v; - if v { d.shape_attr.flip |= 2; } else { d.shape_attr.flip &= !2; } + if v { + d.shape_attr.flip |= 2; + } else { + d.shape_attr.flip &= !2; + } } // 테두리 선 — 색상/굵기 - if let Some(v) = json_i32(props_json, "borderColor") { d.border_line.color = v as u32; } - if let Some(v) = json_i32(props_json, "borderWidth") { d.border_line.width = v; } + if let Some(v) = json_i32(props_json, "borderColor") { + d.border_line.color = v as u32; + } + if let Some(v) = json_i32(props_json, "borderWidth") { + d.border_line.width = v; + } // 테두리 선 — attr 비트필드 개별 필드 업데이트 { @@ -1671,28 +1979,30 @@ impl DocumentCore { if let Some(v) = json_i32(props_json, "fillBgColor") { let solid = d.fill.solid.get_or_insert_with(|| { crate::model::style::SolidFill { - pattern_type: -1, // -1 = 단색 채우기 (0은 채우기 없음) + pattern_type: -1, // -1 = 단색 채우기 (0은 채우기 없음) ..Default::default() } }); solid.background_color = v as u32; } if let Some(v) = json_i32(props_json, "fillPatColor") { - let solid = d.fill.solid.get_or_insert_with(|| { - crate::model::style::SolidFill { + let solid = d + .fill + .solid + .get_or_insert_with(|| crate::model::style::SolidFill { pattern_type: -1, ..Default::default() - } - }); + }); solid.pattern_color = v as u32; } if let Some(v) = json_i32(props_json, "fillPatType") { - let solid = d.fill.solid.get_or_insert_with(|| { - crate::model::style::SolidFill { + let solid = d + .fill + .solid + .get_or_insert_with(|| crate::model::style::SolidFill { pattern_type: -1, ..Default::default() - } - }); + }); solid.pattern_type = v; } if let Some(v) = json_i32(props_json, "fillAlpha") { @@ -1722,17 +2032,33 @@ impl DocumentCore { } // 그림자 - if let Some(v) = super::super::helpers::json_u32(props_json, "shadowType") { d.shadow_type = v; } - if let Some(v) = super::super::helpers::json_i32(props_json, "shadowColor") { d.shadow_color = v as u32; } - if let Some(v) = super::super::helpers::json_i32(props_json, "shadowOffsetX") { d.shadow_offset_x = v; } - if let Some(v) = super::super::helpers::json_i32(props_json, "shadowOffsetY") { d.shadow_offset_y = v; } + if let Some(v) = super::super::helpers::json_u32(props_json, "shadowType") { + d.shadow_type = v; + } + if let Some(v) = super::super::helpers::json_i32(props_json, "shadowColor") { + d.shadow_color = v as u32; + } + if let Some(v) = super::super::helpers::json_i32(props_json, "shadowOffsetX") { + d.shadow_offset_x = v; + } + if let Some(v) = super::super::helpers::json_i32(props_json, "shadowOffsetY") { + d.shadow_offset_y = v; + } // TextBox 속성 업데이트 if let Some(ref mut tb) = d.text_box { - if let Some(v) = json_i32(props_json, "tbMarginLeft") { tb.margin_left = v as i16; } - if let Some(v) = json_i32(props_json, "tbMarginRight") { tb.margin_right = v as i16; } - if let Some(v) = json_i32(props_json, "tbMarginTop") { tb.margin_top = v as i16; } - if let Some(v) = json_i32(props_json, "tbMarginBottom") { tb.margin_bottom = v as i16; } + if let Some(v) = json_i32(props_json, "tbMarginLeft") { + tb.margin_left = v as i16; + } + if let Some(v) = json_i32(props_json, "tbMarginRight") { + tb.margin_right = v as i16; + } + if let Some(v) = json_i32(props_json, "tbMarginTop") { + tb.margin_top = v as i16; + } + if let Some(v) = json_i32(props_json, "tbMarginBottom") { + tb.margin_bottom = v as i16; + } if let Some(v) = json_str(props_json, "tbVerticalAlign") { tb.vertical_align = match v.as_str() { "Top" => crate::model::table::VerticalAlign::Top, @@ -1783,7 +2109,11 @@ impl DocumentCore { self.paginate_if_needed(); self.invalidate_page_tree_cache(); - self.event_log.push(DocumentEvent::PictureResized { section: section_idx, para: parent_para_idx, ctrl: control_idx }); + self.event_log.push(DocumentEvent::PictureResized { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + }); Ok("{\"ok\":true}".to_string()) } @@ -1797,18 +2127,29 @@ impl DocumentCore { control_idx: usize, ) -> Result { if section_idx >= self.document.sections.len() { - return Err(HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx))); + return Err(HwpError::RenderError(format!( + "구역 인덱스 {} 범위 초과", + section_idx + ))); } let section = &mut self.document.sections[section_idx]; if parent_para_idx >= section.paragraphs.len() { - return Err(HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx))); + return Err(HwpError::RenderError(format!( + "문단 인덱스 {} 범위 초과", + parent_para_idx + ))); } let para = &mut section.paragraphs[parent_para_idx]; if control_idx >= para.controls.len() { - return Err(HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx))); + return Err(HwpError::RenderError(format!( + "컨트롤 인덱스 {} 범위 초과", + control_idx + ))); } if !matches!(¶.controls[control_idx], Control::Shape(_)) { - return Err(HwpError::RenderError("지정된 컨트롤이 Shape이 아닙니다".to_string())); + return Err(HwpError::RenderError( + "지정된 컨트롤이 Shape이 아닙니다".to_string(), + )); } // char_offsets 조정 (delete_picture_control_native와 동일) @@ -1817,20 +2158,34 @@ impl DocumentCore { let mut prev_end: u32 = 0; let mut gap_start: Option = None; 'outer: for i in 0..text_chars.len() { - let offset = if i < para.char_offsets.len() { para.char_offsets[i] } else { prev_end }; + let offset = if i < para.char_offsets.len() { + para.char_offsets[i] + } else { + prev_end + }; while prev_end + 8 <= offset && ci < para.controls.len() { - if ci == control_idx { gap_start = Some(prev_end); break 'outer; } + if ci == control_idx { + gap_start = Some(prev_end); + break 'outer; + } ci += 1; prev_end += 8; } - let char_size: u32 = if text_chars[i] == '\t' { 8 } - else if text_chars[i].len_utf16() == 2 { 2 } - else { 1 }; + let char_size: u32 = if text_chars[i] == '\t' { + 8 + } else if text_chars[i].len_utf16() == 2 { + 2 + } else { + 1 + }; prev_end = offset + char_size; } if gap_start.is_none() { while ci < para.controls.len() { - if ci == control_idx { gap_start = Some(prev_end); break; } + if ci == control_idx { + gap_start = Some(prev_end); + break; + } ci += 1; prev_end += 8; } @@ -1838,7 +2193,9 @@ impl DocumentCore { if let Some(gs) = gap_start { let threshold = gs + 8; for offset in para.char_offsets.iter_mut() { - if *offset >= threshold { *offset -= 8; } + if *offset >= threshold { + *offset -= 8; + } } } @@ -1846,7 +2203,9 @@ impl DocumentCore { if control_idx < para.ctrl_data_records.len() { para.ctrl_data_records.remove(control_idx); } - if para.char_count >= 8 { para.char_count -= 8; } + if para.char_count >= 8 { + para.char_count -= 8; + } // line_segs 재계산: 도형 높이가 반영된 line_segs를 텍스트 기반으로 리셋 Self::reflow_paragraph_line_segs_after_control_delete(para, &self.styles, self.dpi); @@ -1855,7 +2214,11 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::PictureDeleted { section: section_idx, para: parent_para_idx, ctrl: control_idx }); + self.event_log.push(DocumentEvent::PictureDeleted { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + }); Ok("{\"ok\":true}".to_string()) } @@ -1876,19 +2239,27 @@ impl DocumentCore { line_flip_y: bool, polygon_points: &[crate::model::Point], ) -> Result { - use crate::model::shape::*; use crate::model::paragraph::{CharShapeRef, LineSeg}; + use crate::model::shape::*; use crate::model::style::{Fill, ShapeBorderLine}; // 유효성 검사 if section_idx >= self.document.sections.len() { - return Err(HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx))); + return Err(HwpError::RenderError(format!( + "구역 인덱스 {} 범위 초과", + section_idx + ))); } if para_idx >= self.document.sections[section_idx].paragraphs.len() { - return Err(HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", para_idx))); + return Err(HwpError::RenderError(format!( + "문단 인덱스 {} 범위 초과", + para_idx + ))); } if width == 0 && height == 0 { - return Err(HwpError::RenderError("폭과 높이가 모두 0입니다".to_string())); + return Err(HwpError::RenderError( + "폭과 높이가 모두 0입니다".to_string(), + )); } let text_wrap = match text_wrap_str { @@ -1903,21 +2274,30 @@ impl DocumentCore { // 커서 위치 문단의 속성 상속 let current_para = &self.document.sections[section_idx].paragraphs[para_idx]; - let default_char_shape_id: u32 = current_para.char_shapes.first() + let default_char_shape_id: u32 = current_para + .char_shapes + .first() .map(|cs| cs.char_shape_id) .unwrap_or(0); let default_para_shape_id: u16 = current_para.para_shape_id; // 편집 영역 폭 let pd = &self.document.sections[section_idx].section_def.page_def; - let content_width = (pd.width as i32 - pd.margin_left as i32 - pd.margin_right as i32).max(7200) as u32; + let content_width = + (pd.width as i32 - pd.margin_left as i32 - pd.margin_right as i32).max(7200) as u32; // attr 비트 계산 // textbox: Para/Top/Column/Left/Square = 0x0A0210 // 도형(line/ellipse/rectangle): 한컴 기본값 0x046A4000 // Paper/Top/Paper/Left/InFrontOfText + textSide=2 + bit16-17=2 + objNumSort=2 + bit26=1 - let mut attr: u32 = if shape_type == "textbox" { 0x0A0210 } else { 0x046A4000 }; - if treat_as_char { attr |= 0x01; } + let mut attr: u32 = if shape_type == "textbox" { + 0x0A0210 + } else { + 0x046A4000 + }; + if treat_as_char { + attr |= 0x01; + } // --- 빈 문단 (글상자 내부용) --- let tb_inner_width = width.saturating_sub(1020); // 양쪽 여백 510+510 @@ -1958,13 +2338,23 @@ impl DocumentCore { // ctrl_id 결정 let is_connector = shape_type.starts_with("connector-"); let ctrl_id: u32 = match shape_type { - "line" | "connector-straight" | "connector-stroke" | "connector-arc" - | "connector-straight-arrow" | "connector-stroke-arrow" | "connector-arc-arrow" - => if is_connector { 0x24636f6c } else { 0x246c696e }, // '$col' or '$lin' - "ellipse" => 0x24656c6c, // '$ell' - "polygon" => 0x24706f6c, // '$pol' - "arc" => 0x24617263, // '$arc' - _ => 0x24726563, // '$rec' (rectangle, textbox) + "line" + | "connector-straight" + | "connector-stroke" + | "connector-arc" + | "connector-straight-arrow" + | "connector-stroke-arrow" + | "connector-arc-arrow" => { + if is_connector { + 0x24636f6c + } else { + 0x246c696e + } + } // '$col' or '$lin' + "ellipse" => 0x24656c6c, // '$ell' + "polygon" => 0x24706f6c, // '$pol' + "arc" => 0x24617263, // '$arc' + _ => 0x24726563, // '$rec' (rectangle, textbox) }; // instance_id 생성: 고유 해시 (z_order 기반 + 위치/크기) @@ -1976,7 +2366,9 @@ impl DocumentCore { h = h.wrapping_add(width); h = h.wrapping_add(height.wrapping_mul(0x1b)); h |= 0x40000000; // bit30 설정 (한컴 호환) - if h == 0 { h = 0x7de34b69; } + if h == 0 { + h = 0x7de34b69; + } h }; @@ -1990,14 +2382,32 @@ impl DocumentCore { z_order: new_z_order, instance_id, margin: if shape_type == "textbox" { - crate::model::Padding { left: 283, right: 283, top: 283, bottom: 283 } + crate::model::Padding { + left: 283, + right: 283, + top: 283, + bottom: 283, + } } else { - crate::model::Padding { left: 0, right: 0, top: 0, bottom: 0 } + crate::model::Padding { + left: 0, + right: 0, + top: 0, + bottom: 0, + } }, treat_as_char, - vert_rel_to: if shape_type == "textbox" { VertRelTo::Para } else { VertRelTo::Paper }, + vert_rel_to: if shape_type == "textbox" { + VertRelTo::Para + } else { + VertRelTo::Paper + }, vert_align: VertAlign::Top, - horz_rel_to: if shape_type == "textbox" { HorzRelTo::Column } else { HorzRelTo::Paper }, + horz_rel_to: if shape_type == "textbox" { + HorzRelTo::Column + } else { + HorzRelTo::Paper + }, horz_align: HorzAlign::Left, text_wrap, description: match shape_type { @@ -2076,17 +2486,22 @@ impl DocumentCore { }; let shape_obj = match shape_type { - "line" | "connector-straight" | "connector-stroke" | "connector-arc" - | "connector-straight-arrow" | "connector-stroke-arrow" | "connector-arc-arrow" => { + "line" + | "connector-straight" + | "connector-stroke" + | "connector-arc" + | "connector-straight-arrow" + | "connector-stroke-arrow" + | "connector-arc-arrow" => { // 드래그 방향에 따라 시작/끝점 결정 let (sx, sy, ex, ey) = match (line_flip_x, line_flip_y) { - (false, false) => (0, 0, w_i, h_i), // 좌상→우하 - (false, true) => (0, h_i, w_i, 0), // 좌하→우상 - (true, false) => (w_i, 0, 0, h_i), // 우상→좌하 - (true, true) => (w_i, h_i, 0, 0), // 우하→좌상 + (false, false) => (0, 0, w_i, h_i), // 좌상→우하 + (false, true) => (0, h_i, w_i, 0), // 좌하→우상 + (true, false) => (w_i, 0, 0, h_i), // 우상→좌하 + (true, true) => (w_i, h_i, 0, 0), // 우하→좌상 }; let connector = if is_connector { - use crate::model::shape::{ConnectorData, ConnectorControlPoint, LinkLineType}; + use crate::model::shape::{ConnectorControlPoint, ConnectorData, LinkLineType}; let link_type = match shape_type { "connector-straight" => LinkLineType::StraightNoArrow, "connector-straight-arrow" => LinkLineType::StraightOneWay, @@ -2099,12 +2514,28 @@ impl DocumentCore { // 꺽인/곡선 연결선: 한컴 호환 제어점 생성 // 구조: 시작앵커(type=3) + 중간점(type=2) + 끝앵커(type=26) let control_points = match link_type { - LinkLineType::StrokeNoArrow | LinkLineType::StrokeOneWay | LinkLineType::StrokeBoth - | LinkLineType::ArcNoArrow | LinkLineType::ArcOneWay | LinkLineType::ArcBoth => { + LinkLineType::StrokeNoArrow + | LinkLineType::StrokeOneWay + | LinkLineType::StrokeBoth + | LinkLineType::ArcNoArrow + | LinkLineType::ArcOneWay + | LinkLineType::ArcBoth => { vec![ - ConnectorControlPoint { x: sx, y: sy, point_type: 3 }, // 시작 앵커 - ConnectorControlPoint { x: ex, y: sy, point_type: 2 }, // 중간 (직각 꺾임) - ConnectorControlPoint { x: ex, y: ey, point_type: 26 }, // 끝 앵커 + ConnectorControlPoint { + x: sx, + y: sy, + point_type: 3, + }, // 시작 앵커 + ConnectorControlPoint { + x: ex, + y: sy, + point_type: 2, + }, // 중간 (직각 꺾임) + ConnectorControlPoint { + x: ex, + y: ey, + point_type: 26, + }, // 끝 앵커 ] } _ => Vec::new(), @@ -2126,7 +2557,11 @@ impl DocumentCore { drawing, start: crate::model::Point { x: sx, y: sy }, end: crate::model::Point { x: ex, y: ey }, - started_right_or_bottom: if is_connector { false } else { line_flip_x || line_flip_y }, + started_right_or_bottom: if is_connector { + false + } else { + line_flip_x || line_flip_y + }, connector, }) } @@ -2134,7 +2569,10 @@ impl DocumentCore { common, drawing, attr: 0, - center: crate::model::Point { x: w_i / 2, y: h_i / 2 }, + center: crate::model::Point { + x: w_i / 2, + y: h_i / 2, + }, axis1: crate::model::Point { x: w_i, y: h_i / 2 }, axis2: crate::model::Point { x: w_i / 2, y: h_i }, start1: crate::model::Point { x: w_i, y: h_i / 2 }, @@ -2166,7 +2604,10 @@ impl DocumentCore { common, drawing, arc_type: 0, // 0=Arc - center: crate::model::Point { x: w_i / 2, y: h_i / 2 }, + center: crate::model::Point { + x: w_i / 2, + y: h_i / 2, + }, axis1: crate::model::Point { x: w_i, y: h_i / 2 }, axis2: crate::model::Point { x: w_i / 2, y: 0 }, }) @@ -2190,7 +2631,8 @@ impl DocumentCore { // 컨트롤 삽입 위치 결정 (char_offset 기준) let insert_idx = { - let positions = crate::document_core::helpers::find_control_text_positions(paragraph); + let positions = + crate::document_core::helpers::find_control_text_positions(paragraph); let mut idx = paragraph.controls.len(); for (i, &pos) in positions.iter().enumerate() { if pos > char_offset { @@ -2202,7 +2644,9 @@ impl DocumentCore { }; // 컨트롤 추가 - paragraph.controls.insert(insert_idx, Control::Shape(Box::new(shape_obj))); + paragraph + .controls + .insert(insert_idx, Control::Shape(Box::new(shape_obj))); paragraph.ctrl_data_records.insert(insert_idx, None); // char_offsets에 raw offset 삽입 @@ -2211,7 +2655,11 @@ impl DocumentCore { paragraph.char_offsets[insert_idx - 1] + 8 } else if !paragraph.char_offsets.is_empty() { let first = paragraph.char_offsets[0]; - if first >= 8 { first - 8 } else { 0 } + if first >= 8 { + first - 8 + } else { + 0 + } } else { (char_offset * 2) as u32 }; @@ -2237,8 +2685,14 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::PictureInserted { section: section_idx, para: insert_para_idx }); - Ok(super::super::helpers::json_ok_with(&format!("\"paraIdx\":{},\"controlIdx\":{}", insert_para_idx, insert_ctrl_idx))) + self.event_log.push(DocumentEvent::PictureInserted { + section: section_idx, + para: insert_para_idx, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"paraIdx\":{},\"controlIdx\":{}", + insert_para_idx, insert_ctrl_idx + ))) } /// 글상자(Shape) z-order 변경 (네이티브). @@ -2250,8 +2704,9 @@ impl DocumentCore { control_idx: usize, operation: &str, ) -> Result { - let section = self.document.sections.get(section_idx) - .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))?; + let section = self.document.sections.get(section_idx).ok_or_else(|| { + HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)) + })?; // 구역 내 모든 Shape의 (z_order, para_idx, ctrl_idx) 수집 let mut shape_infos: Vec<(i32, usize, usize)> = Vec::new(); @@ -2266,7 +2721,8 @@ impl DocumentCore { // (z_order, para_idx, ctrl_idx) 기준 정렬 — 렌더링 순서와 동일 shape_infos.sort(); - let target_pos = shape_infos.iter() + let target_pos = shape_infos + .iter() .position(|&(_, pi, ci)| pi == para_idx && ci == control_idx) .ok_or_else(|| HwpError::RenderError("대상 Shape를 찾을 수 없습니다".to_string()))?; let current_z = shape_infos[target_pos].0; @@ -2318,12 +2774,22 @@ impl DocumentCore { } } } - _ => return Err(HwpError::RenderError(format!("알 수 없는 operation: {}", operation))), + _ => { + return Err(HwpError::RenderError(format!( + "알 수 없는 operation: {}", + operation + ))) + } }; let (new_z, neighbor_change) = match changes { Some(c) => c, - None => return Ok(super::super::helpers::json_ok_with(&format!("\"zOrder\":{}", current_z))), + None => { + return Ok(super::super::helpers::json_ok_with(&format!( + "\"zOrder\":{}", + current_z + ))) + } }; // z_order 변경: 대상 + 이웃 @@ -2343,7 +2809,10 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - Ok(super::super::helpers::json_ok_with(&format!("\"zOrder\":{}", new_z))) + Ok(super::super::helpers::json_ok_with(&format!( + "\"zOrder\":{}", + new_z + ))) } /// 연결선의 SubjectID를 갱신한다 (연결선 생성 후 호출) @@ -2385,9 +2854,18 @@ impl DocumentCore { ) { use crate::model::shape::ConnectorControlPoint; - let section = match self.document.sections.get_mut(section_idx) { Some(s) => s, None => return }; - let para = match section.paragraphs.get_mut(para_idx) { Some(p) => p, None => return }; - let ctrl = match para.controls.get_mut(control_idx) { Some(c) => c, None => return }; + let section = match self.document.sections.get_mut(section_idx) { + Some(s) => s, + None => return, + }; + let para = match section.paragraphs.get_mut(para_idx) { + Some(p) => p, + None => return, + }; + let ctrl = match para.controls.get_mut(control_idx) { + Some(c) => c, + None => return, + }; let line = match ctrl { Control::Shape(ref mut s) => match s.as_mut() { @@ -2443,62 +2921,128 @@ impl DocumentCore { }; conn.control_points = vec![ - ConnectorControlPoint { x: sx, y: sy, point_type: 3 }, // 시작 앵커 - ConnectorControlPoint { x: c1x, y: c1y, point_type: 2 }, // 베지어 ctrl1 - ConnectorControlPoint { x: c2x, y: c2y, point_type: 2 }, // 베지어 ctrl2 - ConnectorControlPoint { x: ex, y: ey, point_type: 26 }, // 끝 앵커 + ConnectorControlPoint { + x: sx, + y: sy, + point_type: 3, + }, // 시작 앵커 + ConnectorControlPoint { + x: c1x, + y: c1y, + point_type: 2, + }, // 베지어 ctrl1 + ConnectorControlPoint { + x: c2x, + y: c2y, + point_type: 2, + }, // 베지어 ctrl2 + ConnectorControlPoint { + x: ex, + y: ey, + point_type: 26, + }, // 끝 앵커 ]; } else { // ─── 꺽인 연결선: 직각 꺾임점 ─── let mut pts = Vec::new(); - pts.push(ConnectorControlPoint { x: sx, y: sy, point_type: 3 }); + pts.push(ConnectorControlPoint { + x: sx, + y: sy, + point_type: 3, + }); match (start_idx, end_idx) { (1, 3) | (3, 1) => { let mid_x = (sx + ex) / 2; - pts.push(ConnectorControlPoint { x: mid_x, y: sy, point_type: 2 }); - pts.push(ConnectorControlPoint { x: mid_x, y: ey, point_type: 2 }); + pts.push(ConnectorControlPoint { + x: mid_x, + y: sy, + point_type: 2, + }); + pts.push(ConnectorControlPoint { + x: mid_x, + y: ey, + point_type: 2, + }); } (2, 0) | (0, 2) => { let mid_y = (sy + ey) / 2; - pts.push(ConnectorControlPoint { x: sx, y: mid_y, point_type: 2 }); - pts.push(ConnectorControlPoint { x: ex, y: mid_y, point_type: 2 }); + pts.push(ConnectorControlPoint { + x: sx, + y: mid_y, + point_type: 2, + }); + pts.push(ConnectorControlPoint { + x: ex, + y: mid_y, + point_type: 2, + }); } (1, 0) | (1, 2) | (3, 0) | (3, 2) => { - pts.push(ConnectorControlPoint { x: ex, y: sy, point_type: 2 }); + pts.push(ConnectorControlPoint { + x: ex, + y: sy, + point_type: 2, + }); } (0, 1) | (0, 3) | (2, 1) | (2, 3) => { - pts.push(ConnectorControlPoint { x: sx, y: ey, point_type: 2 }); + pts.push(ConnectorControlPoint { + x: sx, + y: ey, + point_type: 2, + }); } _ => { let mid_x = (sx + ex) / 2; - pts.push(ConnectorControlPoint { x: mid_x, y: sy, point_type: 2 }); - pts.push(ConnectorControlPoint { x: mid_x, y: ey, point_type: 2 }); + pts.push(ConnectorControlPoint { + x: mid_x, + y: sy, + point_type: 2, + }); + pts.push(ConnectorControlPoint { + x: mid_x, + y: ey, + point_type: 2, + }); } } - pts.push(ConnectorControlPoint { x: ex, y: ey, point_type: 26 }); + pts.push(ConnectorControlPoint { + x: ex, + y: ey, + point_type: 26, + }); conn.control_points = pts; } } /// 구역 내 모든 연결선을 스캔하여 연결된 도형의 현재 위치에 맞게 갱신한다. pub fn update_connectors_in_section(&mut self, section_idx: usize) { - let section = match self.document.sections.get(section_idx) { Some(s) => s, None => return }; + let section = match self.document.sections.get(section_idx) { + Some(s) => s, + None => return, + }; // 1) SC inst_id → 연결점 좌표 맵 구축 (SubjectID = drawing.inst_id) - let mut conn_points: std::collections::HashMap = std::collections::HashMap::new(); + let mut conn_points: std::collections::HashMap = + std::collections::HashMap::new(); for para in §ion.paragraphs { for ctrl in ¶.controls { let (common, inst_id, _is_line) = match ctrl { Control::Shape(s) => { let sc_inst = s.drawing().map(|d| d.inst_id).unwrap_or(0); - (s.common(), sc_inst, matches!(s.as_ref(), ShapeObject::Line(_))) + ( + s.common(), + sc_inst, + matches!(s.as_ref(), ShapeObject::Line(_)), + ) } Control::Picture(p) => (&p.common, 0u32, false), _ => continue, }; - if _is_line { continue; } + if _is_line { + continue; + } let x = common.horizontal_offset as i32; let y = common.vertical_offset as i32; let w = common.width as i32; @@ -2519,7 +3063,10 @@ impl DocumentCore { } // 2) 커넥터 찾기 및 좌표 갱신 - let section = match self.document.sections.get_mut(section_idx) { Some(s) => s, None => return }; + let section = match self.document.sections.get_mut(section_idx) { + Some(s) => s, + None => return, + }; for para in &mut section.paragraphs { for ctrl in &mut para.controls { let line = match ctrl { @@ -2535,7 +3082,9 @@ impl DocumentCore { let end_pts = conn_points.get(&conn.end_subject_id); // 연결된 도형을 찾지 못하면 건너뜀 (연결 끊어진 상태) - if start_pts.is_none() || end_pts.is_none() { continue; } + if start_pts.is_none() || end_pts.is_none() { + continue; + } let si = conn.start_subject_index as usize; let ei = conn.end_subject_index as usize; @@ -2575,14 +3124,22 @@ impl DocumentCore { // 3) 제어점 재계산 (인덱스 수집 후 별도 루프 — borrow checker 대응) let mut routing_targets: Vec<(usize, usize, u32, u32)> = Vec::new(); { - let section = match self.document.sections.get(section_idx) { Some(s) => s, None => return }; + let section = match self.document.sections.get(section_idx) { + Some(s) => s, + None => return, + }; for (pi, para) in section.paragraphs.iter().enumerate() { for (ci, ctrl) in para.controls.iter().enumerate() { if let Control::Shape(ref s) = ctrl { if let ShapeObject::Line(ref l) = s.as_ref() { if let Some(ref c) = l.connector { if c.link_type.is_stroke() || c.link_type.is_arc() { - routing_targets.push((pi, ci, c.start_subject_index, c.end_subject_index)); + routing_targets.push(( + pi, + ci, + c.start_subject_index, + c.end_subject_index, + )); } } } @@ -2601,14 +3158,23 @@ impl DocumentCore { section_idx: usize, para_idx: usize, control_idx: usize, - start_x: i32, start_y: i32, - end_x: i32, end_y: i32, + start_x: i32, + start_y: i32, + end_x: i32, + end_y: i32, ) -> Result { - let section = self.document.sections.get_mut(section_idx) + let section = self + .document + .sections + .get_mut(section_idx) .ok_or_else(|| HwpError::RenderError("구역 범위 초과".to_string()))?; - let para = section.paragraphs.get_mut(para_idx) + let para = section + .paragraphs + .get_mut(para_idx) .ok_or_else(|| HwpError::RenderError("문단 범위 초과".to_string()))?; - let ctrl = para.controls.get_mut(control_idx) + let ctrl = para + .controls + .get_mut(control_idx) .ok_or_else(|| HwpError::RenderError("컨트롤 범위 초과".to_string()))?; let line = match ctrl { Control::Shape(ref mut s) => match s.as_mut() { @@ -2651,35 +3217,57 @@ impl DocumentCore { /// 도형 내부 좌표만 스케일 (common/shape_attr은 변경하지 않음) fn scale_shape_coords(child: &mut crate::model::shape::ShapeObject, sx: f64, sy: f64) { use crate::model::shape::ShapeObject as SO; - fn sp(v: i32, s: f64) -> i32 { (v as f64 * s).round() as i32 } + fn sp(v: i32, s: f64) -> i32 { + (v as f64 * s).round() as i32 + } match child { SO::Line(ref mut s) => { - s.start.x = sp(s.start.x, sx); s.start.y = sp(s.start.y, sy); - s.end.x = sp(s.end.x, sx); s.end.y = sp(s.end.y, sy); + s.start.x = sp(s.start.x, sx); + s.start.y = sp(s.start.y, sy); + s.end.x = sp(s.end.x, sx); + s.end.y = sp(s.end.y, sy); } SO::Rectangle(ref mut s) => { - let w = s.common.width as i32; let h = s.common.height as i32; - s.x_coords = [0, w, w, 0]; s.y_coords = [0, 0, h, h]; + let w = s.common.width as i32; + let h = s.common.height as i32; + s.x_coords = [0, w, w, 0]; + s.y_coords = [0, 0, h, h]; } SO::Ellipse(ref mut s) => { - s.center.x = sp(s.center.x, sx); s.center.y = sp(s.center.y, sy); - s.axis1.x = sp(s.axis1.x, sx); s.axis1.y = sp(s.axis1.y, sy); - s.axis2.x = sp(s.axis2.x, sx); s.axis2.y = sp(s.axis2.y, sy); - s.start1.x = sp(s.start1.x, sx); s.start1.y = sp(s.start1.y, sy); - s.end1.x = sp(s.end1.x, sx); s.end1.y = sp(s.end1.y, sy); - s.start2.x = sp(s.start2.x, sx); s.start2.y = sp(s.start2.y, sy); - s.end2.x = sp(s.end2.x, sx); s.end2.y = sp(s.end2.y, sy); + s.center.x = sp(s.center.x, sx); + s.center.y = sp(s.center.y, sy); + s.axis1.x = sp(s.axis1.x, sx); + s.axis1.y = sp(s.axis1.y, sy); + s.axis2.x = sp(s.axis2.x, sx); + s.axis2.y = sp(s.axis2.y, sy); + s.start1.x = sp(s.start1.x, sx); + s.start1.y = sp(s.start1.y, sy); + s.end1.x = sp(s.end1.x, sx); + s.end1.y = sp(s.end1.y, sy); + s.start2.x = sp(s.start2.x, sx); + s.start2.y = sp(s.start2.y, sy); + s.end2.x = sp(s.end2.x, sx); + s.end2.y = sp(s.end2.y, sy); } SO::Arc(ref mut s) => { - s.center.x = sp(s.center.x, sx); s.center.y = sp(s.center.y, sy); - s.axis1.x = sp(s.axis1.x, sx); s.axis1.y = sp(s.axis1.y, sy); - s.axis2.x = sp(s.axis2.x, sx); s.axis2.y = sp(s.axis2.y, sy); + s.center.x = sp(s.center.x, sx); + s.center.y = sp(s.center.y, sy); + s.axis1.x = sp(s.axis1.x, sx); + s.axis1.y = sp(s.axis1.y, sy); + s.axis2.x = sp(s.axis2.x, sx); + s.axis2.y = sp(s.axis2.y, sy); } SO::Polygon(ref mut s) => { - for p in &mut s.points { p.x = sp(p.x, sx); p.y = sp(p.y, sy); } + for p in &mut s.points { + p.x = sp(p.x, sx); + p.y = sp(p.y, sy); + } } SO::Curve(ref mut s) => { - for p in &mut s.points { p.x = sp(p.x, sx); p.y = sp(p.y, sy); } + for p in &mut s.points { + p.x = sp(p.x, sx); + p.y = sp(p.y, sy); + } } _ => {} } @@ -2688,7 +3276,9 @@ impl DocumentCore { /// 그룹 자식 개체들을 비례 스케일 (크기/위치/도형좌표 포함) fn scale_group_children(children: &mut [crate::model::shape::ShapeObject], sx: f64, sy: f64) { use crate::model::shape::ShapeObject as SO; - fn sp(v: i32, s: f64) -> i32 { (v as f64 * s).round() as i32 } + fn sp(v: i32, s: f64) -> i32 { + (v as f64 * s).round() as i32 + } for child in children.iter_mut() { // CommonObjAttr 스케일 @@ -2705,35 +3295,51 @@ impl DocumentCore { // 도형별 좌표 스케일 match child { SO::Line(ref mut s) => { - s.start.x = sp(s.start.x, sx); s.start.y = sp(s.start.y, sy); - s.end.x = sp(s.end.x, sx); s.end.y = sp(s.end.y, sy); + s.start.x = sp(s.start.x, sx); + s.start.y = sp(s.start.y, sy); + s.end.x = sp(s.end.x, sx); + s.end.y = sp(s.end.y, sy); } SO::Rectangle(ref mut s) => { - let w = new_cw as i32; let h = new_ch as i32; - s.x_coords = [0, w, w, 0]; s.y_coords = [0, 0, h, h]; + let w = new_cw as i32; + let h = new_ch as i32; + s.x_coords = [0, w, w, 0]; + s.y_coords = [0, 0, h, h]; } SO::Ellipse(ref mut s) => { - s.center.x = sp(s.center.x, sx); s.center.y = sp(s.center.y, sy); - s.axis1.x = sp(s.axis1.x, sx); s.axis1.y = sp(s.axis1.y, sy); - s.axis2.x = sp(s.axis2.x, sx); s.axis2.y = sp(s.axis2.y, sy); - s.start1.x = sp(s.start1.x, sx); s.start1.y = sp(s.start1.y, sy); - s.end1.x = sp(s.end1.x, sx); s.end1.y = sp(s.end1.y, sy); - s.start2.x = sp(s.start2.x, sx); s.start2.y = sp(s.start2.y, sy); - s.end2.x = sp(s.end2.x, sx); s.end2.y = sp(s.end2.y, sy); + s.center.x = sp(s.center.x, sx); + s.center.y = sp(s.center.y, sy); + s.axis1.x = sp(s.axis1.x, sx); + s.axis1.y = sp(s.axis1.y, sy); + s.axis2.x = sp(s.axis2.x, sx); + s.axis2.y = sp(s.axis2.y, sy); + s.start1.x = sp(s.start1.x, sx); + s.start1.y = sp(s.start1.y, sy); + s.end1.x = sp(s.end1.x, sx); + s.end1.y = sp(s.end1.y, sy); + s.start2.x = sp(s.start2.x, sx); + s.start2.y = sp(s.start2.y, sy); + s.end2.x = sp(s.end2.x, sx); + s.end2.y = sp(s.end2.y, sy); } SO::Arc(ref mut s) => { - s.center.x = sp(s.center.x, sx); s.center.y = sp(s.center.y, sy); - s.axis1.x = sp(s.axis1.x, sx); s.axis1.y = sp(s.axis1.y, sy); - s.axis2.x = sp(s.axis2.x, sx); s.axis2.y = sp(s.axis2.y, sy); + s.center.x = sp(s.center.x, sx); + s.center.y = sp(s.center.y, sy); + s.axis1.x = sp(s.axis1.x, sx); + s.axis1.y = sp(s.axis1.y, sy); + s.axis2.x = sp(s.axis2.x, sx); + s.axis2.y = sp(s.axis2.y, sy); } SO::Polygon(ref mut s) => { for p in &mut s.points { - p.x = sp(p.x, sx); p.y = sp(p.y, sy); + p.x = sp(p.x, sx); + p.y = sp(p.y, sy); } } SO::Curve(ref mut s) => { for p in &mut s.points { - p.x = sp(p.x, sx); p.y = sp(p.y, sy); + p.x = sp(p.x, sx); + p.y = sp(p.y, sy); } } SO::Group(ref mut g) => { @@ -2771,9 +3377,13 @@ impl DocumentCore { /// 구역 내 모든 Shape의 z_order 최대값을 반환 (새 Shape 생성 시 사용) fn max_shape_z_order_in_section(&self, section_idx: usize) -> i32 { - self.document.sections.get(section_idx) + self.document + .sections + .get(section_idx) .map(|section| { - section.paragraphs.iter() + section + .paragraphs + .iter() .flat_map(|p| p.controls.iter()) .filter_map(|ctrl| { if let Control::Shape(shape) = ctrl { @@ -2798,14 +3408,19 @@ impl DocumentCore { section_idx: usize, targets: &[(usize, usize)], ) -> Result { - use crate::model::shape::*; use crate::model::control::Control; + use crate::model::shape::*; if targets.len() < 2 { - return Err(HwpError::RenderError("묶기 위해서는 2개 이상의 개체가 필요합니다".to_string())); + return Err(HwpError::RenderError( + "묶기 위해서는 2개 이상의 개체가 필요합니다".to_string(), + )); } if section_idx >= self.document.sections.len() { - return Err(HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx))); + return Err(HwpError::RenderError(format!( + "구역 인덱스 {} 범위 초과", + section_idx + ))); } // 1) 대상 개체들을 ShapeObject로 수집 (인덱스 유효성 검사 포함) @@ -2819,10 +3434,16 @@ impl DocumentCore { for &(pi, ci) in targets { if pi >= section.paragraphs.len() { - return Err(HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", pi))); + return Err(HwpError::RenderError(format!( + "문단 인덱스 {} 범위 초과", + pi + ))); } if ci >= section.paragraphs[pi].controls.len() { - return Err(HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과 (문단 {})", ci, pi))); + return Err(HwpError::RenderError(format!( + "컨트롤 인덱스 {} 범위 초과 (문단 {})", + ci, pi + ))); } let ctrl = §ion.paragraphs[pi].controls[ci]; let (common, shape_obj) = match ctrl { @@ -2834,7 +3455,12 @@ impl DocumentCore { let c = p.common.clone(); (c, ShapeObject::Picture(p.clone())) } - _ => return Err(HwpError::RenderError(format!("컨트롤 ({},{})은 Shape/Picture가 아닙니다", pi, ci))), + _ => { + return Err(HwpError::RenderError(format!( + "컨트롤 ({},{})은 Shape/Picture가 아닙니다", + pi, ci + ))) + } }; // 합산 bbox 계산 (HWPUNIT 기준 — horizontal_offset, vertical_offset, width, height) @@ -2881,7 +3507,7 @@ impl DocumentCore { sa.group_level = 1; sa.is_two_ctrl_id = false; // 그룹 자식은 ctrl_id 1번만 sa.raw_rendering = Vec::new(); // 새로 생성 (직렬화 시 재계산) - // 렌더러가 사용하는 변환 행렬 값 설정 + // 렌더러가 사용하는 변환 행렬 값 설정 sa.render_tx = new_horz as f64; sa.render_ty = new_vert as f64; sa.render_sx = 1.0; @@ -2948,20 +3574,34 @@ impl DocumentCore { let mut prev_end: u32 = 0; let mut gap_start: Option = None; 'outer: for i in 0..text_chars.len() { - let offset = if i < para.char_offsets.len() { para.char_offsets[i] } else { prev_end }; + let offset = if i < para.char_offsets.len() { + para.char_offsets[i] + } else { + prev_end + }; while prev_end + 8 <= offset && ctrl_ci < para.controls.len() { - if ctrl_ci == ci { gap_start = Some(prev_end); break 'outer; } + if ctrl_ci == ci { + gap_start = Some(prev_end); + break 'outer; + } ctrl_ci += 1; prev_end += 8; } - let char_size: u32 = if text_chars[i] == '\t' { 8 } - else if text_chars[i].len_utf16() == 2 { 2 } - else { 1 }; + let char_size: u32 = if text_chars[i] == '\t' { + 8 + } else if text_chars[i].len_utf16() == 2 { + 2 + } else { + 1 + }; prev_end = offset + char_size; } if gap_start.is_none() { while ctrl_ci < para.controls.len() { - if ctrl_ci == ci { gap_start = Some(prev_end); break; } + if ctrl_ci == ci { + gap_start = Some(prev_end); + break; + } ctrl_ci += 1; prev_end += 8; } @@ -2969,7 +3609,9 @@ impl DocumentCore { if let Some(gs) = gap_start { let threshold = gs + 8; for offset in para.char_offsets.iter_mut() { - if *offset >= threshold { *offset -= 8; } + if *offset >= threshold { + *offset -= 8; + } } } @@ -2977,13 +3619,16 @@ impl DocumentCore { if ci < para.ctrl_data_records.len() { para.ctrl_data_records.remove(ci); } - if para.char_count >= 8 { para.char_count -= 8; } + if para.char_count >= 8 { + para.char_count -= 8; + } } // 5) 삽입 위치 인덱스 재계산 (제거 후 인덱스가 변했을 수 있음) // insert_target의 para에서 그보다 앞에서 제거된 개체 수만큼 보정 let (insert_pi, insert_ci_orig) = insert_target; - let removed_before = sorted_targets.iter() + let removed_before = sorted_targets + .iter() .filter(|&&(pi, ci)| pi == insert_pi && ci < insert_ci_orig) .count(); let insert_ci = insert_ci_orig - removed_before; @@ -2994,7 +3639,8 @@ impl DocumentCore { // controls/ctrl_data_records 삽입 (범위 보정) let ctrl_insert = insert_ci.min(para.controls.len()); - para.controls.insert(ctrl_insert, Control::Shape(Box::new(group_obj))); + para.controls + .insert(ctrl_insert, Control::Shape(Box::new(group_obj))); let cdr_insert = ctrl_insert.min(para.ctrl_data_records.len()); para.ctrl_data_records.insert(cdr_insert, None); @@ -3016,8 +3662,14 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::PictureInserted { section: section_idx, para: insert_pi }); - Ok(super::super::helpers::json_ok_with(&format!("\"paraIdx\":{},\"controlIdx\":{}", insert_pi, insert_ci))) + self.event_log.push(DocumentEvent::PictureInserted { + section: section_idx, + para: insert_pi, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"paraIdx\":{},\"controlIdx\":{}", + insert_pi, insert_ci + ))) } /// GroupShape를 풀어 자식 개체들을 개별 Shape/Picture로 복원한다. @@ -3028,35 +3680,54 @@ impl DocumentCore { para_idx: usize, control_idx: usize, ) -> Result { - use crate::model::shape::*; use crate::model::control::Control; + use crate::model::shape::*; if section_idx >= self.document.sections.len() { - return Err(HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx))); + return Err(HwpError::RenderError(format!( + "구역 인덱스 {} 범위 초과", + section_idx + ))); } let section = &mut self.document.sections[section_idx]; if para_idx >= section.paragraphs.len() { - return Err(HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", para_idx))); + return Err(HwpError::RenderError(format!( + "문단 인덱스 {} 범위 초과", + para_idx + ))); } let para = &mut section.paragraphs[para_idx]; if control_idx >= para.controls.len() { - return Err(HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx))); + return Err(HwpError::RenderError(format!( + "컨트롤 인덱스 {} 범위 초과", + control_idx + ))); } // GroupShape 추출 match ¶.controls[control_idx] { Control::Shape(s) => match s.as_ref() { ShapeObject::Group(_) => {} - _ => return Err(HwpError::RenderError("지정된 컨트롤이 GroupShape이 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "지정된 컨트롤이 GroupShape이 아닙니다".to_string(), + )) + } }, - _ => return Err(HwpError::RenderError("지정된 컨트롤이 Shape이 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "지정된 컨트롤이 Shape이 아닙니다".to_string(), + )) + } }; // GroupShape를 꺼냄 let group_ctrl = para.controls.remove(control_idx); if control_idx < para.ctrl_data_records.len() { para.ctrl_data_records.remove(control_idx); } - if para.char_count >= 8 { para.char_count -= 8; } + if para.char_count >= 8 { + para.char_count -= 8; + } let group_shape = match group_ctrl { Control::Shape(s) => match *s { @@ -3071,8 +3742,16 @@ impl DocumentCore { let group_y = group_shape.common.vertical_offset as i32; // 그룹 스케일 (리사이즈된 경우) let gsa = &group_shape.shape_attr; - let group_sx = if gsa.original_width > 0 { gsa.current_width as f64 / gsa.original_width as f64 } else { 1.0 }; - let group_sy = if gsa.original_height > 0 { gsa.current_height as f64 / gsa.original_height as f64 } else { 1.0 }; + let group_sx = if gsa.original_width > 0 { + gsa.current_width as f64 / gsa.original_width as f64 + } else { + 1.0 + }; + let group_sy = if gsa.original_height > 0 { + gsa.current_height as f64 / gsa.original_height as f64 + } else { + 1.0 + }; // 자식들을 개별 컨트롤로 복원 let mut insert_idx = control_idx; @@ -3085,15 +3764,24 @@ impl DocumentCore { let sa_ox = sa.offset_x; let sa_oy = sa.offset_y; let c = child.common_mut(); - if c.width == 0 && sa_w > 0 { c.width = sa_w; } - if c.height == 0 && sa_h > 0 { c.height = sa_h; } - if c.horizontal_offset == 0 && sa_ox > 0 { c.horizontal_offset = sa_ox as u32; } - if c.vertical_offset == 0 && sa_oy > 0 { c.vertical_offset = sa_oy as u32; } + if c.width == 0 && sa_w > 0 { + c.width = sa_w; + } + if c.height == 0 && sa_h > 0 { + c.height = sa_h; + } + if c.horizontal_offset == 0 && sa_ox > 0 { + c.horizontal_offset = sa_ox as u32; + } + if c.vertical_offset == 0 && sa_oy > 0 { + c.vertical_offset = sa_oy as u32; + } } // 자식의 로컬 좌표를 글로벌 좌표로 변환 (그룹 스케일 적용) { let c = child.common_mut(); - c.horizontal_offset = (group_x + (c.horizontal_offset as f64 * group_sx) as i32) as u32; + c.horizontal_offset = + (group_x + (c.horizontal_offset as f64 * group_sx) as i32) as u32; c.vertical_offset = (group_y + (c.vertical_offset as f64 * group_sy) as i32) as u32; c.width = ((c.width as f64 * group_sx).round().max(1.0)) as u32; c.height = ((c.height as f64 * group_sy).round().max(1.0)) as u32; @@ -3123,7 +3811,9 @@ impl DocumentCore { ShapeObject::Group(g) => &mut g.shape_attr, ShapeObject::Picture(p) => &mut p.shape_attr, }; - if sa.group_level > 0 { sa.group_level -= 1; } + if sa.group_level > 0 { + sa.group_level -= 1; + } sa.offset_x = 0; sa.offset_y = 0; sa.render_tx = 0.0; @@ -3137,7 +3827,8 @@ impl DocumentCore { } // 문단에 삽입 - para.controls.insert(insert_idx, Control::Shape(Box::new(child))); + para.controls + .insert(insert_idx, Control::Shape(Box::new(child))); para.ctrl_data_records.insert(insert_idx, None); para.char_count += 8; para.control_mask |= 0x00000800; @@ -3159,7 +3850,11 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::PictureDeleted { section: section_idx, para: para_idx, ctrl: control_idx }); + self.event_log.push(DocumentEvent::PictureDeleted { + section: section_idx, + para: para_idx, + ctrl: control_idx, + }); Ok("{\"ok\":true}".to_string()) } @@ -3175,35 +3870,53 @@ impl DocumentCore { cell_idx: Option, cell_para_idx: Option, ) -> Result<&crate::model::control::Equation, HwpError> { - let section = self.document.sections.get(section_idx) - .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))?; + let section = self.document.sections.get(section_idx).ok_or_else(|| { + HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)) + })?; let ctrl = if let (Some(ci), Some(cpi)) = (cell_idx, cell_para_idx) { // 표 셀 내 수식 - let para = section.paragraphs.get(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)))?; + let para = section.paragraphs.get(parent_para_idx).ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)) + })?; let table = match para.controls.get(control_idx) { Some(Control::Table(t)) => t, - _ => return Err(HwpError::RenderError("지정된 컨트롤이 표가 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "지정된 컨트롤이 표가 아닙니다".to_string(), + )) + } }; - let cell = table.cells.get(ci) + let cell = table + .cells + .get(ci) .ok_or_else(|| HwpError::RenderError(format!("셀 인덱스 {} 범위 초과", ci)))?; - let cell_para = cell.paragraphs.get(cpi) - .ok_or_else(|| HwpError::RenderError(format!("셀 문단 인덱스 {} 범위 초과", cpi)))?; + let cell_para = cell.paragraphs.get(cpi).ok_or_else(|| { + HwpError::RenderError(format!("셀 문단 인덱스 {} 범위 초과", cpi)) + })?; // 셀 문단의 첫 번째 수식 컨트롤을 찾는다 - cell_para.controls.iter().find(|c| matches!(c, Control::Equation(_))) - .ok_or_else(|| HwpError::RenderError("셀 문단에 수식 컨트롤이 없습니다".to_string()))? + cell_para + .controls + .iter() + .find(|c| matches!(c, Control::Equation(_))) + .ok_or_else(|| { + HwpError::RenderError("셀 문단에 수식 컨트롤이 없습니다".to_string()) + })? } else { // 본문 수식 - let para = section.paragraphs.get(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)))?; - para.controls.get(control_idx) - .ok_or_else(|| HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)))? + let para = section.paragraphs.get(parent_para_idx).ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)) + })?; + para.controls.get(control_idx).ok_or_else(|| { + HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)) + })? }; match ctrl { Control::Equation(e) => Ok(e), - _ => Err(HwpError::RenderError("지정된 컨트롤이 수식이 아닙니다".to_string())), + _ => Err(HwpError::RenderError( + "지정된 컨트롤이 수식이 아닙니다".to_string(), + )), } } @@ -3216,34 +3929,52 @@ impl DocumentCore { cell_idx: Option, cell_para_idx: Option, ) -> Result<&mut crate::model::control::Equation, HwpError> { - let section = self.document.sections.get_mut(section_idx) - .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))?; + let section = self.document.sections.get_mut(section_idx).ok_or_else(|| { + HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)) + })?; let ctrl = if let (Some(ci), Some(cpi)) = (cell_idx, cell_para_idx) { // 표 셀 내 수식 - let para = section.paragraphs.get_mut(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)))?; + let para = section.paragraphs.get_mut(parent_para_idx).ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)) + })?; let table = match para.controls.get_mut(control_idx) { Some(Control::Table(t)) => t, - _ => return Err(HwpError::RenderError("지정된 컨트롤이 표가 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "지정된 컨트롤이 표가 아닙니다".to_string(), + )) + } }; - let cell = table.cells.get_mut(ci) + let cell = table + .cells + .get_mut(ci) .ok_or_else(|| HwpError::RenderError(format!("셀 인덱스 {} 범위 초과", ci)))?; - let cell_para = cell.paragraphs.get_mut(cpi) - .ok_or_else(|| HwpError::RenderError(format!("셀 문단 인덱스 {} 범위 초과", cpi)))?; - cell_para.controls.iter_mut().find(|c| matches!(c, Control::Equation(_))) - .ok_or_else(|| HwpError::RenderError("셀 문단에 수식 컨트롤이 없습니다".to_string()))? + let cell_para = cell.paragraphs.get_mut(cpi).ok_or_else(|| { + HwpError::RenderError(format!("셀 문단 인덱스 {} 범위 초과", cpi)) + })?; + cell_para + .controls + .iter_mut() + .find(|c| matches!(c, Control::Equation(_))) + .ok_or_else(|| { + HwpError::RenderError("셀 문단에 수식 컨트롤이 없습니다".to_string()) + })? } else { // 본문 수식 - let para = section.paragraphs.get_mut(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)))?; - para.controls.get_mut(control_idx) - .ok_or_else(|| HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)))? + let para = section.paragraphs.get_mut(parent_para_idx).ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)) + })?; + para.controls.get_mut(control_idx).ok_or_else(|| { + HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)) + })? }; match ctrl { Control::Equation(e) => Ok(e), - _ => Err(HwpError::RenderError("지정된 컨트롤이 수식이 아닙니다".to_string())), + _ => Err(HwpError::RenderError( + "지정된 컨트롤이 수식이 아닙니다".to_string(), + )), } } @@ -3255,7 +3986,13 @@ impl DocumentCore { cell_idx: Option, cell_para_idx: Option, ) -> Result { - let eq = self.find_equation_ref(section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx)?; + let eq = self.find_equation_ref( + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + )?; let script_escaped = super::super::helpers::json_escape(&eq.script); let font_name_escaped = super::super::helpers::json_escape(&eq.font_name); @@ -3265,8 +4002,7 @@ impl DocumentCore { "{{\"script\":\"{}\",\"fontSize\":{},\"color\":{},", "\"baseline\":{},\"fontName\":\"{}\"}}" ), - script_escaped, eq.font_size, eq.color, - eq.baseline, font_name_escaped, + script_escaped, eq.font_size, eq.color, eq.baseline, font_name_escaped, )) } @@ -3280,14 +4016,20 @@ impl DocumentCore { cell_para_idx: Option, props_json: &str, ) -> Result { - use super::super::helpers::{json_u32, json_i32, json_str}; - use crate::renderer::equation::tokenizer::tokenize; - use crate::renderer::equation::parser::EqParser; + use super::super::helpers::{json_i32, json_str, json_u32}; use crate::renderer::equation::layout::EqLayout; + use crate::renderer::equation::parser::EqParser; + use crate::renderer::equation::tokenizer::tokenize; use crate::renderer::hwpunit_to_px; let dpi = self.dpi; - let eq = self.find_equation_mut(section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx)?; + let eq = self.find_equation_mut( + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + )?; if let Some(s) = json_str(props_json, "script") { eq.script = s; @@ -3317,8 +4059,10 @@ impl DocumentCore { // 표 셀 내 수식인 경우 표 dirty 플래그 설정 if cell_idx.is_some() { - if let Some(Control::Table(t)) = self.document.sections[section_idx] - .paragraphs[parent_para_idx].controls.get_mut(control_idx) + if let Some(Control::Table(t)) = self.document.sections[section_idx].paragraphs + [parent_para_idx] + .controls + .get_mut(control_idx) { t.dirty = true; } @@ -3340,10 +4084,10 @@ impl DocumentCore { font_size_hwpunit: u32, color: u32, ) -> Result { - use crate::renderer::equation::tokenizer::tokenize; - use crate::renderer::equation::parser::EqParser; use crate::renderer::equation::layout::EqLayout; - use crate::renderer::equation::svg_render::{render_equation_svg, eq_color_to_svg}; + use crate::renderer::equation::parser::EqParser; + use crate::renderer::equation::svg_render::{eq_color_to_svg, render_equation_svg}; + use crate::renderer::equation::tokenizer::tokenize; let font_size_px = crate::renderer::hwpunit_to_px(font_size_hwpunit as i32, self.dpi); let tokens = tokenize(script); @@ -3373,13 +4117,19 @@ impl DocumentCore { char_offset: usize, ) -> Result { use crate::model::footnote::Footnote; - use crate::model::paragraph::{Paragraph, CharShapeRef, LineSeg}; + use crate::model::paragraph::{CharShapeRef, LineSeg, Paragraph}; if section_idx >= self.document.sections.len() { - return Err(HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx))); + return Err(HwpError::RenderError(format!( + "구역 인덱스 {} 범위 초과", + section_idx + ))); } if para_idx >= self.document.sections[section_idx].paragraphs.len() { - return Err(HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", para_idx))); + return Err(HwpError::RenderError(format!( + "문단 인덱스 {} 범위 초과", + para_idx + ))); } // 각주 번호: 삽입 위치 이전의 모든 각주 수 + 1 @@ -3397,28 +4147,39 @@ impl DocumentCore { if is_before { count += 1; } else if is_same { - let positions = crate::document_core::helpers::find_control_text_positions(para); + let positions = + crate::document_core::helpers::find_control_text_positions( + para, + ); let pos = positions.get(ci).copied().unwrap_or(usize::MAX); - if pos <= char_offset { count += 1; } + if pos <= char_offset { + count += 1; + } } } // 표 셀 내 각주 Control::Table(table) if is_before || is_same => { for cell in &table.cells { for cp in &cell.paragraphs { - count += cp.controls.iter() - .filter(|c| matches!(c, Control::Footnote(_))) - .count() as u16; + count += + cp.controls + .iter() + .filter(|c| matches!(c, Control::Footnote(_))) + .count() as u16; } } } // 글상자 내 각주 Control::Shape(shape) if is_before || is_same => { - if let Some(text_box) = shape.drawing().and_then(|d| d.text_box.as_ref()) { + if let Some(text_box) = + shape.drawing().and_then(|d| d.text_box.as_ref()) + { for tp in &text_box.paragraphs { - count += tp.controls.iter() - .filter(|c| matches!(c, Control::Footnote(_))) - .count() as u16; + count += + tp.controls + .iter() + .filter(|c| matches!(c, Control::Footnote(_))) + .count() as u16; } } } @@ -3440,7 +4201,10 @@ impl DocumentCore { if let Control::Footnote(fn_) = ctrl { if let Some(fp) = fn_.paragraphs.first() { found = Some(( - fp.char_shapes.first().map(|cs| cs.char_shape_id).unwrap_or(0), + fp.char_shapes + .first() + .map(|cs| cs.char_shape_id) + .unwrap_or(0), fp.para_shape_id, )); break 'outer; @@ -3454,7 +4218,10 @@ impl DocumentCore { if let Control::Footnote(fn_) = cc { if let Some(fp) = fn_.paragraphs.first() { found = Some(( - fp.char_shapes.first().map(|cs| cs.char_shape_id).unwrap_or(0), + fp.char_shapes + .first() + .map(|cs| cs.char_shape_id) + .unwrap_or(0), fp.para_shape_id, )); break 'outer; @@ -3469,7 +4236,11 @@ impl DocumentCore { found.unwrap_or_else(|| { let current_para = §ion.paragraphs[para_idx]; ( - current_para.char_shapes.first().map(|cs| cs.char_shape_id).unwrap_or(0), + current_para + .char_shapes + .first() + .map(|cs| cs.char_shape_id) + .unwrap_or(0), current_para.para_shape_id, ) }) @@ -3522,7 +4293,9 @@ impl DocumentCore { idx }; - paragraph.controls.insert(insert_idx, Control::Footnote(Box::new(footnote))); + paragraph + .controls + .insert(insert_idx, Control::Footnote(Box::new(footnote))); paragraph.ctrl_data_records.insert(insert_idx, None); // char_offsets 조정: char_offset 위치에 8바이트 갭 생성 @@ -3545,7 +4318,10 @@ impl DocumentCore { { let mut num = 1u16; for pi in 0..self.document.sections[section_idx].paragraphs.len() { - for ci in 0..self.document.sections[section_idx].paragraphs[pi].controls.len() { + for ci in 0..self.document.sections[section_idx].paragraphs[pi] + .controls + .len() + { match &mut self.document.sections[section_idx].paragraphs[pi].controls[ci] { Control::Footnote(ref mut fn_) => { fn_.number = num; @@ -3564,7 +4340,9 @@ impl DocumentCore { } } Control::Shape(ref mut shape) => { - if let Some(text_box) = shape.drawing_mut().and_then(|d| d.text_box.as_mut()) { + if let Some(text_box) = + shape.drawing_mut().and_then(|d| d.text_box.as_mut()) + { for tp in &mut text_box.paragraphs { for tc in &mut tp.controls { if let Control::Footnote(ref mut fn_) = tc { @@ -3586,15 +4364,14 @@ impl DocumentCore { // 본문 문단 리플로우 (각주 마커 폭으로 인한 줄넘김 변경 반영) { - use crate::renderer::hwpunit_to_px; use crate::renderer::composer::reflow_line_segs; + use crate::renderer::hwpunit_to_px; let page_def = &self.document.sections[section_idx].section_def.page_def; - let text_width = page_def.width as i32 - - page_def.margin_left as i32 - - page_def.margin_right as i32; + let text_width = + page_def.width as i32 - page_def.margin_left as i32 - page_def.margin_right as i32; let available_width = hwpunit_to_px(text_width, self.dpi); let para_style = self.styles.para_styles.get( - self.document.sections[section_idx].paragraphs[para_idx].para_shape_id as usize + self.document.sections[section_idx].paragraphs[para_idx].para_shape_id as usize, ); let margin_left = para_style.map(|s| s.margin_left).unwrap_or(0.0); let margin_right = para_style.map(|s| s.margin_right).unwrap_or(0.0); @@ -3608,7 +4385,13 @@ impl DocumentCore { self.paginate_if_needed(); self.invalidate_page_tree_cache(); - self.event_log.push(DocumentEvent::PictureInserted { section: section_idx, para: para_idx }); - Ok(format!("{{\"ok\":true,\"paraIdx\":{},\"controlIdx\":{},\"footnoteNumber\":{}}}", para_idx, insert_idx, footnote_number)) + self.event_log.push(DocumentEvent::PictureInserted { + section: section_idx, + para: para_idx, + }); + Ok(format!( + "{{\"ok\":true,\"paraIdx\":{},\"controlIdx\":{},\"footnoteNumber\":{}}}", + para_idx, insert_idx, footnote_number + )) } } diff --git a/src/document_core/commands/table_ops.rs b/src/document_core/commands/table_ops.rs index 036ecb72..b81288ba 100644 --- a/src/document_core/commands/table_ops.rs +++ b/src/document_core/commands/table_ops.rs @@ -1,11 +1,11 @@ //! 표/셀 CRUD + 속성 조회·수정 관련 native 메서드 -use crate::model::control::Control; -use crate::model::path::{PathSegment, path_from_flat}; +use super::super::helpers::{border_line_type_to_u8_val, color_ref_to_css, navigate_path_to_table}; use crate::document_core::DocumentCore; use crate::error::HwpError; +use crate::model::control::Control; use crate::model::event::DocumentEvent; -use super::super::helpers::{navigate_path_to_table, border_line_type_to_u8_val, color_ref_to_css}; +use crate::model::path::{path_from_flat, PathSegment}; impl DocumentCore { pub(crate) fn get_table_mut( @@ -26,7 +26,8 @@ impl DocumentCore { ) -> Result<&mut crate::model::table::Table, HwpError> { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과", section_idx + "구역 인덱스 {} 범위 초과", + section_idx ))); } let section = &mut self.document.sections[section_idx]; @@ -43,7 +44,8 @@ impl DocumentCore { below: bool, ) -> Result { let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; - table.insert_row(row_idx, below) + table + .insert_row(row_idx, below) .map_err(|e| HwpError::RenderError(e))?; table.dirty = true; let row_count = table.row_count; @@ -53,8 +55,15 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::TableRowInserted { section: section_idx, para: parent_para_idx, ctrl: control_idx }); - Ok(super::super::helpers::json_ok_with(&format!("\"rowCount\":{},\"colCount\":{}", row_count, col_count))) + self.event_log.push(DocumentEvent::TableRowInserted { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"rowCount\":{},\"colCount\":{}", + row_count, col_count + ))) } /// 표에 열을 삽입한다 (네이티브). @@ -67,7 +76,8 @@ impl DocumentCore { right: bool, ) -> Result { let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; - table.insert_column(col_idx, right) + table + .insert_column(col_idx, right) .map_err(|e| HwpError::RenderError(e))?; table.dirty = true; let row_count = table.row_count; @@ -77,8 +87,15 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::TableColumnInserted { section: section_idx, para: parent_para_idx, ctrl: control_idx }); - Ok(super::super::helpers::json_ok_with(&format!("\"rowCount\":{},\"colCount\":{}", row_count, col_count))) + self.event_log.push(DocumentEvent::TableColumnInserted { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"rowCount\":{},\"colCount\":{}", + row_count, col_count + ))) } /// 표에서 행을 삭제한다 (네이티브). @@ -90,7 +107,8 @@ impl DocumentCore { row_idx: u16, ) -> Result { let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; - table.delete_row(row_idx) + table + .delete_row(row_idx) .map_err(|e| HwpError::RenderError(e))?; table.dirty = true; let row_count = table.row_count; @@ -100,8 +118,15 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::TableRowDeleted { section: section_idx, para: parent_para_idx, ctrl: control_idx }); - Ok(super::super::helpers::json_ok_with(&format!("\"rowCount\":{},\"colCount\":{}", row_count, col_count))) + self.event_log.push(DocumentEvent::TableRowDeleted { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"rowCount\":{},\"colCount\":{}", + row_count, col_count + ))) } /// 표에서 열을 삭제한다 (네이티브). @@ -113,7 +138,8 @@ impl DocumentCore { col_idx: u16, ) -> Result { let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; - table.delete_column(col_idx) + table + .delete_column(col_idx) .map_err(|e| HwpError::RenderError(e))?; table.dirty = true; let row_count = table.row_count; @@ -123,8 +149,15 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::TableColumnDeleted { section: section_idx, para: parent_para_idx, ctrl: control_idx }); - Ok(super::super::helpers::json_ok_with(&format!("\"rowCount\":{},\"colCount\":{}", row_count, col_count))) + self.event_log.push(DocumentEvent::TableColumnDeleted { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"rowCount\":{},\"colCount\":{}", + row_count, col_count + ))) } /// 표 셀을 병합한다 (네이티브). @@ -139,7 +172,8 @@ impl DocumentCore { end_col: u16, ) -> Result { let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; - table.merge_cells(start_row, start_col, end_row, end_col) + table + .merge_cells(start_row, start_col, end_row, end_col) .map_err(|e| HwpError::RenderError(e))?; table.dirty = true; let cell_count = table.cells.len(); @@ -148,8 +182,15 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::CellsMerged { section: section_idx, para: parent_para_idx, ctrl: control_idx }); - Ok(super::super::helpers::json_ok_with(&format!("\"cellCount\":{}", cell_count))) + self.event_log.push(DocumentEvent::CellsMerged { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"cellCount\":{}", + cell_count + ))) } pub fn split_table_cell_native( @@ -161,7 +202,8 @@ impl DocumentCore { col: u16, ) -> Result { let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; - table.split_cell(row, col) + table + .split_cell(row, col) .map_err(|e| HwpError::RenderError(e))?; table.dirty = true; let cell_count = table.cells.len(); @@ -170,8 +212,15 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::CellSplit { section: section_idx, para: parent_para_idx, ctrl: control_idx }); - Ok(super::super::helpers::json_ok_with(&format!("\"cellCount\":{}", cell_count))) + self.event_log.push(DocumentEvent::CellSplit { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"cellCount\":{}", + cell_count + ))) } /// 셀을 N줄 × M칸으로 분할한다 (네이티브). @@ -188,7 +237,8 @@ impl DocumentCore { merge_first: bool, ) -> Result { let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; - table.split_cell_into(row, col, n_rows, m_cols, equal_row_height, merge_first) + table + .split_cell_into(row, col, n_rows, m_cols, equal_row_height, merge_first) .map_err(|e| HwpError::RenderError(e))?; table.dirty = true; let cell_count = table.cells.len(); @@ -197,8 +247,15 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::CellSplit { section: section_idx, para: parent_para_idx, ctrl: control_idx }); - Ok(super::super::helpers::json_ok_with(&format!("\"cellCount\":{}", cell_count))) + self.event_log.push(DocumentEvent::CellSplit { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"cellCount\":{}", + cell_count + ))) } /// 범위 내 셀들을 각각 N줄 × M칸으로 분할한다 (네이티브). @@ -216,7 +273,16 @@ impl DocumentCore { equal_row_height: bool, ) -> Result { let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; - table.split_cells_in_range(start_row, start_col, end_row, end_col, n_rows, m_cols, equal_row_height) + table + .split_cells_in_range( + start_row, + start_col, + end_row, + end_col, + n_rows, + m_cols, + equal_row_height, + ) .map_err(|e| HwpError::RenderError(e))?; table.dirty = true; let cell_count = table.cells.len(); @@ -225,8 +291,15 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::CellSplit { section: section_idx, para: parent_para_idx, ctrl: control_idx }); - Ok(super::super::helpers::json_ok_with(&format!("\"cellCount\":{}", cell_count))) + self.event_log.push(DocumentEvent::CellSplit { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"cellCount\":{}", + cell_count + ))) } pub(crate) fn get_table_dimensions_native( @@ -235,19 +308,31 @@ impl DocumentCore { parent_para_idx: usize, control_idx: usize, ) -> Result { - let para = self.document.sections.get(section_idx) + let para = self + .document + .sections + .get(section_idx) .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))? - .paragraphs.get(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)))?; + .paragraphs + .get(parent_para_idx) + .ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)) + })?; let table = match para.controls.get(control_idx) { Some(Control::Table(t)) => t, - _ => return Err(HwpError::RenderError("지정된 컨트롤이 표가 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "지정된 컨트롤이 표가 아닙니다".to_string(), + )) + } }; Ok(format!( "{{\"rowCount\":{},\"colCount\":{},\"cellCount\":{}}}", - table.row_count, table.col_count, table.cells.len() + table.row_count, + table.col_count, + table.cells.len() )) } @@ -259,18 +344,33 @@ impl DocumentCore { control_idx: usize, cell_idx: usize, ) -> Result { - let para = self.document.sections.get(section_idx) + let para = self + .document + .sections + .get(section_idx) .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))? - .paragraphs.get(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)))?; + .paragraphs + .get(parent_para_idx) + .ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)) + })?; let table = match para.controls.get(control_idx) { Some(Control::Table(t)) => t, - _ => return Err(HwpError::RenderError("지정된 컨트롤이 표가 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "지정된 컨트롤이 표가 아닙니다".to_string(), + )) + } }; - let cell = table.cells.get(cell_idx) - .ok_or_else(|| HwpError::RenderError(format!("셀 인덱스 {} 범위 초과 (총 {}개)", cell_idx, table.cells.len())))?; + let cell = table.cells.get(cell_idx).ok_or_else(|| { + HwpError::RenderError(format!( + "셀 인덱스 {} 범위 초과 (총 {}개)", + cell_idx, + table.cells.len() + )) + })?; Ok(format!( "{{\"row\":{},\"col\":{},\"rowSpan\":{},\"colSpan\":{}}}", @@ -292,7 +392,11 @@ impl DocumentCore { "\"fillType\":\"none\",\"fillColor\":\"#ffffff\",\"patternColor\":\"#000000\",\"patternType\":0" ).to_string(); } - let bf = self.document.doc_info.border_fills.get((bf_id - 1) as usize); + let bf = self + .document + .doc_info + .border_fills + .get((bf_id - 1) as usize); match bf { Some(bf) => { use crate::model::style::FillType; @@ -340,17 +444,29 @@ impl DocumentCore { control_idx: usize, cell_idx: usize, ) -> Result { - let para = self.document.sections.get(section_idx) + let para = self + .document + .sections + .get(section_idx) .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))? - .paragraphs.get(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)))?; + .paragraphs + .get(parent_para_idx) + .ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)) + })?; let table = match para.controls.get(control_idx) { Some(Control::Table(t)) => t, - _ => return Err(HwpError::RenderError("지정된 컨트롤이 표가 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "지정된 컨트롤이 표가 아닙니다".to_string(), + )) + } }; - let cell = table.cells.get(cell_idx) + let cell = table + .cells + .get(cell_idx) .ok_or_else(|| HwpError::RenderError(format!("셀 인덱스 {} 범위 초과", cell_idx)))?; let va = match cell.vertical_align { @@ -382,18 +498,32 @@ impl DocumentCore { cell_idx: usize, json: &str, ) -> Result { - use super::super::helpers::{json_u32, json_i16, json_u8, json_bool}; + use super::super::helpers::{json_bool, json_i16, json_u32, json_u8}; let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; - let cell = table.cells.get_mut(cell_idx) + let cell = table + .cells + .get_mut(cell_idx) .ok_or_else(|| HwpError::RenderError(format!("셀 인덱스 {} 범위 초과", cell_idx)))?; - if let Some(v) = json_u32(json, "width") { cell.width = v; } - if let Some(v) = json_u32(json, "height") { cell.height = v; } - if let Some(v) = json_i16(json, "paddingLeft") { cell.padding.left = v; } - if let Some(v) = json_i16(json, "paddingRight") { cell.padding.right = v; } - if let Some(v) = json_i16(json, "paddingTop") { cell.padding.top = v; } - if let Some(v) = json_i16(json, "paddingBottom") { cell.padding.bottom = v; } + if let Some(v) = json_u32(json, "width") { + cell.width = v; + } + if let Some(v) = json_u32(json, "height") { + cell.height = v; + } + if let Some(v) = json_i16(json, "paddingLeft") { + cell.padding.left = v; + } + if let Some(v) = json_i16(json, "paddingRight") { + cell.padding.right = v; + } + if let Some(v) = json_i16(json, "paddingTop") { + cell.padding.top = v; + } + if let Some(v) = json_i16(json, "paddingBottom") { + cell.padding.bottom = v; + } if let Some(v) = json_u8(json, "verticalAlign") { cell.vertical_align = match v { 1 => crate::model::table::VerticalAlign::Center, @@ -401,7 +531,9 @@ impl DocumentCore { _ => crate::model::table::VerticalAlign::Top, }; } - if let Some(v) = json_u8(json, "textDirection") { cell.text_direction = v; } + if let Some(v) = json_u8(json, "textDirection") { + cell.text_direction = v; + } if let Some(v) = json_bool(json, "isHeader") { cell.is_header = v; if v { @@ -426,7 +558,10 @@ impl DocumentCore { // 새 BorderFill의 테두리 데이터 복사 (이웃 셀 갱신용) let new_borders = { let bf_idx = (new_bf_id as usize).saturating_sub(1); - self.document.doc_info.border_fills.get(bf_idx) + self.document + .doc_info + .border_fills + .get(bf_idx) .map(|bf| bf.borders) .unwrap_or_default() }; @@ -434,20 +569,31 @@ impl DocumentCore { // 대상 셀 정보 추출 + border_fill_id 변경 let (target_row, target_col, target_col_span, target_row_span) = { let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; - let cell = table.cells.get_mut(cell_idx) - .ok_or_else(|| HwpError::RenderError(format!("셀 인덱스 {} 범위 초과", cell_idx)))?; + let cell = table.cells.get_mut(cell_idx).ok_or_else(|| { + HwpError::RenderError(format!("셀 인덱스 {} 범위 초과", cell_idx)) + })?; cell.border_fill_id = new_bf_id; - (cell.row as usize, cell.col as usize, cell.col_span as usize, cell.row_span as usize) + ( + cell.row as usize, + cell.col as usize, + cell.col_span as usize, + cell.row_span as usize, + ) }; // 이웃 셀의 공유 엣지 테두리를 갱신 // borders 배열: [좌(0), 우(1), 상(2), 하(3)] self.update_neighbor_borders( - section_idx, parent_para_idx, control_idx, - cell_idx, target_row, target_col, target_col_span, target_row_span, + section_idx, + parent_para_idx, + control_idx, + cell_idx, + target_row, + target_col, + target_col_span, + target_row_span, &new_borders, ); - } self.document.sections[section_idx].raw_stream = None; @@ -486,7 +632,9 @@ impl DocumentCore { Err(_) => return, }; for (ci, cell) in table.cells.iter().enumerate() { - if ci == skip_cell_idx { continue; } + if ci == skip_cell_idx { + continue; + } let cr = cell.row as usize; let cc = cell.col as usize; let cs = cell.col_span as usize; @@ -526,9 +674,13 @@ impl DocumentCore { // 2단계: 각 이웃 셀의 BorderFill 복제 + 해당 방향만 교체 for (ci, old_bf_id, dir, new_border) in updates { - if old_bf_id == 0 { continue; } + if old_bf_id == 0 { + continue; + } let bf_idx = (old_bf_id as usize) - 1; - if bf_idx >= self.document.doc_info.border_fills.len() { continue; } + if bf_idx >= self.document.doc_info.border_fills.len() { + continue; + } let mut new_bf = self.document.doc_info.border_fills[bf_idx].clone(); new_bf.borders[dir] = new_border; @@ -536,7 +688,12 @@ impl DocumentCore { // 동일한 BorderFill 검색/추가 let bf_id = { use super::super::helpers::border_fills_equal; - let found = self.document.doc_info.border_fills.iter().enumerate() + let found = self + .document + .doc_info + .border_fills + .iter() + .enumerate() .find(|(_, existing)| border_fills_equal(existing, &new_bf)) .map(|(i, _)| (i + 1) as u16); match found { @@ -556,7 +713,8 @@ impl DocumentCore { } // 스타일 재계산 - self.styles = crate::renderer::style_resolver::resolve_styles(&self.document.doc_info, self.dpi); + self.styles = + crate::renderer::style_resolver::resolve_styles(&self.document.doc_info, self.dpi); } /// 여러 셀의 width/height를 한 번에 조절한다 (네이티브). @@ -576,7 +734,7 @@ impl DocumentCore { if !trimmed.starts_with('[') || !trimmed.ends_with(']') { return Err(HwpError::RenderError("잘못된 JSON 배열 형식".to_string())); } - let inner = &trimmed[1..trimmed.len()-1]; + let inner = &trimmed[1..trimmed.len() - 1]; // 각 {} 객체를 추출 struct CellUpdate { @@ -591,7 +749,9 @@ impl DocumentCore { for (i, ch) in inner.char_indices() { match ch { '{' => { - if depth == 0 { start = i; } + if depth == 0 { + start = i; + } depth += 1; } '}' => { @@ -600,7 +760,9 @@ impl DocumentCore { let obj = &inner[start..=i]; // cellIdx 파싱 let cell_idx = Self::parse_json_i32(obj, "cellIdx").unwrap_or(-1); - if cell_idx < 0 { continue; } + if cell_idx < 0 { + continue; + } let width_delta = Self::parse_json_i32(obj, "widthDelta").unwrap_or(0); let height_delta = Self::parse_json_i32(obj, "heightDelta").unwrap_or(0); updates.push(CellUpdate { @@ -623,11 +785,13 @@ impl DocumentCore { for upd in &updates { if let Some(cell) = table.cells.get_mut(upd.cell_idx) { if upd.width_delta != 0 { - let new_w = (cell.width as i32 + upd.width_delta).max(MIN_CELL_SIZE as i32) as u32; + let new_w = + (cell.width as i32 + upd.width_delta).max(MIN_CELL_SIZE as i32) as u32; cell.width = new_w; } if upd.height_delta != 0 { - let new_h = (cell.height as i32 + upd.height_delta).max(MIN_CELL_SIZE as i32) as u32; + let new_h = + (cell.height as i32 + upd.height_delta).max(MIN_CELL_SIZE as i32) as u32; cell.height = new_h; } } @@ -639,7 +803,8 @@ impl DocumentCore { let reflow_cells: Vec<(usize, usize)> = { let para = &self.document.sections[section_idx].paragraphs[parent_para_idx]; if let Some(Control::Table(table)) = para.controls.get(control_idx) { - updates.iter() + updates + .iter() .filter(|u| u.width_delta != 0) .filter_map(|u| { let pc = table.cells.get(u.cell_idx)?.paragraphs.len(); @@ -653,7 +818,11 @@ impl DocumentCore { for (cell_idx, para_count) in reflow_cells { for cell_para_idx in 0..para_count { self.reflow_cell_paragraph( - section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx, + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, ); } } @@ -670,8 +839,12 @@ impl DocumentCore { let pattern = format!("\"{}\":", key); let start = json.find(&pattern)? + pattern.len(); let rest = json[start..].trim_start(); - let end = rest.find(|c: char| !c.is_ascii_digit() && c != '-').unwrap_or(rest.len()); - if end == 0 { return None; } + let end = rest + .find(|c: char| !c.is_ascii_digit() && c != '-') + .unwrap_or(rest.len()); + if end == 0 { + return None; + } rest[..end].parse().ok() } @@ -699,24 +872,30 @@ impl DocumentCore { // vertical_offset: raw_ctrl_data[0..4] (i32 LE) let mut new_v = if delta_v != 0 { let cur_v = i32::from_le_bytes([ - table.raw_ctrl_data[0], table.raw_ctrl_data[1], - table.raw_ctrl_data[2], table.raw_ctrl_data[3], + table.raw_ctrl_data[0], + table.raw_ctrl_data[1], + table.raw_ctrl_data[2], + table.raw_ctrl_data[3], ]); let nv = cur_v.wrapping_add(delta_v); table.raw_ctrl_data[0..4].copy_from_slice(&nv.to_le_bytes()); nv } else { i32::from_le_bytes([ - table.raw_ctrl_data[0], table.raw_ctrl_data[1], - table.raw_ctrl_data[2], table.raw_ctrl_data[3], + table.raw_ctrl_data[0], + table.raw_ctrl_data[1], + table.raw_ctrl_data[2], + table.raw_ctrl_data[3], ]) }; // horizontal_offset: raw_ctrl_data[4..8] (i32 LE) if delta_h != 0 { let cur_h = i32::from_le_bytes([ - table.raw_ctrl_data[4], table.raw_ctrl_data[5], - table.raw_ctrl_data[6], table.raw_ctrl_data[7], + table.raw_ctrl_data[4], + table.raw_ctrl_data[5], + table.raw_ctrl_data[6], + table.raw_ctrl_data[7], ]); let new_h = cur_h.wrapping_add(delta_h); table.raw_ctrl_data[4..8].copy_from_slice(&new_h.to_le_bytes()); @@ -730,10 +909,16 @@ impl DocumentCore { // 아래로: v_offset >= line_height이면 반복적으로 다음 문단과 교환 while result_ppi + 1 < para_count { let lh = self.document.sections[section_idx].paragraphs[result_ppi] - .line_segs.first().map(|ls| ls.line_height).unwrap_or(1000); - if new_v < lh { break; } + .line_segs + .first() + .map(|ls| ls.line_height) + .unwrap_or(1000); + if new_v < lh { + break; + } new_v -= lh; - self.document.sections[section_idx].paragraphs + self.document.sections[section_idx] + .paragraphs .swap(result_ppi, result_ppi + 1); result_ppi += 1; } @@ -741,9 +926,13 @@ impl DocumentCore { // 위로: v_offset < 0이면 반복적으로 이전 문단과 교환 while new_v < 0 && result_ppi > 0 { let prev_lh = self.document.sections[section_idx].paragraphs[result_ppi - 1] - .line_segs.first().map(|ls| ls.line_height).unwrap_or(1000); + .line_segs + .first() + .map(|ls| ls.line_height) + .unwrap_or(1000); new_v += prev_lh; - self.document.sections[section_idx].paragraphs + self.document.sections[section_idx] + .paragraphs .swap(result_ppi - 1, result_ppi); result_ppi -= 1; } @@ -759,7 +948,10 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - Ok(format!("{{\"ok\":true,\"ppi\":{},\"ci\":{}}}", result_ppi, control_idx)) + Ok(format!( + "{{\"ok\":true,\"ppi\":{},\"ci\":{}}}", + result_ppi, control_idx + )) } /// 표 속성을 조회한다 (네이티브). @@ -769,14 +961,24 @@ impl DocumentCore { parent_para_idx: usize, control_idx: usize, ) -> Result { - let para = self.document.sections.get(section_idx) + let para = self + .document + .sections + .get(section_idx) .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))? - .paragraphs.get(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)))?; + .paragraphs + .get(parent_para_idx) + .ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)) + })?; let table = match para.controls.get(control_idx) { Some(Control::Table(t)) => t, - _ => return Err(HwpError::RenderError("지정된 컨트롤이 표가 아닙니다".to_string())), + _ => { + return Err(HwpError::RenderError( + "지정된 컨트롤이 표가 아닙니다".to_string(), + )) + } }; let pb = match table.page_break { @@ -789,12 +991,36 @@ impl DocumentCore { // raw_ctrl_data에서 표 크기 & 바깥 여백 추출 let rd = &table.raw_ctrl_data; - let table_width = if rd.len() >= 12 { u32::from_le_bytes([rd[8], rd[9], rd[10], rd[11]]) } else { 0 }; - let table_height = if rd.len() >= 16 { u32::from_le_bytes([rd[12], rd[13], rd[14], rd[15]]) } else { 0 }; - let outer_left = if rd.len() >= 22 { i16::from_le_bytes([rd[20], rd[21]]) } else { 0 }; - let outer_right = if rd.len() >= 24 { i16::from_le_bytes([rd[22], rd[23]]) } else { 0 }; - let outer_top = if rd.len() >= 26 { i16::from_le_bytes([rd[24], rd[25]]) } else { 0 }; - let outer_bottom = if rd.len() >= 28 { i16::from_le_bytes([rd[26], rd[27]]) } else { 0 }; + let table_width = if rd.len() >= 12 { + u32::from_le_bytes([rd[8], rd[9], rd[10], rd[11]]) + } else { + 0 + }; + let table_height = if rd.len() >= 16 { + u32::from_le_bytes([rd[12], rd[13], rd[14], rd[15]]) + } else { + 0 + }; + let outer_left = if rd.len() >= 22 { + i16::from_le_bytes([rd[20], rd[21]]) + } else { + 0 + }; + let outer_right = if rd.len() >= 24 { + i16::from_le_bytes([rd[22], rd[23]]) + } else { + 0 + }; + let outer_top = if rd.len() >= 26 { + i16::from_le_bytes([rd[24], rd[25]]) + } else { + 0 + }; + let outer_bottom = if rd.len() >= 28 { + i16::from_le_bytes([rd[26], rd[27]]) + } else { + 0 + }; // 캡션 정보 let caption_json = if let Some(ref cap) = table.caption { @@ -850,14 +1076,24 @@ impl DocumentCore { crate::model::shape::HorzAlign::Inside => "Inside", crate::model::shape::HorzAlign::Outside => "Outside", }; - let vert_offset = if rd.len() >= 4 { i32::from_le_bytes([rd[0], rd[1], rd[2], rd[3]]) } else { 0 }; - let horz_offset = if rd.len() >= 8 { i32::from_le_bytes([rd[4], rd[5], rd[6], rd[7]]) } else { 0 }; + let vert_offset = if rd.len() >= 4 { + i32::from_le_bytes([rd[0], rd[1], rd[2], rd[3]]) + } else { + 0 + }; + let horz_offset = if rd.len() >= 8 { + i32::from_le_bytes([rd[4], rd[5], rd[6], rd[7]]) + } else { + 0 + }; let restrict_in_page = (table.attr >> 13) & 0x01 != 0; let allow_overlap = (table.attr >> 14) & 0x01 != 0; // raw_ctrl_data[32..36] = prevent_page_break (개체와 조판부호를 항상 같은 쪽에 놓기) let keep_with_anchor = if rd.len() >= 36 { i32::from_le_bytes([rd[32], rd[33], rd[34], rd[35]]) != 0 - } else { false }; + } else { + false + }; Ok(format!( "{{\"cellSpacing\":{},\"paddingLeft\":{},\"paddingRight\":{},\"paddingTop\":{},\"paddingBottom\":{},\"pageBreak\":{},\"repeatHeader\":{},{},\"tableWidth\":{},\"tableHeight\":{},\"outerLeft\":{},\"outerRight\":{},\"outerTop\":{},\"outerBottom\":{}{},\"treatAsChar\":{},\"textWrap\":\"{}\",\"vertRelTo\":\"{}\",\"vertAlign\":\"{}\",\"horzRelTo\":\"{}\",\"horzAlign\":\"{}\",\"vertOffset\":{},\"horzOffset\":{},\"restrictInPage\":{},\"allowOverlap\":{},\"keepWithAnchor\":{}}}", @@ -883,15 +1119,25 @@ impl DocumentCore { control_idx: usize, json: &str, ) -> Result { - use super::super::helpers::{json_i16, json_i32, json_u8, json_u32, json_bool, json_str}; + use super::super::helpers::{json_bool, json_i16, json_i32, json_str, json_u32, json_u8}; let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; - if let Some(v) = json_i16(json, "cellSpacing") { table.cell_spacing = v; } - if let Some(v) = json_i16(json, "paddingLeft") { table.padding.left = v; } - if let Some(v) = json_i16(json, "paddingRight") { table.padding.right = v; } - if let Some(v) = json_i16(json, "paddingTop") { table.padding.top = v; } - if let Some(v) = json_i16(json, "paddingBottom") { table.padding.bottom = v; } + if let Some(v) = json_i16(json, "cellSpacing") { + table.cell_spacing = v; + } + if let Some(v) = json_i16(json, "paddingLeft") { + table.padding.left = v; + } + if let Some(v) = json_i16(json, "paddingRight") { + table.padding.right = v; + } + if let Some(v) = json_i16(json, "paddingTop") { + table.padding.top = v; + } + if let Some(v) = json_i16(json, "paddingBottom") { + table.padding.bottom = v; + } if let Some(v) = json_u8(json, "pageBreak") { table.page_break = match v { 1 => crate::model::table::TablePageBreak::CellBreak, @@ -899,39 +1145,66 @@ impl DocumentCore { _ => crate::model::table::TablePageBreak::None, }; } - if let Some(v) = json_bool(json, "repeatHeader") { table.repeat_header = v; } + if let Some(v) = json_bool(json, "repeatHeader") { + table.repeat_header = v; + } if let Some(v) = json_bool(json, "treatAsChar") { - if v { table.attr |= 0x01; } else { table.attr &= !0x01; } + if v { + table.attr |= 0x01; + } else { + table.attr &= !0x01; + } } // 위치 속성: attr 비트 필드 if let Some(v) = json_str(json, "textWrap") { let bits: u32 = match v.as_str() { - "Square" => 0, "TopAndBottom" => 1, "BehindText" => 2, "InFrontOfText" => 3, _ => 0 + "Square" => 0, + "TopAndBottom" => 1, + "BehindText" => 2, + "InFrontOfText" => 3, + _ => 0, }; table.attr = (table.attr & !(0x07 << 21)) | (bits << 21); } if let Some(v) = json_str(json, "vertRelTo") { let bits: u32 = match v.as_str() { - "Paper" => 0, "Page" => 1, "Para" => 2, _ => 0 + "Paper" => 0, + "Page" => 1, + "Para" => 2, + _ => 0, }; table.attr = (table.attr & !(0x03 << 3)) | (bits << 3); } if let Some(v) = json_str(json, "vertAlign") { let bits: u32 = match v.as_str() { - "Top" => 0, "Center" => 1, "Bottom" => 2, "Inside" => 3, "Outside" => 4, _ => 0 + "Top" => 0, + "Center" => 1, + "Bottom" => 2, + "Inside" => 3, + "Outside" => 4, + _ => 0, }; table.attr = (table.attr & !(0x07 << 5)) | (bits << 5); } if let Some(v) = json_str(json, "horzRelTo") { let bits: u32 = match v.as_str() { - "Paper" => 0, "Page" => 1, "Column" => 2, "Para" => 3, _ => 0 + "Paper" => 0, + "Page" => 1, + "Column" => 2, + "Para" => 3, + _ => 0, }; table.attr = (table.attr & !(0x03 << 8)) | (bits << 8); } if let Some(v) = json_str(json, "horzAlign") { let bits: u32 = match v.as_str() { - "Left" => 0, "Center" => 1, "Right" => 2, "Inside" => 3, "Outside" => 4, _ => 0 + "Left" => 0, + "Center" => 1, + "Right" => 2, + "Inside" => 3, + "Outside" => 4, + _ => 0, }; table.attr = (table.attr & !(0x07 << 10)) | (bits << 10); } @@ -947,11 +1220,19 @@ impl DocumentCore { } // restrictInPage → attr bit 13 if let Some(v) = json_bool(json, "restrictInPage") { - if v { table.attr |= 1 << 13; } else { table.attr &= !(1 << 13); } + if v { + table.attr |= 1 << 13; + } else { + table.attr &= !(1 << 13); + } } // allowOverlap → attr bit 14 if let Some(v) = json_bool(json, "allowOverlap") { - if v { table.attr |= 1 << 14; } else { table.attr &= !(1 << 14); } + if v { + table.attr |= 1 << 14; + } else { + table.attr &= !(1 << 14); + } } // keepWithAnchor → raw_ctrl_data[32..36] (prevent_page_break) if let Some(v) = json_bool(json, "keepWithAnchor") { @@ -989,7 +1270,9 @@ impl DocumentCore { }; let mut cap_para = crate::model::paragraph::Paragraph::new_empty(); // max_width = 표 전체 폭 (열 폭 합산) - let total_width: u32 = table.cells.iter() + let total_width: u32 = table + .cells + .iter() .filter(|c| c.row == 0) .map(|c| c.width as u32) .sum(); @@ -1004,7 +1287,8 @@ impl DocumentCore { cap.spacing = 850; // 약 3mm table.caption = Some(cap); caption_created = true; - table.caption.as_mut().unwrap().paragraphs[0].controls + table.caption.as_mut().unwrap().paragraphs[0] + .controls .push(crate::model::control::Control::AutoNumber(an)); // attr bit 29: 캡션 존재 플래그 (한컴 호환성) table.attr |= 1 << 29; @@ -1064,11 +1348,16 @@ impl DocumentCore { let assigned_num = { let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; let para = &table.caption.as_ref().unwrap().paragraphs[0]; - para.controls.iter().find_map(|c| { - if let crate::model::control::Control::AutoNumber(an) = c { - Some(an.assigned_number) - } else { None } - }).unwrap_or(1) + para.controls + .iter() + .find_map(|c| { + if let crate::model::control::Control::AutoNumber(an) = c { + Some(an.assigned_number) + } else { + None + } + }) + .unwrap_or(1) }; let num_str = format!("{}", assigned_num); // "표 N " 형태의 텍스트 직접 생성 (AutoNumber 치환 불필요) @@ -1082,7 +1371,7 @@ impl DocumentCore { // char_offsets: 순차적 (AutoNumber 갭 없음, 모델=렌더링 일치) para.char_offsets = (0..char_count_text).collect(); para.char_count = char_count_text + 1; // 텍스트 + 끝마커 - // AutoNumber 컨트롤 제거 (번호가 텍스트에 직접 포함됨) + // AutoNumber 컨트롤 제거 (번호가 텍스트에 직접 포함됨) para.controls.clear(); // char_shapes: 전체 텍스트에 기본 스타일(0) 적용 para.char_shapes = vec![crate::model::paragraph::CharShapeRef { @@ -1096,12 +1385,17 @@ impl DocumentCore { // 직접 접근으로 borrow 분리하여 reflow_line_segs 호출 if let Some(crate::model::control::Control::Table(ref mut tbl)) = self.document.sections[section_idx].paragraphs[parent_para_idx] - .controls.get_mut(control_idx) + .controls + .get_mut(control_idx) { if let Some(ref mut cap) = tbl.caption { - let available_width_px = crate::renderer::hwpunit_to_px(cap.max_width as i32, self.dpi); + let available_width_px = + crate::renderer::hwpunit_to_px(cap.max_width as i32, self.dpi); crate::renderer::composer::reflow_line_segs( - &mut cap.paragraphs[0], available_width_px, &self.styles, self.dpi, + &mut cap.paragraphs[0], + available_width_px, + &self.styles, + self.dpi, ); } } @@ -1114,10 +1408,14 @@ impl DocumentCore { if caption_created { let char_offset = { let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; - table.caption.as_ref().map_or(0, |c| - c.paragraphs.first().map_or(0, |p| p.text.chars().count())) + table.caption.as_ref().map_or(0, |c| { + c.paragraphs.first().map_or(0, |p| p.text.chars().count()) + }) }; - Ok(format!("{{\"ok\":true,\"captionCharOffset\":{}}}", char_offset)) + Ok(format!( + "{{\"ok\":true,\"captionCharOffset\":{}}}", + char_offset + )) } else { Ok("{\"ok\":true}".to_string()) } @@ -1133,7 +1431,10 @@ impl DocumentCore { use crate::renderer::render_tree::{RenderNode, RenderNodeType}; // 해당 문단에 표 컨트롤이 실제로 있는지 사전 확인 (전체 페이지 순회 방지) - let has_table = self.document.sections.get(section_idx) + let has_table = self + .document + .sections + .get(section_idx) .and_then(|s| s.paragraphs.get(parent_para_idx)) .and_then(|p| p.controls.get(control_idx)) .map(|c| matches!(c, Control::Table(_))) @@ -1147,7 +1448,9 @@ impl DocumentCore { fn find_table_bbox( node: &RenderNode, - sec: usize, ppi: usize, ci: usize, + sec: usize, + ppi: usize, + ci: usize, page_idx: usize, ) -> Option { if let RenderNodeType::Table(ref tn) = node.node_type { @@ -1173,7 +1476,13 @@ impl DocumentCore { let total_pages = self.page_count() as usize; for page_num in 0..total_pages { let tree = self.build_page_tree_cached(page_num as u32)?; - if let Some(result) = find_table_bbox(&tree.root, section_idx, parent_para_idx, control_idx, page_num) { + if let Some(result) = find_table_bbox( + &tree.root, + section_idx, + parent_para_idx, + control_idx, + page_num, + ) { return Ok(result); } } @@ -1196,25 +1505,31 @@ impl DocumentCore { ) -> Result { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과", section_idx + "구역 인덱스 {} 범위 초과", + section_idx ))); } let section = &mut self.document.sections[section_idx]; if parent_para_idx >= section.paragraphs.len() { return Err(HwpError::RenderError(format!( - "부모 문단 인덱스 {} 범위 초과", parent_para_idx + "부모 문단 인덱스 {} 범위 초과", + parent_para_idx ))); } let para = &mut section.paragraphs[parent_para_idx]; if control_idx >= para.controls.len() { return Err(HwpError::RenderError(format!( - "컨트롤 인덱스 {} 범위 초과", control_idx + "컨트롤 인덱스 {} 범위 초과", + control_idx ))); } // 표 컨트롤인지 확인 - if !matches!(¶.controls[control_idx], crate::model::control::Control::Table(_)) { + if !matches!( + ¶.controls[control_idx], + crate::model::control::Control::Table(_) + ) { return Err(HwpError::RenderError( - "지정된 컨트롤이 표가 아닙니다".to_string() + "지정된 컨트롤이 표가 아닙니다".to_string(), )); } @@ -1225,7 +1540,11 @@ impl DocumentCore { let mut prev_end: u32 = 0; let mut gap_start: Option = None; 'outer: for i in 0..text_chars.len() { - let offset = if i < para.char_offsets.len() { para.char_offsets[i] } else { prev_end }; + let offset = if i < para.char_offsets.len() { + para.char_offsets[i] + } else { + prev_end + }; while prev_end + 8 <= offset && ci < para.controls.len() { if ci == control_idx { gap_start = Some(prev_end); @@ -1235,9 +1554,13 @@ impl DocumentCore { prev_end += 8; } // 문자 크기 산정 - let char_size: u32 = if text_chars[i] == '\t' { 8 } - else if text_chars[i].len_utf16() == 2 { 2 } - else { 1 }; + let char_size: u32 = if text_chars[i] == '\t' { + 8 + } else if text_chars[i].len_utf16() == 2 { + 2 + } else { + 1 + }; prev_end = offset + char_size; } // 텍스트 뒤에 배치된 컨트롤 (남은 컨트롤) @@ -1277,7 +1600,11 @@ impl DocumentCore { self.recompose_section(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::TableColumnDeleted { section: section_idx, para: parent_para_idx, ctrl: control_idx }); + self.event_log.push(DocumentEvent::TableColumnDeleted { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + }); Ok("{\"ok\":true}".to_string()) } @@ -1302,9 +1629,14 @@ impl DocumentCore { write_result: bool, ) -> Result { // 표 가져오기 - let section = self.document.sections.get(section_idx) + let section = self + .document + .sections + .get(section_idx) .ok_or_else(|| HwpError::RenderError("구역 초과".into()))?; - let para = section.paragraphs.get(parent_para_idx) + let para = section + .paragraphs + .get(parent_para_idx) .ok_or_else(|| HwpError::RenderError("문단 초과".into()))?; let table = match para.controls.get(control_idx) { Some(Control::Table(t)) => t, @@ -1318,7 +1650,8 @@ impl DocumentCore { let cells = &table.cells; let get_cell = |col: usize, row: usize| -> Option { let idx = row * col_count + col; - cells.get(idx) + cells + .get(idx) .and_then(|cell| cell.paragraphs.first()) .and_then(|p| parse_cell_number(&p.text)) }; @@ -1360,13 +1693,18 @@ impl DocumentCore { self.recompose_section(section_idx); } - Ok(format!("{{\"ok\":true,\"result\":{},\"formula\":{}}}", result, json_escape(formula))) + Ok(format!( + "{{\"ok\":true,\"result\":{},\"formula\":{}}}", + result, + json_escape(formula) + )) } } /// 셀 텍스트에서 숫자를 추출한다 (콤마 제거, 공백 무시). fn parse_cell_number(text: &str) -> Option { - let cleaned: String = text.chars() + let cleaned: String = text + .chars() .filter(|c| !c.is_whitespace() && *c != ',') .collect(); if cleaned.is_empty() { diff --git a/src/document_core/commands/text_editing.rs b/src/document_core/commands/text_editing.rs index 4406af8b..98ff6e4c 100644 --- a/src/document_core/commands/text_editing.rs +++ b/src/document_core/commands/text_editing.rs @@ -1,14 +1,14 @@ //! 텍스트 삽입/삭제/문단 분리·병합/범위 삭제/문단 쿼리 관련 native 메서드 +use super::super::helpers::get_textbox_from_shape; +use crate::document_core::DocumentCore; +use crate::error::HwpError; use crate::model::control::Control; +use crate::model::event::DocumentEvent; +use crate::model::page::ColumnDef; use crate::model::paragraph::Paragraph; use crate::renderer::composer::{compose_paragraph, reflow_line_segs, ComposedParagraph}; use crate::renderer::page_layout::PageLayoutInfo; -use crate::model::page::ColumnDef; -use crate::document_core::DocumentCore; -use crate::error::HwpError; -use crate::model::event::DocumentEvent; -use super::super::helpers::get_textbox_from_shape; impl DocumentCore { pub fn insert_text_native( @@ -21,13 +21,17 @@ impl DocumentCore { // 인덱스 범위 검증 if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과 (총 {}개)", section_idx, self.document.sections.len() + "구역 인덱스 {} 범위 초과 (총 {}개)", + section_idx, + self.document.sections.len() ))); } let section = &self.document.sections[section_idx]; if para_idx >= section.paragraphs.len() { return Err(HwpError::RenderError(format!( - "문단 인덱스 {} 범위 초과 (총 {}개)", para_idx, section.paragraphs.len() + "문단 인덱스 {} 범위 초과 (총 {}개)", + para_idx, + section.paragraphs.len() ))); } @@ -36,34 +40,39 @@ impl DocumentCore { // 텍스트 삽입 let new_chars_count = text.chars().count(); - self.document.sections[section_idx].paragraphs[para_idx] - .insert_text_at(char_offset, text); + self.document.sections[section_idx].paragraphs[para_idx].insert_text_at(char_offset, text); // line_segs 재계산 (리플로우) → vpos 재계산 → 재구성 → 재페이지네이션 // 다단 문서에서 편집 후 문단이 다른 단으로 재배치될 수 있으므로 // para_column_map 변경 감지 + 재reflow 수렴 루프 (최대 3회) - let old_col = self.para_column_map + let old_col = self + .para_column_map .get(section_idx) .and_then(|m| m.get(para_idx)) .copied() .unwrap_or(0); self.reflow_paragraph(section_idx, para_idx); crate::renderer::composer::recalculate_section_vpos( - &mut self.document.sections[section_idx].paragraphs, para_idx, + &mut self.document.sections[section_idx].paragraphs, + para_idx, ); self.recompose_paragraph(section_idx, para_idx); self.paginate_if_needed(); for _ in 0..2 { - let new_col = self.para_column_map + let new_col = self + .para_column_map .get(section_idx) .and_then(|m| m.get(para_idx)) .copied() .unwrap_or(0); - if new_col == old_col { break; } + if new_col == old_col { + break; + } self.reflow_paragraph(section_idx, para_idx); crate::renderer::composer::recalculate_section_vpos( - &mut self.document.sections[section_idx].paragraphs, para_idx, + &mut self.document.sections[section_idx].paragraphs, + para_idx, ); self.recompose_paragraph(section_idx, para_idx); self.paginate_if_needed(); @@ -79,7 +88,10 @@ impl DocumentCore { } else if !para.char_offsets.is_empty() { let last = para.char_offsets.len() - 1; let last_char = para.text.chars().nth(last); - para.char_offsets[last] + last_char.map(|c| if (c as u32) > 0xFFFF { 2 } else { 1 }).unwrap_or(1) + para.char_offsets[last] + + last_char + .map(|c| if (c as u32) > 0xFFFF { 2 } else { 1 }) + .unwrap_or(1) } else { // 텍스트 없이 컨트롤만 있는 경우 (para.controls.len() as u32) * 8 @@ -91,12 +103,23 @@ impl DocumentCore { // DocInfo raw_stream 내 캐럿 위치만 surgical update (전체 재직렬화 방지) if let Some(ref mut raw) = self.document.doc_info.raw_stream { let _ = crate::serializer::doc_info::surgical_update_caret( - raw, section_idx as u32, para_idx as u32, caret_utf16_pos, + raw, + section_idx as u32, + para_idx as u32, + caret_utf16_pos, ); } - self.event_log.push(DocumentEvent::TextInserted { section: section_idx, para: para_idx, offset: char_offset, len: new_chars_count }); - Ok(super::super::helpers::json_ok_with(&format!("\"charOffset\":{}", new_offset))) + self.event_log.push(DocumentEvent::TextInserted { + section: section_idx, + para: para_idx, + offset: char_offset, + len: new_chars_count, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"charOffset\":{}", + new_offset + ))) } /// 텍스트 삭제 (네이티브 에러 타입) @@ -110,13 +133,17 @@ impl DocumentCore { // 인덱스 범위 검증 if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과 (총 {}개)", section_idx, self.document.sections.len() + "구역 인덱스 {} 범위 초과 (총 {}개)", + section_idx, + self.document.sections.len() ))); } let section = &self.document.sections[section_idx]; if para_idx >= section.paragraphs.len() { return Err(HwpError::RenderError(format!( - "문단 인덱스 {} 범위 초과 (총 {}개)", para_idx, section.paragraphs.len() + "문단 인덱스 {} 범위 초과 (총 {}개)", + para_idx, + section.paragraphs.len() ))); } @@ -124,33 +151,38 @@ impl DocumentCore { self.document.sections[section_idx].raw_stream = None; // 텍스트 삭제 - self.document.sections[section_idx].paragraphs[para_idx] - .delete_text_at(char_offset, count); + self.document.sections[section_idx].paragraphs[para_idx].delete_text_at(char_offset, count); // line_segs 재계산 (리플로우) → 재구성 → 재페이지네이션 // 다단 수렴 루프 (최대 3회) - let old_col = self.para_column_map + let old_col = self + .para_column_map .get(section_idx) .and_then(|m| m.get(para_idx)) .copied() .unwrap_or(0); self.reflow_paragraph(section_idx, para_idx); crate::renderer::composer::recalculate_section_vpos( - &mut self.document.sections[section_idx].paragraphs, para_idx, + &mut self.document.sections[section_idx].paragraphs, + para_idx, ); self.recompose_paragraph(section_idx, para_idx); self.paginate_if_needed(); for _ in 0..2 { - let new_col = self.para_column_map + let new_col = self + .para_column_map .get(section_idx) .and_then(|m| m.get(para_idx)) .copied() .unwrap_or(0); - if new_col == old_col { break; } + if new_col == old_col { + break; + } self.reflow_paragraph(section_idx, para_idx); crate::renderer::composer::recalculate_section_vpos( - &mut self.document.sections[section_idx].paragraphs, para_idx, + &mut self.document.sections[section_idx].paragraphs, + para_idx, ); self.recompose_paragraph(section_idx, para_idx); self.paginate_if_needed(); @@ -163,7 +195,10 @@ impl DocumentCore { } else if !para.char_offsets.is_empty() { let last = para.char_offsets.len() - 1; let last_char = para.text.chars().nth(last); - para.char_offsets[last] + last_char.map(|c| if (c as u32) > 0xFFFF { 2 } else { 1 }).unwrap_or(1) + para.char_offsets[last] + + last_char + .map(|c| if (c as u32) > 0xFFFF { 2 } else { 1 }) + .unwrap_or(1) } else { (para.controls.len() as u32) * 8 }; @@ -174,12 +209,23 @@ impl DocumentCore { // DocInfo raw_stream 내 캐럿 위치만 surgical update (전체 재직렬화 방지) if let Some(ref mut raw) = self.document.doc_info.raw_stream { let _ = crate::serializer::doc_info::surgical_update_caret( - raw, section_idx as u32, para_idx as u32, caret_utf16_pos, + raw, + section_idx as u32, + para_idx as u32, + caret_utf16_pos, ); } - self.event_log.push(DocumentEvent::TextDeleted { section: section_idx, para: para_idx, offset: char_offset, count }); - Ok(super::super::helpers::json_ok_with(&format!("\"charOffset\":{}", char_offset))) + self.event_log.push(DocumentEvent::TextDeleted { + section: section_idx, + para: para_idx, + offset: char_offset, + count, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"charOffset\":{}", + char_offset + ))) } /// 지정된 문단의 line_segs를 컬럼 너비 기반으로 재계산한다. @@ -191,12 +237,15 @@ impl DocumentCore { let layout = PageLayoutInfo::from_page_def(page_def, &column_def, self.dpi); // 페이지네이션 매핑에서 문단의 소속 단 인덱스 조회 - let col_idx = self.para_column_map + let col_idx = self + .para_column_map .get(section_idx) .and_then(|m| m.get(para_idx)) .copied() .unwrap_or(0) as usize; - let col_area = layout.column_areas.get(col_idx) + let col_area = layout + .column_areas + .get(col_idx) .unwrap_or(&layout.column_areas[0]); // 문단 여백 계산 @@ -227,7 +276,11 @@ impl DocumentCore { ) -> Result { // 셀 문단 접근 검증 및 텍스트 삽입 let cell_para = self.get_cell_paragraph_mut( - section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx, + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, )?; let new_chars_count = text.chars().count(); cell_para.insert_text_at(char_offset, text); @@ -236,7 +289,13 @@ impl DocumentCore { self.mark_cell_control_dirty(section_idx, parent_para_idx, control_idx); // 셀 폭 기반 리플로우 - self.reflow_cell_paragraph(section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx); + self.reflow_cell_paragraph( + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ); // raw 스트림 무효화, 재페이지네이션 (셀 편집 → composed 불변, section dirty만 설정) self.document.sections[section_idx].raw_stream = None; @@ -244,8 +303,16 @@ impl DocumentCore { self.paginate_if_needed(); let new_offset = char_offset + new_chars_count; - self.event_log.push(DocumentEvent::CellTextChanged { section: section_idx, para: parent_para_idx, ctrl: control_idx, cell: cell_idx }); - Ok(super::super::helpers::json_ok_with(&format!("\"charOffset\":{}", new_offset))) + self.event_log.push(DocumentEvent::CellTextChanged { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + cell: cell_idx, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"charOffset\":{}", + new_offset + ))) } /// 표 셀 내부 문단에서 텍스트 삭제 (네이티브) @@ -261,7 +328,11 @@ impl DocumentCore { ) -> Result { // 셀 문단 접근 검증 및 텍스트 삭제 let cell_para = self.get_cell_paragraph_mut( - section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx, + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, )?; cell_para.delete_text_at(char_offset, count); @@ -269,15 +340,29 @@ impl DocumentCore { self.mark_cell_control_dirty(section_idx, parent_para_idx, control_idx); // 셀 폭 기반 리플로우 - self.reflow_cell_paragraph(section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx); + self.reflow_cell_paragraph( + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ); // raw 스트림 무효화, 재페이지네이션 (셀 편집 → composed 불변) self.document.sections[section_idx].raw_stream = None; self.mark_section_dirty(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::CellTextChanged { section: section_idx, para: parent_para_idx, ctrl: control_idx, cell: cell_idx }); - Ok(super::super::helpers::json_ok_with(&format!("\"charOffset\":{}", char_offset))) + self.event_log.push(DocumentEvent::CellTextChanged { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + cell: cell_idx, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"charOffset\":{}", + char_offset + ))) } /// 표 셀 또는 글상자 내부 문단에 대한 가변 참조를 얻는다. @@ -291,75 +376,88 @@ impl DocumentCore { ) -> Result<&mut crate::model::paragraph::Paragraph, HwpError> { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과", section_idx + "구역 인덱스 {} 범위 초과", + section_idx ))); } let section = &mut self.document.sections[section_idx]; if parent_para_idx >= section.paragraphs.len() { return Err(HwpError::RenderError(format!( - "부모 문단 인덱스 {} 범위 초과", parent_para_idx + "부모 문단 인덱스 {} 범위 초과", + parent_para_idx ))); } let para = &mut section.paragraphs[parent_para_idx]; if control_idx >= para.controls.len() { return Err(HwpError::RenderError(format!( - "컨트롤 인덱스 {} 범위 초과", control_idx + "컨트롤 인덱스 {} 범위 초과", + control_idx ))); } match &mut para.controls[control_idx] { Control::Table(t) => { // cell_idx == 65534: 표 캡션 접근 (TypeScript에서 표 캡션 편집 시 사용) if cell_idx == 65534 { - let cap = t.caption.as_mut() - .ok_or_else(|| HwpError::RenderError( - "지정된 표 컨트롤에 캡션이 없습니다".to_string() - ))?; + let cap = t.caption.as_mut().ok_or_else(|| { + HwpError::RenderError("지정된 표 컨트롤에 캡션이 없습니다".to_string()) + })?; if cell_para_idx >= cap.paragraphs.len() { return Err(HwpError::RenderError(format!( - "캡션 문단 인덱스 {} 범위 초과 (총 {}개)", cell_para_idx, cap.paragraphs.len() + "캡션 문단 인덱스 {} 범위 초과 (총 {}개)", + cell_para_idx, + cap.paragraphs.len() ))); } return Ok(&mut cap.paragraphs[cell_para_idx]); } if cell_idx >= t.cells.len() { return Err(HwpError::RenderError(format!( - "셀 인덱스 {} 범위 초과 (총 {}개)", cell_idx, t.cells.len() + "셀 인덱스 {} 범위 초과 (총 {}개)", + cell_idx, + t.cells.len() ))); } let cell = &mut t.cells[cell_idx]; if cell_para_idx >= cell.paragraphs.len() { return Err(HwpError::RenderError(format!( - "셀 문단 인덱스 {} 범위 초과 (총 {}개)", cell_para_idx, cell.paragraphs.len() + "셀 문단 인덱스 {} 범위 초과 (총 {}개)", + cell_para_idx, + cell.paragraphs.len() ))); } Ok(&mut cell.paragraphs[cell_para_idx]) } Control::Shape(shape) => { - let tb = super::super::helpers::get_textbox_from_shape_mut(shape) - .ok_or_else(|| HwpError::RenderError( - "지정된 Shape 컨트롤에 텍스트 박스가 없습니다".to_string() - ))?; + let tb = + super::super::helpers::get_textbox_from_shape_mut(shape).ok_or_else(|| { + HwpError::RenderError( + "지정된 Shape 컨트롤에 텍스트 박스가 없습니다".to_string(), + ) + })?; if cell_para_idx >= tb.paragraphs.len() { return Err(HwpError::RenderError(format!( - "글상자 문단 인덱스 {} 범위 초과 (총 {}개)", cell_para_idx, tb.paragraphs.len() + "글상자 문단 인덱스 {} 범위 초과 (총 {}개)", + cell_para_idx, + tb.paragraphs.len() ))); } Ok(&mut tb.paragraphs[cell_para_idx]) } Control::Picture(pic) => { - let cap = pic.caption.as_mut() - .ok_or_else(|| HwpError::RenderError( - "지정된 그림 컨트롤에 캡션이 없습니다".to_string() - ))?; + let cap = pic.caption.as_mut().ok_or_else(|| { + HwpError::RenderError("지정된 그림 컨트롤에 캡션이 없습니다".to_string()) + })?; if cell_para_idx >= cap.paragraphs.len() { return Err(HwpError::RenderError(format!( - "캡션 문단 인덱스 {} 범위 초과 (총 {}개)", cell_para_idx, cap.paragraphs.len() + "캡션 문단 인덱스 {} 범위 초과 (총 {}개)", + cell_para_idx, + cap.paragraphs.len() ))); } Ok(&mut cap.paragraphs[cell_para_idx]) } _ => Err(HwpError::RenderError( - "지정된 컨트롤이 표, 글상자 또는 그림이 아닙니다".to_string() + "지정된 컨트롤이 표, 글상자 또는 그림이 아닙니다".to_string(), )), } } @@ -371,11 +469,14 @@ impl DocumentCore { parent_para_idx: usize, control_idx: usize, ) { - if let Some(ctrl) = self.document.sections[section_idx] - .paragraphs[parent_para_idx].controls.get_mut(control_idx) + if let Some(ctrl) = self.document.sections[section_idx].paragraphs[parent_para_idx] + .controls + .get_mut(control_idx) { match ctrl { - Control::Table(t) => { t.dirty = true; } + Control::Table(t) => { + t.dirty = true; + } // Shape는 별도 dirty 필드가 없으므로 section dirty만으로 충분 _ => {} } @@ -450,7 +551,11 @@ impl DocumentCore { // 문단 여백 계산 let para_shape_id = { let cell_para = self.get_cell_paragraph_ref( - section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx, + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, ); match cell_para { Some(p) => p.para_shape_id, @@ -463,8 +568,9 @@ impl DocumentCore { let final_width = (available_width - margin_left - margin_right).max(0.0); // 가변 참조로 리플로우 실행 - match self.document.sections[section_idx] - .paragraphs[parent_para_idx].controls.get_mut(control_idx) + match self.document.sections[section_idx].paragraphs[parent_para_idx] + .controls + .get_mut(control_idx) { Some(Control::Table(table)) => { if let Some(cell) = table.cells.get_mut(cell_idx) { @@ -512,7 +618,8 @@ impl DocumentCore { // 같은 문단 내 삭제 let count = end_offset - start_offset; if count > 0 { - let cell_para = self.get_cell_paragraph_mut(section_idx, ppi, ci, cei, start_para)?; + let cell_para = + self.get_cell_paragraph_mut(section_idx, ppi, ci, cei, start_para)?; cell_para.delete_text_at(start_offset, count); self.reflow_cell_paragraph(section_idx, ppi, ci, cei, start_para); } @@ -520,7 +627,8 @@ impl DocumentCore { // 다중 문단 셀 내 삭제 // 1) 마지막 문단 앞부분 삭제 if end_offset > 0 { - let cell_para = self.get_cell_paragraph_mut(section_idx, ppi, ci, cei, end_para)?; + let cell_para = + self.get_cell_paragraph_mut(section_idx, ppi, ci, cei, end_para)?; cell_para.delete_text_at(0, end_offset); } // 2) 중간 문단 역순 제거 — 셀 내 문단은 cell.paragraphs에서 직접 제거 @@ -532,7 +640,8 @@ impl DocumentCore { } // 3) 첫 문단 뒷부분 삭제 { - let cell_para = self.get_cell_paragraph_mut(section_idx, ppi, ci, cei, start_para)?; + let cell_para = + self.get_cell_paragraph_mut(section_idx, ppi, ci, cei, start_para)?; let para_len = cell_para.text.chars().count(); if start_offset < para_len { cell_para.delete_text_at(start_offset, para_len - start_offset); @@ -551,8 +660,16 @@ impl DocumentCore { self.mark_cell_control_dirty(section_idx, ppi, ci); self.mark_section_dirty(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::CellTextChanged { section: section_idx, para: ppi, ctrl: ci, cell: cei }); - Ok(super::super::helpers::json_ok_with(&format!("\"paraIdx\":{},\"charOffset\":{}", start_para, start_offset))) + self.event_log.push(DocumentEvent::CellTextChanged { + section: section_idx, + para: ppi, + ctrl: ci, + cell: cei, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"paraIdx\":{},\"charOffset\":{}", + start_para, start_offset + ))) } else { // ─── 본문 deleteRange ─── if start_para == end_para { @@ -563,7 +680,8 @@ impl DocumentCore { .delete_text_at(start_offset, count); self.reflow_paragraph(section_idx, start_para); crate::renderer::composer::recalculate_section_vpos( - &mut self.document.sections[section_idx].paragraphs, start_para, + &mut self.document.sections[section_idx].paragraphs, + start_para, ); } // 변경 문단만 재구성 @@ -576,12 +694,17 @@ impl DocumentCore { } // 2) 중간 문단 역순 제거 (composed도 동기) for mid_para in (start_para + 1..end_para).rev() { - self.document.sections[section_idx].paragraphs.remove(mid_para); + self.document.sections[section_idx] + .paragraphs + .remove(mid_para); self.remove_composed_paragraph(section_idx, mid_para); } // 3) 첫 문단 뒷부분 삭제 { - let para_len = self.document.sections[section_idx].paragraphs[start_para].text.chars().count(); + let para_len = self.document.sections[section_idx].paragraphs[start_para] + .text + .chars() + .count(); if start_offset < para_len { self.document.sections[section_idx].paragraphs[start_para] .delete_text_at(start_offset, para_len - start_offset); @@ -589,13 +712,16 @@ impl DocumentCore { } // 4) 첫-마지막 문단 병합 (마지막 문단이 이제 start_para+1에 위치) if start_para + 1 < self.document.sections[section_idx].paragraphs.len() { - let next = self.document.sections[section_idx].paragraphs.remove(start_para + 1); + let next = self.document.sections[section_idx] + .paragraphs + .remove(start_para + 1); self.remove_composed_paragraph(section_idx, start_para + 1); self.document.sections[section_idx].paragraphs[start_para].merge_from(&next); } self.reflow_paragraph(section_idx, start_para); crate::renderer::composer::recalculate_section_vpos( - &mut self.document.sections[section_idx].paragraphs, start_para, + &mut self.document.sections[section_idx].paragraphs, + start_para, ); // 병합된 문단 재구성 self.recompose_paragraph(section_idx, start_para); @@ -608,8 +734,16 @@ impl DocumentCore { self.document.doc_properties.caret_list_id = section_idx as u32; self.document.doc_properties.caret_para_id = start_para as u32; - self.event_log.push(DocumentEvent::TextDeleted { section: section_idx, para: start_para, offset: start_offset, count: 0 }); - Ok(super::super::helpers::json_ok_with(&format!("\"paraIdx\":{},\"charOffset\":{}", start_para, start_offset))) + self.event_log.push(DocumentEvent::TextDeleted { + section: section_idx, + para: start_para, + offset: start_offset, + count: 0, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"paraIdx\":{},\"charOffset\":{}", + start_para, start_offset + ))) } } @@ -622,16 +756,20 @@ impl DocumentCore { cell_idx: usize, ) -> Result<&mut crate::model::table::Cell, HwpError> { let section = &mut self.document.sections[section_idx]; - let para = section.paragraphs.get_mut(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("부모 문단 인덱스 {} 범위 초과", parent_para_idx)))?; - let ctrl = para.controls.get_mut(control_idx) - .ok_or_else(|| HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)))?; + let para = section.paragraphs.get_mut(parent_para_idx).ok_or_else(|| { + HwpError::RenderError(format!("부모 문단 인덱스 {} 범위 초과", parent_para_idx)) + })?; + let ctrl = para.controls.get_mut(control_idx).ok_or_else(|| { + HwpError::RenderError(format!("컨트롤 인덱스 {} 범위 초과", control_idx)) + })?; match ctrl { - Control::Table(ref mut table) => { - table.cells.get_mut(cell_idx) - .ok_or_else(|| HwpError::RenderError(format!("셀 인덱스 {} 범위 초과", cell_idx))) - } - _ => Err(HwpError::RenderError("테이블 컨트롤이 아닙니다".to_string())), + Control::Table(ref mut table) => table + .cells + .get_mut(cell_idx) + .ok_or_else(|| HwpError::RenderError(format!("셀 인덱스 {} 범위 초과", cell_idx))), + _ => Err(HwpError::RenderError( + "테이블 컨트롤이 아닙니다".to_string(), + )), } } @@ -648,25 +786,26 @@ impl DocumentCore { cell_idx: usize, cell_para_idx: usize, ) -> Option<&crate::model::paragraph::Paragraph> { - let para = self.document.sections.get(section_idx)? - .paragraphs.get(parent_para_idx)?; + let para = self + .document + .sections + .get(section_idx)? + .paragraphs + .get(parent_para_idx)?; match para.controls.get(control_idx)? { Control::Table(table) => { if cell_idx == 65534 { return table.caption.as_ref()?.paragraphs.get(cell_para_idx); } - table.cells.get(cell_idx)? - .paragraphs.get(cell_para_idx) + table.cells.get(cell_idx)?.paragraphs.get(cell_para_idx) } Control::Shape(shape) => { - if cell_idx != 0 { return None; } - get_textbox_from_shape(shape)? - .paragraphs.get(cell_para_idx) - } - Control::Picture(pic) => { - pic.caption.as_ref()? - .paragraphs.get(cell_para_idx) + if cell_idx != 0 { + return None; + } + get_textbox_from_shape(shape)?.paragraphs.get(cell_para_idx) } + Control::Picture(pic) => pic.caption.as_ref()?.paragraphs.get(cell_para_idx), _ => None, } } @@ -679,13 +818,17 @@ impl DocumentCore { ) -> Result { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과 (총 {}개)", section_idx, self.document.sections.len() + "구역 인덱스 {} 범위 초과 (총 {}개)", + section_idx, + self.document.sections.len() ))); } let section = &self.document.sections[section_idx]; if para_idx >= section.paragraphs.len() { return Err(HwpError::RenderError(format!( - "문단 인덱스 {} 범위 초과 (총 {}개)", para_idx, section.paragraphs.len() + "문단 인덱스 {} 범위 초과 (총 {}개)", + para_idx, + section.paragraphs.len() ))); } @@ -693,41 +836,62 @@ impl DocumentCore { self.document.sections[section_idx].raw_stream = None; // 문단 분리 - let new_para = self.document.sections[section_idx].paragraphs[para_idx] - .split_at(char_offset); + let new_para = + self.document.sections[section_idx].paragraphs[para_idx].split_at(char_offset); // 새 문단을 현재 문단 뒤에 삽입 let new_para_idx = para_idx + 1; - self.document.sections[section_idx].paragraphs.insert(new_para_idx, new_para); + self.document.sections[section_idx] + .paragraphs + .insert(new_para_idx, new_para); // 양쪽 문단 리플로우 → vpos 재계산 → 재구성 → 재페이지네이션 + 다단 수렴 루프 - let old_col1 = self.para_column_map.get(section_idx) - .and_then(|m| m.get(para_idx)).copied().unwrap_or(0); + let old_col1 = self + .para_column_map + .get(section_idx) + .and_then(|m| m.get(para_idx)) + .copied() + .unwrap_or(0); self.reflow_paragraph(section_idx, para_idx); self.reflow_paragraph(section_idx, new_para_idx); crate::renderer::composer::recalculate_section_vpos( - &mut self.document.sections[section_idx].paragraphs, para_idx, + &mut self.document.sections[section_idx].paragraphs, + para_idx, ); self.recompose_paragraph(section_idx, para_idx); self.insert_composed_paragraph(section_idx, new_para_idx); self.paginate_if_needed(); for _ in 0..2 { - let new_col1 = self.para_column_map.get(section_idx) - .and_then(|m| m.get(para_idx)).copied().unwrap_or(0); - if new_col1 == old_col1 { break; } + let new_col1 = self + .para_column_map + .get(section_idx) + .and_then(|m| m.get(para_idx)) + .copied() + .unwrap_or(0); + if new_col1 == old_col1 { + break; + } self.reflow_paragraph(section_idx, para_idx); self.reflow_paragraph(section_idx, new_para_idx); crate::renderer::composer::recalculate_section_vpos( - &mut self.document.sections[section_idx].paragraphs, para_idx, + &mut self.document.sections[section_idx].paragraphs, + para_idx, ); self.recompose_paragraph(section_idx, para_idx); self.recompose_paragraph(section_idx, new_para_idx); self.paginate_if_needed(); } - self.event_log.push(DocumentEvent::ParagraphSplit { section: section_idx, para: para_idx, offset: char_offset }); - Ok(super::super::helpers::json_ok_with(&format!("\"paraIdx\":{},\"charOffset\":0", new_para_idx))) + self.event_log.push(DocumentEvent::ParagraphSplit { + section: section_idx, + para: para_idx, + offset: char_offset, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"paraIdx\":{},\"charOffset\":0", + new_para_idx + ))) } /// 강제 쪽 나누기 삽입 (Ctrl+Enter) @@ -742,25 +906,30 @@ impl DocumentCore { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과", section_idx + "구역 인덱스 {} 범위 초과", + section_idx ))); } if para_idx >= self.document.sections[section_idx].paragraphs.len() { return Err(HwpError::RenderError(format!( - "문단 인덱스 {} 범위 초과", para_idx + "문단 인덱스 {} 범위 초과", + para_idx ))); } self.document.sections[section_idx].raw_stream = None; // 문단 분리 - let new_para = self.document.sections[section_idx].paragraphs[para_idx] - .split_at(char_offset); + let new_para = + self.document.sections[section_idx].paragraphs[para_idx].split_at(char_offset); let new_para_idx = para_idx + 1; - self.document.sections[section_idx].paragraphs.insert(new_para_idx, new_para); + self.document.sections[section_idx] + .paragraphs + .insert(new_para_idx, new_para); // 새 문단에 쪽 나누기 설정 - self.document.sections[section_idx].paragraphs[new_para_idx].column_type = ColumnBreakType::Page; + self.document.sections[section_idx].paragraphs[new_para_idx].column_type = + ColumnBreakType::Page; self.document.sections[section_idx].paragraphs[new_para_idx].raw_break_type = 0x04; // 분할된 두 문단 리플로우 @@ -778,8 +947,15 @@ impl DocumentCore { self.paginate_if_needed(); self.invalidate_page_tree_cache(); - self.event_log.push(DocumentEvent::ParagraphSplit { section: section_idx, para: para_idx, offset: char_offset }); - Ok(super::super::helpers::json_ok_with(&format!("\"paraIdx\":{},\"charOffset\":0", new_para_idx))) + self.event_log.push(DocumentEvent::ParagraphSplit { + section: section_idx, + para: para_idx, + offset: char_offset, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"paraIdx\":{},\"charOffset\":0", + new_para_idx + ))) } /// 단 나누기 삽입 (Ctrl+Shift+Enter) @@ -794,22 +970,31 @@ impl DocumentCore { use crate::model::paragraph::ColumnBreakType; if section_idx >= self.document.sections.len() { - return Err(HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx))); + return Err(HwpError::RenderError(format!( + "구역 인덱스 {} 범위 초과", + section_idx + ))); } if para_idx >= self.document.sections[section_idx].paragraphs.len() { - return Err(HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", para_idx))); + return Err(HwpError::RenderError(format!( + "문단 인덱스 {} 범위 초과", + para_idx + ))); } self.document.sections[section_idx].raw_stream = None; // 문단 분리 - let new_para = self.document.sections[section_idx].paragraphs[para_idx] - .split_at(char_offset); + let new_para = + self.document.sections[section_idx].paragraphs[para_idx].split_at(char_offset); let new_para_idx = para_idx + 1; - self.document.sections[section_idx].paragraphs.insert(new_para_idx, new_para); + self.document.sections[section_idx] + .paragraphs + .insert(new_para_idx, new_para); // 새 문단에 단 나누기 설정 - self.document.sections[section_idx].paragraphs[new_para_idx].column_type = ColumnBreakType::Column; + self.document.sections[section_idx].paragraphs[new_para_idx].column_type = + ColumnBreakType::Column; self.document.sections[section_idx].paragraphs[new_para_idx].raw_break_type = 0x08; // 분할된 두 문단 리플로우 @@ -825,8 +1010,15 @@ impl DocumentCore { self.paginate_if_needed(); self.invalidate_page_tree_cache(); - self.event_log.push(DocumentEvent::ParagraphSplit { section: section_idx, para: para_idx, offset: char_offset }); - Ok(super::super::helpers::json_ok_with(&format!("\"paraIdx\":{},\"charOffset\":0", new_para_idx))) + self.event_log.push(DocumentEvent::ParagraphSplit { + section: section_idx, + para: para_idx, + offset: char_offset, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"paraIdx\":{},\"charOffset\":0", + new_para_idx + ))) } /// 다단 설정 변경 @@ -840,14 +1032,17 @@ impl DocumentCore { &mut self, section_idx: usize, column_count: u16, - column_type: u8, // 0=일반(Normal), 1=배분(Distribute), 2=평행(Parallel) + column_type: u8, // 0=일반(Normal), 1=배분(Distribute), 2=평행(Parallel) same_width: bool, - spacing_hu: i16, // 단 간격 (HWPUNIT) + spacing_hu: i16, // 단 간격 (HWPUNIT) ) -> Result { - use crate::model::page::{ColumnType, ColumnDirection}; + use crate::model::page::{ColumnDirection, ColumnType}; if section_idx >= self.document.sections.len() { - return Err(HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx))); + return Err(HwpError::RenderError(format!( + "구역 인덱스 {} 범위 초과", + section_idx + ))); } let col_type = match column_type { @@ -874,7 +1069,9 @@ impl DocumentCore { break; } } - if found { break; } + if found { + break; + } } // 기존 ColumnDef가 없으면 첫 문단에 삽입 @@ -889,7 +1086,8 @@ impl DocumentCore { }; if !self.document.sections[section_idx].paragraphs.is_empty() { self.document.sections[section_idx].paragraphs[0] - .controls.push(Control::ColumnDef(cd)); + .controls + .push(Control::ColumnDef(cd)); } } @@ -908,18 +1106,22 @@ impl DocumentCore { ) -> Result { if section_idx >= self.document.sections.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과 (총 {}개)", section_idx, self.document.sections.len() + "구역 인덱스 {} 범위 초과 (총 {}개)", + section_idx, + self.document.sections.len() ))); } let section = &self.document.sections[section_idx]; if para_idx == 0 { return Err(HwpError::RenderError( - "첫 번째 문단은 병합할 수 없습니다".to_string() + "첫 번째 문단은 병합할 수 없습니다".to_string(), )); } if para_idx >= section.paragraphs.len() { return Err(HwpError::RenderError(format!( - "문단 인덱스 {} 범위 초과 (총 {}개)", para_idx, section.paragraphs.len() + "문단 인덱스 {} 범위 초과 (총 {}개)", + para_idx, + section.paragraphs.len() ))); } @@ -927,36 +1129,56 @@ impl DocumentCore { self.document.sections[section_idx].raw_stream = None; // 현재 문단을 이전 문단에 병합 - let current_para = self.document.sections[section_idx].paragraphs.remove(para_idx); + let current_para = self.document.sections[section_idx] + .paragraphs + .remove(para_idx); let prev_idx = para_idx - 1; - let merge_point = self.document.sections[section_idx].paragraphs[prev_idx] - .merge_from(¤t_para); + let merge_point = + self.document.sections[section_idx].paragraphs[prev_idx].merge_from(¤t_para); // 병합된 문단 리플로우 → vpos 재계산 → 재구성 → 재페이지네이션 + 다단 수렴 루프 - let old_col = self.para_column_map.get(section_idx) - .and_then(|m| m.get(prev_idx)).copied().unwrap_or(0); + let old_col = self + .para_column_map + .get(section_idx) + .and_then(|m| m.get(prev_idx)) + .copied() + .unwrap_or(0); self.reflow_paragraph(section_idx, prev_idx); crate::renderer::composer::recalculate_section_vpos( - &mut self.document.sections[section_idx].paragraphs, prev_idx, + &mut self.document.sections[section_idx].paragraphs, + prev_idx, ); self.remove_composed_paragraph(section_idx, para_idx); self.recompose_paragraph(section_idx, prev_idx); self.paginate_if_needed(); for _ in 0..2 { - let new_col = self.para_column_map.get(section_idx) - .and_then(|m| m.get(prev_idx)).copied().unwrap_or(0); - if new_col == old_col { break; } + let new_col = self + .para_column_map + .get(section_idx) + .and_then(|m| m.get(prev_idx)) + .copied() + .unwrap_or(0); + if new_col == old_col { + break; + } self.reflow_paragraph(section_idx, prev_idx); crate::renderer::composer::recalculate_section_vpos( - &mut self.document.sections[section_idx].paragraphs, prev_idx, + &mut self.document.sections[section_idx].paragraphs, + prev_idx, ); self.recompose_paragraph(section_idx, prev_idx); self.paginate_if_needed(); } - self.event_log.push(DocumentEvent::ParagraphMerged { section: section_idx, para: para_idx }); - Ok(super::super::helpers::json_ok_with(&format!("\"paraIdx\":{},\"charOffset\":{}", prev_idx, merge_point))) + self.event_log.push(DocumentEvent::ParagraphMerged { + section: section_idx, + para: para_idx, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"paraIdx\":{},\"charOffset\":{}", + prev_idx, merge_point + ))) } /// 셀 내부 문단 분할 (네이티브 에러 타입) @@ -971,17 +1193,24 @@ impl DocumentCore { ) -> Result { // 셀 문단 검증 및 분할 let cell_para = self.get_cell_paragraph_mut( - section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx, + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, )?; let new_para = cell_para.split_at(char_offset); // 새 문단을 셀/글상자에 삽입 let new_cell_para_idx = cell_para_idx + 1; - match self.document.sections[section_idx] - .paragraphs[parent_para_idx].controls.get_mut(control_idx) + match self.document.sections[section_idx].paragraphs[parent_para_idx] + .controls + .get_mut(control_idx) { Some(Control::Table(table)) => { - table.cells[cell_idx].paragraphs.insert(new_cell_para_idx, new_para); + table.cells[cell_idx] + .paragraphs + .insert(new_cell_para_idx, new_para); table.dirty = true; } Some(Control::Shape(shape)) => { @@ -998,16 +1227,36 @@ impl DocumentCore { } // 양쪽 문단 리플로우 - self.reflow_cell_paragraph(section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx); - self.reflow_cell_paragraph(section_idx, parent_para_idx, control_idx, cell_idx, new_cell_para_idx); + self.reflow_cell_paragraph( + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ); + self.reflow_cell_paragraph( + section_idx, + parent_para_idx, + control_idx, + cell_idx, + new_cell_para_idx, + ); // raw 스트림 무효화, section dirty, 재페이지네이션 self.document.sections[section_idx].raw_stream = None; self.mark_section_dirty(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::CellTextChanged { section: section_idx, para: parent_para_idx, ctrl: control_idx, cell: cell_idx }); - Ok(super::super::helpers::json_ok_with(&format!("\"cellParaIndex\":{},\"charOffset\":0", new_cell_para_idx))) + self.event_log.push(DocumentEvent::CellTextChanged { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + cell: cell_idx, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"cellParaIndex\":{},\"charOffset\":0", + new_cell_para_idx + ))) } /// 셀 내부 문단 병합 (네이티브 에러 타입) @@ -1023,14 +1272,18 @@ impl DocumentCore { ) -> Result { if cell_para_idx == 0 { return Err(HwpError::RenderError( - "셀 첫 번째 문단은 병합할 수 없습니다".to_string() + "셀 첫 번째 문단은 병합할 수 없습니다".to_string(), )); } // 검증: 셀 문단 인덱스 범위 확인 { let cell_para = self.get_cell_paragraph_mut( - section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx, + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, )?; let _ = cell_para; // 검증만 수행 } @@ -1038,8 +1291,9 @@ impl DocumentCore { // 문단 제거 및 이전 문단에 병합 let prev_idx = cell_para_idx - 1; let merge_point; - match self.document.sections[section_idx] - .paragraphs[parent_para_idx].controls.get_mut(control_idx) + match self.document.sections[section_idx].paragraphs[parent_para_idx] + .controls + .get_mut(control_idx) { Some(Control::Table(table)) => { let removed = table.cells[cell_idx].paragraphs.remove(cell_para_idx); @@ -1052,7 +1306,7 @@ impl DocumentCore { merge_point = tb.paragraphs[prev_idx].merge_from(&removed); } else { return Err(HwpError::RenderError( - "지정된 Shape 컨트롤에 텍스트 박스가 없습니다".to_string() + "지정된 Shape 컨트롤에 텍스트 박스가 없습니다".to_string(), )); } } @@ -1062,27 +1316,41 @@ impl DocumentCore { merge_point = cap.paragraphs[prev_idx].merge_from(&removed); } else { return Err(HwpError::RenderError( - "지정된 그림 컨트롤에 캡션이 없습니다".to_string() + "지정된 그림 컨트롤에 캡션이 없습니다".to_string(), )); } } _ => { return Err(HwpError::RenderError( - "지정된 컨트롤이 표, 글상자 또는 그림이 아닙니다".to_string() + "지정된 컨트롤이 표, 글상자 또는 그림이 아닙니다".to_string(), )); } } // 병합된 문단 리플로우 - self.reflow_cell_paragraph(section_idx, parent_para_idx, control_idx, cell_idx, prev_idx); + self.reflow_cell_paragraph( + section_idx, + parent_para_idx, + control_idx, + cell_idx, + prev_idx, + ); // raw 스트림 무효화, section dirty, 재페이지네이션 self.document.sections[section_idx].raw_stream = None; self.mark_section_dirty(section_idx); self.paginate_if_needed(); - self.event_log.push(DocumentEvent::CellTextChanged { section: section_idx, para: parent_para_idx, ctrl: control_idx, cell: cell_idx }); - Ok(super::super::helpers::json_ok_with(&format!("\"cellParaIndex\":{},\"charOffset\":{}", prev_idx, merge_point))) + self.event_log.push(DocumentEvent::CellTextChanged { + section: section_idx, + para: parent_para_idx, + ctrl: control_idx, + cell: cell_idx, + }); + Ok(super::super::helpers::json_ok_with(&format!( + "\"cellParaIndex\":{},\"charOffset\":{}", + prev_idx, merge_point + ))) } // ─── Phase 1 Native: 기본 편집 보조 API ──────────────────── @@ -1091,22 +1359,32 @@ impl DocumentCore { pub fn get_paragraph_count_native(&self, section_idx: usize) -> Result { let section = self.document.sections.get(section_idx).ok_or_else(|| { HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과 (총 {}개)", section_idx, self.document.sections.len() + "구역 인덱스 {} 범위 초과 (총 {}개)", + section_idx, + self.document.sections.len() )) })?; Ok(section.paragraphs.len()) } /// 문단 글자 수 (네이티브) - pub fn get_paragraph_length_native(&self, section_idx: usize, para_idx: usize) -> Result { + pub fn get_paragraph_length_native( + &self, + section_idx: usize, + para_idx: usize, + ) -> Result { let section = self.document.sections.get(section_idx).ok_or_else(|| { HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과 (총 {}개)", section_idx, self.document.sections.len() + "구역 인덱스 {} 범위 초과 (총 {}개)", + section_idx, + self.document.sections.len() )) })?; let para = section.paragraphs.get(para_idx).ok_or_else(|| { HwpError::RenderError(format!( - "문단 인덱스 {} 범위 초과 (총 {}개)", para_idx, section.paragraphs.len() + "문단 인덱스 {} 범위 초과 (총 {}개)", + para_idx, + section.paragraphs.len() )) })?; Ok(para.text.chars().count()) @@ -1163,7 +1441,11 @@ impl DocumentCore { let p = section.paragraphs.get(para)?; let controls = &p.controls; if forward { - let from = if start_ci < 0 { 0usize } else { (start_ci as usize) + 1 }; + let from = if start_ci < 0 { + 0usize + } else { + (start_ci as usize) + 1 + }; for ci in from..controls.len() { match &controls[ci] { Control::Shape(shape) => { @@ -1178,7 +1460,11 @@ impl DocumentCore { } } } else { - let until = if start_ci < 0 { controls.len() } else { start_ci as usize }; + let until = if start_ci < 0 { + controls.len() + } else { + start_ci as usize + }; for ci in (0..until).rev() { match &controls[ci] { Control::Shape(shape) => { @@ -1215,7 +1501,10 @@ impl DocumentCore { // 1) 같은 문단에서 탐색 if let Some((ci, ty)) = find_in_para(sections, section_idx, para_idx, ctrl_idx, forward) { - return format!("{{\"type\":\"{}\",\"sec\":{},\"para\":{},\"ci\":{}}}", ty, section_idx, para_idx, ci); + return format!( + "{{\"type\":\"{}\",\"sec\":{},\"para\":{},\"ci\":{}}}", + ty, section_idx, para_idx, ci + ); } // 2) 같은 섹션의 다른 문단 탐색 @@ -1229,13 +1518,25 @@ impl DocumentCore { Box::new(std::iter::empty()) }; for pi in para_range { - let search_start = if forward { -1 } else { section.paragraphs[pi].controls.len() as i32 }; - if let Some((ci, ty)) = find_in_para(sections, section_idx, pi, search_start, forward) { - return format!("{{\"type\":\"{}\",\"sec\":{},\"para\":{},\"ci\":{}}}", ty, section_idx, pi, ci); + let search_start = if forward { + -1 + } else { + section.paragraphs[pi].controls.len() as i32 + }; + if let Some((ci, ty)) = + find_in_para(sections, section_idx, pi, search_start, forward) + { + return format!( + "{{\"type\":\"{}\",\"sec\":{},\"para\":{},\"ci\":{}}}", + ty, section_idx, pi, ci + ); } // 네비게이션 가능한 컨트롤이 없는 문단 → body if !has_navigable_control(sections, section_idx, pi) { - return format!("{{\"type\":\"body\",\"sec\":{},\"para\":{}}}", section_idx, pi); + return format!( + "{{\"type\":\"body\",\"sec\":{},\"para\":{}}}", + section_idx, pi + ); } } } @@ -1256,9 +1557,16 @@ impl DocumentCore { Box::new((0..section.paragraphs.len()).rev()) }; for pi in para_range { - let search_start = if forward { -1 } else { section.paragraphs[pi].controls.len() as i32 }; + let search_start = if forward { + -1 + } else { + section.paragraphs[pi].controls.len() as i32 + }; if let Some((ci, ty)) = find_in_para(sections, si, pi, search_start, forward) { - return format!("{{\"type\":\"{}\",\"sec\":{},\"para\":{},\"ci\":{}}}", ty, si, pi, ci); + return format!( + "{{\"type\":\"{}\",\"sec\":{},\"para\":{},\"ci\":{}}}", + ty, si, pi, ci + ); } if !has_navigable_control(sections, si, pi) { return format!("{{\"type\":\"body\",\"sec\":{},\"para\":{}}}", si, pi); @@ -1469,19 +1777,24 @@ impl DocumentCore { ) -> Result { let section = self.document.sections.get(section_idx).ok_or_else(|| { HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과 (총 {}개)", section_idx, self.document.sections.len() + "구역 인덱스 {} 범위 초과 (총 {}개)", + section_idx, + self.document.sections.len() )) })?; let para = section.paragraphs.get(para_idx).ok_or_else(|| { HwpError::RenderError(format!( - "문단 인덱스 {} 범위 초과 (총 {}개)", para_idx, section.paragraphs.len() + "문단 인덱스 {} 범위 초과 (총 {}개)", + para_idx, + section.paragraphs.len() )) })?; let text_chars: Vec = para.text.chars().collect(); let total = text_chars.len(); if char_offset > total { return Err(HwpError::RenderError(format!( - "char_offset {} 범위 초과 (문단 길이 {})", char_offset, total + "char_offset {} 범위 초과 (문단 길이 {})", + char_offset, total ))); } let end = (char_offset + count).min(total); @@ -1497,43 +1810,49 @@ impl DocumentCore { control_idx: usize, cell_idx: usize, ) -> Result { - let para = self.document.sections.get(section_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과", section_idx - )))? - .paragraphs.get(parent_para_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "문단 인덱스 {} 범위 초과", parent_para_idx - )))?; + let para = self + .document + .sections + .get(section_idx) + .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))? + .paragraphs + .get(parent_para_idx) + .ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", parent_para_idx)) + })?; match para.controls.get(control_idx) { Some(Control::Table(table)) => { if cell_idx == 65534 { - let cap = table.caption.as_ref().ok_or_else(|| { - HwpError::RenderError("표에 캡션이 없습니다".to_string()) - })?; + let cap = table + .caption + .as_ref() + .ok_or_else(|| HwpError::RenderError("표에 캡션이 없습니다".to_string()))?; return Ok(cap.paragraphs.len()); } let cell = table.cells.get(cell_idx).ok_or_else(|| { HwpError::RenderError(format!( - "셀 인덱스 {} 범위 초과 (총 {}개)", cell_idx, table.cells.len() + "셀 인덱스 {} 범위 초과 (총 {}개)", + cell_idx, + table.cells.len() )) })?; Ok(cell.paragraphs.len()) } Some(Control::Shape(shape)) => { - let text_box = get_textbox_from_shape(shape).ok_or_else(|| { - HwpError::RenderError("도형에 글상자가 없습니다".to_string()) - })?; + let text_box = get_textbox_from_shape(shape) + .ok_or_else(|| HwpError::RenderError("도형에 글상자가 없습니다".to_string()))?; Ok(text_box.paragraphs.len()) } Some(Control::Picture(pic)) => { - let caption = pic.caption.as_ref().ok_or_else(|| { - HwpError::RenderError("그림에 캡션이 없습니다".to_string()) - })?; + let caption = pic + .caption + .as_ref() + .ok_or_else(|| HwpError::RenderError("그림에 캡션이 없습니다".to_string()))?; Ok(caption.paragraphs.len()) } _ => Err(HwpError::RenderError(format!( - "컨트롤 인덱스 {}가 표/글상자가 아닙니다", control_idx + "컨트롤 인덱스 {}가 표/글상자가 아닙니다", + control_idx ))), } } @@ -1547,12 +1866,20 @@ impl DocumentCore { cell_idx: usize, cell_para_idx: usize, ) -> Result { - let cell_para = self.get_cell_paragraph_ref( - section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx, - ).ok_or_else(|| HwpError::RenderError(format!( - "셀 문단 접근 실패: sec={}, para={}, ctrl={}, cell={}, cellPara={}", - section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx - )))?; + let cell_para = self + .get_cell_paragraph_ref( + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ) + .ok_or_else(|| { + HwpError::RenderError(format!( + "셀 문단 접근 실패: sec={}, para={}, ctrl={}, cell={}, cellPara={}", + section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx + )) + })?; Ok(cell_para.text.chars().count()) } @@ -1567,17 +1894,26 @@ impl DocumentCore { char_offset: usize, count: usize, ) -> Result { - let cell_para = self.get_cell_paragraph_ref( - section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx, - ).ok_or_else(|| HwpError::RenderError(format!( - "셀 문단 접근 실패: sec={}, para={}, ctrl={}, cell={}, cellPara={}", - section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx - )))?; + let cell_para = self + .get_cell_paragraph_ref( + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ) + .ok_or_else(|| { + HwpError::RenderError(format!( + "셀 문단 접근 실패: sec={}, para={}, ctrl={}, cell={}, cellPara={}", + section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx + )) + })?; let text_chars: Vec = cell_para.text.chars().collect(); let total = text_chars.len(); if char_offset > total { return Err(HwpError::RenderError(format!( - "char_offset {} 범위 초과 (셀 문단 길이 {})", char_offset, total + "char_offset {} 범위 초과 (셀 문단 길이 {})", + char_offset, total ))); } let end = (char_offset + count).min(total); @@ -1590,10 +1926,16 @@ impl DocumentCore { // ─── Phase 2 Native: 커서/히트 테스트 API ──────────────────── /// 문단이 포함된 글로벌 페이지 번호 목록을 반환한다. - pub(crate) fn find_pages_for_paragraph(&self, section_idx: usize, para_idx: usize) -> Result, HwpError> { + pub(crate) fn find_pages_for_paragraph( + &self, + section_idx: usize, + para_idx: usize, + ) -> Result, HwpError> { if section_idx >= self.pagination.len() { return Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과 (총 {}개)", section_idx, self.pagination.len() + "구역 인덱스 {} 범위 초과 (총 {}개)", + section_idx, + self.pagination.len() ))); } let mut global_offset = 0u32; @@ -1605,11 +1947,23 @@ impl DocumentCore { for col in &page.column_contents { for item in &col.items { let pi = match item { - crate::renderer::pagination::PageItem::FullParagraph { para_index } => Some(*para_index), - crate::renderer::pagination::PageItem::PartialParagraph { para_index, .. } => Some(*para_index), - crate::renderer::pagination::PageItem::Table { para_index, .. } => Some(*para_index), - crate::renderer::pagination::PageItem::PartialTable { para_index, .. } => Some(*para_index), - crate::renderer::pagination::PageItem::Shape { para_index, .. } => Some(*para_index), + crate::renderer::pagination::PageItem::FullParagraph { + para_index, + } => Some(*para_index), + crate::renderer::pagination::PageItem::PartialParagraph { + para_index, + .. + } => Some(*para_index), + crate::renderer::pagination::PageItem::Table { + para_index, .. + } => Some(*para_index), + crate::renderer::pagination::PageItem::PartialTable { + para_index, + .. + } => Some(*para_index), + crate::renderer::pagination::PageItem::Shape { + para_index, .. + } => Some(*para_index), }; if pi == Some(para_idx) { if result.last() != Some(&global_page) { @@ -1632,7 +1986,9 @@ impl DocumentCore { for wp in &pr.wrap_around_paras { if wp.para_index == para_idx { // 표 호스트 문단의 페이지에서 렌더링됨 - if let Ok(table_pages) = self.find_pages_for_paragraph(section_idx, wp.table_para_index) { + if let Ok(table_pages) = + self.find_pages_for_paragraph(section_idx, wp.table_para_index) + { return Ok(table_pages); } } @@ -1640,7 +1996,8 @@ impl DocumentCore { } return if result.is_empty() { Err(HwpError::RenderError(format!( - "문단 (sec={}, para={})이 페이지에 없습니다", section_idx, para_idx + "문단 (sec={}, para={})이 페이지에 없습니다", + section_idx, para_idx ))) } else { Ok(result) @@ -1649,13 +2006,14 @@ impl DocumentCore { global_offset += pr.pages.len() as u32; } Err(HwpError::RenderError(format!( - "구역 인덱스 {} 범위 초과", section_idx + "구역 인덱스 {} 범위 초과", + section_idx ))) } - } #[cfg(test)] +#[allow(clippy::items_after_test_module)] mod tests { use super::*; @@ -1665,7 +2023,11 @@ mod tests { core.create_blank_document_native().unwrap(); assert_eq!(core.page_count(), 1, "초기 페이지 수"); - assert_eq!(core.document.sections[0].paragraphs.len(), 1, "초기 문단 수"); + assert_eq!( + core.document.sections[0].paragraphs.len(), + 1, + "초기 문단 수" + ); // Enter를 50번 입력하여 페이지 오버플로우 유발 for i in 0..50 { @@ -1676,7 +2038,11 @@ mod tests { let para_count = core.document.sections[0].paragraphs.len(); let page_count = core.page_count(); assert_eq!(para_count, 51, "문단 수"); - assert!(page_count >= 2, "페이지 수: {} (2 이상이어야 함)", page_count); + assert!( + page_count >= 2, + "페이지 수: {} (2 이상이어야 함)", + page_count + ); } #[test] @@ -1692,14 +2058,19 @@ mod tests { // 첫 문단이 여러 줄로 구성되는지 확인 let para0_lines = core.composed[0][0].lines.len(); eprintln!("문단0 줄 수: {}", para0_lines); - assert!(para0_lines >= 2, "첫 문단은 2줄 이상이어야 함: {}", para0_lines); + assert!( + para0_lines >= 2, + "첫 문단은 2줄 이상이어야 함: {}", + para0_lines + ); // Enter로 문단 분리 (텍스트 끝에서) let text_len = core.document.sections[0].paragraphs[0].text.chars().count(); core.split_paragraph_native(0, 0, text_len).unwrap(); // 두 번째 문단에 텍스트 입력 - core.insert_text_native(0, 1, 0, "Second paragraph").unwrap(); + core.insert_text_native(0, 1, 0, "Second paragraph") + .unwrap(); // 렌더 트리 빌드 (페이지 0) let tree = core.build_page_tree(0).unwrap(); @@ -1707,16 +2078,26 @@ mod tests { // 렌더 트리에서 문단들의 Y 좌표를 추출 // 두 번째 문단 "Second" 텍스트가 존재하는지 확인 - assert!(tree_str.contains("Second paragraph"), - "두 번째 문단 텍스트가 렌더 트리에 없음"); + assert!( + tree_str.contains("Second paragraph"), + "두 번째 문단 텍스트가 렌더 트리에 없음" + ); // 렌더 트리에서 TextRun Y 좌표 확인 let para0_last_y = find_text_y(&tree.root, "dog."); let para1_y = find_text_y(&tree.root, "Second"); - eprintln!("문단0 마지막줄 Y: {:?}, 문단1 Y: {:?}", para0_last_y, para1_y); + eprintln!( + "문단0 마지막줄 Y: {:?}, 문단1 Y: {:?}", + para0_last_y, para1_y + ); if let (Some(y0), Some(y1)) = (para0_last_y, para1_y) { - assert!(y1 > y0, "문단1 Y({:.1})가 문단0 Y({:.1})보다 커야 함 (겹침 감지)", y1, y0); + assert!( + y1 > y0, + "문단1 Y({:.1})가 문단0 Y({:.1})보다 커야 함 (겹침 감지)", + y1, + y0 + ); } } @@ -1737,7 +2118,11 @@ mod tests { let page_count = core.page_count(); eprintln!("160% 줄간격: 문단 51개, 페이지 수: {}", page_count); - assert!(page_count >= 2, "160% 줄간격에서 페이지 넘김 필요: {}", page_count); + assert!( + page_count >= 2, + "160% 줄간격에서 페이지 넘김 필요: {}", + page_count + ); } /// 줄간격 100%에서 160%보다 더 많은 문단이 한 페이지에 들어가는지 확인 @@ -1748,7 +2133,9 @@ mod tests { core100.create_blank_document_native().unwrap(); let text = "Tight spacing test line."; // 첫 문단에 줄간격 100% 적용 - core100.apply_para_format_native(0, 0, r#"{"lineSpacing":100}"#).unwrap(); + core100 + .apply_para_format_native(0, 0, r#"{"lineSpacing":100}"#) + .unwrap(); for i in 0..50 { let para_count = core100.document.sections[0].paragraphs.len(); let last = para_count - 1; @@ -1756,7 +2143,9 @@ mod tests { core100.split_paragraph_native(0, last, text.len()).unwrap(); // 새 문단에도 100% 적용 let new_last = core100.document.sections[0].paragraphs.len() - 1; - core100.apply_para_format_native(0, new_last, r#"{"lineSpacing":100}"#).unwrap(); + core100 + .apply_para_format_native(0, new_last, r#"{"lineSpacing":100}"#) + .unwrap(); } let pages_100 = core100.page_count(); @@ -1771,10 +2160,17 @@ mod tests { } let pages_160 = core160.page_count(); - eprintln!("100% → {}페이지, 160% → {}페이지 (문단 51개)", pages_100, pages_160); + eprintln!( + "100% → {}페이지, 160% → {}페이지 (문단 51개)", + pages_100, pages_160 + ); // 100%는 160%보다 같거나 적은 페이지 수 - assert!(pages_100 <= pages_160, - "100% 줄간격({})이 160%({})보다 적은/같은 페이지 수여야 함", pages_100, pages_160); + assert!( + pages_100 <= pages_160, + "100% 줄간격({})이 160%({})보다 적은/같은 페이지 수여야 함", + pages_100, + pages_160 + ); } /// 줄간격 300%에서 160%보다 더 빨리 페이지가 넘어가는지 확인 @@ -1784,14 +2180,18 @@ mod tests { let mut core300 = DocumentCore::new_empty(); core300.create_blank_document_native().unwrap(); let text = "Wide spacing test line."; - core300.apply_para_format_native(0, 0, r#"{"lineSpacing":300}"#).unwrap(); + core300 + .apply_para_format_native(0, 0, r#"{"lineSpacing":300}"#) + .unwrap(); for i in 0..30 { let para_count = core300.document.sections[0].paragraphs.len(); let last = para_count - 1; core300.insert_text_native(0, last, 0, text).unwrap(); core300.split_paragraph_native(0, last, text.len()).unwrap(); let new_last = core300.document.sections[0].paragraphs.len() - 1; - core300.apply_para_format_native(0, new_last, r#"{"lineSpacing":300}"#).unwrap(); + core300 + .apply_para_format_native(0, new_last, r#"{"lineSpacing":300}"#) + .unwrap(); } let pages_300 = core300.page_count(); @@ -1806,9 +2206,16 @@ mod tests { } let pages_160 = core160.page_count(); - eprintln!("300% → {}페이지, 160% → {}페이지 (문단 31개)", pages_300, pages_160); - assert!(pages_300 >= pages_160, - "300% 줄간격({})이 160%({})보다 많은/같은 페이지 수여야 함", pages_300, pages_160); + eprintln!( + "300% → {}페이지, 160% → {}페이지 (문단 31개)", + pages_300, pages_160 + ); + assert!( + pages_300 >= pages_160, + "300% 줄간격({})이 160%({})보다 많은/같은 페이지 수여야 함", + pages_300, + pages_160 + ); } /// 혼합 줄간격: 문단마다 다른 줄간격에서 페이지 넘김 정상 동작 확인 @@ -1833,13 +2240,25 @@ mod tests { let page_count = core.page_count(); let para_count = core.document.sections[0].paragraphs.len(); - eprintln!("혼합 줄간격: 문단 {}개, 페이지 수: {}", para_count, page_count); - assert!(page_count >= 2, "혼합 줄간격에서 페이지 넘김 필요: {}", page_count); + eprintln!( + "혼합 줄간격: 문단 {}개, 페이지 수: {}", + para_count, page_count + ); + assert!( + page_count >= 2, + "혼합 줄간격에서 페이지 넘김 필요: {}", + page_count + ); // 각 페이지에 문단이 배치되었는지 확인 (렌더 트리 빌드 가능) for p in 0..page_count { let tree = core.build_page_tree(p as u32); - assert!(tree.is_ok(), "페이지 {} 렌더 트리 빌드 실패: {:?}", p, tree.err()); + assert!( + tree.is_ok(), + "페이지 {} 렌더 트리 빌드 실패: {:?}", + p, + tree.err() + ); } } @@ -1851,8 +2270,8 @@ mod tests { let text = "Fixed spacing paragraph."; // Fixed 줄간격 30px - core.apply_para_format_native(0, 0, - r#"{"lineSpacing":30,"lineSpacingType":"Fixed"}"#).unwrap(); + core.apply_para_format_native(0, 0, r#"{"lineSpacing":30,"lineSpacingType":"Fixed"}"#) + .unwrap(); for i in 0..50 { let para_count = core.document.sections[0].paragraphs.len(); @@ -1860,13 +2279,21 @@ mod tests { core.insert_text_native(0, last, 0, text).unwrap(); core.split_paragraph_native(0, last, text.len()).unwrap(); let new_last = core.document.sections[0].paragraphs.len() - 1; - core.apply_para_format_native(0, new_last, - r#"{"lineSpacing":30,"lineSpacingType":"Fixed"}"#).unwrap(); + core.apply_para_format_native( + 0, + new_last, + r#"{"lineSpacing":30,"lineSpacingType":"Fixed"}"#, + ) + .unwrap(); } let page_count = core.page_count(); eprintln!("Fixed 줄간격: 문단 51개, 페이지 수: {}", page_count); - assert!(page_count >= 1, "Fixed 줄간격에서 페이지 수 확인: {}", page_count); + assert!( + page_count >= 1, + "Fixed 줄간격에서 페이지 수 확인: {}", + page_count + ); // 렌더 트리 정상 빌드 확인 for p in 0..page_count { @@ -1905,10 +2332,14 @@ mod tests { // 줄간격이 클수록 페이지 수가 많아야 함 for i in 1..page_counts.len() { - assert!(page_counts[i].1 >= page_counts[i-1].1, + assert!( + page_counts[i].1 >= page_counts[i - 1].1, "줄간격 {}%({})가 {}%({})보다 적은 페이지 수", - page_counts[i].0, page_counts[i].1, - page_counts[i-1].0, page_counts[i-1].1); + page_counts[i].0, + page_counts[i].1, + page_counts[i - 1].0, + page_counts[i - 1].1 + ); } } @@ -1946,21 +2377,35 @@ mod tests { let pages = core.page_count(); if pages > prev_pages && boundary_crossed_at == 0 { boundary_crossed_at = spacing; - eprintln!(" 페이지 경계 돌파: {}% 줄간격에서 {}→{}페이지", spacing, prev_pages, pages); + eprintln!( + " 페이지 경계 돌파: {}% 줄간격에서 {}→{}페이지", + spacing, prev_pages, pages + ); } prev_pages = pages; } eprintln!("최종 페이지 수: {} (줄간격 360%)", prev_pages); - assert!(prev_pages > initial_pages, - "줄간격 증가로 페이지 수 증가 필요: {} → {}", initial_pages, prev_pages); - assert!(boundary_crossed_at > 0, - "페이지 경계 돌파 시점이 감지되어야 함"); + assert!( + prev_pages > initial_pages, + "줄간격 증가로 페이지 수 증가 필요: {} → {}", + initial_pages, + prev_pages + ); + assert!( + boundary_crossed_at > 0, + "페이지 경계 돌파 시점이 감지되어야 함" + ); // 모든 페이지 렌더 트리 정상 빌드 확인 for p in 0..prev_pages { let tree = core.build_page_tree(p as u32); - assert!(tree.is_ok(), "페이지 {} 렌더 트리 빌드 실패: {:?}", p, tree.err()); + assert!( + tree.is_ok(), + "페이지 {} 렌더 트리 빌드 실패: {:?}", + p, + tree.err() + ); } } } @@ -1994,34 +2439,42 @@ impl DocumentCore { if path.is_empty() { return Err(HwpError::RenderError("경로가 비어있습니다".to_string())); } - let section = self.document.sections.get_mut(section_idx) + let section = self + .document + .sections + .get_mut(section_idx) .ok_or_else(|| HwpError::RenderError(format!("구역 {} 범위 초과", section_idx)))?; - let mut para: &mut Paragraph = section.paragraphs.get_mut(parent_para_idx) + let mut para: &mut Paragraph = section + .paragraphs + .get_mut(parent_para_idx) .ok_or_else(|| HwpError::RenderError(format!("문단 {} 범위 초과", parent_para_idx)))?; for (i, &(ctrl_idx, cell_idx, cell_para_idx)) in path.iter().enumerate() { let table = match para.controls.get_mut(ctrl_idx) { Some(Control::Table(t)) => t.as_mut(), - _ => return Err(HwpError::RenderError(format!( - "경로[{}]: controls[{}]가 표가 아닙니다", i, ctrl_idx - ))), + _ => { + return Err(HwpError::RenderError(format!( + "경로[{}]: controls[{}]가 표가 아닙니다", + i, ctrl_idx + ))) + } }; - let cell = table.cells.get_mut(cell_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "경로[{}]: 셀 {} 범위 초과", i, cell_idx - )))?; + let cell = table.cells.get_mut(cell_idx).ok_or_else(|| { + HwpError::RenderError(format!("경로[{}]: 셀 {} 범위 초과", i, cell_idx)) + })?; if i == path.len() - 1 { // 마지막 레벨: 이 셀의 문단 반환 - return cell.paragraphs.get_mut(cell_para_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "경로[{}]: 셀문단 {} 범위 초과", i, cell_para_idx - ))); + return cell.paragraphs.get_mut(cell_para_idx).ok_or_else(|| { + HwpError::RenderError(format!( + "경로[{}]: 셀문단 {} 범위 초과", + i, cell_para_idx + )) + }); } // 중간 레벨: 이 셀의 문단으로 진입 후 다음 표 탐색 - para = cell.paragraphs.get_mut(cell_para_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "경로[{}]: 셀문단 {} 범위 초과", i, cell_para_idx - )))?; + para = cell.paragraphs.get_mut(cell_para_idx).ok_or_else(|| { + HwpError::RenderError(format!("경로[{}]: 셀문단 {} 범위 초과", i, cell_para_idx)) + })?; } unreachable!() } @@ -2050,9 +2503,15 @@ impl DocumentCore { let new_offset = char_offset + new_chars_count; self.event_log.push(DocumentEvent::CellTextChanged { - section: section_idx, para: parent_para_idx, ctrl: outer_ctrl, cell: path[0].1, + section: section_idx, + para: parent_para_idx, + ctrl: outer_ctrl, + cell: path[0].1, }); - Ok(super::super::helpers::json_ok_with(&format!("\"charOffset\":{}", new_offset))) + Ok(super::super::helpers::json_ok_with(&format!( + "\"charOffset\":{}", + new_offset + ))) } /// path 기반 셀 텍스트 삭제 (중첩 표 지원) @@ -2074,9 +2533,15 @@ impl DocumentCore { self.paginate_if_needed(); self.event_log.push(DocumentEvent::CellTextChanged { - section: section_idx, para: parent_para_idx, ctrl: outer_ctrl, cell: path[0].1, + section: section_idx, + para: parent_para_idx, + ctrl: outer_ctrl, + cell: path[0].1, }); - Ok(super::super::helpers::json_ok_with(&format!("\"charOffset\":{}", char_offset))) + Ok(super::super::helpers::json_ok_with(&format!( + "\"charOffset\":{}", + char_offset + ))) } /// path 기반 셀 문단 분할 (중첩 표 지원) @@ -2092,9 +2557,14 @@ impl DocumentCore { let cell_para_idx = last.2; // 셀에 접근하여 문단 분할 - let section = self.document.sections.get_mut(section_idx) + let section = self + .document + .sections + .get_mut(section_idx) .ok_or_else(|| HwpError::RenderError("구역 범위 초과".to_string()))?; - let mut para: &mut Paragraph = section.paragraphs.get_mut(parent_para_idx) + let mut para: &mut Paragraph = section + .paragraphs + .get_mut(parent_para_idx) .ok_or_else(|| HwpError::RenderError("문단 범위 초과".to_string()))?; // path를 따라 마지막 셀까지 진입 @@ -2103,7 +2573,9 @@ impl DocumentCore { Some(Control::Table(t)) => t.as_mut(), _ => return Err(HwpError::RenderError("경로: 표가 아닙니다".to_string())), }; - let cell = table.cells.get_mut(cell_idx) + let cell = table + .cells + .get_mut(cell_idx) .ok_or_else(|| HwpError::RenderError("셀 범위 초과".to_string()))?; if i == path.len() - 1 { // 이 셀에서 문단 분할 @@ -2114,7 +2586,9 @@ impl DocumentCore { cell.paragraphs.insert(cell_para_idx + 1, new_para); break; } - para = cell.paragraphs.get_mut(_cpi) + para = cell + .paragraphs + .get_mut(_cpi) .ok_or_else(|| HwpError::RenderError("셀문단 범위 초과".to_string()))?; } @@ -2125,10 +2599,16 @@ impl DocumentCore { self.paginate_if_needed(); self.event_log.push(DocumentEvent::CellTextChanged { - section: section_idx, para: parent_para_idx, ctrl: outer_ctrl, cell: path[0].1, + section: section_idx, + para: parent_para_idx, + ctrl: outer_ctrl, + cell: path[0].1, }); let new_cpi = cell_para_idx + 1; - Ok(super::super::helpers::json_ok_with(&format!("\"cellParaIndex\":{},\"charOffset\":0", new_cpi))) + Ok(super::super::helpers::json_ok_with(&format!( + "\"cellParaIndex\":{},\"charOffset\":0", + new_cpi + ))) } /// path 기반 셀 문단 병합 (중첩 표 지원) @@ -2141,12 +2621,19 @@ impl DocumentCore { let last = path.last().unwrap(); let cell_para_idx = last.2; if cell_para_idx == 0 { - return Err(HwpError::RenderError("첫 문단은 병합할 수 없습니다".to_string())); + return Err(HwpError::RenderError( + "첫 문단은 병합할 수 없습니다".to_string(), + )); } - let section = self.document.sections.get_mut(section_idx) + let section = self + .document + .sections + .get_mut(section_idx) .ok_or_else(|| HwpError::RenderError("구역 범위 초과".to_string()))?; - let mut para: &mut Paragraph = section.paragraphs.get_mut(parent_para_idx) + let mut para: &mut Paragraph = section + .paragraphs + .get_mut(parent_para_idx) .ok_or_else(|| HwpError::RenderError("문단 범위 초과".to_string()))?; let mut merge_point = 0usize; @@ -2155,7 +2642,9 @@ impl DocumentCore { Some(Control::Table(t)) => t.as_mut(), _ => return Err(HwpError::RenderError("경로: 표가 아닙니다".to_string())), }; - let cell = table.cells.get_mut(cell_idx) + let cell = table + .cells + .get_mut(cell_idx) .ok_or_else(|| HwpError::RenderError("셀 범위 초과".to_string()))?; if i == path.len() - 1 { if cell_para_idx >= cell.paragraphs.len() { @@ -2167,7 +2656,9 @@ impl DocumentCore { prev.merge_from(&removed); break; } - para = cell.paragraphs.get_mut(_cpi) + para = cell + .paragraphs + .get_mut(_cpi) .ok_or_else(|| HwpError::RenderError("셀문단 범위 초과".to_string()))?; } @@ -2178,10 +2669,16 @@ impl DocumentCore { self.paginate_if_needed(); self.event_log.push(DocumentEvent::CellTextChanged { - section: section_idx, para: parent_para_idx, ctrl: outer_ctrl, cell: path[0].1, + section: section_idx, + para: parent_para_idx, + ctrl: outer_ctrl, + cell: path[0].1, }); let prev_cpi = cell_para_idx - 1; - Ok(super::super::helpers::json_ok_with(&format!("\"cellParaIndex\":{},\"charOffset\":{}", prev_cpi, merge_point))) + Ok(super::super::helpers::json_ok_with(&format!( + "\"cellParaIndex\":{},\"charOffset\":{}", + prev_cpi, merge_point + ))) } /// path 기반 셀 텍스트 조회 (중첩 표 지원) @@ -2198,7 +2695,8 @@ impl DocumentCore { let total = text_chars.len(); if char_offset > total { return Err(HwpError::RenderError(format!( - "char_offset {} 범위 초과 (셀 문단 길이 {})", char_offset, total + "char_offset {} 범위 초과 (셀 문단 길이 {})", + char_offset, total ))); } let end = (char_offset + count).min(total); diff --git a/src/document_core/helpers.rs b/src/document_core/helpers.rs index a666b5ec..4514bcab 100644 --- a/src/document_core/helpers.rs +++ b/src/document_core/helpers.rs @@ -2,11 +2,11 @@ //! //! JSON 파싱, 색상 변환, HTML 처리, CSS 파싱 등 유틸리티 함수. -use crate::model::paragraph::Paragraph; +use crate::error::HwpError; use crate::model::control::Control; -use crate::model::style::BorderLineType; +use crate::model::paragraph::Paragraph; use crate::model::path::PathSegment; -use crate::error::HwpError; +use crate::model::style::BorderLineType; /// 문단의 탐색 가능한 텍스트 길이를 반환한다. /// @@ -14,19 +14,26 @@ use crate::error::HwpError; /// 레이아웃에서 각 overlap이 char_offset 1개를 차지하므로 보정한다. pub(crate) fn navigable_text_len(para: &Paragraph) -> usize { let text_len = para.text.chars().count(); - let char_overlap_count = para.controls.iter() + let char_overlap_count = para + .controls + .iter() .filter(|c| matches!(c, Control::CharOverlap(_))) .count(); // 인라인 컨트롤의 최대 position을 구하여, text_len보다 클 경우 확장 let positions = find_control_text_positions(para); - let max_inline_pos = para.controls.iter().enumerate() - .filter(|(_, c)| matches!(c, - Control::Shape(_) | Control::Table(_) | - Control::Picture(_) | Control::Equation(_) - )) + let max_inline_pos = para + .controls + .iter() + .enumerate() + .filter(|(_, c)| { + matches!( + c, + Control::Shape(_) | Control::Table(_) | Control::Picture(_) | Control::Equation(_) + ) + }) .filter_map(|(i, _)| positions.get(i).copied()) .max() - .map(|p| p + 1) // position 뒤에 커서가 위치할 수 있으므로 +1 + .map(|p| p + 1) // position 뒤에 커서가 위치할 수 있으므로 +1 .unwrap_or(0); text_len.max(max_inline_pos) + char_overlap_count } @@ -49,7 +56,9 @@ pub(crate) fn logical_to_text_offset(para: &Paragraph, logical_offset: usize) -> // 텍스트 "abc[ctrl]XYZ" → 논리적: a(0) b(1) c(2) [ctrl](3) X(4) Y(5) Z(6) // ctrl_positions = [3] (텍스트 인덱스 3에 컨트롤 삽입) // 정렬된 (텍스트위치, 컨트롤인덱스) 목록 - let mut sorted_ctrls: Vec<(usize, usize)> = ctrl_positions.iter().enumerate() + let mut sorted_ctrls: Vec<(usize, usize)> = ctrl_positions + .iter() + .enumerate() .map(|(ci, &pos)| (pos, ci)) .collect(); sorted_ctrls.sort_by_key(|(pos, _)| *pos); @@ -91,7 +100,10 @@ pub(crate) fn text_to_logical_offset(para: &Paragraph, text_offset: usize) -> us // text_offset 이전(미만)에 있는 컨트롤 수를 더함 // pos < text_offset: 해당 컨트롤은 text_offset 앞에 위치 // pos == text_offset: 컨트롤과 텍스트가 같은 위치 → 컨트롤이 먼저 - let before_count = ctrl_positions.iter().filter(|&&pos| pos < text_offset).count(); + let before_count = ctrl_positions + .iter() + .filter(|&&pos| pos < text_offset) + .count(); text_offset + before_count } @@ -118,9 +130,9 @@ pub(crate) fn find_control_text_positions(para: &Paragraph) -> Vec { let mut positions = Vec::with_capacity(total_controls); for ctrl in ¶.controls { positions.push(pos); - if matches!(ctrl, - Control::Shape(_) | Control::Table(_) | - Control::Picture(_) | Control::Equation(_) + if matches!( + ctrl, + Control::Shape(_) | Control::Table(_) | Control::Picture(_) | Control::Equation(_) ) { pos += 1; } @@ -135,21 +147,31 @@ pub(crate) fn find_control_text_positions(para: &Paragraph) -> Vec { let gap_before = offsets[0] as usize; let n_ctrls_before = gap_before / 8; for _ in 0..n_ctrls_before { - if positions.len() >= total_controls { break; } + if positions.len() >= total_controls { + break; + } positions.push(0); } // 연속된 문자 사이의 갭 for i in 0..offsets.len().saturating_sub(1) { - if positions.len() >= total_controls { break; } + if positions.len() >= total_controls { + break; + } let current_off = offsets[i] as usize; let next_off = offsets[i + 1] as usize; - let char_width = if chars.get(i).map_or(false, |&c| c as u32 > 0xFFFF) { 2 } else { 1 }; + let char_width = if chars.get(i).map_or(false, |&c| c as u32 > 0xFFFF) { + 2 + } else { + 1 + }; if next_off > current_off + char_width { let gap = next_off - current_off - char_width; let n_ctrls = gap / 8; for _ in 0..n_ctrls { - if positions.len() >= total_controls { break; } + if positions.len() >= total_controls { + break; + } positions.push(i + 1); // 현재 문자 다음에 삽입 } } @@ -164,7 +186,9 @@ pub(crate) fn find_control_text_positions(para: &Paragraph) -> Vec { } /// ShapeObject에서 TextBox를 추출하는 헬퍼 -pub(crate) fn get_textbox_from_shape(shape: &crate::model::shape::ShapeObject) -> Option<&crate::model::shape::TextBox> { +pub(crate) fn get_textbox_from_shape( + shape: &crate::model::shape::ShapeObject, +) -> Option<&crate::model::shape::TextBox> { use crate::model::shape::ShapeObject; let drawing = match shape { ShapeObject::Rectangle(s) => &s.drawing, @@ -177,7 +201,9 @@ pub(crate) fn get_textbox_from_shape(shape: &crate::model::shape::ShapeObject) - } /// ShapeObject에서 TextBox 가변 참조를 추출하는 헬퍼 -pub(crate) fn get_textbox_from_shape_mut(shape: &mut crate::model::shape::ShapeObject) -> Option<&mut crate::model::shape::TextBox> { +pub(crate) fn get_textbox_from_shape_mut( + shape: &mut crate::model::shape::ShapeObject, +) -> Option<&mut crate::model::shape::TextBox> { use crate::model::shape::ShapeObject; let drawing = match shape { ShapeObject::Rectangle(s) => &mut s.drawing, @@ -200,30 +226,29 @@ pub(crate) fn navigate_path_to_table<'a>( ) -> Result<&'a mut crate::model::table::Table, HwpError> { match path { [PathSegment::Paragraph(pi), PathSegment::Control(ci)] => { - let para = paragraphs.get_mut(*pi).ok_or_else(|| { - HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", pi)) - })?; + let para = paragraphs + .get_mut(*pi) + .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", pi)))?; match para.controls.get_mut(*ci) { Some(Control::Table(t)) => Ok(t), Some(_) => Err(HwpError::RenderError( "지정된 컨트롤이 표가 아닙니다".to_string(), )), None => Err(HwpError::RenderError(format!( - "컨트롤 인덱스 {} 범위 초과", ci + "컨트롤 인덱스 {} 범위 초과", + ci ))), } } [PathSegment::Paragraph(pi), PathSegment::Control(ci), PathSegment::Cell(row, col), rest @ ..] => { - let para = paragraphs.get_mut(*pi).ok_or_else(|| { - HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", pi)) - })?; + let para = paragraphs + .get_mut(*pi) + .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", pi)))?; match para.controls.get_mut(*ci) { Some(Control::Table(t)) => { let cell = t.cell_at_mut(*row, *col).ok_or_else(|| { - HwpError::RenderError(format!( - "셀({},{}) 접근 실패", row, col - )) + HwpError::RenderError(format!("셀({},{}) 접근 실패", row, col)) })?; navigate_path_to_table(&mut cell.paragraphs, rest) } @@ -231,7 +256,8 @@ pub(crate) fn navigate_path_to_table<'a>( "지정된 컨트롤이 표가 아닙니다".to_string(), )), None => Err(HwpError::RenderError(format!( - "컨트롤 인덱스 {} 범위 초과", ci + "컨트롤 인덱스 {} 범위 초과", + ci ))), } } @@ -241,7 +267,10 @@ pub(crate) fn navigate_path_to_table<'a>( /// UTF-16 위치를 char 인덱스로 변환한다. pub(crate) fn utf16_pos_to_char_idx(char_offsets: &[u32], utf16_pos: u32) -> usize { - char_offsets.iter().position(|&off| off >= utf16_pos).unwrap_or(char_offsets.len()) + char_offsets + .iter() + .position(|&off| off >= utf16_pos) + .unwrap_or(char_offsets.len()) } /// 줄 정보 결과 (구조체 반환용) @@ -254,7 +283,9 @@ pub(crate) struct LineInfoResult { /// 문단이 표 컨트롤을 포함하면 해당 control_idx를 반환한다. pub(crate) fn has_table_control(para: &Paragraph) -> Option { - para.controls.iter().position(|c| matches!(c, Control::Table(_))) + para.controls + .iter() + .position(|c| matches!(c, Control::Table(_))) } /// COLORREF (BGR) → CSS 색상 문자열 변환 (클립보드용). @@ -291,14 +322,30 @@ pub(crate) fn parse_char_shape_mods(json: &str) -> crate::model::style::CharShap use crate::model::style::{CharShapeMods, UnderlineType}; let mut mods = CharShapeMods::default(); - if let Some(v) = json_bool(json, "bold") { mods.bold = Some(v); } - if let Some(v) = json_bool(json, "italic") { mods.italic = Some(v); } - if let Some(v) = json_bool(json, "underline") { mods.underline = Some(v); } - if let Some(v) = json_bool(json, "strikethrough") { mods.strikethrough = Some(v); } - if let Some(v) = json_i32(json, "fontSize") { mods.base_size = Some(v); } - if let Some(v) = json_u16(json, "fontId") { mods.font_id = Some(v); } - if let Some(v) = json_color(json, "textColor") { mods.text_color = Some(v); } - if let Some(v) = json_color(json, "shadeColor") { mods.shade_color = Some(v); } + if let Some(v) = json_bool(json, "bold") { + mods.bold = Some(v); + } + if let Some(v) = json_bool(json, "italic") { + mods.italic = Some(v); + } + if let Some(v) = json_bool(json, "underline") { + mods.underline = Some(v); + } + if let Some(v) = json_bool(json, "strikethrough") { + mods.strikethrough = Some(v); + } + if let Some(v) = json_i32(json, "fontSize") { + mods.base_size = Some(v); + } + if let Some(v) = json_u16(json, "fontId") { + mods.font_id = Some(v); + } + if let Some(v) = json_color(json, "textColor") { + mods.text_color = Some(v); + } + if let Some(v) = json_color(json, "shadeColor") { + mods.shade_color = Some(v); + } // 확장 속성 if let Some(v) = json_str(json, "underlineType") { mods.underline_type = Some(match v.as_str() { @@ -307,28 +354,68 @@ pub(crate) fn parse_char_shape_mods(json: &str) -> crate::model::style::CharShap _ => UnderlineType::None, }); } - if let Some(v) = json_color(json, "underlineColor") { mods.underline_color = Some(v); } - if let Some(v) = json_i32(json, "outlineType") { mods.outline_type = Some(v as u8); } - if let Some(v) = json_i32(json, "shadowType") { mods.shadow_type = Some(v as u8); } - if let Some(v) = json_color(json, "shadowColor") { mods.shadow_color = Some(v); } - if let Some(v) = json_i32(json, "shadowOffsetX") { mods.shadow_offset_x = Some(v as i8); } - if let Some(v) = json_i32(json, "shadowOffsetY") { mods.shadow_offset_y = Some(v as i8); } - if let Some(v) = json_color(json, "strikeColor") { mods.strike_color = Some(v); } - if let Some(v) = json_bool(json, "subscript") { mods.subscript = Some(v); } - if let Some(v) = json_bool(json, "superscript") { mods.superscript = Some(v); } - if let Some(v) = json_bool(json, "emboss") { mods.emboss = Some(v); } - if let Some(v) = json_bool(json, "engrave") { mods.engrave = Some(v); } + if let Some(v) = json_color(json, "underlineColor") { + mods.underline_color = Some(v); + } + if let Some(v) = json_i32(json, "outlineType") { + mods.outline_type = Some(v as u8); + } + if let Some(v) = json_i32(json, "shadowType") { + mods.shadow_type = Some(v as u8); + } + if let Some(v) = json_color(json, "shadowColor") { + mods.shadow_color = Some(v); + } + if let Some(v) = json_i32(json, "shadowOffsetX") { + mods.shadow_offset_x = Some(v as i8); + } + if let Some(v) = json_i32(json, "shadowOffsetY") { + mods.shadow_offset_y = Some(v as i8); + } + if let Some(v) = json_color(json, "strikeColor") { + mods.strike_color = Some(v); + } + if let Some(v) = json_bool(json, "subscript") { + mods.subscript = Some(v); + } + if let Some(v) = json_bool(json, "superscript") { + mods.superscript = Some(v); + } + if let Some(v) = json_bool(json, "emboss") { + mods.emboss = Some(v); + } + if let Some(v) = json_bool(json, "engrave") { + mods.engrave = Some(v); + } // 강조점/밑줄모양/취소선모양/커닝 - if let Some(v) = json_i32(json, "emphasisDot") { mods.emphasis_dot = Some(v as u8); } - if let Some(v) = json_i32(json, "underlineShape") { mods.underline_shape = Some(v as u8); } - if let Some(v) = json_i32(json, "strikeShape") { mods.strike_shape = Some(v as u8); } - if let Some(v) = json_bool(json, "kerning") { mods.kerning = Some(v); } + if let Some(v) = json_i32(json, "emphasisDot") { + mods.emphasis_dot = Some(v as u8); + } + if let Some(v) = json_i32(json, "underlineShape") { + mods.underline_shape = Some(v as u8); + } + if let Some(v) = json_i32(json, "strikeShape") { + mods.strike_shape = Some(v as u8); + } + if let Some(v) = json_bool(json, "kerning") { + mods.kerning = Some(v); + } // 언어별 배열 - if let Some(arr) = json_u16_array(json, "fontIds") { mods.font_ids = Some(arr); } - if let Some(arr) = json_u8_array(json, "ratios") { mods.ratios = Some(arr); } - if let Some(arr) = json_i8_array(json, "spacings") { mods.spacings = Some(arr); } - if let Some(arr) = json_u8_array(json, "relativeSizes") { mods.relative_sizes = Some(arr); } - if let Some(arr) = json_i8_array(json, "charOffsets") { mods.char_offsets = Some(arr); } + if let Some(arr) = json_u16_array(json, "fontIds") { + mods.font_ids = Some(arr); + } + if let Some(arr) = json_u8_array(json, "ratios") { + mods.ratios = Some(arr); + } + if let Some(arr) = json_i8_array(json, "spacings") { + mods.spacings = Some(arr); + } + if let Some(arr) = json_u8_array(json, "relativeSizes") { + mods.relative_sizes = Some(arr); + } + if let Some(arr) = json_i8_array(json, "charOffsets") { + mods.char_offsets = Some(arr); + } mods } @@ -339,11 +426,14 @@ pub(crate) fn json_u8_array(json: &str, key: &str) -> Option<[u8; 7]> { let pos = json.find(&pattern)?; let rest = &json[pos + pattern.len()..]; let end = rest.find(']')?; - let nums: Vec = rest[..end].split(',') + let nums: Vec = rest[..end] + .split(',') .filter_map(|s| s.trim().parse().ok()) .collect(); if nums.len() == 7 { - Some([nums[0], nums[1], nums[2], nums[3], nums[4], nums[5], nums[6]]) + Some([ + nums[0], nums[1], nums[2], nums[3], nums[4], nums[5], nums[6], + ]) } else { None } @@ -355,11 +445,14 @@ pub(crate) fn json_i8_array(json: &str, key: &str) -> Option<[i8; 7]> { let pos = json.find(&pattern)?; let rest = &json[pos + pattern.len()..]; let end = rest.find(']')?; - let nums: Vec = rest[..end].split(',') + let nums: Vec = rest[..end] + .split(',') .filter_map(|s| s.trim().parse().ok()) .collect(); if nums.len() == 7 { - Some([nums[0], nums[1], nums[2], nums[3], nums[4], nums[5], nums[6]]) + Some([ + nums[0], nums[1], nums[2], nums[3], nums[4], nums[5], nums[6], + ]) } else { None } @@ -371,11 +464,14 @@ pub(crate) fn json_u16_array(json: &str, key: &str) -> Option<[u16; 7]> { let pos = json.find(&pattern)?; let rest = &json[pos + pattern.len()..]; let end = rest.find(']')?; - let nums: Vec = rest[..end].split(',') + let nums: Vec = rest[..end] + .split(',') .filter_map(|s| s.trim().parse().ok()) .collect(); if nums.len() == 7 { - Some([nums[0], nums[1], nums[2], nums[3], nums[4], nums[5], nums[6]]) + Some([ + nums[0], nums[1], nums[2], nums[3], nums[4], nums[5], nums[6], + ]) } else { None } @@ -383,8 +479,10 @@ pub(crate) fn json_u16_array(json: &str, key: &str) -> Option<[u16; 7]> { /// JSON에 border/fill 관련 키가 포함되어 있는지 확인한다. pub(crate) fn json_has_border_keys(json: &str) -> bool { - json.contains("\"borderLeft\"") || json.contains("\"borderRight\"") - || json.contains("\"borderTop\"") || json.contains("\"borderBottom\"") + json.contains("\"borderLeft\"") + || json.contains("\"borderRight\"") + || json.contains("\"borderTop\"") + || json.contains("\"borderBottom\"") || json.contains("\"fillType\"") } @@ -393,7 +491,7 @@ pub(crate) fn json_object(json: &str, key: &str) -> Option { let pattern = format!("\"{}\":{{", key); let pos = json.find(&pattern)?; let rest = &json[pos + pattern.len() - 1..]; // '{' 포함 - // 중괄호 매칭 + // 중괄호 매칭 let mut depth = 0; let mut end = 0; for (i, ch) in rest.char_indices() { @@ -409,12 +507,16 @@ pub(crate) fn json_object(json: &str, key: &str) -> Option { _ => {} } } - if end > 0 { Some(rest[..end].to_string()) } else { None } + if end > 0 { + Some(rest[..end].to_string()) + } else { + None + } } /// JSON 문자열에서 ParaShapeMods를 파싱한다. pub(crate) fn parse_para_shape_mods(json: &str) -> crate::model::style::ParaShapeMods { - use crate::model::style::{ParaShapeMods, Alignment, LineSpacingType, HeadType}; + use crate::model::style::{Alignment, HeadType, LineSpacingType, ParaShapeMods}; let mut mods = ParaShapeMods::default(); if let Some(v) = json_str(json, "alignment") { @@ -427,7 +529,9 @@ pub(crate) fn parse_para_shape_mods(json: &str) -> crate::model::style::ParaShap _ => Alignment::Justify, }); } - if let Some(v) = json_i32(json, "lineSpacing") { mods.line_spacing = Some(v); } + if let Some(v) = json_i32(json, "lineSpacing") { + mods.line_spacing = Some(v); + } if let Some(v) = json_str(json, "lineSpacingType") { mods.line_spacing_type = Some(match v.as_str() { "Fixed" => LineSpacingType::Fixed, @@ -436,11 +540,21 @@ pub(crate) fn parse_para_shape_mods(json: &str) -> crate::model::style::ParaShap _ => LineSpacingType::Percent, }); } - if let Some(v) = json_i32(json, "indent") { mods.indent = Some(v); } - if let Some(v) = json_i32(json, "marginLeft") { mods.margin_left = Some(v); } - if let Some(v) = json_i32(json, "marginRight") { mods.margin_right = Some(v); } - if let Some(v) = json_i32(json, "spacingBefore") { mods.spacing_before = Some(v); } - if let Some(v) = json_i32(json, "spacingAfter") { mods.spacing_after = Some(v); } + if let Some(v) = json_i32(json, "indent") { + mods.indent = Some(v); + } + if let Some(v) = json_i32(json, "marginLeft") { + mods.margin_left = Some(v); + } + if let Some(v) = json_i32(json, "marginRight") { + mods.margin_right = Some(v); + } + if let Some(v) = json_i32(json, "spacingBefore") { + mods.spacing_before = Some(v); + } + if let Some(v) = json_i32(json, "spacingAfter") { + mods.spacing_after = Some(v); + } // 확장 탭 속성 if let Some(v) = json_str(json, "headType") { mods.head_type = Some(match v.as_str() { @@ -450,26 +564,54 @@ pub(crate) fn parse_para_shape_mods(json: &str) -> crate::model::style::ParaShap _ => HeadType::None, }); } - if let Some(v) = json_i32(json, "paraLevel") { mods.para_level = Some(v as u8); } - if let Some(v) = json_i32(json, "numberingId") { mods.numbering_id = Some(v as u16); } - if let Some(v) = json_bool(json, "widowOrphan") { mods.widow_orphan = Some(v); } - if let Some(v) = json_bool(json, "keepWithNext") { mods.keep_with_next = Some(v); } - if let Some(v) = json_bool(json, "keepLines") { mods.keep_lines = Some(v); } - if let Some(v) = json_bool(json, "pageBreakBefore") { mods.page_break_before = Some(v); } - if let Some(v) = json_bool(json, "fontLineHeight") { mods.font_line_height = Some(v); } - if let Some(v) = json_bool(json, "singleLine") { mods.single_line = Some(v); } - if let Some(v) = json_bool(json, "autoSpaceKrEn") { mods.auto_space_kr_en = Some(v); } - if let Some(v) = json_bool(json, "autoSpaceKrNum") { mods.auto_space_kr_num = Some(v); } - if let Some(v) = json_i32(json, "verticalAlign") { mods.vertical_align = Some(v as u8); } - if let Some(v) = json_i32(json, "englishBreakUnit") { mods.english_break_unit = Some(v as u8); } - if let Some(v) = json_i32(json, "koreanBreakUnit") { mods.korean_break_unit = Some(v as u8); } + if let Some(v) = json_i32(json, "paraLevel") { + mods.para_level = Some(v as u8); + } + if let Some(v) = json_i32(json, "numberingId") { + mods.numbering_id = Some(v as u16); + } + if let Some(v) = json_bool(json, "widowOrphan") { + mods.widow_orphan = Some(v); + } + if let Some(v) = json_bool(json, "keepWithNext") { + mods.keep_with_next = Some(v); + } + if let Some(v) = json_bool(json, "keepLines") { + mods.keep_lines = Some(v); + } + if let Some(v) = json_bool(json, "pageBreakBefore") { + mods.page_break_before = Some(v); + } + if let Some(v) = json_bool(json, "fontLineHeight") { + mods.font_line_height = Some(v); + } + if let Some(v) = json_bool(json, "singleLine") { + mods.single_line = Some(v); + } + if let Some(v) = json_bool(json, "autoSpaceKrEn") { + mods.auto_space_kr_en = Some(v); + } + if let Some(v) = json_bool(json, "autoSpaceKrNum") { + mods.auto_space_kr_num = Some(v); + } + if let Some(v) = json_i32(json, "verticalAlign") { + mods.vertical_align = Some(v as u8); + } + if let Some(v) = json_i32(json, "englishBreakUnit") { + mods.english_break_unit = Some(v as u8); + } + if let Some(v) = json_i32(json, "koreanBreakUnit") { + mods.korean_break_unit = Some(v as u8); + } mods } /// JSON에 탭 설정 관련 키가 포함되어 있는지 확인한다. pub(crate) fn json_has_tab_keys(json: &str) -> bool { - json.contains("\"tabStops\"") || json.contains("\"tabAutoLeft\"") || json.contains("\"tabAutoRight\"") + json.contains("\"tabStops\"") + || json.contains("\"tabAutoLeft\"") + || json.contains("\"tabAutoRight\"") } /// JSON에서 TabDef를 구성한다. 기존 TabDef를 기반으로 변경된 필드만 덮어쓴다. @@ -479,12 +621,21 @@ pub(crate) fn build_tab_def_from_json( tab_defs: &[crate::model::style::TabDef], ) -> crate::model::style::TabDef { use crate::model::style::TabDef; - let base = tab_defs.get(base_tab_id as usize).cloned().unwrap_or_default(); + let base = tab_defs + .get(base_tab_id as usize) + .cloned() + .unwrap_or_default(); let auto_left = json_bool(json, "tabAutoLeft").unwrap_or(base.auto_tab_left); let auto_right = json_bool(json, "tabAutoRight").unwrap_or(base.auto_tab_right); let tabs = parse_tab_stops_json(json).unwrap_or(base.tabs); let attr = (if auto_left { 1u32 } else { 0 }) | (if auto_right { 2u32 } else { 0 }); - TabDef { raw_data: None, attr, tabs, auto_tab_left: auto_left, auto_tab_right: auto_right } + TabDef { + raw_data: None, + attr, + tabs, + auto_tab_left: auto_left, + auto_tab_right: auto_right, + } } /// JSON "tabStops":[...] 배열에서 Vec을 파싱한다. @@ -506,10 +657,18 @@ pub(crate) fn parse_tab_stops_json(json: &str) -> Option Optio let rest = &json[start + pattern.len()..]; let end = rest.find(']')?; let arr_str = &rest[..end]; - let vals: Vec = arr_str.split(',') + let vals: Vec = arr_str + .split(',') .filter_map(|s| s.trim().parse::().ok()) .collect(); - if vals.len() == count { Some(vals) } else { None } + if vals.len() == count { + Some(vals) + } else { + None + } } /// 간단한 JSON boolean 파싱 @@ -533,9 +697,13 @@ pub(crate) fn json_bool(json: &str, key: &str) -> Option { let pos = json.find(&pattern)?; let rest = &json[pos + pattern.len()..]; let rest = rest.trim_start(); - if rest.starts_with("true") { Some(true) } - else if rest.starts_with("false") { Some(false) } - else { None } + if rest.starts_with("true") { + Some(true) + } else if rest.starts_with("false") { + Some(false) + } else { + None + } } /// 간단한 JSON i32 파싱 @@ -544,7 +712,9 @@ pub(crate) fn json_i32(json: &str, key: &str) -> Option { let pos = json.find(&pattern)?; let rest = &json[pos + pattern.len()..]; let rest = rest.trim_start(); - let end = rest.find(|c: char| !c.is_ascii_digit() && c != '-').unwrap_or(rest.len()); + let end = rest + .find(|c: char| !c.is_ascii_digit() && c != '-') + .unwrap_or(rest.len()); rest[..end].parse().ok() } @@ -570,7 +740,10 @@ pub(crate) fn json_str(json: &str, key: &str) -> Option { Some('t') => result.push('\t'), Some('\\') => result.push('\\'), Some('"') => result.push('"'), - Some(c) => { result.push('\\'); result.push(c); } + Some(c) => { + result.push('\\'); + result.push(c); + } None => return None, }, Some(c) => result.push(c), @@ -582,7 +755,9 @@ pub(crate) fn json_str(json: &str, key: &str) -> Option { /// CSS hex (#rrggbb) → HWP BGR (0x00BBGGRR) 변환 pub(crate) fn css_color_to_bgr(css: &str) -> Option { let hex = css.strip_prefix('#')?; - if hex.len() != 6 { return None; } + if hex.len() != 6 { + return None; + } let r = u32::from_str_radix(&hex[0..2], 16).ok()?; let g = u32::from_str_radix(&hex[2..4], 16).ok()?; let b = u32::from_str_radix(&hex[4..6], 16).ok()?; @@ -601,7 +776,9 @@ pub(crate) fn json_u32(json: &str, key: &str) -> Option { let pos = json.find(&pattern)?; let rest = &json[pos + pattern.len()..]; let rest = rest.trim_start(); - let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()); + let end = rest + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(rest.len()); rest[..end].parse().ok() } @@ -620,7 +797,9 @@ pub(crate) fn json_f64(json: &str, key: &str) -> Option { let pattern = format!("\"{}\":", key); let pos = json.find(&pattern)?; let rest = &json[pos + pattern.len()..]; - let num_str: String = rest.trim_start().chars() + let num_str: String = rest + .trim_start() + .chars() .take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-') .collect(); num_str.parse::().ok() @@ -629,13 +808,17 @@ pub(crate) fn json_f64(json: &str, key: &str) -> Option { /// JSON 필수 필드 usize 파싱 (없으면 에러) pub(crate) fn json_usize(json: &str, key: &str) -> Result { let pattern = format!("\"{}\":", key); - let pos = json.find(&pattern) + let pos = json + .find(&pattern) .ok_or_else(|| HwpError::RenderError(format!("JSON 필드 '{}' 없음", key)))?; let rest = &json[pos + pattern.len()..]; - let num_str: String = rest.trim_start().chars() + let num_str: String = rest + .trim_start() + .chars() .take_while(|c| c.is_ascii_digit()) .collect(); - num_str.parse::() + num_str + .parse::() .map_err(|_| HwpError::RenderError(format!("JSON 필드 '{}' 값 파싱 실패", key))) } @@ -689,7 +872,9 @@ pub(crate) fn color_ref_to_css(color: crate::model::ColorRef) -> String { /// chars 배열에서 pos부터 target 문자를 찾아 인덱스를 반환한다. pub(crate) fn find_char(chars: &[char], start: usize, target: char) -> usize { for i in start..chars.len() { - if chars[i] == target { return i; } + if chars[i] == target { + return i; + } } chars.len() } @@ -697,8 +882,12 @@ pub(crate) fn find_char(chars: &[char], start: usize, target: char) -> usize { /// HTML에서 닫는 태그의 다음 위치를 찾는다 (중첩 고려). /// ASCII 대소문자 무시 바이트 비교 pub(crate) fn ascii_starts_with_ci(haystack: &[u8], needle: &[u8]) -> bool { - if haystack.len() < needle.len() { return false; } - haystack.iter().zip(needle.iter()) + if haystack.len() < needle.len() { + return false; + } + haystack + .iter() + .zip(needle.iter()) .all(|(h, n)| h.to_ascii_lowercase() == *n) } @@ -849,7 +1038,10 @@ pub(crate) fn css_color_to_hwp_bgr(css: &str) -> Option { } } else if css.starts_with("rgb(") || css.starts_with("rgb (") { // rgb(r, g, b) 형식 - let inner = css.trim_start_matches("rgb").trim_start_matches('(').trim_end_matches(')'); + let inner = css + .trim_start_matches("rgb") + .trim_start_matches('(') + .trim_end_matches(')'); let parts: Vec<&str> = inner.split(',').collect(); if parts.len() >= 3 { let r: u32 = parts[0].trim().parse().ok()?; @@ -891,25 +1083,32 @@ pub(crate) fn html_strip_tags(html: &str) -> String { let mut result = String::new(); let mut in_tag = false; for c in html.chars() { - if c == '<' { in_tag = true; continue; } - if c == '>' { in_tag = false; continue; } - if !in_tag { result.push(c); } + if c == '<' { + in_tag = true; + continue; + } + if c == '>' { + in_tag = false; + continue; + } + if !in_tag { + result.push(c); + } } result } /// HTML을 플레인 텍스트로 변환한다 (태그 제거 + 엔티티 디코딩). pub(crate) fn html_to_plain_text(html: &str) -> String { - decode_html_entities(&html_strip_tags(html)).trim().to_string() + decode_html_entities(&html_strip_tags(html)) + .trim() + .to_string() } /// HTML 태그에서 숫자 속성값을 추출한다. pub(crate) fn parse_html_attr_f64(tag: &str, attr: &str) -> Option { // width="200" 또는 width='200' 형식 - let patterns = [ - format!("{}=\"", attr), - format!("{}='", attr), - ]; + let patterns = [format!("{}=\"", attr), format!("{}='", attr)]; for pat in &patterns { if let Some(start) = tag.to_lowercase().find(&pat.to_lowercase()) { let after = &tag[start + pat.len()..]; @@ -936,15 +1135,34 @@ pub(crate) fn parse_css_dimension_pt(css: &str, property: &str) -> f64 { if let Some(val) = parse_css_value(css, property) { let val = val.trim(); if val.ends_with("pt") { - val.trim_end_matches("pt").trim().parse::().unwrap_or(0.0) + val.trim_end_matches("pt") + .trim() + .parse::() + .unwrap_or(0.0) } else if val.ends_with("px") { - val.trim_end_matches("px").trim().parse::().unwrap_or(0.0) * 0.75 + val.trim_end_matches("px") + .trim() + .parse::() + .unwrap_or(0.0) + * 0.75 } else if val.ends_with("cm") { - val.trim_end_matches("cm").trim().parse::().unwrap_or(0.0) * 28.3465 + val.trim_end_matches("cm") + .trim() + .parse::() + .unwrap_or(0.0) + * 28.3465 } else if val.ends_with("mm") { - val.trim_end_matches("mm").trim().parse::().unwrap_or(0.0) * 2.83465 + val.trim_end_matches("mm") + .trim() + .parse::() + .unwrap_or(0.0) + * 2.83465 } else if val.ends_with("in") { - val.trim_end_matches("in").trim().parse::().unwrap_or(0.0) * 72.0 + val.trim_end_matches("in") + .trim() + .parse::() + .unwrap_or(0.0) + * 72.0 } else if val.ends_with('%') { 0.0 // 백분율은 무시 } else { @@ -962,24 +1180,27 @@ pub(crate) fn parse_css_padding_pt(css: &str) -> [f64; 4] { // 축약형 padding: "1.41pt 5.10pt" 또는 "5pt" 또는 "5pt 10pt 5pt 10pt" if let Some(val) = parse_css_value(css, "padding") { - let parts: Vec = val.split_whitespace() + let parts: Vec = val + .split_whitespace() .map(|p| parse_single_dimension_pt(p)) .collect(); match parts.len() { - 1 => { result = [parts[0]; 4]; }, + 1 => { + result = [parts[0]; 4]; + } 2 => { // top/bottom, left/right result = [parts[1], parts[1], parts[0], parts[0]]; - }, + } 3 => { // top, left/right, bottom result = [parts[1], parts[1], parts[0], parts[2]]; - }, + } 4 => { // top, right, bottom, left result = [parts[3], parts[1], parts[0], parts[2]]; - }, - _ => {}, + } + _ => {} } } @@ -1004,15 +1225,34 @@ pub(crate) fn parse_css_padding_pt(css: &str) -> [f64; 4] { pub(crate) fn parse_single_dimension_pt(s: &str) -> f64 { let s = s.trim(); if s.ends_with("pt") { - s.trim_end_matches("pt").trim().parse::().unwrap_or(0.0) + s.trim_end_matches("pt") + .trim() + .parse::() + .unwrap_or(0.0) } else if s.ends_with("px") { - s.trim_end_matches("px").trim().parse::().unwrap_or(0.0) * 0.75 + s.trim_end_matches("px") + .trim() + .parse::() + .unwrap_or(0.0) + * 0.75 } else if s.ends_with("cm") { - s.trim_end_matches("cm").trim().parse::().unwrap_or(0.0) * 28.3465 + s.trim_end_matches("cm") + .trim() + .parse::() + .unwrap_or(0.0) + * 28.3465 } else if s.ends_with("mm") { - s.trim_end_matches("mm").trim().parse::().unwrap_or(0.0) * 2.83465 + s.trim_end_matches("mm") + .trim() + .parse::() + .unwrap_or(0.0) + * 2.83465 } else if s.ends_with("in") { - s.trim_end_matches("in").trim().parse::().unwrap_or(0.0) * 72.0 + s.trim_end_matches("in") + .trim() + .parse::() + .unwrap_or(0.0) + * 72.0 } else { s.parse::().unwrap_or(0.0) } @@ -1035,13 +1275,31 @@ pub(crate) fn parse_css_border_shorthand(val: &str) -> (f64, u32, u8) { let p = part.trim(); // 스타일 키워드 match p { - "solid" => { style = 1; continue; }, - "dashed" => { style = 2; continue; }, - "dotted" => { style = 3; continue; }, - "double" => { style = 4; continue; }, - "none" => { style = 0; continue; }, - "hidden" => { style = 0; continue; }, - _ => {}, + "solid" => { + style = 1; + continue; + } + "dashed" => { + style = 2; + continue; + } + "dotted" => { + style = 3; + continue; + } + "double" => { + style = 4; + continue; + } + "none" => { + style = 0; + continue; + } + "hidden" => { + style = 0; + continue; + } + _ => {} } // 색상 (#hex 또는 rgb()) if p.starts_with('#') || p.starts_with("rgb") { @@ -1064,29 +1322,47 @@ pub(crate) fn parse_css_border_shorthand(val: &str) -> (f64, u32, u8) { /// HWP 스펙: width 값이 선 굵기 인덱스 (0: 0.1mm, 1: 0.12mm, 2: 0.15mm, 3: 0.2mm, 4: 0.25mm, 5: 0.3mm, 6: 0.4mm, 7: 0.5mm) pub(crate) fn css_border_width_to_hwp(pt: f64) -> u8 { let mm = pt * 0.3528; // 1pt ≈ 0.3528mm - if mm < 0.11 { 0 } - else if mm < 0.14 { 1 } - else if mm < 0.18 { 2 } - else if mm < 0.23 { 3 } - else if mm < 0.28 { 4 } - else if mm < 0.35 { 5 } - else if mm < 0.45 { 6 } - else { 7 } + if mm < 0.11 { + 0 + } else if mm < 0.14 { + 1 + } else if mm < 0.18 { + 2 + } else if mm < 0.23 { + 3 + } else if mm < 0.28 { + 4 + } else if mm < 0.35 { + 5 + } else if mm < 0.45 { + 6 + } else { + 7 + } } /// BorderLineType을 u8 값으로 변환한다. pub(crate) fn border_line_type_to_u8_val(lt: crate::model::style::BorderLineType) -> u8 { use crate::model::style::BorderLineType; match lt { - BorderLineType::None => 0, BorderLineType::Solid => 1, - BorderLineType::Dash => 2, BorderLineType::Dot => 3, - BorderLineType::DashDot => 4, BorderLineType::DashDotDot => 5, - BorderLineType::LongDash => 6, BorderLineType::Circle => 7, - BorderLineType::Double => 8, BorderLineType::ThinThickDouble => 9, - BorderLineType::ThickThinDouble => 10, BorderLineType::ThinThickThinTriple => 11, - BorderLineType::Wave => 12, BorderLineType::DoubleWave => 13, - BorderLineType::Thick3D => 14, BorderLineType::Thick3DReverse => 15, - BorderLineType::Thin3D => 16, BorderLineType::Thin3DReverse => 17, + BorderLineType::None => 0, + BorderLineType::Solid => 1, + BorderLineType::Dash => 2, + BorderLineType::Dot => 3, + BorderLineType::DashDot => 4, + BorderLineType::DashDotDot => 5, + BorderLineType::LongDash => 6, + BorderLineType::Circle => 7, + BorderLineType::Double => 8, + BorderLineType::ThinThickDouble => 9, + BorderLineType::ThickThinDouble => 10, + BorderLineType::ThinThickThinTriple => 11, + BorderLineType::Wave => 12, + BorderLineType::DoubleWave => 13, + BorderLineType::Thick3D => 14, + BorderLineType::Thick3DReverse => 15, + BorderLineType::Thin3D => 16, + BorderLineType::Thin3DReverse => 17, } } @@ -1094,29 +1370,51 @@ pub(crate) fn border_line_type_to_u8_val(lt: crate::model::style::BorderLineType pub(crate) fn u8_to_border_line_type(v: u8) -> crate::model::style::BorderLineType { use crate::model::style::BorderLineType; match v { - 0 => BorderLineType::None, 1 => BorderLineType::Solid, - 2 => BorderLineType::Dash, 3 => BorderLineType::Dot, - 4 => BorderLineType::DashDot, 5 => BorderLineType::DashDotDot, - 6 => BorderLineType::LongDash, 7 => BorderLineType::Circle, - 8 => BorderLineType::Double, 9 => BorderLineType::ThinThickDouble, - 10 => BorderLineType::ThickThinDouble, 11 => BorderLineType::ThinThickThinTriple, - 12 => BorderLineType::Wave, 13 => BorderLineType::DoubleWave, - 14 => BorderLineType::Thick3D, 15 => BorderLineType::Thick3DReverse, - 16 => BorderLineType::Thin3D, 17 => BorderLineType::Thin3DReverse, + 0 => BorderLineType::None, + 1 => BorderLineType::Solid, + 2 => BorderLineType::Dash, + 3 => BorderLineType::Dot, + 4 => BorderLineType::DashDot, + 5 => BorderLineType::DashDotDot, + 6 => BorderLineType::LongDash, + 7 => BorderLineType::Circle, + 8 => BorderLineType::Double, + 9 => BorderLineType::ThinThickDouble, + 10 => BorderLineType::ThickThinDouble, + 11 => BorderLineType::ThinThickThinTriple, + 12 => BorderLineType::Wave, + 13 => BorderLineType::DoubleWave, + 14 => BorderLineType::Thick3D, + 15 => BorderLineType::Thick3DReverse, + 16 => BorderLineType::Thin3D, + 17 => BorderLineType::Thin3DReverse, _ => BorderLineType::None, } } /// 두 BorderFill이 동일한지 비교한다. -pub(crate) fn border_fills_equal(a: &crate::model::style::BorderFill, b: &crate::model::style::BorderFill) -> bool { - if a.attr != b.attr { return false; } +pub(crate) fn border_fills_equal( + a: &crate::model::style::BorderFill, + b: &crate::model::style::BorderFill, +) -> bool { + if a.attr != b.attr { + return false; + } for i in 0..4 { - if a.borders[i].line_type != b.borders[i].line_type { return false; } - if a.borders[i].width != b.borders[i].width { return false; } - if a.borders[i].color != b.borders[i].color { return false; } + if a.borders[i].line_type != b.borders[i].line_type { + return false; + } + if a.borders[i].width != b.borders[i].width { + return false; + } + if a.borders[i].color != b.borders[i].color { + return false; + } } // fill 비교 (fill_type + solid color) - if a.fill.fill_type != b.fill.fill_type { return false; } + if a.fill.fill_type != b.fill.fill_type { + return false; + } match (&a.fill.solid, &b.fill.solid) { (Some(sa), Some(sb)) => sa.background_color == sb.background_color, (None, None) => true, diff --git a/src/document_core/html_table_import.rs b/src/document_core/html_table_import.rs index a43ba24d..4bf8a334 100644 --- a/src/document_core/html_table_import.rs +++ b/src/document_core/html_table_import.rs @@ -1,16 +1,16 @@ //! HTML 표 파싱 + BorderFill 생성 + 이미지 파싱 관련 native 메서드 -use crate::model::control::Control; -use crate::model::paragraph::Paragraph; +use super::helpers::*; use crate::document_core::DocumentCore; use crate::error::HwpError; -use super::helpers::*; +use crate::model::control::Control; +use crate::model::paragraph::Paragraph; use crate::renderer::style_resolver::resolve_styles; impl DocumentCore { pub(crate) fn parse_table_html(&mut self, paragraphs: &mut Vec, table_html: &str) { - use crate::model::table::{Table, Cell, TablePageBreak}; use crate::model::control::Control; + use crate::model::table::{Cell, Table, TablePageBreak}; // --- 1. HTML 파싱: 행/셀 구조 추출 --- let table_lower = table_html.to_lowercase(); @@ -20,10 +20,10 @@ impl DocumentCore { row_span: u16, width_pt: f64, height_pt: f64, - padding_pt: [f64; 4], // left, right, top, bottom - border_widths_pt: [f64; 4], // left, right, top, bottom - border_colors: [u32; 4], // BGR - border_styles: [u8; 4], // 0=none, 1=solid, 2=dashed, 3=dotted, 4=double + padding_pt: [f64; 4], // left, right, top, bottom + border_widths_pt: [f64; 4], // left, right, top, bottom + border_colors: [u32; 4], // BGR + border_styles: [u8; 4], // 0=none, 1=solid, 2=dashed, 3=dotted, 4=double background_color: Option, // BGR content_html: String, is_header: bool, @@ -48,7 +48,13 @@ impl DocumentCore { let th_match = tr_inner_lower[td_pos..].find(" if a <= b { (a, false) } else { (b, true) }, + (Some(a), Some(b)) => { + if a <= b { + (a, false) + } else { + (b, true) + } + } (Some(a), None) => (a, false), (None, Some(b)) => (b, true), (None, None) => break, @@ -107,21 +113,23 @@ impl DocumentCore { .and_then(|v| css_color_to_hwp_bgr(&v)); // 수직 정렬 (0=미지정, 1=center, 2=bottom, 3=명시적 top) - let vertical_align = match parse_css_value(&css_lower, "vertical-align").as_deref() { - Some("middle") | Some("center") => 1u8, - Some("bottom") => 2u8, - Some("top") => 3u8, // 명시적 top - _ => 0u8, // 미지정 → Center (HWP 기본) - }; + let vertical_align = + match parse_css_value(&css_lower, "vertical-align").as_deref() { + Some("middle") | Some("center") => 1u8, + Some("bottom") => 2u8, + Some("top") => 3u8, // 명시적 top + _ => 0u8, // 미지정 → Center (HWP 기본) + }; // 셀 내용 HTML 추출 let content_start = cell_abs + gt + 1; let close_tag = format!("", tag_name); - let content_end = if let Some(close) = tr_inner_lower[content_start..].find(&close_tag) { - content_start + close - } else { - tr_inner.len() - }; + let content_end = + if let Some(close) = tr_inner_lower[content_start..].find(&close_tag) { + content_start + close + } else { + tr_inner.len() + }; let content_html = tr_inner[content_start..content_end].to_string(); row_cells.push(ParsedCell { @@ -161,7 +169,9 @@ impl DocumentCore { let mut max_cols: usize = 0; for row in &parsed_rows { let sum: usize = row.iter().map(|c| c.col_span as usize).sum(); - if sum > max_cols { max_cols = sum; } + if sum > max_cols { + max_cols = sum; + } } max_cols = max_cols.max(1); // rowspan 처리를 위한 점유 그리드 @@ -239,7 +249,9 @@ impl DocumentCore { } } for w in col_widths.iter_mut() { - if *w == 0 { *w = default_col_width; } + if *w == 0 { + *w = default_col_width; + } } // 행별 높이 @@ -256,7 +268,9 @@ impl DocumentCore { } } for h in row_heights.iter_mut() { - if *h == 0 { *h = default_row_height; } + if *h == 0 { + *h = default_row_height; + } } // --- 4. BorderFill 생성 및 Cell 구조체 조립 --- @@ -268,10 +282,20 @@ impl DocumentCore { // 셀 폭/높이 (병합 고려) let cell_width: u32 = (cp.col..cp.col + cp.col_span) - .map(|c| col_widths.get(c as usize).copied().unwrap_or(default_col_width)) + .map(|c| { + col_widths + .get(c as usize) + .copied() + .unwrap_or(default_col_width) + }) .sum(); let cell_height: u32 = (cp.row..cp.row + cp.row_span) - .map(|r| row_heights.get(r as usize).copied().unwrap_or(default_row_height)) + .map(|r| { + row_heights + .get(r as usize) + .copied() + .unwrap_or(default_row_height) + }) .sum(); // BorderFill 생성/재사용 @@ -285,10 +309,26 @@ impl DocumentCore { // 패딩 (pt → HWPUNIT16, CSS 미지정 시 기본 1.4mm ≈ 397 HWPUNIT) let default_pad: f64 = 141.0; // ~0.5mm HWPUNIT let padding = crate::model::Padding { - left: if pc.padding_pt[0] > 0.01 { (pc.padding_pt[0] * 100.0).round() as i16 } else { default_pad as i16 }, - right: if pc.padding_pt[1] > 0.01 { (pc.padding_pt[1] * 100.0).round() as i16 } else { default_pad as i16 }, - top: if pc.padding_pt[2] > 0.01 { (pc.padding_pt[2] * 100.0).round() as i16 } else { default_pad as i16 }, - bottom: if pc.padding_pt[3] > 0.01 { (pc.padding_pt[3] * 100.0).round() as i16 } else { default_pad as i16 }, + left: if pc.padding_pt[0] > 0.01 { + (pc.padding_pt[0] * 100.0).round() as i16 + } else { + default_pad as i16 + }, + right: if pc.padding_pt[1] > 0.01 { + (pc.padding_pt[1] * 100.0).round() as i16 + } else { + default_pad as i16 + }, + top: if pc.padding_pt[2] > 0.01 { + (pc.padding_pt[2] * 100.0).round() as i16 + } else { + default_pad as i16 + }, + bottom: if pc.padding_pt[3] > 0.01 { + (pc.padding_pt[3] * 100.0).round() as i16 + } else { + default_pad as i16 + }, }; // 셀 내용 파싱 @@ -300,7 +340,9 @@ impl DocumentCore { } else { let parsed = self.parse_html_to_paragraphs(&pc.content_html); if parsed.is_empty() - || parsed.iter().all(|p| p.text.trim().is_empty() && p.controls.is_empty()) + || parsed + .iter() + .all(|p| p.text.trim().is_empty() && p.controls.is_empty()) { vec![Paragraph::new_empty()] } else { @@ -316,7 +358,7 @@ impl DocumentCore { let mut cell_paragraphs = cell_paragraphs; for cp_para in &mut cell_paragraphs { cp_para.char_count_msb = true; // 셀 문단은 항상 MSB 설정 - // char_count에 문단끝 마커(+1) 포함 + // char_count에 문단끝 마커(+1) 포함 let text_chars = cp_para.text.chars().count() as u32; cp_para.char_count = text_chars + 1; @@ -326,11 +368,17 @@ impl DocumentCore { // DIFF-2: char_shapes가 비어있으면 기본 CharShapeRef 추가 // 모든 셀 문단은 최소 1개의 명시적 CharShapeRef를 가져야 함 if cp_para.char_shapes.is_empty() { - let base_cs_id = if !self.document.doc_info.char_shapes.is_empty() { 0u32 } else { 0u32 }; - cp_para.char_shapes.push(crate::model::paragraph::CharShapeRef { - start_pos: 0, - char_shape_id: base_cs_id, - }); + let base_cs_id = if !self.document.doc_info.char_shapes.is_empty() { + 0u32 + } else { + 0u32 + }; + cp_para + .char_shapes + .push(crate::model::paragraph::CharShapeRef { + start_pos: 0, + char_shape_id: base_cs_id, + }); } // raw_header_extra에 instance_id = 0x80000000 설정 @@ -348,8 +396,15 @@ impl DocumentCore { } // line_segs: 폰트 크기 기반 높이 계산 - let font_size = cp_para.char_shapes.first() - .and_then(|cs| self.document.doc_info.char_shapes.get(cs.char_shape_id as usize)) + let font_size = cp_para + .char_shapes + .first() + .and_then(|cs| { + self.document + .doc_info + .char_shapes + .get(cs.char_shape_id as usize) + }) .map(|cs| cs.base_size.max(400)) .unwrap_or(1000); let line_h = font_size; @@ -357,8 +412,7 @@ impl DocumentCore { let baseline = (font_size as f64 * 0.85) as i32; let spacing = (font_size as f64 * 0.6) as i32; // seg_width: 셀 폭에서 좌우 패딩을 뺀 텍스트 영역 폭 - let seg_w = (cell_width as i32) - - (padding.left as i32) - (padding.right as i32); + let seg_w = (cell_width as i32) - (padding.left as i32) - (padding.right as i32); // tag(flags): 0x00060000 = bit 17,18 (정상 HWP 셀 문단 패턴) let line_tag: u32 = 0x00060000; @@ -391,7 +445,9 @@ impl DocumentCore { } } - if pc.is_header { has_header_row = true; } + if pc.is_header { + has_header_row = true; + } // list_header_width_ref: is_header면 bit 2 설정 let lh_width_ref: u16 = if pc.is_header { 0x04 } else { 0 }; @@ -402,7 +458,7 @@ impl DocumentCore { 0 => crate::model::table::VerticalAlign::Center, // CSS 미지정 → Center (HWP 기본) 1 => crate::model::table::VerticalAlign::Center, 2 => crate::model::table::VerticalAlign::Bottom, - 3 => crate::model::table::VerticalAlign::Top, // 명시적 top + 3 => crate::model::table::VerticalAlign::Top, // 명시적 top _ => crate::model::table::VerticalAlign::Center, }; @@ -461,7 +517,9 @@ impl DocumentCore { h = h.wrapping_add(total_width); h = h.wrapping_add(total_height.wrapping_mul(0x1b)); h ^= cells.len() as u32 * 0x4b69; - if h == 0 { h = 0x7c154b69; } // 절대 0이 되지 않도록 + if h == 0 { + h = 0x7c154b69; + } // 절대 0이 되지 않도록 h }; raw_ctrl_data[28..32].copy_from_slice(&instance_id.to_le_bytes()); @@ -483,16 +541,32 @@ impl DocumentCore { }; // HTML
CSS에서 표 패딩 파싱 - let table_style = parse_inline_style( - &table_html[..table_html.find('>').unwrap_or(table_html.len()) + 1] - ).to_lowercase(); + let table_style = + parse_inline_style(&table_html[..table_html.find('>').unwrap_or(table_html.len()) + 1]) + .to_lowercase(); let table_padding_pt = parse_css_padding_pt(&table_style); // 기본값: L:510 R:510 T:141 B:141 (정상 HWP 파일 패턴) let table_padding = crate::model::Padding { - left: if table_padding_pt[0] > 0.01 { (table_padding_pt[0] * 100.0).round() as i16 } else { 510 }, - right: if table_padding_pt[1] > 0.01 { (table_padding_pt[1] * 100.0).round() as i16 } else { 510 }, - top: if table_padding_pt[2] > 0.01 { (table_padding_pt[2] * 100.0).round() as i16 } else { 141 }, - bottom: if table_padding_pt[3] > 0.01 { (table_padding_pt[3] * 100.0).round() as i16 } else { 141 }, + left: if table_padding_pt[0] > 0.01 { + (table_padding_pt[0] * 100.0).round() as i16 + } else { + 510 + }, + right: if table_padding_pt[1] > 0.01 { + (table_padding_pt[1] * 100.0).round() as i16 + } else { + 510 + }, + top: if table_padding_pt[2] > 0.01 { + (table_padding_pt[2] * 100.0).round() as i16 + } else { + 141 + }, + bottom: if table_padding_pt[3] > 0.01 { + (table_padding_pt[3] * 100.0).round() as i16 + } else { + 141 + }, }; // table.attr: 기존 문서의 표와 동일한 패턴 사용 @@ -536,8 +610,13 @@ impl DocumentCore { // --- 6. Table Control을 포함하는 Paragraph 생성 --- // 제어문자는 text에 포함하지 않음 (serialize_para_text가 controls에서 생성) - let default_char_shape_id = if !self.document.doc_info.char_shapes.is_empty() { 0u32 } else { - self.document.doc_info.char_shapes.push(crate::model::style::CharShape::default()); + let default_char_shape_id = if !self.document.doc_info.char_shapes.is_empty() { + 0u32 + } else { + self.document + .doc_info + .char_shapes + .push(crate::model::style::CharShape::default()); 0 }; @@ -566,7 +645,7 @@ impl DocumentCore { // 정상 파일에서 표 문단의 instance_id = 0x80000000 let mut table_raw_header_extra = vec![0u8; 10]; table_raw_header_extra[0..2].copy_from_slice(&1u16.to_le_bytes()); // n_char_shapes=1 - // [2..4] n_range_tags=0, [4..6] n_line_segs=1 + // [2..4] n_range_tags=0, [4..6] n_line_segs=1 table_raw_header_extra[4..6].copy_from_slice(&1u16.to_le_bytes()); // [6..10] instance_id=0x80000000 table_raw_header_extra[6..10].copy_from_slice(&0x80000000u32.to_le_bytes()); @@ -615,7 +694,9 @@ impl DocumentCore { border_styles: &[u8; 4], background_color: Option, ) -> u16 { - use crate::model::style::{BorderFill, BorderLine, BorderLineType, Fill, FillType, SolidFill, DiagonalLine}; + use crate::model::style::{ + BorderFill, BorderLine, BorderLineType, DiagonalLine, Fill, FillType, SolidFill, + }; let mut borders = [BorderLine::default(); 4]; for i in 0..4 { @@ -684,7 +765,9 @@ impl DocumentCore { /// JSON에서 border/fill 속성을 파싱하여 BorderFill을 생성/재사용한다. /// 프론트엔드 글자 테두리/배경 대화상자에서 호출된다. pub(crate) fn create_border_fill_from_json(&mut self, json: &str) -> u16 { - use crate::model::style::{BorderFill, BorderLine, DiagonalLine, Fill, FillType, SolidFill}; + use crate::model::style::{ + BorderFill, BorderLine, DiagonalLine, Fill, FillType, SolidFill, + }; // 4방향 테두리 파싱 let dir_keys = ["borderLeft", "borderRight", "borderTop", "borderBottom"]; @@ -766,8 +849,14 @@ impl DocumentCore { let mut para = Paragraph::default(); para.text = "[이미지]".to_string(); para.char_count = para.text.encode_utf16().count() as u32; - para.char_offsets = para.text.chars() - .scan(0u32, |acc, c| { let off = *acc; *acc += c.len_utf16() as u32; Some(off) }) + para.char_offsets = para + .text + .chars() + .scan(0u32, |acc, c| { + let off = *acc; + *acc += c.len_utf16() as u32; + Some(off) + }) .collect(); paragraphs.push(para); return; @@ -788,15 +877,23 @@ impl DocumentCore { Err(_) => return, }; - if decoded.is_empty() { return; } + if decoded.is_empty() { + return; + } // BinData로 등록 let new_bin_id = (self.document.bin_data_content.len() + 1) as u16; - self.document.bin_data_content.push(crate::model::bin_data::BinDataContent { - id: new_bin_id, - data: decoded.clone(), - extension: detect_clipboard_image_mime(&decoded).split('/').nth(1).unwrap_or("png").to_string(), - }); + self.document + .bin_data_content + .push(crate::model::bin_data::BinDataContent { + id: new_bin_id, + data: decoded.clone(), + extension: detect_clipboard_image_mime(&decoded) + .split('/') + .nth(1) + .unwrap_or("png") + .to_string(), + }); // width/height 추출 let width = parse_html_attr_f64(img_tag, "width").unwrap_or(200.0); @@ -810,8 +907,14 @@ impl DocumentCore { let mut para = Paragraph::default(); para.text = "[이미지]".to_string(); para.char_count = para.text.encode_utf16().count() as u32; - para.char_offsets = para.text.chars() - .scan(0u32, |acc, c| { let off = *acc; *acc += c.len_utf16() as u32; Some(off) }) + para.char_offsets = para + .text + .chars() + .scan(0u32, |acc, c| { + let off = *acc; + *acc += c.len_utf16() as u32; + Some(off) + }) .collect(); // Picture 컨트롤 생성 @@ -825,5 +928,4 @@ impl DocumentCore { paragraphs.push(para); } - } diff --git a/src/document_core/mod.rs b/src/document_core/mod.rs index 8083293e..400cf484 100644 --- a/src/document_core/mod.rs +++ b/src/document_core/mod.rs @@ -7,22 +7,22 @@ pub(crate) mod helpers; pub(crate) use helpers::*; mod commands; -mod queries; pub(crate) mod html_table_import; +mod queries; pub mod table_calc; -use std::cell::RefCell; -use std::collections::HashMap; use crate::model::document::Document; use crate::model::event::DocumentEvent; use crate::model::paragraph::Paragraph; -use crate::renderer::pagination::PaginationResult; -use crate::renderer::height_measurer::{MeasuredTable, MeasuredSection}; +use crate::renderer::composer::ComposedParagraph; +use crate::renderer::height_measurer::{MeasuredSection, MeasuredTable}; use crate::renderer::layout::LayoutEngine; +use crate::renderer::pagination::PaginationResult; use crate::renderer::render_tree::PageRenderTree; use crate::renderer::style_resolver::ResolvedStyleSet; -use crate::renderer::composer::ComposedParagraph; use crate::renderer::DEFAULT_DPI; +use std::cell::RefCell; +use std::collections::HashMap; /// 기본 폰트 fallback 경로 pub const DEFAULT_FALLBACK_FONT: &str = "/usr/share/fonts/truetype/nanum/NanumGothic.ttf"; @@ -86,7 +86,8 @@ pub struct DocumentCore { /// 이벤트 로그 (Command 실행 시 누적) pub(crate) event_log: Vec, /// 글상자 오버플로우 연결 캐시 (섹션별, 지연 계산) - pub(crate) overflow_links_cache: RefCell>>, + pub(crate) overflow_links_cache: + RefCell>>, /// Undo/Redo용 Document 스냅샷 저장소 (ID → Document 클론) pub(crate) snapshot_store: Vec<(u32, Document)>, /// 다음 스냅샷 ID @@ -139,25 +140,35 @@ impl DocumentCore { fonts.insert(resolved.to_string()); } } - let fonts_json: Vec = fonts.iter().map(|f| { - // 폰트 이름의 특수문자를 JSON 이스케이프 처리 - let escaped: String = f.chars().flat_map(|c| match c { + let fonts_json: Vec = fonts + .iter() + .map(|f| { + // 폰트 이름의 특수문자를 JSON 이스케이프 처리 + let escaped: String = f + .chars() + .flat_map(|c| match c { + '"' => vec!['\\', '"'], + '\\' => vec!['\\', '\\'], + '\n' => vec!['\\', 'n'], + '\r' => vec!['\\', 'r'], + '\t' => vec!['\\', 't'], + c if c < '\x20' => vec![], + c => vec![c], + }) + .collect(); + format!("\"{}\"", escaped) + }) + .collect(); + + let escaped_fallback: String = self + .fallback_font + .chars() + .flat_map(|c| match c { '"' => vec!['\\', '"'], '\\' => vec!['\\', '\\'], - '\n' => vec!['\\', 'n'], - '\r' => vec!['\\', 'r'], - '\t' => vec!['\\', 't'], - c if c < '\x20' => vec![], c => vec![c], - }).collect(); - format!("\"{}\"", escaped) - }).collect(); - - let escaped_fallback: String = self.fallback_font.chars().flat_map(|c| match c { - '"' => vec!['\\', '"'], - '\\' => vec!['\\', '\\'], - c => vec![c], - }).collect(); + }) + .collect(); format!( "{{\"version\":\"{}.{}.{}.{}\",\"sectionCount\":{},\"pageCount\":{},\"encrypted\":{},\"fallbackFont\":\"{}\",\"fontsUsed\":[{}]}}", self.document.header.version.major, diff --git a/src/document_core/queries/bookmark_query.rs b/src/document_core/queries/bookmark_query.rs index 504fc519..62c0ba28 100644 --- a/src/document_core/queries/bookmark_query.rs +++ b/src/document_core/queries/bookmark_query.rs @@ -1,7 +1,7 @@ //! 책갈피 조회/조작 기능 -use crate::document_core::DocumentCore; use crate::document_core::helpers::find_control_text_positions; +use crate::document_core::DocumentCore; use crate::error::HwpError; use crate::model::control::{Bookmark, Control}; @@ -20,12 +20,19 @@ impl DocumentCore { /// 문서 내 모든 책갈피 목록을 JSON으로 반환 pub fn get_bookmarks_native(&self) -> Result { let bookmarks = self.collect_bookmarks(); - let items: Vec = bookmarks.iter().map(|b| { - format!( - "{{\"name\":{},\"sec\":{},\"para\":{},\"ctrlIdx\":{},\"charPos\":{}}}", - json_escape(&b.name), b.sec, b.para, b.ctrl_idx, b.char_pos - ) - }).collect(); + let items: Vec = bookmarks + .iter() + .map(|b| { + format!( + "{{\"name\":{},\"sec\":{},\"para\":{},\"ctrlIdx\":{},\"charPos\":{}}}", + json_escape(&b.name), + b.sec, + b.para, + b.ctrl_idx, + b.char_pos + ) + }) + .collect(); Ok(format!("[{}]", items.join(","))) } @@ -47,25 +54,38 @@ impl DocumentCore { // 중복 검사 let existing = self.collect_bookmarks(); if existing.iter().any(|b| b.name == name) { - return Ok(r#"{"ok":false,"error":"같은 이름의 책갈피가 이미 등록되어 있습니다."}"#.to_string()); + return Ok( + r#"{"ok":false,"error":"같은 이름의 책갈피가 이미 등록되어 있습니다."}"# + .to_string(), + ); } - let section = self.document.sections.get_mut(sec) + let section = self + .document + .sections + .get_mut(sec) .ok_or_else(|| HwpError::RenderError("구역 범위 초과".into()))?; - let paragraph = section.paragraphs.get_mut(para) + let paragraph = section + .paragraphs + .get_mut(para) .ok_or_else(|| HwpError::RenderError("문단 범위 초과".into()))?; // char_offset에 해당하는 컨트롤 삽입 위치 결정 let insert_idx = find_control_insert_index(paragraph, char_offset); - paragraph.controls.insert(insert_idx, Control::Bookmark(Bookmark { - name: name.to_string(), - })); + paragraph.controls.insert( + insert_idx, + Control::Bookmark(Bookmark { + name: name.to_string(), + }), + ); // CTRL_DATA 레코드 생성 (ParameterSet: 책갈피 이름) let ctrl_data = build_bookmark_ctrl_data(name); if paragraph.ctrl_data_records.len() >= insert_idx { - paragraph.ctrl_data_records.insert(insert_idx, Some(ctrl_data)); + paragraph + .ctrl_data_records + .insert(insert_idx, Some(ctrl_data)); } // char_offsets에 컨트롤 위치 정보 추가 @@ -86,9 +106,14 @@ impl DocumentCore { para: usize, ctrl_idx: usize, ) -> Result { - let section = self.document.sections.get_mut(sec) + let section = self + .document + .sections + .get_mut(sec) .ok_or_else(|| HwpError::RenderError("구역 범위 초과".into()))?; - let paragraph = section.paragraphs.get_mut(para) + let paragraph = section + .paragraphs + .get_mut(para) .ok_or_else(|| HwpError::RenderError("문단 범위 초과".into()))?; if ctrl_idx >= paragraph.controls.len() { @@ -127,13 +152,23 @@ impl DocumentCore { // 중복 검사 (자기 자신 제외) let existing = self.collect_bookmarks(); - if existing.iter().any(|b| b.name == new_name && !(b.sec == sec && b.para == para && b.ctrl_idx == ctrl_idx)) { - return Ok(r#"{"ok":false,"error":"같은 이름의 책갈피가 이미 등록되어 있습니다."}"#.to_string()); + if existing.iter().any(|b| { + b.name == new_name && !(b.sec == sec && b.para == para && b.ctrl_idx == ctrl_idx) + }) { + return Ok( + r#"{"ok":false,"error":"같은 이름의 책갈피가 이미 등록되어 있습니다."}"# + .to_string(), + ); } - let section = self.document.sections.get_mut(sec) + let section = self + .document + .sections + .get_mut(sec) .ok_or_else(|| HwpError::RenderError("구역 범위 초과".into()))?; - let paragraph = section.paragraphs.get_mut(para) + let paragraph = section + .paragraphs + .get_mut(para) .ok_or_else(|| HwpError::RenderError("문단 범위 초과".into()))?; if ctrl_idx >= paragraph.controls.len() { @@ -156,9 +191,7 @@ impl DocumentCore { fn collect_bookmarks(&self) -> Vec { let mut result = vec![]; for (sec_idx, section) in self.document.sections.iter().enumerate() { - collect_bookmarks_from_paragraphs( - §ion.paragraphs, sec_idx, None, &mut result, - ); + collect_bookmarks_from_paragraphs(§ion.paragraphs, sec_idx, None, &mut result); } result } @@ -197,33 +230,51 @@ fn collect_bookmarks_from_paragraphs( Control::Table(t) => { for cell in &t.cells { collect_bookmarks_from_paragraphs( - &cell.paragraphs, sec, Some(effective_para), result, + &cell.paragraphs, + sec, + Some(effective_para), + result, ); } } Control::Header(h) => { collect_bookmarks_from_paragraphs( - &h.paragraphs, sec, Some(effective_para), result, + &h.paragraphs, + sec, + Some(effective_para), + result, ); } Control::Footer(f) => { collect_bookmarks_from_paragraphs( - &f.paragraphs, sec, Some(effective_para), result, + &f.paragraphs, + sec, + Some(effective_para), + result, ); } Control::Footnote(n) => { collect_bookmarks_from_paragraphs( - &n.paragraphs, sec, Some(effective_para), result, + &n.paragraphs, + sec, + Some(effective_para), + result, ); } Control::Endnote(n) => { collect_bookmarks_from_paragraphs( - &n.paragraphs, sec, Some(effective_para), result, + &n.paragraphs, + sec, + Some(effective_para), + result, ); } Control::HiddenComment(hc) => { collect_bookmarks_from_paragraphs( - &hc.paragraphs, sec, Some(effective_para), result, + &hc.paragraphs, + sec, + Some(effective_para), + result, ); } _ => {} @@ -260,7 +311,11 @@ fn char_offset_to_raw( } else if !para.char_offsets.is_empty() { // 첫 위치에 삽입: 기존 첫 번째보다 작은 값 let first = para.char_offsets[0]; - if first >= 8 { first - 8 } else { 0 } + if first >= 8 { + first - 8 + } else { + 0 + } } else { // char_offsets가 비어있으면 char_offset * 2 (UTF-16 추정) (char_offset * 2) as u32 @@ -274,10 +329,10 @@ fn build_bookmark_ctrl_data(name: &str) -> Vec { let utf16: Vec = name.encode_utf16().collect(); let mut data = Vec::with_capacity(12 + utf16.len() * 2); data.extend_from_slice(&0x021Bu16.to_le_bytes()); // ps_id - data.extend_from_slice(&1i16.to_le_bytes()); // count = 1 - data.extend_from_slice(&0u16.to_le_bytes()); // dummy + data.extend_from_slice(&1i16.to_le_bytes()); // count = 1 + data.extend_from_slice(&0u16.to_le_bytes()); // dummy data.extend_from_slice(&0x4000u16.to_le_bytes()); // item_id - data.extend_from_slice(&1u16.to_le_bytes()); // item_type = String + data.extend_from_slice(&1u16.to_le_bytes()); // item_type = String data.extend_from_slice(&(utf16.len() as u16).to_le_bytes()); // name_len for &ch in &utf16 { data.extend_from_slice(&ch.to_le_bytes()); diff --git a/src/document_core/queries/cursor_nav.rs b/src/document_core/queries/cursor_nav.rs index ac52e2d6..a414757b 100644 --- a/src/document_core/queries/cursor_nav.rs +++ b/src/document_core/queries/cursor_nav.rs @@ -1,11 +1,14 @@ //! 커서 이동/줄 정보/경로 탐색/선택 영역 관련 native 메서드 +use super::super::helpers::{ + get_textbox_from_shape, has_table_control, navigable_text_len, utf16_pos_to_char_idx, + LineInfoResult, +}; +use crate::document_core::DocumentCore; +use crate::error::HwpError; use crate::model::control::Control; use crate::model::paragraph::Paragraph; use crate::renderer::render_tree::PageRenderTree; -use crate::document_core::DocumentCore; -use crate::error::HwpError; -use super::super::helpers::{LineInfoResult, utf16_pos_to_char_idx, has_table_control, get_textbox_from_shape, navigable_text_len}; impl DocumentCore { pub(crate) fn get_line_info_native( @@ -14,9 +17,13 @@ impl DocumentCore { para_idx: usize, char_offset: usize, ) -> Result { - let para = self.document.sections.get(section_idx) + let para = self + .document + .sections + .get(section_idx) .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))? - .paragraphs.get(para_idx) + .paragraphs + .get(para_idx) .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", para_idx)))?; Self::compute_line_info(para, char_offset) @@ -32,11 +39,20 @@ impl DocumentCore { cell_para_idx: usize, char_offset: usize, ) -> Result { - let para = self.get_cell_paragraph_ref(section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "셀 문단 참조 실패: sec={} ppi={} ci={} cei={} cpi={}", - section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx - )))?; + let para = self + .get_cell_paragraph_ref( + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ) + .ok_or_else(|| { + HwpError::RenderError(format!( + "셀 문단 참조 실패: sec={} ppi={} ci={} cei={} cpi={}", + section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx + )) + })?; Self::compute_line_info(para, char_offset) } @@ -102,19 +118,34 @@ impl DocumentCore { raw_char_end }; - Ok(LineInfoResult { line_index, line_count, char_start, char_end }) + Ok(LineInfoResult { + line_index, + line_count, + char_start, + char_end, + }) } /// 문단의 line_segs에서 각 줄의 시작 char index 배열을 구한다. pub(crate) fn build_line_char_starts(para: &crate::model::paragraph::Paragraph) -> Vec { let char_offsets = ¶.char_offsets; - para.line_segs.iter().map(|ls| { - if ls.text_start == 0 { 0 } else { utf16_pos_to_char_idx(char_offsets, ls.text_start) } - }).collect() + para.line_segs + .iter() + .map(|ls| { + if ls.text_start == 0 { + 0 + } else { + utf16_pos_to_char_idx(char_offsets, ls.text_start) + } + }) + .collect() } /// 특정 줄의 문자 범위(charStart, charEnd)를 반환한다. - pub(crate) fn get_line_char_range(para: &crate::model::paragraph::Paragraph, line_index: usize) -> (usize, usize) { + pub(crate) fn get_line_char_range( + para: &crate::model::paragraph::Paragraph, + line_index: usize, + ) -> (usize, usize) { let char_count = navigable_text_len(para); if para.line_segs.is_empty() { return (0, char_count); @@ -125,7 +156,11 @@ impl DocumentCore { return (char_count, char_count); } let char_start = starts[line_index]; - let char_end = if line_index + 1 < line_count { starts[line_index + 1] } else { char_count }; + let char_end = if line_index + 1 < line_count { + starts[line_index + 1] + } else { + char_count + }; (char_start, char_end) } @@ -178,8 +213,8 @@ impl DocumentCore { preferred_x: f64, cell_ctx: Option<(usize, usize, usize, usize)>, ) -> Result { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; use crate::renderer::layout::compute_char_positions; + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; // ═══ PHASE 1: preferredX 결정 ═══ let actual_px = if preferred_x < 0.0 { @@ -193,8 +228,13 @@ impl DocumentCore { // ═══ PHASE 2: 현재 줄 정보 + 목표 줄 결정 ═══ let current_para = self.resolve_paragraph(sec, para, cell_ctx)?; - let line_info = Self::compute_line_info_struct(current_para, char_offset) - .unwrap_or(LineInfoResult { line_index: 0, line_count: 1, char_start: 0, char_end: navigable_text_len(current_para) }); + let line_info = + Self::compute_line_info_struct(current_para, char_offset).unwrap_or(LineInfoResult { + line_index: 0, + line_count: 1, + char_start: 0, + char_end: navigable_text_len(current_para), + }); let target_line = line_info.line_index as i32 + delta; // ═══ PHASE 3: 목표 위치 결정 ═══ @@ -219,32 +259,43 @@ impl DocumentCore { actual_px }; let target_range = Self::get_line_char_range(current_para, target_line as usize); - let new_offset = self.find_char_at_x_on_line(sec, para, cell_ctx, target_range, px_for_target)?; + let new_offset = + self.find_char_at_x_on_line(sec, para, cell_ctx, target_range, px_for_target)?; new_pos = (sec, para, new_offset, cell_ctx); } else if cell_ctx.is_some() { // CASE C: 셀 내부 경계 - new_pos = self.handle_cell_boundary(sec, para, char_offset, delta, actual_px, cell_ctx.unwrap())?; + new_pos = self.handle_cell_boundary( + sec, + para, + char_offset, + delta, + actual_px, + cell_ctx.unwrap(), + )?; } else { // CASE B: 본문 문단/구역 경계 new_pos = self.handle_body_boundary(sec, para, delta, actual_px)?; } // ═══ PHASE 4: 최종 커서 좌표 계산 + 결과 포맷 ═══ - let (rect_valid, page_idx, fx, fy, fh) = match self.get_cursor_rect_values( - new_pos.0, new_pos.1, new_pos.2, new_pos.3, - ) { - Ok((p, x, y, h)) => (true, p, x, y, h), - Err(_) => (false, 0, 0.0, 0.0, 16.0), - }; + let (rect_valid, page_idx, fx, fy, fh) = + match self.get_cursor_rect_values(new_pos.0, new_pos.1, new_pos.2, new_pos.3) { + Ok((p, x, y, h)) => (true, p, x, y, h), + Err(_) => (false, 0, 0.0, 0.0, 16.0), + }; // JSON 직렬화 let pos_json = if let Some((ppi, ci, cei, cpi)) = new_pos.3 { // 글상자 여부: cell_index==0이고 컨트롤이 Shape - let is_tb = cei == 0 && self.document.sections.get(new_pos.0) - .and_then(|s| s.paragraphs.get(ppi)) - .and_then(|p| p.controls.get(ci)) - .map(|c| matches!(c, Control::Shape(_))) - .unwrap_or(false); + let is_tb = cei == 0 + && self + .document + .sections + .get(new_pos.0) + .and_then(|s| s.paragraphs.get(ppi)) + .and_then(|p| p.controls.get(ci)) + .map(|c| matches!(c, Control::Shape(_))) + .unwrap_or(false); let tb_str = if is_tb { ",\"isTextBox\":true" } else { "" }; format!( "\"sectionIndex\":{},\"paragraphIndex\":{},\"charOffset\":{},\"parentParaIndex\":{},\"controlIndex\":{},\"cellIndex\":{},\"cellParaIndex\":{}{}", @@ -257,7 +308,11 @@ impl DocumentCore { ) }; - let rect_valid_str = if rect_valid { "" } else { ",\"rectValid\":false" }; + let rect_valid_str = if rect_valid { + "" + } else { + ",\"rectValid\":false" + }; Ok(format!( "{{{},\"pageIndex\":{},\"x\":{:.1},\"y\":{:.1},\"height\":{:.1},\"preferredX\":{:.1}{}}}", pos_json, page_idx, fx, fy, fh, actual_px, rect_valid_str @@ -280,13 +335,19 @@ impl DocumentCore { } } self.get_cell_paragraph_ref(sec, ppi, ci, cei, cpi) - .ok_or_else(|| HwpError::RenderError(format!( - "셀 문단 참조 실패: sec={} ppi={} ci={} cei={} cpi={}", sec, ppi, ci, cei, cpi - ))) + .ok_or_else(|| { + HwpError::RenderError(format!( + "셀 문단 참조 실패: sec={} ppi={} ci={} cei={} cpi={}", + sec, ppi, ci, cei, cpi + )) + }) } else { - self.document.sections.get(sec) + self.document + .sections + .get(sec) .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", sec)))? - .paragraphs.get(para) + .paragraphs + .get(para) .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", para))) } } @@ -300,9 +361,9 @@ impl DocumentCore { cpi: usize, ) -> Option<&Paragraph> { let overflow_links = self.get_overflow_links(sec); - let link = overflow_links.iter().find(|l| - l.target_parent_para == ppi && l.target_ctrl_idx == ci - )?; + let link = overflow_links + .iter() + .find(|l| l.target_parent_para == ppi && l.target_ctrl_idx == ci)?; let section = self.document.sections.get(sec)?; let src_para = section.paragraphs.get(link.source_parent_para)?; if let Control::Shape(s) = src_para.controls.get(link.source_ctrl_idx)? { @@ -316,9 +377,9 @@ impl DocumentCore { /// 오버플로우 타겟 글상자의 유효 문단 수를 반환한다. fn overflow_para_count(&self, sec: usize, ppi: usize, ci: usize) -> Option { let overflow_links = self.get_overflow_links(sec); - let link = overflow_links.iter().find(|l| - l.target_parent_para == ppi && l.target_ctrl_idx == ci - )?; + let link = overflow_links + .iter() + .find(|l| l.target_parent_para == ppi && l.target_ctrl_idx == ci)?; let section = self.document.sections.get(sec)?; let src_para = section.paragraphs.get(link.source_parent_para)?; if let Control::Shape(s) = src_para.controls.get(link.source_ctrl_idx)? { @@ -332,9 +393,9 @@ impl DocumentCore { /// 오버플로우 소스 글상자의 렌더 문단 수(overflow_start)를 반환한다. fn source_rendered_para_count(&self, sec: usize, ppi: usize, ci: usize) -> Option { let overflow_links = self.get_overflow_links(sec); - let link = overflow_links.iter().find(|l| - l.source_parent_para == ppi && l.source_ctrl_idx == ci - )?; + let link = overflow_links + .iter() + .find(|l| l.source_parent_para == ppi && l.source_ctrl_idx == ci)?; Some(link.overflow_start) } @@ -343,9 +404,11 @@ impl DocumentCore { // 경량 JSON 파서: [{"controlIndex":N,"cellIndex":N,"cellParaIndex":N}, ...] let trimmed = path_json.trim(); if !trimmed.starts_with('[') || !trimmed.ends_with(']') { - return Err(HwpError::RenderError("cellPath JSON은 배열이어야 합니다".to_string())); + return Err(HwpError::RenderError( + "cellPath JSON은 배열이어야 합니다".to_string(), + )); } - let inner = &trimmed[1..trimmed.len()-1]; + let inner = &trimmed[1..trimmed.len() - 1]; if inner.trim().is_empty() { return Ok(Vec::new()); } @@ -356,7 +419,12 @@ impl DocumentCore { let mut start = 0; for (i, ch) in inner.char_indices() { match ch { - '{' => { if depth == 0 { start = i; } depth += 1; } + '{' => { + if depth == 0 { + start = i; + } + depth += 1; + } '}' => { depth -= 1; if depth == 0 { @@ -386,17 +454,24 @@ impl DocumentCore { return Err(HwpError::RenderError("경로가 비어있습니다".to_string())); } - let mut para = self.document.sections.get(sec) + let mut para = self + .document + .sections + .get(sec) .ok_or_else(|| HwpError::RenderError(format!("구역 {} 범위 초과", sec)))? - .paragraphs.get(parent_para) + .paragraphs + .get(parent_para) .ok_or_else(|| HwpError::RenderError(format!("문단 {} 범위 초과", parent_para)))?; for (i, &(ctrl_idx, cell_idx, cell_para_idx)) in path.iter().enumerate() { let table = match para.controls.get(ctrl_idx) { Some(Control::Table(t)) => t, - _ => return Err(HwpError::RenderError(format!( - "경로[{}]: controls[{}]가 표가 아닙니다", i, ctrl_idx - ))), + _ => { + return Err(HwpError::RenderError(format!( + "경로[{}]: controls[{}]가 표가 아닙니다", + i, ctrl_idx + ))) + } }; if i == path.len() - 1 { @@ -404,14 +479,22 @@ impl DocumentCore { } // 다음 레벨로 진입: 셀 → 문단 → 다음 표 - let cell = table.cells.get(cell_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "경로[{}]: 셀 {} 범위 초과 (총 {}개)", i, cell_idx, table.cells.len() - )))?; - para = cell.paragraphs.get(cell_para_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "경로[{}]: 셀문단 {} 범위 초과 (총 {}개)", i, cell_para_idx, cell.paragraphs.len() - )))?; + let cell = table.cells.get(cell_idx).ok_or_else(|| { + HwpError::RenderError(format!( + "경로[{}]: 셀 {} 범위 초과 (총 {}개)", + i, + cell_idx, + table.cells.len() + )) + })?; + para = cell.paragraphs.get(cell_para_idx).ok_or_else(|| { + HwpError::RenderError(format!( + "경로[{}]: 셀문단 {} 범위 초과 (총 {}개)", + i, + cell_para_idx, + cell.paragraphs.len() + )) + })?; } unreachable!() @@ -430,10 +513,13 @@ impl DocumentCore { let last = path.last().unwrap(); let table = self.resolve_table_by_path(sec, parent_para, path)?; - table.cells.get(last.1) - .ok_or_else(|| HwpError::RenderError(format!( - "셀 {} 범위 초과 (총 {}개)", last.1, table.cells.len() - ))) + table.cells.get(last.1).ok_or_else(|| { + HwpError::RenderError(format!( + "셀 {} 범위 초과 (총 {}개)", + last.1, + table.cells.len() + )) + }) } /// 경로 기반으로 셀/글상자 내 문단을 탐색한다 (표와 글상자 모두 지원). @@ -447,41 +533,63 @@ impl DocumentCore { return Err(HwpError::RenderError("경로가 비어있습니다".to_string())); } - let mut para = self.document.sections.get(sec) + let mut para = self + .document + .sections + .get(sec) .ok_or_else(|| HwpError::RenderError(format!("구역 {} 범위 초과", sec)))? - .paragraphs.get(parent_para) + .paragraphs + .get(parent_para) .ok_or_else(|| HwpError::RenderError(format!("문단 {} 범위 초과", parent_para)))?; for (i, &(ctrl_idx, cell_idx, cell_para_idx)) in path.iter().enumerate() { let next_para = match para.controls.get(ctrl_idx) { Some(Control::Table(table)) => { - let cell = table.cells.get(cell_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "경로[{}]: 셀 {} 범위 초과 (총 {}개)", i, cell_idx, table.cells.len() - )))?; - cell.paragraphs.get(cell_para_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "경로[{}]: 셀문단 {} 범위 초과 (총 {}개)", i, cell_para_idx, cell.paragraphs.len() - )))? + let cell = table.cells.get(cell_idx).ok_or_else(|| { + HwpError::RenderError(format!( + "경로[{}]: 셀 {} 범위 초과 (총 {}개)", + i, + cell_idx, + table.cells.len() + )) + })?; + cell.paragraphs.get(cell_para_idx).ok_or_else(|| { + HwpError::RenderError(format!( + "경로[{}]: 셀문단 {} 범위 초과 (총 {}개)", + i, + cell_para_idx, + cell.paragraphs.len() + )) + })? } Some(Control::Shape(shape)) => { if cell_idx != 0 { return Err(HwpError::RenderError(format!( - "경로[{}]: 글상자의 cell_index는 0이어야 합니다 ({})", i, cell_idx + "경로[{}]: 글상자의 cell_index는 0이어야 합니다 ({})", + i, cell_idx ))); } - let text_box = get_textbox_from_shape(shape) - .ok_or_else(|| HwpError::RenderError(format!( - "경로[{}]: controls[{}]가 텍스트 글상자가 아닙니다", i, ctrl_idx - )))?; - text_box.paragraphs.get(cell_para_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "경로[{}]: 글상자문단 {} 범위 초과 (총 {}개)", i, cell_para_idx, text_box.paragraphs.len() - )))? + let text_box = get_textbox_from_shape(shape).ok_or_else(|| { + HwpError::RenderError(format!( + "경로[{}]: controls[{}]가 텍스트 글상자가 아닙니다", + i, ctrl_idx + )) + })?; + text_box.paragraphs.get(cell_para_idx).ok_or_else(|| { + HwpError::RenderError(format!( + "경로[{}]: 글상자문단 {} 범위 초과 (총 {}개)", + i, + cell_para_idx, + text_box.paragraphs.len() + )) + })? + } + _ => { + return Err(HwpError::RenderError(format!( + "경로[{}]: controls[{}]가 표/글상자가 아닙니다", + i, ctrl_idx + ))) } - _ => return Err(HwpError::RenderError(format!( - "경로[{}]: controls[{}]가 표/글상자가 아닙니다", i, ctrl_idx - ))), }; para = next_para; @@ -501,37 +609,46 @@ impl DocumentCore { return Err(HwpError::RenderError("경로가 비어있습니다".to_string())); } - let mut para = self.document.sections.get(sec) + let mut para = self + .document + .sections + .get(sec) .ok_or_else(|| HwpError::RenderError(format!("구역 {} 범위 초과", sec)))? - .paragraphs.get(parent_para) + .paragraphs + .get(parent_para) .ok_or_else(|| HwpError::RenderError(format!("문단 {} 범위 초과", parent_para)))?; // 중간 경로 탐색 (마지막 엔트리 제외) - for (i, &(ctrl_idx, cell_idx, cell_para_idx)) in path[..path.len()-1].iter().enumerate() { + for (i, &(ctrl_idx, cell_idx, cell_para_idx)) in path[..path.len() - 1].iter().enumerate() { let next_para = match para.controls.get(ctrl_idx) { Some(Control::Table(table)) => { - let cell = table.cells.get(cell_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "경로[{}]: 셀 {} 범위 초과", i, cell_idx - )))?; - cell.paragraphs.get(cell_para_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "경로[{}]: 셀문단 {} 범위 초과", i, cell_para_idx - )))? + let cell = table.cells.get(cell_idx).ok_or_else(|| { + HwpError::RenderError(format!("경로[{}]: 셀 {} 범위 초과", i, cell_idx)) + })?; + cell.paragraphs.get(cell_para_idx).ok_or_else(|| { + HwpError::RenderError(format!( + "경로[{}]: 셀문단 {} 범위 초과", + i, cell_para_idx + )) + })? } Some(Control::Shape(shape)) => { - let text_box = get_textbox_from_shape(shape) - .ok_or_else(|| HwpError::RenderError(format!( - "경로[{}]: 글상자가 아닙니다", i - )))?; - text_box.paragraphs.get(cell_para_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "경로[{}]: 글상자문단 {} 범위 초과", i, cell_para_idx - )))? + let text_box = get_textbox_from_shape(shape).ok_or_else(|| { + HwpError::RenderError(format!("경로[{}]: 글상자가 아닙니다", i)) + })?; + text_box.paragraphs.get(cell_para_idx).ok_or_else(|| { + HwpError::RenderError(format!( + "경로[{}]: 글상자문단 {} 범위 초과", + i, cell_para_idx + )) + })? + } + _ => { + return Err(HwpError::RenderError(format!( + "경로[{}]: controls[{}]가 표/글상자가 아닙니다", + i, ctrl_idx + ))) } - _ => return Err(HwpError::RenderError(format!( - "경로[{}]: controls[{}]가 표/글상자가 아닙니다", i, ctrl_idx - ))), }; para = next_para; } @@ -540,21 +657,20 @@ impl DocumentCore { let last = path.last().unwrap(); match para.controls.get(last.0) { Some(Control::Table(table)) => { - let cell = table.cells.get(last.1) - .ok_or_else(|| HwpError::RenderError(format!( - "셀 {} 범위 초과", last.1 - )))?; + let cell = table + .cells + .get(last.1) + .ok_or_else(|| HwpError::RenderError(format!("셀 {} 범위 초과", last.1)))?; Ok(cell.paragraphs.len()) } Some(Control::Shape(shape)) => { let text_box = get_textbox_from_shape(shape) - .ok_or_else(|| HwpError::RenderError( - "글상자가 아닙니다".to_string() - ))?; + .ok_or_else(|| HwpError::RenderError("글상자가 아닙니다".to_string()))?; Ok(text_box.paragraphs.len()) } _ => Err(HwpError::RenderError(format!( - "controls[{}]가 표/글상자가 아닙니다", last.0 + "controls[{}]가 표/글상자가 아닙니다", + last.0 ))), } } @@ -589,8 +705,8 @@ impl DocumentCore { char_range: (usize, usize), preferred_x: f64, ) -> Result { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; use crate::renderer::layout::compute_char_positions; + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; // 해당 문단이 포함된 페이지의 렌더 트리 빌드 let pages = if let Some((ppi, _, _, _)) = cell_ctx { @@ -667,13 +783,14 @@ impl DocumentCore { if global_offset < char_range.0 || global_offset > char_range.1 { continue; } - let x = run.bbox_x + if i < run.char_positions.len() { - run.char_positions[i] - } else if !run.char_positions.is_empty() { - *run.char_positions.last().unwrap() - } else { - 0.0 - }; + let x = run.bbox_x + + if i < run.char_positions.len() { + run.char_positions[i] + } else if !run.char_positions.is_empty() { + *run.char_positions.last().unwrap() + } else { + 0.0 + }; let dist = (x - preferred_x).abs(); if dist < best_dist { best_dist = dist; @@ -697,7 +814,10 @@ impl DocumentCore { delta: i32, preferred_x: f64, ) -> Result<(usize, usize, usize, Option<(usize, usize, usize, usize)>), HwpError> { - let section = self.document.sections.get(sec) + let section = self + .document + .sections + .get(sec) .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", sec)))?; let target_para_i = para as i32 + delta; @@ -725,7 +845,8 @@ impl DocumentCore { } // 칼럼 경계를 넘는 경우 preferredX를 대상 칼럼 좌표계로 변환 - let adjusted_px = self.transform_preferred_x_across_columns(sec, para, target_para, preferred_x); + let adjusted_px = + self.transform_preferred_x_across_columns(sec, para, target_para, preferred_x); self.enter_paragraph(sec, target_para, delta, adjusted_px) } @@ -737,10 +858,16 @@ impl DocumentCore { delta: i32, preferred_x: f64, ) -> Result<(usize, usize, usize, Option<(usize, usize, usize, usize)>), HwpError> { - let para_ref = self.document.sections.get(sec) + let para_ref = self + .document + .sections + .get(sec) .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", sec)))? - .paragraphs.get(target_para) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", target_para)))?; + .paragraphs + .get(target_para) + .ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", target_para)) + })?; // 표 컨트롤 확인 if let Some(ctrl_idx) = has_table_control(para_ref) { @@ -752,7 +879,8 @@ impl DocumentCore { let cell_para = &first_cell.paragraphs[0]; let range = Self::get_line_char_range(cell_para, 0); let cell_ctx = Some((target_para, ctrl_idx, 0, 0)); - let offset = self.find_char_at_x_on_line(sec, 0, cell_ctx, range, preferred_x) + let offset = self + .find_char_at_x_on_line(sec, 0, cell_ctx, range, preferred_x) .unwrap_or(0); return Ok((sec, 0, offset, cell_ctx)); } @@ -764,10 +892,15 @@ impl DocumentCore { if let Some(last_cell) = table.cells.get(last_cell_idx) { let last_cpi = last_cell.paragraphs.len().saturating_sub(1); if let Some(cell_para) = last_cell.paragraphs.get(last_cpi) { - let last_line = if cell_para.line_segs.is_empty() { 0 } else { cell_para.line_segs.len() - 1 }; + let last_line = if cell_para.line_segs.is_empty() { + 0 + } else { + cell_para.line_segs.len() - 1 + }; let range = Self::get_line_char_range(cell_para, last_line); let cell_ctx = Some((target_para, ctrl_idx, last_cell_idx, last_cpi)); - let offset = self.find_char_at_x_on_line(sec, last_cpi, cell_ctx, range, preferred_x) + let offset = self + .find_char_at_x_on_line(sec, last_cpi, cell_ctx, range, preferred_x) .unwrap_or(navigable_text_len(cell_para)); return Ok((sec, last_cpi, offset, cell_ctx)); } @@ -779,12 +912,23 @@ impl DocumentCore { } // 일반 문단 - let target_line = if delta > 0 { 0 } else { - if para_ref.line_segs.is_empty() { 0 } else { para_ref.line_segs.len() - 1 } + let target_line = if delta > 0 { + 0 + } else { + if para_ref.line_segs.is_empty() { + 0 + } else { + para_ref.line_segs.len() - 1 + } }; let range = Self::get_line_char_range(para_ref, target_line); - let offset = self.find_char_at_x_on_line(sec, target_para, None, range, preferred_x) - .unwrap_or(if delta > 0 { 0 } else { navigable_text_len(para_ref) }); + let offset = self + .find_char_at_x_on_line(sec, target_para, None, range, preferred_x) + .unwrap_or(if delta > 0 { + 0 + } else { + navigable_text_len(para_ref) + }); Ok((sec, target_para, offset, None)) } @@ -798,9 +942,13 @@ impl DocumentCore { preferred_x: f64, (ppi, ci, cei, cpi): (usize, usize, usize, usize), ) -> Result<(usize, usize, usize, Option<(usize, usize, usize, usize)>), HwpError> { - let table_para = self.document.sections.get(sec) + let table_para = self + .document + .sections + .get(sec) .ok_or_else(|| HwpError::RenderError("구역 범위 초과".to_string()))? - .paragraphs.get(ppi) + .paragraphs + .get(ppi) .ok_or_else(|| HwpError::RenderError("문단 범위 초과".to_string()))?; // 글상자인 경우: 문단 간 이동만, 셀 이동 없이 경계에서 본문 탈출 @@ -808,7 +956,8 @@ impl DocumentCore { if let Some(text_box) = get_textbox_from_shape(shape) { // 오버플로우 타겟: 소스의 오버플로우 문단 수 사용 // 오버플로우 소스: 렌더 문단 수(overflow_start)만 사용 - let effective_para_count = self.overflow_para_count(sec, ppi, ci) + let effective_para_count = self + .overflow_para_count(sec, ppi, ci) .or_else(|| self.source_rendered_para_count(sec, ppi, ci)) .unwrap_or(text_box.paragraphs.len()); @@ -817,7 +966,8 @@ impl DocumentCore { let cell_ctx = Some((ppi, ci, 0, next_cpi)); let next_para = self.resolve_paragraph(sec, next_cpi, cell_ctx)?; let range = Self::get_line_char_range(next_para, 0); - let offset = self.find_char_at_x_on_line(sec, next_cpi, cell_ctx, range, preferred_x) + let offset = self + .find_char_at_x_on_line(sec, next_cpi, cell_ctx, range, preferred_x) .unwrap_or(0); return Ok((sec, next_cpi, offset, cell_ctx)); } @@ -825,9 +975,14 @@ impl DocumentCore { let prev_cpi = cpi - 1; let cell_ctx = Some((ppi, ci, 0, prev_cpi)); let prev_para = self.resolve_paragraph(sec, prev_cpi, cell_ctx)?; - let last_line = if prev_para.line_segs.is_empty() { 0 } else { prev_para.line_segs.len() - 1 }; + let last_line = if prev_para.line_segs.is_empty() { + 0 + } else { + prev_para.line_segs.len() - 1 + }; let range = Self::get_line_char_range(prev_para, last_line); - let offset = self.find_char_at_x_on_line(sec, prev_cpi, cell_ctx, range, preferred_x) + let offset = self + .find_char_at_x_on_line(sec, prev_cpi, cell_ctx, range, preferred_x) .unwrap_or(navigable_text_len(prev_para)); return Ok((sec, prev_cpi, offset, cell_ctx)); } @@ -841,7 +996,9 @@ impl DocumentCore { _ => return Err(HwpError::RenderError("표 컨트롤이 아닙니다".to_string())), }; - let cell = table.cells.get(cei) + let cell = table + .cells + .get(cei) .ok_or_else(|| HwpError::RenderError("셀 범위 초과".to_string()))?; // 1. 셀 내 다른 문단으로 이동 시도 @@ -850,17 +1007,23 @@ impl DocumentCore { let next_para = &cell.paragraphs[next_cpi]; let range = Self::get_line_char_range(next_para, 0); let cell_ctx = Some((ppi, ci, cei, next_cpi)); - let offset = self.find_char_at_x_on_line(sec, next_cpi, cell_ctx, range, preferred_x) + let offset = self + .find_char_at_x_on_line(sec, next_cpi, cell_ctx, range, preferred_x) .unwrap_or(0); return Ok((sec, next_cpi, offset, cell_ctx)); } if delta < 0 && cpi > 0 { let prev_cpi = cpi - 1; let prev_para = &cell.paragraphs[prev_cpi]; - let last_line = if prev_para.line_segs.is_empty() { 0 } else { prev_para.line_segs.len() - 1 }; + let last_line = if prev_para.line_segs.is_empty() { + 0 + } else { + prev_para.line_segs.len() - 1 + }; let range = Self::get_line_char_range(prev_para, last_line); let cell_ctx = Some((ppi, ci, cei, prev_cpi)); - let offset = self.find_char_at_x_on_line(sec, prev_cpi, cell_ctx, range, preferred_x) + let offset = self + .find_char_at_x_on_line(sec, prev_cpi, cell_ctx, range, preferred_x) .unwrap_or(navigable_text_len(prev_para)); return Ok((sec, prev_cpi, offset, cell_ctx)); } @@ -880,15 +1043,22 @@ impl DocumentCore { } else { let last_cpi = target_cell.paragraphs.len().saturating_sub(1); let last_line = if let Some(p) = target_cell.paragraphs.get(last_cpi) { - if p.line_segs.is_empty() { 0 } else { p.line_segs.len() - 1 } - } else { 0 }; + if p.line_segs.is_empty() { + 0 + } else { + p.line_segs.len() - 1 + } + } else { + 0 + }; (last_cpi, last_line) }; if let Some(target_para) = target_cell.paragraphs.get(target_cpi) { let range = Self::get_line_char_range(target_para, target_line); let cell_ctx = Some((ppi, ci, target_cell_idx, target_cpi)); - let offset = self.find_char_at_x_on_line(sec, target_cpi, cell_ctx, range, preferred_x) + let offset = self + .find_char_at_x_on_line(sec, target_cpi, cell_ctx, range, preferred_x) .unwrap_or(0); return Ok((sec, target_cpi, offset, cell_ctx)); } @@ -945,7 +1115,8 @@ impl DocumentCore { sec: usize, para: usize, ) -> Option<(u16, f64, f64)> { - let col_idx = self.para_column_map + let col_idx = self + .para_column_map .get(sec) .and_then(|m| m.get(para)) .copied() @@ -1011,8 +1182,13 @@ impl DocumentCore { let area = areas.get(col.column_index as usize)?; return Some((col.column_index, area.x, area.width)); } - PageItem::PartialParagraph { para_index, start_line, end_line } - if *para_index == para && line_index >= *start_line && line_index < *end_line => + PageItem::PartialParagraph { + para_index, + start_line, + end_line, + } if *para_index == para + && line_index >= *start_line + && line_index < *end_line => { let area = areas.get(col.column_index as usize)?; return Some((col.column_index, area.x, area.width)); @@ -1040,15 +1216,23 @@ impl DocumentCore { end_char_offset: usize, cell_ctx: Option<(usize, usize, usize)>, ) -> Result { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; use crate::renderer::layout::compute_char_positions; + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; // ── 커서 위치를 pre-built tree에서 직접 찾는 헬퍼 ── - struct CursorHit { page: u32, x: f64, y: f64, h: f64 } + struct CursorHit { + page: u32, + x: f64, + y: f64, + h: f64, + } fn find_body_cursor( - node: &RenderNode, sec: usize, para: usize, - offset: usize, page: u32, + node: &RenderNode, + sec: usize, + para: usize, + offset: usize, + page: u32, ) -> Option { if let RenderNodeType::TextRun(ref tr) = node.node_type { if tr.section_index == Some(sec) @@ -1060,11 +1244,18 @@ impl DocumentCore { if offset >= cs && offset <= cs + cc { let pos = compute_char_positions(&tr.text, &tr.style); let lo = offset - cs; - let xr = if lo < pos.len() { pos[lo] } - else if !pos.is_empty() { *pos.last().unwrap() } - else { 0.0 }; + let xr = if lo < pos.len() { + pos[lo] + } else if !pos.is_empty() { + *pos.last().unwrap() + } else { + 0.0 + }; return Some(CursorHit { - page, x: node.bbox.x + xr, y: node.bbox.y, h: node.bbox.height, + page, + x: node.bbox.x + xr, + y: node.bbox.y, + h: node.bbox.height, }); } } @@ -1078,8 +1269,13 @@ impl DocumentCore { } fn find_cell_cursor( - node: &RenderNode, ppi: usize, ci: usize, cei: usize, - cpi: usize, offset: usize, page: u32, + node: &RenderNode, + ppi: usize, + ci: usize, + cei: usize, + cpi: usize, + offset: usize, + page: u32, ) -> Option { if let RenderNodeType::TextRun(ref tr) = node.node_type { let matches_cell = tr.cell_context.as_ref().map_or(false, |ctx| { @@ -1094,11 +1290,18 @@ impl DocumentCore { if offset >= cs && offset <= cs + cc { let pos = compute_char_positions(&tr.text, &tr.style); let lo = offset - cs; - let xr = if lo < pos.len() { pos[lo] } - else if !pos.is_empty() { *pos.last().unwrap() } - else { 0.0 }; + let xr = if lo < pos.len() { + pos[lo] + } else if !pos.is_empty() { + *pos.last().unwrap() + } else { + 0.0 + }; return Some(CursorHit { - page, x: node.bbox.x + xr, y: node.bbox.y, h: node.bbox.height, + page, + x: node.bbox.x + xr, + y: node.bbox.y, + h: node.bbox.height, }); } } @@ -1115,7 +1318,11 @@ impl DocumentCore { let mut tree_cache: Vec<(u32, crate::renderer::render_tree::PageRenderTree)> = Vec::new(); // 선택 범위에 관련된 페이지 번호 수집 (중복 제거) - let lookup_para = if let Some((ppi, _, _)) = cell_ctx { ppi } else { start_para_idx }; + let lookup_para = if let Some((ppi, _, _)) = cell_ctx { + ppi + } else { + start_para_idx + }; let page_nums = self.find_pages_for_paragraph(section_idx, lookup_para)?; // 끝 문단이 다른 페이지에 있을 수 있으므로 추가 if cell_ctx.is_none() && end_para_idx != start_para_idx { @@ -1154,7 +1361,10 @@ impl DocumentCore { } else { find_body_cursor(&tree.root, section_idx, $para_idx, $offset, *pn) }; - if hit.is_some() { result = hit; break; } + if hit.is_some() { + result = hit; + break; + } } result }}; @@ -1165,7 +1375,8 @@ impl DocumentCore { self.find_page(page) .map(|(pc, _, _)| { let areas = &pc.layout.column_areas; - areas.iter() + areas + .iter() .find(|ca| rx >= ca.x - 2.0 && rx <= ca.x + ca.width + 2.0) .or_else(|| { areas.iter().min_by(|a, b| { @@ -1186,23 +1397,42 @@ impl DocumentCore { for para_idx in start_para_idx..=end_para_idx { let para = if let Some((ppi, ci, cei)) = cell_ctx { self.get_cell_paragraph_ref(section_idx, ppi, ci, cei, para_idx) - .ok_or_else(|| HwpError::RenderError(format!( - "셀 문단 참조 실패: sec={} ppi={} ci={} cei={} cpi={}", - section_idx, ppi, ci, cei, para_idx - )))? + .ok_or_else(|| { + HwpError::RenderError(format!( + "셀 문단 참조 실패: sec={} ppi={} ci={} cei={} cpi={}", + section_idx, ppi, ci, cei, para_idx + )) + })? } else { - self.document.sections.get(section_idx) - .ok_or_else(|| HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)))? - .paragraphs.get(para_idx) - .ok_or_else(|| HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", para_idx)))? + self.document + .sections + .get(section_idx) + .ok_or_else(|| { + HwpError::RenderError(format!("구역 인덱스 {} 범위 초과", section_idx)) + })? + .paragraphs + .get(para_idx) + .ok_or_else(|| { + HwpError::RenderError(format!("문단 인덱스 {} 범위 초과", para_idx)) + })? }; let char_count = navigable_text_len(para); let line_count = Self::build_line_char_starts(para).len().max(1); - let sel_start = if para_idx == start_para_idx { start_char_offset } else { 0 }; - let sel_end = if para_idx == end_para_idx { end_char_offset } else { char_count }; - if sel_start >= sel_end { continue; } + let sel_start = if para_idx == start_para_idx { + start_char_offset + } else { + 0 + }; + let sel_end = if para_idx == end_para_idx { + end_char_offset + } else { + char_count + }; + if sel_start >= sel_end { + continue; + } // 본문 문단이 다른 페이지에 있을 수 있으므로 트리 캐시에 추가 if cell_ctx.is_none() { @@ -1219,22 +1449,28 @@ impl DocumentCore { let (line_char_start, line_char_end) = Self::get_line_char_range(para, line_idx); let range_start = sel_start.max(line_char_start); let range_end = sel_end.min(line_char_end); - if range_start >= range_end { continue; } + if range_start >= range_end { + continue; + } let left_hit = find_cursor!(para_idx, range_start); // range_end가 줄바꿈 등 비렌더링 문자 위치이면 한 칸 앞으로 재시도 - let right_hit = find_cursor!(para_idx, range_end) - .or_else(|| if range_end > range_start { find_cursor!(para_idx, range_end - 1) } else { None }); + let right_hit = find_cursor!(para_idx, range_end).or_else(|| { + if range_end > range_start { + find_cursor!(para_idx, range_end - 1) + } else { + None + } + }); if let (Some(lh), Some(rh)) = (left_hit, right_hit) { let partial_start = range_start > line_char_start; - let selection_continues = cell_ctx.is_none() && ( - (range_end < sel_end) || + let selection_continues = cell_ctx.is_none() + && ((range_end < sel_end) || (para_idx < end_para_idx && range_end == sel_end) || // 같은 문단 내 강제 줄바꿈: 줄 끝까지 선택되고 다음 줄 시작이 sel_end이면 확장 - (range_end == sel_end && range_end >= line_char_end && line_idx + 1 < line_count) - ); + (range_end == sel_end && range_end >= line_char_end && line_idx + 1 < line_count)); let (area_left, area_right) = if cell_ctx.is_none() { find_column_area(rh.page, rh.x) @@ -1243,7 +1479,8 @@ impl DocumentCore { }; // y/h는 항상 left_hit 기준 (right_hit가 다음 줄에 있을 수 있음) - let (page_idx, rect_x, rect_y, rect_h) = if !partial_start && cell_ctx.is_none() { + let (page_idx, rect_x, rect_y, rect_h) = if !partial_start && cell_ctx.is_none() + { (lh.page, area_left, lh.y, lh.h) } else { (lh.page, lh.x, lh.y, lh.h) @@ -1269,5 +1506,4 @@ impl DocumentCore { Ok(format!("[{}]", rects.join(","))) } - } diff --git a/src/document_core/queries/cursor_rect.rs b/src/document_core/queries/cursor_rect.rs index df5003e8..f808c557 100644 --- a/src/document_core/queries/cursor_rect.rs +++ b/src/document_core/queries/cursor_rect.rs @@ -1,11 +1,14 @@ //! 커서 좌표/히트테스트/셀 커서/경로 기반 조작 관련 native 메서드 +use super::super::helpers::{ + color_ref_to_css, find_char_at_x, has_table_control, navigable_text_len, utf16_pos_to_char_idx, + LineInfoResult, +}; +use crate::document_core::DocumentCore; +use crate::error::HwpError; use crate::model::control::Control; use crate::model::paragraph::Paragraph; use crate::model::path::PathSegment; -use crate::document_core::DocumentCore; -use crate::error::HwpError; -use super::super::helpers::{LineInfoResult, utf16_pos_to_char_idx, color_ref_to_css, has_table_control, find_char_at_x, navigable_text_len}; use crate::renderer::render_tree::TextRunNode; /// PUA 다자리 글자겹침 TextRun의 논리적 char_count (1) 반환, 아니면 실제 글자 수 반환 @@ -26,8 +29,8 @@ impl DocumentCore { para_idx: usize, char_offset: usize, ) -> Result { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; use crate::renderer::layout::compute_char_positions; + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; // 문단이 포함된 페이지 찾기 let pages = self.find_pages_for_paragraph(section_idx, para_idx)?; @@ -53,52 +56,59 @@ impl DocumentCore { if let RenderNodeType::TextRun(ref text_run) = node.node_type { // 번호/글머리표 TextRun (char_start: None)은 건너뛴다 if let Some(char_start) = text_run.char_start { - if text_run.section_index == Some(sec) - && text_run.para_index == Some(para) - && text_run.cell_context.is_none() - { - let char_count = effective_char_count(text_run); + if text_run.section_index == Some(sec) + && text_run.para_index == Some(para) + && text_run.cell_context.is_none() + { + let char_count = effective_char_count(text_run); - // 커서가 이 TextRun 범위 안에 있는지 확인 - // char_start <= offset <= char_start + char_count - if offset >= char_start && offset <= char_start + char_count { - // exact_only 모드: zero-width 앵커(bbox.width==0)만 허용 - if exact_only && !(char_count == 0 && offset == char_start && node.bbox.width == 0.0) { - // skip: 이 TextRun은 경계 매칭일 뿐 정확한 앵커가 아님 - } else { - let local_offset = offset - char_start; - // PUA 다자리 글자겹침: 커서 위치는 [0.0, bbox.width] - let positions = if text_run.char_overlap.is_some() && char_count == 1 { - vec![0.0, node.bbox.width] - } else { - compute_char_positions(&text_run.text, &text_run.style) - }; - let x_in_run = if local_offset < positions.len() { - positions[local_offset] - } else if !positions.is_empty() { - *positions.last().unwrap() - } else { - 0.0 - }; - // 베이스라인 기반 캐럿 y 계산: - // 같은 줄에 서로 다른 글꼴 크기가 혼재할 때 - // 각 글자의 ascent 위치에서 캐럿이 시작되어야 함 - let font_size = text_run.style.font_size; - let ascent = font_size * 0.8; - let caret_y = node.bbox.y + text_run.baseline - ascent; - return Some(CursorHit { - page_index, - x: node.bbox.x + x_in_run, - y: caret_y, - height: font_size, - }); + // 커서가 이 TextRun 범위 안에 있는지 확인 + // char_start <= offset <= char_start + char_count + if offset >= char_start && offset <= char_start + char_count { + // exact_only 모드: zero-width 앵커(bbox.width==0)만 허용 + if exact_only + && !(char_count == 0 + && offset == char_start + && node.bbox.width == 0.0) + { + // skip: 이 TextRun은 경계 매칭일 뿐 정확한 앵커가 아님 + } else { + let local_offset = offset - char_start; + // PUA 다자리 글자겹침: 커서 위치는 [0.0, bbox.width] + let positions = + if text_run.char_overlap.is_some() && char_count == 1 { + vec![0.0, node.bbox.width] + } else { + compute_char_positions(&text_run.text, &text_run.style) + }; + let x_in_run = if local_offset < positions.len() { + positions[local_offset] + } else if !positions.is_empty() { + *positions.last().unwrap() + } else { + 0.0 + }; + // 베이스라인 기반 캐럿 y 계산: + // 같은 줄에 서로 다른 글꼴 크기가 혼재할 때 + // 각 글자의 ascent 위치에서 캐럿이 시작되어야 함 + let font_size = text_run.style.font_size; + let ascent = font_size * 0.8; + let caret_y = node.bbox.y + text_run.baseline - ascent; + return Some(CursorHit { + page_index, + x: node.bbox.x + x_in_run, + y: caret_y, + height: font_size, + }); + } } } - } } // if let Some(char_start) // 도형 조판부호 마커 (char_start=None, ShapeMarker(pos)) - if let crate::renderer::render_tree::FieldMarkerType::ShapeMarker(marker_pos) = text_run.field_marker { + if let crate::renderer::render_tree::FieldMarkerType::ShapeMarker(marker_pos) = + text_run.field_marker + { if text_run.section_index == Some(sec) && text_run.para_index == Some(para) && text_run.cell_context.is_none() @@ -128,7 +138,9 @@ impl DocumentCore { } } for child in &node.children { - if let Some(hit) = find_cursor_in_node(child, sec, para, offset, page_index, exact_only) { + if let Some(hit) = + find_cursor_in_node(child, sec, para, offset, page_index, exact_only) + { return Some(hit); } } @@ -139,8 +151,24 @@ impl DocumentCore { // 1차: 정확한 앵커(zero-width 노드) 우선 검색, 2차: 일반 검색 for &page_num in &pages { let tree = self.build_page_tree(page_num)?; - let exact_hit = find_cursor_in_node(&tree.root, section_idx, para_idx, char_offset, page_num, true); - let hit_result = exact_hit.or_else(|| find_cursor_in_node(&tree.root, section_idx, para_idx, char_offset, page_num, false)); + let exact_hit = find_cursor_in_node( + &tree.root, + section_idx, + para_idx, + char_offset, + page_num, + true, + ); + let hit_result = exact_hit.or_else(|| { + find_cursor_in_node( + &tree.root, + section_idx, + para_idx, + char_offset, + page_num, + false, + ) + }); if let Some(hit) = hit_result { return Ok(format!( "{{\"pageIndex\":{},\"x\":{:.1},\"y\":{:.1},\"height\":{:.1}}}", @@ -159,29 +187,42 @@ impl DocumentCore { // char_offset 위치에 인라인 컨트롤이 있는지 확인 let inline_ctrl = para.controls.iter().enumerate().find(|(ci, ctrl)| { - matches!(ctrl, Control::Shape(_) | Control::Picture(_) | Control::Equation(_)) - && ctrl_positions.get(*ci).copied() == Some(char_offset) - && char_offset != text_len + matches!( + ctrl, + Control::Shape(_) | Control::Picture(_) | Control::Equation(_) + ) && ctrl_positions.get(*ci).copied() == Some(char_offset) + && char_offset != text_len }); // 텍스트 범위 밖이지만 navigable 범위 내 (도형이 텍스트 뒤에 있을 때) - let beyond_ctrl = if char_offset > text_len && char_offset <= navigable_text_len(para) { - para.controls.iter().enumerate().find(|(ci, ctrl)| { - matches!(ctrl, Control::Shape(_) | Control::Picture(_) | Control::Equation(_)) - && ctrl_positions.get(*ci).copied() == Some(char_offset) - }) - } else { - None - }; + let beyond_ctrl = + if char_offset > text_len && char_offset <= navigable_text_len(para) { + para.controls.iter().enumerate().find(|(ci, ctrl)| { + matches!( + ctrl, + Control::Shape(_) | Control::Picture(_) | Control::Equation(_) + ) && ctrl_positions.get(*ci).copied() == Some(char_offset) + }) + } else { + None + }; if let Some((ci, _ctrl)) = inline_ctrl.or(beyond_ctrl) { // inline_shape_positions에서 Shape 좌표 조회 let first_page = pages[0]; let tree = self.build_page_tree(first_page)?; - if let Some((sx, sy)) = tree.get_inline_shape_position(section_idx, para_idx, ci) { + if let Some((sx, sy)) = + tree.get_inline_shape_position(section_idx, para_idx, ci) + { let shape_h = if let Some(Control::Shape(s)) = para.controls.get(ci) { - crate::renderer::hwpunit_to_px(s.common().height as i32, crate::renderer::DEFAULT_DPI) + crate::renderer::hwpunit_to_px( + s.common().height as i32, + crate::renderer::DEFAULT_DPI, + ) } else if let Some(Control::Picture(p)) = para.controls.get(ci) { - crate::renderer::hwpunit_to_px(p.common.height as i32, crate::renderer::DEFAULT_DPI) + crate::renderer::hwpunit_to_px( + p.common.height as i32, + crate::renderer::DEFAULT_DPI, + ) } else { 16.0 }; @@ -230,22 +271,35 @@ impl DocumentCore { let adjusted_x = if char_offset > 0 { // 해당 문단의 인라인 Shape/Picture/Table 노드 bbox를 수집 fn collect_inline_bboxes( - node: &RenderNode, sec: usize, para: usize, bboxes: &mut Vec<(f64, f64)>, + node: &RenderNode, + sec: usize, + para: usize, + bboxes: &mut Vec<(f64, f64)>, ) { match &node.node_type { - RenderNodeType::Line(ln) if ln.section_index == Some(sec) && ln.para_index == Some(para) => { + RenderNodeType::Line(ln) + if ln.section_index == Some(sec) && ln.para_index == Some(para) => + { bboxes.push((node.bbox.x, node.bbox.x + node.bbox.width)); } - RenderNodeType::Rectangle(rn) if rn.section_index == Some(sec) && rn.para_index == Some(para) => { + RenderNodeType::Rectangle(rn) + if rn.section_index == Some(sec) && rn.para_index == Some(para) => + { bboxes.push((node.bbox.x, node.bbox.x + node.bbox.width)); } - RenderNodeType::Ellipse(en) if en.section_index == Some(sec) && en.para_index == Some(para) => { + RenderNodeType::Ellipse(en) + if en.section_index == Some(sec) && en.para_index == Some(para) => + { bboxes.push((node.bbox.x, node.bbox.x + node.bbox.width)); } - RenderNodeType::Table(tn) if tn.section_index == Some(sec) && tn.para_index == Some(para) => { + RenderNodeType::Table(tn) + if tn.section_index == Some(sec) && tn.para_index == Some(para) => + { bboxes.push((node.bbox.x, node.bbox.x + node.bbox.width)); } - RenderNodeType::Image(im) if im.section_index == Some(sec) && im.para_index == Some(para) => { + RenderNodeType::Image(im) + if im.section_index == Some(sec) && im.para_index == Some(para) => + { bboxes.push((node.bbox.x, node.bbox.x + node.bbox.width)); } _ => {} @@ -287,8 +341,8 @@ impl DocumentCore { /// 페이지 좌표에서 문서 위치 찾기 (네이티브) pub fn hit_test_native(&self, page_num: u32, x: f64, y: f64) -> Result { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; use crate::renderer::layout::{compute_char_positions, CellContext}; + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; let tree = self.build_page_tree_cached(page_num)?; @@ -382,8 +436,8 @@ impl DocumentCore { if let RenderNodeType::TextRun(ref text_run) = node.node_type { if let (Some(si), Some(pi)) = (text_run.section_index, text_run.para_index) { // 머리말/꼬리말·각주 마커 TextRun 건너뛰기 - if pi >= (usize::MAX - 3000) { /* skip marker runs */ } - else if let Some(cs) = text_run.char_start { + if pi >= (usize::MAX - 3000) { /* skip marker runs */ + } else if let Some(cs) = text_run.char_start { let ecc = effective_char_count(text_run); let positions = if text_run.char_overlap.is_some() && ecc == 1 { vec![0.0, node.bbox.width] @@ -447,12 +501,22 @@ impl DocumentCore { ); if let Some(ref ctx) = run.cell_context { let outer = &ctx.path[0]; - let tb = if run.is_textbox { ",\"isTextBox\":true" } else { "" }; + let tb = if run.is_textbox { + ",\"isTextBox\":true" + } else { + "" + }; // cellPath: 전체 중첩 경로 배열 - let path_entries: Vec = ctx.path.iter().map(|e| { - format!("{{\"controlIndex\":{},\"cellIndex\":{},\"cellParaIndex\":{}}}", - e.control_index, e.cell_index, e.cell_para_index) - }).collect(); + let path_entries: Vec = ctx + .path + .iter() + .map(|e| { + format!( + "{{\"controlIndex\":{},\"cellIndex\":{},\"cellParaIndex\":{}}}", + e.control_index, e.cell_index, e.cell_para_index + ) + }) + .collect(); let cell_path = format!(",\"cellPath\":[{}]", path_entries.join(",")); format!("{{{},\"parentParaIndex\":{},\"controlIndex\":{},\"cellIndex\":{},\"cellParaIndex\":{}{}{}{}}}", base, ctx.parent_para_index, outer.control_index, outer.cell_index, outer.cell_para_index, @@ -465,13 +529,23 @@ impl DocumentCore { let mut runs: Vec = Vec::new(); let mut guide_runs: Vec = Vec::new(); let mut cell_bboxes: Vec = Vec::new(); - collect_runs(&tree.root, &mut runs, &mut guide_runs, &mut cell_bboxes, None, None); + collect_runs( + &tree.root, + &mut runs, + &mut guide_runs, + &mut cell_bboxes, + None, + None, + ); // cell_bboxes의 section_index/parent_para_index/control_index를 runs로 재확인하여 보완 // (Table 노드에서 이미 채워진 값이 있어도 runs에서 더 정확한 값을 덮어씀) for cb in &mut cell_bboxes { if let Some(run) = runs.iter().find(|r| { - r.cell_context.as_ref().map(|ctx| ctx.path[0].cell_index == cb.cell_index).unwrap_or(false) + r.cell_context + .as_ref() + .map(|ctx| ctx.path[0].cell_index == cb.cell_index) + .unwrap_or(false) }) { if let Some(ref ctx) = run.cell_context { cb.section_index = run.section_index; @@ -487,7 +561,10 @@ impl DocumentCore { if let Some(ref ctx) = run.cell_context { let outer = &ctx.path[0]; if outer.cell_index == 0 { - let is_shape = self.document.sections.get(run.section_index) + let is_shape = self + .document + .sections + .get(run.section_index) .and_then(|s| s.paragraphs.get(ctx.parent_para_index)) .and_then(|p| p.controls.get(outer.control_index)) .map(|c| matches!(c, Control::Shape(_))) @@ -504,13 +581,20 @@ impl DocumentCore { // 0. 안내문(guide text) 히트 검사 — 필드 클릭 진입 // 안내문 위 클릭 시 해당 필드의 시작 위치로 커서를 보낸다. for gr in &guide_runs { - if x >= gr.bbox_x && x <= gr.bbox_x + gr.bbox_w - && y >= gr.bbox_y && y <= gr.bbox_y + gr.bbox_h + if x >= gr.bbox_x + && x <= gr.bbox_x + gr.bbox_w + && y >= gr.bbox_y + && y <= gr.bbox_y + gr.bbox_h { // 필드 시작 위치 찾기: 해당 문단의 field_ranges에서 검색 if let Some(field_hit) = self.find_field_hit_for_guide( - gr.section_index, gr.paragraph_index, &gr.cell_context, page_num, - gr.bbox_x, gr.bbox_y, gr.bbox_h, + gr.section_index, + gr.paragraph_index, + &gr.cell_context, + page_num, + gr.bbox_x, + gr.bbox_y, + gr.bbox_h, ) { return Ok(field_hit); } @@ -536,26 +620,50 @@ impl DocumentCore { if let Some(ctrl) = para.controls.get(ci) { let (sw, sh) = match ctrl { Control::Shape(s) => ( - crate::renderer::hwpunit_to_px(s.common().width as i32, crate::renderer::DEFAULT_DPI), - crate::renderer::hwpunit_to_px(s.common().height as i32, crate::renderer::DEFAULT_DPI), + crate::renderer::hwpunit_to_px( + s.common().width as i32, + crate::renderer::DEFAULT_DPI, + ), + crate::renderer::hwpunit_to_px( + s.common().height as i32, + crate::renderer::DEFAULT_DPI, + ), ), Control::Picture(p) => ( - crate::renderer::hwpunit_to_px(p.common.width as i32, crate::renderer::DEFAULT_DPI), - crate::renderer::hwpunit_to_px(p.common.height as i32, crate::renderer::DEFAULT_DPI), + crate::renderer::hwpunit_to_px( + p.common.width as i32, + crate::renderer::DEFAULT_DPI, + ), + crate::renderer::hwpunit_to_px( + p.common.height as i32, + crate::renderer::DEFAULT_DPI, + ), ), _ => continue, }; if x >= sx && x <= sx + sw && y >= sy && y <= sy + sh { - let ctrl_positions = crate::document_core::find_control_text_positions(para); + let ctrl_positions = + crate::document_core::find_control_text_positions(para); let char_offset = ctrl_positions.get(ci).copied().unwrap_or(0); // 클릭이 Shape 오른쪽 절반이면 Shape 뒤(offset+1) - let offset = if x > sx + sw / 2.0 { char_offset + 1 } else { char_offset }; + let offset = if x > sx + sw / 2.0 { + char_offset + 1 + } else { + char_offset + }; // 가장 가까운 TextRun을 찾아 format_hit 호출 - let nearest = runs.iter().enumerate() - .filter(|(_, r)| r.section_index == si && r.paragraph_index == pi && r.cell_context.is_none()) + let nearest = runs + .iter() + .enumerate() + .filter(|(_, r)| { + r.section_index == si + && r.paragraph_index == pi + && r.cell_context.is_none() + }) .min_by_key(|(_, r)| { - - if offset >= r.char_start && offset <= r.char_start + r.char_count { + if offset >= r.char_start + && offset <= r.char_start + r.char_count + { 0i64 } else { (offset as i64 - r.char_start as i64).abs() @@ -578,11 +686,13 @@ impl DocumentCore { // 1. 정확한 bbox 히트 검사 // 셀/글상자 TextRun을 본문 TextRun보다 우선한다. // (본문 TextRun이 컨트롤 높이만큼 큰 bbox를 가져서 글상자 영역을 덮을 수 있음) - let mut hit_body: Option<(usize, usize)> = None; // (run_idx, char_offset) + let mut hit_body: Option<(usize, usize)> = None; // (run_idx, char_offset) let mut hit_cell: Option<(usize, usize)> = None; for (i, run) in runs.iter().enumerate() { - if x >= run.bbox_x && x <= run.bbox_x + run.bbox_w - && y >= run.bbox_y && y <= run.bbox_y + run.bbox_h + if x >= run.bbox_x + && x <= run.bbox_x + run.bbox_w + && y >= run.bbox_y + && y <= run.bbox_y + run.bbox_h { let local_x = x - run.bbox_x; let char_offset = find_char_at_x(&run.char_positions, local_x); @@ -604,18 +714,23 @@ impl DocumentCore { let click_column = self.find_column_at_x(page_num, x); // 2. 셀 bbox 기반으로 클릭한 셀 판별 - let clicked_cell: Option<&CellBboxInfo> = cell_bboxes.iter() + let clicked_cell: Option<&CellBboxInfo> = cell_bboxes + .iter() .find(|cb| x >= cb.x && x <= cb.x + cb.w && y >= cb.y && y <= cb.y + cb.h); // 셀 내부 클릭이면: 해당 셀의 run만 검색하여 가장 가까운 위치 반환 if let Some(cb) = clicked_cell { - let cell_runs: Vec<&RunInfo> = runs.iter() + let cell_runs: Vec<&RunInfo> = runs + .iter() .filter(|r| { - r.cell_context.as_ref().map(|ctx| { - ctx.parent_para_index == cb.parent_para_index - && ctx.path[0].control_index == cb.control_index - && ctx.path[0].cell_index == cb.cell_index - }).unwrap_or(false) + r.cell_context + .as_ref() + .map(|ctx| { + ctx.parent_para_index == cb.parent_para_index + && ctx.path[0].control_index == cb.control_index + && ctx.path[0].cell_index == cb.cell_index + }) + .unwrap_or(false) }) .collect(); @@ -643,15 +758,21 @@ impl DocumentCore { } } // y 범위 매칭이 없으면 가장 가까운 run 사용 - if !cell_runs.iter().any(|r| y >= r.bbox_y && y <= r.bbox_y + r.bbox_h) { - let nearest = cell_runs.iter() - .min_by_key(|r| { - let mid_y = r.bbox_y + r.bbox_h / 2.0; - ((y - mid_y).abs() * 1000.0) as i64 - }); + if !cell_runs + .iter() + .any(|r| y >= r.bbox_y && y <= r.bbox_y + r.bbox_h) + { + let nearest = cell_runs.iter().min_by_key(|r| { + let mid_y = r.bbox_y + r.bbox_h / 2.0; + ((y - mid_y).abs() * 1000.0) as i64 + }); if let Some(r) = nearest { best = r; - best_offset = if x < r.bbox_x { r.char_start } else { r.char_start + r.char_count }; + best_offset = if x < r.bbox_x { + r.char_start + } else { + r.char_start + r.char_count + }; } } return Ok(format_hit(best, best_offset, page_num)); @@ -676,10 +797,13 @@ impl DocumentCore { // 같은 줄(y 범위)에서 가장 가까운 본문 TextRun 찾기 // 다단: 클릭 칼럼의 run만 필터 - let mut same_line_runs: Vec<&RunInfo> = runs.iter() + let mut same_line_runs: Vec<&RunInfo> = runs + .iter() .filter(|r| r.cell_context.is_none()) // 본문 run만 .filter(|r| y >= r.bbox_y && y <= r.bbox_y + r.bbox_h) - .filter(|r| click_column.is_none() || r.column_index.is_none() || r.column_index == click_column) + .filter(|r| { + click_column.is_none() || r.column_index.is_none() || r.column_index == click_column + }) .collect(); if !same_line_runs.is_empty() { @@ -691,25 +815,40 @@ impl DocumentCore { } // 줄의 오른쪽이면 마지막 run 끝 let last = same_line_runs.last().unwrap(); - return Ok(format_hit(last, last.char_start + last.char_count, page_num)); + return Ok(format_hit( + last, + last.char_start + last.char_count, + page_num, + )); } // 3. 가장 가까운 줄 찾기 (y 거리 기준) // 다단: 클릭 칼럼의 run을 우선 후보로 사용 - let column_runs: Vec<&RunInfo> = runs.iter() - .filter(|r| click_column.is_none() || r.column_index.is_none() || r.column_index == click_column) + let column_runs: Vec<&RunInfo> = runs + .iter() + .filter(|r| { + click_column.is_none() || r.column_index.is_none() || r.column_index == click_column + }) .collect(); - let candidate_runs = if column_runs.is_empty() { &runs.iter().collect::>() } else { &column_runs }; + let candidate_runs = if column_runs.is_empty() { + &runs.iter().collect::>() + } else { + &column_runs + }; - let closest = candidate_runs.iter().min_by(|a, b| { - let dist_a = (y - (a.bbox_y + a.bbox_h / 2.0)).abs(); - let dist_b = (y - (b.bbox_y + b.bbox_h / 2.0)).abs(); - dist_a.partial_cmp(&dist_b).unwrap() - }).unwrap(); + let closest = candidate_runs + .iter() + .min_by(|a, b| { + let dist_a = (y - (a.bbox_y + a.bbox_h / 2.0)).abs(); + let dist_b = (y - (b.bbox_y + b.bbox_h / 2.0)).abs(); + dist_a.partial_cmp(&dist_b).unwrap() + }) + .unwrap(); let target_y = closest.bbox_y; let target_h = closest.bbox_h; - let mut line_runs: Vec<&&RunInfo> = candidate_runs.iter() + let mut line_runs: Vec<&&RunInfo> = candidate_runs + .iter() .filter(|r| (r.bbox_y - target_y).abs() < 1.0 && (r.bbox_h - target_h).abs() < 1.0) .collect(); line_runs.sort_by(|a, b| a.bbox_x.partial_cmp(&b.bbox_x).unwrap()); @@ -730,7 +869,11 @@ impl DocumentCore { // 줄의 오른쪽 끝 let last = line_runs.last().unwrap(); - Ok(format_hit(last, last.char_start + last.char_count, page_num)) + Ok(format_hit( + last, + last.char_start + last.char_count, + page_num, + )) } /// 안내문 클릭 시 필드 시작 위치를 찾아 hitTest 결과를 반환한다. @@ -748,13 +891,19 @@ impl DocumentCore { // 문단 접근: cell_context가 있으면 전체 경로를 따라가기 (중첩 표 지원) let para = if let Some(ctx) = cell_context { - let path: Vec<(usize, usize, usize)> = ctx.path.iter() + let path: Vec<(usize, usize, usize)> = ctx + .path + .iter() .map(|e| (e.control_index, e.cell_index, e.cell_para_index)) .collect(); - self.resolve_paragraph_by_path(section_index, ctx.parent_para_index, &path).ok()? + self.resolve_paragraph_by_path(section_index, ctx.parent_para_index, &path) + .ok()? } else { - self.document.sections.get(section_index)? - .paragraphs.get(paragraph_index)? + self.document + .sections + .get(section_index)? + .paragraphs + .get(paragraph_index)? }; // 이 문단의 ClickHere 필드 범위 검색 @@ -773,20 +922,33 @@ impl DocumentCore { ); let field_info = format!( ",\"isField\":true,\"fieldId\":{},\"fieldType\":\"{}\"", - field.field_id, field.field_type_str(), + field.field_id, + field.field_type_str(), ); if let Some(ctx) = cell_context { let outer = &ctx.path[0]; let tb = if matches!( - self.document.sections.get(section_index) + self.document + .sections + .get(section_index) .and_then(|s| s.paragraphs.get(ctx.parent_para_index)) .and_then(|p| p.controls.get(outer.control_index)), Some(Control::Shape(_)) - ) { ",\"isTextBox\":true" } else { "" }; - let path_entries: Vec = ctx.path.iter().map(|e| { - format!("{{\"controlIndex\":{},\"cellIndex\":{},\"cellParaIndex\":{}}}", - e.control_index, e.cell_index, e.cell_para_index) - }).collect(); + ) { + ",\"isTextBox\":true" + } else { + "" + }; + let path_entries: Vec = ctx + .path + .iter() + .map(|e| { + format!( + "{{\"controlIndex\":{},\"cellIndex\":{},\"cellParaIndex\":{}}}", + e.control_index, e.cell_index, e.cell_para_index + ) + }) + .collect(); let cell_path = format!(",\"cellPath\":[{}]", path_entries.join(",")); return Some(format!( "{{{},\"parentParaIndex\":{},\"controlIndex\":{},\"cellIndex\":{},\"cellParaIndex\":{}{}{}{}{}}}", @@ -813,14 +975,21 @@ impl DocumentCore { cell_idx: usize, ) -> Result<(u16, u16, f64, f64, f64), HwpError> { use crate::model::control::Control; - let para = self.document.sections.get(section_idx) + let para = self + .document + .sections + .get(section_idx) .and_then(|s| s.paragraphs.get(parent_para_idx)) .ok_or_else(|| HwpError::RenderError("문단 없음".to_string()))?; - let ctrl = para.controls.get(control_idx) + let ctrl = para + .controls + .get(control_idx) .ok_or_else(|| HwpError::RenderError("컨트롤 없음".to_string()))?; match ctrl { Control::Table(ref tbl) => { - let cell = tbl.cells.get(cell_idx) + let cell = tbl + .cells + .get(cell_idx) .ok_or_else(|| HwpError::RenderError("셀 없음".to_string()))?; let dpi_scale = 96.0 / 7200.0; Ok(( @@ -849,8 +1018,8 @@ impl DocumentCore { cell_para_idx: usize, char_offset: usize, ) -> Result { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; use crate::renderer::layout::compute_char_positions; + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; // 표 캡션은 cell_index=65534 센티널로 렌더 트리에도 동일하게 저장됨 @@ -912,7 +1081,15 @@ impl DocumentCore { } } for child in &node.children { - if let Some(hit) = find_cursor_in_cell(child, parent_para, ctrl_idx, c_idx, cp_idx, offset, page_index) { + if let Some(hit) = find_cursor_in_cell( + child, + parent_para, + ctrl_idx, + c_idx, + cp_idx, + offset, + page_index, + ) { return Some(hit); } } @@ -922,7 +1099,13 @@ impl DocumentCore { for &page_num in &pages { let tree = self.build_page_tree(page_num)?; if let Some(hit) = find_cursor_in_cell( - &tree.root, parent_para_idx, control_idx, cell_idx, cell_para_idx, char_offset, page_num + &tree.root, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + char_offset, + page_num, ) { return Ok(format!( "{{\"pageIndex\":{},\"x\":{:.1},\"y\":{:.1},\"height\":{:.1}}}", @@ -961,7 +1144,13 @@ impl DocumentCore { None } - if let Some((x, y, h)) = find_cell_run(&tree.root, parent_para_idx, control_idx, cell_idx, cell_para_idx) { + if let Some((x, y, h)) = find_cell_run( + &tree.root, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ) { return Ok(format!( "{{\"pageIndex\":{},\"x\":{:.1},\"y\":{:.1},\"height\":{:.1}}}", first_page, x, y, h @@ -970,7 +1159,8 @@ impl DocumentCore { // 빈 셀 최종 fallback: 모델에서 셀의 col/row를 조회한 뒤 // 렌더 트리의 TableCell 노드 bbox + 패딩으로 커서 위치 산출 - let cell_pos = self.resolve_cell_position(section_idx, parent_para_idx, control_idx, cell_idx)?; + let cell_pos = + self.resolve_cell_position(section_idx, parent_para_idx, control_idx, cell_idx)?; fn find_table_cell_bbox( node: &RenderNode, @@ -980,15 +1170,17 @@ impl DocumentCore { target_row: u16, ) -> Option<(f64, f64, f64, f64)> { if let RenderNodeType::Table(ref tn) = node.node_type { - let matches_table = tn.para_index == Some(parent_para) - && tn.control_index == Some(ctrl_idx); + let matches_table = + tn.para_index == Some(parent_para) && tn.control_index == Some(ctrl_idx); if matches_table { for child in &node.children { if let RenderNodeType::TableCell(ref tc) = child.node_type { if tc.col == target_col && tc.row == target_row { return Some(( - child.bbox.x, child.bbox.y, - child.bbox.width, child.bbox.height, + child.bbox.x, + child.bbox.y, + child.bbox.width, + child.bbox.height, )); } } @@ -996,7 +1188,9 @@ impl DocumentCore { } } for child in &node.children { - if let Some(r) = find_table_cell_bbox(child, parent_para, ctrl_idx, target_col, target_row) { + if let Some(r) = + find_table_cell_bbox(child, parent_para, ctrl_idx, target_col, target_row) + { return Some(r); } } @@ -1004,7 +1198,11 @@ impl DocumentCore { } if let Some((cx, cy, _cw, ch)) = find_table_cell_bbox( - &tree.root, parent_para_idx, control_idx, cell_pos.0, cell_pos.1 + &tree.root, + parent_para_idx, + control_idx, + cell_pos.0, + cell_pos.1, ) { // 셀 bbox 좌상단 + 패딩 위치에 커서 배치 let pad_left = cell_pos.2; @@ -1012,7 +1210,10 @@ impl DocumentCore { let caret_h = (ch - pad_top - cell_pos.4).max(10.0); // 패딩 제외한 높이 return Ok(format!( "{{\"pageIndex\":{},\"x\":{:.1},\"y\":{:.1},\"height\":{:.1}}}", - first_page, cx + pad_left, cy + pad_top, caret_h + first_page, + cx + pad_left, + cy + pad_top, + caret_h )); } @@ -1063,7 +1264,9 @@ impl DocumentCore { } // 해당 문단이 포함된 페이지들에서 검색 - let pages = self.find_pages_for_paragraph(section_idx, parent_para_idx).ok()?; + let pages = self + .find_pages_for_paragraph(section_idx, parent_para_idx) + .ok()?; let mut max_para: Option = None; for &page_num in &pages { let tree = self.build_page_tree(page_num).ok()?; @@ -1084,8 +1287,8 @@ impl DocumentCore { path_json: &str, char_offset: usize, ) -> Result { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; use crate::renderer::layout::compute_char_positions; + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; let path = Self::parse_cell_path(path_json)?; if path.is_empty() { @@ -1122,9 +1325,13 @@ impl DocumentCore { if offset >= cs && offset <= cs + cc { let positions = compute_char_positions(&tr.text, &tr.style); let lo = offset - cs; - let xr = if lo < positions.len() { positions[lo] } - else if !positions.is_empty() { *positions.last().unwrap() } - else { 0.0 }; + let xr = if lo < positions.len() { + positions[lo] + } else if !positions.is_empty() { + *positions.last().unwrap() + } else { + 0.0 + }; return Some((page, node.bbox.x + xr, node.bbox.y, node.bbox.height)); } } @@ -1139,7 +1346,9 @@ impl DocumentCore { for &page_num in &pages { let tree = self.build_page_tree(page_num)?; - if let Some((pi, x, y, h)) = find_cursor_by_path(&tree.root, parent_para_idx, &path, char_offset, page_num) { + if let Some((pi, x, y, h)) = + find_cursor_by_path(&tree.root, parent_para_idx, &path, char_offset, page_num) + { return Ok(format!( "{{\"pageIndex\":{},\"x\":{:.1},\"y\":{:.1},\"height\":{:.1}}}", pi, x, y, h @@ -1177,7 +1386,8 @@ impl DocumentCore { } None } - if let Some((pi, x, y, h)) = find_any_run(&tree.root, parent_para_idx, &path, page_num) { + if let Some((pi, x, y, h)) = find_any_run(&tree.root, parent_para_idx, &path, page_num) + { return Ok(format!( "{{\"pageIndex\":{},\"x\":{:.1},\"y\":{:.1},\"height\":{:.1}}}", pi, x, y, h @@ -1219,7 +1429,9 @@ impl DocumentCore { Ok(format!( "{{\"rowCount\":{},\"colCount\":{},\"cellCount\":{}}}", - table.row_count, table.col_count, table.cells.len() + table.row_count, + table.col_count, + table.cells.len() )) } @@ -1256,15 +1468,19 @@ impl DocumentCore { return tr.cell_context.as_ref().map_or(false, |ctx| { ctx.parent_para_index == parent_para && ctx.path.len() == path.len() - && ctx.path.iter().zip(path.iter()).enumerate().all(|(i, (a, b))| { - if i < path.len() - 1 { - // 중간 경로: 전체 매칭 (어떤 셀/문단을 경유하는지) - a.control_index == b.0 && a.cell_index == b.1 && a.cell_para_index == b.2 - } else { - // 마지막 경로: control_index만 매칭 (이 표의 모든 셀 포함) - a.control_index == b.0 - } - }) + && ctx.path.iter().zip(path.iter()).enumerate().all( + |(i, (a, b))| { + if i < path.len() - 1 { + // 중간 경로: 전체 매칭 (어떤 셀/문단을 경유하는지) + a.control_index == b.0 + && a.cell_index == b.1 + && a.cell_para_index == b.2 + } else { + // 마지막 경로: control_index만 매칭 (이 표의 모든 셀 포함) + a.control_index == b.0 + } + }, + ) }); } for child in &node.children { @@ -1345,14 +1561,20 @@ impl DocumentCore { let cell = self.resolve_cell_by_path(section_idx, parent_para_idx, &path)?; let cell_para_count = cell.paragraphs.len(); - let current_para_idx = path.last().unwrap().2; // cellParaIndex + let current_para_idx = path.last().unwrap().2; // cellParaIndex - let para = cell.paragraphs.get(current_para_idx) - .ok_or_else(|| HwpError::RenderError(format!("셀문단 {} 범위 초과", current_para_idx)))?; + let para = cell.paragraphs.get(current_para_idx).ok_or_else(|| { + HwpError::RenderError(format!("셀문단 {} 범위 초과", current_para_idx)) + })?; // preferredX 결정 let actual_px = if preferred_x < 0.0 { - match self.get_cursor_rect_by_path_native(section_idx, parent_para_idx, path_json, char_offset) { + match self.get_cursor_rect_by_path_native( + section_idx, + parent_para_idx, + path_json, + char_offset, + ) { Ok(json) => super::super::helpers::json_f64(&json, "x").unwrap_or(0.0), Err(_) => 0.0, } @@ -1361,18 +1583,29 @@ impl DocumentCore { }; // 줄 정보 계산 - let line_info = Self::compute_line_info_struct(para, char_offset) - .unwrap_or(LineInfoResult { line_index: 0, line_count: 1, char_start: 0, char_end: navigable_text_len(para) }); + let line_info = + Self::compute_line_info_struct(para, char_offset).unwrap_or(LineInfoResult { + line_index: 0, + line_count: 1, + char_start: 0, + char_end: navigable_text_len(para), + }); let target_line = line_info.line_index as i32 + delta; // 결과: (new_path, new_char_offset) - let (new_path, new_offset) = - if target_line >= 0 && (target_line as usize) < line_info.line_count { + let (new_path, new_offset) = if target_line >= 0 + && (target_line as usize) < line_info.line_count + { // CASE A: 같은 문단 내 다른 줄 — preferredX 기반 오프셋 찾기 let target_range = Self::get_line_char_range(para, target_line as usize); let best = self.find_best_offset_by_x_in_path( - section_idx, parent_para_idx, &path, current_para_idx, - target_range.0, target_range.1, actual_px, + section_idx, + parent_para_idx, + &path, + current_para_idx, + target_range.0, + target_range.1, + actual_px, ); let mut p = path.clone(); p.last_mut().unwrap().2 = current_para_idx; @@ -1382,12 +1615,18 @@ impl DocumentCore { let prev_para = current_para_idx - 1; let prev = &cell.paragraphs[prev_para]; let prev_line_count = Self::compute_line_info_struct(prev, 0) - .map(|li| li.line_count).unwrap_or(1); + .map(|li| li.line_count) + .unwrap_or(1); let last_line = prev_line_count.saturating_sub(1); let target_range = Self::get_line_char_range(prev, last_line); let best = self.find_best_offset_by_x_in_path( - section_idx, parent_para_idx, &path, prev_para, - target_range.0, target_range.1, actual_px, + section_idx, + parent_para_idx, + &path, + prev_para, + target_range.0, + target_range.1, + actual_px, ); let mut p = path.clone(); p.last_mut().unwrap().2 = prev_para; @@ -1398,8 +1637,13 @@ impl DocumentCore { let next = &cell.paragraphs[next_para]; let target_range = Self::get_line_char_range(next, 0); let best = self.find_best_offset_by_x_in_path( - section_idx, parent_para_idx, &path, next_para, - target_range.0, target_range.1, actual_px, + section_idx, + parent_para_idx, + &path, + next_para, + target_range.0, + target_range.1, + actual_px, ); let mut p = path.clone(); p.last_mut().unwrap().2 = next_para; @@ -1418,28 +1662,43 @@ impl DocumentCore { }; if target_row >= 0 && (target_row as u16) < table.row_count { - if let Some(target_cell_idx) = table.cell_index_at(target_row as u16, current_cell.col) { + if let Some(target_cell_idx) = + table.cell_index_at(target_row as u16, current_cell.col) + { // 인접 셀로 이동 let target_cell = &table.cells[target_cell_idx]; let (target_cpi, target_line_idx) = if delta > 0 { (0usize, 0usize) } else { let last_cpi = target_cell.paragraphs.len().saturating_sub(1); - let last_line = target_cell.paragraphs.get(last_cpi) - .map(|p| if p.line_segs.is_empty() { 0 } else { p.line_segs.len() - 1 }) + let last_line = target_cell + .paragraphs + .get(last_cpi) + .map(|p| { + if p.line_segs.is_empty() { + 0 + } else { + p.line_segs.len() - 1 + } + }) .unwrap_or(0); (last_cpi, last_line) }; let mut new_p = path.clone(); let last = new_p.last_mut().unwrap(); - last.1 = target_cell_idx; // cellIndex 갱신 - last.2 = target_cpi; // cellParaIndex 갱신 + last.1 = target_cell_idx; // cellIndex 갱신 + last.2 = target_cpi; // cellParaIndex 갱신 if let Some(target_para) = target_cell.paragraphs.get(target_cpi) { let target_range = Self::get_line_char_range(target_para, target_line_idx); let best = self.find_best_offset_by_x_in_path( - section_idx, parent_para_idx, &new_p, target_cpi, - target_range.0, target_range.1, actual_px, + section_idx, + parent_para_idx, + &new_p, + target_cpi, + target_range.0, + target_range.1, + actual_px, ); (new_p, best) } else { @@ -1453,9 +1712,10 @@ impl DocumentCore { // CASE D: 중첩 표 경계 탈출 — 부모 셀의 다음/이전 문단으로 if path.len() >= 2 { // 부모 레벨 경로로 올라감 - let mut parent_path = path[..path.len()-1].to_vec(); + let mut parent_path = path[..path.len() - 1].to_vec(); let parent_last = parent_path.last().unwrap(); - let parent_cell = self.resolve_cell_by_path(section_idx, parent_para_idx, &parent_path)?; + let parent_cell = + self.resolve_cell_by_path(section_idx, parent_para_idx, &parent_path)?; let parent_cpi = parent_last.2; if delta > 0 && parent_cpi + 1 < parent_cell.paragraphs.len() { @@ -1465,8 +1725,13 @@ impl DocumentCore { let target_range = Self::get_line_char_range(next_para, 0); parent_path.last_mut().unwrap().2 = next_cpi; let best = self.find_best_offset_by_x_in_path( - section_idx, parent_para_idx, &parent_path, next_cpi, - target_range.0, target_range.1, actual_px, + section_idx, + parent_para_idx, + &parent_path, + next_cpi, + target_range.0, + target_range.1, + actual_px, ); (parent_path, best) } else if delta < 0 && parent_cpi > 0 { @@ -1474,13 +1739,19 @@ impl DocumentCore { let prev_cpi = parent_cpi - 1; let prev_para = &parent_cell.paragraphs[prev_cpi]; let prev_line_count = Self::compute_line_info_struct(prev_para, 0) - .map(|li| li.line_count).unwrap_or(1); + .map(|li| li.line_count) + .unwrap_or(1); let last_line = prev_line_count.saturating_sub(1); let target_range = Self::get_line_char_range(prev_para, last_line); parent_path.last_mut().unwrap().2 = prev_cpi; let best = self.find_best_offset_by_x_in_path( - section_idx, parent_para_idx, &parent_path, prev_cpi, - target_range.0, target_range.1, actual_px, + section_idx, + parent_para_idx, + &parent_path, + prev_cpi, + target_range.0, + target_range.1, + actual_px, ); (parent_path, best) } else { @@ -1499,7 +1770,10 @@ impl DocumentCore { // 커서 좌표 획득 let (rect_valid, page_idx, fx, fy, fh) = match self.get_cursor_rect_by_path_native( - section_idx, parent_para_idx, &path_json_out, new_offset, + section_idx, + parent_para_idx, + &path_json_out, + new_offset, ) { Ok(json) => ( true, @@ -1512,7 +1786,11 @@ impl DocumentCore { }; // MoveVerticalResult 형식 (톱레벨 pageIndex/x/y/height) - let rect_valid_str = if rect_valid { "" } else { ",\"rectValid\":false" }; + let rect_valid_str = if rect_valid { + "" + } else { + ",\"rectValid\":false" + }; Ok(format!( "{{\"sectionIndex\":{},\"paragraphIndex\":{},\"charOffset\":{},\"parentParaIndex\":{},\"controlIndex\":{},\"cellIndex\":{},\"cellParaIndex\":{},\"cellPath\":{},\"pageIndex\":{},\"x\":{:.1},\"y\":{:.1},\"height\":{:.1},\"preferredX\":{:.1}{}}}", section_idx, new_para, new_offset, @@ -1559,9 +1837,15 @@ impl DocumentCore { /// CellPath를 JSON 문자열로 포맷한다. pub(crate) fn format_path_json(path: &[(usize, usize, usize)]) -> String { - let entries: Vec = path.iter().map(|(ci, cei, cpi)| { - format!("{{\"controlIndex\":{},\"cellIndex\":{},\"cellParaIndex\":{}}}", ci, cei, cpi) - }).collect(); + let entries: Vec = path + .iter() + .map(|(ci, cei, cpi)| { + format!( + "{{\"controlIndex\":{},\"cellIndex\":{},\"cellParaIndex\":{}}}", + ci, cei, cpi + ) + }) + .collect(); format!("[{}]", entries.join(",")) } @@ -1580,7 +1864,9 @@ impl DocumentCore { } } // 칼럼 영역 사이(간격)에 클릭한 경우 가장 가까운 칼럼 반환 - areas.iter().enumerate() + areas + .iter() + .enumerate() .min_by(|(_, a), (_, b)| { let da = (x - (a.x + a.width / 2.0)).abs(); let db = (x - (b.x + b.width / 2.0)).abs(); @@ -1601,8 +1887,8 @@ impl DocumentCore { char_offset: usize, preferred_page: i32, ) -> Result { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; use crate::renderer::layout::compute_char_positions; + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; // 머리말/꼬리말 문단의 para_index 마커 값 // layout_header_footer_paragraphs에서 para_index = usize::MAX - i 로 설정됨 @@ -1633,9 +1919,7 @@ impl DocumentCore { ) -> Option { if let RenderNodeType::TextRun(ref text_run) = node.node_type { if let Some(char_start) = text_run.char_start { - if text_run.para_index == Some(marker_para) - && text_run.cell_context.is_none() - { + if text_run.para_index == Some(marker_para) && text_run.cell_context.is_none() { let char_count = effective_char_count(text_run); if offset >= char_start && offset <= char_start + char_count { let local_offset = offset - char_start; @@ -1665,7 +1949,8 @@ impl DocumentCore { } } for child in &node.children { - if let Some(hit) = find_cursor_in_hf_subtree(child, marker_para, offset, page_index) { + if let Some(hit) = find_cursor_in_hf_subtree(child, marker_para, offset, page_index) + { return Some(hit); } } @@ -1710,7 +1995,9 @@ impl DocumentCore { // 루트의 자식에서 Header/Footer 노드 찾기 for child in &tree.root.children { if is_target_node(&child.node_type) { - if let Some(hit) = find_cursor_in_hf_subtree(child, marker_para_idx, char_offset, page_num) { + if let Some(hit) = + find_cursor_in_hf_subtree(child, marker_para_idx, char_offset, page_num) + { return Ok(format!( "{{\"pageIndex\":{},\"x\":{:.1},\"y\":{:.1},\"height\":{:.1}}}", hit.page_index, hit.x, hit.y, hit.height @@ -1726,7 +2013,9 @@ impl DocumentCore { // Header/Footer 노드는 있지만 TextRun이 없는 경우 — 영역 좌표 반환 return Ok(format!( "{{\"pageIndex\":{},\"x\":{:.1},\"y\":{:.1},\"height\":{:.1}}}", - page_num, child.bbox.x, child.bbox.y, + page_num, + child.bbox.x, + child.bbox.y, if child.bbox.height > 0.0 { 12.0 } else { 12.0 } )); } @@ -1757,10 +2046,14 @@ impl DocumentCore { for child in &tree.root.children { let is_header = matches!(child.node_type, RenderNodeType::Header); let is_footer = matches!(child.node_type, RenderNodeType::Footer); - if !is_header && !is_footer { continue; } + if !is_header && !is_footer { + continue; + } - if x >= child.bbox.x && x <= child.bbox.x + child.bbox.width - && y >= child.bbox.y && y <= child.bbox.y + child.bbox.height + if x >= child.bbox.x + && x <= child.bbox.x + child.bbox.width + && y >= child.bbox.y + && y <= child.bbox.y + child.bbox.height { // active header/footer에서 source_section_index와 apply_to 추출 // 머리말/꼬리말은 이전 구역에서 상속될 수 있으므로 @@ -1813,7 +2106,11 @@ impl DocumentCore { if page_num < offset + count { let local_page = (page_num - offset) as usize; let page = &pr.pages[local_page]; - let hf_ref = if is_header { &page.active_header } else { &page.active_footer }; + let hf_ref = if is_header { + &page.active_header + } else { + &page.active_footer + }; if let Some(ref r) = hf_ref { let source_sec = r.source_section_index; if let Some(section) = self.document.sections.get(source_sec) { @@ -1856,8 +2153,8 @@ impl DocumentCore { x: f64, y: f64, ) -> Result { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; use crate::renderer::layout::compute_char_positions; + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; let tree = self.build_page_tree(page_num)?; @@ -1930,10 +2227,14 @@ impl DocumentCore { fn find_char_at_x_hf(positions: &[f64], local_x: f64) -> usize { for (i, &px) in positions.iter().enumerate() { if i == 0 { - if local_x < px / 2.0 { return 0; } + if local_x < px / 2.0 { + return 0; + } } else { let mid = (positions[i - 1] + px) / 2.0; - if local_x < mid { return i; } + if local_x < mid { + return i; + } } } positions.len() @@ -1962,8 +2263,10 @@ impl DocumentCore { // 1단계: 정확한 bbox 히트 for run in &runs { - if x >= run.bbox_x && x <= run.bbox_x + run.bbox_w - && y >= run.bbox_y && y <= run.bbox_y + run.bbox_h + if x >= run.bbox_x + && x <= run.bbox_x + run.bbox_w + && y >= run.bbox_y + && y <= run.bbox_y + run.bbox_h { let local_x = x - run.bbox_x; let char_offset = find_char_at_x_hf(&run.char_positions, local_x); @@ -1972,7 +2275,8 @@ impl DocumentCore { } // 2단계: 같은 줄(y 범위)에서 가장 가까운 run - let same_line: Vec<&HfRunInfo> = runs.iter() + let same_line: Vec<&HfRunInfo> = runs + .iter() .filter(|r| y >= r.bbox_y && y <= r.bbox_y + r.bbox_h) .collect(); if !same_line.is_empty() { @@ -1981,19 +2285,27 @@ impl DocumentCore { return Ok(format_hf_hit(run, run.char_start, page_num)); } let last = same_line.last().unwrap(); - return Ok(format_hf_hit(last, last.char_start + last.char_count, page_num)); + return Ok(format_hf_hit( + last, + last.char_start + last.char_count, + page_num, + )); } // 3단계: 가장 가까운 줄 - let closest = runs.iter().min_by(|a, b| { - let da = (y - (a.bbox_y + a.bbox_h / 2.0)).abs(); - let db = (y - (b.bbox_y + b.bbox_h / 2.0)).abs(); - da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) - }).unwrap(); + let closest = runs + .iter() + .min_by(|a, b| { + let da = (y - (a.bbox_y + a.bbox_h / 2.0)).abs(); + let db = (y - (b.bbox_y + b.bbox_h / 2.0)).abs(); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap(); let target_y = closest.bbox_y; let target_h = closest.bbox_h; - let mut line_runs: Vec<&HfRunInfo> = runs.iter() + let mut line_runs: Vec<&HfRunInfo> = runs + .iter() .filter(|r| (r.bbox_y - target_y).abs() < 1.0 && (r.bbox_h - target_h).abs() < 1.0) .collect(); line_runs.sort_by(|a, b| a.bbox_x.partial_cmp(&b.bbox_x).unwrap()); @@ -2012,7 +2324,11 @@ impl DocumentCore { } let last = line_runs.last().unwrap(); - Ok(format_hf_hit(last, last.char_start + last.char_count, page_num)) + Ok(format_hf_hit( + last, + last.char_start + last.char_count, + page_num, + )) } /// 각주 영역 히트테스트 @@ -2030,10 +2346,14 @@ impl DocumentCore { let tree = self.build_page_tree(page_num)?; for child in &tree.root.children { - if !matches!(child.node_type, RenderNodeType::FootnoteArea) { continue; } + if !matches!(child.node_type, RenderNodeType::FootnoteArea) { + continue; + } - if x >= child.bbox.x && x <= child.bbox.x + child.bbox.width - && y >= child.bbox.y && y <= child.bbox.y + child.bbox.height + if x >= child.bbox.x + && x <= child.bbox.x + child.bbox.width + && y >= child.bbox.y + && y <= child.bbox.y + child.bbox.height { // FootnoteArea 내에서 가장 가까운 TextRun의 footnote_index 반환 let mut fn_idx = 0usize; @@ -2047,7 +2367,9 @@ impl DocumentCore { } } } - for c in &node.children { find_fn_idx(c, best); } + for c in &node.children { + find_fn_idx(c, best); + } } find_fn_idx(child, &mut fn_idx); return Ok(format!("{{\"hit\":true,\"footnoteIndex\":{}}}", fn_idx)); @@ -2067,14 +2389,16 @@ impl DocumentCore { x: f64, y: f64, ) -> Result { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; use crate::renderer::layout::compute_char_positions; + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; let tree = self.build_page_tree(page_num)?; - let fn_node = tree.root.children.iter().find(|child| { - matches!(child.node_type, RenderNodeType::FootnoteArea) - }); + let fn_node = tree + .root + .children + .iter() + .find(|child| matches!(child.node_type, RenderNodeType::FootnoteArea)); let fn_node = match fn_node { Some(n) => n, None => return Ok("{\"hit\":false}".to_string()), @@ -2107,7 +2431,11 @@ impl DocumentCore { baseline: f64, } - fn collect_fn_runs(node: &RenderNode, runs: &mut Vec, number_runs: &mut Vec) { + fn collect_fn_runs( + node: &RenderNode, + runs: &mut Vec, + number_runs: &mut Vec, + ) { if let RenderNodeType::TextRun(ref text_run) = node.node_type { if let (Some(marker_para), Some(marker_section)) = (text_run.para_index, text_run.section_index) @@ -2156,9 +2484,11 @@ impl DocumentCore { collect_fn_runs(fn_node, &mut runs, &mut number_runs); // Y 좌표로 가장 가까운 각주의 footnoteIndex 결정 (텍스트 run이 없는 빈 각주 지원) - if runs.is_empty() || !runs.iter().any(|r| { - y >= r.bbox_y && y <= r.bbox_y + r.bbox_h - }) { + if runs.is_empty() + || !runs + .iter() + .any(|r| y >= r.bbox_y && y <= r.bbox_y + r.bbox_h) + { // 번호 run에서 Y 좌표로 가장 가까운 각주 찾기 let closest_num = number_runs.iter().min_by(|a, b| { let da = (y - (a.bbox_y + a.bbox_h / 2.0)).abs(); @@ -2183,10 +2513,14 @@ impl DocumentCore { fn find_char_at_x(positions: &[f64], local_x: f64) -> usize { for (i, &px) in positions.iter().enumerate() { if i == 0 { - if local_x < px / 2.0 { return 0; } + if local_x < px / 2.0 { + return 0; + } } else { let mid = (positions[i - 1] + px) / 2.0; - if local_x < mid { return i; } + if local_x < mid { + return i; + } } } positions.len() @@ -2215,8 +2549,10 @@ impl DocumentCore { // 1단계: 정확한 bbox 히트 for run in &runs { - if x >= run.bbox_x && x <= run.bbox_x + run.bbox_w - && y >= run.bbox_y && y <= run.bbox_y + run.bbox_h + if x >= run.bbox_x + && x <= run.bbox_x + run.bbox_w + && y >= run.bbox_y + && y <= run.bbox_y + run.bbox_h { let local_x = x - run.bbox_x; let char_offset = find_char_at_x(&run.char_positions, local_x); @@ -2225,7 +2561,8 @@ impl DocumentCore { } // 2단계: 같은 줄(y 범위)에서 가장 가까운 run - let same_line: Vec<&FnRunInfo> = runs.iter() + let same_line: Vec<&FnRunInfo> = runs + .iter() .filter(|r| y >= r.bbox_y && y <= r.bbox_y + r.bbox_h) .collect(); if !same_line.is_empty() { @@ -2234,19 +2571,27 @@ impl DocumentCore { return Ok(format_fn_hit(run, run.char_start, page_num)); } let last = same_line.last().unwrap(); - return Ok(format_fn_hit(last, last.char_start + last.char_count, page_num)); + return Ok(format_fn_hit( + last, + last.char_start + last.char_count, + page_num, + )); } // 3단계: 가장 가까운 줄 - let closest = runs.iter().min_by(|a, b| { - let da = (y - (a.bbox_y + a.bbox_h / 2.0)).abs(); - let db = (y - (b.bbox_y + b.bbox_h / 2.0)).abs(); - da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) - }).unwrap(); + let closest = runs + .iter() + .min_by(|a, b| { + let da = (y - (a.bbox_y + a.bbox_h / 2.0)).abs(); + let db = (y - (b.bbox_y + b.bbox_h / 2.0)).abs(); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap(); let target_y = closest.bbox_y; let target_h = closest.bbox_h; - let mut line_runs: Vec<&FnRunInfo> = runs.iter() + let mut line_runs: Vec<&FnRunInfo> = runs + .iter() .filter(|r| (r.bbox_y - target_y).abs() < 1.0 && (r.bbox_h - target_h).abs() < 1.0) .collect(); line_runs.sort_by(|a, b| a.bbox_x.partial_cmp(&b.bbox_x).unwrap()); @@ -2257,7 +2602,11 @@ impl DocumentCore { } let last = line_runs.last().unwrap(); - Ok(format_fn_hit(last, last.char_start + last.char_count, page_num)) + Ok(format_fn_hit( + last, + last.char_start + last.char_count, + page_num, + )) } /// 각주 내 커서 위치 (커서 렉트) 계산 @@ -2270,17 +2619,23 @@ impl DocumentCore { fn_para_idx: usize, char_offset: usize, ) -> Result { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; use crate::renderer::layout::compute_char_positions; + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; let tree = self.build_page_tree(page_num)?; - let fn_node = tree.root.children.iter().find(|child| { - matches!(child.node_type, RenderNodeType::FootnoteArea) - }); + let fn_node = tree + .root + .children + .iter() + .find(|child| matches!(child.node_type, RenderNodeType::FootnoteArea)); let fn_node = match fn_node { Some(n) => n, - None => return Err(HwpError::RenderError("각주 영역을 찾을 수 없습니다".to_string())), + None => { + return Err(HwpError::RenderError( + "각주 영역을 찾을 수 없습니다".to_string(), + )) + } }; let marker_para = usize::MAX - 2000 - fn_para_idx; @@ -2298,7 +2653,10 @@ impl DocumentCore { } fn collect_cursor_runs( - node: &RenderNode, target_section: usize, target_para: usize, runs: &mut Vec, + node: &RenderNode, + target_section: usize, + target_para: usize, + runs: &mut Vec, ) { if let RenderNodeType::TextRun(ref tr) = node.node_type { if tr.section_index == Some(target_section) && tr.para_index == Some(target_para) { @@ -2317,7 +2675,9 @@ impl DocumentCore { } } } - for c in &node.children { collect_cursor_runs(c, target_section, target_para, runs); } + for c in &node.children { + collect_cursor_runs(c, target_section, target_para, runs); + } } let mut runs: Vec = Vec::new(); @@ -2326,15 +2686,28 @@ impl DocumentCore { if runs.is_empty() { // 폴백: 번호 TextRun(char_start=None) 뒤의 위치를 찾기 // 번호 run은 section_index=footnote_index, para_index=marker_para, char_start=None - fn find_number_run_end(node: &RenderNode, target_sec: usize, target_para: usize) -> Option<(f64, f64, f64)> { + fn find_number_run_end( + node: &RenderNode, + target_sec: usize, + target_para: usize, + ) -> Option<(f64, f64, f64)> { if let RenderNodeType::TextRun(ref tr) = node.node_type { - if tr.section_index == Some(target_sec) && tr.para_index == Some(target_para) && tr.char_start.is_none() { + if tr.section_index == Some(target_sec) + && tr.para_index == Some(target_para) + && tr.char_start.is_none() + { // 번호 run의 오른쪽 끝 - return Some((node.bbox.x + node.bbox.width, node.bbox.y + tr.baseline - tr.style.font_size * 0.8, tr.style.font_size)); + return Some(( + node.bbox.x + node.bbox.width, + node.bbox.y + tr.baseline - tr.style.font_size * 0.8, + tr.style.font_size, + )); } } for c in &node.children { - if let Some(r) = find_number_run_end(c, target_sec, target_para) { return Some(r); } + if let Some(r) = find_number_run_end(c, target_sec, target_para) { + return Some(r); + } } None } @@ -2397,20 +2770,38 @@ impl DocumentCore { use crate::renderer::pagination::FootnoteSource; let (section_idx, local_page) = self.find_section_for_page(page_num); - let pr = self.pagination.get(section_idx) + let pr = self + .pagination + .get(section_idx) .ok_or_else(|| HwpError::RenderError("구역을 찾을 수 없습니다".to_string()))?; - let page = pr.pages.get(local_page) + let page = pr + .pages + .get(local_page) .ok_or_else(|| HwpError::RenderError("페이지를 찾을 수 없습니다".to_string()))?; - let fn_ref = page.footnotes.get(footnote_index) - .ok_or_else(|| HwpError::RenderError(format!( - "각주 인덱스 {} 범위 초과 (총 {}개)", footnote_index, page.footnotes.len() - )))?; + let fn_ref = page.footnotes.get(footnote_index).ok_or_else(|| { + HwpError::RenderError(format!( + "각주 인덱스 {} 범위 초과 (총 {}개)", + footnote_index, + page.footnotes.len() + )) + })?; let (para_idx, control_idx, source_type) = match &fn_ref.source { - FootnoteSource::Body { para_index, control_index } => (*para_index, *control_index, "body"), - FootnoteSource::TableCell { para_index, table_control_index, .. } => (*para_index, *table_control_index, "table"), - FootnoteSource::ShapeTextBox { para_index, shape_control_index, .. } => (*para_index, *shape_control_index, "shape"), + FootnoteSource::Body { + para_index, + control_index, + } => (*para_index, *control_index, "body"), + FootnoteSource::TableCell { + para_index, + table_control_index, + .. + } => (*para_index, *table_control_index, "table"), + FootnoteSource::ShapeTextBox { + para_index, + shape_control_index, + .. + } => (*para_index, *shape_control_index, "shape"), }; Ok(format!( diff --git a/src/document_core/queries/doc_tree_nav.rs b/src/document_core/queries/doc_tree_nav.rs index bc5875f2..73935cb0 100644 --- a/src/document_core/queries/doc_tree_nav.rs +++ b/src/document_core/queries/doc_tree_nav.rs @@ -3,8 +3,10 @@ //! HWP 문서의 계층 구조(Section → Paragraph → Control → 내부 Paragraph → ...)를 //! DFS로 순회하여 다음/이전 편집 가능 위치를 찾는다. +use crate::document_core::helpers::{ + find_control_text_positions, get_textbox_from_shape, navigable_text_len, +}; use crate::document_core::DocumentCore; -use crate::document_core::helpers::{find_control_text_positions, get_textbox_from_shape, navigable_text_len}; use crate::model::control::Control; use crate::model::document::Section; use crate::model::paragraph::Paragraph; @@ -100,13 +102,11 @@ fn resolve_paragraphs<'a>( let tb = get_textbox_from_shape(s.as_ref())?; // 마지막 컨텍스트 entry가 빈 글상자이면 오버플로우 타겟인지 확인 - if depth == context.len() - 1 - && tb.paragraphs.iter().all(|p| p.text.is_empty()) - { - if let Some(link) = overflow_links.iter().find(|l| + if depth == context.len() - 1 && tb.paragraphs.iter().all(|p| p.text.is_empty()) { + if let Some(link) = overflow_links.iter().find(|l| { l.target_parent_para == entry.parent_para - && l.target_ctrl_idx == entry.ctrl_idx - ) { + && l.target_ctrl_idx == entry.ctrl_idx + }) { // 소스 글상자의 오버플로우 문단 반환 let src_para = section_paras.get(link.source_parent_para)?; let src_ctrl = src_para.controls.get(link.source_ctrl_idx)?; @@ -142,15 +142,27 @@ fn resolve_paragraphs<'a>( /// Table의 reading order로 다음 셀 인덱스를 반환한다. /// 행 우선 순서 (row 0 col 0, row 0 col 1, ..., row 1 col 0, ...) -fn next_cell_index(table: &crate::model::table::Table, current_cell_idx: usize, forward: bool) -> Option { +fn next_cell_index( + table: &crate::model::table::Table, + current_cell_idx: usize, + forward: bool, +) -> Option { if table.cells.is_empty() { return None; } if forward { let next = current_cell_idx + 1; - if next < table.cells.len() { Some(next) } else { None } + if next < table.cells.len() { + Some(next) + } else { + None + } } else { - if current_cell_idx > 0 { Some(current_cell_idx - 1) } else { None } + if current_cell_idx > 0 { + Some(current_cell_idx - 1) + } else { + None + } } } @@ -175,7 +187,10 @@ impl DocumentCore { for entry in context { let ctrl_text_pos = if let Some(para) = paragraphs.get(entry.parent_para) { let positions = find_control_text_positions(para); - positions.get(entry.ctrl_idx).copied().unwrap_or(entry.ctrl_text_pos) + positions + .get(entry.ctrl_idx) + .copied() + .unwrap_or(entry.ctrl_text_pos) } else { entry.ctrl_text_pos }; @@ -262,14 +277,22 @@ impl DocumentCore { if is_tb { // 글상자: 진입 return self.enter_control_forward( - sec, para, ci, cpos, is_tb, context, overflow_links, + sec, + para, + ci, + cpos, + is_tb, + context, + overflow_links, ); } // 도형/표/그림/수식: 컨트롤 다음 위치로 이동 (글자처럼 1칸) let next = cpos + 1; if next <= text_len { return NavResult::Text { - sec, para, char_offset: next, + sec, + para, + char_offset: next, context: context.to_vec(), }; } @@ -284,14 +307,22 @@ impl DocumentCore { if let Some(is_tb) = classify_navigable(ctrl) { if is_tb { return self.enter_control_forward( - sec, para, ci, cpos, is_tb, context, overflow_links, + sec, + para, + ci, + cpos, + is_tb, + context, + overflow_links, ); } // 표: 건너뛰기 let skip = cpos + 1; if skip <= text_len { return NavResult::Text { - sec, para, char_offset: skip, + sec, + para, + char_offset: skip, context: context.to_vec(), }; } @@ -307,12 +338,20 @@ impl DocumentCore { if let Some(is_tb) = classify_navigable(ctrl) { if is_tb { return self.enter_control_forward( - sec, para, ci, cpos, is_tb, context, overflow_links, + sec, + para, + ci, + cpos, + is_tb, + context, + overflow_links, ); } // 도형/표: next_offset을 반환 (getCursorRect에서 적절히 처리) return NavResult::Text { - sec, para, char_offset: next_offset, + sec, + para, + char_offset: next_offset, context: context.to_vec(), }; } @@ -345,13 +384,21 @@ impl DocumentCore { if let Some(is_tb) = classify_navigable(ctrl) { if is_tb { return self.enter_control_backward( - sec, para, ci, cpos, is_tb, context, overflow_links, + sec, + para, + ci, + cpos, + is_tb, + context, + overflow_links, ); } // 도형/표/그림: 컨트롤 앞으로 건너뛰기 if cpos > 0 { return NavResult::Text { - sec, para, char_offset: cpos - 1, + sec, + para, + char_offset: cpos - 1, context: context.to_vec(), }; } @@ -363,11 +410,19 @@ impl DocumentCore { if let Some(is_tb) = classify_navigable(ctrl) { if is_tb { return self.enter_control_backward( - sec, para, ci, cpos, is_tb, context, overflow_links, + sec, + para, + ci, + cpos, + is_tb, + context, + overflow_links, ); } return NavResult::Text { - sec, para, char_offset: cpos, + sec, + para, + char_offset: cpos, context: context.to_vec(), }; } @@ -381,13 +436,21 @@ impl DocumentCore { if let Some(is_tb) = classify_navigable(ctrl) { if is_tb { return self.enter_control_backward( - sec, para, ci, cpos, is_tb, context, overflow_links, + sec, + para, + ci, + cpos, + is_tb, + context, + overflow_links, ); } // 표: 건너뛰기 if cpos > 0 { return NavResult::Text { - sec, para, char_offset: cpos - 1, + sec, + para, + char_offset: cpos - 1, context: context.to_vec(), }; } @@ -423,10 +486,10 @@ impl DocumentCore { if !context.is_empty() && max_para.is_some() { let last = &context[context.len() - 1]; if last.is_textbox { - if let Some(link) = overflow_links.iter().find(|l| + if let Some(link) = overflow_links.iter().find(|l| { l.source_parent_para == last.parent_para - && l.source_ctrl_idx == last.ctrl_idx - ) { + && l.source_ctrl_idx == last.ctrl_idx + }) { // 타겟 글상자 컨텍스트로 전환 (para 0부터 시작) let mut target_ctx = context[..context.len() - 1].to_vec(); let ctrl_text_pos = last.ctrl_text_pos; // 같은 텍스트 위치 유지 @@ -452,10 +515,10 @@ impl DocumentCore { if !context.is_empty() { let last = &context[context.len() - 1]; if last.is_textbox { - if let Some(link) = overflow_links.iter().find(|l| + if let Some(link) = overflow_links.iter().find(|l| { l.target_parent_para == last.parent_para - && l.target_ctrl_idx == last.ctrl_idx - ) { + && l.target_ctrl_idx == last.ctrl_idx + }) { // 소스 글상자의 마지막 렌더 문단 끝으로 복귀 let mut source_ctx = context[..context.len() - 1].to_vec(); let ctrl_text_pos = last.ctrl_text_pos; @@ -468,7 +531,12 @@ impl DocumentCore { }); // overflow_start - 1 = 소스에서 마지막으로 렌더링된 문단 let last_rendered = link.overflow_start.saturating_sub(1); - return self.navigate_to_para_end(sec, last_rendered, &source_ctx, overflow_links); + return self.navigate_to_para_end( + sec, + last_rendered, + &source_ctx, + overflow_links, + ); } } } @@ -487,10 +555,11 @@ impl DocumentCore { return self.exit_control(sec, &last, forward, &parent_ctx, overflow_links); } else { // Table 탈출: 다음/이전 셀 확인 - let parent_paras = match resolve_paragraphs(sections, sec, &parent_ctx, overflow_links) { - Some(p) => p, - None => return NavResult::Boundary, - }; + let parent_paras = + match resolve_paragraphs(sections, sec, &parent_ctx, overflow_links) { + Some(p) => p, + None => return NavResult::Boundary, + }; let parent_para = match parent_paras.get(last.parent_para) { Some(p) => p, None => return NavResult::Boundary, @@ -508,9 +577,17 @@ impl DocumentCore { if forward { return self.navigate_to_para_start(sec, 0, &new_ctx, overflow_links); } else { - let cell_para_count = table.cells.get(next_cell) - .map(|c| c.paragraphs.len()).unwrap_or(1); - return self.navigate_to_para_end(sec, cell_para_count.saturating_sub(1), &new_ctx, overflow_links); + let cell_para_count = table + .cells + .get(next_cell) + .map(|c| c.paragraphs.len()) + .unwrap_or(1); + return self.navigate_to_para_end( + sec, + cell_para_count.saturating_sub(1), + &new_ctx, + overflow_links, + ); } } // 모든 셀 소진 → 표 탈출 (컨트롤 재진입 방지) @@ -529,7 +606,12 @@ impl DocumentCore { if sec > 0 { let prev_sec = sec - 1; let prev_para_count = sections[prev_sec].paragraphs.len(); - return self.navigate_to_para_end(prev_sec, prev_para_count.saturating_sub(1), &[], overflow_links); + return self.navigate_to_para_end( + prev_sec, + prev_para_count.saturating_sub(1), + &[], + overflow_links, + ); } } @@ -562,18 +644,36 @@ impl DocumentCore { if let Some(is_tb) = classify_navigable(ctrl) { if is_tb { // 글상자: 진입 - return self.enter_control_forward(sec, para, ci, 0, is_tb, context, overflow_links); + return self.enter_control_forward( + sec, + para, + ci, + 0, + is_tb, + context, + overflow_links, + ); } // 표: 건너뛰기 → 다음 위치로 let skip = 1; if skip <= text_len { return NavResult::Text { - sec, para, char_offset: skip, + sec, + para, + char_offset: skip, context: context.to_vec(), }; } // 표만 있는 문단 → 다음 문단으로 - return self.navigate_next_editable(sec, para, 0, 1, context, None, overflow_links); + return self.navigate_next_editable( + sec, + para, + 0, + 1, + context, + None, + overflow_links, + ); } } if cpos > 0 { @@ -616,7 +716,15 @@ impl DocumentCore { if let Some(is_tb) = classify_navigable(ctrl) { if is_tb { // 글상자: 진입 - return self.enter_control_backward(sec, para, ci, cpos, is_tb, context, overflow_links); + return self.enter_control_backward( + sec, + para, + ci, + cpos, + is_tb, + context, + overflow_links, + ); } // 표: 건너뛰기 → 표 앞 위치로 // (text_len 위치에 표가 있으므로 text_len은 표 FFFC 뒤 = 실제 텍스트 끝) @@ -703,9 +811,10 @@ impl DocumentCore { }); // 이 글상자가 오버플로우 소스이면, 타겟의 마지막 문단 끝으로 진입 - if let Some(link) = overflow_links.iter().find(|l| - l.source_parent_para == para && l.source_ctrl_idx == ctrl_idx - ) { + if let Some(link) = overflow_links + .iter() + .find(|l| l.source_parent_para == para && l.source_ctrl_idx == ctrl_idx) + { let mut target_ctx = context.to_vec(); target_ctx.push(NavContextEntry { parent_para: link.target_parent_para, @@ -714,10 +823,11 @@ impl DocumentCore { cell_idx: 0, is_textbox: true, }); - let target_paras = match resolve_paragraphs(sections, sec, &target_ctx, overflow_links) { - Some(p) => p, - None => return NavResult::Boundary, - }; + let target_paras = + match resolve_paragraphs(sections, sec, &target_ctx, overflow_links) { + Some(p) => p, + None => return NavResult::Boundary, + }; let last_para = target_paras.len().saturating_sub(1); return self.navigate_to_para_end(sec, last_para, &target_ctx, overflow_links); } @@ -749,9 +859,17 @@ impl DocumentCore { cell_idx: last_cell, is_textbox: false, }); - let cell_para_count = table.cells.get(last_cell) - .map(|c| c.paragraphs.len()).unwrap_or(1); - return self.navigate_to_para_end(sec, cell_para_count.saturating_sub(1), &new_ctx, overflow_links); + let cell_para_count = table + .cells + .get(last_cell) + .map(|c| c.paragraphs.len()) + .unwrap_or(1); + return self.navigate_to_para_end( + sec, + cell_para_count.saturating_sub(1), + &new_ctx, + overflow_links, + ); } NavResult::Boundary } @@ -777,10 +895,9 @@ impl DocumentCore { let sections = &self.document.sections; // 오버플로우 타겟 탈출인지 확인 - let target_link = overflow_links.iter().find(|l| - l.target_parent_para == exited.parent_para - && l.target_ctrl_idx == exited.ctrl_idx - ); + let target_link = overflow_links.iter().find(|l| { + l.target_parent_para == exited.parent_para && l.target_ctrl_idx == exited.ctrl_idx + }); if forward { // 부모 문단의 텍스트 길이 확인 @@ -788,7 +905,8 @@ impl DocumentCore { Some(p) => p, None => return NavResult::Boundary, }; - let text_len = parent_paras.get(exited.parent_para) + let text_len = parent_paras + .get(exited.parent_para) .map(|p| navigable_text_len(p)) .unwrap_or(0); @@ -812,7 +930,13 @@ impl DocumentCore { // 같은 위치에 다른 편집 가능 컨트롤 → 진입 if let Some(is_tb) = classify_navigable(ctrl) { return self.enter_control_forward( - sec, exited.parent_para, ci, cpos, is_tb, parent_ctx, overflow_links, + sec, + exited.parent_para, + ci, + cpos, + is_tb, + parent_ctx, + overflow_links, ); } } @@ -837,13 +961,24 @@ impl DocumentCore { // ctrl_text_pos >= text_len → 다음 문단으로 let para_count = parent_paras.len(); if exited.parent_para + 1 < para_count { - return self.navigate_to_para_start(sec, exited.parent_para + 1, parent_ctx, overflow_links); + return self.navigate_to_para_start( + sec, + exited.parent_para + 1, + parent_ctx, + overflow_links, + ); } // 부모 컨테이너도 끝 → 더 상위로 탈출 if !parent_ctx.is_empty() { let mut grandparent_ctx = parent_ctx.to_vec(); let grandparent = grandparent_ctx.pop().unwrap(); - return self.exit_control(sec, &grandparent, true, &grandparent_ctx, overflow_links); + return self.exit_control( + sec, + &grandparent, + true, + &grandparent_ctx, + overflow_links, + ); } // Body 수준 → 다음 섹션 if sec + 1 < sections.len() { @@ -865,12 +1000,20 @@ impl DocumentCore { if ci >= exited.ctrl_idx { continue; } - let cpos = ctrl_positions.get(ci).copied() + let cpos = ctrl_positions + .get(ci) + .copied() .unwrap_or(navigable_text_len(parent_p)); if cpos == exited.ctrl_text_pos { if let Some(is_tb) = classify_navigable(ctrl) { return self.enter_control_backward( - sec, exited.parent_para, ci, cpos, is_tb, parent_ctx, overflow_links, + sec, + exited.parent_para, + ci, + cpos, + is_tb, + parent_ctx, + overflow_links, ); } } @@ -890,12 +1033,20 @@ impl DocumentCore { if ci >= exited.ctrl_idx { continue; } - let cpos = ctrl_positions.get(ci).copied() + let cpos = ctrl_positions + .get(ci) + .copied() .unwrap_or(navigable_text_len(parent_p)); if cpos == prev_pos { if let Some(is_tb) = classify_navigable(ctrl) { return self.enter_control_backward( - sec, exited.parent_para, ci, cpos, is_tb, parent_ctx, overflow_links, + sec, + exited.parent_para, + ci, + cpos, + is_tb, + parent_ctx, + overflow_links, ); } } @@ -914,19 +1065,35 @@ impl DocumentCore { // ctrl_text_pos == 0: 문단 시작 → 이전 문단 if exited.parent_para > 0 { - return self.navigate_to_para_end(sec, exited.parent_para - 1, parent_ctx, overflow_links); + return self.navigate_to_para_end( + sec, + exited.parent_para - 1, + parent_ctx, + overflow_links, + ); } // 부모 컨테이너도 시작 → 더 상위로 탈출 if !parent_ctx.is_empty() { let mut grandparent_ctx = parent_ctx.to_vec(); let grandparent = grandparent_ctx.pop().unwrap(); - return self.exit_control(sec, &grandparent, false, &grandparent_ctx, overflow_links); + return self.exit_control( + sec, + &grandparent, + false, + &grandparent_ctx, + overflow_links, + ); } // Body 수준 → 이전 섹션 if sec > 0 { let prev_sec = sec - 1; let prev_para_count = sections[prev_sec].paragraphs.len(); - return self.navigate_to_para_end(prev_sec, prev_para_count.saturating_sub(1), &[], overflow_links); + return self.navigate_to_para_end( + prev_sec, + prev_para_count.saturating_sub(1), + &[], + overflow_links, + ); } NavResult::Boundary } @@ -935,7 +1102,12 @@ impl DocumentCore { /// NavResult를 JSON 문자열로 직렬화한다. pub(crate) fn nav_result_to_json(result: &NavResult) -> String { match result { - NavResult::Text { sec, para, char_offset, context } => { + NavResult::Text { + sec, + para, + char_offset, + context, + } => { let ctx_json: Vec = context.iter().map(|e| { format!( "{{\"parentPara\":{},\"ctrlIdx\":{},\"ctrlTextPos\":{},\"cellIdx\":{},\"isTextBox\":{}}}", @@ -947,9 +1119,7 @@ impl DocumentCore { sec, para, char_offset, ctx_json.join(",") ) } - NavResult::Boundary => { - "{\"type\":\"boundary\"}".to_string() - } + NavResult::Boundary => "{\"type\":\"boundary\"}".to_string(), } } @@ -995,7 +1165,7 @@ impl DocumentCore { } fn parse_nav_entry(json: &str) -> Option { - use crate::document_core::helpers::{json_i32, json_bool}; + use crate::document_core::helpers::{json_bool, json_i32}; let parent_para = json_i32(json, "parentPara")? as usize; let ctrl_idx = json_i32(json, "ctrlIdx")? as usize; let ctrl_text_pos = json_i32(json, "ctrlTextPos").unwrap_or(0) as usize; @@ -1019,7 +1189,9 @@ impl DocumentCore { } } let links = self.compute_overflow_links(sec_idx); - self.overflow_links_cache.borrow_mut().insert(sec_idx, links.clone()); + self.overflow_links_cache + .borrow_mut() + .insert(sec_idx, links.clone()); links } @@ -1058,7 +1230,9 @@ impl DocumentCore { let has_text = tb.paragraphs.iter().any(|p| !p.text.is_empty()); if !has_text { // 빈 글상자 → 타겟 후보 - let inner_sw = tb.paragraphs.first() + let inner_sw = tb + .paragraphs + .first() .and_then(|p| p.line_segs.first()) .map(|ls| ls.segment_width) .unwrap_or(0); @@ -1067,7 +1241,9 @@ impl DocumentCore { } // 오버플로우 감지 (scan_textbox_overflow와 동일) - let first_sw = tb.paragraphs.first() + let first_sw = tb + .paragraphs + .first() .and_then(|p| p.line_segs.first()) .map(|ls| ls.segment_width) .unwrap_or(0); @@ -1075,7 +1251,8 @@ impl DocumentCore { let mut overflow_idx: Option = None; for (tpi, tp) in tb.paragraphs.iter().enumerate() { if let Some(first_ls) = tp.line_segs.first() { - if tpi > 0 && first_ls.segment_width != first_sw + if tpi > 0 + && first_ls.segment_width != first_sw && first_ls.vertical_pos < max_vpos_end { overflow_idx = Some(tpi); @@ -1091,7 +1268,9 @@ impl DocumentCore { } if let Some(oi) = overflow_idx { - let target_sw = tb.paragraphs[oi].line_segs.first() + let target_sw = tb.paragraphs[oi] + .line_segs + .first() .map(|ls| ls.segment_width) .unwrap_or(0); overflow_sources.push((target_sw, pi, ci, oi)); @@ -1102,7 +1281,8 @@ impl DocumentCore { // 소스→타겟 매핑 (segment_width 가장 가까운 빈 글상자) let mut links = Vec::new(); for (target_sw, src_pi, src_ci, oi) in overflow_sources { - let best = empty_targets.iter() + let best = empty_targets + .iter() .enumerate() .min_by_key(|(_, (_, _, esw))| (target_sw - *esw).abs()); if let Some((idx, &(tgt_pi, tgt_ci, _))) = best { diff --git a/src/document_core/queries/field_query.rs b/src/document_core/queries/field_query.rs index 0673be41..b44db694 100644 --- a/src/document_core/queries/field_query.rs +++ b/src/document_core/queries/field_query.rs @@ -3,9 +3,9 @@ //! 문서 전체에서 필드를 재귀 탐색하여 조회·설정하는 기능을 제공한다. use crate::document_core::DocumentCore; +use crate::error::HwpError; use crate::model::control::{Control, Field, FieldType}; use crate::model::paragraph::Paragraph; -use crate::error::HwpError; /// 필드 위치 정보 #[derive(Debug, Clone)] @@ -20,9 +20,16 @@ pub struct FieldLocation { #[derive(Debug, Clone)] pub enum NestedEntry { /// 표 셀: (control_index, cell_index, para_index) - TableCell { control_index: usize, cell_index: usize, para_index: usize }, + TableCell { + control_index: usize, + cell_index: usize, + para_index: usize, + }, /// 글상자: (control_index, para_index) - TextBox { control_index: usize, para_index: usize }, + TextBox { + control_index: usize, + para_index: usize, + }, } /// 필드 검색 결과 @@ -79,7 +86,10 @@ impl DocumentCore { let fields = self.collect_all_fields(); for fi in &fields { if fi.field.field_id == field_id { - return Ok(format!("{{\"ok\":true,\"value\":{}}}", json_escape(&fi.value))); + return Ok(format!( + "{{\"ok\":true,\"value\":{}}}", + json_escape(&fi.value) + )); } } Err(HwpError::InvalidField(format!("필드 ID {} 없음", field_id))) @@ -103,10 +113,16 @@ impl DocumentCore { } /// setFieldValue: field_id로 필드 값 설정 - pub fn set_field_value_by_id(&mut self, field_id: u32, value: &str) -> Result { + pub fn set_field_value_by_id( + &mut self, + field_id: u32, + value: &str, + ) -> Result { // 먼저 필드 위치 찾기 let fields = self.collect_all_fields(); - let fi = fields.iter().find(|f| f.field.field_id == field_id) + let fi = fields + .iter() + .find(|f| f.field.field_id == field_id) .ok_or_else(|| HwpError::InvalidField(format!("필드 ID {} 없음", field_id)))?; let location = fi.location.clone(); @@ -128,9 +144,10 @@ impl DocumentCore { /// setFieldValueByName: 필드 이름으로 값 설정 pub fn set_field_value_by_name(&mut self, name: &str, value: &str) -> Result { let fields = self.collect_all_fields(); - let fi = fields.iter().find(|f| { - f.field.field_name().map(|n| n == name).unwrap_or(false) - }).ok_or_else(|| HwpError::InvalidField(format!("필드 이름 '{}' 없음", name)))?; + let fi = fields + .iter() + .find(|f| f.field.field_name().map(|n| n == name).unwrap_or(false)) + .ok_or_else(|| HwpError::InvalidField(format!("필드 이름 '{}' 없음", name)))?; let field_id = fi.field.field_id; let location = fi.location.clone(); @@ -163,22 +180,39 @@ impl DocumentCore { } /// 셀 필드의 텍스트를 교체한다 (셀의 첫 문단 텍스트를 value로 대체). - fn set_cell_field_text(&mut self, location: &FieldLocation, value: &str) -> Result<(), HwpError> { + fn set_cell_field_text( + &mut self, + location: &FieldLocation, + value: &str, + ) -> Result<(), HwpError> { if location.nested_path.is_empty() { - return Err(HwpError::InvalidField("셀 필드 위치에 중첩 경로 없음".into())); + return Err(HwpError::InvalidField( + "셀 필드 위치에 중첩 경로 없음".into(), + )); } let entry = &location.nested_path[0]; match entry { - NestedEntry::TableCell { control_index, cell_index, .. } => { - let sec = self.document.sections.get_mut(location.section_index) + NestedEntry::TableCell { + control_index, + cell_index, + .. + } => { + let sec = self + .document + .sections + .get_mut(location.section_index) .ok_or_else(|| HwpError::InvalidField("구역 초과".into()))?; - let para = sec.paragraphs.get_mut(location.para_index) + let para = sec + .paragraphs + .get_mut(location.para_index) .ok_or_else(|| HwpError::InvalidField("문단 초과".into()))?; let table = match para.controls.get_mut(*control_index) { Some(Control::Table(t)) => t, _ => return Err(HwpError::InvalidField("컨트롤이 표가 아님".into())), }; - let cell = table.cells.get_mut(*cell_index) + let cell = table + .cells + .get_mut(*cell_index) .ok_or_else(|| HwpError::InvalidField("셀 인덱스 초과".into()))?; // 첫 문단의 텍스트를 교체 if let Some(cell_para) = cell.paragraphs.first_mut() { @@ -194,13 +228,20 @@ impl DocumentCore { } /// 필드 위치에서 텍스트를 교체한다. - fn set_field_text_at(&mut self, location: &FieldLocation, field_range_index: usize, value: &str) -> Result<(), HwpError> { + fn set_field_text_at( + &mut self, + location: &FieldLocation, + field_range_index: usize, + value: &str, + ) -> Result<(), HwpError> { // raw_stream 무효화: 직렬화 시 수정된 모델을 사용하도록 강제 if let Some(sec) = self.document.sections.get_mut(location.section_index) { sec.raw_stream = None; } let para = self.get_para_mut_at_location(location)?; - let fr = para.field_ranges.get(field_range_index) + let fr = para + .field_ranges + .get(field_range_index) .ok_or_else(|| HwpError::InvalidField("field_range 인덱스 초과".into()))? .clone(); @@ -239,10 +280,18 @@ impl DocumentCore { /// FieldLocation에 해당하는 Paragraph의 가변 참조를 반환한다. /// /// 중첩 경로는 1단계만 지원 (표 셀 또는 글상자 내 문단). - fn get_para_mut_at_location(&mut self, location: &FieldLocation) -> Result<&mut Paragraph, HwpError> { - let sec = self.document.sections.get_mut(location.section_index) + fn get_para_mut_at_location( + &mut self, + location: &FieldLocation, + ) -> Result<&mut Paragraph, HwpError> { + let sec = self + .document + .sections + .get_mut(location.section_index) .ok_or_else(|| HwpError::InvalidField("구역 인덱스 초과".into()))?; - let host_para = sec.paragraphs.get_mut(location.para_index) + let host_para = sec + .paragraphs + .get_mut(location.para_index) .ok_or_else(|| HwpError::InvalidField("문단 인덱스 초과".into()))?; if location.nested_path.is_empty() { @@ -252,27 +301,45 @@ impl DocumentCore { // 1단계 중첩만 처리 let entry = &location.nested_path[0]; match entry { - NestedEntry::TableCell { control_index, cell_index, para_index } => { - let ctrl = host_para.controls.get_mut(*control_index) + NestedEntry::TableCell { + control_index, + cell_index, + para_index, + } => { + let ctrl = host_para + .controls + .get_mut(*control_index) .ok_or_else(|| HwpError::InvalidField("컨트롤 인덱스 초과".into()))?; if let Control::Table(ref mut table) = ctrl { - let cell = table.cells.get_mut(*cell_index) + let cell = table + .cells + .get_mut(*cell_index) .ok_or_else(|| HwpError::InvalidField("셀 인덱스 초과".into()))?; - cell.paragraphs.get_mut(*para_index) + cell.paragraphs + .get_mut(*para_index) .ok_or_else(|| HwpError::InvalidField("셀 문단 인덱스 초과".into())) } else { Err(HwpError::InvalidField("예상된 Table 컨트롤이 아님".into())) } } - NestedEntry::TextBox { control_index, para_index } => { - let ctrl = host_para.controls.get_mut(*control_index) + NestedEntry::TextBox { + control_index, + para_index, + } => { + let ctrl = host_para + .controls + .get_mut(*control_index) .ok_or_else(|| HwpError::InvalidField("컨트롤 인덱스 초과".into()))?; if let Control::Shape(ref mut shape) = ctrl { - let drawing = shape.drawing_mut() - .ok_or_else(|| HwpError::InvalidField("Shape에 DrawingObjAttr 없음".into()))?; - let tb = drawing.text_box.as_mut() + let drawing = shape.drawing_mut().ok_or_else(|| { + HwpError::InvalidField("Shape에 DrawingObjAttr 없음".into()) + })?; + let tb = drawing + .text_box + .as_mut() .ok_or_else(|| HwpError::InvalidField("Shape에 TextBox 없음".into()))?; - tb.paragraphs.get_mut(*para_index) + tb.paragraphs + .get_mut(*para_index) .ok_or_else(|| HwpError::InvalidField("글상자 문단 인덱스 초과".into())) } else { Err(HwpError::InvalidField("예상된 Shape 컨트롤이 아님".into())) @@ -284,8 +351,16 @@ impl DocumentCore { /// 본문 문단의 커서 위치에서 필드를 제거한다 (텍스트 유지, 필드 마커만 삭제). /// /// 성공 시 `{"ok":true}`, 필드가 없으면 에러를 반환한다. - pub fn remove_field_at(&mut self, section_idx: usize, para_idx: usize, char_offset: usize) -> Result { - let para = self.document.sections.get_mut(section_idx) + pub fn remove_field_at( + &mut self, + section_idx: usize, + para_idx: usize, + char_offset: usize, + ) -> Result { + let para = self + .document + .sections + .get_mut(section_idx) .and_then(|s| s.paragraphs.get_mut(para_idx)) .ok_or_else(|| HwpError::InvalidField("문단 위치 초과".into()))?; remove_field_in_para(para, char_offset)?; @@ -305,27 +380,39 @@ impl DocumentCore { is_textbox: bool, ) -> Result { let para = { - let host = self.document.sections.get_mut(section_idx) + let host = self + .document + .sections + .get_mut(section_idx) .and_then(|s| s.paragraphs.get_mut(parent_para_idx)) .ok_or_else(|| HwpError::InvalidField("호스트 문단 위치 초과".into()))?; - let ctrl = host.controls.get_mut(control_idx) + let ctrl = host + .controls + .get_mut(control_idx) .ok_or_else(|| HwpError::InvalidField("컨트롤 인덱스 초과".into()))?; if is_textbox { if let Control::Shape(shape) = ctrl { - let drawing = shape.drawing_mut() - .ok_or_else(|| HwpError::InvalidField("Shape에 DrawingObjAttr 없음".into()))?; - let tb = drawing.text_box.as_mut() + let drawing = shape.drawing_mut().ok_or_else(|| { + HwpError::InvalidField("Shape에 DrawingObjAttr 없음".into()) + })?; + let tb = drawing + .text_box + .as_mut() .ok_or_else(|| HwpError::InvalidField("Shape에 TextBox 없음".into()))?; - tb.paragraphs.get_mut(cell_para_idx) + tb.paragraphs + .get_mut(cell_para_idx) .ok_or_else(|| HwpError::InvalidField("글상자 문단 인덱스 초과".into()))? } else { return Err(HwpError::InvalidField("예상된 Shape 컨트롤이 아님".into())); } } else { if let Control::Table(table) = ctrl { - let cell = table.cells.get_mut(cell_idx) + let cell = table + .cells + .get_mut(cell_idx) .ok_or_else(|| HwpError::InvalidField("셀 인덱스 초과".into()))?; - cell.paragraphs.get_mut(cell_para_idx) + cell.paragraphs + .get_mut(cell_para_idx) .ok_or_else(|| HwpError::InvalidField("셀 문단 인덱스 초과".into()))? } else { return Err(HwpError::InvalidField("예상된 Table 컨트롤이 아님".into())); @@ -342,12 +429,20 @@ impl DocumentCore { /// 본문 문단: `set_active_field(sec, para, char_offset)` /// 설정 후 해당 페이지의 렌더 트리 캐시를 무효화한다. /// 활성 필드를 설정한다. 변경이 발생하면 true를 반환한다. - pub fn set_active_field(&mut self, section_idx: usize, para_idx: usize, char_offset: usize) -> bool { + pub fn set_active_field( + &mut self, + section_idx: usize, + para_idx: usize, + char_offset: usize, + ) -> bool { use super::super::ActiveFieldInfo; let ctrl_idx = self.find_field_control_idx(section_idx, para_idx, char_offset, None); if let Some(ci) = ctrl_idx { let new_info = ActiveFieldInfo { - section_idx, para_idx, control_idx: ci, cell_path: None, + section_idx, + para_idx, + control_idx: ci, + cell_path: None, }; if self.active_field.as_ref() != Some(&new_info) { self.active_field = Some(new_info); @@ -360,17 +455,32 @@ impl DocumentCore { /// 셀/글상자 내 활성 필드를 설정한다. 변경이 발생하면 true를 반환한다. pub fn set_active_field_in_cell( - &mut self, section_idx: usize, parent_para_idx: usize, control_idx: usize, - cell_idx: usize, cell_para_idx: usize, char_offset: usize, is_textbox: bool, + &mut self, + section_idx: usize, + parent_para_idx: usize, + control_idx: usize, + cell_idx: usize, + cell_para_idx: usize, + char_offset: usize, + is_textbox: bool, ) -> bool { use super::super::ActiveFieldInfo; let cell_path = Some(vec![(control_idx, cell_idx, cell_para_idx)]); let ctrl_idx = self.find_field_control_idx_in_cell( - section_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx, char_offset, is_textbox, + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + char_offset, + is_textbox, ); if let Some(ci) = ctrl_idx { let new_info = ActiveFieldInfo { - section_idx, para_idx: cell_para_idx, control_idx: ci, cell_path, + section_idx, + para_idx: cell_para_idx, + control_idx: ci, + cell_path, }; if self.active_field.as_ref() != Some(&new_info) { self.active_field = Some(new_info); @@ -393,8 +503,16 @@ impl DocumentCore { /// /// 커서가 필드 범위 내에 있으면 필드 정보를 JSON으로 반환하고, /// 필드 밖이면 `{"inField":false}`를 반환한다. - pub fn get_field_info_at(&self, section_idx: usize, para_idx: usize, char_offset: usize) -> String { - let para = match self.document.sections.get(section_idx) + pub fn get_field_info_at( + &self, + section_idx: usize, + para_idx: usize, + char_offset: usize, + ) -> String { + let para = match self + .document + .sections + .get(section_idx) .and_then(|s| s.paragraphs.get(para_idx)) { Some(p) => p, @@ -415,8 +533,12 @@ impl DocumentCore { is_textbox: bool, ) -> String { let para = (|| { - let host = self.document.sections.get(section_idx)? - .paragraphs.get(parent_para_idx)?; + let host = self + .document + .sections + .get(section_idx)? + .paragraphs + .get(parent_para_idx)?; let ctrl = host.controls.get(control_idx)?; if is_textbox { if let Control::Shape(shape) = ctrl { @@ -439,8 +561,11 @@ impl DocumentCore { /// path 기반: 중첩 표 셀의 필드 범위 정보를 조회한다. pub fn get_field_info_at_by_path( - &self, section_idx: usize, parent_para_idx: usize, - path: &[(usize, usize, usize)], char_offset: usize, + &self, + section_idx: usize, + parent_para_idx: usize, + path: &[(usize, usize, usize)], + char_offset: usize, ) -> String { match self.resolve_paragraph_by_path(section_idx, parent_para_idx, path) { Ok(para) => field_info_at_in_para(para, char_offset), @@ -450,8 +575,11 @@ impl DocumentCore { /// path 기반: 중첩 표 셀 내 활성 필드를 설정한다. pub fn set_active_field_by_path( - &mut self, section_idx: usize, parent_para_idx: usize, - path: &[(usize, usize, usize)], char_offset: usize, + &mut self, + section_idx: usize, + parent_para_idx: usize, + path: &[(usize, usize, usize)], + char_offset: usize, ) -> bool { use super::super::ActiveFieldInfo; let para = match self.resolve_paragraph_by_path(section_idx, parent_para_idx, path) { @@ -465,7 +593,10 @@ impl DocumentCore { // cell_path: 전체 path를 저장 (중첩 표 구분용) let cell_path = Some(path.to_vec()); let new_info = ActiveFieldInfo { - section_idx, para_idx: cell_para_idx, control_idx: ci, cell_path, + section_idx, + para_idx: cell_para_idx, + control_idx: ci, + cell_path, }; if self.active_field.as_ref() != Some(&new_info) { self.active_field = Some(new_info); @@ -547,7 +678,9 @@ fn collect_fields_from_paragraph( para_index: 0, }); // 셀의 첫 문단 텍스트를 값으로 사용 - let value = cell.paragraphs.first() + let value = cell + .paragraphs + .first() .map(|p| p.text.clone()) .unwrap_or_default(); result.push(FieldInfo { @@ -616,7 +749,9 @@ fn field_location_json(loc: &FieldLocation) -> String { }).collect(); format!( "{{\"sectionIndex\":{},\"paraIndex\":{},\"path\":[{}]}}", - loc.section_index, loc.para_index, path_entries.join(","), + loc.section_index, + loc.para_index, + path_entries.join(","), ) } } @@ -624,31 +759,52 @@ fn field_location_json(loc: &FieldLocation) -> String { impl DocumentCore { /// 본문 문단에서 커서 위치의 필드 컨트롤 인덱스를 찾는다. fn find_field_control_idx( - &self, section_idx: usize, para_idx: usize, char_offset: usize, + &self, + section_idx: usize, + para_idx: usize, + char_offset: usize, _cell_path: Option<(usize, usize, usize)>, ) -> Option { - let para = self.document.sections.get(section_idx)? - .paragraphs.get(para_idx)?; + let para = self + .document + .sections + .get(section_idx)? + .paragraphs + .get(para_idx)?; find_field_ctrl_idx_in_para(para, char_offset) } /// 셀/글상자 내 문단에서 커서 위치의 필드 컨트롤 인덱스를 찾는다. fn find_field_control_idx_in_cell( - &self, section_idx: usize, parent_para_idx: usize, control_idx: usize, - cell_idx: usize, cell_para_idx: usize, char_offset: usize, is_textbox: bool, + &self, + section_idx: usize, + parent_para_idx: usize, + control_idx: usize, + cell_idx: usize, + cell_para_idx: usize, + char_offset: usize, + is_textbox: bool, ) -> Option { - let host = self.document.sections.get(section_idx)? - .paragraphs.get(parent_para_idx)?; + let host = self + .document + .sections + .get(section_idx)? + .paragraphs + .get(parent_para_idx)?; let ctrl = host.controls.get(control_idx)?; let para = if is_textbox { if let Control::Shape(shape) = ctrl { let tb = shape.drawing()?.text_box.as_ref()?; tb.paragraphs.get(cell_para_idx)? - } else { return None; } + } else { + return None; + } } else { if let Control::Table(table) = ctrl { table.cells.get(cell_idx)?.paragraphs.get(cell_para_idx)? - } else { return None; } + } else { + return None; + } }; find_field_ctrl_idx_in_para(para, char_offset) } @@ -658,7 +814,9 @@ impl DocumentCore { fn find_field_ctrl_idx_in_para(para: &Paragraph, char_offset: usize) -> Option { for fr in ¶.field_ranges { if let Some(Control::Field(field)) = para.controls.get(fr.control_idx) { - if field.field_type != FieldType::ClickHere { continue; } + if field.field_type != FieldType::ClickHere { + continue; + } if char_offset >= fr.start_char_idx && char_offset <= fr.end_char_idx { return Some(fr.control_idx); } @@ -684,7 +842,9 @@ fn remove_field_in_para(para: &mut Paragraph, char_offset: usize) -> Result<(), para.field_ranges.remove(i); Ok(()) } - None => Err(HwpError::InvalidField("커서 위치에 누름틀 필드 없음".into())), + None => Err(HwpError::InvalidField( + "커서 위치에 누름틀 필드 없음".into(), + )), } } diff --git a/src/document_core/queries/form_query.rs b/src/document_core/queries/form_query.rs index 9143456e..12c86ff9 100644 --- a/src/document_core/queries/form_query.rs +++ b/src/document_core/queries/form_query.rs @@ -5,7 +5,7 @@ use crate::document_core::DocumentCore; use crate::model::control::{Control, FormType}; use crate::model::table::Table; -use crate::renderer::render_tree::{RenderNode, RenderNodeType, FormObjectNode}; +use crate::renderer::render_tree::{FormObjectNode, RenderNode, RenderNodeType}; impl DocumentCore { /// 페이지 좌표에서 양식 개체를 찾는다. @@ -24,14 +24,17 @@ impl DocumentCore { let form_type_str = form_type_to_str(form.form_type); // 셀 내부 위치 정보 직렬화 let cell_loc_json = if let Some((tpi, tci, ci_idx, cp_idx)) = form.cell_location { - format!(r#","inCell":true,"tablePara":{},"tableCi":{},"cellIdx":{},"cellPara":{}"#, - tpi, tci, ci_idx, cp_idx) + format!( + r#","inCell":true,"tablePara":{},"tableCi":{},"cellIdx":{},"cellPara":{}"#, + tpi, tci, ci_idx, cp_idx + ) } else { String::new() }; // sec/para는 최상위 문단 인덱스로 반환 // cell_location이 있으면 table_para_index를 para로 사용 - let (ret_para, ret_ci) = if let Some((tpi, _tci, _ci_idx, _cp_idx)) = form.cell_location { + let (ret_para, ret_ci) = if let Some((tpi, _tci, _ci_idx, _cp_idx)) = form.cell_location + { (tpi, form.control_index) } else { (form.para_index, form.control_index) @@ -46,7 +49,10 @@ impl DocumentCore { form.value, escape_json(&form.caption), escape_json(&form.text), - bbox.0, bbox.1, bbox.2, bbox.3, + bbox.0, + bbox.1, + bbox.2, + bbox.3, cell_loc_json, )) } else { @@ -61,7 +67,10 @@ impl DocumentCore { para: usize, ci: usize, ) -> Result { - let control = self.document.sections.get(sec) + let control = self + .document + .sections + .get(sec) .and_then(|s| s.paragraphs.get(para)) .and_then(|p| p.controls.get(ci)); @@ -92,7 +101,10 @@ impl DocumentCore { ci: usize, value_json: &str, ) -> Result { - let control = self.document.sections.get_mut(sec) + let control = self + .document + .sections + .get_mut(sec) .and_then(|s| s.paragraphs.get_mut(para)) .and_then(|p| p.controls.get_mut(ci)); @@ -123,14 +135,29 @@ impl DocumentCore { form_ci: usize, value_json: &str, ) -> Result { - let form = self.document.sections.get_mut(sec) + let form = self + .document + .sections + .get_mut(sec) .and_then(|s| s.paragraphs.get_mut(table_para)) .and_then(|p| p.controls.get_mut(table_ci)) - .and_then(|c| if let Control::Table(ref mut t) = c { Some(t.as_mut()) } else { None }) + .and_then(|c| { + if let Control::Table(ref mut t) = c { + Some(t.as_mut()) + } else { + None + } + }) .and_then(|t: &mut Table| t.cells.get_mut(cell_idx)) .and_then(|cell| cell.paragraphs.get_mut(cell_para)) .and_then(|p| p.controls.get_mut(form_ci)) - .and_then(|c| if let Control::Form(ref mut f) = c { Some(f) } else { None }); + .and_then(|c| { + if let Control::Form(ref mut f) = c { + Some(f) + } else { + None + } + }); match form { Some(f) => { @@ -150,7 +177,10 @@ impl DocumentCore { para: usize, ci: usize, ) -> Result { - let control = self.document.sections.get(sec) + let control = self + .document + .sections + .get(sec) .and_then(|s| s.paragraphs.get(para)) .and_then(|p| p.controls.get(ci)); @@ -158,20 +188,22 @@ impl DocumentCore { Some(Control::Form(f)) => { let form_type_str = form_type_to_str(f.form_type); // properties를 JSON 객체로 직렬화 - let props: Vec = f.properties.iter() + let props: Vec = f + .properties + .iter() .map(|(k, v)| format!(r#""{}":"{}""#, escape_json(k), escape_json(v))) .collect(); let props_json = format!("{{{}}}", props.join(",")); // ComboBox: 스크립트에서 InsertString 항목 추출 let items_json = if f.form_type == FormType::ComboBox { - let items = extract_combobox_items_from_script( - &self.document.extra_streams, &f.name, - ); + let items = + extract_combobox_items_from_script(&self.document.extra_streams, &f.name); if items.is_empty() { "[]".to_string() } else { - let arr: Vec = items.iter() + let arr: Vec = items + .iter() .map(|s| format!(r#""{}""#, escape_json(s))) .collect(); format!("[{}]", arr.join(",")) @@ -215,7 +247,11 @@ fn apply_form_value(f: &mut crate::model::control::FormObject, value_json: &str) } /// 렌더 트리를 재귀 순회하여 좌표에 해당하는 FormObject 노드를 찾는다. -fn find_form_node_at(node: &RenderNode, x: f64, y: f64) -> Option<(&FormObjectNode, (f64, f64, f64, f64))> { +fn find_form_node_at( + node: &RenderNode, + x: f64, + y: f64, +) -> Option<(&FormObjectNode, (f64, f64, f64, f64))> { // 자식 먼저 (더 구체적인 노드 우선) for child in &node.children { if let Some(result) = find_form_node_at(child, x, y) { @@ -244,10 +280,10 @@ fn form_type_to_str(ft: FormType) -> &'static str { fn escape_json(s: &str) -> String { s.replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('\n', "\\n") - .replace('\r', "\\r") - .replace('\t', "\\t") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") } /// 간단한 JSON에서 정수값 추출: `"key":123` @@ -256,7 +292,9 @@ fn extract_json_int(json: &str, key: &str) -> Option { if let Some(pos) = json.find(&pattern) { let start = pos + pattern.len(); let rest = &json[start..]; - let end = rest.find(|c: char| !c.is_ascii_digit() && c != '-').unwrap_or(rest.len()); + let end = rest + .find(|c: char| !c.is_ascii_digit() && c != '-') + .unwrap_or(rest.len()); rest[..end].parse().ok() } else { None @@ -287,11 +325,14 @@ fn extract_json_string(json: &str, key: &str) -> Option { /// extra_streams에서 Scripts/DefaultJScript 스트림을 찾아 디코딩한다. /// HWP 스크립트는 zlib 압축 + UTF-16LE로 저장됨. fn decode_hwp_script(extra_streams: &[(String, Vec)]) -> Option { - let data = extra_streams.iter() + let data = extra_streams + .iter() .find(|(path, _)| path == "/Scripts/DefaultJScript" || path == "Scripts/DefaultJScript") .map(|(_, data)| data)?; - if data.is_empty() { return None; } + if data.is_empty() { + return None; + } // zlib 해제 (raw deflate, no header) use std::io::Read; @@ -302,8 +343,11 @@ fn decode_hwp_script(extra_streams: &[(String, Vec)]) -> Option { } // UTF-16LE 디코딩 - if decompressed.len() < 2 { return None; } - let u16s: Vec = decompressed.chunks_exact(2) + if decompressed.len() < 2 { + return None; + } + let u16s: Vec = decompressed + .chunks_exact(2) .map(|c| u16::from_le_bytes([c[0], c[1]])) .collect(); Some(String::from_utf16_lossy(&u16s)) @@ -351,7 +395,9 @@ fn parse_insert_string_args(args: &str) -> Option<(String, usize)> { // , 인덱스 추출 let after_comma = after_quote.trim_start().strip_prefix(',')?; - let idx_str: String = after_comma.trim().chars() + let idx_str: String = after_comma + .trim() + .chars() .take_while(|c| c.is_ascii_digit()) .collect(); let idx = idx_str.parse().unwrap_or(0); diff --git a/src/document_core/queries/mod.rs b/src/document_core/queries/mod.rs index a96c2623..e150405f 100644 --- a/src/document_core/queries/mod.rs +++ b/src/document_core/queries/mod.rs @@ -1,8 +1,8 @@ -mod rendering; +mod bookmark_query; mod cursor_nav; mod cursor_rect; pub(crate) mod doc_tree_nav; pub(crate) mod field_query; mod form_query; +mod rendering; mod search_query; -mod bookmark_query; diff --git a/src/document_core/queries/rendering.rs b/src/document_core/queries/rendering.rs index 4bab5869..5a86f707 100644 --- a/src/document_core/queries/rendering.rs +++ b/src/document_core/queries/rendering.rs @@ -1,36 +1,91 @@ //! 렌더링/페이지 정보/구성/페이지네이션/페이지 트리 관련 native 메서드 -use std::cell::RefCell; -use crate::model::document::Section; +use super::super::helpers::color_ref_to_css; +use crate::document_core::DocumentCore; +use crate::error::HwpError; use crate::model::control::Control; -use crate::model::paragraph::Paragraph; +use crate::model::document::Section; use crate::model::page::ColumnDef; -use crate::renderer::pagination::{Paginator, PaginationResult}; -use crate::renderer::height_measurer::{MeasuredTable, MeasuredSection, HeightMeasurer}; +use crate::model::paragraph::Paragraph; +use crate::paint::{LayerBuilder, PageLayerTree, RenderProfile}; +use crate::renderer::canvas::CanvasRenderer; +use crate::renderer::composer::{compose_paragraph, compose_section, ComposedParagraph}; +use crate::renderer::height_measurer::{HeightMeasurer, MeasuredSection, MeasuredTable}; +use crate::renderer::html::HtmlRenderer; +use crate::renderer::layer_renderer::LayerRenderer; use crate::renderer::layout::LayoutEngine; +use crate::renderer::page_layout::PageLayoutInfo; +use crate::renderer::pagination::{PaginationResult, Paginator}; use crate::renderer::render_tree::PageRenderTree; -use crate::renderer::svg::SvgRenderer; -use crate::renderer::html::HtmlRenderer; -use crate::renderer::canvas::CanvasRenderer; +#[cfg(all(not(target_arch = "wasm32"), feature = "native-skia"))] +use crate::renderer::skia::SkiaLayerRenderer; use crate::renderer::style_resolver::resolve_styles; -use crate::renderer::composer::{compose_section, compose_paragraph, ComposedParagraph}; -use crate::renderer::page_layout::PageLayoutInfo; -use crate::document_core::DocumentCore; -use crate::error::HwpError; -use super::super::helpers::color_ref_to_css; +use crate::renderer::svg::SvgRenderer; +use crate::renderer::svg_layer::SvgLayerRenderer; +use std::cell::RefCell; impl DocumentCore { - pub fn render_page_svg_native(&self, page_num: u32) -> Result { + fn build_page_tree_for_output(&self, page_num: u32) -> Result { let tree = self.build_page_tree(page_num)?; let _overflows = self.layout_engine.take_overflows(); - let mut renderer = SvgRenderer::new(); + Ok(tree) + } + + fn build_page_layer_tree_for_output(&self, page_num: u32) -> Result { + let tree = self.build_page_tree_cached(page_num)?; + let _overflows = self.layout_engine.take_overflows(); + Ok(self.build_layer_tree_from_page_tree(&tree)) + } + + fn build_layer_tree_from_page_tree(&self, tree: &PageRenderTree) -> PageLayerTree { + let mut builder = LayerBuilder::new(RenderProfile::Screen); + builder.build(tree) + } + + fn configure_svg_renderer(&self, renderer: &mut SvgRenderer) { renderer.show_paragraph_marks = self.show_paragraph_marks; renderer.show_control_codes = self.show_control_codes; renderer.debug_overlay = self.debug_overlay; + } + + pub fn render_page_svg_native(&self, page_num: u32) -> Result { + if matches!( + std::env::var("RHWP_RENDER_PATH").ok().as_deref(), + Some("layer-svg") + ) { + return self.render_page_svg_layer_native(page_num); + } + self.render_page_svg_legacy_native(page_num) + } + + pub fn render_page_svg_legacy_native(&self, page_num: u32) -> Result { + let tree = self.build_page_tree_for_output(page_num)?; + let mut renderer = SvgRenderer::new(); + self.configure_svg_renderer(&mut renderer); renderer.render_tree(&tree); Ok(renderer.output().to_string()) } + pub fn render_page_svg_layer_native(&self, page_num: u32) -> Result { + let layer_tree = self.build_page_layer_tree_for_output(page_num)?; + let mut renderer = SvgLayerRenderer::new(); + renderer.configure_output( + self.show_paragraph_marks, + self.show_control_codes, + self.debug_overlay, + ); + renderer.render_page(&layer_tree); + Ok(renderer.output().to_string()) + } + + #[cfg(all(not(target_arch = "wasm32"), feature = "native-skia"))] + pub fn render_page_png_native(&self, page_num: u32) -> Result, HwpError> { + let layer_tree = self.build_page_layer_tree_for_output(page_num)?; + SkiaLayerRenderer::new() + .render_png(&layer_tree) + .map_err(HwpError::RenderError) + } + /// SVG 렌더링 (폰트 임베딩 옵션 포함) #[cfg(not(target_arch = "wasm32"))] pub fn render_page_svg_with_fonts( @@ -39,12 +94,43 @@ impl DocumentCore { font_embed_mode: crate::renderer::svg::FontEmbedMode, font_paths: &[std::path::PathBuf], ) -> Result { - let tree = self.build_page_tree(page_num)?; - let _overflows = self.layout_engine.take_overflows(); + if matches!( + std::env::var("RHWP_RENDER_PATH").ok().as_deref(), + Some("layer-svg") + ) { + let tree = self.build_page_tree_for_output(page_num)?; + let layer_tree = self.build_layer_tree_from_page_tree(&tree); + + let mut layer_renderer = SvgLayerRenderer::new(); + layer_renderer.configure_output( + self.show_paragraph_marks, + self.show_control_codes, + self.debug_overlay, + ); + layer_renderer.render_page(&layer_tree); + + let mut collector = SvgRenderer::new(); + self.configure_svg_renderer(&mut collector); + collector.font_embed_mode = font_embed_mode; + collector.font_paths = font_paths.to_vec(); + collector.render_tree(&tree); + + let mut svg = layer_renderer.output().to_string(); + if font_embed_mode != crate::renderer::svg::FontEmbedMode::None { + let style_css = crate::renderer::svg::generate_font_style(&collector, font_paths); + if !style_css.is_empty() { + if let Some(pos) = svg.find('>') { + let insert = format!("\n\n", style_css); + svg.insert_str(pos + 1, &insert); + } + } + } + return Ok(svg); + } + + let tree = self.build_page_tree_for_output(page_num)?; let mut renderer = SvgRenderer::new(); - renderer.show_paragraph_marks = self.show_paragraph_marks; - renderer.show_control_codes = self.show_control_codes; - renderer.debug_overlay = self.debug_overlay; + self.configure_svg_renderer(&mut renderer); renderer.font_embed_mode = font_embed_mode; renderer.font_paths = font_paths.to_vec(); renderer.render_tree(&tree); @@ -52,9 +138,7 @@ impl DocumentCore { // 폰트 임베딩 후처리 let mut svg = renderer.output().to_string(); if font_embed_mode != crate::renderer::svg::FontEmbedMode::None { - let style_css = crate::renderer::svg::generate_font_style( - &renderer, font_paths, - ); + let style_css = crate::renderer::svg::generate_font_style(&renderer, font_paths); if !style_css.is_empty() { // 직후에 {}\n", mark_x, node.bbox.y, font_size, mark, @@ -184,8 +197,10 @@ impl HtmlRenderer { } RenderNodeType::Rectangle(rect) => { self.draw_rect( - node.bbox.x, node.bbox.y, - node.bbox.width, node.bbox.height, + node.bbox.x, + node.bbox.y, + node.bbox.width, + node.bbox.height, rect.corner_radius, &rect.style, ); @@ -248,7 +263,11 @@ impl Renderer for HtmlRenderer { } fn draw_text(&mut self, text: &str, x: f64, y: f64, style: &TextStyle) { - let font_size = if style.font_size > 0.0 { style.font_size } else { 12.0 }; + let font_size = if style.font_size > 0.0 { + style.font_size + } else { + 12.0 + }; let color = color_to_css(style.color); let font_family = if style.font_family.is_empty() { "sans-serif".to_string() @@ -291,7 +310,10 @@ impl Renderer for HtmlRenderer { } else { "" }; - css.push_str(&format!("text-decoration:underline;text-decoration-style:{};{}", ul_style, ul_pos)); + css.push_str(&format!( + "text-decoration:underline;text-decoration-style:{};{}", + ul_style, ul_pos + )); } if style.strikethrough { let st_style = match style.strike_shape { @@ -302,7 +324,10 @@ impl Renderer for HtmlRenderer { 11 => "wavy", _ => "solid", }; - css.push_str(&format!("text-decoration:line-through;text-decoration-style:{};", st_style)); + css.push_str(&format!( + "text-decoration:line-through;text-decoration-style:{};", + st_style + )); } // 외곽선 if style.outline_type > 0 { @@ -323,21 +348,36 @@ impl Renderer for HtmlRenderer { // 형광펜 배경 (CharShape.shade_color 기반 — 편집기에서 적용한 형광펜) let shade_rgb = style.shade_color & 0x00FFFFFF; if shade_rgb != 0x00FFFFFF && shade_rgb != 0 { - css.push_str(&format!("background-color:{};", color_to_css(style.shade_color))); + css.push_str(&format!( + "background-color:{};", + color_to_css(style.shade_color) + )); } let ratio = if style.ratio > 0.0 { style.ratio } else { 1.0 }; if (ratio - 1.0).abs() > 0.01 { - css.push_str(&format!("transform:scaleX({:.4});transform-origin:left;", ratio)); + css.push_str(&format!( + "transform:scaleX({:.4});transform-origin:left;", + ratio + )); } self.output.push_str(&format!( "{}\n", - css, escape_html(text), + css, + escape_html(text), )); } - fn draw_rect(&mut self, x: f64, y: f64, w: f64, h: f64, corner_radius: f64, style: &ShapeStyle) { + fn draw_rect( + &mut self, + x: f64, + y: f64, + w: f64, + h: f64, + corner_radius: f64, + style: &ShapeStyle, + ) { let mut css = format!( "position:absolute;left:{}px;top:{}px;width:{}px;height:{}px;", x, y, w, h, @@ -352,7 +392,11 @@ impl Renderer for HtmlRenderer { } if let Some(stroke) = style.stroke_color { - css.push_str(&format!("border:{}px solid {};", style.stroke_width, color_to_css(stroke))); + css.push_str(&format!( + "border:{}px solid {};", + style.stroke_width, + color_to_css(stroke) + )); } self.output.push_str(&format!( @@ -376,7 +420,10 @@ impl Renderer for HtmlRenderer { fn draw_ellipse(&mut self, cx: f64, cy: f64, rx: f64, ry: f64, style: &ShapeStyle) { let mut css = format!( "position:absolute;left:{}px;top:{}px;width:{}px;height:{}px;border-radius:50%;", - cx - rx, cy - ry, rx * 2.0, ry * 2.0, + cx - rx, + cy - ry, + rx * 2.0, + ry * 2.0, ); if let Some(fill) = style.fill_color { @@ -408,17 +455,23 @@ impl Renderer for HtmlRenderer { d.push_str(&format!("C{} {} {} {} {} {} ", x1, y1, x2, y2, x, y)); } PathCommand::ArcTo(rx, ry, x_rot, large_arc, sweep, x, y) => { - d.push_str(&format!("A{} {} {} {} {} {} {} ", - rx, ry, x_rot, + d.push_str(&format!( + "A{} {} {} {} {} {} {} ", + rx, + ry, + x_rot, if *large_arc { 1 } else { 0 }, if *sweep { 1 } else { 0 }, - x, y)); + x, + y + )); } PathCommand::ClosePath => d.push_str("Z "), } } - let fill = style.fill_color + let fill = style + .fill_color .map(|c| color_to_css(c)) .unwrap_or_else(|| "none".to_string()); @@ -474,12 +527,17 @@ mod tests { fn test_html_draw_text() { let mut renderer = HtmlRenderer::new(); renderer.begin_page(800.0, 600.0); - renderer.draw_text("테스트", 10.0, 20.0, &TextStyle { - font_size: 14.0, - bold: true, - italic: true, - ..Default::default() - }); + renderer.draw_text( + "테스트", + 10.0, + 20.0, + &TextStyle { + font_size: 14.0, + bold: true, + italic: true, + ..Default::default() + }, + ); let output = renderer.output(); assert!(output.contains("font-weight:bold")); assert!(output.contains("font-style:italic")); @@ -489,10 +547,17 @@ mod tests { fn test_html_draw_rect() { let mut renderer = HtmlRenderer::new(); renderer.begin_page(800.0, 600.0); - renderer.draw_rect(0.0, 0.0, 100.0, 50.0, 0.0, &ShapeStyle { - fill_color: Some(0x00FF0000), - ..Default::default() - }); + renderer.draw_rect( + 0.0, + 0.0, + 100.0, + 50.0, + 0.0, + &ShapeStyle { + fill_color: Some(0x00FF0000), + ..Default::default() + }, + ); let output = renderer.output(); assert!(output.contains("hwp-rect")); assert!(output.contains("background:#0000ff")); // BGR → RGB @@ -500,8 +565,10 @@ mod tests { #[test] fn test_html_escape() { - assert_eq!(escape_html(""), - "<script>alert('xss')</script>"); + assert_eq!( + escape_html(""), + "<script>alert('xss')</script>" + ); } #[test] diff --git a/src/renderer/layer_renderer.rs b/src/renderer/layer_renderer.rs new file mode 100644 index 00000000..f87bfd41 --- /dev/null +++ b/src/renderer/layer_renderer.rs @@ -0,0 +1,6 @@ +use crate::paint::PageLayerTree; + +/// visual layer tree를 backend 출력으로 재생한다. +pub trait LayerRenderer { + fn render_page(&mut self, tree: &PageLayerTree); +} diff --git a/src/renderer/layout.rs b/src/renderer/layout.rs index abd66b27..0d4217ea 100644 --- a/src/renderer/layout.rs +++ b/src/renderer/layout.rs @@ -3,22 +3,29 @@ //! 페이지 분할 결과를 받아 각 요소의 정확한 위치와 크기를 계산하고 //! 렌더 트리(PageRenderTree)를 생성한다. -use crate::model::paragraph::Paragraph; -use crate::model::shape::{Caption, CaptionDirection, CommonObjAttr, HorzAlign, HorzRelTo, VertAlign, VertRelTo}; -use crate::model::style::{Alignment, BorderLine, BorderLineType, HeadType, Numbering, UnderlineType}; -use crate::model::table::VerticalAlign; -use crate::model::footnote::{FootnoteShape, NumberFormat}; +use super::composer::{compose_paragraph, ComposedParagraph}; +use super::font_metrics_data; +use super::height_measurer::MeasuredTable; +use super::page_layout::{LayoutRect, PageLayoutInfo}; +use super::pagination::{ColumnContent, FootnoteRef, FootnoteSource, PageContent, PageItem}; +use super::render_tree::*; +use super::style_resolver::ResolvedStyleSet; +use super::{ + format_number, hwpunit_to_px, ArrowStyle, AutoNumberCounter, LineStyle, NumberFormat as NumFmt, + PathCommand, ShapeStyle, StrokeDash, TextStyle, DEFAULT_DPI, +}; use crate::model::bin_data::BinDataContent; use crate::model::control::Control; +use crate::model::footnote::{FootnoteShape, NumberFormat}; use crate::model::header_footer::MasterPage; -use super::render_tree::*; -use super::page_layout::{LayoutRect, PageLayoutInfo}; -use super::pagination::{ColumnContent, PageContent, PageItem, FootnoteRef, FootnoteSource}; -use super::height_measurer::MeasuredTable; -use super::composer::{ComposedParagraph, compose_paragraph}; -use super::style_resolver::ResolvedStyleSet; -use super::font_metrics_data; -use super::{TextStyle, ShapeStyle, LineStyle, PathCommand, StrokeDash, ArrowStyle, hwpunit_to_px, DEFAULT_DPI, AutoNumberCounter, format_number, NumberFormat as NumFmt}; +use crate::model::paragraph::Paragraph; +use crate::model::shape::{ + Caption, CaptionDirection, CommonObjAttr, HorzAlign, HorzRelTo, VertAlign, VertRelTo, +}; +use crate::model::style::{ + Alignment, BorderLine, BorderLineType, HeadType, Numbering, UnderlineType, +}; +use crate::model::table::VerticalAlign; /// layout_column_item의 읽기 전용 컨텍스트 (파라미터 묶음) struct ColumnItemCtx<'a> { @@ -60,15 +67,25 @@ pub struct CellContext { impl CellContext { /// 최외곽 표의 컨트롤 인덱스 - pub fn outermost_control(&self) -> usize { self.path[0].control_index } + pub fn outermost_control(&self) -> usize { + self.path[0].control_index + } /// 최외곽 표의 셀 인덱스 - pub fn outermost_cell(&self) -> usize { self.path[0].cell_index } + pub fn outermost_cell(&self) -> usize { + self.path[0].cell_index + } /// 최외곽 표의 셀 문단 인덱스 - pub fn outermost_cell_para(&self) -> usize { self.path[0].cell_para_index } + pub fn outermost_cell_para(&self) -> usize { + self.path[0].cell_para_index + } /// 최내곽 레벨의 엔트리 - pub fn innermost(&self) -> &CellPathEntry { self.path.last().unwrap() } + pub fn innermost(&self) -> &CellPathEntry { + self.path.last().unwrap() + } /// 텍스트 방향 (최내곽 기준) - pub fn text_direction(&self) -> u8 { self.innermost().text_direction } + pub fn text_direction(&self) -> u8 { + self.innermost().text_direction + } } /// 문단 번호 상태 (수준별 카운터) @@ -143,7 +160,6 @@ impl NumberingState { // 현재 수준 증가 self.counters[level] += 1; - // 하위 수준 리셋 for i in (level + 1)..7 { self.counters[i] = 0; @@ -151,7 +167,6 @@ impl NumberingState { self.counters } - } /// 레이아웃 엔진 @@ -221,32 +236,40 @@ pub struct LayoutEngine { /// 현재 활성 필드 위치 — 안내문 렌더링 스킵용 /// (section_idx, para_idx, control_idx, cell_path) /// cell_path: 셀 내 필드일 경우 Some(Vec<(ctrl, cell, para)>) - active_field: std::cell::RefCell>)>>, + active_field: + std::cell::RefCell>)>>, /// 조판부호 표시 여부 show_control_codes: std::cell::Cell, /// 현재 페이지 용지 너비 (표 HorzRelTo::Paper 위치 계산용) current_paper_width: std::cell::Cell, } -mod text_measurement; +mod border_rendering; mod paragraph_layout; +mod picture_footnote; +mod shape_layout; +mod table_cell_content; mod table_layout; mod table_partial; -mod table_cell_content; -mod shape_layout; -mod picture_footnote; -mod border_rendering; +mod text_measurement; mod utils; -pub(crate) use text_measurement::{resolved_to_text_style, estimate_text_width, estimate_text_width_unrounded, compute_char_positions, is_cjk_char, split_into_clusters, find_next_tab_stop, extract_tab_leaders_with_extended}; -pub(crate) use paragraph_layout::{map_pua_bullet_char, ensure_min_baseline}; -pub(crate) use utils::{resolve_numbering_id, find_bin_data, drawing_to_shape_style, drawing_to_line_style, layout_rect_to_bbox, format_page_number}; pub(crate) use border_rendering::{border_width_to_px, create_border_line_nodes}; +pub(crate) use paragraph_layout::{ensure_min_baseline, map_pua_bullet_char}; +pub(crate) use text_measurement::{ + compute_char_positions, estimate_text_width, estimate_text_width_unrounded, + extract_tab_leaders_with_extended, find_next_tab_stop, is_cjk_char, resolved_to_text_style, + split_into_clusters, +}; +pub(crate) use utils::{ + drawing_to_line_style, drawing_to_shape_style, find_bin_data, format_page_number, + layout_rect_to_bbox, resolve_numbering_id, +}; -#[cfg(test)] -mod tests; #[cfg(test)] mod integration_tests; +#[cfg(test)] +mod tests; impl LayoutEngine { pub fn new(dpi: f64) -> Self { @@ -296,7 +319,12 @@ impl LayoutEngine { } /// 이미 렌더된 인라인 이미지 노드의 y 좌표를 dy만큼 이동 (캡션 Top 보정) - fn offset_inline_image_y(node: &mut RenderNode, para_index: usize, control_index: usize, dy: f64) { + fn offset_inline_image_y( + node: &mut RenderNode, + para_index: usize, + control_index: usize, + dy: f64, + ) { for child in node.children.iter_mut() { if let RenderNodeType::Image(ref img) = child.node_type { if img.para_index == Some(para_index) && img.control_index == Some(control_index) { @@ -311,7 +339,9 @@ impl LayoutEngine { /// 번호 카운터를 진행시킨다 (이전 페이지 문단의 번호 재계산용). pub fn advance_numbering(&self, numbering_id: u16, level: u8) { - self.numbering_state.borrow_mut().advance(numbering_id, level, None); + self.numbering_state + .borrow_mut() + .advance(numbering_id, level, None); } /// 잘림 보기 여부를 설정한다. @@ -340,7 +370,10 @@ impl LayoutEngine { } /// 활성 필드 설정 (안내문 렌더링 스킵용) - pub fn set_active_field(&self, info: Option<(usize, usize, usize, Option>)>) { + pub fn set_active_field( + &self, + info: Option<(usize, usize, usize, Option>)>, + ) { *self.active_field.borrow_mut() = info; } @@ -383,26 +416,52 @@ impl LayoutEngine { ); // 페이지 배경 - self.build_page_background(&mut tree, layout, page_border_fill, styles, bin_data_content); + self.build_page_background( + &mut tree, + layout, + page_border_fill, + styles, + bin_data_content, + ); // 쪽 테두리선 self.build_page_borders(&mut tree, layout, page_border_fill, styles); // 바탕쪽 (감추기 설정 시 건너뜀) - let hide_master = page_content.page_hide.as_ref() - .map(|ph| ph.hide_master_page).unwrap_or(false); + let hide_master = page_content + .page_hide + .as_ref() + .map(|ph| ph.hide_master_page) + .unwrap_or(false); if !hide_master { self.build_master_page( - &mut tree, active_master_page, layout, composed, styles, - bin_data_content, page_content.section_index, page_content.page_number, + &mut tree, + active_master_page, + layout, + composed, + styles, + bin_data_content, + page_content.section_index, + page_content.page_number, ); } // 머리말 (감추기 설정 시 건너뜀) - let hide_header = page_content.page_hide.as_ref() - .map(|ph| ph.hide_header).unwrap_or(false); + let hide_header = page_content + .page_hide + .as_ref() + .map(|ph| ph.hide_header) + .unwrap_or(false); if !hide_header { - self.build_header(&mut tree, page_content, header_paragraphs, composed, styles, layout, bin_data_content); + self.build_header( + &mut tree, + page_content, + header_paragraphs, + composed, + styles, + layout, + bin_data_content, + ); } // 본문 영역 노드 @@ -422,9 +481,17 @@ impl LayoutEngine { // 단별 콘텐츠 레이아웃 let mut paper_images: Vec = Vec::new(); self.build_columns( - &mut tree, &mut body_node, &mut paper_images, - page_content, paragraphs, composed, styles, - bin_data_content, measured_tables, layout, outline_numbering_id, + &mut tree, + &mut body_node, + &mut paper_images, + page_content, + paragraphs, + composed, + styles, + bin_data_content, + measured_tables, + layout, + outline_numbering_id, wrap_around_paras, ); @@ -439,16 +506,38 @@ impl LayoutEngine { tree.root.children.push(body_node); // 각주 영역 - self.build_footnote_area(&mut tree, page_content, paragraphs, footnote_shape, styles, layout); + self.build_footnote_area( + &mut tree, + page_content, + paragraphs, + footnote_shape, + styles, + layout, + ); // 꼬리말 + 쪽 번호 (감추기 설정 시 건너뜀) - let hide_footer = page_content.page_hide.as_ref() - .map(|ph| ph.hide_footer).unwrap_or(false); + let hide_footer = page_content + .page_hide + .as_ref() + .map(|ph| ph.hide_footer) + .unwrap_or(false); let mut footer_node = if !hide_footer { - self.build_footer(&mut tree, page_content, footer_paragraphs, composed, styles, layout, bin_data_content) + self.build_footer( + &mut tree, + page_content, + footer_paragraphs, + composed, + styles, + layout, + bin_data_content, + ) } else { let fid = tree.next_id(); - RenderNode::new(fid, RenderNodeType::Footer, layout_rect_to_bbox(&layout.footer_area)) + RenderNode::new( + fid, + RenderNodeType::Footer, + layout_rect_to_bbox(&layout.footer_area), + ) }; self.build_page_number(&mut tree, &mut footer_node, page_content, layout); tree.root.children.push(footer_node); @@ -474,20 +563,37 @@ impl LayoutEngine { // 테이블 컨트롤이 있으면 테이블 렌더링 let has_table = para.controls.iter().any(|c| matches!(c, Control::Table(_))); let has_shape = para.controls.iter().any(|c| matches!(c, Control::Shape(_))); - let has_picture = para.controls.iter().any(|c| matches!(c, Control::Picture(_))); + let has_picture = para + .controls + .iter() + .any(|c| matches!(c, Control::Picture(_))); if has_table { for (ci, ctrl) in para.controls.iter().enumerate() { if let Control::Table(t) = ctrl { - let alignment = styles.para_styles + let alignment = styles + .para_styles .get(para.para_shape_id as usize) .map(|s| s.alignment) .unwrap_or(Alignment::Left); y_offset = self.layout_table( - tree, area_node, t, - 0, styles, area, y_offset, bin_data_content, - None, 0, - Some((i, ci)), alignment, - None, 0.0, 0.0, None, None, None, + tree, + area_node, + t, + 0, + styles, + area, + y_offset, + bin_data_content, + None, + 0, + Some((i, ci)), + alignment, + None, + 0.0, + 0.0, + None, + None, + None, ); } } @@ -506,8 +612,15 @@ impl LayoutEngine { height: area.height - (y_offset - area.y), }; self.layout_picture( - tree, area_node, pic, &pic_container, - bin_data_content, Alignment::Left, None, None, None, + tree, + area_node, + pic, + &pic_container, + bin_data_content, + Alignment::Left, + None, + None, + None, ); let pic_h = hwpunit_to_px(pic.common.height as i32, self.dpi); y_offset += pic_h; @@ -516,8 +629,17 @@ impl LayoutEngine { } else { // TAC Picture: layout_paragraph에서 인라인 배치 y_offset = self.layout_paragraph( - tree, area_node, para, Some(&comp), styles, area, y_offset, - 0, usize::MAX - i, None, Some(bin_data_content), + tree, + area_node, + para, + Some(&comp), + styles, + area, + y_offset, + 0, + usize::MAX - i, + None, + Some(bin_data_content), ); } } else if has_shape { @@ -525,12 +647,20 @@ impl LayoutEngine { for (ci, ctrl) in para.controls.iter().enumerate() { if let Control::Shape(_) = ctrl { self.layout_shape( - tree, area_node, - hf_paragraphs, i, ci, + tree, + area_node, + hf_paragraphs, + i, + ci, 0, // section_index - styles, area, area, area, - y_offset, Alignment::Left, - bin_data_content, &std::collections::HashMap::new(), + styles, + area, + area, + area, + y_offset, + Alignment::Left, + bin_data_content, + &std::collections::HashMap::new(), ); } } @@ -539,8 +669,17 @@ impl LayoutEngine { let mut comp = compose_paragraph(para); self.substitute_hf_field_markers(&mut comp, page_number); y_offset = self.layout_paragraph( - tree, area_node, para, Some(&comp), styles, area, y_offset, - 0, usize::MAX - i, None, None, + tree, + area_node, + para, + Some(&comp), + styles, + area, + y_offset, + 0, + usize::MAX - i, + None, + None, ); } } else { @@ -548,8 +687,17 @@ impl LayoutEngine { let mut comp = compose_paragraph(para); self.substitute_hf_field_markers(&mut comp, page_number); y_offset = self.layout_paragraph( - tree, area_node, para, Some(&comp), styles, area, y_offset, - 0, usize::MAX - i, None, None, + tree, + area_node, + para, + Some(&comp), + styles, + area, + y_offset, + 0, + usize::MAX - i, + None, + None, ); } if y_offset >= area.y + area.height { @@ -571,12 +719,16 @@ impl LayoutEngine { for line in &mut comp.lines { let mut new_runs = Vec::new(); for run in &line.runs { - if !run.text.contains('\u{0015}') && !run.text.contains('\u{0016}') && !run.text.contains('\u{0017}') { + if !run.text.contains('\u{0015}') + && !run.text.contains('\u{0016}') + && !run.text.contains('\u{0017}') + { new_runs.push(run.clone()); continue; } // 마커가 포함된 런 → 치환 후 분할 - let replaced = run.text + let replaced = run + .text .replace('\u{0015}', &page_str) .replace('\u{0016}', &total_str) .replace('\u{0017}', &file_name); @@ -602,11 +754,12 @@ impl LayoutEngine { let bf_idx = (pbf.border_fill_id - 1) as usize; if let Some(bs) = styles.border_styles.get(bf_idx) { let img = bs.image_fill.as_ref().and_then(|img_fill| { - find_bin_data(bin_data_content, img_fill.bin_data_id) - .map(|c| PageBackgroundImage { + find_bin_data(bin_data_content, img_fill.bin_data_id).map(|c| { + PageBackgroundImage { data: c.data.clone(), fill_mode: img_fill.fill_mode, - }) + } + }) }); (bs.fill_color.or(Some(0x00FFFFFF)), bs.gradient.clone(), img) } else { @@ -619,9 +772,16 @@ impl LayoutEngine { (Some(0x00FFFFFF), None, None) }; - let fill_area = page_border_fill.map(|pbf| (pbf.attr >> 3) & 0x03).unwrap_or(0); + let fill_area = page_border_fill + .map(|pbf| (pbf.attr >> 3) & 0x03) + .unwrap_or(0); let bg_bbox = match fill_area { - 1 => BoundingBox::new(layout.body_area.x, layout.body_area.y, layout.body_area.width, layout.body_area.height), + 1 => BoundingBox::new( + layout.body_area.x, + layout.body_area.y, + layout.body_area.width, + layout.body_area.height, + ), _ => BoundingBox::new(0.0, 0.0, layout.page_width, layout.page_height), }; @@ -655,7 +815,12 @@ impl LayoutEngine { 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) + ( + layout.body_area.x, + layout.body_area.y, + layout.body_area.width, + layout.body_area.height, + ) }; let sp_l = hwpunit_to_px(pbf.spacing_left as i32, self.dpi); @@ -669,13 +834,23 @@ impl LayoutEngine { let borders = &bs.borders; let top_nodes = create_border_line_nodes(tree, &borders[2], bx, by, bx + bw, by); - for n in top_nodes { tree.root.children.push(n); } - let bottom_nodes = create_border_line_nodes(tree, &borders[3], bx, by + bh, bx + bw, by + bh); - for n in bottom_nodes { tree.root.children.push(n); } + for n in top_nodes { + tree.root.children.push(n); + } + let bottom_nodes = + create_border_line_nodes(tree, &borders[3], bx, by + bh, bx + bw, by + bh); + for n in bottom_nodes { + tree.root.children.push(n); + } let left_nodes = create_border_line_nodes(tree, &borders[0], bx, by, bx, by + bh); - for n in left_nodes { tree.root.children.push(n); } - let right_nodes = create_border_line_nodes(tree, &borders[1], bx + bw, by, bx + bw, by + bh); - for n in right_nodes { tree.root.children.push(n); } + for n in left_nodes { + tree.root.children.push(n); + } + let right_nodes = + create_border_line_nodes(tree, &borders[1], bx + bw, by, bx + bw, by + bh); + for n in right_nodes { + tree.root.children.push(n); + } } } } @@ -692,7 +867,16 @@ impl LayoutEngine { section_index: usize, page_number: u32, ) { - self.build_master_page(tree, active_master_page, layout, composed, styles, bin_data_content, section_index, page_number); + self.build_master_page( + tree, + active_master_page, + layout, + composed, + styles, + bin_data_content, + section_index, + page_number, + ); } /// 바탕쪽 영역 노드를 생성하여 tree에 추가한다. @@ -712,7 +896,8 @@ impl LayoutEngine { if !mp.paragraphs.is_empty() { let mp_id = tree.next_id(); let paper_area = LayoutRect { - x: 0.0, y: 0.0, + x: 0.0, + y: 0.0, width: layout.page_width, height: layout.page_height, }; @@ -732,47 +917,85 @@ impl LayoutEngine { match ctrl { Control::Shape(_) | Control::Equation(_) => { self.layout_shape( - tree, &mut mp_node, - &mp.paragraphs, pi, ci, + tree, + &mut mp_node, + &mp.paragraphs, + pi, + ci, section_index, - styles, body_area, body_area, &paper_area, - body_area.y, Alignment::Left, + styles, + body_area, + body_area, + &paper_area, + body_area.y, + Alignment::Left, bin_data_content, &std::collections::HashMap::new(), ); } Control::Picture(pic) => { let (pic_w, pic_h) = self.resolve_object_size( - &pic.common, body_area, body_area, &paper_area, + &pic.common, + body_area, + body_area, + &paper_area, ); let (pic_x, pic_y) = self.compute_object_position( - &pic.common, pic_w, pic_h, - body_area, body_area, body_area, &paper_area, - body_area.y, Alignment::Left, + &pic.common, + pic_w, + pic_h, + body_area, + body_area, + body_area, + &paper_area, + body_area.y, + Alignment::Left, ); let pic_area = super::layout::LayoutRect { - x: pic_x, y: pic_y, width: pic_w, height: pic_h, + x: pic_x, + y: pic_y, + width: pic_w, + height: pic_h, }; self.layout_picture( - tree, &mut mp_node, pic, &pic_area, - bin_data_content, Alignment::Left, - Some(section_index), None, None, + tree, + &mut mp_node, + pic, + &pic_area, + bin_data_content, + Alignment::Left, + Some(section_index), + None, + None, ); } Control::Table(t) => { - let alignment = styles.para_styles + let alignment = styles + .para_styles .get(para.para_shape_id as usize) .map(|s| s.alignment) .unwrap_or(Alignment::Left); // 바탕쪽 표: paper_area를 col_area로 전달하여 // compute_table_x/y_position이 올바르게 위치 계산 self.layout_table( - tree, &mut mp_node, - t, section_index, - styles, &paper_area, 0.0, - bin_data_content, None, 0, - Some((pi, ci)), alignment, - None, 0.0, 0.0, None, None, None, + tree, + &mut mp_node, + t, + section_index, + styles, + &paper_area, + 0.0, + bin_data_content, + None, + 0, + Some((pi, ci)), + alignment, + None, + 0.0, + 0.0, + None, + None, + None, ); } _ => {} @@ -786,20 +1009,30 @@ impl LayoutEngine { comp.tab_extended.clear(); // LINE_SEG vpos로 문단 시작 y 결정 (빈 문단 건너뜀 보상) if let Some(first_ls) = para.line_segs.first() { - let vpos_y = paper_area.y + hwpunit_to_px(first_ls.vertical_pos, self.dpi); + let vpos_y = + paper_area.y + hwpunit_to_px(first_ls.vertical_pos, self.dpi); if vpos_y > mp_y_offset { mp_y_offset = vpos_y; } } mp_y_offset = self.layout_paragraph( - tree, &mut mp_node, para, Some(&comp), styles, - &paper_area, mp_y_offset, - 0, usize::MAX - pi, None, None, + tree, + &mut mp_node, + para, + Some(&comp), + styles, + &paper_area, + mp_y_offset, + 0, + usize::MAX - pi, + None, + None, ); } else { // 빈 문단: LINE_SEG vpos로 y 위치 갱신 if let Some(first_ls) = para.line_segs.first() { - let vpos_y = paper_area.y + hwpunit_to_px(first_ls.vertical_pos, self.dpi); + let vpos_y = + paper_area.y + hwpunit_to_px(first_ls.vertical_pos, self.dpi); let lh = hwpunit_to_px(first_ls.line_height, self.dpi); let ls = hwpunit_to_px(first_ls.line_spacing, self.dpi); mp_y_offset = (vpos_y + lh + ls).max(mp_y_offset); @@ -830,7 +1063,9 @@ impl LayoutEngine { layout_rect_to_bbox(&layout.header_area), ); // 감추기 플래그가 설정된 페이지는 머리말 내용을 렌더링하지 않음 - let hidden = self.hidden_header_footer.borrow() + let hidden = self + .hidden_header_footer + .borrow() .contains(&(page_content.page_index, true)); if !hidden { if let Some(hf_ref) = &page_content.active_header { @@ -838,8 +1073,11 @@ impl LayoutEngine { if let Some(ctrl) = para.controls.get(hf_ref.control_index) { if let Control::Header(header) = ctrl { self.layout_header_footer_paragraphs( - tree, &mut header_node, - &header.paragraphs, composed, styles, + tree, + &mut header_node, + &header.paragraphs, + composed, + styles, &layout.header_area, page_content.page_index, page_content.page_number, @@ -904,7 +1142,9 @@ impl LayoutEngine { layout_rect_to_bbox(&layout.footer_area), ); // 감추기 플래그가 설정된 페이지는 꼬리말 내용을 렌더링하지 않음 - let hidden = self.hidden_header_footer.borrow() + let hidden = self + .hidden_header_footer + .borrow() .contains(&(page_content.page_index, false)); if !hidden { if let Some(hf_ref) = &page_content.active_footer { @@ -912,8 +1152,11 @@ impl LayoutEngine { if let Some(ctrl) = para.controls.get(hf_ref.control_index) { if let Control::Footer(footer) = ctrl { self.layout_header_footer_paragraphs( - tree, &mut footer_node, - &footer.paragraphs, composed, styles, + tree, + &mut footer_node, + &footer.paragraphs, + composed, + styles, &layout.footer_area, page_content.page_index, page_content.page_number, @@ -955,7 +1198,10 @@ impl LayoutEngine { let sep_node = RenderNode::new( sep_id, RenderNodeType::Line(LineNode::new( - sep_x, sep_y1, sep_x, sep_y2, + sep_x, + sep_y1, + sep_x, + sep_y2, LineStyle { color: layout.separator_color, width: line_width, @@ -963,7 +1209,12 @@ impl LayoutEngine { ..Default::default() }, )), - BoundingBox::new(sep_x - line_width / 2.0, sep_y1, line_width, sep_y2 - sep_y1), + BoundingBox::new( + sep_x - line_width / 2.0, + sep_y1, + line_width, + sep_y2 - sep_y1, + ), ); body_node.children.push(sep_node); } @@ -983,7 +1234,9 @@ impl LayoutEngine { let mut footnote_layout = layout.clone(); if !page_content.footnotes.is_empty() { let fn_height = self.estimate_footnote_area_height( - &page_content.footnotes, paragraphs, footnote_shape, + &page_content.footnotes, + paragraphs, + footnote_shape, ); footnote_layout.update_footnote_area(fn_height); } @@ -1028,8 +1281,11 @@ impl LayoutEngine { return; } let page_num_text = format_page_number( - page_content.page_number, pnp.format, - pnp.prefix_char, pnp.suffix_char, pnp.dash_char, + page_content.page_number, + pnp.format, + pnp.prefix_char, + pnp.suffix_char, + pnp.dash_char, ); let target_area = match pnp.position { 1..=3 | 7 | 9 => &layout.header_area, @@ -1045,17 +1301,21 @@ impl LayoutEngine { 3 | 6 => target_area.x + target_area.width - text_width, 2 | 5 => target_area.x + (target_area.width - text_width) / 2.0, // 바깥쪽: 홀수쪽→오른쪽, 짝수쪽→왼쪽 - 7 | 8 => if is_odd_page { - target_area.x + target_area.width - text_width - } else { - target_area.x - }, + 7 | 8 => { + if is_odd_page { + target_area.x + target_area.width - text_width + } else { + target_area.x + } + } // 안쪽: 홀수쪽→왼쪽, 짝수쪽→오른쪽 - 9 | 10 => if is_odd_page { - target_area.x - } else { - target_area.x + target_area.width - text_width - }, + 9 | 10 => { + if is_odd_page { + target_area.x + } else { + target_area.x + target_area.width - text_width + } + } _ => target_area.x + (target_area.width - text_width) / 2.0, }; @@ -1130,7 +1390,9 @@ impl LayoutEngine { // (한 단에만 할당되더라도 모든 단에 적용) let body_wide_reserved: Vec<(usize, f64)> = if page_content.column_contents.len() > 1 { self.calculate_body_wide_shape_reserved( - paragraphs, &page_content.column_contents, &layout.body_area, + paragraphs, + &page_content.column_contents, + &layout.body_area, ) } else { Vec::new() @@ -1160,18 +1422,26 @@ impl LayoutEngine { x: col_area_base.x, y: current_zone_start_y, width: col_area_base.width, - height: (col_area_base.y + col_area_base.height - current_zone_start_y).max(0.0), + height: (col_area_base.y + col_area_base.height - current_zone_start_y) + .max(0.0), } } else { *col_area_base }; let (col_node, y_offset) = self.build_single_column( - tree, paper_images, - col_content, page_content, - paragraphs, composed, styles, - bin_data_content, measured_tables, - layout, zone_layout, &col_area, + tree, + paper_images, + col_content, + page_content, + paragraphs, + composed, + styles, + bin_data_content, + measured_tables, + layout, + zone_layout, + &col_area, outline_numbering_id, wrap_around_paras, &body_wide_reserved, @@ -1219,7 +1489,10 @@ impl LayoutEngine { // TopAndBottom 글상자/표/이미지의 앵커 문단별 예약 높이 목록 let mut shape_reserved = self.calculate_shape_reserved_heights( - paragraphs, &col_content.items, col_area, &layout.body_area, + paragraphs, + &col_content.items, + col_area, + &layout.body_area, ); // body_area 전체에 걸치는 개체의 예약 높이 병합 (현재 단에도 반영) for &(pi, bottom_y) in body_wide_reserved { @@ -1239,9 +1512,8 @@ impl LayoutEngine { } } - - - let mut para_start_y: std::collections::HashMap = std::collections::HashMap::new(); + let mut para_start_y: std::collections::HashMap = + std::collections::HashMap::new(); let multi_col_width = if zone_layout.column_areas.len() > 1 { let widths: Vec = zone_layout.column_areas.iter().map(|a| a.width).collect(); @@ -1270,21 +1542,22 @@ impl LayoutEngine { // 페이지 첫 항목의 vpos를 기준점으로 삼아 모든 페이지에서 vpos 보정 적용 let mut vpos_page_base: Option = col_content.items.first().and_then(|item| { match item { - PageItem::FullParagraph { para_index } => { - paragraphs.get(*para_index) - .and_then(|p| p.line_segs.first()) - .map(|seg| seg.vertical_pos) - } - PageItem::PartialParagraph { para_index, start_line, .. } => { - paragraphs.get(*para_index) - .and_then(|p| p.line_segs.get(*start_line)) - .map(|seg| seg.vertical_pos) - } - PageItem::Table { para_index, .. } => { - paragraphs.get(*para_index) - .and_then(|p| p.line_segs.first()) - .map(|seg| seg.vertical_pos) - } + PageItem::FullParagraph { para_index } => paragraphs + .get(*para_index) + .and_then(|p| p.line_segs.first()) + .map(|seg| seg.vertical_pos), + PageItem::PartialParagraph { + para_index, + start_line, + .. + } => paragraphs + .get(*para_index) + .and_then(|p| p.line_segs.get(*start_line)) + .map(|seg| seg.vertical_pos), + PageItem::Table { para_index, .. } => paragraphs + .get(*para_index) + .and_then(|p| p.line_segs.first()) + .map(|seg| seg.vertical_pos), // PartialTable/Shape: 지연 보정 사용 _ => None, } @@ -1311,63 +1584,70 @@ impl LayoutEngine { } if !shape_jumped && !prev_tac_seg_applied { - if let Some(prev_pi) = prev_layout_para { - if item_para != prev_pi { - // 글앞으로/글뒤로 Shape가 있는 문단: vpos에 Shape 높이가 포함되어 과대 → bypass - let prev_has_overlay_shape = paragraphs.get(prev_pi).map(|p| { + if let Some(prev_pi) = prev_layout_para { + if item_para != prev_pi { + // 글앞으로/글뒤로 Shape가 있는 문단: vpos에 Shape 높이가 포함되어 과대 → bypass + let prev_has_overlay_shape = paragraphs.get(prev_pi).map(|p| { p.controls.iter().any(|c| matches!(c, Control::Shape(s) if matches!(s.common().text_wrap, crate::model::shape::TextWrap::InFrontOfText | crate::model::shape::TextWrap::BehindText))) }).unwrap_or(false); - if !prev_has_overlay_shape { - if let Some(prev_para) = paragraphs.get(prev_pi) { - let prev_seg = prev_para.line_segs.iter().rev().find(|ls| { - ls.segment_width > 0 && (ls.segment_width - col_width_hu).abs() < 3000 - }); - if let Some(seg) = prev_seg { - if !(seg.vertical_pos == 0 && prev_pi > 0) { - let vpos_end = seg.vertical_pos + seg.line_height + seg.line_spacing; - let base = if let Some(b) = vpos_page_base { - b - } else if let Some(b) = vpos_lazy_base { - b - } else { - // 지연 보정: 첫 보정 시점에서 기준점 산출 - // sequential y_offset에서 역산하여 기준 vpos 결정 - let y_delta_hu = ((y_offset - col_area.y) / self.dpi * 7200.0).round() as i32; - let lazy_base = vpos_end - y_delta_hu; - // lazy_base가 음수이면 자리차지 표 등으로 y_offset이 - // vpos 누적보다 크게 밀린 것 → 역산 무효 - if lazy_base < 0 { - // 보정 건너뛰기: base를 vpos_end로 설정하여 - // end_y = col_area.y + 0 → 검증 실패 → 보정 미적용 - vpos_end - } else { - vpos_lazy_base = Some(lazy_base); - lazy_base + if !prev_has_overlay_shape { + if let Some(prev_para) = paragraphs.get(prev_pi) { + let prev_seg = prev_para.line_segs.iter().rev().find(|ls| { + ls.segment_width > 0 + && (ls.segment_width - col_width_hu).abs() < 3000 + }); + if let Some(seg) = prev_seg { + if !(seg.vertical_pos == 0 && prev_pi > 0) { + let vpos_end = + seg.vertical_pos + seg.line_height + seg.line_spacing; + let base = if let Some(b) = vpos_page_base { + b + } else if let Some(b) = vpos_lazy_base { + b + } else { + // 지연 보정: 첫 보정 시점에서 기준점 산출 + // sequential y_offset에서 역산하여 기준 vpos 결정 + let y_delta_hu = ((y_offset - col_area.y) / self.dpi + * 7200.0) + .round() + as i32; + let lazy_base = vpos_end - y_delta_hu; + // lazy_base가 음수이면 자리차지 표 등으로 y_offset이 + // vpos 누적보다 크게 밀린 것 → 역산 무효 + if lazy_base < 0 { + // 보정 건너뛰기: base를 vpos_end로 설정하여 + // end_y = col_area.y + 0 → 검증 실패 → 보정 미적용 + vpos_end + } else { + vpos_lazy_base = Some(lazy_base); + lazy_base + } + }; + let end_y = + col_area.y + hwpunit_to_px(vpos_end - base, self.dpi); + // 자가 검증: 보정값이 컬럼 영역 내에 있고 + // 현재 y_offset보다 뒤로 가지 않아야 유효 + if end_y >= col_area.y + && end_y <= col_area.y + col_area.height + && end_y >= y_offset - 1.0 + { + y_offset = end_y; + } } - }; - let end_y = col_area.y - + hwpunit_to_px(vpos_end - base, self.dpi); - // 자가 검증: 보정값이 컬럼 영역 내에 있고 - // 현재 y_offset보다 뒤로 가지 않아야 유효 - if end_y >= col_area.y && end_y <= col_area.y + col_area.height - && end_y >= y_offset - 1.0 - { - y_offset = end_y; } } } } - } - } - } // !prev_has_overlay_shape + } // !prev_has_overlay_shape } // !shape_jumped prev_layout_para = Some(item_para); // Percent 전환: 표 하단과 비교 (Task #9) if fix_overlay_active { - let is_fixed = paragraphs.get(item_para) + let is_fixed = paragraphs + .get(item_para) .and_then(|p| styles.para_styles.get(p.para_shape_id as usize)) .map(|ps| ps.line_spacing_type == crate::model::style::LineSpacingType::Fixed) .unwrap_or(false); @@ -1381,10 +1661,22 @@ impl LayoutEngine { } let (new_y, was_tac) = self.layout_column_item( - tree, &mut col_node, paper_images, &mut para_start_y, - item, page_content, paragraphs, composed, styles, - bin_data_content, measured_tables, layout, col_area, - outline_numbering_id, multi_col_width, y_offset, + tree, + &mut col_node, + paper_images, + &mut para_start_y, + item, + page_content, + paragraphs, + composed, + styles, + bin_data_content, + measured_tables, + layout, + col_area, + outline_numbering_id, + multi_col_width, + y_offset, prev_tac_seg_applied, wrap_around_paras, ); @@ -1399,8 +1691,10 @@ impl LayoutEngine { // 표 시작 y와 시각적 높이 저장 (Percent 전환 시 비교용) let ps = styles.para_styles.get(para.para_shape_id as usize); let sa = ps.map(|s| s.spacing_after).unwrap_or(0.0); - fix_table_start_y = y_offset - hwpunit_to_px( - seg.line_height + seg.line_spacing, self.dpi).max(0.0) - sa; + fix_table_start_y = y_offset + - hwpunit_to_px(seg.line_height + seg.line_spacing, self.dpi) + .max(0.0) + - sa; fix_table_visual_h = hwpunit_to_px(seg.line_height, self.dpi); fix_overlay_active = true; } @@ -1412,8 +1706,10 @@ impl LayoutEngine { // 표/Shape의 LINE_SEG lh는 개체 높이를 포함하여 실제 렌더링 높이와 다르므로 // vpos 누적이 순차 y_offset과 drift를 일으킴 → 기준점 재산출 필요 // TAC/비-TAC 모두 해당 (비-TAC 표도 vpos에 표 높이가 포함됨) - let is_table_or_shape = matches!(item, - PageItem::Table { .. } | PageItem::PartialTable { .. } | PageItem::Shape { .. }); + let is_table_or_shape = matches!( + item, + PageItem::Table { .. } | PageItem::PartialTable { .. } | PageItem::Shape { .. } + ); if was_tac || is_table_or_shape { vpos_page_base = None; vpos_lazy_base = None; @@ -1425,7 +1721,9 @@ impl LayoutEngine { if y_offset > col_bottom + tolerance { let (item_type, para_idx) = match item { PageItem::FullParagraph { para_index } => ("FullParagraph", *para_index), - PageItem::PartialParagraph { para_index, .. } => ("PartialParagraph", *para_index), + PageItem::PartialParagraph { para_index, .. } => { + ("PartialParagraph", *para_index) + } PageItem::Table { para_index, .. } => ("Table", *para_index), PageItem::PartialTable { para_index, .. } => ("PartialTable", *para_index), PageItem::Shape { para_index, .. } => ("Shape", *para_index), @@ -1444,10 +1742,17 @@ impl LayoutEngine { // 2차 패스: 글상자(Shape) z-order 정렬 후 렌더링 self.layout_column_shapes_pass( - tree, &mut col_node, paper_images, - col_content, page_content, - paragraphs, composed, styles, - bin_data_content, layout, col_area, + tree, + &mut col_node, + paper_images, + col_content, + page_content, + paragraphs, + composed, + styles, + bin_data_content, + layout, + col_area, ¶_start_y, ); @@ -1470,16 +1775,23 @@ impl LayoutEngine { for (bf_id, x, y_start, w, y_end) in groups { let height = y_end - y_start; - if height <= 0.0 { continue; } + if height <= 0.0 { + continue; + } let idx = (bf_id as usize).saturating_sub(1); let border_style = styles.border_styles.get(idx); let fill_color = border_style.and_then(|bs| bs.fill_color); let (stroke_color, stroke_width) = if let Some(bs) = border_style { - let has_border = bs.borders.iter().any(|b| - !matches!(b.line_type, crate::model::style::BorderLineType::None) && b.width > 0); + let has_border = bs.borders.iter().any(|b| { + !matches!(b.line_type, crate::model::style::BorderLineType::None) + && b.width > 0 + }); if has_border { let top = &bs.borders[2]; - (Some(top.color), super::layout::border_rendering::border_width_to_px(top.width)) + ( + Some(top.color), + super::layout::border_rendering::border_width_to_px(top.width), + ) } else { (None, 0.0) } @@ -1534,9 +1846,18 @@ impl LayoutEngine { wrap_around_paras: &[super::pagination::WrapAroundPara], ) -> (f64, bool) { let ctx = ColumnItemCtx { - page_content, paragraphs, composed, styles, bin_data_content, - measured_tables, layout, col_area, outline_numbering_id, - multi_col_width, prev_tac_seg_applied, wrap_around_paras, + page_content, + paragraphs, + composed, + styles, + bin_data_content, + measured_tables, + layout, + col_area, + outline_numbering_id, + multi_col_width, + prev_tac_seg_applied, + wrap_around_paras, }; match item { PageItem::FullParagraph { para_index } => { @@ -1547,9 +1868,17 @@ impl LayoutEngine { if let Some(para) = paragraphs.get(*para_index) { para_start_y.insert(*para_index, y_offset); self.layout_paragraph( - tree, col_node, para, Some(comp), styles, - col_area, y_offset, page_content.section_index, - *para_index, multi_col_width, Some(bin_data_content), + tree, + col_node, + para, + Some(comp), + styles, + col_area, + y_offset, + page_content.section_index, + *para_index, + multi_col_width, + Some(bin_data_content), ); } } @@ -1563,14 +1892,26 @@ impl LayoutEngine { && !crate::renderer::height_measurer::is_tac_table_inline(t, seg_width, ¶.text, ¶.controls)))); if has_block_table { let comp = composed.get(*para_index); - let para_style_id = comp.map(|c| c.para_style_id as usize).unwrap_or(para.para_shape_id as usize); + let para_style_id = comp + .map(|c| c.para_style_id as usize) + .unwrap_or(para.para_shape_id as usize); if let Some(para_style) = styles.para_styles.get(para_style_id) { // 번호 카운터 전진 (후속 문단의 번호 연속성 유지) // Bullet은 카운터를 사용하지 않으므로 제외 - if para_style.head_type == HeadType::Outline || para_style.head_type == HeadType::Number { - let nid = resolve_numbering_id(para_style.head_type, para_style.numbering_id, outline_numbering_id); + if para_style.head_type == HeadType::Outline + || para_style.head_type == HeadType::Number + { + let nid = resolve_numbering_id( + para_style.head_type, + para_style.numbering_id, + outline_numbering_id, + ); if nid > 0 { - self.numbering_state.borrow_mut().advance(nid, para_style.para_level, para.numbering_restart); + self.numbering_state.borrow_mut().advance( + nid, + para_style.para_level, + para.numbering_restart, + ); } } if para_style.spacing_before > 0.0 { @@ -1580,17 +1921,29 @@ impl LayoutEngine { // 어울림 표 호스트 문단의 텍스트는 layout_wrap_around_paras에서 처리 let is_wrap_host = para.controls.iter().any(|c| { if let Control::Table(t) = c { - !t.common.treat_as_char && matches!(t.common.text_wrap, crate::model::shape::TextWrap::Square) - } else { false } + !t.common.treat_as_char + && matches!( + t.common.text_wrap, + crate::model::shape::TextWrap::Square + ) + } else { + false + } }); // 블록 표/도형 외에 실제 텍스트가 있는지 확인 // (예: [선][선][표][표]참고문헌 → 표 아래에 텍스트 렌더링 필요) - let has_real_text = !is_wrap_host && para.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}' && !c.is_whitespace()); + let has_real_text = !is_wrap_host + && para + .text + .chars() + .any(|c| c > '\u{001F}' && c != '\u{FFFC}' && !c.is_whitespace()); if has_real_text { if let Some(comp) = comp { // 컨트롤 전용 줄(runs가 모두 제어문자)을 건너뛰고 텍스트 줄부터 렌더링 let text_start_line = comp.lines.iter().position(|line| { - line.runs.iter().any(|r| r.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}')) + line.runs.iter().any(|r| { + r.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}') + }) }); if let Some(start_line) = text_start_line { para_start_y.insert(*para_index, y_offset); @@ -1622,7 +1975,10 @@ impl LayoutEngine { if has_inline_tables { // 인라인 표 문단도 번호 카운터 전진 필요 self.apply_paragraph_numbering( - composed.get(*para_index), para, styles, outline_numbering_id, + composed.get(*para_index), + para, + styles, + outline_numbering_id, ); para_start_y.insert(*para_index, y_offset); y_offset = self.layout_inline_table_paragraph( @@ -1641,7 +1997,10 @@ impl LayoutEngine { } else { let comp = composed.get(*para_index); let numbered_comp = self.apply_paragraph_numbering( - comp, para, styles, outline_numbering_id, + comp, + para, + styles, + outline_numbering_id, ); let final_comp = numbered_comp.as_ref().or(comp); @@ -1665,14 +2024,20 @@ impl LayoutEngine { // LINE_SEG lh가 Shape+캡션+간격을 모두 포함하므로 max(Shape.height, lh)를 사용. // 보정 시 원래 문단 간격(spacing_after)도 유지한다. { - let has_tac_shape = para.controls.iter() + let has_tac_shape = para + .controls + .iter() .any(|c| matches!(c, Control::Shape(s) if s.common().treat_as_char)); if has_tac_shape { // LINE_SEG lh = 이미지+캡션+간격 전체 높이 - let seg_lh: f64 = para.line_segs.iter() + let seg_lh: f64 = para + .line_segs + .iter() .map(|seg| hwpunit_to_px(seg.line_height, self.dpi)) .fold(0.0f64, f64::max); - let shape_max_h: f64 = para.controls.iter() + let shape_max_h: f64 = para + .controls + .iter() .filter_map(|c| match c { Control::Shape(s) if s.common().treat_as_char => { Some(hwpunit_to_px(s.common().height as i32, self.dpi)) @@ -1685,7 +2050,8 @@ impl LayoutEngine { let para_start = *para_start_y.get(para_index).unwrap_or(&y_offset); let shape_bottom = para_start + effective_h; if shape_bottom > y_offset { - let spacing = styles.para_styles + let spacing = styles + .para_styles .get(para.para_shape_id as usize) .map(|s| s.spacing_after) .unwrap_or(0.0); @@ -1695,31 +2061,43 @@ impl LayoutEngine { } } // 각주 위첨자: footnote_positions가 있으면 인라인으로 이미 처리됨 - let has_inline_fn = composed.get(*para_index) - .map(|c| !c.footnote_positions.is_empty()).unwrap_or(false); + let has_inline_fn = composed + .get(*para_index) + .map(|c| !c.footnote_positions.is_empty()) + .unwrap_or(false); if !has_inline_fn { - self.add_footnote_superscripts( - tree, col_node, para, styles, - ); + self.add_footnote_superscripts(tree, col_node, para, styles); } } } - PageItem::PartialParagraph { para_index, start_line, end_line } => { + PageItem::PartialParagraph { + para_index, + start_line, + end_line, + } => { if let Some(para) = paragraphs.get(*para_index) { // TAC 블록 표 문단의 post-text PP: 텍스트가 공백만이면 건너뜀 // (Table PageItem에서 이미 y_offset이 결정됨) if prev_tac_seg_applied { - let seg_width = para.line_segs.first().map(|s| s.segment_width).unwrap_or(0); - let has_tac_block = para.controls.iter().any(|c| + let seg_width = + para.line_segs.first().map(|s| s.segment_width).unwrap_or(0); + let has_tac_block = para.controls.iter().any(|c| { matches!(c, Control::Table(t) if t.common.treat_as_char && !crate::renderer::height_measurer::is_tac_table_inline( - t, seg_width, ¶.text, ¶.controls))); + t, seg_width, ¶.text, ¶.controls)) + }); if has_tac_block { let pp_text_only_ws = if let Some(comp) = composed.get(*para_index) { comp.lines[*start_line..*end_line].iter().all(|line| { - line.runs.iter().all(|r| r.text.chars().all(|c| c.is_whitespace() || c <= '\u{001F}' || c == '\u{FFFC}')) + line.runs.iter().all(|r| { + r.text.chars().all(|c| { + c.is_whitespace() || c <= '\u{001F}' || c == '\u{FFFC}' + }) + }) }) - } else { false }; + } else { + false + }; if pp_text_only_ws { // Table PageItem에서 이미 표 높이가 반영됨 // 공백만인 PartialParagraph는 높이 추가 없이 건너뜀 @@ -1730,7 +2108,10 @@ impl LayoutEngine { // 첫 부분에서만 번호 카운터 전진 + 번호 텍스트 적용 let comp = if *start_line == 0 { let numbered = self.apply_paragraph_numbering( - composed.get(*para_index), para, styles, outline_numbering_id, + composed.get(*para_index), + para, + styles, + outline_numbering_id, ); // numbered가 있으면 composed 업데이트는 불가하므로 // layout_partial_paragraph에 직접 전달 @@ -1755,25 +2136,58 @@ impl LayoutEngine { ); } } - PageItem::Table { para_index, control_index } => { + PageItem::Table { + para_index, + control_index, + } => { return self.layout_table_item( - tree, col_node, paper_images, para_start_y, - *para_index, *control_index, &ctx, y_offset, + tree, + col_node, + paper_images, + para_start_y, + *para_index, + *control_index, + &ctx, + y_offset, ); } - PageItem::PartialTable { para_index, control_index, start_row, end_row, is_continuation, - split_start_content_offset, split_end_content_limit } => { + PageItem::PartialTable { + para_index, + control_index, + start_row, + end_row, + is_continuation, + split_start_content_offset, + split_end_content_limit, + } => { y_offset = self.layout_partial_table_item( - tree, col_node, para_start_y, - *para_index, *control_index, *start_row, *end_row, - *is_continuation, *split_start_content_offset, *split_end_content_limit, - &ctx, y_offset, + tree, + col_node, + para_start_y, + *para_index, + *control_index, + *start_row, + *end_row, + *is_continuation, + *split_start_content_offset, + *split_end_content_limit, + &ctx, + y_offset, ); } - PageItem::Shape { para_index, control_index } => { + PageItem::Shape { + para_index, + control_index, + } => { y_offset = self.layout_shape_item( - tree, col_node, paper_images, para_start_y, - *para_index, *control_index, &ctx, y_offset, + tree, + col_node, + paper_images, + para_start_y, + *para_index, + *control_index, + &ctx, + y_offset, ); } } @@ -1794,14 +2208,24 @@ impl LayoutEngine { mut y_offset: f64, ) -> (f64, bool) { let ColumnItemCtx { - page_content, paragraphs, composed, styles, bin_data_content, - measured_tables, layout, col_area, multi_col_width, - prev_tac_seg_applied, wrap_around_paras, .. + page_content, + paragraphs, + composed, + styles, + bin_data_content, + measured_tables, + layout, + col_area, + multi_col_width, + prev_tac_seg_applied, + wrap_around_paras, + .. } = ctx; // 표 앵커 문단의 y 위치 등록 // TAC 표: 이전 TAC가 y_offset을 진행시킨 경우 갱신 (같은 문단 TAC+블록 구조) // 비-TAC 표: 문단 시작 y를 유지 (각 표가 독립적으로 vert offset 기준 배치) - let is_current_tac = paragraphs.get(para_index) + let is_current_tac = paragraphs + .get(para_index) .and_then(|p| p.controls.get(control_index)) .map(|c| matches!(c, Control::Table(t) if t.common.treat_as_char)) .unwrap_or(false); @@ -1814,25 +2238,32 @@ impl LayoutEngine { } let para_y_for_table = *para_start_y.get(¶_index).unwrap_or(&y_offset); if let Some(para) = paragraphs.get(para_index) { - let is_tac = para.controls.get(control_index) + let is_tac = para + .controls + .get(control_index) .map(|c| matches!(c, Control::Table(t) if t.common.treat_as_char)) .unwrap_or(false); // ── 표 위 간격 ── { let comp = composed.get(para_index); - let ps_id = comp.map(|c| c.para_style_id as usize) + let ps_id = comp + .map(|c| c.para_style_id as usize) .unwrap_or(para.para_shape_id as usize); let is_column_top = (y_offset - col_area.y).abs() < 1.0; if is_tac { if !prev_tac_seg_applied { - let outer_margin_top_px = if let Some(Control::Table(t)) = para.controls.get(control_index) { - hwpunit_to_px(t.outer_margin_top as i32, self.dpi) - } else { - 0.0 - }; + let outer_margin_top_px = + if let Some(Control::Table(t)) = para.controls.get(control_index) { + hwpunit_to_px(t.outer_margin_top as i32, self.dpi) + } else { + 0.0 + }; if !is_column_top { - let spacing_before = styles.para_styles.get(ps_id) - .map(|ps| ps.spacing_before).unwrap_or(0.0); + let spacing_before = styles + .para_styles + .get(ps_id) + .map(|ps| ps.spacing_before) + .unwrap_or(0.0); if spacing_before > 0.0 { y_offset += spacing_before; } @@ -1856,26 +2287,50 @@ impl LayoutEngine { }) }); if !is_tac && !text_already_laid_out { - let host_is_not_square = if let Some(Control::Table(ht)) = para.controls.get(control_index) { - !matches!(ht.common.text_wrap, crate::model::shape::TextWrap::Square) - } else { true }; + let host_is_not_square = + if let Some(Control::Table(ht)) = para.controls.get(control_index) { + !matches!(ht.common.text_wrap, crate::model::shape::TextWrap::Square) + } else { + true + }; if host_is_not_square { - let has_real_text = para.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}'); + let has_real_text = + para.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}'); if has_real_text { if let Some(comp) = composed.get(para_index) { let text_start_line = comp.lines.iter().position(|line| { - line.runs.iter().any(|r| r.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}')) + line.runs.iter().any(|r| { + r.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}') + }) }); if let Some(start_line) = text_start_line { - let text_end_line = comp.lines.iter().rposition(|line| { - line.runs.iter().any(|r| r.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}')) - }).map(|i| i + 1).unwrap_or(comp.lines.len()); + let text_end_line = comp + .lines + .iter() + .rposition(|line| { + line.runs.iter().any(|r| { + r.text + .chars() + .any(|c| c > '\u{001F}' && c != '\u{FFFC}') + }) + }) + .map(|i| i + 1) + .unwrap_or(comp.lines.len()); para_start_y.insert(para_index, y_offset); let _text_y_end = self.layout_partial_paragraph( - tree, col_node, para, Some(comp), styles, - col_area, y_offset, start_line, text_end_line, - page_content.section_index, para_index, - *multi_col_width, Some(bin_data_content), + tree, + col_node, + para, + Some(comp), + styles, + col_area, + y_offset, + start_line, + text_end_line, + page_content.section_index, + para_index, + *multi_col_width, + Some(bin_data_content), ); } } @@ -1884,35 +2339,31 @@ impl LayoutEngine { } // ── 표 레이아웃 ── let mut tac_seg_applied = false; - let tac_table_y_before = y_offset; // Task #9: 표 렌더 전 y 보존 + let tac_table_y_before = y_offset; // Task #9: 표 렌더 전 y 보존 if let Some(Control::Table(t)) = para.controls.get(control_index) { - let mt = measured_tables.iter().find(|mt| - mt.para_index == para_index && mt.control_index == control_index - ); - let para_style = styles.para_styles - .get(para.para_shape_id as usize); - let alignment = para_style - .map(|s| s.alignment) - .unwrap_or(Alignment::Left); - let margin_left = para_style - .map(|s| s.margin_left) - .unwrap_or(0.0); - let indent = para_style - .map(|s| s.indent) - .unwrap_or(0.0); + let mt = measured_tables + .iter() + .find(|mt| mt.para_index == para_index && mt.control_index == control_index); + let para_style = styles.para_styles.get(para.para_shape_id as usize); + let alignment = para_style.map(|s| s.alignment).unwrap_or(Alignment::Left); + let margin_left = para_style.map(|s| s.margin_left).unwrap_or(0.0); + let indent = para_style.map(|s| s.indent).unwrap_or(0.0); let effective_margin = if indent > 0.0 { margin_left + indent } else { margin_left }; - let margin_right = para_style - .map(|s| s.margin_right) - .unwrap_or(0.0); + let margin_right = para_style.map(|s| s.margin_right).unwrap_or(0.0); let table_y_before = y_offset; - let tbl_is_square = matches!(t.common.text_wrap, crate::model::shape::TextWrap::Square); + let tbl_is_square = + matches!(t.common.text_wrap, crate::model::shape::TextWrap::Square); // インラインTAC表: paragraph_layoutで計算された位置を使用 let inline_pos = if is_tac { - tree.get_inline_shape_position(page_content.section_index, para_index, control_index) + tree.get_inline_shape_position( + page_content.section_index, + para_index, + control_index, + ) } else { None }; @@ -1926,14 +2377,23 @@ impl LayoutEngine { // vert=Paper로 body_area 위에 배치되는 표 let renders_above_body = !is_tac && matches!(t.common.vert_rel_to, crate::model::shape::VertRelTo::Paper) - && matches!(t.common.text_wrap, crate::model::shape::TextWrap::TopAndBottom) + && matches!( + t.common.text_wrap, + crate::model::shape::TextWrap::TopAndBottom + ) && { let tbl_h = hwpunit_to_px(t.common.height as i32, self.dpi); let v_off = hwpunit_to_px(t.common.vertical_offset as i32, self.dpi); let tbl_y = match t.common.vert_align { - crate::model::shape::VertAlign::Top | crate::model::shape::VertAlign::Inside => v_off, - crate::model::shape::VertAlign::Center => (layout.page_height - tbl_h) / 2.0 + v_off, - crate::model::shape::VertAlign::Bottom | crate::model::shape::VertAlign::Outside => layout.page_height - tbl_h - v_off, + crate::model::shape::VertAlign::Top + | crate::model::shape::VertAlign::Inside => v_off, + crate::model::shape::VertAlign::Center => { + (layout.page_height - tbl_h) / 2.0 + v_off + } + crate::model::shape::VertAlign::Bottom + | crate::model::shape::VertAlign::Outside => { + layout.page_height - tbl_h - v_off + } }; tbl_y < layout.body_area.y }; @@ -1945,40 +2405,75 @@ impl LayoutEngine { layout_rect_to_bbox(&layout.body_area), ); let _table_y_end = self.layout_table( - tree, &mut tmp_node, t, - page_content.section_index, styles, &layout.body_area, - y_offset, bin_data_content, mt, 0, + tree, + &mut tmp_node, + t, + page_content.section_index, + styles, + &layout.body_area, + y_offset, + bin_data_content, + mt, + 0, Some((para_index, control_index)), - alignment, None, effective_margin, margin_right, - tbl_inline_x, None, Some(para_y_for_table), + alignment, + None, + effective_margin, + margin_right, + tbl_inline_x, + None, + Some(para_y_for_table), ); for child in tmp_node.children.drain(..) { paper_images.push(child); } } else { - let table_y_start = if let Some((_, iy)) = inline_pos { iy } else { y_offset }; + let table_y_start = if let Some((_, iy)) = inline_pos { + iy + } else { + y_offset + }; y_offset = self.layout_table( - tree, col_node, t, - page_content.section_index, styles, col_area, - table_y_start, bin_data_content, mt, 0, + tree, + col_node, + t, + page_content.section_index, + styles, + col_area, + table_y_start, + bin_data_content, + mt, + 0, Some((para_index, control_index)), - alignment, None, effective_margin, margin_right, - tbl_inline_x, None, Some(para_y_for_table), + alignment, + None, + effective_margin, + margin_right, + tbl_inline_x, + None, + Some(para_y_for_table), ); } let table_y_end = y_offset; // layout_table 반환값 보존 - // ── TAC 표: 줄간격 처리 ── - // layout_table 반환값(표 하단)에 line_spacing을 더하여 다음 표 시작 y 결정 + // ── TAC 표: 줄간격 처리 ── + // layout_table 반환값(표 하단)에 line_spacing을 더하여 다음 표 시작 y 결정 if is_tac { let seg_idx = control_index; - let tac_count_total = para.controls.iter() + let tac_count_total = para + .controls + .iter() .filter(|c| matches!(c, Control::Table(t) if t.common.treat_as_char)) .count(); - let tac_idx_current = para.controls.iter().take(control_index + 1) + let tac_idx_current = para + .controls + .iter() + .take(control_index + 1) .filter(|c| matches!(c, Control::Table(t) if t.common.treat_as_char)) .count(); // TAC 표 사이에 non-TAC 표가 있는지 확인 - let has_non_tac_between = para.controls.iter() + let has_non_tac_between = para + .controls + .iter() .skip(control_index + 1) .take_while(|c| !matches!(c, Control::Table(t) if t.common.treat_as_char)) .any(|c| matches!(c, Control::Table(t) if !t.common.treat_as_char)); @@ -1986,7 +2481,9 @@ impl LayoutEngine { // 다음 TAC가 있으면: vpos 차이분만 추가 (= line_spacing) // 이후 tac_seg_applied 경로의 line_spacing 추가를 스킵하기 위해 // 여기서 직접 return (spacing_after/line_spacing 이중 적용 방지) - if let (Some(seg), Some(next_seg)) = (para.line_segs.get(seg_idx), para.line_segs.get(seg_idx + 1)) { + if let (Some(seg), Some(next_seg)) = + (para.line_segs.get(seg_idx), para.line_segs.get(seg_idx + 1)) + { let gap = next_seg.vertical_pos - (seg.vertical_pos + seg.line_height); y_offset += hwpunit_to_px(gap, self.dpi); } @@ -1998,7 +2495,8 @@ impl LayoutEngine { let line_end = para_y_for_table + hwpunit_to_px(seg.vertical_pos + seg.line_height, self.dpi); let clamped = line_end.min(table_y_end); - let max_correction = hwpunit_to_px(seg.line_spacing * 2 + 1000, self.dpi); + let max_correction = + hwpunit_to_px(seg.line_spacing * 2 + 1000, self.dpi); if clamped > y_offset && (clamped - y_offset) <= max_correction { y_offset = clamped; } @@ -2007,18 +2505,28 @@ impl LayoutEngine { tac_seg_applied = true; } // ── 어울림 문단 렌더링 ── - let table_is_square = matches!(t.common.text_wrap, crate::model::shape::TextWrap::Square); + let table_is_square = + matches!(t.common.text_wrap, crate::model::shape::TextWrap::Square); if !is_tac && table_is_square && !wrap_around_paras.is_empty() { let wrap_cs = para.line_segs.first().map(|s| s.column_start).unwrap_or(0); let wrap_sw = para.line_segs.first().map(|s| s.segment_width).unwrap_or(0); let wrap_text_x = col_area.x + hwpunit_to_px(wrap_cs, self.dpi); let wrap_text_width = hwpunit_to_px(wrap_sw, self.dpi); self.layout_wrap_around_paras( - tree, col_node, paragraphs, composed, styles, col_area, + tree, + col_node, + paragraphs, + composed, + styles, + col_area, page_content.section_index, - para_index, wrap_around_paras, - table_y_before, y_offset, - wrap_text_x, wrap_text_width, 0.0, + para_index, + wrap_around_paras, + table_y_before, + y_offset, + wrap_text_x, + wrap_text_width, + 0.0, bin_data_content, ); } @@ -2027,26 +2535,38 @@ impl LayoutEngine { let is_above_body = if let Some(Control::Table(t)) = para.controls.get(control_index) { !t.common.treat_as_char && matches!(t.common.vert_rel_to, crate::model::shape::VertRelTo::Paper) - && matches!(t.common.text_wrap, crate::model::shape::TextWrap::TopAndBottom) + && matches!( + t.common.text_wrap, + crate::model::shape::TextWrap::TopAndBottom + ) && { let v_off = hwpunit_to_px(t.common.vertical_offset as i32, self.dpi); let tbl_y = match t.common.vert_align { - crate::model::shape::VertAlign::Top | crate::model::shape::VertAlign::Inside => v_off, + crate::model::shape::VertAlign::Top + | crate::model::shape::VertAlign::Inside => v_off, _ => v_off, }; tbl_y < layout.body_area.y } - } else { false }; + } else { + false + }; if !tac_seg_applied && !is_above_body { let comp = composed.get(para_index); - let para_style_id = comp.map(|c| c.para_style_id as usize).unwrap_or(para.para_shape_id as usize); + let para_style_id = comp + .map(|c| c.para_style_id as usize) + .unwrap_or(para.para_shape_id as usize); if let Some(para_style) = styles.para_styles.get(para_style_id) { if para_style.spacing_after > 0.0 { y_offset += para_style.spacing_after; } } if let Some(seg) = para.line_segs.last() { - let gap = if seg.line_spacing > 0 { seg.line_spacing } else { seg.line_height }; + let gap = if seg.line_spacing > 0 { + seg.line_spacing + } else { + seg.line_height + }; y_offset += hwpunit_to_px(gap, self.dpi); } } @@ -2057,12 +2577,15 @@ impl LayoutEngine { } else if seg.line_spacing < 0 { // 음수 ls (Fixed 줄간격 TAC 표): y를 문단 advance로 리셋 (Task #9) // 표 렌더 높이가 아닌, 일반 문단과 동일한 lh+ls advance 사용 - let advance = hwpunit_to_px(seg.line_height + seg.line_spacing, self.dpi).max(0.0); + let advance = + hwpunit_to_px(seg.line_height + seg.line_spacing, self.dpi).max(0.0); y_offset = tac_table_y_before + advance; } } let comp = composed.get(para_index); - let ps_id = comp.map(|c| c.para_style_id as usize).unwrap_or(para.para_shape_id as usize); + let ps_id = comp + .map(|c| c.para_style_id as usize) + .unwrap_or(para.para_shape_id as usize); if let Some(ps) = styles.para_styles.get(ps_id) { if ps.spacing_after > 0.0 { y_offset += ps.spacing_after; @@ -2074,30 +2597,61 @@ impl LayoutEngine { if !is_tac { let seg_width = para.line_segs.first().map(|s| s.segment_width).unwrap_or(0); for (ci, ctrl) in para.controls.iter().enumerate() { - if ci == control_index { continue; } + if ci == control_index { + continue; + } if let Control::Table(inline_t) = ctrl { if inline_t.common.treat_as_char - && crate::renderer::height_measurer::is_tac_table_inline(inline_t, seg_width, ¶.text, ¶.controls) + && crate::renderer::height_measurer::is_tac_table_inline( + inline_t, + seg_width, + ¶.text, + ¶.controls, + ) { - let mt = measured_tables.iter().find(|m| m.para_index == para_index && m.control_index == ci); - let alignment = composed.get(para_index) - .map(|c| styles.para_styles.get(c.para_style_id as usize) - .map(|s| s.alignment).unwrap_or(Alignment::Left)) + let mt = measured_tables + .iter() + .find(|m| m.para_index == para_index && m.control_index == ci); + let alignment = composed + .get(para_index) + .map(|c| { + styles + .para_styles + .get(c.para_style_id as usize) + .map(|s| s.alignment) + .unwrap_or(Alignment::Left) + }) .unwrap_or(Alignment::Left); // paragraph_layout에서 계산된 인라인 좌표 사용 let inline_pos = tree.get_inline_shape_position( - page_content.section_index, para_index, ci); + page_content.section_index, + para_index, + ci, + ); let (inline_x, inline_y) = if let Some((ix, iy)) = inline_pos { (Some(ix), iy) } else { (None, para_y_for_table) }; let tac_new_y = self.layout_table( - tree, col_node, inline_t, - page_content.section_index, styles, col_area, inline_y, - bin_data_content, mt, 0, + tree, + col_node, + inline_t, + page_content.section_index, + styles, + col_area, + inline_y, + bin_data_content, + mt, + 0, Some((para_index, ci)), - alignment, None, 0.0, 0.0, inline_x, None, None, + alignment, + None, + 0.0, + 0.0, + inline_x, + None, + None, ); y_offset = y_offset.max(tac_new_y); } @@ -2133,32 +2687,63 @@ impl LayoutEngine { mut y_offset: f64, ) -> f64 { let ColumnItemCtx { - page_content, paragraphs, composed, styles, bin_data_content, - measured_tables, col_area, multi_col_width, wrap_around_paras, .. + page_content, + paragraphs, + composed, + styles, + bin_data_content, + measured_tables, + col_area, + multi_col_width, + wrap_around_paras, + .. } = ctx; // ── 분할 표 첫 부분: 호스트 문단 텍스트 렌더링 ── if !is_continuation { if let Some(para) = paragraphs.get(para_index) { - let is_tac = para.controls.get(control_index) + let is_tac = para + .controls + .get(control_index) .map(|c| matches!(c, Control::Table(t) if t.common.treat_as_char)) .unwrap_or(false); if !is_tac { - let has_real_text = para.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}'); + let has_real_text = + para.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}'); if has_real_text { if let Some(comp) = composed.get(para_index) { let text_start_line = comp.lines.iter().position(|line| { - line.runs.iter().any(|r| r.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}')) + line.runs.iter().any(|r| { + r.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}') + }) }); if let Some(start_line) = text_start_line { - let text_end_line = comp.lines.iter().rposition(|line| { - line.runs.iter().any(|r| r.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}')) - }).map(|i| i + 1).unwrap_or(comp.lines.len()); + let text_end_line = comp + .lines + .iter() + .rposition(|line| { + line.runs.iter().any(|r| { + r.text + .chars() + .any(|c| c > '\u{001F}' && c != '\u{FFFC}') + }) + }) + .map(|i| i + 1) + .unwrap_or(comp.lines.len()); para_start_y.insert(para_index, y_offset); let _text_y_end = self.layout_partial_paragraph( - tree, col_node, para, Some(comp), styles, - col_area, y_offset, start_line, text_end_line, - page_content.section_index, para_index, - *multi_col_width, Some(bin_data_content), + tree, + col_node, + para, + Some(comp), + styles, + col_area, + y_offset, + start_line, + text_end_line, + page_content.section_index, + para_index, + *multi_col_width, + Some(bin_data_content), ); } } @@ -2175,15 +2760,18 @@ impl LayoutEngine { } else { (0.0, 0.0) }; - let pt_mt = measured_tables.iter().find(|mt| - mt.para_index == para_index && mt.control_index == control_index - ); + let pt_mt = measured_tables + .iter() + .find(|mt| mt.para_index == para_index && mt.control_index == control_index); // 비-TAC 자리차지 표에서 vert offset이 있으면 문단 시작 y 전달 // layout_partial_table 내부에서 vert_offset을 적용하므로 이중 적용 방지 let pt_y_start = if let Some(para) = paragraphs.get(para_index) { if let Some(Control::Table(t)) = para.controls.get(control_index) { if !t.common.treat_as_char - && matches!(t.common.text_wrap, crate::model::shape::TextWrap::TopAndBottom) + && matches!( + t.common.text_wrap, + crate::model::shape::TextWrap::TopAndBottom + ) && matches!(t.common.vert_rel_to, crate::model::shape::VertRelTo::Para) && t.common.vertical_offset > 0 { @@ -2191,32 +2779,54 @@ impl LayoutEngine { } else { y_offset } - } else { y_offset } - } else { y_offset }; + } else { + y_offset + } + } else { + y_offset + }; let pt_y_before = y_offset; y_offset = self.layout_partial_table( - tree, col_node, paragraphs, - para_index, control_index, - page_content.section_index, styles, col_area, - pt_y_start, bin_data_content, - start_row, end_row, is_continuation, - split_start_content_offset, split_end_content_limit, - pt_margin_left, pt_margin_right, pt_mt, + tree, + col_node, + paragraphs, + para_index, + control_index, + page_content.section_index, + styles, + col_area, + pt_y_start, + bin_data_content, + start_row, + end_row, + is_continuation, + split_start_content_offset, + split_end_content_limit, + pt_margin_left, + pt_margin_right, + pt_mt, ); if let Some(para) = paragraphs.get(para_index) { let comp = composed.get(para_index); - let para_style_id = comp.map(|c| c.para_style_id as usize).unwrap_or(para.para_shape_id as usize); + let para_style_id = comp + .map(|c| c.para_style_id as usize) + .unwrap_or(para.para_shape_id as usize); if let Some(para_style) = styles.para_styles.get(para_style_id) { - let is_tac = para.controls.get(control_index) + let is_tac = para + .controls + .get(control_index) .map(|c| matches!(c, Control::Table(t) if t.common.treat_as_char)) .unwrap_or(false); if is_tac { if para_style.spacing_after > 0.0 { y_offset += para_style.spacing_after; } - let outer_margin_bottom_px = if let Some(Control::Table(t)) = para.controls.get(control_index) { - hwpunit_to_px(t.outer_margin_bottom as i32, self.dpi) - } else { 0.0 }; + let outer_margin_bottom_px = + if let Some(Control::Table(t)) = para.controls.get(control_index) { + hwpunit_to_px(t.outer_margin_bottom as i32, self.dpi) + } else { + 0.0 + }; if outer_margin_bottom_px > 0.0 { y_offset += outer_margin_bottom_px; } @@ -2231,7 +2841,8 @@ impl LayoutEngine { if let Some(para) = paragraphs.get(para_index) { if let Some(Control::Table(t)) = para.controls.get(control_index) { let pt_is_tac = t.common.treat_as_char; - let pt_is_square = matches!(t.common.text_wrap, crate::model::shape::TextWrap::Square); + let pt_is_square = + matches!(t.common.text_wrap, crate::model::shape::TextWrap::Square); if !pt_is_tac && pt_is_square && !wrap_around_paras.is_empty() { let wrap_cs = para.line_segs.first().map(|s| s.column_start).unwrap_or(0); let wrap_sw = para.line_segs.first().map(|s| s.segment_width).unwrap_or(0); @@ -2239,12 +2850,24 @@ impl LayoutEngine { let wrap_text_width = hwpunit_to_px(wrap_sw, self.dpi); let content_offset = if let Some(mt) = pt_mt { mt.range_height(0, start_row) - } else { 0.0 }; + } else { + 0.0 + }; self.layout_wrap_around_paras( - tree, col_node, paragraphs, composed, styles, col_area, - page_content.section_index, para_index, wrap_around_paras, - pt_y_before, y_offset, - wrap_text_x, wrap_text_width, content_offset, + tree, + col_node, + paragraphs, + composed, + styles, + col_area, + page_content.section_index, + para_index, + wrap_around_paras, + pt_y_before, + y_offset, + wrap_text_x, + wrap_text_width, + content_offset, bin_data_content, ); } @@ -2267,8 +2890,14 @@ impl LayoutEngine { y_offset: f64, ) -> f64 { let ColumnItemCtx { - page_content, paragraphs, composed, styles, bin_data_content, - layout, col_area, .. + page_content, + paragraphs, + composed, + styles, + bin_data_content, + layout, + col_area, + .. } = ctx; para_start_y.entry(para_index).or_insert(y_offset); let mut result_y = y_offset; @@ -2284,16 +2913,19 @@ impl LayoutEngine { let caption_spacing = hwpunit_to_px(caption.spacing as i32, self.dpi); let caption_h = self.calculate_caption_height(&pic.caption, styles); let comp = composed.get(para_index); - let para_style_id = comp.map(|c| c.para_style_id as usize) + let para_style_id = comp + .map(|c| c.para_style_id as usize) .unwrap_or(para.para_shape_id as usize); - let para_alignment = styles.para_styles.get(para_style_id) + let para_alignment = styles + .para_styles + .get(para_style_id) .map(|s| s.alignment) .unwrap_or(Alignment::Left); let pic_x = match para_alignment { - Alignment::Center | Alignment::Distribute => - col_area.x + (col_area.width - pic_w).max(0.0) / 2.0, - Alignment::Right => - col_area.x + (col_area.width - pic_w).max(0.0), + Alignment::Center | Alignment::Distribute => { + col_area.x + (col_area.width - pic_w).max(0.0) / 2.0 + } + Alignment::Right => col_area.x + (col_area.width - pic_w).max(0.0), _ => col_area.x, }; let cap_y = match caption.direction { @@ -2303,7 +2935,12 @@ impl LayoutEngine { }; if caption.direction == CaptionDirection::Top { let dy = caption_h + caption_spacing; - Self::offset_inline_image_y(col_node, para_index, control_index, dy); + Self::offset_inline_image_y( + col_node, + para_index, + control_index, + dy, + ); } let cell_ctx = CellContext { parent_para_index: para_index, @@ -2315,15 +2952,23 @@ impl LayoutEngine { }], }; self.layout_caption( - tree, col_node, caption, styles, col_area, - pic_x, pic_w, cap_y, + tree, + col_node, + caption, + styles, + col_area, + pic_x, + pic_w, + cap_y, &mut self.auto_counter.borrow_mut(), Some(cell_ctx), ); } } else { - let is_paper_based = (pic.common.vert_rel_to == VertRelTo::Paper || pic.common.vert_rel_to == VertRelTo::Page) - && (pic.common.horz_rel_to == HorzRelTo::Paper || pic.common.horz_rel_to == HorzRelTo::Page); + let is_paper_based = (pic.common.vert_rel_to == VertRelTo::Paper + || pic.common.vert_rel_to == VertRelTo::Page) + && (pic.common.horz_rel_to == HorzRelTo::Paper + || pic.common.horz_rel_to == HorzRelTo::Page); if is_paper_based { let mut temp_parent = RenderNode::new( tree.next_id(), @@ -2331,37 +2976,67 @@ impl LayoutEngine { BoundingBox::new(0.0, 0.0, layout.page_width, layout.page_height), ); let paper_area = LayoutRect { - x: 0.0, y: 0.0, + x: 0.0, + y: 0.0, width: layout.page_width, height: layout.page_height, }; let _ = self.layout_body_picture( - tree, &mut temp_parent, pic, - &paper_area, col_area, &layout.body_area, &paper_area, - bin_data_content, styles, Alignment::Left, 0.0, - page_content.section_index, para_index, control_index, + tree, + &mut temp_parent, + pic, + &paper_area, + col_area, + &layout.body_area, + &paper_area, + bin_data_content, + styles, + Alignment::Left, + 0.0, + page_content.section_index, + para_index, + control_index, ); for child in temp_parent.children.drain(..) { paper_images.push(child); } } else { let comp = composed.get(para_index); - let para_style_id = comp.map(|c| c.para_style_id as usize).unwrap_or(para.para_shape_id as usize); - let alignment = styles.para_styles.get(para_style_id) + let para_style_id = comp + .map(|c| c.para_style_id as usize) + .unwrap_or(para.para_shape_id as usize); + let alignment = styles + .para_styles + .get(para_style_id) .map(|s| s.alignment) .unwrap_or(Alignment::Left); let pic_y = para_start_y.get(¶_index).copied().unwrap_or(y_offset); let pic_container = LayoutRect { - x: col_area.x, y: pic_y, + x: col_area.x, + y: pic_y, width: col_area.width, height: col_area.height - (pic_y - col_area.y), }; result_y = self.layout_body_picture( - tree, col_node, pic, - &pic_container, col_area, &layout.body_area, - &LayoutRect { x: 0.0, y: 0.0, width: layout.page_width, height: layout.page_height }, - bin_data_content, styles, alignment, pic_y, - page_content.section_index, para_index, control_index, + tree, + col_node, + pic, + &pic_container, + col_area, + &layout.body_area, + &LayoutRect { + x: 0.0, + y: 0.0, + width: layout.page_width, + height: layout.page_height, + }, + bin_data_content, + styles, + alignment, + pic_y, + page_content.section_index, + para_index, + control_index, ); } } @@ -2390,7 +3065,8 @@ impl LayoutEngine { bin_data_content: &[BinDataContent], ) { // 이 표에 연관된 어울림 문단만 필터링 - let related: Vec<_> = wrap_around_paras.iter() + let related: Vec<_> = wrap_around_paras + .iter() .filter(|wp| wp.table_para_index == table_para_index) .collect(); @@ -2414,12 +3090,17 @@ impl LayoutEngine { }; // 호스트 문단(표 문단) 텍스트를 어울림 영역에 렌더링 - let has_host_text = table_para.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}'); + let has_host_text = table_para + .text + .chars() + .any(|c| c > '\u{001F}' && c != '\u{FFFC}'); if table_content_offset == 0.0 { if has_host_text { if let Some(comp) = composed.get(table_para_index) { let text_start_line = comp.lines.iter().position(|line| { - line.runs.iter().any(|r| r.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}')) + line.runs + .iter() + .any(|r| r.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}')) }); if let Some(start_line) = text_start_line { // 다중 LINE_SEG 문단: wrap 영역에 해당하는 줄만 렌더링 @@ -2427,14 +3108,30 @@ impl LayoutEngine { // 첫 번째 텍스트 줄만 렌더링 (wrap 영역) start_line + 1 } else { - comp.lines.iter().rposition(|line| { - line.runs.iter().any(|r| r.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}')) - }).map(|i| i + 1).unwrap_or(comp.lines.len()) + comp.lines + .iter() + .rposition(|line| { + line.runs.iter().any(|r| { + r.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}') + }) + }) + .map(|i| i + 1) + .unwrap_or(comp.lines.len()) }; self.layout_partial_paragraph( - tree, col_node, table_para, Some(comp), styles, - &wrap_area, table_y_start, start_line, text_end_line, - section_index, table_para_index, None, Some(bin_data_content), + tree, + col_node, + table_para, + Some(comp), + styles, + &wrap_area, + table_y_start, + start_line, + text_end_line, + section_index, + table_para_index, + None, + Some(bin_data_content), ); // 어울림 문단은 항상 ↵ 표시 필요 — 부분 렌더링 시 is_para_end 강제 설정 force_para_end_on_last_run(col_node); @@ -2443,8 +3140,12 @@ impl LayoutEngine { } else { // 호스트 문단에 텍스트 없음 (빈 문단 + 표): ↵ 마크 렌더링 let seg = table_para.line_segs.first(); - let line_height = seg.map(|s| crate::renderer::hwpunit_to_px(s.line_height, self.dpi)).unwrap_or(13.3); - let font_size = seg.map(|s| crate::renderer::hwpunit_to_px(s.line_height, self.dpi)).unwrap_or(13.3); + let line_height = seg + .map(|s| crate::renderer::hwpunit_to_px(s.line_height, self.dpi)) + .unwrap_or(13.3); + let font_size = seg + .map(|s| crate::renderer::hwpunit_to_px(s.line_height, self.dpi)) + .unwrap_or(13.3); let baseline = font_size * 0.8; let line_id = tree.next_id(); let line_node = RenderNode::new( @@ -2529,9 +3230,19 @@ impl LayoutEngine { comp.map(|c| c.lines.len()).unwrap_or(1) }; self.layout_partial_paragraph( - tree, col_node, para, comp, styles, - &wrap_area, para_y, 0, end_line, - section_index, wp.para_index, None, Some(bin_data_content), + tree, + col_node, + para, + comp, + styles, + &wrap_area, + para_y, + 0, + end_line, + section_index, + wp.para_index, + None, + Some(bin_data_content), ); // 어울림 문단은 항상 ↵ 표시 필요 force_para_end_on_last_run(col_node); @@ -2540,8 +3251,14 @@ impl LayoutEngine { let line_height = crate::renderer::hwpunit_to_px(seg.line_height, self.dpi); // 문단의 글자 모양에서 실제 폰트 크기 추출 let font_size = { - let cs_id = para.char_shapes.first().map(|cs| cs.char_shape_id).unwrap_or(0); - styles.char_styles.get(cs_id as usize) + let cs_id = para + .char_shapes + .first() + .map(|cs| cs.char_shape_id) + .unwrap_or(0); + styles + .char_styles + .get(cs_id as usize) .map(|cs| cs.font_size) .filter(|fs| *fs > 0.0) .unwrap_or(13.3) @@ -2611,18 +3328,26 @@ impl LayoutEngine { ) { let mut shape_render_items: Vec<(i32, usize, usize, f64, Alignment)> = Vec::new(); for item in &col_content.items { - if let PageItem::Shape { para_index, control_index } = item { + if let PageItem::Shape { + para_index, + control_index, + } = item + { let para_y = para_start_y.get(para_index).copied().unwrap_or(col_area.y); let comp = composed.get(*para_index); let para_style_id = if let Some(para) = paragraphs.get(*para_index) { - comp.map(|c| c.para_style_id as usize).unwrap_or(para.para_shape_id as usize) + comp.map(|c| c.para_style_id as usize) + .unwrap_or(para.para_shape_id as usize) } else { 0 }; - let alignment = styles.para_styles.get(para_style_id) + let alignment = styles + .para_styles + .get(para_style_id) .map(|s| s.alignment) .unwrap_or(Alignment::Left); - let z_order = paragraphs.get(*para_index) + let z_order = paragraphs + .get(*para_index) .and_then(|p| p.controls.get(*control_index)) .map(|ctrl| match ctrl { Control::Shape(shape) => shape.z_order(), @@ -2638,7 +3363,8 @@ impl LayoutEngine { let overflow_map = self.scan_textbox_overflow(paragraphs, &shape_render_items); for (_, para_index, control_index, para_y, alignment) in shape_render_items { - let ctrl = paragraphs.get(para_index) + let ctrl = paragraphs + .get(para_index) .and_then(|p| p.controls.get(control_index)); let is_paper_based = ctrl .map(|ctrl| { @@ -2647,23 +3373,29 @@ impl LayoutEngine { Control::Table(t) => Some(&t.common), _ => None, }; - common.map(|c| { - matches!(c.horz_rel_to, HorzRelTo::Paper | HorzRelTo::Page) - || matches!(c.vert_rel_to, VertRelTo::Paper | VertRelTo::Page) - }).unwrap_or(false) + common + .map(|c| { + matches!(c.horz_rel_to, HorzRelTo::Paper | HorzRelTo::Page) + || matches!(c.vert_rel_to, VertRelTo::Paper | VertRelTo::Page) + }) + .unwrap_or(false) }) .unwrap_or(false); - let is_table_control = ctrl.map(|c| matches!(c, Control::Table(_))).unwrap_or(false); + let is_table_control = ctrl + .map(|c| matches!(c, Control::Table(_))) + .unwrap_or(false); let paper_area = LayoutRect { - x: 0.0, y: 0.0, + x: 0.0, + y: 0.0, width: layout.page_width, height: layout.page_height, }; if is_table_control { // InFrontOfText/BehindText 표: paper 기준 절대 위치에 렌더링 - if let Some(Control::Table(table)) = paragraphs.get(para_index) + if let Some(Control::Table(table)) = paragraphs + .get(para_index) .and_then(|p| p.controls.get(control_index)) { let mut temp_parent = RenderNode::new( @@ -2672,11 +3404,24 @@ impl LayoutEngine { BoundingBox::new(0.0, 0.0, layout.page_width, layout.page_height), ); self.layout_table( - tree, &mut temp_parent, table, - page_content.section_index, styles, col_area, para_y, - bin_data_content, None, 0, + tree, + &mut temp_parent, + table, + page_content.section_index, + styles, + col_area, + para_y, + bin_data_content, + None, + 0, Some((para_index, control_index)), - alignment, None, 0.0, 0.0, None, None, None, + alignment, + None, + 0.0, + 0.0, + None, + None, + None, ); for child in temp_parent.children.drain(..) { paper_images.push(child); @@ -2747,8 +3492,11 @@ impl LayoutEngine { // margin_left를 미리 계산 (text_pos=0 early return에도 사용) let para_style_id_for_ml = comp.map(|c| c.para_style_id as usize).unwrap_or(0); - let margin_left = styles.para_styles.get(para_style_id_for_ml) - .map(|s| s.margin_left).unwrap_or(0.0); + let margin_left = styles + .para_styles + .get(para_style_id_for_ml) + .map(|s| s.margin_left) + .unwrap_or(0.0); // x_base: 텍스트가 시작되는 절대 x 위치 (문단 첫 글자 위치) let x_base = col_area.x + margin_left; @@ -2768,11 +3516,18 @@ impl LayoutEngine { let available_width = col_area.width - margin_left; // ctrl_text_pos 이전에 있는 treat_as_char 컨트롤(text_pos > 0)의 너비 목록 - let mut preceding_tac: Vec<(usize, f64)> = para.controls.iter().enumerate() + let mut preceding_tac: Vec<(usize, f64)> = para + .controls + .iter() + .enumerate() .filter_map(|(ci, ctrl)| { - if ci >= control_index { return None; } + if ci >= control_index { + return None; + } let tp = positions.get(ci).copied().unwrap_or(0); - if tp == 0 || tp >= ctrl_text_pos { return None; } + if tp == 0 || tp >= ctrl_text_pos { + return None; + } let w = match ctrl { Control::Picture(p) if p.common.treat_as_char => { hwpunit_to_px(p.common.width as i32, self.dpi) @@ -2816,7 +3571,11 @@ impl LayoutEngine { ts.line_x_offset = est_x; if ch == '\t' { let (tp, _, _) = find_next_tab_stop( - est_x, &ts.tab_stops, ts.default_tab_width, ts.auto_tab_right, ts.available_width, + est_x, + &ts.tab_stops, + ts.default_tab_width, + ts.auto_tab_right, + ts.available_width, ); est_x = tp; } else { diff --git a/src/renderer/layout/border_rendering.rs b/src/renderer/layout/border_rendering.rs index e7c2b79b..83665483 100644 --- a/src/renderer/layout/border_rendering.rs +++ b/src/renderer/layout/border_rendering.rs @@ -1,14 +1,18 @@ //! 표 테두리 수집/렌더링 + 문단 테두리 라인 생성 -use crate::model::style::{BorderLine, BorderLineType}; -use crate::model::table::Table; use super::super::render_tree::*; use super::super::style_resolver::ResolvedBorderStyle; -use super::super::{StrokeDash, LineStyle}; +use super::super::{LineStyle, StrokeDash}; +use crate::model::style::{BorderLine, BorderLineType}; +use crate::model::table::Table; fn merge_border(a: &BorderLine, b: &BorderLine) -> BorderLine { - if a.line_type == BorderLineType::None { return *b; } - if b.line_type == BorderLineType::None { return *a; } + if a.line_type == BorderLineType::None { + return *b; + } + if b.line_type == BorderLineType::None { + return *a; + } let a_w = border_width_to_px(a.width); let b_w = border_width_to_px(b.width); @@ -20,17 +24,25 @@ fn merge_border(a: &BorderLine, b: &BorderLine) -> BorderLine { match lt { BorderLineType::None => 0, BorderLineType::ThinThickThinTriple => 4, - BorderLineType::Double | BorderLineType::ThinThickDouble | BorderLineType::ThickThinDouble => 3, + BorderLineType::Double + | BorderLineType::ThinThickDouble + | BorderLineType::ThickThinDouble => 3, BorderLineType::Wave | BorderLineType::DoubleWave => 2, _ => 1, } }; - if priority(a.line_type) >= priority(b.line_type) { *a } else { *b } + if priority(a.line_type) >= priority(b.line_type) { + *a + } else { + *b + } } /// 엣지 그리드 슬롯에 테두리를 병합 저장 fn merge_edge_slot(slot: &mut Option, border: &BorderLine) { - if border.line_type == BorderLineType::None { return; } + if border.line_type == BorderLineType::None { + return; + } *slot = Some(match *slot { Some(existing) => merge_border(&existing, border), None => *border, @@ -64,7 +76,8 @@ pub(crate) fn build_row_col_x( // 열 너비는 col_widths(전체 행 최대값)로 균일 적용 (한컴 동작) let mut base_rx = vec![0.0f64; col_count + 1]; for c in 0..col_count { - base_rx[c + 1] = base_rx[c] + col_widths[c] + if c + 1 < col_count { cell_spacing } else { 0.0 }; + base_rx[c + 1] = + base_rx[c] + col_widths[c] + if c + 1 < col_count { cell_spacing } else { 0.0 }; } vec![base_rx; row_count] } @@ -76,7 +89,10 @@ pub(crate) fn build_row_col_x( pub(crate) fn collect_cell_borders( h_edges: &mut [Vec>], v_edges: &mut [Vec>], - col: usize, row: usize, col_span: usize, row_span: usize, + col: usize, + row: usize, + col_span: usize, + row_span: usize, borders: &[BorderLine; 4], ) { let h_rows = h_edges.len(); @@ -140,7 +156,9 @@ pub(crate) fn render_edge_borders( for (ci, edge_opt) in h_row.iter().enumerate() { let same_style = match (edge_opt, &seg_border) { - (Some(e), Some(s)) => e.line_type == s.line_type && e.width == s.width && e.color == s.color, + (Some(e), Some(s)) => { + e.line_type == s.line_type && e.width == s.width && e.color == s.color + } _ => false, }; @@ -182,10 +200,18 @@ pub(crate) fn render_edge_borders( let mut seg_x: f64 = 0.0; for (ri, edge_opt) in v_col.iter().enumerate() { - let x = table_x + row_col_x.get(ri).and_then(|rx| rx.get(ci).copied()).unwrap_or(0.0); + let x = table_x + + row_col_x + .get(ri) + .and_then(|rx| rx.get(ci).copied()) + .unwrap_or(0.0); let same_style = match (edge_opt, &seg_border) { - (Some(e), Some(s)) => e.line_type == s.line_type && e.width == s.width && e.color == s.color - && (x - seg_x).abs() < 0.01, + (Some(e), Some(s)) => { + e.line_type == s.line_type + && e.width == s.width + && e.color == s.color + && (x - seg_x).abs() < 0.01 + } _ => false, }; @@ -271,7 +297,11 @@ pub(crate) fn render_transparent_borders( let mut seg_x: f64 = 0.0; for (ri, edge_opt) in v_col.iter().enumerate() { - let x = table_x + row_col_x.get(ri).and_then(|rx| rx.get(ci).copied()).unwrap_or(0.0); + let x = table_x + + row_col_x + .get(ri) + .and_then(|rx| rx.get(ci).copied()) + .unwrap_or(0.0); if edge_opt.is_none() { if seg_start.is_none() { seg_start = Some(ri); @@ -280,21 +310,27 @@ pub(crate) fn render_transparent_borders( // x가 바뀌면 이전 세그먼트 마무리 후 새 세그먼트 시작 let y1 = table_y + row_y[seg_start.unwrap()]; let y2 = table_y + row_y[ri]; - nodes.extend(create_single_line(tree, color, width, dash, seg_x, y1, seg_x, y2)); + nodes.extend(create_single_line( + tree, color, width, dash, seg_x, y1, seg_x, y2, + )); seg_start = Some(ri); seg_x = x; } } else if let Some(start) = seg_start { let y1 = table_y + row_y[start]; let y2 = table_y + row_y[ri]; - nodes.extend(create_single_line(tree, color, width, dash, seg_x, y1, seg_x, y2)); + nodes.extend(create_single_line( + tree, color, width, dash, seg_x, y1, seg_x, y2, + )); seg_start = None; } } if let Some(start) = seg_start { let y1 = table_y + row_y[start]; let y2 = table_y + row_y.get(v_col.len()).copied().unwrap_or(row_y[start]); - nodes.extend(create_single_line(tree, color, width, dash, seg_x, y1, seg_x, y2)); + nodes.extend(create_single_line( + tree, color, width, dash, seg_x, y1, seg_x, y2, + )); } } @@ -306,7 +342,10 @@ pub(crate) fn render_transparent_borders( pub(crate) fn create_border_line_nodes( tree: &mut PageRenderTree, border: &BorderLine, - x1: f64, y1: f64, x2: f64, y2: f64, + x1: f64, + y1: f64, + x2: f64, + y2: f64, ) -> Vec { if border.line_type == BorderLineType::None { return vec![]; @@ -323,8 +362,16 @@ pub(crate) fn create_border_line_nodes( let sub_w = (total * 0.3).max(0.4); let gap = (total * 0.4).max(1.0); let offset = (gap + sub_w) / 2.0; - create_parallel_lines(tree, border.color, x1, y1, x2, y2, - &[(-offset, sub_w), (offset, sub_w)], StrokeDash::Solid) + create_parallel_lines( + tree, + border.color, + x1, + y1, + x2, + y2, + &[(-offset, sub_w), (offset, sub_w)], + StrokeDash::Solid, + ) } // 가는선-굵은선 이중선 @@ -335,8 +382,16 @@ pub(crate) fn create_border_line_nodes( let gap = (total * 0.4).max(1.0); let thin_offset = -(gap + thin_w) / 2.0; let thick_offset = (gap + thick_w) / 2.0; - create_parallel_lines(tree, border.color, x1, y1, x2, y2, - &[(thin_offset, thin_w), (thick_offset, thick_w)], StrokeDash::Solid) + create_parallel_lines( + tree, + border.color, + x1, + y1, + x2, + y2, + &[(thin_offset, thin_w), (thick_offset, thick_w)], + StrokeDash::Solid, + ) } // 굵은선-가는선 이중선 @@ -347,8 +402,16 @@ pub(crate) fn create_border_line_nodes( let gap = (total * 0.4).max(1.0); let thick_offset = -(gap + thick_w) / 2.0; let thin_offset = (gap + thin_w) / 2.0; - create_parallel_lines(tree, border.color, x1, y1, x2, y2, - &[(thick_offset, thick_w), (thin_offset, thin_w)], StrokeDash::Solid) + create_parallel_lines( + tree, + border.color, + x1, + y1, + x2, + y2, + &[(thick_offset, thick_w), (thin_offset, thin_w)], + StrokeDash::Solid, + ) } // 가는선-굵은선-가는선 삼중선 @@ -358,8 +421,20 @@ pub(crate) fn create_border_line_nodes( let thick_w = (total * 0.3).max(0.6); let gap = (total * 0.15).max(0.8); let outer_offset = thick_w / 2.0 + gap + thin_w / 2.0; - create_parallel_lines(tree, border.color, x1, y1, x2, y2, - &[(-outer_offset, thin_w), (0.0, thick_w), (outer_offset, thin_w)], StrokeDash::Solid) + create_parallel_lines( + tree, + border.color, + x1, + y1, + x2, + y2, + &[ + (-outer_offset, thin_w), + (0.0, thick_w), + (outer_offset, thin_w), + ], + StrokeDash::Solid, + ) } // 단일선 타입들 @@ -378,7 +453,10 @@ pub(crate) fn create_border_line_nodes( fn create_parallel_lines( tree: &mut PageRenderTree, color: u32, - x1: f64, y1: f64, x2: f64, y2: f64, + x1: f64, + y1: f64, + x2: f64, + y2: f64, lines: &[(f64, f64)], dash: StrokeDash, ) -> Vec { @@ -396,7 +474,10 @@ fn create_parallel_lines( nodes.push(RenderNode::new( id, RenderNodeType::Line(LineNode::new( - lx1, ly1, lx2, ly2, + lx1, + ly1, + lx2, + ly2, LineStyle { color, width, @@ -422,13 +503,19 @@ fn create_single_line( color: u32, width: f64, dash: StrokeDash, - x1: f64, y1: f64, x2: f64, y2: f64, + x1: f64, + y1: f64, + x2: f64, + y2: f64, ) -> Vec { let id = tree.next_id(); vec![RenderNode::new( id, RenderNodeType::Line(LineNode::new( - x1, y1, x2, y2, + x1, + y1, + x2, + y2, LineStyle { color, width, diff --git a/src/renderer/layout/integration_tests.rs b/src/renderer/layout/integration_tests.rs index d3c2ceac..3f05ce63 100644 --- a/src/renderer/layout/integration_tests.rs +++ b/src/renderer/layout/integration_tests.rs @@ -5,7 +5,19 @@ #[cfg(test)] mod tests { - use std::path::Path; + use resvg::{tiny_skia, usvg}; + use std::path::{Path, PathBuf}; + use std::sync::{Mutex, OnceLock}; + + const SKIA_TOLERANT_CHANNEL_DELTA: u8 = 8; + const SKIA_TOLERANT_MAX_DIFF_PIXELS: usize = 64; + const SKIA_RASTER_TOLERANT_NEIGHBOR_RADIUS: usize = 1; + const SKIA_RASTER_TOLERANT_MAX_DIFF_RATIO: f64 = 0.013; + + fn render_path_env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } /// 테스트용 DocumentCore 생성 헬퍼 fn load_document(path: &str) -> Option { @@ -18,33 +30,643 @@ mod tests { crate::document_core::DocumentCore::from_bytes(&data).ok() } + fn rasterize_svg(svg: &str) -> Option { + let mut options = usvg::Options::default(); + let fontdb = options.fontdb_mut(); + fontdb.load_system_fonts(); + fontdb.set_sans_serif_family("Noto Sans CJK KR"); + fontdb.set_serif_family("Noto Serif CJK KR"); + fontdb.set_monospace_family("D2Coding"); + let tree = usvg::Tree::from_str(svg, &options).ok()?; + let pixmap_size = tree.size().to_int_size(); + let mut pixmap = tiny_skia::Pixmap::new(pixmap_size.width(), pixmap_size.height())?; + resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut()); + Some(pixmap) + } + + #[cfg(all(not(target_arch = "wasm32"), feature = "native-skia"))] + fn decode_png(bytes: &[u8]) -> Option { + tiny_skia::Pixmap::decode_png(bytes).ok() + } + + struct PixmapDiff { + diff_pixels: usize, + total_pixels: usize, + max_channel_delta: u8, + mean_abs_channel_delta: f64, + diff_pixmap: tiny_skia::Pixmap, + } + + fn pixel_max_delta(expected_px: &[u8], actual_px: &[u8]) -> u8 { + let mut pixel_max_delta = 0u8; + for channel in 0..4 { + pixel_max_delta = + pixel_max_delta.max(expected_px[channel].abs_diff(actual_px[channel])); + } + pixel_max_delta + } + + fn pixel_matches_within_delta( + expected_px: &[u8], + actual_px: &[u8], + ignored_channel_delta: u8, + ) -> bool { + pixel_max_delta(expected_px, actual_px) <= ignored_channel_delta + } + + fn diff_pixmaps( + expected: &tiny_skia::Pixmap, + actual: &tiny_skia::Pixmap, + ignored_channel_delta: u8, + ) -> PixmapDiff { + let total_pixels = (expected.width() as usize) * (expected.height() as usize); + let mut diff_pixmap = tiny_skia::Pixmap::new(expected.width(), expected.height()) + .expect("diff pixmap 생성 실패"); + let mut diff_pixels = 0usize; + let mut total_channel_delta = 0u64; + let mut max_channel_delta = 0u8; + + for (idx, (expected_px, actual_px)) in expected + .data() + .chunks_exact(4) + .zip(actual.data().chunks_exact(4)) + .enumerate() + { + for channel in 0..4 { + let delta = expected_px[channel].abs_diff(actual_px[channel]); + total_channel_delta += u64::from(delta); + max_channel_delta = max_channel_delta.max(delta); + } + + let pixel_max_delta = pixel_max_delta(expected_px, actual_px); + if pixel_max_delta > ignored_channel_delta { + diff_pixels += 1; + let base = idx * 4; + diff_pixmap.data_mut()[base..base + 4].copy_from_slice(&[ + pixel_max_delta.max(32), + 0, + 0, + 255, + ]); + } + } + + let mean_abs_channel_delta = if total_pixels == 0 { + 0.0 + } else { + total_channel_delta as f64 / (total_pixels as f64 * 4.0) + }; + + PixmapDiff { + diff_pixels, + total_pixels, + max_channel_delta, + mean_abs_channel_delta, + diff_pixmap, + } + } + + fn diff_pixmaps_with_neighborhood( + expected: &tiny_skia::Pixmap, + actual: &tiny_skia::Pixmap, + ignored_channel_delta: u8, + radius: usize, + ) -> PixmapDiff { + let total_pixels = (expected.width() as usize) * (expected.height() as usize); + let mut diff_pixmap = tiny_skia::Pixmap::new(expected.width(), expected.height()) + .expect("diff pixmap 생성 실패"); + let mut diff_pixels = 0usize; + let mut total_channel_delta = 0u64; + let mut max_channel_delta = 0u8; + let width = expected.width() as usize; + let height = expected.height() as usize; + let expected_data = expected.data(); + let actual_data = actual.data(); + + for y in 0..height { + for x in 0..width { + let idx = y * width + x; + let base = idx * 4; + let expected_px = &expected_data[base..base + 4]; + let actual_px = &actual_data[base..base + 4]; + + for channel in 0..4 { + let delta = expected_px[channel].abs_diff(actual_px[channel]); + total_channel_delta += u64::from(delta); + max_channel_delta = max_channel_delta.max(delta); + } + + let pixel_max_delta = pixel_max_delta(expected_px, actual_px); + if pixel_max_delta <= ignored_channel_delta { + continue; + } + + let mut matched = false; + let min_y = y.saturating_sub(radius); + let max_y = (y + radius).min(height - 1); + let min_x = x.saturating_sub(radius); + let max_x = (x + radius).min(width - 1); + + 'search_actual: for ny in min_y..=max_y { + for nx in min_x..=max_x { + let neighbor_base = (ny * width + nx) * 4; + let candidate = &actual_data[neighbor_base..neighbor_base + 4]; + if pixel_matches_within_delta(expected_px, candidate, ignored_channel_delta) + { + matched = true; + break 'search_actual; + } + } + } + + if !matched { + 'search_expected: for ny in min_y..=max_y { + for nx in min_x..=max_x { + let neighbor_base = (ny * width + nx) * 4; + let candidate = &expected_data[neighbor_base..neighbor_base + 4]; + if pixel_matches_within_delta( + candidate, + actual_px, + ignored_channel_delta, + ) { + matched = true; + break 'search_expected; + } + } + } + } + + if matched { + continue; + } + + diff_pixels += 1; + diff_pixmap.data_mut()[base..base + 4].copy_from_slice(&[ + pixel_max_delta.max(32), + 0, + 0, + 255, + ]); + } + } + + let mean_abs_channel_delta = if total_pixels == 0 { + 0.0 + } else { + total_channel_delta as f64 / (total_pixels as f64 * 4.0) + }; + + PixmapDiff { + diff_pixels, + total_pixels, + max_channel_delta, + mean_abs_channel_delta, + diff_pixmap, + } + } + + fn save_diff_artifacts( + output_dir: &str, + sample: &str, + page_num: u32, + expected_name: &str, + actual_name: &str, + diff_name: &str, + expected: &tiny_skia::Pixmap, + actual: &tiny_skia::Pixmap, + diff: &tiny_skia::Pixmap, + ) -> (PathBuf, PathBuf, PathBuf) { + let output_dir = Path::new(output_dir); + let _ = std::fs::create_dir_all(output_dir); + let stem = Path::new(sample) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("sample"); + let expected_path = output_dir.join(format!("{stem}-{expected_name}-p{page_num}.png")); + let actual_path = output_dir.join(format!("{stem}-{actual_name}-p{page_num}.png")); + let diff_path = output_dir.join(format!("{stem}-{diff_name}-p{page_num}.png")); + let _ = expected.save_png(&expected_path); + let _ = actual.save_png(&actual_path); + let _ = diff.save_png(&diff_path); + (expected_path, actual_path, diff_path) + } + + fn assert_layer_svg_pixels_match(sample: &str, page_num: u32) { + let Some(core) = load_document(sample) else { + return; + }; + let legacy = core + .render_page_svg_legacy_native(page_num) + .unwrap_or_default(); + let layered = core + .render_page_svg_layer_native(page_num) + .unwrap_or_default(); + let legacy_pixmap = rasterize_svg(&legacy).expect("legacy SVG rasterize 실패"); + let layered_pixmap = rasterize_svg(&layered).expect("layer SVG rasterize 실패"); + + assert_eq!( + (layered_pixmap.width(), layered_pixmap.height()), + (legacy_pixmap.width(), legacy_pixmap.height()), + "legacy/layer raster 크기가 달라서는 안 됨", + ); + + let diff = diff_pixmaps(&legacy_pixmap, &layered_pixmap, 0); + if diff.diff_pixels > 0 { + let (legacy_path, layered_path, diff_path) = save_diff_artifacts( + "output/layer-svg-diff", + sample, + page_num, + "legacy", + "layer", + "diff", + &legacy_pixmap, + &layered_pixmap, + &diff.diff_pixmap, + ); + panic!( + "legacy/layer raster diff 발생: {} pixels (legacy: {}, layer: {}, diff: {})", + diff.diff_pixels, + legacy_path.display(), + layered_path.display(), + diff_path.display(), + ); + } + } + + #[cfg(all(not(target_arch = "wasm32"), feature = "native-skia"))] + fn compare_skia_png_matches_layer_svg(sample: &str, page_num: u32) -> Result<(), String> { + let path = Path::new(sample); + if !path.exists() { + return Ok(()); + } + + let data = std::fs::read(path).map_err(|err| format!("샘플 읽기 실패: {sample}: {err}"))?; + let core = crate::document_core::DocumentCore::from_bytes(&data) + .map_err(|err| format!("문서 파싱 실패: {sample}: {err}"))?; + if page_num >= core.page_count() { + return Err(format!( + "페이지 범위 초과: {sample} requested={} page_count={}", + page_num, + core.page_count() + )); + } + + let layered_svg = core + .render_page_svg_layer_native(page_num) + .map_err(|err| format!("layer SVG 렌더 실패: {sample} p{page_num}: {err}"))?; + let expected = rasterize_svg(&layered_svg) + .ok_or_else(|| format!("layer SVG rasterize 실패: {sample} p{page_num}"))?; + let actual_png = core + .render_page_png_native(page_num) + .map_err(|err| format!("Skia PNG 렌더 실패: {sample} p{page_num}: {err}"))?; + let actual = decode_png(&actual_png) + .ok_or_else(|| format!("Skia PNG decode 실패: {sample} p{page_num}"))?; + + if (actual.width(), actual.height()) != (expected.width(), expected.height()) { + return Err(format!( + "Skia/layer raster 크기 불일치: {sample} p{page_num} expected=({},{}) actual=({},{})", + expected.width(), + expected.height(), + actual.width(), + actual.height(), + )); + } + + let exact_diff = diff_pixmaps(&expected, &actual, 0); + let raw_tolerant_diff = diff_pixmaps(&expected, &actual, SKIA_TOLERANT_CHANNEL_DELTA); + let raster_tolerant_diff = diff_pixmaps_with_neighborhood( + &expected, + &actual, + SKIA_TOLERANT_CHANNEL_DELTA, + SKIA_RASTER_TOLERANT_NEIGHBOR_RADIUS, + ); + let raster_tolerant_ratio = + raster_tolerant_diff.diff_pixels as f64 / raster_tolerant_diff.total_pixels as f64; + + let exact_paths = if exact_diff.diff_pixels > 0 { + Some(save_diff_artifacts( + "output/skia-diff", + sample, + page_num, + "layer", + "skia", + "diff", + &expected, + &actual, + &exact_diff.diff_pixmap, + )) + } else { + None + }; + + let tolerant_paths = if raw_tolerant_diff.diff_pixels > SKIA_TOLERANT_MAX_DIFF_PIXELS { + Some(save_diff_artifacts( + "output/skia-diff", + sample, + page_num, + "layer", + "skia", + "tolerant-diff", + &expected, + &actual, + &raw_tolerant_diff.diff_pixmap, + )) + } else { + None + }; + + if raster_tolerant_ratio > SKIA_RASTER_TOLERANT_MAX_DIFF_RATIO { + let (expected_path, actual_path, diff_path) = + exact_paths.expect("tolerant diff가 있으면 exact diff도 있어야 함"); + let tolerant_diff_path = tolerant_paths + .as_ref() + .map(|(_, _, path)| path.display().to_string()) + .unwrap_or_else(|| "-".to_string()); + let (_, _, raster_tolerant_diff_path) = save_diff_artifacts( + "output/skia-diff", + sample, + page_num, + "layer", + "skia", + "raster-tolerant-diff", + &expected, + &actual, + &raster_tolerant_diff.diff_pixmap, + ); + return Err(format!( + "Skia raster diff 발생: exact={} pixels, tolerant={} pixels (budget={}, ignored_channel_delta<={}), raster_tolerant={} pixels (radius={}, ratio={:.3}%, budget={:.3}%) (layer: {}, skia: {}, exact diff: {}, tolerant diff: {}, raster tolerant diff: {})", + exact_diff.diff_pixels, + raw_tolerant_diff.diff_pixels, + SKIA_TOLERANT_MAX_DIFF_PIXELS, + SKIA_TOLERANT_CHANNEL_DELTA, + raster_tolerant_diff.diff_pixels, + SKIA_RASTER_TOLERANT_NEIGHBOR_RADIUS, + raster_tolerant_ratio * 100.0, + SKIA_RASTER_TOLERANT_MAX_DIFF_RATIO * 100.0, + expected_path.display(), + actual_path.display(), + diff_path.display(), + tolerant_diff_path, + raster_tolerant_diff_path.display(), + )); + } + + Ok(()) + } + + #[cfg(all(not(target_arch = "wasm32"), feature = "native-skia"))] + fn assert_skia_png_matches_layer_svg_for_corpus(label: &str, samples: &[(String, Vec)]) { + let mut failures = Vec::new(); + let total_pages: usize = samples.iter().map(|(_, pages)| pages.len()).sum(); + let mut completed_pages = 0usize; + + for (sample, pages) in samples { + for &page_num in pages { + completed_pages += 1; + eprintln!( + "[skia-corpus:{label}] {completed_pages}/{total_pages} {sample} p{page_num}" + ); + if let Err(err) = compare_skia_png_matches_layer_svg(sample, page_num) { + failures.push(err); + } + } + } + + if !failures.is_empty() { + panic!( + "Skia corpus screenshot regression 실패 {}건:\n{}", + failures.len(), + failures.join("\n"), + ); + } + } + + #[cfg(all(not(target_arch = "wasm32"), feature = "native-skia"))] + fn assert_skia_png_matches_layer_svg(sample: &str, page_num: u32) { + if let Err(err) = compare_skia_png_matches_layer_svg(sample, page_num) { + panic!("{err}"); + } + } + + #[cfg(all(not(target_arch = "wasm32"), feature = "native-skia"))] + fn assert_skia_layer_tree_matches_svg( + case_name: &str, + layer_tree: &crate::paint::PageLayerTree, + ) { + use crate::renderer::layer_renderer::LayerRenderer; + use crate::renderer::skia::SkiaLayerRenderer; + use crate::renderer::svg_layer::SvgLayerRenderer; + + let mut svg_renderer = SvgLayerRenderer::new(); + svg_renderer.render_page(layer_tree); + let expected = rasterize_svg(svg_renderer.output()).expect("synthetic SVG rasterize 실패"); + let actual_png = SkiaLayerRenderer::new() + .render_png(layer_tree) + .expect("synthetic Skia PNG 렌더 실패"); + let actual = decode_png(&actual_png).expect("synthetic Skia PNG decode 실패"); + let tolerant_diff = diff_pixmaps(&expected, &actual, SKIA_TOLERANT_CHANNEL_DELTA); + let raster_tolerant_diff = diff_pixmaps_with_neighborhood( + &expected, + &actual, + SKIA_TOLERANT_CHANNEL_DELTA, + SKIA_RASTER_TOLERANT_NEIGHBOR_RADIUS, + ); + let raster_tolerant_ratio = + raster_tolerant_diff.diff_pixels as f64 / raster_tolerant_diff.total_pixels as f64; + + if raster_tolerant_ratio > SKIA_RASTER_TOLERANT_MAX_DIFF_RATIO { + let exact_diff = diff_pixmaps(&expected, &actual, 0); + let (expected_path, actual_path, diff_path) = save_diff_artifacts( + "output/skia-diff", + case_name, + 0, + "layer", + "skia", + "diff", + &expected, + &actual, + &exact_diff.diff_pixmap, + ); + let (_, _, tolerant_path) = save_diff_artifacts( + "output/skia-diff", + case_name, + 0, + "layer", + "skia", + "tolerant-diff", + &expected, + &actual, + &tolerant_diff.diff_pixmap, + ); + let (_, _, raster_tolerant_path) = save_diff_artifacts( + "output/skia-diff", + case_name, + 0, + "layer", + "skia", + "raster-tolerant-diff", + &expected, + &actual, + &raster_tolerant_diff.diff_pixmap, + ); + panic!( + "synthetic Skia raster diff 발생: exact={} tolerant={} (budget={}), raster_tolerant={} (radius={}, ratio={:.3}%, budget={:.3}%) (layer: {}, skia: {}, exact diff: {}, tolerant diff: {}, raster tolerant diff: {})", + exact_diff.diff_pixels, + tolerant_diff.diff_pixels, + SKIA_TOLERANT_MAX_DIFF_PIXELS, + raster_tolerant_diff.diff_pixels, + SKIA_RASTER_TOLERANT_NEIGHBOR_RADIUS, + raster_tolerant_ratio * 100.0, + SKIA_RASTER_TOLERANT_MAX_DIFF_RATIO * 100.0, + expected_path.display(), + actual_path.display(), + diff_path.display(), + tolerant_path.display(), + raster_tolerant_path.display(), + ); + } + } + + fn synthetic_png_bytes() -> Vec { + let mut pixmap = tiny_skia::Pixmap::new(40, 30).expect("synthetic pixmap 생성 실패"); + for y in 0..30usize { + for x in 0..40usize { + let (r, g, b) = match (x < 20, y < 15) { + (true, true) => (255, 32, 32), + (false, true) => (32, 200, 64), + (true, false) => (48, 96, 255), + (false, false) => (255, 200, 32), + }; + let base = (y * 40 + x) * 4; + pixmap.data_mut()[base..base + 4].copy_from_slice(&[r, g, b, 255]); + } + } + pixmap.encode_png().expect("synthetic png 인코딩 실패") + } + + #[test] + fn test_diff_pixmaps_ignores_small_channel_deltas_when_configured() { + let mut expected = tiny_skia::Pixmap::new(2, 1).expect("expected pixmap 생성 실패"); + let mut actual = tiny_skia::Pixmap::new(2, 1).expect("actual pixmap 생성 실패"); + + expected + .data_mut() + .copy_from_slice(&[10, 20, 30, 255, 80, 90, 100, 255]); + actual + .data_mut() + .copy_from_slice(&[12, 20, 30, 255, 80, 90, 106, 255]); + + let exact = diff_pixmaps(&expected, &actual, 0); + let tolerant = diff_pixmaps(&expected, &actual, 4); + + assert_eq!(exact.total_pixels, 2); + assert_eq!(exact.diff_pixels, 2); + assert_eq!(tolerant.diff_pixels, 1); + assert_eq!(exact.max_channel_delta, 6); + assert_eq!(tolerant.max_channel_delta, 6); + } + + #[test] + fn test_skia_tolerant_budget_zeroes_passing_diff() { + let mut expected = tiny_skia::Pixmap::new(4, 1).expect("expected pixmap 생성 실패"); + let mut actual = tiny_skia::Pixmap::new(4, 1).expect("actual pixmap 생성 실패"); + + expected.data_mut().copy_from_slice(&[ + 10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 1, 2, 3, 255, + ]); + actual.data_mut().copy_from_slice(&[ + 10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 91, 255, 1, 2, 4, 255, + ]); + + let raw_tolerant = diff_pixmaps(&expected, &actual, 0); + let budgeted_tolerant = if raw_tolerant.diff_pixels <= 2 { + 0 + } else { + raw_tolerant.diff_pixels + }; + + assert_eq!(raw_tolerant.diff_pixels, 2); + assert_eq!(budgeted_tolerant, 0); + } + + #[test] + fn test_diff_pixmaps_with_neighborhood_ignores_one_pixel_shift() { + let mut expected = tiny_skia::Pixmap::new(5, 1).expect("expected pixmap 생성 실패"); + let mut actual = tiny_skia::Pixmap::new(5, 1).expect("actual pixmap 생성 실패"); + + expected + .data_mut() + .copy_from_slice(&[0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + actual + .data_mut() + .copy_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0]); + + let diff = diff_pixmaps_with_neighborhood(&expected, &actual, 8, 1); + assert_eq!(diff.diff_pixels, 0); + } + + #[test] + fn test_diff_pixmaps_with_neighborhood_preserves_large_shift() { + let mut expected = tiny_skia::Pixmap::new(10, 1).expect("expected pixmap 생성 실패"); + let mut actual = tiny_skia::Pixmap::new(10, 1).expect("actual pixmap 생성 실패"); + + expected.data_mut().copy_from_slice(&[ + 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]); + actual.data_mut().copy_from_slice(&[ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, + 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]); + + let diff = diff_pixmaps_with_neighborhood(&expected, &actual, 8, 1); + assert!(diff.diff_pixels > 0); + } + // ─── 페이지 수 검증 ─── #[test] fn test_hwpspec_w_page_count() { - let Some(core) = load_document("samples/hwpspec-w.hwp") else { return }; + let Some(core) = load_document("samples/hwpspec-w.hwp") else { + return; + }; let page_count = core.page_count(); - assert!(page_count >= 170, "hwpspec-w.hwp 페이지 수 170 이상 (실제: {})", page_count); + assert!( + page_count >= 170, + "hwpspec-w.hwp 페이지 수 170 이상 (실제: {})", + page_count + ); } #[test] fn test_exam_math_page_count() { - let Some(core) = load_document("samples/exam_math.hwp") else { return }; + let Some(core) = load_document("samples/exam_math.hwp") else { + return; + }; let page_count = core.page_count(); - assert!(page_count >= 18, "exam_math.hwp 페이지 수 18 이상 (실제: {})", page_count); + assert!( + page_count >= 18, + "exam_math.hwp 페이지 수 18 이상 (실제: {})", + page_count + ); } // ─── 2단 레이아웃 검증 ─── #[test] fn test_exam_math_two_column_layout() { - let Some(core) = load_document("samples/exam_math.hwp") else { return }; + let Some(core) = load_document("samples/exam_math.hwp") else { + return; + }; // 1페이지: 2단 레이아웃이어야 함 let pages = &core.pagination; if let Some(result) = pages.first() { if let Some(page) = result.pages.first() { - assert!(page.column_contents.len() >= 2, - "exam_math.hwp 1페이지는 2단 이상 (실제: {}단)", page.column_contents.len()); + assert!( + page.column_contents.len() >= 2, + "exam_math.hwp 1페이지는 2단 이상 (실제: {}단)", + page.column_contents.len() + ); } } } @@ -53,25 +675,33 @@ mod tests { #[test] fn test_exam_math_no_header_on_first_page() { - let Some(core) = load_document("samples/exam_math_no.hwp") else { return }; + let Some(core) = load_document("samples/exam_math_no.hwp") else { + return; + }; let pages = &core.pagination; if let Some(result) = pages.first() { if let Some(page) = result.pages.first() { - assert!(page.active_header.is_none(), - "exam_math_no.hwp 1페이지에는 머리말이 없어야 함"); + assert!( + page.active_header.is_none(), + "exam_math_no.hwp 1페이지에는 머리말이 없어야 함" + ); } } } #[test] fn test_exam_math_header_from_second_page() { - let Some(core) = load_document("samples/exam_math_no.hwp") else { return }; + let Some(core) = load_document("samples/exam_math_no.hwp") else { + return; + }; let pages = &core.pagination; if let Some(result) = pages.first() { if result.pages.len() > 1 { let page2 = &result.pages[1]; - assert!(page2.active_header.is_some(), - "exam_math_no.hwp 2페이지부터 머리말이 있어야 함"); + assert!( + page2.active_header.is_some(), + "exam_math_no.hwp 2페이지부터 머리말이 있어야 함" + ); } } } @@ -80,24 +710,32 @@ mod tests { #[test] fn test_hwpspec_w_table_split() { - let Some(core) = load_document("samples/hwpspec-w.hwp") else { return }; + let Some(core) = load_document("samples/hwpspec-w.hwp") else { + return; + }; use crate::renderer::pagination::PageItem; let has_partial_table = core.pagination.iter().any(|result| { result.pages.iter().any(|p| { p.column_contents.iter().any(|cc| { - cc.items.iter().any(|item| matches!(item, PageItem::PartialTable { .. })) + cc.items + .iter() + .any(|item| matches!(item, PageItem::PartialTable { .. })) }) }) }); - assert!(has_partial_table, - "hwpspec-w.hwp에는 페이지 분할된 표(PartialTable)가 있어야 함"); + assert!( + has_partial_table, + "hwpspec-w.hwp에는 페이지 분할된 표(PartialTable)가 있어야 함" + ); } // ─── SVG 내보내기 검증 ─── #[test] fn test_export_svg_produces_output() { - let Some(core) = load_document("samples/hwpspec-w.hwp") else { return }; + let Some(core) = load_document("samples/hwpspec-w.hwp") else { + return; + }; let svg = core.render_page_svg_native(0).unwrap_or_default(); assert!(!svg.is_empty(), "SVG 출력이 비어있으면 안 됨"); assert!(svg.contains("") { + if let Some(style_end) = embedded.find("") { + let mut normalized = embedded.clone(); + normalized.replace_range(style_start..style_end + "".len(), ""); + normalized + } else { + embedded.clone() + } + } else { + embedded.clone() + }; + let normalize_svg = |svg: String| { + svg.lines() + .map(str::trim_end) + .filter(|line| !line.trim().is_empty()) + .collect::>() + .join("\n") + }; + + assert_eq!( + normalize_svg(embedded_without_style), + normalize_svg(layered), + "font-embed 경로도 layer-svg 선택을 존중해야 함" + ); } } diff --git a/src/renderer/layout/paragraph_layout.rs b/src/renderer/layout/paragraph_layout.rs index a47823d2..bfc4c091 100644 --- a/src/renderer/layout/paragraph_layout.rs +++ b/src/renderer/layout/paragraph_layout.rs @@ -1,19 +1,26 @@ //! 문단 레이아웃 (인라인 표, 문단 전체/부분, composed/raw) + 번호 매기기 -use crate::model::paragraph::Paragraph; -use crate::model::style::{Alignment, HeadType, LineSpacingType, Numbering, UnderlineType}; -use crate::model::control::Control; -use crate::model::bin_data::BinDataContent; -use super::super::render_tree::*; -use super::super::page_layout::LayoutRect; +use super::super::composer::{compose_paragraph, ComposedParagraph}; use super::super::height_measurer::MeasuredTable; -use super::super::composer::{ComposedParagraph, compose_paragraph}; +use super::super::page_layout::LayoutRect; +use super::super::render_tree::*; use super::super::style_resolver::ResolvedStyleSet; -use super::super::{TextStyle, ShapeStyle, hwpunit_to_px, format_number, NumberFormat as NumFmt, AutoNumberCounter}; -use super::{LayoutEngine, CellContext}; -use super::text_measurement::{resolved_to_text_style, estimate_text_width, compute_char_positions, extract_tab_leaders_with_extended, find_next_tab_stop}; +use super::super::{ + format_number, hwpunit_to_px, AutoNumberCounter, NumberFormat as NumFmt, ShapeStyle, TextStyle, +}; use super::border_rendering::create_border_line_nodes; -use super::utils::{resolve_numbering_id, expand_numbering_format, numbering_format_to_number_format, find_bin_data}; +use super::text_measurement::{ + compute_char_positions, estimate_text_width, extract_tab_leaders_with_extended, + find_next_tab_stop, resolved_to_text_style, +}; +use super::utils::{ + expand_numbering_format, find_bin_data, numbering_format_to_number_format, resolve_numbering_id, +}; +use super::{CellContext, LayoutEngine}; +use crate::model::bin_data::BinDataContent; +use crate::model::control::Control; +use crate::model::paragraph::Paragraph; +use crate::model::style::{Alignment, HeadType, LineSpacingType, Numbering, UnderlineType}; /// lineseg baseline_distance를 폰트 어센트 기준으로 보정한다. /// CENTER 문단 수직정렬 등으로 baseline이 50% 이하로 설정된 경우, @@ -44,7 +51,8 @@ impl LayoutEngine { use crate::model::control::Control; // 1. 문단 스타일 조회 - let para_style_id = composed.map(|c| c.para_style_id as usize) + let para_style_id = composed + .map(|c| c.para_style_id as usize) .unwrap_or(para.para_shape_id as usize); let para_style = styles.para_styles.get(para_style_id); let margin_left = para_style.map(|s| s.margin_left).unwrap_or(0.0); @@ -56,10 +64,15 @@ impl LayoutEngine { let y = y_start + spacing_before; // 2. treat_as_char 표 목록과 폭 수집 - let inline_tables: Vec<(usize, &crate::model::table::Table)> = para.controls.iter().enumerate() + let inline_tables: Vec<(usize, &crate::model::table::Table)> = para + .controls + .iter() + .enumerate() .filter_map(|(i, c)| { if let Control::Table(t) = c { - if t.common.treat_as_char { return Some((i, t.as_ref())); } + if t.common.treat_as_char { + return Some((i, t.as_ref())); + } } None }) @@ -85,7 +98,11 @@ impl LayoutEngine { let mut seg_start = 0; for i in 1..offsets.len() { - let prev_char_utf16_len = if text_chars[i - 1] >= '\u{10000}' { 2u32 } else { 1 }; + let prev_char_utf16_len = if text_chars[i - 1] >= '\u{10000}' { + 2u32 + } else { + 1 + }; let gap = offsets[i] - offsets[i - 1]; if gap > prev_char_utf16_len + 4 { // 갭에 컨트롤이 있음 @@ -100,56 +117,73 @@ impl LayoutEngine { // 4. 각 요소의 폭 계산 // 4a. 표 폭 계산 - let table_widths: Vec = inline_tables.iter().map(|(_, t)| { - // col_widths로부터 table_width 계산 - let col_count = t.col_count as usize; - let cell_spacing = hwpunit_to_px(t.cell_spacing as i32, self.dpi); - let mut col_widths = vec![0.0f64; col_count]; - for cell in &t.cells { - let c = cell.col as usize; - let span = cell.col_span.max(1) as usize; - if c + span <= col_count { - let w = hwpunit_to_px(cell.width as i32, self.dpi); - if span == 1 { - if w > col_widths[c] { col_widths[c] = w; } + let table_widths: Vec = inline_tables + .iter() + .map(|(_, t)| { + // col_widths로부터 table_width 계산 + let col_count = t.col_count as usize; + let cell_spacing = hwpunit_to_px(t.cell_spacing as i32, self.dpi); + let mut col_widths = vec![0.0f64; col_count]; + for cell in &t.cells { + let c = cell.col as usize; + let span = cell.col_span.max(1) as usize; + if c + span <= col_count { + let w = hwpunit_to_px(cell.width as i32, self.dpi); + if span == 1 { + if w > col_widths[c] { + col_widths[c] = w; + } + } } } - } - let total: f64 = col_widths.iter().sum::() - + cell_spacing * (col_count.saturating_sub(1) as f64); - total - }).collect(); + let total: f64 = col_widths.iter().sum::() + + cell_spacing * (col_count.saturating_sub(1) as f64); + total + }) + .collect(); // 4b. 텍스트 세그먼트 폭 계산 - let char_style_id = para.char_shapes.first() + let char_style_id = para + .char_shapes + .first() .map(|cs| cs.char_shape_id as u32) .unwrap_or(0); - let seg_widths: Vec = segments.iter().map(|(s, e)| { - let seg_text: String = text_chars[*s..*e].iter().collect(); - if seg_text.is_empty() { return 0.0; } - // 세그먼트 내 char_shape 변경을 고려한 폭 계산 - let mut total = 0.0; - for ch_idx in *s..*e { - // 해당 문자의 char_shape 찾기 - let utf16_pos = offsets[ch_idx]; - let cs_id = para.char_shapes.iter().rev() - .find(|cs| cs.start_pos <= utf16_pos) - .map(|cs| cs.char_shape_id as u32) - .unwrap_or(char_style_id); - let ch = text_chars[ch_idx]; - let lang = super::super::style_resolver::detect_lang_category(ch); - let ts = resolved_to_text_style(styles, cs_id, lang); - total += estimate_text_width(&ch.to_string(), &ts); - } - total - }).collect(); + let seg_widths: Vec = segments + .iter() + .map(|(s, e)| { + let seg_text: String = text_chars[*s..*e].iter().collect(); + if seg_text.is_empty() { + return 0.0; + } + // 세그먼트 내 char_shape 변경을 고려한 폭 계산 + let mut total = 0.0; + for ch_idx in *s..*e { + // 해당 문자의 char_shape 찾기 + let utf16_pos = offsets[ch_idx]; + let cs_id = para + .char_shapes + .iter() + .rev() + .find(|cs| cs.start_pos <= utf16_pos) + .map(|cs| cs.char_shape_id as u32) + .unwrap_or(char_style_id); + let ch = text_chars[ch_idx]; + let lang = super::super::style_resolver::detect_lang_category(ch); + let ts = resolved_to_text_style(styles, cs_id, lang); + total += estimate_text_width(&ch.to_string(), &ts); + } + total + }) + .collect(); // 5. 총 폭과 정렬 계산 let total_width: f64 = seg_widths.iter().sum::() + table_widths.iter().sum::(); let available_width = col_area.width - margin_left - margin_right; let start_x = match alignment { - Alignment::Center | Alignment::Distribute => col_area.x + margin_left + (available_width - total_width).max(0.0) / 2.0, + Alignment::Center | Alignment::Distribute => { + col_area.x + margin_left + (available_width - total_width).max(0.0) / 2.0 + } Alignment::Right => col_area.x + margin_left + (available_width - total_width).max(0.0), _ => col_area.x + margin_left, }; @@ -168,18 +202,32 @@ impl LayoutEngine { }; // 폰트 어센트 보정용: 문단 내 최대 폰트 크기 let para_max_font_size = { - let default_cs = para.char_shapes.first().map(|cs| cs.char_shape_id as u32).unwrap_or(0); + let default_cs = para + .char_shapes + .first() + .map(|cs| cs.char_shape_id as u32) + .unwrap_or(0); let ts = resolved_to_text_style(styles, default_cs, 0); - if ts.font_size > 0.0 { ts.font_size } else { 12.0 } + if ts.font_size > 0.0 { + ts.font_size + } else { + 12.0 + } }; let baseline_dist = if let Some(ls) = para.line_segs.first() { - ensure_min_baseline(hwpunit_to_px(ls.baseline_distance, self.dpi), para_max_font_size) + ensure_min_baseline( + hwpunit_to_px(ls.baseline_distance, self.dpi), + para_max_font_size, + ) } else { line_height * 0.8 }; // 텍스트 줄(표 아래) 전용 메트릭: line_seg[1]이 있으면 사용 let text_line_baseline = if let Some(ls) = para.line_segs.get(1) { - ensure_min_baseline(hwpunit_to_px(ls.baseline_distance, self.dpi), para_max_font_size) + ensure_min_baseline( + hwpunit_to_px(ls.baseline_distance, self.dpi), + para_max_font_size, + ) } else { baseline_dist }; @@ -222,8 +270,12 @@ impl LayoutEngine { let first_offset = para.char_offsets[0]; ctrl_gap += first_offset; // 선행 컨트롤 for i in 1..para.char_offsets.len() { - let prev_len = if text_chars[i-1] >= '\u{10000}' { 2u32 } else { 1 }; - let gap = para.char_offsets[i] - para.char_offsets[i-1]; + let prev_len = if text_chars[i - 1] >= '\u{10000}' { + 2u32 + } else { + 1 + }; + let gap = para.char_offsets[i] - para.char_offsets[i - 1]; if gap > prev_len + 4 { ctrl_gap += gap - prev_len; // 중간 컨트롤 갭 } @@ -268,7 +320,9 @@ impl LayoutEngine { let mut line_run_x = inline_x; // 현재 줄 run의 x 시작 let mut current_cs_id = { let utf16_pos = offsets[*s]; - para.char_shapes.iter().rev() + para.char_shapes + .iter() + .rev() .find(|cs| cs.start_pos <= utf16_pos) .map(|cs| cs.char_shape_id as u32) .unwrap_or(char_style_id) @@ -276,27 +330,48 @@ impl LayoutEngine { for ch_idx in *s..*e { // 각주 마커 삽입: 현재 문자 위치에 각주가 있으면 먼저 run flush + FootnoteMarker 노드 삽입 - if let Some(&(_, fn_num)) = composed.and_then(|c| c.footnote_positions.iter().find(|&&(pos, _)| pos == ch_idx)) { + if let Some(&(_, fn_num)) = composed.and_then(|c| { + c.footnote_positions.iter().find(|&&(pos, _)| pos == ch_idx) + }) { // 현재까지 누적된 run 출력 if ch_idx > line_run_start { - let run_text: String = text_chars[line_run_start..ch_idx].iter().collect(); - let first_lang = super::super::style_resolver::detect_lang_category(text_chars[line_run_start]); - let run_ts = resolved_to_text_style(styles, current_cs_id, first_lang); + let run_text: String = + text_chars[line_run_start..ch_idx].iter().collect(); + let first_lang = super::super::style_resolver::detect_lang_category( + text_chars[line_run_start], + ); + let run_ts = + resolved_to_text_style(styles, current_cs_id, first_lang); let run_width = estimate_text_width(&run_text, &run_ts); - let run_bbox_h = if wrapped_below_table { text_line_baseline } else { baseline_dist }; + let run_bbox_h = if wrapped_below_table { + text_line_baseline + } else { + baseline_dist + }; let run_id = tree.next_id(); - let run_node = RenderNode::new(run_id, + let run_node = RenderNode::new( + run_id, RenderNodeType::TextRun(TextRunNode { - text: run_text, style: run_ts, + text: run_text, + style: run_ts, char_shape_id: Some(current_cs_id), para_shape_id: Some(para_style_id as u16), section_index: Some(section_index), para_index: Some(para_index), char_start: Some(line_run_start), - cell_context: None, is_para_end: false, is_line_break_end: false, - rotation: 0.0, is_vertical: false, char_overlap: None, - border_fill_id: styles.char_styles.get(current_cs_id as usize).map(|cs| cs.border_fill_id).unwrap_or(0), - baseline: run_bbox_h, field_marker: FieldMarkerType::None, + cell_context: None, + is_para_end: false, + is_line_break_end: false, + rotation: 0.0, + is_vertical: false, + char_overlap: None, + border_fill_id: styles + .char_styles + .get(current_cs_id as usize) + .map(|cs| cs.border_fill_id) + .unwrap_or(0), + baseline: run_bbox_h, + field_marker: FieldMarkerType::None, }), BoundingBox::new(line_run_x, current_y, run_width, run_bbox_h), ); @@ -309,15 +384,29 @@ impl LayoutEngine { let fn_text = format!("{})", fn_num); let base_ts = resolved_to_text_style(styles, current_cs_id, 0); let sup_font_size = (base_ts.font_size * 0.55).max(7.0); - let sup_ts = TextStyle { font_size: sup_font_size, font_family: base_ts.font_family.clone(), ..Default::default() }; + let sup_ts = TextStyle { + font_size: sup_font_size, + font_family: base_ts.font_family.clone(), + ..Default::default() + }; let sup_w = estimate_text_width(&fn_text, &sup_ts); - let run_bbox_h = if wrapped_below_table { text_line_baseline } else { baseline_dist }; + let run_bbox_h = if wrapped_below_table { + text_line_baseline + } else { + baseline_dist + }; // 각주 컨트롤 인덱스 찾기 - let fn_ctrl_idx = composed.map(|c| { - c.footnote_positions.iter().position(|&(p, _)| p == ch_idx).unwrap_or(0) - }).unwrap_or(0); + let fn_ctrl_idx = composed + .map(|c| { + c.footnote_positions + .iter() + .position(|&(p, _)| p == ch_idx) + .unwrap_or(0) + }) + .unwrap_or(0); let marker_id = tree.next_id(); - let marker_node = RenderNode::new(marker_id, + let marker_node = RenderNode::new( + marker_id, RenderNodeType::FootnoteMarker(FootnoteMarkerNode { number: fn_num, text: fn_text, @@ -336,7 +425,10 @@ impl LayoutEngine { } let utf16_pos = offsets[ch_idx]; - let cs_id = para.char_shapes.iter().rev() + let cs_id = para + .char_shapes + .iter() + .rev() .find(|cs| cs.start_pos <= utf16_pos) .map(|cs| cs.char_shape_id as u32) .unwrap_or(char_style_id); @@ -356,12 +448,19 @@ impl LayoutEngine { let cs_changed = cs_id != current_cs_id; // 줄바꿈된 텍스트의 BoundingBox 높이: 표 줄 vs 텍스트 줄 - let run_bbox_h = if wrapped_below_table { text_line_baseline } else { baseline_dist }; + let run_bbox_h = if wrapped_below_table { + text_line_baseline + } else { + baseline_dist + }; if (cs_changed || need_wrap) && ch_idx > line_run_start { // 누적된 run 출력 - let run_text: String = text_chars[line_run_start..ch_idx].iter().collect(); - let first_lang = super::super::style_resolver::detect_lang_category(text_chars[line_run_start]); + let run_text: String = + text_chars[line_run_start..ch_idx].iter().collect(); + let first_lang = super::super::style_resolver::detect_lang_category( + text_chars[line_run_start], + ); let run_ts = resolved_to_text_style(styles, current_cs_id, first_lang); let run_width = estimate_text_width(&run_text, &run_ts); @@ -382,8 +481,11 @@ impl LayoutEngine { rotation: 0.0, is_vertical: false, char_overlap: None, - border_fill_id: styles.char_styles.get(current_cs_id as usize) - .map(|cs| cs.border_fill_id).unwrap_or(0), + border_fill_id: styles + .char_styles + .get(current_cs_id as usize) + .map(|cs| cs.border_fill_id) + .unwrap_or(0), baseline: run_bbox_h, field_marker: FieldMarkerType::None, }), @@ -414,12 +516,18 @@ impl LayoutEngine { } // 남은 run의 BoundingBox 높이 - let remaining_bbox_h = if wrapped_below_table { text_line_baseline } else { baseline_dist }; + let remaining_bbox_h = if wrapped_below_table { + text_line_baseline + } else { + baseline_dist + }; // 남은 run 출력 if line_run_start < *e { let run_text: String = text_chars[line_run_start..*e].iter().collect(); - let first_lang = super::super::style_resolver::detect_lang_category(text_chars[line_run_start]); + let first_lang = super::super::style_resolver::detect_lang_category( + text_chars[line_run_start], + ); let run_ts = resolved_to_text_style(styles, current_cs_id, first_lang); let run_width = estimate_text_width(&run_text, &run_ts); @@ -440,8 +548,11 @@ impl LayoutEngine { rotation: 0.0, is_vertical: false, char_overlap: None, - border_fill_id: styles.char_styles.get(current_cs_id as usize) - .map(|cs| cs.border_fill_id).unwrap_or(0), + border_fill_id: styles + .char_styles + .get(current_cs_id as usize) + .map(|cs| cs.border_fill_id) + .unwrap_or(0), baseline: remaining_bbox_h, field_marker: FieldMarkerType::None, }), @@ -456,22 +567,35 @@ impl LayoutEngine { // 표 하단 = 베이스라인 + outer_margin_bottom if table_idx < inline_tables.len() { let (ctrl_idx, tbl) = &inline_tables[table_idx]; - let mt = measured_tables.iter().find(|mt| - mt.para_index == para_index && mt.control_index == *ctrl_idx - ); + let mt = measured_tables + .iter() + .find(|mt| mt.para_index == para_index && mt.control_index == *ctrl_idx); let tw = table_widths[table_idx]; - let tbl_h = mt.map(|m| m.total_height) + let tbl_h = mt + .map(|m| m.total_height) .unwrap_or_else(|| hwpunit_to_px(tbl.common.height as i32, self.dpi)); let om_bottom = hwpunit_to_px(tbl.outer_margin_bottom as i32, self.dpi); let tbl_y = (current_y + baseline_dist + om_bottom - tbl_h).max(current_y); let table_bottom = self.layout_table( - tree, col_node, tbl, - section_index, styles, col_area, tbl_y, - bin_data_content, mt, 0, + tree, + col_node, + tbl, + section_index, + styles, + col_area, + tbl_y, + bin_data_content, + mt, + 0, Some((para_index, *ctrl_idx)), - Alignment::Left, None, 0.0, 0.0, - Some(inline_x), None, None, + Alignment::Left, + None, + 0.0, + 0.0, + Some(inline_x), + None, + None, ); if table_bottom > max_table_bottom { max_table_bottom = table_bottom; @@ -485,22 +609,35 @@ impl LayoutEngine { // 후행 표 (텍스트 세그먼트보다 표가 더 많은 경우) while table_idx < inline_tables.len() { let (ctrl_idx, tbl) = &inline_tables[table_idx]; - let mt = measured_tables.iter().find(|mt| - mt.para_index == para_index && mt.control_index == *ctrl_idx - ); + let mt = measured_tables + .iter() + .find(|mt| mt.para_index == para_index && mt.control_index == *ctrl_idx); let tw = table_widths[table_idx]; - let tbl_h = mt.map(|m| m.total_height) + let tbl_h = mt + .map(|m| m.total_height) .unwrap_or_else(|| hwpunit_to_px(tbl.common.height as i32, self.dpi)); let om_bottom = hwpunit_to_px(tbl.outer_margin_bottom as i32, self.dpi); let tbl_y = (current_y + baseline_dist + om_bottom - tbl_h).max(current_y); let table_bottom = self.layout_table( - tree, col_node, tbl, - section_index, styles, col_area, tbl_y, - bin_data_content, mt, 0, + tree, + col_node, + tbl, + section_index, + styles, + col_area, + tbl_y, + bin_data_content, + mt, + 0, Some((para_index, *ctrl_idx)), - Alignment::Left, None, 0.0, 0.0, - Some(inline_x), None, None, + Alignment::Left, + None, + 0.0, + 0.0, + Some(inline_x), + None, + None, ); if table_bottom > max_table_bottom { max_table_bottom = table_bottom; @@ -518,7 +655,9 @@ impl LayoutEngine { current_y + line_height + line_spacing }; // 표와 텍스트 중 더 큰 하단을 사용 - let effective_line_bottom = max_table_bottom.max(text_bottom).max(y + line_height + line_spacing); + let effective_line_bottom = max_table_bottom + .max(text_bottom) + .max(y + line_height + line_spacing); effective_line_bottom + spacing_after } @@ -541,8 +680,19 @@ impl LayoutEngine { .map(|c| c.lines.len()) .unwrap_or(para.line_segs.len()); self.layout_partial_paragraph( - tree, col_node, para, composed, styles, col_area, y_start, 0, end_line, - section_index, para_index, multi_col_width_hu, bin_data_content, + tree, + col_node, + para, + composed, + styles, + col_area, + y_start, + 0, + end_line, + section_index, + para_index, + multi_col_width_hu, + bin_data_content, ) } @@ -565,9 +715,22 @@ impl LayoutEngine { ) -> f64 { if let Some(comp) = composed { return self.layout_composed_paragraph( - tree, col_node, comp, styles, col_area, y_start, start_line, end_line, - section_index, para_index, None, false, 0.0, multi_col_width_hu, - Some(para), bin_data_content, + tree, + col_node, + comp, + styles, + col_area, + y_start, + start_line, + end_line, + section_index, + para_index, + None, + false, + 0.0, + multi_col_width_hu, + Some(para), + bin_data_content, ); } @@ -609,7 +772,9 @@ impl LayoutEngine { let margin_left = para_style.map(|s| s.margin_left).unwrap_or(0.0); let margin_right = para_style.map(|s| s.margin_right).unwrap_or(0.0); let indent = para_style.map(|s| s.indent).unwrap_or(0.0); - let alignment = para_style.map(|s| s.alignment).unwrap_or(Alignment::Justify); + let alignment = para_style + .map(|s| s.alignment) + .unwrap_or(Alignment::Justify); let spacing_before = para_style.map(|s| s.spacing_before).unwrap_or(0.0); let spacing_after = para_style.map(|s| s.spacing_after).unwrap_or(0.0); let tab_width = para_style.map(|s| s.default_tab_width).unwrap_or(0.0); @@ -618,7 +783,9 @@ impl LayoutEngine { // treat_as_char 컨트롤의 px 폭 목록 (절대 char 위치, px 폭, control_index) — 정렬 보장 let tac_offsets_px: Vec<(usize, f64, usize)> = { - let mut v: Vec<(usize, f64, usize)> = composed.tac_controls.iter() + let mut v: Vec<(usize, f64, usize)> = composed + .tac_controls + .iter() .map(|(pos, w_hu, ci)| (*pos, hwpunit_to_px(*w_hu, self.dpi), *ci)) .collect(); v.sort_by_key(|(p, _, _)| *p); @@ -643,26 +810,30 @@ impl LayoutEngine { // 문단 전체에서 모든 라인의 runs가 비어있는지 확인 // (텍스트 없이 TAC 이미지만 있는 문단) - let all_runs_empty = composed.lines[start_line..end].iter().all(|l| l.runs.is_empty()); + let all_runs_empty = composed.lines[start_line..end] + .iter() + .all(|l| l.runs.is_empty()); // 개요 번호/글머리표 마커 폭 사전 계산 (첫 줄 가용폭 차감용) let numbering_width = if start_line == 0 { if let Some(ref num_text) = composed.numbering_text { - let num_style = composed.lines.first() + let num_style = composed + .lines + .first() .and_then(|l| l.runs.first()) .map(|r| resolved_to_text_style(styles, r.char_style_id, r.lang_index)) .unwrap_or_else(|| resolved_to_text_style(styles, 0, 0)); estimate_text_width(num_text, &num_style) - } else { 0.0 } - } else { 0.0 }; + } else { + 0.0 + } + } else { + 0.0 + }; // 배경/테두리 렌더링을 위한 시작 위치 기록 // 문단 경계 = 이전 문단 끝 = y_start (spacing_before 적용 전) - let bg_y_start = if para_border_fill_id > 0 { - y_start - } else { - y - }; + let bg_y_start = if para_border_fill_id > 0 { y_start } else { y }; let bg_insert_idx = col_node.children.len(); // start_line까지의 누적 문자 오프셋 계산 (편집용 문서 좌표) @@ -695,10 +866,16 @@ impl LayoutEngine { } // 최대 폰트 크기 계산 (line_height 최솟값 보정에도 사용) - let max_fs = comp_line.runs.iter() + let max_fs = comp_line + .runs + .iter() .map(|r| { let ts = resolved_to_text_style(styles, r.char_style_id, r.lang_index); - if ts.font_size > 0.0 { ts.font_size } else { 12.0 } + if ts.font_size > 0.0 { + ts.font_size + } else { + 12.0 + } }) .fold(0.0f64, f64::max); // LineSeg.line_height는 HWP에서 줄간격이 이미 반영된 값. @@ -706,26 +883,39 @@ impl LayoutEngine { // ParaShape의 줄간격 설정(line_spacing_type + line_spacing)으로 올바른 줄 높이를 계산한다. let raw_lh = hwpunit_to_px(comp_line.line_height, self.dpi); let line_height = { - let ls_val = para_style.map(|s| s.line_spacing).unwrap_or(160.0); - let ls_type = para_style.map(|s| s.line_spacing_type).unwrap_or(LineSpacingType::Percent); + let ls_val = para_style.map(|s| s.line_spacing).unwrap_or(160.0); + let ls_type = para_style + .map(|s| s.line_spacing_type) + .unwrap_or(LineSpacingType::Percent); crate::renderer::corrected_line_height(raw_lh, max_fs, ls_type, ls_val) }; // 인라인 Shape(글상자)가 있는 줄: line_height에 Shape 높이가 포함됨 // Shape는 별도 패스에서 para_y 기준으로 렌더링되므로, // 텍스트의 y와 line_height를 폰트 기반으로 보정하여 baseline 정렬 - let has_tac_shape = !tac_offsets_px.is_empty() && para.map(|p| { - tac_offsets_px.iter().any(|(_, _, ci)| { - p.controls.get(*ci).map(|c| matches!(c, Control::Shape(_))).unwrap_or(false) - }) - }).unwrap_or(false); + let has_tac_shape = !tac_offsets_px.is_empty() + && para + .map(|p| { + tac_offsets_px.iter().any(|(_, _, ci)| { + p.controls + .get(*ci) + .map(|c| matches!(c, Control::Shape(_))) + .unwrap_or(false) + }) + }) + .unwrap_or(false); let (line_height, baseline) = if has_tac_shape && raw_lh > max_fs * 1.5 { // Shape 높이가 line_height에 포함 → 폰트 기반 line_height 사용 let font_lh = max_fs * 1.2; // 폰트 크기의 120% let font_bl = max_fs * 0.85; (font_lh, ensure_min_baseline(font_bl, max_fs)) } else { - (line_height, ensure_min_baseline( - hwpunit_to_px(comp_line.baseline_distance, self.dpi), max_fs)) + ( + line_height, + ensure_min_baseline( + hwpunit_to_px(comp_line.baseline_distance, self.dpi), + max_fs, + ), + ) }; // 들여쓰기/내어쓰기: 문단 여백은 무조건 적용 @@ -733,9 +923,17 @@ impl LayoutEngine { // - 들여쓰기(ind>0): 첫줄 margin_left+indent, 다음줄 margin_left // - 내어쓰기(ind<0): 첫줄 margin_left, 다음줄 margin_left+|indent| let line_indent = if indent > 0.0 { - if line_idx == 0 { indent } else { 0.0 } + if line_idx == 0 { + indent + } else { + 0.0 + } } else if indent < 0.0 { - if line_idx == 0 { 0.0 } else { indent.abs() } + if line_idx == 0 { + 0.0 + } else { + indent.abs() + } } else { 0.0 }; @@ -766,7 +964,12 @@ impl LayoutEngine { let line_id = tree.next_id(); let mut line_node = RenderNode::new( line_id, - RenderNodeType::TextLine(TextLineNode::with_para(line_height, baseline, section_index, para_index)), + RenderNodeType::TextLine(TextLineNode::with_para( + line_height, + baseline, + section_index, + para_index, + )), BoundingBox::new( col_area.x + effective_margin_left, text_y, @@ -775,11 +978,19 @@ impl LayoutEngine { ), ); - let inline_offset = if line_idx == start_line { first_line_x_offset } else { 0.0 }; + let inline_offset = if line_idx == start_line { + first_line_x_offset + } else { + 0.0 + }; // 번호/글머리표 마커: 모든 줄에서 마커 폭만큼 가용폭 차감 (행잉 인덴트) - let num_offset = if numbering_width > 0.0 { numbering_width } else { 0.0 }; - let available_width = col_area.width - effective_margin_left - margin_right - inline_offset - num_offset; - + let num_offset = if numbering_width > 0.0 { + numbering_width + } else { + 0.0 + }; + let available_width = + col_area.width - effective_margin_left - margin_right - inline_offset - num_offset; // 텍스트 정렬을 위한 전체 줄 폭 계산 (자연 폭, 추가 간격 미포함) // treat_as_char 이미지 폭도 포함하여 정확한 폭 산출 @@ -816,13 +1027,18 @@ impl LayoutEngine { } // 글자겹침 run: PUA 다자리 숫자는 1글자 폭, 그 외는 font_size * char_count if run.char_overlap.is_some() { - let fs = if ts.font_size > 0.0 { ts.font_size } else { 12.0 }; - let chars: Vec = run.text.chars().collect(); - let w = if crate::renderer::composer::decode_pua_overlap_number(&chars).is_some() { - fs // 다자리 PUA 숫자는 하나의 원/사각형 = 1글자 폭 + let fs = if ts.font_size > 0.0 { + ts.font_size } else { - fs * run_char_count_est as f64 + 12.0 }; + let chars: Vec = run.text.chars().collect(); + let w = + if crate::renderer::composer::decode_pua_overlap_number(&chars).is_some() { + fs // 다자리 PUA 숫자는 하나의 원/사각형 = 1글자 폭 + } else { + fs * run_char_count_est as f64 + }; est_x += w; run_char_pos_est = run_char_end_est; continue; @@ -831,10 +1047,18 @@ impl LayoutEngine { // 마지막 run에서는 run_char_end 위치의 TAC도 포함 let run_chars_est: Vec = run.text.chars().collect(); let mut seg_start_est = 0usize; - let is_last_run_est_tac = run_char_end_est >= comp_line.runs.iter().map(|r| r.text.chars().count()).sum::() + comp_line.char_start; - for &(tac_abs_pos, tac_w, _) in tac_offsets_px.iter() - .filter(|(pos, _, _)| *pos >= run_char_pos_est && (*pos < run_char_end_est || (is_last_run_est_tac && *pos == run_char_end_est))) - { + let is_last_run_est_tac = run_char_end_est + >= comp_line + .runs + .iter() + .map(|r| r.text.chars().count()) + .sum::() + + comp_line.char_start; + for &(tac_abs_pos, tac_w, _) in tac_offsets_px.iter().filter(|(pos, _, _)| { + *pos >= run_char_pos_est + && (*pos < run_char_end_est + || (is_last_run_est_tac && *pos == run_char_end_est)) + }) { let tac_rel = tac_abs_pos - run_char_pos_est; if seg_start_est < tac_rel { let seg: String = run_chars_est[seg_start_est..tac_rel].iter().collect(); @@ -858,7 +1082,11 @@ impl LayoutEngine { let abs_before = ts.line_x_offset + w_before; let tw = if tab_width > 0.0 { tab_width } else { 48.0 }; let (tp, tt, _) = find_next_tab_stop( - abs_before, &tab_stops, tw, auto_tab_right, available_width, + abs_before, + &tab_stops, + tw, + auto_tab_right, + available_width, ); if tt == 1 || tt == 2 { pending_right_tab_est = Some((tp, tt)); @@ -866,12 +1094,25 @@ impl LayoutEngine { } } // 각주 마커 폭: run 내에 각주가 있으면 마커 위첨자 폭 추가 - let is_last_run_est = run_char_end_est >= comp_line.runs.iter().map(|r| r.text.chars().count()).sum::() + comp_line.char_start; + let is_last_run_est = run_char_end_est + >= comp_line + .runs + .iter() + .map(|r| r.text.chars().count()) + .sum::() + + comp_line.char_start; for &(fpos, fnum) in composed.footnote_positions.iter() { - if fpos >= run_char_pos_est && (fpos < run_char_end_est || (is_last_run_est && fpos == run_char_end_est)) { + if fpos >= run_char_pos_est + && (fpos < run_char_end_est + || (is_last_run_est && fpos == run_char_end_est)) + { let fn_text = format!("{})", fnum); let sup_size = (ts.font_size * 0.55).max(7.0); - let sup_ts = TextStyle { font_size: sup_size, font_family: ts.font_family.clone(), ..Default::default() }; + let sup_ts = TextStyle { + font_size: sup_size, + font_family: ts.font_family.clone(), + ..Default::default() + }; est_x += estimate_text_width(&fn_text, &sup_ts); } } @@ -882,10 +1123,16 @@ impl LayoutEngine { let mut total_text_width = (est_x - est_x_start).max(0.0); // TAC 이미지/Shape 폭이 est_x에 미포함된 경우 별도 추가 // (이미지가 텍스트 끝 위치에 있으면 run 범위 필터에서 제외됨) - let total_tac_width_in_line: f64 = tac_offsets_px.iter() + let total_tac_width_in_line: f64 = tac_offsets_px + .iter() .filter(|(pos, _, _)| { let line_start = comp_line.char_start; - let line_end = line_start + comp_line.runs.iter().map(|r| r.text.chars().count()).sum::(); + let line_end = line_start + + comp_line + .runs + .iter() + .map(|r| r.text.chars().count()) + .sum::(); *pos >= line_start && *pos <= line_end }) .map(|(_, w, _)| w) @@ -895,62 +1142,88 @@ impl LayoutEngine { } let is_last_line_of_para = line_idx == end - 1 && end == composed.lines.len(); - // 정렬별 간격 분배 계산 let has_forced_break = comp_line.has_line_break; - let needs_justify = alignment == Alignment::Justify - && !is_last_line_of_para && !has_forced_break; + let needs_justify = + alignment == Alignment::Justify && !is_last_line_of_para && !has_forced_break; let needs_distribute = alignment == Alignment::Distribute || (alignment == Alignment::Split && !is_last_line_of_para && !has_forced_break); let has_tabs = comp_line.runs.iter().any(|r| r.text.contains('\t')); - let total_char_count: usize = comp_line.runs.iter() - .map(|r| r.text.chars().filter(|c| *c != '\t').count()).sum(); + let total_char_count: usize = comp_line + .runs + .iter() + .map(|r| r.text.chars().filter(|c| *c != '\t').count()) + .sum(); let (extra_word_sp, extra_char_sp) = if needs_justify { // 양쪽 정렬: 후행 공백 제외한 내부 공백에 분배 - let all_chars: Vec = comp_line.runs.iter() - .flat_map(|r| r.text.chars()).collect(); - let trailing_spaces = all_chars.iter().rev() - .take_while(|c| **c == ' ').count(); + let all_chars: Vec = + comp_line.runs.iter().flat_map(|r| r.text.chars()).collect(); + let trailing_spaces = all_chars.iter().rev().take_while(|c| **c == ' ').count(); let visible_count = all_chars.len() - trailing_spaces; - let interior_spaces = all_chars[..visible_count].iter() - .filter(|c| **c == ' ').count(); + let interior_spaces = all_chars[..visible_count] + .iter() + .filter(|c| **c == ' ') + .count(); if interior_spaces > 0 { // 후행 공백 폭 계산 let trailing_width = if trailing_spaces > 0 { if let Some(last_run) = comp_line.runs.last() { - let mut ts = resolved_to_text_style(styles, last_run.char_style_id, last_run.lang_index); + let mut ts = resolved_to_text_style( + styles, + last_run.char_style_id, + last_run.lang_index, + ); ts.default_tab_width = tab_width; let trailing_str: String = " ".repeat(trailing_spaces); estimate_text_width(&trailing_str, &ts) - } else { 0.0 } - } else { 0.0 }; + } else { + 0.0 + } + } else { + 0.0 + }; let effective_used = total_text_width - trailing_width; // 양쪽 정렬: 단어 간격 분배 // 메트릭 차이로 text_w > avail이면 음수가 되지만, // 공백 최소 폭을 보장하여 글자 겹침 방지 let raw_ews = (available_width - effective_used) / interior_spaces as f64; - let space_base_w = estimate_text_width(" ", &resolved_to_text_style( - styles, comp_line.runs[0].char_style_id, comp_line.runs[0].lang_index)); + let space_base_w = estimate_text_width( + " ", + &resolved_to_text_style( + styles, + comp_line.runs[0].char_style_id, + comp_line.runs[0].lang_index, + ), + ); let min_ews = -(space_base_w * 0.5); // 공백 폭의 50%까지만 축소 허용 (raw_ews.max(min_ews), 0.0) } else if total_char_count > 1 { // 양쪽 정렬이지만 공백 없음 (일본어 등): // 단어 간격 대신 글자 간격으로 양쪽 맞춤 - (0.0, (available_width - total_text_width) / total_char_count as f64) + ( + 0.0, + (available_width - total_text_width) / total_char_count as f64, + ) } else { (0.0, 0.0) } } else if needs_distribute && total_char_count > 1 { // 배분/나눔 정렬: 모든 글자에 균등 분배 (음수 허용으로 압축 가능) - (0.0, (available_width - total_text_width) / total_char_count as f64) + ( + 0.0, + (available_width - total_text_width) / total_char_count as f64, + ) } else if total_text_width > available_width && total_char_count > 1 && !has_tabs { // 비정렬(왼쪽/오른쪽/가운데) 텍스트가 오버플로우할 때 글자 간격 압축 // 원본 HWP line_segs가 우리 폰트 메트릭과 다를 경우 // 텍스트가 body_area를 넘지 않도록 균등 압축 // 탭이 있는 줄은 탭 정지가 절대 위치를 제어하므로 압축하지 않음 - (0.0, (available_width - total_text_width) / total_char_count as f64) + ( + 0.0, + (available_width - total_text_width) / total_char_count as f64, + ) } else { (0.0, 0.0) }; @@ -958,16 +1231,30 @@ impl LayoutEngine { // 비첫줄에서 번호 마커 오프셋 (첫 줄은 마커 렌더링이 x를 전진시킴) let num_x_offset = if num_offset > 0.0 && !(line_idx == start_line && start_line == 0) { num_offset - } else { 0.0 }; + } else { + 0.0 + }; let x_start = match alignment { Alignment::Center => { - col_area.x + effective_margin_left + inline_offset + num_x_offset + (available_width - total_text_width).max(0.0) / 2.0 + col_area.x + + effective_margin_left + + inline_offset + + num_x_offset + + (available_width - total_text_width).max(0.0) / 2.0 } Alignment::Distribute if !needs_distribute || total_char_count <= 1 => { - col_area.x + effective_margin_left + inline_offset + num_x_offset + (available_width - total_text_width).max(0.0) / 2.0 + col_area.x + + effective_margin_left + + inline_offset + + num_x_offset + + (available_width - total_text_width).max(0.0) / 2.0 } Alignment::Right => { - col_area.x + effective_margin_left + inline_offset + num_x_offset + (available_width - total_text_width).max(0.0) + col_area.x + + effective_margin_left + + inline_offset + + num_x_offset + + (available_width - total_text_width).max(0.0) } _ => col_area.x + effective_margin_left + inline_offset + num_x_offset, // Left, Justify, Split, Distribute(분배중) }; @@ -980,7 +1267,11 @@ impl LayoutEngine { if line_idx == start_line && start_line == 0 { if let Some(ref num_text) = composed.numbering_text { let num_style = if let Some(first_run) = comp_line.runs.first() { - resolved_to_text_style(styles, first_run.char_style_id, first_run.lang_index) + resolved_to_text_style( + styles, + first_run.char_style_id, + first_run.lang_index, + ) } else { resolved_to_text_style(styles, 0, 0) }; @@ -1021,38 +1312,61 @@ impl LayoutEngine { let show_ctrl = self.show_control_codes.get(); let shape_markers: Vec<(usize, String)> = if show_ctrl { if let Some(ref pa) = para { - let ctrl_positions = crate::document_core::helpers::find_control_text_positions(pa); - pa.controls.iter().enumerate().filter_map(|(ci, ctrl)| { - let pos = ctrl_positions.get(ci).copied().unwrap_or(0); - match ctrl { - Control::Shape(s) => Some((pos, format!("[{}]", s.shape_name()))), - Control::Picture(_) => Some((pos, "[그림]".to_string())), - Control::Table(t) if t.common.treat_as_char => Some((pos, "[표]".to_string())), - Control::PageHide(_) => Some((pos, "[감추기]".to_string())), - Control::PageNumberPos(_) => Some((pos, "[쪽 번호 위치]".to_string())), - Control::Header(h) => { - let apply = match h.apply_to { - crate::model::header_footer::HeaderFooterApply::Both => "양 쪽", - crate::model::header_footer::HeaderFooterApply::Even => "짝수 쪽", - crate::model::header_footer::HeaderFooterApply::Odd => "홀수 쪽", - }; - Some((pos, format!("[머리말({})]", apply))) - } - Control::Footer(f) => { - let apply = match f.apply_to { - crate::model::header_footer::HeaderFooterApply::Both => "양 쪽", - crate::model::header_footer::HeaderFooterApply::Even => "짝수 쪽", - crate::model::header_footer::HeaderFooterApply::Odd => "홀수 쪽", - }; - Some((pos, format!("[꼬리말({})]", apply))) + let ctrl_positions = + crate::document_core::helpers::find_control_text_positions(pa); + pa.controls + .iter() + .enumerate() + .filter_map(|(ci, ctrl)| { + let pos = ctrl_positions.get(ci).copied().unwrap_or(0); + match ctrl { + Control::Shape(s) => Some((pos, format!("[{}]", s.shape_name()))), + Control::Picture(_) => Some((pos, "[그림]".to_string())), + Control::Table(t) if t.common.treat_as_char => { + Some((pos, "[표]".to_string())) + } + Control::PageHide(_) => Some((pos, "[감추기]".to_string())), + Control::PageNumberPos(_) => { + Some((pos, "[쪽 번호 위치]".to_string())) + } + Control::Header(h) => { + let apply = match h.apply_to { + crate::model::header_footer::HeaderFooterApply::Both => { + "양 쪽" + } + crate::model::header_footer::HeaderFooterApply::Even => { + "짝수 쪽" + } + crate::model::header_footer::HeaderFooterApply::Odd => { + "홀수 쪽" + } + }; + Some((pos, format!("[머리말({})]", apply))) + } + Control::Footer(f) => { + let apply = match f.apply_to { + crate::model::header_footer::HeaderFooterApply::Both => { + "양 쪽" + } + crate::model::header_footer::HeaderFooterApply::Even => { + "짝수 쪽" + } + crate::model::header_footer::HeaderFooterApply::Odd => { + "홀수 쪽" + } + }; + Some((pos, format!("[꼬리말({})]", apply))) + } + Control::Footnote(_) => Some((pos, "[각주]".to_string())), + Control::Endnote(_) => Some((pos, "[미주]".to_string())), + Control::NewNumber(_) => Some((pos, "[새 번호]".to_string())), + Control::Bookmark(bm) => { + Some((pos, format!("[책갈피:{}]", bm.name))) + } + _ => None, } - Control::Footnote(_) => Some((pos, "[각주]".to_string())), - Control::Endnote(_) => Some((pos, "[미주]".to_string())), - Control::NewNumber(_) => Some((pos, "[새 번호]".to_string())), - Control::Bookmark(bm) => Some((pos, format!("[책갈피:{}]", bm.name))), - _ => None, - } - }).collect() + }) + .collect() } else { Vec::new() } @@ -1074,30 +1388,41 @@ impl LayoutEngine { for (smi, (spos, stext)) in shape_markers.iter().enumerate() { if !shape_marker_inserted[smi] && *spos <= run_char_pos { shape_marker_inserted[smi] = true; - let base_style = resolved_to_text_style(styles, run.char_style_id, run.lang_index); + let base_style = + resolved_to_text_style(styles, run.char_style_id, run.lang_index); let mut ms = base_style; ms.color = 0x0000FF; // BGR: 빨간색 ms.font_size *= 0.55; let mw = estimate_text_width(stext, &ms); let mid = tree.next_id(); - let mn = RenderNode::new(mid, RenderNodeType::TextRun(TextRunNode { - text: stext.clone(), style: ms, - char_shape_id: None, - para_shape_id: Some(composed.para_style_id), - section_index: Some(section_index), - para_index: Some(para_index), - char_start: None, - cell_context: cell_ctx.clone(), - is_para_end: false, is_line_break_end: false, - rotation: 0.0, is_vertical: false, - char_overlap: None, border_fill_id: 0, baseline, - field_marker: FieldMarkerType::ShapeMarker(*spos), - }), BoundingBox::new(x, y, mw, line_height)); + let mn = RenderNode::new( + mid, + RenderNodeType::TextRun(TextRunNode { + text: stext.clone(), + style: ms, + char_shape_id: None, + para_shape_id: Some(composed.para_style_id), + section_index: Some(section_index), + para_index: Some(para_index), + char_start: None, + cell_context: cell_ctx.clone(), + is_para_end: false, + is_line_break_end: false, + rotation: 0.0, + is_vertical: false, + char_overlap: None, + border_fill_id: 0, + baseline, + field_marker: FieldMarkerType::ShapeMarker(*spos), + }), + BoundingBox::new(x, y, mw, line_height), + ); line_node.children.push(mn); x += mw; } } - let mut text_style = resolved_to_text_style(styles, run.char_style_id, run.lang_index); + let mut text_style = + resolved_to_text_style(styles, run.char_style_id, run.lang_index); text_style.default_tab_width = tab_width; text_style.tab_stops = tab_stops.clone(); text_style.auto_tab_right = auto_tab_right; @@ -1117,11 +1442,18 @@ impl LayoutEngine { text_style.line_x_offset = x - col_area.x; text_style.extra_word_spacing = extra_word_sp; text_style.extra_char_spacing = extra_char_sp; - let run_border_fill_id = styles.char_styles.get(run.char_style_id as usize) - .map(|cs| cs.border_fill_id).unwrap_or(0); + let run_border_fill_id = styles + .char_styles + .get(run.char_style_id as usize) + .map(|cs| cs.border_fill_id) + .unwrap_or(0); let full_width = if run.char_overlap.is_some() { // 글자겹침: PUA 다자리 숫자는 1글자 폭, 그 외는 font_size * char_count - let fs = if text_style.font_size > 0.0 { text_style.font_size } else { 12.0 }; + let fs = if text_style.font_size > 0.0 { + text_style.font_size + } else { + 12.0 + }; let chars: Vec = run.text.chars().collect(); if crate::renderer::composer::decode_pua_overlap_number(&chars).is_some() { fs @@ -1137,7 +1469,12 @@ impl LayoutEngine { let saved_inline_tabs = std::mem::take(&mut text_style.inline_tabs); let positions = compute_char_positions(&run.text, &text_style); text_style.inline_tabs = saved_inline_tabs; - text_style.tab_leaders = extract_tab_leaders_with_extended(&run.text, &positions, &text_style, &composed.tab_extended); + text_style.tab_leaders = extract_tab_leaders_with_extended( + &run.text, + &positions, + &text_style, + &composed.tab_extended, + ); } // 교차 run 오른쪽/가운데 탭 감지: // run이 \t로 끝나면 해당 탭의 종류를 확인하여 다음 run 조정에 사용 @@ -1148,7 +1485,11 @@ impl LayoutEngine { let abs_before = text_style.line_x_offset + w_before; let tw = if tab_width > 0.0 { tab_width } else { 48.0 }; let (tp, tt, _) = find_next_tab_stop( - abs_before, &tab_stops, tw, auto_tab_right, available_width, + abs_before, + &tab_stops, + tw, + auto_tab_right, + available_width, ); if tt == 1 || tt == 2 { pending_right_tab_render = Some((tp, tt)); @@ -1172,15 +1513,20 @@ impl LayoutEngine { // treat_as_char 분기점: run 내 이미지 위치 목록 (rel_pos, width_px, control_index) // 마지막 run에서는 run_char_end 위치의 TAC도 포함 (문단 끝 수식/그림) - 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))) + 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(); if run_tacs.is_empty() { // tac 없음: 기존 렌더링 경로 // 선행 공백 분리 - let leading_spaces: String = run.text.chars().take_while(|c| *c == ' ').collect(); + let leading_spaces: String = + run.text.chars().take_while(|c| *c == ' ').collect(); let content = run.text.trim_start_matches(' '); // 글자 테두리/배경: bbox 계산용 run_x, run_w @@ -1219,16 +1565,24 @@ impl LayoutEngine { // 형광펜 배경 사각형 (RangeTag type=2) if let Some(p) = para { if !p.range_tags.is_empty() { - let char_w = if run_char_count > 0 { run_w / run_char_count as f64 } else { 0.0 }; + let char_w = if run_char_count > 0 { + run_w / run_char_count as f64 + } else { + 0.0 + }; for rt in &p.range_tags { let rt_type = (rt.tag >> 24) & 0xFF; - if rt_type != 2 { continue; } + if rt_type != 2 { + continue; + } let rt_start = rt.start as usize; let rt_end = rt.end as usize; // run과 RangeTag가 겹치는 문자 범위 let overlap_start = rt_start.max(run_char_pos); let overlap_end = rt_end.min(run_char_end); - if overlap_start >= overlap_end { continue; } + if overlap_start >= overlap_end { + continue; + } let hl_color = rt.tag & 0x00FFFFFF; let hl_x = run_x + (overlap_start - run_char_pos) as f64 * char_w; let hl_w = (overlap_end - overlap_start) as f64 * char_w; @@ -1257,9 +1611,12 @@ impl LayoutEngine { // run 내 각주 위치 수집 (run 내 상대 위치, 각주 번호, fn_positions 인덱스) // 마지막 run에서는 run_char_end 위치의 각주도 포함 (문단 끝 각주) let is_last = is_last_run_of_line(run_idx); - let run_fn_markers: Vec<(usize, u16, usize)> = fn_positions.iter().enumerate() + let run_fn_markers: Vec<(usize, u16, usize)> = fn_positions + .iter() + .enumerate() .filter_map(|(fni, &(fpos, fnum))| { - let in_range = fpos >= run_char_pos && (fpos < run_char_end || (is_last && fpos == run_char_end)); + let in_range = fpos >= run_char_pos + && (fpos < run_char_end || (is_last && fpos == run_char_end)); if !fn_marker_inserted[fni] && in_range { Some((fpos - run_char_pos, fnum, fni)) } else { @@ -1305,22 +1662,29 @@ impl LayoutEngine { fn_marker_inserted[fni] = true; // 각주 앞 텍스트 세그먼트 if rel_pos > seg_start { - let seg_text: String = run_chars[seg_start..rel_pos].iter().collect(); + let seg_text: String = + run_chars[seg_start..rel_pos].iter().collect(); let seg_w = estimate_text_width(&seg_text, &text_style); let seg_id = tree.next_id(); - let seg_node = RenderNode::new(seg_id, + let seg_node = RenderNode::new( + seg_id, RenderNodeType::TextRun(TextRunNode { - text: seg_text, style: text_style.clone(), + text: seg_text, + style: text_style.clone(), char_shape_id: Some(run.char_style_id), para_shape_id: Some(composed.para_style_id), section_index: Some(section_index), para_index: Some(para_index), char_start: Some(sub_char_offset), cell_context: cell_ctx.clone(), - is_para_end: false, is_line_break_end: false, - rotation: 0.0, is_vertical: false, - char_overlap: None, border_fill_id: run_border_fill_id, - baseline, field_marker: FieldMarkerType::None, + is_para_end: false, + is_line_break_end: false, + rotation: 0.0, + is_vertical: false, + char_overlap: None, + border_fill_id: run_border_fill_id, + baseline, + field_marker: FieldMarkerType::None, }), BoundingBox::new(sub_x, y, seg_w, line_height), ); @@ -1332,19 +1696,28 @@ impl LayoutEngine { let fn_text = format!("{})", fnum); let base_ts = &text_style; let sup_size = (base_ts.font_size * 0.55).max(7.0); - let sup_ts = TextStyle { font_size: sup_size, font_family: base_ts.font_family.clone(), color: base_ts.color, ..Default::default() }; - let sup_w = estimate_text_width(&fn_text, &sup_ts); - let fid = tree.next_id(); - let fn_node = RenderNode::new(fid, RenderNodeType::FootnoteMarker(FootnoteMarkerNode { - number: fnum, - text: fn_text, - base_font_size: base_ts.font_size, + let sup_ts = TextStyle { + font_size: sup_size, font_family: base_ts.font_family.clone(), color: base_ts.color, - section_index, - para_index, - control_index: fni, - }), BoundingBox::new(sub_x, y, sup_w, line_height)); + ..Default::default() + }; + let sup_w = estimate_text_width(&fn_text, &sup_ts); + let fid = tree.next_id(); + let fn_node = RenderNode::new( + fid, + RenderNodeType::FootnoteMarker(FootnoteMarkerNode { + number: fnum, + text: fn_text, + base_font_size: base_ts.font_size, + font_family: base_ts.font_family.clone(), + color: base_ts.color, + section_index, + para_index, + control_index: fni, + }), + BoundingBox::new(sub_x, y, sup_w, line_height), + ); line_node.children.push(fn_node); sub_x += sup_w; fn_split_extra += sup_w; @@ -1355,9 +1728,11 @@ impl LayoutEngine { let seg_text: String = run_chars[seg_start..].iter().collect(); let seg_w = estimate_text_width(&seg_text, &text_style); let seg_id = tree.next_id(); - let seg_node = RenderNode::new(seg_id, + let seg_node = RenderNode::new( + seg_id, RenderNodeType::TextRun(TextRunNode { - text: seg_text, style: text_style, + text: seg_text, + style: text_style, char_shape_id: Some(run.char_style_id), para_shape_id: Some(composed.para_style_id), section_index: Some(section_index), @@ -1366,10 +1741,12 @@ impl LayoutEngine { cell_context: cell_ctx.clone(), is_para_end: is_last_run, is_line_break_end: is_line_break, - rotation: 0.0, is_vertical: false, + rotation: 0.0, + is_vertical: false, char_overlap: run.char_overlap.clone(), border_fill_id: run_border_fill_id, - baseline, field_marker: FieldMarkerType::None, + baseline, + field_marker: FieldMarkerType::None, }), BoundingBox::new(sub_x, y, seg_w, line_height), ); @@ -1394,7 +1771,14 @@ impl LayoutEngine { (bx, by + bh, bx + bw, by + bh, 3), // bottom ]; for (lx1, ly1, lx2, ly2, bi) in border_pairs { - let nodes = create_border_line_nodes(tree, &bs.borders[bi], lx1, ly1, lx2, ly2); + let nodes = create_border_line_nodes( + tree, + &bs.borders[bi], + lx1, + ly1, + lx2, + ly2, + ); for n in nodes { line_node.children.push(n); } @@ -1412,15 +1796,18 @@ impl LayoutEngine { // 인라인 Shape 중 글상자(TextBox)가 있는 경우에만 텍스트 스킵 // (글상자 텍스트는 table_layout에서 렌더링) // 단순 도형(사각형, 원 등)은 TextBox가 없으므로 텍스트를 여기서 렌더링 - let skip_text_for_inline_shape = has_tac_shape && para.map(|p| { - tac_offsets_px.iter().any(|(_, _, ci)| { - if let Some(Control::Shape(s)) = p.controls.get(*ci) { - s.drawing().map(|d| d.text_box.is_some()).unwrap_or(false) - } else { - false - } - }) - }).unwrap_or(false); + let skip_text_for_inline_shape = has_tac_shape + && para + .map(|p| { + tac_offsets_px.iter().any(|(_, _, ci)| { + if let Some(Control::Shape(s)) = p.controls.get(*ci) { + s.drawing().map(|d| d.text_box.is_some()).unwrap_or(false) + } else { + false + } + }) + }) + .unwrap_or(false); for &(tac_rel, tac_w, tac_ci) in &run_tacs { // tac 앞 텍스트 세그먼트 렌더링 @@ -1431,7 +1818,12 @@ impl LayoutEngine { // 탭 리더 계산 if has_tabs && seg_text.contains('\t') { let positions = compute_char_positions(&seg_text, &seg_style); - seg_style.tab_leaders = extract_tab_leaders_with_extended(&seg_text, &positions, &seg_style, &composed.tab_extended); + seg_style.tab_leaders = extract_tab_leaders_with_extended( + &seg_text, + &positions, + &seg_style, + &composed.tab_extended, + ); } let seg_w = estimate_text_width(&seg_text, &seg_style); let seg_char_count = tac_rel - seg_start; @@ -1471,8 +1863,8 @@ impl LayoutEngine { let pic_h = hwpunit_to_px(pic.common.height as i32, self.dpi); let img_y = (y + baseline - pic_h).max(y); let bin_data_id = pic.image_attr.bin_data_id; - let image_data = find_bin_data(bdc, bin_data_id) - .map(|c| c.data.clone()); + let image_data = + find_bin_data(bdc, bin_data_id).map(|c| c.data.clone()); let img_id = tree.next_id(); let img_node = RenderNode::new( img_id, @@ -1497,7 +1889,13 @@ impl LayoutEngine { let shape_h = hwpunit_to_px(common.height as i32, self.dpi); let shape_y = (y + baseline - shape_h).max(y); // 인라인 좌표 등록 → shape_layout.rs에서 이 Shape를 스킵 - tree.set_inline_shape_position(section_index, para_index, tac_ci, x, shape_y); + tree.set_inline_shape_position( + section_index, + para_index, + tac_ci, + x, + shape_y, + ); } } // 인라인 수식: 직접 EquationNode로 렌더링 @@ -1506,46 +1904,68 @@ impl LayoutEngine { let eq_h = hwpunit_to_px(eq.common.height as i32, self.dpi); let eq_y = (y + baseline - eq_h).max(y); // 수식 스크립트 → AST → 레이아웃 → SVG 조각 - let tokens = crate::renderer::equation::tokenizer::tokenize(&eq.script); - let ast = crate::renderer::equation::parser::EqParser::new(tokens).parse(); + let tokens = + crate::renderer::equation::tokenizer::tokenize(&eq.script); + let ast = crate::renderer::equation::parser::EqParser::new(tokens) + .parse(); let font_size_px = hwpunit_to_px(eq.font_size as i32, self.dpi); - let layout_box = crate::renderer::equation::layout::EqLayout::new(font_size_px).layout(&ast); - let color_str = crate::renderer::equation::svg_render::eq_color_to_svg(eq.color); - let svg_content = crate::renderer::equation::svg_render::render_equation_svg( - &layout_box, &color_str, font_size_px, - ); - let (eq_cell_idx, eq_cell_para_idx) = if let Some(ref ctx) = cell_ctx { - (Some(ctx.path[0].cell_index), Some(ctx.path[0].cell_para_index)) - } else { - (None, None) - }; + let layout_box = + crate::renderer::equation::layout::EqLayout::new(font_size_px) + .layout(&ast); + let color_str = + crate::renderer::equation::svg_render::eq_color_to_svg( + eq.color, + ); + let svg_content = + crate::renderer::equation::svg_render::render_equation_svg( + &layout_box, + &color_str, + font_size_px, + ); + let (eq_cell_idx, eq_cell_para_idx) = + if let Some(ref ctx) = cell_ctx { + ( + Some(ctx.path[0].cell_index), + Some(ctx.path[0].cell_para_index), + ) + } else { + (None, None) + }; let eq_node = RenderNode::new( tree.next_id(), - RenderNodeType::Equation(crate::renderer::render_tree::EquationNode { - svg_content, - layout_box, - color_str, - color: eq.color, - font_size: font_size_px, - section_index: Some(section_index), - para_index: if let Some(ref ctx) = cell_ctx { - Some(ctx.parent_para_index) - } else { - Some(para_index) - }, - control_index: if let Some(ref ctx) = cell_ctx { - Some(ctx.path[0].control_index) - } else { - Some(tac_ci) + RenderNodeType::Equation( + crate::renderer::render_tree::EquationNode { + svg_content, + layout_box, + color_str, + color: eq.color, + font_size: font_size_px, + section_index: Some(section_index), + para_index: if let Some(ref ctx) = cell_ctx { + Some(ctx.parent_para_index) + } else { + Some(para_index) + }, + control_index: if let Some(ref ctx) = cell_ctx { + Some(ctx.path[0].control_index) + } else { + Some(tac_ci) + }, + cell_index: eq_cell_idx, + cell_para_index: eq_cell_para_idx, }, - cell_index: eq_cell_idx, - cell_para_index: eq_cell_para_idx, - }), + ), BoundingBox::new(x, eq_y, tac_w, eq_h), ); line_node.children.push(eq_node); // 인라인 좌표 등록 → shape_layout에서 이 수식을 스킵 - tree.set_inline_shape_position(section_index, para_index, tac_ci, x, eq_y); + tree.set_inline_shape_position( + section_index, + para_index, + tac_ci, + x, + eq_y, + ); } } // 인라인 TAC 표: 텍스트 흐름 위치에 직접 렌더링 @@ -1554,18 +1974,37 @@ impl LayoutEngine { if let Some(Control::Table(t)) = p.controls.get(tac_ci) { if t.common.treat_as_char { let table_h = hwpunit_to_px(t.common.height as i32, self.dpi); - let om_bottom = hwpunit_to_px(t.outer_margin_bottom as i32, self.dpi); + let om_bottom = + hwpunit_to_px(t.outer_margin_bottom as i32, self.dpi); let table_y = (y + baseline + om_bottom - table_h).max(y); self.layout_table( - tree, col_node, t, - section_index, styles, col_area, - table_y, bdc, None, 0, + tree, + col_node, + t, + section_index, + styles, + col_area, + table_y, + bdc, + None, + 0, Some((para_index, tac_ci)), - alignment, None, 0.0, 0.0, - Some(x), None, None, + alignment, + None, + 0.0, + 0.0, + Some(x), + None, + None, ); // 스킵 마커 등록 (별도 Table PageItem에서 중복 렌더 방지) - tree.set_inline_shape_position(section_index, para_index, tac_ci, x, table_y); + tree.set_inline_shape_position( + section_index, + para_index, + tac_ci, + x, + table_y, + ); } } } @@ -1577,7 +2016,12 @@ impl LayoutEngine { // 셀 내부인 경우 cell_location 채우기 let cell_location = cell_ctx.as_ref().map(|ctx| { let e = &ctx.path[0]; - (ctx.parent_para_index, e.control_index, e.cell_index, e.cell_para_index) + ( + ctx.parent_para_index, + e.control_index, + e.cell_index, + e.cell_para_index, + ) }); let form_node = RenderNode::new( tree.next_id(), @@ -1612,7 +2056,12 @@ impl LayoutEngine { seg_style.line_x_offset = x - col_area.x; if has_tabs && remaining.contains('\t') { let positions = compute_char_positions(&remaining, &seg_style); - seg_style.tab_leaders = extract_tab_leaders_with_extended(&remaining, &positions, &seg_style, &composed.tab_extended); + seg_style.tab_leaders = extract_tab_leaders_with_extended( + &remaining, + &positions, + &seg_style, + &composed.tab_extended, + ); } let seg_w = estimate_text_width(&remaining, &seg_style); if !skip_text_for_inline_shape { @@ -1689,19 +2138,28 @@ impl LayoutEngine { ms.font_size *= 0.55; let mw = estimate_text_width(stext, &ms); let mid = tree.next_id(); - let mn = RenderNode::new(mid, RenderNodeType::TextRun(TextRunNode { - text: stext.clone(), style: ms, - char_shape_id: None, - para_shape_id: Some(composed.para_style_id), - section_index: Some(section_index), - para_index: Some(para_index), - char_start: None, - cell_context: cell_ctx.clone(), - is_para_end: false, is_line_break_end: false, - rotation: 0.0, is_vertical: false, - char_overlap: None, border_fill_id: 0, baseline, - field_marker: FieldMarkerType::ShapeMarker(*spos), - }), BoundingBox::new(x, y, mw, line_height)); + let mn = RenderNode::new( + mid, + RenderNodeType::TextRun(TextRunNode { + text: stext.clone(), + style: ms, + char_shape_id: None, + para_shape_id: Some(composed.para_style_id), + section_index: Some(section_index), + para_index: Some(para_index), + char_start: None, + cell_context: cell_ctx.clone(), + is_para_end: false, + is_line_break_end: false, + rotation: 0.0, + is_vertical: false, + char_overlap: None, + border_fill_id: 0, + baseline, + field_marker: FieldMarkerType::ShapeMarker(*spos), + }), + BoundingBox::new(x, y, mw, line_height), + ); line_node.children.push(mn); x += mw; } @@ -1719,8 +2177,8 @@ impl LayoutEngine { let pic_h = hwpunit_to_px(pic.common.height as i32, self.dpi); let img_y = (y + baseline - pic_h).max(y); let bin_data_id = pic.image_attr.bin_data_id; - let image_data = find_bin_data(bdc, bin_data_id) - .map(|c| c.data.clone()); + let image_data = + find_bin_data(bdc, bin_data_id).map(|c| c.data.clone()); let img_id = tree.next_id(); let img_node = RenderNode::new( img_id, @@ -1750,7 +2208,12 @@ impl LayoutEngine { let form_y = (y + baseline - form_h).max(y); let cell_location = cell_ctx.as_ref().map(|ctx| { let e = &ctx.path[0]; - (ctx.parent_para_index, e.control_index, e.cell_index, e.cell_para_index) + ( + ctx.parent_para_index, + e.control_index, + e.cell_index, + e.cell_para_index, + ) }); let form_node = RenderNode::new( tree.next_id(), @@ -1782,7 +2245,11 @@ impl LayoutEngine { // runs가 없는 빈 줄에서 treat_as_char 이미지 렌더링 // 테이블 셀 내부에서는 table_layout.rs가 layout_picture로 이미 처리하므로 스킵. // 셀 외부에서 텍스트 없이 TAC만 있는 문단인 경우에만 여기서 렌더링. - if cell_ctx.is_none() && all_runs_empty && !tac_offsets_px.is_empty() && line_idx == start_line { + if cell_ctx.is_none() + && all_runs_empty + && !tac_offsets_px.is_empty() + && line_idx == start_line + { if let (Some(p), Some(bdc)) = (para, bin_data_content) { // TAC 이미지 전체 폭 계산 후 문단 정렬 적용 let total_tac_width: f64 = tac_offsets_px.iter().map(|(_, w, _)| w).sum(); @@ -1790,9 +2257,7 @@ impl LayoutEngine { Alignment::Center | Alignment::Distribute => { (available_width - total_tac_width).max(0.0) / 2.0 } - Alignment::Right => { - (available_width - total_tac_width).max(0.0) - } + Alignment::Right => (available_width - total_tac_width).max(0.0), _ => 0.0, // Left, Justify }; let mut img_x = col_area.x + effective_margin_left + align_offset; @@ -1802,8 +2267,8 @@ impl LayoutEngine { let pic_h = hwpunit_to_px(pic.common.height as i32, self.dpi); let img_y = (y + baseline - pic_h).max(y); let bin_data_id = pic.image_attr.bin_data_id; - let image_data = find_bin_data(bdc, bin_data_id) - .map(|c| c.data.clone()); + let image_data = + find_bin_data(bdc, bin_data_id).map(|c| c.data.clone()); let img_id = tree.next_id(); let img_node = RenderNode::new( img_id, @@ -1865,7 +2330,9 @@ impl LayoutEngine { let (c0, x0) = char_x_map[i]; let (c1, x1) = char_x_map[i + 1]; if target >= c0 && target <= c1 { - if c1 == c0 { return x0; } + if c1 == c0 { + return x0; + } let ratio = (target - c0) as f64 / (c1 - c0) as f64; return x0 + ratio * (x1 - x0); } @@ -1887,31 +2354,42 @@ impl LayoutEngine { continue; } let is_empty = fr.start_char_idx == fr.end_char_idx; - let start_in_line = fr.start_char_idx >= line_char_start && fr.start_char_idx <= line_char_end; - let end_in_line = fr.end_char_idx >= line_char_start && fr.end_char_idx <= line_char_end; + let start_in_line = fr.start_char_idx >= line_char_start + && fr.start_char_idx <= line_char_end; + let end_in_line = + fr.end_char_idx >= line_char_start && fr.end_char_idx <= line_char_end; - if !start_in_line && !end_in_line { continue; } + if !start_in_line && !end_in_line { + continue; + } - let is_active = if let Some((af_sec, af_para, af_ctrl, ref af_cell)) = *active { - if af_sec != section_index || af_para != para_index || af_ctrl != fr.control_idx { - false - } else { - // cell_path 전체 일치 확인 - match (af_cell, &cell_ctx) { - (None, None) => true, - (Some(af_path), Some(ctx)) => { - // af_path와 ctx.path의 (control_index, cell_index) 쌍이 모두 일치해야 함 - af_path.len() == ctx.path.len() - && af_path.iter().zip(ctx.path.iter()).all(|(&(ac, ax, _ap), entry)| { - ac == entry.control_index && ax == entry.cell_index - }) + let is_active = + if let Some((af_sec, af_para, af_ctrl, ref af_cell)) = *active { + if af_sec != section_index + || af_para != para_index + || af_ctrl != fr.control_idx + { + false + } else { + // cell_path 전체 일치 확인 + match (af_cell, &cell_ctx) { + (None, None) => true, + (Some(af_path), Some(ctx)) => { + // af_path와 ctx.path의 (control_index, cell_index) 쌍이 모두 일치해야 함 + af_path.len() == ctx.path.len() + && af_path.iter().zip(ctx.path.iter()).all( + |(&(ac, ax, _ap), entry)| { + ac == entry.control_index + && ax == entry.cell_index + }, + ) + } + _ => false, } - _ => false, } - } - } else { - false - }; + } else { + false + }; let base_run = comp_line.runs.last().or(comp_line.runs.first()); let base_style = if let Some(run) = base_run { @@ -1951,7 +2429,11 @@ impl LayoutEngine { }), BoundingBox::new(marker_x, y, marker_w, line_height), ); - markers.push(MarkerInsert { marker_x, marker_w, node: m_node }); + markers.push(MarkerInsert { + marker_x, + marker_w, + node: m_node, + }); } // 빈 필드 커서 앵커: getCursorRect가 필드 시작 위치를 찾을 수 있도록 @@ -1981,7 +2463,11 @@ impl LayoutEngine { }), BoundingBox::new(anchor_x, y, 0.0, line_height), ); - markers.push(MarkerInsert { marker_x: anchor_x, marker_w: 0.0, node: anchor_node }); + markers.push(MarkerInsert { + marker_x: anchor_x, + marker_w: 0.0, + node: anchor_node, + }); } // 빈 필드 안내문 (활성 필드가 아닐 때만) @@ -2016,7 +2502,11 @@ impl LayoutEngine { }), BoundingBox::new(guide_x, y, guide_width, line_height), ); - markers.push(MarkerInsert { marker_x: guide_x, marker_w: guide_width, node: guide_node }); + markers.push(MarkerInsert { + marker_x: guide_x, + marker_w: guide_width, + node: guide_node, + }); } } @@ -2051,21 +2541,30 @@ impl LayoutEngine { }), BoundingBox::new(marker_x, y, marker_w, line_height), ); - markers.push(MarkerInsert { marker_x, marker_w, node: m_node }); + markers.push(MarkerInsert { + marker_x, + marker_w, + node: m_node, + }); } } } // 책갈피 조판부호 마커 if ctrl_codes { - let ctrl_positions = crate::document_core::helpers::find_control_text_positions(p); + let ctrl_positions = + crate::document_core::helpers::find_control_text_positions(p); for (ci, ctrl) in p.controls.iter().enumerate() { if let Control::Bookmark(_bm) = ctrl { let char_pos = ctrl_positions.get(ci).copied().unwrap_or(0); if char_pos >= line_char_start && char_pos <= line_char_end { let base_run = comp_line.runs.last().or(comp_line.runs.first()); let bm_base_style = if let Some(run) = base_run { - resolved_to_text_style(styles, run.char_style_id, run.lang_index) + resolved_to_text_style( + styles, + run.char_style_id, + run.lang_index, + ) } else { resolved_to_text_style(styles, 0, 0) }; @@ -2098,7 +2597,11 @@ impl LayoutEngine { }), BoundingBox::new(marker_x, y, marker_w, line_height), ); - markers.push(MarkerInsert { marker_x, marker_w, node: m_node }); + markers.push(MarkerInsert { + marker_x, + marker_w, + node: m_node, + }); } } } @@ -2108,7 +2611,11 @@ impl LayoutEngine { // 마커를 왼쪽부터 삽입하면서, 각 마커 뒤의 기존 노드와 이후 마커를 오른쪽으로 shift // zero-width 앵커(커서 위치용)는 shift하지 않고 원래 위치 유지 - markers.sort_by(|a, b| a.marker_x.partial_cmp(&b.marker_x).unwrap_or(std::cmp::Ordering::Equal)); + markers.sort_by(|a, b| { + a.marker_x + .partial_cmp(&b.marker_x) + .unwrap_or(std::cmp::Ordering::Equal) + }); let mut accumulated_shift = 0.0_f64; for mi in 0..markers.len() { let mw = markers[mi].marker_w; @@ -2156,9 +2663,13 @@ impl LayoutEngine { if para_border_fill_id > 0 { let bg_height = y - bg_y_start; if bg_height > 0.0 { - self.para_border_ranges.borrow_mut().push( - (para_border_fill_id, col_area.x, bg_y_start, col_area.width, y) - ); + self.para_border_ranges.borrow_mut().push(( + para_border_fill_id, + col_area.x, + bg_y_start, + col_area.width, + y, + )); } } @@ -2173,7 +2684,12 @@ impl LayoutEngine { let line_id = tree.next_id(); let mut line_node = RenderNode::new( line_id, - RenderNodeType::TextLine(TextLineNode::with_para(default_height, default_height * 0.8, section_index, para_index)), + RenderNodeType::TextLine(TextLineNode::with_para( + default_height, + default_height * 0.8, + section_index, + para_index, + )), BoundingBox::new(col_area.x, y, col_area.width, default_height), ); @@ -2338,29 +2854,48 @@ impl LayoutEngine { let head_text = match para_style.head_type { HeadType::None => return None, HeadType::Outline | HeadType::Number => { - let numbering_id = resolve_numbering_id(para_style.head_type, para_style.numbering_id, outline_numbering_id); + let numbering_id = resolve_numbering_id( + para_style.head_type, + para_style.numbering_id, + outline_numbering_id, + ); let level = para_style.para_level; - if numbering_id == 0 { return None; } + if numbering_id == 0 { + return None; + } let numbering = styles.numberings.get((numbering_id - 1) as usize)?; - let counters = self.numbering_state.borrow_mut().advance(numbering_id, level, para.numbering_restart); + let counters = self.numbering_state.borrow_mut().advance( + numbering_id, + level, + para.numbering_restart, + ); let start_numbers = numbering.level_start_numbers; let level_idx = (level as usize).min(6); let format_str = &numbering.level_formats[level_idx]; - if format_str.is_empty() { return None; } + if format_str.is_empty() { + return None; + } - let text = expand_numbering_format(format_str, &counters, numbering, &start_numbers); - if text.is_empty() { return None; } + let text = + expand_numbering_format(format_str, &counters, numbering, &start_numbers); + if text.is_empty() { + return None; + } text } HeadType::Bullet => { // Bullet: numbering_id(1-based)로 Bullet 참조 let bullet_id = para_style.numbering_id; - if bullet_id == 0 { return None; } + if bullet_id == 0 { + return None; + } let bullet = styles.bullets.get((bullet_id - 1) as usize)?; // U+FFFF는 이미지 글머리표 표시자 — 문자 렌더링 불가, 건너뜀 - if bullet.bullet_char == '\u{FFFF}' { return None; } + if bullet.bullet_char == '\u{FFFF}' { + return None; + } // PUA 문자(0xF000~0xF0FF)를 표준 Unicode로 매핑 // HWP는 Symbol 폰트 문자를 PUA(0xF000+code)로 저장 let bullet_ch = map_pua_bullet_char(bullet.bullet_char); @@ -2396,10 +2931,19 @@ impl LayoutEngine { let num_fmt = NumFmt::from_hwp_format(an.format); let num_str = format_number(an.assigned_number, num_fmt); let num_str = if an.prefix_char != '\0' || an.suffix_char != '\0' { - format!("{}{}{}", - if an.prefix_char != '\0' { an.prefix_char.to_string() } else { String::new() }, + format!( + "{}{}{}", + if an.prefix_char != '\0' { + an.prefix_char.to_string() + } else { + String::new() + }, num_str, - if an.suffix_char != '\0' { an.suffix_char.to_string() } else { String::new() }, + if an.suffix_char != '\0' { + an.suffix_char.to_string() + } else { + String::new() + }, ) } else { num_str @@ -2410,7 +2954,12 @@ impl LayoutEngine { for line in &mut composed.lines { for run in &mut line.runs { if let Some(pos) = run.text.find(" ") { - run.text = format!("{}{}{}", &run.text[..pos+1], num_str, &run.text[pos+1..]); + run.text = format!( + "{}{}{}", + &run.text[..pos + 1], + num_str, + &run.text[pos + 1..] + ); return; // 첫 번째 발견 시 처리 완료 } } diff --git a/src/renderer/layout/picture_footnote.rs b/src/renderer/layout/picture_footnote.rs index 751f2ca7..f2697cf5 100644 --- a/src/renderer/layout/picture_footnote.rs +++ b/src/renderer/layout/picture_footnote.rs @@ -1,21 +1,26 @@ //! 그림/캡션 레이아웃 + 각주 영역 레이아웃 -use crate::model::paragraph::Paragraph; -use crate::model::style::Alignment; -use crate::model::shape::{Caption, CaptionDirection, CommonObjAttr, HorzAlign, HorzRelTo, VertAlign, VertRelTo}; -use crate::model::footnote::{FootnoteShape, NumberFormat}; +use super::super::composer::{compose_paragraph, ComposedParagraph}; +use super::super::page_layout::LayoutRect; use super::super::pagination::{FootnoteRef, FootnoteSource}; -use crate::model::control::Control; -use crate::model::bin_data::BinDataContent; use super::super::render_tree::*; -use super::super::page_layout::LayoutRect; -use super::super::composer::{compose_paragraph, ComposedParagraph}; use super::super::style_resolver::ResolvedStyleSet; -use super::super::{hwpunit_to_px, StrokeDash, LineStyle, TextStyle, AutoNumberCounter, format_number, NumberFormat as NumFmt}; -use super::LayoutEngine; +use super::super::{ + format_number, hwpunit_to_px, AutoNumberCounter, LineStyle, NumberFormat as NumFmt, StrokeDash, + TextStyle, +}; use super::border_rendering::border_width_to_px; +use super::text_measurement::{estimate_text_width, resolved_to_text_style}; use super::utils::find_bin_data; -use super::text_measurement::{resolved_to_text_style, estimate_text_width}; +use super::LayoutEngine; +use crate::model::bin_data::BinDataContent; +use crate::model::control::Control; +use crate::model::footnote::{FootnoteShape, NumberFormat}; +use crate::model::paragraph::Paragraph; +use crate::model::shape::{ + Caption, CaptionDirection, CommonObjAttr, HorzAlign, HorzRelTo, VertAlign, VertRelTo, +}; +use crate::model::style::Alignment; impl LayoutEngine { pub(crate) fn layout_picture( @@ -55,12 +60,16 @@ impl LayoutEngine { let x = match picture.common.horz_align { HorzAlign::Left | HorzAlign::Inside => container.x + h_offset, HorzAlign::Center => container.x + (container.width - pic_width) / 2.0 + h_offset, - HorzAlign::Right | HorzAlign::Outside => container.x + container.width - pic_width - h_offset, + HorzAlign::Right | HorzAlign::Outside => { + container.x + container.width - pic_width - h_offset + } }; let y = match picture.common.vert_align { VertAlign::Top | VertAlign::Inside => container.y + v_offset, VertAlign::Center => container.y + (container.height - pic_height) / 2.0 + v_offset, - VertAlign::Bottom | VertAlign::Outside => container.y + container.height - pic_height - v_offset, + VertAlign::Bottom | VertAlign::Outside => { + container.y + container.height - pic_height - v_offset + } }; (x, y) } else { @@ -68,9 +77,7 @@ impl LayoutEngine { Alignment::Center | Alignment::Distribute => { container.x + (container.width - pic_width).max(0.0) / 2.0 } - Alignment::Right => { - container.x + (container.width - pic_width).max(0.0) - } + Alignment::Right => container.x + (container.width - pic_width).max(0.0), _ => container.x, }; (x, container.y) @@ -78,13 +85,15 @@ impl LayoutEngine { // BinData에서 이미지 데이터 찾기 (bin_data_id는 1-indexed 순번) let bin_data_id = picture.image_attr.bin_data_id; - let image_data = find_bin_data(bin_data_content, bin_data_id) - .map(|c| c.data.clone()); + let image_data = find_bin_data(bin_data_content, bin_data_id).map(|c| c.data.clone()); // 그림 자르기: crop 좌표를 그대로 저장 (렌더러에서 이미지 px 크기와 비교) let crop = { let c = &picture.crop; - if c.right > c.left && c.bottom > c.top && (c.left != 0 || c.top != 0 || c.right != 0 || c.bottom != 0) { + if c.right > c.left + && c.bottom > c.top + && (c.left != 0 || c.top != 0 || c.right != 0 || c.bottom != 0) + { Some((c.left, c.top, c.right, c.bottom)) } else { None @@ -109,7 +118,15 @@ impl LayoutEngine { parent_node.children.push(img_node); // 그림 테두리(선) 렌더링 - self.render_picture_border(tree, parent_node, picture, pic_x, pic_y, pic_width, pic_height); + self.render_picture_border( + tree, + parent_node, + picture, + pic_x, + pic_y, + pic_width, + pic_height, + ); } /// 개체(Picture/Shape)의 절대 좌표 (x, y)를 계산한다. @@ -165,9 +182,7 @@ impl LayoutEngine { Alignment::Center | Alignment::Distribute => { container.x + (container.width - obj_width).max(0.0) / 2.0 } - Alignment::Right => { - container.x + (container.width - obj_width).max(0.0) - } + Alignment::Right => container.x + (container.width - obj_width).max(0.0), _ => container.x, } } else { @@ -253,7 +268,15 @@ impl LayoutEngine { // 통합 좌표 계산 (캡션 포함 전체 크기 기준) let (pic_x, base_y) = self.compute_object_position( - &picture.common, total_width, total_height, container, col_area, body_area, paper_area, y_offset, alignment, + &picture.common, + total_width, + total_height, + container, + col_area, + body_area, + paper_area, + y_offset, + alignment, ); // 캡션 방향에 따라 그림 위치 오프셋 계산 @@ -275,8 +298,7 @@ impl LayoutEngine { // BinData에서 이미지 데이터 찾기 (bin_data_id는 1-indexed 순번) let bin_data_id = picture.image_attr.bin_data_id; - let image_data = find_bin_data(bin_data_content, bin_data_id) - .map(|c| c.data.clone()); + let image_data = find_bin_data(bin_data_content, bin_data_id).map(|c| c.data.clone()); // 그림 자르기 let crop = { @@ -306,14 +328,26 @@ impl LayoutEngine { parent_node.children.push(img_node); // 그림 테두리(선) 렌더링 - self.render_picture_border(tree, parent_node, picture, adjusted_pic_x, pic_y, pic_width, pic_height); + self.render_picture_border( + tree, + parent_node, + picture, + adjusted_pic_x, + pic_y, + pic_width, + pic_height, + ); // 캡션 렌더링 if let Some(ref caption) = picture.caption { use crate::model::shape::CaptionVertAlign; let (cap_x, cap_w, cap_y) = match caption.direction { CaptionDirection::Top => (adjusted_pic_x, pic_width, base_y), - CaptionDirection::Bottom => (adjusted_pic_x, pic_width, pic_y + pic_height + caption_spacing), + CaptionDirection::Bottom => ( + adjusted_pic_x, + pic_width, + pic_y + pic_height + caption_spacing, + ), CaptionDirection::Left | CaptionDirection::Right => { let cw = hwpunit_to_px(caption.width as i32, self.dpi); let cx = if caption.direction == CaptionDirection::Left { @@ -323,7 +357,9 @@ impl LayoutEngine { }; let cy = match caption.vert_align { CaptionVertAlign::Top => pic_y, - CaptionVertAlign::Center => pic_y + (pic_height - caption_height).max(0.0) / 2.0, + CaptionVertAlign::Center => { + pic_y + (pic_height - caption_height).max(0.0) / 2.0 + } CaptionVertAlign::Bottom => pic_y + (pic_height - caption_height).max(0.0), }; (cx, cw, cy) @@ -340,8 +376,14 @@ impl LayoutEngine { }], }; self.layout_caption( - tree, parent_node, caption, styles, col_area, - cap_x, cap_w, cap_y, + tree, + parent_node, + caption, + styles, + col_area, + cap_x, + cap_w, + cap_y, &mut self.auto_counter.borrow_mut(), Some(cell_ctx), ); @@ -349,7 +391,13 @@ impl LayoutEngine { // y_offset 업데이트: Para 기준 그림만 높이만큼 진행 // Page/Paper 기준 그림은 플로팅이므로 y_offset 변경 없음 - let total_height = pic_height + caption_height + if caption_height > 0.0 { caption_spacing } else { 0.0 }; + let total_height = pic_height + + caption_height + + if caption_height > 0.0 { + caption_spacing + } else { + 0.0 + }; match picture.common.vert_rel_to { VertRelTo::Para => y_offset + total_height, VertRelTo::Page | VertRelTo::Paper => y_offset, @@ -443,7 +491,14 @@ impl LayoutEngine { para_y, 0, composed.lines.len(), - 0, 0, ctx, false, 0.0, None, None, None, + 0, + 0, + ctx, + false, + 0.0, + None, + None, + None, ); } } @@ -514,7 +569,10 @@ impl LayoutEngine { let sep_node = RenderNode::new( sep_id, RenderNodeType::Line(LineNode::new( - fn_area.x, y, fn_area.x + sep_length, y, + fn_area.x, + y, + fn_area.x + sep_length, + y, LineStyle { color: shape.separator_color, width: line_width, @@ -536,27 +594,52 @@ impl LayoutEngine { // para_index = usize::MAX - 2000 - fn_para_idx (각주 내 문단 인덱스) for (i, fn_ref) in footnotes.iter().enumerate() { let fn_paras = get_footnote_paragraphs(fn_ref, paragraphs); - let number_text = format_footnote_number(fn_ref.number, &shape.number_format, shape.suffix_char); + let number_text = + format_footnote_number(fn_ref.number, &shape.number_format, shape.suffix_char); for (p_idx, para) in fn_paras.iter().enumerate() { let composed = compose_paragraph(para); let marker_section = i; // footnote_index let marker_para = usize::MAX - 2000 - p_idx; // 각주 내 문단 인덱스 - // 각주 번호 스타일용 기본 char_shape_id (빈/비빈 문단 모두 동일) - let base_cs_id = para.char_shapes.first() + // 각주 번호 스타일용 기본 char_shape_id (빈/비빈 문단 모두 동일) + let base_cs_id = para + .char_shapes + .first() .map(|cs| cs.char_shape_id as u32) .unwrap_or(composed.para_style_id as u32); if p_idx == 0 { // 첫 문단: 각주 번호를 텍스트 앞에 삽입 y = self.layout_footnote_paragraph_with_number( - tree, fn_node, &composed, styles, fn_area, y, &number_text, - marker_section, marker_para, base_cs_id, + tree, + fn_node, + &composed, + styles, + fn_area, + y, + &number_text, + marker_section, + marker_para, + base_cs_id, ); } else { y = self.layout_composed_paragraph( - tree, fn_node, &composed, styles, fn_area, y, 0, composed.lines.len(), - marker_section, marker_para, None, false, 0.0, None, None, None, + tree, + fn_node, + &composed, + styles, + fn_area, + y, + 0, + composed.lines.len(), + marker_section, + marker_para, + None, + false, + 0.0, + None, + None, + None, ); } } @@ -704,7 +787,9 @@ impl LayoutEngine { ) { // layout_composed_paragraph에서 이미 인라인 FootnoteMarker를 삽입한 경우 건너뜀 let has_inline_markers = parent.children.iter().any(|line| { - line.children.iter().any(|n| matches!(n.node_type, RenderNodeType::FootnoteMarker(_))) + line.children + .iter() + .any(|n| matches!(n.node_type, RenderNodeType::FootnoteMarker(_))) }); if has_inline_markers { return; @@ -742,7 +827,9 @@ impl LayoutEngine { // TextRun의 char_start로 각주 위치 찾기 if *char_pos < usize::MAX { 'outer: for (li, line_node) in parent.children.iter().enumerate() { - if !matches!(line_node.node_type, RenderNodeType::TextLine(_)) { continue; } + if !matches!(line_node.node_type, RenderNodeType::TextLine(_)) { + continue; + } // 이 줄의 char_start 범위 확인: 첫 run의 char_start ~ 마지막 run의 (char_start + len) let mut line_min_cs = usize::MAX; let mut line_max_end = 0usize; @@ -755,7 +842,9 @@ impl LayoutEngine { } } // 각주 위치가 이 줄에 포함되지 않으면 다음 줄 - if *char_pos > line_max_end || line_min_cs == usize::MAX { continue; } + if *char_pos > line_max_end || line_min_cs == usize::MAX { + continue; + } for run_node in &line_node.children { if let RenderNodeType::TextRun(ref run) = run_node.node_type { @@ -764,8 +853,10 @@ impl LayoutEngine { let run_end = cs + run_len; if *char_pos >= cs && *char_pos <= run_end { let chars_before = char_pos - cs; - let partial_text: String = run.text.chars().take(chars_before).collect(); - let partial_width = estimate_text_width(&partial_text, &run.style); + let partial_text: String = + run.text.chars().take(chars_before).collect(); + let partial_width = + estimate_text_width(&partial_text, &run.style); insert_x = run_node.bbox.x + partial_width; line_height = line_node.bbox.height; line_y = line_node.bbox.y; @@ -782,9 +873,17 @@ impl LayoutEngine { // 최종 폴백: 마지막 TextLine 끝 if target_line_idx.is_none() { - if let Some(li) = parent.children.iter().rposition(|n| matches!(n.node_type, RenderNodeType::TextLine(_))) { + if let Some(li) = parent + .children + .iter() + .rposition(|n| matches!(n.node_type, RenderNodeType::TextLine(_))) + { let line = &parent.children[li]; - insert_x = line.children.last().map(|c| c.bbox.x + c.bbox.width).unwrap_or(line.bbox.x); + insert_x = line + .children + .last() + .map(|c| c.bbox.x + c.bbox.width) + .unwrap_or(line.bbox.x); line_height = line.bbox.height; line_y = line.bbox.y; if let Some(last_run) = line.children.last() { @@ -844,7 +943,10 @@ fn get_footnote_paragraphs<'a>( paragraphs: &'a [Paragraph], ) -> &'a [Paragraph] { match &fn_ref.source { - FootnoteSource::Body { para_index, control_index } => { + FootnoteSource::Body { + para_index, + control_index, + } => { if let Some(para) = paragraphs.get(*para_index) { if let Some(Control::Footnote(footnote)) = para.controls.get(*control_index) { return &footnote.paragraphs; @@ -863,7 +965,9 @@ fn get_footnote_paragraphs<'a>( if let Some(Control::Table(table)) = para.controls.get(*table_control_index) { if let Some(cell) = table.cells.get(*cell_index) { if let Some(cp) = cell.paragraphs.get(*cell_para_index) { - if let Some(Control::Footnote(footnote)) = cp.controls.get(*cell_control_index) { + if let Some(Control::Footnote(footnote)) = + cp.controls.get(*cell_control_index) + { return &footnote.paragraphs; } } @@ -882,7 +986,9 @@ fn get_footnote_paragraphs<'a>( if let Some(Control::Shape(shape_obj)) = para.controls.get(*shape_control_index) { if let Some(text_box) = shape_obj.drawing().and_then(|d| d.text_box.as_ref()) { if let Some(tp) = text_box.paragraphs.get(*tb_para_index) { - if let Some(Control::Footnote(footnote)) = tp.controls.get(*tb_control_index) { + if let Some(Control::Footnote(footnote)) = + tp.controls.get(*tb_control_index) + { return &footnote.paragraphs; } } @@ -946,7 +1052,10 @@ impl LayoutEngine { tree: &mut PageRenderTree, parent: &mut RenderNode, picture: &crate::model::image::Picture, - x: f64, y: f64, w: f64, h: f64, + x: f64, + y: f64, + w: f64, + h: f64, ) { let line_type = picture.border_attr.attr & 0x3F; // 선 종류 0 = 없음 @@ -960,9 +1069,9 @@ impl LayoutEngine { 0.1 / 25.4 * self.dpi }; let stroke_dash = match line_type { - 2 => super::super::StrokeDash::Dot, // 점선 - 3 => super::super::StrokeDash::Dash, // 긴 점선 (파선) - 4 => super::super::StrokeDash::DashDot, // 일점쇄선 + 2 => super::super::StrokeDash::Dot, // 점선 + 3 => super::super::StrokeDash::Dash, // 긴 점선 (파선) + 4 => super::super::StrokeDash::DashDot, // 일점쇄선 5 => super::super::StrokeDash::DashDotDot, // 이점쇄선 _ => super::super::StrokeDash::Solid, // 1=실선, 기타 }; diff --git a/src/renderer/layout/shape_layout.rs b/src/renderer/layout/shape_layout.rs index b64db5c1..58519446 100644 --- a/src/renderer/layout/shape_layout.rs +++ b/src/renderer/layout/shape_layout.rs @@ -1,21 +1,26 @@ //! 도형/글상자/그룹 개체 레이아웃 -use crate::model::paragraph::Paragraph; -use crate::model::style::Alignment; -use crate::model::control::Control; -use crate::model::shape::CommonObjAttr; -use crate::model::bin_data::BinDataContent; -use super::super::render_tree::*; -use super::super::page_layout::LayoutRect; use super::super::composer::compose_paragraph; -use super::super::style_resolver::ResolvedStyleSet; -use super::super::{hwpunit_to_px, PathCommand, TextStyle, ShapeStyle}; +use super::super::page_layout::LayoutRect; use super::super::pagination::PageItem; -use crate::model::shape::{HorzRelTo, HorzAlign, VertRelTo, VertAlign}; +use super::super::render_tree::*; +use super::super::style_resolver::ResolvedStyleSet; +use super::super::{hwpunit_to_px, PathCommand, ShapeStyle, TextStyle}; +use super::text_measurement::{ + estimate_text_width, is_cjk_char, is_vertical_rotate_char, resolved_to_text_style, + vertical_substitute_char, +}; +use super::utils::{ + drawing_to_line_style, drawing_to_shape_style, extract_shape_transform, find_bin_data, +}; use super::LayoutEngine; -use super::utils::{drawing_to_shape_style, drawing_to_line_style, find_bin_data, extract_shape_transform}; -use super::text_measurement::{resolved_to_text_style, estimate_text_width, is_cjk_char, is_vertical_rotate_char, vertical_substitute_char}; use super::{CellContext, CellPathEntry}; +use crate::model::bin_data::BinDataContent; +use crate::model::control::Control; +use crate::model::paragraph::Paragraph; +use crate::model::shape::CommonObjAttr; +use crate::model::shape::{HorzAlign, HorzRelTo, VertAlign, VertRelTo}; +use crate::model::style::Alignment; impl LayoutEngine { pub(crate) fn scan_textbox_overflow( @@ -27,12 +32,18 @@ impl LayoutEngine { // 1단계: 오버플로우 문단 수집 (소스 텍스트박스에서) let mut overflow_paras: Vec<(i32, Vec)> = Vec::new(); // (target_sw, paragraphs) - // 빈 텍스트박스 수집 (타겟 후보) + // 빈 텍스트박스 수집 (타겟 후보) let mut empty_targets: Vec<(usize, usize, i32)> = Vec::new(); // (para_idx, ctrl_idx, inner_sw) for &(_, pi, ci, _, _) in shape_items { - let para = match paragraphs.get(pi) { Some(p) => p, None => continue }; - let ctrl = match para.controls.get(ci) { Some(c) => c, None => continue }; + let para = match paragraphs.get(pi) { + Some(p) => p, + None => continue, + }; + let ctrl = match para.controls.get(ci) { + Some(c) => c, + None => continue, + }; let drawing = match ctrl { Control::Shape(s) => match s.as_ref() { ShapeObject::Rectangle(r) => &r.drawing, @@ -40,14 +51,19 @@ impl LayoutEngine { }, _ => continue, }; - let tb = match &drawing.text_box { Some(tb) => tb, None => continue }; + let tb = match &drawing.text_box { + Some(tb) => tb, + None => continue, + }; // 텍스트가 있는 문단 수 let has_text = tb.paragraphs.iter().any(|p| !p.text.is_empty()); if !has_text { // 빈 텍스트박스: 첫 문단의 line_seg sw를 inner_sw로 사용하거나 계산 // 실제로는 오버플로우 문단이 렌더링될 때 sw를 사용 - let inner_sw = tb.paragraphs.first() + let inner_sw = tb + .paragraphs + .first() .and_then(|p| p.line_segs.first()) .map(|ls| ls.segment_width) .unwrap_or(0); @@ -56,7 +72,9 @@ impl LayoutEngine { } // 오버플로우 감지: 첫 문단의 sw와 다른 sw를 가진 문단 찾기 - let first_sw = tb.paragraphs.first() + let first_sw = tb + .paragraphs + .first() .and_then(|p| p.line_segs.first()) .map(|ls| ls.segment_width) .unwrap_or(0); @@ -64,18 +82,25 @@ impl LayoutEngine { let mut overflow_idx: Option = None; for (tpi, tp) in tb.paragraphs.iter().enumerate() { if let Some(first_ls) = tp.line_segs.first() { - if tpi > 0 && first_ls.segment_width != first_sw && first_ls.vertical_pos < max_vpos_end { + if tpi > 0 + && first_ls.segment_width != first_sw + && first_ls.vertical_pos < max_vpos_end + { overflow_idx = Some(tpi); break; } if let Some(last_ls) = tp.line_segs.last() { let end = last_ls.vertical_pos + last_ls.line_height; - if end > max_vpos_end { max_vpos_end = end; } + if end > max_vpos_end { + max_vpos_end = end; + } } } } if let Some(oi) = overflow_idx { - let target_sw = tb.paragraphs[oi].line_segs.first() + let target_sw = tb.paragraphs[oi] + .line_segs + .first() .map(|ls| ls.segment_width) .unwrap_or(0); let overflow: Vec = tb.paragraphs[oi..].to_vec(); @@ -87,7 +112,8 @@ impl LayoutEngine { let mut result = std::collections::HashMap::new(); for (target_sw, paras) in overflow_paras { // sw가 가장 가까운 빈 텍스트박스 찾기 - let best = empty_targets.iter() + let best = empty_targets + .iter() .enumerate() .min_by_key(|(_, (_, _, esw))| (target_sw - *esw).abs()); if let Some((idx, &(pi, ci, _))) = best { @@ -132,7 +158,8 @@ impl LayoutEngine { // 수식 컨트롤 처리 if let Control::Equation(eq) = ctrl { // 인라인 좌표가 등록되어 있으면 paragraph_layout에서 이미 렌더링됨 → 스킵 - let inline_pos = tree.get_inline_shape_position(section_index, para_index, control_index); + let inline_pos = + tree.get_inline_shape_position(section_index, para_index, control_index); if inline_pos.is_some() { return; } @@ -144,9 +171,7 @@ impl LayoutEngine { Alignment::Center | Alignment::Distribute => { col_area.x + (col_area.width - eq_w).max(0.0) / 2.0 } - Alignment::Right => { - col_area.x + (col_area.width - eq_w).max(0.0) - } + Alignment::Right => col_area.x + (col_area.width - eq_w).max(0.0), _ => col_area.x, }; let eq_y = para_y; @@ -155,10 +180,13 @@ impl LayoutEngine { let tokens = super::super::equation::tokenizer::tokenize(&eq.script); let ast = super::super::equation::parser::EqParser::new(tokens).parse(); let font_size_px = hwpunit_to_px(eq.font_size as i32, self.dpi); - let layout_box = super::super::equation::layout::EqLayout::new(font_size_px).layout(&ast); + let layout_box = + super::super::equation::layout::EqLayout::new(font_size_px).layout(&ast); let color_str = super::super::equation::svg_render::eq_color_to_svg(eq.color); let svg_content = super::super::equation::svg_render::render_equation_svg( - &layout_box, &color_str, font_size_px, + &layout_box, + &color_str, + font_size_px, ); let eq_node = RenderNode::new( @@ -188,7 +216,8 @@ impl LayoutEngine { let common = shape.common(); - let (mut shape_w, mut shape_h) = self.resolve_object_size(common, col_area, body_area, paper_area); + let (mut shape_w, mut shape_h) = + self.resolve_object_size(common, col_area, body_area, paper_area); // current size가 common size보다 크면 current size 사용 // (스케일 행렬이 적용된 글상자 등에서 common.height < current_height인 경우) @@ -196,23 +225,22 @@ impl LayoutEngine { let sa = shape.shape_attr(); let cur_w = hwpunit_to_px(sa.current_width as i32, self.dpi); let cur_h = hwpunit_to_px(sa.current_height as i32, self.dpi); - if cur_w > shape_w && cur_w > 0.0 { shape_w = cur_w; } - if cur_h > shape_h && cur_h > 0.0 { shape_h = cur_h; } + if cur_w > shape_w && cur_w > 0.0 { + shape_w = cur_w; + } + if cur_h > shape_h && cur_h > 0.0 { + shape_h = cur_h; + } } // 문단 여백 반영: Para 기준 위치 지정 시 문단의 왼쪽/오른쪽 여백 고려 - let composed_para = paragraphs.get(para_index) - .and_then(|_| { - // composed 데이터가 없으므로 paragraphs에서 직접 para_shape_id 사용 - let pid = para.para_shape_id as usize; - styles.para_styles.get(pid) - }); - let para_margin_left = composed_para - .map(|ps| ps.margin_left) - .unwrap_or(0.0); - let para_margin_right = composed_para - .map(|ps| ps.margin_right) - .unwrap_or(0.0); + let composed_para = paragraphs.get(para_index).and_then(|_| { + // composed 데이터가 없으므로 paragraphs에서 직접 para_shape_id 사용 + let pid = para.para_shape_id as usize; + styles.para_styles.get(pid) + }); + let para_margin_left = composed_para.map(|ps| ps.margin_left).unwrap_or(0.0); + let para_margin_right = composed_para.map(|ps| ps.margin_right).unwrap_or(0.0); // 인라인 Shape: paragraph_layout에서 계산된 좌표가 있으면 사용 let inline_pos = if common.treat_as_char { @@ -232,22 +260,32 @@ impl LayoutEngine { (ix, iy) } else { self.compute_object_position( - common, shape_w, shape_h, &shape_container, col_area, body_area, paper_area, para_y, alignment, + common, + shape_w, + shape_h, + &shape_container, + col_area, + body_area, + paper_area, + para_y, + alignment, ) }; // 캡션 높이 및 간격 계산 let drawing = shape.drawing(); - let caption_opt = drawing.and_then(|d| d.caption.clone()) - .or_else(|| { - if let ShapeObject::Group(g) = shape { g.caption.clone() } else { None } - }); + let caption_opt = drawing.and_then(|d| d.caption.clone()).or_else(|| { + if let ShapeObject::Group(g) = shape { + g.caption.clone() + } else { + None + } + }); let caption = caption_opt.as_ref(); - let caption_height = self.calculate_caption_height( - &caption_opt, - styles, - ); - let caption_spacing = caption.map(|c| hwpunit_to_px(c.spacing as i32, self.dpi)).unwrap_or(0.0); + let caption_height = self.calculate_caption_height(&caption_opt, styles); + let caption_spacing = caption + .map(|c| hwpunit_to_px(c.spacing as i32, self.dpi)) + .unwrap_or(0.0); use crate::model::shape::CaptionDirection; @@ -269,10 +307,18 @@ impl LayoutEngine { // 도형 타입별 렌더 노드 생성 self.layout_shape_object( - tree, parent, shape, - adjusted_shape_x, adjusted_shape_y, shape_w, shape_h, - section_index, para_index, control_index, - styles, bin_data_content, + tree, + parent, + shape, + adjusted_shape_x, + adjusted_shape_y, + shape_w, + shape_h, + section_index, + para_index, + control_index, + styles, + bin_data_content, overflow_map, &[], ); @@ -282,7 +328,11 @@ impl LayoutEngine { use crate::model::shape::CaptionVertAlign; let (cap_x, cap_w, cap_y) = match caption.direction { CaptionDirection::Top => (adjusted_shape_x, shape_w, shape_y), - CaptionDirection::Bottom => (adjusted_shape_x, shape_w, adjusted_shape_y + shape_h + caption_spacing), + CaptionDirection::Bottom => ( + adjusted_shape_x, + shape_w, + adjusted_shape_y + shape_h + caption_spacing, + ), CaptionDirection::Left | CaptionDirection::Right => { let cw = hwpunit_to_px(caption.width as i32, self.dpi); let cx = if caption.direction == CaptionDirection::Left { @@ -293,15 +343,25 @@ impl LayoutEngine { // Left/Right 캡션의 세로 정렬 let cy = match caption.vert_align { CaptionVertAlign::Top => adjusted_shape_y, - CaptionVertAlign::Center => adjusted_shape_y + (shape_h - caption_height).max(0.0) / 2.0, - CaptionVertAlign::Bottom => adjusted_shape_y + (shape_h - caption_height).max(0.0), + CaptionVertAlign::Center => { + adjusted_shape_y + (shape_h - caption_height).max(0.0) / 2.0 + } + CaptionVertAlign::Bottom => { + adjusted_shape_y + (shape_h - caption_height).max(0.0) + } }; (cx, cw, cy) } }; self.layout_caption( - tree, parent, caption, styles, col_area, - cap_x, cap_w, cap_y, + tree, + parent, + caption, + styles, + col_area, + cap_x, + cap_w, + cap_y, &mut self.auto_counter.borrow_mut(), None, ); @@ -391,10 +451,7 @@ impl LayoutEngine { let (x2, y2) = transform_pt(line.end.x as f64, line.end.y as f64); let min_x = x1.min(x2); let min_y = y1.min(y2); - let commands = vec![ - PathCommand::MoveTo(x1, y1), - PathCommand::LineTo(x2, y2), - ]; + let commands = vec![PathCommand::MoveTo(x1, y1), PathCommand::LineTo(x2, y2)]; let node_id = tree.next_id(); let node = RenderNode::new( node_id, @@ -414,17 +471,22 @@ impl LayoutEngine { if !points.is_empty() { let (px, py) = transform_pt(points[0].x as f64, points[0].y as f64); commands.push(PathCommand::MoveTo(px, py)); - min_x = min_x.min(px); min_y = min_y.min(py); - max_x = max_x.max(px); max_y = max_y.max(py); + min_x = min_x.min(px); + min_y = min_y.min(py); + max_x = max_x.max(px); + max_y = max_y.max(py); let mut i = 1; while i + 2 < points.len() { let (cx1, cy1) = transform_pt(points[i].x as f64, points[i].y as f64); - let (cx2, cy2) = transform_pt(points[i+1].x as f64, points[i+1].y as f64); - let (ex, ey) = transform_pt(points[i+2].x as f64, points[i+2].y as f64); + let (cx2, cy2) = + transform_pt(points[i + 1].x as f64, points[i + 1].y as f64); + let (ex, ey) = transform_pt(points[i + 2].x as f64, points[i + 2].y as f64); commands.push(PathCommand::CurveTo(cx1, cy1, cx2, cy2, ex, ey)); - for &(px, py) in &[(cx1,cy1),(cx2,cy2),(ex,ey)] { - min_x = min_x.min(px); min_y = min_y.min(py); - max_x = max_x.max(px); max_y = max_y.max(py); + for &(px, py) in &[(cx1, cy1), (cx2, cy2), (ex, ey)] { + min_x = min_x.min(px); + min_y = min_y.min(py); + max_x = max_x.max(px); + max_y = max_y.max(py); } i += 3; } @@ -452,8 +514,10 @@ impl LayoutEngine { let mut max_y = f64::MIN; for (i, &(ox, oy)) in corners.iter().enumerate() { let (px, py) = transform_pt(ox, oy); - min_x = min_x.min(px); min_y = min_y.min(py); - max_x = max_x.max(px); max_y = max_y.max(py); + min_x = min_x.min(px); + min_y = min_y.min(py); + max_x = max_x.max(px); + max_y = max_y.max(py); if i == 0 { commands.push(PathCommand::MoveTo(px, py)); } else { @@ -465,7 +529,12 @@ impl LayoutEngine { let node = RenderNode::new( node_id, RenderNodeType::Path(PathNode::new(commands, style, gradient)), - BoundingBox::new(min_x, min_y, (max_x - min_x).max(0.0), (max_y - min_y).max(0.0)), + BoundingBox::new( + min_x, + min_y, + (max_x - min_x).max(0.0), + (max_y - min_y).max(0.0), + ), ); parent.children.push(node); } @@ -479,10 +548,18 @@ impl LayoutEngine { let child_h = (p1y - p0y).abs(); let empty_map = std::collections::HashMap::new(); self.layout_shape_object( - tree, parent, child, - child_x, child_y, child_w, child_h, - section_index, para_index, control_index, - styles, bin_data_content, + tree, + parent, + child, + child_x, + child_y, + child_w, + child_h, + section_index, + para_index, + control_index, + styles, + bin_data_content, &empty_map, parent_cell_path, ); @@ -555,15 +632,47 @@ impl LayoutEngine { BoundingBox::new(render_x, render_y, render_w, render_h), ); // 이미지 채우기가 있으면 자식으로 이미지 노드 추가 - self.add_image_fill_node(tree, &mut node, &rect.drawing, render_x, render_y, render_w, render_h, bin_data_content); + self.add_image_fill_node( + tree, + &mut node, + &rect.drawing, + render_x, + render_y, + render_w, + render_h, + bin_data_content, + ); // TextBox가 있으면 자식으로 텍스트 레이아웃 - self.layout_textbox_content(tree, &mut node, &rect.drawing, render_x, render_y, render_w, render_h, section_index, para_index, control_index, styles, bin_data_content, overflow_map, parent_cell_path); + self.layout_textbox_content( + tree, + &mut node, + &rect.drawing, + render_x, + render_y, + render_w, + render_h, + section_index, + para_index, + control_index, + styles, + bin_data_content, + overflow_map, + parent_cell_path, + ); parent.children.push(node); } ShapeObject::Line(line) => { let sa = &line.drawing.shape_attr; - let sx = if sa.original_width > 0 { render_w / hwpunit_to_px(sa.original_width as i32, self.dpi) } else { 1.0 }; - let sy = if sa.original_height > 0 { render_h / hwpunit_to_px(sa.original_height as i32, self.dpi) } else { 1.0 }; + let sx = if sa.original_width > 0 { + render_w / hwpunit_to_px(sa.original_width as i32, self.dpi) + } else { + 1.0 + }; + let sy = if sa.original_height > 0 { + render_h / hwpunit_to_px(sa.original_height as i32, self.dpi) + } else { + 1.0 + }; // 연결선: 제어점이 있으면 Path로, 없으면 Line으로 렌더링 if let Some(ref conn) = line.connector { @@ -572,11 +681,15 @@ impl LayoutEngine { // 연결선 화살표: LinkLineType → ArrowStyle use crate::model::shape::LinkLineType; match conn.link_type { - LinkLineType::StraightOneWay | LinkLineType::StrokeOneWay | LinkLineType::ArcOneWay => { + LinkLineType::StraightOneWay + | LinkLineType::StrokeOneWay + | LinkLineType::ArcOneWay => { line_style.end_arrow = super::super::ArrowStyle::Arrow; line_style.end_arrow_size = 4; // 중간 크기 } - LinkLineType::StraightBoth | LinkLineType::StrokeBoth | LinkLineType::ArcBoth => { + LinkLineType::StraightBoth + | LinkLineType::StrokeBoth + | LinkLineType::ArcBoth => { line_style.start_arrow = super::super::ArrowStyle::Arrow; line_style.start_arrow_size = 4; line_style.end_arrow = super::super::ArrowStyle::Arrow; @@ -598,12 +711,15 @@ impl LayoutEngine { let end_x = conn_x2; let end_y = conn_y2; // type=2인 제어점만 추출 - let ctrl_pts: Vec<(f64, f64)> = cps.iter() + let ctrl_pts: Vec<(f64, f64)> = cps + .iter() .filter(|cp| cp.point_type == 2) - .map(|cp| ( - render_x + hwpunit_to_px(cp.x, self.dpi) * sx, - render_y + hwpunit_to_px(cp.y, self.dpi) * sy, - )) + .map(|cp| { + ( + render_x + hwpunit_to_px(cp.x, self.dpi) * sx, + render_y + hwpunit_to_px(cp.y, self.dpi) * sy, + ) + }) .collect(); match ctrl_pts.len() { 0 => { @@ -622,13 +738,17 @@ impl LayoutEngine { let cy1 = (sy0 + 2.0 * qy) / 3.0; let cx2 = (2.0 * qx + end_x) / 3.0; let cy2 = (2.0 * qy + end_y) / 3.0; - commands.push(PathCommand::CurveTo(cx1, cy1, cx2, cy2, end_x, end_y)); + commands.push(PathCommand::CurveTo( + cx1, cy1, cx2, cy2, end_x, end_y, + )); } 2 => { // 제어점 2개 → cubic bezier let (cx1, cy1) = ctrl_pts[0]; let (cx2, cy2) = ctrl_pts[1]; - commands.push(PathCommand::CurveTo(cx1, cy1, cx2, cy2, end_x, end_y)); + commands.push(PathCommand::CurveTo( + cx1, cy1, cx2, cy2, end_x, end_y, + )); } _ => { // 3개 이상 → 여러 cubic bezier 세그먼트 @@ -637,11 +757,15 @@ impl LayoutEngine { let (cx1, cy1) = ctrl_pts[i]; let (cx2, cy2) = ctrl_pts[i + 1]; let (ex, ey) = if i + 2 < ctrl_pts.len() { - ((cx2 + ctrl_pts[i + 2].0) / 2.0, (cy2 + ctrl_pts[i + 2].1) / 2.0) + ( + (cx2 + ctrl_pts[i + 2].0) / 2.0, + (cy2 + ctrl_pts[i + 2].1) / 2.0, + ) } else { (end_x, end_y) }; - commands.push(PathCommand::CurveTo(cx1, cy1, cx2, cy2, ex, ey)); + commands + .push(PathCommand::CurveTo(cx1, cy1, cx2, cy2, ex, ey)); i += 2; } } @@ -671,7 +795,9 @@ impl LayoutEngine { path_node.transform = transform; // 연결선: 시작/끝 좌표 (선 선택 방식용) + 화살표 path_node.connector_endpoints = Some((conn_x1, conn_y1, conn_x2, conn_y2)); - if line_style.start_arrow != super::super::ArrowStyle::None || line_style.end_arrow != super::super::ArrowStyle::None { + if line_style.start_arrow != super::super::ArrowStyle::None + || line_style.end_arrow != super::super::ArrowStyle::None + { path_node.line_style = Some(line_style); } let node = RenderNode::new( @@ -685,11 +811,15 @@ impl LayoutEngine { let mut line_style = drawing_to_line_style(&line.drawing); use crate::model::shape::LinkLineType; match conn.link_type { - LinkLineType::StraightOneWay | LinkLineType::StrokeOneWay | LinkLineType::ArcOneWay => { + LinkLineType::StraightOneWay + | LinkLineType::StrokeOneWay + | LinkLineType::ArcOneWay => { line_style.end_arrow = super::super::ArrowStyle::Arrow; line_style.end_arrow_size = 4; } - LinkLineType::StraightBoth | LinkLineType::StrokeBoth | LinkLineType::ArcBoth => { + LinkLineType::StraightBoth + | LinkLineType::StrokeBoth + | LinkLineType::ArcBoth => { line_style.start_arrow = super::super::ArrowStyle::Arrow; line_style.start_arrow_size = 4; line_style.end_arrow = super::super::ArrowStyle::Arrow; @@ -748,17 +878,49 @@ impl LayoutEngine { RenderNodeType::Ellipse(ell_node), BoundingBox::new(render_x, render_y, render_w, render_h), ); - self.add_image_fill_node(tree, &mut node, &ellipse.drawing, render_x, render_y, render_w, render_h, bin_data_content); + self.add_image_fill_node( + tree, + &mut node, + &ellipse.drawing, + render_x, + render_y, + render_w, + render_h, + bin_data_content, + ); let empty_map = std::collections::HashMap::new(); - self.layout_textbox_content(tree, &mut node, &ellipse.drawing, render_x, render_y, render_w, render_h, section_index, para_index, control_index, styles, bin_data_content, &empty_map, parent_cell_path); + self.layout_textbox_content( + tree, + &mut node, + &ellipse.drawing, + render_x, + render_y, + render_w, + render_h, + section_index, + para_index, + control_index, + styles, + bin_data_content, + &empty_map, + parent_cell_path, + ); parent.children.push(node); } ShapeObject::Arc(arc) => { let (style, gradient) = drawing_to_shape_style(&arc.drawing); // 호(Arc) 좌표 계산: center, axis1, axis2를 렌더 좌표로 변환 let sa = &arc.drawing.shape_attr; - let sx = if sa.original_width > 0 { render_w / hwpunit_to_px(sa.original_width as i32, self.dpi) } else { 1.0 }; - let sy = if sa.original_height > 0 { render_h / hwpunit_to_px(sa.original_height as i32, self.dpi) } else { 1.0 }; + let sx = if sa.original_width > 0 { + render_w / hwpunit_to_px(sa.original_width as i32, self.dpi) + } else { + 1.0 + }; + let sy = if sa.original_height > 0 { + render_h / hwpunit_to_px(sa.original_height as i32, self.dpi) + } else { + 1.0 + }; let cx = render_x + hwpunit_to_px(arc.center.x, self.dpi) * sx; let cy = render_y + hwpunit_to_px(arc.center.y, self.dpi) * sy; @@ -787,7 +949,8 @@ impl LayoutEngine { // axis1이 y축 근처(90° 또는 -90°)이고 axis2가 x축 근처(0° 또는 180°) if (a1_abs - std::f64::consts::FRAC_PI_2).abs() < 0.3 && a2_abs < 0.3 { (r2, r1) // axis2→rx, axis1→ry - } else if a1_abs < 0.3 && (a2_abs - std::f64::consts::FRAC_PI_2).abs() < 0.3 { + } else if a1_abs < 0.3 && (a2_abs - std::f64::consts::FRAC_PI_2).abs() < 0.3 + { (r1, r2) // axis1→rx, axis2→ry } else { (r1.max(r2), r1.min(r2)) @@ -804,12 +967,16 @@ impl LayoutEngine { // axis1→axis2 방향: 반시계 방향(SVG 좌표) = sweep=0 // 각도 차이로 large_arc 결정 let mut sweep_angle = angle1 - angle2; - if sweep_angle < 0.0 { sweep_angle += 2.0 * std::f64::consts::PI; } + if sweep_angle < 0.0 { + sweep_angle += 2.0 * std::f64::consts::PI; + } let large_arc = sweep_angle > std::f64::consts::PI; let mut commands = Vec::new(); commands.push(PathCommand::MoveTo(ax1, ay1)); - commands.push(PathCommand::ArcTo(ell_rx, ell_ry, 0.0, large_arc, false, ax2, ay2)); + commands.push(PathCommand::ArcTo( + ell_rx, ell_ry, 0.0, large_arc, false, ax2, ay2, + )); match arc.arc_type { 1 => { @@ -843,8 +1010,16 @@ impl LayoutEngine { let (style, gradient) = drawing_to_shape_style(&poly.drawing); // 꼭짓점 좌표를 PathCommand로 변환 (원본→렌더 스케일링 적용) let sa = &poly.drawing.shape_attr; - let sx = if sa.original_width > 0 { render_w / hwpunit_to_px(sa.original_width as i32, self.dpi) } else { 1.0 }; - let sy = if sa.original_height > 0 { render_h / hwpunit_to_px(sa.original_height as i32, self.dpi) } else { 1.0 }; + let sx = if sa.original_width > 0 { + render_w / hwpunit_to_px(sa.original_width as i32, self.dpi) + } else { + 1.0 + }; + let sy = if sa.original_height > 0 { + render_h / hwpunit_to_px(sa.original_height as i32, self.dpi) + } else { + 1.0 + }; let mut commands = Vec::new(); for (i, pt) in poly.points.iter().enumerate() { let px = render_x + hwpunit_to_px(pt.x, self.dpi) * sx; @@ -874,17 +1049,50 @@ impl LayoutEngine { RenderNodeType::Path(path_node), BoundingBox::new(render_x, render_y, render_w, render_h), ); - self.add_image_fill_node(tree, &mut node, &poly.drawing, render_x, render_y, render_w, render_h, bin_data_content); + self.add_image_fill_node( + tree, + &mut node, + &poly.drawing, + render_x, + render_y, + render_w, + render_h, + bin_data_content, + ); let empty_map = std::collections::HashMap::new(); - self.layout_textbox_content(tree, &mut node, &poly.drawing, base_x, base_y, w, h, section_index, para_index, control_index, styles, bin_data_content, &empty_map, parent_cell_path); + self.layout_textbox_content( + tree, + &mut node, + &poly.drawing, + base_x, + base_y, + w, + h, + section_index, + para_index, + control_index, + styles, + bin_data_content, + &empty_map, + parent_cell_path, + ); parent.children.push(node); } ShapeObject::Curve(curve) => { let (style, gradient) = drawing_to_shape_style(&curve.drawing); let sa = &curve.drawing.shape_attr; - let sx = if sa.original_width > 0 { render_w / hwpunit_to_px(sa.original_width as i32, self.dpi) } else { 1.0 }; - let sy = if sa.original_height > 0 { render_h / hwpunit_to_px(sa.original_height as i32, self.dpi) } else { 1.0 }; - let commands = self.curve_to_path_commands_scaled(curve, render_x, render_y, sx, sy); + let sx = if sa.original_width > 0 { + render_w / hwpunit_to_px(sa.original_width as i32, self.dpi) + } else { + 1.0 + }; + let sy = if sa.original_height > 0 { + render_h / hwpunit_to_px(sa.original_height as i32, self.dpi) + } else { + 1.0 + }; + let commands = + self.curve_to_path_commands_scaled(curve, render_x, render_y, sx, sy); let node_id = tree.next_id(); let mut path_node = PathNode::new(commands, style, gradient); path_node.section_index = Some(section_index); @@ -896,9 +1104,33 @@ impl LayoutEngine { RenderNodeType::Path(path_node), BoundingBox::new(render_x, render_y, render_w, render_h), ); - self.add_image_fill_node(tree, &mut node, &curve.drawing, render_x, render_y, render_w, render_h, bin_data_content); + self.add_image_fill_node( + tree, + &mut node, + &curve.drawing, + render_x, + render_y, + render_w, + render_h, + bin_data_content, + ); let empty_map = std::collections::HashMap::new(); - self.layout_textbox_content(tree, &mut node, &curve.drawing, base_x, base_y, w, h, section_index, para_index, control_index, styles, bin_data_content, &empty_map, parent_cell_path); + self.layout_textbox_content( + tree, + &mut node, + &curve.drawing, + base_x, + base_y, + w, + h, + section_index, + para_index, + control_index, + styles, + bin_data_content, + &empty_map, + parent_cell_path, + ); parent.children.push(node); } ShapeObject::Group(group) => { @@ -915,8 +1147,16 @@ impl LayoutEngine { ); // 그룹 스케일 팩터: current_size / original_size (리사이즈 시 적용) let gsa = &group.shape_attr; - let group_sx = if gsa.original_width > 0 { gsa.current_width as f64 / gsa.original_width as f64 } else { 1.0 }; - let group_sy = if gsa.original_height > 0 { gsa.current_height as f64 / gsa.original_height as f64 } else { 1.0 }; + let group_sx = if gsa.original_width > 0 { + gsa.current_width as f64 / gsa.original_width as f64 + } else { + 1.0 + }; + let group_sy = if gsa.original_height > 0 { + gsa.current_height as f64 / gsa.original_height as f64 + } else { + 1.0 + }; for (_ci, child) in group.children.iter().enumerate() { let sa = child.shape_attr(); @@ -924,23 +1164,46 @@ impl LayoutEngine { if has_rotation { self.layout_group_child_affine( - tree, &mut group_node, child, base_x, base_y, - sa, section_index, para_index, control_index, - styles, bin_data_content, parent_cell_path, + tree, + &mut group_node, + child, + base_x, + base_y, + sa, + section_index, + para_index, + control_index, + styles, + bin_data_content, + parent_cell_path, ); } else { // render_tx/ty와 render_sx/sy에는 이미 그룹 스케일이 반영되어 있으므로 // group_sx/sy를 추가 적용하지 않음 let child_x = base_x + hwpunit_to_px(sa.render_tx as i32, self.dpi); let child_y = base_y + hwpunit_to_px(sa.render_ty as i32, self.dpi); - let child_w = hwpunit_to_px((sa.original_width as f64 * sa.render_sx.abs()) as i32, self.dpi); - let child_h = hwpunit_to_px((sa.original_height as f64 * sa.render_sy.abs()) as i32, self.dpi); + let child_w = hwpunit_to_px( + (sa.original_width as f64 * sa.render_sx.abs()) as i32, + self.dpi, + ); + let child_h = hwpunit_to_px( + (sa.original_height as f64 * sa.render_sy.abs()) as i32, + self.dpi, + ); let empty_map = std::collections::HashMap::new(); self.layout_shape_object( - tree, &mut group_node, child, - child_x, child_y, child_w, child_h, - section_index, para_index, control_index, - styles, bin_data_content, + tree, + &mut group_node, + child, + child_x, + child_y, + child_w, + child_h, + section_index, + para_index, + control_index, + styles, + bin_data_content, &empty_map, parent_cell_path, ); @@ -951,8 +1214,8 @@ impl LayoutEngine { ShapeObject::Picture(pic) => { // 그룹 내 그림: common이 비어있으므로 w, h(shape_attr 기반)를 직접 사용 let bin_data_id = pic.image_attr.bin_data_id; - let image_data = find_bin_data(bin_data_content, bin_data_id) - .map(|c| c.data.clone()); + let image_data = + find_bin_data(bin_data_content, bin_data_id).map(|c| c.data.clone()); let img_id = tree.next_id(); let img_node = RenderNode::new( img_id, @@ -984,8 +1247,8 @@ impl LayoutEngine { if drawing.fill.fill_type == FillType::Image { if let Some(ref img_fill) = drawing.fill.image { let bin_data_id = img_fill.bin_data_id; - let image_data = find_bin_data(bin_data_content, bin_data_id) - .map(|c| c.data.clone()); + let image_data = + find_bin_data(bin_data_content, bin_data_id).map(|c| c.data.clone()); // 이미지 원본 크기: shape_attr의 original_width/height (HWPUNIT) let original_size = { let ow = drawing.shape_attr.original_width; @@ -1063,14 +1326,22 @@ impl LayoutEngine { if text_direction != 0 { // 세로쓰기 오버플로우 수신: 오버플로우 문단을 세로 레이아웃으로 렌더링 self.layout_vertical_textbox_text_with_paras( - tree, shape_node, overflow_paras, text_box, styles, - &inner_area, text_direction, - section_index, para_index, control_index, + tree, + shape_node, + overflow_paras, + text_box, + styles, + &inner_area, + text_direction, + section_index, + para_index, + control_index, parent_cell_path, ); } else { // 이 텍스트박스는 연결된 텍스트박스의 타겟: 오버플로우 문단 렌더링 - let composed_paras: Vec<_> = overflow_paras.iter() + let composed_paras: Vec<_> = overflow_paras + .iter() .map(|p| compose_paragraph(p)) .collect(); let mut para_y = inner_area.y; @@ -1080,7 +1351,10 @@ impl LayoutEngine { let vpos_y = inner_area.y + hwpunit_to_px(first_ls.vertical_pos, self.dpi); para_y = vpos_y.max(para_y); } - let para_col_area = LayoutRect { y: para_y, ..inner_area }; + let para_col_area = LayoutRect { + y: para_y, + ..inner_area + }; let cell_ctx = CellContext { parent_para_index: para_index, path: { @@ -1104,10 +1378,14 @@ impl LayoutEngine { para_y, 0, composed.lines.len(), - section_index, tb_para_idx, Some(cell_ctx), + section_index, + tb_para_idx, + Some(cell_ctx), is_last_para, 0.0, - None, Some(para), None, + None, + Some(para), + None, ); } } @@ -1117,7 +1395,9 @@ impl LayoutEngine { // 오버플로우 감지 (가로/세로 공통): 텍스트박스 내 문단의 line_segs에서 // vpos가 리셋(이전 문단보다 감소)되고 sw가 변경되면 // 해당 문단은 다른 텍스트박스(연결된 글상자)에 속함 - let first_sw = text_box.paragraphs.first() + let first_sw = text_box + .paragraphs + .first() .and_then(|p| p.line_segs.first()) .map(|ls| ls.segment_width) .unwrap_or(0); @@ -1148,17 +1428,23 @@ impl LayoutEngine { // 세로쓰기: 오버플로우 감지 후 세로 레이아웃으로 분기 if text_direction != 0 { self.layout_vertical_textbox_text_with_paras( - tree, shape_node, + tree, + shape_node, &text_box.paragraphs[..para_count], - text_box, styles, - &inner_area, text_direction, - section_index, para_index, control_index, + text_box, + styles, + &inner_area, + text_direction, + section_index, + para_index, + control_index, parent_cell_path, ); return; } - let mut composed_paras: Vec<_> = text_box.paragraphs[..para_count].iter() + let mut composed_paras: Vec<_> = text_box.paragraphs[..para_count] + .iter() .map(|p| compose_paragraph(p)) .collect(); @@ -1166,9 +1452,10 @@ impl LayoutEngine { let current_pn = self.current_page_number.get(); if current_pn > 0 { for (pi, para) in text_box.paragraphs[..para_count].iter().enumerate() { - let has_page_auto = para.controls.iter().any(|c| + let has_page_auto = para.controls.iter().any(|c| { matches!(c, crate::model::control::Control::AutoNumber(an) - if an.number_type == crate::model::control::AutoNumberType::Page)); + if an.number_type == crate::model::control::AutoNumberType::Page) + }); if has_page_auto { let page_str = current_pn.to_string(); if let Some(comp) = composed_paras.get_mut(pi) { @@ -1192,7 +1479,8 @@ impl LayoutEngine { match text_box.vertical_align { VerticalAlign::Center | VerticalAlign::Bottom => { // 전체 텍스트 높이 = 마지막 문단의 마지막 line_seg 끝 위치 - let total_text_height = text_box.paragraphs[..para_count].iter() + let total_text_height = text_box.paragraphs[..para_count] + .iter() .flat_map(|p| p.line_segs.last()) .map(|ls| hwpunit_to_px(ls.vertical_pos + ls.line_height, self.dpi)) .last() @@ -1215,12 +1503,15 @@ impl LayoutEngine { // 더 큰 값 사용 (원본 호환 + 편집 후 정상 배치 모두 지원) let para = &text_box.paragraphs[tb_para_idx]; if let Some(first_ls) = para.line_segs.first() { - let vpos_y = inner_area.y + vert_offset + hwpunit_to_px(first_ls.vertical_pos, self.dpi); + let vpos_y = + inner_area.y + vert_offset + hwpunit_to_px(first_ls.vertical_pos, self.dpi); para_y = vpos_y.max(para_y); } // 인라인(treat_as_char) 컨트롤의 총 폭 계산 - let tb_inline_width: f64 = para.controls.iter().map(|ctrl| { - match ctrl { + let tb_inline_width: f64 = para + .controls + .iter() + .map(|ctrl| match ctrl { Control::Picture(pic) if pic.common.treat_as_char => { hwpunit_to_px(pic.common.width as i32, self.dpi) } @@ -1228,9 +1519,12 @@ impl LayoutEngine { hwpunit_to_px(shape.common().width as i32, self.dpi) } _ => 0.0, - } - }).sum(); - let para_col_area = LayoutRect { y: para_y, ..inner_area }; + }) + .sum(); + let para_col_area = LayoutRect { + y: para_y, + ..inner_area + }; let cell_ctx = CellContext { parent_para_index: para_index, path: { @@ -1254,10 +1548,14 @@ impl LayoutEngine { para_y, 0, composed.lines.len(), - section_index, tb_para_idx, Some(cell_ctx), + section_index, + tb_para_idx, + Some(cell_ctx), is_last_para, tb_inline_width, - None, Some(para), None, + None, + Some(para), + None, ); } @@ -1281,7 +1579,9 @@ impl LayoutEngine { // 문단 정렬 조회 let para_alignment = if pi < composed_paras.len() { let para_style_id = composed_paras[pi].para_style_id as usize; - styles.para_styles.get(para_style_id) + styles + .para_styles + .get(para_style_id) .map(|s| s.alignment) .unwrap_or(Alignment::Left) } else { @@ -1299,19 +1599,23 @@ impl LayoutEngine { Control::Shape(shape) => { let child_common = shape.as_ref().common(); if child_common.treat_as_char { - total_inline_width += hwpunit_to_px(child_common.width as i32, self.dpi); - max_inline_height = max_inline_height.max(hwpunit_to_px(child_common.height as i32, self.dpi)); + total_inline_width += + hwpunit_to_px(child_common.width as i32, self.dpi); + max_inline_height = max_inline_height + .max(hwpunit_to_px(child_common.height as i32, self.dpi)); } } Control::Picture(pic) => { if pic.common.treat_as_char { total_inline_width += hwpunit_to_px(pic.common.width as i32, self.dpi); - max_inline_height = max_inline_height.max(hwpunit_to_px(pic.common.height as i32, self.dpi)); + max_inline_height = max_inline_height + .max(hwpunit_to_px(pic.common.height as i32, self.dpi)); } } Control::Equation(eq) => { total_inline_width += hwpunit_to_px(eq.common.width as i32, self.dpi); - max_inline_height = max_inline_height.max(hwpunit_to_px(eq.common.height as i32, self.dpi)); + max_inline_height = + max_inline_height.max(hwpunit_to_px(eq.common.height as i32, self.dpi)); } _ => {} } @@ -1319,25 +1623,37 @@ impl LayoutEngine { // 인라인 컨트롤의 시작 x 위치 (정렬 기반) // 이미지+텍스트 전체 폭을 기준으로 정렬 (함께 센터링) - let first_line_text_width: f64 = if total_inline_width > 0.0 && pi < composed_paras.len() { + let first_line_text_width: f64 = if total_inline_width > 0.0 + && pi < composed_paras.len() + { if let Some(first_line) = composed_paras[pi].lines.first() { - let tab_width = styles.para_styles.get(composed_paras[pi].para_style_id as usize) - .map(|s| s.default_tab_width).unwrap_or(0.0); - first_line.runs.iter().map(|run| { - let mut ts = resolved_to_text_style(styles, run.char_style_id, run.lang_index); - ts.default_tab_width = tab_width; - estimate_text_width(&run.text, &ts) - }).sum() - } else { 0.0 } - } else { 0.0 }; + let tab_width = styles + .para_styles + .get(composed_paras[pi].para_style_id as usize) + .map(|s| s.default_tab_width) + .unwrap_or(0.0); + first_line + .runs + .iter() + .map(|run| { + let mut ts = + resolved_to_text_style(styles, run.char_style_id, run.lang_index); + ts.default_tab_width = tab_width; + estimate_text_width(&run.text, &ts) + }) + .sum() + } else { + 0.0 + } + } else { + 0.0 + }; let total_line_width = total_inline_width + first_line_text_width; let mut inline_x = match para_alignment { Alignment::Center | Alignment::Distribute => { inner_area.x + (inner_area.width - total_line_width).max(0.0) / 2.0 } - Alignment::Right => { - inner_area.x + (inner_area.width - total_line_width).max(0.0) - } + Alignment::Right => inner_area.x + (inner_area.width - total_line_width).max(0.0), _ => inner_area.x, }; @@ -1357,8 +1673,13 @@ impl LayoutEngine { } else { // 절대 위치 도형 ( - base_x + hwpunit_to_px(child_common.horizontal_offset as i32, self.dpi), - base_y + hwpunit_to_px(child_common.vertical_offset as i32, self.dpi), + base_x + + hwpunit_to_px( + child_common.horizontal_offset as i32, + self.dpi, + ), + base_y + + hwpunit_to_px(child_common.vertical_offset as i32, self.dpi), ) }; @@ -1372,10 +1693,18 @@ impl LayoutEngine { }); let empty_map = std::collections::HashMap::new(); self.layout_shape_object( - tree, shape_node, shape.as_ref(), - child_x, child_y, child_w, child_h, - section_index, para_index, ctrl_idx_in_para, - styles, bin_data_content, + tree, + shape_node, + shape.as_ref(), + child_x, + child_y, + child_w, + child_h, + section_index, + para_index, + ctrl_idx_in_para, + styles, + bin_data_content, &empty_map, &nested_parent_path, ); @@ -1391,7 +1720,17 @@ impl LayoutEngine { width: pic_w, height: pic_h, }; - self.layout_picture(tree, shape_node, pic, &pic_container, bin_data_content, Alignment::Left, None, None, None); + self.layout_picture( + tree, + shape_node, + pic, + &pic_container, + bin_data_content, + Alignment::Left, + None, + None, + None, + ); inline_x += pic_w; } else { // 절대 위치 이미지 @@ -1401,7 +1740,17 @@ impl LayoutEngine { width: inner_area.width, height: (inner_area.height - (inline_y - inner_area.y)).max(0.0), }; - self.layout_picture(tree, shape_node, pic, &pic_container, bin_data_content, para_alignment, None, None, None); + self.layout_picture( + tree, + shape_node, + pic, + &pic_container, + bin_data_content, + para_alignment, + None, + None, + None, + ); let pic_h = hwpunit_to_px(pic.common.height as i32, self.dpi); max_inline_height = max_inline_height.max(pic_h); } @@ -1419,10 +1768,15 @@ impl LayoutEngine { let tokens = super::super::equation::tokenizer::tokenize(&eq.script); let ast = super::super::equation::parser::EqParser::new(tokens).parse(); let font_size_px = hwpunit_to_px(eq.font_size as i32, self.dpi); - let layout_box = super::super::equation::layout::EqLayout::new(font_size_px).layout(&ast); - let color_str = super::super::equation::svg_render::eq_color_to_svg(eq.color); + let layout_box = + super::super::equation::layout::EqLayout::new(font_size_px) + .layout(&ast); + let color_str = + super::super::equation::svg_render::eq_color_to_svg(eq.color); let svg_content = super::super::equation::svg_render::render_equation_svg( - &layout_box, &color_str, font_size_px, + &layout_box, + &color_str, + font_size_px, ); let eq_node = RenderNode::new( @@ -1454,14 +1808,24 @@ impl LayoutEngine { text_direction: 0, }); // 호스트 문단의 정렬 속성 - let host_align = styles.para_styles + let host_align = styles + .para_styles .get(para.para_shape_id as usize) .map(|ps| ps.alignment) .unwrap_or(Alignment::Left); inline_y = self.layout_embedded_table( - tree, shape_node, table, styles, - &inner_area, para_start_y, - Some((section_index, para_index, &table_enclosing_path, ctrl_idx_in_para)), + tree, + shape_node, + table, + styles, + &inner_area, + para_start_y, + Some(( + section_index, + para_index, + &table_enclosing_path, + ctrl_idx_in_para, + )), bin_data_content, host_align, ); @@ -1512,19 +1876,19 @@ impl LayoutEngine { struct ColumnInfo { start_idx: usize, end_idx: usize, - col_width: f64, // line_height + line_spacing (px), 마지막 칼럼은 line_height만 + col_width: f64, // line_height + line_spacing (px), 마지막 칼럼은 line_height만 col_spacing: f64, // 항상 0 (line_spacing이 col_width에 흡수됨) total_height: f64, alignment: Alignment, absorbed_spacing: f64, // 흡수된 line_spacing (px) — 마지막 칼럼 후처리용 } - let composed_paras: Vec<_> = paragraphs.iter() - .map(|p| compose_paragraph(p)) - .collect(); + let composed_paras: Vec<_> = paragraphs.iter().map(|p| compose_paragraph(p)).collect(); let get_alignment = |para_style_id: u16| -> Alignment { - styles.para_styles.get(para_style_id as usize) + styles + .para_styles + .get(para_style_id as usize) .map(|s| s.alignment) .unwrap_or(Alignment::Left) }; @@ -1540,11 +1904,14 @@ impl LayoutEngine { // 빈 문단: 빈 열 추가 // 칼럼 너비 = line_height + line_spacing (전체 피치를 칼럼에 흡수) let ls = para.line_segs.first(); - let spacing = ls.map(|l| hwpunit_to_px(l.line_spacing, self.dpi)).unwrap_or(0.0); + let spacing = ls + .map(|l| hwpunit_to_px(l.line_spacing, self.dpi)) + .unwrap_or(0.0); columns.push(ColumnInfo { start_idx: chars.len(), end_idx: chars.len(), - col_width: ls.map(|l| hwpunit_to_px(l.line_height + l.line_spacing, self.dpi)) + col_width: ls + .map(|l| hwpunit_to_px(l.line_height + l.line_spacing, self.dpi)) .unwrap_or(13.0), col_spacing: 0.0, total_height: 0.0, @@ -1558,27 +1925,30 @@ impl LayoutEngine { for (line_idx, line) in composed.lines.iter().enumerate() { let ls = para.line_segs.get(line_idx); // 칼럼 너비 = line_height + line_spacing (전체 피치 흡수) - let col_width = ls.map(|l| hwpunit_to_px(l.line_height + l.line_spacing, self.dpi)) + let col_width = ls + .map(|l| hwpunit_to_px(l.line_height + l.line_spacing, self.dpi)) .unwrap_or(13.0); let col_spacing = 0.0; - let absorbed_spacing = ls.map(|l| hwpunit_to_px(l.line_spacing, self.dpi)).unwrap_or(0.0); + let absorbed_spacing = ls + .map(|l| hwpunit_to_px(l.line_spacing, self.dpi)) + .unwrap_or(0.0); let col_start = chars.len(); let mut col_height = 0.0; for run in &line.runs { - let text_style = resolved_to_text_style(styles, run.char_style_id, run.lang_index); + let text_style = + resolved_to_text_style(styles, run.char_style_id, run.lang_index); for ch in run.text.chars() { if ch == '\n' || ch == '\r' { char_offset += 1; continue; } let is_rotate = is_vertical_rotate_char(ch); - let needs_rotation = is_rotate - || (text_direction == 1 && !is_cjk_char(ch)); + let needs_rotation = is_rotate || (text_direction == 1 && !is_cjk_char(ch)); // 세로쓰기에서 구두점/기호만 반칸 advance (영문/숫자는 캐릭터 높이) - let half_advance = needs_rotation - || (!is_cjk_char(ch) && !ch.is_ascii_alphanumeric()); + let half_advance = + needs_rotation || (!is_cjk_char(ch) && !ch.is_ascii_alphanumeric()); let advance = if half_advance { text_style.font_size * 0.5 } else { @@ -1632,7 +2002,10 @@ impl LayoutEngine { 0.0 } else { columns.iter().map(|c| c.col_width).sum::() - + columns[..columns.len() - 1].iter().map(|c| c.col_spacing).sum::() + + columns[..columns.len() - 1] + .iter() + .map(|c| c.col_spacing) + .sum::() }; use crate::model::table::VerticalAlign; @@ -1654,22 +2027,22 @@ impl LayoutEngine { col_x -= col.col_width; let free_space = (inner_area.height - col.total_height).max(0.0); - let y_start = inner_area.y + match col.alignment { - Alignment::Center | Alignment::Distribute => free_space / 2.0, - Alignment::Right => free_space, - _ => 0.0, - }; + let y_start = inner_area.y + + match col.alignment { + Alignment::Center | Alignment::Distribute => free_space / 2.0, + Alignment::Right => free_space, + _ => 0.0, + }; let mut char_y = y_start; let col_bottom = inner_area.y + inner_area.height; for i in col.start_idx..col.end_idx { let ci = &chars[i]; let is_rotate = is_vertical_rotate_char(ci.ch); - let needs_rotation = is_rotate - || (text_direction == 1 && !is_cjk_char(ci.ch)); + let needs_rotation = is_rotate || (text_direction == 1 && !is_cjk_char(ci.ch)); // 세로쓰기에서 구두점/기호만 반칸 advance (영문/숫자는 캐릭터 높이) - let half_advance = needs_rotation - || (!is_cjk_char(ci.ch) && !ci.ch.is_ascii_alphanumeric()); + let half_advance = + needs_rotation || (!is_cjk_char(ci.ch) && !ci.ch.is_ascii_alphanumeric()); let advance = if half_advance { ci.style.font_size * 0.5 } else { @@ -1739,8 +2112,11 @@ impl LayoutEngine { rotation, is_vertical: true, char_overlap: None, - border_fill_id: styles.char_styles.get(ci.char_style_id as usize) - .map(|cs| cs.border_fill_id).unwrap_or(0), + border_fill_id: styles + .char_styles + .get(ci.char_style_id as usize) + .map(|cs| cs.border_fill_id) + .unwrap_or(0), baseline: advance * 0.85, field_marker: FieldMarkerType::None, }), @@ -1770,11 +2146,15 @@ impl LayoutEngine { return commands; } - let pts: Vec<(f64, f64)> = curve.points.iter() - .map(|p| ( - base_x + hwpunit_to_px(p.x, self.dpi) * sx, - base_y + hwpunit_to_px(p.y, self.dpi) * sy, - )) + let pts: Vec<(f64, f64)> = curve + .points + .iter() + .map(|p| { + ( + base_x + hwpunit_to_px(p.x, self.dpi) * sx, + base_y + hwpunit_to_px(p.y, self.dpi) * sy, + ) + }) .collect(); commands.push(PathCommand::MoveTo(pts[0].0, pts[0].1)); @@ -1786,9 +2166,12 @@ impl LayoutEngine { if seg_type == 1 && i + 2 < pts.len() { // 베지어 곡선: 제어점 2개 + 끝점 1개 commands.push(PathCommand::CurveTo( - pts[i].0, pts[i].1, - pts[i + 1].0, pts[i + 1].1, - pts[i + 2].0, pts[i + 2].1, + pts[i].0, + pts[i].1, + pts[i + 1].0, + pts[i + 1].1, + pts[i + 2].0, + pts[i + 2].1, )); i += 3; } else { @@ -1818,8 +2201,14 @@ impl LayoutEngine { for item in items { let (para_index, control_index) = match item { - PageItem::Shape { para_index, control_index } => (para_index, control_index), - PageItem::Table { para_index, control_index } => (para_index, control_index), + PageItem::Shape { + para_index, + control_index, + } => (para_index, control_index), + PageItem::Table { + para_index, + control_index, + } => (para_index, control_index), _ => continue, }; { @@ -1876,9 +2265,8 @@ impl LayoutEngine { None }; let effective_ref = effective_common.as_ref().unwrap_or(common); - let (bottom_y, shape_y) = self.calc_shape_bottom_y( - effective_ref, col_area, body_area, - ); + let (bottom_y, shape_y) = + self.calc_shape_bottom_y(effective_ref, col_area, body_area); // 본문 시작 근처만 고려 (페이지 하단 개체는 제외) let threshold_y = col_area.y + col_area.height / 3.0; @@ -1907,7 +2295,7 @@ impl LayoutEngine { col_area: &LayoutRect, body_area: &LayoutRect, ) -> (f64, f64) { - use crate::model::shape::{VertRelTo, VertAlign}; + use crate::model::shape::{VertAlign, VertRelTo}; let v_offset = hwpunit_to_px(common.vertical_offset as i32, self.dpi); let shape_h = hwpunit_to_px(common.height as i32, self.dpi); let (ref_y, ref_h) = match common.vert_rel_to { @@ -1940,8 +2328,14 @@ impl LayoutEngine { for col_content in column_contents { for item in &col_content.items { let (para_index, control_index) = match item { - super::super::pagination::PageItem::Shape { para_index, control_index } => (para_index, control_index), - super::super::pagination::PageItem::Table { para_index, control_index } => (para_index, control_index), + super::super::pagination::PageItem::Shape { + para_index, + control_index, + } => (para_index, control_index), + super::super::pagination::PageItem::Table { + para_index, + control_index, + } => (para_index, control_index), _ => continue, }; let para = match paragraphs.get(*para_index) { @@ -1987,7 +2381,9 @@ impl LayoutEngine { /// 표의 실제 렌더링 높이를 계산 (셀 콘텐츠 + 패딩 포함) fn measure_table_actual_height(&self, table: &crate::model::table::Table) -> u32 { let row_count = table.row_count as usize; - if row_count == 0 { return table.common.height; } + if row_count == 0 { + return table.common.height; + } let mut row_heights = vec![0u32; row_count]; for cell in &table.cells { if cell.row_span == 1 && (cell.row as usize) < row_count { @@ -2004,10 +2400,22 @@ impl LayoutEngine { let (pad_top, pad_bottom) = if !cell.apply_inner_margin { (table.padding.top as u32, table.padding.bottom as u32) } else { - (if cell.padding.top != 0 { cell.padding.top as u32 } else { table.padding.top as u32 }, - if cell.padding.bottom != 0 { cell.padding.bottom as u32 } else { table.padding.bottom as u32 }) + ( + if cell.padding.top != 0 { + cell.padding.top as u32 + } else { + table.padding.top as u32 + }, + if cell.padding.bottom != 0 { + cell.padding.bottom as u32 + } else { + table.padding.bottom as u32 + }, + ) }; - let content_h: i32 = cell.paragraphs.iter() + let content_h: i32 = cell + .paragraphs + .iter() .flat_map(|p| p.line_segs.last()) .map(|s| s.vertical_pos + s.line_height) .max() @@ -2029,7 +2437,7 @@ impl LayoutEngine { col_area: &LayoutRect, body_area: &LayoutRect, ) -> bool { - use crate::model::shape::{HorzRelTo, HorzAlign}; + use crate::model::shape::{HorzAlign, HorzRelTo}; let h_offset = hwpunit_to_px(common.horizontal_offset as i32, self.dpi); let shape_w = hwpunit_to_px(common.width as i32, self.dpi); let (ref_x, ref_w) = match common.horz_rel_to { @@ -2048,4 +2456,3 @@ impl LayoutEngine { shape_right >= col_area.x && shape_x <= col_right } } - diff --git a/src/renderer/layout/table_cell_content.rs b/src/renderer/layout/table_cell_content.rs index 853786b0..2def7206 100644 --- a/src/renderer/layout/table_cell_content.rs +++ b/src/renderer/layout/table_cell_content.rs @@ -1,19 +1,23 @@ //! 표 셀 내용 레이아웃 (세로쓰기, 셀 도형, 내장 표) -use crate::model::paragraph::Paragraph; -use crate::model::control::Control; -use crate::model::style::Alignment; -use crate::model::bin_data::BinDataContent; -use crate::model::table::VerticalAlign; -use super::super::render_tree::*; -use super::super::page_layout::LayoutRect; use super::super::composer::{compose_paragraph, ComposedParagraph}; +use super::super::page_layout::LayoutRect; +use super::super::render_tree::*; use super::super::style_resolver::ResolvedStyleSet; -use super::super::{hwpunit_to_px, TextStyle, ShapeStyle}; -use super::{LayoutEngine, CellContext, CellPathEntry}; -use super::border_rendering::{build_row_col_x, collect_cell_borders, render_edge_borders, render_transparent_borders}; -use super::text_measurement::{resolved_to_text_style, is_cjk_char, is_vertical_rotate_char, vertical_substitute_char}; +use super::super::{hwpunit_to_px, ShapeStyle, TextStyle}; +use super::border_rendering::{ + build_row_col_x, collect_cell_borders, render_edge_borders, render_transparent_borders, +}; +use super::text_measurement::{ + is_cjk_char, is_vertical_rotate_char, resolved_to_text_style, vertical_substitute_char, +}; use super::utils::find_bin_data; +use super::{CellContext, CellPathEntry, LayoutEngine}; +use crate::model::bin_data::BinDataContent; +use crate::model::control::Control; +use crate::model::paragraph::Paragraph; +use crate::model::style::Alignment; +use crate::model::table::VerticalAlign; impl LayoutEngine { /// 세로쓰기 셀의 텍스트를 수직 방향으로 배치한다. @@ -62,7 +66,9 @@ impl LayoutEngine { } let get_alignment = |para_style_id: u16| -> Alignment { - styles.para_styles.get(para_style_id as usize) + styles + .para_styles + .get(para_style_id as usize) .map(|s| s.alignment) .unwrap_or(Alignment::Left) }; @@ -78,11 +84,14 @@ impl LayoutEngine { // 빈 문단: 빈 열 추가 (개행) // 칼럼 너비 = line_height + line_spacing (전체 피치를 칼럼에 흡수) let ls = para.and_then(|p| p.line_segs.first()); - let spacing = ls.map(|l| hwpunit_to_px(l.line_spacing, self.dpi)).unwrap_or(0.0); + let spacing = ls + .map(|l| hwpunit_to_px(l.line_spacing, self.dpi)) + .unwrap_or(0.0); columns.push(ColumnInfo { start_idx: chars.len(), end_idx: chars.len(), - col_width: ls.map(|l| hwpunit_to_px(l.line_height + l.line_spacing, self.dpi)) + col_width: ls + .map(|l| hwpunit_to_px(l.line_height + l.line_spacing, self.dpi)) .unwrap_or(13.0), col_spacing: 0.0, total_height: 0.0, @@ -97,27 +106,30 @@ impl LayoutEngine { let ls = para.and_then(|p| p.line_segs.get(line_idx)); // 칼럼 너비 = line_height + line_spacing (전체 피치 흡수) // 마지막 칼럼은 후처리로 line_spacing분 제거 - let col_width = ls.map(|l| hwpunit_to_px(l.line_height + l.line_spacing, self.dpi)) + let col_width = ls + .map(|l| hwpunit_to_px(l.line_height + l.line_spacing, self.dpi)) .unwrap_or(13.0); let col_spacing = 0.0; - let absorbed_spacing = ls.map(|l| hwpunit_to_px(l.line_spacing, self.dpi)).unwrap_or(0.0); + let absorbed_spacing = ls + .map(|l| hwpunit_to_px(l.line_spacing, self.dpi)) + .unwrap_or(0.0); let col_start = chars.len(); let mut col_height = 0.0; for run in &line.runs { - let text_style = resolved_to_text_style(styles, run.char_style_id, run.lang_index); + let text_style = + resolved_to_text_style(styles, run.char_style_id, run.lang_index); for ch in run.text.chars() { if ch == '\n' || ch == '\r' { char_offset += 1; continue; } let is_rotate = is_vertical_rotate_char(ch); - let needs_rotation = is_rotate - || (text_direction == 1 && !is_cjk_char(ch)); + let needs_rotation = is_rotate || (text_direction == 1 && !is_cjk_char(ch)); // 세로쓰기에서 구두점/기호만 반칸 advance (영문/숫자는 캐릭터 높이) - let half_advance = needs_rotation - || (!is_cjk_char(ch) && !ch.is_ascii_alphanumeric()); + let half_advance = + needs_rotation || (!is_cjk_char(ch) && !ch.is_ascii_alphanumeric()); let advance = if half_advance { text_style.font_size * 0.5 } else { @@ -173,7 +185,10 @@ impl LayoutEngine { 0.0 } else { columns.iter().map(|c| c.col_width).sum::() - + columns[..columns.len() - 1].iter().map(|c| c.col_spacing).sum::() + + columns[..columns.len() - 1] + .iter() + .map(|c| c.col_spacing) + .sum::() }; // 열이 셀보다 넓으면 첫 열이 오른쪽 가장자리에서 시작하도록 클램핑 @@ -194,22 +209,22 @@ impl LayoutEngine { col_x -= col.col_width; let free_space = (inner_area.height - col.total_height).max(0.0); - let y_start = inner_area.y + match col.alignment { - Alignment::Center | Alignment::Distribute => free_space / 2.0, - Alignment::Right => free_space, - _ => 0.0, - }; + let y_start = inner_area.y + + match col.alignment { + Alignment::Center | Alignment::Distribute => free_space / 2.0, + Alignment::Right => free_space, + _ => 0.0, + }; let mut char_y = y_start; let col_bottom = inner_area.y + inner_area.height; for i in col.start_idx..col.end_idx { let ci = &chars[i]; let is_rotate = is_vertical_rotate_char(ci.ch); - let needs_rotation = is_rotate - || (text_direction == 1 && !is_cjk_char(ci.ch)); + let needs_rotation = is_rotate || (text_direction == 1 && !is_cjk_char(ci.ch)); // 세로쓰기에서 구두점/기호만 반칸 advance (영문/숫자는 캐릭터 높이) - let half_advance = needs_rotation - || (!is_cjk_char(ci.ch) && !ci.ch.is_ascii_alphanumeric()); + let half_advance = + needs_rotation || (!is_cjk_char(ci.ch) && !ci.ch.is_ascii_alphanumeric()); let advance = if half_advance { ci.style.font_size * 0.5 } else { @@ -277,8 +292,11 @@ impl LayoutEngine { rotation, is_vertical: true, char_overlap: None, - border_fill_id: styles.char_styles.get(ci.char_style_id as usize) - .map(|cs| cs.border_fill_id).unwrap_or(0), + border_fill_id: styles + .char_styles + .get(ci.char_style_id as usize) + .map(|cs| cs.border_fill_id) + .unwrap_or(0), baseline: advance * 0.85, field_marker: FieldMarkerType::None, }), @@ -295,7 +313,6 @@ impl LayoutEngine { } } - /// 테이블 셀 내 도형(Shape) 컨트롤을 레이아웃한다. pub(crate) fn layout_cell_shape( &self, @@ -319,9 +336,7 @@ impl LayoutEngine { Alignment::Center | Alignment::Distribute => { inner_area.x + (inner_area.width - child_w).max(0.0) / 2.0 } - Alignment::Right => { - inner_area.x + (inner_area.width - child_w).max(0.0) - } + Alignment::Right => inner_area.x + (inner_area.width - child_w).max(0.0), _ => inner_area.x, }; (x, para_y) @@ -334,18 +349,14 @@ impl LayoutEngine { HorzAlign::Right | HorzAlign::Outside => { inner_area.x + inner_area.width - child_w - h_offset } - HorzAlign::Center => { - inner_area.x + (inner_area.width - child_w) / 2.0 + h_offset - } + HorzAlign::Center => inner_area.x + (inner_area.width - child_w) / 2.0 + h_offset, _ => inner_area.x + h_offset, }; let y = match child_common.vert_align { VertAlign::Bottom | VertAlign::Outside => { inner_area.y + inner_area.height - child_h - v_offset } - VertAlign::Center => { - inner_area.y + (inner_area.height - child_h) / 2.0 + v_offset - } + VertAlign::Center => inner_area.y + (inner_area.height - child_h) / 2.0 + v_offset, _ => inner_area.y + v_offset, }; (x, y) @@ -353,10 +364,18 @@ impl LayoutEngine { let empty_map = std::collections::HashMap::new(); self.layout_shape_object( - tree, cell_node, shape, - child_x, child_y, child_w, child_h, - 0, 0, 0, - styles, bin_data_content, + tree, + cell_node, + shape, + child_x, + child_y, + child_w, + child_h, + 0, + 0, + 0, + styles, + bin_data_content, &empty_map, &[], ); @@ -423,23 +442,35 @@ impl LayoutEngine { // 누적 위치 계산 let mut col_x = vec![0.0f64; col_count + 1]; for i in 0..col_count { - col_x[i + 1] = col_x[i] + col_widths[i] + if i + 1 < col_count { cell_spacing } else { 0.0 }; + col_x[i + 1] = + col_x[i] + col_widths[i] + if i + 1 < col_count { cell_spacing } else { 0.0 }; } let mut row_y = vec![0.0f64; row_count + 1]; for i in 0..row_count { - row_y[i + 1] = row_y[i] + row_heights[i] + if i + 1 < row_count { cell_spacing } else { 0.0 }; + row_y[i + 1] = + row_y[i] + row_heights[i] + if i + 1 < row_count { cell_spacing } else { 0.0 }; } // 행별 열 위치 계산 (셀별 독립 너비 지원) - let row_col_x = build_row_col_x(table, &col_widths, col_count, row_count, cell_spacing, self.dpi); + let row_col_x = build_row_col_x( + table, + &col_widths, + col_count, + row_count, + cell_spacing, + self.dpi, + ); - let table_width = row_col_x.iter() + let table_width = row_col_x + .iter() .map(|rx| rx.last().copied().unwrap_or(0.0)) .fold(col_x.last().copied().unwrap_or(0.0), f64::max); let table_height = row_y.last().copied().unwrap_or(0.0); // TAC 표: 호스트 문단 정렬에 따라 배치 let table_x = match host_alignment { - Alignment::Center | Alignment::Distribute => container.x + (container.width - table_width).max(0.0) / 2.0, + Alignment::Center | Alignment::Distribute => { + container.x + (container.width - table_width).max(0.0) / 2.0 + } Alignment::Right => container.x + (container.width - table_width).max(0.0), _ => container.x, // 왼쪽 정렬 (기본) }; @@ -470,8 +501,13 @@ impl LayoutEngine { let tbl_idx = (table.border_fill_id as usize).saturating_sub(1); if let Some(tbl_bs) = styles.border_styles.get(tbl_idx) { self.render_cell_background( - tree, &mut table_node, Some(tbl_bs), - table_x, table_y, table_width, table_height, + tree, + &mut table_node, + Some(tbl_bs), + table_x, + table_y, + table_width, + table_height, ); } } @@ -541,14 +577,19 @@ impl LayoutEngine { // 셀 테두리를 엣지 그리드에 수집 if let Some(bs) = border_style { collect_cell_borders( - &mut h_edges, &mut v_edges, - c, r, cell.col_span as usize, cell.row_span as usize, + &mut h_edges, + &mut v_edges, + c, + r, + cell.col_span as usize, + cell.row_span as usize, &bs.borders, ); } // 셀 패딩 (apply_inner_margin 고려) - let (pad_left, pad_right, pad_top, _pad_bottom) = self.resolve_cell_padding(cell, table); + let (pad_left, pad_right, pad_top, _pad_bottom) = + self.resolve_cell_padding(cell, table); let inner_x = cell_x + pad_left; let inner_width = (cell_w - pad_left - pad_right).max(0.0); @@ -560,14 +601,20 @@ impl LayoutEngine { }; // 셀 내 문단 레이아웃 - let composed_paras: Vec<_> = cell.paragraphs.iter() + let composed_paras: Vec<_> = cell + .paragraphs + .iter() .map(|p| compose_paragraph(p)) .collect(); let mut para_y = cell_y + pad_top; let para_count = composed_paras.len(); let cell_idx = cell_enum_idx; - for (pidx, (composed, para)) in composed_paras.iter().zip(cell.paragraphs.iter()).enumerate() { + for (pidx, (composed, para)) in composed_paras + .iter() + .zip(cell.paragraphs.iter()) + .enumerate() + { // enclosing context가 있으면 글상자 경로 + 표 셀 경로를 합성 let cell_ctx = enclosing_ctx.map(|(sec_idx, para_idx, parent_path, table_ci)| { let mut path = parent_path.to_vec(); @@ -577,10 +624,14 @@ impl LayoutEngine { cell_para_index: pidx, text_direction: cell.text_direction, }); - (sec_idx, para_idx, CellContext { - parent_para_index: para_idx, - path, - }) + ( + sec_idx, + para_idx, + CellContext { + parent_para_index: para_idx, + path, + }, + ) }); let (sec_for_layout, para_for_layout, ctx) = match cell_ctx { Some((s, p, c)) => (s, pidx, Some(c)), @@ -595,10 +646,14 @@ impl LayoutEngine { para_y, 0, composed.lines.len(), - sec_for_layout, para_for_layout, ctx, + sec_for_layout, + para_for_layout, + ctx, pidx + 1 == para_count, 0.0, - None, Some(para), None, + None, + Some(para), + None, ); // 셀 내 그림/도형 컨트롤 렌더링 @@ -609,7 +664,11 @@ impl LayoutEngine { let pic_h = hwpunit_to_px(pic.common.height as i32, self.dpi); // 셀 내부에 맞추어 크기 제한 let fit_w = pic_w.min(inner_width); - let fit_h = if pic_w > 0.0 { pic_h * (fit_w / pic_w) } else { pic_h }; + let fit_h = if pic_w > 0.0 { + pic_h * (fit_w / pic_w) + } else { + pic_h + }; // TAC: 문단 시작 위치 (표의 왼쪽 상단) let pic_x = inner_x; // vpos 기반 y 위치: LINE_SEG의 vertical_pos 사용 @@ -620,8 +679,8 @@ impl LayoutEngine { }; let bin_id = pic.image_attr.bin_data_id; - let img_data = find_bin_data(bin_data_content, bin_id) - .map(|bd| bd.data.clone()); + let img_data = + find_bin_data(bin_data_content, bin_id).map(|bd| bd.data.clone()); let img_node_id = tree.next_id(); let img_node = RenderNode::new( img_node_id, diff --git a/src/renderer/layout/table_layout.rs b/src/renderer/layout/table_layout.rs index eda91904..31bf3ab1 100644 --- a/src/renderer/layout/table_layout.rs +++ b/src/renderer/layout/table_layout.rs @@ -1,23 +1,26 @@ //! 표 레이아웃 (layout_table + 셀 높이/줄범위 계산) -use crate::model::paragraph::Paragraph; -use crate::model::style::{Alignment, BorderLine}; -use crate::model::table::VerticalAlign; -use crate::model::control::Control; -use crate::model::bin_data::BinDataContent; -use super::super::render_tree::*; -use super::super::page_layout::LayoutRect; -use super::super::height_measurer::MeasuredTable; use super::super::composer::{compose_paragraph, ComposedParagraph}; +use super::super::height_measurer::MeasuredTable; +use super::super::page_layout::LayoutRect; +use super::super::render_tree::*; use super::super::style_resolver::ResolvedStyleSet; use super::super::{hwpunit_to_px, ShapeStyle}; -use super::{LayoutEngine, CellContext, CellPathEntry}; -use super::border_rendering::{build_row_col_x, collect_cell_borders, render_cell_diagonal, render_edge_borders, render_transparent_borders}; -use super::text_measurement::{resolved_to_text_style, estimate_text_width}; +use super::border_rendering::{ + build_row_col_x, collect_cell_borders, render_cell_diagonal, render_edge_borders, + render_transparent_borders, +}; +use super::text_measurement::{estimate_text_width, resolved_to_text_style}; use super::utils::find_bin_data; +use super::{CellContext, CellPathEntry, LayoutEngine}; +use crate::model::bin_data::BinDataContent; +use crate::model::control::Control; +use crate::model::paragraph::Paragraph; +use crate::model::style::{Alignment, BorderLine}; +use crate::model::table::VerticalAlign; // 표 수평 정렬: model::shape 타입 사용 -use crate::model::shape::{HorzRelTo, HorzAlign}; +use crate::model::shape::{HorzAlign, HorzRelTo}; /// 중첩 표 부분 렌더링을 위한 행 범위 정보 pub(crate) struct NestedTableSplit { @@ -39,14 +42,19 @@ pub(crate) fn calc_nested_split_rows( ) -> NestedTableSplit { let row_count = row_heights.len(); if row_count == 0 { - return NestedTableSplit { start_row: 0, end_row: 0, visible_height: 0.0, offset_within_start: 0.0 }; + return NestedTableSplit { + start_row: 0, + end_row: 0, + visible_height: 0.0, + offset_within_start: 0.0, + }; } // row_y 누적 배열 (layout_table과 동일 방식) let mut row_y = vec![0.0f64; row_count + 1]; for i in 0..row_count { - row_y[i + 1] = row_y[i] + row_heights[i] - + if i + 1 < row_count { cell_spacing } else { 0.0 }; + row_y[i + 1] = + row_y[i] + row_heights[i] + if i + 1 < row_count { cell_spacing } else { 0.0 }; } // offset에 해당하는 시작 행 찾기 @@ -100,10 +108,14 @@ pub(crate) fn calc_nested_split_rows( space.min(range_height) }; - NestedTableSplit { start_row, end_row, visible_height, offset_within_start: 0.0 } + NestedTableSplit { + start_row, + end_row, + visible_height, + offset_within_start: 0.0, + } } - impl LayoutEngine { #[allow(clippy::too_many_arguments)] pub(crate) fn layout_table( @@ -128,24 +140,52 @@ impl LayoutEngine { para_y: Option, ) -> f64 { if table.cells.is_empty() { - if depth == 0 { return y_start; } else { return 0.0; } + if depth == 0 { + return y_start; + } else { + return 0.0; + } } // 1x1 래퍼 표 감지: 외곽 표를 무시하고 내부 표를 직접 렌더링 if table.row_count == 1 && table.col_count == 1 && table.cells.len() == 1 { let cell = &table.cells[0]; - let has_visible_text = cell.paragraphs.iter() - .any(|p| p.text.chars().any(|ch| !ch.is_whitespace() && ch != '\r' && ch != '\n')); + let has_visible_text = cell.paragraphs.iter().any(|p| { + p.text + .chars() + .any(|ch| !ch.is_whitespace() && ch != '\r' && ch != '\n') + }); if !has_visible_text { - if let Some(nested) = cell.paragraphs.iter() + if let Some(nested) = cell + .paragraphs + .iter() .flat_map(|p| p.controls.iter()) - .find_map(|c| if let Control::Table(t) = c { Some(t.as_ref()) } else { None }) + .find_map(|c| { + if let Control::Table(t) = c { + Some(t.as_ref()) + } else { + None + } + }) { return self.layout_table( - tree, col_node, nested, - section_index, styles, col_area, y_start, - bin_data_content, None, depth, - table_meta, host_alignment, enclosing_cell_ctx, host_margin_left, - host_margin_right, inline_x_override, nested_split, para_y, + tree, + col_node, + nested, + section_index, + styles, + col_area, + y_start, + bin_data_content, + None, + depth, + table_meta, + host_alignment, + enclosing_cell_ctx, + host_margin_left, + host_margin_right, + inline_x_override, + nested_split, + para_y, ); } } @@ -157,16 +197,19 @@ impl LayoutEngine { // ── 1. 열 폭 + 행 높이 계산 ── let col_widths = self.resolve_column_widths(table, col_count); - let row_heights = self.resolve_row_heights(table, col_count, row_count, measured_table, styles); + let row_heights = + self.resolve_row_heights(table, col_count, row_count, measured_table, styles); // ── 2. 누적 위치 계산 ── let mut col_x = vec![0.0f64; col_count + 1]; for i in 0..col_count { - col_x[i + 1] = col_x[i] + col_widths[i] + if i + 1 < col_count { cell_spacing } else { 0.0 }; + col_x[i + 1] = + col_x[i] + col_widths[i] + if i + 1 < col_count { cell_spacing } else { 0.0 }; } let mut row_y = vec![0.0f64; row_count + 1]; for i in 0..row_count { - row_y[i + 1] = row_y[i] + row_heights[i] + if i + 1 < row_count { cell_spacing } else { 0.0 }; + row_y[i + 1] = + row_y[i] + row_heights[i] + if i + 1 < row_count { cell_spacing } else { 0.0 }; } // 중첩 표 부분 렌더링: row_y를 시프트하여 보이는 행만 표시 @@ -193,9 +236,17 @@ impl LayoutEngine { (0.0, None, 0.0) }; - let row_col_x = build_row_col_x(table, &col_widths, col_count, row_count, cell_spacing, self.dpi); + let row_col_x = build_row_col_x( + table, + &col_widths, + col_count, + row_count, + cell_spacing, + self.dpi, + ); - let table_width = row_col_x.iter() + let table_width = row_col_x + .iter() .map(|rx| rx.last().copied().unwrap_or(0.0)) .fold(col_x.last().copied().unwrap_or(0.0), f64::max); let table_height = if let Some((_, er)) = split_row_range { @@ -208,12 +259,22 @@ impl LayoutEngine { let pw = self.current_paper_width.get(); let paper_w = if pw > 0.0 { Some(pw) } else { None }; let mut table_x = self.compute_table_x_position( - table, table_width, col_area, depth, host_alignment, host_margin_left, host_margin_right, inline_x_override, paper_w, + table, + table_width, + col_area, + depth, + host_alignment, + host_margin_left, + host_margin_right, + inline_x_override, + paper_w, ); let (caption_height, caption_spacing) = if depth == 0 { let ch = self.calculate_caption_height(&table.caption, styles); - let cs = table.caption.as_ref() + let cs = table + .caption + .as_ref() .map(|c| hwpunit_to_px(c.spacing as i32, self.dpi)) .unwrap_or(0.0); (ch, cs) @@ -231,13 +292,23 @@ impl LayoutEngine { } } - let table_text_wrap = if depth == 0 { table.common.text_wrap } else { crate::model::shape::TextWrap::Square }; + let table_text_wrap = if depth == 0 { + table.common.text_wrap + } else { + crate::model::shape::TextWrap::Square + }; // inline_x_override가 있으면 외부에서 이미 위치를 계산했으므로 y_start 그대로 사용 let table_y = if inline_x_override.is_some() { y_start } else { self.compute_table_y_position( - table, table_height, y_start, col_area, depth, caption_height, caption_spacing, + table, + table_height, + y_start, + col_area, + depth, + caption_height, + caption_spacing, para_y, ) - split_y_offset }; @@ -262,15 +333,22 @@ impl LayoutEngine { let tbl_idx = (table.border_fill_id as usize).saturating_sub(1); if let Some(tbl_bs) = styles.border_styles.get(tbl_idx) { self.render_cell_background( - tree, &mut table_node, Some(tbl_bs), - table_x, table_y, table_width, table_height, + tree, + &mut table_node, + Some(tbl_bs), + table_x, + table_y, + table_width, + table_height, ); } } // ── 4-2. cellzone 배경 렌더링 (zone 전체 영역에 한 번) ── for zone in &table.zones { - if zone.border_fill_id == 0 { continue; } + if zone.border_fill_id == 0 { + continue; + } let zone_idx = (zone.border_fill_id as usize).saturating_sub(1); if let Some(zone_bs) = styles.border_styles.get(zone_idx) { // zone 영역의 좌표 계산 @@ -279,35 +357,74 @@ impl LayoutEngine { let sr = zone.start_row as usize; let er = (zone.end_row as usize + 1).min(row_count); if sc < col_count && sr < row_count { - let zone_x = table_x + row_col_x.get(sr).and_then(|r| r.get(sc)).copied().unwrap_or(0.0); + let zone_x = table_x + + row_col_x + .get(sr) + .and_then(|r| r.get(sc)) + .copied() + .unwrap_or(0.0); let zone_y = table_y + row_y.get(sr).copied().unwrap_or(0.0); - let zone_x_end = table_x + row_col_x.get(sr).and_then(|r| { - if ec < r.len() { Some(r[ec]) } else { r.last().map(|&last_x| { - // 마지막 열 끝 = 마지막 열 시작 + 해당 셀 너비 - let last_col = r.len() - 1; - table.cells.iter() - .find(|c| c.row as usize == sr && c.col as usize == last_col) - .map(|c| last_x + hwpunit_to_px(c.width as i32, self.dpi)) - .unwrap_or(last_x) - })} - }).unwrap_or(0.0); - let zone_y_end = table_y + row_y.get(er).copied().unwrap_or_else(|| { - // 마지막 행 끝 = 마지막 행 시작 + 해당 행 높이 - row_y.get(er - 1).copied().unwrap_or(0.0) + table.row_sizes.get(er - 1).map(|&h| hwpunit_to_px(h as i32, self.dpi)).unwrap_or(0.0) - }); + let zone_x_end = table_x + + row_col_x + .get(sr) + .and_then(|r| { + if ec < r.len() { + Some(r[ec]) + } else { + r.last().map(|&last_x| { + // 마지막 열 끝 = 마지막 열 시작 + 해당 셀 너비 + let last_col = r.len() - 1; + table + .cells + .iter() + .find(|c| { + c.row as usize == sr && c.col as usize == last_col + }) + .map(|c| { + last_x + hwpunit_to_px(c.width as i32, self.dpi) + }) + .unwrap_or(last_x) + }) + } + }) + .unwrap_or(0.0); + let zone_y_end = table_y + + row_y.get(er).copied().unwrap_or_else(|| { + // 마지막 행 끝 = 마지막 행 시작 + 해당 행 높이 + row_y.get(er - 1).copied().unwrap_or(0.0) + + table + .row_sizes + .get(er - 1) + .map(|&h| hwpunit_to_px(h as i32, self.dpi)) + .unwrap_or(0.0) + }); let zone_w = (zone_x_end - zone_x).max(0.0); let zone_h = (zone_y_end - zone_y).max(0.0); // 단색/패턴/그라데이션 배경 - self.render_cell_background(tree, &mut table_node, Some(zone_bs), zone_x, zone_y, zone_w, zone_h); + self.render_cell_background( + tree, + &mut table_node, + Some(zone_bs), + zone_x, + zone_y, + zone_w, + zone_h, + ); // 이미지 채우기 if let Some(ref img_fill) = zone_bs.image_fill { - if let Some(img_content) = crate::renderer::layout::find_bin_data(bin_data_content, img_fill.bin_data_id) { + if let Some(img_content) = crate::renderer::layout::find_bin_data( + bin_data_content, + img_fill.bin_data_id, + ) { let img_id = tree.next_id(); let img_node = RenderNode::new( img_id, RenderNodeType::Image(ImageNode { fill_mode: Some(img_fill.fill_mode), - ..ImageNode::new(img_fill.bin_data_id, Some(img_content.data.clone())) + ..ImageNode::new( + img_fill.bin_data_id, + Some(img_content.data.clone()), + ) }), BoundingBox::new(zone_x, zone_y, zone_w, zone_h), ); @@ -323,13 +440,26 @@ impl LayoutEngine { let mut v_edges: Vec>> = vec![vec![None; row_count]; col_count + 1]; self.layout_table_cells( - tree, &mut table_node, table, - section_index, styles, col_area, bin_data_content, - depth, table_meta, enclosing_cell_ctx, - &row_col_x, &row_y, col_count, row_count, - table_x, table_y, - &mut h_edges, &mut v_edges, - split_row_range, row_y_shift, + tree, + &mut table_node, + table, + section_index, + styles, + col_area, + bin_data_content, + depth, + table_meta, + enclosing_cell_ctx, + &row_col_x, + &row_y, + col_count, + row_count, + table_x, + table_y, + &mut h_edges, + &mut v_edges, + split_row_range, + row_y_shift, ); // ── 6. 테두리 렌더링 ── @@ -350,7 +480,11 @@ impl LayoutEngine { use crate::model::shape::{CaptionDirection, CaptionVertAlign}; let (cap_x, cap_w, cap_y) = match caption.direction { CaptionDirection::Top => (table_x, table_width, y_start), - CaptionDirection::Bottom => (table_x, table_width, table_y + table_height + caption_spacing), + CaptionDirection::Bottom => ( + table_x, + table_width, + table_y + table_height + caption_spacing, + ), CaptionDirection::Left | CaptionDirection::Right => { let cw = hwpunit_to_px(caption.width as i32, self.dpi); let cx = if caption.direction == CaptionDirection::Left { @@ -360,8 +494,12 @@ impl LayoutEngine { }; let cy = match caption.vert_align { CaptionVertAlign::Top => table_y, - CaptionVertAlign::Center => table_y + (table_height - caption_height).max(0.0) / 2.0, - CaptionVertAlign::Bottom => table_y + (table_height - caption_height).max(0.0), + CaptionVertAlign::Center => { + table_y + (table_height - caption_height).max(0.0) / 2.0 + } + CaptionVertAlign::Bottom => { + table_y + (table_height - caption_height).max(0.0) + } }; (cx, cw, cy) } @@ -376,8 +514,14 @@ impl LayoutEngine { }], }); self.layout_caption( - tree, col_node, caption, styles, col_area, - cap_x, cap_w, cap_y, + tree, + col_node, + caption, + styles, + col_area, + cap_x, + cap_w, + cap_y, &mut self.auto_counter.borrow_mut(), cap_cell_ctx, ); @@ -389,17 +533,31 @@ impl LayoutEngine { // Left/Right 캡션은 표 높이에 영향 없음 let is_lr_cap = table.caption.as_ref().map_or(false, |c| { use crate::model::shape::CaptionDirection; - matches!(c.direction, CaptionDirection::Left | CaptionDirection::Right) + matches!( + c.direction, + CaptionDirection::Left | CaptionDirection::Right + ) }); let caption_extra = if is_lr_cap { 0.0 } else { - caption_height + if caption_height > 0.0 { caption_spacing } else { 0.0 } + caption_height + + if caption_height > 0.0 { + caption_spacing + } else { + 0.0 + } }; - if matches!(table_text_wrap, crate::model::shape::TextWrap::BehindText | crate::model::shape::TextWrap::InFrontOfText) { + if matches!( + table_text_wrap, + crate::model::shape::TextWrap::BehindText + | crate::model::shape::TextWrap::InFrontOfText + ) { // 글뒤로/글앞으로: y_offset 변경 없음 y_start - } else if matches!(table_text_wrap, crate::model::shape::TextWrap::TopAndBottom) && !table.common.treat_as_char { + } else if matches!(table_text_wrap, crate::model::shape::TextWrap::TopAndBottom) + && !table.common.treat_as_char + { // 자리차지: 표 아래쪽까지 y_offset 진행 (절대 위치 기준) let table_bottom = table_y + table_height + caption_extra; table_bottom.max(y_start) @@ -440,8 +598,11 @@ impl LayoutEngine { let span = cell.col_span as usize; if span > 1 && c + span <= col_count { let total_w = hwpunit_to_px(cell.width as i32, self.dpi); - if let Some(existing) = constraints.iter_mut().find(|x| x.0 == c && x.1 == span) { - if total_w > existing.2 { existing.2 = total_w; } + if let Some(existing) = constraints.iter_mut().find(|x| x.0 == c && x.1 == span) + { + if total_w > existing.2 { + existing.2 = total_w; + } } else { constraints.push((c, span, total_w)); } @@ -454,23 +615,23 @@ impl LayoutEngine { let mut progress = false; for &(c, span, total_w) in &constraints { let known_sum: f64 = (c..c + span).map(|i| col_widths[i]).sum(); - let unknown_cols: Vec = (c..c + span) - .filter(|&i| col_widths[i] == 0.0) - .collect(); + let unknown_cols: Vec = + (c..c + span).filter(|&i| col_widths[i] == 0.0).collect(); if unknown_cols.len() == 1 { let remaining = (total_w - known_sum).max(0.0); col_widths[unknown_cols[0]] = remaining; progress = true; } } - if !progress { break; } + if !progress { + break; + } } for &(c, span, total_w) in &constraints { let known_sum: f64 = (c..c + span).map(|i| col_widths[i]).sum(); - let unknown_cols: Vec = (c..c + span) - .filter(|&i| col_widths[i] == 0.0) - .collect(); + let unknown_cols: Vec = + (c..c + span).filter(|&i| col_widths[i] == 0.0).collect(); if !unknown_cols.is_empty() { let remaining = (total_w - known_sum).max(0.0); let per_col = remaining / unknown_cols.len() as f64; @@ -548,8 +709,11 @@ impl LayoutEngine { let span = cell.row_span as usize; if span > 1 && r + span <= row_count && cell.height < 0x80000000 { let total_h = hwpunit_to_px(cell.height as i32, self.dpi); - if let Some(existing) = constraints.iter_mut().find(|x| x.0 == r && x.1 == span) { - if total_h > existing.2 { existing.2 = total_h; } + if let Some(existing) = constraints.iter_mut().find(|x| x.0 == r && x.1 == span) + { + if total_h > existing.2 { + existing.2 = total_h; + } } else { constraints.push((r, span, total_h)); } @@ -561,22 +725,22 @@ impl LayoutEngine { let mut progress = false; for &(r, span, total_h) in &constraints { let known_sum: f64 = (r..r + span).map(|i| row_heights[i]).sum(); - let unknown_rows: Vec = (r..r + span) - .filter(|&i| row_heights[i] == 0.0) - .collect(); + let unknown_rows: Vec = + (r..r + span).filter(|&i| row_heights[i] == 0.0).collect(); if unknown_rows.len() == 1 { let remaining = (total_h - known_sum).max(0.0); row_heights[unknown_rows[0]] = remaining; progress = true; } } - if !progress { break; } + if !progress { + break; + } } for &(r, span, total_h) in &constraints { let known_sum: f64 = (r..r + span).map(|i| row_heights[i]).sum(); - let unknown_rows: Vec = (r..r + span) - .filter(|&i| row_heights[i] == 0.0) - .collect(); + let unknown_rows: Vec = + (r..r + span).filter(|&i| row_heights[i] == 0.0).collect(); if !unknown_rows.is_empty() { let remaining = (total_h - known_sum).max(0.0); let per_row = remaining / unknown_rows.len() as f64; @@ -593,7 +757,8 @@ impl LayoutEngine { let span = cell.row_span as usize; if span > 1 && r + span <= row_count { let (_, _, pad_top, pad_bottom) = self.resolve_cell_padding(cell, table); - let content_height = self.calc_cell_paragraphs_content_height(&cell.paragraphs, styles); + let content_height = + self.calc_cell_paragraphs_content_height(&cell.paragraphs, styles); // LINE_SEG의 line_height에 이미 셀 내 중첩 표 높이가 반영되어 있으므로 // controls_height를 별도로 더하면 이중 계산됨 let required_height = content_height + pad_top + pad_bottom; @@ -621,12 +786,17 @@ impl LayoutEngine { styles: &ResolvedStyleSet, ) -> f64 { let cell_para_count = paragraphs.len(); - paragraphs.iter() + paragraphs + .iter() .enumerate() .map(|(pidx, p)| { let comp = compose_paragraph(p); - self.calc_para_lines_height(&comp.lines, pidx, cell_para_count, - styles.para_styles.get(p.para_shape_id as usize)) + self.calc_para_lines_height( + &comp.lines, + pidx, + cell_para_count, + styles.para_styles.get(p.para_shape_id as usize), + ) }) .sum() } @@ -639,12 +809,17 @@ impl LayoutEngine { styles: &ResolvedStyleSet, ) -> f64 { let cell_para_count = paragraphs.len(); - composed_paras.iter() + composed_paras + .iter() .zip(paragraphs.iter()) .enumerate() .map(|(pidx, (comp, para))| { - self.calc_para_lines_height(&comp.lines, pidx, cell_para_count, - styles.para_styles.get(para.para_shape_id as usize)) + self.calc_para_lines_height( + &comp.lines, + pidx, + cell_para_count, + styles.para_styles.get(para.para_shape_id as usize), + ) }) .sum() } @@ -672,7 +847,8 @@ impl LayoutEngine { spacing_before + hwpunit_to_px(400, self.dpi) + spacing_after } else { let line_count = lines.len(); - let lines_total: f64 = lines.iter() + let lines_total: f64 = lines + .iter() .enumerate() .map(|(i, line)| { let h = hwpunit_to_px(line.line_height, self.dpi); @@ -691,10 +867,7 @@ impl LayoutEngine { /// 세로쓰기 셀의 콘텐츠 높이 계산 /// 세로쓰기에서 line_seg.segment_width = 열의 세로 길이 (HWPUNIT) /// 셀 높이 = 최대 segment_width - fn calc_vertical_cell_content_height( - &self, - paragraphs: &[Paragraph], - ) -> f64 { + fn calc_vertical_cell_content_height(&self, paragraphs: &[Paragraph]) -> f64 { let mut max_seg_height: f64 = 0.0; for para in paragraphs { for ls in ¶.line_segs { @@ -749,7 +922,10 @@ impl LayoutEngine { tree: &mut PageRenderTree, cell_node: &mut RenderNode, border_style: Option<&crate::renderer::style_resolver::ResolvedBorderStyle>, - cell_x: f64, cell_y: f64, cell_w: f64, cell_h: f64, + cell_x: f64, + cell_y: f64, + cell_w: f64, + cell_h: f64, ) { let fill_color = border_style.and_then(|bs| bs.fill_color); let pattern = border_style.and_then(|bs| bs.pattern); @@ -803,7 +979,9 @@ impl LayoutEngine { let ref_x = col_area.x + host_margin_left; let ref_w = col_area.width - host_margin_left - host_margin_right; match host_alignment { - Alignment::Center | Alignment::Distribute => ref_x + (ref_w - table_width).max(0.0) / 2.0, + Alignment::Center | Alignment::Distribute => { + ref_x + (ref_w - table_width).max(0.0) / 2.0 + } Alignment::Right => ref_x + (ref_w - table_width).max(0.0), _ => ref_x, } @@ -825,13 +1003,18 @@ impl LayoutEngine { (0.0, paper_w) } HorzRelTo::Page => (col_area.x, col_area.width), - HorzRelTo::Para => (col_area.x + host_margin_left, col_area.width - host_margin_left), + HorzRelTo::Para => ( + col_area.x + host_margin_left, + col_area.width - host_margin_left, + ), _ => (col_area.x, col_area.width), }; match horz_align { HorzAlign::Left | HorzAlign::Inside => ref_x + h_offset, HorzAlign::Center => ref_x + (ref_w - table_width).max(0.0) / 2.0 + h_offset, - HorzAlign::Right | HorzAlign::Outside => ref_x + (ref_w - table_width).max(0.0) + h_offset, + HorzAlign::Right | HorzAlign::Outside => { + ref_x + (ref_w - table_width).max(0.0) + h_offset + } } } else { // 중첩 표: outer_margin_left 적용 + host_alignment에 따라 셀 내에서 정렬 @@ -839,7 +1022,9 @@ impl LayoutEngine { let area_x = col_area.x + om_left; let area_w = (col_area.width - om_left).max(0.0); match host_alignment { - Alignment::Center | Alignment::Distribute => area_x + (area_w - table_width).max(0.0) / 2.0, + Alignment::Center | Alignment::Distribute => { + area_x + (area_w - table_width).max(0.0) / 2.0 + } Alignment::Right => area_x + (area_w - table_width).max(0.0), _ => area_x, } @@ -859,29 +1044,47 @@ impl LayoutEngine { para_y: Option, ) -> f64 { let table_treat_as_char = table.common.treat_as_char; - let table_text_wrap = if depth == 0 { table.common.text_wrap } else { crate::model::shape::TextWrap::Square }; + let table_text_wrap = if depth == 0 { + table.common.text_wrap + } else { + crate::model::shape::TextWrap::Square + }; - if depth == 0 && !table_treat_as_char && matches!(table_text_wrap, crate::model::shape::TextWrap::TopAndBottom | crate::model::shape::TextWrap::BehindText | crate::model::shape::TextWrap::InFrontOfText) { + if depth == 0 + && !table_treat_as_char + && matches!( + table_text_wrap, + crate::model::shape::TextWrap::TopAndBottom + | crate::model::shape::TextWrap::BehindText + | crate::model::shape::TextWrap::InFrontOfText + ) + { // 자리차지(1) / 글뒤로(2) / 글앞으로(3): v_offset 기반 절대 위치 - - + let v_offset = hwpunit_to_px(table.common.vertical_offset as i32, self.dpi); // 문단 기준일 때 para_y 사용 (같은 문단의 여러 표가 동일 기준점 공유) let anchor_y = para_y.unwrap_or(y_start); // bit 13: VertRelTo가 'para'일 때 본문 영역으로 제한 - + let page_h_approx = col_area.y * 2.0 + col_area.height; let vert_rel_to = table.common.vert_rel_to; let (ref_y, ref_h) = match vert_rel_to { crate::model::shape::VertRelTo::Page => (0.0, page_h_approx), - crate::model::shape::VertRelTo::Para => (anchor_y, col_area.height - (anchor_y - col_area.y).max(0.0)), // Para + crate::model::shape::VertRelTo::Para => { + (anchor_y, col_area.height - (anchor_y - col_area.y).max(0.0)) + } // Para crate::model::shape::VertRelTo::Paper => (0.0, page_h_approx), }; // Top 캡션: 표 위치를 캡션 높이만큼 아래로 이동 let caption_top_offset = if let Some(ref cap) = table.caption { use crate::model::shape::CaptionDirection; if matches!(cap.direction, CaptionDirection::Top) { - caption_height + if caption_height > 0.0 { caption_spacing } else { 0.0 } + caption_height + + if caption_height > 0.0 { + caption_spacing + } else { + 0.0 + } } else { 0.0 } @@ -890,23 +1093,34 @@ impl LayoutEngine { }; let vert_align = table.common.vert_align; let raw_y = match vert_align { - crate::model::shape::VertAlign::Top | crate::model::shape::VertAlign::Inside => ref_y + v_offset + caption_top_offset, - crate::model::shape::VertAlign::Center => ref_y + (ref_h - table_height) / 2.0 + v_offset + caption_top_offset, - crate::model::shape::VertAlign::Bottom | crate::model::shape::VertAlign::Outside => ref_y + ref_h - table_height - v_offset + caption_top_offset, + crate::model::shape::VertAlign::Top | crate::model::shape::VertAlign::Inside => { + ref_y + v_offset + caption_top_offset + } + crate::model::shape::VertAlign::Center => { + ref_y + (ref_h - table_height) / 2.0 + v_offset + caption_top_offset + } + crate::model::shape::VertAlign::Bottom + | crate::model::shape::VertAlign::Outside => { + ref_y + ref_h - table_height - v_offset + caption_top_offset + } }; // Para 기준 + bit 13: 본문 영역으로 제한 // 앞선 표/텍스트가 차지한 영역(y_start) 아래로 밀어내고, 본문 영역 내로 클램핑 if matches!(vert_rel_to, crate::model::shape::VertRelTo::Para) { let body_top = col_area.y; let body_bottom = col_area.y + col_area.height - table_height; - raw_y.max(y_start).clamp(body_top, body_bottom.max(body_top)) + raw_y + .max(y_start) + .clamp(body_top, body_bottom.max(body_top)) } else { raw_y } } else if depth == 0 { let v_offset = if table_treat_as_char { hwpunit_to_px(table.common.vertical_offset as i32, self.dpi) - } else { 0.0 }; + } else { + 0.0 + }; if let Some(ref caption) = table.caption { use crate::model::shape::CaptionDirection; if matches!(caption.direction, CaptionDirection::Top) { @@ -968,7 +1182,11 @@ impl LayoutEngine { // row_y는 이미 시프트된 상태이므로 음수일 수 있음 (start_row 이전 행) // 행 스패닝 셀의 경우 table_y 이상으로 클램프 let raw_cell_y = table_y + row_y[r]; - let cell_y = if row_filter.is_some() { raw_cell_y.max(table_y) } else { raw_cell_y }; + let cell_y = if row_filter.is_some() { + raw_cell_y.max(table_y) + } else { + raw_cell_y + }; let end_col = (c + cell.col_span as usize).min(col_count); let end_row = (r + cell.row_span as usize).min(row_count); let cell_w = row_col_x[r][end_col] - row_col_x[r][c]; @@ -1005,7 +1223,15 @@ impl LayoutEngine { }; // (a) 셀 배경 - self.render_cell_background(tree, &mut cell_node, border_style, cell_x, cell_y, cell_w, cell_h); + self.render_cell_background( + tree, + &mut cell_node, + border_style, + cell_x, + cell_y, + cell_w, + cell_h, + ); // 셀 패딩 (cell.padding이 0이면 table.padding fallback) let (pad_left, pad_right, pad_top, pad_bottom) = self.resolve_cell_padding(cell, table); @@ -1014,7 +1240,9 @@ impl LayoutEngine { let inner_width = (cell_w - pad_left - pad_right).max(0.0); let inner_height = (cell_h - pad_top - pad_bottom).max(0.0); - let mut composed_paras: Vec<_> = cell.paragraphs.iter() + let mut composed_paras: Vec<_> = cell + .paragraphs + .iter() .map(|p| compose_paragraph(p)) .collect(); @@ -1022,9 +1250,10 @@ impl LayoutEngine { let current_pn = self.current_page_number.get(); if current_pn > 0 { for (cpi, para) in cell.paragraphs.iter().enumerate() { - let has_page_auto = para.controls.iter().any(|c| + let has_page_auto = para.controls.iter().any(|c| { matches!(c, Control::AutoNumber(an) - if an.number_type == crate::model::control::AutoNumberType::Page)); + if an.number_type == crate::model::control::AutoNumberType::Page) + }); if has_page_auto { let page_str = current_pn.to_string(); if let Some(comp) = composed_paras.get_mut(cpi) { @@ -1049,7 +1278,9 @@ impl LayoutEngine { // (A) composed 기반: LINE_SEG line_height 합산 + 비인라인 도형/그림 let total_content_height: f64 = { let mut text_height: f64 = self.calc_composed_paras_content_height( - &composed_paras, &cell.paragraphs, styles, + &composed_paras, + &cell.paragraphs, + styles, ); for para in &cell.paragraphs { for ctrl in ¶.controls { @@ -1147,609 +1378,826 @@ impl LayoutEngine { height: inner_height, }; self.layout_vertical_cell_text( - tree, &mut cell_node, &composed_paras, &cell.paragraphs, - styles, &vert_inner_area, cell.vertical_align, cell.text_direction, - section_index, table_meta, cell_idx, enclosing_cell_ctx.clone(), + tree, + &mut cell_node, + &composed_paras, + &cell.paragraphs, + styles, + &vert_inner_area, + cell.vertical_align, + cell.text_direction, + section_index, + table_meta, + cell_idx, + enclosing_cell_ctx.clone(), ); } else { - - let inner_area = LayoutRect { - x: inner_x, - y: text_y_start, - width: inner_width, - height: inner_height, - }; - - // 셀 내 문단 + 컨트롤 통합 레이아웃 - let mut para_y = text_y_start; - let mut has_preceding_text = false; - for (cp_idx, (composed, para)) in composed_paras.iter().zip(cell.paragraphs.iter()).enumerate() { - let cell_context = if let Some(ref ctx) = enclosing_cell_ctx { - let mut new_ctx = ctx.clone(); - if let Some(last) = new_ctx.path.last_mut() { - last.cell_index = cell_idx; - last.cell_para_index = cp_idx; - last.text_direction = cell.text_direction; - } - Some(new_ctx) - } else { - table_meta.map(|(pi, ci)| CellContext { - parent_para_index: pi, - path: vec![CellPathEntry { - control_index: ci, - cell_index: cell_idx, - cell_para_index: cp_idx, - text_direction: cell.text_direction, - }], - }) + let inner_area = LayoutRect { + x: inner_x, + y: text_y_start, + width: inner_width, + height: inner_height, }; - let has_table_ctrl = para.controls.iter().any(|c| matches!(c, Control::Table(_))); + // 셀 내 문단 + 컨트롤 통합 레이아웃 + let mut para_y = text_y_start; + let mut has_preceding_text = false; + for (cp_idx, (composed, para)) in composed_paras + .iter() + .zip(cell.paragraphs.iter()) + .enumerate() + { + let cell_context = if let Some(ref ctx) = enclosing_cell_ctx { + let mut new_ctx = ctx.clone(); + if let Some(last) = new_ctx.path.last_mut() { + last.cell_index = cell_idx; + last.cell_para_index = cp_idx; + last.text_direction = cell.text_direction; + } + Some(new_ctx) + } else { + table_meta.map(|(pi, ci)| CellContext { + parent_para_index: pi, + path: vec![CellPathEntry { + control_index: ci, + cell_index: cell_idx, + cell_para_index: cp_idx, + text_direction: cell.text_direction, + }], + }) + }; - let para_y_before_compose = para_y; + let has_table_ctrl = + para.controls.iter().any(|c| matches!(c, Control::Table(_))); - // 줄별 TAC 컨트롤 너비 합산: 각 TAC가 속한 줄을 판별하여 줄별 최대 너비 계산 - let tac_line_widths: Vec = { - // 줄별 너비 합산 벡터 - let mut line_widths = vec![0.0f64; composed.lines.len().max(1)]; - for ctrl in ¶.controls { - let (is_tac, w) = match ctrl { - Control::Picture(pic) if pic.common.treat_as_char => { - (true, hwpunit_to_px(pic.common.width as i32, self.dpi)) - } - Control::Shape(shape) if shape.common().treat_as_char => { - (true, hwpunit_to_px(shape.common().width as i32, self.dpi)) - } - Control::Equation(eq) => { - (true, hwpunit_to_px(eq.common.width as i32, self.dpi)) - } - Control::Table(t) if t.common.treat_as_char => { - (true, hwpunit_to_px(t.common.width as i32, self.dpi)) - } - _ => (false, 0.0), - }; - if !is_tac { continue; } - // 줄이 1개이면 무조건 0번 줄 - if composed.lines.len() <= 1 { - line_widths[0] += w; - } else { - // 아직 줄 분배 전이므로 순서대로 채워넣기: - // 현재 줄 너비 + 이 컨트롤 너비 > 셀 너비이면 다음 줄로 - let mut placed = false; - for lw in line_widths.iter_mut() { - if *lw == 0.0 || *lw + w <= inner_width + 0.5 { - *lw += w; - placed = true; - break; + let para_y_before_compose = para_y; + + // 줄별 TAC 컨트롤 너비 합산: 각 TAC가 속한 줄을 판별하여 줄별 최대 너비 계산 + let tac_line_widths: Vec = { + // 줄별 너비 합산 벡터 + let mut line_widths = vec![0.0f64; composed.lines.len().max(1)]; + for ctrl in ¶.controls { + let (is_tac, w) = match ctrl { + Control::Picture(pic) if pic.common.treat_as_char => { + (true, hwpunit_to_px(pic.common.width as i32, self.dpi)) + } + Control::Shape(shape) if shape.common().treat_as_char => { + (true, hwpunit_to_px(shape.common().width as i32, self.dpi)) + } + Control::Equation(eq) => { + (true, hwpunit_to_px(eq.common.width as i32, self.dpi)) } + Control::Table(t) if t.common.treat_as_char => { + (true, hwpunit_to_px(t.common.width as i32, self.dpi)) + } + _ => (false, 0.0), + }; + if !is_tac { + continue; } - if !placed { - if let Some(last) = line_widths.last_mut() { - *last += w; + // 줄이 1개이면 무조건 0번 줄 + if composed.lines.len() <= 1 { + line_widths[0] += w; + } else { + // 아직 줄 분배 전이므로 순서대로 채워넣기: + // 현재 줄 너비 + 이 컨트롤 너비 > 셀 너비이면 다음 줄로 + let mut placed = false; + for lw in line_widths.iter_mut() { + if *lw == 0.0 || *lw + w <= inner_width + 0.5 { + *lw += w; + placed = true; + break; + } + } + if !placed { + if let Some(last) = line_widths.last_mut() { + *last += w; + } } } } - } - line_widths - }; - let total_inline_width: f64 = tac_line_widths.iter().cloned().fold(0.0f64, f64::max); - - if !has_table_ctrl { - let is_last_para = cp_idx + 1 == composed_paras.len(); - // 분할 중첩 표: 셀 하단을 초과하는 줄은 렌더링하지 않음 - let end_line = if row_filter.is_some() { - let cell_bottom = cell_y + cell_h; - let mut sim_y = para_y; - let mut fit = composed.lines.len(); - for (li, line) in composed.lines.iter().enumerate() { - let lh = hwpunit_to_px(line.line_height, self.dpi); - if sim_y + lh > cell_bottom + 0.5 { - fit = li; - break; + line_widths + }; + let total_inline_width: f64 = + tac_line_widths.iter().cloned().fold(0.0f64, f64::max); + + if !has_table_ctrl { + let is_last_para = cp_idx + 1 == composed_paras.len(); + // 분할 중첩 표: 셀 하단을 초과하는 줄은 렌더링하지 않음 + let end_line = if row_filter.is_some() { + let cell_bottom = cell_y + cell_h; + let mut sim_y = para_y; + let mut fit = composed.lines.len(); + for (li, line) in composed.lines.iter().enumerate() { + let lh = hwpunit_to_px(line.line_height, self.dpi); + if sim_y + lh > cell_bottom + 0.5 { + fit = li; + break; + } + sim_y += lh + hwpunit_to_px(line.line_spacing, self.dpi); } - sim_y += lh + hwpunit_to_px(line.line_spacing, self.dpi); + fit + } else { + composed.lines.len() + }; + para_y = self.layout_composed_paragraph( + tree, + &mut cell_node, + composed, + styles, + &inner_area, + para_y, + 0, + end_line, + section_index, + cp_idx, + cell_context.clone(), + is_last_para, + 0.0, + None, + Some(para), + Some(bin_data_content), + ); + + let has_visible_text = composed + .lines + .iter() + .any(|line| line.runs.iter().any(|run| !run.text.trim().is_empty())); + if has_visible_text { + has_preceding_text = true; } - fit } else { - composed.lines.len() - }; - para_y = self.layout_composed_paragraph( - tree, - &mut cell_node, - composed, - styles, - &inner_area, - para_y, - 0, - end_line, - section_index, cp_idx, - cell_context.clone(), - is_last_para, - 0.0, - None, Some(para), Some(bin_data_content), - ); - - let has_visible_text = composed.lines.iter() - .any(|line| line.runs.iter().any(|run| !run.text.trim().is_empty())); - if has_visible_text { - has_preceding_text = true; + // has_table_ctrl: 표가 포함된 문단 + // LINE_SEG vpos가 문단 위치를 정확히 지정하므로, + // 추가 spacing 없이 para_y를 그대로 사용. + // (leading spacing은 LINE_SEG vpos에 이미 반영되어 있음) } - } else { - // has_table_ctrl: 표가 포함된 문단 - // LINE_SEG vpos가 문단 위치를 정확히 지정하므로, - // 추가 spacing 없이 para_y를 그대로 사용. - // (leading spacing은 LINE_SEG vpos에 이미 반영되어 있음) - } - let para_alignment = styles.para_styles - .get(para.para_shape_id as usize) - .map(|s| s.alignment) - .unwrap_or(Alignment::Left); - - let mut prev_tac_text_pos: usize = 0; - // LINE_SEG 기반 줄별 TAC 이미지 배치를 위한 상태 - // 빈 문단(runs 없음)에서 TAC 컨트롤을 LINE_SEG에 순서대로 매핑 - let all_runs_empty = composed.lines.iter().all(|l| l.runs.is_empty()); - let mut tac_seq_index: usize = 0; // TAC 컨트롤 순번 (빈 문단용) - let mut current_tac_line: usize = 0; - let mut inline_x = { - let line_w = tac_line_widths.first().copied().unwrap_or(total_inline_width); - match para_alignment { - Alignment::Center | Alignment::Distribute => { - inner_area.x + (inner_area.width - line_w).max(0.0) / 2.0 - } - Alignment::Right => { - inner_area.x + (inner_area.width - line_w).max(0.0) + let para_alignment = styles + .para_styles + .get(para.para_shape_id as usize) + .map(|s| s.alignment) + .unwrap_or(Alignment::Left); + + let mut prev_tac_text_pos: usize = 0; + // LINE_SEG 기반 줄별 TAC 이미지 배치를 위한 상태 + // 빈 문단(runs 없음)에서 TAC 컨트롤을 LINE_SEG에 순서대로 매핑 + let all_runs_empty = composed.lines.iter().all(|l| l.runs.is_empty()); + let mut tac_seq_index: usize = 0; // TAC 컨트롤 순번 (빈 문단용) + let mut current_tac_line: usize = 0; + let mut inline_x = { + let line_w = tac_line_widths + .first() + .copied() + .unwrap_or(total_inline_width); + match para_alignment { + Alignment::Center | Alignment::Distribute => { + inner_area.x + (inner_area.width - line_w).max(0.0) / 2.0 + } + Alignment::Right => inner_area.x + (inner_area.width - line_w).max(0.0), + _ => inner_area.x, } - _ => inner_area.x, - } - }; - let mut tac_img_y = para_y_before_compose; - - for (ctrl_idx, ctrl) in para.controls.iter().enumerate() { - match ctrl { - Control::Picture(pic) => { - if pic.common.treat_as_char { - let pic_w = hwpunit_to_px(pic.common.width as i32, self.dpi); - // layout_composed_paragraph에서 텍스트 흐름 안에 렌더링됐는지 확인: - // 이미지 위치가 실제 run 범위에 포함될 때만 스킵 - let will_render_inline = composed.tac_controls.iter().any(|&(abs_pos, _, ci)| { - ci == ctrl_idx && composed.lines.iter().any(|line| { - let line_chars: usize = line.runs.iter().map(|r| r.text.chars().count()).sum(); - abs_pos >= line.char_start && abs_pos < line.char_start + line_chars - }) - }); - if !will_render_inline { - // LINE_SEG 기반 줄 판별 - let target_line = if all_runs_empty && para.line_segs.len() > 1 { - // 빈 문단: TAC 순번으로 LINE_SEG에 1:1 매핑 - let li = tac_seq_index.min(para.line_segs.len() - 1); - tac_seq_index += 1; - li - } else { - // 텍스트 있는 문단: char position으로 줄 판별 - composed.tac_controls.iter() - .find(|&&(_, _, ci)| ci == ctrl_idx) - .map(|&(abs_pos, _, _)| { - composed.lines.iter().enumerate() - .rev() - .find(|(_, line)| abs_pos >= line.char_start) - .map(|(li, _)| li) - .unwrap_or(0) - }) - .unwrap_or(0) - }; + }; + let mut tac_img_y = para_y_before_compose; - if target_line > current_tac_line { - // 줄이 바뀜: inline_x 리셋, y를 LINE_SEG vpos 기준으로 이동 - current_tac_line = target_line; - let line_w = tac_line_widths.get(target_line).copied().unwrap_or(0.0); - inline_x = match para_alignment { - Alignment::Center | Alignment::Distribute => { - inner_area.x + (inner_area.width - line_w).max(0.0) / 2.0 - } - Alignment::Right => { - inner_area.x + (inner_area.width - line_w).max(0.0) - } - _ => inner_area.x, + for (ctrl_idx, ctrl) in para.controls.iter().enumerate() { + match ctrl { + Control::Picture(pic) => { + if pic.common.treat_as_char { + let pic_w = hwpunit_to_px(pic.common.width as i32, self.dpi); + // layout_composed_paragraph에서 텍스트 흐름 안에 렌더링됐는지 확인: + // 이미지 위치가 실제 run 범위에 포함될 때만 스킵 + let will_render_inline = + composed.tac_controls.iter().any(|&(abs_pos, _, ci)| { + ci == ctrl_idx + && composed.lines.iter().any(|line| { + let line_chars: usize = line + .runs + .iter() + .map(|r| r.text.chars().count()) + .sum(); + abs_pos >= line.char_start + && abs_pos < line.char_start + line_chars + }) + }); + if !will_render_inline { + // LINE_SEG 기반 줄 판별 + let target_line = if all_runs_empty + && para.line_segs.len() > 1 + { + // 빈 문단: TAC 순번으로 LINE_SEG에 1:1 매핑 + let li = tac_seq_index.min(para.line_segs.len() - 1); + tac_seq_index += 1; + li + } else { + // 텍스트 있는 문단: char position으로 줄 판별 + composed + .tac_controls + .iter() + .find(|&&(_, _, ci)| ci == ctrl_idx) + .map(|&(abs_pos, _, _)| { + composed + .lines + .iter() + .enumerate() + .rev() + .find(|(_, line)| { + abs_pos >= line.char_start + }) + .map(|(li, _)| li) + .unwrap_or(0) + }) + .unwrap_or(0) }; - if let Some(seg) = para.line_segs.get(target_line) { - tac_img_y = para_y_before_compose + hwpunit_to_px(seg.vertical_pos, self.dpi); + + if target_line > current_tac_line { + // 줄이 바뀜: inline_x 리셋, y를 LINE_SEG vpos 기준으로 이동 + current_tac_line = target_line; + let line_w = tac_line_widths + .get(target_line) + .copied() + .unwrap_or(0.0); + inline_x = match para_alignment { + Alignment::Center | Alignment::Distribute => { + inner_area.x + + (inner_area.width - line_w).max(0.0) / 2.0 + } + Alignment::Right => { + inner_area.x + + (inner_area.width - line_w).max(0.0) + } + _ => inner_area.x, + }; + if let Some(seg) = para.line_segs.get(target_line) { + tac_img_y = para_y_before_compose + + hwpunit_to_px(seg.vertical_pos, self.dpi); + } } - } + let pic_h = + hwpunit_to_px(pic.common.height as i32, self.dpi); + let pic_area = LayoutRect { + x: inline_x, + y: tac_img_y, + width: pic_w, + height: pic_h, + }; + self.layout_picture( + tree, + &mut cell_node, + pic, + &pic_area, + bin_data_content, + Alignment::Left, + Some(section_index), + None, + None, + ); + } + inline_x += pic_w; + } else { + // 비-인라인(자리차지/글뒤로/글앞으로) 이미지: + // 본문배치 속성(가로/세로 기준, 정렬, 오프셋) 적용 + let pic_w = hwpunit_to_px(pic.common.width as i32, self.dpi); let pic_h = hwpunit_to_px(pic.common.height as i32, self.dpi); + let cell_area = LayoutRect { + y: para_y, + height: (inner_area.height - (para_y - inner_area.y)) + .max(0.0), + ..inner_area + }; + let (pic_x, pic_y) = self.compute_object_position( + &pic.common, + pic_w, + pic_h, + &cell_area, + &inner_area, + &inner_area, + &inner_area, + para_y, + para_alignment, + ); let pic_area = LayoutRect { - x: inline_x, - y: tac_img_y, + x: pic_x, + y: pic_y, width: pic_w, height: pic_h, }; - self.layout_picture(tree, &mut cell_node, pic, &pic_area, bin_data_content, Alignment::Left, Some(section_index), None, None); + self.layout_picture( + tree, + &mut cell_node, + pic, + &pic_area, + bin_data_content, + Alignment::Left, + Some(section_index), + None, + None, + ); + para_y += pic_h; } - inline_x += pic_w; - } else { - // 비-인라인(자리차지/글뒤로/글앞으로) 이미지: - // 본문배치 속성(가로/세로 기준, 정렬, 오프셋) 적용 - let pic_w = hwpunit_to_px(pic.common.width as i32, self.dpi); - let pic_h = hwpunit_to_px(pic.common.height as i32, self.dpi); - let cell_area = LayoutRect { - y: para_y, - height: (inner_area.height - (para_y - inner_area.y)).max(0.0), - ..inner_area - }; - let (pic_x, pic_y) = self.compute_object_position( - &pic.common, pic_w, pic_h, - &cell_area, &inner_area, &inner_area, &inner_area, - para_y, para_alignment, - ); - let pic_area = LayoutRect { - x: pic_x, - y: pic_y, - width: pic_w, - height: pic_h, - }; - self.layout_picture(tree, &mut cell_node, pic, &pic_area, bin_data_content, Alignment::Left, Some(section_index), None, None); - para_y += pic_h; + has_preceding_text = true; } - has_preceding_text = true; - } - Control::Shape(shape) => { - if shape.common().treat_as_char { - let shape_w = hwpunit_to_px(shape.common().width as i32, self.dpi); - // Shape 앞의 텍스트 너비 계산: tac_controls에서 이 Shape의 text_pos와 - // 이전 Shape의 text_pos 차이에 해당하는 텍스트 너비를 inline_x에 반영 - if let Some(&(tac_pos, _, _)) = composed.tac_controls.iter().find(|&&(_, _, ci)| ci == ctrl_idx) { - // 이 Shape 앞에 아직 inline_x에 반영되지 않은 텍스트가 있는지 계산 - let text_before: String = composed.lines.first() - .map(|line| { - let mut chars_so_far = 0usize; - let mut result = String::new(); - for run in &line.runs { - for ch in run.text.chars() { - if chars_so_far >= prev_tac_text_pos && chars_so_far < tac_pos { - result.push(ch); + Control::Shape(shape) => { + if shape.common().treat_as_char { + let shape_w = + hwpunit_to_px(shape.common().width as i32, self.dpi); + // Shape 앞의 텍스트 너비 계산: tac_controls에서 이 Shape의 text_pos와 + // 이전 Shape의 text_pos 차이에 해당하는 텍스트 너비를 inline_x에 반영 + if let Some(&(tac_pos, _, _)) = composed + .tac_controls + .iter() + .find(|&&(_, _, ci)| ci == ctrl_idx) + { + // 이 Shape 앞에 아직 inline_x에 반영되지 않은 텍스트가 있는지 계산 + let text_before: String = composed + .lines + .first() + .map(|line| { + let mut chars_so_far = 0usize; + let mut result = String::new(); + for run in &line.runs { + for ch in run.text.chars() { + if chars_so_far >= prev_tac_text_pos + && chars_so_far < tac_pos + { + result.push(ch); + } + chars_so_far += 1; } - chars_so_far += 1; } - } - result - }) - .unwrap_or_default(); - if !text_before.is_empty() { - let char_style_id = composed.lines.first() - .and_then(|l| l.runs.first()) - .map(|r| r.char_style_id).unwrap_or(0); - let lang_index = composed.lines.first() - .and_then(|l| l.runs.first()) - .map(|r| r.lang_index).unwrap_or(0); - let ts = resolved_to_text_style(styles, char_style_id, lang_index); - let text_w = estimate_text_width(&text_before, &ts); - let text_font_size = ts.font_size; - // 텍스트 렌더링: Shape 사이에 배치 - // 텍스트 y를 Shape 하단 baseline에 맞춤 - // (Shape 높이 - 폰트 줄 높이)만큼 아래로 이동 - let text_baseline = text_font_size * 0.85; - let font_line_h = text_font_size * 1.2; - // 인접 Shape의 높이를 사용하여 텍스트 y를 baseline 정렬 - let adjacent_shape_h = para.controls.iter() - .find_map(|c| if let Control::Shape(s) = c { - if s.common().treat_as_char { Some(hwpunit_to_px(s.common().height as i32, self.dpi)) } else { None } - } else { None }) - .unwrap_or(0.0); - let text_y = para_y_before_compose + (adjacent_shape_h - font_line_h).max(0.0); - let text_node_id = tree.next_id(); - let text_node = RenderNode::new( - text_node_id, - RenderNodeType::TextRun(TextRunNode { - text: text_before, - style: ts, - char_shape_id: Some(char_style_id), - para_shape_id: Some(composed.para_style_id), - section_index: Some(section_index), - para_index: None, - char_start: None, - cell_context: None, - is_para_end: false, - is_line_break_end: false, - rotation: 0.0, - is_vertical: false, - char_overlap: None, - border_fill_id: 0, - baseline: text_baseline, - field_marker: FieldMarkerType::None, - }), - BoundingBox::new(inline_x, text_y, text_w, font_line_h), - ); - cell_node.children.push(text_node); - inline_x += text_w; - } - prev_tac_text_pos = tac_pos; - } - let shape_area = LayoutRect { - x: inline_x, - y: para_y_before_compose, - width: shape_w, - height: inner_area.height, - }; - self.layout_cell_shape(tree, &mut cell_node, shape, &shape_area, para_y_before_compose, Alignment::Left, styles, bin_data_content); - inline_x += shape_w; - } else { - self.layout_cell_shape(tree, &mut cell_node, shape, &inner_area, para_y, para_alignment, styles, bin_data_content); - } - } - Control::Equation(eq) => { - // 수식 컨트롤: 글자처럼 인라인 배치 - let eq_w = hwpunit_to_px(eq.common.width as i32, self.dpi); - - // 수식이 텍스트 run 사이에 인라인으로 배치되는 경우 - // layout_composed_paragraph에서 이미 렌더링됨 → 건너뛰기 - let has_text_in_para = para.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}'); - if has_text_in_para { - // 텍스트가 있는 문단: paragraph_layout에서 처리됨 - inline_x += eq_w; - } else { - // 수식만 있는 문단: 여기서 직접 렌더링 - let eq_h = hwpunit_to_px(eq.common.height as i32, self.dpi); - let eq_x = { - let x = inline_x; - inline_x += eq_w; - x - }; - let eq_y = para_y_before_compose; - - let tokens = super::super::equation::tokenizer::tokenize(&eq.script); - let ast = super::super::equation::parser::EqParser::new(tokens).parse(); - let font_size_px = hwpunit_to_px(eq.font_size as i32, self.dpi); - let layout_box = super::super::equation::layout::EqLayout::new(font_size_px).layout(&ast); - let color_str = super::super::equation::svg_render::eq_color_to_svg(eq.color); - let svg_content = super::super::equation::svg_render::render_equation_svg( - &layout_box, &color_str, font_size_px, - ); - - let eq_node = RenderNode::new( - tree.next_id(), - RenderNodeType::Equation(EquationNode { - svg_content, - layout_box, - color_str, - color: eq.color, - font_size: font_size_px, - section_index: Some(section_index), - para_index: table_meta.map(|(pi, _)| pi), - control_index: Some(ctrl_idx), - cell_index: Some(cell_idx), - cell_para_index: Some(cp_idx), - }), - BoundingBox::new(eq_x, eq_y, eq_w, eq_h), - ); - cell_node.children.push(eq_node); - } - } - Control::Table(nested_table) => { - let is_tac_table = nested_table.common.treat_as_char; - let nested_y = if has_preceding_text { - para_y - } else { - inner_area.y - }; - let nested_ctx = cell_context.as_ref().map(|ctx| { - let mut new_ctx = ctx.clone(); - new_ctx.path.push(CellPathEntry { - control_index: ctrl_idx, - cell_index: 0, - cell_para_index: 0, - text_direction: 0, - }); - new_ctx - }); - if is_tac_table { - // TAC 표: inline_x를 사용하여 수평 배치 - let tac_w = hwpunit_to_px(nested_table.common.width as i32, self.dpi); - let ctrl_area = LayoutRect { - x: inline_x, - y: para_y_before_compose, - width: tac_w, - height: (inner_area.height - (para_y_before_compose - inner_area.y)).max(0.0), - }; - let table_h = self.layout_table( - tree, &mut cell_node, nested_table, - section_index, styles, &ctrl_area, para_y_before_compose, - bin_data_content, None, depth + 1, - None, para_alignment, - nested_ctx, - 0.0, 0.0, Some(inline_x), None, None, - ); - inline_x += tac_w; - // para_y는 TAC 표 높이만큼 갱신 (같은 문단 내 다음 표도 같은 y) - let new_bottom = para_y_before_compose + table_h; - if new_bottom > para_y { - para_y = new_bottom; - } - } else { - // 비-TAC 표: 기존 수직 배치 - // 앞 텍스트 너비만큼 x 오프셋 적용 - let tac_text_offset = if nested_table.attr & 0x01 != 0 { - let mut text_w = 0.0; - for line in &composed.lines { - for run in &line.runs { - if !run.text.is_empty() { - let ts = resolved_to_text_style( - styles, run.char_style_id, run.lang_index); - text_w += estimate_text_width(&run.text, &ts); - } - } - } - text_w - } else { - 0.0 - }; - // TAC 표 앞 텍스트 렌더링 (문단부호 등 표시용) - if tac_text_offset > 0.0 { - let line_h = composed.lines.first() - .map(|l| hwpunit_to_px(l.line_height, self.dpi)) - .unwrap_or(12.0); - let baseline = line_h * 0.85; - let line_id = tree.next_id(); - let mut line_node = RenderNode::new( - line_id, - RenderNodeType::TextLine(TextLineNode::new(line_h, baseline)), - BoundingBox::new(inner_area.x, nested_y, tac_text_offset, line_h), - ); - let mut run_x = inner_area.x; - for line in &composed.lines { - for run in &line.runs { - if run.text.is_empty() { continue; } + result + }) + .unwrap_or_default(); + if !text_before.is_empty() { + let char_style_id = composed + .lines + .first() + .and_then(|l| l.runs.first()) + .map(|r| r.char_style_id) + .unwrap_or(0); + let lang_index = composed + .lines + .first() + .and_then(|l| l.runs.first()) + .map(|r| r.lang_index) + .unwrap_or(0); let ts = resolved_to_text_style( - styles, run.char_style_id, run.lang_index); - let run_w = estimate_text_width(&run.text, &ts); - let run_id = tree.next_id(); - let run_node = RenderNode::new( - run_id, + styles, + char_style_id, + lang_index, + ); + let text_w = estimate_text_width(&text_before, &ts); + let text_font_size = ts.font_size; + // 텍스트 렌더링: Shape 사이에 배치 + // 텍스트 y를 Shape 하단 baseline에 맞춤 + // (Shape 높이 - 폰트 줄 높이)만큼 아래로 이동 + let text_baseline = text_font_size * 0.85; + let font_line_h = text_font_size * 1.2; + // 인접 Shape의 높이를 사용하여 텍스트 y를 baseline 정렬 + let adjacent_shape_h = para + .controls + .iter() + .find_map(|c| { + if let Control::Shape(s) = c { + if s.common().treat_as_char { + Some(hwpunit_to_px( + s.common().height as i32, + self.dpi, + )) + } else { + None + } + } else { + None + } + }) + .unwrap_or(0.0); + let text_y = para_y_before_compose + + (adjacent_shape_h - font_line_h).max(0.0); + let text_node_id = tree.next_id(); + let text_node = RenderNode::new( + text_node_id, RenderNodeType::TextRun(TextRunNode { - text: run.text.clone(), + text: text_before, style: ts, - char_shape_id: Some(run.char_style_id), - para_shape_id: Some(para.para_shape_id), + char_shape_id: Some(char_style_id), + para_shape_id: Some(composed.para_style_id), section_index: Some(section_index), para_index: None, char_start: None, - cell_context: cell_context.clone(), + cell_context: None, is_para_end: false, is_line_break_end: false, rotation: 0.0, is_vertical: false, char_overlap: None, border_fill_id: 0, - baseline, + baseline: text_baseline, field_marker: FieldMarkerType::None, }), - BoundingBox::new(run_x, nested_y, run_w, line_h), + BoundingBox::new( + inline_x, + text_y, + text_w, + font_line_h, + ), ); - line_node.children.push(run_node); - run_x += run_w; + cell_node.children.push(text_node); + inline_x += text_w; } + prev_tac_text_pos = tac_pos; } - cell_node.children.push(line_node); + let shape_area = LayoutRect { + x: inline_x, + y: para_y_before_compose, + width: shape_w, + height: inner_area.height, + }; + self.layout_cell_shape( + tree, + &mut cell_node, + shape, + &shape_area, + para_y_before_compose, + Alignment::Left, + styles, + bin_data_content, + ); + inline_x += shape_w; + } else { + self.layout_cell_shape( + tree, + &mut cell_node, + shape, + &inner_area, + para_y, + para_alignment, + styles, + bin_data_content, + ); + } + } + Control::Equation(eq) => { + // 수식 컨트롤: 글자처럼 인라인 배치 + let eq_w = hwpunit_to_px(eq.common.width as i32, self.dpi); + + // 수식이 텍스트 run 사이에 인라인으로 배치되는 경우 + // layout_composed_paragraph에서 이미 렌더링됨 → 건너뛰기 + let has_text_in_para = + para.text.chars().any(|c| c > '\u{001F}' && c != '\u{FFFC}'); + if has_text_in_para { + // 텍스트가 있는 문단: paragraph_layout에서 처리됨 + inline_x += eq_w; + } else { + // 수식만 있는 문단: 여기서 직접 렌더링 + let eq_h = hwpunit_to_px(eq.common.height as i32, self.dpi); + let eq_x = { + let x = inline_x; + inline_x += eq_w; + x + }; + let eq_y = para_y_before_compose; + + let tokens = + super::super::equation::tokenizer::tokenize(&eq.script); + let ast = super::super::equation::parser::EqParser::new(tokens) + .parse(); + let font_size_px = hwpunit_to_px(eq.font_size as i32, self.dpi); + let layout_box = + super::super::equation::layout::EqLayout::new(font_size_px) + .layout(&ast); + let color_str = + super::super::equation::svg_render::eq_color_to_svg( + eq.color, + ); + let svg_content = + super::super::equation::svg_render::render_equation_svg( + &layout_box, + &color_str, + font_size_px, + ); + + let eq_node = RenderNode::new( + tree.next_id(), + RenderNodeType::Equation(EquationNode { + svg_content, + layout_box, + color_str, + color: eq.color, + font_size: font_size_px, + section_index: Some(section_index), + para_index: table_meta.map(|(pi, _)| pi), + control_index: Some(ctrl_idx), + cell_index: Some(cell_idx), + cell_para_index: Some(cp_idx), + }), + BoundingBox::new(eq_x, eq_y, eq_w, eq_h), + ); + cell_node.children.push(eq_node); } - let ctrl_area = LayoutRect { - x: inner_area.x + tac_text_offset, - y: nested_y, - width: (inner_area.width - tac_text_offset).max(0.0), - height: (inner_area.height - (nested_y - inner_area.y)).max(0.0), + } + Control::Table(nested_table) => { + let is_tac_table = nested_table.common.treat_as_char; + let nested_y = if has_preceding_text { + para_y + } else { + inner_area.y }; - let table_h = self.layout_table( - tree, &mut cell_node, nested_table, - section_index, styles, &ctrl_area, nested_y, - bin_data_content, None, depth + 1, - None, para_alignment, - nested_ctx, - 0.0, 0.0, None, None, None, - ); - para_y = nested_y + table_h; + let nested_ctx = cell_context.as_ref().map(|ctx| { + let mut new_ctx = ctx.clone(); + new_ctx.path.push(CellPathEntry { + control_index: ctrl_idx, + cell_index: 0, + cell_para_index: 0, + text_direction: 0, + }); + new_ctx + }); + if is_tac_table { + // TAC 표: inline_x를 사용하여 수평 배치 + let tac_w = + hwpunit_to_px(nested_table.common.width as i32, self.dpi); + let ctrl_area = LayoutRect { + x: inline_x, + y: para_y_before_compose, + width: tac_w, + height: (inner_area.height + - (para_y_before_compose - inner_area.y)) + .max(0.0), + }; + let table_h = self.layout_table( + tree, + &mut cell_node, + nested_table, + section_index, + styles, + &ctrl_area, + para_y_before_compose, + bin_data_content, + None, + depth + 1, + None, + para_alignment, + nested_ctx, + 0.0, + 0.0, + Some(inline_x), + None, + None, + ); + inline_x += tac_w; + // para_y는 TAC 표 높이만큼 갱신 (같은 문단 내 다음 표도 같은 y) + let new_bottom = para_y_before_compose + table_h; + if new_bottom > para_y { + para_y = new_bottom; + } + } else { + // 비-TAC 표: 기존 수직 배치 + // 앞 텍스트 너비만큼 x 오프셋 적용 + let tac_text_offset = if nested_table.attr & 0x01 != 0 { + let mut text_w = 0.0; + for line in &composed.lines { + for run in &line.runs { + if !run.text.is_empty() { + let ts = resolved_to_text_style( + styles, + run.char_style_id, + run.lang_index, + ); + text_w += estimate_text_width(&run.text, &ts); + } + } + } + text_w + } else { + 0.0 + }; + // TAC 표 앞 텍스트 렌더링 (문단부호 등 표시용) + if tac_text_offset > 0.0 { + let line_h = composed + .lines + .first() + .map(|l| hwpunit_to_px(l.line_height, self.dpi)) + .unwrap_or(12.0); + let baseline = line_h * 0.85; + let line_id = tree.next_id(); + let mut line_node = RenderNode::new( + line_id, + RenderNodeType::TextLine(TextLineNode::new( + line_h, baseline, + )), + BoundingBox::new( + inner_area.x, + nested_y, + tac_text_offset, + line_h, + ), + ); + let mut run_x = inner_area.x; + for line in &composed.lines { + for run in &line.runs { + if run.text.is_empty() { + continue; + } + let ts = resolved_to_text_style( + styles, + run.char_style_id, + run.lang_index, + ); + let run_w = estimate_text_width(&run.text, &ts); + let run_id = tree.next_id(); + let run_node = RenderNode::new( + run_id, + RenderNodeType::TextRun(TextRunNode { + text: run.text.clone(), + style: ts, + char_shape_id: Some(run.char_style_id), + para_shape_id: Some(para.para_shape_id), + section_index: Some(section_index), + para_index: None, + char_start: None, + cell_context: cell_context.clone(), + is_para_end: false, + is_line_break_end: false, + rotation: 0.0, + is_vertical: false, + char_overlap: None, + border_fill_id: 0, + baseline, + field_marker: FieldMarkerType::None, + }), + BoundingBox::new( + run_x, nested_y, run_w, line_h, + ), + ); + line_node.children.push(run_node); + run_x += run_w; + } + } + cell_node.children.push(line_node); + } + let ctrl_area = LayoutRect { + x: inner_area.x + tac_text_offset, + y: nested_y, + width: (inner_area.width - tac_text_offset).max(0.0), + height: (inner_area.height - (nested_y - inner_area.y)) + .max(0.0), + }; + let table_h = self.layout_table( + tree, + &mut cell_node, + nested_table, + section_index, + styles, + &ctrl_area, + nested_y, + bin_data_content, + None, + depth + 1, + None, + para_alignment, + nested_ctx, + 0.0, + 0.0, + None, + None, + None, + ); + para_y = nested_y + table_h; + } + has_preceding_text = true; } - has_preceding_text = true; + _ => {} } - _ => {} } - } - // 마지막 인라인 Shape 이후의 남은 텍스트 렌더링 (예: "일") - if prev_tac_text_pos > 0 { - let total_text_chars = composed.lines.first() - .map(|line| line.runs.iter().map(|r| r.text.chars().count()).sum::()) - .unwrap_or(0); - if prev_tac_text_pos < total_text_chars { - let remaining_text: String = composed.lines.first() + // 마지막 인라인 Shape 이후의 남은 텍스트 렌더링 (예: "일") + if prev_tac_text_pos > 0 { + let total_text_chars = composed + .lines + .first() .map(|line| { - let mut chars_so_far = 0usize; - let mut result = String::new(); - for run in &line.runs { - for ch in run.text.chars() { - if chars_so_far >= prev_tac_text_pos { - result.push(ch); + line.runs + .iter() + .map(|r| r.text.chars().count()) + .sum::() + }) + .unwrap_or(0); + if prev_tac_text_pos < total_text_chars { + let remaining_text: String = composed + .lines + .first() + .map(|line| { + let mut chars_so_far = 0usize; + let mut result = String::new(); + for run in &line.runs { + for ch in run.text.chars() { + if chars_so_far >= prev_tac_text_pos { + result.push(ch); + } + chars_so_far += 1; } - chars_so_far += 1; } - } - result - }) - .unwrap_or_default(); - let remaining_trimmed = remaining_text.trim_end(); - if !remaining_trimmed.is_empty() { - let char_style_id = composed.lines.first() - .and_then(|l| l.runs.last()) - .map(|r| r.char_style_id).unwrap_or(0); - let lang_index = composed.lines.first() - .and_then(|l| l.runs.last()) - .map(|r| r.lang_index).unwrap_or(0); - let ts = resolved_to_text_style(styles, char_style_id, lang_index); - let text_w = estimate_text_width(remaining_trimmed, &ts); - let text_baseline = ts.font_size * 0.85; - let text_h = ts.font_size * 1.2; - // 마지막 Shape 높이 기준으로 텍스트 y 계산 - let last_shape_h = para.controls.iter().rev() - .find_map(|c| if let Control::Shape(s) = c { - if s.common().treat_as_char { Some(hwpunit_to_px(s.common().height as i32, self.dpi)) } else { None } - } else { None }) - .unwrap_or(0.0); - let text_y = para_y_before_compose + (last_shape_h - text_h).max(0.0); - let text_node_id = tree.next_id(); - let text_node = RenderNode::new( - text_node_id, - RenderNodeType::TextRun(TextRunNode { - text: remaining_trimmed.to_string(), - style: ts, - char_shape_id: Some(char_style_id), - para_shape_id: Some(composed.para_style_id), - section_index: Some(section_index), - para_index: None, - char_start: None, - cell_context: None, - is_para_end: false, - is_line_break_end: false, - rotation: 0.0, - is_vertical: false, - char_overlap: None, - border_fill_id: 0, - baseline: text_baseline, - field_marker: FieldMarkerType::None, - }), - BoundingBox::new(inline_x, text_y, text_w, text_h), - ); - cell_node.children.push(text_node); + result + }) + .unwrap_or_default(); + let remaining_trimmed = remaining_text.trim_end(); + if !remaining_trimmed.is_empty() { + let char_style_id = composed + .lines + .first() + .and_then(|l| l.runs.last()) + .map(|r| r.char_style_id) + .unwrap_or(0); + let lang_index = composed + .lines + .first() + .and_then(|l| l.runs.last()) + .map(|r| r.lang_index) + .unwrap_or(0); + let ts = resolved_to_text_style(styles, char_style_id, lang_index); + let text_w = estimate_text_width(remaining_trimmed, &ts); + let text_baseline = ts.font_size * 0.85; + let text_h = ts.font_size * 1.2; + // 마지막 Shape 높이 기준으로 텍스트 y 계산 + let last_shape_h = para + .controls + .iter() + .rev() + .find_map(|c| { + if let Control::Shape(s) = c { + if s.common().treat_as_char { + Some(hwpunit_to_px( + s.common().height as i32, + self.dpi, + )) + } else { + None + } + } else { + None + } + }) + .unwrap_or(0.0); + let text_y = + para_y_before_compose + (last_shape_h - text_h).max(0.0); + let text_node_id = tree.next_id(); + let text_node = RenderNode::new( + text_node_id, + RenderNodeType::TextRun(TextRunNode { + text: remaining_trimmed.to_string(), + style: ts, + char_shape_id: Some(char_style_id), + para_shape_id: Some(composed.para_style_id), + section_index: Some(section_index), + para_index: None, + char_start: None, + cell_context: None, + is_para_end: false, + is_line_break_end: false, + rotation: 0.0, + is_vertical: false, + char_overlap: None, + border_fill_id: 0, + baseline: text_baseline, + field_marker: FieldMarkerType::None, + }), + BoundingBox::new(inline_x, text_y, text_w, text_h), + ); + cell_node.children.push(text_node); + } } } - } - if has_table_ctrl { - // LINE_SEG vpos 기반으로 para_y 보정. - // LINE_SEG.line_height에는 중첩 표 높이가 미포함될 수 있으므로 - // layout_table 반환값과 vpos 기반 중 적절한 값을 선택한다. - let is_last_para = cp_idx + 1 == composed_paras.len(); - // 다음 문단의 vpos가 있으면 그것을 기준으로 para_y 보정 - if !is_last_para { - if let Some(next_para) = cell.paragraphs.get(cp_idx + 1) { - if let Some(next_seg) = next_para.line_segs.first() { - let next_vpos_y = text_y_start + hwpunit_to_px( - next_seg.vertical_pos, self.dpi); - // layout_table 기반 para_y와 다음 문단 vpos 중 - // 더 큰 값 사용 (표가 LINE_SEG보다 클 수 있으므로) - para_y = para_y.max(next_vpos_y); + if has_table_ctrl { + // LINE_SEG vpos 기반으로 para_y 보정. + // LINE_SEG.line_height에는 중첩 표 높이가 미포함될 수 있으므로 + // layout_table 반환값과 vpos 기반 중 적절한 값을 선택한다. + let is_last_para = cp_idx + 1 == composed_paras.len(); + // 다음 문단의 vpos가 있으면 그것을 기준으로 para_y 보정 + if !is_last_para { + if let Some(next_para) = cell.paragraphs.get(cp_idx + 1) { + if let Some(next_seg) = next_para.line_segs.first() { + let next_vpos_y = text_y_start + + hwpunit_to_px(next_seg.vertical_pos, self.dpi); + // layout_table 기반 para_y와 다음 문단 vpos 중 + // 더 큰 값 사용 (표가 LINE_SEG보다 클 수 있으므로) + para_y = para_y.max(next_vpos_y); + } } } - } - // 음수 line_spacing 처리 (중첩 구조에서 para_y 되돌리기) - if !(is_last_para && enclosing_cell_ctx.is_some()) { - if let Some(last_line) = composed.lines.last() { - let ls = hwpunit_to_px(last_line.line_spacing, self.dpi); - if ls < -0.01 { - para_y += ls; + // 음수 line_spacing 처리 (중첩 구조에서 para_y 되돌리기) + if !(is_last_para && enclosing_cell_ctx.is_some()) { + if let Some(last_line) = composed.lines.last() { + let ls = hwpunit_to_px(last_line.line_spacing, self.dpi); + if ls < -0.01 { + para_y += ls; + } } } } } - } } // else (가로쓰기) // 셀 내 각주 참조 번호 윗첨자 @@ -1760,8 +2208,12 @@ impl LayoutEngine { // (b) 셀 테두리를 엣지 그리드에 수집 if let Some(bs) = border_style { collect_cell_borders( - h_edges, v_edges, - c, r, cell.col_span as usize, cell.row_span as usize, + h_edges, + v_edges, + c, + r, + cell.col_span as usize, + cell.row_span as usize, &bs.borders, ); } @@ -1770,9 +2222,9 @@ impl LayoutEngine { // (c) 셀 대각선 렌더링 (셀 콘텐츠 위에 그림) if let Some(bs) = border_style { - table_node.children.extend( - render_cell_diagonal(tree, bs, cell_x, cell_y, cell_w, cell_h), - ); + table_node.children.extend(render_cell_diagonal( + tree, bs, cell_x, cell_y, cell_w, cell_h, + )); } } } @@ -1799,8 +2251,10 @@ impl LayoutEngine { let cell_spacing = hwpunit_to_px(table.cell_spacing as i32, self.dpi); let om_top = hwpunit_to_px(table.outer_margin_top as i32, self.dpi); let om_bottom = hwpunit_to_px(table.outer_margin_bottom as i32, self.dpi); - row_heights.iter().sum::() + cell_spacing * (row_count.saturating_sub(1) as f64) - + om_top + om_bottom + row_heights.iter().sum::() + + cell_spacing * (row_count.saturating_sub(1) as f64) + + om_top + + om_bottom } /// 셀의 content_offset 이후 실제 남은 콘텐츠 높이를 계산한다. @@ -1832,39 +2286,57 @@ impl LayoutEngine { }; if comp.lines.is_empty() { // 중첩 표 컨트롤 문단: 실제 중첩 표 높이로 계산 - let nested_h: f64 = p.controls.iter().map(|ctrl| { - if let Control::Table(t) = ctrl { - self.calc_nested_table_height(t, styles) - } else { - 0.0 - } - }).sum(); - let h = if nested_h > 0.0 { nested_h } else { hwpunit_to_px(400, self.dpi) }; + let nested_h: f64 = p + .controls + .iter() + .map(|ctrl| { + if let Control::Table(t) = ctrl { + self.calc_nested_table_height(t, styles) + } else { + 0.0 + } + }) + .sum(); + let h = if nested_h > 0.0 { + nested_h + } else { + hwpunit_to_px(400, self.dpi) + }; total += spacing_before + h + spacing_after; } else { // 중첩 표가 있는 문단: LINE_SEG 높이와 실제 중첩 표 높이 중 큰 값 사용 let has_table_in_para = p.controls.iter().any(|c| matches!(c, Control::Table(_))); let line_count = comp.lines.len(); - let line_based_h: f64 = comp.lines.iter().enumerate().map(|(li, line)| { - let h = hwpunit_to_px(line.line_height, self.dpi); - let is_cell_last_line = is_last_para && li + 1 == line_count; - let ls = if !is_cell_last_line { - hwpunit_to_px(line.line_spacing, self.dpi) - } else { - 0.0 - }; - spacing_before * (if li == 0 { 1.0 } else { 0.0 }) - + h + ls - + spacing_after * (if li + 1 == line_count { 1.0 } else { 0.0 }) - }).sum(); - if has_table_in_para { - let nested_h: f64 = p.controls.iter().map(|ctrl| { - if let Control::Table(t) = ctrl { - self.calc_nested_table_height(t, styles) + let line_based_h: f64 = comp + .lines + .iter() + .enumerate() + .map(|(li, line)| { + let h = hwpunit_to_px(line.line_height, self.dpi); + let is_cell_last_line = is_last_para && li + 1 == line_count; + let ls = if !is_cell_last_line { + hwpunit_to_px(line.line_spacing, self.dpi) } else { 0.0 - } - }).sum(); + }; + spacing_before * (if li == 0 { 1.0 } else { 0.0 }) + + h + + ls + + spacing_after * (if li + 1 == line_count { 1.0 } else { 0.0 }) + }) + .sum(); + if has_table_in_para { + let nested_h: f64 = p + .controls + .iter() + .map(|ctrl| { + if let Control::Table(t) = ctrl { + self.calc_nested_table_height(t, styles) + } else { + 0.0 + } + }) + .sum(); total += nested_h.max(line_based_h); } else { total += line_based_h; @@ -1885,26 +2357,50 @@ impl LayoutEngine { ) -> Vec<(usize, usize)> { let mut result = Vec::with_capacity(composed_paras.len()); let mut offset_remaining = content_offset; - let mut limit_remaining = if content_limit > 0.0 { content_limit } else { f64::MAX }; + let mut limit_remaining = if content_limit > 0.0 { + content_limit + } else { + f64::MAX + }; let total_paras = composed_paras.len(); - for (pi, (comp, para)) in composed_paras.iter().zip(cell.paragraphs.iter()).enumerate() { + for (pi, (comp, para)) in composed_paras + .iter() + .zip(cell.paragraphs.iter()) + .enumerate() + { let para_style = styles.para_styles.get(para.para_shape_id as usize); let is_last_para = pi + 1 == total_paras; // MeasuredCell 규칙: 첫 문단은 spacing_before 없음, 마지막 문단은 spacing_after 없음 - let spacing_before = if pi > 0 { para_style.map(|s| s.spacing_before).unwrap_or(0.0) } else { 0.0 }; - let spacing_after = if !is_last_para { para_style.map(|s| s.spacing_after).unwrap_or(0.0) } else { 0.0 }; + let spacing_before = if pi > 0 { + para_style.map(|s| s.spacing_before).unwrap_or(0.0) + } else { + 0.0 + }; + let spacing_after = if !is_last_para { + para_style.map(|s| s.spacing_after).unwrap_or(0.0) + } else { + 0.0 + }; let line_count = comp.lines.len(); if line_count == 0 { // 중첩 표 컨트롤 문단: 실제 중첩 표 높이로 offset/limit 소비 - let nested_h: f64 = para.controls.iter().map(|ctrl| { - if let Control::Table(t) = ctrl { - self.calc_nested_table_height(t, styles) - } else { - 0.0 - } - }).sum(); - let h = if nested_h > 0.0 { nested_h } else { hwpunit_to_px(400, self.dpi) }; + let nested_h: f64 = para + .controls + .iter() + .map(|ctrl| { + if let Control::Table(t) = ctrl { + self.calc_nested_table_height(t, styles) + } else { + 0.0 + } + }) + .sum(); + let h = if nested_h > 0.0 { + nested_h + } else { + hwpunit_to_px(400, self.dpi) + }; let para_h = spacing_before + h + spacing_after; if offset_remaining > 0.0 { @@ -1926,22 +2422,35 @@ impl LayoutEngine { // 실제 중첩 표 높이를 포함한 전체 높이를 사용 let has_table_in_para = para.controls.iter().any(|c| matches!(c, Control::Table(_))); if has_table_in_para { - let nested_h: f64 = para.controls.iter().map(|ctrl| { - if let Control::Table(t) = ctrl { - self.calc_nested_table_height(t, styles) - } else { - 0.0 - } - }).sum(); - let line_based_h: f64 = comp.lines.iter().enumerate().map(|(li, line)| { - let h = hwpunit_to_px(line.line_height, self.dpi); - let ls = hwpunit_to_px(line.line_spacing, self.dpi); - let is_cell_last_line = is_last_para && li + 1 == line_count; - let mut lh = if !is_cell_last_line { h + ls } else { h }; - if li == 0 { lh += spacing_before; } - if li == line_count - 1 { lh += spacing_after; } - lh - }).sum(); + let nested_h: f64 = para + .controls + .iter() + .map(|ctrl| { + if let Control::Table(t) = ctrl { + self.calc_nested_table_height(t, styles) + } else { + 0.0 + } + }) + .sum(); + let line_based_h: f64 = comp + .lines + .iter() + .enumerate() + .map(|(li, line)| { + let h = hwpunit_to_px(line.line_height, self.dpi); + let ls = hwpunit_to_px(line.line_spacing, self.dpi); + let is_cell_last_line = is_last_para && li + 1 == line_count; + let mut lh = if !is_cell_last_line { h + ls } else { h }; + if li == 0 { + lh += spacing_before; + } + if li == line_count - 1 { + lh += spacing_after; + } + lh + }) + .sum(); let para_h = nested_h.max(line_based_h); if offset_remaining > 0.0 { @@ -1956,7 +2465,9 @@ impl LayoutEngine { } } // 중첩 표 문단은 모든 줄을 포함하거나 모두 제외 - if offset_remaining > 0.0 || (offset_remaining == 0.0 && content_offset > 0.0 && para_h <= content_offset) { + if offset_remaining > 0.0 + || (offset_remaining == 0.0 && content_offset > 0.0 && para_h <= content_offset) + { result.push((line_count, line_count)); // 이미 지나간 문단 } else { result.push((0, line_count)); // 아직 보여야 할 문단 @@ -2029,12 +2540,15 @@ impl LayoutEngine { // 실제 렌더링되는 첫/마지막 문단 인덱스 찾기 let first_visible_pi = line_ranges.iter().position(|&(s, e)| s < e); let last_visible_pi = line_ranges.iter().rposition(|&(s, e)| s < e); - for (pi, ((comp, para), &(start, end))) in composed_paras.iter() + for (pi, ((comp, para), &(start, end))) in composed_paras + .iter() .zip(paragraphs.iter()) .zip(line_ranges.iter()) .enumerate() { - if start >= end { continue; } + if start >= end { + continue; + } let para_style = styles.para_styles.get(para.para_shape_id as usize); let is_last_para = pi + 1 == para_count; let is_visible_first = Some(pi) == first_visible_pi; diff --git a/src/renderer/layout/table_partial.rs b/src/renderer/layout/table_partial.rs index a4601f40..ed16644e 100644 --- a/src/renderer/layout/table_partial.rs +++ b/src/renderer/layout/table_partial.rs @@ -1,21 +1,23 @@ //! 페이지 분할 표 레이아웃 (layout_partial_table) -use crate::model::paragraph::Paragraph; -use crate::model::style::{Alignment, BorderLine}; -use crate::model::control::Control; -use crate::model::bin_data::BinDataContent; -use crate::model::shape::CaptionDirection; -use super::utils::find_bin_data; -use super::super::render_tree::*; -use super::super::page_layout::LayoutRect; use super::super::composer::compose_paragraph; +use super::super::height_measurer::MeasuredTable; +use super::super::page_layout::LayoutRect; +use super::super::render_tree::*; use super::super::style_resolver::ResolvedStyleSet; use super::super::{hwpunit_to_px, ShapeStyle}; -use super::{LayoutEngine, CellContext, CellPathEntry}; -use super::border_rendering::{build_row_col_x, collect_cell_borders, render_edge_borders, render_transparent_borders}; -use super::text_measurement::{resolved_to_text_style, estimate_text_width}; -use super::table_layout::{NestedTableSplit, calc_nested_split_rows}; -use super::super::height_measurer::MeasuredTable; +use super::border_rendering::{ + build_row_col_x, collect_cell_borders, render_edge_borders, render_transparent_borders, +}; +use super::table_layout::{calc_nested_split_rows, NestedTableSplit}; +use super::text_measurement::{estimate_text_width, resolved_to_text_style}; +use super::utils::find_bin_data; +use super::{CellContext, CellPathEntry, LayoutEngine}; +use crate::model::bin_data::BinDataContent; +use crate::model::control::Control; +use crate::model::paragraph::Paragraph; +use crate::model::shape::CaptionDirection; +use crate::model::style::{Alignment, BorderLine}; // 표 수평 정렬 보조 타입은 table_layout.rs에 통합됨 @@ -60,9 +62,16 @@ impl LayoutEngine { } // 분할 표 첫 부분: vert_offset 적용 (자리차지 표의 세로 오프셋) - let y_start = if !is_continuation && !table.common.treat_as_char - && matches!(table.common.text_wrap, crate::model::shape::TextWrap::TopAndBottom) - && matches!(table.common.vert_rel_to, crate::model::shape::VertRelTo::Para) + let y_start = if !is_continuation + && !table.common.treat_as_char + && matches!( + table.common.text_wrap, + crate::model::shape::TextWrap::TopAndBottom + ) + && matches!( + table.common.vert_rel_to, + crate::model::shape::VertRelTo::Para + ) && table.common.vertical_offset > 0 { y_start + hwpunit_to_px(table.common.vertical_offset as i32, self.dpi) @@ -76,7 +85,8 @@ impl LayoutEngine { // ── 1. 열 폭 계산 + 2. 행 높이 계산 (table_layout 공유 메서드) ── let col_widths = self.resolve_column_widths(table, col_count); - let mut row_heights = self.resolve_row_heights(table, col_count, row_count, measured_table, styles); + let mut row_heights = + self.resolve_row_heights(table, col_count, row_count, measured_table, styles); // ── 2b. 분할 행 높이 오버라이드 ── if split_start_content_offset > 0.0 && start_row < row_count { @@ -88,7 +98,9 @@ impl LayoutEngine { let (_, _, pad_top, pad_bottom) = self.resolve_cell_padding(cell, table); // 셀 내 중첩 표 유무 확인 - let has_nested_table = cell.paragraphs.iter() + let has_nested_table = cell + .paragraphs + .iter() .any(|p| p.controls.iter().any(|c| matches!(c, Control::Table(_)))); let remaining = if has_nested_table { @@ -101,7 +113,8 @@ impl LayoutEngine { let mut post_table_h = 0.0f64; let mut in_continuation_zone = false; for p in &cell.paragraphs { - let p_has_table = p.controls.iter().any(|c| matches!(c, Control::Table(_))); + let p_has_table = + p.controls.iter().any(|c| matches!(c, Control::Table(_))); if p_has_table { found_nested_table = true; for ctrl in &p.controls { @@ -109,16 +122,32 @@ impl LayoutEngine { let inner_rc = inner_table.row_count as usize; let inner_cc = inner_table.col_count as usize; let inner_rh = self.resolve_row_heights( - inner_table, inner_cc, inner_rc, None, styles, + inner_table, + inner_cc, + inner_rc, + None, + styles, ); - let inner_spacing = hwpunit_to_px(inner_table.cell_spacing as i32, self.dpi); - let nested_h = self.calc_nested_table_height(inner_table, styles); + let inner_spacing = hwpunit_to_px( + inner_table.cell_spacing as i32, + self.dpi, + ); + let nested_h = + self.calc_nested_table_height(inner_table, styles); let split_info = calc_nested_split_rows( - &inner_rh, inner_spacing, - split_start_content_offset, nested_h - split_start_content_offset, + &inner_rh, + inner_spacing, + split_start_content_offset, + nested_h - split_start_content_offset, + ); + let om_top = hwpunit_to_px( + inner_table.outer_margin_top as i32, + self.dpi, + ); + let om_bottom = hwpunit_to_px( + inner_table.outer_margin_bottom as i32, + self.dpi, ); - let om_top = hwpunit_to_px(inner_table.outer_margin_top as i32, self.dpi); - let om_bottom = hwpunit_to_px(inner_table.outer_margin_bottom as i32, self.dpi); visible_h += split_info.visible_height + om_top + om_bottom; } } @@ -132,10 +161,14 @@ impl LayoutEngine { in_continuation_zone = true; } let is_after_split = in_continuation_zone - || hwpunit_to_px(first_seg.vertical_pos, self.dpi) >= split_start_content_offset; + || hwpunit_to_px(first_seg.vertical_pos, self.dpi) + >= split_start_content_offset; if is_after_split { for seg in &p.line_segs { - post_table_h += hwpunit_to_px(seg.line_height + seg.line_spacing, self.dpi); + post_table_h += hwpunit_to_px( + seg.line_height + seg.line_spacing, + self.dpi, + ); } } } @@ -143,12 +176,23 @@ impl LayoutEngine { } visible_h + post_table_h } else { - let composed: Vec<_> = cell.paragraphs.iter() + let composed: Vec<_> = cell + .paragraphs + .iter() .map(|p| compose_paragraph(p)) .collect(); - let ranges = self.compute_cell_line_ranges(cell, &composed, split_start_content_offset, 0.0, styles); + let ranges = self.compute_cell_line_ranges( + cell, + &composed, + split_start_content_offset, + 0.0, + styles, + ); self.calc_visible_content_height_from_ranges( - &composed, &cell.paragraphs, &ranges, styles, + &composed, + &cell.paragraphs, + &ranges, + styles, ) }; let cell_h = remaining + pad_top + pad_bottom; @@ -184,13 +228,22 @@ impl LayoutEngine { // ── 3. 누적 위치 계산 ── let mut col_x = vec![0.0f64; col_count + 1]; for i in 0..col_count { - col_x[i + 1] = col_x[i] + col_widths[i] + if i + 1 < col_count { cell_spacing } else { 0.0 }; + col_x[i + 1] = + col_x[i] + col_widths[i] + if i + 1 < col_count { cell_spacing } else { 0.0 }; } // 행별 열 위치 계산 (셀별 독립 너비 지원) - let row_col_x = build_row_col_x(table, &col_widths, col_count, row_count, cell_spacing, self.dpi); + let row_col_x = build_row_col_x( + table, + &col_widths, + col_count, + row_count, + cell_spacing, + self.dpi, + ); - let table_width = row_col_x.iter() + let table_width = row_col_x + .iter() .map(|rx| rx.last().copied().unwrap_or(0.0)) .fold(col_x.last().copied().unwrap_or(0.0), f64::max); @@ -198,13 +251,25 @@ impl LayoutEngine { let pw = self.current_paper_width.get(); let paper_w = if pw > 0.0 { Some(pw) } else { None }; let table_x = self.compute_table_x_position( - table, table_width, col_area, 0, Alignment::Left, host_margin_left, host_margin_right, None, paper_w, + table, + table_width, + col_area, + 0, + Alignment::Left, + host_margin_left, + host_margin_right, + None, + paper_w, ); // ── 4. 렌더링할 행 목록 구성 ── // is_continuation && repeat_header → 제목행(0)에 제목 셀(is_header)이 있으면 반복 - let render_header = is_continuation && table.repeat_header && start_row > 0 - && table.cells.iter() + let render_header = is_continuation + && table.repeat_header + && start_row > 0 + && table + .cells + .iter() .filter(|c| c.row == 0) .any(|c| c.is_header); let mut render_rows: Vec = Vec::new(); @@ -220,15 +285,21 @@ impl LayoutEngine { let mut y_accum = 0.0; for (i, &r) in render_rows.iter().enumerate() { render_row_y.push(y_accum); - y_accum += row_heights[r] + if i + 1 < render_rows.len() { cell_spacing } else { 0.0 }; + y_accum += row_heights[r] + + if i + 1 < render_rows.len() { + cell_spacing + } else { + 0.0 + }; } let partial_table_height = y_accum; - // 엣지 기반 테두리 수집을 위한 그리드 (렌더링 행 기준) let render_row_count = render_rows.len(); - let mut h_edges: Vec>> = vec![vec![None; col_count]; render_row_count + 1]; - let mut v_edges: Vec>> = vec![vec![None; render_row_count]; col_count + 1]; + let mut h_edges: Vec>> = + vec![vec![None; col_count]; render_row_count + 1]; + let mut v_edges: Vec>> = + vec![vec![None; render_row_count]; col_count + 1]; let mut grid_row_y = render_row_y.clone(); grid_row_y.push(partial_table_height); @@ -237,7 +308,9 @@ impl LayoutEngine { let is_last_part = end_row >= row_count && split_end_content_limit == 0.0; let (caption_height, caption_spacing) = if is_first_part || is_last_part { let ch = self.calculate_caption_height(&table.caption, styles); - let cs = table.caption.as_ref() + let cs = table + .caption + .as_ref() .map(|c| hwpunit_to_px(c.spacing as i32, self.dpi)) .unwrap_or(0.0); (ch, cs) @@ -255,7 +328,9 @@ impl LayoutEngine { let render_lr_caption = is_lr_cap; // Left 캡션: 표를 오른쪽으로 이동 - let cap_width_px = table.caption.as_ref() + let cap_width_px = table + .caption + .as_ref() .map(|c| hwpunit_to_px(c.width as i32, self.dpi)) .unwrap_or(0.0); let table_x = if is_left_cap { @@ -290,8 +365,13 @@ impl LayoutEngine { let tbl_idx = (table.border_fill_id as usize).saturating_sub(1); if let Some(tbl_bs) = styles.border_styles.get(tbl_idx) { self.render_cell_background( - tree, &mut table_node, Some(tbl_bs), - table_x, table_y, table_width, partial_table_height, + tree, + &mut table_node, + Some(tbl_bs), + table_x, + table_y, + table_width, + partial_table_height, ); } } @@ -311,7 +391,8 @@ impl LayoutEngine { // 제목행 반복으로 렌더링되는 셀인지 판별 // (원래 범위 밖이지만 render_header 때문에 포함되는 행0 셀) - let is_repeated_header_cell = render_header && cell_row == 0 && cell_end_row <= start_row; + let is_repeated_header_cell = + render_header && cell_row == 0 && cell_end_row <= start_row; // 셀이 렌더링 범위와 겹치는지 확인 if cell_row >= render_range_end || cell_end_row <= render_range_start { @@ -324,10 +405,11 @@ impl LayoutEngine { // render_rows에서 이 셀의 시작 행 위치 찾기 // row_span이 페이지 경계를 넘는 셀: cell_row가 render_rows에 없을 수 있음 // 이 경우 셀 span 범위 내에서 render_rows에 포함된 첫 번째 행을 찾음 - let render_idx = render_rows.iter().position(|&r| r == cell_row) - .or_else(|| { - render_rows.iter().position(|&r| r > cell_row && r < cell_end_row) - }); + let render_idx = render_rows.iter().position(|&r| r == cell_row).or_else(|| { + render_rows + .iter() + .position(|&r| r > cell_row && r < cell_end_row) + }); let render_y_offset = match render_idx { Some(idx) => render_row_y[idx], None => continue, // 렌더링 범위에 없음 @@ -361,7 +443,8 @@ impl LayoutEngine { // 이 셀이 분할 행에 속하는지 판별 (clip 플래그에 사용) let is_split_start_row = split_start_content_offset > 0.0 && cell_row == start_row; - let is_split_end_row = split_end_content_limit > 0.0 && cell_row == end_row.saturating_sub(1); + let is_split_end_row = + split_end_content_limit > 0.0 && cell_row == end_row.saturating_sub(1); let is_in_split_row = is_split_start_row || is_split_end_row; let cell_id = tree.next_id(); @@ -389,7 +472,15 @@ impl LayoutEngine { }; // 셀 배경 - self.render_cell_background(tree, &mut cell_node, border_style, cell_x, cell_y, cell_w, cell_h); + self.render_cell_background( + tree, + &mut cell_node, + border_style, + cell_x, + cell_y, + cell_w, + cell_h, + ); // 셀 패딩 let (pad_left, pad_right, pad_top, pad_bottom) = self.resolve_cell_padding(cell, table); @@ -399,15 +490,24 @@ impl LayoutEngine { let inner_height = (cell_h - pad_top - pad_bottom).max(0.0); // 셀 내 문단 구성 - let composed_paras: Vec<_> = cell.paragraphs.iter() + let composed_paras: Vec<_> = cell + .paragraphs + .iter() .map(|p| compose_paragraph(p)) .collect(); - // 분할 행: compute_cell_line_ranges()로 표시할 줄 범위 계산 let line_ranges: Option> = if is_in_split_row { - let co = if is_split_start_row { split_start_content_offset } else { 0.0 }; - let cl = if is_split_end_row { split_end_content_limit } else { 0.0 }; + let co = if is_split_start_row { + split_start_content_offset + } else { + 0.0 + }; + let cl = if is_split_end_row { + split_end_content_limit + } else { + 0.0 + }; Some(self.compute_cell_line_ranges(cell, &composed_paras, co, cl, styles)) } else { None @@ -418,7 +518,8 @@ impl LayoutEngine { let split_para_count = cell.paragraphs.len(); let total_content_height = if let Some(ref ranges) = line_ranges { let mut total = 0.0; - for (pi, ((comp, para), &(start, end))) in composed_paras.iter() + for (pi, ((comp, para), &(start, end))) in composed_paras + .iter() .zip(cell.paragraphs.iter()) .zip(ranges.iter()) .enumerate() @@ -453,22 +554,30 @@ impl LayoutEngine { } else { // 중첩 표가 있는 셀: LINE_SEG.line_height에 중첩 표 높이가 미포함되므로 // vpos 기반으로 전체 콘텐츠 높이를 계산 - let has_nested = cell.paragraphs.iter() + let has_nested = cell + .paragraphs + .iter() .any(|p| p.controls.iter().any(|c| matches!(c, Control::Table(_)))); if has_nested { - let last_seg_end: i32 = cell.paragraphs.iter() + let last_seg_end: i32 = cell + .paragraphs + .iter() .flat_map(|p| p.line_segs.last()) .map(|s| s.vertical_pos + s.line_height) .max() .unwrap_or(0); let vpos_h = hwpunit_to_px(last_seg_end, self.dpi); let line_h = self.calc_composed_paras_content_height( - &composed_paras, &cell.paragraphs, styles, + &composed_paras, + &cell.paragraphs, + styles, ); vpos_h.max(line_h) } else { self.calc_composed_paras_content_height( - &composed_paras, &cell.paragraphs, styles, + &composed_paras, + &cell.paragraphs, + styles, ) } }; @@ -501,20 +610,38 @@ impl LayoutEngine { height: inner_height, }; self.layout_vertical_cell_text( - tree, &mut cell_node, &composed_paras, &cell.paragraphs, - styles, &vert_inner_area, cell.vertical_align, cell.text_direction, - section_index, Some((para_index, control_index)), cell_idx, None, + tree, + &mut cell_node, + &composed_paras, + &cell.paragraphs, + styles, + &vert_inner_area, + cell.vertical_align, + cell.text_direction, + section_index, + Some((para_index, control_index)), + cell_idx, + None, ); // 세로쓰기 셀도 테두리를 엣지 그리드에 수집 if let Some(bs) = border_style { let cell_end_row_idx = cell_row + cell.row_span as usize; - let first_ri = render_rows.iter().position(|&r| r == cell_row) - .or_else(|| render_rows.iter().position(|&r| r > cell_row && r < cell_end_row_idx)); - let last_ri = render_rows.iter().rposition(|&r| r >= cell_row && r < cell_end_row_idx); + let first_ri = render_rows.iter().position(|&r| r == cell_row).or_else(|| { + render_rows + .iter() + .position(|&r| r > cell_row && r < cell_end_row_idx) + }); + let last_ri = render_rows + .iter() + .rposition(|&r| r >= cell_row && r < cell_end_row_idx); if let (Some(fri), Some(lri)) = (first_ri, last_ri) { collect_cell_borders( - &mut h_edges, &mut v_edges, - cell_col, fri, cell.col_span as usize, lri + 1 - fri, + &mut h_edges, + &mut v_edges, + cell_col, + fri, + cell.col_span as usize, + lri + 1 - fri, &bs.borders, ); } @@ -549,7 +676,11 @@ impl LayoutEngine { let mut has_preceding_text = false; // 분할 셀에서 중첩 표 오프셋 계산을 위한 누적 콘텐츠 높이 추적 let mut content_y_accum = 0.0f64; - for (cp_idx, (composed, para)) in composed_paras.iter().zip(cell.paragraphs.iter()).enumerate() { + for (cp_idx, (composed, para)) in composed_paras + .iter() + .zip(cell.paragraphs.iter()) + .enumerate() + { // 분할 행이면 해당 문단의 줄 범위 적용 let (start_line, end_line) = if let Some(ref ranges) = line_ranges { if cp_idx < ranges.len() { @@ -568,11 +699,17 @@ impl LayoutEngine { // 중첩 표 문단: offset 범위 안에 있으면 스킵, 아니면 렌더링 필요 if has_nested_table && is_in_split_row && split_start_content_offset > 0.0 { // content_y_accum으로 이 문단의 중첩 표가 완전히 offset 이전인지 판단 - let nested_h: f64 = para.controls.iter().map(|ctrl| { - if let Control::Table(t) = ctrl { - self.calc_nested_table_height(t, styles) - } else { 0.0 } - }).sum(); + let nested_h: f64 = para + .controls + .iter() + .map(|ctrl| { + if let Control::Table(t) = ctrl { + self.calc_nested_table_height(t, styles) + } else { + 0.0 + } + }) + .sum(); let nested_end = content_y_accum + nested_h; if nested_end <= split_start_content_offset { // 이전 페이지에서 완전히 렌더링됨 → content_y_accum 전진 후 스킵 @@ -602,20 +739,20 @@ impl LayoutEngine { let para_y_before_compose = para_y; // 인라인(treat_as_char) 컨트롤의 총 폭을 미리 계산 - let total_inline_width: f64 = para.controls.iter().map(|ctrl| { - match ctrl { + let total_inline_width: f64 = para + .controls + .iter() + .map(|ctrl| match ctrl { Control::Picture(pic) if pic.common.treat_as_char => { hwpunit_to_px(pic.common.width as i32, self.dpi) } Control::Shape(shape) if shape.common().treat_as_char => { hwpunit_to_px(shape.common().width as i32, self.dpi) } - Control::Equation(eq) => { - hwpunit_to_px(eq.common.width as i32, self.dpi) - } + Control::Equation(eq) => hwpunit_to_px(eq.common.width as i32, self.dpi), _ => 0.0, - } - }).sum(); + }) + .sum(); // 이 문단의 전체 텍스트 높이 계산 (분할 셀에서 콘텐츠 위치 추적용) // (보이지 않는 줄 포함 — content_y_accum은 실제 콘텐츠 위치를 추적) @@ -628,25 +765,42 @@ impl LayoutEngine { let line_based_h = if lc == 0 { sp_before + hwpunit_to_px(400, self.dpi) + sp_after } else { - composed.lines.iter().enumerate().map(|(li, line)| { - let h = hwpunit_to_px(line.line_height, self.dpi); - let ls = hwpunit_to_px(line.line_spacing, self.dpi); - let is_cell_last = is_lp && li + 1 == lc; - let mut lh = if !is_cell_last { h + ls } else { h }; - if li == 0 { lh += sp_before; } - if li == lc - 1 { lh += sp_after; } - lh - }).sum() + composed + .lines + .iter() + .enumerate() + .map(|(li, line)| { + let h = hwpunit_to_px(line.line_height, self.dpi); + let ls = hwpunit_to_px(line.line_spacing, self.dpi); + let is_cell_last = is_lp && li + 1 == lc; + let mut lh = if !is_cell_last { h + ls } else { h }; + if li == 0 { + lh += sp_before; + } + if li == lc - 1 { + lh += sp_after; + } + lh + }) + .sum() }; // 중첩 표가 있으면 실제 높이로 대체 - let nested_h: f64 = para.controls.iter().map(|ctrl| { - if let Control::Table(t) = ctrl { - self.calc_nested_table_height(t, styles) - } else { - 0.0 - } - }).sum(); - if nested_h > 0.0 { nested_h.max(line_based_h) } else { line_based_h } + let nested_h: f64 = para + .controls + .iter() + .map(|ctrl| { + if let Control::Table(t) = ctrl { + self.calc_nested_table_height(t, styles) + } else { + 0.0 + } + }) + .sum(); + if nested_h > 0.0 { + nested_h.max(line_based_h) + } else { + line_based_h + } } else { 0.0 }; @@ -664,14 +818,19 @@ impl LayoutEngine { para_y, start_line, end_line, - section_index, cp_idx, + section_index, + cp_idx, Some(cell_context.clone()), is_last_para, 0.0, - None, Some(para), Some(bin_data_content), + None, + Some(para), + Some(bin_data_content), ); - let has_visible_text = composed.lines.iter() + let has_visible_text = composed + .lines + .iter() .any(|line| line.runs.iter().any(|run| !run.text.trim().is_empty())); if has_visible_text { has_preceding_text = true; @@ -685,7 +844,8 @@ impl LayoutEngine { // 이 문단의 컨트롤(이미지/도형/중첩테이블) 배치 // 제목행 반복 셀에서는 컨트롤을 건너뜀 (이미지/도형 중복 방지) if !is_repeated_header_cell { - let para_alignment = styles.para_styles + let para_alignment = styles + .para_styles .get(para.para_shape_id as usize) .map(|s| s.alignment) .unwrap_or(Alignment::Left); @@ -708,32 +868,61 @@ impl LayoutEngine { let pic_w = hwpunit_to_px(pic.common.width as i32, self.dpi); // layout_composed_paragraph에서 텍스트 흐름 안에 렌더링됐는지 확인: // 이미지 위치가 실제 run 범위에 포함될 때만 스킵 - let will_render_inline = composed.tac_controls.iter().any(|&(abs_pos, _, ci)| { - ci == ctrl_idx && composed.lines.iter().any(|line| { - let line_chars: usize = line.runs.iter().map(|r| r.text.chars().count()).sum(); - abs_pos >= line.char_start && abs_pos < line.char_start + line_chars - }) - }); + let will_render_inline = + composed.tac_controls.iter().any(|&(abs_pos, _, ci)| { + ci == ctrl_idx + && composed.lines.iter().any(|line| { + let line_chars: usize = line + .runs + .iter() + .map(|r| r.text.chars().count()) + .sum(); + abs_pos >= line.char_start + && abs_pos < line.char_start + line_chars + }) + }); if !will_render_inline { // 단독 이미지(텍스트 없는 문단): 직접 렌더링 - let pic_h = hwpunit_to_px(pic.common.height as i32, self.dpi); + let pic_h = + hwpunit_to_px(pic.common.height as i32, self.dpi); let pic_area = LayoutRect { x: inline_x, y: para_y_before_compose, width: pic_w, height: pic_h, }; - self.layout_picture(tree, &mut cell_node, pic, &pic_area, bin_data_content, Alignment::Left, None, None, None); + self.layout_picture( + tree, + &mut cell_node, + pic, + &pic_area, + bin_data_content, + Alignment::Left, + None, + None, + None, + ); } inline_x += pic_w; } else { // 비인라인 이미지: 기존 동작 let pic_area = LayoutRect { y: para_y, - height: (inner_area.height - (para_y - inner_area.y)).max(0.0), + height: (inner_area.height - (para_y - inner_area.y)) + .max(0.0), ..inner_area }; - self.layout_picture(tree, &mut cell_node, pic, &pic_area, bin_data_content, para_alignment, None, None, None); + self.layout_picture( + tree, + &mut cell_node, + pic, + &pic_area, + bin_data_content, + para_alignment, + None, + None, + None, + ); let pic_h = hwpunit_to_px(pic.common.height as i32, self.dpi); para_y += pic_h; } @@ -742,18 +931,37 @@ impl LayoutEngine { Control::Shape(shape) => { if shape.common().treat_as_char { // 인라인 도형: 순차 X 위치로 배치 - let shape_w = hwpunit_to_px(shape.common().width as i32, self.dpi); + let shape_w = + hwpunit_to_px(shape.common().width as i32, self.dpi); let shape_area = LayoutRect { x: inline_x, y: para_y_before_compose, width: shape_w, height: inner_area.height, }; - self.layout_cell_shape(tree, &mut cell_node, shape, &shape_area, para_y_before_compose, Alignment::Left, styles, bin_data_content); + self.layout_cell_shape( + tree, + &mut cell_node, + shape, + &shape_area, + para_y_before_compose, + Alignment::Left, + styles, + bin_data_content, + ); inline_x += shape_w; } else { // 비인라인 도형: 기존 동작 - self.layout_cell_shape(tree, &mut cell_node, shape, &inner_area, para_y, para_alignment, styles, bin_data_content); + self.layout_cell_shape( + tree, + &mut cell_node, + shape, + &inner_area, + para_y, + para_alignment, + styles, + bin_data_content, + ); } } Control::Equation(eq) => { @@ -766,14 +974,22 @@ impl LayoutEngine { (x, para_y_before_compose) }; - let tokens = super::super::equation::tokenizer::tokenize(&eq.script); - let ast = super::super::equation::parser::EqParser::new(tokens).parse(); + let tokens = + super::super::equation::tokenizer::tokenize(&eq.script); + let ast = + super::super::equation::parser::EqParser::new(tokens).parse(); let font_size_px = hwpunit_to_px(eq.font_size as i32, self.dpi); - let layout_box = super::super::equation::layout::EqLayout::new(font_size_px).layout(&ast); - let color_str = super::super::equation::svg_render::eq_color_to_svg(eq.color); - let svg_content = super::super::equation::svg_render::render_equation_svg( - &layout_box, &color_str, font_size_px, - ); + let layout_box = + super::super::equation::layout::EqLayout::new(font_size_px) + .layout(&ast); + let color_str = + super::super::equation::svg_render::eq_color_to_svg(eq.color); + let svg_content = + super::super::equation::svg_render::render_equation_svg( + &layout_box, + &color_str, + font_size_px, + ); let eq_node = RenderNode::new( tree.next_id(), @@ -805,24 +1021,32 @@ impl LayoutEngine { // 중첩 표가 split_start_content_offset 이전에 완전히 끝나면 스킵 // (LINE_SEG.lh에 이미 포함되므로 content_y_accum에 별도 추가 불필요) - if split_start_content_offset > 0.0 && nested_content_end <= split_start_content_offset { + if split_start_content_offset > 0.0 + && nested_content_end <= split_start_content_offset + { continue; } // 중첩 표가 split_end_content_limit 이후에 시작하면 스킵 - if split_end_content_limit > 0.0 && nested_content_start >= split_end_content_limit { + if split_end_content_limit > 0.0 + && nested_content_start >= split_end_content_limit + { continue; } // 중첩 표 내에서의 오프셋 (연속 페이지: 표 시작이 오프셋 이전) - let offset_into_table = if split_start_content_offset > nested_content_start { - (split_start_content_offset - nested_content_start).min(nested_h) - } else { - 0.0 - }; + let offset_into_table = + if split_start_content_offset > nested_content_start { + (split_start_content_offset - nested_content_start) + .min(nested_h) + } else { + 0.0 + }; // 중첩 표에 할당 가능한 공간 계산 let visible_space = if split_end_content_limit > 0.0 { - let end_in_table = (split_end_content_limit - nested_content_start).min(nested_h); + let end_in_table = (split_end_content_limit + - nested_content_start) + .min(nested_h); (end_in_table - offset_into_table).max(0.0) } else { nested_h - offset_into_table @@ -831,12 +1055,25 @@ impl LayoutEngine { // 행 범위 계산: 보이는 부분에 해당하는 행만 렌더링 let ncol = nested_table.col_count as usize; let nrow = nested_table.row_count as usize; - let nrow_heights = self.resolve_row_heights(nested_table, ncol, nrow, None, styles); - let ncell_spacing = hwpunit_to_px(nested_table.cell_spacing as i32, self.dpi); - let split_info = calc_nested_split_rows(&nrow_heights, ncell_spacing, offset_into_table, visible_space); + let nrow_heights = self.resolve_row_heights( + nested_table, + ncol, + nrow, + None, + styles, + ); + let ncell_spacing = + hwpunit_to_px(nested_table.cell_spacing as i32, self.dpi); + let split_info = calc_nested_split_rows( + &nrow_heights, + ncell_spacing, + offset_into_table, + visible_space, + ); // 전체 행이 모두 보이면 split 없이, 아니면 행 범위 필터 적용 - let need_split = split_info.start_row > 0 || split_info.end_row < nrow; + let need_split = + split_info.start_row > 0 || split_info.end_row < nrow; let nested_y = if has_preceding_text { para_y @@ -850,7 +1087,10 @@ impl LayoutEngine { for run in &line.runs { if !run.text.is_empty() { let ts = resolved_to_text_style( - styles, run.char_style_id, run.lang_index); + styles, + run.char_style_id, + run.lang_index, + ); text_w += estimate_text_width(&run.text, &ts); } } @@ -875,14 +1115,27 @@ impl LayoutEngine { }); new_ctx }); - let split_ref = if need_split { Some(&split_info) } else { None }; + let split_ref = + if need_split { Some(&split_info) } else { None }; let table_h_rendered = self.layout_table( - tree, &mut cell_node, nested_table, - section_index, styles, &ctrl_area, nested_y, - bin_data_content, None, 1, - None, para_alignment, + tree, + &mut cell_node, + nested_table, + section_index, + styles, + &ctrl_area, + nested_y, + bin_data_content, + None, + 1, + None, + para_alignment, nested_ctx, - 0.0, 0.0, None, split_ref, None, + 0.0, + 0.0, + None, + split_ref, + None, ); // 렌더링된 높이만큼 para_y 전진 para_y = nested_y + table_h_rendered; @@ -897,7 +1150,8 @@ impl LayoutEngine { } else { inner_area.y }; - let available_h = (inner_area.height - (nested_y - inner_area.y)).max(0.0); + let available_h = + (inner_area.height - (nested_y - inner_area.y)).max(0.0); // TAC(글자처럼 취급) 표: 앞 텍스트 너비만큼 x 오프셋 적용 let tac_text_offset = if nested_table.common.treat_as_char { let mut text_w = 0.0; @@ -905,7 +1159,10 @@ impl LayoutEngine { for run in &line.runs { if !run.text.is_empty() { let ts = resolved_to_text_style( - styles, run.char_style_id, run.lang_index); + styles, + run.char_style_id, + run.lang_index, + ); text_w += estimate_text_width(&run.text, &ts); } } @@ -925,13 +1182,30 @@ impl LayoutEngine { let split_info = if nested_h > available_h + 0.5 { let ncol = nested_table.col_count as usize; let nrow = nested_table.row_count as usize; - let nrow_heights = self.resolve_row_heights(nested_table, ncol, nrow, None, styles); - let ncell_spacing = hwpunit_to_px(nested_table.cell_spacing as i32, self.dpi); - Some(calc_nested_split_rows(&nrow_heights, ncell_spacing, 0.0, available_h)) + let nrow_heights = self.resolve_row_heights( + nested_table, + ncol, + nrow, + None, + styles, + ); + let ncell_spacing = hwpunit_to_px( + nested_table.cell_spacing as i32, + self.dpi, + ); + Some(calc_nested_split_rows( + &nrow_heights, + ncell_spacing, + 0.0, + available_h, + )) } else { None }; - let split_ref = split_info.as_ref().filter(|s| s.start_row > 0 || s.end_row < nested_table.row_count as usize); + let split_ref = split_info.as_ref().filter(|s| { + s.start_row > 0 + || s.end_row < nested_table.row_count as usize + }); let nested_ctx = cell_context_opt.as_ref().map(|ctx| { let mut new_ctx = ctx.clone(); @@ -944,12 +1218,24 @@ impl LayoutEngine { new_ctx }); let table_h_rendered = self.layout_table( - tree, &mut cell_node, nested_table, - section_index, styles, &ctrl_area, nested_y, - bin_data_content, None, 1, - None, para_alignment, + tree, + &mut cell_node, + nested_table, + section_index, + styles, + &ctrl_area, + nested_y, + bin_data_content, + None, + 1, + None, + para_alignment, nested_ctx, - 0.0, 0.0, None, split_ref, None, + 0.0, + 0.0, + None, + split_ref, + None, ); para_y = nested_y + table_h_rendered; has_preceding_text = true; @@ -966,8 +1252,8 @@ impl LayoutEngine { if !is_last_para { if let Some(next_para) = cell.paragraphs.get(cp_idx + 1) { if let Some(next_seg) = next_para.line_segs.first() { - let next_vpos_y = text_y_start + hwpunit_to_px( - next_seg.vertical_pos, self.dpi); + let next_vpos_y = + text_y_start + hwpunit_to_px(next_seg.vertical_pos, self.dpi); para_y = para_y.max(next_vpos_y); } } @@ -988,13 +1274,22 @@ impl LayoutEngine { // 셀 테두리를 엣지 그리드에 수집 (인접 셀 중복 제거) if let Some(bs) = border_style { let cell_end_row_idx = cell_row + cell.row_span as usize; - let first_ri = render_rows.iter().position(|&r| r == cell_row) - .or_else(|| render_rows.iter().position(|&r| r > cell_row && r < cell_end_row_idx)); - let last_ri = render_rows.iter().rposition(|&r| r >= cell_row && r < cell_end_row_idx); + let first_ri = render_rows.iter().position(|&r| r == cell_row).or_else(|| { + render_rows + .iter() + .position(|&r| r > cell_row && r < cell_end_row_idx) + }); + let last_ri = render_rows + .iter() + .rposition(|&r| r >= cell_row && r < cell_end_row_idx); if let (Some(fri), Some(lri)) = (first_ri, last_ri) { collect_cell_borders( - &mut h_edges, &mut v_edges, - cell_col, fri, cell.col_span as usize, lri + 1 - fri, + &mut h_edges, + &mut v_edges, + cell_col, + fri, + cell.col_span as usize, + lri + 1 - fri, &bs.borders, ); } @@ -1005,11 +1300,23 @@ impl LayoutEngine { // 엣지 기반 테두리 렌더링 table_node.children.extend(render_edge_borders( - tree, &h_edges, &v_edges, &row_col_x, &grid_row_y, table_x, table_y, + tree, + &h_edges, + &v_edges, + &row_col_x, + &grid_row_y, + table_x, + table_y, )); if self.show_transparent_borders.get() { table_node.children.extend(render_transparent_borders( - tree, &h_edges, &v_edges, &row_col_x, &grid_row_y, table_x, table_y, + tree, + &h_edges, + &v_edges, + &row_col_x, + &grid_row_y, + table_x, + table_y, )); } @@ -1029,8 +1336,14 @@ impl LayoutEngine { if render_top_caption { if let Some(ref caption) = table.caption { self.layout_caption( - tree, col_node, caption, styles, col_area, - table_x, table_width, y_start, + tree, + col_node, + caption, + styles, + col_area, + table_x, + table_width, + y_start, &mut self.auto_counter.borrow_mut(), cap_cell_ctx.clone(), ); @@ -1038,13 +1351,22 @@ impl LayoutEngine { } if render_bottom_caption { if let Some(ref caption) = table.caption { - let host_line_spacing = para.line_segs.first() + let host_line_spacing = para + .line_segs + .first() .map(|seg| hwpunit_to_px(seg.line_spacing, self.dpi)) .unwrap_or(0.0); - let caption_y = table_y + partial_table_height + host_line_spacing + caption_spacing; + let caption_y = + table_y + partial_table_height + host_line_spacing + caption_spacing; self.layout_caption( - tree, col_node, caption, styles, col_area, - table_x, table_width, caption_y, + tree, + col_node, + caption, + styles, + col_area, + table_x, + table_width, + caption_y, &mut self.auto_counter.borrow_mut(), cap_cell_ctx.clone(), ); @@ -1060,12 +1382,22 @@ impl LayoutEngine { }; let cap_y = match caption.vert_align { CaptionVertAlign::Top => table_y, - CaptionVertAlign::Center => table_y + (partial_table_height - caption_height).max(0.0) / 2.0, - CaptionVertAlign::Bottom => table_y + (partial_table_height - caption_height).max(0.0), + CaptionVertAlign::Center => { + table_y + (partial_table_height - caption_height).max(0.0) / 2.0 + } + CaptionVertAlign::Bottom => { + table_y + (partial_table_height - caption_height).max(0.0) + } }; self.layout_caption( - tree, col_node, caption, styles, col_area, - cap_x, cap_width_px, cap_y, + tree, + col_node, + caption, + styles, + col_area, + cap_x, + cap_width_px, + cap_y, &mut self.auto_counter.borrow_mut(), cap_cell_ctx.clone(), ); @@ -1073,12 +1405,25 @@ impl LayoutEngine { } let caption_total = if render_top_caption { - caption_height + if caption_height > 0.0 { caption_spacing } else { 0.0 } + caption_height + + if caption_height > 0.0 { + caption_spacing + } else { + 0.0 + } } else if render_bottom_caption { - let host_line_spacing = para.line_segs.first() + let host_line_spacing = para + .line_segs + .first() .map(|seg| hwpunit_to_px(seg.line_spacing, self.dpi)) .unwrap_or(0.0); - caption_height + host_line_spacing + if caption_height > 0.0 { caption_spacing } else { 0.0 } + caption_height + + host_line_spacing + + if caption_height > 0.0 { + caption_spacing + } else { + 0.0 + } } else { // Left/Right 캡션은 표 높이에 영향 없음 0.0 diff --git a/src/renderer/layout/tests.rs b/src/renderer/layout/tests.rs index c42bed24..5f681c32 100644 --- a/src/renderer/layout/tests.rs +++ b/src/renderer/layout/tests.rs @@ -1,13 +1,13 @@ +use super::super::page_layout::PageLayoutInfo; +use super::super::pagination::{ColumnContent, PageContent, PageItem}; +use super::text_measurement::estimate_text_width; +use super::utils::{expand_numbering_format, numbering_format_to_number_format}; use super::*; -use crate::model::paragraph::{Paragraph, LineSeg, CharShapeRef}; -use crate::model::page::{PageDef, ColumnDef}; +use crate::model::page::{ColumnDef, PageDef}; +use crate::model::paragraph::{CharShapeRef, LineSeg, Paragraph}; use crate::model::style::{Numbering, NumberingHead}; use crate::renderer::composer::compose_paragraph; use crate::renderer::style_resolver::ResolvedStyleSet; -use super::super::pagination::{PageContent, ColumnContent, PageItem}; -use super::super::page_layout::PageLayoutInfo; -use super::utils::{expand_numbering_format, numbering_format_to_number_format}; -use super::text_measurement::estimate_text_width; fn a4_page_def() -> PageDef { PageDef { @@ -27,10 +27,7 @@ fn a4_page_def() -> PageDef { #[test] fn test_build_empty_page() { let engine = LayoutEngine::with_default_dpi(); - let layout = PageLayoutInfo::from_page_def_default( - &a4_page_def(), - &ColumnDef::default(), - ); + let layout = PageLayoutInfo::from_page_def_default(&a4_page_def(), &ColumnDef::default()); let page_content = PageContent { page_index: 0, page_number: 0, @@ -39,12 +36,28 @@ fn test_build_empty_page() { column_contents: Vec::new(), active_header: None, active_footer: None, - page_number_pos: None, page_hide: None, + page_number_pos: None, + page_hide: None, footnotes: Vec::new(), - active_master_page: None, extra_master_pages: Vec::new(), + active_master_page: None, + extra_master_pages: Vec::new(), }; let styles = ResolvedStyleSet::default(); - let tree = engine.build_render_tree(&page_content, &[], &[], &[], &[], &styles, &FootnoteShape::default(), &[], None, &[], None, 0, &[]); + let tree = engine.build_render_tree( + &page_content, + &[], + &[], + &[], + &[], + &styles, + &FootnoteShape::default(), + &[], + None, + &[], + None, + 0, + &[], + ); // 페이지 노드 + 배경 + 머리말 + 본문 + 각주 + 꼬리말 assert!(tree.root.children.len() >= 4); } @@ -52,10 +65,7 @@ fn test_build_empty_page() { #[test] fn test_build_page_with_paragraph() { let engine = LayoutEngine::with_default_dpi(); - let layout = PageLayoutInfo::from_page_def_default( - &a4_page_def(), - &ColumnDef::default(), - ); + let layout = PageLayoutInfo::from_page_def_default(&a4_page_def(), &ColumnDef::default()); let paragraphs = vec![Paragraph { text: "안녕하세요".to_string(), @@ -84,16 +94,36 @@ fn test_build_page_with_paragraph() { }], active_header: None, active_footer: None, - page_number_pos: None, page_hide: None, + page_number_pos: None, + page_hide: None, footnotes: Vec::new(), - active_master_page: None, extra_master_pages: Vec::new(), + active_master_page: None, + extra_master_pages: Vec::new(), }; - let tree = engine.build_render_tree(&page_content, ¶graphs, ¶graphs, ¶graphs, &composed, &styles, &FootnoteShape::default(), &[], None, &[], None, 0, &[]); + let tree = engine.build_render_tree( + &page_content, + ¶graphs, + ¶graphs, + ¶graphs, + &composed, + &styles, + &FootnoteShape::default(), + &[], + None, + &[], + None, + 0, + &[], + ); assert!(tree.needs_render()); // Body 노드 찾기 - let body = tree.root.children.iter().find(|n| matches!(n.node_type, RenderNodeType::Body { .. })); + let body = tree + .root + .children + .iter() + .find(|n| matches!(n.node_type, RenderNodeType::Body { .. })); assert!(body.is_some()); let body = body.unwrap(); // Column 노드가 있어야 함 @@ -105,18 +135,21 @@ fn test_layout_with_composed_styles() { use crate::renderer::style_resolver::ResolvedCharStyle; let engine = LayoutEngine::with_default_dpi(); - let layout = PageLayoutInfo::from_page_def_default( - &a4_page_def(), - &ColumnDef::default(), - ); + let layout = PageLayoutInfo::from_page_def_default(&a4_page_def(), &ColumnDef::default()); let paragraphs = vec![Paragraph { text: "AAABBB".to_string(), char_offsets: vec![0, 1, 2, 3, 4, 5], char_count: 7, char_shapes: vec![ - CharShapeRef { start_pos: 0, char_shape_id: 0 }, - CharShapeRef { start_pos: 3, char_shape_id: 1 }, + CharShapeRef { + start_pos: 0, + char_shape_id: 0, + }, + CharShapeRef { + start_pos: 3, + char_shape_id: 1, + }, ], line_segs: vec![LineSeg { line_height: 800, @@ -164,15 +197,34 @@ fn test_layout_with_composed_styles() { }], active_header: None, active_footer: None, - page_number_pos: None, page_hide: None, + page_number_pos: None, + page_hide: None, footnotes: Vec::new(), - active_master_page: None, extra_master_pages: Vec::new(), + active_master_page: None, + extra_master_pages: Vec::new(), }; - let tree = engine.build_render_tree(&page_content, ¶graphs, ¶graphs, ¶graphs, &composed, &styles, &FootnoteShape::default(), &[], None, &[], None, 0, &[]); + let tree = engine.build_render_tree( + &page_content, + ¶graphs, + ¶graphs, + ¶graphs, + &composed, + &styles, + &FootnoteShape::default(), + &[], + None, + &[], + None, + 0, + &[], + ); // Body > Column > TextLine 찾기 - let body = tree.root.children.iter() + let body = tree + .root + .children + .iter() .find(|n| matches!(n.node_type, RenderNodeType::Body { .. })) .unwrap(); let col = &body.children[0]; @@ -211,18 +263,21 @@ fn test_layout_multi_run_x_position() { use crate::renderer::style_resolver::ResolvedCharStyle; let engine = LayoutEngine::with_default_dpi(); - let layout = PageLayoutInfo::from_page_def_default( - &a4_page_def(), - &ColumnDef::default(), - ); + let layout = PageLayoutInfo::from_page_def_default(&a4_page_def(), &ColumnDef::default()); let paragraphs = vec![Paragraph { text: "AB가나".to_string(), char_offsets: vec![0, 1, 2, 3], char_count: 5, char_shapes: vec![ - CharShapeRef { start_pos: 0, char_shape_id: 0 }, - CharShapeRef { start_pos: 2, char_shape_id: 1 }, + CharShapeRef { + start_pos: 0, + char_shape_id: 0, + }, + CharShapeRef { + start_pos: 2, + char_shape_id: 1, + }, ], line_segs: vec![LineSeg { line_height: 400, @@ -235,8 +290,14 @@ fn test_layout_multi_run_x_position() { let composed: Vec<_> = paragraphs.iter().map(|p| compose_paragraph(p)).collect(); let styles = ResolvedStyleSet { char_styles: vec![ - ResolvedCharStyle { font_size: 16.0, ..Default::default() }, - ResolvedCharStyle { font_size: 16.0, ..Default::default() }, + ResolvedCharStyle { + font_size: 16.0, + ..Default::default() + }, + ResolvedCharStyle { + font_size: 16.0, + ..Default::default() + }, ], para_styles: Vec::new(), border_styles: Vec::new(), @@ -258,14 +319,33 @@ fn test_layout_multi_run_x_position() { }], active_header: None, active_footer: None, - page_number_pos: None, page_hide: None, + page_number_pos: None, + page_hide: None, footnotes: Vec::new(), - active_master_page: None, extra_master_pages: Vec::new(), + active_master_page: None, + extra_master_pages: Vec::new(), }; - let tree = engine.build_render_tree(&page_content, ¶graphs, ¶graphs, ¶graphs, &composed, &styles, &FootnoteShape::default(), &[], None, &[], None, 0, &[]); + let tree = engine.build_render_tree( + &page_content, + ¶graphs, + ¶graphs, + ¶graphs, + &composed, + &styles, + &FootnoteShape::default(), + &[], + None, + &[], + None, + 0, + &[], + ); - let body = tree.root.children.iter() + let body = tree + .root + .children + .iter() .find(|n| matches!(n.node_type, RenderNodeType::Body { .. })) .unwrap(); let col = &body.children[0]; @@ -282,8 +362,8 @@ fn test_layout_multi_run_x_position() { #[test] fn test_resolved_to_text_style() { - use crate::renderer::style_resolver::ResolvedCharStyle; use crate::model::style::UnderlineType; + use crate::renderer::style_resolver::ResolvedCharStyle; let styles = ResolvedStyleSet { char_styles: vec![ResolvedCharStyle { @@ -345,7 +425,10 @@ fn test_resolved_to_text_style_missing_id() { #[test] fn test_estimate_text_width() { - let style = TextStyle { font_size: 16.0, ..Default::default() }; + let style = TextStyle { + font_size: 16.0, + ..Default::default() + }; // Latin characters: 0.5 * font_size each let w = estimate_text_width("AB", &style); @@ -363,19 +446,31 @@ fn test_estimate_text_width() { #[test] fn test_estimate_text_width_with_ratio() { // 장평 80%: 기본 폭의 80% - let style = TextStyle { font_size: 16.0, ratio: 0.8, ..Default::default() }; + let style = TextStyle { + font_size: 16.0, + ratio: 0.8, + ..Default::default() + }; let w = estimate_text_width("가나", &style); // base: 2 * 16.0 = 32.0, * 0.8 = 25.6 → round = 26.0 assert!((w - 26.0).abs() < 0.01); // 장평 150% - let style = TextStyle { font_size: 16.0, ratio: 1.5, ..Default::default() }; + let style = TextStyle { + font_size: 16.0, + ratio: 1.5, + ..Default::default() + }; let w = estimate_text_width("AB", &style); // base: 2 * 8.0 = 16.0, * 1.5 = 24.0 assert!((w - 24.0).abs() < 0.01); // 장평 100%: 기존과 동일 - let style = TextStyle { font_size: 16.0, ratio: 1.0, ..Default::default() }; + let style = TextStyle { + font_size: 16.0, + ratio: 1.0, + ..Default::default() + }; let w = estimate_text_width("가나", &style); assert!((w - 32.0).abs() < 0.01); } @@ -430,7 +525,10 @@ fn test_estimate_text_width_with_extra_spacing() { #[test] fn test_extra_spacing_zero_default() { // 기본값(0.0)에서는 기존 동작과 동일 - let style = TextStyle { font_size: 16.0, ..Default::default() }; + let style = TextStyle { + font_size: 16.0, + ..Default::default() + }; let w_no_extra = estimate_text_width("가나다", &style); let positions_no_extra = compute_char_positions("가나다", &style); @@ -452,7 +550,10 @@ fn test_extra_spacing_zero_default() { #[test] fn test_extra_word_spacing_no_effect_on_non_space() { // 공백 없는 텍스트에서 extra_word_spacing은 영향 없음 - let style_base = TextStyle { font_size: 16.0, ..Default::default() }; + let style_base = TextStyle { + font_size: 16.0, + ..Default::default() + }; let style_extra = TextStyle { font_size: 16.0, extra_word_spacing: 100.0, @@ -480,15 +581,12 @@ fn test_tab_not_affected_by_extra_spacing() { #[test] fn test_layout_table_basic() { - use crate::model::table::{Table, Cell}; use crate::model::control::Control; + use crate::model::table::{Cell, Table}; use crate::renderer::style_resolver::ResolvedBorderStyle; let engine = LayoutEngine::with_default_dpi(); - let layout = PageLayoutInfo::from_page_def_default( - &a4_page_def(), - &ColumnDef::default(), - ); + let layout = PageLayoutInfo::from_page_def_default(&a4_page_def(), &ColumnDef::default()); // 2x2 표가 있는 문단 (각 셀에 border_fill_id=1 설정) let table = Table { @@ -497,27 +595,59 @@ fn test_layout_table_basic() { row_sizes: vec![2, 2], // 행별 셀 수 cells: vec![ Cell { - col: 0, row: 0, col_span: 1, row_span: 1, - width: 3000, height: 1200, border_fill_id: 1, - paragraphs: vec![Paragraph { text: "A".to_string(), ..Default::default() }], + col: 0, + row: 0, + col_span: 1, + row_span: 1, + width: 3000, + height: 1200, + border_fill_id: 1, + paragraphs: vec![Paragraph { + text: "A".to_string(), + ..Default::default() + }], ..Default::default() }, Cell { - col: 1, row: 0, col_span: 1, row_span: 1, - width: 3000, height: 1200, border_fill_id: 1, - paragraphs: vec![Paragraph { text: "B".to_string(), ..Default::default() }], + col: 1, + row: 0, + col_span: 1, + row_span: 1, + width: 3000, + height: 1200, + border_fill_id: 1, + paragraphs: vec![Paragraph { + text: "B".to_string(), + ..Default::default() + }], ..Default::default() }, Cell { - col: 0, row: 1, col_span: 1, row_span: 1, - width: 3000, height: 1200, border_fill_id: 1, - paragraphs: vec![Paragraph { text: "C".to_string(), ..Default::default() }], + col: 0, + row: 1, + col_span: 1, + row_span: 1, + width: 3000, + height: 1200, + border_fill_id: 1, + paragraphs: vec![Paragraph { + text: "C".to_string(), + ..Default::default() + }], ..Default::default() }, Cell { - col: 1, row: 1, col_span: 1, row_span: 1, - width: 3000, height: 1200, border_fill_id: 1, - paragraphs: vec![Paragraph { text: "D".to_string(), ..Default::default() }], + col: 1, + row: 1, + col_span: 1, + row_span: 1, + width: 3000, + height: 1200, + border_fill_id: 1, + paragraphs: vec![Paragraph { + text: "D".to_string(), + ..Default::default() + }], ..Default::default() }, ], @@ -527,7 +657,10 @@ fn test_layout_table_basic() { let paragraphs = vec![Paragraph { text: String::new(), controls: vec![Control::Table(Box::new(table))], - line_segs: vec![LineSeg { line_height: 400, ..Default::default() }], + line_segs: vec![LineSeg { + line_height: 400, + ..Default::default() + }], ..Default::default() }]; @@ -547,7 +680,10 @@ fn test_layout_table_basic() { column_index: 0, items: vec![ PageItem::FullParagraph { para_index: 0 }, - PageItem::Table { para_index: 0, control_index: 0 }, + PageItem::Table { + para_index: 0, + control_index: 0, + }, ], zone_layout: None, zone_y_offset: 0.0, @@ -555,25 +691,48 @@ fn test_layout_table_basic() { }], active_header: None, active_footer: None, - page_number_pos: None, page_hide: None, + page_number_pos: None, + page_hide: None, footnotes: Vec::new(), - active_master_page: None, extra_master_pages: Vec::new(), + active_master_page: None, + extra_master_pages: Vec::new(), }; - let tree = engine.build_render_tree(&page_content, ¶graphs, ¶graphs, ¶graphs, &composed, &styles, &FootnoteShape::default(), &[], None, &[], None, 0, &[]); + let tree = engine.build_render_tree( + &page_content, + ¶graphs, + ¶graphs, + ¶graphs, + &composed, + &styles, + &FootnoteShape::default(), + &[], + None, + &[], + None, + 0, + &[], + ); // Body > Column 내에 Table 노드가 있어야 함 - let body = tree.root.children.iter() + let body = tree + .root + .children + .iter() .find(|n| matches!(n.node_type, RenderNodeType::Body { .. })) .unwrap(); let col = &body.children[0]; - let table_node = col.children.iter() + let table_node = col + .children + .iter() .find(|n| matches!(n.node_type, RenderNodeType::Table(_))) .expect("Table node should exist"); // 4개 셀 + 엣지 기반 테두리 Line 노드들 - let cell_count = table_node.children.iter() + let cell_count = table_node + .children + .iter() .filter(|c| matches!(c.node_type, RenderNodeType::TableCell(_))) .count(); assert_eq!(cell_count, 4); @@ -581,32 +740,67 @@ fn test_layout_table_basic() { // 엣지 기반 테두리: 표 노드의 직접 자식으로 Line 노드가 있어야 함 // 2x2 표: 수평 3줄 + 수직 3줄 = 6개 이상의 Line 노드 // (기본 Solid 테두리이므로 이중선/삼중선이 아니면 각 엣지당 1개) - let table_line_count = table_node.children.iter() + let table_line_count = table_node + .children + .iter() .filter(|c| matches!(c.node_type, RenderNodeType::Line(_))) .count(); - assert!(table_line_count >= 6, "표에 6개 이상의 엣지 테두리가 있어야 함 (실제: {})", table_line_count); + assert!( + table_line_count >= 6, + "표에 6개 이상의 엣지 테두리가 있어야 함 (실제: {})", + table_line_count + ); } #[test] fn test_layout_table_cell_positions() { - use crate::model::table::{Table, Cell}; use crate::model::control::Control; + use crate::model::table::{Cell, Table}; let engine = LayoutEngine::with_default_dpi(); - let layout = PageLayoutInfo::from_page_def_default( - &a4_page_def(), - &ColumnDef::default(), - ); + let layout = PageLayoutInfo::from_page_def_default(&a4_page_def(), &ColumnDef::default()); let table = Table { row_count: 2, col_count: 2, row_sizes: vec![2, 2], // 행별 셀 수 cells: vec![ - Cell { col: 0, row: 0, col_span: 1, row_span: 1, width: 3600, height: 720, ..Default::default() }, - Cell { col: 1, row: 0, col_span: 1, row_span: 1, width: 3600, height: 720, ..Default::default() }, - Cell { col: 0, row: 1, col_span: 1, row_span: 1, width: 3600, height: 720, ..Default::default() }, - Cell { col: 1, row: 1, col_span: 1, row_span: 1, width: 3600, height: 720, ..Default::default() }, + Cell { + col: 0, + row: 0, + col_span: 1, + row_span: 1, + width: 3600, + height: 720, + ..Default::default() + }, + Cell { + col: 1, + row: 0, + col_span: 1, + row_span: 1, + width: 3600, + height: 720, + ..Default::default() + }, + Cell { + col: 0, + row: 1, + col_span: 1, + row_span: 1, + width: 3600, + height: 720, + ..Default::default() + }, + Cell { + col: 1, + row: 1, + col_span: 1, + row_span: 1, + width: 3600, + height: 720, + ..Default::default() + }, ], ..Default::default() }; @@ -614,7 +808,10 @@ fn test_layout_table_cell_positions() { let paragraphs = vec![Paragraph { text: String::new(), controls: vec![Control::Table(Box::new(table))], - line_segs: vec![LineSeg { line_height: 400, ..Default::default() }], + line_segs: vec![LineSeg { + line_height: 400, + ..Default::default() + }], ..Default::default() }]; @@ -630,7 +827,10 @@ fn test_layout_table_cell_positions() { column_index: 0, items: vec![ PageItem::FullParagraph { para_index: 0 }, - PageItem::Table { para_index: 0, control_index: 0 }, + PageItem::Table { + para_index: 0, + control_index: 0, + }, ], zone_layout: None, zone_y_offset: 0.0, @@ -638,18 +838,39 @@ fn test_layout_table_cell_positions() { }], active_header: None, active_footer: None, - page_number_pos: None, page_hide: None, + page_number_pos: None, + page_hide: None, footnotes: Vec::new(), - active_master_page: None, extra_master_pages: Vec::new(), + active_master_page: None, + extra_master_pages: Vec::new(), }; - let tree = engine.build_render_tree(&page_content, ¶graphs, ¶graphs, ¶graphs, &composed, &styles, &FootnoteShape::default(), &[], None, &[], None, 0, &[]); + let tree = engine.build_render_tree( + &page_content, + ¶graphs, + ¶graphs, + ¶graphs, + &composed, + &styles, + &FootnoteShape::default(), + &[], + None, + &[], + None, + 0, + &[], + ); - let body = tree.root.children.iter() + let body = tree + .root + .children + .iter() .find(|n| matches!(n.node_type, RenderNodeType::Body { .. })) .unwrap(); let col = &body.children[0]; - let table_node = col.children.iter() + let table_node = col + .children + .iter() .find(|n| matches!(n.node_type, RenderNodeType::Table(_))) .unwrap(); @@ -711,22 +932,37 @@ fn test_numbering_state_advance() { fn test_expand_numbering_format_digit() { let numbering = Numbering { raw_data: None, - heads: [NumberingHead { number_format: 0, ..Default::default() }; 7], + heads: [NumberingHead { + number_format: 0, + ..Default::default() + }; 7], level_formats: [ - "^1.".to_string(), "^2.".to_string(), "^3)".to_string(), - String::new(), String::new(), String::new(), String::new(), + "^1.".to_string(), + "^2.".to_string(), + "^3)".to_string(), + String::new(), + String::new(), + String::new(), + String::new(), ], start_number: 0, level_start_numbers: [1, 1, 1, 1, 1, 1, 1], }; let counters = [3, 2, 1, 0, 0, 0, 0]; - let result = expand_numbering_format("^1.", &counters, &numbering, &numbering.level_start_numbers); + let result = + expand_numbering_format("^1.", &counters, &numbering, &numbering.level_start_numbers); assert_eq!(result, "3."); - let result = expand_numbering_format("^2.", &counters, &numbering, &numbering.level_start_numbers); + let result = + expand_numbering_format("^2.", &counters, &numbering, &numbering.level_start_numbers); assert_eq!(result, "2."); - let result = expand_numbering_format("(^3)", &counters, &numbering, &numbering.level_start_numbers); + let result = expand_numbering_format( + "(^3)", + &counters, + &numbering, + &numbering.level_start_numbers, + ); assert_eq!(result, "(1)"); } @@ -738,24 +974,45 @@ fn test_expand_numbering_format_hangul() { raw_data: None, heads, level_formats: [ - String::new(), "^2.".to_string(), String::new(), - String::new(), String::new(), String::new(), String::new(), + String::new(), + "^2.".to_string(), + String::new(), + String::new(), + String::new(), + String::new(), + String::new(), ], start_number: 0, level_start_numbers: [1, 1, 1, 1, 1, 1, 1], }; let counters = [1, 3, 0, 0, 0, 0, 0]; - let result = expand_numbering_format("^2.", &counters, &numbering, &numbering.level_start_numbers); + let result = + expand_numbering_format("^2.", &counters, &numbering, &numbering.level_start_numbers); assert_eq!(result, "다."); } #[test] fn test_numbering_format_to_number_format() { - assert!(matches!(numbering_format_to_number_format(0), NumFmt::Digit)); - assert!(matches!(numbering_format_to_number_format(1), NumFmt::CircledDigit)); - assert!(matches!(numbering_format_to_number_format(2), NumFmt::RomanUpper)); - assert!(matches!(numbering_format_to_number_format(8), NumFmt::HangulGaNaDa)); - assert!(matches!(numbering_format_to_number_format(255), NumFmt::Digit)); + assert!(matches!( + numbering_format_to_number_format(0), + NumFmt::Digit + )); + assert!(matches!( + numbering_format_to_number_format(1), + NumFmt::CircledDigit + )); + assert!(matches!( + numbering_format_to_number_format(2), + NumFmt::RomanUpper + )); + assert!(matches!( + numbering_format_to_number_format(8), + NumFmt::HangulGaNaDa + )); + assert!(matches!( + numbering_format_to_number_format(255), + NumFmt::Digit + )); } // ===================================================================== diff --git a/src/renderer/layout/text_measurement.rs b/src/renderer/layout/text_measurement.rs index e7bd2e0d..c6a399b8 100644 --- a/src/renderer/layout/text_measurement.rs +++ b/src/renderer/layout/text_measurement.rs @@ -1,8 +1,8 @@ //! 텍스트 폭 측정, 문자 클러스터 분할, CJK 판별 관련 함수 use super::super::font_metrics_data; -use super::super::{TextStyle, TabStop, TabLeaderInfo, hwpunit_to_px}; use super::super::style_resolver::ResolvedStyleSet; +use super::super::{hwpunit_to_px, TabLeaderInfo, TabStop, TextStyle}; use crate::model::style::UnderlineType; // ── TextMeasurer trait ────────────────────────────────────────────── @@ -35,7 +35,9 @@ fn build_cluster_len(chars: &[char]) -> Vec { ci += 1; if ci < char_count && is_hangul_jungseong(chars[ci]) { ci += 1; - if ci < char_count && is_hangul_jongseong(chars[ci]) { ci += 1; } + if ci < char_count && is_hangul_jongseong(chars[ci]) { + ci += 1; + } } cluster_len[start] = (ci - start) as u8; } else { @@ -48,9 +50,17 @@ fn build_cluster_len(chars: &[char]) -> Vec { /// 스타일에서 공통 파라미터 추출 (font_size, ratio, tab_w) fn style_params(style: &TextStyle) -> (f64, f64, f64) { - let font_size = if style.font_size > 0.0 { style.font_size } else { 12.0 }; + let font_size = if style.font_size > 0.0 { + style.font_size + } else { + 12.0 + }; let ratio = if style.ratio > 0.0 { style.ratio } else { 1.0 }; - let tab_w = if style.default_tab_width > 0.0 { style.default_tab_width } else { font_size * 4.0 }; + let tab_w = if style.default_tab_width > 0.0 { + style.default_tab_width + } else { + font_size * 4.0 + }; (font_size, ratio, tab_w) } @@ -82,7 +92,11 @@ pub(crate) fn find_next_tab_stop( return (available_width, 1, 0); // type=1(오른쪽), fill=0(없음) } // 기본 등간격 탭 - let tab_w = if default_tab_width > 0.0 { default_tab_width } else { 48.0 }; + let tab_w = if default_tab_width > 0.0 { + default_tab_width + } else { + 48.0 + }; let next = ((abs_x / tab_w).floor() + 1.0) * tab_w; (next, 0, 0) // type=0(왼쪽), fill=0(없음) } @@ -96,8 +110,12 @@ fn measure_segment_from( ) -> f64 { let mut w = 0.0; for i in start..chars.len() { - if chars[i] == '\t' { break; } - if cluster_len[i] == 0 { continue; } + if chars[i] == '\t' { + break; + } + if cluster_len[i] == 0 { + continue; + } w += char_width(i); } w @@ -111,9 +129,16 @@ pub fn extract_tab_leaders(text: &str, positions: &[f64], style: &TextStyle) -> /// 탭 리더 추출 (tab_extended 지원) /// tab_extended: HWPX 인라인 탭 또는 HWP 탭 확장 데이터 (ext[1] = leader/fill_type) pub fn extract_tab_leaders_with_extended( - text: &str, positions: &[f64], style: &TextStyle, tab_extended: &[[u16; 7]], + text: &str, + positions: &[f64], + style: &TextStyle, + tab_extended: &[[u16; 7]], ) -> Vec { - let tab_w = if style.default_tab_width > 0.0 { style.default_tab_width } else { 48.0 }; + let tab_w = if style.default_tab_width > 0.0 { + style.default_tab_width + } else { + 48.0 + }; let mut leaders = Vec::new(); let mut tab_idx = 0usize; // tab_extended 인덱스 for (i, c) in text.chars().enumerate() { @@ -132,8 +157,11 @@ pub fn extract_tab_leaders_with_extended( let tabdef_fill = if !style.tab_stops.is_empty() || style.auto_tab_right { let abs_before = style.line_x_offset + before_x; let (_, _, ft) = find_next_tab_stop( - abs_before, &style.tab_stops, tab_w, - style.auto_tab_right, style.available_width, + abs_before, + &style.tab_stops, + tab_w, + style.auto_tab_right, + style.available_width, ); ft } else { @@ -179,36 +207,58 @@ impl TextMeasurer for EmbeddedTextMeasurer { if c == '\u{2007}' { return font_size * 0.5 * ratio + style.letter_spacing + style.extra_char_spacing; } - let base_w = if let Some(w) = measure_char_width_embedded(&style.font_family, style.bold, style.italic, c, font_size) { + let base_w = if let Some(w) = measure_char_width_embedded( + &style.font_family, + style.bold, + style.italic, + c, + font_size, + ) { w - } else if cluster_len[i] > 1 || is_cjk_char(c) || is_fullwidth_symbol(c) { font_size } else { font_size * 0.5 }; + } else if cluster_len[i] > 1 || is_cjk_char(c) || is_fullwidth_symbol(c) { + font_size + } else { + font_size * 0.5 + }; let mut w = base_w * ratio + style.letter_spacing + style.extra_char_spacing; - if c == ' ' { w += style.extra_word_spacing; } + if c == ' ' { + w += style.extra_word_spacing; + } w }; let mut total = 0.0; for i in 0..char_count { let c = chars[i]; - if cluster_len[i] == 0 { continue; } + if cluster_len[i] == 0 { + continue; + } if c == '\t' { if has_custom_tabs { let abs_x = style.line_x_offset + total; let (tab_pos, tab_type, _) = find_next_tab_stop( - abs_x, &style.tab_stops, tab_w, - style.auto_tab_right, style.available_width, + abs_x, + &style.tab_stops, + tab_w, + style.auto_tab_right, + style.available_width, ); let rel_tab = tab_pos - style.line_x_offset; match tab_type { - 1 => { // 오른쪽 - let seg_w = measure_segment_from(&chars, &cluster_len, i + 1, &char_width); + 1 => { + // 오른쪽 + let seg_w = + measure_segment_from(&chars, &cluster_len, i + 1, &char_width); total = (rel_tab - seg_w).max(total); } - 2 => { // 가운데 - let seg_w = measure_segment_from(&chars, &cluster_len, i + 1, &char_width); + 2 => { + // 가운데 + let seg_w = + measure_segment_from(&chars, &cluster_len, i + 1, &char_width); total = (rel_tab - seg_w / 2.0).max(total); } - _ => { // 왼쪽(0), 소수점(3) → 왼쪽과 동일 처리 + _ => { + // 왼쪽(0), 소수점(3) → 왼쪽과 동일 처리 total = rel_tab.max(total); } } @@ -220,7 +270,9 @@ impl TextMeasurer for EmbeddedTextMeasurer { } continue; } - if cluster_len[i] == 0 { continue; } + if cluster_len[i] == 0 { + continue; + } total += char_width(i); } total.round() @@ -242,11 +294,23 @@ impl TextMeasurer for EmbeddedTextMeasurer { if c == '\u{2007}' { return font_size * 0.5 * ratio + style.letter_spacing + style.extra_char_spacing; } - let base_w = if let Some(w) = measure_char_width_embedded(&style.font_family, style.bold, style.italic, c, font_size) { + let base_w = if let Some(w) = measure_char_width_embedded( + &style.font_family, + style.bold, + style.italic, + c, + font_size, + ) { w - } else if cluster_len[i] > 1 || is_cjk_char(c) || is_fullwidth_symbol(c) { font_size } else { font_size * 0.5 }; + } else if cluster_len[i] > 1 || is_cjk_char(c) || is_fullwidth_symbol(c) { + font_size + } else { + font_size * 0.5 + }; let mut w = base_w * ratio + style.letter_spacing + style.extra_char_spacing; - if c == ' ' { w += style.extra_word_spacing; } + if c == ' ' { + w += style.extra_word_spacing; + } w }; @@ -265,15 +329,20 @@ impl TextMeasurer for EmbeddedTextMeasurer { let tab_type = ext[2]; let tab_target = x + tab_width_px; match tab_type { - 1 => { // 오른쪽 - let seg_w = measure_segment_from(&chars, &cluster_len, i + 1, &char_width); + 1 => { + // 오른쪽 + let seg_w = + measure_segment_from(&chars, &cluster_len, i + 1, &char_width); x = (tab_target - seg_w).max(x); } - 2 => { // 가운데 - let seg_w = measure_segment_from(&chars, &cluster_len, i + 1, &char_width); + 2 => { + // 가운데 + let seg_w = + measure_segment_from(&chars, &cluster_len, i + 1, &char_width); x = (tab_target - seg_w / 2.0).max(x); } - _ => { // 왼쪽(0) + _ => { + // 왼쪽(0) x = tab_target.max(x); } } @@ -281,24 +350,32 @@ impl TextMeasurer for EmbeddedTextMeasurer { } else if has_custom_tabs { let abs_x = style.line_x_offset + x; let (tab_pos, tab_type, _) = find_next_tab_stop( - abs_x, &style.tab_stops, tab_w, - style.auto_tab_right, style.available_width, + abs_x, + &style.tab_stops, + tab_w, + style.auto_tab_right, + style.available_width, ); let rel_tab = tab_pos - style.line_x_offset; match tab_type { - 1 => { // 오른쪽 - let seg_w = measure_segment_from(&chars, &cluster_len, i + 1, &char_width); + 1 => { + // 오른쪽 + let seg_w = + measure_segment_from(&chars, &cluster_len, i + 1, &char_width); if tab_type == 1 { eprintln!("[DEBUG_TAB_POS] RIGHT tab: abs_x={:.2}, tab_pos={:.2}, line_x_offset={:.2}, rel_tab={:.2}, seg_w={:.2}, avail_w={:.2}, result_x={:.2}", abs_x, tab_pos, style.line_x_offset, rel_tab, seg_w, style.available_width, (rel_tab - seg_w).max(x)); } x = (rel_tab - seg_w).max(x); } - 2 => { // 가운데 - let seg_w = measure_segment_from(&chars, &cluster_len, i + 1, &char_width); + 2 => { + // 가운데 + let seg_w = + measure_segment_from(&chars, &cluster_len, i + 1, &char_width); x = (rel_tab - seg_w / 2.0).max(x); } - _ => { // 왼쪽(0), 소수점(3) + _ => { + // 왼쪽(0), 소수점(3) x = rel_tab.max(x); } } @@ -328,9 +405,9 @@ impl TextMeasurer for EmbeddedTextMeasurer { #[cfg(target_arch = "wasm32")] mod wasm_internals { - use wasm_bindgen::prelude::*; - use std::cell::RefCell; use crate::renderer::TextStyle; + use std::cell::RefCell; + use wasm_bindgen::prelude::*; // globalThis.measureTextWidth(font, text) → width in pixels // editor.html/index.html의 에 정의된 글로벌 함수를 호출한다. @@ -357,7 +434,10 @@ mod wasm_internals { impl MeasureCache { fn new(capacity: usize) -> Self { - Self { entries: Vec::with_capacity(capacity), capacity } + Self { + entries: Vec::with_capacity(capacity), + capacity, + } } fn get(&mut self, key: u64) -> Option { @@ -387,8 +467,8 @@ mod wasm_internals { /// 캐시 키 생성: hash(measure_font + char) fn measure_cache_key(measure_font: &str, c: char) -> u64 { - use std::hash::{Hash, Hasher}; use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; let mut h = DefaultHasher::new(); measure_font.hash(&mut h); c.hash(&mut h); @@ -427,9 +507,18 @@ mod wasm_internals { /// 한컴 webhwp 방식 문자 폭 측정 (HWP 단위 양자화) /// /// 파이프라인: 내장 메트릭 → JS 1000px 측정 → font_size/1000 스케일링 → HWP 단위(×75) → 정수 반올림 → px - pub(super) fn measure_char_width_hwp(measure_font: &str, font_family: &str, bold: bool, italic: bool, c: char, hangul_width_hwp: i32, font_size: f64) -> f64 { + pub(super) fn measure_char_width_hwp( + measure_font: &str, + font_family: &str, + bold: bool, + italic: bool, + c: char, + hangul_width_hwp: i32, + font_size: f64, + ) -> f64 { // 1차: 내장 메트릭 (JS 브릿지 호출 불필요) - if let Some(w) = super::measure_char_width_embedded(font_family, bold, italic, c, font_size) { + if let Some(w) = super::measure_char_width_embedded(font_family, bold, italic, c, font_size) + { return w; } @@ -447,8 +536,16 @@ mod wasm_internals { /// 한글 '가' 대리 측정값 (HWP 단위, 정수) /// 내장 메트릭이 있으면 JS 호출 없이 반환. - pub(super) fn measure_hangul_width_hwp(measure_font: &str, font_family: &str, bold: bool, italic: bool, font_size: f64) -> i32 { - if let Some(w) = super::measure_char_width_embedded(font_family, bold, italic, '\u{AC00}', font_size) { + pub(super) fn measure_hangul_width_hwp( + measure_font: &str, + font_family: &str, + bold: bool, + italic: bool, + font_size: f64, + ) -> i32 { + if let Some(w) = + super::measure_char_width_embedded(font_family, bold, italic, '\u{AC00}', font_size) + { return (w * 75.0).round() as i32; } let raw_px = cached_js_measure(measure_font, '\u{AC00}'); @@ -472,7 +569,11 @@ impl TextMeasurer for WasmTextMeasurer { let (font_size, ratio, tab_w) = style_params(style); let measure_font = wasm_internals::build_1000pt_font_string(style); let hangul_hwp = wasm_internals::measure_hangul_width_hwp( - &measure_font, &style.font_family, style.bold, style.italic, font_size, + &measure_font, + &style.font_family, + style.bold, + style.italic, + font_size, ); let chars: Vec = text.chars().collect(); @@ -489,34 +590,48 @@ impl TextMeasurer for WasmTextMeasurer { hangul_hwp as f64 / 75.0 } else { wasm_internals::measure_char_width_hwp( - &measure_font, &style.font_family, style.bold, style.italic, - c, hangul_hwp, font_size, + &measure_font, + &style.font_family, + style.bold, + style.italic, + c, + hangul_hwp, + font_size, ) }; let mut w = char_px * ratio + style.letter_spacing + style.extra_char_spacing; - if c == ' ' { w += style.extra_word_spacing; } + if c == ' ' { + w += style.extra_word_spacing; + } w }; let mut total = 0.0; for i in 0..char_count { let c = chars[i]; - if cluster_len[i] == 0 { continue; } + if cluster_len[i] == 0 { + continue; + } if c == '\t' { if has_custom_tabs { let abs_x = style.line_x_offset + total; let (tab_pos, tab_type, _) = find_next_tab_stop( - abs_x, &style.tab_stops, tab_w, - style.auto_tab_right, style.available_width, + abs_x, + &style.tab_stops, + tab_w, + style.auto_tab_right, + style.available_width, ); let rel_tab = tab_pos - style.line_x_offset; match tab_type { 1 => { - let seg_w = measure_segment_from(&chars, &cluster_len, i + 1, &char_width); + let seg_w = + measure_segment_from(&chars, &cluster_len, i + 1, &char_width); total = (rel_tab - seg_w).max(total); } 2 => { - let seg_w = measure_segment_from(&chars, &cluster_len, i + 1, &char_width); + let seg_w = + measure_segment_from(&chars, &cluster_len, i + 1, &char_width); total = (rel_tab - seg_w / 2.0).max(total); } _ => { @@ -549,7 +664,11 @@ impl TextMeasurer for WasmTextMeasurer { let measure_font = wasm_internals::build_1000pt_font_string(style); let hangul_hwp = wasm_internals::measure_hangul_width_hwp( - &measure_font, &style.font_family, style.bold, style.italic, font_size, + &measure_font, + &style.font_family, + style.bold, + style.italic, + font_size, ); let char_width = |i: usize| -> f64 { @@ -561,12 +680,19 @@ impl TextMeasurer for WasmTextMeasurer { hangul_hwp as f64 / 75.0 } else { wasm_internals::measure_char_width_hwp( - &measure_font, &style.font_family, style.bold, style.italic, - c, hangul_hwp, font_size, + &measure_font, + &style.font_family, + style.bold, + style.italic, + c, + hangul_hwp, + font_size, ) }; let mut w = char_px * ratio + style.letter_spacing + style.extra_char_spacing; - if c == ' ' { w += style.extra_word_spacing; } + if c == ' ' { + w += style.extra_word_spacing; + } w }; @@ -580,17 +706,22 @@ impl TextMeasurer for WasmTextMeasurer { if has_custom_tabs { let abs_x = style.line_x_offset + x; let (tab_pos, tab_type, _) = find_next_tab_stop( - abs_x, &style.tab_stops, tab_w, - style.auto_tab_right, style.available_width, + abs_x, + &style.tab_stops, + tab_w, + style.auto_tab_right, + style.available_width, ); let rel_tab = tab_pos - style.line_x_offset; match tab_type { 1 => { - let seg_w = measure_segment_from(&chars, &cluster_len, i + 1, &char_width); + let seg_w = + measure_segment_from(&chars, &cluster_len, i + 1, &char_width); x = (rel_tab - seg_w).max(x); } 2 => { - let seg_w = measure_segment_from(&chars, &cluster_len, i + 1, &char_width); + let seg_w = + measure_segment_from(&chars, &cluster_len, i + 1, &char_width); x = (rel_tab - seg_w / 2.0).max(x); } _ => { @@ -617,14 +748,22 @@ impl TextMeasurer for WasmTextMeasurer { // ── 플랫폼별 기본 측정기 선택 ─────────────────────────────────────── #[cfg(target_arch = "wasm32")] -fn default_measurer() -> WasmTextMeasurer { WasmTextMeasurer } +fn default_measurer() -> WasmTextMeasurer { + WasmTextMeasurer +} #[cfg(not(target_arch = "wasm32"))] -fn default_measurer() -> EmbeddedTextMeasurer { EmbeddedTextMeasurer } +fn default_measurer() -> EmbeddedTextMeasurer { + EmbeddedTextMeasurer +} // ── 스타일 변환 ───────────────────────────────────────────────────── -pub(crate) fn resolved_to_text_style(styles: &ResolvedStyleSet, char_style_id: u32, lang_index: usize) -> TextStyle { +pub(crate) fn resolved_to_text_style( + styles: &ResolvedStyleSet, + char_style_id: u32, + lang_index: usize, +) -> TextStyle { if let Some(cs) = styles.char_styles.get(char_style_id as usize) { TextStyle { font_family: cs.font_family_for_lang(lang_index).to_string(), @@ -672,7 +811,13 @@ pub(crate) fn resolved_to_text_style(styles: &ResolvedStyleSet, char_style_id: u /// /// 내장 메트릭이 있으면 JS 브릿지 호출 없이 즉시 반환. /// 없으면 None을 반환하여 폴백 경로를 사용하게 한다. -fn measure_char_width_embedded(font_family: &str, bold: bool, italic: bool, c: char, font_size: f64) -> Option { +fn measure_char_width_embedded( + font_family: &str, + bold: bool, + italic: bool, + c: char, + font_size: f64, +) -> Option { // CSS font-family 체인에서 첫 번째 폰트명으로 메트릭 조회 let primary_name = font_family.split(',').next().unwrap_or(font_family).trim(); let mm = font_metrics_data::find_metric(primary_name, bold, italic)?; @@ -683,9 +828,11 @@ fn measure_char_width_embedded(font_family: &str, bold: bool, italic: bool, c: c let glyph_w = mm.metric.get_width(c)?; // 한컴은 스마트 따옴표, 가운뎃점 등을 반각으로 처리 // 폰트 메트릭에서 전각(em_size)으로 기록되어 있어도 em/2로 강제 - let is_halfwidth_punct = matches!(c, - '\u{2018}'..='\u{2027}' | // ''‚‛""„‟†‡•‣․‥…‧ 구두점/기호 - '\u{00B7}' // · MIDDLE DOT + let is_halfwidth_punct = matches!( + c, + '\u{2018}' + ..='\u{2027}' | // ''‚‛""„‟†‡•‣․‥…‧ 구두점/기호 + '\u{00B7}' // · MIDDLE DOT ); if is_halfwidth_punct && glyph_w >= mm.metric.em_size { mm.metric.em_size / 2 @@ -737,17 +884,27 @@ pub(crate) fn estimate_text_width_unrounded(text: &str, style: &TextStyle) -> f6 if c == '\u{2007}' { return font_size * 0.5 * ratio + style.letter_spacing + style.extra_char_spacing; } - let base_w = if let Some(w) = measure_char_width_embedded(&style.font_family, style.bold, style.italic, c, font_size) { + let base_w = if let Some(w) = + measure_char_width_embedded(&style.font_family, style.bold, style.italic, c, font_size) + { w - } else if cluster_len[i] > 1 || is_cjk_char(c) || is_fullwidth_symbol(c) { font_size } else { font_size * 0.5 }; + } else if cluster_len[i] > 1 || is_cjk_char(c) || is_fullwidth_symbol(c) { + font_size + } else { + font_size * 0.5 + }; let mut w = base_w * ratio + style.letter_spacing + style.extra_char_spacing; - if c == ' ' { w += style.extra_word_spacing; } + if c == ' ' { + w += style.extra_word_spacing; + } w }; let mut total = 0.0; for i in 0..char_count { - if cluster_len[i] == 0 { continue; } + if cluster_len[i] == 0 { + continue; + } let c = chars[i]; if c == '\t' { let abs_x = style.line_x_offset + total; @@ -786,11 +943,12 @@ pub(crate) fn is_cjk_char(c: char) -> bool { /// 한컴이 전각으로 처리하는 기호 (메트릭 폴백 시 font_size 사용) fn is_fullwidth_symbol(c: char) -> bool { - matches!(c, + matches!( + c, '\u{20A9}' | // ₩ WON SIGN '\u{20AC}' | // € EURO SIGN '\u{00A3}' | // £ POUND SIGN - '\u{00A5}' // ¥ YEN SIGN + '\u{00A5}' // ¥ YEN SIGN ) } @@ -849,7 +1007,8 @@ pub fn split_into_clusters(text: &str) -> Vec<(usize, String)> { /// - 괄호류: ( ) [ ] { } < > 〈 〉 《 》 「 」 『 』 【 】 /// - 문장부호: . , _ - ~ … ― ─ pub(crate) fn is_vertical_rotate_char(c: char) -> bool { - matches!(c, + matches!( + c, '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | '.' | ',' | '_' | '-' | '~' | '\u{2026}' // … (ellipsis) @@ -876,33 +1035,33 @@ pub(crate) fn is_vertical_rotate_char(c: char) -> bool { pub(crate) fn vertical_substitute_char(c: char) -> Option { match c { // 괄호류 - '(' | '\u{FF08}' => Some('\u{FE35}'), // ︵ - ')' | '\u{FF09}' => Some('\u{FE36}'), // ︶ - '{' | '\u{FF5B}' => Some('\u{FE37}'), // ︷ - '}' | '\u{FF5D}' => Some('\u{FE38}'), // ︸ - '[' | '\u{FF3B}' => Some('\u{FE39}'), // ︹ - ']' | '\u{FF3D}' => Some('\u{FE3A}'), // ︺ - '\u{3010}' => Some('\u{FE3B}'), // 【 → ︻ - '\u{3011}' => Some('\u{FE3C}'), // 】 → ︼ - '\u{3008}' => Some('\u{FE3F}'), // 〈 → ︿ - '\u{3009}' => Some('\u{FE40}'), // 〉 → ﹀ - '\u{300A}' => Some('\u{FE3D}'), // 《 → ︽ - '\u{300B}' => Some('\u{FE3E}'), // 》 → ︾ - '\u{300C}' => Some('\u{FE41}'), // 「 → ﹁ - '\u{300D}' => Some('\u{FE42}'), // 」 → ﹂ - '\u{300E}' => Some('\u{FE43}'), // 『 → ﹃ - '\u{300F}' => Some('\u{FE44}'), // 』 → ﹄ + '(' | '\u{FF08}' => Some('\u{FE35}'), // ︵ + ')' | '\u{FF09}' => Some('\u{FE36}'), // ︶ + '{' | '\u{FF5B}' => Some('\u{FE37}'), // ︷ + '}' | '\u{FF5D}' => Some('\u{FE38}'), // ︸ + '[' | '\u{FF3B}' => Some('\u{FE39}'), // ︹ + ']' | '\u{FF3D}' => Some('\u{FE3A}'), // ︺ + '\u{3010}' => Some('\u{FE3B}'), // 【 → ︻ + '\u{3011}' => Some('\u{FE3C}'), // 】 → ︼ + '\u{3008}' => Some('\u{FE3F}'), // 〈 → ︿ + '\u{3009}' => Some('\u{FE40}'), // 〉 → ﹀ + '\u{300A}' => Some('\u{FE3D}'), // 《 → ︽ + '\u{300B}' => Some('\u{FE3E}'), // 》 → ︾ + '\u{300C}' => Some('\u{FE41}'), // 「 → ﹁ + '\u{300D}' => Some('\u{FE42}'), // 」 → ﹂ + '\u{300E}' => Some('\u{FE43}'), // 『 → ﹃ + '\u{300F}' => Some('\u{FE44}'), // 』 → ﹄ // 대시/선 - '\u{2014}' => Some('\u{FE31}'), // — → ︱ (em dash) - '\u{2013}' => Some('\u{FE32}'), // – → ︲ (en dash) - '\u{2015}' => Some('\u{FE31}'), // ― → ︱ (horizontal bar) - '\u{2500}' => Some('\u{2502}'), // ─ → │ (box drawing) + '\u{2014}' => Some('\u{FE31}'), // — → ︱ (em dash) + '\u{2013}' => Some('\u{FE32}'), // – → ︲ (en dash) + '\u{2015}' => Some('\u{FE31}'), // ― → ︱ (horizontal bar) + '\u{2500}' => Some('\u{2502}'), // ─ → │ (box drawing) // 말줄임 - '\u{2026}' => Some('\u{FE19}'), // … → ︙ (vertical ellipsis) + '\u{2026}' => Some('\u{FE19}'), // … → ︙ (vertical ellipsis) // 물결표 - '~' => Some('\u{FE34}'), // ~ → ︴ (vertical wavy low line) + '~' => Some('\u{FE34}'), // ~ → ︴ (vertical wavy low line) // 밑줄 - '_' => Some('\u{FE33}'), // _ → ︳ (vertical low line) + '_' => Some('\u{FE33}'), // _ → ︳ (vertical low line) _ => None, } } @@ -928,13 +1087,17 @@ mod tests { let cluster_len = build_cluster_len(&chars); let mut total = 0.0; for i in 0..chars.len() { - if cluster_len[i] == 0 { continue; } + if cluster_len[i] == 0 { + continue; + } if chars[i] == '\t' { total = ((total / tab_w).floor() + 1.0) * tab_w; continue; } total += self.char_width * ratio + style.letter_spacing + style.extra_char_spacing; - if chars[i] == ' ' { total += style.extra_word_spacing; } + if chars[i] == ' ' { + total += style.extra_word_spacing; + } } total } @@ -957,7 +1120,9 @@ mod tests { continue; } x += self.char_width * ratio + style.letter_spacing + style.extra_char_spacing; - if chars[i] == ' ' { x += style.extra_word_spacing; } + if chars[i] == ' ' { + x += style.extra_word_spacing; + } positions.push(x); } positions @@ -969,7 +1134,10 @@ mod tests { #[test] fn test_mock_measurer_fixed_width() { let m = MockTextMeasurer { char_width: 10.0 }; - let style = TextStyle { font_size: 16.0, ..Default::default() }; + let style = TextStyle { + font_size: 16.0, + ..Default::default() + }; let w = m.estimate_text_width("ABC", &style); assert!((w - 30.0).abs() < 0.01, "expected 30.0, got {}", w); } @@ -977,7 +1145,10 @@ mod tests { #[test] fn test_mock_measurer_positions() { let m = MockTextMeasurer { char_width: 10.0 }; - let style = TextStyle { font_size: 16.0, ..Default::default() }; + let style = TextStyle { + font_size: 16.0, + ..Default::default() + }; let pos = m.compute_char_positions("AB", &style); assert_eq!(pos.len(), 3); assert!((pos[0]).abs() < 0.01); @@ -988,23 +1159,43 @@ mod tests { #[test] fn test_mock_measurer_ratio() { let m = MockTextMeasurer { char_width: 10.0 }; - let style = TextStyle { font_size: 16.0, ratio: 0.5, ..Default::default() }; + let style = TextStyle { + font_size: 16.0, + ratio: 0.5, + ..Default::default() + }; let w = m.estimate_text_width("AB", &style); - assert!((w - 10.0).abs() < 0.01, "expected 10.0 (2*10*0.5), got {}", w); + assert!( + (w - 10.0).abs() < 0.01, + "expected 10.0 (2*10*0.5), got {}", + w + ); } #[test] fn test_mock_measurer_letter_spacing() { let m = MockTextMeasurer { char_width: 10.0 }; - let style = TextStyle { font_size: 16.0, letter_spacing: 2.0, ..Default::default() }; + let style = TextStyle { + font_size: 16.0, + letter_spacing: 2.0, + ..Default::default() + }; let w = m.estimate_text_width("AB", &style); - assert!((w - 24.0).abs() < 0.01, "expected 24.0 (2*(10+2)), got {}", w); + assert!( + (w - 24.0).abs() < 0.01, + "expected 24.0 (2*(10+2)), got {}", + w + ); } #[test] fn test_mock_measurer_extra_word_spacing() { let m = MockTextMeasurer { char_width: 10.0 }; - let style = TextStyle { font_size: 16.0, extra_word_spacing: 5.0, ..Default::default() }; + let style = TextStyle { + font_size: 16.0, + extra_word_spacing: 5.0, + ..Default::default() + }; // "A B" = A(10) + space(10+5) + B(10) = 35 let w = m.estimate_text_width("A B", &style); assert!((w - 35.0).abs() < 0.01, "expected 35.0, got {}", w); @@ -1013,12 +1204,23 @@ mod tests { #[test] fn test_mock_measurer_tab() { let m = MockTextMeasurer { char_width: 10.0 }; - let style = TextStyle { font_size: 16.0, ..Default::default() }; + let style = TextStyle { + font_size: 16.0, + ..Default::default() + }; // tab_w = font_size * 4 = 64, "\tA" → tab snaps to 64, then A at 74 let pos = m.compute_char_positions("\tA", &style); assert_eq!(pos.len(), 3); - assert!((pos[1] - 64.0).abs() < 0.01, "tab should snap to 64, got {}", pos[1]); - assert!((pos[2] - 74.0).abs() < 0.01, "A should be at 74, got {}", pos[2]); + assert!( + (pos[1] - 64.0).abs() < 0.01, + "tab should snap to 64, got {}", + pos[1] + ); + assert!( + (pos[2] - 74.0).abs() < 0.01, + "A should be at 74, got {}", + pos[2] + ); } // ── EmbeddedTextMeasurer 테스트 ── @@ -1026,19 +1228,33 @@ mod tests { #[test] fn test_embedded_measurer_latin_heuristic() { let m = EmbeddedTextMeasurer; - let style = TextStyle { font_size: 16.0, ..Default::default() }; + let style = TextStyle { + font_size: 16.0, + ..Default::default() + }; // 기본 폰트("")는 내장 메트릭 없음 → 휴리스틱: Latin = font_size * 0.5 let w = m.estimate_text_width("AB", &style); - assert!((w - 16.0).abs() < 0.01, "expected 16.0 (2*8.0 heuristic), got {}", w); + assert!( + (w - 16.0).abs() < 0.01, + "expected 16.0 (2*8.0 heuristic), got {}", + w + ); } #[test] fn test_embedded_measurer_cjk_heuristic() { let m = EmbeddedTextMeasurer; - let style = TextStyle { font_size: 16.0, ..Default::default() }; + let style = TextStyle { + font_size: 16.0, + ..Default::default() + }; // 기본 폰트("")는 내장 메트릭 없음 → 휴리스틱: CJK = font_size let w = m.estimate_text_width("가나", &style); - assert!((w - 32.0).abs() < 0.01, "expected 32.0 (2*16.0 heuristic), got {}", w); + assert!( + (w - 32.0).abs() < 0.01, + "expected 32.0 (2*16.0 heuristic), got {}", + w + ); } #[test] @@ -1051,24 +1267,36 @@ mod tests { }; // 내장 메트릭이 있는 폰트: Latin 문자는 CJK보다 좁아야 함 let w = m.estimate_text_width("A", &style); - assert!(w > 0.0 && w < 16.0, "Latin 'A' should be narrower than CJK, got {}", w); + assert!( + w > 0.0 && w < 16.0, + "Latin 'A' should be narrower than CJK, got {}", + w + ); } #[test] fn test_embedded_matches_free_fn() { // 자유 함수 래퍼가 EmbeddedTextMeasurer로 위임하는지 확인 - let style = TextStyle { font_size: 16.0, ..Default::default() }; + let style = TextStyle { + font_size: 16.0, + ..Default::default() + }; let free_fn_result = estimate_text_width("ABC가나다", &style); let trait_result = EmbeddedTextMeasurer.estimate_text_width("ABC가나다", &style); assert!( (free_fn_result - trait_result).abs() < 0.01, - "free fn ({}) != trait ({})", free_fn_result, trait_result, + "free fn ({}) != trait ({})", + free_fn_result, + trait_result, ); } #[test] fn test_embedded_positions_match_free_fn() { - let style = TextStyle { font_size: 16.0, ..Default::default() }; + let style = TextStyle { + font_size: 16.0, + ..Default::default() + }; let free_fn_result = compute_char_positions("ABC", &style); let trait_result = EmbeddedTextMeasurer.compute_char_positions("ABC", &style); assert_eq!(free_fn_result.len(), trait_result.len()); diff --git a/src/renderer/layout/utils.rs b/src/renderer/layout/utils.rs index b99bae32..3d3a21a2 100644 --- a/src/renderer/layout/utils.rs +++ b/src/renderer/layout/utils.rs @@ -1,16 +1,22 @@ //! 유틸리티 함수 (BinData 검색, 번호 포맷, 도형 스타일 변환) -use crate::model::style::{HeadType, Numbering}; +use super::super::page_layout::LayoutRect; +use super::super::render_tree::*; +use super::super::{ + format_number, ArrowStyle, LineStyle, NumberFormat as NumFmt, PathCommand, ShapeStyle, + StrokeDash, +}; use crate::model::bin_data::BinDataContent; use crate::model::footnote::NumberFormat; -use super::super::render_tree::*; -use super::super::page_layout::LayoutRect; -use super::super::{ShapeStyle, LineStyle, PathCommand, StrokeDash, ArrowStyle, format_number, NumberFormat as NumFmt}; +use crate::model::style::{HeadType, Numbering}; /// bin_data_id(1-indexed 순번)로 BinDataContent를 찾는다. /// bin_data_id는 doc_info의 BinData 레코드 순번(1부터 시작)이며, /// BinDataContent 배열도 같은 순서로 저장되어 있다. -pub(crate) fn find_bin_data<'a>(bin_data_content: &'a [BinDataContent], bin_data_id: u16) -> Option<&'a BinDataContent> { +pub(crate) fn find_bin_data<'a>( + bin_data_content: &'a [BinDataContent], + bin_data_id: u16, +) -> Option<&'a BinDataContent> { if bin_data_id == 0 { return None; } @@ -19,7 +25,11 @@ pub(crate) fn find_bin_data<'a>(bin_data_content: &'a [BinDataContent], bin_data /// 문단의 실효 numbering_id를 반환한다. /// Outline 문단이고 para_style.numbering_id==0이면 구역의 outline_numbering_id로 fallback. -pub fn resolve_numbering_id(head_type: HeadType, para_numbering_id: u16, outline_numbering_id: u16) -> u16 { +pub fn resolve_numbering_id( + head_type: HeadType, + para_numbering_id: u16, + outline_numbering_id: u16, +) -> u16 { if para_numbering_id == 0 && head_type == HeadType::Outline { outline_numbering_id } else { @@ -68,26 +78,44 @@ pub(crate) fn expand_numbering_format( /// HWP 표 43 번호 형식 코드 → NumberFormat 변환 pub(crate) fn numbering_format_to_number_format(code: u8) -> NumFmt { match code { - 0 => NumFmt::Digit, // 1, 2, 3 - 1 => NumFmt::CircledDigit, // ①, ②, ③ - 2 => NumFmt::RomanUpper, // I, II, III - 3 => NumFmt::RomanLower, // i, ii, iii - 4 => NumFmt::LatinUpper, // A, B, C - 5 => NumFmt::LatinLower, // a, b, c - 8 => NumFmt::HangulGaNaDa, // 가, 나, 다 - 12 => NumFmt::HangulNumber, // 일, 이, 삼 - 13 => NumFmt::HanjaNumber, // 一, 二, 三 + 0 => NumFmt::Digit, // 1, 2, 3 + 1 => NumFmt::CircledDigit, // ①, ②, ③ + 2 => NumFmt::RomanUpper, // I, II, III + 3 => NumFmt::RomanLower, // i, ii, iii + 4 => NumFmt::LatinUpper, // A, B, C + 5 => NumFmt::LatinLower, // a, b, c + 8 => NumFmt::HangulGaNaDa, // 가, 나, 다 + 12 => NumFmt::HangulNumber, // 일, 이, 삼 + 13 => NumFmt::HanjaNumber, // 一, 二, 三 _ => NumFmt::Digit, } } /// 쪽 번호를 형식에 맞게 문자열로 변환 (mod.rs의 format_number 재사용) -pub(crate) fn format_page_number(page_num: u32, format: u8, prefix_char: char, suffix_char: char, dash_char: char) -> String { +pub(crate) fn format_page_number( + page_num: u32, + format: u8, + prefix_char: char, + suffix_char: char, + dash_char: char, +) -> String { let num_fmt = NumFmt::from_hwp_format(format); let formatted = format_number(page_num as u16, num_fmt); - let prefix = if prefix_char != '\0' { prefix_char.to_string() } else { String::new() }; - let suffix = if suffix_char != '\0' { suffix_char.to_string() } else { String::new() }; - let dash = if dash_char != '\0' { dash_char.to_string() } else { String::new() }; + let prefix = if prefix_char != '\0' { + prefix_char.to_string() + } else { + String::new() + }; + let suffix = if suffix_char != '\0' { + suffix_char.to_string() + } else { + String::new() + }; + let dash = if dash_char != '\0' { + dash_char.to_string() + } else { + String::new() + }; if prefix.is_empty() && suffix.is_empty() && dash.is_empty() { formatted } else { @@ -96,7 +124,9 @@ pub(crate) fn format_page_number(page_num: u32, format: u8, prefix_char: char, s } /// ShapeComponentAttr에서 ShapeTransform을 추출한다. -pub(crate) fn extract_shape_transform(sa: &crate::model::shape::ShapeComponentAttr) -> ShapeTransform { +pub(crate) fn extract_shape_transform( + sa: &crate::model::shape::ShapeComponentAttr, +) -> ShapeTransform { ShapeTransform { rotation: sa.rotation_angle as f64, horz_flip: sa.horz_flip, @@ -104,9 +134,11 @@ pub(crate) fn extract_shape_transform(sa: &crate::model::shape::ShapeComponentAt } } -pub(crate) fn drawing_to_shape_style(drawing: &crate::model::shape::DrawingObjAttr) -> (ShapeStyle, Option>) { - use crate::model::style::FillType; +pub(crate) fn drawing_to_shape_style( + drawing: &crate::model::shape::DrawingObjAttr, +) -> (ShapeStyle, Option>) { use super::super::GradientFillInfo; + use crate::model::style::FillType; // 배경색: solid 필드가 있으면 fill_type과 무관하게 배경색 적용 // (Image/Gradient와 단색 채우기가 동시에 적용되는 케이스 지원) @@ -124,7 +156,9 @@ pub(crate) fn drawing_to_shape_style(drawing: &crate::model::shape::DrawingObjAt FillType::Gradient => drawing.fill.gradient.as_ref().map(|g| { let positions: Vec = if g.positions.is_empty() { let n = g.colors.len(); - (0..n).map(|i| i as f64 / (n.max(2) - 1).max(1) as f64).collect() + (0..n) + .map(|i| i as f64 / (n.max(2) - 1).max(1) as f64) + .collect() } else { g.positions.iter().map(|&p| p as f64 / 100.0).collect() }; @@ -245,12 +279,21 @@ pub(crate) fn drawing_to_line_style(drawing: &crate::model::shape::DrawingObjAtt 3 => (StrokeDash::Dot, super::super::LineRenderType::Single), 4 => (StrokeDash::DashDot, super::super::LineRenderType::Single), 5 => (StrokeDash::DashDotDot, super::super::LineRenderType::Single), - 6 => (StrokeDash::Dash, super::super::LineRenderType::Single), // LongDash - 7 => (StrokeDash::Dot, super::super::LineRenderType::Single), // CircleDot + 6 => (StrokeDash::Dash, super::super::LineRenderType::Single), // LongDash + 7 => (StrokeDash::Dot, super::super::LineRenderType::Single), // CircleDot 8 => (StrokeDash::Solid, super::super::LineRenderType::Double), - 9 => (StrokeDash::Solid, super::super::LineRenderType::ThinThickDouble), - 10 => (StrokeDash::Solid, super::super::LineRenderType::ThickThinDouble), - 11 => (StrokeDash::Solid, super::super::LineRenderType::ThinThickThinTriple), + 9 => ( + StrokeDash::Solid, + super::super::LineRenderType::ThinThickDouble, + ), + 10 => ( + StrokeDash::Solid, + super::super::LineRenderType::ThickThinDouble, + ), + 11 => ( + StrokeDash::Solid, + super::super::LineRenderType::ThinThickThinTriple, + ), _ => (StrokeDash::Solid, super::super::LineRenderType::Single), }; @@ -302,11 +345,29 @@ fn arrow_type_from_hwp(hwp_type: u32, fill: bool) -> ArrowStyle { match hwp_type { 0 => ArrowStyle::None, 1 => ArrowStyle::Arrow, - 2 => ArrowStyle::Arrow, // LinedArrow (선형 화살표) → Arrow로 근사 + 2 => ArrowStyle::Arrow, // LinedArrow (선형 화살표) → Arrow로 근사 3 => ArrowStyle::ConcaveArrow, - 4 => if fill { ArrowStyle::Diamond } else { ArrowStyle::OpenDiamond }, - 5 => if fill { ArrowStyle::Circle } else { ArrowStyle::OpenCircle }, - 6 => if fill { ArrowStyle::Square } else { ArrowStyle::OpenSquare }, + 4 => { + if fill { + ArrowStyle::Diamond + } else { + ArrowStyle::OpenDiamond + } + } + 5 => { + if fill { + ArrowStyle::Circle + } else { + ArrowStyle::OpenCircle + } + } + 6 => { + if fill { + ArrowStyle::Square + } else { + ArrowStyle::OpenSquare + } + } _ => ArrowStyle::None, } } diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs index 2f3f10a5..6c06d515 100644 --- a/src/renderer/mod.rs +++ b/src/renderer/mod.rs @@ -11,15 +11,19 @@ pub mod equation; pub mod font_metrics_data; pub mod height_measurer; pub mod html; +pub mod layer_renderer; pub mod layout; pub mod page_layout; pub mod pagination; +#[cfg(not(target_arch = "wasm32"))] +pub mod pdf; pub mod render_tree; pub mod scheduler; +#[cfg(all(not(target_arch = "wasm32"), feature = "native-skia"))] +pub mod skia; pub mod style_resolver; pub mod svg; -#[cfg(not(target_arch = "wasm32"))] -pub mod pdf; +pub mod svg_layer; pub mod typeset; #[cfg(target_arch = "wasm32")] pub mod web_canvas; @@ -347,11 +351,15 @@ pub enum PathCommand { /// (x1, y1): 시작점, (x2, y2): 끝점, rx/ry: 반지름, /// phi: x축 회전(도), large_arc/sweep: 플래그 pub fn svg_arc_to_beziers( - x1: f64, y1: f64, - mut rx: f64, mut ry: f64, + x1: f64, + y1: f64, + mut rx: f64, + mut ry: f64, phi_deg: f64, - large_arc: bool, sweep: bool, - x2: f64, y2: f64, + large_arc: bool, + sweep: bool, + x2: f64, + y2: f64, ) -> Vec { use std::f64::consts::PI; @@ -497,10 +505,10 @@ pub fn corrected_line_height( ) -> f64 { if max_fs > 0.0 && raw_lh < max_fs { match ls_type { - LineSpacingType::Percent => max_fs * ls_val / 100.0, - LineSpacingType::Fixed => ls_val.max(max_fs), + LineSpacingType::Percent => max_fs * ls_val / 100.0, + LineSpacingType::Fixed => ls_val.max(max_fs), LineSpacingType::SpaceOnly => max_fs + ls_val, - LineSpacingType::Minimum => ls_val.max(max_fs), + LineSpacingType::Minimum => ls_val.max(max_fs), } } else { raw_lh @@ -526,33 +534,38 @@ pub fn px_to_hwpunit(px: f64, dpi: f64) -> i32 { pub fn generic_fallback(font_family: &str) -> &'static str { if font_family.is_empty() { // Sans-serif: Windows → macOS/iOS → Android → 오픈소스 → generic - return "'Malgun Gothic','맑은 고딕','Apple SD Gothic Neo','Noto Sans KR','Pretendard',sans-serif"; + return "'Malgun Gothic','맑은 고딕','Apple SD Gothic Neo','Noto Sans CJK KR','NanumGothic','나눔고딕','Noto Sans KR','Pretendard',sans-serif"; } // 고정폭 키워드 let lower = font_family.to_ascii_lowercase(); - if font_family.contains("굴림체") || font_family.contains("바탕체") - || lower.contains("gulimche") || lower.contains("batangche") - || lower.contains("coding") || lower.contains("courier") + if font_family.contains("굴림체") + || font_family.contains("바탕체") + || lower.contains("gulimche") + || lower.contains("batangche") + || lower.contains("coding") + || lower.contains("courier") { // Monospace: Windows → 오픈소스 → generic - return "'GulimChe','굴림체','D2Coding','Noto Sans Mono',monospace"; + return "'GulimChe','굴림체','D2Coding','NanumGothicCoding','나눔고딕코딩','Noto Sans Mono',monospace"; } // 세리프 키워드 (한글) - if font_family.contains("바탕") || font_family.contains("명조") - || font_family.contains("궁서") + if font_family.contains("바탕") || font_family.contains("명조") || font_family.contains("궁서") { // Serif: Windows → macOS/iOS → Android → 오픈소스 → generic - return "'Batang','바탕','AppleMyungjo','Noto Serif KR',serif"; + return "'Batang','바탕','AppleMyungjo','Noto Serif CJK KR','NanumMyeongjo','나눔명조','Noto Serif KR',serif"; } // 세리프 키워드 (영문) - if lower.contains("times") || lower.contains("hymjre") - || lower.contains("palatino") || lower.contains("georgia") - || lower.contains("batang") || lower.contains("gungsuh") + if lower.contains("times") + || lower.contains("hymjre") + || lower.contains("palatino") + || lower.contains("georgia") + || lower.contains("batang") + || lower.contains("gungsuh") { - return "'Batang','바탕','AppleMyungjo','Noto Serif KR',serif"; + return "'Batang','바탕','AppleMyungjo','Noto Serif CJK KR','NanumMyeongjo','나눔명조','Noto Serif KR',serif"; } // Sans-serif: Windows → macOS/iOS → Android → 오픈소스 → generic - "'Malgun Gothic','맑은 고딕','Apple SD Gothic Neo','Noto Sans KR','Pretendard',sans-serif" + "'Malgun Gothic','맑은 고딕','Apple SD Gothic Neo','Noto Sans CJK KR','NanumGothic','나눔고딕','Noto Sans KR','Pretendard',sans-serif" } // ============================================================ @@ -694,8 +707,8 @@ pub fn format_number(number: u16, format: NumberFormat) -> String { /// 원 문자 변환 (① ~ ⑳, 이후 숫자) fn format_circled_digit(n: u16) -> String { const CIRCLED: [char; 20] = [ - '①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', - '⑪', '⑫', '⑬', '⑭', '⑮', '⑯', '⑰', '⑱', '⑲', '⑳', + '①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫', '⑬', '⑭', '⑮', '⑯', '⑰', '⑱', + '⑲', '⑳', ]; if n >= 1 && n <= 20 { CIRCLED[(n - 1) as usize].to_string() @@ -711,10 +724,18 @@ fn format_roman(n: u16, upper: bool) -> String { } let values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; - let symbols_upper = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"]; - let symbols_lower = ["m", "cm", "d", "cd", "c", "xc", "l", "xl", "x", "ix", "v", "iv", "i"]; + let symbols_upper = [ + "M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I", + ]; + let symbols_lower = [ + "m", "cm", "d", "cd", "c", "xc", "l", "xl", "x", "ix", "v", "iv", "i", + ]; - let symbols = if upper { &symbols_upper } else { &symbols_lower }; + let symbols = if upper { + &symbols_upper + } else { + &symbols_lower + }; let mut result = String::new(); let mut num = n as i32; @@ -751,7 +772,9 @@ fn format_latin(n: u16, upper: bool) -> String { /// 한글 가나다 변환 fn format_hangul_ganada(n: u16) -> String { - const GANADA: [char; 14] = ['가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', '타', '파', '하']; + const GANADA: [char; 14] = [ + '가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', '타', '파', '하', + ]; if n >= 1 && n <= 14 { GANADA[(n - 1) as usize].to_string() } else { @@ -783,7 +806,11 @@ fn format_hangul_number(n: u16) -> String { while g > 0 { let digit = g % 10; if digit > 0 { - let digit_str = if digit == 1 && unit > 0 { "" } else { HANGUL_DIGITS[digit] }; + let digit_str = if digit == 1 && unit > 0 { + "" + } else { + HANGUL_DIGITS[digit] + }; group_str.insert_str(0, HANGUL_UNITS[unit]); group_str.insert_str(0, digit_str); } @@ -823,7 +850,11 @@ fn format_hanja_number(n: u16) -> String { while g > 0 { let digit = g % 10; if digit > 0 { - let digit_str = if digit == 1 && unit > 0 { "" } else { HANJA_DIGITS[digit] }; + let digit_str = if digit == 1 && unit > 0 { + "" + } else { + HANJA_DIGITS[digit] + }; group_str.insert_str(0, HANJA_UNITS[unit]); group_str.insert_str(0, digit_str); } @@ -845,7 +876,10 @@ mod tests { #[test] fn test_render_backend_from_str() { - assert_eq!(RenderBackend::from_str("canvas"), Some(RenderBackend::Canvas)); + assert_eq!( + RenderBackend::from_str("canvas"), + Some(RenderBackend::Canvas) + ); assert_eq!(RenderBackend::from_str("svg"), Some(RenderBackend::Svg)); assert_eq!(RenderBackend::from_str("html"), Some(RenderBackend::Html)); assert_eq!(RenderBackend::from_str("unknown"), None); @@ -919,9 +953,9 @@ mod tests { #[test] fn test_generic_fallback() { - let serif = "'Batang','바탕','AppleMyungjo','Noto Serif KR',serif"; - let sans = "'Malgun Gothic','맑은 고딕','Apple SD Gothic Neo','Noto Sans KR','Pretendard',sans-serif"; - let mono = "'GulimChe','굴림체','D2Coding','Noto Sans Mono',monospace"; + let serif = "'Batang','바탕','AppleMyungjo','Noto Serif CJK KR','NanumMyeongjo','나눔명조','Noto Serif KR',serif"; + let sans = "'Malgun Gothic','맑은 고딕','Apple SD Gothic Neo','Noto Sans CJK KR','NanumGothic','나눔고딕','Noto Sans KR','Pretendard',sans-serif"; + let mono = "'GulimChe','굴림체','D2Coding','NanumGothicCoding','나눔고딕코딩','Noto Sans Mono',monospace"; // 세리프 계열 assert_eq!(generic_fallback("함초롬바탕"), serif); assert_eq!(generic_fallback("바탕"), serif); diff --git a/src/renderer/page_layout.rs b/src/renderer/page_layout.rs index b24e94f0..ca14df27 100644 --- a/src/renderer/page_layout.rs +++ b/src/renderer/page_layout.rs @@ -1,8 +1,8 @@ //! 페이지 레이아웃 계산 (PageDef → 렌더링 영역) -use crate::model::page::{PageDef, ColumnDef, PageAreas}; -use crate::model::Rect; use super::{hwpunit_to_px, DEFAULT_DPI}; +use crate::model::page::{ColumnDef, PageAreas, PageDef}; +use crate::model::Rect; /// 페이지 레이아웃 정보 (픽셀 단위로 변환된 영역) #[derive(Debug, Clone)] @@ -100,7 +100,8 @@ impl PageLayoutInfo { /// 단 너비 (HWPUNIT) — vpos 보정에서 segment_width 비교에 사용 pub fn column_width_hu(&self) -> i32 { - self.column_areas.first() + self.column_areas + .first() .map(|a| super::px_to_hwpunit(a.width, self.dpi)) .unwrap_or(super::px_to_hwpunit(self.body_area.width, self.dpi)) } @@ -141,11 +142,17 @@ fn calculate_column_areas( if column_def.proportional_widths { // HWP 5.0 바이너리: widths/gaps는 비례값 (합계=32768) // body_area.width에 대한 비례로 변환 - let total: f64 = column_def.widths.iter() + let total: f64 = column_def + .widths + .iter() .chain(column_def.gaps.iter()) .map(|&v| (v as u16) as f64) .sum(); - let scale = if total > 0.0 { body_area.width / total } else { 1.0 }; + let scale = if total > 0.0 { + body_area.width / total + } else { + 1.0 + }; for i in 0..col_count { let w = (column_def.widths[i] as u16) as f64 * scale; @@ -201,7 +208,7 @@ fn calculate_column_areas( #[cfg(test)] mod tests { use super::*; - use crate::model::page::{PageDef, ColumnDef}; + use crate::model::page::{ColumnDef, PageDef}; fn a4_page_def() -> PageDef { PageDef { @@ -221,7 +228,10 @@ mod tests { #[test] fn test_single_column_layout() { let page_def = a4_page_def(); - let col_def = ColumnDef { column_count: 1, ..Default::default() }; + let col_def = ColumnDef { + column_count: 1, + ..Default::default() + }; let layout = PageLayoutInfo::from_page_def_default(&page_def, &col_def); assert!((layout.page_width - 793.7).abs() < 1.0); diff --git a/src/renderer/pagination.rs b/src/renderer/pagination.rs index 2359a414..62e47183 100644 --- a/src/renderer/pagination.rs +++ b/src/renderer/pagination.rs @@ -7,15 +7,15 @@ //! 1. HeightMeasurer로 모든 콘텐츠의 실제 렌더링 높이를 측정 //! 2. 측정된 높이를 기반으로 정확한 페이지 분할 수행 -use crate::model::control::Control; -use crate::model::header_footer::HeaderFooterApply; -use crate::model::paragraph::{Paragraph, ColumnBreakType}; -use crate::model::page::{PageDef, ColumnDef}; -use crate::model::shape::CaptionDirection; use super::composer::ComposedParagraph; use super::height_measurer::{HeightMeasurer, MeasuredSection}; use super::page_layout::PageLayoutInfo; use super::style_resolver::ResolvedStyleSet; +use crate::model::control::Control; +use crate::model::header_footer::HeaderFooterApply; +use crate::model::page::{ColumnDef, PageDef}; +use crate::model::paragraph::{ColumnBreakType, Paragraph}; +use crate::model::shape::CaptionDirection; /// 페이지 분할 결과: 페이지별 콘텐츠 참조 #[derive(Debug)] @@ -202,19 +202,49 @@ impl PageItem { pub fn with_offset(&self, offset: i32) -> Self { let adjust = |pi: usize| (pi as i64 + offset as i64).max(0) as usize; match self { - PageItem::FullParagraph { para_index } => - PageItem::FullParagraph { para_index: adjust(*para_index) }, - PageItem::PartialParagraph { para_index, start_line, end_line } => - PageItem::PartialParagraph { para_index: adjust(*para_index), start_line: *start_line, end_line: *end_line }, - PageItem::Table { para_index, control_index } => - PageItem::Table { para_index: adjust(*para_index), control_index: *control_index }, - PageItem::PartialTable { para_index, control_index, start_row, end_row, is_continuation, - split_start_content_offset, split_end_content_limit } => - PageItem::PartialTable { para_index: adjust(*para_index), control_index: *control_index, - start_row: *start_row, end_row: *end_row, is_continuation: *is_continuation, - split_start_content_offset: *split_start_content_offset, split_end_content_limit: *split_end_content_limit }, - PageItem::Shape { para_index, control_index } => - PageItem::Shape { para_index: adjust(*para_index), control_index: *control_index }, + PageItem::FullParagraph { para_index } => PageItem::FullParagraph { + para_index: adjust(*para_index), + }, + PageItem::PartialParagraph { + para_index, + start_line, + end_line, + } => PageItem::PartialParagraph { + para_index: adjust(*para_index), + start_line: *start_line, + end_line: *end_line, + }, + PageItem::Table { + para_index, + control_index, + } => PageItem::Table { + para_index: adjust(*para_index), + control_index: *control_index, + }, + PageItem::PartialTable { + para_index, + control_index, + start_row, + end_row, + is_continuation, + split_start_content_offset, + split_end_content_limit, + } => PageItem::PartialTable { + para_index: adjust(*para_index), + control_index: *control_index, + start_row: *start_row, + end_row: *end_row, + is_continuation: *is_continuation, + split_start_content_offset: *split_start_content_offset, + split_end_content_limit: *split_end_content_limit, + }, + PageItem::Shape { + para_index, + control_index, + } => PageItem::Shape { + para_index: adjust(*para_index), + control_index: *control_index, + }, } } @@ -222,20 +252,58 @@ impl PageItem { fn matches_with_offset(&self, other: &PageItem, offset: i32) -> bool { let adj = |pi: usize| (pi as i64 + offset as i64) as usize; match (self, other) { - (PageItem::FullParagraph { para_index: a }, PageItem::FullParagraph { para_index: b }) => - *a == adj(*b), - (PageItem::PartialParagraph { para_index: a, start_line: s1, end_line: e1 }, - PageItem::PartialParagraph { para_index: b, start_line: s2, end_line: e2 }) => - *a == adj(*b) && s1 == s2 && e1 == e2, - (PageItem::Table { para_index: a, control_index: c1 }, - PageItem::Table { para_index: b, control_index: c2 }) => - *a == adj(*b) && c1 == c2, - (PageItem::PartialTable { para_index: a, control_index: c1, start_row: sr1, end_row: er1, .. }, - PageItem::PartialTable { para_index: b, control_index: c2, start_row: sr2, end_row: er2, .. }) => - *a == adj(*b) && c1 == c2 && sr1 == sr2 && er1 == er2, - (PageItem::Shape { para_index: a, control_index: c1 }, - PageItem::Shape { para_index: b, control_index: c2 }) => - *a == adj(*b) && c1 == c2, + ( + PageItem::FullParagraph { para_index: a }, + PageItem::FullParagraph { para_index: b }, + ) => *a == adj(*b), + ( + PageItem::PartialParagraph { + para_index: a, + start_line: s1, + end_line: e1, + }, + PageItem::PartialParagraph { + para_index: b, + start_line: s2, + end_line: e2, + }, + ) => *a == adj(*b) && s1 == s2 && e1 == e2, + ( + PageItem::Table { + para_index: a, + control_index: c1, + }, + PageItem::Table { + para_index: b, + control_index: c2, + }, + ) => *a == adj(*b) && c1 == c2, + ( + PageItem::PartialTable { + para_index: a, + control_index: c1, + start_row: sr1, + end_row: er1, + .. + }, + PageItem::PartialTable { + para_index: b, + control_index: c2, + start_row: sr2, + end_row: er2, + .. + }, + ) => *a == adj(*b) && c1 == c2 && sr1 == sr2 && er1 == er2, + ( + PageItem::Shape { + para_index: a, + control_index: c1, + }, + PageItem::Shape { + para_index: b, + control_index: c2, + }, + ) => *a == adj(*b) && c1 == c2, _ => false, } } @@ -246,17 +314,26 @@ impl PaginationResult { /// offset: 문단 인덱스 변화량 (삽입=+1, 삭제=-1) /// 반환: 수렴 시작 페이지 인덱스 (None이면 수렴 없음) pub fn find_convergence(&self, old: &PaginationResult, offset: i32) -> Option { - if offset == 0 { return Some(0); } + if offset == 0 { + return Some(0); + } for page_idx in 0..self.pages.len().min(old.pages.len()) { let new_page = &self.pages[page_idx]; let old_page = &old.pages[page_idx]; - if new_page.column_contents.len() != old_page.column_contents.len() { continue; } - let matched = new_page.column_contents.iter() + if new_page.column_contents.len() != old_page.column_contents.len() { + continue; + } + let matched = new_page + .column_contents + .iter() .zip(old_page.column_contents.iter()) .all(|(nc, oc)| { nc.items.len() == oc.items.len() - && nc.items.iter().zip(oc.items.iter()) - .all(|(ni, oi)| ni.matches_with_offset(oi, offset)) + && nc + .items + .iter() + .zip(oc.items.iter()) + .all(|(ni, oi)| ni.matches_with_offset(oi, offset)) }); if matched { return Some(page_idx); @@ -266,7 +343,12 @@ impl PaginationResult { } /// 수렴 이후 페이지를 이전 결과에서 복사한다 (para_index offset 적용). - pub fn copy_converged_pages(&mut self, old: &PaginationResult, converge_page: usize, offset: i32) { + pub fn copy_converged_pages( + &mut self, + old: &PaginationResult, + converge_page: usize, + offset: i32, + ) { // 수렴 페이지 이후를 이전 결과에서 복사 self.pages.truncate(converge_page); for old_page in &old.pages[converge_page..] { @@ -275,37 +357,73 @@ impl PaginationResult { page_number: old_page.page_number, section_index: old_page.section_index, layout: old_page.layout.clone(), - column_contents: old_page.column_contents.iter().map(|cc| { - ColumnContent { + column_contents: old_page + .column_contents + .iter() + .map(|cc| ColumnContent { column_index: cc.column_index, items: cc.items.iter().map(|it| it.with_offset(offset)).collect(), zone_layout: cc.zone_layout.clone(), zone_y_offset: cc.zone_y_offset, - wrap_around_paras: cc.wrap_around_paras.iter().map(|w| WrapAroundPara { - para_index: (w.para_index as i64 + offset as i64).max(0) as usize, - table_para_index: (w.table_para_index as i64 + offset as i64).max(0) as usize, - has_text: w.has_text, - }).collect(), - } - }).collect(), + wrap_around_paras: cc + .wrap_around_paras + .iter() + .map(|w| WrapAroundPara { + para_index: (w.para_index as i64 + offset as i64).max(0) as usize, + table_para_index: (w.table_para_index as i64 + offset as i64).max(0) + as usize, + has_text: w.has_text, + }) + .collect(), + }) + .collect(), active_header: old_page.active_header.clone(), active_footer: old_page.active_footer.clone(), page_number_pos: old_page.page_number_pos.clone(), page_hide: old_page.page_hide.clone(), - footnotes: old_page.footnotes.iter().map(|f| { - let source = match &f.source { - FootnoteSource::Body { para_index, control_index } => - FootnoteSource::Body { para_index: (*para_index as i64 + offset as i64).max(0) as usize, control_index: *control_index }, - FootnoteSource::TableCell { para_index, table_control_index, cell_index, cell_para_index, cell_control_index } => - FootnoteSource::TableCell { para_index: (*para_index as i64 + offset as i64).max(0) as usize, - table_control_index: *table_control_index, cell_index: *cell_index, - cell_para_index: *cell_para_index, cell_control_index: *cell_control_index }, - FootnoteSource::ShapeTextBox { para_index, shape_control_index, tb_para_index, tb_control_index } => - FootnoteSource::ShapeTextBox { para_index: (*para_index as i64 + offset as i64).max(0) as usize, - shape_control_index: *shape_control_index, tb_para_index: *tb_para_index, tb_control_index: *tb_control_index }, - }; - FootnoteRef { number: f.number, source } - }).collect(), + footnotes: old_page + .footnotes + .iter() + .map(|f| { + let source = match &f.source { + FootnoteSource::Body { + para_index, + control_index, + } => FootnoteSource::Body { + para_index: (*para_index as i64 + offset as i64).max(0) as usize, + control_index: *control_index, + }, + FootnoteSource::TableCell { + para_index, + table_control_index, + cell_index, + cell_para_index, + cell_control_index, + } => FootnoteSource::TableCell { + para_index: (*para_index as i64 + offset as i64).max(0) as usize, + table_control_index: *table_control_index, + cell_index: *cell_index, + cell_para_index: *cell_para_index, + cell_control_index: *cell_control_index, + }, + FootnoteSource::ShapeTextBox { + para_index, + shape_control_index, + tb_para_index, + tb_control_index, + } => FootnoteSource::ShapeTextBox { + para_index: (*para_index as i64 + offset as i64).max(0) as usize, + shape_control_index: *shape_control_index, + tb_para_index: *tb_para_index, + tb_control_index: *tb_control_index, + }, + }; + FootnoteRef { + number: f.number, + source, + } + }) + .collect(), active_master_page: old_page.active_master_page.clone(), extra_master_pages: old_page.extra_master_pages.clone(), }; @@ -316,7 +434,11 @@ impl PaginationResult { for w in &old.wrap_around_paras { let shifted_pi = (w.para_index as i64 + offset as i64).max(0) as usize; let shifted_tpi = (w.table_para_index as i64 + offset as i64).max(0) as usize; - if !self.wrap_around_paras.iter().any(|e| e.para_index == shifted_pi) { + if !self + .wrap_around_paras + .iter() + .any(|e| e.para_index == shifted_pi) + { self.wrap_around_paras.push(WrapAroundPara { para_index: shifted_pi, table_para_index: shifted_tpi, @@ -391,7 +513,14 @@ impl Paginator { let measured = measurer.measure_section(paragraphs, composed, styles); // === 2-패스: 측정된 높이로 페이지 분할 === - let result = self.paginate_with_measured(paragraphs, &measured, page_def, column_def, section_index, &styles.para_styles); + let result = self.paginate_with_measured( + paragraphs, + &measured, + page_def, + column_def, + section_index, + &styles.para_styles, + ); (result, measured) } } diff --git a/src/renderer/pagination/engine.rs b/src/renderer/pagination/engine.rs index 5d98f49c..244db2d2 100644 --- a/src/renderer/pagination/engine.rs +++ b/src/renderer/pagination/engine.rs @@ -1,14 +1,14 @@ //! 페이지 분할 엔진 (paginate_with_measured) +use super::state::PaginationState; +use super::*; use crate::model::control::Control; use crate::model::header_footer::HeaderFooterApply; -use crate::model::paragraph::{Paragraph, ColumnBreakType}; -use crate::model::page::{PageDef, ColumnDef}; +use crate::model::page::{ColumnDef, PageDef}; +use crate::model::paragraph::{ColumnBreakType, Paragraph}; use crate::model::shape::CaptionDirection; use crate::renderer::height_measurer::{HeightMeasurer, MeasuredSection}; use crate::renderer::page_layout::PageLayoutInfo; -use super::*; -use super::state::PaginationState; impl Paginator { pub fn paginate_with_measured( @@ -20,7 +20,15 @@ impl Paginator { section_index: usize, para_styles: &[crate::renderer::style_resolver::ResolvedParaStyle], ) -> PaginationResult { - self.paginate_with_measured_opts(paragraphs, measured, page_def, column_def, section_index, para_styles, false) + self.paginate_with_measured_opts( + paragraphs, + measured, + page_def, + column_def, + section_index, + para_styles, + false, + ) } pub fn paginate_with_measured_opts( @@ -45,11 +53,13 @@ impl Paginator { let footnote_safety_margin = crate::renderer::hwpunit_to_px(3000, self.dpi); let mut st = PaginationState::new( - layout, col_count, section_index, - footnote_separator_overhead, footnote_safety_margin, + layout, + col_count, + section_index, + footnote_separator_overhead, + footnote_safety_margin, ); - // 비-TAC 표 뒤의 ghost 빈 문단 스킵. // HWP에서 비-TAC 표의 LINE_SEG 높이는 실제 표 높이보다 작으며, // 그 차이를 빈 문단으로 채워넣음. 이 빈 문단들은 표 영역 안에 숨겨짐. @@ -57,10 +67,10 @@ impl Paginator { // 어울림 표는 후속 문단들 위에 겹쳐서 렌더링됨. // 동일한 column_start(cs) 값을 가진 빈 문단은 표와 나란히 배치되므로 // pagination에서 높이를 소비하지 않음. - let mut wrap_around_cs: i32 = -1; // -1 = 비활성 - let mut wrap_around_sw: i32 = -1; // wrap zone의 segment_width - let mut wrap_around_table_para: usize = 0; // 어울림 표의 문단 인덱스 - let mut prev_pagination_para: Option = None; // vpos 보정용 이전 문단 + let mut wrap_around_cs: i32 = -1; // -1 = 비활성 + let mut wrap_around_sw: i32 = -1; // wrap zone의 segment_width + let mut wrap_around_table_para: usize = 0; // 어울림 표의 문단 인덱스 + let mut prev_pagination_para: Option = None; // vpos 보정용 이전 문단 // 고정값 줄간격 TAC 표 병행 (Task #9): // Percent 전환 시 표 높이 - Fixed 누적 차이분을 current_height에 추가 @@ -71,7 +81,8 @@ impl Paginator { // 빈 줄 감추기: 페이지 시작 부분에서 감춘 빈 줄 수 (최대 2개) let mut hidden_empty_lines: u8 = 0; let mut hidden_empty_page: usize = 0; // 현재 감추기 중인 페이지 - let mut hidden_empty_paras: std::collections::HashSet = std::collections::HashSet::new(); + let mut hidden_empty_paras: std::collections::HashSet = + std::collections::HashSet::new(); for (para_idx, para) in paragraphs.iter().enumerate() { // 표 컨트롤 여부 사전 감지 @@ -105,7 +116,8 @@ impl Paginator { // 고정값→글자에따라 전환: 표 높이와 Fixed 누적의 차이분 추가 (Task #9) if fix_overlay_active && !has_table { - let is_fixed = para_styles.get(para.para_shape_id as usize) + let is_fixed = para_styles + .get(para.para_shape_id as usize) .map(|ps| ps.line_spacing_type == crate::model::style::LineSpacingType::Fixed) .unwrap_or(false); if !is_fixed { @@ -140,52 +152,84 @@ impl Paginator { let para_style = para_styles.get(para.para_shape_id as usize); let para_style_break = para_style.map(|s| s.page_break_before).unwrap_or(false); - if (force_page_break || para_style_break) && !st.current_items.is_empty() { self.process_page_break(&mut st); } // tac 표: 표 실측 높이 + 텍스트 줄 높이(th)로 판단 (Task #19) let para_height_for_fit = if has_table { - let has_tac = para.controls.iter().any(|c| - matches!(c, Control::Table(t) if t.common.treat_as_char)); + let has_tac = para + .controls + .iter() + .any(|c| matches!(c, Control::Table(t) if t.common.treat_as_char)); if has_tac { // 표 실측 높이 합산 (outer_top 포함, outer_bottom 제외) // 캡션은 paginate_table_control에서 별도 처리하므로 여기서는 제외 // 표 실측 높이 합산 (outer_top + line_spacing 포함, outer_bottom 제외) // 캡션은 paginate_table_control에서 별도 처리하므로 여기서는 제외 let mut tac_ci = 0usize; - let tac_h: f64 = para.controls.iter().enumerate() + let tac_h: f64 = para + .controls + .iter() + .enumerate() .filter_map(|(ci, c)| { if let Control::Table(t) = c { if t.common.treat_as_char { let mt = measured.get_measured_table(para_idx, ci); - let mt_h = mt.map(|m| { - let cap_h = m.caption_height; - let cap_s = if cap_h > 0.0 { - t.caption.as_ref() - .map(|c| crate::renderer::hwpunit_to_px(c.spacing as i32, self.dpi)) - .unwrap_or(0.0) - } else { 0.0 }; - m.total_height - cap_h - cap_s - }).unwrap_or(0.0); + let mt_h = mt + .map(|m| { + let cap_h = m.caption_height; + let cap_s = if cap_h > 0.0 { + t.caption + .as_ref() + .map(|c| { + crate::renderer::hwpunit_to_px( + c.spacing as i32, + self.dpi, + ) + }) + .unwrap_or(0.0) + } else { + 0.0 + }; + m.total_height - cap_h - cap_s + }) + .unwrap_or(0.0); let outer_top = crate::renderer::hwpunit_to_px( - t.outer_margin_top as i32, self.dpi); - let ls = para.line_segs.get(tac_ci) + t.outer_margin_top as i32, + self.dpi, + ); + let ls = para + .line_segs + .get(tac_ci) .filter(|seg| seg.line_spacing > 0) - .map(|seg| crate::renderer::hwpunit_to_px(seg.line_spacing, self.dpi)) + .map(|seg| { + crate::renderer::hwpunit_to_px( + seg.line_spacing, + self.dpi, + ) + }) .unwrap_or(0.0); tac_ci += 1; Some(mt_h + outer_top + ls) - } else { None } - } else { None } + } else { + None + } + } else { + None + } }) .sum(); // 텍스트 줄 높이: th 기반 (lh에 표 높이가 포함되므로 th 사용) - let text_h: f64 = para.line_segs.iter() + let text_h: f64 = para + .line_segs + .iter() .filter(|seg| seg.text_height > 0 && seg.text_height < seg.line_height / 3) .map(|seg| { - crate::renderer::hwpunit_to_px(seg.text_height + seg.line_spacing, self.dpi) + crate::renderer::hwpunit_to_px( + seg.text_height + seg.line_spacing, + self.dpi, + ) }) .sum(); // host spacing (sb + sa) @@ -202,21 +246,30 @@ impl Paginator { // 현재 페이지에 넣을 수 있는지 확인 (표 문단만 플러시) // 다중 TAC 표 문단은 개별 표가 paginate_table_control에서 처리되므로 스킵 - let tac_table_count_for_flush = para.controls.iter() + let tac_table_count_for_flush = para + .controls + .iter() .filter(|c| matches!(c, Control::Table(t) if t.common.treat_as_char)) .count(); // trailing ls 경계 조건: trailing ls 제거 시 들어가면 flush 안 함 - let has_tac_for_flush = para.controls.iter().any(|c| - matches!(c, Control::Table(t) if t.common.treat_as_char)); + let has_tac_for_flush = para + .controls + .iter() + .any(|c| matches!(c, Control::Table(t) if t.common.treat_as_char)); let trailing_tac_ls = if has_tac_for_flush { - para.line_segs.last() + para.line_segs + .last() .filter(|seg| seg.line_spacing > 0) .map(|seg| crate::renderer::hwpunit_to_px(seg.line_spacing, self.dpi)) .unwrap_or(0.0) - } else { 0.0 }; - let fit_without_trail = st.current_height + para_height_for_fit - trailing_tac_ls <= available_height + 0.5; + } else { + 0.0 + }; + let fit_without_trail = + st.current_height + para_height_for_fit - trailing_tac_ls <= available_height + 0.5; let fit_with_trail = st.current_height + para_height_for_fit <= available_height + 0.5; - if !fit_with_trail && !fit_without_trail + if !fit_with_trail + && !fit_without_trail && !st.current_items.is_empty() && has_table && tac_table_count_for_flush <= 1 @@ -252,33 +305,32 @@ impl Paginator { crate::model::shape::TextWrap::InFrontOfText | crate::model::shape::TextWrap::BehindText))) }).unwrap_or(false); if !prev_has_tac_eq { - if let Some(base) = st.page_vpos_base { - if let Some(prev_para) = paragraphs.get(prev_pi) { - let col_width_hu = st.layout.column_width_hu(); - let prev_seg = prev_para.line_segs.iter().rev().find(|ls| { - ls.segment_width > 0 - && (ls.segment_width - col_width_hu).abs() < 3000 - }); - if let Some(seg) = prev_seg { - if !(seg.vertical_pos == 0 && prev_pi > 0) { - let vpos_end = seg.vertical_pos - + seg.line_height - + seg.line_spacing; - let vpos_h = crate::renderer::hwpunit_to_px( - vpos_end - base, - self.dpi, - ); - if vpos_h > st.current_height && vpos_h > 0.0 { - let avail = st.available_height(); - if vpos_h <= avail { - st.current_height = vpos_h; + if let Some(base) = st.page_vpos_base { + if let Some(prev_para) = paragraphs.get(prev_pi) { + let col_width_hu = st.layout.column_width_hu(); + let prev_seg = prev_para.line_segs.iter().rev().find(|ls| { + ls.segment_width > 0 + && (ls.segment_width - col_width_hu).abs() < 3000 + }); + if let Some(seg) = prev_seg { + if !(seg.vertical_pos == 0 && prev_pi > 0) { + let vpos_end = + seg.vertical_pos + seg.line_height + seg.line_spacing; + let vpos_h = crate::renderer::hwpunit_to_px( + vpos_end - base, + self.dpi, + ); + if vpos_h > st.current_height && vpos_h > 0.0 { + let avail = st.available_height(); + if vpos_h <= avail { + st.current_height = vpos_h; + } } } } } } } - } } } prev_pagination_para = Some(para_idx); @@ -286,30 +338,39 @@ impl Paginator { // 어울림 배치 표 오버랩 구간: 동일 cs를 가진 문단은 표 옆에 배치 if wrap_around_cs >= 0 && !has_table { let para_cs = para.line_segs.first().map(|s| s.column_start).unwrap_or(0); - let para_sw = para.line_segs.first().map(|s| s.segment_width as i32).unwrap_or(0); - let is_empty_para = para.text.chars().all(|ch| ch.is_whitespace() || ch == '\r' || ch == '\n') + let para_sw = para + .line_segs + .first() + .map(|s| s.segment_width as i32) + .unwrap_or(0); + let is_empty_para = para + .text + .chars() + .all(|ch| ch.is_whitespace() || ch == '\r' || ch == '\n') && para.controls.is_empty(); // 여러 LINE_SEG 중 하나라도 어울림 cs/sw와 일치하면 어울림 문단 - let any_seg_matches = para.line_segs.iter().any(|s| + let any_seg_matches = para.line_segs.iter().any(|s| { s.column_start == wrap_around_cs && s.segment_width as i32 == wrap_around_sw - ); + }); // sw=0인 어울림 표: 표가 전체 폭을 차지하므로 // 후속 빈 문단의 sw가 문서 본문 폭보다 현저히 작으면 어울림 문단 - let body_w = (page_def.width as i32) - (page_def.margin_left as i32) - (page_def.margin_right as i32); - let sw0_match = wrap_around_sw == 0 && is_empty_para && para_sw > 0 - && para_sw < body_w / 2; + let body_w = (page_def.width as i32) + - (page_def.margin_left as i32) + - (page_def.margin_right as i32); + let sw0_match = + wrap_around_sw == 0 && is_empty_para && para_sw > 0 && para_sw < body_w / 2; if para_cs == wrap_around_cs && para_sw == wrap_around_sw || (any_seg_matches && is_empty_para) - || sw0_match { + || sw0_match + { // 어울림 문단: 표 옆에 배치 — pagination에서 높이 소비 없이 기록 // (표가 이미 이 공간을 차지하고 있음) - st.current_column_wrap_around_paras.push( - super::WrapAroundPara { + st.current_column_wrap_around_paras + .push(super::WrapAroundPara { para_index: para_idx, table_para_index: wrap_around_table_para, has_text: !is_empty_para, - } - ); + }); continue; } else { wrap_around_cs = -1; @@ -320,7 +381,11 @@ impl Paginator { // 비-표 문단 처리 if !has_table { self.paginate_text_lines( - &mut st, para_idx, para, measured, para_height, + &mut st, + para_idx, + para, + measured, + para_height, base_available_height, ); } @@ -331,8 +396,15 @@ impl Paginator { // 인라인 컨트롤 감지 (표/도형/각주) self.process_controls( - &mut st, para_idx, para, measured, &measurer, - para_height, para_height_for_fit, base_available_height, page_def, + &mut st, + para_idx, + para, + measured, + &measurer, + para_height, + para_height_for_fit, + base_available_height, + page_def, height_before_controls, ); @@ -342,7 +414,11 @@ impl Paginator { // line_seg.line_height가 실측 표 높이보다 클 수 있으므로 // 실측 높이를 기준으로 보정하여 레이아웃과 일치시킴 let has_tac_block_table = para.controls.iter().any(|c| { - if let Control::Table(t) = c { t.common.treat_as_char } else { false } + if let Control::Table(t) = c { + t.common.treat_as_char + } else { + false + } }); // 비-TAC 어울림(text_wrap=0) 표: 후속 빈 문단의 cs를 기록 let has_non_tac_table = has_table && !has_tac_block_table; @@ -355,15 +431,17 @@ impl Paginator { let is_wrap_around = para.controls.iter().any(|c| { if let Control::Table(t) = c { matches!(t.common.text_wrap, crate::model::shape::TextWrap::Square) - } else { false } + } else { + false + } }); if is_wrap_around { // 어울림 배치: 표의 LINE_SEG (cs, sw) 쌍과 동일한 후속 문단은 // 표 옆에 배치되므로 높이를 소비하지 않음 - wrap_around_cs = para.line_segs.first() - .map(|s| s.column_start) - .unwrap_or(0); - wrap_around_sw = para.line_segs.first() + wrap_around_cs = para.line_segs.first().map(|s| s.column_start).unwrap_or(0); + wrap_around_sw = para + .line_segs + .first() .map(|s| s.segment_width as i32) .unwrap_or(0); wrap_around_table_para = para_idx; @@ -375,7 +453,9 @@ impl Paginator { // Layout과 동일한 기준으로 TAC 표 높이 계산: // layout에서는 max(표 실측 높이, seg.vpos + seg.lh) + ls/2를 사용하므로 // line_seg의 line_height를 기준으로 계산해야 layout과 일치함 - let tac_count = para.controls.iter() + let tac_count = para + .controls + .iter() .filter(|c| matches!(c, Control::Table(t) if t.common.treat_as_char)) .count(); let tac_seg_total: f64 = if tac_count > 0 && !para.line_segs.is_empty() { @@ -387,12 +467,16 @@ impl Paginator { if t.common.treat_as_char { if let Some(seg) = para.line_segs.get(tac_idx) { // layout과 동일: max(표 실측, seg.lh) + ls - let seg_lh = crate::renderer::hwpunit_to_px(seg.line_height, self.dpi); - let mt_h = measured.get_table_height(para_idx, ci).unwrap_or(0.0); + let seg_lh = + crate::renderer::hwpunit_to_px(seg.line_height, self.dpi); + let mt_h = + measured.get_table_height(para_idx, ci).unwrap_or(0.0); let effective_h = seg_lh.max(mt_h); let ls = if seg.line_spacing > 0 { crate::renderer::hwpunit_to_px(seg.line_spacing, self.dpi) - } else { 0.0 }; + } else { + 0.0 + }; total += effective_h + ls; } tac_idx += 1; @@ -407,10 +491,13 @@ impl Paginator { let mp = measured.get_measured_paragraph(para_idx); let sb = mp.map(|m| m.spacing_before).unwrap_or(0.0); let sa = mp.map(|m| m.spacing_after).unwrap_or(0.0); - let outer_top: f64 = para.controls.iter() + let outer_top: f64 = para + .controls + .iter() .filter_map(|c| match c { - Control::Table(t) if t.common.treat_as_char => - Some(crate::renderer::hwpunit_to_px(t.outer_margin_top as i32, self.dpi)), + Control::Table(t) if t.common.treat_as_char => Some( + crate::renderer::hwpunit_to_px(t.outer_margin_top as i32, self.dpi), + ), _ => None, }) .sum(); @@ -418,10 +505,18 @@ impl Paginator { let effective_sb = if is_col_top { 0.0 } else { sb }; // TAC 블록 표 문단의 post-text 줄 높이 (마지막 LINE_SEG) let post_text_h = if para.line_segs.len() > tac_count { - para.line_segs.last() - .map(|seg| crate::renderer::hwpunit_to_px(seg.line_height + seg.line_spacing, self.dpi)) + para.line_segs + .last() + .map(|seg| { + crate::renderer::hwpunit_to_px( + seg.line_height + seg.line_spacing, + self.dpi, + ) + }) .unwrap_or(0.0) - } else { 0.0 }; + } else { + 0.0 + }; (effective_sb + outer_top + tac_seg_total + post_text_h + sa).min(para_height) } else { para_height @@ -435,7 +530,8 @@ impl Paginator { // fix_overlay는 고정값→글자에따라 전환이 있는 경우에만 유효 if let Some(seg) = para.line_segs.first() { if seg.line_spacing < 0 { - fix_table_visual_h = crate::renderer::hwpunit_to_px(seg.line_height, self.dpi); + fix_table_visual_h = + crate::renderer::hwpunit_to_px(seg.line_height, self.dpi); fix_vpos_tmp = 0.0; fix_overlay_active = true; } else if has_tac_block_table { @@ -450,7 +546,6 @@ impl Paginator { if fix_overlay_active && !has_table { fix_vpos_tmp += para_height; } - } // 마지막 남은 항목 처리 @@ -469,9 +564,20 @@ impl Paginator { } } // 페이지 번호 + 머리말/꼬리말 할당 - Self::finalize_pages(&mut st.pages, &hf_entries, &page_number_pos, &page_hides, &new_page_numbers, section_index); + Self::finalize_pages( + &mut st.pages, + &hf_entries, + &page_number_pos, + &page_hides, + &new_page_numbers, + section_index, + ); - PaginationResult { pages: st.pages, wrap_around_paras: all_wrap_around_paras, hidden_empty_paras } + PaginationResult { + pages: st.pages, + wrap_around_paras: all_wrap_around_paras, + hidden_empty_paras, + } } /// 머리말/꼬리말/쪽 번호 위치/새 번호 컨트롤 수집 @@ -494,11 +600,19 @@ impl Paginator { for (ci, ctrl) in para.controls.iter().enumerate() { match ctrl { Control::Header(h) => { - let r = HeaderFooterRef { para_index: pi, control_index: ci, source_section_index: section_index }; + let r = HeaderFooterRef { + para_index: pi, + control_index: ci, + source_section_index: section_index, + }; hf_entries.push((pi, r, true, h.apply_to)); } Control::Footer(f) => { - let r = HeaderFooterRef { para_index: pi, control_index: ci, source_section_index: section_index }; + let r = HeaderFooterRef { + para_index: pi, + control_index: ci, + source_section_index: section_index, + }; hf_entries.push((pi, r, false, f.apply_to)); } Control::PageHide(ph) => { @@ -535,7 +649,8 @@ impl Paginator { let mut max_vpos_end: i32 = 0; for prev_idx in (0..para_idx).rev() { if let Some(last_seg) = paragraphs[prev_idx].line_segs.last() { - let vpos_end = last_seg.vertical_pos + last_seg.line_height + last_seg.line_spacing; + let vpos_end = + last_seg.vertical_pos + last_seg.line_height + last_seg.line_spacing; if vpos_end > max_vpos_end { max_vpos_end = vpos_end; } @@ -590,17 +705,27 @@ impl Paginator { let available_now = st.available_height(); // 다단 레이아웃에서 문단 내 단 경계 감지 - let col_breaks = if st.col_count > 1 && st.current_column == 0 && st.on_first_multicolumn_page { - Self::detect_column_breaks_in_paragraph(para) - } else { - vec![0] - }; + let col_breaks = + if st.col_count > 1 && st.current_column == 0 && st.on_first_multicolumn_page { + Self::detect_column_breaks_in_paragraph(para) + } else { + vec![0] + }; if col_breaks.len() > 1 { - self.paginate_multicolumn_paragraph(st, para_idx, para, measured, para_height, &col_breaks); + self.paginate_multicolumn_paragraph( + st, + para_idx, + para, + measured, + para_height, + &col_breaks, + ); } else if { // 문단 적합성 검사: trailing line_spacing 제외 - let trailing_ls = para.line_segs.last() + let trailing_ls = para + .line_segs + .last() .map(|seg| crate::renderer::hwpunit_to_px(seg.line_spacing, self.dpi)) .unwrap_or(0.0); // 페이지 하단 여유가 적으면(full para_height 기준 넘침) trailing 제외 비율 축소 @@ -648,9 +773,18 @@ impl Paginator { // 줄 단위 분할 루프 let mut cursor_line: usize = 0; while cursor_line < line_count { - let fn_margin = if st.current_footnote_height > 0.0 { st.footnote_safety_margin } else { 0.0 }; + let fn_margin = if st.current_footnote_height > 0.0 { + st.footnote_safety_margin + } else { + 0.0 + }; let page_avail = if cursor_line == 0 { - (base_available_height - st.current_footnote_height - fn_margin - st.current_height - st.current_zone_y_offset).max(0.0) + (base_available_height + - st.current_footnote_height + - fn_margin + - st.current_height + - st.current_zone_y_offset) + .max(0.0) } else { base_available_height }; @@ -675,7 +809,11 @@ impl Paginator { } let part_line_height: f64 = mp.line_advances_sum(cursor_line..end_line); - let part_sp_after = if end_line >= line_count { sp_after } else { 0.0 }; + let part_sp_after = if end_line >= line_count { + sp_after + } else { + 0.0 + }; let part_height = sp_b + part_line_height + part_sp_after; if cursor_line == 0 && end_line >= line_count { @@ -684,7 +822,11 @@ impl Paginator { matches!(item, PageItem::Table { .. } | PageItem::PartialTable { .. }) }); let overflow_threshold = if prev_is_table { - let trailing_ls = mp.line_spacings.get(end_line.saturating_sub(1)).copied().unwrap_or(0.0); + let trailing_ls = mp + .line_spacings + .get(end_line.saturating_sub(1)) + .copied() + .unwrap_or(0.0); cumulative - trailing_ls } else { cumulative @@ -754,7 +896,8 @@ impl Paginator { col_breaks: &[usize], ) { let line_count = para.line_segs.len(); - let measured_line_count = measured.get_measured_paragraph(para_idx) + let measured_line_count = measured + .get_measured_paragraph(para_idx) .map(|mp| mp.line_heights.len()) .unwrap_or(line_count); for (bi, &break_start) in col_breaks.iter().enumerate() { @@ -814,7 +957,11 @@ impl Paginator { match ctrl { Control::Table(table) => { // 글앞으로 / 글뒤로: Shape처럼 취급 — 공간 차지 없음 - if matches!(table.common.text_wrap, crate::model::shape::TextWrap::InFrontOfText | crate::model::shape::TextWrap::BehindText) { + if matches!( + table.common.text_wrap, + crate::model::shape::TextWrap::InFrontOfText + | crate::model::shape::TextWrap::BehindText + ) { st.current_items.push(PageItem::Shape { para_index: para_idx, control_index: ctrl_idx, @@ -824,11 +971,20 @@ impl Paginator { // 페이지 하단/중앙 고정 비-TAC 표 (vert=Page/Paper + Bottom/Center): // 본문 흐름 무관 — 현재 페이지에 배치하고 높이 미추가 if !table.common.treat_as_char - && matches!(table.common.text_wrap, crate::model::shape::TextWrap::TopAndBottom) - && matches!(table.common.vert_rel_to, - crate::model::shape::VertRelTo::Page | crate::model::shape::VertRelTo::Paper) - && matches!(table.common.vert_align, - crate::model::shape::VertAlign::Bottom | crate::model::shape::VertAlign::Center) + && matches!( + table.common.text_wrap, + crate::model::shape::TextWrap::TopAndBottom + ) + && matches!( + table.common.vert_rel_to, + crate::model::shape::VertRelTo::Page + | crate::model::shape::VertRelTo::Paper + ) + && matches!( + table.common.vert_align, + crate::model::shape::VertAlign::Bottom + | crate::model::shape::VertAlign::Center + ) { st.current_items.push(PageItem::Table { para_index: para_idx, @@ -839,13 +995,25 @@ impl Paginator { // treat_as_char 표: 인라인이면 skip if table.common.treat_as_char { let seg_w = para.line_segs.first().map(|s| s.segment_width).unwrap_or(0); - if crate::renderer::height_measurer::is_tac_table_inline(table, seg_w, ¶.text, ¶.controls) { + if crate::renderer::height_measurer::is_tac_table_inline( + table, + seg_w, + ¶.text, + ¶.controls, + ) { continue; } } self.paginate_table_control( - st, para_idx, ctrl_idx, para, measured, measurer, - para_height, para_height_for_fit, base_available_height, + st, + para_idx, + ctrl_idx, + para, + measured, + measurer, + para_height, + para_height_for_fit, + base_available_height, para_start_height, ); } @@ -869,7 +1037,8 @@ impl Paginator { tb_control_index: tc_idx, }, }); - let fn_height = measurer.estimate_single_footnote_height(&fn_ctrl); + let fn_height = + measurer.estimate_single_footnote_height(&fn_ctrl); st.add_footnote_height(fn_height); } } @@ -884,13 +1053,20 @@ impl Paginator { }); // 비-TAC 그림: 본문 공간을 차지하는 배치이면 높이 추가 (Task #10) if !pic.common.treat_as_char - && matches!(pic.common.text_wrap, + && matches!( + pic.common.text_wrap, crate::model::shape::TextWrap::Square - | crate::model::shape::TextWrap::TopAndBottom) + | crate::model::shape::TextWrap::TopAndBottom + ) { - let pic_h = crate::renderer::hwpunit_to_px(pic.common.height as i32, self.dpi); - let margin_top = crate::renderer::hwpunit_to_px(pic.common.margin.top as i32, self.dpi); - let margin_bottom = crate::renderer::hwpunit_to_px(pic.common.margin.bottom as i32, self.dpi); + let pic_h = + crate::renderer::hwpunit_to_px(pic.common.height as i32, self.dpi); + let margin_top = + crate::renderer::hwpunit_to_px(pic.common.margin.top as i32, self.dpi); + let margin_bottom = crate::renderer::hwpunit_to_px( + pic.common.margin.bottom as i32, + self.dpi, + ); st.current_height += pic_h + margin_top + margin_bottom; } } @@ -932,17 +1108,25 @@ impl Paginator { base_available_height: f64, para_start_height: f64, ) { - let table = if let Control::Table(t) = ¶.controls[ctrl_idx] { t } else { return }; + let table = if let Control::Table(t) = ¶.controls[ctrl_idx] { + t + } else { + return; + }; let measured_table = measured.get_measured_table(para_idx, ctrl_idx); // 표 본체 높이 (캡션 제외 — 캡션은 host_spacing/caption_overhead에서 별도 처리) let effective_height = measured_table .map(|mt| { let cap_h = mt.caption_height; let cap_s = if cap_h > 0.0 { - table.caption.as_ref() + table + .caption + .as_ref() .map(|c| crate::renderer::hwpunit_to_px(c.spacing as i32, self.dpi)) .unwrap_or(0.0) - } else { 0.0 }; + } else { + 0.0 + }; mt.total_height - cap_h - cap_s }) .unwrap_or_else(|| { @@ -957,7 +1141,11 @@ impl Paginator { } } let table_height: f64 = row_heights.iter().sum(); - if table_height > 0.0 { table_height } else { crate::renderer::hwpunit_to_px(1000, self.dpi) } + if table_height > 0.0 { + table_height + } else { + crate::renderer::hwpunit_to_px(1000, self.dpi) + } }); // 표 내 각주 높이 사전 계산 @@ -980,8 +1168,14 @@ impl Paginator { // 현재 사용 가능한 높이 let total_footnote = st.current_footnote_height + table_footnote_height; - let table_margin = if total_footnote > 0.0 { st.footnote_safety_margin } else { 0.0 }; - let table_available_height = (base_available_height - total_footnote - table_margin - st.current_zone_y_offset).max(0.0); + let table_margin = if total_footnote > 0.0 { + st.footnote_safety_margin + } else { + 0.0 + }; + let table_available_height = + (base_available_height - total_footnote - table_margin - st.current_zone_y_offset) + .max(0.0); // 호스트 문단 간격 계산 let is_tac_table = table.common.treat_as_char; @@ -1001,12 +1195,14 @@ impl Paginator { // TAC 표: ctrl_idx 위치의 LINE_SEG line_spacing 사용 // 비-TAC 표: 마지막 LINE_SEG line_spacing 사용 let host_line_spacing = if is_tac_table { - para.line_segs.get(ctrl_idx) + para.line_segs + .get(ctrl_idx) .filter(|seg| seg.line_spacing > 0) .map(|seg| crate::renderer::hwpunit_to_px(seg.line_spacing, self.dpi)) .unwrap_or(0.0) } else { - para.line_segs.last() + para.line_segs + .last() .filter(|seg| seg.line_spacing > 0) .map(|seg| crate::renderer::hwpunit_to_px(seg.line_spacing, self.dpi)) .unwrap_or(0.0) @@ -1015,8 +1211,13 @@ impl Paginator { // 자리차지(text_wrap=TopAndBottom) 비-TAC 표: // - vert=Paper/Page: spacing_before 제외 (shape_reserved가 y_offset 처리) // - vert=Para: spacing_before 포함 (레이아웃에서 문단 상대 위치로 spacing_before 반영) - let before = if !is_tac_table && matches!(table_text_wrap, crate::model::shape::TextWrap::TopAndBottom) { - let is_para_relative = matches!(table.common.vert_rel_to, crate::model::shape::VertRelTo::Para); + let before = if !is_tac_table + && matches!(table_text_wrap, crate::model::shape::TextWrap::TopAndBottom) + { + let is_para_relative = matches!( + table.common.vert_rel_to, + crate::model::shape::VertRelTo::Para + ); if is_para_relative { (if !is_column_top { sb } else { 0.0 }) + outer_top } else { @@ -1028,11 +1229,17 @@ impl Paginator { // spacing_before_px: 레이아웃에서 표 배치 전 y_offset을 전진시키는 양 // (= before에서 outer_top을 뺀 순수 spacing_before 부분) let spacing_before_px = before - outer_top; - (before + sa + outer_bottom + host_line_spacing, host_line_spacing, spacing_before_px) + ( + before + sa + outer_bottom + host_line_spacing, + host_line_spacing, + spacing_before_px, + ) }; // 문단 내 표 컨트롤 수: 여러 개이면 개별 표 높이 사용 - let tac_table_count = para.controls.iter() + let tac_table_count = para + .controls + .iter() .filter(|c| matches!(c, Control::Table(t) if t.common.treat_as_char)) .count(); let table_total_height = if is_tac_table && para_height > 0.0 && tac_table_count <= 1 { @@ -1051,22 +1258,30 @@ impl Paginator { } else if is_tac_table && tac_table_count > 1 { // 다중 TAC 표: LINE_SEG 데이터로 개별 표 높이 계산 // LINE_SEG[k] = k번째 TAC 표의 줄 높이(표 높이 포함) + 줄간격 - let tac_idx = para.controls.iter().take(ctrl_idx) + let tac_idx = para + .controls + .iter() + .take(ctrl_idx) .filter(|c| matches!(c, Control::Table(t) if t.common.treat_as_char)) .count(); let is_last_tac = tac_idx + 1 == tac_table_count; - para.line_segs.get(tac_idx).map(|seg| { - let line_h = crate::renderer::hwpunit_to_px(seg.line_height, self.dpi); - if is_last_tac { - // 마지막 TAC: line_spacing 제외 (trailing spacing) - line_h - } else { - let ls = if seg.line_spacing > 0 { - crate::renderer::hwpunit_to_px(seg.line_spacing, self.dpi) - } else { 0.0 }; - line_h + ls - } - }).unwrap_or(effective_height + host_spacing) + para.line_segs + .get(tac_idx) + .map(|seg| { + let line_h = crate::renderer::hwpunit_to_px(seg.line_height, self.dpi); + if is_last_tac { + // 마지막 TAC: line_spacing 제외 (trailing spacing) + line_h + } else { + let ls = if seg.line_spacing > 0 { + crate::renderer::hwpunit_to_px(seg.line_spacing, self.dpi) + } else { + 0.0 + }; + line_h + ls + } + }) + .unwrap_or(effective_height + host_spacing) } else { effective_height + host_spacing }; @@ -1080,16 +1295,27 @@ impl Paginator { if mt.caption_height > 0.0 { let is_lr = table.caption.as_ref().map_or(false, |c| { use crate::model::shape::CaptionDirection; - matches!(c.direction, CaptionDirection::Left | CaptionDirection::Right) + matches!( + c.direction, + CaptionDirection::Left | CaptionDirection::Right + ) }); if !is_lr { - let cap_s = table.caption.as_ref() + let cap_s = table + .caption + .as_ref() .map(|c| crate::renderer::hwpunit_to_px(c.spacing as i32, self.dpi)) .unwrap_or(0.0); mt.caption_height + cap_s - } else { 0.0 } - } else { 0.0 } - } else { 0.0 }; + } else { + 0.0 + } + } else { + 0.0 + } + } else { + 0.0 + }; // 비-TAC 자리차지 표: vert=Para + vert_offset > 0이면 문단 시작 y 기준으로 피트 판단 // 같은 문단의 여러 표가 독립적인 vert offset으로 각자 배치되는 경우, @@ -1098,10 +1324,14 @@ impl Paginator { // ci=2 처리 후 current_height가 증가해도 ci=3의 피트는 문단 시작 기준이어야 한다. let effective_table_height = if !is_tac_table && matches!(table_text_wrap, crate::model::shape::TextWrap::TopAndBottom) - && matches!(table.common.vert_rel_to, crate::model::shape::VertRelTo::Para) + && matches!( + table.common.vert_rel_to, + crate::model::shape::VertRelTo::Para + ) && table.common.vertical_offset > 0 { - let v_off = crate::renderer::hwpunit_to_px(table.common.vertical_offset as i32, self.dpi); + let v_off = + crate::renderer::hwpunit_to_px(table.common.vertical_offset as i32, self.dpi); // 표의 절대 하단 y = 문단 시작 y + vert_offset + 표 높이 // 피트 판단식: current_height + effective_table_height <= available // 이를 만족하도록 effective_table_height = abs_bottom - current_height @@ -1115,22 +1345,58 @@ impl Paginator { // 표가 현재 페이지에 전체 들어가는지 확인 // 텍스트 문단과 동일한 0.5px 부동소수점 톨러런스 적용 if st.current_height + effective_table_height <= table_available_height + 0.5 { - self.place_table_fits(st, para_idx, ctrl_idx, para, measured, table, - table_total_height, para_height, para_height_for_fit, is_tac_table, - para_start_height, effective_height, caption_extra_for_current); + self.place_table_fits( + st, + para_idx, + ctrl_idx, + para, + measured, + table, + table_total_height, + para_height, + para_height_for_fit, + is_tac_table, + para_start_height, + effective_height, + caption_extra_for_current, + ); } else if is_tac_table { // 글자처럼 취급 표: 페이지에 걸치지 않고 통째로 다음 페이지로 이동 if !st.current_items.is_empty() { st.advance_column_or_new_page(); } - self.place_table_fits(st, para_idx, ctrl_idx, para, measured, table, - table_total_height, para_height, para_height_for_fit, is_tac_table, - para_start_height, effective_height, caption_extra_for_current); + self.place_table_fits( + st, + para_idx, + ctrl_idx, + para, + measured, + table, + table_total_height, + para_height, + para_height_for_fit, + is_tac_table, + para_start_height, + effective_height, + caption_extra_for_current, + ); } else if let Some(mt) = measured_table { // 비-TAC 표: 행 단위 분할 - self.split_table_rows(st, para_idx, ctrl_idx, para, measured, measurer, mt, - table, table_available_height, base_available_height, - host_spacing, spacing_before_px, is_tac_table); + self.split_table_rows( + st, + para_idx, + ctrl_idx, + para, + measured, + measurer, + mt, + table, + table_available_height, + base_available_height, + host_spacing, + spacing_before_px, + is_tac_table, + ); } else { // MeasuredTable 없으면 기존 방식 (전체 배치) if !st.current_items.is_empty() { @@ -1187,7 +1453,11 @@ impl Paginator { ) { let vertical_offset = Self::get_table_vertical_offset(table); // 어울림 표(text_wrap=0)는 호스트 텍스트를 wrap 영역에서 처리 - let is_wrap_around_table = !table.common.treat_as_char && matches!(table.common.text_wrap, crate::model::shape::TextWrap::Square); + let is_wrap_around_table = !table.common.treat_as_char + && matches!( + table.common.text_wrap, + crate::model::shape::TextWrap::Square + ); if let Some(mp) = measured.get_measured_paragraph(para_idx) { let total_lines = mp.line_heights.len(); @@ -1206,12 +1476,17 @@ impl Paginator { // 표 앞 텍스트 배치 (첫 번째 표에서만, 중복 방지) // 어울림 표는 wrap 영역에서 텍스트 처리하므로 건너뜀 - let is_first_table = !para.controls.iter().take(ctrl_idx) + let is_first_table = !para + .controls + .iter() + .take(ctrl_idx) .any(|c| matches!(c, Control::Table(_))); if pre_table_end_line > 0 && is_first_table && !is_wrap_around_table { // 강제 줄넘김+TAC 표: th 기반으로 텍스트 줄 높이 계산 (Task #19) let pre_height: f64 = if has_forced_linebreak { - para.line_segs.iter().take(pre_table_end_line) + para.line_segs + .iter() + .take(pre_table_end_line) .map(|seg| { let th = crate::renderer::hwpunit_to_px(seg.text_height, self.dpi); let ls = crate::renderer::hwpunit_to_px(seg.line_spacing, self.dpi); @@ -1239,11 +1514,18 @@ impl Paginator { // 후속 문단은 이 표의 하단 이후에 배치되어야 하므로 // current_height = max(current_height, para_start_height + v_off + 표높이) let is_independent_float = !is_tac_table - && matches!(table.common.text_wrap, crate::model::shape::TextWrap::TopAndBottom) - && matches!(table.common.vert_rel_to, crate::model::shape::VertRelTo::Para) + && matches!( + table.common.text_wrap, + crate::model::shape::TextWrap::TopAndBottom + ) + && matches!( + table.common.vert_rel_to, + crate::model::shape::VertRelTo::Para + ) && table.common.vertical_offset > 0; if is_independent_float { - let v_off = crate::renderer::hwpunit_to_px(table.common.vertical_offset as i32, self.dpi); + let v_off = + crate::renderer::hwpunit_to_px(table.common.vertical_offset as i32, self.dpi); let float_bottom = para_start_height + v_off + effective_height; if float_bottom > st.current_height { st.current_height = float_bottom; @@ -1258,11 +1540,16 @@ impl Paginator { // 표 뒤 텍스트 배치 // 다중 TAC 표 문단인 경우: 각 LINE_SEG가 개별 표의 높이를 담고 있으므로 // post-text를 추가하면 뒤 표들의 LINE_SEG 높이가 이중으로 계산됨 → 스킵 - let tac_table_count = para.controls.iter() + let tac_table_count = para + .controls + .iter() .filter(|c| matches!(c, Control::Table(t) if t.common.treat_as_char)) .count(); // 현재 표가 문단 내 마지막 표인지 확인 (중복 텍스트 방지) - let is_last_table = !para.controls.iter().skip(ctrl_idx + 1) + let is_last_table = !para + .controls + .iter() + .skip(ctrl_idx + 1) .any(|c| matches!(c, Control::Table(_))); let post_table_start = if has_forced_linebreak && pre_table_end_line > 0 { // 강제 줄넘김 후 TAC 표: 표 이후 post-text 없음 (Task #19) @@ -1277,11 +1564,18 @@ impl Paginator { pre_table_end_line }; // 중복 방지: 이전 표가 이미 같은 문단의 pre-text(start_line=0)를 추가했으면 건너뜀 - let pre_text_exists = post_table_start == 0 && st.current_items.iter().any(|item| { - matches!(item, PageItem::PartialParagraph { para_index, start_line, .. } + let pre_text_exists = post_table_start == 0 + && st.current_items.iter().any(|item| { + matches!(item, PageItem::PartialParagraph { para_index, start_line, .. } if *para_index == para_idx && *start_line == 0) - }); - if is_last_table && tac_table_count <= 1 && !para.text.is_empty() && total_lines > post_table_start && !is_wrap_around_table && !pre_text_exists { + }); + if is_last_table + && tac_table_count <= 1 + && !para.text.is_empty() + && total_lines > post_table_start + && !is_wrap_around_table + && !pre_text_exists + { let post_height: f64 = mp.line_advances_sum(post_table_start..total_lines); st.current_items.push(PageItem::PartialParagraph { para_index: para_idx, @@ -1322,7 +1616,11 @@ impl Paginator { ) { let row_count = mt.row_heights.len(); let cs = mt.cell_spacing; - let header_row_height = if row_count > 0 { mt.row_heights[0] } else { 0.0 }; + let header_row_height = if row_count > 0 { + mt.row_heights[0] + } else { + 0.0 + }; // 호스트 문단 텍스트 높이 계산 (예: <붙임2>) // 표의 v_offset으로 호스트 텍스트 공간이 확보되므로, @@ -1330,10 +1628,14 @@ impl Paginator { // (레이아웃 코드가 PartialTable의 호스트 텍스트를 직접 렌더링함) let vertical_offset = Self::get_table_vertical_offset(table); let host_text_height = if vertical_offset > 0 && !para.text.is_empty() { - let is_first_table = !para.controls.iter().take(ctrl_idx) + let is_first_table = !para + .controls + .iter() + .take(ctrl_idx) .any(|c| matches!(c, Control::Table(_))); if is_first_table { - measured.get_measured_paragraph(para_idx) + measured + .get_measured_paragraph(para_idx) .map(|mp| mp.line_advances_sum(0..mp.line_heights.len())) .unwrap_or(0.0) } else { @@ -1349,9 +1651,14 @@ impl Paginator { } else { 0.0 }; - let remaining_on_page = table_available_height - st.current_height - host_text_height - v_offset_px; + let remaining_on_page = + table_available_height - st.current_height - host_text_height - v_offset_px; - let first_row_h = if row_count > 0 { mt.row_heights[0] } else { 0.0 }; + let first_row_h = if row_count > 0 { + mt.row_heights[0] + } else { + 0.0 + }; let can_intra_split_early = !mt.cells.is_empty(); if remaining_on_page < first_row_h && !st.current_items.is_empty() { @@ -1370,23 +1677,31 @@ impl Paginator { // 캡션 방향 let caption_is_top = if let Some(Control::Table(t)) = para.controls.get(ctrl_idx) { - t.caption.as_ref() + t.caption + .as_ref() .map(|c| matches!(c.direction, CaptionDirection::Top)) .unwrap_or(false) - } else { false }; + } else { + false + }; // 캡션 높이 계산 - let host_line_spacing_for_caption = para.line_segs.first() + let host_line_spacing_for_caption = para + .line_segs + .first() .map(|seg| crate::renderer::hwpunit_to_px(seg.line_spacing, self.dpi)) .unwrap_or(0.0); let caption_base_overhead = { let ch = mt.caption_height; if ch > 0.0 { let cs_val = if let Some(Control::Table(t)) = para.controls.get(ctrl_idx) { - t.caption.as_ref() + t.caption + .as_ref() .map(|c| crate::renderer::hwpunit_to_px(c.spacing as i32, self.dpi)) .unwrap_or(0.0) - } else { 0.0 }; + } else { + 0.0 + }; ch + cs_val } else { 0.0 @@ -1415,11 +1730,12 @@ impl Paginator { } } - let caption_extra = if !is_continuation && cursor_row == 0 && content_offset == 0.0 && caption_is_top { - caption_overhead - } else { - 0.0 - }; + let caption_extra = + if !is_continuation && cursor_row == 0 && content_offset == 0.0 && caption_is_top { + caption_overhead + } else { + 0.0 + }; let host_extra = if !is_continuation && cursor_row == 0 && content_offset == 0.0 { host_text_height } else { @@ -1434,14 +1750,16 @@ impl Paginator { let page_avail = if is_continuation { base_available_height } else { - (table_available_height - st.current_height - caption_extra - host_extra - v_extra).max(0.0) + (table_available_height - st.current_height - caption_extra - host_extra - v_extra) + .max(0.0) }; - let header_overhead = if is_continuation && mt.repeat_header && mt.has_header_cells && row_count > 1 { - header_row_height + cs - } else { - 0.0 - }; + let header_overhead = + if is_continuation && mt.repeat_header && mt.has_header_cells && row_count > 1 { + header_row_height + cs + } else { + 0.0 + }; // 첫 분할에서 spacing_before만큼 차감: // 레이아웃 엔진은 표 배치 전 spacing_before만큼 y_offset을 전진시키지만, // page_avail 계산에는 반영되지 않으므로 avail_for_rows에서 보정한다. @@ -1465,7 +1783,8 @@ impl Paginator { { const MIN_SPLIT_CONTENT_PX: f64 = 10.0; - let approx_end = mt.find_break_row(avail_for_rows, cursor_row, effective_first_row_h); + let approx_end = + mt.find_break_row(avail_for_rows, cursor_row, effective_first_row_h); if approx_end <= cursor_row { let r = cursor_row; @@ -1568,7 +1887,11 @@ impl Paginator { let actual_split_end = split_end_limit; // 마지막 파트에 Bottom 캡션 공간 확보 - if end_row >= row_count && split_end_limit == 0.0 && !caption_is_top && caption_overhead > 0.0 { + if end_row >= row_count + && split_end_limit == 0.0 + && !caption_is_top + && caption_overhead > 0.0 + { let total_with_caption = partial_height + caption_overhead; let avail = if is_continuation { (page_avail - header_overhead).max(0.0) @@ -1585,7 +1908,11 @@ impl Paginator { if end_row >= row_count && split_end_limit == 0.0 { // 나머지 전부가 현재 페이지에 들어감 - let bottom_caption_extra = if !caption_is_top { caption_overhead } else { 0.0 }; + let bottom_caption_extra = if !caption_is_top { + caption_overhead + } else { + 0.0 + }; if cursor_row == 0 && !is_continuation && content_offset == 0.0 { st.current_items.push(PageItem::Table { para_index: para_idx, @@ -1660,23 +1987,28 @@ impl Paginator { // 머리말/꼬리말은 정의된 문단이 등장하는 페이지부터 적용 // (전체 스캔 초기 등록 제거 — 각 페이지의 범위 내 머리말만 누적) // 각 페이지의 다음 페이지 첫 문단 인덱스 사전 계산 (borrow 충돌 방지) - let next_page_first_paras: Vec = (0..pages.len()).map(|i| { - pages.get(i + 1) - .and_then(|p| p.column_contents.first()) - .and_then(|cc| cc.items.first()) - .map(|item| match item { - PageItem::FullParagraph { para_index } => *para_index, - PageItem::PartialParagraph { para_index, .. } => *para_index, - PageItem::Table { para_index, .. } => *para_index, - PageItem::PartialTable { para_index, .. } => *para_index, - PageItem::Shape { para_index, .. } => *para_index, - }) - .unwrap_or(usize::MAX) - }).collect(); + let next_page_first_paras: Vec = (0..pages.len()) + .map(|i| { + pages + .get(i + 1) + .and_then(|p| p.column_contents.first()) + .and_then(|cc| cc.items.first()) + .map(|item| match item { + PageItem::FullParagraph { para_index } => *para_index, + PageItem::PartialParagraph { para_index, .. } => *para_index, + PageItem::Table { para_index, .. } => *para_index, + PageItem::PartialTable { para_index, .. } => *para_index, + PageItem::Shape { para_index, .. } => *para_index, + }) + .unwrap_or(usize::MAX) + }) + .collect(); for (i, page) in pages.iter_mut().enumerate() { page.page_index = i as u32; - let page_last_para = page.column_contents.iter() + let page_last_para = page + .column_contents + .iter() .flat_map(|col| col.items.iter()) .filter_map(|item| match item { PageItem::FullParagraph { para_index } => Some(*para_index), @@ -1698,13 +2030,13 @@ impl Paginator { match apply_to { HeaderFooterApply::Both => header_both = Some(hf_ref.clone()), HeaderFooterApply::Even => header_even = Some(hf_ref.clone()), - HeaderFooterApply::Odd => header_odd = Some(hf_ref.clone()), + HeaderFooterApply::Odd => header_odd = Some(hf_ref.clone()), } } else { match apply_to { HeaderFooterApply::Both => footer_both = Some(hf_ref.clone()), HeaderFooterApply::Even => footer_even = Some(hf_ref.clone()), - HeaderFooterApply::Odd => footer_odd = Some(hf_ref.clone()), + HeaderFooterApply::Odd => footer_odd = Some(hf_ref.clone()), } } } @@ -1754,8 +2086,14 @@ impl Paginator { for col in &page.column_contents { for item in &col.items { match item { - PageItem::FullParagraph { para_index } if *para_index == para_idx => return true, - PageItem::PartialParagraph { para_index, start_line, .. } if *para_index == para_idx && *start_line == 0 => return true, + PageItem::FullParagraph { para_index } if *para_index == para_idx => { + return true + } + PageItem::PartialParagraph { + para_index, + start_line, + .. + } if *para_index == para_idx && *start_line == 0 => return true, PageItem::Table { para_index, .. } if *para_index == para_idx => return true, PageItem::Shape { para_index, .. } if *para_index == para_idx => return true, _ => {} @@ -1776,7 +2114,9 @@ impl Paginator { PageItem::PartialTable { para_index, .. } => *para_index, PageItem::Shape { para_index, .. } => *para_index, }; - if pi == para_idx { return true; } + if pi == para_idx { + return true; + } } } false diff --git a/src/renderer/pagination/state.rs b/src/renderer/pagination/state.rs index d6ece07e..b1f0e762 100644 --- a/src/renderer/pagination/state.rs +++ b/src/renderer/pagination/state.rs @@ -1,8 +1,8 @@ //! PaginationState: paginate_with_measured의 가변 상태를 캡슐화 -use std::collections::HashMap; +use super::{ColumnContent, PageContent, PageItem, WrapAroundPara}; use crate::renderer::page_layout::PageLayoutInfo; -use super::{PageContent, ColumnContent, PageItem, WrapAroundPara}; +use std::collections::HashMap; /// 페이지당 방어 로직 최대 실행 횟수. /// 정상 문서에서는 절대 도달하지 않는 값. 이 값을 초과하면 무한 루프로 판단하고 강제 배치. @@ -130,7 +130,10 @@ impl PaginationState { return; } let is_para_item = self.current_items.last().map_or(false, |item| { - matches!(item, PageItem::FullParagraph { .. } | PageItem::PartialParagraph { .. }) + matches!( + item, + PageItem::FullParagraph { .. } | PageItem::PartialParagraph { .. } + ) }); if !is_para_item { return; diff --git a/src/renderer/pagination/tests.rs b/src/renderer/pagination/tests.rs index 76c97e8b..5bc7f198 100644 --- a/src/renderer/pagination/tests.rs +++ b/src/renderer/pagination/tests.rs @@ -1,6 +1,6 @@ use super::*; -use crate::model::paragraph::{Paragraph, LineSeg}; -use crate::model::page::{PageDef, ColumnDef}; +use crate::model::page::{ColumnDef, PageDef}; +use crate::model::paragraph::{LineSeg, Paragraph}; fn a4_page_def() -> PageDef { PageDef { @@ -79,7 +79,7 @@ fn test_page_overflow() { 0, ); // 여러 페이지로 분할되어야 함 - assert!(result.pages.len() >= 1); + assert!(!result.pages.is_empty()); } #[test] @@ -91,8 +91,8 @@ fn test_paginator_dpi() { #[test] fn test_table_page_split() { // 표가 페이지를 초과할 때 PartialTable로 분할되는지 테스트 - use crate::model::table::{Table, Cell}; use crate::model::control::Control; + use crate::model::table::{Cell, Table}; let paginator = Paginator::with_default_dpi(); let styles = ResolvedStyleSet::default(); @@ -104,14 +104,78 @@ fn test_table_page_split() { row_count: 4, col_count: 2, cells: vec![ - Cell { row: 0, col: 0, row_span: 1, col_span: 1, height: 30000, width: 5000, ..Default::default() }, - Cell { row: 0, col: 1, row_span: 1, col_span: 1, height: 30000, width: 5000, ..Default::default() }, - Cell { row: 1, col: 0, row_span: 1, col_span: 1, height: 30000, width: 5000, ..Default::default() }, - Cell { row: 1, col: 1, row_span: 1, col_span: 1, height: 30000, width: 5000, ..Default::default() }, - Cell { row: 2, col: 0, row_span: 1, col_span: 1, height: 30000, width: 5000, ..Default::default() }, - Cell { row: 2, col: 1, row_span: 1, col_span: 1, height: 30000, width: 5000, ..Default::default() }, - Cell { row: 3, col: 0, row_span: 1, col_span: 1, height: 30000, width: 5000, ..Default::default() }, - Cell { row: 3, col: 1, row_span: 1, col_span: 1, height: 30000, width: 5000, ..Default::default() }, + Cell { + row: 0, + col: 0, + row_span: 1, + col_span: 1, + height: 30000, + width: 5000, + ..Default::default() + }, + Cell { + row: 0, + col: 1, + row_span: 1, + col_span: 1, + height: 30000, + width: 5000, + ..Default::default() + }, + Cell { + row: 1, + col: 0, + row_span: 1, + col_span: 1, + height: 30000, + width: 5000, + ..Default::default() + }, + Cell { + row: 1, + col: 1, + row_span: 1, + col_span: 1, + height: 30000, + width: 5000, + ..Default::default() + }, + Cell { + row: 2, + col: 0, + row_span: 1, + col_span: 1, + height: 30000, + width: 5000, + ..Default::default() + }, + Cell { + row: 2, + col: 1, + row_span: 1, + col_span: 1, + height: 30000, + width: 5000, + ..Default::default() + }, + Cell { + row: 3, + col: 0, + row_span: 1, + col_span: 1, + height: 30000, + width: 5000, + ..Default::default() + }, + Cell { + row: 3, + col: 1, + row_span: 1, + col_span: 1, + height: 30000, + width: 5000, + ..Default::default() + }, ], ..Default::default() }))); @@ -128,7 +192,11 @@ fn test_table_page_split() { ); // 표가 1페이지에 안 맞으므로 2페이지 이상이어야 함 - assert!(result.pages.len() >= 2, "표가 페이지를 넘어 분할되어야 함, pages={}", result.pages.len()); + assert!( + result.pages.len() >= 2, + "표가 페이지를 넘어 분할되어야 함, pages={}", + result.pages.len() + ); // PartialTable 항목이 존재하는지 확인 let mut has_partial_table = false; @@ -147,8 +215,8 @@ fn test_table_page_split() { #[test] fn test_table_fits_single_page() { // 표가 페이지에 들어가면 Table로 배치 (분할 안 됨) - use crate::model::table::{Table, Cell}; use crate::model::control::Control; + use crate::model::table::{Cell, Table}; let paginator = Paginator::with_default_dpi(); let styles = ResolvedStyleSet::default(); @@ -158,10 +226,42 @@ fn test_table_fits_single_page() { row_count: 2, col_count: 2, cells: vec![ - Cell { row: 0, col: 0, row_span: 1, col_span: 1, height: 2000, width: 5000, ..Default::default() }, - Cell { row: 0, col: 1, row_span: 1, col_span: 1, height: 2000, width: 5000, ..Default::default() }, - Cell { row: 1, col: 0, row_span: 1, col_span: 1, height: 2000, width: 5000, ..Default::default() }, - Cell { row: 1, col: 1, row_span: 1, col_span: 1, height: 2000, width: 5000, ..Default::default() }, + Cell { + row: 0, + col: 0, + row_span: 1, + col_span: 1, + height: 2000, + width: 5000, + ..Default::default() + }, + Cell { + row: 0, + col: 1, + row_span: 1, + col_span: 1, + height: 2000, + width: 5000, + ..Default::default() + }, + Cell { + row: 1, + col: 0, + row_span: 1, + col_span: 1, + height: 2000, + width: 5000, + ..Default::default() + }, + Cell { + row: 1, + col: 1, + row_span: 1, + col_span: 1, + height: 2000, + width: 5000, + ..Default::default() + }, ], ..Default::default() }))); @@ -181,14 +281,17 @@ fn test_table_fits_single_page() { assert_eq!(result.pages.len(), 1); // Table 항목이어야 함 (PartialTable 아님) let items = &result.pages[0].column_contents[0].items; - assert!(matches!(items[0], PageItem::Table { .. }), "작은 표는 Table로 배치되어야 함"); + assert!( + matches!(items[0], PageItem::Table { .. }), + "작은 표는 Table로 배치되어야 함" + ); } #[test] fn test_table_split_with_repeat_header() { // repeat_header=true인 표가 분할될 때 is_continuation 확인 - use crate::model::table::{Table, Cell}; use crate::model::control::Control; + use crate::model::table::{Cell, Table}; let paginator = Paginator::with_default_dpi(); let styles = ResolvedStyleSet::default(); @@ -199,10 +302,43 @@ fn test_table_split_with_repeat_header() { col_count: 1, repeat_header: true, cells: vec![ - Cell { row: 0, col: 0, row_span: 1, col_span: 1, height: 5000, width: 10000, is_header: true, ..Default::default() }, - Cell { row: 1, col: 0, row_span: 1, col_span: 1, height: 40000, width: 10000, ..Default::default() }, - Cell { row: 2, col: 0, row_span: 1, col_span: 1, height: 40000, width: 10000, ..Default::default() }, - Cell { row: 3, col: 0, row_span: 1, col_span: 1, height: 40000, width: 10000, ..Default::default() }, + Cell { + row: 0, + col: 0, + row_span: 1, + col_span: 1, + height: 5000, + width: 10000, + is_header: true, + ..Default::default() + }, + Cell { + row: 1, + col: 0, + row_span: 1, + col_span: 1, + height: 40000, + width: 10000, + ..Default::default() + }, + Cell { + row: 2, + col: 0, + row_span: 1, + col_span: 1, + height: 40000, + width: 10000, + ..Default::default() + }, + Cell { + row: 3, + col: 0, + row_span: 1, + col_span: 1, + height: 40000, + width: 10000, + ..Default::default() + }, ], ..Default::default() }))); @@ -226,7 +362,10 @@ fn test_table_split_with_repeat_header() { for page in result.pages.iter().skip(1) { for col in &page.column_contents { for item in &col.items { - if let PageItem::PartialTable { is_continuation, .. } = item { + if let PageItem::PartialTable { + is_continuation, .. + } = item + { if *is_continuation { found_continuation = true; } @@ -234,7 +373,10 @@ fn test_table_split_with_repeat_header() { } } } - assert!(found_continuation, "연속 페이지에 is_continuation=true인 PartialTable이 있어야 함"); + assert!( + found_continuation, + "연속 페이지에 is_continuation=true인 PartialTable이 있어야 함" + ); } /// 여러 줄로 구성된 문단 생성 (줄 수, 줄당 높이 HWPUNIT) @@ -268,7 +410,11 @@ fn test_partial_paragraph_split() { ); // 2페이지 이상으로 분할되어야 함 - assert!(result.pages.len() >= 2, "긴 문단이 2페이지 이상으로 분할되어야 함, pages={}", result.pages.len()); + assert!( + result.pages.len() >= 2, + "긴 문단이 2페이지 이상으로 분할되어야 함, pages={}", + result.pages.len() + ); // PartialParagraph 항목이 존재하는지 확인 let mut has_partial = false; @@ -276,7 +422,12 @@ fn test_partial_paragraph_split() { for page in &result.pages { for col in &page.column_contents { for item in &col.items { - if let PageItem::PartialParagraph { start_line, end_line, .. } = item { + if let PageItem::PartialParagraph { + start_line, + end_line, + .. + } = item + { has_partial = true; partial_ranges.push((*start_line, *end_line)); } @@ -291,15 +442,20 @@ fn test_partial_paragraph_split() { // 파트가 연속적이어야 함 (이전 end_line == 다음 start_line) for i in 1..partial_ranges.len() { assert_eq!( - partial_ranges[i - 1].1, partial_ranges[i].0, + partial_ranges[i - 1].1, + partial_ranges[i].0, "파트 {}의 end_line({})이 파트 {}의 start_line({})과 일치해야 함", - i - 1, partial_ranges[i - 1].1, i, partial_ranges[i].0, + i - 1, + partial_ranges[i - 1].1, + i, + partial_ranges[i].0, ); } // 마지막 파트의 end_line은 전체 줄 수(10)여야 함 assert_eq!( - partial_ranges.last().unwrap().1, 10, + partial_ranges.last().unwrap().1, + 10, "마지막 파트 end_line은 전체 줄 수(10)여야 함" ); } @@ -322,7 +478,10 @@ fn test_short_paragraph_no_split() { assert_eq!(result.pages.len(), 1); let items = &result.pages[0].column_contents[0].items; - assert!(matches!(items[0], PageItem::FullParagraph { .. }), "짧은 문단은 FullParagraph여야 함"); + assert!( + matches!(items[0], PageItem::FullParagraph { .. }), + "짧은 문단은 FullParagraph여야 함" + ); } #[test] @@ -341,7 +500,11 @@ fn test_partial_paragraph_multi_page_span() { 0, ); - assert!(result.pages.len() >= 3, "30줄 문단이 3페이지 이상이어야 함, pages={}", result.pages.len()); + assert!( + result.pages.len() >= 3, + "30줄 문단이 3페이지 이상이어야 함, pages={}", + result.pages.len() + ); } #[test] @@ -368,19 +531,28 @@ fn test_partial_paragraph_after_content() { // 첫 페이지에 첫 문단 FullParagraph + 두번째 문단 PartialParagraph let page1_items = &result.pages[0].column_contents[0].items; - assert!(matches!(page1_items[0], PageItem::FullParagraph { para_index: 0 }), - "첫 문단은 FullParagraph여야 함"); + assert!( + matches!(page1_items[0], PageItem::FullParagraph { para_index: 0 }), + "첫 문단은 FullParagraph여야 함" + ); - let has_partial_on_page1 = page1_items.iter().any(|item| - matches!(item, PageItem::PartialParagraph { para_index: 1, .. }) + let has_partial_on_page1 = page1_items + .iter() + .any(|item| matches!(item, PageItem::PartialParagraph { para_index: 1, .. })); + assert!( + has_partial_on_page1, + "첫 페이지에 두번째 문단의 PartialParagraph가 있어야 함" ); - assert!(has_partial_on_page1, "첫 페이지에 두번째 문단의 PartialParagraph가 있어야 함"); } /// 셀 내용이 포함된 CellBreak 표 생성 헬퍼 -fn make_cellbreak_table(row_count: u16, col_count: u16, cell_height: u32) -> crate::model::table::Table { - use crate::model::table::{Table, Cell, TablePageBreak}; +fn make_cellbreak_table( + row_count: u16, + col_count: u16, + cell_height: u32, +) -> crate::model::table::Table { use crate::model::paragraph::LineSeg; + use crate::model::table::{Cell, Table, TablePageBreak}; let mut cells = Vec::new(); for r in 0..row_count { @@ -443,8 +615,11 @@ fn test_table_cell_break_intra_row_split() { ); // 2페이지 이상이어야 함 - assert!(result.pages.len() >= 2, - "CellBreak 큰 행이 분할되어야 함, pages={}", result.pages.len()); + assert!( + result.pages.len() >= 2, + "CellBreak 큰 행이 분할되어야 함, pages={}", + result.pages.len() + ); // split_start_content_offset 또는 split_end_content_limit > 0인 PartialTable 존재 확인 let mut has_intra_split = false; @@ -453,8 +628,10 @@ fn test_table_cell_break_intra_row_split() { for item in &col.items { if let PageItem::PartialTable { split_start_content_offset, - split_end_content_limit, .. - } = item { + split_end_content_limit, + .. + } = item + { if *split_start_content_offset > 0.0 || *split_end_content_limit > 0.0 { has_intra_split = true; } @@ -462,14 +639,17 @@ fn test_table_cell_break_intra_row_split() { } } } - assert!(has_intra_split, "CellBreak 표에 인트라-로우 분할이 발생해야 함"); + assert!( + has_intra_split, + "CellBreak 표에 인트라-로우 분할이 발생해야 함" + ); } #[test] fn test_table_none_also_intra_row_split() { // page_break=None 표도 인트라-로우 분할 적용 (모든 표에 적용) - use crate::model::table::TablePageBreak; use crate::model::control::Control; + use crate::model::table::TablePageBreak; let paginator = Paginator::with_default_dpi(); let styles = ResolvedStyleSet::default(); @@ -493,8 +673,11 @@ fn test_table_none_also_intra_row_split() { ); // 2페이지 이상이어야 함 - assert!(result.pages.len() >= 2, - "None 표도 큰 행이 분할되어야 함, pages={}", result.pages.len()); + assert!( + result.pages.len() >= 2, + "None 표도 큰 행이 분할되어야 함, pages={}", + result.pages.len() + ); // 인트라-로우 분할이 발생해야 함 let mut has_intra_split = false; @@ -503,8 +686,10 @@ fn test_table_none_also_intra_row_split() { for item in &col.items { if let PageItem::PartialTable { split_start_content_offset, - split_end_content_limit, .. - } = item { + split_end_content_limit, + .. + } = item + { if *split_start_content_offset > 0.0 || *split_end_content_limit > 0.0 { has_intra_split = true; } @@ -512,7 +697,10 @@ fn test_table_none_also_intra_row_split() { } } } - assert!(has_intra_split, "None 표에도 인트라-로우 분할이 발생해야 함"); + assert!( + has_intra_split, + "None 표에도 인트라-로우 분할이 발생해야 함" + ); } #[test] @@ -540,15 +728,22 @@ fn test_table_cell_break_multi_page_row() { ); // 3페이지 이상 - assert!(result.pages.len() >= 3, - "200000 HWPUNIT 행이 3+페이지에 걸쳐야 함, pages={}", result.pages.len()); + assert!( + result.pages.len() >= 3, + "200000 HWPUNIT 행이 3+페이지에 걸쳐야 함, pages={}", + result.pages.len() + ); // content_offset이 누적되는지 확인 let mut offsets: Vec = Vec::new(); for page in &result.pages { for col in &page.column_contents { for item in &col.items { - if let PageItem::PartialTable { split_start_content_offset, .. } = item { + if let PageItem::PartialTable { + split_start_content_offset, + .. + } = item + { offsets.push(*split_start_content_offset); } } @@ -559,11 +754,19 @@ fn test_table_cell_break_multi_page_row() { if offsets.len() >= 2 { assert_eq!(offsets[0], 0.0, "첫 페이지 offset은 0이어야 함"); for i in 1..offsets.len() { - assert!(offsets[i] > 0.0, - "{}번째 페이지 offset은 0보다 커야 함: {}", i + 1, offsets[i]); + assert!( + offsets[i] > 0.0, + "{}번째 페이지 offset은 0보다 커야 함: {}", + i + 1, + offsets[i] + ); if i >= 2 { - assert!(offsets[i] > offsets[i - 1], - "offset이 증가해야 함: {} > {}", offsets[i], offsets[i - 1]); + assert!( + offsets[i] > offsets[i - 1], + "offset이 증가해야 함: {} > {}", + offsets[i], + offsets[i - 1] + ); } } } @@ -574,8 +777,8 @@ fn test_table_cell_break_multi_page_row() { /// 10행 표가 페이지 하단에서 시작 → 행 단위 분리 검증 (S1) #[test] fn test_table_split_10rows_at_page_bottom() { - use crate::model::table::{Table, Cell}; use crate::model::control::Control; + use crate::model::table::{Cell, Table}; let paginator = Paginator::with_default_dpi(); let styles = ResolvedStyleSet::default(); @@ -588,32 +791,51 @@ fn test_table_split_10rows_at_page_bottom() { for r in 0..10u16 { for c in 0..2u16 { cells.push(Cell { - row: r, col: c, row_span: 1, col_span: 1, - height: 6000, width: 5000, ..Default::default() + row: r, + col: c, + row_span: 1, + col_span: 1, + height: 6000, + width: 5000, + ..Default::default() }); } } let mut table_para = Paragraph::default(); table_para.controls.push(Control::Table(Box::new(Table { - row_count: 10, col_count: 2, cells, ..Default::default() + row_count: 10, + col_count: 2, + cells, + ..Default::default() }))); let paras = vec![filler, table_para]; let composed: Vec = Vec::new(); let (result, _measured) = paginator.paginate( - ¶s, &composed, &styles, &a4_page_def(), &ColumnDef::default(), 0, + ¶s, + &composed, + &styles, + &a4_page_def(), + &ColumnDef::default(), + 0, ); // 2페이지 이상으로 분할되어야 함 - assert!(result.pages.len() >= 2, - "10행 표가 페이지 하단에서 분할되어야 함, pages={}", result.pages.len()); + assert!( + result.pages.len() >= 2, + "10행 표가 페이지 하단에서 분할되어야 함, pages={}", + result.pages.len() + ); // PartialTable들의 행 범위를 수집 let mut partials: Vec<(usize, usize)> = Vec::new(); for page in &result.pages { for col in &page.column_contents { for item in &col.items { - if let PageItem::PartialTable { start_row, end_row, .. } = item { + if let PageItem::PartialTable { + start_row, end_row, .. + } = item + { partials.push((*start_row, *end_row)); } } @@ -626,21 +848,30 @@ fn test_table_split_10rows_at_page_bottom() { // 파트가 연속적이어야 함 (이전 end_row == 다음 start_row) for i in 1..partials.len() { - assert_eq!(partials[i - 1].1, partials[i].0, + assert_eq!( + partials[i - 1].1, + partials[i].0, "행 범위가 연속적이어야 함: 파트{} end_row={} ≠ 파트{} start_row={}", - i - 1, partials[i - 1].1, i, partials[i].0); + i - 1, + partials[i - 1].1, + i, + partials[i].0 + ); } // 마지막 파트 end_row=10 - assert_eq!(partials.last().unwrap().1, 10, - "마지막 파트 end_row은 전체 행 수(10)여야 함"); + assert_eq!( + partials.last().unwrap().1, + 10, + "마지막 파트 end_row은 전체 행 수(10)여야 함" + ); } /// 50행 대형 표 → 여러 페이지 분할 검증 (S2) #[test] fn test_table_split_50rows_multi_page() { - use crate::model::table::{Table, Cell}; use crate::model::control::Control; + use crate::model::table::{Cell, Table}; let paginator = Paginator::with_default_dpi(); let styles = ResolvedStyleSet::default(); @@ -649,31 +880,50 @@ fn test_table_split_50rows_multi_page() { let mut cells = Vec::new(); for r in 0..50u16 { cells.push(Cell { - row: r, col: 0, row_span: 1, col_span: 1, - height: 4000, width: 10000, ..Default::default() + row: r, + col: 0, + row_span: 1, + col_span: 1, + height: 4000, + width: 10000, + ..Default::default() }); } let mut table_para = Paragraph::default(); table_para.controls.push(Control::Table(Box::new(Table { - row_count: 50, col_count: 1, cells, ..Default::default() + row_count: 50, + col_count: 1, + cells, + ..Default::default() }))); let paras = vec![table_para]; let composed: Vec = Vec::new(); let (result, _measured) = paginator.paginate( - ¶s, &composed, &styles, &a4_page_def(), &ColumnDef::default(), 0, + ¶s, + &composed, + &styles, + &a4_page_def(), + &ColumnDef::default(), + 0, ); // 3페이지 이상 - assert!(result.pages.len() >= 3, - "50행 대형 표가 3+페이지에 걸쳐야 함, pages={}", result.pages.len()); + assert!( + result.pages.len() >= 3, + "50행 대형 표가 3+페이지에 걸쳐야 함, pages={}", + result.pages.len() + ); // 모든 PartialTable의 행 범위 수집 let mut partials: Vec<(usize, usize)> = Vec::new(); for page in &result.pages { for col in &page.column_contents { for item in &col.items { - if let PageItem::PartialTable { start_row, end_row, .. } = item { + if let PageItem::PartialTable { + start_row, end_row, .. + } = item + { partials.push((*start_row, *end_row)); } } @@ -683,12 +933,17 @@ fn test_table_split_50rows_multi_page() { // 전체 50행이 빠짐없이 커버되어야 함 assert_eq!(partials[0].0, 0, "첫 파트 start_row=0"); for i in 1..partials.len() { - assert_eq!(partials[i - 1].1, partials[i].0, + assert_eq!( + partials[i - 1].1, + partials[i].0, "행 범위 연속: 파트{} end={} ≠ 파트{} start={}", - i - 1, partials[i - 1].1, i, partials[i].0); + i - 1, + partials[i - 1].1, + i, + partials[i].0 + ); } - assert_eq!(partials.last().unwrap().1, 50, - "마지막 파트 end_row=50"); + assert_eq!(partials.last().unwrap().1, 50, "마지막 파트 end_row=50"); // 각 파트의 행 범위가 비어있지 않아야 함 for (i, (s, e)) in partials.iter().enumerate() { @@ -699,9 +954,9 @@ fn test_table_split_50rows_multi_page() { /// 셀 내 중첩 표가 있는 행의 분할 검증 (S3) #[test] fn test_table_split_with_nested_table() { - use crate::model::table::{Table, Cell}; use crate::model::control::Control; use crate::model::paragraph::LineSeg; + use crate::model::table::{Cell, Table}; let paginator = Paginator::with_default_dpi(); let styles = ResolvedStyleSet::default(); @@ -710,37 +965,61 @@ fn test_table_split_with_nested_table() { let mut nested_cells = Vec::new(); for r in 0..10u16 { nested_cells.push(Cell { - row: r, col: 0, row_span: 1, col_span: 1, - height: 8000, width: 5000, + row: r, + col: 0, + row_span: 1, + col_span: 1, + height: 8000, + width: 5000, paragraphs: vec![Paragraph { - line_segs: vec![LineSeg { line_height: 8000, ..Default::default() }], + line_segs: vec![LineSeg { + line_height: 8000, + ..Default::default() + }], ..Default::default() }], ..Default::default() }); } let nested_table = Table { - row_count: 10, col_count: 1, cells: nested_cells, ..Default::default() + row_count: 10, + col_count: 1, + cells: nested_cells, + ..Default::default() }; // 외부 표: 2행, 첫 행에 중첩 표 포함 // 셀 높이를 중첩 표 전체 높이(~1067px)로 설정 → 본문 영역 초과 → 분할 필수 let nested_h: i32 = 8000 * 10; // 80000 HWPUNIT let outer_cell_0 = Cell { - row: 0, col: 0, row_span: 1, col_span: 1, - height: nested_h as u32, width: 10000, + row: 0, + col: 0, + row_span: 1, + col_span: 1, + height: nested_h as u32, + width: 10000, paragraphs: vec![Paragraph { - line_segs: vec![LineSeg { line_height: nested_h, ..Default::default() }], + line_segs: vec![LineSeg { + line_height: nested_h, + ..Default::default() + }], controls: vec![Control::Table(Box::new(nested_table))], ..Default::default() }], ..Default::default() }; let outer_cell_1 = Cell { - row: 1, col: 0, row_span: 1, col_span: 1, - height: 5000, width: 10000, + row: 1, + col: 0, + row_span: 1, + col_span: 1, + height: 5000, + width: 10000, paragraphs: vec![Paragraph { - line_segs: vec![LineSeg { line_height: 5000, ..Default::default() }], + line_segs: vec![LineSeg { + line_height: 5000, + ..Default::default() + }], ..Default::default() }], ..Default::default() @@ -748,7 +1027,8 @@ fn test_table_split_with_nested_table() { let mut table_para = Paragraph::default(); table_para.controls.push(Control::Table(Box::new(Table { - row_count: 2, col_count: 1, + row_count: 2, + col_count: 1, cells: vec![outer_cell_0, outer_cell_1], ..Default::default() }))); @@ -758,27 +1038,40 @@ fn test_table_split_with_nested_table() { let paras = vec![filler, table_para]; let composed: Vec = Vec::new(); let (result, _measured) = paginator.paginate( - ¶s, &composed, &styles, &a4_page_def(), &ColumnDef::default(), 0, + ¶s, + &composed, + &styles, + &a4_page_def(), + &ColumnDef::default(), + 0, ); // 페이지가 분할되어야 함 - assert!(result.pages.len() >= 2, - "중첩 표가 있는 외부 표가 분할되어야 함, pages={}", result.pages.len()); + assert!( + result.pages.len() >= 2, + "중첩 표가 있는 외부 표가 분할되어야 함, pages={}", + result.pages.len() + ); // PartialTable 존재 확인 - let has_partial = result.pages.iter().any(|p| - p.column_contents.iter().any(|c| - c.items.iter().any(|i| matches!(i, PageItem::PartialTable { .. })) - ) + let has_partial = result.pages.iter().any(|p| { + p.column_contents.iter().any(|c| { + c.items + .iter() + .any(|i| matches!(i, PageItem::PartialTable { .. })) + }) + }); + assert!( + has_partial, + "중첩 표 포함 외부 표에 PartialTable이 존재해야 함" ); - assert!(has_partial, "중첩 표 포함 외부 표에 PartialTable이 존재해야 함"); } /// B-011 재현: 표 높이가 body area를 초과하지 않는지 검증 (S4) #[test] fn test_table_height_within_body_area() { - use crate::model::table::{Table, Cell}; use crate::model::control::Control; + use crate::model::table::{Cell, Table}; let paginator = Paginator::with_default_dpi(); let styles = ResolvedStyleSet::default(); @@ -799,21 +1092,34 @@ fn test_table_height_within_body_area() { for r in 0..5u16 { for c in 0..2u16 { cells.push(Cell { - row: r, col: c, row_span: 1, col_span: 1, - height: 10000, width: 5000, ..Default::default() + row: r, + col: c, + row_span: 1, + col_span: 1, + height: 10000, + width: 5000, + ..Default::default() }); } } let mut table_para = Paragraph::default(); table_para.controls.push(Control::Table(Box::new(Table { - row_count: 5, col_count: 2, cells, ..Default::default() + row_count: 5, + col_count: 2, + cells, + ..Default::default() }))); paras.push(table_para); } let composed: Vec = Vec::new(); let (result, measured) = paginator.paginate( - ¶s, &composed, &styles, &page_def, &ColumnDef::default(), 0, + ¶s, + &composed, + &styles, + &page_def, + &ColumnDef::default(), + 0, ); // 각 페이지의 콘텐츠 높이 합이 body area를 초과하지 않는지 확인 @@ -834,14 +1140,28 @@ fn test_table_height_within_body_area() { height_sum += m.total_height; } } - PageItem::PartialTable { para_index, start_row, end_row, .. } => { + PageItem::PartialTable { + para_index, + start_row, + end_row, + .. + } => { let mt = measured.get_measured_table(*para_index, 0); if let Some(m) = mt { // cumulative_heights로 부분 높이 계산 let start_h = if *start_row > 0 { - m.cumulative_heights.get(*start_row - 1).copied().unwrap_or(0.0) - } else { 0.0 }; - let end_h = m.cumulative_heights.get(*end_row - 1).copied().unwrap_or(m.total_height); + m.cumulative_heights + .get(*start_row - 1) + .copied() + .unwrap_or(0.0) + } else { + 0.0 + }; + let end_h = m + .cumulative_heights + .get(*end_row - 1) + .copied() + .unwrap_or(m.total_height); height_sum += end_h - start_h; } } @@ -849,9 +1169,13 @@ fn test_table_height_within_body_area() { } } // body area를 초과하면 안 됨 (약간의 여유 허용) - assert!(height_sum <= body_h_px + 2.0, + assert!( + height_sum <= body_h_px + 2.0, "page {} 콘텐츠 높이({:.1}px)가 body area({:.1}px)를 초과함", - page_idx, height_sum, body_h_px); + page_idx, + height_sum, + body_h_px + ); } } } diff --git a/src/renderer/pdf.rs b/src/renderer/pdf.rs index 12913e74..fb419e41 100644 --- a/src/renderer/pdf.rs +++ b/src/renderer/pdf.rs @@ -25,8 +25,14 @@ fn create_fontdb() -> usvg::fontdb::Database { /// SVG에서 없는 한글 폰트명에 fallback 추가 #[cfg(not(target_arch = "wasm32"))] fn add_font_fallbacks(svg: &str) -> String { - svg.replace("font-family=\"휴먼명조\"", "font-family=\"휴먼명조, 바탕, serif\"") - .replace("font-family=\"HCI Poppy\"", "font-family=\"HCI Poppy, 맑은 고딕, sans-serif\"") + svg.replace( + "font-family=\"휴먼명조\"", + "font-family=\"휴먼명조, 바탕, serif\"", + ) + .replace( + "font-family=\"HCI Poppy\"", + "font-family=\"HCI Poppy, 맑은 고딕, sans-serif\"", + ) } /// 단일 SVG를 PDF로 변환 @@ -38,8 +44,12 @@ pub fn svg_to_pdf(svg_content: &str) -> Result, String> { let svg_with_fallback = add_font_fallbacks(svg_content); let tree = usvg::Tree::from_str(&svg_with_fallback, &options) .map_err(|e| format!("SVG 파싱 실패: {}", e))?; - let pdf = svg2pdf::to_pdf(&tree, svg2pdf::ConversionOptions::default(), svg2pdf::PageOptions::default()) - .map_err(|e| format!("PDF 변환 실패: {:?}", e))?; + let pdf = svg2pdf::to_pdf( + &tree, + svg2pdf::ConversionOptions::default(), + svg2pdf::PageOptions::default(), + ) + .map_err(|e| format!("PDF 변환 실패: {:?}", e))?; Ok(pdf) } @@ -53,7 +63,7 @@ pub fn svgs_to_pdf(svg_pages: &[String]) -> Result, String> { return svg_to_pdf(&svg_pages[0]); } - use pdf_writer::{Pdf, Ref, Finish}; + use pdf_writer::{Finish, Pdf, Ref}; use std::collections::HashMap; let fontdb = create_fontdb(); @@ -86,7 +96,12 @@ pub fn svgs_to_pdf(svg_pages: &[String]) -> Result, String> { let w = tree.size().width() * dpi_ratio; let h = tree.size().height() * dpi_ratio; - page_datas.push(PageData { chunk, svg_ref, width: w, height: h }); + page_datas.push(PageData { + chunk, + svg_ref, + width: w, + height: h, + }); } // 각 chunk를 재번호화하고 페이지 참조 수집 @@ -101,9 +116,9 @@ pub fn svgs_to_pdf(svg_pages: &[String]) -> Result, String> { // chunk 재번호화 let mut map = HashMap::new(); - let renumbered = pd.chunk.renumber(|old| { - *map.entry(old).or_insert_with(|| alloc.bump()) - }); + let renumbered = pd + .chunk + .renumber(|old| *map.entry(old).or_insert_with(|| alloc.bump())); let remapped_svg_ref = map.get(&pd.svg_ref).copied().unwrap_or(pd.svg_ref); svg_refs_remapped.push(remapped_svg_ref); @@ -150,7 +165,8 @@ pub fn svgs_to_pdf(svg_pages: &[String]) -> Result, String> { // 문서 정보 let info_ref = alloc.bump(); - pdf.document_info(info_ref).producer(pdf_writer::TextStr("rhwp")); + pdf.document_info(info_ref) + .producer(pdf_writer::TextStr("rhwp")); Ok(pdf.finish()) } diff --git a/src/renderer/render_tree.rs b/src/renderer/render_tree.rs index 4def4891..76b4ebc0 100644 --- a/src/renderer/render_tree.rs +++ b/src/renderer/render_tree.rs @@ -3,12 +3,12 @@ //! IR(Document Model)로부터 변환된 렌더링 전용 트리 구조. //! 각 노드는 페이지 내 위치와 크기가 계산된 상태를 가진다. -use crate::model::{ColorRef, Rect}; -use crate::model::style::ImageFillMode; -use crate::model::image::ImageEffect; -use super::{TextStyle, ShapeStyle, LineStyle, PathCommand, GradientFillInfo}; use super::composer::CharOverlapInfo; use super::layout::CellContext; +use super::{GradientFillInfo, LineStyle, PathCommand, ShapeStyle, TextStyle}; +use crate::model::image::ImageEffect; +use crate::model::style::ImageFillMode; +use crate::model::{ColorRef, Rect}; /// 렌더 노드 고유 ID pub type NodeId = u32; @@ -87,17 +87,37 @@ impl RenderNode { RenderNodeType::Body { .. } => ("Body", String::new()), RenderNodeType::Column(c) => ("Column", format!(",\"col\":{}", c)), RenderNodeType::FootnoteArea => ("FootnoteArea", String::new()), - RenderNodeType::TextLine(tl) => ("TextLine", format!( - ",\"pi\":{}", tl.para_index.unwrap_or(0))), - RenderNodeType::TextRun(tr) => ("TextRun", format!( - ",\"text\":{},\"pi\":{}", json_escape(&tr.text), - tr.section_index.map(|_| tr.para_index.unwrap_or(0)).unwrap_or(0))), - RenderNodeType::Table(tn) => ("Table", format!( - ",\"rows\":{},\"cols\":{}{}{}", tn.row_count, tn.col_count, - tn.para_index.map(|pi| format!(",\"pi\":{}", pi)).unwrap_or_default(), - tn.control_index.map(|ci| format!(",\"ci\":{}", ci)).unwrap_or_default())), - RenderNodeType::TableCell(tc) => ("Cell", format!( - ",\"row\":{},\"col\":{}", tc.row, tc.col)), + RenderNodeType::TextLine(tl) => ( + "TextLine", + format!(",\"pi\":{}", tl.para_index.unwrap_or(0)), + ), + RenderNodeType::TextRun(tr) => ( + "TextRun", + format!( + ",\"text\":{},\"pi\":{}", + json_escape(&tr.text), + tr.section_index + .map(|_| tr.para_index.unwrap_or(0)) + .unwrap_or(0) + ), + ), + RenderNodeType::Table(tn) => ( + "Table", + format!( + ",\"rows\":{},\"cols\":{}{}{}", + tn.row_count, + tn.col_count, + tn.para_index + .map(|pi| format!(",\"pi\":{}", pi)) + .unwrap_or_default(), + tn.control_index + .map(|ci| format!(",\"ci\":{}", ci)) + .unwrap_or_default() + ), + ), + RenderNodeType::TableCell(tc) => { + ("Cell", format!(",\"row\":{},\"col\":{}", tc.row, tc.col)) + } RenderNodeType::Image(_) => ("Image", String::new()), RenderNodeType::TextBox => ("TextBox", String::new()), RenderNodeType::Equation(_) => ("Equation", String::new()), @@ -109,13 +129,17 @@ impl RenderNode { RenderNodeType::FormObject(_) => ("Form", String::new()), RenderNodeType::FootnoteMarker(_) => ("FnMarker", String::new()), }; - buf.push_str(&format!("\"type\":\"{}\",\"bbox\":{{\"x\":{:.1},\"y\":{:.1},\"w\":{:.1},\"h\":{:.1}}}", - type_str, self.bbox.x, self.bbox.y, self.bbox.width, self.bbox.height)); + buf.push_str(&format!( + "\"type\":\"{}\",\"bbox\":{{\"x\":{:.1},\"y\":{:.1},\"w\":{:.1},\"h\":{:.1}}}", + type_str, self.bbox.x, self.bbox.y, self.bbox.width, self.bbox.height + )); buf.push_str(&extra); if !self.children.is_empty() { buf.push_str(",\"children\":["); for (i, child) in self.children.iter().enumerate() { - if i > 0 { buf.push(','); } + if i > 0 { + buf.push(','); + } child.write_json(buf); } buf.push(']'); @@ -259,7 +283,12 @@ pub struct BoundingBox { impl BoundingBox { pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self { - Self { x, y, width, height } + Self { + x, + y, + width, + height, + } } /// 다른 박스와 겹치는지 확인 @@ -343,12 +372,27 @@ pub struct TextLineNode { impl TextLineNode { /// 기본 생성 (문단 식별 정보 없음) pub fn new(line_height: f64, baseline: f64) -> Self { - Self { line_height, baseline, section_index: None, para_index: None } + Self { + line_height, + baseline, + section_index: None, + para_index: None, + } } /// 문단 식별 정보 포함 생성 (커서 위치 계산용) - pub fn with_para(line_height: f64, baseline: f64, section_index: usize, para_index: usize) -> Self { - Self { line_height, baseline, section_index: Some(section_index), para_index: Some(para_index) } + pub fn with_para( + line_height: f64, + baseline: f64, + section_index: usize, + para_index: usize, + ) -> Self { + Self { + line_height, + baseline, + section_index: Some(section_index), + para_index: Some(para_index), + } } } @@ -483,9 +527,17 @@ pub struct LineNode { impl LineNode { pub fn new(x1: f64, y1: f64, x2: f64, y2: f64, style: LineStyle) -> Self { - Self { x1, y1, x2, y2, style, - section_index: None, para_index: None, control_index: None, - transform: ShapeTransform::default() } + Self { + x1, + y1, + x2, + y2, + style, + section_index: None, + para_index: None, + control_index: None, + transform: ShapeTransform::default(), + } } } @@ -509,10 +561,18 @@ pub struct RectangleNode { } impl RectangleNode { - pub fn new(corner_radius: f64, style: ShapeStyle, gradient: Option>) -> Self { + pub fn new( + corner_radius: f64, + style: ShapeStyle, + gradient: Option>, + ) -> Self { Self { - corner_radius, style, gradient, - section_index: None, para_index: None, control_index: None, + corner_radius, + style, + gradient, + section_index: None, + para_index: None, + control_index: None, transform: ShapeTransform::default(), } } @@ -537,9 +597,14 @@ pub struct EllipseNode { impl EllipseNode { pub fn new(style: ShapeStyle, gradient: Option>) -> Self { - Self { style, gradient, - section_index: None, para_index: None, control_index: None, - transform: ShapeTransform::default() } + Self { + style, + gradient, + section_index: None, + para_index: None, + control_index: None, + transform: ShapeTransform::default(), + } } } @@ -567,12 +632,22 @@ pub struct PathNode { } impl PathNode { - pub fn new(commands: Vec, style: ShapeStyle, gradient: Option>) -> Self { - Self { commands, style, gradient, - section_index: None, para_index: None, control_index: None, + pub fn new( + commands: Vec, + style: ShapeStyle, + gradient: Option>, + ) -> Self { + Self { + commands, + style, + gradient, + section_index: None, + para_index: None, + control_index: None, transform: ShapeTransform::default(), connector_endpoints: None, - line_style: None } + line_style: None, + } } } @@ -607,9 +682,13 @@ pub struct ImageNode { impl ImageNode { pub fn new(bin_data_id: u16, data: Option>) -> Self { Self { - bin_data_id, data, - section_index: None, para_index: None, control_index: None, - fill_mode: None, original_size: None, + bin_data_id, + data, + section_index: None, + para_index: None, + control_index: None, + fill_mode: None, + original_size: None, transform: ShapeTransform::default(), crop: None, effect: ImageEffect::RealPic, @@ -677,21 +756,40 @@ impl PageRenderTree { }), BoundingBox::new(0.0, 0.0, width, height), ); - Self { root, next_id: 1, inline_shape_positions: std::collections::HashMap::new() } + Self { + root, + next_id: 1, + inline_shape_positions: std::collections::HashMap::new(), + } } /// 인라인 Shape 좌표 등록 - pub fn set_inline_shape_position(&mut self, sec: usize, para: usize, ctrl: usize, x: f64, y: f64) { - self.inline_shape_positions.insert((sec, para, ctrl), (x, y)); + pub fn set_inline_shape_position( + &mut self, + sec: usize, + para: usize, + ctrl: usize, + x: f64, + y: f64, + ) { + self.inline_shape_positions + .insert((sec, para, ctrl), (x, y)); } /// 인라인 Shape 좌표 조회 - pub fn get_inline_shape_position(&self, sec: usize, para: usize, ctrl: usize) -> Option<(f64, f64)> { + pub fn get_inline_shape_position( + &self, + sec: usize, + para: usize, + ctrl: usize, + ) -> Option<(f64, f64)> { self.inline_shape_positions.get(&(sec, para, ctrl)).copied() } /// 인라인 Shape 좌표 전체 참조 (hitTest용) - pub fn inline_shape_positions(&self) -> &std::collections::HashMap<(usize, usize, usize), (f64, f64)> { + pub fn inline_shape_positions( + &self, + ) -> &std::collections::HashMap<(usize, usize, usize), (f64, f64)> { &self.inline_shape_positions } @@ -761,7 +859,12 @@ mod tests { #[test] fn test_bounding_box_from_hwpunit() { use crate::model::Rect; - let rect = Rect { left: 0, top: 0, right: 7200, bottom: 7200 }; + let rect = Rect { + left: 0, + top: 0, + right: 7200, + bottom: 7200, + }; let bbox = BoundingBox::from_hwpunit_rect(&rect, 96.0); assert!((bbox.width - 96.0).abs() < 0.01); assert!((bbox.height - 96.0).abs() < 0.01); diff --git a/src/renderer/scheduler.rs b/src/renderer/scheduler.rs index 36da99af..5bce6fd2 100644 --- a/src/renderer/scheduler.rs +++ b/src/renderer/scheduler.rs @@ -4,7 +4,7 @@ //! - **RenderWorker**: 렌더링 작업을 우선순위에 따라 실행 //! - **RenderScheduler**: Observer와 Worker를 연결하여 효율적인 렌더링을 조율 -use super::render_tree::{PageRenderTree, BoundingBox}; +use super::render_tree::{BoundingBox, PageRenderTree}; /// 렌더링 작업 우선순위 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -215,7 +215,11 @@ impl RenderScheduler { /// 현재 뷰포트에서 보이는 페이지 인덱스 목록 pub fn visible_pages(&self) -> Vec { if self.page_offsets.is_empty() { - return if self.total_pages > 0 { vec![0] } else { vec![] }; + return if self.total_pages > 0 { + vec![0] + } else { + vec![] + }; } let vp_top = self.viewport.scroll_y; @@ -241,7 +245,9 @@ impl RenderScheduler { pub fn next_task(&mut self) -> Option<&RenderTask> { // Pending 작업 중 우선순위가 가장 높은 것 self.task_queue.sort_by_key(|t| t.priority); - self.task_queue.iter().find(|t| t.status == TaskStatus::Pending) + self.task_queue + .iter() + .find(|t| t.status == TaskStatus::Pending) } /// 작업 완료 마킹 @@ -253,14 +259,16 @@ impl RenderScheduler { /// 완료/취소된 작업 정리 pub fn cleanup_tasks(&mut self) { - self.task_queue.retain(|t| { - t.status == TaskStatus::Pending || t.status == TaskStatus::InProgress - }); + self.task_queue + .retain(|t| t.status == TaskStatus::Pending || t.status == TaskStatus::InProgress); } /// 대기 중인 작업 수 pub fn pending_count(&self) -> usize { - self.task_queue.iter().filter(|t| t.status == TaskStatus::Pending).count() + self.task_queue + .iter() + .filter(|t| t.status == TaskStatus::Pending) + .count() } // --- 내부 메서드 --- @@ -292,14 +300,15 @@ impl RenderScheduler { fn enqueue_task(&mut self, page_index: u32, priority: RenderPriority) { let id = self.next_task_id; self.next_task_id += 1; - self.task_queue.push(RenderTask::new(id, page_index, priority)); + self.task_queue + .push(RenderTask::new(id, page_index, priority)); } /// 특정 페이지에 대기 중인 작업이 있는지 확인 fn has_pending_task(&self, page_index: u32) -> bool { - self.task_queue.iter().any(|t| { - t.page_index == page_index && t.status == TaskStatus::Pending - }) + self.task_queue + .iter() + .any(|t| t.page_index == page_index && t.status == TaskStatus::Pending) } /// 모든 대기 작업 취소 @@ -318,9 +327,9 @@ impl RenderScheduler { RenderPriority::Immediate } else { // 인접 페이지이면 Prefetch - let near_visible = visible.iter().any(|&v| { - page_index.abs_diff(v) <= self.prefetch_range - }); + let near_visible = visible + .iter() + .any(|&v| page_index.abs_diff(v) <= self.prefetch_range); if near_visible { RenderPriority::Prefetch } else { @@ -444,7 +453,13 @@ mod tests { let before = scheduler.task_queue.len(); scheduler.cleanup_tasks(); // 완료된 작업은 제거됨 - assert!(scheduler.task_queue.len() < before || scheduler.task_queue.iter().all(|t| t.status != TaskStatus::Completed)); + assert!( + scheduler.task_queue.len() < before + || scheduler + .task_queue + .iter() + .all(|t| t.status != TaskStatus::Completed) + ); } #[test] diff --git a/src/renderer/skia/equation_conv.rs b/src/renderer/skia/equation_conv.rs new file mode 100644 index 00000000..14d31cff --- /dev/null +++ b/src/renderer/skia/equation_conv.rs @@ -0,0 +1,692 @@ +use skia_safe::{paint, Canvas, Color, FontMgr, Paint, PathBuilder, Point}; + +use crate::renderer::equation::ast::MatrixStyle; +use crate::renderer::equation::layout::{LayoutBox, LayoutKind, BIG_OP_SCALE, SCRIPT_SCALE}; +use crate::renderer::equation::symbols::{DecoKind, FontStyleKind}; +use crate::renderer::TextStyle; + +use super::paint_conv::{colorref_to_skia, make_font}; + +const EQ_FONT_FAMILY: &str = + "Latin Modern Math, STIX Two Math, Cambria Math, Pretendard, Noto Serif CJK KR, serif"; + +pub fn render_equation( + canvas: &Canvas, + font_mgr: &FontMgr, + layout: &LayoutBox, + origin_x: f64, + origin_y: f64, + color: u32, + base_font_size: f64, +) { + render_box( + canvas, + font_mgr, + layout, + origin_x, + origin_y, + colorref_to_skia(color, 1.0), + base_font_size, + false, + false, + ); +} + +fn render_box( + canvas: &Canvas, + font_mgr: &FontMgr, + lb: &LayoutBox, + parent_x: f64, + parent_y: f64, + color: Color, + fs: f64, + italic: bool, + bold: bool, +) { + let x = parent_x + lb.x; + let y = parent_y + lb.y; + + match &lb.kind { + LayoutKind::Row(children) => { + for child in children { + render_box(canvas, font_mgr, child, x, y, color, fs, italic, bold); + } + } + LayoutKind::Text(text) => { + let fi = font_size_from_box(lb, fs); + draw_text( + canvas, + font_mgr, + text, + x, + y + lb.baseline, + fi, + EQ_FONT_FAMILY, + true, + bold, + color, + false, + ); + } + LayoutKind::Number(text) => { + let fi = font_size_from_box(lb, fs); + draw_text( + canvas, + font_mgr, + text, + x, + y + lb.baseline, + fi, + EQ_FONT_FAMILY, + false, + bold, + color, + false, + ); + } + LayoutKind::Symbol(text) => { + let fi = font_size_from_box(lb, fs); + draw_text( + canvas, + font_mgr, + text, + x + lb.width / 2.0, + y + lb.baseline, + fi, + EQ_FONT_FAMILY, + false, + false, + color, + true, + ); + } + LayoutKind::MathSymbol(text) => { + let fi = font_size_from_box(lb, fs); + draw_text( + canvas, + font_mgr, + text, + x, + y + lb.baseline, + fi, + EQ_FONT_FAMILY, + false, + false, + color, + false, + ); + } + LayoutKind::Function(name) => { + let fi = font_size_from_box(lb, fs); + draw_text( + canvas, + font_mgr, + name, + x, + y + lb.baseline, + fi, + EQ_FONT_FAMILY, + false, + false, + color, + false, + ); + } + LayoutKind::Fraction { numer, denom } => { + render_box(canvas, font_mgr, numer, x, y, color, fs, italic, bold); + let line_y = y + lb.baseline; + let line_thick = fs * 0.04; + canvas.draw_line( + ((x + fs * 0.05) as f32, line_y as f32), + ((x + lb.width - fs * 0.05) as f32, line_y as f32), + &stroke_paint(color, line_thick), + ); + render_box(canvas, font_mgr, denom, x, y, color, fs, italic, bold); + } + LayoutKind::Sqrt { index, body } => { + let sign_h = lb.height; + let body_left = x + body.x - fs * 0.1; + let sign_x = x; + let v_top = y; + let v_mid_x = body_left - fs * 0.15; + let v_mid_y = y + sign_h; + let v_start_x = v_mid_x - fs * 0.3; + let v_start_y = y + sign_h * 0.6; + let tick_x = v_start_x - fs * 0.1; + let tick_y = v_start_y - fs * 0.05; + + let mut sign = PathBuilder::new(); + sign.move_to((tick_x as f32, tick_y as f32)); + sign.line_to((v_start_x as f32, v_start_y as f32)); + sign.line_to((v_mid_x as f32, v_mid_y as f32)); + sign.line_to((body_left as f32, v_top as f32)); + sign.line_to(((x + lb.width) as f32, v_top as f32)); + canvas.draw_path(&sign.detach(), &stroke_paint(color, fs * 0.04)); + + if let Some(idx) = index { + render_box( + canvas, + font_mgr, + idx, + sign_x, + y, + color, + fs * SCRIPT_SCALE, + false, + false, + ); + } + render_box(canvas, font_mgr, body, x, y, color, fs, italic, bold); + } + LayoutKind::Superscript { base, sup } => { + render_box(canvas, font_mgr, base, x, y, color, fs, italic, bold); + render_box( + canvas, + font_mgr, + sup, + x, + y, + color, + fs * SCRIPT_SCALE, + italic, + bold, + ); + } + LayoutKind::Subscript { base, sub } => { + render_box(canvas, font_mgr, base, x, y, color, fs, italic, bold); + render_box( + canvas, + font_mgr, + sub, + x, + y, + color, + fs * SCRIPT_SCALE, + italic, + bold, + ); + } + LayoutKind::SubSup { base, sub, sup } => { + render_box(canvas, font_mgr, base, x, y, color, fs, italic, bold); + render_box( + canvas, + font_mgr, + sub, + x, + y, + color, + fs * SCRIPT_SCALE, + italic, + bold, + ); + render_box( + canvas, + font_mgr, + sup, + x, + y, + color, + fs * SCRIPT_SCALE, + italic, + bold, + ); + } + LayoutKind::BigOp { symbol, sub, sup } => { + let op_fs = fs * BIG_OP_SCALE; + let sup_h = sup.as_ref().map(|b| b.height + fs * 0.05).unwrap_or(0.0); + let op_x = x + (lb.width - estimate_op_width(symbol, op_fs)) / 2.0; + let op_y = y + sup_h + op_fs * 0.8; + draw_text( + canvas, + font_mgr, + symbol, + op_x, + op_y, + op_fs, + EQ_FONT_FAMILY, + false, + false, + color, + false, + ); + if let Some(sup_box) = sup { + render_box( + canvas, + font_mgr, + sup_box, + x, + y, + color, + fs * SCRIPT_SCALE, + false, + false, + ); + } + if let Some(sub_box) = sub { + render_box( + canvas, + font_mgr, + sub_box, + x, + y, + color, + fs * SCRIPT_SCALE, + false, + false, + ); + } + } + LayoutKind::Limit { is_upper, sub } => { + let name = if *is_upper { "Lim" } else { "lim" }; + let fi = font_size_from_box(lb, fs); + draw_text( + canvas, + font_mgr, + name, + x, + y + fi * 0.8, + fi, + EQ_FONT_FAMILY, + false, + false, + color, + false, + ); + if let Some(sub_box) = sub { + render_box( + canvas, + font_mgr, + sub_box, + x, + y, + color, + fs * SCRIPT_SCALE, + false, + false, + ); + } + } + LayoutKind::Matrix { cells, style } => { + let bracket_chars = match style { + MatrixStyle::Paren => ("(", ")"), + MatrixStyle::Bracket => ("[", "]"), + MatrixStyle::Vert => ("|", "|"), + MatrixStyle::Plain => ("", ""), + }; + if !bracket_chars.0.is_empty() { + draw_stretch_bracket( + canvas, + font_mgr, + bracket_chars.0, + x, + y, + fs * 0.3, + lb.height, + color, + fs, + ); + draw_stretch_bracket( + canvas, + font_mgr, + bracket_chars.1, + x + lb.width - fs * 0.3, + y, + fs * 0.3, + lb.height, + color, + fs, + ); + } + for row in cells { + for cell in row { + render_box(canvas, font_mgr, cell, x, y, color, fs, italic, bold); + } + } + } + LayoutKind::Rel { arrow, over, under } => { + render_box(canvas, font_mgr, over, x, y, color, fs, italic, bold); + render_box(canvas, font_mgr, arrow, x, y, color, fs, italic, bold); + if let Some(under) = under { + render_box(canvas, font_mgr, under, x, y, color, fs, italic, bold); + } + } + LayoutKind::EqAlign { rows } => { + for (left, right) in rows { + render_box(canvas, font_mgr, left, x, y, color, fs, italic, bold); + render_box(canvas, font_mgr, right, x, y, color, fs, italic, bold); + } + } + LayoutKind::Paren { left, right, body } => { + if !left.is_empty() { + draw_stretch_bracket(canvas, font_mgr, left, x, y, fs * 0.3, lb.height, color, fs); + } + render_box(canvas, font_mgr, body, x, y, color, fs, italic, bold); + if !right.is_empty() { + let paren_w = fs * 0.3; + let right_x = x + lb.width - paren_w; + draw_stretch_bracket( + canvas, font_mgr, right, right_x, y, paren_w, lb.height, color, fs, + ); + } + } + LayoutKind::Decoration { kind, body } => { + render_box(canvas, font_mgr, body, x, y, color, fs, italic, bold); + let deco_y = y + fs * 0.05; + let mid_x = x + body.x + body.width / 2.0; + draw_decoration(canvas, *kind, mid_x, deco_y, body.width, color, fs); + } + LayoutKind::FontStyle { style, body } => { + let (new_italic, new_bold) = match style { + FontStyleKind::Roman => (false, false), + FontStyleKind::Italic => (true, bold), + FontStyleKind::Bold => (italic, true), + }; + render_box( + canvas, font_mgr, body, x, y, color, fs, new_italic, new_bold, + ); + } + LayoutKind::Space(_) | LayoutKind::Newline | LayoutKind::Empty => {} + } +} + +fn draw_text( + canvas: &Canvas, + font_mgr: &FontMgr, + text: &str, + x: f64, + baseline_y: f64, + font_size: f64, + font_family: &str, + italic: bool, + bold: bool, + color: Color, + centered: bool, +) { + if text.is_empty() { + return; + } + + let style = TextStyle { + font_family: font_family.to_string(), + font_size, + bold, + italic, + ..Default::default() + }; + let font = make_font(&style, font_mgr, text); + let paint = fill_paint(color); + let draw_x = if centered { + let (width, _) = font.measure_str(text, Some(&paint)); + x - f64::from(width) / 2.0 + } else { + x + }; + let glyphs = font.text_to_glyphs_vec(text); + let mut glyph_positions = vec![Point::default(); glyphs.len()]; + font.get_pos( + &glyphs, + &mut glyph_positions, + Some(Point::new(draw_x as f32, baseline_y as f32)), + ); + + let mut drew_any_path = false; + for (glyph_id, glyph_position) in glyphs.into_iter().zip(glyph_positions) { + let Some(path) = font.get_path(glyph_id) else { + continue; + }; + drew_any_path = true; + let path = path.with_offset((glyph_position.x, glyph_position.y)); + canvas.draw_path(&path, &paint); + } + + if !drew_any_path { + canvas.draw_str(text, (draw_x as f32, baseline_y as f32), &font, &paint); + } +} + +fn draw_stretch_bracket( + canvas: &Canvas, + font_mgr: &FontMgr, + bracket: &str, + x: f64, + y: f64, + w: f64, + h: f64, + color: Color, + fs: f64, +) { + let mid_x = x + w / 2.0; + let paint = stroke_paint(color, fs * 0.04); + + match bracket { + "(" => { + let mut path = PathBuilder::new(); + path.move_to(((mid_x + w * 0.2) as f32, y as f32)); + path.quad_to( + (x as f32, (y + h / 2.0) as f32), + ((mid_x + w * 0.2) as f32, (y + h) as f32), + ); + canvas.draw_path(&path.detach(), &paint); + } + ")" => { + let mut path = PathBuilder::new(); + path.move_to(((mid_x - w * 0.2) as f32, y as f32)); + path.quad_to( + ((x + w) as f32, (y + h / 2.0) as f32), + ((mid_x - w * 0.2) as f32, (y + h) as f32), + ); + canvas.draw_path(&path.detach(), &paint); + } + "[" => { + let mut path = PathBuilder::new(); + path.move_to(((mid_x + w * 0.2) as f32, y as f32)); + path.line_to(((mid_x - w * 0.2) as f32, y as f32)); + path.line_to(((mid_x - w * 0.2) as f32, (y + h) as f32)); + path.line_to(((mid_x + w * 0.2) as f32, (y + h) as f32)); + canvas.draw_path(&path.detach(), &paint); + } + "]" => { + let mut path = PathBuilder::new(); + path.move_to(((mid_x - w * 0.2) as f32, y as f32)); + path.line_to(((mid_x + w * 0.2) as f32, y as f32)); + path.line_to(((mid_x + w * 0.2) as f32, (y + h) as f32)); + path.line_to(((mid_x - w * 0.2) as f32, (y + h) as f32)); + canvas.draw_path(&path.detach(), &paint); + } + "{" => { + let qh = h / 4.0; + let mut path = PathBuilder::new(); + path.move_to(((mid_x + w * 0.2) as f32, y as f32)); + path.quad_to( + ((mid_x - w * 0.1) as f32, y as f32), + ((mid_x - w * 0.1) as f32, (y + qh) as f32), + ); + path.quad_to( + ((mid_x - w * 0.1) as f32, (y + qh * 2.0) as f32), + ((mid_x - w * 0.3) as f32, (y + qh * 2.0) as f32), + ); + path.quad_to( + ((mid_x - w * 0.1) as f32, (y + qh * 2.0) as f32), + ((mid_x - w * 0.1) as f32, (y + qh * 3.0) as f32), + ); + path.quad_to( + ((mid_x - w * 0.1) as f32, (y + h) as f32), + ((mid_x + w * 0.2) as f32, (y + h) as f32), + ); + canvas.draw_path(&path.detach(), &paint); + } + "}" => { + let qh = h / 4.0; + let mut path = PathBuilder::new(); + path.move_to(((mid_x - w * 0.2) as f32, y as f32)); + path.quad_to( + ((mid_x + w * 0.1) as f32, y as f32), + ((mid_x + w * 0.1) as f32, (y + qh) as f32), + ); + path.quad_to( + ((mid_x + w * 0.1) as f32, (y + qh * 2.0) as f32), + ((mid_x + w * 0.3) as f32, (y + qh * 2.0) as f32), + ); + path.quad_to( + ((mid_x + w * 0.1) as f32, (y + qh * 2.0) as f32), + ((mid_x + w * 0.1) as f32, (y + qh * 3.0) as f32), + ); + path.quad_to( + ((mid_x + w * 0.1) as f32, (y + h) as f32), + ((mid_x - w * 0.2) as f32, (y + h) as f32), + ); + canvas.draw_path(&path.detach(), &paint); + } + "|" => { + canvas.draw_line( + (mid_x as f32, y as f32), + (mid_x as f32, (y + h) as f32), + &paint, + ); + } + _ => { + draw_text( + canvas, + font_mgr, + bracket, + mid_x, + y + h * 0.7, + h, + EQ_FONT_FAMILY, + false, + false, + color, + true, + ); + } + } +} + +fn draw_decoration( + canvas: &Canvas, + kind: DecoKind, + mid_x: f64, + y: f64, + width: f64, + color: Color, + fs: f64, +) { + let stroke_w = fs * 0.03; + let half_w = width / 2.0; + let paint = stroke_paint(color, stroke_w); + + match kind { + DecoKind::Hat => { + let mut path = PathBuilder::new(); + path.move_to(((mid_x - half_w * 0.6) as f32, (y + fs * 0.15) as f32)); + path.line_to((mid_x as f32, y as f32)); + path.line_to(((mid_x + half_w * 0.6) as f32, (y + fs * 0.15) as f32)); + canvas.draw_path(&path.detach(), &paint); + } + DecoKind::Bar | DecoKind::Overline => { + canvas.draw_line( + ((mid_x - half_w) as f32, (y + fs * 0.05) as f32), + ((mid_x + half_w) as f32, (y + fs * 0.05) as f32), + &paint, + ); + } + DecoKind::Vec => { + let arrow_y = y + fs * 0.05; + canvas.draw_line( + ((mid_x - half_w) as f32, arrow_y as f32), + ((mid_x + half_w) as f32, arrow_y as f32), + &paint, + ); + let mut head = PathBuilder::new(); + head.move_to(( + (mid_x + half_w - fs * 0.1) as f32, + (arrow_y - fs * 0.06) as f32, + )); + head.line_to(((mid_x + half_w) as f32, arrow_y as f32)); + head.line_to(( + (mid_x + half_w - fs * 0.1) as f32, + (arrow_y + fs * 0.06) as f32, + )); + canvas.draw_path(&head.detach(), &paint); + } + DecoKind::Tilde => { + let ty = y + fs * 0.08; + let mut path = PathBuilder::new(); + path.move_to(((mid_x - half_w * 0.6) as f32, ty as f32)); + path.quad_to( + ((mid_x - half_w * 0.2) as f32, (ty - fs * 0.08) as f32), + (mid_x as f32, ty as f32), + ); + path.quad_to( + ((mid_x + half_w * 0.2) as f32, (ty + fs * 0.08) as f32), + ((mid_x + half_w * 0.6) as f32, ty as f32), + ); + canvas.draw_path(&path.detach(), &paint); + } + DecoKind::Dot => { + canvas.draw_circle( + (mid_x as f32, (y + fs * 0.06) as f32), + (fs * 0.03) as f32, + &fill_paint(color), + ); + } + DecoKind::DDot => { + let gap = fs * 0.1; + let fill = fill_paint(color); + canvas.draw_circle( + ((mid_x - gap) as f32, (y + fs * 0.06) as f32), + (fs * 0.03) as f32, + &fill, + ); + canvas.draw_circle( + ((mid_x + gap) as f32, (y + fs * 0.06) as f32), + (fs * 0.03) as f32, + &fill, + ); + } + DecoKind::Underline | DecoKind::Under => { + let underline_y = y + fs * 1.1; + canvas.draw_line( + ((mid_x - half_w) as f32, underline_y as f32), + ((mid_x + half_w) as f32, underline_y as f32), + &paint, + ); + } + _ => { + canvas.draw_line( + ((mid_x - half_w * 0.5) as f32, (y + fs * 0.1) as f32), + ((mid_x + half_w * 0.5) as f32, (y + fs * 0.1) as f32), + &paint, + ); + } + } +} + +fn font_size_from_box(lb: &LayoutBox, base_fs: f64) -> f64 { + if lb.height > 0.0 { + lb.height + } else { + base_fs + } +} + +fn estimate_op_width(text: &str, fs: f64) -> f64 { + text.chars().count() as f64 * fs * 0.6 +} + +fn fill_paint(color: Color) -> Paint { + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(paint::Style::Fill); + paint.set_color(color); + paint +} + +fn stroke_paint(color: Color, width: f64) -> Paint { + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(paint::Style::Stroke); + paint.set_stroke_width(width as f32); + paint.set_color(color); + paint +} diff --git a/src/renderer/skia/image_conv.rs b/src/renderer/skia/image_conv.rs new file mode 100644 index 00000000..cc0ff01f --- /dev/null +++ b/src/renderer/skia/image_conv.rs @@ -0,0 +1,285 @@ +use resvg::{tiny_skia, usvg}; +use skia_safe::{ + canvas::SrcRectConstraint, Canvas, Data, FilterMode, Image, MipmapMode, Paint, Rect, + SamplingOptions, +}; + +use crate::model::style::ImageFillMode; + +pub fn draw_image_bytes( + canvas: &Canvas, + bytes: &[u8], + x: f32, + y: f32, + width: f32, + height: f32, + fill_mode: Option, + original_size: Option<(f64, f64)>, + crop: Option<(i32, i32, i32, i32)>, +) { + let Some(image) = decode_image(bytes) else { + return; + }; + let dst = Rect::from_xywh(x, y, width, height); + let mut paint = Paint::default(); + paint.set_anti_alias(true); + let mode = fill_mode.unwrap_or(ImageFillMode::FitToSize); + + let draw_image_rect = |canvas: &Canvas, src: Option, dst: Rect| { + if let Some(src) = src.as_ref() { + canvas.draw_image_rect_with_sampling_options( + &image, + Some((src, SrcRectConstraint::Strict)), + dst, + SamplingOptions::new(FilterMode::Linear, MipmapMode::None), + &paint, + ); + } else { + canvas.draw_image_rect_with_sampling_options( + &image, + None, + dst, + SamplingOptions::new(FilterMode::Linear, MipmapMode::None), + &paint, + ); + } + }; + + if matches!(mode, ImageFillMode::FitToSize | ImageFillMode::None) { + if let Some((left, top, right, bottom)) = crop { + let image_width = image.width() as f32; + let image_height = image.height() as f32; + if image_width > 0.0 && image_height > 0.0 { + let scale_x = right as f32 / image_width; + if scale_x > 0.0 { + let src_x = left as f32 / scale_x; + let src_y = top as f32 / scale_x; + let src_w = (right - left) as f32 / scale_x; + let src_h = (bottom - top) as f32 / scale_x; + let is_cropped = src_x > 0.5 + || src_y > 0.5 + || (src_w - image_width).abs() > 1.0 + || (src_h - image_height).abs() > 1.0; + if is_cropped { + let scale_x = width / src_w.max(1.0); + let scale_y = height / src_h.max(1.0); + let draw_x = x - src_x * scale_x; + let draw_y = y - src_y * scale_y; + let draw_w = image_width * scale_x; + let draw_h = image_height * scale_y; + + canvas.save(); + canvas.clip_rect(dst, None, Some(true)); + draw_image_rect( + canvas, + None, + Rect::from_xywh(draw_x, draw_y, draw_w, draw_h), + ); + canvas.restore(); + return; + } + } + } + } + + draw_image_rect(canvas, None, dst); + return; + } + + let image_width = original_size + .map(|(width, _)| width as f32) + .unwrap_or_else(|| image.width() as f32); + let image_height = original_size + .map(|(_, height)| height as f32) + .unwrap_or_else(|| image.height() as f32); + + canvas.save(); + canvas.clip_rect(dst, None, Some(true)); + + if matches!( + mode, + ImageFillMode::TileAll + | ImageFillMode::TileHorzTop + | ImageFillMode::TileHorzBottom + | ImageFillMode::TileVertLeft + | ImageFillMode::TileVertRight + ) { + if matches!(mode, ImageFillMode::TileAll) { + let mut tile_y = y; + while tile_y < y + height { + let mut tile_x = x; + while tile_x < x + width { + draw_image_rect( + canvas, + None, + Rect::from_xywh(tile_x, tile_y, image_width, image_height), + ); + tile_x += image_width.max(1.0); + } + tile_y += image_height.max(1.0); + } + } else if matches!( + mode, + ImageFillMode::TileHorzTop | ImageFillMode::TileHorzBottom + ) { + let tile_y = if matches!(mode, ImageFillMode::TileHorzTop) { + y + } else { + y + height - image_height + }; + let mut tile_x = x; + while tile_x < x + width { + draw_image_rect( + canvas, + None, + Rect::from_xywh(tile_x, tile_y, image_width, image_height), + ); + tile_x += image_width.max(1.0); + } + } else { + let tile_x = if matches!(mode, ImageFillMode::TileVertLeft) { + x + } else { + x + width - image_width + }; + let mut tile_y = y; + while tile_y < y + height { + draw_image_rect( + canvas, + None, + Rect::from_xywh(tile_x, tile_y, image_width, image_height), + ); + tile_y += image_height.max(1.0); + } + } + } else { + let (image_x, image_y) = + resolve_image_placement(mode, x, y, width, height, image_width, image_height); + draw_image_rect( + canvas, + None, + Rect::from_xywh(image_x, image_y, image_width, image_height), + ); + } + + canvas.restore(); +} + +pub fn draw_svg_fragment( + canvas: &Canvas, + svg_fragment: &str, + x: f32, + y: f32, + width: f32, + height: f32, +) { + let Some(image) = decode_svg_fragment(svg_fragment, width, height) else { + return; + }; + + let dst = Rect::from_xywh(x, y, width, height); + let mut paint = Paint::default(); + paint.set_anti_alias(true); + canvas.draw_image_rect_with_sampling_options( + &image, + None, + dst, + SamplingOptions::new(FilterMode::Linear, MipmapMode::None), + &paint, + ); +} + +fn resolve_image_placement( + fill_mode: ImageFillMode, + x: f32, + y: f32, + width: f32, + height: f32, + image_width: f32, + image_height: f32, +) -> (f32, f32) { + match fill_mode { + ImageFillMode::LeftTop => (x, y), + ImageFillMode::CenterTop => (x + (width - image_width) / 2.0, y), + ImageFillMode::RightTop => (x + width - image_width, y), + ImageFillMode::LeftCenter => (x, y + (height - image_height) / 2.0), + ImageFillMode::Center => ( + x + (width - image_width) / 2.0, + y + (height - image_height) / 2.0, + ), + ImageFillMode::RightCenter => (x + width - image_width, y + (height - image_height) / 2.0), + ImageFillMode::LeftBottom => (x, y + height - image_height), + ImageFillMode::CenterBottom => (x + (width - image_width) / 2.0, y + height - image_height), + ImageFillMode::RightBottom => (x + width - image_width, y + height - image_height), + _ => (x, y), + } +} + +fn decode_image(bytes: &[u8]) -> Option { + match detect_image_mime_type(bytes) { + "image/x-wmf" => { + let svg = crate::renderer::svg::convert_wmf_to_svg(bytes)?; + let mut options = usvg::Options::default(); + options.fontdb_mut().load_system_fonts(); + let tree = usvg::Tree::from_data(&svg, &options).ok()?; + let size = tree.size().to_int_size(); + let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height())?; + resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut()); + let png = pixmap.encode_png().ok()?; + Image::from_encoded(Data::new_copy(&png)) + } + _ => Image::from_encoded(Data::new_copy(bytes)), + } +} + +fn decode_svg_fragment(svg_fragment: &str, width: f32, height: f32) -> Option { + if width <= 0.0 || height <= 0.0 { + return None; + } + + let svg = format!( + "{svg_fragment}" + ); + let mut options = usvg::Options::default(); + let fontdb = options.fontdb_mut(); + fontdb.load_system_fonts(); + fontdb.set_sans_serif_family("Noto Sans CJK KR"); + fontdb.set_serif_family("Noto Serif CJK KR"); + fontdb.set_monospace_family("D2Coding"); + + let tree = usvg::Tree::from_str(&svg, &options).ok()?; + let size = tree.size().to_int_size(); + let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height())?; + resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut()); + let png = pixmap.encode_png().ok()?; + Image::from_encoded(Data::new_copy(&png)) +} + +fn detect_image_mime_type(data: &[u8]) -> &'static str { + if data.len() >= 8 { + if data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) { + return "image/png"; + } + if data.starts_with(&[0xFF, 0xD8, 0xFF]) { + return "image/jpeg"; + } + if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") { + return "image/gif"; + } + if data.starts_with(&[0x42, 0x4D]) { + return "image/bmp"; + } + if data.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) + || data.starts_with(&[0x01, 0x00, 0x09, 0x00]) + { + return "image/x-wmf"; + } + if data.starts_with(&[0x49, 0x49, 0x2A, 0x00]) + || data.starts_with(&[0x4D, 0x4D, 0x00, 0x2A]) + { + return "image/tiff"; + } + } + + "application/octet-stream" +} diff --git a/src/renderer/skia/mod.rs b/src/renderer/skia/mod.rs new file mode 100644 index 00000000..fc50b2aa --- /dev/null +++ b/src/renderer/skia/mod.rs @@ -0,0 +1,7 @@ +pub mod equation_conv; +pub mod image_conv; +pub mod paint_conv; +pub mod path_conv; +pub mod renderer; + +pub use renderer::SkiaLayerRenderer; diff --git a/src/renderer/skia/paint_conv.rs b/src/renderer/skia/paint_conv.rs new file mode 100644 index 00000000..ae2f3965 --- /dev/null +++ b/src/renderer/skia/paint_conv.rs @@ -0,0 +1,395 @@ +use skia_safe::{paint, Color, Font, FontHinting, FontMgr, FontStyle, Paint}; + +use crate::renderer::{generic_fallback, LineStyle, ShapeStyle, StrokeDash, TextStyle}; + +pub fn colorref_to_skia(color: u32, alpha_scale: f32) -> Color { + let b = ((color >> 16) & 0xFF) as u8; + let g = ((color >> 8) & 0xFF) as u8; + let r = (color & 0xFF) as u8; + let a = (255.0 * alpha_scale.clamp(0.0, 1.0)).round() as u8; + Color::from_argb(a, r, g, b) +} + +pub fn make_fill_paint(style: &ShapeStyle) -> Option { + let fill_color = style.fill_color?; + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(paint::Style::Fill); + paint.set_color(colorref_to_skia(fill_color, style.opacity as f32)); + Some(paint) +} + +pub fn make_stroke_paint(style: &ShapeStyle) -> Option { + let stroke_color = style.stroke_color?; + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(paint::Style::Stroke); + paint.set_stroke_width(if style.stroke_width > 0.0 { + style.stroke_width as f32 + } else { + 1.0 + }); + paint.set_color(colorref_to_skia(stroke_color, style.opacity as f32)); + apply_dash(&mut paint, style.stroke_dash); + Some(paint) +} + +pub fn make_line_paint(style: &LineStyle) -> Paint { + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(paint::Style::Stroke); + paint.set_stroke_width(if style.width > 0.0 { + style.width as f32 + } else { + 1.0 + }); + paint.set_color(colorref_to_skia(style.color, 1.0)); + apply_dash(&mut paint, style.dash); + paint +} + +pub fn make_font(text_style: &TextStyle, font_mgr: &FontMgr, sample_text: &str) -> Font { + let font_size = if text_style.font_size > 0.0 { + text_style.font_size as f32 + } else { + 12.0 + }; + let font_style = match (text_style.bold, text_style.italic) { + (true, true) => FontStyle::bold_italic(), + (true, false) => FontStyle::bold(), + (false, true) => FontStyle::italic(), + (false, false) => FontStyle::normal(), + }; + + let mut family_candidates = Vec::new(); + for candidate_list in [ + text_style.font_family.as_str(), + generic_fallback(&text_style.font_family), + ] { + for candidate in candidate_list.split(',') { + let candidate = candidate.trim().trim_matches('\'').trim_matches('"'); + if candidate.is_empty() { + continue; + } + + for alias in match candidate { + "함초롬바탕" => vec!["함초롬바탕", "HCR Batang"], + "함초롬돋움" => vec!["함초롬돋움", "HCR Dotum"], + "함초롱바탕" => vec!["함초롱바탕", "HCR Batang"], + "함초롱돋움" => vec!["함초롱돋움", "HCR Dotum"], + "한컴바탕" => vec!["한컴바탕", "함초롬바탕", "HCR Batang"], + "한컴돋움" => vec!["한컴돋움", "함초롬돋움", "HCR Dotum"], + "맑은 고딕" => vec!["맑은 고딕", "Malgun Gothic"], + "바탕" => vec!["바탕", "Batang"], + "돋움" => vec!["돋움", "Dotum"], + "굴림" => vec!["굴림", "Gulim"], + "굴림체" => vec!["굴림체", "GulimChe"], + "바탕체" => vec!["바탕체", "BatangChe"], + "궁서" => vec!["궁서", "Gungsuh"], + "궁서체" => vec!["궁서체", "GungsuhChe"], + _ => vec![candidate], + } { + if family_candidates + .iter() + .any(|existing: &String| existing == alias) + { + continue; + } + family_candidates.push(alias.to_string()); + } + } + } + + let probe_char = sample_text + .chars() + .find(|ch| !ch.is_whitespace() && !ch.is_ascii()); + + if family_candidates + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case("monospace")) + { + for candidate in [ + "D2Coding", + "NanumGothicCoding", + "Noto Sans Mono", + "DejaVu Sans Mono", + "monospace", + ] { + if family_candidates + .iter() + .any(|existing| existing == candidate) + { + continue; + } + family_candidates.push(candidate.to_string()); + } + } else if family_candidates + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case("serif")) + { + let serif_fallbacks: &[&str] = if probe_char.is_some() { + &[ + "Noto Serif CJK KR", + "NanumMyeongjo", + "DejaVu Serif", + "serif", + ] + } else { + &[ + "DejaVu Serif", + "Noto Serif CJK KR", + "NanumMyeongjo", + "serif", + ] + }; + for candidate in serif_fallbacks { + if family_candidates + .iter() + .any(|existing| existing == candidate) + { + continue; + } + family_candidates.push((*candidate).to_string()); + } + } else { + for candidate in [ + "Noto Sans CJK KR", + "NanumGothic", + "DejaVu Sans", + "sans-serif", + ] { + if family_candidates + .iter() + .any(|existing| existing == candidate) + { + continue; + } + family_candidates.push(candidate.to_string()); + } + } + + let mut matched = None; + for candidate in &family_candidates { + let typeface = if let Some(probe_char) = probe_char { + if matches!( + candidate.as_str(), + "D2Coding" + | "NanumGothicCoding" + | "Noto Sans Mono" + | "DejaVu Sans Mono" + | "Noto Serif CJK KR" + | "NanumMyeongjo" + | "DejaVu Serif" + | "Noto Sans CJK KR" + | "NanumGothic" + | "DejaVu Sans" + | "serif" + | "sans-serif" + | "monospace" + ) { + font_mgr.match_family_style_character( + candidate, + font_style, + &["ko", "en"], + probe_char as i32, + ) + } else { + font_mgr.match_family_style(candidate, font_style) + } + } else { + font_mgr.match_family_style(candidate, font_style) + }; + let Some(typeface) = typeface else { + continue; + }; + let family_name = typeface.family_name(); + if matches!(candidate.as_str(), "serif" | "sans-serif" | "monospace") + || family_name == *candidate + || family_name.eq_ignore_ascii_case(candidate) + { + matched = Some(typeface); + break; + } + } + let matched = matched.or_else(|| font_mgr.legacy_make_typeface(None::<&str>, font_style)); + + let mut font = if let Some(typeface) = matched { + Font::new(typeface, font_size) + } else { + let mut font = Font::default(); + font.set_size(font_size); + font + }; + + font.set_edging(skia_safe::font::Edging::AntiAlias); + font.set_hinting(FontHinting::None); + font.set_subpixel(false); + font.set_linear_metrics(true); + font.set_baseline_snap(false); + font.set_scale_x(if text_style.ratio > 0.0 { + text_style.ratio as f32 + } else { + 1.0 + }); + font +} + +pub fn make_text_paint(text_style: &TextStyle) -> Paint { + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(paint::Style::Fill); + paint.set_color(colorref_to_skia(text_style.color, 1.0)); + paint +} + +fn apply_dash(paint: &mut Paint, dash: StrokeDash) { + let intervals: Option<[f32; 6]> = match dash { + StrokeDash::Solid => None, + StrokeDash::Dash => Some([6.0, 3.0, 0.0, 0.0, 0.0, 0.0]), + StrokeDash::Dot => Some([2.0, 2.0, 0.0, 0.0, 0.0, 0.0]), + StrokeDash::DashDot => Some([6.0, 3.0, 2.0, 3.0, 0.0, 0.0]), + StrokeDash::DashDotDot => Some([6.0, 3.0, 2.0, 3.0, 2.0, 3.0]), + }; + if let Some(intervals) = intervals { + let trimmed: Vec = intervals.into_iter().filter(|value| *value > 0.0).collect(); + if let Some(effect) = skia_safe::PathEffect::dash(&trimmed, 0.0) { + paint.set_path_effect(effect); + } + } +} + +#[cfg(test)] +mod tests { + use super::make_font; + use crate::renderer::TextStyle; + use skia_safe::{FontMgr, FontStyle}; + + #[test] + fn resolves_deterministic_generic_fallback_families() { + let font_mgr = FontMgr::default(); + + let mono_family = make_font( + &TextStyle { + font_family: "바탕체".to_string(), + font_size: 12.0, + ..Default::default() + }, + &font_mgr, + "A", + ) + .typeface() + .family_name(); + let sans_family = make_font( + &TextStyle { + font_family: "한컴 윤고딕 230".to_string(), + font_size: 12.0, + ..Default::default() + }, + &font_mgr, + "A", + ) + .typeface() + .family_name(); + let hangul_sans_family = make_font( + &TextStyle { + font_family: "한컴 윤고딕 230".to_string(), + font_size: 12.0, + ..Default::default() + }, + &font_mgr, + "표", + ) + .typeface() + .family_name(); + + assert!( + matches!( + mono_family.as_str(), + "D2Coding" | "NanumGothicCoding" | "Noto Sans Mono" | "DejaVu Sans Mono" + ), + "unexpected mono fallback family: {mono_family}" + ); + assert!( + matches!( + sans_family.as_str(), + "Noto Sans CJK KR" | "NanumGothic" | "DejaVu Sans" | "Arial" + ), + "unexpected sans fallback family: {sans_family}" + ); + if font_mgr + .match_family_style("Noto Sans CJK KR", FontStyle::normal()) + .is_some() + { + assert_eq!( + hangul_sans_family, "Noto Sans CJK KR", + "unexpected Hangul sans fallback family: {hangul_sans_family}" + ); + } else if font_mgr + .match_family_style("NanumGothic", FontStyle::normal()) + .is_some() + { + assert_eq!( + hangul_sans_family, "NanumGothic", + "unexpected Hangul sans fallback family: {hangul_sans_family}" + ); + } + } + + #[test] + fn prioritizes_ascii_and_hangul_serif_fallbacks_differently() { + let font_mgr = FontMgr::default(); + + let ascii_family = make_font( + &TextStyle { + font_family: "serif".to_string(), + font_size: 12.0, + ..Default::default() + }, + &font_mgr, + "n", + ) + .typeface() + .family_name(); + let hangul_family = make_font( + &TextStyle { + font_family: "serif".to_string(), + font_size: 12.0, + ..Default::default() + }, + &font_mgr, + "표", + ) + .typeface() + .family_name(); + + if font_mgr + .match_family_style("DejaVu Serif", FontStyle::normal()) + .is_some() + { + assert_eq!( + ascii_family, "DejaVu Serif", + "unexpected ASCII serif fallback family: {ascii_family}" + ); + } + if font_mgr + .match_family_style("Noto Serif CJK KR", FontStyle::normal()) + .is_some() + { + assert!( + matches!( + hangul_family.as_str(), + "Noto Serif CJK KR" | "NanumMyeongjo" + ), + "unexpected Hangul serif fallback family: {hangul_family}" + ); + } else if font_mgr + .match_family_style("NanumMyeongjo", FontStyle::normal()) + .is_some() + { + assert_eq!( + hangul_family, "NanumMyeongjo", + "unexpected Hangul serif fallback family: {hangul_family}" + ); + } + } +} diff --git a/src/renderer/skia/path_conv.rs b/src/renderer/skia/path_conv.rs new file mode 100644 index 00000000..3d41fc1b --- /dev/null +++ b/src/renderer/skia/path_conv.rs @@ -0,0 +1,46 @@ +use skia_safe::PathBuilder; + +use crate::renderer::{svg_arc_to_beziers, PathCommand}; + +pub fn to_skia_path(commands: &[PathCommand]) -> skia_safe::Path { + let mut builder = PathBuilder::new(); + let mut current = (0.0, 0.0); + for cmd in commands { + match *cmd { + PathCommand::MoveTo(x, y) => { + builder.move_to((x as f32, y as f32)); + current = (x, y); + } + PathCommand::LineTo(x, y) => { + builder.line_to((x as f32, y as f32)); + current = (x, y); + } + PathCommand::CurveTo(x1, y1, x2, y2, x, y) => { + builder.cubic_to( + (x1 as f32, y1 as f32), + (x2 as f32, y2 as f32), + (x as f32, y as f32), + ); + current = (x, y); + } + PathCommand::ArcTo(rx, ry, rotation, large_arc, sweep, x, y) => { + for bezier in svg_arc_to_beziers( + current.0, current.1, rx, ry, rotation, large_arc, sweep, x, y, + ) { + if let PathCommand::CurveTo(x1, y1, x2, y2, ex, ey) = bezier { + builder.cubic_to( + (x1 as f32, y1 as f32), + (x2 as f32, y2 as f32), + (ex as f32, ey as f32), + ); + current = (ex, ey); + } + } + } + PathCommand::ClosePath => { + builder.close(); + } + } + } + builder.detach() +} diff --git a/src/renderer/skia/renderer.rs b/src/renderer/skia/renderer.rs new file mode 100644 index 00000000..243b25ee --- /dev/null +++ b/src/renderer/skia/renderer.rs @@ -0,0 +1,684 @@ +use skia_safe::{ + surfaces, Canvas, Color, EncodedImageFormat, FontMgr, Paint, PathBuilder, Point, Rect, +}; + +use crate::paint::{LayerNode, LayerNodeKind, PageLayerTree, PaintOp}; +use crate::renderer::layout::{compute_char_positions, split_into_clusters}; +use crate::renderer::render_tree::{BoundingBox, TextRunNode}; +use crate::renderer::{LineRenderType, UnderlineType}; + +use super::equation_conv::render_equation; +use super::image_conv::draw_image_bytes; +use super::paint_conv::{ + colorref_to_skia, make_fill_paint, make_font, make_line_paint, make_stroke_paint, + make_text_paint, +}; +use super::path_conv::to_skia_path; + +pub struct SkiaLayerRenderer { + font_mgr: FontMgr, +} + +impl SkiaLayerRenderer { + pub fn new() -> Self { + Self { + font_mgr: FontMgr::default(), + } + } + + pub fn render_png(&self, tree: &PageLayerTree) -> Result, String> { + let width = tree.page_width.max(1.0).ceil() as i32; + let height = tree.page_height.max(1.0).ceil() as i32; + let mut surface = surfaces::raster_n32_premul((width, height)) + .ok_or_else(|| "Skia raster surface 생성 실패".to_string())?; + let canvas = surface.canvas(); + canvas.clear(Color::from_argb(0, 0, 0, 0)); + self.render_node(canvas, &tree.root); + let image = surface.image_snapshot(); + let data = image + .encode(None, EncodedImageFormat::PNG, None) + .ok_or_else(|| "Skia PNG 인코딩 실패".to_string())?; + Ok(data.as_bytes().to_vec()) + } + + fn render_node(&self, canvas: &Canvas, node: &LayerNode) { + match &node.kind { + LayerNodeKind::Group { children, .. } => { + for child in children { + self.render_node(canvas, child); + } + } + LayerNodeKind::ClipRect { clip, child, .. } => { + canvas.save(); + canvas.clip_rect( + Rect::from_xywh( + clip.x as f32, + clip.y as f32, + clip.width as f32, + clip.height as f32, + ), + None, + Some(true), + ); + self.render_node(canvas, child); + canvas.restore(); + } + LayerNodeKind::Leaf { ops } => { + for op in ops { + self.render_op(canvas, op); + } + } + } + } + + fn render_op(&self, canvas: &Canvas, op: &PaintOp) { + match op { + PaintOp::PageBackground { bbox, background } => { + if let Some(color) = background.background_color { + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_color(colorref_to_skia(color, 1.0)); + canvas.draw_rect( + Rect::from_xywh( + bbox.x as f32, + bbox.y as f32, + bbox.width as f32, + bbox.height as f32, + ), + &paint, + ); + } + if let Some(image) = &background.image { + draw_image_bytes( + canvas, + &image.data, + bbox.x as f32, + bbox.y as f32, + bbox.width as f32, + bbox.height as f32, + Some(image.fill_mode), + None, + None, + ); + } + if let Some(border) = background.border_color { + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(skia_safe::paint::Style::Stroke); + paint.set_stroke_width(if background.border_width > 0.0 { + background.border_width as f32 + } else { + 1.0 + }); + paint.set_color(colorref_to_skia(border, 1.0)); + canvas.draw_rect( + Rect::from_xywh( + bbox.x as f32, + bbox.y as f32, + bbox.width as f32, + bbox.height as f32, + ), + &paint, + ); + } + } + PaintOp::TextRun { bbox, run } => self.render_text_run(canvas, bbox, run), + PaintOp::FootnoteMarker { bbox, marker } => { + let mut font = make_font( + &crate::renderer::TextStyle { + font_family: marker.font_family.clone(), + font_size: (marker.base_font_size * 0.55).max(7.0), + color: marker.color, + ..Default::default() + }, + &self.font_mgr, + &marker.text, + ); + font.set_size((marker.base_font_size * 0.55).max(7.0) as f32); + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_color(colorref_to_skia(marker.color, 1.0)); + canvas.draw_str( + &marker.text, + (bbox.x as f32, (bbox.y + bbox.height * 0.4) as f32), + &font, + &paint, + ); + } + PaintOp::Line { line, .. } => { + self.with_shape_transform(canvas, line.transform, None, |canvas| { + let paint = make_line_paint(&line.style); + match line.style.line_type { + LineRenderType::Single => canvas.draw_line( + (line.x1 as f32, line.y1 as f32), + (line.x2 as f32, line.y2 as f32), + &paint, + ), + _ => canvas.draw_line( + (line.x1 as f32, line.y1 as f32), + (line.x2 as f32, line.y2 as f32), + &paint, + ), + }; + }); + } + PaintOp::Rectangle { bbox, rect } => { + self.with_shape_transform(canvas, rect.transform, Some(*bbox), |canvas| { + let sk_rect = Rect::from_xywh( + bbox.x as f32, + bbox.y as f32, + bbox.width as f32, + bbox.height as f32, + ); + if let Some(fill) = make_fill_paint(&rect.style) { + if rect.corner_radius > 0.0 { + canvas.draw_round_rect( + sk_rect, + rect.corner_radius as f32, + rect.corner_radius as f32, + &fill, + ); + } else { + canvas.draw_rect(sk_rect, &fill); + } + } + if let Some(stroke) = make_stroke_paint(&rect.style) { + if rect.corner_radius > 0.0 { + canvas.draw_round_rect( + sk_rect, + rect.corner_radius as f32, + rect.corner_radius as f32, + &stroke, + ); + } else { + canvas.draw_rect(sk_rect, &stroke); + } + } + }); + } + PaintOp::Ellipse { bbox, ellipse } => { + self.with_shape_transform(canvas, ellipse.transform, Some(*bbox), |canvas| { + let oval = Rect::from_xywh( + bbox.x as f32, + bbox.y as f32, + bbox.width as f32, + bbox.height as f32, + ); + if let Some(fill) = make_fill_paint(&ellipse.style) { + canvas.draw_oval(oval, &fill); + } + if let Some(stroke) = make_stroke_paint(&ellipse.style) { + canvas.draw_oval(oval, &stroke); + } + }); + } + PaintOp::Path { path, .. } => { + self.with_shape_transform(canvas, path.transform, None, |canvas| { + let sk_path = to_skia_path(&path.commands); + if let Some(fill) = make_fill_paint(&path.style) { + canvas.draw_path(&sk_path, &fill); + } + if let Some(stroke) = make_stroke_paint(&path.style) { + canvas.draw_path(&sk_path, &stroke); + } + }); + } + PaintOp::Image { bbox, image } => { + self.with_shape_transform(canvas, image.transform, Some(*bbox), |canvas| { + if let Some(data) = &image.data { + draw_image_bytes( + canvas, + data, + bbox.x as f32, + bbox.y as f32, + bbox.width as f32, + bbox.height as f32, + image.fill_mode, + image.original_size, + image.crop, + ); + } + }); + } + PaintOp::Equation { bbox, equation } => { + render_equation( + canvas, + &self.font_mgr, + &equation.layout_box, + bbox.x, + bbox.y, + equation.color, + equation.font_size, + ); + } + PaintOp::FormObject { bbox, form } => self.render_form_object(canvas, bbox, form), + } + } + + fn render_form_object( + &self, + canvas: &Canvas, + bbox: &BoundingBox, + form: &crate::renderer::render_tree::FormObjectNode, + ) { + let parse_css = |value: &str, fallback: Color| { + if let Some(hex) = value.strip_prefix('#') { + if hex.len() == 6 { + let parsed = ( + u8::from_str_radix(&hex[0..2], 16), + u8::from_str_radix(&hex[2..4], 16), + u8::from_str_radix(&hex[4..6], 16), + ); + if let (Ok(r), Ok(g), Ok(b)) = parsed { + return Color::from_argb(255, r, g, b); + } + } + } + fallback + }; + let rect = Rect::from_xywh( + bbox.x as f32, + bbox.y as f32, + bbox.width as f32, + bbox.height as f32, + ); + let mut text_style = crate::renderer::TextStyle { + font_family: "Noto Sans CJK KR".to_string(), + ..Default::default() + }; + + match form.form_type { + crate::model::control::FormType::PushButton => { + let mut fill = Paint::default(); + fill.set_anti_alias(true); + fill.set_color(Color::from_argb(255, 208, 208, 208)); + canvas.draw_rect(rect, &fill); + + let mut stroke = Paint::default(); + stroke.set_anti_alias(true); + stroke.set_style(skia_safe::paint::Style::Stroke); + stroke.set_stroke_width(0.5); + stroke.set_color(Color::from_argb(255, 160, 160, 160)); + canvas.draw_rect(rect, &stroke); + + if !form.caption.is_empty() { + let font_size = (bbox.height * 0.55).clamp(7.0, 12.0); + text_style.font_size = font_size; + let font = + super::paint_conv::make_font(&text_style, &self.font_mgr, &form.caption); + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_color(Color::from_argb(255, 128, 128, 128)); + let text_width = form.caption.chars().count() as f32 * font_size as f32 * 0.55; + canvas.draw_str( + &form.caption, + ( + bbox.x as f32 + bbox.width as f32 / 2.0 - text_width / 2.0, + bbox.y as f32 + bbox.height as f32 / 2.0 + font_size as f32 * 0.35, + ), + &font, + &paint, + ); + } + } + crate::model::control::FormType::CheckBox => { + let box_size = (bbox.height * 0.7).min(13.0) as f32; + let box_x = bbox.x as f32 + 2.0; + let box_y = bbox.y as f32 + (bbox.height as f32 - box_size) / 2.0; + + let mut fill = Paint::default(); + fill.set_anti_alias(true); + fill.set_color(Color::WHITE); + canvas.draw_rect(Rect::from_xywh(box_x, box_y, box_size, box_size), &fill); + + let mut stroke = Paint::default(); + stroke.set_anti_alias(true); + stroke.set_style(skia_safe::paint::Style::Stroke); + stroke.set_stroke_width(0.8); + stroke.set_color(Color::from_argb(255, 96, 96, 96)); + canvas.draw_rect(Rect::from_xywh(box_x, box_y, box_size, box_size), &stroke); + + if form.value != 0 { + let mut check = PathBuilder::new(); + check.move_to((box_x + box_size * 0.2, box_y + box_size * 0.55)); + check.line_to((box_x + box_size * 0.45, box_y + box_size * 0.8)); + check.line_to((box_x + box_size * 0.85, box_y + box_size * 0.2)); + let mut mark = Paint::default(); + mark.set_anti_alias(true); + mark.set_style(skia_safe::paint::Style::Stroke); + mark.set_stroke_width(1.5); + mark.set_color(Color::BLACK); + canvas.draw_path(&check.detach(), &mark); + } + + if !form.caption.is_empty() { + let font_size = (bbox.height * 0.55).clamp(7.0, 12.0); + text_style.font_size = font_size; + let font = + super::paint_conv::make_font(&text_style, &self.font_mgr, &form.caption); + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_color(parse_css(&form.fore_color, Color::BLACK)); + canvas.draw_str( + &form.caption, + ( + box_x + box_size + 3.0, + bbox.y as f32 + bbox.height as f32 / 2.0 + font_size as f32 * 0.35, + ), + &font, + &paint, + ); + } + } + crate::model::control::FormType::RadioButton => { + let radius = (bbox.height * 0.3).min(6.5) as f32; + let cx = bbox.x as f32 + 2.0 + radius; + let cy = bbox.y as f32 + bbox.height as f32 / 2.0; + + let mut fill = Paint::default(); + fill.set_anti_alias(true); + fill.set_color(Color::WHITE); + canvas.draw_circle((cx, cy), radius, &fill); + + let mut stroke = Paint::default(); + stroke.set_anti_alias(true); + stroke.set_style(skia_safe::paint::Style::Stroke); + stroke.set_stroke_width(0.8); + stroke.set_color(Color::from_argb(255, 96, 96, 96)); + canvas.draw_circle((cx, cy), radius, &stroke); + + if form.value != 0 { + let mut dot = Paint::default(); + dot.set_anti_alias(true); + dot.set_color(Color::BLACK); + canvas.draw_circle((cx, cy), radius * 0.5, &dot); + } + + if !form.caption.is_empty() { + let font_size = (bbox.height * 0.55).clamp(7.0, 12.0); + text_style.font_size = font_size; + let font = + super::paint_conv::make_font(&text_style, &self.font_mgr, &form.caption); + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_color(parse_css(&form.fore_color, Color::BLACK)); + canvas.draw_str( + &form.caption, + ( + cx + radius + 3.0, + bbox.y as f32 + bbox.height as f32 / 2.0 + font_size as f32 * 0.35, + ), + &font, + &paint, + ); + } + } + crate::model::control::FormType::ComboBox => { + let btn_w = (bbox.height * 0.8).min(16.0) as f32; + let mut fill = Paint::default(); + fill.set_anti_alias(true); + fill.set_color(Color::WHITE); + canvas.draw_rect(rect, &fill); + + let mut stroke = Paint::default(); + stroke.set_anti_alias(true); + stroke.set_style(skia_safe::paint::Style::Stroke); + stroke.set_stroke_width(0.8); + stroke.set_color(Color::from_argb(255, 160, 160, 160)); + canvas.draw_rect(rect, &stroke); + + let button_rect = Rect::from_xywh( + bbox.x as f32 + bbox.width as f32 - btn_w, + bbox.y as f32, + btn_w, + bbox.height as f32, + ); + let mut button_fill = Paint::default(); + button_fill.set_anti_alias(true); + button_fill.set_color(Color::from_argb(255, 224, 224, 224)); + canvas.draw_rect(button_rect, &button_fill); + + let mut button_stroke = Paint::default(); + button_stroke.set_anti_alias(true); + button_stroke.set_style(skia_safe::paint::Style::Stroke); + button_stroke.set_stroke_width(0.5); + button_stroke.set_color(Color::from_argb(255, 160, 160, 160)); + canvas.draw_rect(button_rect, &button_stroke); + + let arrow_cx = bbox.x as f32 + bbox.width as f32 - btn_w / 2.0; + let arrow_cy = bbox.y as f32 + bbox.height as f32 / 2.0; + let arrow_size = (bbox.height * 0.2).min(4.0) as f32; + let mut arrow = PathBuilder::new(); + arrow.move_to((arrow_cx - arrow_size, arrow_cy - arrow_size * 0.5)); + arrow.line_to((arrow_cx + arrow_size, arrow_cy - arrow_size * 0.5)); + arrow.line_to((arrow_cx, arrow_cy + arrow_size * 0.5)); + arrow.close(); + let mut arrow_paint = Paint::default(); + arrow_paint.set_anti_alias(true); + arrow_paint.set_color(Color::from_argb(255, 64, 64, 64)); + canvas.draw_path(&arrow.detach(), &arrow_paint); + + if !form.text.is_empty() { + let font_size = (bbox.height * 0.55).clamp(7.0, 12.0); + text_style.font_size = font_size; + let font = + super::paint_conv::make_font(&text_style, &self.font_mgr, &form.text); + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_color(parse_css(&form.fore_color, Color::BLACK)); + canvas.draw_str( + &form.text, + ( + bbox.x as f32 + 3.0, + bbox.y as f32 + bbox.height as f32 / 2.0 + font_size as f32 * 0.35, + ), + &font, + &paint, + ); + } + } + crate::model::control::FormType::Edit => { + let mut fill = Paint::default(); + fill.set_anti_alias(true); + fill.set_color(Color::WHITE); + canvas.draw_rect(rect, &fill); + + let mut stroke = Paint::default(); + stroke.set_anti_alias(true); + stroke.set_style(skia_safe::paint::Style::Stroke); + stroke.set_stroke_width(0.8); + stroke.set_color(Color::from_argb(255, 160, 160, 160)); + canvas.draw_rect(rect, &stroke); + + if !form.text.is_empty() { + let font_size = (bbox.height * 0.55).clamp(7.0, 12.0); + text_style.font_size = font_size; + let font = + super::paint_conv::make_font(&text_style, &self.font_mgr, &form.text); + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_color(parse_css(&form.fore_color, Color::BLACK)); + canvas.draw_str( + &form.text, + ( + bbox.x as f32 + 3.0, + bbox.y as f32 + bbox.height as f32 / 2.0 + font_size as f32 * 0.35, + ), + &font, + &paint, + ); + } + } + } + } + + fn render_text_run(&self, canvas: &Canvas, bbox: &BoundingBox, run: &TextRunNode) { + let paint = make_text_paint(&run.style); + let y = (bbox.y + run.baseline) as f32; + let char_positions = compute_char_positions(&run.text, &run.style); + let clusters = split_into_clusters(&run.text); + let metrics_font = make_font(&run.style, &self.font_mgr, &run.text); + + if run.style.shadow_type > 0 { + let mut shadow_paint = Paint::default(); + shadow_paint.set_anti_alias(true); + shadow_paint.set_color(colorref_to_skia(run.style.shadow_color, 1.0)); + for (char_idx, cluster) in &clusters { + if cluster == " " || cluster == "\t" { + continue; + } + let font = make_font(&run.style, &self.font_mgr, cluster); + let x = bbox.x + char_positions[*char_idx] + run.style.shadow_offset_x; + let shadow_y = y + run.style.shadow_offset_y as f32; + let glyphs = font.text_to_glyphs_vec(cluster); + let mut glyph_positions = vec![Point::default(); glyphs.len()]; + font.get_pos( + &glyphs, + &mut glyph_positions, + Some(Point::new(x as f32, shadow_y)), + ); + for (glyph_id, glyph_position) in glyphs.into_iter().zip(glyph_positions) { + if let Some(path) = font.get_path(glyph_id) { + let path = path.with_offset((glyph_position.x, glyph_position.y)); + canvas.draw_path(&path, &shadow_paint); + } + } + } + } + + for (char_idx, cluster) in &clusters { + if cluster == " " || cluster == "\t" { + continue; + } + let font = make_font(&run.style, &self.font_mgr, cluster); + let x = bbox.x + char_positions[*char_idx]; + let glyphs = font.text_to_glyphs_vec(cluster); + let mut glyph_positions = vec![Point::default(); glyphs.len()]; + font.get_pos(&glyphs, &mut glyph_positions, Some(Point::new(x as f32, y))); + for (glyph_id, glyph_position) in glyphs.into_iter().zip(glyph_positions) { + if let Some(path) = font.get_path(glyph_id) { + let path = path.with_offset((glyph_position.x, glyph_position.y)); + canvas.draw_path(&path, &paint); + } + } + } + + let text_width = char_positions.last().copied().unwrap_or(0.0) as f32; + if !matches!(run.style.underline, UnderlineType::None) { + let ul_y = match run.style.underline { + UnderlineType::Top => y - metrics_font.size() + 1.0, + _ => y + 2.0, + }; + let mut line_paint = Paint::default(); + line_paint.set_anti_alias(true); + line_paint.set_style(skia_safe::paint::Style::Stroke); + line_paint.set_stroke_width(1.0); + line_paint.set_color(colorref_to_skia( + if run.style.underline_color != 0 { + run.style.underline_color + } else { + run.style.color + }, + 1.0, + )); + canvas.draw_line( + (bbox.x as f32, ul_y), + ((bbox.x as f32) + text_width, ul_y), + &line_paint, + ); + } + if run.style.strikethrough { + let strike_y = y - metrics_font.size() * 0.3; + let mut line_paint = Paint::default(); + line_paint.set_anti_alias(true); + line_paint.set_style(skia_safe::paint::Style::Stroke); + line_paint.set_stroke_width(1.0); + line_paint.set_color(colorref_to_skia( + if run.style.strike_color != 0 { + run.style.strike_color + } else { + run.style.color + }, + 1.0, + )); + canvas.draw_line( + (bbox.x as f32, strike_y), + ((bbox.x as f32) + text_width, strike_y), + &line_paint, + ); + } + } + + fn with_shape_transform( + &self, + canvas: &Canvas, + transform: crate::renderer::render_tree::ShapeTransform, + bbox: Option, + draw: F, + ) where + F: FnOnce(&Canvas), + { + if !transform.has_transform() { + draw(canvas); + return; + } + let bbox = bbox.unwrap_or(BoundingBox::new(0.0, 0.0, 0.0, 0.0)); + let cx = (bbox.x + bbox.width / 2.0) as f32; + let cy = (bbox.y + bbox.height / 2.0) as f32; + canvas.save(); + if transform.horz_flip { + canvas.translate((cx * 2.0, 0.0)); + canvas.scale((-1.0, 1.0)); + } + if transform.vert_flip { + canvas.translate((0.0, cy * 2.0)); + canvas.scale((1.0, -1.0)); + } + if transform.rotation != 0.0 { + canvas.rotate(transform.rotation as f32, Some((cx, cy).into())); + } + draw(canvas); + canvas.restore(); + } +} + +#[cfg(test)] +mod tests { + use super::SkiaLayerRenderer; + use crate::paint::{LayerBuilder, RenderProfile}; + use crate::renderer::render_tree::{ + BoundingBox, PageNode, RectangleNode, RenderNode, RenderNodeType, + }; + use crate::renderer::ShapeStyle; + + #[test] + fn renders_basic_rect_to_png() { + let mut tree = crate::renderer::render_tree::PageRenderTree::new(0, 120.0, 80.0); + tree.root.node_type = RenderNodeType::Page(PageNode { + page_index: 0, + width: 120.0, + height: 80.0, + section_index: 0, + }); + tree.root.children.push(RenderNode::new( + 1, + RenderNodeType::Rectangle(RectangleNode::new( + 0.0, + ShapeStyle { + fill_color: Some(0x0000FF00), + stroke_color: Some(0x00000000), + stroke_width: 1.0, + ..Default::default() + }, + None, + )), + BoundingBox::new(10.0, 10.0, 50.0, 30.0), + )); + let mut builder = LayerBuilder::new(RenderProfile::Screen); + let layer_tree = builder.build(&tree); + let renderer = SkiaLayerRenderer::new(); + let png = renderer.render_png(&layer_tree).expect("skia png render"); + assert!(!png.is_empty()); + assert_eq!(&png[0..8], b"\x89PNG\r\n\x1a\n"); + } +} diff --git a/src/renderer/style_resolver.rs b/src/renderer/style_resolver.rs index 7677cab8..cfbce643 100644 --- a/src/renderer/style_resolver.rs +++ b/src/renderer/style_resolver.rs @@ -3,13 +3,13 @@ //! DocInfo 참조 테이블을 렌더링에서 바로 사용할 수 있는 //! 해소된 스타일 목록(ResolvedStyleSet)으로 변환한다. +use super::{hwpunit_to_px, GradientFillInfo, PatternFillInfo, TabStop}; use crate::model::document::DocInfo; use crate::model::style::{ - Alignment, BorderFill, BorderLine, Bullet, CharShape, DiagonalLine, HeadType, - ImageFillMode, LineSpacingType, Numbering, ParaShape, TabDef, UnderlineType, FillType, + Alignment, BorderFill, BorderLine, Bullet, CharShape, DiagonalLine, FillType, HeadType, + ImageFillMode, LineSpacingType, Numbering, ParaShape, TabDef, UnderlineType, }; use crate::model::ColorRef; -use super::{hwpunit_to_px, GradientFillInfo, PatternFillInfo, TabStop}; /// HWP 언어 카테고리 수 (한국어, 영어, 한자, 일본어, 기타, 기호, 사용자) pub const LANG_COUNT: usize = 7; @@ -307,11 +307,7 @@ fn resolve_char_styles(doc_info: &DocInfo, dpi: f64) -> Vec { } /// 개별 CharShape 해소 -fn resolve_single_char_style( - cs: &CharShape, - doc_info: &DocInfo, - dpi: f64, -) -> ResolvedCharStyle { +fn resolve_single_char_style(cs: &CharShape, doc_info: &DocInfo, dpi: f64) -> ResolvedCharStyle { // base_size는 HWPUNIT 단위 let font_size = hwpunit_to_px(cs.base_size, dpi); @@ -445,7 +441,11 @@ pub fn primary_font_name(font_family: &str) -> &str { /// HWP 문서의 원본 폰트 이름 + 타입(TTF/HFT) + 언어 카테고리를 기반으로 /// @font-face에 등록된 최종 폰트로 치환한다. /// 체인이 이미 평탄화되어 1회 조회로 최종 결과를 반환한다. -pub(crate) fn resolve_font_substitution(name: &str, alt_type: u8, lang_index: usize) -> Option<&'static str> { +pub(crate) fn resolve_font_substitution( + name: &str, + alt_type: u8, + lang_index: usize, +) -> Option<&'static str> { // HFT(type=2) 폰트 치환 if alt_type == 2 { if let Some(result) = resolve_hft_font(name, lang_index) { @@ -483,13 +483,20 @@ fn resolve_hft_font(name: &str, lang_index: usize) -> Option<&'static str> { "명조" => Some("HY견명조"), // 체인 평탄화: 다단계 HFT→HFT→...→TTF 체인의 최종 결과 "휴먼명조" => Some("HY신명조"), - "문화바탕" | "문화바탕제목" | "문화쓰기" | "문화쓰기흘림" => Some("HY신명조"), - "신명 세명조" | "신명 신명조" | "신명 신신명조" | "신명 중명조" - | "신명 순명조" | "신명 신문명조" => Some("HY신명조"), + "문화바탕" | "문화바탕제목" | "문화쓰기" | "문화쓰기흘림" => { + Some("HY신명조") + } + "신명 세명조" + | "신명 신명조" + | "신명 신신명조" + | "신명 중명조" + | "신명 순명조" + | "신명 신문명조" => Some("HY신명조"), "옛한글" | "양재 다운명조M" => Some("HY신명조"), - "#세명조" | "#신명조" | "#중명조" | "#신중명조" - | "#화명조A" | "#화명조B" | "#태명조" | "#신태명조" | "#태신명조" - | "#견명조" | "#신문명조" | "#신문태명" => Some("HY신명조"), + "#세명조" | "#신명조" | "#중명조" | "#신중명조" | "#화명조A" | "#화명조B" | "#태명조" + | "#신태명조" | "#태신명조" | "#견명조" | "#신문명조" | "#신문태명" => { + Some("HY신명조") + } // 고딕 계열 "휴먼고딕" | "문화돋움" | "문화돋움제목" | "태 나무" => Some("돋움"), "휴먼옛체" | "딸기" => Some("돋움"), @@ -500,9 +507,8 @@ fn resolve_hft_font(name: &str, lang_index: usize) -> Option<&'static str> { "양재 매화" | "양재 소슬" | "양재 샤넬" | "옥수수" => Some("돋움"), "양재 본목각M" | "복숭아" => Some("돋움"), "신명 세고딕" | "신명 디나루" | "신명 세나루" => Some("돋움"), - "#세고딕" | "#신세고딕" | "#중고딕" | "#태고딕" - | "#신문고딕" | "#신문태고" | "#세나루" | "#신세나루" - | "#디나루" | "#신디나루" => Some("돋움"), + "#세고딕" | "#신세고딕" | "#중고딕" | "#태고딕" | "#신문고딕" | "#신문태고" | "#세나루" + | "#신세나루" | "#디나루" | "#신디나루" => Some("돋움"), // 그래픽/궁서/기타 "신명 신그래픽" | "강낭콩" => Some("굴림"), "#그래픽" | "#신그래픽" | "#공작" => Some("굴림"), @@ -510,7 +516,9 @@ fn resolve_hft_font(name: &str, lang_index: usize) -> Option<&'static str> { "#빅" => Some("HY견고딕"), "태 헤드라인T" => Some("HY견고딕"), "태 헤드라인D" => Some("HY견명조"), - "가는공한" | "중간공한" | "굵은공한" | "필기" | "타이프" => Some("HY견명조"), + "가는공한" | "중간공한" | "굵은공한" | "필기" | "타이프" => { + Some("HY견명조") + } "가지" | "오이" | "양재 둘기" => Some("HY견명조"), "신명 궁서" | "#궁서" => Some("궁서"), "#수암A" | "#수암B" => Some("돋움"), @@ -532,19 +540,41 @@ fn resolve_hft_font(name: &str, lang_index: usize) -> Option<&'static str> { // 영어(1) 전용 HFT 치환 if lang_index == 1 { match name { - "HCI Tulip" | "HCI Morning Glory" | "HCI Centaurea" - | "HCI Bellflower" | "AmeriGarmnd BT" | "Bodoni Bd BT" - | "Bodoni Bk BT" | "Baskerville BT" | "GoudyOlSt BT" - | "Cooper Blk BT" | "Stencil BT" | "BrushScript BT" - | "CommercialScript BT" | "Liberty BT" | "MurrayHill Bd BT" - | "ParkAvenue BT" | "CentSchbook BT" | "펜흘림" => Some("HY견명조"), - "HCI Hollyhock" | "HCI Hollyhock Narrow" | "HCI Acacia" - | "Swis721 BT" | "Hobo BT" | "Orbit-B BT" - | "Blippo Blk BT" | "BroadwayEngraved BT" - | "FuturaBlack BT" | "Newtext Bk BT" | "DomCasual BT" - | "가는안상수체영문" | "중간안상수체영문" | "굵은안상수체영문" => Some("HY중고딕"), - "HCI Columbine" | "Courier10 BT" | "OCR-A BT" - | "OCR-B-10 BT" | "Orator10 BT" => Some("Calibri"), + "HCI Tulip" + | "HCI Morning Glory" + | "HCI Centaurea" + | "HCI Bellflower" + | "AmeriGarmnd BT" + | "Bodoni Bd BT" + | "Bodoni Bk BT" + | "Baskerville BT" + | "GoudyOlSt BT" + | "Cooper Blk BT" + | "Stencil BT" + | "BrushScript BT" + | "CommercialScript BT" + | "Liberty BT" + | "MurrayHill Bd BT" + | "ParkAvenue BT" + | "CentSchbook BT" + | "펜흘림" => Some("HY견명조"), + "HCI Hollyhock" + | "HCI Hollyhock Narrow" + | "HCI Acacia" + | "Swis721 BT" + | "Hobo BT" + | "Orbit-B BT" + | "Blippo Blk BT" + | "BroadwayEngraved BT" + | "FuturaBlack BT" + | "Newtext Bk BT" + | "DomCasual BT" + | "가는안상수체영문" + | "중간안상수체영문" + | "굵은안상수체영문" => Some("HY중고딕"), + "HCI Columbine" | "Courier10 BT" | "OCR-A BT" | "OCR-B-10 BT" | "Orator10 BT" => { + Some("Calibri") + } "BernhardFashion BT" | "Freehand591 BT" => Some("HY중고딕"), _ => None, } @@ -614,11 +644,16 @@ fn resolve_single_para_style(ps: &ParaShape, tab_defs: &[TabDef], dpi: f64) -> R // 렌더링 시 2로 나누어야 한다 (hwp2hwpx 변환 코드 및 HWP 대화상자 확인). let tab_def = tab_defs.get(ps.tab_def_id as usize); let tab_stops: Vec = tab_def - .map(|td| td.tabs.iter().map(|t| TabStop { - position: hwpunit_to_px(t.position as i32, dpi) / 2.0, - tab_type: t.tab_type, - fill_type: t.fill_type, - }).collect()) + .map(|td| { + td.tabs + .iter() + .map(|t| TabStop { + position: hwpunit_to_px(t.position as i32, dpi) / 2.0, + tab_type: t.tab_type, + fill_type: t.fill_type, + }) + .collect() + }) .unwrap_or_default(); let auto_tab_right = tab_def.map(|td| td.auto_tab_right).unwrap_or(false); @@ -708,7 +743,9 @@ fn resolve_single_border_style(bf: &BorderFill) -> ResolvedBorderStyle { } let positions: Vec = if g.positions.is_empty() { let n = g.colors.len(); - (0..n).map(|i| i as f64 / (n.max(2) - 1).max(1) as f64).collect() + (0..n) + .map(|i| i as f64 / (n.max(2) - 1).max(1) as f64) + .collect() } else { g.positions.iter().map(|&p| p as f64 / 100.0).collect() }; @@ -725,11 +762,9 @@ fn resolve_single_border_style(bf: &BorderFill) -> ResolvedBorderStyle { }; let image_fill = match bf.fill.fill_type { - FillType::Image => bf.fill.image.as_ref().map(|img| { - ResolvedImageFill { - bin_data_id: img.bin_data_id, - fill_mode: img.fill_mode, - } + FillType::Image => bf.fill.image.as_ref().map(|img| ResolvedImageFill { + bin_data_id: img.bin_data_id, + fill_mode: img.fill_mode, }), _ => None, }; @@ -770,7 +805,7 @@ mod tests { char_shapes: vec![ CharShape { font_ids: [0, 0, 0, 0, 0, 0, 0], // 함초롬돋움 - base_size: 2400, // 24pt = 2400 HWPUNIT (1pt = 100 HWPUNIT) + base_size: 2400, // 24pt = 2400 HWPUNIT (1pt = 100 HWPUNIT) bold: true, italic: false, text_color: 0x00000000, // 검정 @@ -780,7 +815,7 @@ mod tests { }, CharShape { font_ids: [1, 1, 1, 1, 1, 1, 1], // 함초롬바탕 - base_size: 1000, // 10pt + base_size: 1000, // 10pt bold: false, italic: true, text_color: 0x00FF0000, // 파란색 (BGR) @@ -815,25 +850,39 @@ mod tests { ..Default::default() }, ], - border_fills: vec![ - BorderFill { - borders: [ - BorderLine { line_type: BorderLineType::Solid, width: 1, color: 0 }, - BorderLine { line_type: BorderLineType::Solid, width: 1, color: 0 }, - BorderLine { line_type: BorderLineType::Solid, width: 1, color: 0 }, - BorderLine { line_type: BorderLineType::Solid, width: 1, color: 0 }, - ], - fill: Fill { - fill_type: FillType::Solid, - solid: Some(SolidFill { - background_color: 0x00FFFFFF, - ..Default::default() - }), - ..Default::default() + border_fills: vec![BorderFill { + borders: [ + BorderLine { + line_type: BorderLineType::Solid, + width: 1, + color: 0, + }, + BorderLine { + line_type: BorderLineType::Solid, + width: 1, + color: 0, + }, + BorderLine { + line_type: BorderLineType::Solid, + width: 1, + color: 0, + }, + BorderLine { + line_type: BorderLineType::Solid, + width: 1, + color: 0, }, + ], + fill: Fill { + fill_type: FillType::Solid, + solid: Some(SolidFill { + background_color: 0x00FFFFFF, + ..Default::default() + }), ..Default::default() }, - ], + ..Default::default() + }], ..Default::default() } } @@ -930,12 +979,18 @@ mod tests { // 퍼센트 타입: 그대로 160.0 assert!((styles.para_styles[0].line_spacing - 160.0).abs() < 0.01); - assert_eq!(styles.para_styles[0].line_spacing_type, LineSpacingType::Percent); + assert_eq!( + styles.para_styles[0].line_spacing_type, + LineSpacingType::Percent + ); // 고정 타입: 1200 HWPUNIT → px 변환 let expected = hwpunit_to_px(1200, DEFAULT_DPI); assert!((styles.para_styles[1].line_spacing - expected).abs() < 0.01); - assert_eq!(styles.para_styles[1].line_spacing_type, LineSpacingType::Fixed); + assert_eq!( + styles.para_styles[1].line_spacing_type, + LineSpacingType::Fixed + ); } #[test] @@ -960,7 +1015,10 @@ mod tests { assert_eq!(styles.border_styles.len(), 1); assert_eq!(styles.border_styles[0].fill_color, Some(0x00FFFFFF)); - assert_eq!(styles.border_styles[0].borders[0].line_type, BorderLineType::Solid); + assert_eq!( + styles.border_styles[0].borders[0].line_type, + BorderLineType::Solid + ); } #[test] @@ -1042,28 +1100,29 @@ mod tests { DocInfo { font_faces: vec![ // lang=0 (한국어) - vec![ - Font { name: "함초롬돋움".to_string(), ..Default::default() }, - ], + vec![Font { + name: "함초롬돋움".to_string(), + ..Default::default() + }], // lang=1 (영어) - vec![ - Font { name: "Arial".to_string(), ..Default::default() }, - ], + vec![Font { + name: "Arial".to_string(), + ..Default::default() + }], // lang=2 (한자) - vec![ - Font { name: "SimSun".to_string(), ..Default::default() }, - ], - // lang=3~6 (나머지) - 비어있을 수 있음 - ], - char_shapes: vec![ - CharShape { - font_ids: [0, 0, 0, 0, 0, 0, 0], // 모든 언어에서 0번 폰트 - base_size: 1000, - ratios: [100, 80, 90, 100, 100, 100, 100], - spacings: [0, -5, 0, 0, 0, 0, 0], + vec![Font { + name: "SimSun".to_string(), ..Default::default() - }, + }], + // lang=3~6 (나머지) - 비어있을 수 있음 ], + char_shapes: vec![CharShape { + font_ids: [0, 0, 0, 0, 0, 0, 0], // 모든 언어에서 0번 폰트 + base_size: 1000, + ratios: [100, 80, 90, 100, 100, 100, 100], + spacings: [0, -5, 0, 0, 0, 0, 0], + ..Default::default() + }], ..Default::default() } } @@ -1076,10 +1135,10 @@ mod tests { let cs = &styles.char_styles[0]; assert_eq!(cs.font_families.len(), 7); assert_eq!(cs.font_families[0], "함초롬돋움"); // 한국어 - assert_eq!(cs.font_families[1], "Arial"); // 영어 - assert_eq!(cs.font_families[2], "SimSun"); // 한자 - assert_eq!(cs.font_families[3], ""); // 일본어 (없음) - assert_eq!(cs.font_family, "함초롬돋움"); // 기본값 = 한국어 + assert_eq!(cs.font_families[1], "Arial"); // 영어 + assert_eq!(cs.font_families[2], "SimSun"); // 한자 + assert_eq!(cs.font_families[3], ""); // 일본어 (없음) + assert_eq!(cs.font_family, "함초롬돋움"); // 기본값 = 한국어 } #[test] @@ -1088,10 +1147,10 @@ mod tests { let styles = resolve_styles(&doc_info, DEFAULT_DPI); let cs = &styles.char_styles[0]; - assert!((cs.ratios[0] - 1.0).abs() < 0.01); // 한국어 100% - assert!((cs.ratios[1] - 0.8).abs() < 0.01); // 영어 80% - assert!((cs.ratios[2] - 0.9).abs() < 0.01); // 한자 90% - assert!((cs.ratio - 1.0).abs() < 0.01); // 기본값 = 한국어 + assert!((cs.ratios[0] - 1.0).abs() < 0.01); // 한국어 100% + assert!((cs.ratios[1] - 0.8).abs() < 0.01); // 영어 80% + assert!((cs.ratios[2] - 0.9).abs() < 0.01); // 한자 90% + assert!((cs.ratio - 1.0).abs() < 0.01); // 기본값 = 한국어 } #[test] diff --git a/src/renderer/svg.rs b/src/renderer/svg.rs index 1978032a..58afb506 100644 --- a/src/renderer/svg.rs +++ b/src/renderer/svg.rs @@ -3,11 +3,17 @@ //! 렌더 트리를 SVG 문자열로 변환한다. //! 정적 출력(인쇄, PDF 변환 등)에 적합하다. -use super::{Renderer, TextStyle, ShapeStyle, LineStyle, PathCommand, GradientFillInfo, PatternFillInfo, StrokeDash}; -use super::render_tree::{PageRenderTree, RenderNode, RenderNodeType, ImageNode, FormObjectNode, ShapeTransform, BoundingBox}; -use super::composer::{CharOverlapInfo, pua_to_display_text, decode_pua_overlap_number}; -use crate::model::control::FormType; +use super::composer::{decode_pua_overlap_number, pua_to_display_text, CharOverlapInfo}; use super::layout::{compute_char_positions, split_into_clusters}; +use super::render_tree::{ + BoundingBox, FormObjectNode, ImageNode, PageRenderTree, RenderNode, RenderNodeType, + ShapeTransform, +}; +use super::{ + GradientFillInfo, LineStyle, PathCommand, PatternFillInfo, Renderer, ShapeStyle, StrokeDash, + TextStyle, +}; +use crate::model::control::FormType; use crate::model::style::{ImageFillMode, UnderlineType}; use base64::Engine; @@ -117,7 +123,9 @@ impl SvgRenderer { } /// 수집된 폰트별 사용 글자 목록 반환 - pub fn font_codepoints(&self) -> &std::collections::HashMap> { + pub fn font_codepoints( + &self, + ) -> &std::collections::HashMap> { &self.font_codepoints } @@ -142,9 +150,7 @@ impl SvgRenderer { let color_str = color_to_svg(color); self.output.push_str(&format!( "\n", - node.bbox.x, node.bbox.y, - node.bbox.width, node.bbox.height, - color_str, + node.bbox.x, node.bbox.y, node.bbox.width, node.bbox.height, color_str, )); } // 그라데이션 (배경색 위에 덮음) @@ -152,9 +158,7 @@ impl SvgRenderer { let grad_id = self.create_gradient_def(grad); self.output.push_str(&format!( "\n", - node.bbox.x, node.bbox.y, - node.bbox.width, node.bbox.height, - grad_id, + node.bbox.x, node.bbox.y, node.bbox.width, node.bbox.height, grad_id, )); } // 이미지 (최상위) @@ -172,8 +176,10 @@ impl SvgRenderer { } RenderNodeType::TextRun(run) => { // 폰트 임베딩: 사용된 폰트/글자 수집 - if self.font_embed_mode != FontEmbedMode::None && !run.style.font_family.is_empty() { - let codepoints = self.font_codepoints + if self.font_embed_mode != FontEmbedMode::None && !run.style.font_family.is_empty() + { + let codepoints = self + .font_codepoints .entry(run.style.font_family.clone()) .or_default(); for ch in run.text.chars() { @@ -185,39 +191,67 @@ impl SvgRenderer { if let Some(ref overlap) = run.char_overlap { // 글자겹침(CharOverlap) 렌더링: 각 문자에 테두리 도형 + 텍스트 self.draw_char_overlap( - &run.text, &run.style, overlap, - node.bbox.x, node.bbox.y, node.bbox.width, node.bbox.height, + &run.text, + &run.style, + overlap, + node.bbox.x, + node.bbox.y, + node.bbox.width, + node.bbox.height, ); } else if run.rotation != 0.0 { // 회전 텍스트: bbox 중앙에 중앙 정렬 후 회전 let cx = node.bbox.x + node.bbox.width / 2.0; let cy = node.bbox.y + node.bbox.height / 2.0; let color = color_to_svg(run.style.color); - let font_size = if run.style.font_size > 0.0 { run.style.font_size } else { 12.0 }; - let font_family = if run.style.font_family.is_empty() { - "sans-serif".to_string() + let font_size = if run.style.font_size > 0.0 { + run.style.font_size } else { - let fb = super::generic_fallback(&run.style.font_family); - format!("{},{}", run.style.font_family, fb) + 12.0 }; + let font_family = Self::font_family_with_svg_fallbacks(&run.style.font_family); let mut attrs = format!("font-family=\"{}\" font-size=\"{}\" fill=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\"", escape_xml(&font_family), font_size, color); - if run.style.bold { attrs.push_str(" font-weight=\"bold\""); } - if run.style.italic { attrs.push_str(" font-style=\"italic\""); } + if run.style.bold { + attrs.push_str(" font-weight=\"bold\""); + } + if run.style.italic { + attrs.push_str(" font-style=\"italic\""); + } for c in run.text.chars() { - if c == ' ' { continue; } + if c == ' ' { + continue; + } self.output.push_str(&format!( "{}\n", - cx, cy, attrs, run.rotation, cx, cy, escape_xml(&c.to_string()), + cx, + cy, + attrs, + run.rotation, + cx, + cy, + escape_xml(&c.to_string()), )); } } else { - self.draw_text(&run.text, node.bbox.x, node.bbox.y + run.baseline, &run.style); + self.draw_text( + &run.text, + node.bbox.x, + node.bbox.y + run.baseline, + &run.style, + ); } if self.show_paragraph_marks || self.show_control_codes { // 조판부호 마커 TextRun은 공백 기호 표시 건너뛰기 - let is_marker = !matches!(run.field_marker, crate::renderer::render_tree::FieldMarkerType::None); - let font_size = if run.style.font_size > 0.0 { run.style.font_size } else { 12.0 }; + let is_marker = !matches!( + run.field_marker, + crate::renderer::render_tree::FieldMarkerType::None + ); + let font_size = if run.style.font_size > 0.0 { + run.style.font_size + } else { + 12.0 + }; // 공백·탭 기호: 각 문자 위치에 오버레이 if !run.text.is_empty() && !is_marker { let char_positions = compute_char_positions(&run.text, &run.style); @@ -247,11 +281,22 @@ impl SvgRenderer { } // 하드 리턴·강제 줄바꿈 기호 if run.is_para_end || run.is_line_break_end { - let mark_x = if run.text.is_empty() { node.bbox.x } else { node.bbox.x + node.bbox.width }; - let mark = if run.is_line_break_end { "\u{2193}" } else { "\u{21B5}" }; + let mark_x = if run.text.is_empty() { + node.bbox.x + } else { + node.bbox.x + node.bbox.width + }; + let mark = if run.is_line_break_end { + "\u{2193}" + } else { + "\u{21B5}" + }; self.output.push_str(&format!( "{}\n", - mark_x, node.bbox.y + run.baseline, font_size, mark, + mark_x, + node.bbox.y + run.baseline, + font_size, + mark, )); } } @@ -259,18 +304,20 @@ impl SvgRenderer { RenderNodeType::FootnoteMarker(marker) => { let sup_size = (marker.base_font_size * 0.55).max(7.0); let color = color_to_svg(marker.color); - let font_family = if marker.font_family.is_empty() { "sans-serif" } else { &marker.font_family }; + let font_family = Self::font_family_with_svg_fallbacks(&marker.font_family); let y = node.bbox.y + node.bbox.height * 0.4; self.output.push_str(&format!( "{}\n", - node.bbox.x, y, escape_xml(font_family), sup_size, color, escape_xml(&marker.text), + node.bbox.x, y, escape_xml(&font_family), sup_size, color, escape_xml(&marker.text), )); } RenderNodeType::Rectangle(rect) => { self.open_shape_transform(&rect.transform, &node.bbox); self.draw_rect_with_gradient( - node.bbox.x, node.bbox.y, - node.bbox.width, node.bbox.height, + node.bbox.x, + node.bbox.y, + node.bbox.width, + node.bbox.height, rect.corner_radius, &rect.style, rect.gradient.as_deref(), @@ -284,7 +331,14 @@ impl SvgRenderer { self.open_shape_transform(&ellipse.transform, &node.bbox); let cx = node.bbox.x + node.bbox.width / 2.0; let cy = node.bbox.y + node.bbox.height / 2.0; - self.draw_ellipse_with_gradient(cx, cy, node.bbox.width / 2.0, node.bbox.height / 2.0, &ellipse.style, ellipse.gradient.as_deref()); + self.draw_ellipse_with_gradient( + cx, + cy, + node.bbox.width / 2.0, + node.bbox.height / 2.0, + &ellipse.style, + ellipse.gradient.as_deref(), + ); } RenderNodeType::Image(img) => { self.open_shape_transform(&img.transform, &node.bbox); @@ -304,7 +358,8 @@ impl SvgRenderer { self.output.push_str("\n"); // 폰트 임베딩: 수식에서 사용된 글자 수집 if self.font_embed_mode != FontEmbedMode::None { - let codepoints = self.font_codepoints + let codepoints = self + .font_codepoints .entry("Latin Modern Math".to_string()) .or_default(); // SVG 요소 내부의 텍스트에서 문자 추출 @@ -320,13 +375,16 @@ impl SvgRenderer { RenderNodeType::FormObject(form) => { self.render_form_object(form, &node.bbox); } - RenderNodeType::Body { clip_rect: Some(cr) } => { + RenderNodeType::Body { + clip_rect: Some(cr), + } => { let clip_id = format!("body-clip-{}", node.id); self.defs.push(format!( "\n", clip_id, cr.x, cr.y, cr.width, cr.height, )); - self.output.push_str(&format!("", clip_id)); + self.output + .push_str(&format!("", clip_id)); } RenderNodeType::TableCell(ref tc) if tc.clip => { let clip_id = format!("cell-clip-{}", node.id); @@ -334,7 +392,8 @@ impl SvgRenderer { "\n", clip_id, node.bbox.x, node.bbox.y, node.bbox.width, node.bbox.height, )); - self.output.push_str(&format!("", clip_id)); + self.output + .push_str(&format!("", clip_id)); } _ => {} } @@ -357,16 +416,23 @@ impl SvgRenderer { } else { // (section, para) 복합키로 섹션 간 구분 let key = si * 100000 + pi; - let entry = self.overlay_para_bounds.entry(key).or_insert(OverlayBounds { - section_index: si, - x: node.bbox.x, y: node.bbox.y, - width: node.bbox.width, height: node.bbox.height, - }); + let entry = + self.overlay_para_bounds + .entry(key) + .or_insert(OverlayBounds { + section_index: si, + x: node.bbox.x, + y: node.bbox.y, + width: node.bbox.width, + height: node.bbox.height, + }); // 기존 bounds 확장 (여러 줄이 하나의 문단) let min_x = entry.x.min(node.bbox.x); let min_y = entry.y.min(node.bbox.y); - let max_x = (entry.x + entry.width).max(node.bbox.x + node.bbox.width); - let max_y = (entry.y + entry.height).max(node.bbox.y + node.bbox.height); + let max_x = + (entry.x + entry.width).max(node.bbox.x + node.bbox.width); + let max_y = + (entry.y + entry.height).max(node.bbox.y + node.bbox.height); entry.x = min_x; entry.y = min_y; entry.width = max_x - min_x; @@ -397,15 +463,22 @@ impl SvgRenderer { }); // 표를 포함하는 문단 bounds도 확장 (텍스트 없는 문단 처리) let key = tbl_si * 100000 + pi; - let entry = self.overlay_para_bounds.entry(key).or_insert(OverlayBounds { - section_index: tbl_si, - x: node.bbox.x, y: node.bbox.y, - width: node.bbox.width, height: node.bbox.height, - }); + let entry = + self.overlay_para_bounds + .entry(key) + .or_insert(OverlayBounds { + section_index: tbl_si, + x: node.bbox.x, + y: node.bbox.y, + width: node.bbox.width, + height: node.bbox.height, + }); let min_x = entry.x.min(node.bbox.x); let min_y = entry.y.min(node.bbox.y); - let max_x = (entry.x + entry.width).max(node.bbox.x + node.bbox.width); - let max_y = (entry.y + entry.height).max(node.bbox.y + node.bbox.height); + let max_x = + (entry.x + entry.width).max(node.bbox.x + node.bbox.width); + let max_y = + (entry.y + entry.height).max(node.bbox.y + node.bbox.height); entry.x = min_x; entry.y = min_y; entry.width = max_x - min_x; @@ -416,9 +489,12 @@ impl SvgRenderer { self.overlay_skip_depth += 1; } // 머리말/꼬리말/바탕쪽/각주/텍스트박스/그룹: body 외 영역 제외 - RenderNodeType::Header | RenderNodeType::Footer - | RenderNodeType::MasterPage | RenderNodeType::FootnoteArea - | RenderNodeType::TextBox | RenderNodeType::Group(_) => { + RenderNodeType::Header + | RenderNodeType::Footer + | RenderNodeType::MasterPage + | RenderNodeType::FootnoteArea + | RenderNodeType::TextBox + | RenderNodeType::Group(_) => { self.overlay_skip_depth += 1; } _ => {} @@ -433,9 +509,12 @@ impl SvgRenderer { if self.debug_overlay { match &node.node_type { RenderNodeType::Table(_) - | RenderNodeType::Header | RenderNodeType::Footer - | RenderNodeType::MasterPage | RenderNodeType::FootnoteArea - | RenderNodeType::TextBox | RenderNodeType::Group(_) => { + | RenderNodeType::Header + | RenderNodeType::Footer + | RenderNodeType::MasterPage + | RenderNodeType::FootnoteArea + | RenderNodeType::TextBox + | RenderNodeType::Group(_) => { self.overlay_skip_depth = self.overlay_skip_depth.saturating_sub(1); } _ => {} @@ -461,7 +540,10 @@ impl SvgRenderer { let fs = 10.0; // 조판부호 고정 크기 self.output.push_str(&format!( "{}\n", - node.bbox.x, node.bbox.y + fs, fs, label, + node.bbox.x, + node.bbox.y + fs, + fs, + label, )); } } @@ -501,7 +583,8 @@ impl SvgRenderer { if transform.rotation != 0.0 { parts.push(format!("rotate({},{},{})", transform.rotation, cx, cy)); } - self.output.push_str(&format!("\n", parts.join(" "))); + self.output + .push_str(&format!("\n", parts.join(" "))); } /// 도형 변환 그룹을 닫는다 (open_shape_transform에 대응). @@ -593,7 +676,11 @@ impl SvgRenderer { } /// ShapeStyle에서 SVG fill 속성 문자열 생성 - fn build_fill_attr(&mut self, style: &ShapeStyle, gradient: Option<&GradientFillInfo>) -> String { + fn build_fill_attr( + &mut self, + style: &ShapeStyle, + gradient: Option<&GradientFillInfo>, + ) -> String { if let Some(grad) = gradient { let grad_id = self.create_gradient_def(grad); format!(" fill=\"url(#{})\"", grad_id) @@ -612,8 +699,13 @@ impl SvgRenderer { /// HWP 화살표 크기(0-8): {작은,중간,큰} × {작은,중간,큰} (너비 × 길이) /// 선 두께와 길이를 고려하여 마커 크기 결정 fn ensure_arrow_marker( - &mut self, color: &str, stroke_width: f64, line_len: f64, - arrow: &super::ArrowStyle, arrow_size: u8, is_start: bool, + &mut self, + color: &str, + stroke_width: f64, + line_len: f64, + arrow: &super::ArrowStyle, + arrow_size: u8, + is_start: bool, ) -> String { let type_name = match arrow { super::ArrowStyle::Arrow => "arrow", @@ -639,20 +731,20 @@ impl SvgRenderer { // arrow_size: 0=작은-작은, 1=작은-중간, 2=작은-큰, // 3=중간-작은, 4=중간-중간, 5=중간-큰, // 6=큰-작은, 7=큰-중간, 8=큰-큰 - let width_level = arrow_size / 3; // 0=작은, 1=중간, 2=큰 + let width_level = arrow_size / 3; // 0=작은, 1=중간, 2=큰 let length_level = arrow_size % 3; // 0=작은, 1=중간, 2=큰 // 너비 배율 (선 두께 대비 화살표 높이) let width_mult = match width_level { - 0 => 1.5, // 작은: 선 두께의 1.5배 - 1 => 2.5, // 중간: 선 두께의 2.5배 - _ => 3.5, // 큰: 선 두께의 3.5배 + 0 => 1.5, // 작은: 선 두께의 1.5배 + 1 => 2.5, // 중간: 선 두께의 2.5배 + _ => 3.5, // 큰: 선 두께의 3.5배 }; // 길이 배율 (화살표 높이 대비 길이) let length_mult = match length_level { - 0 => 1.0, // 작은 - 1 => 1.5, // 중간 - _ => 2.0, // 큰 + 0 => 1.0, // 작은 + 1 => 1.5, // 중간 + _ => 2.0, // 큰 }; let arrow_h = (stroke_width * width_mult).max(3.0); @@ -798,7 +890,11 @@ impl SvgRenderer { grad.positions[i] * 100.0 } else { let n = grad.colors.len(); - if n <= 1 { 0.0 } else { i as f64 / (n - 1) as f64 * 100.0 } + if n <= 1 { + 0.0 + } else { + i as f64 / (n - 1) as f64 * 100.0 + } }; stops.push_str(&format!( "\n", @@ -810,18 +906,33 @@ impl SvgRenderer { } /// 그라데이션을 포함한 사각형 그리기 (렌더 트리 전용) - fn draw_rect_with_gradient(&mut self, x: f64, y: f64, w: f64, h: f64, corner_radius: f64, style: &ShapeStyle, gradient: Option<&GradientFillInfo>) { + fn draw_rect_with_gradient( + &mut self, + x: f64, + y: f64, + w: f64, + h: f64, + corner_radius: f64, + style: &ShapeStyle, + gradient: Option<&GradientFillInfo>, + ) { let mut attrs = format!("x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", x, y, w, h); if corner_radius > 0.0 { - attrs.push_str(&format!(" rx=\"{}\" ry=\"{}\"", corner_radius, corner_radius)); + attrs.push_str(&format!( + " rx=\"{}\" ry=\"{}\"", + corner_radius, corner_radius + )); } attrs.push_str(&self.build_fill_attr(style, gradient)); if let Some(stroke) = style.stroke_color { - attrs.push_str(&format!(" stroke=\"{}\" stroke-width=\"{}\"", - color_to_svg(stroke), style.stroke_width)); + attrs.push_str(&format!( + " stroke=\"{}\" stroke-width=\"{}\"", + color_to_svg(stroke), + style.stroke_width + )); } if style.opacity < 1.0 { @@ -832,14 +943,25 @@ impl SvgRenderer { } /// 그라데이션을 포함한 타원 그리기 (렌더 트리 전용) - fn draw_ellipse_with_gradient(&mut self, cx: f64, cy: f64, rx: f64, ry: f64, style: &ShapeStyle, gradient: Option<&GradientFillInfo>) { + fn draw_ellipse_with_gradient( + &mut self, + cx: f64, + cy: f64, + rx: f64, + ry: f64, + style: &ShapeStyle, + gradient: Option<&GradientFillInfo>, + ) { let mut attrs = format!("cx=\"{}\" cy=\"{}\" rx=\"{}\" ry=\"{}\"", cx, cy, rx, ry); attrs.push_str(&self.build_fill_attr(style, gradient)); if let Some(stroke) = style.stroke_color { - attrs.push_str(&format!(" stroke=\"{}\" stroke-width=\"{}\"", - color_to_svg(stroke), style.stroke_width)); + attrs.push_str(&format!( + " stroke=\"{}\" stroke-width=\"{}\"", + color_to_svg(stroke), + style.stroke_width + )); } if style.opacity < 1.0 { @@ -850,19 +972,31 @@ impl SvgRenderer { } /// 그라데이션을 포함한 패스 그리기 (렌더 트리 전용) - fn draw_path_with_gradient(&mut self, commands: &[PathCommand], style: &ShapeStyle, gradient: Option<&GradientFillInfo>) { + fn draw_path_with_gradient( + &mut self, + commands: &[PathCommand], + style: &ShapeStyle, + gradient: Option<&GradientFillInfo>, + ) { let mut d = String::new(); for cmd in commands { match cmd { PathCommand::MoveTo(x, y) => d.push_str(&format!("M{} {} ", x, y)), PathCommand::LineTo(x, y) => d.push_str(&format!("L{} {} ", x, y)), - PathCommand::CurveTo(x1, y1, x2, y2, x, y) => d.push_str(&format!("C{} {} {} {} {} {} ", x1, y1, x2, y2, x, y)), + PathCommand::CurveTo(x1, y1, x2, y2, x, y) => { + d.push_str(&format!("C{} {} {} {} {} {} ", x1, y1, x2, y2, x, y)) + } PathCommand::ArcTo(rx, ry, x_rot, large_arc, sweep, x, y) => { - d.push_str(&format!("A{} {} {} {} {} {} {} ", - rx, ry, x_rot, + d.push_str(&format!( + "A{} {} {} {} {} {} {} ", + rx, + ry, + x_rot, if *large_arc { 1 } else { 0 }, if *sweep { 1 } else { 0 }, - x, y)); + x, + y + )); } PathCommand::ClosePath => d.push_str("Z "), } @@ -873,8 +1007,11 @@ impl SvgRenderer { attrs.push_str(&self.build_fill_attr(style, gradient)); if let Some(stroke) = style.stroke_color { - attrs.push_str(&format!(" stroke=\"{}\" stroke-width=\"{}\"", - color_to_svg(stroke), style.stroke_width)); + attrs.push_str(&format!( + " stroke=\"{}\" stroke-width=\"{}\"", + color_to_svg(stroke), + style.stroke_width + )); match style.stroke_dash { StrokeDash::Dash => attrs.push_str(" stroke-dasharray=\"6 3\""), StrokeDash::Dot => attrs.push_str(" stroke-dasharray=\"2 2\""), @@ -915,7 +1052,10 @@ impl SvgRenderer { /// 이중선/삼중선 렌더링: 원래 선에 수직 방향으로 평행선들을 그림 fn draw_multi_line( &mut self, - x1: f64, y1: f64, x2: f64, y2: f64, + x1: f64, + y1: f64, + x2: f64, + y2: f64, total_width: f64, color: &str, line_type: &super::LineRenderType, @@ -923,7 +1063,9 @@ impl SvgRenderer { let dx = x2 - x1; let dy = y2 - y1; let len = (dx * dx + dy * dy).sqrt(); - if len < 0.001 { return; } + if len < 0.001 { + return; + } // 수직 방향 단위벡터 (선의 법선) let nx = -dy / len; @@ -979,20 +1121,22 @@ impl SvgRenderer { // 그림 효과(그레이스케일/흑백) → SVG 필터 래핑 let effect_filter_id = self.ensure_image_effect_filter(img.effect); if let Some(ref fid) = effect_filter_id { - self.output.push_str(&format!("\n", fid)); + self.output + .push_str(&format!("\n", fid)); } let mime_type = detect_image_mime_type(data); // WMF → SVG 변환 (브라우저는 WMF를 렌더링할 수 없으므로 SVG로 변환) - let (render_data, render_mime): (std::borrow::Cow<[u8]>, &str) = if mime_type == "image/x-wmf" { - match convert_wmf_to_svg(data) { - Some(svg_bytes) => (std::borrow::Cow::Owned(svg_bytes), "image/svg+xml"), - None => (std::borrow::Cow::Borrowed(data), mime_type), - } - } else { - (std::borrow::Cow::Borrowed(data), mime_type) - }; + let (render_data, render_mime): (std::borrow::Cow<[u8]>, &str) = + if mime_type == "image/x-wmf" { + match convert_wmf_to_svg(data) { + Some(svg_bytes) => (std::borrow::Cow::Owned(svg_bytes), "image/svg+xml"), + None => (std::borrow::Cow::Borrowed(data), mime_type), + } + } else { + (std::borrow::Cow::Borrowed(data), mime_type) + }; let base64_data = base64::engine::general_purpose::STANDARD.encode(&*render_data); let data_uri = format!("data:{};base64,{}", render_mime, base64_data); @@ -1022,8 +1166,10 @@ impl SvgRenderer { let src_w = (cr - cl) as f64 / scale_x; let src_h = (cb - ct) as f64 / scale_x; // 전체 이미지 대비 잘림이 있는지 확인 - let is_cropped = src_x > 0.5 || src_y > 0.5 - || (src_w - img_w).abs() > 1.0 || (src_h - img_h).abs() > 1.0; + let is_cropped = src_x > 0.5 + || src_y > 0.5 + || (src_w - img_w).abs() > 1.0 + || (src_h - img_h).abs() > 1.0; if is_cropped { // SVG: 중첩 svg + viewBox로 crop 영역만 표시 self.output.push_str(&format!( @@ -1056,19 +1202,46 @@ impl SvgRenderer { } ImageFillMode::TileAll => { // 바둑판식으로-모두: 원래 크기로 전체 타일링 - self.render_tiled_image(&render_data, &data_uri, bbox, true, true, img.original_size); + self.render_tiled_image( + &render_data, + &data_uri, + bbox, + true, + true, + img.original_size, + ); } ImageFillMode::TileHorzTop | ImageFillMode::TileHorzBottom => { // 바둑판식으로-가로: 가로 방향만 타일링 (위 또는 아래 기준) - self.render_tiled_image(&render_data, &data_uri, bbox, true, false, img.original_size); + self.render_tiled_image( + &render_data, + &data_uri, + bbox, + true, + false, + img.original_size, + ); } ImageFillMode::TileVertLeft | ImageFillMode::TileVertRight => { // 바둑판식으로-세로: 세로 방향만 타일링 (왼쪽 또는 오른쪽 기준) - self.render_tiled_image(&render_data, &data_uri, bbox, false, true, img.original_size); + self.render_tiled_image( + &render_data, + &data_uri, + bbox, + false, + true, + img.original_size, + ); } _ => { // 배치 모드: 원래 크기대로 지정 위치에 배치 - self.render_positioned_image(&render_data, &data_uri, bbox, fill_mode, img.original_size); + self.render_positioned_image( + &render_data, + &data_uri, + bbox, + fill_mode, + img.original_size, + ); } } @@ -1079,7 +1252,10 @@ impl SvgRenderer { /// 그림 효과(ImageEffect)에 해당하는 SVG 필터를 defs에 보장하고 ID를 반환한다. /// RealPic(기본)은 필터가 필요 없으므로 None 반환. - fn ensure_image_effect_filter(&mut self, effect: crate::model::image::ImageEffect) -> Option { + fn ensure_image_effect_filter( + &mut self, + effect: crate::model::image::ImageEffect, + ) -> Option { use crate::model::image::ImageEffect; let (id, def) = match effect { ImageEffect::RealPic => return None, @@ -1167,11 +1343,23 @@ impl SvgRenderer { ImageFillMode::CenterTop => (bbox.x + (bbox.width - img_width) / 2.0, bbox.y), ImageFillMode::RightTop => (bbox.x + bbox.width - img_width, bbox.y), ImageFillMode::LeftCenter => (bbox.x, bbox.y + (bbox.height - img_height) / 2.0), - ImageFillMode::Center => (bbox.x + (bbox.width - img_width) / 2.0, bbox.y + (bbox.height - img_height) / 2.0), - ImageFillMode::RightCenter => (bbox.x + bbox.width - img_width, bbox.y + (bbox.height - img_height) / 2.0), + ImageFillMode::Center => ( + bbox.x + (bbox.width - img_width) / 2.0, + bbox.y + (bbox.height - img_height) / 2.0, + ), + ImageFillMode::RightCenter => ( + bbox.x + bbox.width - img_width, + bbox.y + (bbox.height - img_height) / 2.0, + ), ImageFillMode::LeftBottom => (bbox.x, bbox.y + bbox.height - img_height), - ImageFillMode::CenterBottom => (bbox.x + (bbox.width - img_width) / 2.0, bbox.y + bbox.height - img_height), - ImageFillMode::RightBottom => (bbox.x + bbox.width - img_width, bbox.y + bbox.height - img_height), + ImageFillMode::CenterBottom => ( + bbox.x + (bbox.width - img_width) / 2.0, + bbox.y + bbox.height - img_height, + ), + ImageFillMode::RightBottom => ( + bbox.x + bbox.width - img_width, + bbox.y + bbox.height - img_height, + ), _ => (bbox.x, bbox.y), }; @@ -1247,10 +1435,20 @@ impl SvgRenderer { /// border_type=0이고 PUA 겹침 숫자이면 원형(circle)으로 자동 렌더링. /// 한컴 방식: 장평 조절로 좁은 숫자를 하나의 도형 안에 배치. fn draw_char_overlap( - &mut self, text: &str, style: &TextStyle, overlap: &CharOverlapInfo, - bbox_x: f64, bbox_y: f64, bbox_w: f64, bbox_h: f64, + &mut self, + text: &str, + style: &TextStyle, + overlap: &CharOverlapInfo, + bbox_x: f64, + bbox_y: f64, + bbox_w: f64, + bbox_h: f64, ) { - let font_size = if style.font_size > 0.0 { style.font_size } else { 12.0 }; + let font_size = if style.font_size > 0.0 { + style.font_size + } else { + 12.0 + }; let chars: Vec = text.chars().collect(); if chars.is_empty() { return; @@ -1258,13 +1456,25 @@ impl SvgRenderer { // PUA 다자리 숫자 디코딩 시도 if let Some(number_str) = decode_pua_overlap_number(&chars) { - self.draw_char_overlap_combined(style, overlap, &number_str, bbox_x, bbox_y, bbox_w, bbox_h); + self.draw_char_overlap_combined( + style, + overlap, + &number_str, + bbox_x, + bbox_y, + bbox_w, + bbox_h, + ); return; } // 기존 단일 문자 처리 let box_size = font_size; - let char_advance = if chars.len() > 1 { bbox_w / chars.len() as f64 } else { box_size }; + let char_advance = if chars.len() > 1 { + bbox_w / chars.len() as f64 + } else { + box_size + }; let is_reversed = overlap.border_type == 2 || overlap.border_type == 4; let is_circle = overlap.border_type == 1 || overlap.border_type == 2; @@ -1279,16 +1489,24 @@ impl SvgRenderer { let fill_color = if is_reversed { "#000000" } else { "none" }; let stroke_color = "#000000"; - let text_color = if is_reversed { "#FFFFFF" } else { &color_to_svg(style.color) }; - - let font_family_str = if style.font_family.is_empty() { - "sans-serif".to_string() + let text_color = if is_reversed { + "#FFFFFF" } else { - format!("{},sans-serif", style.font_family) + &color_to_svg(style.color) }; - let mut font_attrs = format!("font-family=\"{}\" font-size=\"{:.2}\"", escape_xml(&font_family_str), inner_font_size); - if style.bold { font_attrs.push_str(" font-weight=\"bold\""); } - if style.italic { font_attrs.push_str(" font-style=\"italic\""); } + + let font_family_str = Self::font_family_with_svg_fallbacks(&style.font_family); + let mut font_attrs = format!( + "font-family=\"{}\" font-size=\"{:.2}\"", + escape_xml(&font_family_str), + inner_font_size + ); + if style.bold { + font_attrs.push_str(" font-weight=\"bold\""); + } + if style.italic { + font_attrs.push_str(" font-style=\"italic\""); + } for (i, ch) in chars.iter().enumerate() { let display_str = { @@ -1332,14 +1550,28 @@ impl SvgRenderer { /// border_type=0이면 원형으로 자동 렌더링 (PUA 겹침 숫자는 원래 원문자) /// 장평 조절: textLength 속성으로 숫자 문자열을 도형 내부 폭에 맞춤 fn draw_char_overlap_combined( - &mut self, style: &TextStyle, overlap: &CharOverlapInfo, - number_str: &str, bbox_x: f64, bbox_y: f64, bbox_w: f64, bbox_h: f64, + &mut self, + style: &TextStyle, + overlap: &CharOverlapInfo, + number_str: &str, + bbox_x: f64, + bbox_y: f64, + bbox_w: f64, + bbox_h: f64, ) { - let font_size = if style.font_size > 0.0 { style.font_size } else { 12.0 }; + let font_size = if style.font_size > 0.0 { + style.font_size + } else { + 12.0 + }; let box_size = font_size; // border_type=0이고 PUA 숫자이면 원형으로 자동 렌더링 - let effective_border = if overlap.border_type == 0 { 1u8 } else { overlap.border_type }; + let effective_border = if overlap.border_type == 0 { + 1u8 + } else { + overlap.border_type + }; let is_reversed = effective_border == 2 || effective_border == 4; let is_circle = effective_border == 1 || effective_border == 2; let is_rect = effective_border == 3 || effective_border == 4; @@ -1353,16 +1585,24 @@ impl SvgRenderer { let fill_color = if is_reversed { "#000000" } else { "none" }; let stroke_color = "#000000"; - let text_color = if is_reversed { "#FFFFFF" } else { &color_to_svg(style.color) }; - - let font_family_str = if style.font_family.is_empty() { - "sans-serif".to_string() + let text_color = if is_reversed { + "#FFFFFF" } else { - format!("{},sans-serif", style.font_family) + &color_to_svg(style.color) }; - let mut font_attrs = format!("font-family=\"{}\" font-size=\"{:.2}\"", escape_xml(&font_family_str), inner_font_size); - if style.bold { font_attrs.push_str(" font-weight=\"bold\""); } - if style.italic { font_attrs.push_str(" font-style=\"italic\""); } + + let font_family_str = Self::font_family_with_svg_fallbacks(&style.font_family); + let mut font_attrs = format!( + "font-family=\"{}\" font-size=\"{:.2}\"", + escape_xml(&font_family_str), + inner_font_size + ); + if style.bold { + font_attrs.push_str(" font-weight=\"bold\""); + } + if style.italic { + font_attrs.push_str(" font-style=\"italic\""); + } let cx = bbox_x + box_size / 2.0; let cy = bbox_y + bbox_h / 2.0; @@ -1385,7 +1625,7 @@ impl SvgRenderer { // 장평 조절: 숫자 자릿수에 따라 textLength로 폭 압축 let text_width = box_size * 0.7; // 도형 내부 여백 고려 - // 다자리 숫자는 baseline을 살짝 올려 시각적 중앙 맞춤 + // 다자리 숫자는 baseline을 살짝 올려 시각적 중앙 맞춤 let text_y = cy - font_size * 0.08; self.output.push_str(&format!( "{}\n", @@ -1447,12 +1687,20 @@ impl SvgRenderer { while cx < x2 { let next = (cx + wave_w).min(x2); let cy = if up { y1 - wave_h } else { y1 + wave_h }; - d.push_str(&format!(" Q{:.2},{:.2} {:.2},{:.2}", (cx + next) / 2.0, cy, next, y1)); + d.push_str(&format!( + " Q{:.2},{:.2} {:.2},{:.2}", + (cx + next) / 2.0, + cy, + next, + y1 + )); cx = next; up = !up; } self.output.push_str(&format!( - "\n", d, color)); + "\n", + d, color + )); } 12 => { // 이중물결선 @@ -1466,12 +1714,20 @@ impl SvgRenderer { while cx < x2 { let next = (cx + wave_w).min(x2); let cy = if up { wy - wave_h } else { wy + wave_h }; - d.push_str(&format!(" Q{:.2},{:.2} {:.2},{:.2}", (cx + next) / 2.0, cy, next, wy)); + d.push_str(&format!( + " Q{:.2},{:.2} {:.2},{:.2}", + (cx + next) / 2.0, + cy, + next, + wy + )); cx = next; up = !up; } self.output.push_str(&format!( - "\n", d, color)); + "\n", + d, color + )); } } _ => { @@ -1484,7 +1740,7 @@ impl SvgRenderer { 4 => " stroke-dasharray=\"6 2 1 2 1 2\"", 5 => " stroke-dasharray=\"8 4\"", 6 => " stroke-dasharray=\"0.1 2.5\" stroke-linecap=\"round\"", - _ => "", // 0=실선 + _ => "", // 0=실선 }; self.output.push_str(&format!( "\n", @@ -1554,7 +1810,10 @@ impl SvgRenderer { if form.value != 0 { self.output.push_str(&format!( "\n", - cx, cy, r * 0.5)); + cx, + cy, + r * 0.5 + )); } // 캡션 if !form.caption.is_empty() { @@ -1581,9 +1840,13 @@ impl SvgRenderer { let arrow_size = (h * 0.2).min(4.0); self.output.push_str(&format!( "\n", - arrow_cx - arrow_size, arrow_cy - arrow_size * 0.5, - arrow_cx + arrow_size, arrow_cy - arrow_size * 0.5, - arrow_cx, arrow_cy + arrow_size * 0.5)); + arrow_cx - arrow_size, + arrow_cy - arrow_size * 0.5, + arrow_cx + arrow_size, + arrow_cy - arrow_size * 0.5, + arrow_cx, + arrow_cy + arrow_size * 0.5 + )); // 텍스트 if !form.text.is_empty() { let font_size = (h * 0.55).min(12.0).max(7.0); @@ -1608,10 +1871,13 @@ impl SvgRenderer { } /// 디버그 오버레이: 문단/표 경계와 인덱스 라벨을 렌더링 fn render_debug_overlay(&mut self) { - self.output.push_str("\n"); + self.output + .push_str("\n"); // 색상 팔레트: 문단별 교대 색상 - let colors = ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F"]; + let colors = [ + "#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F", + ]; // 문단 경계 렌더링 let mut sorted_paras: Vec<_> = self.overlay_para_bounds.iter().collect(); @@ -1631,7 +1897,10 @@ impl SvgRenderer { let label_w = label.len() as f64 * 5.0 + 4.0; self.output.push_str(&format!( "\n", - bounds.x, bounds.y - 10.0, label_w, color, + bounds.x, + bounds.y - 10.0, + label_w, + color, )); self.output.push_str(&format!( "{}\n", @@ -1648,12 +1917,22 @@ impl SvgRenderer { tbl.x, tbl.y, tbl.width, tbl.height, )); // 표 라벨 (우측 상단) - let label = format!("s{}:pi={} ci={} {}x{} y={:.1}", tbl.section_index, tbl.para_index, tbl.control_index, tbl.row_count, tbl.col_count, tbl.y); + let label = format!( + "s{}:pi={} ci={} {}x{} y={:.1}", + tbl.section_index, + tbl.para_index, + tbl.control_index, + tbl.row_count, + tbl.col_count, + tbl.y + ); let label_w = label.len() as f64 * 5.0 + 4.0; let label_x = (tbl.x + tbl.width - label_w).max(tbl.x); self.output.push_str(&format!( "\n", - label_x, tbl.y - 11.0, label_w, + label_x, + tbl.y - 11.0, + label_w, )); self.output.push_str(&format!( "{}\n", @@ -1664,6 +1943,26 @@ impl SvgRenderer { self.output.push_str("\n"); } + fn font_family_with_svg_fallbacks(font_family: &str) -> String { + if font_family.is_empty() { + return "sans-serif".to_string(); + } + let primary = Self::quote_svg_font_family(font_family); + let fallback = super::generic_fallback(font_family); + format!("{},{}", primary, fallback) + } + + fn quote_svg_font_family(font_family: &str) -> String { + match font_family { + "serif" | "sans-serif" | "monospace" | "cursive" | "fantasy" | "system-ui" => { + font_family.to_string() + } + _ => format!( + "'{}'", + font_family.replace('\\', "\\\\").replace('\'', "\\'") + ), + } + } } impl Renderer for SvgRenderer { @@ -1704,13 +2003,12 @@ impl Renderer for SvgRenderer { fn draw_text(&mut self, text: &str, x: f64, y: f64, style: &TextStyle) { let color = color_to_svg(style.color); - let font_size = if style.font_size > 0.0 { style.font_size } else { 12.0 }; - let font_family = if style.font_family.is_empty() { - "sans-serif".to_string() + let font_size = if style.font_size > 0.0 { + style.font_size } else { - let fb = super::generic_fallback(&style.font_family); - format!("{},{}", style.font_family, fb) + 12.0 }; + let font_family = Self::font_family_with_svg_fallbacks(&style.font_family); let ratio = if style.ratio > 0.0 { style.ratio } else { 1.0 }; let has_ratio = (ratio - 1.0).abs() > 0.01; @@ -1718,7 +2016,8 @@ impl Renderer for SvgRenderer { // 공통 스타일 속성 구성 (fill 제외 — 그림자/원본에서 각각 설정) let mut base_attrs = format!( "font-family=\"{}\" font-size=\"{}\"", - escape_xml(&font_family), font_size, + escape_xml(&font_family), + font_size, ); if style.bold { base_attrs.push_str(" font-weight=\"bold\""); @@ -1738,18 +2037,27 @@ impl Renderer for SvgRenderer { let dx = style.shadow_offset_x; let dy = style.shadow_offset_y; for (char_idx, cluster_str) in &clusters { - if cluster_str == " " || cluster_str == "\t" { continue; } + if cluster_str == " " || cluster_str == "\t" { + continue; + } let char_x = x + char_positions[*char_idx] + dx; let char_y = y + dy; if has_ratio { self.output.push_str(&format!( "{}\n", - char_x, char_y, ratio, shadow_attrs, escape_xml(cluster_str), + char_x, + char_y, + ratio, + shadow_attrs, + escape_xml(cluster_str), )); } else { self.output.push_str(&format!( "{}\n", - char_x, char_y, shadow_attrs, escape_xml(cluster_str), + char_x, + char_y, + shadow_attrs, + escape_xml(cluster_str), )); } } @@ -1758,18 +2066,27 @@ impl Renderer for SvgRenderer { // 원본 텍스트 렌더링 let common_attrs = format!("{} fill=\"{}\"", base_attrs, color); for (char_idx, cluster_str) in &clusters { - if cluster_str == " " || cluster_str == "\t" { continue; } + if cluster_str == " " || cluster_str == "\t" { + continue; + } let char_x = x + char_positions[*char_idx]; if has_ratio { self.output.push_str(&format!( "{}\n", - char_x, y, ratio, common_attrs, escape_xml(cluster_str), + char_x, + y, + ratio, + common_attrs, + escape_xml(cluster_str), )); } else { self.output.push_str(&format!( "{}\n", - char_x, y, common_attrs, escape_xml(cluster_str), + char_x, + y, + common_attrs, + escape_xml(cluster_str), )); } } @@ -1786,7 +2103,14 @@ impl Renderer for SvgRenderer { UnderlineType::Top => y - font_size + 1.0, _ => y + 2.0, }; - self.draw_line_shape(x, ul_y, x + text_width, ul_y, &ul_color, style.underline_shape); + self.draw_line_shape( + x, + ul_y, + x + text_width, + ul_y, + &ul_color, + style.underline_shape, + ); } // 취소선 처리 @@ -1798,13 +2122,26 @@ impl Renderer for SvgRenderer { } else { color.to_string() }; - self.draw_line_shape(x, strike_y, x + text_width, strike_y, &st_color, style.strike_shape); + self.draw_line_shape( + x, + strike_y, + x + text_width, + strike_y, + &st_color, + style.strike_shape, + ); } // 강조점 처리 if style.emphasis_dot > 0 { let dot_char = match style.emphasis_dot { - 1 => "●", 2 => "○", 3 => "ˇ", 4 => "˜", 5 => "・", 6 => "˸", _ => "", + 1 => "●", + 2 => "○", + 3 => "ˇ", + 4 => "˜", + 5 => "・", + 6 => "˸", + _ => "", }; if !dot_char.is_empty() { let dot_size = font_size * 0.3; @@ -1821,13 +2158,15 @@ impl Renderer for SvgRenderer { // 탭 리더(채움 기호) 렌더링 for leader in &style.tab_leaders { - if leader.fill_type == 0 { continue; } + if leader.fill_type == 0 { + continue; + } let lx1 = x + leader.start_x; let lx2 = x + leader.end_x; let ly = y - font_size * 0.35; // 글자 세로 중앙 (베이스라인에서 x-height 절반) - // 채울 모양 12종: 0=없음, 1=실선, 2=파선, 3=점선, 4=일점쇄선, - // 5=이점쇄선, 6=긴파선, 7=원형점선, 8=이중실선, - // 9=얇고굵은이중선, 10=굵고얇은이중선, 11=얇고굵고얇은삼중선 + // 채울 모양 12종: 0=없음, 1=실선, 2=파선, 3=점선, 4=일점쇄선, + // 5=이점쇄선, 6=긴파선, 7=원형점선, 8=이중실선, + // 9=얇고굵은이중선, 10=굵고얇은이중선, 11=얇고굵고얇은삼중선 match leader.fill_type { 1 => { // 실선 @@ -1927,7 +2266,15 @@ impl Renderer for SvgRenderer { } } - fn draw_rect(&mut self, x: f64, y: f64, w: f64, h: f64, corner_radius: f64, style: &ShapeStyle) { + fn draw_rect( + &mut self, + x: f64, + y: f64, + w: f64, + h: f64, + corner_radius: f64, + style: &ShapeStyle, + ) { self.draw_rect_with_gradient(x, y, w, h, corner_radius, style, None); } @@ -1937,10 +2284,10 @@ impl Renderer for SvgRenderer { // 이중선/삼중선 처리: 여러 평행선으로 렌더링 match style.line_type { - super::LineRenderType::Double | - super::LineRenderType::ThinThickDouble | - super::LineRenderType::ThickThinDouble | - super::LineRenderType::ThinThickThinTriple => { + super::LineRenderType::Double + | super::LineRenderType::ThinThickDouble + | super::LineRenderType::ThickThinDouble + | super::LineRenderType::ThinThickThinTriple => { self.draw_multi_line(x1, y1, x2, y2, width, &color, &style.line_type); return; } @@ -1961,12 +2308,19 @@ impl Renderer for SvgRenderer { let mut marker_end_attr = String::new(); if line_len > 0.0 { - let ux = dx / line_len; // 단위 벡터 + let ux = dx / line_len; // 단위 벡터 let uy = dy / line_len; if style.start_arrow != super::ArrowStyle::None { let (arrow_w, _) = Self::calc_arrow_dims(width, line_len, style.start_arrow_size); - let marker_id = self.ensure_arrow_marker(&color, width, line_len, &style.start_arrow, style.start_arrow_size, true); + let marker_id = self.ensure_arrow_marker( + &color, + width, + line_len, + &style.start_arrow, + style.start_arrow_size, + true, + ); marker_start_attr = format!(" marker-start=\"url(#{})\"", marker_id); // 시작점을 화살표 길이만큼 전진 lx1 += ux * arrow_w; @@ -1974,7 +2328,14 @@ impl Renderer for SvgRenderer { } if style.end_arrow != super::ArrowStyle::None { let (arrow_w, _) = Self::calc_arrow_dims(width, line_len, style.end_arrow_size); - let marker_id = self.ensure_arrow_marker(&color, width, line_len, &style.end_arrow, style.end_arrow_size, false); + let marker_id = self.ensure_arrow_marker( + &color, + width, + line_len, + &style.end_arrow, + style.end_arrow_size, + false, + ); marker_end_attr = format!(" marker-end=\"url(#{})\"", marker_id); // 끝점을 화살표 길이만큼 후퇴 lx2 -= ux * arrow_w; @@ -2036,7 +2397,9 @@ fn escape_xml(s: &str) -> String { // XML 1.0 허용 문자: #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF] // 그 외(제어문자, U+FFFE, U+FFFF 등)는 제거 '\u{09}' | '\u{0A}' | '\u{0D}' => result.push(c), - '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}' => result.push(c), + '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}' => { + result.push(c) + } _ => {} // XML 무효 문자 제거 } } @@ -2071,11 +2434,15 @@ fn detect_image_mime_type(data: &[u8]) -> &'static str { return "image/bmp"; } // WMF: Placeable (D7 CD C6 9A) 또는 Standard (01 00 09 00) - if data.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) || data.starts_with(&[0x01, 0x00, 0x09, 0x00]) { + if data.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) + || data.starts_with(&[0x01, 0x00, 0x09, 0x00]) + { return "image/x-wmf"; } // TIFF: II or MM - if data.starts_with(&[0x49, 0x49, 0x2A, 0x00]) || data.starts_with(&[0x4D, 0x4D, 0x00, 0x2A]) { + if data.starts_with(&[0x49, 0x49, 0x2A, 0x00]) + || data.starts_with(&[0x4D, 0x4D, 0x00, 0x2A]) + { return "image/tiff"; } } @@ -2107,7 +2474,11 @@ fn parse_image_dimensions(data: &[u8]) -> Option<(u32, u32)> { let marker = data[i + 1]; // SOF0-SOF3 (0xC0-0xC3), SOF5-SOF7 (0xC5-0xC7), // SOF9-SOF11 (0xC9-0xCB), SOF13-SOF15 (0xCD-0xCF) - if (marker >= 0xC0 && marker <= 0xCF) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC { + if (marker >= 0xC0 && marker <= 0xCF) + && marker != 0xC4 + && marker != 0xC8 + && marker != 0xCC + { let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32; let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32; if w > 0 && h > 0 { @@ -2161,14 +2532,22 @@ fn font_local_aliases(font_family: &str) -> Vec<&'static str> { /// 폰트명 → 알려진 파일명 매핑 (HWP/한컴/MS 폰트) fn known_font_filenames(font_name: &str) -> Vec<&'static str> { match font_name { - "함초롬바탕" | "함초롱바탕" | "한컴바탕" => vec!["hamchob-r.ttf", "HBATANG.TTF"], - "함초롬돋움" | "함초롱돋움" | "한컴돋움" => vec!["hamchod-r.ttf", "HDOTUM.TTF"], + "함초롬바탕" | "함초롱바탕" | "한컴바탕" => { + vec!["hamchob-r.ttf", "HBATANG.TTF"] + } + "함초롬돋움" | "함초롱돋움" | "한컴돋움" => { + vec!["hamchod-r.ttf", "HDOTUM.TTF"] + } "HY헤드라인M" | "HYHeadLine M" => vec!["H2HDRM.TTF"], "HY견고딕" | "HYGothic-Extra" => vec!["HYGTRE.TTF"], "HY그래픽" | "HYGraphic-Medium" => vec!["HYGPRM.TTF"], "HY견명조" | "HYMyeongJo-Extra" => vec!["HYMJRE.TTF"], "HY신명조" => vec!["HYSNMJ.TTF", "hamchob-r.ttf"], - "Latin Modern Math" => vec!["latinmodern-math.otf", "LatinModernMath-Regular.otf", "lmmath-regular.otf"], + "Latin Modern Math" => vec![ + "latinmodern-math.otf", + "LatinModernMath-Regular.otf", + "lmmath-regular.otf", + ], "맑은 고딕" | "Malgun Gothic" => vec!["malgun.ttf", "MalgunGothic.ttf"], "바탕" | "Batang" => vec!["batang.ttc", "BATANG.TTC", "hamchob-r.ttf"], "돋움" | "Dotum" => vec!["dotum.ttc", "DOTUM.TTC", "hamchod-r.ttf"], @@ -2177,19 +2556,27 @@ fn known_font_filenames(font_name: &str) -> Vec<&'static str> { "굴림체" | "GulimChe" => vec!["gulim.ttc", "hamchod-r.ttf"], "바탕체" | "BatangChe" => vec!["batang.ttc", "hamchob-r.ttf"], "휴먼명조" => vec!["HYMJRE.TTF", "hamchob-r.ttf"], - "새바탕" | "새돋움" | "새굴림" | "새궁서" => vec!["hamchob-r.ttf", "hamchod-r.ttf"], + "새바탕" | "새돋움" | "새굴림" | "새궁서" => { + vec!["hamchob-r.ttf", "hamchod-r.ttf"] + } _ => vec![], } } /// 폰트명으로 TTF/OTF 파일을 탐색한다. #[cfg(not(target_arch = "wasm32"))] -fn find_font_file(font_name: &str, extra_paths: &[std::path::PathBuf]) -> Option { +fn find_font_file( + font_name: &str, + extra_paths: &[std::path::PathBuf], +) -> Option { use std::path::Path; // 폰트명 → 파일명 후보 생성 let candidates: Vec = { - let mut files: Vec = known_font_filenames(font_name).iter().map(|s| s.to_string()).collect(); + let mut files: Vec = known_font_filenames(font_name) + .iter() + .map(|s| s.to_string()) + .collect(); let aliases = font_local_aliases(font_name); let mut names = vec![font_name.to_string()]; for a in &aliases { @@ -2237,7 +2624,9 @@ fn find_font_file(font_name: &str, extra_paths: &[std::path::PathBuf]) -> Option } for dir in &search_dirs { - if !dir.exists() { continue; } + if !dir.exists() { + continue; + } for candidate in &candidates { let path = dir.join(candidate); if path.exists() { @@ -2250,10 +2639,7 @@ fn find_font_file(font_name: &str, extra_paths: &[std::path::PathBuf]) -> Option /// SvgRenderer의 수집된 폰트 정보를 기반으로 @font-face CSS를 생성한다. #[cfg(not(target_arch = "wasm32"))] -pub fn generate_font_style( - renderer: &SvgRenderer, - font_paths: &[std::path::PathBuf], -) -> String { +pub fn generate_font_style(renderer: &SvgRenderer, font_paths: &[std::path::PathBuf]) -> String { let codepoints = renderer.font_codepoints(); if codepoints.is_empty() { return String::new(); @@ -2268,7 +2654,8 @@ pub fn generate_font_style( let src = if aliases.is_empty() { format!("local(\"{}\")", font_name) } else { - aliases.iter() + aliases + .iter() .map(|a| format!("local(\"{}\")", a)) .collect::>() .join(", ") @@ -2297,18 +2684,26 @@ pub fn generate_font_style( // 서브셋 추출 match subsetter::subset(&font_data, 0, &remapper) { Ok(subset_data) => { - let b64 = base64::engine::general_purpose::STANDARD.encode(&subset_data); + let b64 = + base64::engine::general_purpose::STANDARD.encode(&subset_data); css.push_str(&format!( "@font-face {{ font-family: \"{}\"; src: url(\"data:font/opentype;base64,{}\") format(\"opentype\"); }}\n", font_name, b64, )); - eprintln!(" [font-embed] {} → 서브셋 {:.1}KB ({}글자, 원본 {:.1}KB)", - font_name, subset_data.len() as f64 / 1024.0, - chars.len(), font_data.len() as f64 / 1024.0); + eprintln!( + " [font-embed] {} → 서브셋 {:.1}KB ({}글자, 원본 {:.1}KB)", + font_name, + subset_data.len() as f64 / 1024.0, + chars.len(), + font_data.len() as f64 / 1024.0 + ); continue; } Err(e) => { - eprintln!(" [font-embed] {} 서브셋 실패: {} → local() 폴백", font_name, e); + eprintln!( + " [font-embed] {} 서브셋 실패: {} → local() 폴백", + font_name, e + ); } } } @@ -2318,7 +2713,11 @@ pub fn generate_font_style( let src = if aliases.is_empty() { format!("local(\"{}\")", font_name) } else { - aliases.iter().map(|a| format!("local(\"{}\")", a)).collect::>().join(", ") + aliases + .iter() + .map(|a| format!("local(\"{}\")", a)) + .collect::>() + .join(", ") }; css.push_str(&format!( "@font-face {{ font-family: \"{}\"; src: {}; }}\n", @@ -2335,7 +2734,11 @@ pub fn generate_font_style( "@font-face {{ font-family: \"{}\"; src: url(\"data:font/opentype;base64,{}\") format(\"opentype\"); }}\n", font_name, b64, )); - eprintln!(" [font-embed] {} → 전체 {:.1}KB", font_name, font_data.len() as f64 / 1024.0); + eprintln!( + " [font-embed] {} → 전체 {:.1}KB", + font_name, + font_data.len() as f64 / 1024.0 + ); continue; } } @@ -2344,7 +2747,11 @@ pub fn generate_font_style( let src = if aliases.is_empty() { format!("local(\"{}\")", font_name) } else { - aliases.iter().map(|a| format!("local(\"{}\")", a)).collect::>().join(", ") + aliases + .iter() + .map(|a| format!("local(\"{}\")", a)) + .collect::>() + .join(", ") }; css.push_str(&format!( "@font-face {{ font-family: \"{}\"; src: {}; }}\n", diff --git a/src/renderer/svg/tests.rs b/src/renderer/svg/tests.rs index 932369ae..e7f1ceda 100644 --- a/src/renderer/svg/tests.rs +++ b/src/renderer/svg/tests.rs @@ -15,11 +15,16 @@ fn test_svg_begin_end_page() { fn test_svg_draw_text() { let mut renderer = SvgRenderer::new(); renderer.begin_page(800.0, 600.0); - renderer.draw_text("안녕하세요", 10.0, 20.0, &TextStyle { - font_size: 16.0, - bold: true, - ..Default::default() - }); + renderer.draw_text( + "안녕하세요", + 10.0, + 20.0, + &TextStyle { + font_size: 16.0, + bold: true, + ..Default::default() + }, + ); let output = renderer.output(); assert!(output.contains(" 요소로 출력 let underline_count = output.matches("y1=\"22\"").count(); // y + 2.0 assert!(underline_count > 0, "밑줄 요소가 있어야 함"); // 취소선: 요소로 출력 - let strike_count = output.matches("stroke=\"#000000\" stroke-width=\"1\"").count(); + let strike_count = output + .matches("stroke=\"#000000\" stroke-width=\"1\"") + .count(); assert!(strike_count >= 2, "취소선과 밑줄 요소가 있어야 함"); } @@ -85,11 +109,16 @@ fn test_svg_text_ratio() { let mut renderer = SvgRenderer::new(); renderer.begin_page(800.0, 600.0); // ratio 80%: 문자별 transform 적용 - renderer.draw_text("장평", 50.0, 100.0, &TextStyle { - font_size: 16.0, - ratio: 0.8, - ..Default::default() - }); + renderer.draw_text( + "장평", + 50.0, + 100.0, + &TextStyle { + font_size: 16.0, + ratio: 0.8, + ..Default::default() + }, + ); let output = renderer.output(); // 첫 문자 '장': translate(50,100) scale(0.8000,1) assert!(output.contains("transform=\"translate(50,100) scale(0.8000,1)\"")); @@ -103,11 +132,16 @@ fn test_svg_text_ratio_default() { let mut renderer = SvgRenderer::new(); renderer.begin_page(800.0, 600.0); // ratio 100%: transform 미적용, 문자별 x좌표 - renderer.draw_text("기본", 50.0, 100.0, &TextStyle { - font_size: 16.0, - ratio: 1.0, - ..Default::default() - }); + renderer.draw_text( + "기본", + 50.0, + 100.0, + &TextStyle { + font_size: 16.0, + ratio: 1.0, + ..Default::default() + }, + ); let output = renderer.output(); assert!(!output.contains("transform=")); // 첫 문자는 x=50 @@ -146,4 +180,3 @@ fn test_color_to_svg() { assert_eq!(color_to_svg(0x000000FF), "#ff0000"); assert_eq!(color_to_svg(0x00FFFFFF), "#ffffff"); } - diff --git a/src/renderer/svg_layer.rs b/src/renderer/svg_layer.rs new file mode 100644 index 00000000..9a7d4f79 --- /dev/null +++ b/src/renderer/svg_layer.rs @@ -0,0 +1,292 @@ +use crate::paint::{ClipKind, GroupKind, LayerNode, LayerNodeKind, PageLayerTree, PaintOp}; + +use super::layer_renderer::LayerRenderer; +use super::render_tree::{ + BoundingBox, GroupNode, PageRenderTree, RenderNode, RenderNodeType, TableCellNode, +}; +use super::svg::SvgRenderer; + +/// PageLayerTree를 SVG로 재생하는 transition renderer. +/// +/// 1차 전환에서는 layer tree를 temporary render node tree로 다시 조립해 +/// 기존 SVG leaf/backend 로직을 그대로 재사용한다. +pub struct SvgLayerRenderer { + renderer: SvgRenderer, + next_generated_id: u32, +} + +impl SvgLayerRenderer { + pub fn new() -> Self { + Self { + renderer: SvgRenderer::new(), + next_generated_id: 1_000_000, + } + } + + pub fn output(&self) -> &str { + self.renderer.output() + } + + pub fn configure_output( + &mut self, + show_paragraph_marks: bool, + show_control_codes: bool, + debug_overlay: bool, + ) { + self.renderer.show_paragraph_marks = show_paragraph_marks; + self.renderer.show_control_codes = show_control_codes; + self.renderer.debug_overlay = debug_overlay; + } + + fn build_render_tree(&mut self, tree: &PageLayerTree) -> PageRenderTree { + let mut render_tree = PageRenderTree::new(0, tree.page_width, tree.page_height); + render_tree.root.bbox = tree.root.bounds; + render_tree.root.children = self.expand_children(&tree.root); + render_tree + } + + fn expand_children(&mut self, node: &LayerNode) -> Vec { + match &node.kind { + LayerNodeKind::Group { children, .. } => children + .iter() + .flat_map(|child| self.expand_node(child)) + .collect(), + LayerNodeKind::ClipRect { .. } | LayerNodeKind::Leaf { .. } => self.expand_node(node), + } + } + + fn expand_node(&mut self, node: &LayerNode) -> Vec { + match &node.kind { + LayerNodeKind::Group { + children, + group_kind, + .. + } => { + let mut render_node = RenderNode::new( + self.take_node_id(node.source_node_id), + self.group_kind_to_render_node_type(group_kind), + node.bounds, + ); + render_node.children = children + .iter() + .flat_map(|child| self.expand_node(child)) + .collect(); + vec![render_node] + } + LayerNodeKind::ClipRect { + clip, + child, + clip_kind, + } => { + let node_type = match clip_kind { + ClipKind::Body => RenderNodeType::Body { + clip_rect: Some(*clip), + }, + ClipKind::TableCell => match &child.kind { + LayerNodeKind::Group { + group_kind: GroupKind::TableCell(cell), + .. + } => { + let mut cell = cell.clone(); + cell.clip = true; + RenderNodeType::TableCell(cell) + } + _ => RenderNodeType::TableCell(TableCellNode { + col: 0, + row: 0, + col_span: 1, + row_span: 1, + border_fill_id: 0, + text_direction: 0, + clip: true, + model_cell_index: None, + }), + }, + ClipKind::Generic => RenderNodeType::Body { + clip_rect: Some(*clip), + }, + }; + + let mut render_node = RenderNode::new( + self.take_node_id(node.source_node_id), + node_type, + node.bounds, + ); + render_node.children = self.expand_children(child); + vec![render_node] + } + LayerNodeKind::Leaf { ops } => ops + .iter() + .map(|op| self.paint_op_to_render_node(op, node.source_node_id)) + .collect(), + } + } + + fn paint_op_to_render_node(&mut self, op: &PaintOp, source_node_id: Option) -> RenderNode { + match op { + PaintOp::PageBackground { bbox, background } => RenderNode::new( + self.take_node_id(source_node_id), + RenderNodeType::PageBackground(background.clone()), + *bbox, + ), + PaintOp::TextRun { bbox, run } => RenderNode::new( + self.take_node_id(source_node_id), + RenderNodeType::TextRun(run.clone()), + *bbox, + ), + PaintOp::FootnoteMarker { bbox, marker } => RenderNode::new( + self.take_node_id(source_node_id), + RenderNodeType::FootnoteMarker(marker.clone()), + *bbox, + ), + PaintOp::Line { bbox, line } => RenderNode::new( + self.take_node_id(source_node_id), + RenderNodeType::Line(line.clone()), + *bbox, + ), + PaintOp::Rectangle { bbox, rect } => RenderNode::new( + self.take_node_id(source_node_id), + RenderNodeType::Rectangle(rect.clone()), + *bbox, + ), + PaintOp::Ellipse { bbox, ellipse } => RenderNode::new( + self.take_node_id(source_node_id), + RenderNodeType::Ellipse(ellipse.clone()), + *bbox, + ), + PaintOp::Path { bbox, path } => RenderNode::new( + self.take_node_id(source_node_id), + RenderNodeType::Path(path.clone()), + *bbox, + ), + PaintOp::Image { bbox, image } => RenderNode::new( + self.take_node_id(source_node_id), + RenderNodeType::Image(image.clone()), + *bbox, + ), + PaintOp::Equation { bbox, equation } => RenderNode::new( + self.take_node_id(source_node_id), + RenderNodeType::Equation(equation.clone()), + *bbox, + ), + PaintOp::FormObject { bbox, form } => RenderNode::new( + self.take_node_id(source_node_id), + RenderNodeType::FormObject(form.clone()), + *bbox, + ), + } + } + + fn group_kind_to_render_node_type(&self, group_kind: &GroupKind) -> RenderNodeType { + match group_kind { + GroupKind::Generic => RenderNodeType::Group(GroupNode { + section_index: None, + para_index: None, + control_index: None, + }), + GroupKind::MasterPage => RenderNodeType::MasterPage, + GroupKind::Header => RenderNodeType::Header, + GroupKind::Footer => RenderNodeType::Footer, + GroupKind::Body => RenderNodeType::Body { clip_rect: None }, + GroupKind::Column(index) => RenderNodeType::Column(*index), + GroupKind::FootnoteArea => RenderNodeType::FootnoteArea, + GroupKind::TextLine(line) => RenderNodeType::TextLine(line.clone()), + GroupKind::Table(table) => RenderNodeType::Table(table.clone()), + GroupKind::TableCell(cell) => RenderNodeType::TableCell(cell.clone()), + GroupKind::TextBox => RenderNodeType::TextBox, + GroupKind::Group(group) => RenderNodeType::Group(group.clone()), + } + } + + fn take_node_id(&mut self, source_node_id: Option) -> u32 { + if let Some(id) = source_node_id { + return id; + } + let id = self.next_generated_id; + self.next_generated_id += 1; + id + } +} + +impl LayerRenderer for SvgLayerRenderer { + fn render_page(&mut self, tree: &PageLayerTree) { + let render_tree = self.build_render_tree(tree); + self.renderer.render_tree(&render_tree); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::paint::{LayerBuilder, RenderProfile}; + use crate::renderer::render_tree::{PageNode, RectangleNode, TextRunNode}; + use crate::renderer::svg::SvgRenderer; + use crate::renderer::{ShapeStyle, TextStyle}; + + #[test] + fn replays_basic_layer_tree_to_same_svg() { + let mut render_tree = PageRenderTree::new(0, 400.0, 300.0); + render_tree.root.node_type = RenderNodeType::Page(PageNode { + page_index: 0, + width: 400.0, + height: 300.0, + section_index: 0, + }); + let mut line = RenderNode::new( + 10, + RenderNodeType::TextLine(crate::renderer::render_tree::TextLineNode::new(20.0, 15.0)), + BoundingBox::new(20.0, 20.0, 120.0, 20.0), + ); + line.children.push(RenderNode::new( + 11, + RenderNodeType::TextRun(TextRunNode { + text: "레이어".to_string(), + style: TextStyle { + font_family: "Noto Sans CJK KR".to_string(), + font_size: 14.0, + ..Default::default() + }, + char_shape_id: None, + para_shape_id: None, + section_index: None, + para_index: None, + char_start: None, + cell_context: None, + is_para_end: false, + is_line_break_end: false, + rotation: 0.0, + is_vertical: false, + char_overlap: None, + border_fill_id: 0, + baseline: 15.0, + field_marker: Default::default(), + }), + BoundingBox::new(20.0, 20.0, 60.0, 20.0), + )); + render_tree.root.children.push(line); + render_tree.root.children.push(RenderNode::new( + 12, + RenderNodeType::Rectangle(RectangleNode::new( + 0.0, + ShapeStyle { + fill_color: Some(0x00F0F0F0), + stroke_color: Some(0x00000000), + stroke_width: 1.0, + ..Default::default() + }, + None, + )), + BoundingBox::new(18.0, 18.0, 90.0, 28.0), + )); + + let mut legacy = SvgRenderer::new(); + legacy.render_tree(&render_tree); + + let mut builder = LayerBuilder::new(RenderProfile::Screen); + let layer_tree = builder.build(&render_tree); + let mut layer = SvgLayerRenderer::new(); + layer.render_page(&layer_tree); + + assert_eq!(layer.output(), legacy.output()); + } +} diff --git a/src/renderer/typeset.rs b/src/renderer/typeset.rs index 603aa878..f3329137 100644 --- a/src/renderer/typeset.rs +++ b/src/renderer/typeset.rs @@ -8,20 +8,20 @@ //! Chromium LayoutNG의 Break Token 패턴, LibreOffice Writer의 Master/Follow Chain, //! MS Word/OOXML의 cantSplit/tblHeader를 참고. +use super::pagination::{ + ColumnContent, FootnoteRef, FootnoteSource, HeaderFooterRef, PageContent, PageItem, + PaginationResult, +}; use crate::model::control::Control; -use crate::model::shape::CaptionDirection; use crate::model::header_footer::HeaderFooterApply; -use crate::model::paragraph::{Paragraph, ColumnBreakType}; -use crate::model::page::{PageDef, ColumnDef}; +use crate::model::page::{ColumnDef, PageDef}; +use crate::model::paragraph::{ColumnBreakType, Paragraph}; +use crate::model::shape::CaptionDirection; use crate::renderer::composer::ComposedParagraph; use crate::renderer::height_measurer::MeasuredTable; use crate::renderer::page_layout::PageLayoutInfo; use crate::renderer::style_resolver::ResolvedStyleSet; use crate::renderer::{hwpunit_to_px, DEFAULT_DPI}; -use super::pagination::{ - PaginationResult, PageContent, ColumnContent, PageItem, - HeaderFooterRef, FootnoteRef, FootnoteSource, -}; // ======================================================== // Break Token — 조판 분할 지점 (Chromium LayoutNG 참고) @@ -289,7 +289,8 @@ impl FormattedParagraph { /// 줄 범위의 advance 합계 fn line_advances_sum(&self, range: std::ops::Range) -> f64 { - range.into_iter() + range + .into_iter() .map(|i| self.line_heights[i] + self.line_spacings[i]) .sum() } @@ -324,8 +325,11 @@ impl TypesetEngine { let footnote_safety_margin = hwpunit_to_px(3000, self.dpi); let mut st = TypesetState::new( - layout, col_count, section_index, - footnote_separator_overhead, footnote_safety_margin, + layout, + col_count, + section_index, + footnote_separator_overhead, + footnote_safety_margin, ); // 머리말/꼬리말/쪽 번호/새 번호 컨트롤 수집 @@ -365,8 +369,13 @@ impl TypesetEngine { } else { // 표 문단: Phase 2에서 전환 예정. 현재는 기존 방식 호환용 stub. self.typeset_table_paragraph( - &mut st, para_idx, para, composed.get(para_idx), - styles, measured_tables, page_def, + &mut st, + para_idx, + para, + composed.get(para_idx), + styles, + measured_tables, + page_def, ); } @@ -409,11 +418,18 @@ impl TypesetEngine { // 페이지 번호 + 머리말/꼬리말 할당 Self::finalize_pages( - &mut st.pages, &hf_entries, &page_number_pos, - &new_page_numbers, section_index, + &mut st.pages, + &hf_entries, + &page_number_pos, + &new_page_numbers, + section_index, ); - PaginationResult { pages: st.pages, wrap_around_paras: Vec::new(), hidden_empty_paras: std::collections::HashSet::new() } + PaginationResult { + pages: st.pages, + wrap_around_paras: Vec::new(), + hidden_empty_paras: std::collections::HashSet::new(), + } } // ======================================================== @@ -434,16 +450,22 @@ impl TypesetEngine { let spacing_after = para_style.map(|s| s.spacing_after).unwrap_or(0.0); let ls_val = para_style.map(|s| s.line_spacing).unwrap_or(160.0); - let ls_type = para_style.map(|s| s.line_spacing_type) + let ls_type = para_style + .map(|s| s.line_spacing_type) .unwrap_or(crate::model::style::LineSpacingType::Percent); let (line_heights, line_spacings): (Vec, Vec) = if let Some(comp) = composed { - comp.lines.iter() + comp.lines + .iter() .map(|line| { let raw_lh = hwpunit_to_px(line.line_height, self.dpi); - let max_fs = line.runs.iter() + let max_fs = line + .runs + .iter() .map(|r| { - styles.char_styles.get(r.char_style_id as usize) + styles + .char_styles + .get(r.char_style_id as usize) .map(|cs| cs.font_size) .unwrap_or(0.0) }) @@ -451,10 +473,10 @@ impl TypesetEngine { let lh = if max_fs > 0.0 && raw_lh < max_fs { use crate::model::style::LineSpacingType; let computed = match ls_type { - LineSpacingType::Percent => max_fs * ls_val / 100.0, - LineSpacingType::Fixed => ls_val.max(max_fs), + LineSpacingType::Percent => max_fs * ls_val / 100.0, + LineSpacingType::Fixed => ls_val.max(max_fs), LineSpacingType::SpaceOnly => max_fs + ls_val, - LineSpacingType::Minimum => ls_val.max(max_fs), + LineSpacingType::Minimum => ls_val.max(max_fs), }; computed.max(max_fs) } else { @@ -464,17 +486,22 @@ impl TypesetEngine { }) .unzip() } else if !para.line_segs.is_empty() { - para.line_segs.iter() - .map(|seg| ( - hwpunit_to_px(seg.line_height, self.dpi), - hwpunit_to_px(seg.line_spacing, self.dpi), - )) + para.line_segs + .iter() + .map(|seg| { + ( + hwpunit_to_px(seg.line_height, self.dpi), + hwpunit_to_px(seg.line_spacing, self.dpi), + ) + }) .unzip() } else { (vec![hwpunit_to_px(400, self.dpi)], vec![0.0]) }; - let lines_total: f64 = line_heights.iter().zip(line_spacings.iter()) + let lines_total: f64 = line_heights + .iter() + .zip(line_spacings.iter()) .map(|(h, s)| h + s) .sum(); let total_height = spacing_before + lines_total + spacing_after; @@ -509,11 +536,12 @@ impl TypesetEngine { let available = st.available_height(); // 다단 레이아웃에서 문단 내 단 경계 감지 - let col_breaks = if st.col_count > 1 && st.current_column == 0 && st.on_first_multicolumn_page { - Self::detect_column_breaks_in_paragraph(para) - } else { - vec![0] - }; + let col_breaks = + if st.col_count > 1 && st.current_column == 0 && st.on_first_multicolumn_page { + Self::detect_column_breaks_in_paragraph(para) + } else { + vec![0] + }; if col_breaks.len() > 1 { self.typeset_multicolumn_paragraph(st, para_idx, para, fmt, &col_breaks); @@ -560,13 +588,21 @@ impl TypesetEngine { 0.0 }; let page_avail = if cursor_line == 0 { - (base_available - st.current_footnote_height - fn_margin - - st.current_height - st.current_zone_y_offset).max(0.0) + (base_available + - st.current_footnote_height + - fn_margin + - st.current_height + - st.current_zone_y_offset) + .max(0.0) } else { base_available }; - let sp_b = if cursor_line == 0 { fmt.spacing_before } else { 0.0 }; + let sp_b = if cursor_line == 0 { + fmt.spacing_before + } else { + 0.0 + }; let avail_for_lines = (page_avail - sp_b).max(0.0); // 현재 페이지에 들어갈 줄 범위 결정 @@ -586,7 +622,11 @@ impl TypesetEngine { } let part_line_height = fmt.line_advances_sum(cursor_line..end_line); - let part_sp_after = if end_line >= line_count { fmt.spacing_after } else { 0.0 }; + let part_sp_after = if end_line >= line_count { + fmt.spacing_after + } else { + 0.0 + }; let part_height = sp_b + part_line_height + part_sp_after; if cursor_line == 0 && end_line >= line_count { @@ -595,7 +635,11 @@ impl TypesetEngine { matches!(item, PageItem::Table { .. } | PageItem::PartialTable { .. }) }); let overflow_threshold = if prev_is_table { - let trailing_ls = fmt.line_spacings.get(end_line.saturating_sub(1)).copied().unwrap_or(0.0); + let trailing_ls = fmt + .line_spacings + .get(end_line.saturating_sub(1)) + .copied() + .unwrap_or(0.0); cumulative - trailing_ls } else { cumulative @@ -661,15 +705,16 @@ impl TypesetEngine { composed: Option<&ComposedParagraph>, is_column_top: bool, ) -> FormattedTable { - let mt = measured_tables.iter().find(|mt| - mt.para_index == para_idx && mt.control_index == ctrl_idx - ); + let mt = measured_tables + .iter() + .find(|mt| mt.para_index == para_idx && mt.control_index == ctrl_idx); let is_tac = table.attr & 0x01 != 0; let table_text_wrap = (table.attr >> 21) & 0x07; // host_spacing 계산 — layout과 동일한 규칙 - let para_style_id = composed.map(|c| c.para_style_id as usize) + let para_style_id = composed + .map(|c| c.para_style_id as usize) .unwrap_or(para.para_shape_id as usize); let para_style = styles.para_styles.get(para_style_id); let sb = para_style.map(|s| s.spacing_before).unwrap_or(0.0); @@ -688,7 +733,8 @@ impl TypesetEngine { // 비-TAC 표: 호스트 문단의 trailing line_spacing도 포함 let host_line_spacing = if !is_tac { - para.line_segs.last() + para.line_segs + .last() .filter(|seg| seg.line_spacing > 0) .map(|seg| hwpunit_to_px(seg.line_spacing, self.dpi)) .unwrap_or(0.0) @@ -706,11 +752,27 @@ impl TypesetEngine { (if !is_column_top { sb } else { 0.0 }) + outer_top }; let after = sa + outer_bottom + host_line_spacing; - let host_spacing = HostSpacing { before, after, spacing_after_only: sa }; + let host_spacing = HostSpacing { + before, + after, + spacing_after_only: sa, + }; - let (row_heights, cell_spacing, effective_height, caption_height, - cumulative_heights, page_break, cells, header_row_count) = if let Some(mt) = mt { - let hrc = if mt.repeat_header && mt.has_header_cells { 1 } else { 0 }; + let ( + row_heights, + cell_spacing, + effective_height, + caption_height, + cumulative_heights, + page_break, + cells, + header_row_count, + ) = if let Some(mt) = mt { + let hrc = if mt.repeat_header && mt.has_header_cells { + 1 + } else { + 0 + }; ( mt.row_heights.clone(), mt.cell_spacing, @@ -722,7 +784,16 @@ impl TypesetEngine { hrc, ) } else { - (Vec::new(), 0.0, 0.0, 0.0, vec![0.0], Default::default(), Vec::new(), 0) + ( + Vec::new(), + 0.0, + 0.0, + 0.0, + vec![0.0], + Default::default(), + Vec::new(), + 0, + ) }; let total_height = effective_height + host_spacing.before + host_spacing.after; @@ -778,12 +849,18 @@ impl TypesetEngine { let fmt = self.format_paragraph(para, composed, styles); // TAC 표 카운트 및 플러시 판단 - let tac_count = para.controls.iter() + let tac_count = para + .controls + .iter() .filter(|c| matches!(c, Control::Table(t) if t.attr & 0x01 != 0)) .count(); let has_tac = tac_count > 0; - let height_for_fit = if has_tac { fmt.height_for_fit } else { fmt.total_height }; + let height_for_fit = if has_tac { + fmt.height_for_fit + } else { + fmt.total_height + }; // 넘치면 flush (단일 TAC 표만) if st.current_height + height_for_fit > st.available_height() @@ -805,16 +882,27 @@ impl TypesetEngine { Control::Table(table) => { let is_column_top = st.current_height < 1.0; let ft = self.format_table( - para, para_idx, ctrl_idx, table, - measured_tables, styles, composed, is_column_top, + para, + para_idx, + ctrl_idx, + table, + measured_tables, + styles, + composed, + is_column_top, ); - let mt = measured_tables.iter().find(|mt| - mt.para_index == para_idx && mt.control_index == ctrl_idx); + let mt = measured_tables + .iter() + .find(|mt| mt.para_index == para_idx && mt.control_index == ctrl_idx); if ft.is_tac { - self.typeset_tac_table(st, para_idx, ctrl_idx, para, table, &ft, &fmt, tac_count); + self.typeset_tac_table( + st, para_idx, ctrl_idx, para, table, &ft, &fmt, tac_count, + ); } else { - self.typeset_block_table(st, para_idx, ctrl_idx, para, table, &ft, &fmt, mt); + self.typeset_block_table( + st, para_idx, ctrl_idx, para, table, &ft, &fmt, mt, + ); } // 표 셀 내 각주 수집 (Paginator engine.rs:679-701 동일) @@ -834,7 +922,8 @@ impl TypesetEngine { }, }); } - let fn_height = Self::estimate_footnote_height(fn_ctrl, self.dpi); + let fn_height = + Self::estimate_footnote_height(fn_ctrl, self.dpi); st.add_footnote_height(fn_height); } } @@ -862,7 +951,8 @@ impl TypesetEngine { if t.attr & 0x01 != 0 { if let Some(seg) = para.line_segs.get(tac_idx) { let seg_lh = hwpunit_to_px(seg.line_height, self.dpi); - let mt_h = measured_tables.iter() + let mt_h = measured_tables + .iter() .find(|mt| mt.para_index == para_idx && mt.control_index == ci) .map(|mt| mt.total_height) .unwrap_or(0.0); @@ -877,10 +967,13 @@ impl TypesetEngine { let cap = if tac_seg_total > 0.0 { let is_col_top = height_before < 1.0; let effective_sb = if is_col_top { 0.0 } else { fmt.spacing_before }; - let outer_top: f64 = para.controls.iter() + let outer_top: f64 = para + .controls + .iter() .filter_map(|c| match c { - Control::Table(t) if t.attr & 0x01 != 0 => - Some(hwpunit_to_px(t.outer_margin_top as i32, self.dpi)), + Control::Table(t) if t.attr & 0x01 != 0 => { + Some(hwpunit_to_px(t.outer_margin_top as i32, self.dpi)) + } _ => None, }) .sum(); @@ -908,18 +1001,24 @@ impl TypesetEngine { ) { // 다중 TAC 표: LINE_SEG 기반 개별 높이 계산 let table_height = if tac_count > 1 { - let tac_idx = para.controls.iter().take(ctrl_idx) + let tac_idx = para + .controls + .iter() + .take(ctrl_idx) .filter(|c| matches!(c, Control::Table(t) if t.attr & 0x01 != 0)) .count(); let is_last_tac = tac_idx + 1 == tac_count; - para.line_segs.get(tac_idx).map(|seg| { - let line_h = hwpunit_to_px(seg.line_height, self.dpi); - if is_last_tac { - line_h - } else { - line_h + hwpunit_to_px(seg.line_spacing, self.dpi) - } - }).unwrap_or(ft.total_height) + para.line_segs + .get(tac_idx) + .map(|seg| { + let line_h = hwpunit_to_px(seg.line_height, self.dpi); + if is_last_tac { + line_h + } else { + line_h + hwpunit_to_px(seg.line_spacing, self.dpi) + } + }) + .unwrap_or(ft.total_height) } else if fmt.total_height > 0.0 { // 단일 TAC: 호스트 문단의 height_for_fit 사용 fmt.height_for_fit @@ -956,7 +1055,10 @@ impl TypesetEngine { }; // pre-table 텍스트 (첫 번째 표에서만) - let is_first_table = !para.controls.iter().take(ctrl_idx) + let is_first_table = !para + .controls + .iter() + .take(ctrl_idx) .any(|c| matches!(c, Control::Table(_))); if pre_table_end_line > 0 && is_first_table { let pre_height: f64 = fmt.line_advances_sum(0..pre_table_end_line); @@ -976,9 +1078,14 @@ impl TypesetEngine { st.current_height += table_total_height; // post-table 텍스트 - let is_last_table = !para.controls.iter().skip(ctrl_idx + 1) + let is_last_table = !para + .controls + .iter() + .skip(ctrl_idx + 1) .any(|c| matches!(c, Control::Table(_))); - let tac_table_count = para.controls.iter() + let tac_table_count = para + .controls + .iter() .filter(|c| matches!(c, Control::Table(t) if t.attr & 0x01 != 0)) .count(); let post_table_start = if table.attr & 0x01 != 0 { @@ -988,7 +1095,10 @@ impl TypesetEngine { } else { pre_table_end_line }; - let should_add_post_text = is_last_table && tac_table_count <= 1 && !para.text.is_empty() && total_lines > post_table_start; + let should_add_post_text = is_last_table + && tac_table_count <= 1 + && !para.text.is_empty() + && total_lines > post_table_start; if should_add_post_text { let post_height: f64 = fmt.line_advances_sum(post_table_start..total_lines); st.current_items.push(PageItem::PartialParagraph { @@ -1029,8 +1139,14 @@ impl TypesetEngine { 0.0 }; let total_footnote = st.current_footnote_height + table_fn_h + fn_separator; - let fn_margin = if total_footnote > 0.0 { st.footnote_safety_margin } else { 0.0 }; - let available = (st.base_available_height() - total_footnote - fn_margin - st.current_zone_y_offset).max(0.0); + let fn_margin = if total_footnote > 0.0 { + st.footnote_safety_margin + } else { + 0.0 + }; + let available = + (st.base_available_height() - total_footnote - fn_margin - st.current_zone_y_offset) + .max(0.0); let host_spacing_total = ft.host_spacing.before + ft.host_spacing.after; let table_total = ft.effective_height + host_spacing_total; @@ -1080,24 +1196,41 @@ impl TypesetEngine { } // 캡션 처리 - let caption_is_top = para.controls.get(ctrl_idx).and_then(|c| { - if let Control::Table(t) = c { - t.caption.as_ref().map(|cap| - matches!(cap.direction, CaptionDirection::Top)) - } else { None } - }).unwrap_or(false); - - let host_line_spacing_for_caption = para.line_segs.first() + let caption_is_top = para + .controls + .get(ctrl_idx) + .and_then(|c| { + if let Control::Table(t) = c { + t.caption + .as_ref() + .map(|cap| matches!(cap.direction, CaptionDirection::Top)) + } else { + None + } + }) + .unwrap_or(false); + + let host_line_spacing_for_caption = para + .line_segs + .first() .map(|seg| hwpunit_to_px(seg.line_spacing, self.dpi)) .unwrap_or(0.0); let caption_base_overhead = { let ch = ft.caption_height; if ch > 0.0 { - let cs_val = para.controls.get(ctrl_idx).and_then(|c| { - if let Control::Table(t) = c { - t.caption.as_ref().map(|cap| hwpunit_to_px(cap.spacing as i32, self.dpi)) - } else { None } - }).unwrap_or(0.0); + let cs_val = para + .controls + .get(ctrl_idx) + .and_then(|c| { + if let Control::Table(t) = c { + t.caption + .as_ref() + .map(|cap| hwpunit_to_px(cap.spacing as i32, self.dpi)) + } else { + None + } + }) + .unwrap_or(0.0); ch + cs_val } else { 0.0 @@ -1116,7 +1249,8 @@ impl TypesetEngine { while cursor_row < row_count { // 이전 분할에서 모든 콘텐츠가 소진된 행은 건너뜀 - if content_offset > 0.0 && can_intra_split + if content_offset > 0.0 + && can_intra_split && mt.remaining_content_for_row(cursor_row, content_offset) <= 0.0 { cursor_row += 1; @@ -1124,22 +1258,24 @@ impl TypesetEngine { continue; } - let caption_extra = if !is_continuation && cursor_row == 0 && content_offset == 0.0 && caption_is_top { - caption_overhead - } else { - 0.0 - }; + let caption_extra = + if !is_continuation && cursor_row == 0 && content_offset == 0.0 && caption_is_top { + caption_overhead + } else { + 0.0 + }; let page_avail = if is_continuation { base_available } else { (table_available - st.current_height - caption_extra).max(0.0) }; - let header_overhead = if is_continuation && mt.repeat_header && mt.has_header_cells && row_count > 1 { - header_row_height + cs - } else { - 0.0 - }; + let header_overhead = + if is_continuation && mt.repeat_header && mt.has_header_cells && row_count > 1 { + header_row_height + cs + } else { + 0.0 + }; let avail_for_rows = (page_avail - header_overhead).max(0.0); let effective_first_row_h = if content_offset > 0.0 && can_intra_split { @@ -1155,7 +1291,8 @@ impl TypesetEngine { { const MIN_SPLIT_CONTENT_PX: f64 = 10.0; - let approx_end = mt.find_break_row(avail_for_rows, cursor_row, effective_first_row_h); + let approx_end = + mt.find_break_row(avail_for_rows, cursor_row, effective_first_row_h); if approx_end <= cursor_row { let r = cursor_row; @@ -1255,7 +1392,11 @@ impl TypesetEngine { let actual_split_end = split_end_limit; // 마지막 파트에 Bottom 캡션 공간 확보 - if end_row >= row_count && split_end_limit == 0.0 && !caption_is_top && caption_overhead > 0.0 { + if end_row >= row_count + && split_end_limit == 0.0 + && !caption_is_top + && caption_overhead > 0.0 + { let total_with_caption = partial_height + caption_overhead; let avail = if is_continuation { (page_avail - header_overhead).max(0.0) @@ -1272,7 +1413,11 @@ impl TypesetEngine { if end_row >= row_count && split_end_limit == 0.0 { // 나머지 전부가 현재 페이지에 들어감 - let bottom_caption_extra = if !caption_is_top { caption_overhead } else { 0.0 }; + let bottom_caption_extra = if !caption_is_top { + caption_overhead + } else { + 0.0 + }; if cursor_row == 0 && !is_continuation && content_offset == 0.0 { st.current_items.push(PageItem::Table { para_index: para_idx, @@ -1291,7 +1436,8 @@ impl TypesetEngine { }); // 마지막 fragment: spacing_after만 포함 (Paginator engine.rs:1051 동일) // host_line_spacing과 outer_bottom은 포함하지 않음 - st.current_height += partial_height + bottom_caption_extra + ft.host_spacing.spacing_after_only; + st.current_height += + partial_height + bottom_caption_extra + ft.host_spacing.spacing_after_only; } break; } @@ -1407,7 +1553,8 @@ impl TypesetEngine { let mut max_vpos_end: i32 = 0; for prev_idx in (0..para_idx).rev() { if let Some(last_seg) = paragraphs[prev_idx].line_segs.last() { - let vpos_end = last_seg.vertical_pos + last_seg.line_height + last_seg.line_spacing; + let vpos_end = + last_seg.vertical_pos + last_seg.line_height + last_seg.line_spacing; if vpos_end > max_vpos_end { max_vpos_end = vpos_end; } @@ -1506,7 +1653,9 @@ impl TypesetEngine { for page in pages.iter_mut() { // 새 번호 지정 확인 - let first_para = page.column_contents.first() + let first_para = page + .column_contents + .first() .and_then(|col| col.items.first()) .map(|item| match item { PageItem::FullParagraph { para_index } => *para_index, @@ -1525,7 +1674,9 @@ impl TypesetEngine { } // 이 페이지에 속하는 머리말/꼬리말 갱신 - let page_last_para = page.column_contents.iter() + let page_last_para = page + .column_contents + .iter() .flat_map(|col| col.items.iter()) .map(|item| match item { PageItem::FullParagraph { para_index } => *para_index, @@ -1591,8 +1742,8 @@ impl TypesetEngine { #[cfg(test)] mod tests { use super::*; - use crate::model::paragraph::{Paragraph, LineSeg}; - use crate::model::page::{PageDef, ColumnDef}; + use crate::model::page::{ColumnDef, PageDef}; + use crate::model::paragraph::{LineSeg, Paragraph}; use crate::renderer::composer::ComposedParagraph; use crate::renderer::height_measurer::HeightMeasurer; use crate::renderer::pagination::Paginator; @@ -1624,11 +1775,7 @@ mod tests { } /// 두 PaginationResult의 페이지 수와 각 페이지의 항목 수가 동일한지 비교 - fn assert_pagination_match( - old: &PaginationResult, - new: &PaginationResult, - label: &str, - ) { + fn assert_pagination_match(old: &PaginationResult, new: &PaginationResult, label: &str) { assert_eq!( old.pages.len(), new.pages.len(), @@ -1643,17 +1790,23 @@ mod tests { old_page.column_contents.len(), new_page.column_contents.len(), "{}: p{} 단 수 불일치", - label, pi, + label, + pi, ); - for (ci, (old_col, new_col)) in old_page.column_contents.iter() - .zip(new_page.column_contents.iter()).enumerate() + for (ci, (old_col, new_col)) in old_page + .column_contents + .iter() + .zip(new_page.column_contents.iter()) + .enumerate() { assert_eq!( old_col.items.len(), new_col.items.len(), "{}: p{} col{} 항목 수 불일치 (old={}, new={})", - label, pi, ci, + label, + pi, + ci, old_col.items.len(), new_col.items.len(), ); @@ -1674,8 +1827,13 @@ mod tests { let composed: Vec = Vec::new(); let result = engine.typeset_section( - &[], &composed, &styles, - &a4_page_def(), &ColumnDef::default(), 0, &[], + &[], + &composed, + &styles, + &a4_page_def(), + &ColumnDef::default(), + 0, + &[], ); assert_eq!(result.pages.len(), 1, "빈 문서도 최소 1페이지"); @@ -1691,11 +1849,15 @@ mod tests { let page_def = a4_page_def(); let col_def = ColumnDef::default(); - let (old_result, measured) = paginator.paginate( - ¶s, &composed, &styles, &page_def, &col_def, 0, - ); + let (old_result, measured) = + paginator.paginate(¶s, &composed, &styles, &page_def, &col_def, 0); let new_result = engine.typeset_section( - ¶s, &composed, &styles, &page_def, &col_def, 0, + ¶s, + &composed, + &styles, + &page_def, + &col_def, + 0, &measured.tables, ); @@ -1707,18 +1869,20 @@ mod tests { let engine = TypesetEngine::with_default_dpi(); let paginator = Paginator::with_default_dpi(); let styles = ResolvedStyleSet::default(); - let paras: Vec = (0..100) - .map(|_| make_paragraph_with_height(2000)) - .collect(); + let paras: Vec = (0..100).map(|_| make_paragraph_with_height(2000)).collect(); let composed: Vec = Vec::new(); let page_def = a4_page_def(); let col_def = ColumnDef::default(); - let (old_result, measured) = paginator.paginate( - ¶s, &composed, &styles, &page_def, &col_def, 0, - ); + let (old_result, measured) = + paginator.paginate(¶s, &composed, &styles, &page_def, &col_def, 0); let new_result = engine.typeset_section( - ¶s, &composed, &styles, &page_def, &col_def, 0, + ¶s, + &composed, + &styles, + &page_def, + &col_def, + 0, &measured.tables, ); @@ -1733,22 +1897,28 @@ mod tests { // 여러 줄이 있는 큰 문단 (페이지 경계에서 줄 단위 분할) let paras = vec![Paragraph { - line_segs: (0..50).map(|_| LineSeg { - line_height: 1800, - line_spacing: 200, - ..Default::default() - }).collect(), + line_segs: (0..50) + .map(|_| LineSeg { + line_height: 1800, + line_spacing: 200, + ..Default::default() + }) + .collect(), ..Default::default() }]; let composed: Vec = Vec::new(); let page_def = a4_page_def(); let col_def = ColumnDef::default(); - let (old_result, measured) = paginator.paginate( - ¶s, &composed, &styles, &page_def, &col_def, 0, - ); + let (old_result, measured) = + paginator.paginate(¶s, &composed, &styles, &page_def, &col_def, 0); let new_result = engine.typeset_section( - ¶s, &composed, &styles, &page_def, &col_def, 0, + ¶s, + &composed, + &styles, + &page_def, + &col_def, + 0, &measured.tables, ); @@ -1764,21 +1934,25 @@ mod tests { // 다양한 높이의 문단 혼합 let paras: Vec = vec![ make_paragraph_with_height(400), - make_paragraph_with_height(10000), // 큰 문단 + make_paragraph_with_height(10000), // 큰 문단 make_paragraph_with_height(400), make_paragraph_with_height(800), - make_paragraph_with_height(20000), // 매우 큰 문단 + make_paragraph_with_height(20000), // 매우 큰 문단 make_paragraph_with_height(400), ]; let composed: Vec = Vec::new(); let page_def = a4_page_def(); let col_def = ColumnDef::default(); - let (old_result, measured) = paginator.paginate( - ¶s, &composed, &styles, &page_def, &col_def, 0, - ); + let (old_result, measured) = + paginator.paginate(¶s, &composed, &styles, &page_def, &col_def, 0); let new_result = engine.typeset_section( - ¶s, &composed, &styles, &page_def, &col_def, 0, + ¶s, + &composed, + &styles, + &page_def, + &col_def, + 0, &measured.tables, ); @@ -1805,11 +1979,15 @@ mod tests { let page_def = a4_page_def(); let col_def = ColumnDef::default(); - let (old_result, measured) = paginator.paginate( - ¶s, &composed, &styles, &page_def, &col_def, 0, - ); + let (old_result, measured) = + paginator.paginate(¶s, &composed, &styles, &page_def, &col_def, 0); let new_result = engine.typeset_section( - ¶s, &composed, &styles, &page_def, &col_def, 0, + ¶s, + &composed, + &styles, + &page_def, + &col_def, + 0, &measured.tables, ); @@ -1843,14 +2021,14 @@ mod tests { for (sec_idx, section) in doc.document.sections.iter().enumerate() { let composed = &doc.composed[sec_idx]; let measured_tables = &doc.measured_tables[sec_idx]; - let column_def = crate::document_core::DocumentCore::find_initial_column_def( - §ion.paragraphs, - ); + let column_def = + crate::document_core::DocumentCore::find_initial_column_def(§ion.paragraphs); // 구역에 표가 포함되어 있는지 확인 - let has_tables = section.paragraphs.iter().any(|p| - p.controls.iter().any(|c| matches!(c, Control::Table(_))) - ); + let has_tables = section + .paragraphs + .iter() + .any(|p| p.controls.iter().any(|c| matches!(c, Control::Table(_)))); let new_result = engine.typeset_section( §ion.paragraphs, @@ -1870,7 +2048,9 @@ mod tests { if old_result.pages.len() != new_result.pages.len() { eprintln!( "WARN {}: 표 포함 구역 페이지 수 차이 (old={}, new={}) — Phase 2에서 해결", - label, old_result.pages.len(), new_result.pages.len(), + label, + old_result.pages.len(), + new_result.pages.len(), ); } } else { @@ -1879,17 +2059,23 @@ mod tests { old_result.pages.len(), new_result.pages.len(), "{}: 페이지 수 불일치 (old={}, new={})", - label, old_result.pages.len(), new_result.pages.len(), + label, + old_result.pages.len(), + new_result.pages.len(), ); - for (pi, (old_page, new_page)) in old_result.pages.iter() - .zip(new_result.pages.iter()).enumerate() + for (pi, (old_page, new_page)) in old_result + .pages + .iter() + .zip(new_result.pages.iter()) + .enumerate() { assert_eq!( old_page.column_contents.len(), new_page.column_contents.len(), "{}: p{} 단 수 불일치", - label, pi, + label, + pi, ); } } diff --git a/src/renderer/web_canvas.rs b/src/renderer/web_canvas.rs index 558ce6c5..b4a9d4c1 100644 --- a/src/renderer/web_canvas.rs +++ b/src/renderer/web_canvas.rs @@ -3,23 +3,28 @@ //! 브라우저의 Canvas 2D API를 사용하여 HWP 페이지를 렌더링한다. //! web-sys를 통해 CanvasRenderingContext2d에 직접 그린다. +#[cfg(target_arch = "wasm32")] +use base64::Engine; #[cfg(target_arch = "wasm32")] use wasm_bindgen::prelude::*; #[cfg(target_arch = "wasm32")] use wasm_bindgen::JsCast; #[cfg(target_arch = "wasm32")] use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, HtmlImageElement}; -#[cfg(target_arch = "wasm32")] -use base64::Engine; -use super::{Renderer, TextStyle, ShapeStyle, LineStyle, PathCommand, StrokeDash, GradientFillInfo, PatternFillInfo}; -use crate::model::style::UnderlineType; -use crate::model::style::ImageFillMode; -use super::render_tree::{BoundingBox, FormObjectNode, PageRenderTree, RenderNode, RenderNodeType, ShapeTransform}; -use super::composer::{CharOverlapInfo, pua_to_display_text, decode_pua_overlap_number}; -use crate::model::control::FormType; +use super::composer::{decode_pua_overlap_number, pua_to_display_text, CharOverlapInfo}; #[cfg(target_arch = "wasm32")] use super::layout::{compute_char_positions, split_into_clusters}; +use super::render_tree::{ + BoundingBox, FormObjectNode, PageRenderTree, RenderNode, RenderNodeType, ShapeTransform, +}; +use super::{ + GradientFillInfo, LineStyle, PathCommand, PatternFillInfo, Renderer, ShapeStyle, StrokeDash, + TextStyle, +}; +use crate::model::control::FormType; +use crate::model::style::ImageFillMode; +use crate::model::style::UnderlineType; // 이미지 캐시: data 해시 → HtmlImageElement // WASM 단일 스레드이므로 thread_local 안전 @@ -55,7 +60,10 @@ fn detect_image_mime_type(data: &[u8]) -> &'static str { "image/x-icon" } else if data.len() >= 2 && &data[0..2] == b"BM" { "image/bmp" - } else if data.len() >= 4 && (data.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) || data.starts_with(&[0x01, 0x00, 0x09, 0x00])) { + } else if data.len() >= 4 + && (data.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) + || data.starts_with(&[0x01, 0x00, 0x09, 0x00])) + { "image/x-wmf" } else { "application/octet-stream" @@ -79,12 +87,21 @@ fn parse_image_dimensions_canvas(data: &[u8]) -> Option<(u32, u32)> { if data.starts_with(&[0xFF, 0xD8, 0xFF]) { let mut i = 2; while i + 9 < data.len() { - if data[i] != 0xFF { i += 1; continue; } + if data[i] != 0xFF { + i += 1; + continue; + } let marker = data[i + 1]; - if (marker >= 0xC0 && marker <= 0xCF) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC { + if (marker >= 0xC0 && marker <= 0xCF) + && marker != 0xC4 + && marker != 0xC8 + && marker != 0xCC + { let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32; let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32; - if w > 0 && h > 0 { return Some((w, h)); } + if w > 0 && h > 0 { + return Some((w, h)); + } } let seg_len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize; i += 2 + seg_len; @@ -172,31 +189,48 @@ impl WebCanvasRenderer { // 배경색 if let Some(color) = bg.background_color { self.ctx.set_fill_style_str(&color_to_css(color)); - self.ctx.fill_rect( - node.bbox.x, node.bbox.y, - node.bbox.width, node.bbox.height, - ); + self.ctx + .fill_rect(node.bbox.x, node.bbox.y, node.bbox.width, node.bbox.height); } // 그라데이션 if let Some(grad) = &bg.gradient { - if self.apply_gradient_fill(grad, node.bbox.x, node.bbox.y, node.bbox.width, node.bbox.height) { + if self.apply_gradient_fill( + grad, + node.bbox.x, + node.bbox.y, + node.bbox.width, + node.bbox.height, + ) { self.ctx.fill_rect( - node.bbox.x, node.bbox.y, - node.bbox.width, node.bbox.height, + node.bbox.x, + node.bbox.y, + node.bbox.width, + node.bbox.height, ); } } // 이미지 배경 if let Some(img) = &bg.image { - self.draw_image(&img.data, node.bbox.x, node.bbox.y, node.bbox.width, node.bbox.height); + self.draw_image( + &img.data, + node.bbox.x, + node.bbox.y, + node.bbox.width, + node.bbox.height, + ); } } RenderNodeType::TextRun(run) => { // 글자겹침(CharOverlap): 도형 + 텍스트를 Canvas로 렌더링 if let Some(ref overlap) = run.char_overlap { self.draw_char_overlap( - &run.text, &run.style, overlap, - node.bbox.x, node.bbox.y, node.bbox.width, node.bbox.height, + &run.text, + &run.style, + overlap, + node.bbox.x, + node.bbox.y, + node.bbox.width, + node.bbox.height, ); } else if run.rotation != 0.0 { // 회전 텍스트: bbox 중앙 기준으로 중앙 정렬 후 회전 @@ -205,14 +239,21 @@ impl WebCanvasRenderer { // 폰트 설정 let font_weight = if run.style.bold { "bold " } else { "" }; let font_style_str = if run.style.italic { "italic " } else { "" }; - let font_size = if run.style.font_size > 0.0 { run.style.font_size } else { 12.0 }; + let font_size = if run.style.font_size > 0.0 { + run.style.font_size + } else { + 12.0 + }; let font_family = if run.style.font_family.is_empty() { "sans-serif".to_string() } else { let fallback = super::generic_fallback(&run.style.font_family); format!("\"{}\" , {}", run.style.font_family, fallback) }; - let font = format!("{}{}{:.3}px {}", font_style_str, font_weight, font_size, font_family); + let font = format!( + "{}{}{:.3}px {}", + font_style_str, font_weight, font_size, font_family + ); self.ctx.set_font(&font); self.ctx.set_fill_style_str(&color_to_css(run.style.color)); self.ctx.save(); @@ -232,14 +273,22 @@ impl WebCanvasRenderer { ); } if self.show_paragraph_marks || self.show_control_codes { - let is_marker = !matches!(run.field_marker, crate::renderer::render_tree::FieldMarkerType::None); - let font_size = if run.style.font_size > 0.0 { run.style.font_size } else { 12.0 }; + let is_marker = !matches!( + run.field_marker, + crate::renderer::render_tree::FieldMarkerType::None + ); + let font_size = if run.style.font_size > 0.0 { + run.style.font_size + } else { + 12.0 + }; // 공백·탭 기호 (조판부호 마커는 건너뜀) if !run.text.is_empty() && !is_marker { let char_positions = compute_char_positions(&run.text, &run.style); let mark_font_size = font_size * 0.5; self.ctx.set_fill_style_str("#4A90D9"); - self.ctx.set_font(&format!("{:.3}px sans-serif", mark_font_size)); + self.ctx + .set_font(&format!("{:.3}px sans-serif", mark_font_size)); for (i, c) in run.text.chars().enumerate() { if c == ' ' { let cx = node.bbox.x + char_positions[i]; @@ -249,10 +298,16 @@ impl WebCanvasRenderer { node.bbox.x + node.bbox.width }; let mid_x = (cx + next_x) / 2.0 - mark_font_size * 0.25; - let _ = self.ctx.fill_text("\u{2228}", mid_x, node.bbox.y + run.baseline); + let _ = self.ctx.fill_text( + "\u{2228}", + mid_x, + node.bbox.y + run.baseline, + ); } else if c == '\t' { let cx = node.bbox.x + char_positions[i]; - let _ = self.ctx.fill_text("\u{2192}", cx, node.bbox.y + run.baseline); + let _ = + self.ctx + .fill_text("\u{2192}", cx, node.bbox.y + run.baseline); } } } @@ -269,13 +324,25 @@ impl WebCanvasRenderer { let _ = self.ctx.translate(cx, cy); let _ = self.ctx.rotate(90.0 * std::f64::consts::PI / 180.0); let _ = self.ctx.translate(-cx, -cy); - let mark = if run.is_line_break_end { "\u{2193}" } else { "\u{21B5}" }; + let mark = if run.is_line_break_end { + "\u{2193}" + } else { + "\u{21B5}" + }; let _ = self.ctx.fill_text(mark, mark_x, mark_y); self.ctx.restore(); } else { - let mark_x = if run.text.is_empty() { node.bbox.x } else { node.bbox.x + node.bbox.width }; + let mark_x = if run.text.is_empty() { + node.bbox.x + } else { + node.bbox.x + node.bbox.width + }; let mark_y = node.bbox.y + run.baseline; - let mark = if run.is_line_break_end { "\u{2193}" } else { "\u{21B5}" }; + let mark = if run.is_line_break_end { + "\u{2193}" + } else { + "\u{21B5}" + }; let _ = self.ctx.fill_text(mark, mark_x, mark_y); } } @@ -284,8 +351,10 @@ impl WebCanvasRenderer { RenderNodeType::Rectangle(rect) => { self.open_shape_transform(&rect.transform, &node.bbox); self.draw_rect_with_gradient( - node.bbox.x, node.bbox.y, - node.bbox.width, node.bbox.height, + node.bbox.x, + node.bbox.y, + node.bbox.width, + node.bbox.height, rect.corner_radius, &rect.style, rect.gradient.as_deref(), @@ -300,8 +369,10 @@ impl WebCanvasRenderer { let cx = node.bbox.x + node.bbox.width / 2.0; let cy = node.bbox.y + node.bbox.height / 2.0; self.draw_ellipse_with_gradient( - cx, cy, - node.bbox.width / 2.0, node.bbox.height / 2.0, + cx, + cy, + node.bbox.width / 2.0, + node.bbox.height / 2.0, &ellipse.style, ellipse.gradient.as_deref(), ); @@ -310,7 +381,11 @@ impl WebCanvasRenderer { self.open_shape_transform(&img.transform, &node.bbox); if let Some(ref data) = img.data { self.draw_image_with_fill_mode( - data, &node.bbox, img.fill_mode, img.original_size, img.crop, + data, + &node.bbox, + img.fill_mode, + img.original_size, + img.crop, ); } } @@ -318,11 +393,15 @@ impl WebCanvasRenderer { self.open_shape_transform(&path.transform, &node.bbox); self.draw_path_with_gradient(&path.commands, &path.style, path.gradient.as_deref()); // 연결선 화살표: 경로의 시작/끝 접선 방향 사용 - if let (Some(ref ls), Some((x1, y1, x2, y2))) = (&path.line_style, path.connector_endpoints) { + if let (Some(ref ls), Some((x1, y1, x2, y2))) = + (&path.line_style, path.connector_endpoints) + { let color = color_to_css(ls.color); let width = ls.width; let cmds = &path.commands; - let len = ((x2-x1)*(x2-x1) + (y2-y1)*(y2-y1)).sqrt().max(1.0); + let len = ((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) + .sqrt() + .max(1.0); // 시작 화살표: 시작점과 다른 첫 번째 점 방향 if ls.start_arrow != super::ArrowStyle::None { let (dx, dy) = { @@ -340,9 +419,20 @@ impl WebCanvasRenderer { } found }; - let d = (dx*dx + dy*dy).sqrt().max(0.001); + let d = (dx * dx + dy * dy).sqrt().max(0.001); let (aw, ah) = calc_arrow_dims(width, len, ls.start_arrow_size); - draw_arrow_head(&self.ctx, x1, y1, dx/d, dy/d, aw, ah, &ls.start_arrow, &color, width); + draw_arrow_head( + &self.ctx, + x1, + y1, + dx / d, + dy / d, + aw, + ah, + &ls.start_arrow, + &color, + width, + ); } // 끝 화살표: 끝점과 다른 마지막 점 → 끝점 방향 if ls.end_arrow != super::ArrowStyle::None { @@ -350,8 +440,10 @@ impl WebCanvasRenderer { let mut pts: Vec<(f64, f64)> = Vec::new(); for cmd in cmds.iter() { match cmd { - super::PathCommand::MoveTo(px, py) | - super::PathCommand::LineTo(px, py) => { pts.push((*px, *py)); } + super::PathCommand::MoveTo(px, py) + | super::PathCommand::LineTo(px, py) => { + pts.push((*px, *py)); + } super::PathCommand::CurveTo(_, _, cx, cy, ex, ey) => { pts.push((*cx, *cy)); pts.push((*ex, *ey)); @@ -371,13 +463,26 @@ impl WebCanvasRenderer { } found }; - let d = (dx*dx + dy*dy).sqrt().max(0.001); + let d = (dx * dx + dy * dy).sqrt().max(0.001); let (aw, ah) = calc_arrow_dims(width, len, ls.end_arrow_size); - draw_arrow_head(&self.ctx, x2, y2, dx/d, dy/d, aw, ah, &ls.end_arrow, &color, width); + draw_arrow_head( + &self.ctx, + x2, + y2, + dx / d, + dy / d, + aw, + ah, + &ls.end_arrow, + &color, + width, + ); } } } - RenderNodeType::Body { clip_rect: Some(cr) } => { + RenderNodeType::Body { + clip_rect: Some(cr), + } => { self.ctx.save(); self.ctx.begin_path(); // 우측 여유: 레이아웃 메트릭과 브라우저 글리프 폭 차이 흡수 @@ -388,7 +493,12 @@ impl WebCanvasRenderer { self.ctx.save(); self.ctx.begin_path(); // 셀 우측 여유: 레이아웃 반올림 오차로 마지막 글리프 잘림 방지 - self.ctx.rect(node.bbox.x, node.bbox.y, node.bbox.width + 4.0, node.bbox.height); + self.ctx.rect( + node.bbox.x, + node.bbox.y, + node.bbox.width + 4.0, + node.bbox.height, + ); self.ctx.clip(); } RenderNodeType::Equation(eq) => { @@ -458,7 +568,10 @@ impl WebCanvasRenderer { if matches!(node.node_type, RenderNodeType::Body { clip_rect: Some(_) }) { self.ctx.restore(); // 편집 모드: 여백을 벗어난 도형/이미지/표를 재렌더링 (좌우 넘침 허용) - if let RenderNodeType::Body { clip_rect: Some(ref cr) } = node.node_type { + if let RenderNodeType::Body { + clip_rect: Some(ref cr), + } = node.node_type + { self.render_overflow_controls(node, cr); } } @@ -478,7 +591,9 @@ impl WebCanvasRenderer { let sy = if transform.vert_flip { -1.0 } else { 1.0 }; let _ = self.ctx.scale(sx, sy); if transform.rotation != 0.0 { - let _ = self.ctx.rotate(transform.rotation * std::f64::consts::PI / 180.0); + let _ = self + .ctx + .rotate(transform.rotation * std::f64::consts::PI / 180.0); } let _ = self.ctx.translate(-cx, -cy); } @@ -506,16 +621,19 @@ impl WebCanvasRenderer { // 오버플로우 컨트롤 존재 여부 빠른 확인 let has_overflow = body_node.children.iter().any(|col| { - col.children.iter().any(|child| { - Self::is_overflow_control(child, body_left, body_right) - }) + col.children + .iter() + .any(|child| Self::is_overflow_control(child, body_left, body_right)) }); - if !has_overflow { return; } + if !has_overflow { + return; + } // 상하만 본문 영역 클리핑 (좌우 전폭) self.ctx.save(); self.ctx.begin_path(); - self.ctx.rect(0.0, body_clip.y, self.width, body_clip.height); + self.ctx + .rect(0.0, body_clip.y, self.width, body_clip.height); self.ctx.clip(); for col in &body_node.children { @@ -604,8 +722,12 @@ impl WebCanvasRenderer { let cos_a = rad.cos(); let cx = x + w / 2.0; let cy = y + h / 2.0; - (cx - sin_a * w / 2.0, cy - cos_a * h / 2.0, - cx + sin_a * w / 2.0, cy + cos_a * h / 2.0) + ( + cx - sin_a * w / 2.0, + cy - cos_a * h / 2.0, + cx + sin_a * w / 2.0, + cy + cos_a * h / 2.0, + ) } } } @@ -712,7 +834,10 @@ impl WebCanvasRenderer { } // createPattern으로 반복 패턴 생성 - match self.ctx.create_pattern_with_html_canvas_element(&tile_canvas, "repeat") { + match self + .ctx + .create_pattern_with_html_canvas_element(&tile_canvas, "repeat") + { Ok(Some(pattern)) => { self.ctx.set_fill_style_canvas_pattern(&pattern); true @@ -761,7 +886,16 @@ impl WebCanvasRenderer { } /// 그라데이션을 포함한 사각형 그리기 - fn draw_rect_with_gradient(&mut self, x: f64, y: f64, w: f64, h: f64, corner_radius: f64, style: &ShapeStyle, gradient: Option<&GradientFillInfo>) { + fn draw_rect_with_gradient( + &mut self, + x: f64, + y: f64, + w: f64, + h: f64, + corner_radius: f64, + style: &ShapeStyle, + gradient: Option<&GradientFillInfo>, + ) { let need_opacity = style.opacity < 1.0; if need_opacity { self.ctx.save(); @@ -846,10 +980,20 @@ impl WebCanvasRenderer { } /// 그라데이션을 포함한 타원 그리기 - fn draw_ellipse_with_gradient(&mut self, cx: f64, cy: f64, rx: f64, ry: f64, style: &ShapeStyle, gradient: Option<&GradientFillInfo>) { + fn draw_ellipse_with_gradient( + &mut self, + cx: f64, + cy: f64, + rx: f64, + ry: f64, + style: &ShapeStyle, + gradient: Option<&GradientFillInfo>, + ) { self.apply_shadow(style); self.ctx.begin_path(); - let _ = self.ctx.ellipse(cx, cy, rx.abs(), ry.abs(), 0.0, 0.0, std::f64::consts::TAU); + let _ = self + .ctx + .ellipse(cx, cy, rx.abs(), ry.abs(), 0.0, 0.0, std::f64::consts::TAU); if let Some(grad) = gradient { let x = cx - rx; @@ -883,7 +1027,12 @@ impl WebCanvasRenderer { } /// 그라데이션을 포함한 패스 그리기 - fn draw_path_with_gradient(&mut self, commands: &[PathCommand], style: &ShapeStyle, gradient: Option<&GradientFillInfo>) { + fn draw_path_with_gradient( + &mut self, + commands: &[PathCommand], + style: &ShapeStyle, + gradient: Option<&GradientFillInfo>, + ) { self.apply_shadow(style); self.ctx.begin_path(); let mut min_x = f64::MAX; @@ -898,42 +1047,58 @@ impl WebCanvasRenderer { match cmd { PathCommand::MoveTo(x, y) => { self.ctx.move_to(*x, *y); - cur_x = *x; cur_y = *y; - min_x = min_x.min(*x); min_y = min_y.min(*y); - max_x = max_x.max(*x); max_y = max_y.max(*y); + cur_x = *x; + cur_y = *y; + min_x = min_x.min(*x); + min_y = min_y.min(*y); + max_x = max_x.max(*x); + max_y = max_y.max(*y); } PathCommand::LineTo(x, y) => { self.ctx.line_to(*x, *y); - cur_x = *x; cur_y = *y; - min_x = min_x.min(*x); min_y = min_y.min(*y); - max_x = max_x.max(*x); max_y = max_y.max(*y); + cur_x = *x; + cur_y = *y; + min_x = min_x.min(*x); + min_y = min_y.min(*y); + max_x = max_x.max(*x); + max_y = max_y.max(*y); } PathCommand::CurveTo(cp1x, cp1y, cp2x, cp2y, x, y) => { self.ctx.bezier_curve_to(*cp1x, *cp1y, *cp2x, *cp2y, *x, *y); - cur_x = *x; cur_y = *y; - min_x = min_x.min(*x); min_y = min_y.min(*y); - max_x = max_x.max(*x); max_y = max_y.max(*y); + cur_x = *x; + cur_y = *y; + min_x = min_x.min(*x); + min_y = min_y.min(*y); + max_x = max_x.max(*x); + max_y = max_y.max(*y); } PathCommand::ArcTo(rx, ry, x_rot, large_arc, sweep, x, y) => { // SVG arc → cubic bezier 변환 let beziers = super::svg_arc_to_beziers( - cur_x, cur_y, *rx, *ry, *x_rot, - *large_arc, *sweep, *x, *y, + cur_x, cur_y, *rx, *ry, *x_rot, *large_arc, *sweep, *x, *y, ); for bcmd in &beziers { if let PathCommand::CurveTo(cp1x, cp1y, cp2x, cp2y, ex, ey) = bcmd { - self.ctx.bezier_curve_to(*cp1x, *cp1y, *cp2x, *cp2y, *ex, *ey); - min_x = min_x.min(*ex); min_y = min_y.min(*ey); - max_x = max_x.max(*ex); max_y = max_y.max(*ey); + self.ctx + .bezier_curve_to(*cp1x, *cp1y, *cp2x, *cp2y, *ex, *ey); + min_x = min_x.min(*ex); + min_y = min_y.min(*ey); + max_x = max_x.max(*ex); + max_y = max_y.max(*ey); } else if let PathCommand::LineTo(lx, ly) = bcmd { self.ctx.line_to(*lx, *ly); - min_x = min_x.min(*lx); min_y = min_y.min(*ly); - max_x = max_x.max(*lx); max_y = max_y.max(*ly); + min_x = min_x.min(*lx); + min_y = min_y.min(*ly); + max_x = max_x.max(*lx); + max_y = max_y.max(*ly); } } - cur_x = *x; cur_y = *y; - min_x = min_x.min(*x); min_y = min_y.min(*y); - max_x = max_x.max(*x); max_y = max_y.max(*y); + cur_x = *x; + cur_y = *y; + min_x = min_x.min(*x); + min_y = min_y.min(*y); + max_x = max_x.max(*x); + max_y = max_y.max(*y); } PathCommand::ClosePath => { self.ctx.close_path(); @@ -944,8 +1109,16 @@ impl WebCanvasRenderer { if let Some(grad) = gradient { let bx = if min_x.is_finite() { min_x } else { 0.0 }; let by = if min_y.is_finite() { min_y } else { 0.0 }; - let bw = if max_x.is_finite() && min_x.is_finite() { max_x - min_x } else { 100.0 }; - let bh = if max_y.is_finite() && min_y.is_finite() { max_y - min_y } else { 100.0 }; + let bw = if max_x.is_finite() && min_x.is_finite() { + max_x - min_x + } else { + 100.0 + }; + let bh = if max_y.is_finite() && min_y.is_finite() { + max_y - min_y + } else { + 100.0 + }; if !self.apply_gradient_fill(grad, bx, by, bw, bh) { if let Some(fill) = style.fill_color { self.ctx.set_fill_style_str(&color_to_css(fill)); @@ -984,7 +1157,11 @@ impl WebCanvasRenderer { /// 도형 그림자 적용 fn apply_shadow(&self, style: &ShapeStyle) { if let Some(ref shadow) = style.shadow { - let opacity = if shadow.alpha > 0 { 1.0 - (shadow.alpha as f64 / 255.0) } else { 1.0 }; + let opacity = if shadow.alpha > 0 { + 1.0 - (shadow.alpha as f64 / 255.0) + } else { + 1.0 + }; let r = (shadow.color >> 0) & 0xFF; let g = (shadow.color >> 8) & 0xFF; let b = (shadow.color >> 16) & 0xFF; @@ -1058,7 +1235,9 @@ impl WebCanvasRenderer { self.ctx.set_font(&format!("{}px sans-serif", font_size)); self.ctx.set_fill_style_str(&form.fore_color); self.ctx.set_text_baseline("middle"); - let _ = self.ctx.fill_text(&form.caption, x + box_size + 4.0, y + h / 2.0); + let _ = self + .ctx + .fill_text(&form.caption, x + box_size + 4.0, y + h / 2.0); self.ctx.set_text_baseline("alphabetic"); } } @@ -1087,7 +1266,9 @@ impl WebCanvasRenderer { self.ctx.set_font(&format!("{}px sans-serif", font_size)); self.ctx.set_fill_style_str(&form.fore_color); self.ctx.set_text_baseline("middle"); - let _ = self.ctx.fill_text(&form.caption, x + r * 2.0 + 4.0, y + h / 2.0); + let _ = self + .ctx + .fill_text(&form.caption, x + r * 2.0 + 4.0, y + h / 2.0); self.ctx.set_text_baseline("alphabetic"); } } @@ -1169,7 +1350,11 @@ impl Renderer for WebCanvasRenderer { // 글꼴 설정 let font_weight = if style.bold { "bold " } else { "" }; let font_style = if style.italic { "italic " } else { "" }; - let base_font_size = if style.font_size > 0.0 { style.font_size } else { 12.0 }; + let base_font_size = if style.font_size > 0.0 { + style.font_size + } else { + 12.0 + }; // 위첨자/아래첨자: 글꼴 크기 축소 + y좌표 조정 let (font_size, y) = if style.superscript { @@ -1187,7 +1372,10 @@ impl Renderer for WebCanvasRenderer { format!("\"{}\", {}", style.font_family, fallback) }; - let font = format!("{}{}{:.3}px {}", font_style, font_weight, font_size, font_family); + let font = format!( + "{}{}{:.3}px {}", + font_style, font_weight, font_size, font_family + ); self.ctx.set_font(&font); // 장평 적용 @@ -1205,39 +1393,73 @@ impl Renderer for WebCanvasRenderer { if shade_rgb != 0x00FFFFFF && shade_rgb != 0 { let text_width = *char_positions.last().unwrap_or(&0.0); if text_width > 0.0 { - self.ctx.set_fill_style_str(&color_to_css(style.shade_color)); - self.ctx.fill_rect(x, y - font_size, text_width, font_size * 1.2); + self.ctx + .set_fill_style_str(&color_to_css(style.shade_color)); + self.ctx + .fill_rect(x, y - font_size, text_width, font_size * 1.2); } } - let has_effect = style.outline_type > 0 || style.shadow_type > 0 - || style.emboss || style.engrave; + let has_effect = + style.outline_type > 0 || style.shadow_type > 0 || style.emboss || style.engrave; if has_effect { self.draw_text_with_effects( - &clusters, &char_positions, x, y, style, font_size, ratio, has_ratio, + &clusters, + &char_positions, + x, + y, + style, + font_size, + ratio, + has_ratio, ); } else { // 기본 렌더링 (효과 없음) self.ctx.set_fill_style_str(&color_to_css(style.color)); for (char_idx, cluster_str) in &clusters { - if cluster_str == " " || cluster_str == "\t" || cluster_str == "\u{2007}" { continue; } + if cluster_str == " " || cluster_str == "\t" || cluster_str == "\u{2007}" { + continue; + } // XML/HTML 무효 제어문자 건너뜀 (SVG의 escape_xml과 동일) - if cluster_str.starts_with(|c: char| c < '\u{0020}' && !matches!(c, '\t' | '\n' | '\r')) { continue; } + if cluster_str + .starts_with(|c: char| c < '\u{0020}' && !matches!(c, '\t' | '\n' | '\r')) + { + continue; + } let char_x = x + char_positions[*char_idx]; let ch = cluster_str.chars().next().unwrap_or(' '); // 통화 기호 등 글리프 미포함 문자: 폴백 폰트로 임시 전환 - let needs_font_fallback = matches!(ch, + let needs_currency_fallback = matches!( + ch, '\u{20A9}' | '\u{20AC}' | '\u{00A3}' | '\u{00A5}' // ₩€£¥ ); - if needs_font_fallback { + let needs_symbol_fallback = matches!( + ch, + '\u{2460}'..='\u{24FF}' + | '\u{25A0}'..='\u{25FF}' + | '\u{2600}'..='\u{27BF}' + ); + if needs_currency_fallback || needs_symbol_fallback { self.ctx.save(); - let fallback_font = format!("{}{}{:.3}px 'Malgun Gothic','맑은 고딕',sans-serif", - if style.italic { "italic " } else { "" }, - if style.bold { "bold " } else { "" }, - font_size); + let fallback_font = if needs_symbol_fallback { + format!( + "{}{}{:.3}px {}", + if style.italic { "italic " } else { "" }, + if style.bold { "bold " } else { "" }, + font_size, + super::generic_fallback("굴림체") + ) + } else { + format!( + "{}{}{:.3}px 'Malgun Gothic','맑은 고딕',sans-serif", + if style.italic { "italic " } else { "" }, + if style.bold { "bold " } else { "" }, + font_size + ) + }; self.ctx.set_font(&fallback_font); let _ = self.ctx.fill_text(cluster_str, char_x, y); self.ctx.restore(); @@ -1246,9 +1468,8 @@ impl Renderer for WebCanvasRenderer { } // 반각 강제 구두점: 폰트 글리프가 전각이지만 반각 공간에 배치 - let needs_halfwidth_scale = matches!(ch, - '\u{2018}'..='\u{2027}' | '\u{00B7}' - ) && !has_ratio; + let needs_halfwidth_scale = + matches!(ch, '\u{2018}'..='\u{2027}' | '\u{00B7}') && !has_ratio; if needs_halfwidth_scale { self.ctx.save(); @@ -1280,7 +1501,14 @@ impl Renderer for WebCanvasRenderer { UnderlineType::Top => y - font_size + 1.0, _ => y + 2.0, }; - self.draw_line_shape_canvas(x, ul_y, x + text_width, ul_y, &ul_color, style.underline_shape); + self.draw_line_shape_canvas( + x, + ul_y, + x + text_width, + ul_y, + &ul_color, + style.underline_shape, + ); } // 취소선 처리 @@ -1292,13 +1520,26 @@ impl Renderer for WebCanvasRenderer { } else { color_to_css(style.color) }; - self.draw_line_shape_canvas(x, strike_y, x + text_width, strike_y, &st_color, style.strike_shape); + self.draw_line_shape_canvas( + x, + strike_y, + x + text_width, + strike_y, + &st_color, + style.strike_shape, + ); } // 강조점 처리 if style.emphasis_dot > 0 { let dot_char = match style.emphasis_dot { - 1 => "●", 2 => "○", 3 => "ˇ", 4 => "˜", 5 => "・", 6 => "˸", _ => "", + 1 => "●", + 2 => "○", + 3 => "ˇ", + 4 => "˜", + 5 => "・", + 6 => "˸", + _ => "", }; if !dot_char.is_empty() { let dot_size = font_size * 0.3; @@ -1320,31 +1561,36 @@ impl Renderer for WebCanvasRenderer { // 6=긴파선, 7=원형점선, 8=이중실선, 9=얇고굵은이중선, // 10=굵고얇은이중선, 11=얇고굵고얇은삼중선 for leader in &style.tab_leaders { - if leader.fill_type == 0 { continue; } + if leader.fill_type == 0 { + continue; + } let lx1 = x + leader.start_x; let lx2 = x + leader.end_x; let ly = y - font_size * 0.35; // 글자 세로 중앙 let stroke_color = color_to_css(style.color); - let draw_line = |ctx: &web_sys::CanvasRenderingContext2d, y: f64, width: f64, dash: &[f64]| { - let arr = js_sys::Array::new(); - for &d in dash { arr.push(&JsValue::from(d)); } - let _ = ctx.set_line_dash(&arr); - ctx.set_line_width(width); - ctx.begin_path(); - ctx.move_to(lx1, y); - ctx.line_to(lx2, y); - ctx.stroke(); - }; + let draw_line = + |ctx: &web_sys::CanvasRenderingContext2d, y: f64, width: f64, dash: &[f64]| { + let arr = js_sys::Array::new(); + for &d in dash { + arr.push(&JsValue::from(d)); + } + let _ = ctx.set_line_dash(&arr); + ctx.set_line_width(width); + ctx.begin_path(); + ctx.move_to(lx1, y); + ctx.line_to(lx2, y); + ctx.stroke(); + }; self.ctx.set_stroke_style_str(&stroke_color); match leader.fill_type { - 1 => draw_line(&self.ctx, ly, 0.5, &[]), // 실선 - 2 => draw_line(&self.ctx, ly, 0.5, &[3.0, 3.0]), // 파선 - 3 => draw_line(&self.ctx, ly, 0.5, &[1.0, 2.0]), // 점선 - 4 => draw_line(&self.ctx, ly, 0.5, &[6.0, 2.0, 1.0, 2.0]), // 일점쇄선 + 1 => draw_line(&self.ctx, ly, 0.5, &[]), // 실선 + 2 => draw_line(&self.ctx, ly, 0.5, &[3.0, 3.0]), // 파선 + 3 => draw_line(&self.ctx, ly, 0.5, &[1.0, 2.0]), // 점선 + 4 => draw_line(&self.ctx, ly, 0.5, &[6.0, 2.0, 1.0, 2.0]), // 일점쇄선 5 => draw_line(&self.ctx, ly, 0.5, &[6.0, 2.0, 1.0, 2.0, 1.0, 2.0]), // 이점쇄선 - 6 => draw_line(&self.ctx, ly, 0.5, &[8.0, 4.0]), // 긴파선 + 6 => draw_line(&self.ctx, ly, 0.5, &[8.0, 4.0]), // 긴파선 7 => { // 원형점선 ●●● self.ctx.set_line_cap("round"); @@ -1372,13 +1618,21 @@ impl Renderer for WebCanvasRenderer { draw_line(&self.ctx, ly, 0.8, &[]); draw_line(&self.ctx, ly + 2.0, 0.3, &[]); } - _ => draw_line(&self.ctx, ly, 0.5, &[1.0, 2.0]), // 폴백: 점선 + _ => draw_line(&self.ctx, ly, 0.5, &[1.0, 2.0]), // 폴백: 점선 } let _ = self.ctx.set_line_dash(&js_sys::Array::new()); } } - fn draw_rect(&mut self, x: f64, y: f64, w: f64, h: f64, corner_radius: f64, style: &ShapeStyle) { + fn draw_rect( + &mut self, + x: f64, + y: f64, + w: f64, + h: f64, + corner_radius: f64, + style: &ShapeStyle, + ) { self.draw_rect_with_gradient(x, y, w, h, corner_radius, style, None); } @@ -1400,13 +1654,35 @@ impl Renderer for WebCanvasRenderer { if style.start_arrow != super::ArrowStyle::None { let (arrow_w, arrow_h) = calc_arrow_dims(width, line_len, style.start_arrow_size); - draw_arrow_head(&self.ctx, x1, y1, -ux, -uy, arrow_w, arrow_h, &style.start_arrow, &color, width); + draw_arrow_head( + &self.ctx, + x1, + y1, + -ux, + -uy, + arrow_w, + arrow_h, + &style.start_arrow, + &color, + width, + ); lx1 += ux * arrow_w; ly1 += uy * arrow_w; } if style.end_arrow != super::ArrowStyle::None { let (arrow_w, arrow_h) = calc_arrow_dims(width, line_len, style.end_arrow_size); - draw_arrow_head(&self.ctx, x2, y2, ux, uy, arrow_w, arrow_h, &style.end_arrow, &color, width); + draw_arrow_head( + &self.ctx, + x2, + y2, + ux, + uy, + arrow_w, + arrow_h, + &style.end_arrow, + &color, + width, + ); lx2 -= ux * arrow_w; ly2 -= uy * arrow_w; } @@ -1414,11 +1690,16 @@ impl Renderer for WebCanvasRenderer { // 그림자 if let Some(ref shadow) = style.shadow { - let opacity = if shadow.alpha > 0 { 1.0 - (shadow.alpha as f64 / 255.0) } else { 1.0 }; + let opacity = if shadow.alpha > 0 { + 1.0 - (shadow.alpha as f64 / 255.0) + } else { + 1.0 + }; let r = (shadow.color >> 0) & 0xFF; let g = (shadow.color >> 8) & 0xFF; let b = (shadow.color >> 16) & 0xFF; - self.ctx.set_shadow_color(&format!("rgba({},{},{},{:.2})", r, g, b, opacity)); + self.ctx + .set_shadow_color(&format!("rgba({},{},{},{:.2})", r, g, b, opacity)); self.ctx.set_shadow_offset_x(shadow.offset_x); self.ctx.set_shadow_offset_y(shadow.offset_y); self.ctx.set_shadow_blur(2.0); @@ -1430,10 +1711,10 @@ impl Renderer for WebCanvasRenderer { // 이중선/삼중선: SVG draw_multi_line과 동일한 오프셋 비율 방식 // (width_ratio, offset_ratio) — offset은 선 중심으로부터의 거리 비율 match style.line_type { - super::LineRenderType::Double | - super::LineRenderType::ThickThinDouble | - super::LineRenderType::ThinThickDouble | - super::LineRenderType::ThinThickThinTriple => { + super::LineRenderType::Double + | super::LineRenderType::ThickThinDouble + | super::LineRenderType::ThinThickDouble + | super::LineRenderType::ThinThickThinTriple => { let lines: Vec<(f64, f64)> = match style.line_type { super::LineRenderType::Double => { vec![(0.30, -0.35), (0.30, 0.35)] @@ -1506,9 +1787,9 @@ impl Renderer for WebCanvasRenderer { if let Some(img) = cached { if img.complete() && img.natural_width() > 0 { - let _ = self.ctx.draw_image_with_html_image_element_and_dw_and_dh( - &img, x, y, w, h, - ); + let _ = self + .ctx + .draw_image_with_html_image_element_and_dw_and_dh(&img, x, y, w, h); return; } } @@ -1517,14 +1798,15 @@ impl Renderer for WebCanvasRenderer { let mime_type = detect_image_mime_type(data); // WMF → SVG 변환 (브라우저는 WMF를 렌더링할 수 없으므로 SVG로 변환) - let (render_data, render_mime): (std::borrow::Cow<[u8]>, &str) = if mime_type == "image/x-wmf" { - match crate::renderer::svg::convert_wmf_to_svg(data) { - Some(svg_bytes) => (std::borrow::Cow::Owned(svg_bytes), "image/svg+xml"), - None => (std::borrow::Cow::Borrowed(data), mime_type), - } - } else { - (std::borrow::Cow::Borrowed(data), mime_type) - }; + let (render_data, render_mime): (std::borrow::Cow<[u8]>, &str) = + if mime_type == "image/x-wmf" { + match crate::renderer::svg::convert_wmf_to_svg(data) { + Some(svg_bytes) => (std::borrow::Cow::Owned(svg_bytes), "image/svg+xml"), + None => (std::borrow::Cow::Borrowed(data), mime_type), + } + } else { + (std::borrow::Cow::Borrowed(data), mime_type) + }; // Base64 인코딩 및 data URL 생성 let base64_data = base64::engine::general_purpose::STANDARD.encode(&*render_data); @@ -1545,9 +1827,9 @@ impl Renderer for WebCanvasRenderer { // 이미지가 즉시 사용 가능하면 그리기 if img.complete() && img.natural_width() > 0 { - let _ = self.ctx.draw_image_with_html_image_element_and_dw_and_dh( - &img, x, y, w, h, - ); + let _ = self + .ctx + .draw_image_with_html_image_element_and_dw_and_dh(&img, x, y, w, h); } // 아직 로드되지 않은 경우: 캐시에 저장되었으므로 // 재렌더링 시 캐시에서 로드 완료된 이미지를 즉시 사용한다. @@ -1568,9 +1850,17 @@ impl Renderer for WebCanvasRenderer { #[cfg(target_arch = "wasm32")] impl WebCanvasRenderer { /// crop 영역만 표시하는 drawImage (9인자 버전) - fn draw_image_cropped(&mut self, data: &[u8], - sx: f64, sy: f64, sw: f64, sh: f64, - dx: f64, dy: f64, dw: f64, dh: f64, + fn draw_image_cropped( + &mut self, + data: &[u8], + sx: f64, + sy: f64, + sw: f64, + sh: f64, + dx: f64, + dy: f64, + dw: f64, + dh: f64, ) { let key = hash_bytes(data); @@ -1581,9 +1871,11 @@ impl WebCanvasRenderer { if let Some(img) = cached { if img.complete() && img.natural_width() > 0 { - let _ = self.ctx.draw_image_with_html_image_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh( - &img, sx, sy, sw, sh, dx, dy, dw, dh, - ); + let _ = self + .ctx + .draw_image_with_html_image_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh( + &img, sx, sy, sw, sh, dx, dy, dw, dh, + ); return; } } @@ -1597,7 +1889,8 @@ impl WebCanvasRenderer { &self, clusters: &[(usize, String)], char_positions: &[f64], - x: f64, y: f64, + x: f64, + y: f64, style: &TextStyle, font_size: f64, ratio: f64, @@ -1607,9 +1900,12 @@ impl WebCanvasRenderer { // 클러스터 단위로 fill/stroke 하는 헬퍼 클로저 let render_pass = |ctx: &web_sys::CanvasRenderingContext2d, - dx: f64, dy: f64, + dx: f64, + dy: f64, fill_color: &str, - stroke: bool, stroke_color: &str, line_width: f64| { + stroke: bool, + stroke_color: &str, + line_width: f64| { ctx.set_fill_style_str(fill_color); if stroke { ctx.set_stroke_style_str(stroke_color); @@ -1617,8 +1913,12 @@ impl WebCanvasRenderer { } for (char_idx, cluster_str) in clusters { let cs: &str = cluster_str; - if cs == " " || cs == "\t" || cs == "\u{2007}" { continue; } - if cs.starts_with(|c: char| c < '\u{0020}' && !matches!(c, '\t' | '\n' | '\r')) { continue; } + if cs == " " || cs == "\t" || cs == "\u{2007}" { + continue; + } + if cs.starts_with(|c: char| c < '\u{0020}' && !matches!(c, '\t' | '\n' | '\r')) { + continue; + } let char_x = x + char_positions[*char_idx] + dx; let char_y = y + dy; @@ -1627,11 +1927,15 @@ impl WebCanvasRenderer { ctx.translate(char_x, char_y).unwrap_or(()); ctx.scale(ratio, 1.0).unwrap_or(()); let _ = ctx.fill_text(cs, 0.0, 0.0); - if stroke { let _ = ctx.stroke_text(cs, 0.0, 0.0); } + if stroke { + let _ = ctx.stroke_text(cs, 0.0, 0.0); + } ctx.restore(); } else { let _ = ctx.fill_text(cs, char_x, char_y); - if stroke { let _ = ctx.stroke_text(cs, char_x, char_y); } + if stroke { + let _ = ctx.stroke_text(cs, char_x, char_y); + } } } }; @@ -1663,7 +1967,15 @@ impl WebCanvasRenderer { // 외곽선 (fillText(흰색) + strokeText(글자색)) if style.outline_type > 0 { let line_width = (font_size / 25.0).max(0.5); - render_pass(&self.ctx, 0.0, 0.0, "#ffffff", true, &text_color_css, line_width); + render_pass( + &self.ctx, + 0.0, + 0.0, + "#ffffff", + true, + &text_color_css, + line_width, + ); } else { // 일반 텍스트 (그림자 위에 원본) render_pass(&self.ctx, 0.0, 0.0, &text_color_css, false, "", 0.0); @@ -1672,16 +1984,36 @@ impl WebCanvasRenderer { /// 글자겹침(CharOverlap)을 Canvas 2D로 렌더링한다. fn draw_char_overlap( - &mut self, text: &str, style: &TextStyle, overlap: &CharOverlapInfo, - bbox_x: f64, bbox_y: f64, bbox_w: f64, bbox_h: f64, + &mut self, + text: &str, + style: &TextStyle, + overlap: &CharOverlapInfo, + bbox_x: f64, + bbox_y: f64, + bbox_w: f64, + bbox_h: f64, ) { - let font_size = if style.font_size > 0.0 { style.font_size } else { 12.0 }; + let font_size = if style.font_size > 0.0 { + style.font_size + } else { + 12.0 + }; let chars: Vec = text.chars().collect(); - if chars.is_empty() { return; } + if chars.is_empty() { + return; + } // PUA 다자리 숫자 디코딩 시도 if let Some(number_str) = decode_pua_overlap_number(&chars) { - self.draw_char_overlap_combined(style, overlap, &number_str, bbox_x, bbox_y, bbox_w, bbox_h); + self.draw_char_overlap_combined( + style, + overlap, + &number_str, + bbox_x, + bbox_y, + bbox_w, + bbox_h, + ); return; } @@ -1689,7 +2021,11 @@ impl WebCanvasRenderer { self.ctx.save(); let box_size = font_size; - let char_advance = if chars.len() > 1 { bbox_w / chars.len() as f64 } else { box_size }; + let char_advance = if chars.len() > 1 { + bbox_w / chars.len() as f64 + } else { + box_size + }; let is_reversed = overlap.border_type == 2 || overlap.border_type == 4; let is_circle = overlap.border_type == 1 || overlap.border_type == 2; @@ -1718,7 +2054,10 @@ impl WebCanvasRenderer { }; let font_weight = if style.bold { "bold " } else { "" }; let font_style_str = if style.italic { "italic " } else { "" }; - let font = format!("{}{}{:.3}px {}", font_style_str, font_weight, inner_font_size, font_family); + let font = format!( + "{}{}{:.3}px {}", + font_style_str, font_weight, inner_font_size, font_family + ); for (i, ch) in chars.iter().enumerate() { let display_str = { @@ -1770,15 +2109,29 @@ impl WebCanvasRenderer { /// PUA 다자리 숫자를 하나의 도형 안에 합쳐서 Canvas 렌더링 fn draw_char_overlap_combined( - &mut self, style: &TextStyle, overlap: &CharOverlapInfo, - number_str: &str, bbox_x: f64, bbox_y: f64, bbox_w: f64, bbox_h: f64, + &mut self, + style: &TextStyle, + overlap: &CharOverlapInfo, + number_str: &str, + bbox_x: f64, + bbox_y: f64, + bbox_w: f64, + bbox_h: f64, ) { - let font_size = if style.font_size > 0.0 { style.font_size } else { 12.0 }; + let font_size = if style.font_size > 0.0 { + style.font_size + } else { + 12.0 + }; let box_size = font_size; self.ctx.save(); - let effective_border = if overlap.border_type == 0 { 1u8 } else { overlap.border_type }; + let effective_border = if overlap.border_type == 0 { + 1u8 + } else { + overlap.border_type + }; let is_reversed = effective_border == 2 || effective_border == 4; let is_circle = effective_border == 1 || effective_border == 2; let is_rect = effective_border == 3 || effective_border == 4; @@ -1834,11 +2187,18 @@ impl WebCanvasRenderer { // 장평 조절: 숫자 자릿수에 따라 scaleX로 폭 압축 let digit_count = number_str.len(); - let scale_x = if digit_count > 1 { 0.7 / digit_count as f64 * 2.0 } else { 1.0 }; + let scale_x = if digit_count > 1 { + 0.7 / digit_count as f64 * 2.0 + } else { + 1.0 + }; let font_weight = if style.bold { "bold " } else { "" }; let font_style_str = if style.italic { "italic " } else { "" }; - let font = format!("{}{}{:.3}px {}", font_style_str, font_weight, inner_font_size, font_family); + let font = format!( + "{}{}{:.3}px {}", + font_style_str, font_weight, inner_font_size, font_family + ); self.ctx.set_font(&font); self.ctx.set_fill_style_str(&text_color); @@ -1915,7 +2275,16 @@ impl WebCanvasRenderer { } } - fn draw_wave_canvas(&self, x1: f64, y1: f64, x2: f64, color: &str, width: f64, wave_h: f64, wave_w: f64) { + fn draw_wave_canvas( + &self, + x1: f64, + y1: f64, + x2: f64, + color: &str, + width: f64, + wave_h: f64, + wave_w: f64, + ) { self.ctx.save(); self.ctx.begin_path(); self.ctx.move_to(x1, y1); @@ -1934,7 +2303,16 @@ impl WebCanvasRenderer { self.ctx.restore(); } - fn draw_single_canvas_line(&self, x1: f64, y1: f64, x2: f64, y2: f64, color: &str, width: f64, dash: &[f64]) { + fn draw_single_canvas_line( + &self, + x1: f64, + y1: f64, + x2: f64, + y2: f64, + color: &str, + width: f64, + dash: &[f64], + ) { self.ctx.save(); self.ctx.begin_path(); self.ctx.move_to(x1, y1); @@ -1977,11 +2355,22 @@ impl WebCanvasRenderer { let src_y = ct as f64 / scale_x; let src_w = (cr - cl) as f64 / scale_x; let src_h = (cb - ct) as f64 / scale_x; - let is_cropped = src_x > 0.5 || src_y > 0.5 - || (src_w - img_w).abs() > 1.0 || (src_h - img_h).abs() > 1.0; + let is_cropped = src_x > 0.5 + || src_y > 0.5 + || (src_w - img_w).abs() > 1.0 + || (src_h - img_h).abs() > 1.0; if is_cropped { - self.draw_image_cropped(data, src_x, src_y, src_w, src_h, - bbox.x, bbox.y, bbox.width, bbox.height); + self.draw_image_cropped( + data, + src_x, + src_y, + src_w, + src_h, + bbox.x, + bbox.y, + bbox.width, + bbox.height, + ); return; } } @@ -2007,14 +2396,31 @@ impl WebCanvasRenderer { ImageFillMode::LeftTop => (bbox.x, bbox.y), ImageFillMode::CenterTop => (bbox.x + (bbox.width - img_width) / 2.0, bbox.y), ImageFillMode::RightTop => (bbox.x + bbox.width - img_width, bbox.y), - ImageFillMode::LeftCenter => (bbox.x, bbox.y + (bbox.height - img_height) / 2.0), - ImageFillMode::Center => (bbox.x + (bbox.width - img_width) / 2.0, bbox.y + (bbox.height - img_height) / 2.0), - ImageFillMode::RightCenter => (bbox.x + bbox.width - img_width, bbox.y + (bbox.height - img_height) / 2.0), + ImageFillMode::LeftCenter => { + (bbox.x, bbox.y + (bbox.height - img_height) / 2.0) + } + ImageFillMode::Center => ( + bbox.x + (bbox.width - img_width) / 2.0, + bbox.y + (bbox.height - img_height) / 2.0, + ), + ImageFillMode::RightCenter => ( + bbox.x + bbox.width - img_width, + bbox.y + (bbox.height - img_height) / 2.0, + ), ImageFillMode::LeftBottom => (bbox.x, bbox.y + bbox.height - img_height), - ImageFillMode::CenterBottom => (bbox.x + (bbox.width - img_width) / 2.0, bbox.y + bbox.height - img_height), - ImageFillMode::RightBottom => (bbox.x + bbox.width - img_width, bbox.y + bbox.height - img_height), - ImageFillMode::TileAll | ImageFillMode::TileHorzTop | ImageFillMode::TileHorzBottom - | ImageFillMode::TileVertLeft | ImageFillMode::TileVertRight => (bbox.x, bbox.y), + ImageFillMode::CenterBottom => ( + bbox.x + (bbox.width - img_width) / 2.0, + bbox.y + bbox.height - img_height, + ), + ImageFillMode::RightBottom => ( + bbox.x + bbox.width - img_width, + bbox.y + bbox.height - img_height, + ), + ImageFillMode::TileAll + | ImageFillMode::TileHorzTop + | ImageFillMode::TileHorzBottom + | ImageFillMode::TileVertLeft + | ImageFillMode::TileVertRight => (bbox.x, bbox.y), _ => (bbox.x, bbox.y), }; @@ -2038,7 +2444,11 @@ impl WebCanvasRenderer { } } ImageFillMode::TileHorzTop | ImageFillMode::TileHorzBottom => { - let ty = if mode == ImageFillMode::TileHorzTop { bbox.y } else { bbox.y + bbox.height - img_height }; + let ty = if mode == ImageFillMode::TileHorzTop { + bbox.y + } else { + bbox.y + bbox.height - img_height + }; let mut tx = bbox.x; while tx < bbox.x + bbox.width { self.draw_image(data, tx, ty, img_width, img_height); @@ -2046,7 +2456,11 @@ impl WebCanvasRenderer { } } ImageFillMode::TileVertLeft | ImageFillMode::TileVertRight => { - let tx = if mode == ImageFillMode::TileVertLeft { bbox.x } else { bbox.x + bbox.width - img_width }; + let tx = if mode == ImageFillMode::TileVertLeft { + bbox.x + } else { + bbox.x + bbox.width - img_width + }; let mut ty = bbox.y; while ty < bbox.y + bbox.height { self.draw_image(data, tx, ty, img_width, img_height); @@ -2093,9 +2507,12 @@ fn calc_arrow_dims(stroke_width: f64, line_len: f64, arrow_size: u8) -> (f64, f6 #[cfg(target_arch = "wasm32")] fn draw_arrow_head( ctx: &web_sys::CanvasRenderingContext2d, - tip_x: f64, tip_y: f64, - dir_x: f64, dir_y: f64, - arrow_w: f64, arrow_h: f64, + tip_x: f64, + tip_y: f64, + dir_x: f64, + dir_y: f64, + arrow_w: f64, + arrow_h: f64, arrow_style: &super::ArrowStyle, color: &str, stroke_width: f64, @@ -2106,7 +2523,7 @@ fn draw_arrow_head( // along: 선 방향 (tip → base), perp: 수직 방향 let along_x = -dir_x; // tip에서 base 방향 let along_y = -dir_y; - let perp_x = dir_y; // 90도 회전 (오른쪽) + let perp_x = dir_y; // 90도 회전 (오른쪽) let perp_y = -dir_x; let half_h = arrow_h / 2.0; @@ -2148,10 +2565,10 @@ fn draw_arrow_head( } ArrowStyle::Diamond | ArrowStyle::OpenDiamond => { let half_w = arrow_w / 2.0; - let (px1, py1) = to_world(0.0, 0.0); // 앞 꼭짓점 (tip 쪽) + let (px1, py1) = to_world(0.0, 0.0); // 앞 꼭짓점 (tip 쪽) let (px2, py2) = to_world(half_w, -half_h); // 좌 - let (px3, py3) = to_world(arrow_w, 0.0); // 뒤 꼭짓점 - let (px4, py4) = to_world(half_w, half_h); // 우 + let (px3, py3) = to_world(arrow_w, 0.0); // 뒤 꼭짓점 + let (px4, py4) = to_world(half_w, half_h); // 우 ctx.begin_path(); ctx.move_to(px1, py1); ctx.line_to(px2, py2); diff --git a/src/serializer/body_text.rs b/src/serializer/body_text.rs index 883cfcd8..0137d572 100644 --- a/src/serializer/body_text.rs +++ b/src/serializer/body_text.rs @@ -54,10 +54,18 @@ pub fn serialize_paragraph_list( /// 단일 문단을 레코드로 직렬화 (MSB를 위치 기반으로 강제 설정) /// /// is_last: 이 문단이 현재 스코프(섹션/셀/텍스트박스 등)의 마지막 문단인지 여부 -fn serialize_paragraph_with_msb(para: &Paragraph, base_level: u16, is_last: bool, records: &mut Vec) { +fn serialize_paragraph_with_msb( + para: &Paragraph, + base_level: u16, + is_last: bool, + records: &mut Vec, +) { // HWP는 모든 문단에 최소 1개의 PARA_CHAR_SHAPE 엔트리 필요 // char_shapes가 비어있으면 기본 엔트리(위치 0, char_shape_id 0)를 사용 - let default_char_shape = [CharShapeRef { start_pos: 0, char_shape_id: 0 }]; + let default_char_shape = [CharShapeRef { + start_pos: 0, + char_shape_id: 0, + }]; let effective_char_shapes: &[CharShapeRef] = if para.char_shapes.is_empty() { &default_char_shape } else { @@ -91,7 +99,13 @@ fn serialize_paragraph_with_msb(para: &Paragraph, base_level: u16, is_last: bool tag_id: tags::HWPTAG_PARA_HEADER, level: base_level, size: 0, - data: serialize_para_header_with_mask(para, effective_char_shapes.len(), is_last, actual_control_mask, actual_char_count), + data: serialize_para_header_with_mask( + para, + effective_char_shapes.len(), + is_last, + actual_control_mask, + actual_char_count, + ), }); // PARA_TEXT @@ -139,7 +153,9 @@ fn serialize_paragraph_with_msb(para: &Paragraph, base_level: u16, is_last: bool // CTRL_HEADER (컨트롤별) + CTRL_DATA (있으면) for (ctrl_idx, ctrl) in para.controls.iter().enumerate() { - let ctrl_data_record = para.ctrl_data_records.get(ctrl_idx) + let ctrl_data_record = para + .ctrl_data_records + .get(ctrl_idx) .and_then(|opt| opt.as_ref()) .map(|v| v.as_slice()); super::control::serialize_control(ctrl, base_level + 1, ctrl_data_record, records); @@ -181,7 +197,13 @@ fn compute_control_mask(para: &Paragraph) -> u32 { /// /// 레이아웃: char_count(u32) + control_mask(u32) + para_shape_id(u16) + style_id(u8) + break_type(u8) /// + numCharShapes(u16) + numRangeTags(u16) + numLineSegs(u16) + instanceId(u32) + [추가 바이트] -fn serialize_para_header_with_mask(para: &Paragraph, num_char_shapes: usize, is_last: bool, control_mask: u32, char_count: u32) -> Vec { +fn serialize_para_header_with_mask( + para: &Paragraph, + num_char_shapes: usize, + is_last: bool, + control_mask: u32, + char_count: u32, +) -> Vec { let mut w = ByteWriter::new(); // MSB는 위치 기반으로 결정: 현재 스코프의 마지막 문단만 MSB=1 @@ -276,7 +298,9 @@ fn serialize_para_text(para: &Paragraph) -> Vec { let mut trailing_orphan_ends: Vec = Vec::new(); for fr in ¶.field_ranges { - let ctrl_id = if let Some(crate::model::control::Control::Field(f)) = para.controls.get(fr.control_idx) { + let ctrl_id = if let Some(crate::model::control::Control::Field(f)) = + para.controls.get(fr.control_idx) + { f.ctrl_id } else { 0 @@ -286,7 +310,10 @@ fn serialize_para_text(para: &Paragraph) -> Vec { } else { // trailing FIELD_END: control_idx가 남은 컨트롤에 포함되는지 판별은 // 메인 루프 후에 수행 (ctrl_idx 확정 후) - trailing_end_after_ctrl.entry(fr.control_idx).or_default().push(ctrl_id); + trailing_end_after_ctrl + .entry(fr.control_idx) + .or_default() + .push(ctrl_id); } } @@ -323,7 +350,9 @@ fn serialize_para_text(para: &Paragraph) -> Vec { code_units.push(cu); } } else { - for _ in 0..7 { code_units.push(0); } + for _ in 0..7 { + code_units.push(0); + } } tab_idx += 1; prev_end = offset + 8; @@ -339,7 +368,9 @@ fn serialize_para_text(para: &Paragraph) -> Vec { c => { let mut buf = [0u16; 2]; let encoded = c.encode_utf16(&mut buf); - for cu in encoded.iter() { code_units.push(*cu); } + for cu in encoded.iter() { + code_units.push(*cu); + } prev_end = offset + encoded.len() as u32; } } @@ -793,7 +824,7 @@ mod tests { #[test] fn test_control_char_code() { assert_eq!( - control_char_code_and_id(&Control::SectionDef(Box::new(SectionDef::default()))).0, + control_char_code_and_id(&Control::SectionDef(Box::default())).0, 0x0002 ); assert_eq!( diff --git a/src/serializer/cfb_writer.rs b/src/serializer/cfb_writer.rs index 9d87ce98..9b750490 100644 --- a/src/serializer/cfb_writer.rs +++ b/src/serializer/cfb_writer.rs @@ -10,8 +10,8 @@ use std::io::Write; -use crate::model::bin_data::{BinData, BinDataType}; use crate::model::bin_data::BinDataContent; +use crate::model::bin_data::{BinData, BinDataType}; use crate::model::document::{Document, Preview}; use super::body_text::serialize_section; @@ -123,7 +123,8 @@ fn write_hwp_cfb( // 4. /BinData/BIN{XXXX}.{ext} // BinData는 개별 압축 속성에 따라 재압축 for content in bin_data_content { - let (storage_id, ext, should_compress) = find_bin_data_info_with_compress(bin_data_list, content, compressed); + let (storage_id, ext, should_compress) = + find_bin_data_info_with_compress(bin_data_list, content, compressed); let storage_name = format!("BIN{:04X}.{}", storage_id, ext); let path = format!("/BinData/{}", storage_name); let data = if should_compress { @@ -191,6 +192,5 @@ fn find_bin_data_info_with_compress<'a>( (content.id, &content.extension, doc_compressed) } - #[cfg(test)] mod tests; diff --git a/src/serializer/cfb_writer/tests.rs b/src/serializer/cfb_writer/tests.rs index fbf4896c..ae389f69 100644 --- a/src/serializer/cfb_writer/tests.rs +++ b/src/serializer/cfb_writer/tests.rs @@ -225,8 +225,7 @@ fn test_full_roundtrip_uncompressed() { // BodyText 라운드트립 let section_data = cfb.read_body_text_section(0, false, false).unwrap(); - let parsed_section = - crate::parser::body_text::parse_body_text_section(§ion_data).unwrap(); + let parsed_section = crate::parser::body_text::parse_body_text_section(§ion_data).unwrap(); assert_eq!(parsed_section.paragraphs.len(), 1); assert_eq!(parsed_section.paragraphs[0].text, "안녕하세요"); } @@ -294,8 +293,7 @@ fn test_full_roundtrip_compressed() { // BodyText 라운드트립 (압축 해제) let section_data = cfb.read_body_text_section(0, true, false).unwrap(); - let parsed_section = - crate::parser::body_text::parse_body_text_section(§ion_data).unwrap(); + let parsed_section = crate::parser::body_text::parse_body_text_section(§ion_data).unwrap(); assert_eq!(parsed_section.paragraphs[0].text, "Hello World"); } @@ -358,22 +356,35 @@ fn test_serialize_after_edit_roundtrip() { assert!(result.is_ok(), "{}: 텍스트 삽입 실패", file_path); // 직렬화 - let bytes = doc.export_hwp_native() + let bytes = doc + .export_hwp_native() .unwrap_or_else(|e| panic!("{}: 직렬화 실패: {}", file_path, e)); // CFB 매직 확인 - assert_eq!(&bytes[0..4], &[0xD0, 0xCF, 0x11, 0xE0], - "{}: CFB 매직 불일치", file_path); + assert_eq!( + &bytes[0..4], + &[0xD0, 0xCF, 0x11, 0xE0], + "{}: CFB 매직 불일치", + file_path + ); // 라운드트립: 다시 파싱 가능한지 검증 let parsed = crate::parser::parse_hwp(&bytes); - assert!(parsed.is_ok(), "{}: 라운드트립 파싱 실패: {:?}", file_path, parsed.err()); + assert!( + parsed.is_ok(), + "{}: 라운드트립 파싱 실패: {:?}", + file_path, + parsed.err() + ); let parsed = parsed.unwrap(); let para_text = &parsed.sections[0].paragraphs[0].text; - assert!(para_text.starts_with("테스트추가"), - "{}: 삽입된 텍스트 미발견, 실제: '{}'", file_path, - ¶_text[..para_text.len().min(30)]); + assert!( + para_text.starts_with("테스트추가"), + "{}: 삽입된 텍스트 미발견, 실제: '{}'", + file_path, + ¶_text[..para_text.len().min(30)] + ); eprintln!("{}: 라운드트립 성공 ({}KB)", file_path, bytes.len() / 1024); } @@ -411,8 +422,12 @@ fn test_serialize_real_hwp_files() { Ok(bytes) => { eprintln!(" 직렬화 성공: {}KB", bytes.len() / 1024); // CFB 시그니처 확인 - assert_eq!(&bytes[0..4], &[0xD0, 0xCF, 0x11, 0xE0], - "{}: CFB 시그니처 불일치", fname); + assert_eq!( + &bytes[0..4], + &[0xD0, 0xCF, 0x11, 0xE0], + "{}: CFB 시그니처 불일치", + fname + ); } Err(e) => { panic!("{}: 직렬화 실패: {}", fname, e); @@ -441,7 +456,11 @@ fn test_table_structure_change_roundtrip() { doc.insert_table_row_native(0, 3, 0, 0, true).unwrap(); let bytes = doc.export_hwp_native().unwrap(); let parsed = crate::parser::parse_hwp(&bytes); - assert!(parsed.is_ok(), "행 추가 후 라운드트립 실패: {:?}", parsed.err()); + assert!( + parsed.is_ok(), + "행 추가 후 라운드트립 실패: {:?}", + parsed.err() + ); eprintln!("행 추가 라운드트립: 성공"); } @@ -452,7 +471,11 @@ fn test_table_structure_change_roundtrip() { doc.insert_table_column_native(0, 3, 0, 0, true).unwrap(); let bytes = doc.export_hwp_native().unwrap(); let parsed = crate::parser::parse_hwp(&bytes); - assert!(parsed.is_ok(), "열 추가 후 라운드트립 실패: {:?}", parsed.err()); + assert!( + parsed.is_ok(), + "열 추가 후 라운드트립 실패: {:?}", + parsed.err() + ); eprintln!("열 추가 라운드트립: 성공"); } @@ -463,7 +486,11 @@ fn test_table_structure_change_roundtrip() { doc.delete_table_row_native(0, 3, 0, 0).unwrap(); let bytes = doc.export_hwp_native().unwrap(); let parsed = crate::parser::parse_hwp(&bytes); - assert!(parsed.is_ok(), "행 삭제 후 라운드트립 실패: {:?}", parsed.err()); + assert!( + parsed.is_ok(), + "행 삭제 후 라운드트립 실패: {:?}", + parsed.err() + ); eprintln!("행 삭제 라운드트립: 성공"); } @@ -474,7 +501,11 @@ fn test_table_structure_change_roundtrip() { doc.delete_table_column_native(0, 3, 0, 0).unwrap(); let bytes = doc.export_hwp_native().unwrap(); let parsed = crate::parser::parse_hwp(&bytes); - assert!(parsed.is_ok(), "열 삭제 후 라운드트립 실패: {:?}", parsed.err()); + assert!( + parsed.is_ok(), + "열 삭제 후 라운드트립 실패: {:?}", + parsed.err() + ); eprintln!("열 삭제 라운드트립: 성공"); } } @@ -501,15 +532,19 @@ fn test_delete_table_control_roundtrip() { // 라운드트립: 직렬화 → 파싱 let bytes = doc.export_hwp_native().unwrap(); let parsed = crate::parser::parse_hwp(&bytes); - assert!(parsed.is_ok(), "표 삭제 후 라운드트립 실패: {:?}", parsed.err()); + assert!( + parsed.is_ok(), + "표 삭제 후 라운드트립 실패: {:?}", + parsed.err() + ); eprintln!("표 삭제 라운드트립: 성공"); } /// 원본 HWP와 직렬화 결과를 스트림별로 비교하는 진단 테스트 #[test] fn test_roundtrip_stream_comparison() { - use std::path::Path; use crate::parser::record::Record; + use std::path::Path; let path = Path::new("samples/hwp_table_test.hwp"); if !path.exists() { @@ -534,7 +569,13 @@ fn test_roundtrip_stream_comparison() { // 파싱 let doc = crate::parser::parse_hwp(&original_data).unwrap(); eprintln!("\n=== Document 구조 ==="); - eprintln!(" 버전: {}.{}.{}.{}", doc.header.version.major, doc.header.version.minor, doc.header.version.build, doc.header.version.revision); + eprintln!( + " 버전: {}.{}.{}.{}", + doc.header.version.major, + doc.header.version.minor, + doc.header.version.build, + doc.header.version.revision + ); eprintln!(" flags: 0x{:08X}", doc.header.flags); eprintln!(" compressed: {}", doc.header.compressed); eprintln!(" 섹션수: {}", doc.sections.len()); @@ -575,13 +616,20 @@ fn test_roundtrip_stream_comparison() { // FileHeader 비교 let ser_header = ser_cfb.read_file_header().unwrap(); eprintln!("\n=== FileHeader 비교 (256바이트) ==="); - eprintln!(" 원본 크기: {}, 직렬화 크기: {}", orig_header.len(), ser_header.len()); + eprintln!( + " 원본 크기: {}, 직렬화 크기: {}", + orig_header.len(), + ser_header.len() + ); let mut header_diffs = 0; for i in 0..256.min(orig_header.len()).min(ser_header.len()) { if orig_header[i] != ser_header[i] { header_diffs += 1; if header_diffs <= 20 { - eprintln!(" [{}] 원본=0x{:02X} 직렬화=0x{:02X}", i, orig_header[i], ser_header[i]); + eprintln!( + " [{}] 원본=0x{:02X} 직렬화=0x{:02X}", + i, orig_header[i], ser_header[i] + ); } } } @@ -624,8 +672,14 @@ fn test_roundtrip_stream_comparison() { let data_match = o.data == s.data; if !tag_match || !level_match || !data_match { let tag_name = crate::parser::tags::tag_name(o.tag_id); - eprintln!(" [{}] {} (tag={}, level={}, size={})", - i, tag_name, o.tag_id, o.level, o.data.len()); + eprintln!( + " [{}] {} (tag={}, level={}, size={})", + i, + tag_name, + o.tag_id, + o.level, + o.data.len() + ); if !tag_match { eprintln!(" TAG 불일치: 원본={} 직렬화={}", o.tag_id, s.tag_id); } @@ -633,11 +687,18 @@ fn test_roundtrip_stream_comparison() { eprintln!(" LEVEL 불일치: 원본={} 직렬화={}", o.level, s.level); } if !data_match { - eprintln!(" DATA 불일치: 원본 {}B vs 직렬화 {}B", o.data.len(), s.data.len()); + eprintln!( + " DATA 불일치: 원본 {}B vs 직렬화 {}B", + o.data.len(), + s.data.len() + ); let min_len = o.data.len().min(s.data.len()).min(64); for j in 0..min_len { if o.data[j] != s.data[j] { - eprintln!(" 첫 차이 offset={}: 0x{:02X} vs 0x{:02X}", j, o.data[j], s.data[j]); + eprintln!( + " 첫 차이 offset={}: 0x{:02X} vs 0x{:02X}", + j, o.data[j], s.data[j] + ); break; } } @@ -646,11 +707,23 @@ fn test_roundtrip_stream_comparison() { } (Some(o), None) => { let tag_name = crate::parser::tags::tag_name(o.tag_id); - eprintln!(" [{}] 직렬화에 누락: {} (tag={}, size={})", i, tag_name, o.tag_id, o.data.len()); + eprintln!( + " [{}] 직렬화에 누락: {} (tag={}, size={})", + i, + tag_name, + o.tag_id, + o.data.len() + ); } (None, Some(s)) => { let tag_name = crate::parser::tags::tag_name(s.tag_id); - eprintln!(" [{}] 직렬화에 추가: {} (tag={}, size={})", i, tag_name, s.tag_id, s.data.len()); + eprintln!( + " [{}] 직렬화에 추가: {} (tag={}, size={})", + i, + tag_name, + s.tag_id, + s.data.len() + ); } _ => {} } @@ -691,21 +764,37 @@ fn test_roundtrip_stream_comparison() { let data_match = o.data == s.data; if !tag_match || !level_match || !data_match { let tag_name = crate::parser::tags::tag_name(o.tag_id); - eprintln!(" [{}] {} (tag={}, level={}, size={})", - i, tag_name, o.tag_id, o.level, o.data.len()); + eprintln!( + " [{}] {} (tag={}, level={}, size={})", + i, + tag_name, + o.tag_id, + o.level, + o.data.len() + ); if !tag_match { let s_tag_name = crate::parser::tags::tag_name(s.tag_id); - eprintln!(" TAG 불일치: 원본={}({}) 직렬화={}({})", o.tag_id, tag_name, s.tag_id, s_tag_name); + eprintln!( + " TAG 불일치: 원본={}({}) 직렬화={}({})", + o.tag_id, tag_name, s.tag_id, s_tag_name + ); } if !level_match { eprintln!(" LEVEL 불일치: 원본={} 직렬화={}", o.level, s.level); } if !data_match { - eprintln!(" DATA 불일치: 원본 {}B vs 직렬화 {}B", o.data.len(), s.data.len()); + eprintln!( + " DATA 불일치: 원본 {}B vs 직렬화 {}B", + o.data.len(), + s.data.len() + ); let min_len = o.data.len().min(s.data.len()).min(64); for j in 0..min_len { if o.data[j] != s.data[j] { - eprintln!(" 첫 차이 offset={}: 0x{:02X} vs 0x{:02X}", j, o.data[j], s.data[j]); + eprintln!( + " 첫 차이 offset={}: 0x{:02X} vs 0x{:02X}", + j, o.data[j], s.data[j] + ); break; } } @@ -714,11 +803,25 @@ fn test_roundtrip_stream_comparison() { } (Some(o), None) => { let tag_name = crate::parser::tags::tag_name(o.tag_id); - eprintln!(" [{}] 직렬화에 누락: {} (tag={}, level={}, size={})", i, tag_name, o.tag_id, o.level, o.data.len()); + eprintln!( + " [{}] 직렬화에 누락: {} (tag={}, level={}, size={})", + i, + tag_name, + o.tag_id, + o.level, + o.data.len() + ); } (None, Some(s)) => { let tag_name = crate::parser::tags::tag_name(s.tag_id); - eprintln!(" [{}] 직렬화에 추가: {} (tag={}, level={}, size={})", i, tag_name, s.tag_id, s.level, s.data.len()); + eprintln!( + " [{}] 직렬화에 추가: {} (tag={}, level={}, size={})", + i, + tag_name, + s.tag_id, + s.level, + s.data.len() + ); } _ => {} } @@ -760,8 +863,16 @@ fn test_roundtrip_stream_comparison() { if saved_fh.len() >= 32 { let sig = std::str::from_utf8(&saved_fh[0..17]).unwrap_or("?"); eprintln!(" 시그니처: '{}'", sig); - eprintln!(" 버전: {}.{}.{}.{}", saved_fh[35], saved_fh[34], saved_fh[33], saved_fh[32]); - let flags = u32::from_le_bytes([saved_fh[36], saved_fh[37], saved_fh[38], saved_fh[39]]); + eprintln!( + " 버전: {}.{}.{}.{}", + saved_fh[35], saved_fh[34], saved_fh[33], saved_fh[32] + ); + let flags = u32::from_le_bytes([ + saved_fh[36], + saved_fh[37], + saved_fh[38], + saved_fh[39], + ]); eprintln!(" flags: 0x{:08X} (compressed={})", flags, flags & 1 != 0); } } @@ -818,8 +929,16 @@ fn test_cfb_structure_comparison() { let saved_data = std::fs::read(saved_path).unwrap(); eprintln!("\n=== CFB 헤더 비교 (512바이트) ==="); - eprintln!("원본 크기: {} bytes ({} sectors)", orig_data.len(), (orig_data.len() - 512) / 512); - eprintln!("저장본 크기: {} bytes ({} sectors)", saved_data.len(), (saved_data.len() - 512) / 512); + eprintln!( + "원본 크기: {} bytes ({} sectors)", + orig_data.len(), + (orig_data.len() - 512) / 512 + ); + eprintln!( + "저장본 크기: {} bytes ({} sectors)", + saved_data.len(), + (saved_data.len() - 512) / 512 + ); // 헤더 주요 필드 비교 let orig_hdr = &orig_data[..512]; @@ -846,7 +965,10 @@ fn test_cfb_structure_comparison() { let o = &orig_hdr[*start..*end]; let s = &saved_hdr[*start..*end]; if o != s { - eprintln!(" {} [{}..{}]: 원본={:?} 저장본={:?}", name, start, end, o, s); + eprintln!( + " {} [{}..{}]: 원본={:?} 저장본={:?}", + name, start, end, o, s + ); } } @@ -854,8 +976,18 @@ fn test_cfb_structure_comparison() { eprintln!("\n--- DIFAT ---"); for i in 0..5 { let offset = 76 + i * 4; - let o = u32::from_le_bytes([orig_hdr[offset], orig_hdr[offset+1], orig_hdr[offset+2], orig_hdr[offset+3]]); - let s = u32::from_le_bytes([saved_hdr[offset], saved_hdr[offset+1], saved_hdr[offset+2], saved_hdr[offset+3]]); + let o = u32::from_le_bytes([ + orig_hdr[offset], + orig_hdr[offset + 1], + orig_hdr[offset + 2], + orig_hdr[offset + 3], + ]); + let s = u32::from_le_bytes([ + saved_hdr[offset], + saved_hdr[offset + 1], + saved_hdr[offset + 2], + saved_hdr[offset + 3], + ]); if o != 0xFFFFFFFF || s != 0xFFFFFFFF { eprintln!(" DIFAT[{}]: 원본=0x{:08X} 저장본=0x{:08X}", i, o, s); } @@ -869,8 +1001,12 @@ fn test_cfb_structure_comparison() { fn walk_entries(cf: &cfb::CompoundFile>>, label: &str) { eprintln!(" [{}]", label); for entry in cf.walk() { - eprintln!(" {:?} path={} len={}", - entry.name(), entry.path().display(), entry.len()); + eprintln!( + " {:?} path={} len={}", + entry.name(), + entry.path().display(), + entry.len() + ); } } walk_entries(&orig_cfb, "원본"); @@ -879,37 +1015,47 @@ fn test_cfb_structure_comparison() { // 원본의 Raw 디렉토리 엔트리 바이트 비교 eprintln!("\n=== Raw 디렉토리 엔트리 비교 ==="); // 원본 디렉토리: 첫 섹터부터 - let orig_first_dir = u32::from_le_bytes([orig_hdr[48], orig_hdr[49], orig_hdr[50], orig_hdr[51]]) as usize; - let saved_first_dir = u32::from_le_bytes([saved_hdr[48], saved_hdr[49], saved_hdr[50], saved_hdr[51]]) as usize; + let orig_first_dir = + u32::from_le_bytes([orig_hdr[48], orig_hdr[49], orig_hdr[50], orig_hdr[51]]) as usize; + let saved_first_dir = + u32::from_le_bytes([saved_hdr[48], saved_hdr[49], saved_hdr[50], saved_hdr[51]]) as usize; eprintln!(" 원본 첫 Dir 섹터: {}", orig_first_dir); eprintln!(" 저장본 첫 Dir 섹터: {}", saved_first_dir); // 각 디렉토리 엔트리 상세 비교 fn read_entry_name(entry: &[u8]) -> String { let name_size = u16::from_le_bytes([entry[64], entry[65]]) as usize; - if name_size <= 2 { return "(empty)".to_string(); } + if name_size <= 2 { + return "(empty)".to_string(); + } let char_count = (name_size / 2) - 1; let mut chars = Vec::new(); for j in 0..char_count { - let ch = u16::from_le_bytes([entry[j*2], entry[j*2+1]]); + let ch = u16::from_le_bytes([entry[j * 2], entry[j * 2 + 1]]); chars.push(ch); } String::from_utf16_lossy(&chars) } fn read_entry_at(data: &[u8], off: usize) -> Option<(String, u8)> { - if off + 128 > data.len() { return None; } - let e = &data[off..off+128]; + if off + 128 > data.len() { + return None; + } + let e = &data[off..off + 128]; let obj_type = e[66]; - if obj_type == 0 { return None; } + if obj_type == 0 { + return None; + } let name_size = u16::from_le_bytes([e[64], e[65]]) as usize; - if name_size <= 2 { return Some(("(empty)".to_string(), obj_type)); } + if name_size <= 2 { + return Some(("(empty)".to_string(), obj_type)); + } let char_count = ((name_size / 2) - 1).min(31); let mut chars = Vec::new(); for j in 0..char_count { let pos = j * 2; if pos + 1 < 64 { - let ch = u16::from_le_bytes([e[pos], e[pos+1]]); + let ch = u16::from_le_bytes([e[pos], e[pos + 1]]); chars.push(ch); } } @@ -925,12 +1071,22 @@ fn test_cfb_structure_comparison() { let mut fat = Vec::new(); for fi in 0..fat_sectors { let difat_off = 76 + fi * 4; - let fat_sid = u32::from_le_bytes([data[difat_off], data[difat_off+1], data[difat_off+2], data[difat_off+3]]) as usize; + let fat_sid = u32::from_le_bytes([ + data[difat_off], + data[difat_off + 1], + data[difat_off + 2], + data[difat_off + 3], + ]) as usize; let fat_off = 512 + fat_sid * 512; for j in 0..128 { let entry_off = fat_off + j * 4; if entry_off + 4 <= data.len() { - let v = u32::from_le_bytes([data[entry_off], data[entry_off+1], data[entry_off+2], data[entry_off+3]]); + let v = u32::from_le_bytes([ + data[entry_off], + data[entry_off + 1], + data[entry_off + 2], + data[entry_off + 3], + ]); fat.push(v); } } @@ -950,13 +1106,21 @@ fn test_cfb_structure_comparison() { for &sec in &dir_sectors { for slot in 0..4 { let off = 512 + sec * 512 + slot * 128; - if off + 128 > data.len() { continue; } - let e = &data[off..off+128]; + if off + 128 > data.len() { + continue; + } + let e = &data[off..off + 128]; let obj_type = e[66]; - if obj_type == 0 { entry_idx += 1; continue; } + if obj_type == 0 { + entry_idx += 1; + continue; + } let name = match read_entry_at(data, off) { Some((n, _)) => n, - None => { entry_idx += 1; continue; } + None => { + entry_idx += 1; + continue; + } }; let color = e[67]; let left = u32::from_le_bytes([e[68], e[69], e[70], e[71]]); @@ -966,9 +1130,23 @@ fn test_cfb_structure_comparison() { let size = u32::from_le_bytes([e[120], e[121], e[122], e[123]]); let clsid = &e[80..96]; let clsid_nonzero = clsid.iter().any(|&b| b != 0); - eprintln!(" [{}] '{}' type={} color={} start={} size={} L/R/C={}/{}/{}{}", - entry_idx, name, obj_type, color, start, size, left, right, child, - if clsid_nonzero { format!(" CLSID={:02X?}", clsid) } else { String::new() }); + eprintln!( + " [{}] '{}' type={} color={} start={} size={} L/R/C={}/{}/{}{}", + entry_idx, + name, + obj_type, + color, + start, + size, + left, + right, + child, + if clsid_nonzero { + format!(" CLSID={:02X?}", clsid) + } else { + String::new() + } + ); let ctime = &e[100..108]; let mtime = &e[108..116]; let has_time = ctime.iter().any(|&b| b != 0) || mtime.iter().any(|&b| b != 0); @@ -986,7 +1164,10 @@ fn test_cfb_structure_comparison() { // 네이티브 직렬화 결과 생성 및 비교 let doc = crate::parser::parse_hwp(&orig_data).unwrap(); let serialized = super::serialize_hwp(&doc).unwrap(); - eprintln!("\n 네이티브 직렬화(mini_cfb) 크기: {} bytes", serialized.len()); + eprintln!( + "\n 네이티브 직렬화(mini_cfb) 크기: {} bytes", + serialized.len() + ); dump_entries(&serialized, "네이티브 직렬화(mini_cfb)"); // cfb 크레이트로 생성한 파일과 비교 @@ -1000,9 +1181,15 @@ fn test_cfb_structure_comparison() { // cfb 크레이트 출력 재파싱 검증 match crate::parser::parse_hwp(&cfb_bytes) { Ok(reparsed) => { - eprintln!(" cfb 크레이트 결과 재파싱 성공: {} 섹션, {} 문단", + eprintln!( + " cfb 크레이트 결과 재파싱 성공: {} 섹션, {} 문단", reparsed.sections.len(), - reparsed.sections.iter().map(|s| s.paragraphs.len()).sum::()); + reparsed + .sections + .iter() + .map(|s| s.paragraphs.len()) + .sum::() + ); } Err(e) => eprintln!(" cfb 크레이트 결과 재파싱 실패: {}", e), } @@ -1015,15 +1202,24 @@ fn test_cfb_structure_comparison() { } let no_raw_bytes = super::serialize_hwp(&doc_no_raw).unwrap(); let _ = std::fs::write("output/roundtrip_no_raw.hwp", &no_raw_bytes); - eprintln!("\n raw_stream 없이 재직렬화 크기: {} bytes", no_raw_bytes.len()); + eprintln!( + "\n raw_stream 없이 재직렬화 크기: {} bytes", + no_raw_bytes.len() + ); dump_entries(&no_raw_bytes, "재직렬화(raw 없음)"); // 재직렬화 결과 재파싱 검증 match crate::parser::parse_hwp(&no_raw_bytes) { Ok(reparsed) => { - eprintln!(" 재직렬화(raw 없음) 재파싱 성공: {} 섹션, {} 문단", + eprintln!( + " 재직렬화(raw 없음) 재파싱 성공: {} 섹션, {} 문단", reparsed.sections.len(), - reparsed.sections.iter().map(|s| s.paragraphs.len()).sum::()); + reparsed + .sections + .iter() + .map(|s| s.paragraphs.len()) + .sum::() + ); } Err(e) => eprintln!(" 재직렬화(raw 없음) 재파싱 실패: {}", e), } @@ -1038,9 +1234,12 @@ fn test_cfb_structure_comparison() { let mut ncfb = crate::parser::cfb_reader::CfbReader::open(&no_raw_bytes).unwrap(); ncfb.read_doc_info(true).unwrap() }; - eprintln!(" DocInfo: 저장본={}B 재직렬화={}B 동일={}", - saved_decompressed_di.len(), noraw_decompressed_di.len(), - saved_decompressed_di == noraw_decompressed_di); + eprintln!( + " DocInfo: 저장본={}B 재직렬화={}B 동일={}", + saved_decompressed_di.len(), + noraw_decompressed_di.len(), + saved_decompressed_di == noraw_decompressed_di + ); let saved_decompressed_bt = { let mut scfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); @@ -1050,15 +1249,22 @@ fn test_cfb_structure_comparison() { let mut ncfb = crate::parser::cfb_reader::CfbReader::open(&no_raw_bytes).unwrap(); ncfb.read_body_text_section(0, true, false).unwrap() }; - eprintln!(" BodyText: 저장본={}B 재직렬화={}B 동일={}", - saved_decompressed_bt.len(), noraw_decompressed_bt.len(), - saved_decompressed_bt == noraw_decompressed_bt); + eprintln!( + " BodyText: 저장본={}B 재직렬화={}B 동일={}", + saved_decompressed_bt.len(), + noraw_decompressed_bt.len(), + saved_decompressed_bt == noraw_decompressed_bt + ); if saved_decompressed_bt != noraw_decompressed_bt { // 레코드별 비교 let saved_recs = crate::parser::record::Record::read_all(&saved_decompressed_bt).unwrap(); let noraw_recs = crate::parser::record::Record::read_all(&noraw_decompressed_bt).unwrap(); - eprintln!(" 레코드 수: 저장본={} 재직렬화={}", saved_recs.len(), noraw_recs.len()); + eprintln!( + " 레코드 수: 저장본={} 재직렬화={}", + saved_recs.len(), + noraw_recs.len() + ); let max = saved_recs.len().max(noraw_recs.len()); let mut diff_count = 0; for i in 0..max { @@ -1068,8 +1274,17 @@ fn test_cfb_structure_comparison() { diff_count += 1; if diff_count <= 10 { let tag = crate::parser::tags::tag_name(s.tag_id); - eprintln!(" [{}] {} tag={}/{} level={}/{} size={}/{}", - i, tag, s.tag_id, n.tag_id, s.level, n.level, s.data.len(), n.data.len()); + eprintln!( + " [{}] {} tag={}/{} level={}/{} size={}/{}", + i, + tag, + s.tag_id, + n.tag_id, + s.level, + n.level, + s.data.len(), + n.data.len() + ); } } } @@ -1096,13 +1311,22 @@ fn test_cfb_structure_comparison() { } let browser_sim_bytes = super::serialize_hwp(&doc_browser_sim).unwrap(); let _ = std::fs::write("output/roundtrip_browser_sim.hwp", &browser_sim_bytes); - eprintln!("\n 브라우저 시뮬레이션 크기: {} bytes", browser_sim_bytes.len()); + eprintln!( + "\n 브라우저 시뮬레이션 크기: {} bytes", + browser_sim_bytes.len() + ); dump_entries(&browser_sim_bytes, "브라우저 시뮬레이션"); match crate::parser::parse_hwp(&browser_sim_bytes) { Ok(reparsed) => { - eprintln!(" 브라우저 시뮬레이션 재파싱 성공: {} 섹션, {} 문단", + eprintln!( + " 브라우저 시뮬레이션 재파싱 성공: {} 섹션, {} 문단", reparsed.sections.len(), - reparsed.sections.iter().map(|s| s.paragraphs.len()).sum::()); + reparsed + .sections + .iter() + .map(|s| s.paragraphs.len()) + .sum::() + ); } Err(e) => eprintln!(" 브라우저 시뮬레이션 재파싱 실패: {}", e), } @@ -1111,8 +1335,8 @@ fn test_cfb_structure_comparison() { /// 원본 BodyText와 재직렬화 BodyText를 레코드 단위로 비교 #[test] fn test_bodytext_reserialization_diff() { - use std::path::Path; use crate::parser::record::Record; + use std::path::Path; let path = Path::new("samples/hwp_table_test.hwp"); if !path.exists() { @@ -1125,7 +1349,9 @@ fn test_bodytext_reserialization_diff() { // 원본 decompressed BodyText let mut cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); - let orig_bt = cfb.read_body_text_section(0, doc.header.compressed, false).unwrap(); + let orig_bt = cfb + .read_body_text_section(0, doc.header.compressed, false) + .unwrap(); // 재직렬화 BodyText (raw_stream = None으로 강제) doc.sections[0].raw_stream = None; @@ -1155,23 +1381,41 @@ fn test_bodytext_reserialization_diff() { if diff_count <= 30 { let tag = crate::parser::tags::tag_name(o.tag_id); let rtag = crate::parser::tags::tag_name(r.tag_id); - eprintln!(" [{}] 원본: {} L{} {}B | 재직렬화: {} L{} {}B", - i, tag, o.level, o.data.len(), rtag, r.level, r.data.len()); + eprintln!( + " [{}] 원본: {} L{} {}B | 재직렬화: {} L{} {}B", + i, + tag, + o.level, + o.data.len(), + rtag, + r.level, + r.data.len() + ); if o.tag_id == r.tag_id && o.data.len() == r.data.len() { // 같은 크기면 바이트 차이 표시 for j in 0..o.data.len().min(64) { if o.data[j] != r.data[j] { - eprintln!(" offset {}: 0x{:02X} → 0x{:02X}", j, o.data[j], r.data[j]); + eprintln!( + " offset {}: 0x{:02X} → 0x{:02X}", + j, o.data[j], r.data[j] + ); } } } else if o.tag_id == r.tag_id && o.data.len() != r.data.len() { - eprintln!(" 크기 차이: {}B vs {}B ({}B)", o.data.len(), r.data.len(), - r.data.len() as i64 - o.data.len() as i64); + eprintln!( + " 크기 차이: {}B vs {}B ({}B)", + o.data.len(), + r.data.len(), + r.data.len() as i64 - o.data.len() as i64 + ); // 앞부분 비교 let min_len = o.data.len().min(r.data.len()).min(32); for j in 0..min_len { if o.data[j] != r.data[j] { - eprintln!(" 첫 차이 offset {}: 0x{:02X} → 0x{:02X}", j, o.data[j], r.data[j]); + eprintln!( + " 첫 차이 offset {}: 0x{:02X} → 0x{:02X}", + j, o.data[j], r.data[j] + ); break; } } @@ -1183,29 +1427,53 @@ fn test_bodytext_reserialization_diff() { missing_count += 1; let tag = crate::parser::tags::tag_name(o.tag_id); if missing_count <= 20 { - eprintln!(" [{}] 재직렬화에 없음: {} L{} {}B", i, tag, o.level, o.data.len()); + eprintln!( + " [{}] 재직렬화에 없음: {} L{} {}B", + i, + tag, + o.level, + o.data.len() + ); } } (None, Some(r)) => { extra_count += 1; let tag = crate::parser::tags::tag_name(r.tag_id); if extra_count <= 20 { - eprintln!(" [{}] 원본에 없음: {} L{} {}B", i, tag, r.level, r.data.len()); + eprintln!( + " [{}] 원본에 없음: {} L{} {}B", + i, + tag, + r.level, + r.data.len() + ); } } _ => {} } } - eprintln!(" 차이: {} 레코드, 누락: {}, 추가: {}", diff_count, missing_count, extra_count); + eprintln!( + " 차이: {} 레코드, 누락: {}, 추가: {}", + diff_count, missing_count, extra_count + ); // DocInfo도 비교 let orig_di = cfb.read_doc_info(doc.header.compressed).unwrap(); - let reser_di = crate::serializer::doc_info::serialize_doc_info(&doc.doc_info, &doc.doc_properties); + let reser_di = + crate::serializer::doc_info::serialize_doc_info(&doc.doc_info, &doc.doc_properties); let orig_di_recs = Record::read_all(&orig_di).unwrap(); let reser_di_recs = Record::read_all(&reser_di).unwrap(); eprintln!("\n=== DocInfo 비교 ==="); - eprintln!(" 원본: {} records, {} bytes", orig_di_recs.len(), orig_di.len()); - eprintln!(" 재직렬화: {} records, {} bytes", reser_di_recs.len(), reser_di.len()); + eprintln!( + " 원본: {} records, {} bytes", + orig_di_recs.len(), + orig_di.len() + ); + eprintln!( + " 재직렬화: {} records, {} bytes", + reser_di_recs.len(), + reser_di.len() + ); // DocInfo에서 누락된 태그 식별 let max_di = orig_di_recs.len().max(reser_di_recs.len()); @@ -1217,17 +1485,37 @@ fn test_bodytext_reserialization_diff() { if di_diff <= 10 { let otag = crate::parser::tags::tag_name(o.tag_id); let rtag = crate::parser::tags::tag_name(r.tag_id); - eprintln!(" [{}] 원본: {} L{} {}B | 재직렬화: {} L{} {}B", - i, otag, o.level, o.data.len(), rtag, r.level, r.data.len()); + eprintln!( + " [{}] 원본: {} L{} {}B | 재직렬화: {} L{} {}B", + i, + otag, + o.level, + o.data.len(), + rtag, + r.level, + r.data.len() + ); } } (Some(o), None) => { let tag = crate::parser::tags::tag_name(o.tag_id); - eprintln!(" [{}] 재직렬화에 없음: {} L{} {}B", i, tag, o.level, o.data.len()); + eprintln!( + " [{}] 재직렬화에 없음: {} L{} {}B", + i, + tag, + o.level, + o.data.len() + ); } (None, Some(r)) => { let tag = crate::parser::tags::tag_name(r.tag_id); - eprintln!(" [{}] 원본에 없음: {} L{} {}B", i, tag, r.level, r.data.len()); + eprintln!( + " [{}] 원본에 없음: {} L{} {}B", + i, + tag, + r.level, + r.data.len() + ); } _ => {} } diff --git a/src/serializer/control.rs b/src/serializer/control.rs index a07fa121..947c7957 100644 --- a/src/serializer/control.rs +++ b/src/serializer/control.rs @@ -9,18 +9,16 @@ use super::byte_writer::ByteWriter; use crate::model::control::*; use crate::model::document::SectionDef; use crate::model::footnote::FootnoteShape; -use crate::model::header_footer::{Header, Footer, HeaderFooterApply}; -use crate::model::footnote::{Footnote, Endnote}; -use crate::model::page::{ - ColumnDef, ColumnDirection, ColumnType, PageBorderFill, PageDef, -}; -use crate::model::table::{Cell, Table, TablePageBreak, VerticalAlign}; +use crate::model::footnote::{Endnote, Footnote}; +use crate::model::header_footer::{Footer, Header, HeaderFooterApply}; +use crate::model::image::{ImageEffect, Picture}; +use crate::model::page::{ColumnDef, ColumnDirection, ColumnType, PageBorderFill, PageDef}; use crate::model::shape::{ - CommonObjAttr, ShapeObject, ShapeComponentAttr, Caption, CaptionDirection, CaptionVertAlign, - DrawingObjAttr, + Caption, CaptionDirection, CaptionVertAlign, CommonObjAttr, DrawingObjAttr, ShapeComponentAttr, + ShapeObject, }; -use crate::model::style::{Fill, FillType, ShapeBorderLine, ImageFillMode}; -use crate::model::image::{Picture, ImageEffect}; +use crate::model::style::{Fill, FillType, ImageFillMode, ShapeBorderLine}; +use crate::model::table::{Cell, Table, TablePageBreak, VerticalAlign}; use crate::parser::record::Record; use crate::parser::tags; @@ -113,10 +111,7 @@ pub fn serialize_control( }); } // 미구현 컨트롤은 최소한의 CTRL_HEADER만 생성 - Control::Hyperlink(_) - | Control::Ruby(_) - | Control::Form(_) - | Control::Unknown(_) => { + Control::Hyperlink(_) | Control::Ruby(_) | Control::Form(_) | Control::Unknown(_) => { let ctrl_id = match ctrl { Control::Unknown(u) => u.ctrl_id, _ => 0, @@ -138,12 +133,15 @@ pub fn serialize_control( // Picture/Shape 컨트롤은 SHAPE_COMPONENT 내부(level+2)에도 추가 배치됨 if let Some(data) = ctrl_data_record { let ctrl_data_pos = insert_pos + 1; // CTRL_HEADER 바로 다음 - records.insert(ctrl_data_pos, Record { - tag_id: tags::HWPTAG_CTRL_DATA, - level: level + 1, - size: data.len() as u32, - data: data.to_vec(), - }); + records.insert( + ctrl_data_pos, + Record { + tag_id: tags::HWPTAG_CTRL_DATA, + level: level + 1, + size: data.len() as u32, + data: data.to_vec(), + }, + ); } } @@ -185,7 +183,11 @@ fn serialize_section_def(sd: &SectionDef, level: u16, records: &mut Vec) w.write_bytes(&sd.raw_ctrl_extra).unwrap(); } - records.push(make_ctrl_record(tags::CTRL_SECTION_DEF, level, w.as_bytes())); + records.push(make_ctrl_record( + tags::CTRL_SECTION_DEF, + level, + w.as_bytes(), + )); // PAGE_DEF records.push(Record { @@ -345,8 +347,14 @@ fn serialize_column_def(cd: &ColumnDef, level: u16, records: &mut Vec) { fn serialize_table(table: &Table, level: u16, records: &mut Vec) { // CTRL_HEADER: raw_ctrl_data는 CommonObjAttr 전체 (attr 포함) // Task 271에서 파싱 변경: ctrl_data 전체 = CommonObjAttr - records.push(make_ctrl_record(tags::CTRL_TABLE, level, - if !table.raw_ctrl_data.is_empty() { &table.raw_ctrl_data } else { &[] } + records.push(make_ctrl_record( + tags::CTRL_TABLE, + level, + if !table.raw_ctrl_data.is_empty() { + &table.raw_ctrl_data + } else { + &[] + }, )); // 캡션 (TABLE 이전, level+1) @@ -593,14 +601,18 @@ fn serialize_auto_number(an: &AutoNumber) -> Vec { AutoNumberType::Equation => 5, }; let mut attr: u32 = type_val & 0x0F; - attr |= ((an.format as u32) & 0xFF) << 4; // bit 4~11: 번호 모양 + attr |= ((an.format as u32) & 0xFF) << 4; // bit 4~11: 번호 모양 if an.superscript { - attr |= 0x1000; // bit 12: 위 첨자 + attr |= 0x1000; // bit 12: 위 첨자 } let mut data = Vec::new(); data.extend_from_slice(&attr.to_le_bytes()); // number가 0이면 assigned_number를 사용 (캡션 등 새로 생성된 AutoNumber) - let num = if an.number > 0 { an.number } else { an.assigned_number }; + let num = if an.number > 0 { + an.number + } else { + an.assigned_number + }; data.extend_from_slice(&num.to_le_bytes()); data.extend_from_slice(&(an.user_symbol as u16).to_le_bytes()); data.extend_from_slice(&(an.prefix_char as u16).to_le_bytes()); @@ -685,7 +697,12 @@ fn serialize_char_overlap(co: &CharOverlap) -> Vec { // 그림 ('gso ' + Picture) // ============================================================ -fn serialize_picture_control(pic: &Picture, level: u16, ctrl_data_record: Option<&[u8]>, records: &mut Vec) { +fn serialize_picture_control( + pic: &Picture, + level: u16, + ctrl_data_record: Option<&[u8]>, + records: &mut Vec, +) { // CTRL_HEADER: ctrl_id(gso) + common_obj_attr records.push(make_ctrl_record( tags::CTRL_GEN_SHAPE, @@ -770,8 +787,8 @@ fn serialize_picture_data(pic: &Picture) -> Vec { w.write_u8(pic.border_opacity).unwrap(); w.write_u32(pic.instance_id).unwrap(); w.write_u32(0).unwrap(); // image_effect_extra - // 원본 이미지 크기(HWPUNIT) + 플래그(1): 한컴 호환 추가 9바이트 - w.write_u32(pic.crop.right as u32).unwrap(); // original width in HWPUNIT + // 원본 이미지 크기(HWPUNIT) + 플래그(1): 한컴 호환 추가 9바이트 + w.write_u32(pic.crop.right as u32).unwrap(); // original width in HWPUNIT w.write_u32(pic.crop.bottom as u32).unwrap(); // original height in HWPUNIT w.write_u8(0).unwrap(); // flag } @@ -783,7 +800,12 @@ fn serialize_picture_data(pic: &Picture) -> Vec { // 도형 ('gso ' + Shape) // ============================================================ -fn serialize_shape_control(shape: &ShapeObject, level: u16, ctrl_data_record: Option<&[u8]>, records: &mut Vec) { +fn serialize_shape_control( + shape: &ShapeObject, + level: u16, + ctrl_data_record: Option<&[u8]>, + records: &mut Vec, +) { // CTRL_DATA를 SHAPE_COMPONENT 자식으로 배치하는 헬퍼 let emit_ctrl_data = |records: &mut Vec| { if let Some(data) = ctrl_data_record { @@ -799,7 +821,11 @@ fn serialize_shape_control(shape: &ShapeObject, level: u16, ctrl_data_record: Op match shape { ShapeObject::Line(line) => { let is_connector = line.connector.is_some(); - let sc_ctrl_id = if is_connector { tags::SHAPE_CONNECTOR_ID } else { tags::SHAPE_LINE_ID }; + let sc_ctrl_id = if is_connector { + tags::SHAPE_CONNECTOR_ID + } else { + tags::SHAPE_LINE_ID + }; records.push(make_ctrl_record( tags::CTRL_GEN_SHAPE, level, @@ -833,7 +859,8 @@ fn serialize_shape_control(shape: &ShapeObject, level: u16, ctrl_data_record: Op } w.write_bytes(&conn.raw_trailing).unwrap(); } else { - w.write_i32(if line.started_right_or_bottom { 1 } else { 0 }).unwrap(); + w.write_i32(if line.started_right_or_bottom { 1 } else { 0 }) + .unwrap(); } records.push(Record { tag_id: tags::HWPTAG_SHAPE_COMPONENT_LINE, @@ -880,7 +907,11 @@ fn serialize_shape_control(shape: &ShapeObject, level: u16, ctrl_data_record: Op tag_id: tags::HWPTAG_SHAPE_COMPONENT, level: level + 1, size: 0, - data: serialize_drawing_shape_component(tags::SHAPE_ELLIPSE_ID, &ellipse.drawing, true), + data: serialize_drawing_shape_component( + tags::SHAPE_ELLIPSE_ID, + &ellipse.drawing, + true, + ), }); emit_ctrl_data(records); serialize_text_box_if_present(&ellipse.drawing, level + 2, records); @@ -917,7 +948,11 @@ fn serialize_shape_control(shape: &ShapeObject, level: u16, ctrl_data_record: Op tag_id: tags::HWPTAG_SHAPE_COMPONENT, level: level + 1, size: 0, - data: serialize_drawing_shape_component(tags::SHAPE_POLYGON_ID, &poly.drawing, true), + data: serialize_drawing_shape_component( + tags::SHAPE_POLYGON_ID, + &poly.drawing, + true, + ), }); emit_ctrl_data(records); serialize_text_box_if_present(&poly.drawing, level + 2, records); @@ -1004,7 +1039,7 @@ fn serialize_shape_control(shape: &ShapeObject, level: u16, ctrl_data_record: Op // 그룹 컨테이너: SHAPE_COMPONENT + 자식 수 + 자식 ctrl_id 목록 (한컴 호환) { let mut data = serialize_shape_component(0x24636f6e, &group.shape_attr, true); // '$con' - // 자식 수 (u16) + // 자식 수 (u16) let mut w = ByteWriter::new(); w.write_u16(group.children.len() as u16).unwrap(); // 각 자식의 ctrl_id (u32) @@ -1048,15 +1083,19 @@ fn serialize_shape_control(shape: &ShapeObject, level: u16, ctrl_data_record: Op /// 그룹 자식 개체 직렬화 (CTRL_HEADER 없이 SHAPE_COMPONENT + 도형별 태그) fn serialize_group_child( child: &ShapeObject, - comp_level: u16, // SHAPE_COMPONENT level - type_level: u16, // 도형별 태그 level + comp_level: u16, // SHAPE_COMPONENT level + type_level: u16, // 도형별 태그 level records: &mut Vec, ) { use crate::parser::tags; match child { ShapeObject::Line(line) => { - let sc_ctrl_id = if line.connector.is_some() { tags::SHAPE_CONNECTOR_ID } else { tags::SHAPE_LINE_ID }; + let sc_ctrl_id = if line.connector.is_some() { + tags::SHAPE_CONNECTOR_ID + } else { + tags::SHAPE_LINE_ID + }; records.push(Record { tag_id: tags::HWPTAG_SHAPE_COMPONENT, level: comp_level, @@ -1083,7 +1122,8 @@ fn serialize_group_child( } w.write_bytes(&conn.raw_trailing).unwrap(); } else { - w.write_i32(if line.started_right_or_bottom { 1 } else { 0 }).unwrap(); + w.write_i32(if line.started_right_or_bottom { 1 } else { 0 }) + .unwrap(); } records.push(Record { tag_id: tags::HWPTAG_SHAPE_COMPONENT_LINE, @@ -1118,7 +1158,11 @@ fn serialize_group_child( tag_id: tags::HWPTAG_SHAPE_COMPONENT, level: comp_level, size: 0, - data: serialize_drawing_shape_component(tags::SHAPE_ELLIPSE_ID, &ellipse.drawing, false), + data: serialize_drawing_shape_component( + tags::SHAPE_ELLIPSE_ID, + &ellipse.drawing, + false, + ), }); serialize_text_box_if_present(&ellipse.drawing, type_level, records); let mut w = ByteWriter::new(); @@ -1172,7 +1216,11 @@ fn serialize_group_child( tag_id: tags::HWPTAG_SHAPE_COMPONENT, level: comp_level, size: 0, - data: serialize_drawing_shape_component(tags::SHAPE_POLYGON_ID, &poly.drawing, false), + data: serialize_drawing_shape_component( + tags::SHAPE_POLYGON_ID, + &poly.drawing, + false, + ), }); serialize_text_box_if_present(&poly.drawing, type_level, records); let mut w = ByteWriter::new(); @@ -1193,7 +1241,11 @@ fn serialize_group_child( tag_id: tags::HWPTAG_SHAPE_COMPONENT, level: comp_level, size: 0, - data: serialize_drawing_shape_component(tags::SHAPE_CURVE_ID, &curve.drawing, false), + data: serialize_drawing_shape_component( + tags::SHAPE_CURVE_ID, + &curve.drawing, + false, + ), }); serialize_text_box_if_present(&curve.drawing, type_level, records); let mut w = ByteWriter::new(); @@ -1232,11 +1284,7 @@ fn serialize_group_child( } /// DrawingObjAttr의 text_box가 있으면 LIST_HEADER + 문단 목록 직렬화 -fn serialize_text_box_if_present( - drawing: &DrawingObjAttr, - level: u16, - records: &mut Vec, -) { +fn serialize_text_box_if_present(drawing: &DrawingObjAttr, level: u16, records: &mut Vec) { if let Some(ref text_box) = drawing.text_box { // LIST_HEADER let mut w = ByteWriter::new(); @@ -1297,7 +1345,11 @@ fn serialize_common_obj_attr(common: &CommonObjAttr) -> Vec { /// SHAPE_COMPONENT 데이터 직렬화 (ShapeComponentAttr만 — Picture, Group용) /// /// 구조: ctrl_id(×1 or ×2) + ShapeComponentAttr + rendering_info -fn serialize_shape_component(default_ctrl_id: u32, attr: &ShapeComponentAttr, top_level: bool) -> Vec { +fn serialize_shape_component( + default_ctrl_id: u32, + attr: &ShapeComponentAttr, + top_level: bool, +) -> Vec { let mut w = ByteWriter::new(); write_shape_component_base(&mut w, default_ctrl_id, attr, top_level); w.into_bytes() @@ -1306,7 +1358,11 @@ fn serialize_shape_component(default_ctrl_id: u32, attr: &ShapeComponentAttr, to /// SHAPE_COMPONENT 데이터 직렬화 (DrawingObjAttr 전체 — 도형용) /// /// 구조: ctrl_id(×1 or ×2) + ShapeComponentAttr + rendering_info + border_line + fill + shadow -fn serialize_drawing_shape_component(default_ctrl_id: u32, drawing: &DrawingObjAttr, top_level: bool) -> Vec { +fn serialize_drawing_shape_component( + default_ctrl_id: u32, + drawing: &DrawingObjAttr, + top_level: bool, +) -> Vec { let mut w = ByteWriter::new(); write_shape_component_base(&mut w, default_ctrl_id, &drawing.shape_attr, top_level); @@ -1334,10 +1390,23 @@ fn serialize_drawing_shape_component(default_ctrl_id: u32, drawing: &DrawingObjA } /// ShapeComponentAttr 공통 기록 (ctrl_id + 속성 + 렌더링 행렬) -fn write_shape_component_base(w: &mut ByteWriter, default_ctrl_id: u32, attr: &ShapeComponentAttr, top_level: bool) { +fn write_shape_component_base( + w: &mut ByteWriter, + default_ctrl_id: u32, + attr: &ShapeComponentAttr, + top_level: bool, +) { // ctrl_id: 원본에서 보존된 값 사용, 없으면 기본값 - let actual_id = if attr.ctrl_id != 0 { attr.ctrl_id } else { default_ctrl_id }; - let is_two = if attr.ctrl_id != 0 { attr.is_two_ctrl_id } else { top_level }; + let actual_id = if attr.ctrl_id != 0 { + attr.ctrl_id + } else { + default_ctrl_id + }; + let is_two = if attr.ctrl_id != 0 { + attr.is_two_ctrl_id + } else { + top_level + }; w.write_u32(actual_id).unwrap(); if is_two { @@ -1359,8 +1428,12 @@ fn write_shape_component_base(w: &mut ByteWriter, default_ctrl_id: u32, attr: &S attr.flip } else { let mut f: u32 = 0; - if attr.horz_flip { f |= 0x01; } - if attr.vert_flip { f |= 0x02; } + if attr.horz_flip { + f |= 0x01; + } + if attr.vert_flip { + f |= 0x02; + } f }; w.write_u32(flip).unwrap(); @@ -1383,8 +1456,8 @@ fn write_shape_component_base(w: &mut ByteWriter, default_ctrl_id: u32, attr: &S w.write_f64(0.0).unwrap(); w.write_f64(1.0).unwrap(); w.write_f64(attr.offset_y as f64).unwrap(); // ty - // scale matrix = identity [1, 0, 0, 0, 1, 0] - // (스케일은 current_width/original_width 값으로 표현 — 행렬에 중복 기록하면 이중 적용됨) + // scale matrix = identity [1, 0, 0, 0, 1, 0] + // (스케일은 current_width/original_width 값으로 표현 — 행렬에 중복 기록하면 이중 적용됨) w.write_f64(1.0).unwrap(); w.write_f64(0.0).unwrap(); w.write_f64(0.0).unwrap(); @@ -1528,7 +1601,6 @@ fn serialize_list_header_with_paragraphs( serialize_paragraph_list(paragraphs, level + 1, records); } - // ============================================================ // 수식 ('eqed') // ============================================================ diff --git a/src/serializer/doc_info.rs b/src/serializer/doc_info.rs index 65368043..ebdf23f1 100644 --- a/src/serializer/doc_info.rs +++ b/src/serializer/doc_info.rs @@ -50,24 +50,36 @@ pub fn serialize_doc_info(doc_info: &DocInfo, doc_props: &DocProperties) -> Vec< // 3~10: ID_MAPPINGS 하위 레코드 (모두 level 1) for bin_data in &doc_info.bin_data_list { - let data = bin_data.raw_data.clone().unwrap_or_else(|| serialize_bin_data(bin_data)); + let data = bin_data + .raw_data + .clone() + .unwrap_or_else(|| serialize_bin_data(bin_data)); stream.extend(write_record(tags::HWPTAG_BIN_DATA, 1, &data)); } for lang_fonts in &doc_info.font_faces { for font in lang_fonts { - let data = font.raw_data.clone().unwrap_or_else(|| serialize_face_name(font)); + let data = font + .raw_data + .clone() + .unwrap_or_else(|| serialize_face_name(font)); stream.extend(write_record(tags::HWPTAG_FACE_NAME, 1, &data)); } } for bf in &doc_info.border_fills { - let data = bf.raw_data.clone().unwrap_or_else(|| serialize_border_fill(bf)); + let data = bf + .raw_data + .clone() + .unwrap_or_else(|| serialize_border_fill(bf)); stream.extend(write_record(tags::HWPTAG_BORDER_FILL, 1, &data)); } for cs in &doc_info.char_shapes { - let data = cs.raw_data.clone().unwrap_or_else(|| serialize_char_shape(cs)); + let data = cs + .raw_data + .clone() + .unwrap_or_else(|| serialize_char_shape(cs)); stream.extend(write_record(tags::HWPTAG_CHAR_SHAPE, 1, &data)); } @@ -77,32 +89,40 @@ pub fn serialize_doc_info(doc_info: &DocInfo, doc_props: &DocProperties) -> Vec< } for numbering in &doc_info.numberings { - let data = numbering.raw_data.clone().unwrap_or_else(|| serialize_numbering(numbering)); + let data = numbering + .raw_data + .clone() + .unwrap_or_else(|| serialize_numbering(numbering)); stream.extend(write_record(tags::HWPTAG_NUMBERING, 1, &data)); } for bullet in &doc_info.bullets { - let data = bullet.raw_data.clone().unwrap_or_else(|| serialize_bullet(bullet)); + let data = bullet + .raw_data + .clone() + .unwrap_or_else(|| serialize_bullet(bullet)); stream.extend(write_record(tags::HWPTAG_BULLET, 1, &data)); } for ps in &doc_info.para_shapes { - let data = ps.raw_data.clone().unwrap_or_else(|| serialize_para_shape(ps)); + let data = ps + .raw_data + .clone() + .unwrap_or_else(|| serialize_para_shape(ps)); stream.extend(write_record(tags::HWPTAG_PARA_SHAPE, 1, &data)); } for style in &doc_info.styles { - let data = style.raw_data.clone().unwrap_or_else(|| serialize_style(style)); + let data = style + .raw_data + .clone() + .unwrap_or_else(|| serialize_style(style)); stream.extend(write_record(tags::HWPTAG_STYLE, 1, &data)); } // 미지원 레코드 원본 보존 for record in &doc_info.extra_records { - stream.extend(write_record( - record.tag_id, - record.level, - &record.data, - )); + stream.extend(write_record(record.tag_id, record.level, &record.data)); } stream @@ -267,7 +287,8 @@ pub fn serialize_border_fill(bf: &BorderFill) -> Vec { // 4방향 테두리 (인터리브: 종류 + 굵기 + 색상) for border in &bf.borders { - w.write_u8(border_line_type_to_u8(border.line_type)).unwrap(); + w.write_u8(border_line_type_to_u8(border.line_type)) + .unwrap(); w.write_u8(border.width).unwrap(); w.write_color_ref(border.color).unwrap(); } @@ -365,9 +386,17 @@ pub fn serialize_char_shape(cs: &CharShape) -> Vec { // attr: 원본 비트를 기반으로, 모델링된 필드 반영 let mut attr = cs.attr; // bit 0: italic - if cs.italic { attr |= 0x01; } else { attr &= !0x01; } + if cs.italic { + attr |= 0x01; + } else { + attr &= !0x01; + } // bit 1: bold - if cs.bold { attr |= 0x02; } else { attr &= !0x02; } + if cs.bold { + attr |= 0x02; + } else { + attr &= !0x02; + } // bits 2-3: underline_type (0=none, 1=bottom, 3=top) attr &= !0x0C; attr |= match cs.underline_type { @@ -382,18 +411,36 @@ pub fn serialize_char_shape(cs: &CharShape) -> Vec { attr &= !(0x03 << 11); attr |= (cs.shadow_type as u32 & 0x03) << 11; // bit 13: emboss - if cs.emboss { attr |= 1u32 << 13; } else { attr &= !(1u32 << 13); } + if cs.emboss { + attr |= 1u32 << 13; + } else { + attr &= !(1u32 << 13); + } // bit 14: engrave - if cs.engrave { attr |= 1u32 << 14; } else { attr &= !(1u32 << 14); } + if cs.engrave { + attr |= 1u32 << 14; + } else { + attr &= !(1u32 << 14); + } // HWP 스펙 표 37: bit 15 = 위첨자(superscript), bit 16 = 아래첨자(subscript) - if cs.superscript { attr |= 1u32 << 15; } else { attr &= !(1u32 << 15); } - if cs.subscript { attr |= 1u32 << 16; } else { attr &= !(1u32 << 16); } + if cs.superscript { + attr |= 1u32 << 15; + } else { + attr &= !(1u32 << 15); + } + if cs.subscript { + attr |= 1u32 << 16; + } else { + attr &= !(1u32 << 16); + } // bits 4-7: underline_shape (표 27 선 종류) attr &= !(0x0F << 4); attr |= (cs.underline_shape as u32 & 0x0F) << 4; // bits 18-20: strikethrough (≥2 means active) if cs.strikethrough { - if (attr >> 18) & 0x07 < 2 { attr = (attr & !(0x07 << 18)) | (2u32 << 18); } + if (attr >> 18) & 0x07 < 2 { + attr = (attr & !(0x07 << 18)) | (2u32 << 18); + } } else { attr &= !(0x07 << 18); } @@ -404,7 +451,11 @@ pub fn serialize_char_shape(cs: &CharShape) -> Vec { attr &= !(0x0F << 26); attr |= (cs.strike_shape as u32 & 0x0F) << 26; // bit 30: kerning - if cs.kerning { attr |= 1u32 << 30; } else { attr &= !(1u32 << 30); } + if cs.kerning { + attr |= 1u32 << 30; + } else { + attr &= !(1u32 << 30); + } w.write_u32(attr).unwrap(); // shadow offsets (i8 × 2) w.write_i8(cs.shadow_offset_x).unwrap(); @@ -583,8 +634,10 @@ fn scan_records(stream: &[u8]) -> Vec { while offset + 4 <= stream.len() { let header = u32::from_le_bytes([ - stream[offset], stream[offset + 1], - stream[offset + 2], stream[offset + 3], + stream[offset], + stream[offset + 1], + stream[offset + 2], + stream[offset + 3], ]); let tag_id = (header & 0x3FF) as u16; let level = ((header >> 10) & 0x3FF) as u16; @@ -593,10 +646,14 @@ fn scan_records(stream: &[u8]) -> Vec { let header_bytes; let data_offset; if size == 0xFFF { - if offset + 8 > stream.len() { break; } + if offset + 8 > stream.len() { + break; + } size = u32::from_le_bytes([ - stream[offset + 4], stream[offset + 5], - stream[offset + 6], stream[offset + 7], + stream[offset + 4], + stream[offset + 5], + stream[offset + 6], + stream[offset + 7], ]); header_bytes = 8; data_offset = offset + 8; @@ -605,7 +662,9 @@ fn scan_records(stream: &[u8]) -> Vec { data_offset = offset + 4; } - if data_offset + size as usize > stream.len() { break; } + if data_offset + size as usize > stream.len() { + break; + } positions.push(RecordPos { tag_id, @@ -665,7 +724,8 @@ pub fn surgical_insert_record( }; // ID_MAPPINGS 위치를 삽입 전에 저장 - let id_mappings_info = positions.iter() + let id_mappings_info = positions + .iter() .find(|r| r.tag_id == tags::HWPTAG_ID_MAPPINGS) .map(|r| (r.data_offset, r.data_size)); @@ -683,8 +743,10 @@ pub fn surgical_insert_record( let abs = data_off + field_off; if abs + 4 <= raw_stream.len() && field_off + 4 <= data_size as usize { let cur = u32::from_le_bytes([ - raw_stream[abs], raw_stream[abs + 1], - raw_stream[abs + 2], raw_stream[abs + 3], + raw_stream[abs], + raw_stream[abs + 1], + raw_stream[abs + 2], + raw_stream[abs + 3], ]); raw_stream[abs..abs + 4].copy_from_slice(&(cur + 1).to_le_bytes()); } @@ -699,16 +761,14 @@ pub fn surgical_insert_record( /// FACE_NAME 레코드는 언어별로 연속 배치된다: /// [lang0_font0, lang0_font1, ..., lang1_font0, lang1_font1, ..., lang6_fontN] /// 각 언어 섹션의 끝에 한 레코드씩 삽입하고 ID_MAPPINGS 카운트를 갱신한다. -pub fn surgical_insert_font_all_langs( - raw_stream: &mut Vec, - data: &[u8], -) -> Result<(), String> { +pub fn surgical_insert_font_all_langs(raw_stream: &mut Vec, data: &[u8]) -> Result<(), String> { use super::record_writer::write_record; let positions = scan_records(raw_stream); // ID_MAPPINGS에서 언어별 카운트 읽기 - let id_mappings = positions.iter() + let id_mappings = positions + .iter() .find(|r| r.tag_id == tags::HWPTAG_ID_MAPPINGS) .ok_or_else(|| "ID_MAPPINGS not found".to_string())?; let idm_data_off = id_mappings.data_offset; @@ -719,14 +779,17 @@ pub fn surgical_insert_font_all_langs( let off = idm_data_off + 4 + lang * 4; if off + 4 <= raw_stream.len() && 4 + lang * 4 + 4 <= idm_data_size { lang_counts[lang] = u32::from_le_bytes([ - raw_stream[off], raw_stream[off + 1], - raw_stream[off + 2], raw_stream[off + 3], + raw_stream[off], + raw_stream[off + 1], + raw_stream[off + 2], + raw_stream[off + 3], ]); } } // FACE_NAME 레코드 목록 - let face_recs: Vec<&RecordPos> = positions.iter() + let face_recs: Vec<&RecordPos> = positions + .iter() .filter(|r| r.tag_id == tags::HWPTAG_FACE_NAME) .collect(); @@ -743,9 +806,15 @@ pub fn surgical_insert_font_all_langs( last.header_offset + last.total_bytes } else { // FACE_NAME이 없으면 BIN_DATA 뒤 또는 ID_MAPPINGS 뒤 - positions.iter().rev() + positions + .iter() + .rev() .find(|r| r.tag_id == tags::HWPTAG_BIN_DATA) - .or_else(|| positions.iter().find(|r| r.tag_id == tags::HWPTAG_ID_MAPPINGS)) + .or_else(|| { + positions + .iter() + .find(|r| r.tag_id == tags::HWPTAG_ID_MAPPINGS) + }) .map(|r| r.header_offset + r.total_bytes) .unwrap_or(raw_stream.len()) }; @@ -760,14 +829,19 @@ pub fn surgical_insert_font_all_langs( // ID_MAPPINGS 재스캔 후 7개 언어 카운트 각각 +1 let positions = scan_records(raw_stream); - if let Some(idm) = positions.iter().find(|r| r.tag_id == tags::HWPTAG_ID_MAPPINGS) { + if let Some(idm) = positions + .iter() + .find(|r| r.tag_id == tags::HWPTAG_ID_MAPPINGS) + { for lang in 0..7usize { let field_off = 4 + lang * 4; let abs = idm.data_offset + field_off; if abs + 4 <= raw_stream.len() && field_off + 4 <= idm.data_size as usize { let cur = u32::from_le_bytes([ - raw_stream[abs], raw_stream[abs + 1], - raw_stream[abs + 2], raw_stream[abs + 3], + raw_stream[abs], + raw_stream[abs + 1], + raw_stream[abs + 2], + raw_stream[abs + 3], ]); raw_stream[abs..abs + 4].copy_from_slice(&(cur + 1).to_le_bytes()); } @@ -780,10 +854,7 @@ pub fn surgical_insert_font_all_langs( /// DocInfo raw_stream에서 특정 tag_id의 모든 레코드를 제거한다. /// /// convert_to_editable()에서 DISTRIBUTE_DOC_DATA 제거 시 사용. -pub fn surgical_remove_records( - raw_stream: &mut Vec, - tag_id: u16, -) -> usize { +pub fn surgical_remove_records(raw_stream: &mut Vec, tag_id: u16) -> usize { let positions = scan_records(raw_stream); let mut removed = 0; @@ -815,14 +886,16 @@ pub fn surgical_update_caret( ) -> Result<(), String> { let positions = scan_records(raw_stream); - let doc_props_pos = positions.iter() + let doc_props_pos = positions + .iter() .find(|r| r.tag_id == tags::HWPTAG_DOCUMENT_PROPERTIES) .ok_or_else(|| "DOCUMENT_PROPERTIES 레코드를 찾을 수 없음".to_string())?; let data_off = doc_props_pos.data_offset; if doc_props_pos.data_size < 26 { return Err(format!( - "DOCUMENT_PROPERTIES 데이터 크기 부족: {} < 26", doc_props_pos.data_size + "DOCUMENT_PROPERTIES 데이터 크기 부족: {} < 26", + doc_props_pos.data_size )); } @@ -834,6 +907,5 @@ pub fn surgical_update_caret( Ok(()) } - #[cfg(test)] mod tests; diff --git a/src/serializer/doc_info/tests.rs b/src/serializer/doc_info/tests.rs index 363b48c0..54c1cecf 100644 --- a/src/serializer/doc_info/tests.rs +++ b/src/serializer/doc_info/tests.rs @@ -282,13 +282,11 @@ fn test_serialize_tab_def() { let td = TabDef { raw_data: None, attr: 0x03, - tabs: vec![ - crate::model::style::TabItem { - position: 7200, - tab_type: 0, - fill_type: 0, - }, - ], + tabs: vec![crate::model::style::TabItem { + position: 7200, + tab_type: 0, + fill_type: 0, + }], auto_tab_left: true, auto_tab_right: true, }; diff --git a/src/serializer/mini_cfb.rs b/src/serializer/mini_cfb.rs index 1f9675b3..9b93f0f5 100644 --- a/src/serializer/mini_cfb.rs +++ b/src/serializer/mini_cfb.rs @@ -248,9 +248,8 @@ pub fn build_cfb(named_streams: &[(&str, &[u8])]) -> Result, String> { for (i, &mf) in mini_fat.iter().enumerate() { let sector_idx = i / FAT_ENTRIES_PER_SECTOR; let entry_in_sector = i % FAT_ENTRIES_PER_SECTOR; - let offset = 512 - + (mini_fat_start as usize + sector_idx) * SECTOR_SIZE - + entry_in_sector * 4; + let offset = + 512 + (mini_fat_start as usize + sector_idx) * SECTOR_SIZE + entry_in_sector * 4; output[offset..offset + 4].copy_from_slice(&mf.to_le_bytes()); } } @@ -544,20 +543,14 @@ mod tests { let mut cfb = cfb::CompoundFile::open(cursor).unwrap(); let mut s0 = Vec::new(); - std::io::Read::read_to_end( - &mut cfb.open_stream("/BodyText/Section0").unwrap(), - &mut s0, - ) - .unwrap(); + std::io::Read::read_to_end(&mut cfb.open_stream("/BodyText/Section0").unwrap(), &mut s0) + .unwrap(); assert_eq!(s0.len(), 2000); assert!(s0.iter().all(|&b| b == 0x03)); let mut s1 = Vec::new(); - std::io::Read::read_to_end( - &mut cfb.open_stream("/BodyText/Section1").unwrap(), - &mut s1, - ) - .unwrap(); + std::io::Read::read_to_end(&mut cfb.open_stream("/BodyText/Section1").unwrap(), &mut s1) + .unwrap(); assert_eq!(s1.len(), 1500); assert!(s1.iter().all(|&b| b == 0x04)); } @@ -573,11 +566,8 @@ mod tests { let mut cfb = cfb::CompoundFile::open(cursor).unwrap(); let mut read_data = Vec::new(); - std::io::Read::read_to_end( - &mut cfb.open_stream("/BigStream").unwrap(), - &mut read_data, - ) - .unwrap(); + std::io::Read::read_to_end(&mut cfb.open_stream("/BigStream").unwrap(), &mut read_data) + .unwrap(); assert_eq!(read_data, data); } @@ -586,10 +576,7 @@ mod tests { // 미니 스트림(< 4096)과 정규 스트림(>= 4096) 혼합 let small = vec![0x11u8; 100]; let large = vec![0x22u8; 5000]; - let streams = vec![ - ("/Small", small.as_slice()), - ("/Large", large.as_slice()), - ]; + let streams = vec![("/Small", small.as_slice()), ("/Large", large.as_slice())]; let bytes = build_cfb(&streams).unwrap(); let cursor = std::io::Cursor::new(&bytes); diff --git a/src/serializer/record_writer.rs b/src/serializer/record_writer.rs index 0d543104..89225f24 100644 --- a/src/serializer/record_writer.rs +++ b/src/serializer/record_writer.rs @@ -39,9 +39,10 @@ pub fn write_record_from(record: &Record) -> Vec { /// 여러 레코드를 연결하여 바이너리 스트림 생성 pub fn write_records(records: &[Record]) -> Vec { - let total_size: usize = records.iter().map(|r| { - 4 + if r.data.len() >= 0xFFF { 4 } else { 0 } + r.data.len() - }).sum(); + let total_size: usize = records + .iter() + .map(|r| 4 + if r.data.len() >= 0xFFF { 4 } else { 0 } + r.data.len()) + .sum(); let mut bytes = Vec::with_capacity(total_size); for record in records { diff --git a/src/tools/font_metric_gen.rs b/src/tools/font_metric_gen.rs index 19b92742..f2c4dc78 100644 --- a/src/tools/font_metric_gen.rs +++ b/src/tools/font_metric_gen.rs @@ -60,7 +60,9 @@ fn parse_table_directory_at(data: &[u8], header_off: usize) -> Vec { let mut tables = Vec::new(); for i in 0..num_tables as usize { let entry_off = header_off + 12 + i * 16; - if entry_off + 16 > data.len() { break; } + if entry_off + 16 > data.len() { + break; + } tables.push(TableEntry { tag: tag_str(data, entry_off), offset: read_u32_be(data, entry_off + 8), @@ -71,7 +73,10 @@ fn parse_table_directory_at(data: &[u8], header_off: usize) -> Vec { } fn table_offset(tables: &[TableEntry], tag: &str) -> Option { - tables.iter().find(|t| t.tag == tag).map(|t| t.offset as usize) + tables + .iter() + .find(|t| t.tag == tag) + .map(|t| t.offset as usize) } // ─── head 테이블: unitsPerEm, macStyle ─── @@ -110,12 +115,16 @@ fn parse_cmap(data: &[u8], tables: &[TableEntry]) -> HashMap { for i in 0..num_subtables { let rec = cmap_off + 4 + i * 8; - if rec + 8 > data.len() { break; } + if rec + 8 > data.len() { + break; + } let platform_id = read_u16_be(data, rec); let encoding_id = read_u16_be(data, rec + 2); let subtable_off = cmap_off + read_u32_be(data, rec + 4) as usize; - if subtable_off >= data.len() { continue; } + if subtable_off >= data.len() { + continue; + } let format = read_u16_be(data, subtable_off); if platform_id == 3 { @@ -142,7 +151,9 @@ fn parse_cmap(data: &[u8], tables: &[TableEntry]) -> HashMap { let n_groups = read_u32_be(data, off + 12) as usize; for g in 0..n_groups { let rec = off + 16 + g * 12; - if rec + 12 > data.len() { break; } + if rec + 12 > data.len() { + break; + } let start_char = read_u32_be(data, rec); let end_char = read_u32_be(data, rec + 4); let start_glyph = read_u32_be(data, rec + 8); @@ -168,15 +179,16 @@ fn parse_cmap(data: &[u8], tables: &[TableEntry]) -> HashMap { let id_delta = read_i16_be(data, id_delta_off + seg * 2) as i32; let id_range_offset = read_u16_be(data, id_range_off + seg * 2) as usize; - if start_code == 0xFFFF { break; } + if start_code == 0xFFFF { + break; + } for c in start_code..=end_code { let gid = if id_range_offset == 0 { ((c as i32 + id_delta) & 0xFFFF) as u16 } else { - let glyph_idx_off = id_range_off + seg * 2 - + id_range_offset - + ((c - start_code) as usize) * 2; + let glyph_idx_off = + id_range_off + seg * 2 + id_range_offset + ((c - start_code) as usize) * 2; if glyph_idx_off + 2 <= data.len() { let gid = read_u16_be(data, glyph_idx_off); if gid != 0 { @@ -236,40 +248,42 @@ fn parse_name(data: &[u8], tables: &[TableEntry]) -> String { // nameID=1 (Font Family), platformID=3 (Windows), encodingID=1 (Unicode BMP) for i in 0..count { let rec = name_off + 6 + i * 12; - if rec + 12 > data.len() { break; } + if rec + 12 > data.len() { + break; + } let platform_id = read_u16_be(data, rec); let encoding_id = read_u16_be(data, rec + 2); let name_id = read_u16_be(data, rec + 6); let length = read_u16_be(data, rec + 8) as usize; let offset = string_offset + read_u16_be(data, rec + 10) as usize; - if name_id == 1 && platform_id == 3 && encoding_id == 1 - && offset + length <= data.len() { - // UTF-16 BE 디코딩 - let mut s = String::new(); - for j in (0..length).step_by(2) { - let ch = read_u16_be(data, offset + j); - if let Some(c) = char::from_u32(ch as u32) { - s.push(c); - } + if name_id == 1 && platform_id == 3 && encoding_id == 1 && offset + length <= data.len() { + // UTF-16 BE 디코딩 + let mut s = String::new(); + for j in (0..length).step_by(2) { + let ch = read_u16_be(data, offset + j); + if let Some(c) = char::from_u32(ch as u32) { + s.push(c); } - return s; } + return s; + } } // 폴백: platformID=1 (Mac), encodingID=0 (Roman) for i in 0..count { let rec = name_off + 6 + i * 12; - if rec + 12 > data.len() { break; } + if rec + 12 > data.len() { + break; + } let platform_id = read_u16_be(data, rec); let name_id = read_u16_be(data, rec + 6); let length = read_u16_be(data, rec + 8) as usize; let offset = string_offset + read_u16_be(data, rec + 10) as usize; - if name_id == 1 && platform_id == 1 - && offset + length <= data.len() { - return String::from_utf8_lossy(&data[offset..offset + length]).to_string(); - } + if name_id == 1 && platform_id == 1 && offset + length <= data.len() { + return String::from_utf8_lossy(&data[offset..offset + length]).to_string(); + } } String::new() @@ -300,12 +314,18 @@ fn parse_ttf_all(path: &Path) -> Result, String> { } let offsets = get_font_offsets(&data); - let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + let file_name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); let mut results = Vec::new(); for &font_off in &offsets { let tables = parse_table_directory_at(&data, font_off); - if tables.is_empty() { continue; } + if tables.is_empty() { + continue; + } let head = parse_head(&data, &tables); let num_glyphs = parse_maxp(&data, &tables); @@ -407,8 +427,10 @@ fn find_best_grouping( max_jong: u8, ) -> HangulCompressed { // 음절 폭을 3D 배열로 변환 - let mut widths_3d = vec![vec![vec![0u16; JONG_COUNT as usize]; JUNG_COUNT as usize]; CHO_COUNT as usize]; - let mut has_data = vec![vec![vec![false; JONG_COUNT as usize]; JUNG_COUNT as usize]; CHO_COUNT as usize]; + let mut widths_3d = + vec![vec![vec![0u16; JONG_COUNT as usize]; JUNG_COUNT as usize]; CHO_COUNT as usize]; + let mut has_data = + vec![vec![vec![false; JONG_COUNT as usize]; JUNG_COUNT as usize]; CHO_COUNT as usize]; for &(code, w) in syllable_widths { let (cho, jung, jong) = decompose_hangul(code); @@ -447,7 +469,13 @@ fn find_best_grouping( let group_widths: Vec = group_sums .iter() .zip(group_counts.iter()) - .map(|(&sum, &cnt)| if cnt > 0 { (sum / cnt as u64) as u16 } else { 0 }) + .map(|(&sum, &cnt)| { + if cnt > 0 { + (sum / cnt as u64) as u16 + } else { + 0 + } + }) .collect(); // 오차 측정 @@ -489,7 +517,11 @@ fn find_best_grouping( jong_map: jong_map_arr, widths: group_widths, max_error, - avg_error: if count > 0 { total_error as f64 / count as f64 } else { 0.0 }, + avg_error: if count > 0 { + total_error as f64 / count as f64 + } else { + 0.0 + }, } } @@ -526,7 +558,11 @@ fn compute_axis_averages( } } } - avgs[idx] = if cnt > 0 { sum as f64 / cnt as f64 } else { 0.0 }; + avgs[idx] = if cnt > 0 { + sum as f64 / cnt as f64 + } else { + 0.0 + }; } avgs } @@ -534,11 +570,20 @@ fn compute_axis_averages( /// 1D K-means 클러스터링 (간단한 정렬 기반 분할) fn kmeans_group(values: &[f64], k: usize) -> Vec { let n = values.len(); - if n == 0 || k == 0 { return vec![0; n]; } - if k >= n { return (0..n).map(|i| i as u8).collect(); } + if n == 0 || k == 0 { + return vec![0; n]; + } + if k >= n { + return (0..n).map(|i| i as u8).collect(); + } // 값-인덱스 쌍을 정렬 - let mut indexed: Vec<(f64, usize)> = values.iter().copied().enumerate().map(|(i, v)| (v, i)).collect(); + let mut indexed: Vec<(f64, usize)> = values + .iter() + .copied() + .enumerate() + .map(|(i, v)| (v, i)) + .collect(); indexed.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); // 정렬된 순서에서 k등분 @@ -697,7 +742,10 @@ fn generate_rust_source(metrics: &[FontMetric]) -> String { for (ri, range) in latin_ranges.iter().enumerate() { out.push_str(&format!( "static {}_LATIN_{}: [u16; {}] = {:?};\n", - var_prefix, ri, range.widths.len(), range.widths + var_prefix, + ri, + range.widths.len(), + range.widths )); } @@ -786,8 +834,16 @@ fn generate_rust_source(metrics: &[FontMetric]) -> String { fn print_diagnostic(metric: &FontMetric) { let total_chars = metric.char_widths.len(); - let hangul_count = metric.char_widths.keys().filter(|&&c| (HANGUL_BASE..=HANGUL_END).contains(&c)).count(); - let latin_count = metric.char_widths.keys().filter(|&&c| (0x20..=0x7E).contains(&c)).count(); + let hangul_count = metric + .char_widths + .keys() + .filter(|&&c| (HANGUL_BASE..=HANGUL_END).contains(&c)) + .count(); + let latin_count = metric + .char_widths + .keys() + .filter(|&&c| (0x20..=0x7E).contains(&c)) + .count(); let style = match (metric.bold, metric.italic) { (false, false) => "Regular", @@ -844,7 +900,10 @@ fn main() { if args[1] == "--dir" { let dir = PathBuf::from(&args[2]); let list_mode = args.iter().any(|a| a == "--list"); - let output_path = args.iter().position(|a| a == "--output").map(|i| PathBuf::from(&args[i + 1])); + let output_path = args + .iter() + .position(|a| a == "--output") + .map(|i| PathBuf::from(&args[i + 1])); let mut entries: Vec<_> = fs::read_dir(&dir) .expect("디렉토리 열기 실패") @@ -867,7 +926,14 @@ fn main() { (false, true) => " [I]", (true, true) => " [BI]", }; - println!(" {} → \"{}\"{} (em={}, 글리프={})", m.file_name, m.family_name, style, m.em_size, m.char_widths.len()); + println!( + " {} → \"{}\"{} (em={}, 글리프={})", + m.file_name, + m.family_name, + style, + m.em_size, + m.char_widths.len() + ); } Err(e) => println!(" {} → 오류: {}", entry.file_name().to_string_lossy(), e), } @@ -918,22 +984,44 @@ fn main() { } // 우선 폰트를 앞쪽에 배치 (HWP 기본 폰트 → 기타) let priority_fonts: Vec<&str> = vec![ - "함초롬돋움", "함초롬바탕", "HCR Batang", "HCR Dotum", - "Malgun Gothic", "맑은 고딕", - "Haansoft Batang", "Haansoft Dotum", - "NanumGothic", "NanumMyeongjo", "NanumBarunGothic", - "Noto Sans KR", "Noto Serif KR", - "Arial", "Times New Roman", "Calibri", "Verdana", "Tahoma", - "Batang", "Dotum", "Gulim", "Gungsuh", + "함초롬돋움", + "함초롬바탕", + "HCR Batang", + "HCR Dotum", + "Malgun Gothic", + "맑은 고딕", + "Haansoft Batang", + "Haansoft Dotum", + "NanumGothic", + "NanumMyeongjo", + "NanumBarunGothic", + "Noto Sans KR", + "Noto Serif KR", + "Arial", + "Times New Roman", + "Calibri", + "Verdana", + "Tahoma", + "Batang", + "Dotum", + "Gulim", + "Gungsuh", ]; deduped.sort_by(|a, b| { let pa = priority_fonts.iter().position(|&p| p == a.family_name); let pb = priority_fonts.iter().position(|&p| p == b.family_name); match (pa, pb) { - (Some(ia), Some(ib)) => ia.cmp(&ib).then(a.bold.cmp(&b.bold)).then(a.italic.cmp(&b.italic)), + (Some(ia), Some(ib)) => ia + .cmp(&ib) + .then(a.bold.cmp(&b.bold)) + .then(a.italic.cmp(&b.italic)), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => a.family_name.cmp(&b.family_name).then(a.bold.cmp(&b.bold)).then(a.italic.cmp(&b.italic)), + (None, None) => a + .family_name + .cmp(&b.family_name) + .then(a.bold.cmp(&b.bold)) + .then(a.italic.cmp(&b.italic)), } }); println!("중복 제거: {} → {} 엔트리", before_dedup, deduped.len()); @@ -942,7 +1030,12 @@ fn main() { println!("\nRust 소스코드 생성 중..."); let source = generate_rust_source(&deduped); fs::write(&output, &source).expect("출력 파일 쓰기 실패"); - println!("출력: {} ({} 바이트, {} 폰트)", output.display(), source.len(), deduped.len()); + println!( + "출력: {} ({} 바이트, {} 폰트)", + output.display(), + source.len(), + deduped.len() + ); } } else { // 단일 파일 진단 diff --git a/src/wasm_api.rs b/src/wasm_api.rs index 34692806..83969caa 100644 --- a/src/wasm_api.rs +++ b/src/wasm_api.rs @@ -7,7 +7,6 @@ //! - `HwpDocument::render_page_svg(page_num)` - SVG로 렌더링 //! - `HwpDocument::render_page_html(page_num)` - HTML로 렌더링 - // 하위 호환성: tests.rs에서 super::json_escape 등으로 접근 가능하도록 재내보내기 pub(crate) use crate::document_core::helpers::*; @@ -15,25 +14,29 @@ use wasm_bindgen::prelude::*; #[cfg(target_arch = "wasm32")] use web_sys::HtmlCanvasElement; -use crate::model::document::{Document, Section}; +use crate::document_core::{DocumentCore, DEFAULT_FALLBACK_FONT}; +use crate::error::HwpError; use crate::model::control::Control; -use crate::model::paragraph::Paragraph; +use crate::model::document::{Document, Section}; use crate::model::page::ColumnDef; -use crate::model::path::{PathSegment, DocumentPath, path_from_flat}; -use crate::renderer::pagination::{Paginator, PaginationResult}; -use crate::renderer::height_measurer::{MeasuredTable, MeasuredSection, HeightMeasurer}; +use crate::model::paragraph::Paragraph; +use crate::model::path::{path_from_flat, DocumentPath, PathSegment}; +use crate::renderer::canvas::CanvasRenderer; +use crate::renderer::composer::{ + compose_paragraph, compose_section, reflow_line_segs, ComposedParagraph, +}; +use crate::renderer::height_measurer::{HeightMeasurer, MeasuredSection, MeasuredTable}; +use crate::renderer::html::HtmlRenderer; use crate::renderer::layout::LayoutEngine; +use crate::renderer::page_layout::PageLayoutInfo; +use crate::renderer::pagination::{PaginationResult, Paginator}; use crate::renderer::render_tree::PageRenderTree; +use crate::renderer::scheduler::{RenderEvent, RenderObserver, RenderScheduler, Viewport}; +use crate::renderer::style_resolver::{ + resolve_font_substitution, resolve_styles, ResolvedStyleSet, +}; use crate::renderer::svg::SvgRenderer; -use crate::renderer::html::HtmlRenderer; -use crate::renderer::canvas::CanvasRenderer; -use crate::renderer::scheduler::{RenderScheduler, RenderObserver, RenderEvent, Viewport}; -use crate::renderer::style_resolver::{resolve_styles, resolve_font_substitution, ResolvedStyleSet}; -use crate::renderer::composer::{compose_section, compose_paragraph, reflow_line_segs, ComposedParagraph}; -use crate::renderer::page_layout::PageLayoutInfo; use crate::renderer::DEFAULT_DPI; -use crate::error::HwpError; -use crate::document_core::{DocumentCore, DEFAULT_FALLBACK_FONT}; impl From for JsValue { fn from(err: HwpError) -> Self { @@ -172,7 +175,8 @@ impl HwpDocument { /// 특정 페이지를 Canvas 명령 수로 반환한다. #[wasm_bindgen(js_name = renderPageCanvas)] pub fn render_page_canvas(&self, page_num: u32) -> Result { - self.render_page_canvas_native(page_num).map_err(|e| e.into()) + self.render_page_canvas_native(page_num) + .map_err(|e| e.into()) } /// 특정 페이지를 Canvas 2D에 직접 렌더링한다. @@ -189,19 +193,28 @@ impl HwpDocument { ) -> Result<(), JsValue> { use crate::renderer::web_canvas::WebCanvasRenderer; - let tree = self.build_page_tree_cached(page_num).map_err(|e| JsValue::from(e))?; + let tree = self + .build_page_tree_cached(page_num) + .map_err(|e| JsValue::from(e))?; // scale 정규화: 0 이하 또는 NaN이면 1.0, 최소 0.25 최대 12.0 // (zoom 3.0 × DPR 4.0 = 12.0 지원) - let scale = if scale <= 0.0 || scale.is_nan() { 1.0 } else { scale.clamp(0.25, 12.0) }; + let scale = if scale <= 0.0 || scale.is_nan() { + 1.0 + } else { + scale.clamp(0.25, 12.0) + }; // 최대 캔버스 크기 가드 (16384px) let max_dim = 16384.0; - let scale = if tree.root.bbox.width * scale > max_dim || tree.root.bbox.height * scale > max_dim { - (max_dim / tree.root.bbox.width).min(max_dim / tree.root.bbox.height).min(scale) - } else { - scale - }; + let scale = + if tree.root.bbox.width * scale > max_dim || tree.root.bbox.height * scale > max_dim { + (max_dim / tree.root.bbox.width) + .min(max_dim / tree.root.bbox.height) + .min(scale) + } else { + scale + }; // 캔버스 크기 = 페이지 크기 × scale canvas.set_width((tree.root.bbox.width * scale) as u32); @@ -218,10 +231,19 @@ impl HwpDocument { /// 페이지 렌더 트리를 JSON 문자열로 반환한다. #[wasm_bindgen(js_name = getPageRenderTree)] pub fn get_page_render_tree(&self, page_num: u32) -> Result { - let tree = self.build_page_tree_cached(page_num).map_err(|e| JsValue::from_str(&e.to_string()))?; + let tree = self + .build_page_tree_cached(page_num) + .map_err(|e| JsValue::from_str(&e.to_string()))?; Ok(tree.root.to_json()) } + /// 페이지 레이어 트리를 JSON 문자열로 반환한다. + #[wasm_bindgen(js_name = getPageLayerTree)] + pub fn get_page_layer_tree(&self, page_num: u32) -> Result { + self.get_page_layer_tree_native(page_num) + .map_err(|e| e.into()) + } + /// 페이지 정보를 JSON 문자열로 반환한다. #[wasm_bindgen(js_name = getPageInfo)] pub fn get_page_info(&self, page_num: u32) -> Result { @@ -231,25 +253,29 @@ impl HwpDocument { /// 구역의 용지 설정(PageDef)을 HWPUNIT 원본값으로 반환한다. #[wasm_bindgen(js_name = getPageDef)] pub fn get_page_def(&self, section_idx: u32) -> Result { - self.get_page_def_native(section_idx as usize).map_err(|e| e.into()) + self.get_page_def_native(section_idx as usize) + .map_err(|e| e.into()) } /// 구역의 용지 설정(PageDef)을 변경하고 재페이지네이션한다. #[wasm_bindgen(js_name = setPageDef)] pub fn set_page_def(&mut self, section_idx: u32, json: &str) -> Result { - self.set_page_def_native(section_idx as usize, json).map_err(|e| e.into()) + self.set_page_def_native(section_idx as usize, json) + .map_err(|e| e.into()) } /// 구역 정의(SectionDef)를 JSON으로 반환한다. #[wasm_bindgen(js_name = getSectionDef)] pub fn get_section_def(&self, section_idx: u32) -> Result { - self.get_section_def_native(section_idx as usize).map_err(|e| e.into()) + self.get_section_def_native(section_idx as usize) + .map_err(|e| e.into()) } /// 구역 정의(SectionDef)를 변경하고 재페이지네이션한다. #[wasm_bindgen(js_name = setSectionDef)] pub fn set_section_def(&mut self, section_idx: u32, json: &str) -> Result { - self.set_section_def_native(section_idx as usize, json).map_err(|e| e.into()) + self.set_section_def_native(section_idx as usize, json) + .map_err(|e| e.into()) } /// 모든 구역의 SectionDef를 일괄 변경하고 재페이지네이션한다. @@ -269,13 +295,15 @@ impl HwpDocument { /// 각 TextRun의 위치, 텍스트, 글자별 X 좌표 경계값을 포함한다. #[wasm_bindgen(js_name = getPageTextLayout)] pub fn get_page_text_layout(&self, page_num: u32) -> Result { - self.get_page_text_layout_native(page_num).map_err(|e| e.into()) + self.get_page_text_layout_native(page_num) + .map_err(|e| e.into()) } /// 컨트롤(표, 이미지 등) 레이아웃 정보를 반환한다. #[wasm_bindgen(js_name = getPageControlLayout)] pub fn get_page_control_layout(&self, page_num: u32) -> Result { - self.get_page_control_layout_native(page_num).map_err(|e| e.into()) + self.get_page_control_layout_native(page_num) + .map_err(|e| e.into()) } /// DPI를 설정한다. @@ -320,8 +348,13 @@ impl HwpDocument { char_offset: u32, text: &str, ) -> Result { - self.insert_text_native(section_idx as usize, para_idx as usize, char_offset as usize, text) - .map_err(|e| e.into()) + self.insert_text_native( + section_idx as usize, + para_idx as usize, + char_offset as usize, + text, + ) + .map_err(|e| e.into()) } /// 논리적 오프셋으로 텍스트를 삽입한다. @@ -340,16 +373,21 @@ impl HwpDocument { ) -> Result { let sec = section_idx as usize; let pi = para_idx as usize; - if sec >= self.document.sections.len() || pi >= self.document.sections[sec].paragraphs.len() { + if sec >= self.document.sections.len() || pi >= self.document.sections[sec].paragraphs.len() + { return Err(JsValue::from_str("인덱스 범위 초과")); } let (text_offset, _) = crate::document_core::helpers::logical_to_text_offset( - &self.document.sections[sec].paragraphs[pi], logical_offset as usize); + &self.document.sections[sec].paragraphs[pi], + logical_offset as usize, + ); let result = self.insert_text_native(sec, pi, text_offset, text)?; // 삽입 후 논리적 오프셋 반환 let new_text_offset = text_offset + text.chars().count(); let new_logical = crate::document_core::helpers::text_to_logical_offset( - &self.document.sections[sec].paragraphs[pi], new_text_offset); + &self.document.sections[sec].paragraphs[pi], + new_text_offset, + ); Ok(format!("{{\"ok\":true,\"logicalOffset\":{}}}", new_logical)) } @@ -358,36 +396,54 @@ impl HwpDocument { pub fn get_logical_length(&self, section_idx: u32, para_idx: u32) -> Result { let sec = section_idx as usize; let pi = para_idx as usize; - if sec >= self.document.sections.len() || pi >= self.document.sections[sec].paragraphs.len() { + if sec >= self.document.sections.len() || pi >= self.document.sections[sec].paragraphs.len() + { return Err(JsValue::from_str("인덱스 범위 초과")); } Ok(crate::document_core::helpers::logical_paragraph_length( - &self.document.sections[sec].paragraphs[pi]) as u32) + &self.document.sections[sec].paragraphs[pi], + ) as u32) } /// 논리적 오프셋 → 텍스트 오프셋 변환. #[wasm_bindgen(js_name = logicalToTextOffset)] - pub fn logical_to_text_offset(&self, section_idx: u32, para_idx: u32, logical_offset: u32) -> Result { + pub fn logical_to_text_offset( + &self, + section_idx: u32, + para_idx: u32, + logical_offset: u32, + ) -> Result { let sec = section_idx as usize; let pi = para_idx as usize; - if sec >= self.document.sections.len() || pi >= self.document.sections[sec].paragraphs.len() { + if sec >= self.document.sections.len() || pi >= self.document.sections[sec].paragraphs.len() + { return Err(JsValue::from_str("인덱스 범위 초과")); } let (text_offset, _) = crate::document_core::helpers::logical_to_text_offset( - &self.document.sections[sec].paragraphs[pi], logical_offset as usize); + &self.document.sections[sec].paragraphs[pi], + logical_offset as usize, + ); Ok(text_offset as u32) } /// 텍스트 오프셋 → 논리적 오프셋 변환. #[wasm_bindgen(js_name = textToLogicalOffset)] - pub fn text_to_logical_offset(&self, section_idx: u32, para_idx: u32, text_offset: u32) -> Result { + pub fn text_to_logical_offset( + &self, + section_idx: u32, + para_idx: u32, + text_offset: u32, + ) -> Result { let sec = section_idx as usize; let pi = para_idx as usize; - if sec >= self.document.sections.len() || pi >= self.document.sections[sec].paragraphs.len() { + if sec >= self.document.sections.len() || pi >= self.document.sections[sec].paragraphs.len() + { return Err(JsValue::from_str("인덱스 범위 초과")); } Ok(crate::document_core::helpers::text_to_logical_offset( - &self.document.sections[sec].paragraphs[pi], text_offset as usize) as u32) + &self.document.sections[sec].paragraphs[pi], + text_offset as usize, + ) as u32) } /// 문단에서 텍스트를 삭제한다. @@ -402,8 +458,13 @@ impl HwpDocument { char_offset: u32, count: u32, ) -> Result { - self.delete_text_native(section_idx as usize, para_idx as usize, char_offset as usize, count as usize) - .map_err(|e| e.into()) + self.delete_text_native( + section_idx as usize, + para_idx as usize, + char_offset as usize, + count as usize, + ) + .map_err(|e| e.into()) } /// 표 셀 내부 문단에 텍스트를 삽입한다. @@ -421,10 +482,15 @@ impl HwpDocument { text: &str, ) -> Result { self.insert_text_in_cell_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, - cell_para_idx as usize, char_offset as usize, text, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + cell_para_idx as usize, + char_offset as usize, + text, + ) + .map_err(|e| e.into()) } /// 표 셀 내부 문단에서 텍스트를 삭제한다. @@ -442,10 +508,15 @@ impl HwpDocument { count: u32, ) -> Result { self.delete_text_in_cell_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, - cell_para_idx as usize, char_offset as usize, count as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + cell_para_idx as usize, + char_offset as usize, + count as usize, + ) + .map_err(|e| e.into()) } /// 셀 내부 문단을 분할한다 (셀 내 Enter 키). @@ -462,10 +533,14 @@ impl HwpDocument { char_offset: u32, ) -> Result { self.split_paragraph_in_cell_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, - cell_para_idx as usize, char_offset as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + cell_para_idx as usize, + char_offset as usize, + ) + .map_err(|e| e.into()) } /// 셀 내부 문단을 이전 문단에 병합한다 (셀 내 Backspace at start). @@ -481,62 +556,105 @@ impl HwpDocument { cell_para_idx: u32, ) -> Result { self.merge_paragraph_in_cell_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, cell_para_idx as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } // ─── 중첩 표 path 기반 편집 API ────────────────────────── #[wasm_bindgen(js_name = insertTextInCellByPath)] pub fn insert_text_in_cell_by_path_api( - &mut self, section_idx: u32, parent_para_idx: u32, path_json: &str, char_offset: u32, text: &str, + &mut self, + section_idx: u32, + parent_para_idx: u32, + path_json: &str, + char_offset: u32, + text: &str, ) -> Result { let path = DocumentCore::parse_cell_path(path_json)?; self.insert_text_in_cell_by_path( - section_idx as usize, parent_para_idx as usize, &path, char_offset as usize, text, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + &path, + char_offset as usize, + text, + ) + .map_err(|e| e.into()) } #[wasm_bindgen(js_name = deleteTextInCellByPath)] pub fn delete_text_in_cell_by_path_api( - &mut self, section_idx: u32, parent_para_idx: u32, path_json: &str, char_offset: u32, count: u32, + &mut self, + section_idx: u32, + parent_para_idx: u32, + path_json: &str, + char_offset: u32, + count: u32, ) -> Result { let path = DocumentCore::parse_cell_path(path_json)?; self.delete_text_in_cell_by_path( - section_idx as usize, parent_para_idx as usize, &path, char_offset as usize, count as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + &path, + char_offset as usize, + count as usize, + ) + .map_err(|e| e.into()) } #[wasm_bindgen(js_name = splitParagraphInCellByPath)] pub fn split_paragraph_in_cell_by_path_api( - &mut self, section_idx: u32, parent_para_idx: u32, path_json: &str, char_offset: u32, + &mut self, + section_idx: u32, + parent_para_idx: u32, + path_json: &str, + char_offset: u32, ) -> Result { let path = DocumentCore::parse_cell_path(path_json)?; self.split_paragraph_in_cell_by_path( - section_idx as usize, parent_para_idx as usize, &path, char_offset as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + &path, + char_offset as usize, + ) + .map_err(|e| e.into()) } #[wasm_bindgen(js_name = mergeParagraphInCellByPath)] pub fn merge_paragraph_in_cell_by_path_api( - &mut self, section_idx: u32, parent_para_idx: u32, path_json: &str, + &mut self, + section_idx: u32, + parent_para_idx: u32, + path_json: &str, ) -> Result { let path = DocumentCore::parse_cell_path(path_json)?; - self.merge_paragraph_in_cell_by_path( - section_idx as usize, parent_para_idx as usize, &path, - ).map_err(|e| e.into()) + self.merge_paragraph_in_cell_by_path(section_idx as usize, parent_para_idx as usize, &path) + .map_err(|e| e.into()) } #[wasm_bindgen(js_name = getTextInCellByPath)] pub fn get_text_in_cell_by_path_api( - &self, section_idx: u32, parent_para_idx: u32, path_json: &str, char_offset: u32, count: u32, + &self, + section_idx: u32, + parent_para_idx: u32, + path_json: &str, + char_offset: u32, + count: u32, ) -> Result { let path = DocumentCore::parse_cell_path(path_json)?; self.get_text_in_cell_by_path( - section_idx as usize, parent_para_idx as usize, &path, char_offset as usize, count as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + &path, + char_offset as usize, + count as usize, + ) + .map_err(|e| e.into()) } // ─── 머리말/꼬리말 API ────────────────────────────────── @@ -583,9 +701,14 @@ impl HwpDocument { text: &str, ) -> Result { self.insert_text_in_header_footer_native( - section_idx as usize, is_header, apply_to, - hf_para_idx as usize, char_offset as usize, text, - ).map_err(|e| e.into()) + section_idx as usize, + is_header, + apply_to, + hf_para_idx as usize, + char_offset as usize, + text, + ) + .map_err(|e| e.into()) } /// 머리말/꼬리말 내 텍스트 삭제 @@ -602,9 +725,14 @@ impl HwpDocument { count: u32, ) -> Result { self.delete_text_in_header_footer_native( - section_idx as usize, is_header, apply_to, - hf_para_idx as usize, char_offset as usize, count as usize, - ).map_err(|e| e.into()) + section_idx as usize, + is_header, + apply_to, + hf_para_idx as usize, + char_offset as usize, + count as usize, + ) + .map_err(|e| e.into()) } /// 머리말/꼬리말 내 문단 분할 (Enter 키) @@ -620,9 +748,13 @@ impl HwpDocument { char_offset: u32, ) -> Result { self.split_paragraph_in_header_footer_native( - section_idx as usize, is_header, apply_to, - hf_para_idx as usize, char_offset as usize, - ).map_err(|e| e.into()) + section_idx as usize, + is_header, + apply_to, + hf_para_idx as usize, + char_offset as usize, + ) + .map_err(|e| e.into()) } /// 머리말/꼬리말 내 문단 병합 (Backspace at start) @@ -637,9 +769,12 @@ impl HwpDocument { hf_para_idx: u32, ) -> Result { self.merge_paragraph_in_header_footer_native( - section_idx as usize, is_header, apply_to, + section_idx as usize, + is_header, + apply_to, hf_para_idx as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 머리말/꼬리말 문단 정보 조회 @@ -654,9 +789,12 @@ impl HwpDocument { hf_para_idx: u32, ) -> Result { self.get_header_footer_para_info_native( - section_idx as usize, is_header, apply_to, + section_idx as usize, + is_header, + apply_to, hf_para_idx as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 표에 행을 삽입한다. @@ -672,9 +810,13 @@ impl HwpDocument { below: bool, ) -> Result { self.insert_table_row_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, row_idx as u16, below, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + row_idx as u16, + below, + ) + .map_err(|e| e.into()) } /// 표에 열을 삽입한다. @@ -690,9 +832,13 @@ impl HwpDocument { right: bool, ) -> Result { self.insert_table_column_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, col_idx as u16, right, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + col_idx as u16, + right, + ) + .map_err(|e| e.into()) } /// 표에서 행을 삭제한다. @@ -707,9 +853,12 @@ impl HwpDocument { row_idx: u32, ) -> Result { self.delete_table_row_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, row_idx as u16, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + row_idx as u16, + ) + .map_err(|e| e.into()) } /// 표에서 열을 삭제한다. @@ -724,9 +873,12 @@ impl HwpDocument { col_idx: u32, ) -> Result { self.delete_table_column_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, col_idx as u16, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + col_idx as u16, + ) + .map_err(|e| e.into()) } /// 표의 셀을 병합한다. @@ -744,11 +896,15 @@ impl HwpDocument { end_col: u32, ) -> Result { self.merge_table_cells_native( - section_idx as usize, parent_para_idx as usize, + section_idx as usize, + parent_para_idx as usize, control_idx as usize, - start_row as u16, start_col as u16, - end_row as u16, end_col as u16, - ).map_err(|e| e.into()) + start_row as u16, + start_col as u16, + end_row as u16, + end_col as u16, + ) + .map_err(|e| e.into()) } /// 병합된 셀을 나눈다 (split). @@ -764,10 +920,13 @@ impl HwpDocument { col: u32, ) -> Result { self.split_table_cell_native( - section_idx as usize, parent_para_idx as usize, + section_idx as usize, + parent_para_idx as usize, control_idx as usize, - row as u16, col as u16, - ).map_err(|e| e.into()) + row as u16, + col as u16, + ) + .map_err(|e| e.into()) } /// 셀을 N줄 × M칸으로 분할한다. @@ -787,12 +946,17 @@ impl HwpDocument { merge_first: bool, ) -> Result { self.split_table_cell_into_native( - section_idx as usize, parent_para_idx as usize, + section_idx as usize, + parent_para_idx as usize, control_idx as usize, - row as u16, col as u16, - n_rows as u16, m_cols as u16, - equal_row_height, merge_first, - ).map_err(|e| e.into()) + row as u16, + col as u16, + n_rows as u16, + m_cols as u16, + equal_row_height, + merge_first, + ) + .map_err(|e| e.into()) } /// 범위 내 셀들을 각각 N줄 × M칸으로 분할한다. @@ -813,13 +977,18 @@ impl HwpDocument { equal_row_height: bool, ) -> Result { self.split_table_cells_in_range_native( - section_idx as usize, parent_para_idx as usize, + section_idx as usize, + parent_para_idx as usize, control_idx as usize, - start_row as u16, start_col as u16, - end_row as u16, end_col as u16, - n_rows as u16, m_cols as u16, + start_row as u16, + start_col as u16, + end_row as u16, + end_col as u16, + n_rows as u16, + m_cols as u16, equal_row_height, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 캐럿 위치에서 문단을 분할한다 (Enter 키). @@ -833,26 +1002,44 @@ impl HwpDocument { para_idx: u32, char_offset: u32, ) -> Result { - self.split_paragraph_native(section_idx as usize, para_idx as usize, char_offset as usize) - .map_err(|e| e.into()) + self.split_paragraph_native( + section_idx as usize, + para_idx as usize, + char_offset as usize, + ) + .map_err(|e| e.into()) } /// 강제 쪽 나누기 삽입 (Ctrl+Enter) #[wasm_bindgen(js_name = insertPageBreak)] pub fn insert_page_break( - &mut self, section_idx: u32, para_idx: u32, char_offset: u32, + &mut self, + section_idx: u32, + para_idx: u32, + char_offset: u32, ) -> Result { - self.insert_page_break_native(section_idx as usize, para_idx as usize, char_offset as usize) - .map_err(|e| e.into()) + self.insert_page_break_native( + section_idx as usize, + para_idx as usize, + char_offset as usize, + ) + .map_err(|e| e.into()) } /// 단 나누기 삽입 (Ctrl+Shift+Enter) #[wasm_bindgen(js_name = insertColumnBreak)] pub fn insert_column_break( - &mut self, section_idx: u32, para_idx: u32, char_offset: u32, + &mut self, + section_idx: u32, + para_idx: u32, + char_offset: u32, ) -> Result { - self.insert_column_break_native(section_idx as usize, para_idx as usize, char_offset as usize) - .map_err(|e| e.into()) + self.insert_column_break_native( + section_idx as usize, + para_idx as usize, + char_offset as usize, + ) + .map_err(|e| e.into()) } /// 다단 설정 변경 @@ -860,15 +1047,21 @@ impl HwpDocument { /// same_width: 0=다른 너비, 1=같은 너비 #[wasm_bindgen(js_name = setColumnDef)] pub fn set_column_def( - &mut self, section_idx: u32, - column_count: u32, column_type: u32, - same_width: u32, spacing_hu: i32, + &mut self, + section_idx: u32, + column_count: u32, + column_type: u32, + same_width: u32, + spacing_hu: i32, ) -> Result { self.set_column_def_native( section_idx as usize, - column_count as u16, column_type as u8, - same_width != 0, spacing_hu as i16, - ).map_err(|e| e.into()) + column_count as u16, + column_type as u8, + same_width != 0, + spacing_hu as i16, + ) + .map_err(|e| e.into()) } /// 현재 문단을 이전 문단에 병합한다 (Backspace at start). @@ -876,11 +1069,7 @@ impl HwpDocument { /// para_idx의 텍스트가 para_idx-1에 결합되고 para_idx는 삭제된다. /// 반환값: JSON `{"ok":true,"paraIdx":,"charOffset":}` #[wasm_bindgen(js_name = mergeParagraph)] - pub fn merge_paragraph( - &mut self, - section_idx: u32, - para_idx: u32, - ) -> Result { + pub fn merge_paragraph(&mut self, section_idx: u32, para_idx: u32) -> Result { self.merge_paragraph_native(section_idx as usize, para_idx as usize) .map_err(|e| e.into()) } @@ -920,30 +1109,47 @@ impl HwpDocument { /// delta=+1(앞), delta=-1(뒤). ctrl_idx=-1이면 본문 텍스트에서 출발. #[wasm_bindgen(js_name = findNextEditableControl)] pub fn find_next_editable_control( - &self, section_idx: u32, para_idx: u32, ctrl_idx: i32, delta: i32, + &self, + section_idx: u32, + para_idx: u32, + ctrl_idx: i32, + delta: i32, ) -> String { self.find_next_editable_control_native( - section_idx as usize, para_idx as usize, ctrl_idx, delta, + section_idx as usize, + para_idx as usize, + ctrl_idx, + delta, ) } /// 커서에서 이전 방향으로 가장 가까운 선택 가능 컨트롤을 찾는다 (F11 키). #[wasm_bindgen(js_name = findNearestControlBackward)] pub fn find_nearest_control_backward( - &self, section_idx: u32, para_idx: u32, char_offset: u32, + &self, + section_idx: u32, + para_idx: u32, + char_offset: u32, ) -> String { self.find_nearest_control_backward_native( - section_idx as usize, para_idx as usize, char_offset as usize, + section_idx as usize, + para_idx as usize, + char_offset as usize, ) } /// 현재 위치 이후의 가장 가까운 선택 가능 컨트롤을 찾는다 (Shift+F11). #[wasm_bindgen(js_name = findNearestControlForward)] pub fn find_nearest_control_forward( - &self, section_idx: u32, para_idx: u32, char_offset: u32, + &self, + section_idx: u32, + para_idx: u32, + char_offset: u32, ) -> String { self.find_nearest_control_forward_native( - section_idx as usize, para_idx as usize, char_offset as usize, + section_idx as usize, + para_idx as usize, + char_offset as usize, ) } @@ -954,7 +1160,14 @@ impl HwpDocument { if let Some(sec) = sections.get(section_idx as usize) { if let Some(para) = sec.paragraphs.get(para_idx as usize) { let positions = crate::document_core::find_control_text_positions(para); - return format!("[{}]", positions.iter().map(|p| p.to_string()).collect::>().join(",")); + return format!( + "[{}]", + positions + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(",") + ); } } "[]".to_string() @@ -964,12 +1177,19 @@ impl HwpDocument { /// context_json: NavContextEntry 배열의 JSON (빈 배열 "[]" = body) #[wasm_bindgen(js_name = navigateNextEditable)] pub fn navigate_next_editable_wasm( - &self, sec: u32, para: u32, char_offset: u32, delta: i32, context_json: &str, + &self, + sec: u32, + para: u32, + char_offset: u32, + delta: i32, + context_json: &str, ) -> String { let raw_context = DocumentCore::parse_nav_context(context_json); // TypeScript에서 ctrl_text_pos=0으로 전달되므로 실제 값으로 보정 let context = DocumentCore::fix_context_text_positions( - &self.core.document.sections, sec as usize, &raw_context, + &self.core.document.sections, + sec as usize, + &raw_context, ); // 오버플로우 링크 계산 (캐시됨) @@ -979,15 +1199,23 @@ impl HwpDocument { let max_para = if !context.is_empty() { let last = &context[context.len() - 1]; self.core.last_rendered_para_in_container( - sec as usize, last.parent_para, last.ctrl_idx, last.cell_idx, + sec as usize, + last.parent_para, + last.ctrl_idx, + last.cell_idx, ) } else { None }; let result = self.core.navigate_next_editable( - sec as usize, para as usize, char_offset as usize, delta, - &context, max_para, &overflow_links, + sec as usize, + para as usize, + char_offset as usize, + delta, + &context, + max_para, + &overflow_links, ); DocumentCore::nav_result_to_json(&result) } @@ -1002,9 +1230,12 @@ impl HwpDocument { count: u32, ) -> Result { self.get_text_range_native( - section_idx as usize, para_idx as usize, - char_offset as usize, count as usize, - ).map_err(|e| e.into()) + section_idx as usize, + para_idx as usize, + char_offset as usize, + count as usize, + ) + .map_err(|e| e.into()) } /// 표 셀 내 문단 수를 반환한다. @@ -1017,9 +1248,12 @@ impl HwpDocument { cell_idx: u32, ) -> Result { self.get_cell_paragraph_count_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, - ).map(|v| v as u32) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + ) + .map(|v| v as u32) .map_err(|e| e.into()) } @@ -1034,10 +1268,13 @@ impl HwpDocument { cell_para_idx: u32, ) -> Result { self.get_cell_paragraph_length_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, cell_para_idx as usize, - ).map(|v| v as u32) + ) + .map(|v| v as u32) .map_err(|e| e.into()) } @@ -1050,9 +1287,13 @@ impl HwpDocument { path_json: &str, ) -> Result { let path = DocumentCore::parse_cell_path(path_json)?; - let count = self.resolve_container_para_count_by_path( - section_idx as usize, parent_para_idx as usize, &path, - ).map_err(|e| -> JsValue { e.into() })?; + let count = self + .resolve_container_para_count_by_path( + section_idx as usize, + parent_para_idx as usize, + &path, + ) + .map_err(|e| -> JsValue { e.into() })?; Ok(count as u32) } @@ -1065,9 +1306,9 @@ impl HwpDocument { path_json: &str, ) -> Result { let path = DocumentCore::parse_cell_path(path_json)?; - let para = self.resolve_paragraph_by_path( - section_idx as usize, parent_para_idx as usize, &path, - ).map_err(|e| -> JsValue { e.into() })?; + let para = self + .resolve_paragraph_by_path(section_idx as usize, parent_para_idx as usize, &path) + .map_err(|e| -> JsValue { e.into() })?; Ok(para.text.chars().count() as u32) } @@ -1080,13 +1321,19 @@ impl HwpDocument { control_idx: u32, cell_idx: u32, ) -> Result { - let para = self.document.sections.get(section_idx as usize) + let para = self + .document + .sections + .get(section_idx as usize) .ok_or_else(|| JsValue::from_str("구역 인덱스 범위 초과"))? - .paragraphs.get(parent_para_idx as usize) + .paragraphs + .get(parent_para_idx as usize) .ok_or_else(|| JsValue::from_str("문단 인덱스 범위 초과"))?; match para.controls.get(control_idx as usize) { Some(Control::Table(table)) => { - let cell = table.cells.get(cell_idx as usize) + let cell = table + .cells + .get(cell_idx as usize) .ok_or_else(|| JsValue::from_str("셀 인덱스 범위 초과"))?; Ok(cell.text_direction as u32) } @@ -1107,11 +1354,15 @@ impl HwpDocument { count: u32, ) -> Result { self.get_text_in_cell_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, - cell_para_idx as usize, char_offset as usize, + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + cell_para_idx as usize, + char_offset as usize, count as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } // ─── Phase 1 끝 ───────────────────────────────────────── @@ -1132,19 +1383,15 @@ impl HwpDocument { section_idx as usize, para_idx as usize, char_offset as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 페이지 좌표에서 문서 위치를 찾는다. /// /// 반환: JSON `{"sectionIndex":N,"paragraphIndex":N,"charOffset":N}` #[wasm_bindgen(js_name = hitTest)] - pub fn hit_test( - &self, - page_num: u32, - x: f64, - y: f64, - ) -> Result { + pub fn hit_test(&self, page_num: u32, x: f64, y: f64) -> Result { self.hit_test_native(page_num, x, y).map_err(|e| e.into()) } @@ -1163,23 +1410,23 @@ impl HwpDocument { preferred_page: i32, ) -> Result { self.get_cursor_rect_in_header_footer_native( - section_idx as usize, is_header, apply_to, - hf_para_idx as usize, char_offset as usize, + section_idx as usize, + is_header, + apply_to, + hf_para_idx as usize, + char_offset as usize, preferred_page, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 페이지 좌표가 머리말/꼬리말 영역에 해당하는지 판별한다. /// /// 반환: JSON `{"hit":true/false,"isHeader":bool,"sectionIndex":N,"applyTo":N}` #[wasm_bindgen(js_name = hitTestHeaderFooter)] - pub fn hit_test_header_footer( - &self, - page_num: u32, - x: f64, - y: f64, - ) -> Result { - self.hit_test_header_footer_native(page_num, x, y).map_err(|e| e.into()) + pub fn hit_test_header_footer(&self, page_num: u32, x: f64, y: f64) -> Result { + self.hit_test_header_footer_native(page_num, x, y) + .map_err(|e| e.into()) } /// 머리말/꼬리말 내부 텍스트 히트테스트. @@ -1221,8 +1468,14 @@ impl HwpDocument { hf_para_idx: usize, props_json: &str, ) -> Result { - self.apply_para_format_in_hf_native(section_idx, is_header, apply_to, hf_para_idx, props_json) - .map_err(|e| e.into()) + self.apply_para_format_in_hf_native( + section_idx, + is_header, + apply_to, + hf_para_idx, + props_json, + ) + .map_err(|e| e.into()) } /// 머리말/꼬리말 문단에 필드 마커를 삽입한다. @@ -1236,8 +1489,15 @@ impl HwpDocument { char_offset: usize, field_type: u8, ) -> Result { - self.insert_field_in_hf_native(section_idx, is_header, apply_to, hf_para_idx, char_offset, field_type) - .map_err(|e| e.into()) + self.insert_field_in_hf_native( + section_idx, + is_header, + apply_to, + hf_para_idx, + char_offset, + field_type, + ) + .map_err(|e| e.into()) } /// 머리말/꼬리말 마당(템플릿)을 적용한다. @@ -1277,7 +1537,8 @@ impl HwpDocument { current_section_idx as usize, current_is_header, current_apply_to as u8, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 페이지 단위로 이전/다음 머리말·꼬리말로 이동한다. @@ -1322,10 +1583,14 @@ impl HwpDocument { char_offset: u32, ) -> Result { self.get_cursor_rect_in_cell_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, - cell_para_idx as usize, char_offset as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + cell_para_idx as usize, + char_offset as usize, + ) + .map_err(|e| e.into()) } // ─── Phase 3: 커서 이동 API ────────────────────────────── @@ -1341,8 +1606,11 @@ impl HwpDocument { char_offset: u32, ) -> Result { self.get_line_info_native( - section_idx as usize, para_idx as usize, char_offset as usize, - ).map_err(|e| e.into()) + section_idx as usize, + para_idx as usize, + char_offset as usize, + ) + .map_err(|e| e.into()) } /// 표 셀 내 문단의 줄 정보를 반환한다. @@ -1359,10 +1627,14 @@ impl HwpDocument { char_offset: u32, ) -> Result { self.get_line_info_in_cell_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, - cell_para_idx as usize, char_offset as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + cell_para_idx as usize, + char_offset as usize, + ) + .map_err(|e| e.into()) } /// 문서에 저장된 캐럿 위치를 반환한다 (문서 로딩 시 캐럿 자동 배치용). @@ -1384,8 +1656,11 @@ impl HwpDocument { control_idx: u32, ) -> Result { self.get_table_dimensions_native( - section_idx as usize, parent_para_idx as usize, control_idx as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + ) + .map_err(|e| e.into()) } /// 표 셀의 행/열/병합 정보를 반환한다. @@ -1400,9 +1675,12 @@ impl HwpDocument { cell_idx: u32, ) -> Result { self.get_cell_info_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + ) + .map_err(|e| e.into()) } /// 셀 속성을 조회한다. @@ -1417,9 +1695,12 @@ impl HwpDocument { cell_idx: u32, ) -> Result { self.get_cell_properties_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + ) + .map_err(|e| e.into()) } /// 셀 속성을 수정한다. @@ -1435,9 +1716,13 @@ impl HwpDocument { json: &str, ) -> Result { self.set_cell_properties_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, json, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + json, + ) + .map_err(|e| e.into()) } /// 여러 셀의 width/height를 한 번에 조절한다 (배치). @@ -1453,9 +1738,12 @@ impl HwpDocument { json: &str, ) -> Result { self.resize_table_cells_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, json, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + json, + ) + .map_err(|e| e.into()) } /// 표의 위치 오프셋(vertical_offset, horizontal_offset)을 이동한다. @@ -1472,9 +1760,13 @@ impl HwpDocument { delta_v: i32, ) -> Result { self.move_table_offset_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, delta_h, delta_v, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + delta_h, + delta_v, + ) + .map_err(|e| e.into()) } /// 표 속성을 조회한다. @@ -1488,8 +1780,11 @@ impl HwpDocument { control_idx: u32, ) -> Result { self.get_table_properties_native( - section_idx as usize, parent_para_idx as usize, control_idx as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + ) + .map_err(|e| e.into()) } /// 표 속성을 수정한다. @@ -1504,9 +1799,12 @@ impl HwpDocument { json: &str, ) -> Result { self.set_table_properties_native( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, json, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + json, + ) + .map_err(|e| e.into()) } /// 표의 모든 셀 bbox를 반환한다 (F5 셀 선택 모드용). @@ -1521,9 +1819,12 @@ impl HwpDocument { page_hint: Option, ) -> Result { self.get_table_cell_bboxes_from_page( - section_idx as usize, parent_para_idx as usize, control_idx as usize, + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, page_hint.unwrap_or(0) as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 표 전체의 바운딩박스를 반환한다. @@ -1537,8 +1838,11 @@ impl HwpDocument { control_idx: u32, ) -> Result { self.get_table_bbox_native( - section_idx as usize, parent_para_idx as usize, control_idx as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + ) + .map_err(|e| e.into()) } /// 표 컨트롤을 문단에서 삭제한다. @@ -1552,8 +1856,11 @@ impl HwpDocument { control_idx: u32, ) -> Result { self.delete_table_control_native( - section_idx as usize, parent_para_idx as usize, control_idx as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + ) + .map_err(|e| e.into()) } /// 커서 위치에 새 표를 삽입한다. @@ -1569,9 +1876,13 @@ impl HwpDocument { col_count: u32, ) -> Result { self.create_table_native( - section_idx as usize, para_idx as usize, char_offset as usize, - row_count as u16, col_count as u16, - ).map_err(|e| e.into()) + section_idx as usize, + para_idx as usize, + char_offset as usize, + row_count as u16, + col_count as u16, + ) + .map_err(|e| e.into()) } /// 커서 위치에 표를 삽입한다 (확장, JSON 옵션). @@ -1580,7 +1891,7 @@ impl HwpDocument { /// treatAsChar?: bool, colWidths?: [u32, ...] } #[wasm_bindgen(js_name = createTableEx)] pub fn create_table_ex(&mut self, options_json: &str) -> Result { - use crate::document_core::helpers::{json_u32, json_bool}; + use crate::document_core::helpers::{json_bool, json_u32}; let section_idx = json_u32(options_json, "sectionIdx").unwrap_or(0) as usize; let para_idx = json_u32(options_json, "paraIdx").unwrap_or(0) as usize; let char_offset = json_u32(options_json, "charOffset").unwrap_or(0) as usize; @@ -1595,20 +1906,36 @@ impl HwpDocument { if let Some(arr_start) = rest.find('[') { if let Some(arr_end) = rest[arr_start..].find(']') { let arr_str = &rest[arr_start + 1..arr_start + arr_end]; - let nums: Vec = arr_str.split(',') + let nums: Vec = arr_str + .split(',') .filter_map(|s| s.trim().parse::().ok()) .collect(); - if !nums.is_empty() { Some(nums) } else { None } - } else { None } - } else { None } - } else { None } + if !nums.is_empty() { + Some(nums) + } else { + None + } + } else { + None + } + } else { + None + } + } else { + None + } }; self.create_table_ex_native( - section_idx, para_idx, char_offset, - row_count, col_count, treat_as_char, + section_idx, + para_idx, + char_offset, + row_count, + col_count, + treat_as_char, col_widths.as_deref(), - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 커서 위치에 그림을 삽입한다. @@ -1633,10 +1960,18 @@ impl HwpDocument { description: &str, ) -> Result { self.insert_picture_native( - section_idx as usize, para_idx as usize, char_offset as usize, - image_data, width, height, natural_width_px, natural_height_px, - extension, description, - ).map_err(|e| e.into()) + section_idx as usize, + para_idx as usize, + char_offset as usize, + image_data, + width, + height, + natural_width_px, + natural_height_px, + extension, + description, + ) + .map_err(|e| e.into()) } /// 그림 컨트롤의 속성을 조회한다. @@ -1650,8 +1985,11 @@ impl HwpDocument { control_idx: u32, ) -> Result { self.get_picture_properties_native( - section_idx as usize, parent_para_idx as usize, control_idx as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + ) + .map_err(|e| e.into()) } /// 그림 컨트롤의 속성을 변경한다. @@ -1666,9 +2004,12 @@ impl HwpDocument { props_json: &str, ) -> Result { self.set_picture_properties_native( - section_idx as usize, parent_para_idx as usize, control_idx as usize, + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, props_json, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 그림 컨트롤을 문단에서 삭제한다. @@ -1682,8 +2023,11 @@ impl HwpDocument { control_idx: u32, ) -> Result { self.delete_picture_control_native( - section_idx as usize, parent_para_idx as usize, control_idx as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + ) + .map_err(|e| e.into()) } // ─── Equation(수식) API ────────────────────────────── @@ -1700,12 +2044,24 @@ impl HwpDocument { cell_idx: i32, cell_para_idx: i32, ) -> Result { - let ci = if cell_idx >= 0 { Some(cell_idx as usize) } else { None }; - let cpi = if cell_para_idx >= 0 { Some(cell_para_idx as usize) } else { None }; + let ci = if cell_idx >= 0 { + Some(cell_idx as usize) + } else { + None + }; + let cpi = if cell_para_idx >= 0 { + Some(cell_para_idx as usize) + } else { + None + }; self.get_equation_properties_native( - section_idx as usize, parent_para_idx as usize, control_idx as usize, - ci, cpi, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + ci, + cpi, + ) + .map_err(|e| e.into()) } /// 수식 컨트롤의 속성을 변경한다. @@ -1721,12 +2077,25 @@ impl HwpDocument { cell_para_idx: i32, props_json: &str, ) -> Result { - let ci = if cell_idx >= 0 { Some(cell_idx as usize) } else { None }; - let cpi = if cell_para_idx >= 0 { Some(cell_para_idx as usize) } else { None }; + let ci = if cell_idx >= 0 { + Some(cell_idx as usize) + } else { + None + }; + let cpi = if cell_para_idx >= 0 { + Some(cell_para_idx as usize) + } else { + None + }; self.set_equation_properties_native( - section_idx as usize, parent_para_idx as usize, control_idx as usize, - ci, cpi, props_json, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + ci, + cpi, + props_json, + ) + .map_err(|e| e.into()) } /// 수식 스크립트를 SVG로 렌더링하여 반환한다 (미리보기 전용). @@ -1751,16 +2120,17 @@ impl HwpDocument { let rest = &json[start + key.len()..]; if let Some(end) = rest.find(']') { let arr = &rest[..end]; - return arr.split("},").filter_map(|item| { - let item = item.trim().trim_start_matches('{').trim_end_matches('}'); - let x = crate::document_core::helpers::json_i32( - &format!("{{{}}}", item), "x", - )?; - let y = crate::document_core::helpers::json_i32( - &format!("{{{}}}", item), "y", - )?; - Some(crate::model::Point { x, y }) - }).collect(); + return arr + .split("},") + .filter_map(|item| { + let item = item.trim().trim_start_matches('{').trim_end_matches('}'); + let x = + crate::document_core::helpers::json_i32(&format!("{{{}}}", item), "x")?; + let y = + crate::document_core::helpers::json_i32(&format!("{{{}}}", item), "y")?; + Some(crate::model::Point { x, y }) + }) + .collect(); } } Vec::new() @@ -1774,10 +2144,7 @@ impl HwpDocument { /// "horzOffset":N,"vertOffset":N,"treatAsChar":bool,"textWrap":"Square"}` /// 반환: JSON `{"ok":true,"paraIdx":,"controlIdx":0}` #[wasm_bindgen(js_name = createShapeControl)] - pub fn create_shape_control( - &mut self, - json: &str, - ) -> Result { + pub fn create_shape_control(&mut self, json: &str) -> Result { let sec = json_u32(json, "sectionIdx").unwrap_or(0) as usize; let para = json_u32(json, "paraIdx").unwrap_or(0) as usize; let offset = json_u32(json, "charOffset").unwrap_or(0) as usize; @@ -1799,9 +2166,19 @@ impl HwpDocument { Vec::new() }; let result = self.create_shape_control_native( - sec, para, offset, width, height, - horz_offset, vert_offset, treat_as_char, &text_wrap, &shape_type, - line_flip_x, line_flip_y, &polygon_points, + sec, + para, + offset, + width, + height, + horz_offset, + vert_offset, + treat_as_char, + &text_wrap, + &shape_type, + line_flip_x, + line_flip_y, + &polygon_points, )?; // 연결선: SubjectID + 제어점 라우팅 설정 (생성 후) @@ -1813,7 +2190,15 @@ impl HwpDocument { let pi = json_u32(&result, "paraIdx"); let ci = json_u32(&result, "controlIdx"); if let (Some(pi), Some(ci)) = (pi, ci) { - self.update_connector_subject_ids(sec, pi as usize, ci as usize, ssid, ssidx, esid, esidx); + self.update_connector_subject_ids( + sec, + pi as usize, + ci as usize, + ssid, + ssidx, + esid, + esidx, + ); self.recalculate_connector_routing(sec, pi as usize, ci as usize, ssidx, esidx); } } @@ -1832,8 +2217,11 @@ impl HwpDocument { control_idx: u32, ) -> Result { self.get_shape_properties_native( - section_idx as usize, parent_para_idx as usize, control_idx as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + ) + .map_err(|e| e.into()) } /// Shape(글상자) 속성을 변경한다. @@ -1848,9 +2236,12 @@ impl HwpDocument { props_json: &str, ) -> Result { self.set_shape_properties_native( - section_idx as usize, parent_para_idx as usize, control_idx as usize, + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, props_json, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// Shape(글상자) 컨트롤을 문단에서 삭제한다. @@ -1864,8 +2255,11 @@ impl HwpDocument { control_idx: u32, ) -> Result { self.delete_shape_control_native( - section_idx as usize, parent_para_idx as usize, control_idx as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + ) + .map_err(|e| e.into()) } /// Shape z-order 변경 @@ -1879,8 +2273,12 @@ impl HwpDocument { operation: &str, ) -> Result { self.change_shape_z_order_native( - section_idx as usize, parent_para_idx as usize, control_idx as usize, operation, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + operation, + ) + .map_err(|e| e.into()) } /// 선택된 개체들을 하나의 GroupShape로 묶는다. @@ -1897,13 +2295,13 @@ impl HwpDocument { let rest = &json[start..]; if let Some(arr_start) = rest.find('[') { if let Some(arr_end) = rest.find(']') { - let arr = &rest[arr_start+1..arr_end]; + let arr = &rest[arr_start + 1..arr_end]; // 각 {} 블록에서 paraIdx, controlIdx 추출 let mut pos = 0; while let Some(obj_start) = arr[pos..].find('{') { let obj_start = pos + obj_start; if let Some(obj_end) = arr[obj_start..].find('}') { - let obj = &arr[obj_start..obj_start+obj_end+1]; + let obj = &arr[obj_start..obj_start + obj_end + 1]; let pi = json_u32(obj, "paraIdx").unwrap_or(0) as usize; let ci = json_u32(obj, "controlIdx").unwrap_or(0) as usize; result.push((pi, ci)); @@ -1917,19 +2315,38 @@ impl HwpDocument { } result }; - self.group_shapes_native(sec, &targets).map_err(|e| e.into()) + self.group_shapes_native(sec, &targets) + .map_err(|e| e.into()) } /// GroupShape를 풀어 자식 개체들을 개별로 복원한다. #[wasm_bindgen(js_name = ungroupShape)] - pub fn ungroup_shape(&mut self, section_idx: u32, para_idx: u32, control_idx: u32) -> Result { - self.ungroup_shape_native(section_idx as usize, para_idx as usize, control_idx as usize) - .map_err(|e| e.into()) + pub fn ungroup_shape( + &mut self, + section_idx: u32, + para_idx: u32, + control_idx: u32, + ) -> Result { + self.ungroup_shape_native( + section_idx as usize, + para_idx as usize, + control_idx as usize, + ) + .map_err(|e| e.into()) } /// 직선 끝점 이동 (글로벌 HWPUNIT 좌표) #[wasm_bindgen(js_name = moveLineEndpoint)] - pub fn move_line_endpoint(&mut self, sec: u32, para: u32, ci: u32, sx: i32, sy: i32, ex: i32, ey: i32) -> Result { + pub fn move_line_endpoint( + &mut self, + sec: u32, + para: u32, + ci: u32, + sx: i32, + sy: i32, + ex: i32, + ey: i32, + ) -> Result { self.move_line_endpoint_native(sec as usize, para as usize, ci as usize, sx, sy, ex, ey) .map_err(|e| e.into()) } @@ -1942,92 +2359,159 @@ impl HwpDocument { /// 각주를 삽입한다. #[wasm_bindgen(js_name = insertFootnote)] - pub fn insert_footnote(&mut self, section_idx: u32, para_idx: u32, char_offset: u32) -> Result { - self.insert_footnote_native(section_idx as usize, para_idx as usize, char_offset as usize) - .map_err(|e| e.into()) + pub fn insert_footnote( + &mut self, + section_idx: u32, + para_idx: u32, + char_offset: u32, + ) -> Result { + self.insert_footnote_native( + section_idx as usize, + para_idx as usize, + char_offset as usize, + ) + .map_err(|e| e.into()) } /// 각주 정보를 조회한다. #[wasm_bindgen(js_name = getFootnoteInfo)] - pub fn get_footnote_info(&self, section_idx: u32, para_idx: u32, control_idx: u32) -> Result { - self.get_footnote_info_native(section_idx as usize, para_idx as usize, control_idx as usize) - .map_err(|e| e.into()) + pub fn get_footnote_info( + &self, + section_idx: u32, + para_idx: u32, + control_idx: u32, + ) -> Result { + self.get_footnote_info_native( + section_idx as usize, + para_idx as usize, + control_idx as usize, + ) + .map_err(|e| e.into()) } /// 각주 내 텍스트를 삽입한다. #[wasm_bindgen(js_name = insertTextInFootnote)] pub fn insert_text_in_footnote( - &mut self, section_idx: u32, para_idx: u32, control_idx: u32, - fn_para_idx: u32, char_offset: u32, text: &str, + &mut self, + section_idx: u32, + para_idx: u32, + control_idx: u32, + fn_para_idx: u32, + char_offset: u32, + text: &str, ) -> Result { self.insert_text_in_footnote_native( - section_idx as usize, para_idx as usize, control_idx as usize, - fn_para_idx as usize, char_offset as usize, text, - ).map_err(|e| e.into()) + section_idx as usize, + para_idx as usize, + control_idx as usize, + fn_para_idx as usize, + char_offset as usize, + text, + ) + .map_err(|e| e.into()) } /// 각주 내 텍스트를 삭제한다. #[wasm_bindgen(js_name = deleteTextInFootnote)] pub fn delete_text_in_footnote( - &mut self, section_idx: u32, para_idx: u32, control_idx: u32, - fn_para_idx: u32, char_offset: u32, count: u32, + &mut self, + section_idx: u32, + para_idx: u32, + control_idx: u32, + fn_para_idx: u32, + char_offset: u32, + count: u32, ) -> Result { self.delete_text_in_footnote_native( - section_idx as usize, para_idx as usize, control_idx as usize, - fn_para_idx as usize, char_offset as usize, count as usize, - ).map_err(|e| e.into()) + section_idx as usize, + para_idx as usize, + control_idx as usize, + fn_para_idx as usize, + char_offset as usize, + count as usize, + ) + .map_err(|e| e.into()) } /// 각주 내 문단을 분할한다 (Enter). #[wasm_bindgen(js_name = splitParagraphInFootnote)] pub fn split_paragraph_in_footnote( - &mut self, section_idx: u32, para_idx: u32, control_idx: u32, - fn_para_idx: u32, char_offset: u32, + &mut self, + section_idx: u32, + para_idx: u32, + control_idx: u32, + fn_para_idx: u32, + char_offset: u32, ) -> Result { self.split_paragraph_in_footnote_native( - section_idx as usize, para_idx as usize, control_idx as usize, - fn_para_idx as usize, char_offset as usize, - ).map_err(|e| e.into()) + section_idx as usize, + para_idx as usize, + control_idx as usize, + fn_para_idx as usize, + char_offset as usize, + ) + .map_err(|e| e.into()) } /// 각주 내 문단을 병합한다 (Backspace at start). #[wasm_bindgen(js_name = mergeParagraphInFootnote)] pub fn merge_paragraph_in_footnote( - &mut self, section_idx: u32, para_idx: u32, control_idx: u32, + &mut self, + section_idx: u32, + para_idx: u32, + control_idx: u32, fn_para_idx: u32, ) -> Result { self.merge_paragraph_in_footnote_native( - section_idx as usize, para_idx as usize, control_idx as usize, + section_idx as usize, + para_idx as usize, + control_idx as usize, fn_para_idx as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 각주 영역 히트테스트 #[wasm_bindgen(js_name = hitTestFootnote)] pub fn hit_test_footnote(&self, page_num: u32, x: f64, y: f64) -> Result { - self.hit_test_footnote_native(page_num, x, y).map_err(|e| e.into()) + self.hit_test_footnote_native(page_num, x, y) + .map_err(|e| e.into()) } /// 각주 내부 텍스트 히트테스트 #[wasm_bindgen(js_name = hitTestInFootnote)] pub fn hit_test_in_footnote(&self, page_num: u32, x: f64, y: f64) -> Result { - self.hit_test_in_footnote_native(page_num, x, y).map_err(|e| e.into()) + self.hit_test_in_footnote_native(page_num, x, y) + .map_err(|e| e.into()) } /// 페이지의 각주 참조 정보 #[wasm_bindgen(js_name = getPageFootnoteInfo)] - pub fn get_page_footnote_info(&self, page_num: u32, footnote_index: u32) -> Result { - self.get_page_footnote_info_native(page_num, footnote_index as usize).map_err(|e| e.into()) + pub fn get_page_footnote_info( + &self, + page_num: u32, + footnote_index: u32, + ) -> Result { + self.get_page_footnote_info_native(page_num, footnote_index as usize) + .map_err(|e| e.into()) } /// 각주 내 커서 렉트 계산 #[wasm_bindgen(js_name = getCursorRectInFootnote)] pub fn get_cursor_rect_in_footnote( - &self, page_num: u32, footnote_index: u32, fn_para_idx: u32, char_offset: u32, + &self, + page_num: u32, + footnote_index: u32, + fn_para_idx: u32, + char_offset: u32, ) -> Result { self.get_cursor_rect_in_footnote_native( - page_num, footnote_index as usize, fn_para_idx as usize, char_offset as usize, - ).map_err(|e| e.into()) + page_num, + footnote_index as usize, + fn_para_idx as usize, + char_offset as usize, + ) + .map_err(|e| e.into()) } /// 수직 커서 이동 (ArrowUp/Down) — 단일 호출로 줄/문단/표/구역 경계를 모두 처리한다. @@ -2067,7 +2551,8 @@ impl HwpDocument { delta, preferred_x, cell_ctx, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } // ─── 필드 API (Task 230) ───────────────────────────────── @@ -2085,8 +2570,7 @@ impl HwpDocument { /// 반환: `{ok, value}` #[wasm_bindgen(js_name = getFieldValue)] pub fn get_field_value(&self, field_id: u32) -> Result { - self.get_field_value_by_id(field_id) - .map_err(|e| e.into()) + self.get_field_value_by_id(field_id).map_err(|e| e.into()) } /// 필드 이름으로 값을 조회한다. @@ -2094,8 +2578,7 @@ impl HwpDocument { /// 반환: `{ok, fieldId, value}` #[wasm_bindgen(js_name = getFieldValueByName)] pub fn get_field_value_by_name_api(&self, name: &str) -> Result { - self.get_field_value_by_name(name) - .map_err(|e| e.into()) + self.get_field_value_by_name(name).map_err(|e| e.into()) } /// field_id로 필드 값을 설정한다. @@ -2111,7 +2594,11 @@ impl HwpDocument { /// /// 반환: `{ok, fieldId, oldValue, newValue}` #[wasm_bindgen(js_name = setFieldValueByName)] - pub fn set_field_value_by_name_api(&mut self, name: &str, value: &str) -> Result { + pub fn set_field_value_by_name_api( + &mut self, + name: &str, + value: &str, + ) -> Result { self.set_field_value_by_name(name, value) .map_err(|e| e.into()) } @@ -2124,13 +2611,9 @@ impl HwpDocument { /// /// 반환: `{found, sec, para, ci, formType, name, value, caption, text, bbox}` #[wasm_bindgen(js_name = getFormObjectAt)] - pub fn get_form_object_at( - &self, - page_num: u32, - x: f64, - y: f64, - ) -> Result { - self.core.get_form_object_at_native(page_num, x, y) + pub fn get_form_object_at(&self, page_num: u32, x: f64, y: f64) -> Result { + self.core + .get_form_object_at_native(page_num, x, y) .map_err(|e| e.into()) } @@ -2138,13 +2621,9 @@ impl HwpDocument { /// /// 반환: `{ok, formType, name, value, text, caption, enabled}` #[wasm_bindgen(js_name = getFormValue)] - pub fn get_form_value( - &self, - sec: u32, - para: u32, - ci: u32, - ) -> Result { - self.core.get_form_value_native(sec as usize, para as usize, ci as usize) + pub fn get_form_value(&self, sec: u32, para: u32, ci: u32) -> Result { + self.core + .get_form_value_native(sec as usize, para as usize, ci as usize) .map_err(|e| e.into()) } @@ -2160,7 +2639,8 @@ impl HwpDocument { ci: u32, value_json: &str, ) -> Result { - self.core.set_form_value_native(sec as usize, para as usize, ci as usize, value_json) + self.core + .set_form_value_native(sec as usize, para as usize, ci as usize, value_json) .map_err(|e| e.into()) } @@ -2184,24 +2664,26 @@ impl HwpDocument { form_ci: u32, value_json: &str, ) -> Result { - self.core.set_form_value_in_cell_native( - sec as usize, table_para as usize, table_ci as usize, - cell_idx as usize, cell_para as usize, form_ci as usize, - value_json, - ).map_err(|e| e.into()) + self.core + .set_form_value_in_cell_native( + sec as usize, + table_para as usize, + table_ci as usize, + cell_idx as usize, + cell_para as usize, + form_ci as usize, + value_json, + ) + .map_err(|e| e.into()) } /// 양식 개체 상세 정보를 반환한다 (properties 포함). /// /// 반환: `{ok, formType, name, value, text, caption, enabled, width, height, foreColor, backColor, properties}` #[wasm_bindgen(js_name = getFormObjectInfo)] - pub fn get_form_object_info( - &self, - sec: u32, - para: u32, - ci: u32, - ) -> Result { - self.core.get_form_object_info_native(sec as usize, para as usize, ci as usize) + pub fn get_form_object_info(&self, sec: u32, para: u32, ci: u32) -> Result { + self.core + .get_form_object_info_native(sec as usize, para as usize, ci as usize) .map_err(|e| e.into()) } @@ -2218,14 +2700,16 @@ impl HwpDocument { forward: bool, case_sensitive: bool, ) -> Result { - self.core.search_text_native( - query, - from_sec as usize, - from_para as usize, - from_char as usize, - forward, - case_sensitive, - ).map_err(|e| e.into()) + self.core + .search_text_native( + query, + from_sec as usize, + from_para as usize, + from_char as usize, + forward, + case_sensitive, + ) + .map_err(|e| e.into()) } /// 텍스트 치환 (단일) @@ -2238,13 +2722,15 @@ impl HwpDocument { length: u32, new_text: &str, ) -> Result { - self.core.replace_text_native( - sec as usize, - para as usize, - char_offset as usize, - length as usize, - new_text, - ).map_err(|e| e.into()) + self.core + .replace_text_native( + sec as usize, + para as usize, + char_offset as usize, + length as usize, + new_text, + ) + .map_err(|e| e.into()) } /// 전체 치환 @@ -2255,31 +2741,25 @@ impl HwpDocument { new_text: &str, case_sensitive: bool, ) -> Result { - self.core.replace_all_native(query, new_text, case_sensitive) + self.core + .replace_all_native(query, new_text, case_sensitive) .map_err(|e| e.into()) } /// 글로벌 쪽 번호에 해당하는 첫 문단 위치 반환 #[wasm_bindgen(js_name = getPositionOfPage)] - pub fn get_position_of_page( - &self, - global_page: u32, - ) -> Result { - self.core.get_position_of_page_native(global_page as usize) + pub fn get_position_of_page(&self, global_page: u32) -> Result { + self.core + .get_position_of_page_native(global_page as usize) .map_err(|e| e.into()) } /// 위치에 해당하는 글로벌 쪽 번호 반환 #[wasm_bindgen(js_name = getPageOfPosition)] - pub fn get_page_of_position( - &self, - section_idx: u32, - para_idx: u32, - ) -> Result { - self.core.get_page_of_position_native( - section_idx as usize, - para_idx as usize, - ).map_err(|e| e.into()) + pub fn get_page_of_position(&self, section_idx: u32, para_idx: u32) -> Result { + self.core + .get_page_of_position_native(section_idx as usize, para_idx as usize) + .map_err(|e| e.into()) } /// 커서 위치의 필드 범위 정보를 조회한다 (본문 문단). @@ -2292,7 +2772,11 @@ impl HwpDocument { para_idx: u32, char_offset: u32, ) -> String { - self.get_field_info_at(section_idx as usize, para_idx as usize, char_offset as usize) + self.get_field_info_at( + section_idx as usize, + para_idx as usize, + char_offset as usize, + ) } /// 커서 위치의 필드 범위 정보를 조회한다 (셀/글상자 내 문단). @@ -2308,9 +2792,12 @@ impl HwpDocument { is_textbox: bool, ) -> String { self.get_field_info_at_in_cell( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, - cell_para_idx as usize, char_offset as usize, + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + cell_para_idx as usize, + char_offset as usize, is_textbox, ) } @@ -2323,12 +2810,16 @@ impl HwpDocument { para_idx: u32, char_offset: u32, ) -> String { - match self.remove_field_at(section_idx as usize, para_idx as usize, char_offset as usize) { + match self.remove_field_at( + section_idx as usize, + para_idx as usize, + char_offset as usize, + ) { Ok(s) => s, Err(e) => { let escaped = e.to_string().replace('\\', "\\\\").replace('"', "\\\""); format!("{{\"ok\":false,\"error\":\"{}\"}}", escaped) - }, + } } } @@ -2345,16 +2836,19 @@ impl HwpDocument { is_textbox: bool, ) -> String { match self.remove_field_at_in_cell( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, - cell_para_idx as usize, char_offset as usize, + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + cell_para_idx as usize, + char_offset as usize, is_textbox, ) { Ok(s) => s, Err(e) => { let escaped = e.to_string().replace('\\', "\\\\").replace('"', "\\\""); format!("{{\"ok\":false,\"error\":\"{}\"}}", escaped) - }, + } } } @@ -2366,7 +2860,11 @@ impl HwpDocument { para_idx: u32, char_offset: u32, ) -> bool { - self.set_active_field(section_idx as usize, para_idx as usize, char_offset as usize) + self.set_active_field( + section_idx as usize, + para_idx as usize, + char_offset as usize, + ) } /// 활성 필드를 설정한다 (셀/글상자 내 문단 — 안내문 숨김용). @@ -2383,9 +2881,12 @@ impl HwpDocument { is_textbox: bool, ) -> bool { self.set_active_field_in_cell( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, - cell_para_idx as usize, char_offset as usize, + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + cell_para_idx as usize, + char_offset as usize, is_textbox, ) } @@ -2393,11 +2894,18 @@ impl HwpDocument { /// path 기반: 중첩 표 셀의 필드 범위 정보를 조회한다. #[wasm_bindgen(js_name = getFieldInfoAtByPath)] pub fn get_field_info_at_by_path_api( - &self, section_idx: u32, parent_para_idx: u32, path_json: &str, char_offset: u32, + &self, + section_idx: u32, + parent_para_idx: u32, + path_json: &str, + char_offset: u32, ) -> String { match DocumentCore::parse_cell_path(path_json) { Ok(path) => self.get_field_info_at_by_path( - section_idx as usize, parent_para_idx as usize, &path, char_offset as usize, + section_idx as usize, + parent_para_idx as usize, + &path, + char_offset as usize, ), Err(_) => r#"{"inField":false}"#.to_string(), } @@ -2406,11 +2914,18 @@ impl HwpDocument { /// path 기반: 중첩 표 셀 내 활성 필드를 설정한다. #[wasm_bindgen(js_name = setActiveFieldByPath)] pub fn set_active_field_by_path_api( - &mut self, section_idx: u32, parent_para_idx: u32, path_json: &str, char_offset: u32, + &mut self, + section_idx: u32, + parent_para_idx: u32, + path_json: &str, + char_offset: u32, ) -> bool { match DocumentCore::parse_cell_path(path_json) { Ok(path) => self.set_active_field_by_path( - section_idx as usize, parent_para_idx as usize, &path, char_offset as usize, + section_idx as usize, + parent_para_idx as usize, + &path, + char_offset as usize, ), Err(_) => false, } @@ -2444,7 +2959,8 @@ impl HwpDocument { for ctrl in ¶.controls { let paras: Vec<&crate::model::paragraph::Paragraph> = match ctrl { Control::Table(t) => t.cells.iter().flat_map(|c| &c.paragraphs).collect(), - Control::Shape(s) => s.drawing() + Control::Shape(s) => s + .drawing() .and_then(|d| d.text_box.as_ref()) .map(|tb| tb.paragraphs.iter().collect()) .unwrap_or_default(), @@ -2470,14 +2986,19 @@ impl HwpDocument { let guide = f.guide_text().unwrap_or(""); let memo = f.memo_text().unwrap_or(""); // 필드 이름: ctrl_data_name → command Name: 키 순서 - let name = f.ctrl_data_name.as_deref() + let name = f + .ctrl_data_name + .as_deref() .filter(|s| !s.is_empty()) .or_else(|| f.extract_wstring_value("Name:")) .unwrap_or(""); let editable = f.is_editable_in_form(); format!( "{{\"ok\":true,\"guide\":\"{}\",\"memo\":\"{}\",\"name\":\"{}\",\"editable\":{}}}", - json_escape(guide), json_escape(memo), json_escape(name), editable, + json_escape(guide), + json_escape(memo), + json_escape(name), + editable, ) } @@ -2500,7 +3021,11 @@ impl HwpDocument { // 필드를 찾아 수정하고, ctrl_data_records 바이너리도 갱신 fn update_field_in_para( para: &mut crate::model::paragraph::Paragraph, - field_id: u32, guide: &str, memo: &str, new_props_bit: u32, new_name: &str, + field_id: u32, + guide: &str, + memo: &str, + new_props_bit: u32, + new_name: &str, ) -> bool { for (ci, ctrl) in para.controls.iter_mut().enumerate() { if let Control::Field(f) = ctrl { @@ -2518,7 +3043,11 @@ impl HwpDocument { // command가 변경되지 않았으면 원본 보존 f.properties = (f.properties & !1) | new_props_bit; - f.ctrl_data_name = if new_name.is_empty() { None } else { Some(new_name.to_string()) }; + f.ctrl_data_name = if new_name.is_empty() { + None + } else { + Some(new_name.to_string()) + }; // ctrl_data_records 바이너리 갱신 update_ctrl_data_name(&mut para.ctrl_data_records, ci, new_name); return true; @@ -2529,11 +3058,7 @@ impl HwpDocument { } /// ctrl_data_records[ci]의 필드 이름 부분을 새 이름으로 재구축 - fn update_ctrl_data_name( - records: &mut Vec>>, - ci: usize, - new_name: &str, - ) { + fn update_ctrl_data_name(records: &mut Vec>>, ci: usize, new_name: &str) { // records 확장 (인덱스 부족 시) while records.len() <= ci { records.push(None); @@ -2575,19 +3100,26 @@ impl HwpDocument { // 표/글상자 내부 for ctrl in &mut para.controls { let found = match ctrl { - Control::Table(t) => { - t.cells.iter_mut().any(|c| { - c.paragraphs.iter_mut().any(|p| { - update_field_in_para(p, field_id, guide, memo, new_props_bit, name) - }) + Control::Table(t) => t.cells.iter_mut().any(|c| { + c.paragraphs.iter_mut().any(|p| { + update_field_in_para(p, field_id, guide, memo, new_props_bit, name) }) - } + }), Control::Shape(s) => { if let Some(tb) = s.drawing_mut().and_then(|d| d.text_box.as_mut()) { tb.paragraphs.iter_mut().any(|p| { - update_field_in_para(p, field_id, guide, memo, new_props_bit, name) + update_field_in_para( + p, + field_id, + guide, + memo, + new_props_bit, + name, + ) }) - } else { false } + } else { + false + } } _ => false, }; @@ -2616,9 +3148,12 @@ impl HwpDocument { char_offset: u32, ) -> Result { self.get_cursor_rect_by_path_native( - section_idx as usize, parent_para_idx as usize, - path_json, char_offset as usize, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + path_json, + char_offset as usize, + ) + .map_err(|e| e.into()) } /// 경로 기반 셀 정보 조회 (중첩 표용). @@ -2631,9 +3166,8 @@ impl HwpDocument { parent_para_idx: u32, path_json: &str, ) -> Result { - self.get_cell_info_by_path_native( - section_idx as usize, parent_para_idx as usize, path_json, - ).map_err(|e| e.into()) + self.get_cell_info_by_path_native(section_idx as usize, parent_para_idx as usize, path_json) + .map_err(|e| e.into()) } /// 경로 기반 표 차원 조회 (중첩 표용). @@ -2647,8 +3181,11 @@ impl HwpDocument { path_json: &str, ) -> Result { self.get_table_dimensions_by_path_native( - section_idx as usize, parent_para_idx as usize, path_json, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + path_json, + ) + .map_err(|e| e.into()) } /// 경로 기반 표 셀 바운딩박스 조회 (중첩 표용). @@ -2662,8 +3199,11 @@ impl HwpDocument { path_json: &str, ) -> Result { self.get_table_cell_bboxes_by_path_native( - section_idx as usize, parent_para_idx as usize, path_json, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + path_json, + ) + .map_err(|e| e.into()) } /// 경로 기반 수직 커서 이동 (중첩 표용). @@ -2680,9 +3220,14 @@ impl HwpDocument { preferred_x: f64, ) -> Result { self.move_vertical_by_path_native( - section_idx as usize, parent_para_idx as usize, - path_json, char_offset as usize, delta, preferred_x, - ).map_err(|e| e.into()) + section_idx as usize, + parent_para_idx as usize, + path_json, + char_offset as usize, + delta, + preferred_x, + ) + .map_err(|e| e.into()) } // ─── Phase 4: Selection API ────────────────────────────── @@ -2701,10 +3246,13 @@ impl HwpDocument { ) -> Result { self.get_selection_rects_native( section_idx as usize, - start_para_idx as usize, start_char_offset as usize, - end_para_idx as usize, end_char_offset as usize, + start_para_idx as usize, + start_char_offset as usize, + end_para_idx as usize, + end_char_offset as usize, None, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 셀 내 선택 영역의 줄별 사각형을 반환한다. @@ -2724,10 +3272,17 @@ impl HwpDocument { ) -> Result { self.get_selection_rects_native( section_idx as usize, - start_cell_para_idx as usize, start_char_offset as usize, - end_cell_para_idx as usize, end_char_offset as usize, - Some((parent_para_idx as usize, control_idx as usize, cell_idx as usize)), - ).map_err(|e| e.into()) + start_cell_para_idx as usize, + start_char_offset as usize, + end_cell_para_idx as usize, + end_char_offset as usize, + Some(( + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + )), + ) + .map_err(|e| e.into()) } /// 본문 선택 영역을 삭제한다. @@ -2744,10 +3299,13 @@ impl HwpDocument { ) -> Result { self.delete_range_native( section_idx as usize, - start_para_idx as usize, start_char_offset as usize, - end_para_idx as usize, end_char_offset as usize, + start_para_idx as usize, + start_char_offset as usize, + end_para_idx as usize, + end_char_offset as usize, None, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 셀 내 선택 영역을 삭제한다. @@ -2767,10 +3325,17 @@ impl HwpDocument { ) -> Result { self.delete_range_native( section_idx as usize, - start_cell_para_idx as usize, start_char_offset as usize, - end_cell_para_idx as usize, end_char_offset as usize, - Some((parent_para_idx as usize, control_idx as usize, cell_idx as usize)), - ).map_err(|e| e.into()) + start_cell_para_idx as usize, + start_char_offset as usize, + end_cell_para_idx as usize, + end_char_offset as usize, + Some(( + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + )), + ) + .map_err(|e| e.into()) } // ─── Phase 4 끝 ───────────────────────────────────────── @@ -2858,8 +3423,15 @@ impl HwpDocument { cell_para_idx: usize, char_offset: usize, ) -> Result { - self.get_cell_char_properties_at_native(sec_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx, char_offset) - .map_err(|e| e.into()) + self.get_cell_char_properties_at_native( + sec_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + char_offset, + ) + .map_err(|e| e.into()) } /// 캐럿 위치의 문단 속성을 조회한다. @@ -2885,8 +3457,14 @@ impl HwpDocument { cell_idx: usize, cell_para_idx: usize, ) -> Result { - self.get_cell_para_properties_at_native(sec_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx) - .map_err(|e| e.into()) + self.get_cell_para_properties_at_native( + sec_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ) + .map_err(|e| e.into()) } /// 문서에 정의된 스타일 목록을 조회한다. @@ -2921,13 +3499,19 @@ impl HwpDocument { Some(s) => s, None => return "{}".to_string(), }; - let char_json = self.core.build_char_properties_json_by_id(style.char_shape_id); + let char_json = self + .core + .build_char_properties_json_by_id(style.char_shape_id); // 스타일의 기본 ParaShape에 번호 정보가 없으면, // 이 스타일을 사용하는 실제 문단의 ParaShape에서 조회 - let effective_psid = self.find_effective_para_shape_for_style(style_id, style.para_shape_id); + let effective_psid = + self.find_effective_para_shape_for_style(style_id, style.para_shape_id); let para_json = self.core.build_para_properties_json(effective_psid, 0); - format!("{{\"charProps\":{},\"paraProps\":{}}}", char_json, para_json) + format!( + "{{\"charProps\":{},\"paraProps\":{}}}", + char_json, para_json + ) } /// 스타일의 실효 ParaShape ID를 찾는다. @@ -2935,7 +3519,13 @@ impl HwpDocument { fn find_effective_para_shape_for_style(&self, style_id: u32, base_psid: u16) -> u16 { use crate::model::style::HeadType; // 기본 ParaShape에 이미 번호 정보가 있으면 그대로 사용 - if let Some(ps) = self.core.document.doc_info.para_shapes.get(base_psid as usize) { + if let Some(ps) = self + .core + .document + .doc_info + .para_shapes + .get(base_psid as usize) + { if ps.head_type != HeadType::None { return base_psid; } @@ -2945,7 +3535,13 @@ impl HwpDocument { for section in &self.core.document.sections { for para in §ion.paragraphs { if para.style_id == sid { - if let Some(ps) = self.core.document.doc_info.para_shapes.get(para.para_shape_id as usize) { + if let Some(ps) = self + .core + .document + .doc_info + .para_shapes + .get(para.para_shape_id as usize) + { if ps.head_type != HeadType::None { return para.para_shape_id; } @@ -2986,7 +3582,12 @@ impl HwpDocument { /// /// charMods/paraMods는 기존 parse_char_shape_mods/parse_para_shape_mods와 동일한 JSON 형식 #[wasm_bindgen(js_name = updateStyleShapes)] - pub fn update_style_shapes(&mut self, style_id: u32, char_mods_json: &str, para_mods_json: &str) -> bool { + pub fn update_style_shapes( + &mut self, + style_id: u32, + char_mods_json: &str, + para_mods_json: &str, + ) -> bool { let styles = &self.core.document.doc_info.styles; let style = match styles.get(style_id as usize) { Some(s) => s.clone(), @@ -2996,7 +3597,13 @@ impl HwpDocument { // CharShape 수정 if !char_mods_json.is_empty() && char_mods_json != "{}" { let char_mods = crate::document_core::helpers::parse_char_shape_mods(char_mods_json); - if let Some(cs) = self.core.document.doc_info.char_shapes.get(style.char_shape_id as usize) { + if let Some(cs) = self + .core + .document + .doc_info + .char_shapes + .get(style.char_shape_id as usize) + { let new_cs = char_mods.apply_to(cs); // 새 CharShape를 추가하고 스타일에 연결 self.core.document.doc_info.char_shapes.push(new_cs); @@ -3008,7 +3615,13 @@ impl HwpDocument { // ParaShape 수정 if !para_mods_json.is_empty() && para_mods_json != "{}" { let para_mods = crate::document_core::helpers::parse_para_shape_mods(para_mods_json); - if let Some(ps) = self.core.document.doc_info.para_shapes.get(style.para_shape_id as usize) { + if let Some(ps) = self + .core + .document + .doc_info + .para_shapes + .get(style.para_shape_id as usize) + { let new_ps = para_mods.apply_to(ps); self.core.document.doc_info.para_shapes.push(new_ps); let new_id = (self.core.document.doc_info.para_shapes.len() - 1) as u16; @@ -3029,10 +3642,11 @@ impl HwpDocument { if para.style_id == sid { para.para_shape_id = new_psid; para.char_shapes.clear(); - para.char_shapes.push(crate::model::paragraph::CharShapeRef { - start_pos: 0, - char_shape_id: new_csid, - }); + para.char_shapes + .push(crate::model::paragraph::CharShapeRef { + start_pos: 0, + char_shape_id: new_csid, + }); } // 셀 내 문단도 전파 for ctrl in &mut para.controls { @@ -3042,10 +3656,12 @@ impl HwpDocument { if cpara.style_id == sid { cpara.para_shape_id = new_psid; cpara.char_shapes.clear(); - cpara.char_shapes.push(crate::model::paragraph::CharShapeRef { - start_pos: 0, - char_shape_id: new_csid, - }); + cpara + .char_shapes + .push(crate::model::paragraph::CharShapeRef { + start_pos: 0, + char_shape_id: new_csid, + }); } } } @@ -3096,7 +3712,10 @@ impl HwpDocument { self.core.document.doc_info.styles.push(new_style); let new_id = (self.core.document.doc_info.styles.len() - 1) as i32; // 스타일 캐시 갱신 - self.core.styles = crate::renderer::style_resolver::resolve_styles(&self.core.document.doc_info, self.core.dpi); + self.core.styles = crate::renderer::style_resolver::resolve_styles( + &self.core.document.doc_info, + self.core.dpi, + ); new_id } @@ -3141,7 +3760,10 @@ impl HwpDocument { } } // 스타일 캐시 갱신 - self.core.styles = crate::renderer::style_resolver::resolve_styles(&self.core.document.doc_info, self.core.dpi); + self.core.styles = crate::renderer::style_resolver::resolve_styles( + &self.core.document.doc_info, + self.core.dpi, + ); true } @@ -3154,7 +3776,9 @@ impl HwpDocument { let numberings = &self.core.document.doc_info.numberings; let mut items = Vec::new(); for (i, n) in numberings.iter().enumerate() { - let formats: Vec = n.level_formats.iter() + let formats: Vec = n + .level_formats + .iter() .map(|f| format!("\"{}\"", f.replace('"', "\\\""))) .collect(); items.push(format!( @@ -3201,24 +3825,45 @@ impl HwpDocument { use crate::model::style::{Numbering, NumberingHead}; let mut n = Numbering::default(); n.level_formats = [ - "^1.".to_string(), // 1. - "^2)".to_string(), // 가) - "^3)".to_string(), // (1) - "^4)".to_string(), // (가) - "^5)".to_string(), // ① - "^6)".to_string(), // ㄱ) - "^7)".to_string(), // a) + "^1.".to_string(), // 1. + "^2)".to_string(), // 가) + "^3)".to_string(), // (1) + "^4)".to_string(), // (가) + "^5)".to_string(), // ① + "^6)".to_string(), // ㄱ) + "^7)".to_string(), // a) ]; n.start_number = 1; n.level_start_numbers = [1; 7]; // 수준별 번호 형식 코드 설정 - n.heads[0] = NumberingHead { number_format: 0, ..Default::default() }; // 1,2,3 - n.heads[1] = NumberingHead { number_format: 8, ..Default::default() }; // 가,나,다 - n.heads[2] = NumberingHead { number_format: 0, ..Default::default() }; // 1,2,3 - n.heads[3] = NumberingHead { number_format: 8, ..Default::default() }; // 가,나,다 - n.heads[4] = NumberingHead { number_format: 1, ..Default::default() }; // ①②③ - n.heads[5] = NumberingHead { number_format: 10, ..Default::default() }; // ㄱ,ㄴ,ㄷ - n.heads[6] = NumberingHead { number_format: 5, ..Default::default() }; // a,b,c + n.heads[0] = NumberingHead { + number_format: 0, + ..Default::default() + }; // 1,2,3 + n.heads[1] = NumberingHead { + number_format: 8, + ..Default::default() + }; // 가,나,다 + n.heads[2] = NumberingHead { + number_format: 0, + ..Default::default() + }; // 1,2,3 + n.heads[3] = NumberingHead { + number_format: 8, + ..Default::default() + }; // 가,나,다 + n.heads[4] = NumberingHead { + number_format: 1, + ..Default::default() + }; // ①②③ + n.heads[5] = NumberingHead { + number_format: 10, + ..Default::default() + }; // ㄱ,ㄴ,ㄷ + n.heads[6] = NumberingHead { + number_format: 5, + ..Default::default() + }; // a,b,c self.core.document.doc_info.numberings.push(n); 1 } @@ -3229,8 +3874,8 @@ impl HwpDocument { /// 반환값: Numbering ID (1-based) #[wasm_bindgen(js_name = createNumbering)] pub fn create_numbering(&mut self, json: &str) -> u16 { + use crate::document_core::helpers::json_i32; use crate::model::style::{Numbering, NumberingHead}; - use crate::document_core::helpers::{json_i32}; let mut n = Numbering::default(); @@ -3242,7 +3887,9 @@ impl HwpDocument { let arr_str = &rest[bracket_start + 1..bracket_start + bracket_end]; let mut level = 0; for part in arr_str.split(',') { - if level >= 7 { break; } + if level >= 7 { + break; + } let trimmed = part.trim().trim_matches('"'); if !trimmed.is_empty() { n.level_formats[level] = trimmed.to_string(); @@ -3261,9 +3908,14 @@ impl HwpDocument { let arr_str = &rest[bracket_start + 1..bracket_start + bracket_end]; let mut level = 0; for part in arr_str.split(',') { - if level >= 7 { break; } + if level >= 7 { + break; + } if let Ok(code) = part.trim().parse::() { - n.heads[level] = NumberingHead { number_format: code, ..Default::default() }; + n.heads[level] = NumberingHead { + number_format: code, + ..Default::default() + }; level += 1; } } @@ -3309,14 +3961,27 @@ impl HwpDocument { pub fn get_style_at(&self, sec_idx: u32, para_idx: u32) -> String { let sec = sec_idx as usize; let para = para_idx as usize; - let style_id = self.core.document.sections.get(sec) + let style_id = self + .core + .document + .sections + .get(sec) .and_then(|s| s.paragraphs.get(para)) .map(|p| p.style_id as usize) .unwrap_or(0); - let name = self.core.document.doc_info.styles.get(style_id) + let name = self + .core + .document + .doc_info + .styles + .get(style_id) .map(|s| s.local_name.as_str()) .unwrap_or(""); - format!("{{\"id\":{},\"name\":\"{}\"}}", style_id, name.replace('"', "\\\"")) + format!( + "{{\"id\":{},\"name\":\"{}\"}}", + style_id, + name.replace('"', "\\\"") + ) } /// 셀 내부 문단의 스타일을 조회한다. @@ -3329,16 +3994,30 @@ impl HwpDocument { cell_idx: u32, cell_para_idx: u32, ) -> String { - let style_id = self.core.get_cell_paragraph_ref( - sec_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, cell_para_idx as usize, - ) + let style_id = self + .core + .get_cell_paragraph_ref( + sec_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + cell_para_idx as usize, + ) .map(|p| p.style_id as usize) .unwrap_or(0); - let name = self.core.document.doc_info.styles.get(style_id) + let name = self + .core + .document + .doc_info + .styles + .get(style_id) .map(|s| s.local_name.as_str()) .unwrap_or(""); - format!("{{\"id\":{},\"name\":\"{}\"}}", style_id, name.replace('"', "\\\"")) + format!( + "{{\"id\":{},\"name\":\"{}\"}}", + style_id, + name.replace('"', "\\\"") + ) } /// 스타일을 적용한다 (본문 문단). @@ -3349,7 +4028,8 @@ impl HwpDocument { para_idx: u32, style_id: u32, ) -> Result { - self.core.apply_style_native(sec_idx as usize, para_idx as usize, style_id as usize) + self.core + .apply_style_native(sec_idx as usize, para_idx as usize, style_id as usize) .map_err(|e| e.into()) } @@ -3364,11 +4044,16 @@ impl HwpDocument { cell_para_idx: u32, style_id: u32, ) -> Result { - self.core.apply_cell_style_native( - sec_idx as usize, parent_para_idx as usize, - control_idx as usize, cell_idx as usize, - cell_para_idx as usize, style_id as usize, - ).map_err(|e| e.into()) + self.core + .apply_cell_style_native( + sec_idx as usize, + parent_para_idx as usize, + control_idx as usize, + cell_idx as usize, + cell_para_idx as usize, + style_id as usize, + ) + .map_err(|e| e.into()) } /// 표 셀에서 계산식을 실행한다. @@ -3386,11 +4071,17 @@ impl HwpDocument { formula: &str, write_result: bool, ) -> Result { - self.core.evaluate_table_formula( - section_idx as usize, parent_para_idx as usize, - control_idx as usize, target_row as usize, - target_col as usize, formula, write_result, - ).map_err(|e| e.into()) + self.core + .evaluate_table_formula( + section_idx as usize, + parent_para_idx as usize, + control_idx as usize, + target_row as usize, + target_col as usize, + formula, + write_result, + ) + .map_err(|e| e.into()) } /// 글꼴 이름으로 font_id를 조회하거나 새로 생성한다. @@ -3405,7 +4096,8 @@ impl HwpDocument { /// 특정 언어 카테고리에서 글꼴 이름으로 ID를 찾거나 등록한다. #[wasm_bindgen(js_name = findOrCreateFontIdForLang)] pub fn wasm_find_or_create_font_id_for_lang(&mut self, lang: u32, name: &str) -> i32 { - self.core.find_or_create_font_id_for_lang(lang as usize, name) + self.core + .find_or_create_font_id_for_lang(lang as usize, name) } /// 글자 서식을 적용한다 (본문 문단). @@ -3435,22 +4127,43 @@ impl HwpDocument { end_offset: usize, props_json: &str, ) -> Result { - self.apply_char_format_in_cell_native(sec_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx, start_offset, end_offset, props_json) - .map_err(|e| e.into()) + self.apply_char_format_in_cell_native( + sec_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + start_offset, + end_offset, + props_json, + ) + .map_err(|e| e.into()) } /// 감추기 설정 #[wasm_bindgen(js_name = setPageHide)] pub fn set_page_hide( - &mut self, sec: u32, para: u32, - hide_header: bool, hide_footer: bool, hide_master: bool, - hide_border: bool, hide_fill: bool, hide_page_num: bool, + &mut self, + sec: u32, + para: u32, + hide_header: bool, + hide_footer: bool, + hide_master: bool, + hide_border: bool, + hide_fill: bool, + hide_page_num: bool, ) -> Result { self.set_page_hide_native( - sec as usize, para as usize, - hide_header, hide_footer, hide_master, - hide_border, hide_fill, hide_page_num, - ).map_err(|e| e.into()) + sec as usize, + para as usize, + hide_header, + hide_footer, + hide_master, + hide_border, + hide_fill, + hide_page_num, + ) + .map_err(|e| e.into()) } /// 감추기 조회 @@ -3464,7 +4177,11 @@ impl HwpDocument { /// 문단 번호 시작 방식 설정 #[wasm_bindgen(js_name = setNumberingRestart)] pub fn set_numbering_restart( - &mut self, section_idx: u32, para_idx: u32, mode: u8, start_num: u32, + &mut self, + section_idx: u32, + para_idx: u32, + mode: u8, + start_num: u32, ) -> Result { self.set_numbering_restart_native(section_idx as usize, para_idx as usize, mode, start_num) .map_err(|e| e.into()) @@ -3492,8 +4209,15 @@ impl HwpDocument { cell_para_idx: usize, props_json: &str, ) -> Result { - self.apply_para_format_in_cell_native(sec_idx, parent_para_idx, control_idx, cell_idx, cell_para_idx, props_json) - .map_err(|e| e.into()) + self.apply_para_format_in_cell_native( + sec_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + props_json, + ) + .map_err(|e| e.into()) } // ===================================================================== @@ -3536,7 +4260,8 @@ impl HwpDocument { start_char_offset as usize, end_para_idx as usize, end_char_offset as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 표 셀 내부 선택 영역을 내부 클립보드에 복사한다. @@ -3561,7 +4286,8 @@ impl HwpDocument { start_char_offset as usize, end_cell_para_idx as usize, end_char_offset as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 컨트롤 객체(표, 이미지, 도형)를 내부 클립보드에 복사한다. @@ -3576,7 +4302,8 @@ impl HwpDocument { section_idx as usize, para_idx as usize, control_idx as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 내부 클립보드에 컨트롤(표/그림/도형)이 포함되어 있는지 확인한다. @@ -3599,7 +4326,8 @@ impl HwpDocument { section_idx as usize, para_idx as usize, char_offset as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 내부 클립보드의 내용을 캐럿 위치에 붙여넣는다 (본문 문단). @@ -3616,7 +4344,8 @@ impl HwpDocument { section_idx as usize, para_idx as usize, char_offset as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 내부 클립보드의 내용을 표 셀 내부에 붙여넣는다. @@ -3639,7 +4368,8 @@ impl HwpDocument { cell_idx as usize, cell_para_idx as usize, char_offset as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 선택 영역을 HTML 문자열로 변환한다 (본문). @@ -3658,7 +4388,8 @@ impl HwpDocument { start_char_offset as usize, end_para_idx as usize, end_char_offset as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 선택 영역을 HTML 문자열로 변환한다 (셀 내부). @@ -3683,7 +4414,8 @@ impl HwpDocument { start_char_offset as usize, end_cell_para_idx as usize, end_char_offset as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 컨트롤 객체를 HTML 문자열로 변환한다. @@ -3698,7 +4430,8 @@ impl HwpDocument { section_idx as usize, para_idx as usize, control_idx as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 컨트롤의 이미지 바이너리 데이터를 반환한다 (Uint8Array). @@ -3713,7 +4446,8 @@ impl HwpDocument { section_idx as usize, para_idx as usize, control_idx as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 컨트롤의 이미지 MIME 타입을 반환한다. @@ -3728,7 +4462,8 @@ impl HwpDocument { section_idx as usize, para_idx as usize, control_idx as usize, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// HTML 문자열을 파싱하여 캐럿 위치에 삽입한다 (본문). @@ -3745,7 +4480,8 @@ impl HwpDocument { para_idx as usize, char_offset as usize, html, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// HTML 문자열을 파싱하여 셀 내부 캐럿 위치에 삽입한다. @@ -3768,7 +4504,8 @@ impl HwpDocument { cell_para_idx as usize, char_offset as usize, html, - ).map_err(|e| e.into()) + ) + .map_err(|e| e.into()) } /// 문단별 줄 폭 측정 진단 (WASM) @@ -3783,11 +4520,8 @@ impl HwpDocument { } } - pub(crate) mod event; - - /// WASM 뷰어 컨트롤러 (뷰포트 관리 + 스케줄링) #[wasm_bindgen] pub struct HwpViewer { @@ -3804,7 +4538,10 @@ impl HwpViewer { pub fn new(document: HwpDocument) -> Self { let page_count = document.page_count(); let scheduler = RenderScheduler::new(page_count); - Self { document, scheduler } + Self { + document, + scheduler, + } } /// 뷰포트 업데이트 (스크롤/리사이즈 시 호출) @@ -3871,8 +4608,7 @@ impl HwpDocument { /// 문서 내 모든 책갈피 목록 반환 #[wasm_bindgen(js_name = getBookmarks)] pub fn get_bookmarks(&self) -> Result { - self.core.get_bookmarks_native() - .map_err(|e| e.into()) + self.core.get_bookmarks_native().map_err(|e| e.into()) } /// 책갈피 추가 @@ -3884,9 +4620,9 @@ impl HwpDocument { char_offset: u32, name: &str, ) -> Result { - self.core.add_bookmark_native( - sec as usize, para as usize, char_offset as usize, name, - ).map_err(|e| e.into()) + self.core + .add_bookmark_native(sec as usize, para as usize, char_offset as usize, name) + .map_err(|e| e.into()) } /// 책갈피 삭제 @@ -3897,9 +4633,9 @@ impl HwpDocument { para: u32, ctrl_idx: u32, ) -> Result { - self.core.delete_bookmark_native( - sec as usize, para as usize, ctrl_idx as usize, - ).map_err(|e| e.into()) + self.core + .delete_bookmark_native(sec as usize, para as usize, ctrl_idx as usize) + .map_err(|e| e.into()) } /// 책갈피 이름 변경 @@ -3911,9 +4647,9 @@ impl HwpDocument { ctrl_idx: u32, new_name: &str, ) -> Result { - self.core.rename_bookmark_native( - sec as usize, para as usize, ctrl_idx as usize, new_name, - ).map_err(|e| e.into()) + self.core + .rename_bookmark_native(sec as usize, para as usize, ctrl_idx as usize, new_name) + .map_err(|e| e.into()) } } diff --git a/src/wasm_api/tests.rs b/src/wasm_api/tests.rs index bdc14676..da1cb02e 100644 --- a/src/wasm_api/tests.rs +++ b/src/wasm_api/tests.rs @@ -1,1912 +1,2387 @@ - use super::*; - use crate::model::document::{Document, Section}; - use crate::model::paragraph::{Paragraph, LineSeg}; - - #[test] - fn test_create_empty_document() { - let doc = HwpDocument::create_empty(); - assert_eq!(doc.page_count(), 1); - } - - #[test] - fn test_empty_document_info() { - let doc = HwpDocument::create_empty(); - let info = doc.get_document_info(); - assert!(info.contains("\"pageCount\":1")); - assert!(info.contains("\"encrypted\":false")); - } - - #[test] - fn test_render_empty_page_svg() { - let doc = HwpDocument::create_empty(); - let svg = doc.render_page_svg_native(0); - assert!(svg.is_ok()); - let svg = svg.unwrap(); - assert!(svg.contains("")); - } - - #[test] - fn test_render_empty_page_html() { - let doc = HwpDocument::create_empty(); - let html = doc.render_page_html_native(0); - assert!(html.is_ok()); - let html = html.unwrap(); - assert!(html.contains("hwp-page")); - } - - #[test] - fn test_page_out_of_range() { - let doc = HwpDocument::create_empty(); - let result = doc.render_page_svg_native(999); - assert!(result.is_err()); - match result.unwrap_err() { - HwpError::PageOutOfRange(n) => assert_eq!(n, 999), - _ => panic!("Expected PageOutOfRange error"), - } - } - - #[test] - fn test_document_with_paragraphs() { - use crate::model::page::PageDef; - use crate::model::document::SectionDef; - - let mut doc = HwpDocument::create_empty(); - let mut document = Document::default(); - - // A4 크기 페이지 정의 (단위: HwpUnit, 1pt = 100) - let page_def = PageDef { - width: 59528, // A4 가로 (약 210mm) - height: 84188, // A4 세로 (약 297mm) - margin_left: 8504, - margin_right: 8504, - margin_top: 5669, - margin_bottom: 4252, - margin_header: 4252, - margin_footer: 4252, +use super::*; +use crate::model::document::{Document, Section}; +use crate::model::paragraph::{LineSeg, Paragraph}; + +#[test] +fn test_create_empty_document() { + let doc = HwpDocument::create_empty(); + assert_eq!(doc.page_count(), 1); +} + +#[test] +fn test_empty_document_info() { + let doc = HwpDocument::create_empty(); + let info = doc.get_document_info(); + assert!(info.contains("\"pageCount\":1")); + assert!(info.contains("\"encrypted\":false")); +} + +#[test] +fn test_render_empty_page_svg() { + let doc = HwpDocument::create_empty(); + let svg = doc.render_page_svg_native(0); + assert!(svg.is_ok()); + let svg = svg.unwrap(); + assert!(svg.contains("")); +} + +#[test] +fn test_render_empty_page_html() { + let doc = HwpDocument::create_empty(); + let html = doc.render_page_html_native(0); + assert!(html.is_ok()); + let html = html.unwrap(); + assert!(html.contains("hwp-page")); +} + +#[test] +fn test_page_out_of_range() { + let doc = HwpDocument::create_empty(); + let result = doc.render_page_svg_native(999); + assert!(result.is_err()); + match result.unwrap_err() { + HwpError::PageOutOfRange(n) => assert_eq!(n, 999), + _ => panic!("Expected PageOutOfRange error"), + } +} + +#[test] +fn test_document_with_paragraphs() { + use crate::model::document::SectionDef; + use crate::model::page::PageDef; + + let mut doc = HwpDocument::create_empty(); + let mut document = Document::default(); + + // A4 크기 페이지 정의 (단위: HwpUnit, 1pt = 100) + let page_def = PageDef { + width: 59528, // A4 가로 (약 210mm) + height: 84188, // A4 세로 (약 297mm) + margin_left: 8504, + margin_right: 8504, + margin_top: 5669, + margin_bottom: 4252, + margin_header: 4252, + margin_footer: 4252, + ..Default::default() + }; + + document.sections.push(Section { + section_def: SectionDef { + page_def, ..Default::default() - }; - - document.sections.push(Section { - section_def: SectionDef { - page_def, + }, + paragraphs: vec![ + Paragraph { + text: "첫 번째 문단".to_string(), + line_segs: vec![LineSeg { + line_height: 400, + baseline_distance: 320, + ..Default::default() + }], ..Default::default() }, - paragraphs: vec![ - Paragraph { - text: "첫 번째 문단".to_string(), + Paragraph { + text: "두 번째 문단".to_string(), + line_segs: vec![LineSeg { + line_height: 400, + baseline_distance: 320, + ..Default::default() + }], + ..Default::default() + }, + ], + raw_stream: None, + }); + doc.set_document(document); + + assert_eq!(doc.page_count(), 1); + let svg = doc.render_page_svg_native(0).unwrap(); + // 문자별 개별 렌더링이므로 개별 문자 존재 확인 + assert!(svg.contains(">첫")); + assert!(svg.contains(">문")); + assert!(svg.contains(">단")); +} + +#[test] +fn test_set_dpi() { + let mut doc = HwpDocument::create_empty(); + doc.set_dpi(72.0); + assert!((doc.get_dpi() - 72.0).abs() < 0.01); +} + +#[test] +fn test_fallback_font() { + let mut doc = HwpDocument::create_empty(); + assert_eq!(doc.get_fallback_font(), DEFAULT_FALLBACK_FONT); + doc.set_fallback_font("/custom/font.ttf"); + assert_eq!(doc.get_fallback_font(), "/custom/font.ttf"); +} + +#[test] +fn test_viewer_creation() { + let doc = HwpDocument::create_empty(); + let viewer = HwpViewer::new(doc); + assert_eq!(viewer.page_count(), 1); + assert_eq!(viewer.pending_task_count(), 0); +} + +#[test] +fn test_viewer_viewport_update() { + let doc = HwpDocument::create_empty(); + let mut viewer = HwpViewer::new(doc); + viewer.update_viewport(0.0, 0.0, 800.0, 600.0); + let visible = viewer.visible_pages(); + assert!(!visible.is_empty()); +} + +#[test] +fn test_export_hwp_empty() { + let doc = HwpDocument::create_empty(); + let bytes = doc.export_hwp_native(); + assert!(bytes.is_ok()); + let bytes = bytes.unwrap(); + // CFB 시그니처 확인 + assert!(bytes.len() > 512); + assert_eq!(&bytes[0..4], &[0xD0, 0xCF, 0x11, 0xE0]); +} + +#[test] +fn test_hwp_error_display() { + let err = HwpError::InvalidFile("테스트".to_string()); + assert!(err.to_string().contains("테스트")); + let err = HwpError::PageOutOfRange(5); + assert!(err.to_string().contains("5")); +} + +/// 텍스트의 UTF-16 char_offsets를 생성한다. +fn make_char_offsets(text: &str) -> Vec { + let mut offsets = Vec::new(); + let mut pos: u32 = 0; + for c in text.chars() { + offsets.push(pos); + pos += if (c as u32) > 0xFFFF { 2 } else { 1 }; + } + offsets +} + +/// 표 셀이 포함된 테스트 문서를 생성한다. +fn create_doc_with_table() -> HwpDocument { + use crate::model::control::Control; + use crate::model::document::SectionDef; + use crate::model::page::PageDef; + use crate::model::table::{Cell, Table}; + use crate::model::Padding; + + let mut doc = HwpDocument::create_empty(); + let mut document = Document::default(); + + let page_def = PageDef { + width: 59528, + height: 84188, + margin_left: 8504, + margin_right: 8504, + margin_top: 5669, + margin_bottom: 4252, + margin_header: 4252, + margin_footer: 4252, + ..Default::default() + }; + + let table = Table { + row_count: 2, + col_count: 2, + padding: Padding { + left: 100, + right: 100, + top: 100, + bottom: 100, + }, + cells: vec![ + Cell { + col: 0, + row: 0, + col_span: 1, + row_span: 1, + width: 21000, + height: 3000, + paragraphs: vec![Paragraph { + text: "셀A".to_string(), + char_count: 2, + char_offsets: make_char_offsets("셀A"), line_segs: vec![LineSeg { line_height: 400, baseline_distance: 320, ..Default::default() }], ..Default::default() - }, - Paragraph { - text: "두 번째 문단".to_string(), + }], + ..Default::default() + }, + Cell { + col: 1, + row: 0, + col_span: 1, + row_span: 1, + width: 21000, + height: 3000, + paragraphs: vec![Paragraph { + text: "셀B".to_string(), + char_count: 2, + char_offsets: make_char_offsets("셀B"), line_segs: vec![LineSeg { line_height: 400, baseline_distance: 320, ..Default::default() }], ..Default::default() - }, - ], - raw_stream: None, - }); - doc.set_document(document); - - assert_eq!(doc.page_count(), 1); - let svg = doc.render_page_svg_native(0).unwrap(); - // 문자별 개별 렌더링이므로 개별 문자 존재 확인 - assert!(svg.contains(">첫")); - assert!(svg.contains(">문")); - assert!(svg.contains(">단")); - } - - #[test] - fn test_set_dpi() { - let mut doc = HwpDocument::create_empty(); - doc.set_dpi(72.0); - assert!((doc.get_dpi() - 72.0).abs() < 0.01); - } - - #[test] - fn test_fallback_font() { - let mut doc = HwpDocument::create_empty(); - assert_eq!(doc.get_fallback_font(), DEFAULT_FALLBACK_FONT); - doc.set_fallback_font("/custom/font.ttf"); - assert_eq!(doc.get_fallback_font(), "/custom/font.ttf"); - } - - #[test] - fn test_viewer_creation() { - let doc = HwpDocument::create_empty(); - let viewer = HwpViewer::new(doc); - assert_eq!(viewer.page_count(), 1); - assert_eq!(viewer.pending_task_count(), 0); - } - - #[test] - fn test_viewer_viewport_update() { - let doc = HwpDocument::create_empty(); - let mut viewer = HwpViewer::new(doc); - viewer.update_viewport(0.0, 0.0, 800.0, 600.0); - let visible = viewer.visible_pages(); - assert!(!visible.is_empty()); - } - - #[test] - fn test_export_hwp_empty() { - let doc = HwpDocument::create_empty(); - let bytes = doc.export_hwp_native(); - assert!(bytes.is_ok()); - let bytes = bytes.unwrap(); - // CFB 시그니처 확인 - assert!(bytes.len() > 512); - assert_eq!(&bytes[0..4], &[0xD0, 0xCF, 0x11, 0xE0]); - } - - #[test] - fn test_hwp_error_display() { - let err = HwpError::InvalidFile("테스트".to_string()); - assert!(err.to_string().contains("테스트")); - let err = HwpError::PageOutOfRange(5); - assert!(err.to_string().contains("5")); - } - - /// 텍스트의 UTF-16 char_offsets를 생성한다. - fn make_char_offsets(text: &str) -> Vec { - let mut offsets = Vec::new(); - let mut pos: u32 = 0; - for c in text.chars() { - offsets.push(pos); - pos += if (c as u32) > 0xFFFF { 2 } else { 1 }; - } - offsets - } - - /// 표 셀이 포함된 테스트 문서를 생성한다. - fn create_doc_with_table() -> HwpDocument { - use crate::model::page::PageDef; - use crate::model::document::SectionDef; - use crate::model::table::{Table, Cell}; - use crate::model::control::Control; - use crate::model::Padding; - - let mut doc = HwpDocument::create_empty(); - let mut document = Document::default(); - - let page_def = PageDef { - width: 59528, - height: 84188, - margin_left: 8504, - margin_right: 8504, - margin_top: 5669, - margin_bottom: 4252, - margin_header: 4252, - margin_footer: 4252, - ..Default::default() - }; - - let table = Table { - row_count: 2, - col_count: 2, - padding: Padding { left: 100, right: 100, top: 100, bottom: 100 }, - cells: vec![ - Cell { - col: 0, row: 0, col_span: 1, row_span: 1, - width: 21000, height: 3000, - paragraphs: vec![Paragraph { - text: "셀A".to_string(), - char_count: 2, - char_offsets: make_char_offsets("셀A"), - line_segs: vec![LineSeg { - line_height: 400, - baseline_distance: 320, - ..Default::default() - }], - ..Default::default() - }], - ..Default::default() - }, - Cell { - col: 1, row: 0, col_span: 1, row_span: 1, - width: 21000, height: 3000, - paragraphs: vec![Paragraph { - text: "셀B".to_string(), - char_count: 2, - char_offsets: make_char_offsets("셀B"), - line_segs: vec![LineSeg { - line_height: 400, - baseline_distance: 320, - ..Default::default() - }], - ..Default::default() - }], - ..Default::default() - }, - Cell { - col: 0, row: 1, col_span: 1, row_span: 1, - width: 21000, height: 3000, - paragraphs: vec![Paragraph { - text: "셀C".to_string(), - char_count: 2, - char_offsets: make_char_offsets("셀C"), - line_segs: vec![LineSeg { - line_height: 400, - baseline_distance: 320, - ..Default::default() - }], + }], + ..Default::default() + }, + Cell { + col: 0, + row: 1, + col_span: 1, + row_span: 1, + width: 21000, + height: 3000, + paragraphs: vec![Paragraph { + text: "셀C".to_string(), + char_count: 2, + char_offsets: make_char_offsets("셀C"), + line_segs: vec![LineSeg { + line_height: 400, + baseline_distance: 320, ..Default::default() }], ..Default::default() - }, - Cell { - col: 1, row: 1, col_span: 1, row_span: 1, - width: 21000, height: 3000, - paragraphs: vec![Paragraph { - text: "셀D".to_string(), - char_count: 2, - char_offsets: make_char_offsets("셀D"), - line_segs: vec![LineSeg { - line_height: 400, - baseline_distance: 320, - ..Default::default() - }], + }], + ..Default::default() + }, + Cell { + col: 1, + row: 1, + col_span: 1, + row_span: 1, + width: 21000, + height: 3000, + paragraphs: vec![Paragraph { + text: "셀D".to_string(), + char_count: 2, + char_offsets: make_char_offsets("셀D"), + line_segs: vec![LineSeg { + line_height: 400, + baseline_distance: 320, ..Default::default() }], ..Default::default() - }, - ], - ..Default::default() - }; - - let parent_para = Paragraph { - text: String::new(), - controls: vec![Control::Table(Box::new(table))], - line_segs: vec![LineSeg { - line_height: 400, - baseline_distance: 320, - ..Default::default() - }], - ..Default::default() - }; - - document.sections.push(Section { - section_def: SectionDef { - page_def, + }], ..Default::default() }, - paragraphs: vec![parent_para], - raw_stream: None, - }); - doc.set_document(document); - doc - } - - #[test] - fn test_insert_text_in_cell() { - let mut doc = create_doc_with_table(); - let result = doc.insert_text_in_cell_native(0, 0, 0, 0, 0, 1, "추가"); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("\"ok\":true")); - assert!(json.contains("\"charOffset\":3")); + ], + ..Default::default() + }; + + let parent_para = Paragraph { + text: String::new(), + controls: vec![Control::Table(Box::new(table))], + line_segs: vec![LineSeg { + line_height: 400, + baseline_distance: 320, + ..Default::default() + }], + ..Default::default() + }; - if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.get(0) { - assert_eq!(table.cells[0].paragraphs[0].text, "셀추가A"); - } else { - panic!("표 컨트롤을 찾을 수 없음"); - } + document.sections.push(Section { + section_def: SectionDef { + page_def, + ..Default::default() + }, + paragraphs: vec![parent_para], + raw_stream: None, + }); + doc.set_document(document); + doc +} + +#[test] +fn test_insert_text_in_cell() { + let mut doc = create_doc_with_table(); + let result = doc.insert_text_in_cell_native(0, 0, 0, 0, 0, 1, "추가"); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"ok\":true")); + assert!(json.contains("\"charOffset\":3")); + + if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.first() { + assert_eq!(table.cells[0].paragraphs[0].text, "셀추가A"); + } else { + panic!("표 컨트롤을 찾을 수 없음"); } - - #[test] - fn test_delete_text_in_cell() { - let mut doc = create_doc_with_table(); - let result = doc.delete_text_in_cell_native(0, 0, 0, 1, 0, 0, 1); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("\"ok\":true")); - - if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.get(0) { - assert_eq!(table.cells[1].paragraphs[0].text, "B"); - } else { - panic!("표 컨트롤을 찾을 수 없음"); - } +} + +#[test] +fn test_delete_text_in_cell() { + let mut doc = create_doc_with_table(); + let result = doc.delete_text_in_cell_native(0, 0, 0, 1, 0, 0, 1); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"ok\":true")); + + if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.first() { + assert_eq!(table.cells[1].paragraphs[0].text, "B"); + } else { + panic!("표 컨트롤을 찾을 수 없음"); } +} - #[test] - fn test_cell_text_edit_invalid_indices() { - let mut doc = create_doc_with_table(); - - let result = doc.insert_text_in_cell_native(0, 0, 0, 99, 0, 0, "X"); - assert!(result.is_err()); - - let result = doc.insert_text_in_cell_native(0, 0, 5, 0, 0, 0, "X"); - assert!(result.is_err()); +#[test] +fn test_cell_text_edit_invalid_indices() { + let mut doc = create_doc_with_table(); - let result = doc.insert_text_in_cell_native(99, 0, 0, 0, 0, 0, "X"); - assert!(result.is_err()); - } + let result = doc.insert_text_in_cell_native(0, 0, 0, 99, 0, 0, "X"); + assert!(result.is_err()); - #[test] - fn test_cell_text_layout_contains_cell_info() { - let doc = create_doc_with_table(); - let layout = doc.get_page_text_layout_native(0); - assert!(layout.is_ok()); - let json = layout.unwrap(); + let result = doc.insert_text_in_cell_native(0, 0, 5, 0, 0, 0, "X"); + assert!(result.is_err()); - assert!(json.contains("\"parentParaIdx\":")); - assert!(json.contains("\"controlIdx\":")); - assert!(json.contains("\"cellIdx\":")); - assert!(json.contains("\"cellParaIdx\":")); - } + let result = doc.insert_text_in_cell_native(99, 0, 0, 0, 0, 0, "X"); + assert!(result.is_err()); +} - #[test] - fn test_insert_and_delete_roundtrip_in_cell() { - let mut doc = create_doc_with_table(); +#[test] +fn test_cell_text_layout_contains_cell_info() { + let doc = create_doc_with_table(); + let layout = doc.get_page_text_layout_native(0); + assert!(layout.is_ok()); + let json = layout.unwrap(); - let result = doc.insert_text_in_cell_native(0, 0, 0, 2, 0, 2, "테스트"); - assert!(result.is_ok()); + assert!(json.contains("\"parentParaIdx\":")); + assert!(json.contains("\"controlIdx\":")); + assert!(json.contains("\"cellIdx\":")); + assert!(json.contains("\"cellParaIdx\":")); +} - if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.get(0) { - assert_eq!(table.cells[2].paragraphs[0].text, "셀C테스트"); - } +#[test] +fn test_insert_and_delete_roundtrip_in_cell() { + let mut doc = create_doc_with_table(); - let result = doc.delete_text_in_cell_native(0, 0, 0, 2, 0, 2, 3); - assert!(result.is_ok()); + let result = doc.insert_text_in_cell_native(0, 0, 0, 2, 0, 2, "테스트"); + assert!(result.is_ok()); - if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.get(0) { - assert_eq!(table.cells[2].paragraphs[0].text, "셀C"); - } + if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.first() { + assert_eq!(table.cells[2].paragraphs[0].text, "셀C테스트"); } - #[test] - fn test_svg_render_with_table_after_cell_edit() { - let mut doc = create_doc_with_table(); + let result = doc.delete_text_in_cell_native(0, 0, 0, 2, 0, 2, 3); + assert!(result.is_ok()); - doc.insert_text_in_cell_native(0, 0, 0, 3, 0, 2, "수정됨").unwrap(); - // 삽입 후 셀 텍스트 확인 - if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.get(0) { - assert_eq!(table.cells[3].paragraphs[0].text, "셀D수정됨"); - } - let svg = doc.render_page_svg_native(0); - assert!(svg.is_ok()); - let svg = svg.unwrap(); - // 언어별 폰트 분기로 "셀", "D", "수정됨"이 별도 text run으로 분리될 수 있으므로 - // 각 부분이 SVG에 포함되는지 확인 - // 문자별 개별 렌더링이므로 개별 문자 존재 확인 - assert!(svg.contains(">수"), "SVG에 '수' 없음"); - assert!(svg.contains(">정"), "SVG에 '정' 없음"); - assert!(svg.contains(">됨"), "SVG에 '됨' 없음"); + if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.first() { + assert_eq!(table.cells[2].paragraphs[0].text, "셀C"); } +} +#[test] +fn test_svg_render_with_table_after_cell_edit() { + let mut doc = create_doc_with_table(); - #[test] - fn test_get_page_control_layout_with_table() { - let doc = create_doc_with_table(); - let result = doc.get_page_control_layout_native(0); - assert!(result.is_ok()); - let json = result.unwrap(); - - // 표 컨트롤이 포함되어야 함 - assert!(json.contains("\"type\":\"table\"")); - assert!(json.contains("\"rowCount\":")); - assert!(json.contains("\"colCount\":")); - // 문서 좌표 포함 - assert!(json.contains("\"secIdx\":")); - assert!(json.contains("\"paraIdx\":")); - assert!(json.contains("\"controlIdx\":")); - // 셀 정보 포함 - assert!(json.contains("\"cells\":[")); - assert!(json.contains("\"cellIdx\":")); - assert!(json.contains("\"row\":")); - assert!(json.contains("\"col\":")); + doc.insert_text_in_cell_native(0, 0, 0, 3, 0, 2, "수정됨") + .unwrap(); + // 삽입 후 셀 텍스트 확인 + if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.first() { + assert_eq!(table.cells[3].paragraphs[0].text, "셀D수정됨"); + } + let svg = doc.render_page_svg_native(0); + assert!(svg.is_ok()); + let svg = svg.unwrap(); + // 언어별 폰트 분기로 "셀", "D", "수정됨"이 별도 text run으로 분리될 수 있으므로 + // 각 부분이 SVG에 포함되는지 확인 + // 문자별 개별 렌더링이므로 개별 문자 존재 확인 + assert!(svg.contains(">수"), "SVG에 '수' 없음"); + assert!(svg.contains(">정"), "SVG에 '정' 없음"); + assert!(svg.contains(">됨"), "SVG에 '됨' 없음"); +} + +#[test] +fn test_get_page_control_layout_with_table() { + let doc = create_doc_with_table(); + let result = doc.get_page_control_layout_native(0); + assert!(result.is_ok()); + let json = result.unwrap(); + + // 표 컨트롤이 포함되어야 함 + assert!(json.contains("\"type\":\"table\"")); + assert!(json.contains("\"rowCount\":")); + assert!(json.contains("\"colCount\":")); + // 문서 좌표 포함 + assert!(json.contains("\"secIdx\":")); + assert!(json.contains("\"paraIdx\":")); + assert!(json.contains("\"controlIdx\":")); + // 셀 정보 포함 + assert!(json.contains("\"cells\":[")); + assert!(json.contains("\"cellIdx\":")); + assert!(json.contains("\"row\":")); + assert!(json.contains("\"col\":")); +} + +#[test] +fn test_control_layout_cell_bounding_boxes() { + let doc = create_doc_with_table(); + let result = doc.get_page_control_layout_native(0); + assert!(result.is_ok()); + let json = result.unwrap(); + + // JSON 파싱 검증: 표 바운딩 박스가 유효한 크기를 가짐 + assert!(json.contains("\"w\":")); + assert!(json.contains("\"h\":")); + + // 셀이 4개 (2x2 표) + let cell_count = json.matches("\"cellIdx\":").count(); + assert_eq!(cell_count, 4, "2x2 표에는 4개의 셀이 있어야 합니다"); +} + +// === 표 구조 편집 테스트 === + +#[test] +fn test_insert_table_row_below() { + let mut doc = create_doc_with_table(); + let result = doc.insert_table_row_native(0, 0, 0, 0, true); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"rowCount\":3")); + assert!(json.contains("\"colCount\":2")); + + if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.first() { + assert_eq!(table.row_count, 3); + assert_eq!(table.cells.len(), 6); + // 원래 첫 행의 셀A는 여전히 행 0 + assert_eq!(table.cells[0].row, 0); + assert_eq!(table.cells[0].paragraphs[0].text, "셀A"); + // 새 행은 행 1 (빈 문단) + assert_eq!(table.cells[2].row, 1); + assert!(table.cells[2].paragraphs[0].text.is_empty()); + } else { + panic!("표 컨트롤을 찾을 수 없음"); + } +} + +#[test] +fn test_insert_table_column_right() { + let mut doc = create_doc_with_table(); + let result = doc.insert_table_column_native(0, 0, 0, 0, true); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"rowCount\":2")); + assert!(json.contains("\"colCount\":3")); + + if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.first() { + assert_eq!(table.col_count, 3); + assert_eq!(table.cells.len(), 6); + } else { + panic!("표 컨트롤을 찾을 수 없음"); + } +} + +#[test] +fn test_merge_table_cells() { + let mut doc = create_doc_with_table(); + // 첫 행의 2개 셀 병합 + let result = doc.merge_table_cells_native(0, 0, 0, 0, 0, 0, 1); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"cellCount\":3")); // 비주 셀 1개 제거 + + if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.first() { + assert_eq!(table.cells.len(), 3); // 비주 셀 제거됨 + let merged = &table.cells[0]; + assert_eq!(merged.col_span, 2); + assert_eq!(merged.row_span, 1); + } else { + panic!("표 컨트롤을 찾을 수 없음"); + } +} + +#[test] +fn test_split_table_cell() { + let mut doc = create_doc_with_table(); + // 먼저 병합 + doc.merge_table_cells_native(0, 0, 0, 0, 0, 0, 1).unwrap(); + if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.first() { + assert_eq!(table.cells.len(), 3); } - #[test] - fn test_control_layout_cell_bounding_boxes() { - let doc = create_doc_with_table(); - let result = doc.get_page_control_layout_native(0); - assert!(result.is_ok()); - let json = result.unwrap(); - - // JSON 파싱 검증: 표 바운딩 박스가 유효한 크기를 가짐 - assert!(json.contains("\"w\":")); - assert!(json.contains("\"h\":")); - - // 셀이 4개 (2x2 표) - let cell_count = json.matches("\"cellIdx\":").count(); - assert_eq!(cell_count, 4, "2x2 표에는 4개의 셀이 있어야 합니다"); + // 나누기 + let result = doc.split_table_cell_native(0, 0, 0, 0, 0); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"cellCount\":4")); + + if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.first() { + assert_eq!(table.cells.len(), 4); + let cell = &table.cells[0]; + assert_eq!(cell.col_span, 1); + assert_eq!(cell.row_span, 1); + } else { + panic!("표 컨트롤을 찾을 수 없음"); + } +} + +#[test] +fn test_merge_then_control_layout_has_col_span() { + let mut doc = create_doc_with_table(); + // 병합 전: colSpan=1 + let layout_before = doc.get_page_control_layout_native(0).unwrap(); + assert!( + !layout_before.contains("\"colSpan\":2"), + "병합 전에는 colSpan:2가 없어야 합니다" + ); + + // 병합: 첫 행의 2개 셀 + doc.merge_table_cells_native(0, 0, 0, 0, 0, 0, 1).unwrap(); + + // 병합 후: colSpan=2가 레이아웃에 반영되어야 함 + let layout_after = doc.get_page_control_layout_native(0).unwrap(); + assert!( + layout_after.contains("\"colSpan\":2"), + "병합 후 colSpan:2가 있어야 합니다. 레이아웃: {}", + layout_after + ); +} + +#[test] +fn test_insert_table_row_invalid_index() { + let mut doc = create_doc_with_table(); + let result = doc.insert_table_row_native(0, 0, 0, 99, true); + assert!(result.is_err()); +} + +#[test] +fn test_table_structure_edit_roundtrip() { + let mut doc = create_doc_with_table(); + // 행 삽입 + doc.insert_table_row_native(0, 0, 0, 0, true).unwrap(); + // 열 삽입 + doc.insert_table_column_native(0, 0, 0, 0, true).unwrap(); + + // 직렬화 → 재파싱 + let bytes = doc.export_hwp_native(); + assert!(bytes.is_ok(), "행/열 삽입 후 직렬화 실패"); + let bytes = bytes.unwrap(); + assert!(!bytes.is_empty()); + + // 재파싱 가능 여부 확인 + let reparsed = crate::parser::parse_hwp(&bytes); + assert!(reparsed.is_ok(), "재파싱 실패: {:?}", reparsed.err()); +} + +#[test] +fn test_real_hwp_table_insert_row_roundtrip() { + use crate::parser::record::Record; + use std::path::Path; + + let path = Path::new("samples/hwp_table_test.hwp"); + if !path.exists() { + eprintln!("hwp_table_test.hwp 없음 — 건너뜀"); + return; } - // === 표 구조 편집 테스트 === - - #[test] - fn test_insert_table_row_below() { - let mut doc = create_doc_with_table(); - let result = doc.insert_table_row_native(0, 0, 0, 0, true); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("\"rowCount\":3")); - assert!(json.contains("\"colCount\":2")); - - if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.get(0) { - assert_eq!(table.row_count, 3); - assert_eq!(table.cells.len(), 6); - // 원래 첫 행의 셀A는 여전히 행 0 - assert_eq!(table.cells[0].row, 0); - assert_eq!(table.cells[0].paragraphs[0].text, "셀A"); - // 새 행은 행 1 (빈 문단) - assert_eq!(table.cells[2].row, 1); - assert!(table.cells[2].paragraphs[0].text.is_empty()); - } else { - panic!("표 컨트롤을 찾을 수 없음"); + let data = std::fs::read(path).unwrap(); + let doc = crate::parser::parse_hwp(&data).unwrap(); + + // 원본 BodyText 레코드 + let mut cfb = crate::parser::cfb_reader::CfbReader::open(&data).unwrap(); + let orig_bt = cfb + .read_body_text_section(0, doc.header.compressed, false) + .unwrap(); + let orig_recs = Record::read_all(&orig_bt).unwrap(); + + // 원본 표 부분 레코드 추출 (CTRL_HEADER tbl ~ 다음 레벨0 레코드) + let mut table_start = 0; + let mut table_end = 0; + for (i, rec) in orig_recs.iter().enumerate() { + if rec.tag_id == crate::parser::tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes(rec.data[0..4].try_into().unwrap()); + if ctrl_id == crate::parser::tags::CTRL_TABLE { + table_start = i; + // 표 끝 찾기 + table_end = orig_recs.len(); + for j in (i + 1)..orig_recs.len() { + if orig_recs[j].level <= rec.level { + table_end = j; + break; + } + } + break; + } } } - #[test] - fn test_insert_table_column_right() { - let mut doc = create_doc_with_table(); - let result = doc.insert_table_column_native(0, 0, 0, 0, true); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("\"rowCount\":2")); - assert!(json.contains("\"colCount\":3")); + eprintln!( + "=== 원본 표 레코드: [{}..{}] ({} records) ===", + table_start, + table_end, + table_end - table_start + ); + for i in table_start..table_end { + let r = &orig_recs[i]; + let tag = crate::parser::tags::tag_name(r.tag_id); + eprintln!( + " [{}] {} L{} {}B {:02X?}", + i, + tag, + r.level, + r.data.len(), + &r.data[..r.data.len().min(16)] + ); + } - if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.get(0) { - assert_eq!(table.col_count, 3); - assert_eq!(table.cells.len(), 6); - } else { - panic!("표 컨트롤을 찾을 수 없음"); + // 행 삽입 후 내보내기 + let mut hwp_doc = HwpDocument::create_empty(); + hwp_doc.set_document(crate::parser::parse_hwp(&data).unwrap()); + hwp_doc.insert_table_row_native(0, 3, 0, 0, true).unwrap(); + + let exported = hwp_doc.export_hwp_native().unwrap(); + let mut cfb2 = crate::parser::cfb_reader::CfbReader::open(&exported).unwrap(); + let new_doc = crate::parser::parse_hwp(&exported).unwrap(); + let new_bt = cfb2 + .read_body_text_section(0, new_doc.header.compressed, false) + .unwrap(); + let new_recs = Record::read_all(&new_bt).unwrap(); + + // 수정 후 표 레코드 + let mut new_table_start = 0; + let mut new_table_end = 0; + for (i, rec) in new_recs.iter().enumerate() { + if rec.tag_id == crate::parser::tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes(rec.data[0..4].try_into().unwrap()); + if ctrl_id == crate::parser::tags::CTRL_TABLE { + new_table_start = i; + new_table_end = new_recs.len(); + for j in (i + 1)..new_recs.len() { + if new_recs[j].level <= rec.level { + new_table_end = j; + break; + } + } + break; + } } } - #[test] - fn test_merge_table_cells() { - let mut doc = create_doc_with_table(); - // 첫 행의 2개 셀 병합 - let result = doc.merge_table_cells_native(0, 0, 0, 0, 0, 0, 1); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("\"cellCount\":3")); // 비주 셀 1개 제거 - - if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.get(0) { - assert_eq!(table.cells.len(), 3); // 비주 셀 제거됨 - let merged = &table.cells[0]; - assert_eq!(merged.col_span, 2); - assert_eq!(merged.row_span, 1); - } else { - panic!("표 컨트롤을 찾을 수 없음"); - } + eprintln!( + "\n=== 수정 후 표 레코드: [{}..{}] ({} records) ===", + new_table_start, + new_table_end, + new_table_end - new_table_start + ); + for i in new_table_start..new_table_end { + let r = &new_recs[i]; + let tag = crate::parser::tags::tag_name(r.tag_id); + eprintln!( + " [{}] {} L{} {}B {:02X?}", + i, + tag, + r.level, + r.data.len(), + &r.data[..r.data.len().min(16)] + ); } - #[test] - fn test_split_table_cell() { - let mut doc = create_doc_with_table(); - // 먼저 병합 - doc.merge_table_cells_native(0, 0, 0, 0, 0, 0, 1).unwrap(); - if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.get(0) { - assert_eq!(table.cells.len(), 3); + // 원본 빈 셀(1,0)과 새 셀 LIST_HEADER 바이트 비교 + let orig_table_recs = &orig_recs[table_start..table_end]; + let new_table_recs = &new_recs[new_table_start..new_table_end]; + + // LIST_HEADER 레코드 모두 추출 + eprintln!("\n=== 원본 LIST_HEADER 바이트 ==="); + for (i, r) in orig_table_recs.iter().enumerate() { + if r.tag_id == crate::parser::tags::HWPTAG_LIST_HEADER { + eprintln!( + " [{}] {}B: {:02X?}", + table_start + i, + r.data.len(), + &r.data + ); } - - // 나누기 - let result = doc.split_table_cell_native(0, 0, 0, 0, 0); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("\"cellCount\":4")); - - if let Some(Control::Table(table)) = doc.document.sections[0].paragraphs[0].controls.get(0) { - assert_eq!(table.cells.len(), 4); - let cell = &table.cells[0]; - assert_eq!(cell.col_span, 1); - assert_eq!(cell.row_span, 1); - } else { - panic!("표 컨트롤을 찾을 수 없음"); + } + eprintln!("\n=== 수정 후 LIST_HEADER 바이트 ==="); + for (i, r) in new_table_recs.iter().enumerate() { + if r.tag_id == crate::parser::tags::HWPTAG_LIST_HEADER { + eprintln!( + " [{}] {}B: {:02X?}", + new_table_start + i, + r.data.len(), + &r.data + ); } } - #[test] - fn test_merge_then_control_layout_has_colSpan() { - let mut doc = create_doc_with_table(); - // 병합 전: colSpan=1 - let layout_before = doc.get_page_control_layout_native(0).unwrap(); - assert!(!layout_before.contains("\"colSpan\":2"), "병합 전에는 colSpan:2가 없어야 합니다"); - - // 병합: 첫 행의 2개 셀 - doc.merge_table_cells_native(0, 0, 0, 0, 0, 0, 1).unwrap(); - - // 병합 후: colSpan=2가 레이아웃에 반영되어야 함 - let layout_after = doc.get_page_control_layout_native(0).unwrap(); - assert!(layout_after.contains("\"colSpan\":2"), - "병합 후 colSpan:2가 있어야 합니다. 레이아웃: {}", layout_after); + // PARA_HEADER 바이트 비교 + eprintln!("\n=== 원본 PARA_HEADER (표 내부) ==="); + for (i, r) in orig_table_recs.iter().enumerate() { + if r.tag_id == crate::parser::tags::HWPTAG_PARA_HEADER { + eprintln!( + " [{}] {}B: {:02X?}", + table_start + i, + r.data.len(), + &r.data + ); + } } - - #[test] - fn test_insert_table_row_invalid_index() { - let mut doc = create_doc_with_table(); - let result = doc.insert_table_row_native(0, 0, 0, 99, true); - assert!(result.is_err()); + eprintln!("\n=== 수정 후 PARA_HEADER (표 내부) ==="); + for (i, r) in new_table_recs.iter().enumerate() { + if r.tag_id == crate::parser::tags::HWPTAG_PARA_HEADER { + eprintln!( + " [{}] {}B: {:02X?}", + new_table_start + i, + r.data.len(), + &r.data + ); + } } - #[test] - fn test_table_structure_edit_roundtrip() { - let mut doc = create_doc_with_table(); - // 행 삽입 - doc.insert_table_row_native(0, 0, 0, 0, true).unwrap(); - // 열 삽입 - doc.insert_table_column_native(0, 0, 0, 0, true).unwrap(); - - // 직렬화 → 재파싱 - let bytes = doc.export_hwp_native(); - assert!(bytes.is_ok(), "행/열 삽입 후 직렬화 실패"); - let bytes = bytes.unwrap(); - assert!(!bytes.is_empty()); - - // 재파싱 가능 여부 확인 - let reparsed = crate::parser::parse_hwp(&bytes); - assert!(reparsed.is_ok(), "재파싱 실패: {:?}", reparsed.err()); + // TABLE 레코드 비교 + eprintln!("\n=== TABLE 레코드 비교 ==="); + for r in orig_table_recs.iter() { + if r.tag_id == crate::parser::tags::HWPTAG_TABLE { + eprintln!(" 원본: {}B: {:02X?}", r.data.len(), &r.data); + } } - - #[test] - fn test_real_hwp_table_insert_row_roundtrip() { - use std::path::Path; - use crate::parser::record::Record; - - let path = Path::new("samples/hwp_table_test.hwp"); - if !path.exists() { - eprintln!("hwp_table_test.hwp 없음 — 건너뜀"); - return; + for r in new_table_recs.iter() { + if r.tag_id == crate::parser::tags::HWPTAG_TABLE { + eprintln!(" 수정: {}B: {:02X?}", r.data.len(), &r.data); } + } +} + +#[test] +/// 실제 HWP 파일에서 셀 병합 후 포괄적 바이너리 비교 +fn test_merge_cells_roundtrip_real_hwp() { + use crate::parser::record::Record; + use std::path::Path; + + let orig_path = Path::new("samples/hwp_table_test.hwp"); + if !orig_path.exists() { + eprintln!("파일 없음 — 건너뜀"); + return; + } - let data = std::fs::read(path).unwrap(); - let doc = crate::parser::parse_hwp(&data).unwrap(); - - // 원본 BodyText 레코드 - let mut cfb = crate::parser::cfb_reader::CfbReader::open(&data).unwrap(); - let orig_bt = cfb.read_body_text_section(0, doc.header.compressed, false).unwrap(); - let orig_recs = Record::read_all(&orig_bt).unwrap(); - - // 원본 표 부분 레코드 추출 (CTRL_HEADER tbl ~ 다음 레벨0 레코드) - let mut table_start = 0; - let mut table_end = 0; - for (i, rec) in orig_recs.iter().enumerate() { + let orig_data = std::fs::read(orig_path).unwrap(); + + // 1) 원본 → 수정 없이 라운드트립 (기준선) + let mut baseline_doc = HwpDocument::from_bytes(&orig_data).unwrap(); + let baseline_exported = baseline_doc.export_hwp_native().unwrap(); + + // 2) 원본 → 병합 후 내보내기 + let mut merged_doc = HwpDocument::from_bytes(&orig_data).unwrap(); + merged_doc + .merge_table_cells_native(0, 3, 0, 2, 0, 2, 1) + .unwrap(); + let merged_exported = merged_doc.export_hwp_native().unwrap(); + + // 검증용 파일 저장 + let _ = std::fs::create_dir_all("output"); + std::fs::write("output/merge_test_baseline.hwp", &baseline_exported).unwrap(); + std::fs::write("output/merge_test_programmatic.hwp", &merged_exported).unwrap(); + eprintln!("검증 파일 저장: output/merge_test_baseline.hwp, output/merge_test_programmatic.hwp"); + + // 기준선 BodyText + let baseline_parsed = crate::parser::parse_hwp(&baseline_exported).unwrap(); + let mut baseline_cfb = crate::parser::cfb_reader::CfbReader::open(&baseline_exported).unwrap(); + let baseline_bt = baseline_cfb + .read_body_text_section(0, baseline_parsed.header.compressed, false) + .unwrap(); + let baseline_recs = Record::read_all(&baseline_bt).unwrap(); + + // 병합 BodyText + let merged_parsed = crate::parser::parse_hwp(&merged_exported).unwrap(); + let mut merged_cfb = crate::parser::cfb_reader::CfbReader::open(&merged_exported).unwrap(); + let merged_bt = merged_cfb + .read_body_text_section(0, merged_parsed.header.compressed, false) + .unwrap(); + let merged_recs = Record::read_all(&merged_bt).unwrap(); + + eprintln!( + "기준선 레코드: {}, 병합 레코드: {}", + baseline_recs.len(), + merged_recs.len() + ); + + // 표 범위 찾기 + let find_table = |recs: &[Record]| -> (usize, usize) { + for (i, rec) in recs.iter().enumerate() { if rec.tag_id == crate::parser::tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { let ctrl_id = u32::from_le_bytes(rec.data[0..4].try_into().unwrap()); if ctrl_id == crate::parser::tags::CTRL_TABLE { - table_start = i; - // 표 끝 찾기 - table_end = orig_recs.len(); - for j in (i+1)..orig_recs.len() { - if orig_recs[j].level <= rec.level { - table_end = j; + let mut end = recs.len(); + for j in (i + 1)..recs.len() { + if recs[j].level <= rec.level { + end = j; break; } } - break; - } - } + return (i, end); + } + } + } + (0, 0) + }; + + let (bt_start, bt_end) = find_table(&baseline_recs); + let (mt_start, mt_end) = find_table(&merged_recs); + eprintln!( + "기준선 표: [{}..{}] ({} recs), 병합 표: [{}..{}] ({} recs)", + bt_start, + bt_end, + bt_end - bt_start, + mt_start, + mt_end, + mt_end - mt_start + ); + + // 표 앞쪽 레코드 비교 (동일해야 함) + let pre_count = bt_start.min(mt_start); + for i in 0..pre_count { + if baseline_recs[i].tag_id != merged_recs[i].tag_id + || baseline_recs[i].data != merged_recs[i].data + { + let tag = crate::parser::tags::tag_name(baseline_recs[i].tag_id); + eprintln!("!! 표 앞 [{}] {} 차이:", i, tag); + eprintln!( + " 기준: {:02X?}", + &baseline_recs[i].data[..baseline_recs[i].data.len().min(40)] + ); + eprintln!( + " 병합: {:02X?}", + &merged_recs[i].data[..merged_recs[i].data.len().min(40)] + ); } + } - eprintln!("=== 원본 표 레코드: [{}..{}] ({} records) ===", table_start, table_end, table_end - table_start); - for i in table_start..table_end { - let r = &orig_recs[i]; - let tag = crate::parser::tags::tag_name(r.tag_id); - eprintln!(" [{}] {} L{} {}B {:02X?}", i, tag, r.level, r.data.len(), - &r.data[..r.data.len().min(16)]); + // 표 뒤쪽 레코드 비교 + let bt_after = &baseline_recs[bt_end..]; + let mt_after = &merged_recs[mt_end..]; + if bt_after.len() != mt_after.len() { + eprintln!( + "!! 표 뒤 레코드 수 차이: {} vs {}", + bt_after.len(), + mt_after.len() + ); + } + for i in 0..bt_after.len().min(mt_after.len()) { + if bt_after[i].tag_id != mt_after[i].tag_id || bt_after[i].data != mt_after[i].data { + let tag = crate::parser::tags::tag_name(bt_after[i].tag_id); + eprintln!("!! 표 뒤 [{}] {} 차이:", i, tag); + eprintln!( + " 기준: {:02X?}", + &bt_after[i].data[..bt_after[i].data.len().min(40)] + ); + eprintln!( + " 병합: {:02X?}", + &mt_after[i].data[..mt_after[i].data.len().min(40)] + ); } + } - // 행 삽입 후 내보내기 - let mut hwp_doc = HwpDocument::create_empty(); - hwp_doc.set_document(crate::parser::parse_hwp(&data).unwrap()); - hwp_doc.insert_table_row_native(0, 3, 0, 0, true).unwrap(); - - let exported = hwp_doc.export_hwp_native().unwrap(); - let mut cfb2 = crate::parser::cfb_reader::CfbReader::open(&exported).unwrap(); - let new_doc = crate::parser::parse_hwp(&exported).unwrap(); - let new_bt = cfb2.read_body_text_section(0, new_doc.header.compressed, false).unwrap(); - let new_recs = Record::read_all(&new_bt).unwrap(); + // 표 내부 레코드 전체 출력 + eprintln!("\n=== 기준선 표 레코드 ==="); + for i in bt_start..bt_end { + let r = &baseline_recs[i]; + let tag = crate::parser::tags::tag_name(r.tag_id); + eprintln!( + " [{}] {} L{} {}B {:02X?}", + i, + tag, + r.level, + r.data.len(), + &r.data[..r.data.len().min(50)] + ); + } + eprintln!("\n=== 병합 표 레코드 ==="); + for i in mt_start..mt_end { + let r = &merged_recs[i]; + let tag = crate::parser::tags::tag_name(r.tag_id); + eprintln!( + " [{}] {} L{} {}B {:02X?}", + i, + tag, + r.level, + r.data.len(), + &r.data[..r.data.len().min(50)] + ); + } - // 수정 후 표 레코드 - let mut new_table_start = 0; - let mut new_table_end = 0; - for (i, rec) in new_recs.iter().enumerate() { - if rec.tag_id == crate::parser::tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes(rec.data[0..4].try_into().unwrap()); - if ctrl_id == crate::parser::tags::CTRL_TABLE { - new_table_start = i; - new_table_end = new_recs.len(); - for j in (i+1)..new_recs.len() { - if new_recs[j].level <= rec.level { - new_table_end = j; - break; - } - } + // DocInfo 스트림 비교 + let mut baseline_cfb2 = crate::parser::cfb_reader::CfbReader::open(&baseline_exported).unwrap(); + let mut merged_cfb2 = crate::parser::cfb_reader::CfbReader::open(&merged_exported).unwrap(); + let baseline_di = baseline_cfb2 + .read_doc_info(baseline_parsed.header.compressed) + .unwrap(); + let merged_di = merged_cfb2 + .read_doc_info(merged_parsed.header.compressed) + .unwrap(); + if baseline_di == merged_di { + eprintln!("\nDocInfo: 동일 ({}B)", baseline_di.len()); + } else { + eprintln!( + "\n!! DocInfo 차이: {}B vs {}B", + baseline_di.len(), + merged_di.len() + ); + for i in 0..baseline_di.len().min(merged_di.len()) { + if baseline_di[i] != merged_di[i] { + eprintln!( + " offset {}: {:02X} vs {:02X}", + i, baseline_di[i], merged_di[i] + ); + if i > 5 { + eprintln!(" ... (더 있을 수 있음)"); break; } } } + } - eprintln!("\n=== 수정 후 표 레코드: [{}..{}] ({} records) ===", new_table_start, new_table_end, new_table_end - new_table_start); - for i in new_table_start..new_table_end { - let r = &new_recs[i]; - let tag = crate::parser::tags::tag_name(r.tag_id); - eprintln!(" [{}] {} L{} {}B {:02X?}", i, tag, r.level, r.data.len(), - &r.data[..r.data.len().min(16)]); - } + // FileHeader 비교 + let baseline_hdr = &baseline_exported[0..256.min(baseline_exported.len())]; + let merged_hdr = &merged_exported[0..256.min(merged_exported.len())]; + if baseline_hdr != merged_hdr { + eprintln!("\n!! FileHeader 차이 (첫 256바이트)"); + } - // 원본 빈 셀(1,0)과 새 셀 LIST_HEADER 바이트 비교 - let orig_table_recs = &orig_recs[table_start..table_end]; - let new_table_recs = &new_recs[new_table_start..new_table_end]; + eprintln!( + "\n파일 크기: 기준선={}B, 병합={}B", + baseline_exported.len(), + merged_exported.len() + ); +} + +/// 사용자 저장 파일 vs 프로그래밍적 병합 파일 비교 +#[test] +fn test_compare_user_saved_vs_programmatic() { + use crate::parser::record::Record; + use std::path::Path; + + let orig_path = Path::new("samples/hwp_table_test.hwp"); + let saved_path = Path::new("samples/hwp_table_test_saved.hwp"); + if !orig_path.exists() || !saved_path.exists() { + eprintln!("파일 없음 — 건너뜀"); + return; + } - // LIST_HEADER 레코드 모두 추출 - eprintln!("\n=== 원본 LIST_HEADER 바이트 ==="); - for (i, r) in orig_table_recs.iter().enumerate() { - if r.tag_id == crate::parser::tags::HWPTAG_LIST_HEADER { - eprintln!(" [{}] {}B: {:02X?}", table_start + i, r.data.len(), &r.data); - } - } - eprintln!("\n=== 수정 후 LIST_HEADER 바이트 ==="); - for (i, r) in new_table_recs.iter().enumerate() { - if r.tag_id == crate::parser::tags::HWPTAG_LIST_HEADER { - eprintln!(" [{}] {}B: {:02X?}", new_table_start + i, r.data.len(), &r.data); - } + let orig_data = std::fs::read(orig_path).unwrap(); + let saved_data = std::fs::read(saved_path).unwrap(); + + // 프로그래밍적 병합 내보내기 + let mut merged_doc = HwpDocument::from_bytes(&orig_data).unwrap(); + merged_doc + .merge_table_cells_native(0, 3, 0, 2, 0, 2, 1) + .unwrap(); + let prog_data = merged_doc.export_hwp_native().unwrap(); + + // 사용자 저장 파일 BodyText + let saved_parsed = crate::parser::parse_hwp(&saved_data).unwrap(); + let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_parsed.header.compressed, false) + .unwrap(); + let saved_recs = Record::read_all(&saved_bt).unwrap(); + + // 프로그래밍적 병합 BodyText + let prog_parsed = crate::parser::parse_hwp(&prog_data).unwrap(); + let mut prog_cfb = crate::parser::cfb_reader::CfbReader::open(&prog_data).unwrap(); + let prog_bt = prog_cfb + .read_body_text_section(0, prog_parsed.header.compressed, false) + .unwrap(); + let prog_recs = Record::read_all(&prog_bt).unwrap(); + + eprintln!( + "사용자 저장: {} recs, 프로그래밍: {} recs", + saved_recs.len(), + prog_recs.len() + ); + eprintln!( + "사용자 저장 파일: {}B, 프로그래밍 파일: {}B", + saved_data.len(), + prog_data.len() + ); + + // 모든 레코드 비교 + let max_recs = saved_recs.len().max(prog_recs.len()); + let mut diffs = 0; + for i in 0..max_recs { + if i >= saved_recs.len() { + eprintln!( + "!! [{}] 사용자에 없음, 프로그래밍: {} L{}", + i, + crate::parser::tags::tag_name(prog_recs[i].tag_id), + prog_recs[i].level + ); + diffs += 1; + continue; } - - // PARA_HEADER 바이트 비교 - eprintln!("\n=== 원본 PARA_HEADER (표 내부) ==="); - for (i, r) in orig_table_recs.iter().enumerate() { - if r.tag_id == crate::parser::tags::HWPTAG_PARA_HEADER { - eprintln!(" [{}] {}B: {:02X?}", table_start + i, r.data.len(), &r.data); - } + if i >= prog_recs.len() { + eprintln!( + "!! [{}] 프로그래밍에 없음, 사용자: {} L{}", + i, + crate::parser::tags::tag_name(saved_recs[i].tag_id), + saved_recs[i].level + ); + diffs += 1; + continue; } - eprintln!("\n=== 수정 후 PARA_HEADER (표 내부) ==="); - for (i, r) in new_table_recs.iter().enumerate() { - if r.tag_id == crate::parser::tags::HWPTAG_PARA_HEADER { - eprintln!(" [{}] {}B: {:02X?}", new_table_start + i, r.data.len(), &r.data); - } + let s = &saved_recs[i]; + let p = &prog_recs[i]; + if s.tag_id != p.tag_id || s.level != p.level || s.data != p.data { + let stag = crate::parser::tags::tag_name(s.tag_id); + let ptag = crate::parser::tags::tag_name(p.tag_id); + eprintln!("!! [{}] 차이:", i); + eprintln!( + " 사용자: {} L{} {}B {:02X?}", + stag, + s.level, + s.data.len(), + &s.data[..s.data.len().min(50)] + ); + eprintln!( + " 프로그: {} L{} {}B {:02X?}", + ptag, + p.level, + p.data.len(), + &p.data[..p.data.len().min(50)] + ); + diffs += 1; } + } + eprintln!("총 차이: {} 레코드", diffs); + + // DocInfo 비교 + let mut saved_cfb2 = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); + let saved_di = saved_cfb2 + .read_doc_info(saved_parsed.header.compressed) + .unwrap(); + let mut prog_cfb2 = crate::parser::cfb_reader::CfbReader::open(&prog_data).unwrap(); + let prog_di = prog_cfb2 + .read_doc_info(prog_parsed.header.compressed) + .unwrap(); + if saved_di == prog_di { + eprintln!("\nDocInfo: 동일 ({}B)", saved_di.len()); + } else { + eprintln!( + "\n!! DocInfo 차이: 사용자={}B, 프로그래밍={}B", + saved_di.len(), + prog_di.len() + ); + } - // TABLE 레코드 비교 - eprintln!("\n=== TABLE 레코드 비교 ==="); - for r in orig_table_recs.iter() { - if r.tag_id == crate::parser::tags::HWPTAG_TABLE { - eprintln!(" 원본: {}B: {:02X?}", r.data.len(), &r.data); - } - } - for r in new_table_recs.iter() { - if r.tag_id == crate::parser::tags::HWPTAG_TABLE { - eprintln!(" 수정: {}B: {:02X?}", r.data.len(), &r.data); + // BodyText raw bytes 비교 + if saved_bt == prog_bt { + eprintln!("BodyText raw: 동일 ({}B)", saved_bt.len()); + } else { + eprintln!( + "!! BodyText raw 차이: 사용자={}B, 프로그래밍={}B", + saved_bt.len(), + prog_bt.len() + ); + let mut byte_diffs = 0; + for i in 0..saved_bt.len().min(prog_bt.len()) { + if saved_bt[i] != prog_bt[i] { + if byte_diffs < 10 { + eprintln!(" offset {}: {:02X} vs {:02X}", i, saved_bt[i], prog_bt[i]); + } + byte_diffs += 1; } } + eprintln!( + " 총 바이트 차이: {} (+ 길이 차이: {})", + byte_diffs, + (saved_bt.len() as i64 - prog_bt.len() as i64).abs() + ); } - #[test] - /// 실제 HWP 파일에서 셀 병합 후 포괄적 바이너리 비교 - fn test_merge_cells_roundtrip_real_hwp() { - use std::path::Path; - use crate::parser::record::Record; - - let orig_path = Path::new("samples/hwp_table_test.hwp"); - if !orig_path.exists() { - eprintln!("파일 없음 — 건너뜀"); - return; - } - - let orig_data = std::fs::read(orig_path).unwrap(); - - // 1) 원본 → 수정 없이 라운드트립 (기준선) - let mut baseline_doc = HwpDocument::from_bytes(&orig_data).unwrap(); - let baseline_exported = baseline_doc.export_hwp_native().unwrap(); - - // 2) 원본 → 병합 후 내보내기 - let mut merged_doc = HwpDocument::from_bytes(&orig_data).unwrap(); - merged_doc.merge_table_cells_native(0, 3, 0, 2, 0, 2, 1).unwrap(); - let merged_exported = merged_doc.export_hwp_native().unwrap(); + // 전체 CFB 파일 비교 (원본 라운드트립 vs 사용자 저장) + let mut baseline_doc = HwpDocument::from_bytes(&orig_data).unwrap(); + let baseline_data = baseline_doc.export_hwp_native().unwrap(); + eprintln!( + "\n원본 라운드트립: {}B, 사용자 저장: {}B", + baseline_data.len(), + saved_data.len() + ); + + // 프로그래밍적 병합 파일 디스크에 저장 (수동 확인용) + let out_dir = Path::new("output"); + if out_dir.exists() { + std::fs::write(out_dir.join("merge_test_programmatic.hwp"), &prog_data).unwrap(); + std::fs::write(out_dir.join("merge_test_baseline.hwp"), &baseline_data).unwrap(); + eprintln!("\n저장 완료:"); + eprintln!(" output/merge_test_baseline.hwp (수정 없이 라운드트립)"); + eprintln!(" output/merge_test_programmatic.hwp (프로그래밍적 병합)"); + } +} + +/// 한컴 오피스 참조 파일 분석: 병합된 표의 셀 구조 확인 +#[test] +fn test_analyze_hancom_merged_file() { + use crate::parser::record::Record; + use std::path::Path; + + let orig_path = Path::new("samples/hwp_table_test.hwp"); + let hancom_path = Path::new("samples/hwp_table_test-m.hwp"); + if !orig_path.exists() || !hancom_path.exists() { + eprintln!("파일 없음 — 건너뜀"); + return; + } - // 검증용 파일 저장 - let _ = std::fs::create_dir_all("output"); - std::fs::write("output/merge_test_baseline.hwp", &baseline_exported).unwrap(); - std::fs::write("output/merge_test_programmatic.hwp", &merged_exported).unwrap(); - eprintln!("검증 파일 저장: output/merge_test_baseline.hwp, output/merge_test_programmatic.hwp"); - - // 기준선 BodyText - let baseline_parsed = crate::parser::parse_hwp(&baseline_exported).unwrap(); - let mut baseline_cfb = crate::parser::cfb_reader::CfbReader::open(&baseline_exported).unwrap(); - let baseline_bt = baseline_cfb.read_body_text_section(0, baseline_parsed.header.compressed, false).unwrap(); - let baseline_recs = Record::read_all(&baseline_bt).unwrap(); - - // 병합 BodyText - let merged_parsed = crate::parser::parse_hwp(&merged_exported).unwrap(); - let mut merged_cfb = crate::parser::cfb_reader::CfbReader::open(&merged_exported).unwrap(); - let merged_bt = merged_cfb.read_body_text_section(0, merged_parsed.header.compressed, false).unwrap(); - let merged_recs = Record::read_all(&merged_bt).unwrap(); - - eprintln!("기준선 레코드: {}, 병합 레코드: {}", baseline_recs.len(), merged_recs.len()); - - // 표 범위 찾기 - let find_table = |recs: &[Record]| -> (usize, usize) { - for (i, rec) in recs.iter().enumerate() { - if rec.tag_id == crate::parser::tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes(rec.data[0..4].try_into().unwrap()); - if ctrl_id == crate::parser::tags::CTRL_TABLE { - let mut end = recs.len(); - for j in (i+1)..recs.len() { - if recs[j].level <= rec.level { end = j; break; } + let orig_data = std::fs::read(orig_path).unwrap(); + let hancom_data = std::fs::read(hancom_path).unwrap(); + + // 원본 BodyText + let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); + let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); + let orig_bt = orig_cfb + .read_body_text_section(0, orig_doc.header.compressed, false) + .unwrap(); + let orig_recs = Record::read_all(&orig_bt).unwrap(); + + // 한컴 병합 BodyText + let hancom_doc = crate::parser::parse_hwp(&hancom_data).unwrap(); + let mut hancom_cfb = crate::parser::cfb_reader::CfbReader::open(&hancom_data).unwrap(); + let hancom_bt = hancom_cfb + .read_body_text_section(0, hancom_doc.header.compressed, false) + .unwrap(); + let hancom_recs = Record::read_all(&hancom_bt).unwrap(); + + eprintln!( + "원본: {} recs, 한컴 병합: {} recs", + orig_recs.len(), + hancom_recs.len() + ); + + // 표 범위 찾기 + let find_table = |recs: &[Record]| -> (usize, usize) { + for (i, rec) in recs.iter().enumerate() { + if rec.tag_id == crate::parser::tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes(rec.data[0..4].try_into().unwrap()); + if ctrl_id == crate::parser::tags::CTRL_TABLE { + let mut end = recs.len(); + for j in (i + 1)..recs.len() { + if recs[j].level <= rec.level { + end = j; + break; } - return (i, end); } - } - } - (0, 0) - }; + return (i, end); + } + } + } + (0, 0) + }; + + let (ot_start, ot_end) = find_table(&orig_recs); + let (ht_start, ht_end) = find_table(&hancom_recs); + eprintln!( + "원본 표: [{}..{}] ({} recs)", + ot_start, + ot_end, + ot_end - ot_start + ); + eprintln!( + "한컴 표: [{}..{}] ({} recs)", + ht_start, + ht_end, + ht_end - ht_start + ); + + // 한컴 표 레코드 전체 출력 + eprintln!("\n=== 한컴 병합 표 레코드 ==="); + for i in ht_start..ht_end { + let r = &hancom_recs[i]; + let tag = crate::parser::tags::tag_name(r.tag_id); + eprintln!( + " [{}] {} L{} {}B {:02X?}", + i, + tag, + r.level, + r.data.len(), + &r.data[..r.data.len().min(50)] + ); + } - let (bt_start, bt_end) = find_table(&baseline_recs); - let (mt_start, mt_end) = find_table(&merged_recs); - eprintln!("기준선 표: [{}..{}] ({} recs), 병합 표: [{}..{}] ({} recs)", - bt_start, bt_end, bt_end - bt_start, mt_start, mt_end, mt_end - mt_start); - - // 표 앞쪽 레코드 비교 (동일해야 함) - let pre_count = bt_start.min(mt_start); - for i in 0..pre_count { - if baseline_recs[i].tag_id != merged_recs[i].tag_id || baseline_recs[i].data != merged_recs[i].data { - let tag = crate::parser::tags::tag_name(baseline_recs[i].tag_id); - eprintln!("!! 표 앞 [{}] {} 차이:", i, tag); - eprintln!(" 기준: {:02X?}", &baseline_recs[i].data[..baseline_recs[i].data.len().min(40)]); - eprintln!(" 병합: {:02X?}", &merged_recs[i].data[..merged_recs[i].data.len().min(40)]); - } - } - - // 표 뒤쪽 레코드 비교 - let bt_after = &baseline_recs[bt_end..]; - let mt_after = &merged_recs[mt_end..]; - if bt_after.len() != mt_after.len() { - eprintln!("!! 표 뒤 레코드 수 차이: {} vs {}", bt_after.len(), mt_after.len()); - } - for i in 0..bt_after.len().min(mt_after.len()) { - if bt_after[i].tag_id != mt_after[i].tag_id || bt_after[i].data != mt_after[i].data { - let tag = crate::parser::tags::tag_name(bt_after[i].tag_id); - eprintln!("!! 표 뒤 [{}] {} 차이:", i, tag); - eprintln!(" 기준: {:02X?}", &bt_after[i].data[..bt_after[i].data.len().min(40)]); - eprintln!(" 병합: {:02X?}", &mt_after[i].data[..mt_after[i].data.len().min(40)]); - } - } - - // 표 내부 레코드 전체 출력 - eprintln!("\n=== 기준선 표 레코드 ==="); - for i in bt_start..bt_end { - let r = &baseline_recs[i]; - let tag = crate::parser::tags::tag_name(r.tag_id); - eprintln!(" [{}] {} L{} {}B {:02X?}", i, tag, r.level, r.data.len(), - &r.data[..r.data.len().min(50)]); - } - eprintln!("\n=== 병합 표 레코드 ==="); - for i in mt_start..mt_end { - let r = &merged_recs[i]; - let tag = crate::parser::tags::tag_name(r.tag_id); - eprintln!(" [{}] {} L{} {}B {:02X?}", i, tag, r.level, r.data.len(), - &r.data[..r.data.len().min(50)]); - } - - // DocInfo 스트림 비교 - let mut baseline_cfb2 = crate::parser::cfb_reader::CfbReader::open(&baseline_exported).unwrap(); - let mut merged_cfb2 = crate::parser::cfb_reader::CfbReader::open(&merged_exported).unwrap(); - let baseline_di = baseline_cfb2.read_doc_info(baseline_parsed.header.compressed).unwrap(); - let merged_di = merged_cfb2.read_doc_info(merged_parsed.header.compressed).unwrap(); - if baseline_di == merged_di { - eprintln!("\nDocInfo: 동일 ({}B)", baseline_di.len()); - } else { - eprintln!("\n!! DocInfo 차이: {}B vs {}B", baseline_di.len(), merged_di.len()); - for i in 0..baseline_di.len().min(merged_di.len()) { - if baseline_di[i] != merged_di[i] { - eprintln!(" offset {}: {:02X} vs {:02X}", i, baseline_di[i], merged_di[i]); - if i > 5 { eprintln!(" ... (더 있을 수 있음)"); break; } - } - } + // TABLE 레코드 비교 + eprintln!("\n=== TABLE 레코드 비교 ==="); + for r in orig_recs[ot_start..ot_end].iter() { + if r.tag_id == crate::parser::tags::HWPTAG_TABLE { + eprintln!(" 원본: {:02X?}", &r.data); } - - // FileHeader 비교 - let baseline_hdr = &baseline_exported[0..256.min(baseline_exported.len())]; - let merged_hdr = &merged_exported[0..256.min(merged_exported.len())]; - if baseline_hdr != merged_hdr { - eprintln!("\n!! FileHeader 차이 (첫 256바이트)"); + } + for r in hancom_recs[ht_start..ht_end].iter() { + if r.tag_id == crate::parser::tags::HWPTAG_TABLE { + eprintln!(" 한컴: {:02X?}", &r.data); } - - eprintln!("\n파일 크기: 기준선={}B, 병합={}B", baseline_exported.len(), merged_exported.len()); } - /// 사용자 저장 파일 vs 프로그래밍적 병합 파일 비교 - #[test] - fn test_compare_user_saved_vs_programmatic() { - use std::path::Path; - use crate::parser::record::Record; - - let orig_path = Path::new("samples/hwp_table_test.hwp"); - let saved_path = Path::new("samples/hwp_table_test_saved.hwp"); - if !orig_path.exists() || !saved_path.exists() { - eprintln!("파일 없음 — 건너뜀"); - return; + // LIST_HEADER 비교 (셀 구조) + eprintln!("\n=== 원본 LIST_HEADER (row=2 셀들) ==="); + let mut cell_idx = 0; + for r in orig_recs[ot_start..ot_end].iter() { + if r.tag_id == crate::parser::tags::HWPTAG_LIST_HEADER { + let col = u16::from_le_bytes(r.data[8..10].try_into().unwrap()); + let row = u16::from_le_bytes(r.data[10..12].try_into().unwrap()); + if row == 2 { + eprintln!( + " cell[{}] col={} row={}: {:02X?}", + cell_idx, col, row, &r.data + ); + } + cell_idx += 1; } + } - let orig_data = std::fs::read(orig_path).unwrap(); - let saved_data = std::fs::read(saved_path).unwrap(); + eprintln!("\n=== 한컴 LIST_HEADER (row=2 셀들) ==="); + cell_idx = 0; + for r in hancom_recs[ht_start..ht_end].iter() { + if r.tag_id == crate::parser::tags::HWPTAG_LIST_HEADER { + let col = u16::from_le_bytes(r.data[8..10].try_into().unwrap()); + let row = u16::from_le_bytes(r.data[10..12].try_into().unwrap()); + let col_span = u16::from_le_bytes(r.data[12..14].try_into().unwrap()); + let row_span = u16::from_le_bytes(r.data[14..16].try_into().unwrap()); + let width = u32::from_le_bytes(r.data[16..20].try_into().unwrap()); + let height = u32::from_le_bytes(r.data[20..24].try_into().unwrap()); + eprintln!( + " cell[{}] col={} row={} span={}x{} w={} h={}: {:02X?}", + cell_idx, col, row, col_span, row_span, width, height, &r.data + ); + cell_idx += 1; + } + } - // 프로그래밍적 병합 내보내기 - let mut merged_doc = HwpDocument::from_bytes(&orig_data).unwrap(); - merged_doc.merge_table_cells_native(0, 3, 0, 2, 0, 2, 1).unwrap(); - let prog_data = merged_doc.export_hwp_native().unwrap(); + // 셀 개수 비교 + let orig_cells = orig_recs[ot_start..ot_end] + .iter() + .filter(|r| r.tag_id == crate::parser::tags::HWPTAG_LIST_HEADER) + .count(); + let hancom_cells = hancom_recs[ht_start..ht_end] + .iter() + .filter(|r| r.tag_id == crate::parser::tags::HWPTAG_LIST_HEADER) + .count(); + eprintln!("\n셀 개수: 원본={}, 한컴 병합={}", orig_cells, hancom_cells); +} + +#[test] +fn test_merge_cells_then_render() { + let mut doc = create_doc_with_table(); + // 전체 병합 + doc.merge_table_cells_native(0, 0, 0, 0, 0, 1, 1).unwrap(); + + // SVG 렌더링 성공 확인 + let svg = doc.render_page_svg_native(0); + assert!(svg.is_ok()); + assert!(svg.unwrap().contains("= saved_recs.len() { - eprintln!("!! [{}] 사용자에 없음, 프로그래밍: {} L{}", i, - crate::parser::tags::tag_name(prog_recs[i].tag_id), prog_recs[i].level); - diffs += 1; - continue; - } - if i >= prog_recs.len() { - eprintln!("!! [{}] 프로그래밍에 없음, 사용자: {} L{}", i, - crate::parser::tags::tag_name(saved_recs[i].tag_id), saved_recs[i].level); - diffs += 1; - continue; - } - let s = &saved_recs[i]; - let p = &prog_recs[i]; - if s.tag_id != p.tag_id || s.level != p.level || s.data != p.data { - let stag = crate::parser::tags::tag_name(s.tag_id); - let ptag = crate::parser::tags::tag_name(p.tag_id); - eprintln!("!! [{}] 차이:", i); - eprintln!(" 사용자: {} L{} {}B {:02X?}", stag, s.level, s.data.len(), &s.data[..s.data.len().min(50)]); - eprintln!(" 프로그: {} L{} {}B {:02X?}", ptag, p.level, p.data.len(), &p.data[..p.data.len().min(50)]); - diffs += 1; - } - } - eprintln!("총 차이: {} 레코드", diffs); - - // DocInfo 비교 - let mut saved_cfb2 = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); - let saved_di = saved_cfb2.read_doc_info(saved_parsed.header.compressed).unwrap(); - let mut prog_cfb2 = crate::parser::cfb_reader::CfbReader::open(&prog_data).unwrap(); - let prog_di = prog_cfb2.read_doc_info(prog_parsed.header.compressed).unwrap(); - if saved_di == prog_di { - eprintln!("\nDocInfo: 동일 ({}B)", saved_di.len()); - } else { - eprintln!("\n!! DocInfo 차이: 사용자={}B, 프로그래밍={}B", saved_di.len(), prog_di.len()); + // 레코드 유형별 차이 분석 + use std::collections::HashMap; + let count_tags = |recs: &[Record]| -> HashMap { + let mut map = HashMap::new(); + for r in recs { + *map.entry(r.tag_id).or_insert(0) += 1; + } + map + }; + let tags_with = count_tags(&recs_with); + let tags_without = count_tags(&recs_without); + + let mut all_tags: Vec = tags_with + .keys() + .chain(tags_without.keys()) + .copied() + .collect(); + all_tags.sort(); + all_tags.dedup(); + for tag in &all_tags { + let c1 = tags_with.get(tag).unwrap_or(&0); + let c2 = tags_without.get(tag).unwrap_or(&0); + if c1 != c2 { + eprintln!( + " 태그 차이: {} (0x{:04X}): raw={}, reserialized={}", + crate::parser::tags::tag_name(*tag), + tag, + c1, + c2 + ); } + } - // BodyText raw bytes 비교 - if saved_bt == prog_bt { - eprintln!("BodyText raw: 동일 ({}B)", saved_bt.len()); - } else { - eprintln!("!! BodyText raw 차이: 사용자={}B, 프로그래밍={}B", saved_bt.len(), prog_bt.len()); - let mut byte_diffs = 0; - for i in 0..saved_bt.len().min(prog_bt.len()) { - if saved_bt[i] != prog_bt[i] { - if byte_diffs < 10 { - eprintln!(" offset {}: {:02X} vs {:02X}", i, saved_bt[i], prog_bt[i]); + // CTRL_DATA 위치 분석 + for (idx, rec) in recs_with.iter().enumerate() { + if rec.tag_id == crate::parser::tags::HWPTAG_CTRL_DATA { + // 부모 CTRL_HEADER 찾기 + let mut parent_info = "?".to_string(); + for prev_idx in (0..idx).rev() { + if recs_with[prev_idx].tag_id == crate::parser::tags::HWPTAG_CTRL_HEADER + && recs_with[prev_idx].level < rec.level + { + let data = &recs_with[prev_idx].data; + if data.len() >= 4 { + let ctrl_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); + parent_info = format!( + "{} (0x{:08X})", + crate::parser::tags::ctrl_name(ctrl_id), + ctrl_id + ); } - byte_diffs += 1; + break; } } - eprintln!(" 총 바이트 차이: {} (+ 길이 차이: {})", byte_diffs, - (saved_bt.len() as i64 - prog_bt.len() as i64).abs()); - } - - // 전체 CFB 파일 비교 (원본 라운드트립 vs 사용자 저장) - let mut baseline_doc = HwpDocument::from_bytes(&orig_data).unwrap(); - let baseline_data = baseline_doc.export_hwp_native().unwrap(); - eprintln!("\n원본 라운드트립: {}B, 사용자 저장: {}B", baseline_data.len(), saved_data.len()); - - // 프로그래밍적 병합 파일 디스크에 저장 (수동 확인용) - let out_dir = Path::new("output"); - if out_dir.exists() { - std::fs::write(out_dir.join("merge_test_programmatic.hwp"), &prog_data).unwrap(); - std::fs::write(out_dir.join("merge_test_baseline.hwp"), &baseline_data).unwrap(); - eprintln!("\n저장 완료:"); - eprintln!(" output/merge_test_baseline.hwp (수정 없이 라운드트립)"); - eprintln!(" output/merge_test_programmatic.hwp (프로그래밍적 병합)"); + eprintln!( + " CTRL_DATA[{}]: level={}, size={}, parent={}", + idx, + rec.level, + rec.data.len(), + parent_info + ); } } - /// 한컴 오피스 참조 파일 분석: 병합된 표의 셀 구조 확인 - #[test] - fn test_analyze_hancom_merged_file() { - use std::path::Path; - use crate::parser::record::Record; - - let orig_path = Path::new("samples/hwp_table_test.hwp"); - let hancom_path = Path::new("samples/hwp_table_test-m.hwp"); - if !orig_path.exists() || !hancom_path.exists() { - eprintln!("파일 없음 — 건너뜀"); - return; - } + // 인덱스 225~245 주변 레코드 트리 덤프 (level 6 구조 분석) + eprintln!("\n--- 레코드 트리 (225~250) ---"); + for idx in 225..250.min(recs_with.len()) { + let rec = &recs_with[idx]; + let indent = " ".repeat(rec.level as usize); + let mut extra = String::new(); + if rec.tag_id == crate::parser::tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { + let cid = u32::from_le_bytes(rec.data[0..4].try_into().unwrap()); + extra = format!(" ctrl={}", crate::parser::tags::ctrl_name(cid)); + } + eprintln!( + " [{}] {}{}(lv={}, {}B){}", + idx, + indent, + crate::parser::tags::tag_name(rec.tag_id), + rec.level, + rec.data.len(), + extra + ); + } - let orig_data = std::fs::read(orig_path).unwrap(); - let hancom_data = std::fs::read(hancom_path).unwrap(); + // 문단 수가 같아야 함 + assert_eq!( + reserialized_para_count, orig_para_count, + "재직렬화 후 문단 수 불일치!" + ); +} + +/// 재직렬화 vs 원본: BodyText 레코드 상세 비교 (편집 시나리오) +#[test] +fn test_web_saved_vs_original_detailed() { + use crate::parser::record::Record; + use std::path::Path; + + let orig_path = Path::new("samples/20250130-hongbo.hwp"); + if !orig_path.exists() { + eprintln!("SKIP: 파일 없음"); + return; + } - // 원본 BodyText - let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); - let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); - let orig_bt = orig_cfb.read_body_text_section(0, orig_doc.header.compressed, false).unwrap(); - let orig_recs = Record::read_all(&orig_bt).unwrap(); + let orig_data = std::fs::read(orig_path).unwrap(); + + // 현재 코드로 재직렬화 (raw_stream 제거 = 편집 시나리오) + let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); + doc.document.sections[0].raw_stream = None; + let saved_data = doc.export_hwp_native().unwrap(); + eprintln!( + "원본: {} bytes, 재직렬화: {} bytes", + orig_data.len(), + saved_data.len() + ); + + // CFB 스트림 목록 비교 + let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); + let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); + + let orig_streams = orig_cfb.list_streams(); + let saved_streams = saved_cfb.list_streams(); + eprintln!("\n=== CFB 스트림 ==="); + eprintln!("원본: {:?}", orig_streams); + eprintln!("저장: {:?}", saved_streams); + + // FileHeader 비교 + let orig_hdr = orig_cfb.read_file_header().unwrap(); + let saved_hdr = saved_cfb.read_file_header().unwrap(); + if orig_hdr != saved_hdr { + eprintln!("\n=== FileHeader 차이 ==="); + for i in 0..orig_hdr.len().min(saved_hdr.len()) { + if orig_hdr[i] != saved_hdr[i] { + eprintln!(" offset {}: {:02X} → {:02X}", i, orig_hdr[i], saved_hdr[i]); + } + } + } else { + eprintln!("\nFileHeader: 동일"); + } - // 한컴 병합 BodyText - let hancom_doc = crate::parser::parse_hwp(&hancom_data).unwrap(); - let mut hancom_cfb = crate::parser::cfb_reader::CfbReader::open(&hancom_data).unwrap(); - let hancom_bt = hancom_cfb.read_body_text_section(0, hancom_doc.header.compressed, false).unwrap(); - let hancom_recs = Record::read_all(&hancom_bt).unwrap(); - - eprintln!("원본: {} recs, 한컴 병합: {} recs", orig_recs.len(), hancom_recs.len()); - - // 표 범위 찾기 - let find_table = |recs: &[Record]| -> (usize, usize) { - for (i, rec) in recs.iter().enumerate() { - if rec.tag_id == crate::parser::tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes(rec.data[0..4].try_into().unwrap()); - if ctrl_id == crate::parser::tags::CTRL_TABLE { - let mut end = recs.len(); - for j in (i+1)..recs.len() { - if recs[j].level <= rec.level { end = j; break; } - } - return (i, end); + // DocInfo 비교 + let orig_di = orig_cfb.read_doc_info(true).unwrap(); + let saved_di = saved_cfb.read_doc_info(true).unwrap(); + let orig_di_recs = Record::read_all(&orig_di).unwrap(); + let saved_di_recs = Record::read_all(&saved_di).unwrap(); + eprintln!("\n=== DocInfo ==="); + eprintln!( + "원본: {} recs ({}B), 저장: {} recs ({}B)", + orig_di_recs.len(), + orig_di.len(), + saved_di_recs.len(), + saved_di.len() + ); + + // DocInfo 레코드별 비교 + let max_di = orig_di_recs.len().max(saved_di_recs.len()); + let mut di_diffs = 0; + for i in 0..max_di { + let o = orig_di_recs.get(i); + let s = saved_di_recs.get(i); + match (o, s) { + (Some(or), Some(sr)) => { + if or.tag_id != sr.tag_id || or.data != sr.data { + if di_diffs < 20 { + eprintln!( + " DocInfo[{}] 차이: {} ({}B) vs {} ({}B)", + i, + crate::parser::tags::tag_name(or.tag_id), + or.data.len(), + crate::parser::tags::tag_name(sr.tag_id), + sr.data.len() + ); } + di_diffs += 1; } } - (0, 0) - }; - - let (ot_start, ot_end) = find_table(&orig_recs); - let (ht_start, ht_end) = find_table(&hancom_recs); - eprintln!("원본 표: [{}..{}] ({} recs)", ot_start, ot_end, ot_end - ot_start); - eprintln!("한컴 표: [{}..{}] ({} recs)", ht_start, ht_end, ht_end - ht_start); - - // 한컴 표 레코드 전체 출력 - eprintln!("\n=== 한컴 병합 표 레코드 ==="); - for i in ht_start..ht_end { - let r = &hancom_recs[i]; - let tag = crate::parser::tags::tag_name(r.tag_id); - eprintln!(" [{}] {} L{} {}B {:02X?}", i, tag, r.level, r.data.len(), - &r.data[..r.data.len().min(50)]); - } - - // TABLE 레코드 비교 - eprintln!("\n=== TABLE 레코드 비교 ==="); - for r in orig_recs[ot_start..ot_end].iter() { - if r.tag_id == crate::parser::tags::HWPTAG_TABLE { - eprintln!(" 원본: {:02X?}", &r.data); - } - } - for r in hancom_recs[ht_start..ht_end].iter() { - if r.tag_id == crate::parser::tags::HWPTAG_TABLE { - eprintln!(" 한컴: {:02X?}", &r.data); - } - } - - // LIST_HEADER 비교 (셀 구조) - eprintln!("\n=== 원본 LIST_HEADER (row=2 셀들) ==="); - let mut cell_idx = 0; - for r in orig_recs[ot_start..ot_end].iter() { - if r.tag_id == crate::parser::tags::HWPTAG_LIST_HEADER { - let col = u16::from_le_bytes(r.data[8..10].try_into().unwrap()); - let row = u16::from_le_bytes(r.data[10..12].try_into().unwrap()); - if row == 2 { - eprintln!(" cell[{}] col={} row={}: {:02X?}", cell_idx, col, row, &r.data); - } - cell_idx += 1; - } - } - - eprintln!("\n=== 한컴 LIST_HEADER (row=2 셀들) ==="); - cell_idx = 0; - for r in hancom_recs[ht_start..ht_end].iter() { - if r.tag_id == crate::parser::tags::HWPTAG_LIST_HEADER { - let col = u16::from_le_bytes(r.data[8..10].try_into().unwrap()); - let row = u16::from_le_bytes(r.data[10..12].try_into().unwrap()); - let col_span = u16::from_le_bytes(r.data[12..14].try_into().unwrap()); - let row_span = u16::from_le_bytes(r.data[14..16].try_into().unwrap()); - let width = u32::from_le_bytes(r.data[16..20].try_into().unwrap()); - let height = u32::from_le_bytes(r.data[20..24].try_into().unwrap()); - eprintln!(" cell[{}] col={} row={} span={}x{} w={} h={}: {:02X?}", - cell_idx, col, row, col_span, row_span, width, height, &r.data); - cell_idx += 1; + (Some(or), None) => { + eprintln!( + " DocInfo[{}] 원본만: {} ({}B)", + i, + crate::parser::tags::tag_name(or.tag_id), + or.data.len() + ); + di_diffs += 1; + } + (None, Some(sr)) => { + eprintln!( + " DocInfo[{}] 저장만: {} ({}B)", + i, + crate::parser::tags::tag_name(sr.tag_id), + sr.data.len() + ); + di_diffs += 1; } + _ => {} } - - // 셀 개수 비교 - let orig_cells = orig_recs[ot_start..ot_end].iter() - .filter(|r| r.tag_id == crate::parser::tags::HWPTAG_LIST_HEADER).count(); - let hancom_cells = hancom_recs[ht_start..ht_end].iter() - .filter(|r| r.tag_id == crate::parser::tags::HWPTAG_LIST_HEADER).count(); - eprintln!("\n셀 개수: 원본={}, 한컴 병합={}", orig_cells, hancom_cells); - } - - #[test] - fn test_merge_cells_then_render() { - let mut doc = create_doc_with_table(); - // 전체 병합 - doc.merge_table_cells_native(0, 0, 0, 0, 0, 1, 1).unwrap(); - - // SVG 렌더링 성공 확인 - let svg = doc.render_page_svg_native(0); - assert!(svg.is_ok()); - assert!(svg.unwrap().contains(" HashMap { - let mut map = HashMap::new(); - for r in recs { *map.entry(r.tag_id).or_insert(0) += 1; } - map - }; - let tags_with = count_tags(&recs_with); - let tags_without = count_tags(&recs_without); - - let mut all_tags: Vec = tags_with.keys().chain(tags_without.keys()).copied().collect(); - all_tags.sort(); - all_tags.dedup(); - for tag in &all_tags { - let c1 = tags_with.get(tag).unwrap_or(&0); - let c2 = tags_without.get(tag).unwrap_or(&0); - if c1 != c2 { - eprintln!(" 태그 차이: {} (0x{:04X}): raw={}, reserialized={}", - crate::parser::tags::tag_name(*tag), tag, c1, c2); - } - } - - // CTRL_DATA 위치 분석 - for (idx, rec) in recs_with.iter().enumerate() { - if rec.tag_id == crate::parser::tags::HWPTAG_CTRL_DATA { - // 부모 CTRL_HEADER 찾기 - let mut parent_info = "?".to_string(); - for prev_idx in (0..idx).rev() { - if recs_with[prev_idx].tag_id == crate::parser::tags::HWPTAG_CTRL_HEADER - && recs_with[prev_idx].level < rec.level { - let data = &recs_with[prev_idx].data; - if data.len() >= 4 { - let ctrl_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); - parent_info = format!("{} (0x{:08X})", - crate::parser::tags::ctrl_name(ctrl_id), ctrl_id); + eprintln!(" DocInfo 차이 레코드 수: {}", di_diffs); + + // BodyText Section0 비교 + let orig_bt = orig_cfb.read_body_text_section(0, true, false).unwrap(); + let saved_bt = saved_cfb.read_body_text_section(0, true, false).unwrap(); + let orig_recs = Record::read_all(&orig_bt).unwrap(); + let saved_recs = Record::read_all(&saved_bt).unwrap(); + eprintln!("\n=== BodyText Section0 ==="); + eprintln!( + "원본: {} recs ({}B), 저장: {} recs ({}B)", + orig_recs.len(), + orig_bt.len(), + saved_recs.len(), + saved_bt.len() + ); + + // 레코드별 비교 + let max_bt = orig_recs.len().max(saved_recs.len()); + let mut bt_diffs = 0; + for i in 0..max_bt { + let o = orig_recs.get(i); + let s = saved_recs.get(i); + match (o, s) { + (Some(or), Some(sr)) => { + if or.tag_id != sr.tag_id || or.level != sr.level || or.data != sr.data { + if bt_diffs < 30 { + let tag_same = or.tag_id == sr.tag_id; + let data_len_diff = or.data.len() as i64 - sr.data.len() as i64; + eprintln!( + " BT[{}] 차이: {} L{} ({}B) vs {} L{} ({}B) tag_same={} data_diff={}", + i, + crate::parser::tags::tag_name(or.tag_id), + or.level, + or.data.len(), + crate::parser::tags::tag_name(sr.tag_id), + sr.level, + sr.data.len(), + tag_same, + data_len_diff + ); + // 같은 태그인데 데이터만 다른 경우 바이트 비교 + if tag_same && or.data.len() == sr.data.len() && or.data.len() <= 100 { + for j in 0..or.data.len() { + if or.data[j] != sr.data[j] { + eprintln!( + " byte[{}]: {:02X} → {:02X}", + j, or.data[j], sr.data[j] + ); + } + } } - break; } + bt_diffs += 1; } - eprintln!(" CTRL_DATA[{}]: level={}, size={}, parent={}", - idx, rec.level, rec.data.len(), parent_info); - } - } - - // 인덱스 225~245 주변 레코드 트리 덤프 (level 6 구조 분석) - eprintln!("\n--- 레코드 트리 (225~250) ---"); - for idx in 225..250.min(recs_with.len()) { - let rec = &recs_with[idx]; - let indent = " ".repeat(rec.level as usize); - let mut extra = String::new(); - if rec.tag_id == crate::parser::tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { - let cid = u32::from_le_bytes(rec.data[0..4].try_into().unwrap()); - extra = format!(" ctrl={}", crate::parser::tags::ctrl_name(cid)); } - eprintln!(" [{}] {}{}(lv={}, {}B){}", - idx, indent, - crate::parser::tags::tag_name(rec.tag_id), - rec.level, rec.data.len(), extra); - } - - // 문단 수가 같아야 함 - assert_eq!(reserialized_para_count, orig_para_count, - "재직렬화 후 문단 수 불일치!"); - } - - /// 재직렬화 vs 원본: BodyText 레코드 상세 비교 (편집 시나리오) - #[test] - fn test_web_saved_vs_original_detailed() { - use crate::parser::record::Record; - use std::path::Path; - - let orig_path = Path::new("samples/20250130-hongbo.hwp"); - if !orig_path.exists() { - eprintln!("SKIP: 파일 없음"); - return; - } - - let orig_data = std::fs::read(orig_path).unwrap(); - - // 현재 코드로 재직렬화 (raw_stream 제거 = 편집 시나리오) - let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); - doc.document.sections[0].raw_stream = None; - let saved_data = doc.export_hwp_native().unwrap(); - eprintln!("원본: {} bytes, 재직렬화: {} bytes", orig_data.len(), saved_data.len()); - - // CFB 스트림 목록 비교 - let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); - let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); - - let orig_streams = orig_cfb.list_streams(); - let saved_streams = saved_cfb.list_streams(); - eprintln!("\n=== CFB 스트림 ==="); - eprintln!("원본: {:?}", orig_streams); - eprintln!("저장: {:?}", saved_streams); - - // FileHeader 비교 - let orig_hdr = orig_cfb.read_file_header().unwrap(); - let saved_hdr = saved_cfb.read_file_header().unwrap(); - if orig_hdr != saved_hdr { - eprintln!("\n=== FileHeader 차이 ==="); - for i in 0..orig_hdr.len().min(saved_hdr.len()) { - if orig_hdr[i] != saved_hdr[i] { - eprintln!(" offset {}: {:02X} → {:02X}", i, orig_hdr[i], saved_hdr[i]); + (Some(or), None) => { + if bt_diffs < 30 { + eprintln!( + " BT[{}] 원본만: {} L{} ({}B)", + i, + crate::parser::tags::tag_name(or.tag_id), + or.level, + or.data.len() + ); } + bt_diffs += 1; } - } else { - eprintln!("\nFileHeader: 동일"); - } - - // DocInfo 비교 - let orig_di = orig_cfb.read_doc_info(true).unwrap(); - let saved_di = saved_cfb.read_doc_info(true).unwrap(); - let orig_di_recs = Record::read_all(&orig_di).unwrap(); - let saved_di_recs = Record::read_all(&saved_di).unwrap(); - eprintln!("\n=== DocInfo ==="); - eprintln!("원본: {} recs ({}B), 저장: {} recs ({}B)", - orig_di_recs.len(), orig_di.len(), saved_di_recs.len(), saved_di.len()); - - // DocInfo 레코드별 비교 - let max_di = orig_di_recs.len().max(saved_di_recs.len()); - let mut di_diffs = 0; - for i in 0..max_di { - let o = orig_di_recs.get(i); - let s = saved_di_recs.get(i); - match (o, s) { - (Some(or), Some(sr)) => { - if or.tag_id != sr.tag_id || or.data != sr.data { - if di_diffs < 20 { - eprintln!(" DocInfo[{}] 차이: {} ({}B) vs {} ({}B)", - i, - crate::parser::tags::tag_name(or.tag_id), or.data.len(), - crate::parser::tags::tag_name(sr.tag_id), sr.data.len()); - } - di_diffs += 1; - } - } - (Some(or), None) => { - eprintln!(" DocInfo[{}] 원본만: {} ({}B)", i, - crate::parser::tags::tag_name(or.tag_id), or.data.len()); - di_diffs += 1; - } - (None, Some(sr)) => { - eprintln!(" DocInfo[{}] 저장만: {} ({}B)", i, - crate::parser::tags::tag_name(sr.tag_id), sr.data.len()); - di_diffs += 1; + (None, Some(sr)) => { + if bt_diffs < 30 { + eprintln!( + " BT[{}] 저장만: {} L{} ({}B)", + i, + crate::parser::tags::tag_name(sr.tag_id), + sr.level, + sr.data.len() + ); } - _ => {} + bt_diffs += 1; } + _ => {} } - eprintln!(" DocInfo 차이 레코드 수: {}", di_diffs); - - // BodyText Section0 비교 - let orig_bt = orig_cfb.read_body_text_section(0, true, false).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, true, false).unwrap(); - let orig_recs = Record::read_all(&orig_bt).unwrap(); - let saved_recs = Record::read_all(&saved_bt).unwrap(); - eprintln!("\n=== BodyText Section0 ==="); - eprintln!("원본: {} recs ({}B), 저장: {} recs ({}B)", - orig_recs.len(), orig_bt.len(), saved_recs.len(), saved_bt.len()); - - // 레코드별 비교 - let max_bt = orig_recs.len().max(saved_recs.len()); - let mut bt_diffs = 0; - for i in 0..max_bt { - let o = orig_recs.get(i); - let s = saved_recs.get(i); - match (o, s) { - (Some(or), Some(sr)) => { - if or.tag_id != sr.tag_id || or.level != sr.level || or.data != sr.data { - if bt_diffs < 30 { - let tag_same = or.tag_id == sr.tag_id; - let data_len_diff = or.data.len() as i64 - sr.data.len() as i64; - eprintln!(" BT[{}] 차이: {} L{} ({}B) vs {} L{} ({}B) tag_same={} data_diff={}", - i, - crate::parser::tags::tag_name(or.tag_id), or.level, or.data.len(), - crate::parser::tags::tag_name(sr.tag_id), sr.level, sr.data.len(), - tag_same, data_len_diff); - // 같은 태그인데 데이터만 다른 경우 바이트 비교 - if tag_same && or.data.len() == sr.data.len() && or.data.len() <= 100 { - for j in 0..or.data.len() { - if or.data[j] != sr.data[j] { - eprintln!(" byte[{}]: {:02X} → {:02X}", j, or.data[j], sr.data[j]); - } - } - } - } - bt_diffs += 1; - } - } - (Some(or), None) => { - if bt_diffs < 30 { - eprintln!(" BT[{}] 원본만: {} L{} ({}B)", i, - crate::parser::tags::tag_name(or.tag_id), or.level, or.data.len()); - } - bt_diffs += 1; - } - (None, Some(sr)) => { - if bt_diffs < 30 { - eprintln!(" BT[{}] 저장만: {} L{} ({}B)", i, - crate::parser::tags::tag_name(sr.tag_id), sr.level, sr.data.len()); - } - bt_diffs += 1; + } + eprintln!(" BodyText 차이 레코드 수: {}", bt_diffs); + + // BinData 스트림 비교 + eprintln!("\n=== BinData 스트림 비교 ==="); + let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); + let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); + eprintln!( + "원본 bin_data_content: {} 항목", + orig_doc.bin_data_content.len() + ); + eprintln!( + "저장 bin_data_content: {} 항목", + saved_doc.bin_data_content.len() + ); + for bc in &orig_doc.bin_data_content { + let saved_bc = saved_doc.bin_data_content.iter().find(|c| c.id == bc.id); + match saved_bc { + Some(sbc) => { + if bc.data.len() == sbc.data.len() && bc.data == sbc.data { + eprintln!( + " ID {}: 동일 ({}B, ext={})", + bc.id, + bc.data.len(), + bc.extension + ); + } else { + eprintln!( + " ID {}: 크기 차이! 원본={}B, 저장={}B", + bc.id, + bc.data.len(), + sbc.data.len() + ); } - _ => {} } - } - eprintln!(" BodyText 차이 레코드 수: {}", bt_diffs); - - // BinData 스트림 비교 - eprintln!("\n=== BinData 스트림 비교 ==="); - let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); - let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); - eprintln!("원본 bin_data_content: {} 항목", orig_doc.bin_data_content.len()); - eprintln!("저장 bin_data_content: {} 항목", saved_doc.bin_data_content.len()); - for bc in &orig_doc.bin_data_content { - let saved_bc = saved_doc.bin_data_content.iter().find(|c| c.id == bc.id); - match saved_bc { - Some(sbc) => { - if bc.data.len() == sbc.data.len() && bc.data == sbc.data { - eprintln!(" ID {}: 동일 ({}B, ext={})", bc.id, bc.data.len(), bc.extension); - } else { - eprintln!(" ID {}: 크기 차이! 원본={}B, 저장={}B", bc.id, bc.data.len(), sbc.data.len()); - } - } - None => { - eprintln!(" ID {}: 저장본에 없음!", bc.id); - } + None => { + eprintln!(" ID {}: 저장본에 없음!", bc.id); } } } - - // ===================================================================== - // 클립보드 테스트 - // ===================================================================== - - #[test] - fn test_clipboard_copy_paste_single_paragraph() { - let mut doc = HwpDocument::create_empty(); - let mut document = Document::default(); - let mut para = Paragraph::default(); - para.text = "Hello World 안녕하세요".to_string(); - para.char_count = para.text.chars().count() as u32 + 1; - para.char_offsets = para.text.chars().enumerate().map(|(i, _)| i as u32).collect(); - para.char_shapes = vec![crate::model::paragraph::CharShapeRef { +} + +// ===================================================================== +// 클립보드 테스트 +// ===================================================================== + +#[test] +fn test_clipboard_copy_paste_single_paragraph() { + let mut doc = HwpDocument::create_empty(); + let mut document = Document::default(); + let mut para = Paragraph::default(); + para.text = "Hello World 안녕하세요".to_string(); + para.char_count = para.text.chars().count() as u32 + 1; + para.char_offsets = para + .text + .chars() + .enumerate() + .map(|(i, _)| i as u32) + .collect(); + para.char_shapes = vec![crate::model::paragraph::CharShapeRef { + start_pos: 0, + char_shape_id: 0, + }]; + para.line_segs = vec![crate::model::paragraph::LineSeg { + text_start: 0, + line_height: 400, + text_height: 400, + baseline_distance: 320, + ..Default::default() + }]; + para.has_para_text = true; + document.sections.push(Section { + paragraphs: vec![para], + ..Default::default() + }); + doc.set_document(document); + + // "World" 복사 (offset 6~11) + let result = doc.copy_selection_native(0, 0, 6, 0, 11); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"ok\":true")); + assert!(json.contains("World")); + + // 내부 클립보드 확인 + assert!(doc.has_internal_clipboard_native()); + assert_eq!(doc.get_clipboard_text_native(), "World"); + + // 문단 끝에 붙여넣기 + let text_len = doc.document.sections[0].paragraphs[0].text.chars().count(); + let result = doc.paste_internal_native(0, 0, text_len); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"ok\":true")); + + // 텍스트 확인 + let text = &doc.document.sections[0].paragraphs[0].text; + assert!(text.contains("Hello World 안녕하세요World")); +} + +#[test] +fn test_clipboard_copy_paste_multi_paragraph() { + let mut doc = HwpDocument::create_empty(); + let mut document = Document::default(); + + let make_para = |text: &str| { + let mut p = Paragraph::default(); + p.text = text.to_string(); + p.char_count = text.chars().count() as u32 + 1; + p.char_offsets = text.chars().enumerate().map(|(i, _)| i as u32).collect(); + p.char_shapes = vec![crate::model::paragraph::CharShapeRef { start_pos: 0, char_shape_id: 0, }]; - para.line_segs = vec![crate::model::paragraph::LineSeg { + p.line_segs = vec![crate::model::paragraph::LineSeg { text_start: 0, line_height: 400, text_height: 400, baseline_distance: 320, ..Default::default() }]; - para.has_para_text = true; - document.sections.push(Section { - paragraphs: vec![para], - ..Default::default() - }); - doc.set_document(document); - - // "World" 복사 (offset 6~11) - let result = doc.copy_selection_native(0, 0, 6, 0, 11); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("\"ok\":true")); - assert!(json.contains("World")); - - // 내부 클립보드 확인 - assert!(doc.has_internal_clipboard_native()); - assert_eq!(doc.get_clipboard_text_native(), "World"); - - // 문단 끝에 붙여넣기 - let text_len = doc.document.sections[0].paragraphs[0].text.chars().count(); - let result = doc.paste_internal_native(0, 0, text_len); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("\"ok\":true")); - - // 텍스트 확인 - let text = &doc.document.sections[0].paragraphs[0].text; - assert!(text.contains("Hello World 안녕하세요World")); - } - - #[test] - fn test_clipboard_copy_paste_multi_paragraph() { - let mut doc = HwpDocument::create_empty(); - let mut document = Document::default(); - - let make_para = |text: &str| { - let mut p = Paragraph::default(); - p.text = text.to_string(); - p.char_count = text.chars().count() as u32 + 1; - p.char_offsets = text.chars().enumerate().map(|(i, _)| i as u32).collect(); - p.char_shapes = vec![crate::model::paragraph::CharShapeRef { - start_pos: 0, - char_shape_id: 0, - }]; - p.line_segs = vec![crate::model::paragraph::LineSeg { - text_start: 0, - line_height: 400, - text_height: 400, - baseline_distance: 320, - ..Default::default() - }]; - p.has_para_text = true; - p - }; - - document.sections.push(Section { - paragraphs: vec![ - make_para("첫 번째 문단"), - make_para("두 번째 문단"), - make_para("세 번째 문단"), - ], - ..Default::default() - }); - doc.set_document(document); - - // 첫 번째 문단 3번째 글자부터 두 번째 문단 3번째 글자까지 복사 - let result = doc.copy_selection_native(0, 0, 3, 1, 3); - assert!(result.is_ok()); - - // 클립보드에 2개 문단이 있어야 함 - assert!(doc.has_internal_clipboard_native()); - let clip = doc.clipboard.as_ref().unwrap(); - assert_eq!(clip.paragraphs.len(), 2); - - // 세 번째 문단 끝에 붙여넣기 - let text_len = doc.document.sections[0].paragraphs[2].text.chars().count(); - let result = doc.paste_internal_native(0, 2, text_len); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("\"ok\":true")); - - // 문단 수 증가 확인 (3 → 4: 분할 + 삽입) - assert_eq!(doc.document.sections[0].paragraphs.len(), 4); - } - - #[test] - fn test_clipboard_copy_control() { - let mut doc = create_doc_with_table(); - - // 표 컨트롤 복사 - let result = doc.copy_control_native(0, 0, 0); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("[표]")); - - // 클립보드 확인 - assert!(doc.has_internal_clipboard_native()); - let clip = doc.clipboard.as_ref().unwrap(); - assert_eq!(clip.paragraphs.len(), 1); - assert_eq!(clip.paragraphs[0].controls.len(), 1); - assert!(matches!(&clip.paragraphs[0].controls[0], Control::Table(_))); - } - - #[test] - fn test_clipboard_clear() { - let mut doc = HwpDocument::create_empty(); - let mut document = Document::default(); - let mut para = Paragraph::default(); - para.text = "테스트".to_string(); - para.char_count = 4; - para.char_offsets = vec![0, 1, 2]; - para.char_shapes = vec![crate::model::paragraph::CharShapeRef { - start_pos: 0, char_shape_id: 0, - }]; - para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; - para.has_para_text = true; - document.sections.push(Section { - paragraphs: vec![para], - ..Default::default() - }); - doc.set_document(document); - - // 복사 - doc.copy_selection_native(0, 0, 0, 0, 3).unwrap(); - assert!(doc.has_internal_clipboard_native()); - - // 초기화 - doc.clear_clipboard_native(); - assert!(!doc.has_internal_clipboard_native()); - assert_eq!(doc.get_clipboard_text_native(), ""); - } - - #[test] - fn test_clipboard_paste_empty() { - let mut doc = HwpDocument::create_empty(); - let mut document = Document::default(); - let mut para = Paragraph::default(); - para.text = "테스트".to_string(); - para.char_count = 4; - para.char_offsets = vec![0, 1, 2]; - para.char_shapes = vec![crate::model::paragraph::CharShapeRef { - start_pos: 0, char_shape_id: 0, - }]; - para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; - para.has_para_text = true; - document.sections.push(Section { - paragraphs: vec![para], - ..Default::default() - }); - doc.set_document(document); - - // 클립보드 비어있는 상태에서 붙여넣기 - let result = doc.paste_internal_native(0, 0, 0); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("\"ok\":false")); - } - - #[test] - fn test_export_selection_html_basic() { - let mut doc = HwpDocument::create_empty(); - let mut document = Document::default(); - - // CharShape 추가 (bold) - let mut cs = crate::model::style::CharShape::default(); - cs.base_size = 1200; // 12pt - cs.bold = true; - document.doc_info.char_shapes.push(cs); - - // ParaShape 추가 (center align) - let mut ps = crate::model::style::ParaShape::default(); - ps.alignment = crate::model::style::Alignment::Center; - document.doc_info.para_shapes.push(ps); - - let mut para = Paragraph::default(); - para.text = "Hello World".to_string(); - para.char_count = 12; - para.char_offsets = (0..11).collect(); - para.char_shapes = vec![crate::model::paragraph::CharShapeRef { - start_pos: 0, - char_shape_id: 0, - }]; - para.para_shape_id = 0; - para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; - para.has_para_text = true; - - document.sections.push(Section { - paragraphs: vec![para], - ..Default::default() - }); - doc.set_document(document); - - // HTML 내보내기 - let result = doc.export_selection_html_native(0, 0, 0, 0, 11); - assert!(result.is_ok()); - let html = result.unwrap(); - - // 기본 구조 확인 - assert!(html.contains("")); - assert!(html.contains("")); - assert!(html.contains("Hello World")); - assert!(html.contains("

BCD<")); - } - - #[test] - fn test_export_control_html_table() { - let mut doc = create_doc_with_table(); - - let result = doc.export_control_html_native(0, 0, 0); - assert!(result.is_ok()); - let html = result.unwrap(); - - assert!(html.contains("")); - assert!(html.contains("")); - } - - // === HTML 붙여넣기 테스트 === - - #[test] - fn test_paste_html_plain_text() { - let mut doc = HwpDocument::create_empty(); - let mut document = Document::default(); - document.doc_info.char_shapes.push(crate::model::style::CharShape::default()); - document.doc_info.para_shapes.push(crate::model::style::ParaShape::default()); - let mut para = Paragraph::default(); - para.text = "가나다".to_string(); - para.char_count = para.text.encode_utf16().count() as u32; - para.char_offsets = para.text.chars() - .scan(0u32, |acc, c| { let off = *acc; *acc += c.len_utf16() as u32; Some(off) }) - .collect(); - para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; - para.has_para_text = true; - document.sections.push(Section { paragraphs: vec![para], ..Default::default() }); - doc.set_document(document); - - // 플레인 텍스트 HTML 붙여넣기 - let html = "

안녕하세요

"; - let result = doc.paste_html_native(0, 0, 3, html); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("\"ok\":true")); - - // 삽입 후 텍스트 확인 - let text = &doc.document.sections[0].paragraphs[0].text; - assert!(text.contains("안녕하세요")); - assert!(text.contains("가나다")); - } - - #[test] - fn test_paste_html_styled_text() { - let mut doc = HwpDocument::create_empty(); - let mut document = Document::default(); - document.doc_info.char_shapes.push(crate::model::style::CharShape::default()); - document.doc_info.para_shapes.push(crate::model::style::ParaShape::default()); - let mut para = Paragraph::default(); - para.text = "테스트".to_string(); - para.char_count = para.text.encode_utf16().count() as u32; - para.char_offsets = para.text.chars() - .scan(0u32, |acc, c| { let off = *acc; *acc += c.len_utf16() as u32; Some(off) }) - .collect(); - para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; - para.has_para_text = true; - document.sections.push(Section { paragraphs: vec![para], ..Default::default() }); - doc.set_document(document); - - // 볼드+색상 스타일 HTML - let html = r#" + p.has_para_text = true; + p + }; + + document.sections.push(Section { + paragraphs: vec![ + make_para("첫 번째 문단"), + make_para("두 번째 문단"), + make_para("세 번째 문단"), + ], + ..Default::default() + }); + doc.set_document(document); + + // 첫 번째 문단 3번째 글자부터 두 번째 문단 3번째 글자까지 복사 + let result = doc.copy_selection_native(0, 0, 3, 1, 3); + assert!(result.is_ok()); + + // 클립보드에 2개 문단이 있어야 함 + assert!(doc.has_internal_clipboard_native()); + let clip = doc.clipboard.as_ref().unwrap(); + assert_eq!(clip.paragraphs.len(), 2); + + // 세 번째 문단 끝에 붙여넣기 + let text_len = doc.document.sections[0].paragraphs[2].text.chars().count(); + let result = doc.paste_internal_native(0, 2, text_len); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"ok\":true")); + + // 문단 수 증가 확인 (3 → 4: 분할 + 삽입) + assert_eq!(doc.document.sections[0].paragraphs.len(), 4); +} + +#[test] +fn test_clipboard_copy_control() { + let mut doc = create_doc_with_table(); + + // 표 컨트롤 복사 + let result = doc.copy_control_native(0, 0, 0); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("[표]")); + + // 클립보드 확인 + assert!(doc.has_internal_clipboard_native()); + let clip = doc.clipboard.as_ref().unwrap(); + assert_eq!(clip.paragraphs.len(), 1); + assert_eq!(clip.paragraphs[0].controls.len(), 1); + assert!(matches!(&clip.paragraphs[0].controls[0], Control::Table(_))); +} + +#[test] +fn test_clipboard_clear() { + let mut doc = HwpDocument::create_empty(); + let mut document = Document::default(); + let mut para = Paragraph::default(); + para.text = "테스트".to_string(); + para.char_count = 4; + para.char_offsets = vec![0, 1, 2]; + para.char_shapes = vec![crate::model::paragraph::CharShapeRef { + start_pos: 0, + char_shape_id: 0, + }]; + para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; + para.has_para_text = true; + document.sections.push(Section { + paragraphs: vec![para], + ..Default::default() + }); + doc.set_document(document); + + // 복사 + doc.copy_selection_native(0, 0, 0, 0, 3).unwrap(); + assert!(doc.has_internal_clipboard_native()); + + // 초기화 + doc.clear_clipboard_native(); + assert!(!doc.has_internal_clipboard_native()); + assert_eq!(doc.get_clipboard_text_native(), ""); +} + +#[test] +fn test_clipboard_paste_empty() { + let mut doc = HwpDocument::create_empty(); + let mut document = Document::default(); + let mut para = Paragraph::default(); + para.text = "테스트".to_string(); + para.char_count = 4; + para.char_offsets = vec![0, 1, 2]; + para.char_shapes = vec![crate::model::paragraph::CharShapeRef { + start_pos: 0, + char_shape_id: 0, + }]; + para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; + para.has_para_text = true; + document.sections.push(Section { + paragraphs: vec![para], + ..Default::default() + }); + doc.set_document(document); + + // 클립보드 비어있는 상태에서 붙여넣기 + let result = doc.paste_internal_native(0, 0, 0); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"ok\":false")); +} + +#[test] +fn test_export_selection_html_basic() { + let mut doc = HwpDocument::create_empty(); + let mut document = Document::default(); + + // CharShape 추가 (bold) + let mut cs = crate::model::style::CharShape::default(); + cs.base_size = 1200; // 12pt + cs.bold = true; + document.doc_info.char_shapes.push(cs); + + // ParaShape 추가 (center align) + let mut ps = crate::model::style::ParaShape::default(); + ps.alignment = crate::model::style::Alignment::Center; + document.doc_info.para_shapes.push(ps); + + let mut para = Paragraph::default(); + para.text = "Hello World".to_string(); + para.char_count = 12; + para.char_offsets = (0..11).collect(); + para.char_shapes = vec![crate::model::paragraph::CharShapeRef { + start_pos: 0, + char_shape_id: 0, + }]; + para.para_shape_id = 0; + para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; + para.has_para_text = true; + + document.sections.push(Section { + paragraphs: vec![para], + ..Default::default() + }); + doc.set_document(document); + + // HTML 내보내기 + let result = doc.export_selection_html_native(0, 0, 0, 0, 11); + assert!(result.is_ok()); + let html = result.unwrap(); + + // 기본 구조 확인 + assert!(html.contains("")); + assert!(html.contains("")); + assert!(html.contains("Hello World")); + assert!(html.contains("

BCD<")); +} + +#[test] +fn test_export_control_html_table() { + let mut doc = create_doc_with_table(); + + let result = doc.export_control_html_native(0, 0, 0); + assert!(result.is_ok()); + let html = result.unwrap(); + + assert!(html.contains("")); + assert!(html.contains("")); +} + +// === HTML 붙여넣기 테스트 === + +#[test] +fn test_paste_html_plain_text() { + let mut doc = HwpDocument::create_empty(); + let mut document = Document::default(); + document + .doc_info + .char_shapes + .push(crate::model::style::CharShape::default()); + document + .doc_info + .para_shapes + .push(crate::model::style::ParaShape::default()); + let mut para = Paragraph::default(); + para.text = "가나다".to_string(); + para.char_count = para.text.encode_utf16().count() as u32; + para.char_offsets = para + .text + .chars() + .scan(0u32, |acc, c| { + let off = *acc; + *acc += c.len_utf16() as u32; + Some(off) + }) + .collect(); + para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; + para.has_para_text = true; + document.sections.push(Section { + paragraphs: vec![para], + ..Default::default() + }); + doc.set_document(document); + + // 플레인 텍스트 HTML 붙여넣기 + let html = "

안녕하세요

"; + let result = doc.paste_html_native(0, 0, 3, html); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"ok\":true")); + + // 삽입 후 텍스트 확인 + let text = &doc.document.sections[0].paragraphs[0].text; + assert!(text.contains("안녕하세요")); + assert!(text.contains("가나다")); +} + +#[test] +fn test_paste_html_styled_text() { + let mut doc = HwpDocument::create_empty(); + let mut document = Document::default(); + document + .doc_info + .char_shapes + .push(crate::model::style::CharShape::default()); + document + .doc_info + .para_shapes + .push(crate::model::style::ParaShape::default()); + let mut para = Paragraph::default(); + para.text = "테스트".to_string(); + para.char_count = para.text.encode_utf16().count() as u32; + para.char_offsets = para + .text + .chars() + .scan(0u32, |acc, c| { + let off = *acc; + *acc += c.len_utf16() as u32; + Some(off) + }) + .collect(); + para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; + para.has_para_text = true; + document.sections.push(Section { + paragraphs: vec![para], + ..Default::default() + }); + doc.set_document(document); + + // 볼드+색상 스타일 HTML + let html = r#"

볼드 빨강

"#; - let result = doc.paste_html_native(0, 0, 0, html); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("\"ok\":true")); - - // CharShape가 추가되었는지 확인 (bold + red color) - let char_shapes_count = doc.document.doc_info.char_shapes.len(); - assert!(char_shapes_count > 1, "새 CharShape가 생성되어야 함"); - - // 볼드 속성 확인 - let bold_shape = doc.document.doc_info.char_shapes.iter() - .find(|cs| cs.bold); - assert!(bold_shape.is_some(), "볼드 CharShape가 존재해야 함"); - } - - #[test] - fn test_paste_html_multi_paragraph() { - let mut doc = HwpDocument::create_empty(); - let mut document = Document::default(); - document.doc_info.char_shapes.push(crate::model::style::CharShape::default()); - document.doc_info.para_shapes.push(crate::model::style::ParaShape::default()); - let mut para = Paragraph::default(); - para.text = "원본".to_string(); - para.char_count = para.text.encode_utf16().count() as u32; - para.char_offsets = para.text.chars() - .scan(0u32, |acc, c| { let off = *acc; *acc += c.len_utf16() as u32; Some(off) }) - .collect(); - para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; - para.has_para_text = true; - document.sections.push(Section { paragraphs: vec![para], ..Default::default() }); - doc.set_document(document); - - // 다중 문단 HTML - let html = r#" + let result = doc.paste_html_native(0, 0, 0, html); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"ok\":true")); + + // CharShape가 추가되었는지 확인 (bold + red color) + let char_shapes_count = doc.document.doc_info.char_shapes.len(); + assert!(char_shapes_count > 1, "새 CharShape가 생성되어야 함"); + + // 볼드 속성 확인 + let bold_shape = doc.document.doc_info.char_shapes.iter().find(|cs| cs.bold); + assert!(bold_shape.is_some(), "볼드 CharShape가 존재해야 함"); +} + +#[test] +fn test_paste_html_multi_paragraph() { + let mut doc = HwpDocument::create_empty(); + let mut document = Document::default(); + document + .doc_info + .char_shapes + .push(crate::model::style::CharShape::default()); + document + .doc_info + .para_shapes + .push(crate::model::style::ParaShape::default()); + let mut para = Paragraph::default(); + para.text = "원본".to_string(); + para.char_count = para.text.encode_utf16().count() as u32; + para.char_offsets = para + .text + .chars() + .scan(0u32, |acc, c| { + let off = *acc; + *acc += c.len_utf16() as u32; + Some(off) + }) + .collect(); + para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; + para.has_para_text = true; + document.sections.push(Section { + paragraphs: vec![para], + ..Default::default() + }); + doc.set_document(document); + + // 다중 문단 HTML + let html = r#"

첫째 문단

둘째 문단

셋째 문단

"#; - let result = doc.paste_html_native(0, 0, 2, html); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("\"ok\":true")); - - // 문단 수 확인 (원본 1 + 삽입 3 = 최소 3) - let para_count = doc.document.sections[0].paragraphs.len(); - assert!(para_count >= 3, "최소 3개 문단이어야 함, 실제: {}", para_count); - } - - #[test] - fn test_paste_html_table_as_control() { - let mut doc = HwpDocument::create_empty(); - let mut document = Document::default(); - document.doc_info.char_shapes.push(crate::model::style::CharShape::default()); - document.doc_info.para_shapes.push(crate::model::style::ParaShape::default()); - document.doc_info.border_fills.push(crate::model::style::BorderFill::default()); - let mut para = Paragraph::default(); - para.text = "".to_string(); - para.char_count = 0; - para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; - para.has_para_text = true; - document.sections.push(Section { paragraphs: vec![para], ..Default::default() }); - doc.set_document(document); - - // 2×2 표 HTML - let html = r#" + let result = doc.paste_html_native(0, 0, 2, html); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"ok\":true")); + + // 문단 수 확인 (원본 1 + 삽입 3 = 최소 3) + let para_count = doc.document.sections[0].paragraphs.len(); + assert!( + para_count >= 3, + "최소 3개 문단이어야 함, 실제: {}", + para_count + ); +} + +#[test] +fn test_paste_html_table_as_control() { + let mut doc = HwpDocument::create_empty(); + let mut document = Document::default(); + document + .doc_info + .char_shapes + .push(crate::model::style::CharShape::default()); + document + .doc_info + .para_shapes + .push(crate::model::style::ParaShape::default()); + document + .doc_info + .border_fills + .push(crate::model::style::BorderFill::default()); + let mut para = Paragraph::default(); + para.text = "".to_string(); + para.char_count = 0; + para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; + para.has_para_text = true; + document.sections.push(Section { + paragraphs: vec![para], + ..Default::default() + }); + doc.set_document(document); + + // 2×2 표 HTML + let html = r#"
셀1셀2
셀3셀4
"#; - let result = doc.paste_html_native(0, 0, 0, html); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("\"ok\":true")); - - // Table Control이 삽입되었는지 확인 - let paras = &doc.document.sections[0].paragraphs; - let table_para = paras.iter().find(|p| !p.controls.is_empty()); - assert!(table_para.is_some(), "Table Control을 포함하는 문단이 있어야 함"); - - let table_para = table_para.unwrap(); - assert!(table_para.text.is_empty(), "컨트롤 문단의 text는 비어있어야 함"); - assert_eq!(table_para.controls.len(), 1); - - if let Control::Table(ref tbl) = table_para.controls[0] { - assert_eq!(tbl.row_count, 2, "행 수 2"); - assert_eq!(tbl.col_count, 2, "열 수 2"); - assert_eq!(tbl.cells.len(), 4, "셀 4개"); + let result = doc.paste_html_native(0, 0, 0, html); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"ok\":true")); + + // Table Control이 삽입되었는지 확인 + let paras = &doc.document.sections[0].paragraphs; + let table_para = paras.iter().find(|p| !p.controls.is_empty()); + assert!( + table_para.is_some(), + "Table Control을 포함하는 문단이 있어야 함" + ); + + let table_para = table_para.unwrap(); + assert!( + table_para.text.is_empty(), + "컨트롤 문단의 text는 비어있어야 함" + ); + assert_eq!(table_para.controls.len(), 1); + + if let Control::Table(ref tbl) = table_para.controls[0] { + assert_eq!(tbl.row_count, 2, "행 수 2"); + assert_eq!(tbl.col_count, 2, "열 수 2"); + assert_eq!(tbl.cells.len(), 4, "셀 4개"); + + // 셀 내용 확인 + let cell_texts: Vec = tbl + .cells + .iter() + .map(|c| { + c.paragraphs + .iter() + .map(|p| p.text.clone()) + .collect::>() + .join("") + }) + .collect(); + assert!(cell_texts.iter().any(|t| t.contains("셀1")), "셀1 포함"); + assert!(cell_texts.iter().any(|t| t.contains("셀2")), "셀2 포함"); + assert!(cell_texts.iter().any(|t| t.contains("셀3")), "셀3 포함"); + assert!(cell_texts.iter().any(|t| t.contains("셀4")), "셀4 포함"); - // 셀 내용 확인 - let cell_texts: Vec = tbl.cells.iter() - .map(|c| c.paragraphs.iter().map(|p| p.text.clone()).collect::>().join("")) - .collect(); - assert!(cell_texts.iter().any(|t| t.contains("셀1")), "셀1 포함"); - assert!(cell_texts.iter().any(|t| t.contains("셀2")), "셀2 포함"); - assert!(cell_texts.iter().any(|t| t.contains("셀3")), "셀3 포함"); - assert!(cell_texts.iter().any(|t| t.contains("셀4")), "셀4 포함"); - - // 정상 파일 패턴과 일치하는 속성값 검증 - assert_eq!(tbl.attr, 0x082A2311, "table.attr = 0x082A2311"); - assert_eq!(tbl.raw_table_record_attr, 0x04000006, "raw_table_record_attr (DIFF-5: 셀분리금지 항상 설정)"); - assert_eq!(tbl.padding.left, 510, "table padding left"); - assert_eq!(tbl.padding.right, 510, "table padding right"); - assert_eq!(tbl.padding.top, 141, "table padding top"); - assert_eq!(tbl.padding.bottom, 141, "table padding bottom"); - - // 셀 속성 검증 - for cell in &tbl.cells { - assert_eq!(cell.vertical_align, crate::model::table::VerticalAlign::Center, - "Cell({},{}) v_align=Center", cell.row, cell.col); - assert!(cell.raw_list_extra.len() >= 2, "raw_list_extra >= 2 bytes"); - } - - // table_para 속성 검증 - assert_eq!(table_para.char_count, 9, "table para char_count=9"); - assert_eq!(table_para.control_mask, 0x00000800, "control_mask=0x800"); - assert!(table_para.raw_header_extra.len() >= 10, "raw_header_extra >= 10"); - let inst = u32::from_le_bytes([ - table_para.raw_header_extra[6], table_para.raw_header_extra[7], - table_para.raw_header_extra[8], table_para.raw_header_extra[9], - ]); - assert_eq!(inst, 0x80000000, "table para instance_id=0x80000000"); + // 정상 파일 패턴과 일치하는 속성값 검증 + assert_eq!(tbl.attr, 0x082A2311, "table.attr = 0x082A2311"); + assert_eq!( + tbl.raw_table_record_attr, 0x04000006, + "raw_table_record_attr (DIFF-5: 셀분리금지 항상 설정)" + ); + assert_eq!(tbl.padding.left, 510, "table padding left"); + assert_eq!(tbl.padding.right, 510, "table padding right"); + assert_eq!(tbl.padding.top, 141, "table padding top"); + assert_eq!(tbl.padding.bottom, 141, "table padding bottom"); + + // 셀 속성 검증 + for cell in &tbl.cells { + assert_eq!( + cell.vertical_align, + crate::model::table::VerticalAlign::Center, + "Cell({},{}) v_align=Center", + cell.row, + cell.col + ); + assert!(cell.raw_list_extra.len() >= 2, "raw_list_extra >= 2 bytes"); + } - // DIFF-7: CTRL_HEADER instance_id (raw_ctrl_data[28..32]) 가 0이 아닌지 검증 - assert!(tbl.raw_ctrl_data.len() >= 32, "raw_ctrl_data >= 32 bytes"); - let ctrl_instance_id = u32::from_le_bytes([ - tbl.raw_ctrl_data[28], tbl.raw_ctrl_data[29], - tbl.raw_ctrl_data[30], tbl.raw_ctrl_data[31], - ]); - assert_ne!(ctrl_instance_id, 0, "DIFF-7: CTRL_HEADER instance_id != 0 (got 0x{:08X})", ctrl_instance_id); - } else { - panic!("첫 번째 컨트롤이 Table이어야 함"); - } - } - - /// DIFF-1 검증:   만 있는 빈 셀이 char_count=1, has_para_text=false 인지 확인 - #[test] - fn test_diff1_empty_cell_nbsp() { - let mut doc = HwpDocument::create_empty(); - let mut document = Document::default(); - document.doc_info.char_shapes.push(crate::model::style::CharShape::default()); - document.doc_info.para_shapes.push(crate::model::style::ParaShape::default()); - document.doc_info.border_fills.push(crate::model::style::BorderFill::default()); - let mut para = Paragraph::default(); - para.text = "".to_string(); - para.char_count = 0; - para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; - document.sections.push(crate::model::document::Section { - paragraphs: vec![para], - ..Default::default() - }); - doc.document = document; - - //   만 포함된 셀이 있는 2×2 표 (셀2, 셀4는 빈 셀) - let html = r#"
내용1 
내용2   
"#; - let mut paragraphs = Vec::new(); - doc.parse_table_html(&mut paragraphs, html); - - assert_eq!(paragraphs.len(), 1, "표 문단 1개"); - if let crate::model::control::Control::Table(ref tbl) = paragraphs[0].controls[0] { - assert_eq!(tbl.cells.len(), 4, "4 셀"); - // 셀[0]: "내용1" → 텍스트 있음 - assert!(!tbl.cells[0].paragraphs[0].text.is_empty(), "셀[0] 텍스트 있음"); - // 셀[1]:   → 빈 셀 - let empty1 = &tbl.cells[1].paragraphs[0]; - assert_eq!(empty1.char_count, 1, "DIFF-1:   셀은 char_count=1"); - assert!(empty1.text.is_empty(), "DIFF-1:   셀은 text 비어있음"); - assert!(!empty1.has_para_text, "DIFF-1:   셀은 has_para_text=false"); - // 셀[3]:     → 빈 셀 - let empty2 = &tbl.cells[3].paragraphs[0]; - assert_eq!(empty2.char_count, 1, "DIFF-1: 다중   셀은 char_count=1"); - assert!(empty2.text.is_empty(), "DIFF-1: 다중   셀은 text 비어있음"); - assert!(!empty2.has_para_text, "DIFF-1: 다중   셀은 has_para_text=false"); - } else { - panic!("Table 컨트롤이어야 함"); - } - } - - #[test] - fn test_paste_html_table_with_colspan_rowspan() { - let mut doc = HwpDocument::create_empty(); - let mut document = Document::default(); - document.doc_info.char_shapes.push(crate::model::style::CharShape::default()); - document.doc_info.para_shapes.push(crate::model::style::ParaShape::default()); - document.doc_info.border_fills.push(crate::model::style::BorderFill::default()); - let mut para = Paragraph::default(); - para.text = "".to_string(); - para.char_count = 0; - para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; - para.has_para_text = true; - document.sections.push(Section { paragraphs: vec![para], ..Default::default() }); - doc.set_document(document); - - // colspan=2, rowspan=2 포함 표 - let html = r#" + // table_para 속성 검증 + assert_eq!(table_para.char_count, 9, "table para char_count=9"); + assert_eq!(table_para.control_mask, 0x00000800, "control_mask=0x800"); + assert!( + table_para.raw_header_extra.len() >= 10, + "raw_header_extra >= 10" + ); + let inst = u32::from_le_bytes([ + table_para.raw_header_extra[6], + table_para.raw_header_extra[7], + table_para.raw_header_extra[8], + table_para.raw_header_extra[9], + ]); + assert_eq!(inst, 0x80000000, "table para instance_id=0x80000000"); + + // DIFF-7: CTRL_HEADER instance_id (raw_ctrl_data[28..32]) 가 0이 아닌지 검증 + assert!(tbl.raw_ctrl_data.len() >= 32, "raw_ctrl_data >= 32 bytes"); + let ctrl_instance_id = u32::from_le_bytes([ + tbl.raw_ctrl_data[28], + tbl.raw_ctrl_data[29], + tbl.raw_ctrl_data[30], + tbl.raw_ctrl_data[31], + ]); + assert_ne!( + ctrl_instance_id, 0, + "DIFF-7: CTRL_HEADER instance_id != 0 (got 0x{:08X})", + ctrl_instance_id + ); + } else { + panic!("첫 번째 컨트롤이 Table이어야 함"); + } +} + +/// DIFF-1 검증:   만 있는 빈 셀이 char_count=1, has_para_text=false 인지 확인 +#[test] +fn test_diff1_empty_cell_nbsp() { + let mut doc = HwpDocument::create_empty(); + let mut document = Document::default(); + document + .doc_info + .char_shapes + .push(crate::model::style::CharShape::default()); + document + .doc_info + .para_shapes + .push(crate::model::style::ParaShape::default()); + document + .doc_info + .border_fills + .push(crate::model::style::BorderFill::default()); + let mut para = Paragraph::default(); + para.text = "".to_string(); + para.char_count = 0; + para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; + document.sections.push(crate::model::document::Section { + paragraphs: vec![para], + ..Default::default() + }); + doc.document = document; + + //   만 포함된 셀이 있는 2×2 표 (셀2, 셀4는 빈 셀) + let html = r#"
내용1 
내용2   
"#; + let mut paragraphs = Vec::new(); + doc.parse_table_html(&mut paragraphs, html); + + assert_eq!(paragraphs.len(), 1, "표 문단 1개"); + if let crate::model::control::Control::Table(ref tbl) = paragraphs[0].controls[0] { + assert_eq!(tbl.cells.len(), 4, "4 셀"); + // 셀[0]: "내용1" → 텍스트 있음 + assert!( + !tbl.cells[0].paragraphs[0].text.is_empty(), + "셀[0] 텍스트 있음" + ); + // 셀[1]:   → 빈 셀 + let empty1 = &tbl.cells[1].paragraphs[0]; + assert_eq!(empty1.char_count, 1, "DIFF-1:   셀은 char_count=1"); + assert!(empty1.text.is_empty(), "DIFF-1:   셀은 text 비어있음"); + assert!( + !empty1.has_para_text, + "DIFF-1:   셀은 has_para_text=false" + ); + // 셀[3]:     → 빈 셀 + let empty2 = &tbl.cells[3].paragraphs[0]; + assert_eq!( + empty2.char_count, 1, + "DIFF-1: 다중   셀은 char_count=1" + ); + assert!( + empty2.text.is_empty(), + "DIFF-1: 다중   셀은 text 비어있음" + ); + assert!( + !empty2.has_para_text, + "DIFF-1: 다중   셀은 has_para_text=false" + ); + } else { + panic!("Table 컨트롤이어야 함"); + } +} + +#[test] +fn test_paste_html_table_with_colspan_rowspan() { + let mut doc = HwpDocument::create_empty(); + let mut document = Document::default(); + document + .doc_info + .char_shapes + .push(crate::model::style::CharShape::default()); + document + .doc_info + .para_shapes + .push(crate::model::style::ParaShape::default()); + document + .doc_info + .border_fills + .push(crate::model::style::BorderFill::default()); + let mut para = Paragraph::default(); + para.text = "".to_string(); + para.char_count = 0; + para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; + para.has_para_text = true; + document.sections.push(Section { + paragraphs: vec![para], + ..Default::default() + }); + doc.set_document(document); + + // colspan=2, rowspan=2 포함 표 + let html = r#" @@ -1914,49 +2389,61 @@
병합열C
병합행B2C2
"#; - let result = doc.paste_html_native(0, 0, 0, html); - assert!(result.is_ok()); - - let paras = &doc.document.sections[0].paragraphs; - let table_para = paras.iter().find(|p| !p.controls.is_empty()); - assert!(table_para.is_some(), "Table Control 문단이 있어야 함"); - - if let Control::Table(ref tbl) = table_para.unwrap().controls[0] { - assert_eq!(tbl.row_count, 3, "행 수 3"); - assert_eq!(tbl.col_count, 3, "열 수 3"); - - // colspan=2인 셀 확인 - let merged_col = tbl.cells.iter().find(|c| c.col_span == 2); - assert!(merged_col.is_some(), "colspan=2 셀이 있어야 함"); - assert_eq!(merged_col.unwrap().row, 0); - - // rowspan=2인 셀 확인 - let merged_row = tbl.cells.iter().find(|c| c.row_span == 2); - assert!(merged_row.is_some(), "rowspan=2 셀이 있어야 함"); - assert_eq!(merged_row.unwrap().col, 0); - assert_eq!(merged_row.unwrap().row, 1); - } else { - panic!("Table Control이어야 함"); - } - } - - #[test] - fn test_paste_html_table_with_css_styles() { - let mut doc = HwpDocument::create_empty(); - let mut document = Document::default(); - document.doc_info.char_shapes.push(crate::model::style::CharShape::default()); - document.doc_info.para_shapes.push(crate::model::style::ParaShape::default()); - document.doc_info.border_fills.push(crate::model::style::BorderFill::default()); - let mut para = Paragraph::default(); - para.text = "".to_string(); - para.char_count = 0; - para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; - para.has_para_text = true; - document.sections.push(Section { paragraphs: vec![para], ..Default::default() }); - doc.set_document(document); - - // CSS 스타일 포함 표 - let html = r#" + let result = doc.paste_html_native(0, 0, 0, html); + assert!(result.is_ok()); + + let paras = &doc.document.sections[0].paragraphs; + let table_para = paras.iter().find(|p| !p.controls.is_empty()); + assert!(table_para.is_some(), "Table Control 문단이 있어야 함"); + + if let Control::Table(ref tbl) = table_para.unwrap().controls[0] { + assert_eq!(tbl.row_count, 3, "행 수 3"); + assert_eq!(tbl.col_count, 3, "열 수 3"); + + // colspan=2인 셀 확인 + let merged_col = tbl.cells.iter().find(|c| c.col_span == 2); + assert!(merged_col.is_some(), "colspan=2 셀이 있어야 함"); + assert_eq!(merged_col.unwrap().row, 0); + + // rowspan=2인 셀 확인 + let merged_row = tbl.cells.iter().find(|c| c.row_span == 2); + assert!(merged_row.is_some(), "rowspan=2 셀이 있어야 함"); + assert_eq!(merged_row.unwrap().col, 0); + assert_eq!(merged_row.unwrap().row, 1); + } else { + panic!("Table Control이어야 함"); + } +} + +#[test] +fn test_paste_html_table_with_css_styles() { + let mut doc = HwpDocument::create_empty(); + let mut document = Document::default(); + document + .doc_info + .char_shapes + .push(crate::model::style::CharShape::default()); + document + .doc_info + .para_shapes + .push(crate::model::style::ParaShape::default()); + document + .doc_info + .border_fills + .push(crate::model::style::BorderFill::default()); + let mut para = Paragraph::default(); + para.text = "".to_string(); + para.char_count = 0; + para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; + para.has_para_text = true; + document.sections.push(Section { + paragraphs: vec![para], + ..Default::default() + }); + doc.set_document(document); + + // CSS 스타일 포함 표 + let html = r#" @@ -1965,272 +2452,338 @@
데이터1
"#; - let result = doc.paste_html_native(0, 0, 0, html); - assert!(result.is_ok()); + let result = doc.paste_html_native(0, 0, 0, html); + assert!(result.is_ok()); - let paras = &doc.document.sections[0].paragraphs; - let table_para = paras.iter().find(|p| !p.controls.is_empty()); - assert!(table_para.is_some()); + let paras = &doc.document.sections[0].paragraphs; + let table_para = paras.iter().find(|p| !p.controls.is_empty()); + assert!(table_para.is_some()); - if let Control::Table(ref tbl) = table_para.unwrap().controls[0] { - assert_eq!(tbl.row_count, 1); - assert_eq!(tbl.col_count, 2); - assert_eq!(tbl.cells.len(), 2); + if let Control::Table(ref tbl) = table_para.unwrap().controls[0] { + assert_eq!(tbl.row_count, 1); + assert_eq!(tbl.col_count, 2); + assert_eq!(tbl.cells.len(), 2); - // 첫 번째 셀: width=38.50pt → 3850 HWPUNIT - let cell0 = &tbl.cells[0]; - assert!(cell0.width > 3800 && cell0.width < 3900, - "셀 폭 ~3850, 실제: {}", cell0.width); + // 첫 번째 셀: width=38.50pt → 3850 HWPUNIT + let cell0 = &tbl.cells[0]; + assert!( + cell0.width > 3800 && cell0.width < 3900, + "셀 폭 ~3850, 실제: {}", + cell0.width + ); - // 두 번째 셀: background-color → BorderFill에 등록 - let cell1 = &tbl.cells[1]; - assert!(cell1.border_fill_id > 0, "border_fill_id가 설정되어야 함"); + // 두 번째 셀: background-color → BorderFill에 등록 + let cell1 = &tbl.cells[1]; + assert!(cell1.border_fill_id > 0, "border_fill_id가 설정되어야 함"); - // 패딩 확인 (1.41pt ≈ 141, 5.10pt ≈ 510) - assert!(cell0.padding.top > 130 && cell0.padding.top < 150, - "상단 패딩 ~141, 실제: {}", cell0.padding.top); - assert!(cell0.padding.left > 500 && cell0.padding.left < 520, - "좌측 패딩 ~510, 실제: {}", cell0.padding.left); - } else { - panic!("Table Control이어야 함"); - } - } - - #[test] - fn test_paste_html_table_with_th_header() { - let mut doc = HwpDocument::create_empty(); - let mut document = Document::default(); - document.doc_info.char_shapes.push(crate::model::style::CharShape::default()); - document.doc_info.para_shapes.push(crate::model::style::ParaShape::default()); - document.doc_info.border_fills.push(crate::model::style::BorderFill::default()); - let mut para = Paragraph::default(); - para.text = "".to_string(); - para.char_count = 0; - para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; - para.has_para_text = true; - document.sections.push(Section { paragraphs: vec![para], ..Default::default() }); - doc.set_document(document); - - // 헤더 포함 표 - let html = r#" + // 패딩 확인 (1.41pt ≈ 141, 5.10pt ≈ 510) + assert!( + cell0.padding.top > 130 && cell0.padding.top < 150, + "상단 패딩 ~141, 실제: {}", + cell0.padding.top + ); + assert!( + cell0.padding.left > 500 && cell0.padding.left < 520, + "좌측 패딩 ~510, 실제: {}", + cell0.padding.left + ); + } else { + panic!("Table Control이어야 함"); + } +} + +#[test] +fn test_paste_html_table_with_th_header() { + let mut doc = HwpDocument::create_empty(); + let mut document = Document::default(); + document + .doc_info + .char_shapes + .push(crate::model::style::CharShape::default()); + document + .doc_info + .para_shapes + .push(crate::model::style::ParaShape::default()); + document + .doc_info + .border_fills + .push(crate::model::style::BorderFill::default()); + let mut para = Paragraph::default(); + para.text = "".to_string(); + para.char_count = 0; + para.line_segs = vec![crate::model::paragraph::LineSeg::default()]; + para.has_para_text = true; + document.sections.push(Section { + paragraphs: vec![para], + ..Default::default() + }); + doc.set_document(document); + + // 헤더 포함 표 + let html = r#"
이름나이
홍길동30
"#; - let result = doc.paste_html_native(0, 0, 0, html); - assert!(result.is_ok()); - - let paras = &doc.document.sections[0].paragraphs; - let table_para = paras.iter().find(|p| !p.controls.is_empty()); - assert!(table_para.is_some()); - - if let Control::Table(ref tbl) = table_para.unwrap().controls[0] { - assert_eq!(tbl.row_count, 2); - assert_eq!(tbl.col_count, 2); - assert!(tbl.repeat_header, "헤더 반복 활성화"); - - // 첫 행 셀이 is_header=true - let header_cells: Vec<_> = tbl.cells.iter().filter(|c| c.is_header).collect(); - assert_eq!(header_cells.len(), 2, "헤더 셀 2개"); - } else { - panic!("Table Control이어야 함"); - } - } - - #[test] - fn test_table_utility_functions() { - // parse_css_dimension_pt - assert!((super::parse_css_dimension_pt("width:38.50pt", "width") - 38.5).abs() < 0.01); - assert!((super::parse_css_dimension_pt("width:100px", "width") - 75.0).abs() < 0.01); - assert!((super::parse_css_dimension_pt("height:1cm", "height") - 28.3465).abs() < 0.1); - assert_eq!(super::parse_css_dimension_pt("width:auto", "width"), 0.0); - - // parse_css_padding_pt - let p = super::parse_css_padding_pt("padding:1.41pt 5.10pt"); - assert!((p[0] - 5.10).abs() < 0.01, "left = 5.10"); // left - assert!((p[1] - 5.10).abs() < 0.01, "right = 5.10"); // right - assert!((p[2] - 1.41).abs() < 0.01, "top = 1.41"); // top - assert!((p[3] - 1.41).abs() < 0.01, "bottom = 1.41"); // bottom - - // parse_css_border_shorthand - let (w, c, s) = super::parse_css_border_shorthand("solid #000000 0.28pt"); - assert!((w - 0.28).abs() < 0.01, "border width 0.28pt"); - assert_eq!(c, 0x000000, "border color black"); - assert_eq!(s, 1, "border style solid"); - - let (w2, _, s2) = super::parse_css_border_shorthand("none"); - assert_eq!(w2, 0.0); - assert_eq!(s2, 0); - - // css_border_width_to_hwp - assert_eq!(super::css_border_width_to_hwp(0.28), 0); // 0.28pt ≈ 0.1mm → index 0 - assert!(super::css_border_width_to_hwp(1.0) >= 5); // 1.0pt ≈ 0.35mm → index 5+ - - // parse_html_attr_u16 - assert_eq!(super::parse_html_attr_u16(r#""#, "colspan"), Some(3)); - assert_eq!(super::parse_html_attr_u16(r#""#, "colspan"), None); - } - - #[test] - fn test_html_utility_functions() { - // decode_html_entities - assert_eq!(super::decode_html_entities("&<>"), "&<>"); - assert_eq!(super::decode_html_entities(" "), " "); - - // html_strip_tags - assert_eq!(super::html_strip_tags("bold"), "bold"); - assert_eq!(super::html_strip_tags("

text
more

"), "textmore"); - - // html_to_plain_text - assert_eq!(super::html_to_plain_text("

hello & world

"), "hello & world"); + let result = doc.paste_html_native(0, 0, 0, html); + assert!(result.is_ok()); - // parse_inline_style - assert_eq!( - super::parse_inline_style(r#"

"#), - "text-align:center;font-size:12pt;" - ); - - // parse_css_value - assert_eq!( - super::parse_css_value("text-align:center;font-size:12pt;", "text-align"), - Some("center".to_string()) - ); - assert_eq!( - super::parse_css_value("font-size:12pt;", "font-size"), - Some("12pt".to_string()) - ); + let paras = &doc.document.sections[0].paragraphs; + let table_para = paras.iter().find(|p| !p.controls.is_empty()); + assert!(table_para.is_some()); - // parse_pt_value - assert_eq!(super::parse_pt_value("10.0pt"), Some(10.0)); - assert_eq!(super::parse_pt_value("12px"), Some(9.0)); // 12 * 0.75 + if let Control::Table(ref tbl) = table_para.unwrap().controls[0] { + assert_eq!(tbl.row_count, 2); + assert_eq!(tbl.col_count, 2); + assert!(tbl.repeat_header, "헤더 반복 활성화"); - // css_color_to_hwp_bgr - assert_eq!(super::css_color_to_hwp_bgr("#ff0000"), Some(0x0000FF)); // red → BGR - assert_eq!(super::css_color_to_hwp_bgr("#00ff00"), Some(0x00FF00)); // green - assert_eq!(super::css_color_to_hwp_bgr("rgb(255, 0, 0)"), Some(0x0000FF)); + // 첫 행 셀이 is_header=true + let header_cells: Vec<_> = tbl.cells.iter().filter(|c| c.is_header).collect(); + assert_eq!(header_cells.len(), 2, "헤더 셀 2개"); + } else { + panic!("Table Control이어야 함"); } +} + +#[test] +fn test_table_utility_functions() { + // parse_css_dimension_pt + assert!((super::parse_css_dimension_pt("width:38.50pt", "width") - 38.5).abs() < 0.01); + assert!((super::parse_css_dimension_pt("width:100px", "width") - 75.0).abs() < 0.01); + assert!((super::parse_css_dimension_pt("height:1cm", "height") - 28.3465).abs() < 0.1); + assert_eq!(super::parse_css_dimension_pt("width:auto", "width"), 0.0); + + // parse_css_padding_pt + let p = super::parse_css_padding_pt("padding:1.41pt 5.10pt"); + assert!((p[0] - 5.10).abs() < 0.01, "left = 5.10"); // left + assert!((p[1] - 5.10).abs() < 0.01, "right = 5.10"); // right + assert!((p[2] - 1.41).abs() < 0.01, "top = 1.41"); // top + assert!((p[3] - 1.41).abs() < 0.01, "bottom = 1.41"); // bottom + + // parse_css_border_shorthand + let (w, c, s) = super::parse_css_border_shorthand("solid #000000 0.28pt"); + assert!((w - 0.28).abs() < 0.01, "border width 0.28pt"); + assert_eq!(c, 0x000000, "border color black"); + assert_eq!(s, 1, "border style solid"); + + let (w2, _, s2) = super::parse_css_border_shorthand("none"); + assert_eq!(w2, 0.0); + assert_eq!(s2, 0); + + // css_border_width_to_hwp + assert_eq!(super::css_border_width_to_hwp(0.28), 0); // 0.28pt ≈ 0.1mm → index 0 + assert!(super::css_border_width_to_hwp(1.0) >= 5); // 1.0pt ≈ 0.35mm → index 5+ + + // parse_html_attr_u16 + assert_eq!( + super::parse_html_attr_u16(r#""#, "colspan"), + Some(3) + ); + assert_eq!(super::parse_html_attr_u16(r#""#, "colspan"), None); +} + +#[test] +fn test_html_utility_functions() { + // decode_html_entities + assert_eq!(super::decode_html_entities("&<>"), "&<>"); + assert_eq!(super::decode_html_entities(" "), " "); + + // html_strip_tags + assert_eq!(super::html_strip_tags("bold"), "bold"); + assert_eq!(super::html_strip_tags("

text
more

"), "textmore"); + + // html_to_plain_text + assert_eq!( + super::html_to_plain_text("

hello & world

"), + "hello & world" + ); + + // parse_inline_style + assert_eq!( + super::parse_inline_style(r#"

"#), + "text-align:center;font-size:12pt;" + ); + + // parse_css_value + assert_eq!( + super::parse_css_value("text-align:center;font-size:12pt;", "text-align"), + Some("center".to_string()) + ); + assert_eq!( + super::parse_css_value("font-size:12pt;", "font-size"), + Some("12pt".to_string()) + ); + + // parse_pt_value + assert_eq!(super::parse_pt_value("10.0pt"), Some(10.0)); + assert_eq!(super::parse_pt_value("12px"), Some(9.0)); // 12 * 0.75 + + // css_color_to_hwp_bgr + assert_eq!(super::css_color_to_hwp_bgr("#ff0000"), Some(0x0000FF)); // red → BGR + assert_eq!(super::css_color_to_hwp_bgr("#00ff00"), Some(0x00FF00)); // green + assert_eq!( + super::css_color_to_hwp_bgr("rgb(255, 0, 0)"), + Some(0x0000FF) + ); +} + +/// 우리 편집기 저장 파일의 직렬화→재파싱 라운드트립 검증 +#[test] +fn test_roundtrip_saved_file() { + use crate::model::control::Control; + use crate::parser::body_text::parse_body_text_section; + use crate::serializer::body_text::serialize_section; + + let path = "/app/pasts/20250130-hongbo_saved-past-005.hwp"; + let data = match std::fs::read(path) { + Ok(d) => d, + Err(_) => { + eprintln!("File not found: {}", path); + return; + } + }; + let doc = HwpDocument::from_bytes(&data).unwrap(); + for (si, section) in doc.document.sections.iter().enumerate() { + eprintln!("\n=== Section {} ===", si); + eprintln!(" Total paragraphs: {}", section.paragraphs.len()); - /// 우리 편집기 저장 파일의 직렬화→재파싱 라운드트립 검증 - #[test] - fn test_roundtrip_saved_file() { - use crate::model::control::Control; - use crate::serializer::body_text::serialize_section; - use crate::parser::body_text::parse_body_text_section; - - let path = "/app/pasts/20250130-hongbo_saved-past-005.hwp"; - let data = match std::fs::read(path) { - Ok(d) => d, - Err(_) => { eprintln!("File not found: {}", path); return; } - }; - let doc = HwpDocument::from_bytes(&data).unwrap(); - - for (si, section) in doc.document.sections.iter().enumerate() { - eprintln!("\n=== Section {} ===", si); - eprintln!(" Total paragraphs: {}", section.paragraphs.len()); - - // 각 문단의 기본 정보 출력 - for (pi, para) in section.paragraphs.iter().enumerate() { - let ctrl_types: Vec = para.controls.iter().map(|c| match c { + // 각 문단의 기본 정보 출력 + for (pi, para) in section.paragraphs.iter().enumerate() { + let ctrl_types: Vec = para + .controls + .iter() + .map(|c| match c { Control::Table(t) => format!("Table({}x{})", t.row_count, t.col_count), Control::Picture(_) => "Picture".to_string(), Control::Shape(_) => "Shape".to_string(), Control::SectionDef(_) => "SectionDef".to_string(), Control::ColumnDef(_) => "ColumnDef".to_string(), _ => "Other".to_string(), - }).collect(); - if !para.controls.is_empty() || para.text.is_empty() { - eprintln!(" para[{}]: text={:?} chars={} ctrl_mask=0x{:08X} controls={:?} char_count={} msb={}", + }) + .collect(); + if !para.controls.is_empty() || para.text.is_empty() { + eprintln!(" para[{}]: text={:?} chars={} ctrl_mask=0x{:08X} controls={:?} char_count={} msb={}", pi, ¶.text.chars().take(40).collect::(), para.text.len(), para.control_mask, ctrl_types, para.char_count, para.char_count_msb); - } } + } - // 직렬화 → 재파싱 - let serialized = serialize_section(section); - eprintln!("\n Serialized section {} = {} bytes", si, serialized.len()); - - match parse_body_text_section(&serialized) { - Ok(reparsed) => { - eprintln!(" Re-parsed: {} paragraphs", reparsed.paragraphs.len()); + // 직렬화 → 재파싱 + let serialized = serialize_section(section); + eprintln!("\n Serialized section {} = {} bytes", si, serialized.len()); - if reparsed.paragraphs.len() != section.paragraphs.len() { - eprintln!(" *** MISMATCH: original {} vs reparsed {} paragraphs ***", - section.paragraphs.len(), reparsed.paragraphs.len()); - } + match parse_body_text_section(&serialized) { + Ok(reparsed) => { + eprintln!(" Re-parsed: {} paragraphs", reparsed.paragraphs.len()); - // 각 문단 비교 - for pi in 0..section.paragraphs.len().min(reparsed.paragraphs.len()) { - let orig = §ion.paragraphs[pi]; - let repr = &reparsed.paragraphs[pi]; + if reparsed.paragraphs.len() != section.paragraphs.len() { + eprintln!( + " *** MISMATCH: original {} vs reparsed {} paragraphs ***", + section.paragraphs.len(), + reparsed.paragraphs.len() + ); + } - let mut diffs = Vec::new(); - if orig.char_count != repr.char_count { - diffs.push(format!("char_count: {}→{}", orig.char_count, repr.char_count)); - } - if orig.control_mask != repr.control_mask { - diffs.push(format!("control_mask: 0x{:08X}→0x{:08X}", orig.control_mask, repr.control_mask)); - } - if orig.controls.len() != repr.controls.len() { - diffs.push(format!("controls.len: {}→{}", orig.controls.len(), repr.controls.len())); - } - if orig.text != repr.text { - diffs.push(format!("text differs")); - } + // 각 문단 비교 + for pi in 0..section.paragraphs.len().min(reparsed.paragraphs.len()) { + let orig = §ion.paragraphs[pi]; + let repr = &reparsed.paragraphs[pi]; - if !diffs.is_empty() { - eprintln!(" *** para[{}] DIFFS: {} ***", pi, diffs.join(", ")); - } + let mut diffs = Vec::new(); + if orig.char_count != repr.char_count { + diffs.push(format!( + "char_count: {}→{}", + orig.char_count, repr.char_count + )); + } + if orig.control_mask != repr.control_mask { + diffs.push(format!( + "control_mask: 0x{:08X}→0x{:08X}", + orig.control_mask, repr.control_mask + )); + } + if orig.controls.len() != repr.controls.len() { + diffs.push(format!( + "controls.len: {}→{}", + orig.controls.len(), + repr.controls.len() + )); + } + if orig.text != repr.text { + diffs.push(format!("text differs")); + } + + if !diffs.is_empty() { + eprintln!(" *** para[{}] DIFFS: {} ***", pi, diffs.join(", ")); } - } - Err(e) => { - eprintln!(" *** RE-PARSE FAILED: {} ***", e); } } + Err(e) => { + eprintln!(" *** RE-PARSE FAILED: {} ***", e); + } } + } - // DocInfo 라운드트립 검증 - eprintln!("\n=== DocInfo Check ==="); - eprintln!(" raw_stream present: {}", doc.document.doc_info.raw_stream.is_some()); - eprintln!(" char_shapes count: {}", doc.document.doc_info.char_shapes.len()); - eprintln!(" para_shapes count: {}", doc.document.doc_info.para_shapes.len()); - eprintln!(" border_fills count: {}", doc.document.doc_info.border_fills.len()); - - // 모든 셀 문단의 para_shape_id/char_shape_id 범위 검증 - let max_ps = doc.document.doc_info.para_shapes.len(); - let max_cs = doc.document.doc_info.char_shapes.len(); - let max_bf = doc.document.doc_info.border_fills.len(); - for (si, section) in doc.document.sections.iter().enumerate() { - for (pi, para) in section.paragraphs.iter().enumerate() { - if para.para_shape_id as usize >= max_ps { - eprintln!(" *** INVALID para[{}] para_shape_id={} >= max {} ***", pi, para.para_shape_id, max_ps); - } - for cs in ¶.char_shapes { - if cs.char_shape_id as usize >= max_cs { - eprintln!(" *** INVALID para[{}] char_shape_id={} >= max {} ***", pi, cs.char_shape_id, max_cs); - } + // DocInfo 라운드트립 검증 + eprintln!("\n=== DocInfo Check ==="); + eprintln!( + " raw_stream present: {}", + doc.document.doc_info.raw_stream.is_some() + ); + eprintln!( + " char_shapes count: {}", + doc.document.doc_info.char_shapes.len() + ); + eprintln!( + " para_shapes count: {}", + doc.document.doc_info.para_shapes.len() + ); + eprintln!( + " border_fills count: {}", + doc.document.doc_info.border_fills.len() + ); + + // 모든 셀 문단의 para_shape_id/char_shape_id 범위 검증 + let max_ps = doc.document.doc_info.para_shapes.len(); + let max_cs = doc.document.doc_info.char_shapes.len(); + let max_bf = doc.document.doc_info.border_fills.len(); + for (si, section) in doc.document.sections.iter().enumerate() { + for (pi, para) in section.paragraphs.iter().enumerate() { + if para.para_shape_id as usize >= max_ps { + eprintln!( + " *** INVALID para[{}] para_shape_id={} >= max {} ***", + pi, para.para_shape_id, max_ps + ); + } + for cs in ¶.char_shapes { + if cs.char_shape_id as usize >= max_cs { + eprintln!( + " *** INVALID para[{}] char_shape_id={} >= max {} ***", + pi, cs.char_shape_id, max_cs + ); } - // 셀 문단도 검사 - for ctrl in ¶.controls { - if let Control::Table(tbl) = ctrl { - for (ci, cell) in tbl.cells.iter().enumerate() { - if cell.border_fill_id as usize > max_bf { - eprintln!(" *** INVALID table para[{}] cell[{}] border_fill_id={} > max {} ***", + } + // 셀 문단도 검사 + for ctrl in ¶.controls { + if let Control::Table(tbl) = ctrl { + for (ci, cell) in tbl.cells.iter().enumerate() { + if cell.border_fill_id as usize > max_bf { + eprintln!(" *** INVALID table para[{}] cell[{}] border_fill_id={} > max {} ***", pi, ci, cell.border_fill_id, max_bf); - } - for (cpi, cp) in cell.paragraphs.iter().enumerate() { - if cp.para_shape_id as usize >= max_ps { - eprintln!(" *** INVALID table para[{}] cell[{}] cp[{}] para_shape_id={} >= max {} ***", + } + for (cpi, cp) in cell.paragraphs.iter().enumerate() { + if cp.para_shape_id as usize >= max_ps { + eprintln!(" *** INVALID table para[{}] cell[{}] cp[{}] para_shape_id={} >= max {} ***", pi, ci, cpi, cp.para_shape_id, max_ps); - } - for cs in &cp.char_shapes { - if cs.char_shape_id as usize >= max_cs { - eprintln!(" *** INVALID table para[{}] cell[{}] cp[{}] char_shape_id={} >= max {} ***", + } + for cs in &cp.char_shapes { + if cs.char_shape_id as usize >= max_cs { + eprintln!(" *** INVALID table para[{}] cell[{}] cp[{}] char_shape_id={} >= max {} ***", pi, ci, cpi, cs.char_shape_id, max_cs); - } } } } @@ -2238,2316 +2791,3288 @@ } } } + } - // DocInfo 직렬화→재파싱 라운드트립 - let serialized_di = crate::serializer::doc_info::serialize_doc_info( - &doc.document.doc_info, &doc.document.doc_properties); - eprintln!(" Serialized DocInfo = {} bytes", serialized_di.len()); - match crate::parser::doc_info::parse_doc_info(&serialized_di) { - Ok((reparsed_di, _)) => { - eprintln!(" Re-parsed DocInfo: char_shapes={} para_shapes={} border_fills={}", - reparsed_di.char_shapes.len(), reparsed_di.para_shapes.len(), reparsed_di.border_fills.len()); - if reparsed_di.char_shapes.len() != doc.document.doc_info.char_shapes.len() { - eprintln!(" *** CHAR_SHAPES MISMATCH: {} vs {} ***", - doc.document.doc_info.char_shapes.len(), reparsed_di.char_shapes.len()); - } - if reparsed_di.para_shapes.len() != doc.document.doc_info.para_shapes.len() { - eprintln!(" *** PARA_SHAPES MISMATCH: {} vs {} ***", - doc.document.doc_info.para_shapes.len(), reparsed_di.para_shapes.len()); - } - if reparsed_di.border_fills.len() != doc.document.doc_info.border_fills.len() { - eprintln!(" *** BORDER_FILLS MISMATCH: {} vs {} ***", - doc.document.doc_info.border_fills.len(), reparsed_di.border_fills.len()); - } + // DocInfo 직렬화→재파싱 라운드트립 + let serialized_di = crate::serializer::doc_info::serialize_doc_info( + &doc.document.doc_info, + &doc.document.doc_properties, + ); + eprintln!(" Serialized DocInfo = {} bytes", serialized_di.len()); + match crate::parser::doc_info::parse_doc_info(&serialized_di) { + Ok((reparsed_di, _)) => { + eprintln!( + " Re-parsed DocInfo: char_shapes={} para_shapes={} border_fills={}", + reparsed_di.char_shapes.len(), + reparsed_di.para_shapes.len(), + reparsed_di.border_fills.len() + ); + if reparsed_di.char_shapes.len() != doc.document.doc_info.char_shapes.len() { + eprintln!( + " *** CHAR_SHAPES MISMATCH: {} vs {} ***", + doc.document.doc_info.char_shapes.len(), + reparsed_di.char_shapes.len() + ); } - Err(e) => { - eprintln!(" *** DocInfo RE-PARSE FAILED: {} ***", e); + if reparsed_di.para_shapes.len() != doc.document.doc_info.para_shapes.len() { + eprintln!( + " *** PARA_SHAPES MISMATCH: {} vs {} ***", + doc.document.doc_info.para_shapes.len(), + reparsed_di.para_shapes.len() + ); + } + if reparsed_di.border_fills.len() != doc.document.doc_info.border_fills.len() { + eprintln!( + " *** BORDER_FILLS MISMATCH: {} vs {} ***", + doc.document.doc_info.border_fills.len(), + reparsed_di.border_fills.len() + ); } } + Err(e) => { + eprintln!(" *** DocInfo RE-PARSE FAILED: {} ***", e); + } } +} - /// 정상 파일 vs 손상 파일: 바이너리 레코드 레벨 비교 - #[test] - fn test_binary_record_comparison() { - use crate::parser::record::Record; - use crate::parser::cfb_reader::CfbReader; - use crate::parser::tags; +/// 정상 파일 vs 손상 파일: 바이너리 레코드 레벨 비교 +#[test] +fn test_binary_record_comparison() { + use crate::parser::cfb_reader::CfbReader; + use crate::parser::record::Record; + use crate::parser::tags; - let files = [ - ("/app/pasts/20250130-hongbo-p2.hwp", "CORRECT"), - ("/app/pasts/20250130-hongbo_saved-past-006.hwp", "OURS-006"), - ]; + let files = [ + ("/app/pasts/20250130-hongbo-p2.hwp", "CORRECT"), + ("/app/pasts/20250130-hongbo_saved-past-006.hwp", "OURS-006"), + ]; - for (path, label) in &files { - let data = match std::fs::read(path) { - Ok(d) => d, - Err(_) => { eprintln!("File not found: {}", path); continue; } - }; + for (path, label) in &files { + let data = match std::fs::read(path) { + Ok(d) => d, + Err(_) => { + eprintln!("File not found: {}", path); + continue; + } + }; - eprintln!("\n{}", "=".repeat(100)); - eprintln!("=== {} : {} ===", label, path); - eprintln!("{}", "=".repeat(100)); + eprintln!("\n{}", "=".repeat(100)); + eprintln!("=== {} : {} ===", label, path); + eprintln!("{}", "=".repeat(100)); - let mut cfb = CfbReader::open(&data).unwrap(); - let section_data = cfb.read_body_text_section(0, true, false).unwrap(); - let records = Record::read_all(§ion_data).unwrap(); - eprintln!("Total records: {}", records.len()); + let mut cfb = CfbReader::open(&data).unwrap(); + let section_data = cfb.read_body_text_section(0, true, false).unwrap(); + let records = Record::read_all(§ion_data).unwrap(); + eprintln!("Total records: {}", records.len()); - let ctrl_table_id = tags::CTRL_TABLE; + let ctrl_table_id = tags::CTRL_TABLE; - for (ri, rec) in records.iter().enumerate() { - if rec.tag_id == tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - if ctrl_id == ctrl_table_id { - eprintln!("\n--- TABLE CTRL_HEADER record #{} (level={}, size={}) ---", ri, rec.level, rec.data.len()); - for cs in (0..rec.data.len()).step_by(16) { - let ce = (cs + 16).min(rec.data.len()); - let hex: Vec = rec.data[cs..ce].iter().map(|b| format!("{:02X}", b)).collect(); - eprintln!(" [{:04X}] {}", cs, hex.join(" ")); - } - if rec.data.len() >= 8 { - let table_attr = u32::from_le_bytes([rec.data[4], rec.data[5], rec.data[6], rec.data[7]]); - eprintln!(" table.attr = 0x{:08X}", table_attr); - } - let rcd = &rec.data[8..]; - if rcd.len() >= 36 { - let coa_attr = u32::from_le_bytes([rcd[0], rcd[1], rcd[2], rcd[3]]); - let width = u32::from_le_bytes([rcd[12], rcd[13], rcd[14], rcd[15]]); - let height = u32::from_le_bytes([rcd[16], rcd[17], rcd[18], rcd[19]]); - let z_order = i32::from_le_bytes([rcd[20], rcd[21], rcd[22], rcd[23]]); - let margin_l = i16::from_le_bytes([rcd[24], rcd[25]]); - let margin_r = i16::from_le_bytes([rcd[26], rcd[27]]); - let margin_t = i16::from_le_bytes([rcd[28], rcd[29]]); - let margin_b = i16::from_le_bytes([rcd[30], rcd[31]]); - let instance_id = u32::from_le_bytes([rcd[32], rcd[33], rcd[34], rcd[35]]); - eprintln!(" CommonObjAttr: attr=0x{:08X} w={} h={} z={}", coa_attr, width, height, z_order); - eprintln!(" margins=L:{} R:{} T:{} B:{}", margin_l, margin_r, margin_t, margin_b); - eprintln!(" instance_id={} (0x{:08X})", instance_id, instance_id); - if rcd.len() > 36 { - let desc_len = u16::from_le_bytes([rcd[36], rcd[37]]); - eprintln!(" desc_len={}, remaining={} bytes", desc_len, rcd.len().saturating_sub(38)); - } + for (ri, rec) in records.iter().enumerate() { + if rec.tag_id == tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { + let ctrl_id = + u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); + if ctrl_id == ctrl_table_id { + eprintln!( + "\n--- TABLE CTRL_HEADER record #{} (level={}, size={}) ---", + ri, + rec.level, + rec.data.len() + ); + for cs in (0..rec.data.len()).step_by(16) { + let ce = (cs + 16).min(rec.data.len()); + let hex: Vec = rec.data[cs..ce] + .iter() + .map(|b| format!("{:02X}", b)) + .collect(); + eprintln!(" [{:04X}] {}", cs, hex.join(" ")); + } + if rec.data.len() >= 8 { + let table_attr = u32::from_le_bytes([ + rec.data[4], + rec.data[5], + rec.data[6], + rec.data[7], + ]); + eprintln!(" table.attr = 0x{:08X}", table_attr); + } + let rcd = &rec.data[8..]; + if rcd.len() >= 36 { + let coa_attr = u32::from_le_bytes([rcd[0], rcd[1], rcd[2], rcd[3]]); + let width = u32::from_le_bytes([rcd[12], rcd[13], rcd[14], rcd[15]]); + let height = u32::from_le_bytes([rcd[16], rcd[17], rcd[18], rcd[19]]); + let z_order = i32::from_le_bytes([rcd[20], rcd[21], rcd[22], rcd[23]]); + let margin_l = i16::from_le_bytes([rcd[24], rcd[25]]); + let margin_r = i16::from_le_bytes([rcd[26], rcd[27]]); + let margin_t = i16::from_le_bytes([rcd[28], rcd[29]]); + let margin_b = i16::from_le_bytes([rcd[30], rcd[31]]); + let instance_id = u32::from_le_bytes([rcd[32], rcd[33], rcd[34], rcd[35]]); + eprintln!( + " CommonObjAttr: attr=0x{:08X} w={} h={} z={}", + coa_attr, width, height, z_order + ); + eprintln!( + " margins=L:{} R:{} T:{} B:{}", + margin_l, margin_r, margin_t, margin_b + ); + eprintln!(" instance_id={} (0x{:08X})", instance_id, instance_id); + if rcd.len() > 36 { + let desc_len = u16::from_le_bytes([rcd[36], rcd[37]]); + eprintln!( + " desc_len={}, remaining={} bytes", + desc_len, + rcd.len().saturating_sub(38) + ); } + } - let tbl_level = rec.level; - let mut nr = ri + 1; - let mut table_rec_shown = false; - let mut cell_count = 0; - while nr < records.len() && records[nr].level > tbl_level { - let sub = &records[nr]; - if sub.tag_id == tags::HWPTAG_TABLE && !table_rec_shown { - eprintln!("\n HWPTAG_TABLE record #{} (level={}, size={}):", nr, sub.level, sub.data.len()); - for cs in (0..sub.data.len()).step_by(16) { - let ce = (cs + 16).min(sub.data.len()); - let hex: Vec = sub.data[cs..ce].iter().map(|b| format!("{:02X}", b)).collect(); - eprintln!(" [{:04X}] {}", cs, hex.join(" ")); - } - if sub.data.len() >= 18 { - let tbl_attr = u32::from_le_bytes([sub.data[0], sub.data[1], sub.data[2], sub.data[3]]); - let row_cnt = u16::from_le_bytes([sub.data[4], sub.data[5]]); - let col_cnt = u16::from_le_bytes([sub.data[6], sub.data[7]]); - let pad_l = i16::from_le_bytes([sub.data[10], sub.data[11]]); - let pad_r = i16::from_le_bytes([sub.data[12], sub.data[13]]); - let pad_t = i16::from_le_bytes([sub.data[14], sub.data[15]]); - let pad_b = i16::from_le_bytes([sub.data[16], sub.data[17]]); - eprintln!(" attr=0x{:08X} rows={} cols={}", tbl_attr, row_cnt, col_cnt); - eprintln!(" padding=L:{} R:{} T:{} B:{}", pad_l, pad_r, pad_t, pad_b); - let mut off = 18usize; - for _ in 0..row_cnt { off += 2; } - if off + 2 <= sub.data.len() { - let bf_id = u16::from_le_bytes([sub.data[off], sub.data[off+1]]); - eprintln!(" border_fill_id={}", bf_id); - off += 2; - } - if off < sub.data.len() { - let extra: Vec = sub.data[off..].iter().map(|b| format!("{:02X}", b)).collect(); - eprintln!(" extra={}", extra.join(" ")); - } - } - table_rec_shown = true; + let tbl_level = rec.level; + let mut nr = ri + 1; + let mut table_rec_shown = false; + let mut cell_count = 0; + while nr < records.len() && records[nr].level > tbl_level { + let sub = &records[nr]; + if sub.tag_id == tags::HWPTAG_TABLE && !table_rec_shown { + eprintln!( + "\n HWPTAG_TABLE record #{} (level={}, size={}):", + nr, + sub.level, + sub.data.len() + ); + for cs in (0..sub.data.len()).step_by(16) { + let ce = (cs + 16).min(sub.data.len()); + let hex: Vec = sub.data[cs..ce] + .iter() + .map(|b| format!("{:02X}", b)) + .collect(); + eprintln!(" [{:04X}] {}", cs, hex.join(" ")); } - if sub.tag_id == tags::HWPTAG_LIST_HEADER && cell_count < 2 { - cell_count += 1; - eprintln!("\n LIST_HEADER cell #{} record #{} (level={}, size={}):", cell_count, nr, sub.level, sub.data.len()); - for cs in (0..sub.data.len()).step_by(16) { - let ce = (cs + 16).min(sub.data.len()); - let hex: Vec = sub.data[cs..ce].iter().map(|b| format!("{:02X}", b)).collect(); - eprintln!(" [{:04X}] {}", cs, hex.join(" ")); + if sub.data.len() >= 18 { + let tbl_attr = u32::from_le_bytes([ + sub.data[0], + sub.data[1], + sub.data[2], + sub.data[3], + ]); + let row_cnt = u16::from_le_bytes([sub.data[4], sub.data[5]]); + let col_cnt = u16::from_le_bytes([sub.data[6], sub.data[7]]); + let pad_l = i16::from_le_bytes([sub.data[10], sub.data[11]]); + let pad_r = i16::from_le_bytes([sub.data[12], sub.data[13]]); + let pad_t = i16::from_le_bytes([sub.data[14], sub.data[15]]); + let pad_b = i16::from_le_bytes([sub.data[16], sub.data[17]]); + eprintln!( + " attr=0x{:08X} rows={} cols={}", + tbl_attr, row_cnt, col_cnt + ); + eprintln!( + " padding=L:{} R:{} T:{} B:{}", + pad_l, pad_r, pad_t, pad_b + ); + let mut off = 18usize; + for _ in 0..row_cnt { + off += 2; } - if sub.data.len() >= 34 { - let n_p = u16::from_le_bytes([sub.data[0], sub.data[1]]); - let la = u32::from_le_bytes([sub.data[2], sub.data[3], sub.data[4], sub.data[5]]); - let wr = u16::from_le_bytes([sub.data[6], sub.data[7]]); - let col = u16::from_le_bytes([sub.data[8], sub.data[9]]); - let row = u16::from_le_bytes([sub.data[10], sub.data[11]]); - let w = u32::from_le_bytes([sub.data[16], sub.data[17], sub.data[18], sub.data[19]]); - let h = u32::from_le_bytes([sub.data[20], sub.data[21], sub.data[22], sub.data[23]]); - eprintln!(" n_paras={} list_attr=0x{:08X} width_ref={}", n_p, la, wr); - eprintln!(" col={} row={} w={} h={}", col, row, w, h); - if sub.data.len() > 34 { - let extra: Vec = sub.data[34..].iter().map(|b| format!("{:02X}", b)).collect(); - eprintln!(" raw_list_extra ({} bytes) = {}", sub.data.len() - 34, extra.join(" ")); - } + if off + 2 <= sub.data.len() { + let bf_id = + u16::from_le_bytes([sub.data[off], sub.data[off + 1]]); + eprintln!(" border_fill_id={}", bf_id); + off += 2; } - } else if sub.tag_id == tags::HWPTAG_LIST_HEADER { - cell_count += 1; - } - if sub.tag_id == tags::HWPTAG_PARA_HEADER && cell_count <= 2 && cell_count > 0 { - eprintln!("\n Cell #{} PARA_HEADER record #{} (size={}):", cell_count, nr, sub.data.len()); - if sub.data.len() >= 22 { - let ccr = u32::from_le_bytes([sub.data[0], sub.data[1], sub.data[2], sub.data[3]]); - let cc = ccr & 0x7FFFFFFF; - let msb = (ccr & 0x80000000) != 0; - let cm = u32::from_le_bytes([sub.data[4], sub.data[5], sub.data[6], sub.data[7]]); - let ps = u16::from_le_bytes([sub.data[8], sub.data[9]]); - let inst = u32::from_le_bytes([sub.data[18], sub.data[19], sub.data[20], sub.data[21]]); - eprintln!(" cc={} msb={} cm=0x{:08X} ps={} inst={}", cc, msb, cm, ps, inst); + if off < sub.data.len() { + let extra: Vec = sub.data[off..] + .iter() + .map(|b| format!("{:02X}", b)) + .collect(); + eprintln!(" extra={}", extra.join(" ")); } - for cs in (0..sub.data.len()).step_by(16) { - let ce = (cs + 16).min(sub.data.len()); - let hex: Vec = sub.data[cs..ce].iter().map(|b| format!("{:02X}", b)).collect(); - eprintln!(" [{:04X}] {}", cs, hex.join(" ")); + } + table_rec_shown = true; + } + if sub.tag_id == tags::HWPTAG_LIST_HEADER && cell_count < 2 { + cell_count += 1; + eprintln!( + "\n LIST_HEADER cell #{} record #{} (level={}, size={}):", + cell_count, + nr, + sub.level, + sub.data.len() + ); + for cs in (0..sub.data.len()).step_by(16) { + let ce = (cs + 16).min(sub.data.len()); + let hex: Vec = sub.data[cs..ce] + .iter() + .map(|b| format!("{:02X}", b)) + .collect(); + eprintln!(" [{:04X}] {}", cs, hex.join(" ")); + } + if sub.data.len() >= 34 { + let n_p = u16::from_le_bytes([sub.data[0], sub.data[1]]); + let la = u32::from_le_bytes([ + sub.data[2], + sub.data[3], + sub.data[4], + sub.data[5], + ]); + let wr = u16::from_le_bytes([sub.data[6], sub.data[7]]); + let col = u16::from_le_bytes([sub.data[8], sub.data[9]]); + let row = u16::from_le_bytes([sub.data[10], sub.data[11]]); + let w = u32::from_le_bytes([ + sub.data[16], + sub.data[17], + sub.data[18], + sub.data[19], + ]); + let h = u32::from_le_bytes([ + sub.data[20], + sub.data[21], + sub.data[22], + sub.data[23], + ]); + eprintln!( + " n_paras={} list_attr=0x{:08X} width_ref={}", + n_p, la, wr + ); + eprintln!(" col={} row={} w={} h={}", col, row, w, h); + if sub.data.len() > 34 { + let extra: Vec = sub.data[34..] + .iter() + .map(|b| format!("{:02X}", b)) + .collect(); + eprintln!( + " raw_list_extra ({} bytes) = {}", + sub.data.len() - 34, + extra.join(" ") + ); } } - nr += 1; + } else if sub.tag_id == tags::HWPTAG_LIST_HEADER { + cell_count += 1; + } + if sub.tag_id == tags::HWPTAG_PARA_HEADER + && cell_count <= 2 + && cell_count > 0 + { + eprintln!( + "\n Cell #{} PARA_HEADER record #{} (size={}):", + cell_count, + nr, + sub.data.len() + ); + if sub.data.len() >= 22 { + let ccr = u32::from_le_bytes([ + sub.data[0], + sub.data[1], + sub.data[2], + sub.data[3], + ]); + let cc = ccr & 0x7FFFFFFF; + let msb = (ccr & 0x80000000) != 0; + let cm = u32::from_le_bytes([ + sub.data[4], + sub.data[5], + sub.data[6], + sub.data[7], + ]); + let ps = u16::from_le_bytes([sub.data[8], sub.data[9]]); + let inst = u32::from_le_bytes([ + sub.data[18], + sub.data[19], + sub.data[20], + sub.data[21], + ]); + eprintln!( + " cc={} msb={} cm=0x{:08X} ps={} inst={}", + cc, msb, cm, ps, inst + ); + } + for cs in (0..sub.data.len()).step_by(16) { + let ce = (cs + 16).min(sub.data.len()); + let hex: Vec = sub.data[cs..ce] + .iter() + .map(|b| format!("{:02X}", b)) + .collect(); + eprintln!(" [{:04X}] {}", cs, hex.join(" ")); + } } - eprintln!(" Total cells: {}", cell_count); + nr += 1; } + eprintln!(" Total cells: {}", cell_count); } + } - // 테이블 포함 문단의 PARA_HEADER (level 0) - if rec.tag_id == tags::HWPTAG_PARA_HEADER && rec.level == 0 { - let mut has_table = false; - let mut nk = ri + 1; - while nk < records.len() && records[nk].level > rec.level { - if records[nk].tag_id == tags::HWPTAG_CTRL_HEADER && records[nk].data.len() >= 4 { - let cid = u32::from_le_bytes([records[nk].data[0], records[nk].data[1], records[nk].data[2], records[nk].data[3]]); - if cid == ctrl_table_id { has_table = true; break; } - } - nk += 1; - } - if has_table { - eprintln!("\n--- TABLE's PARA_HEADER record #{} (level={}, size={}) ---", ri, rec.level, rec.data.len()); - for cs in (0..rec.data.len()).step_by(16) { - let ce = (cs + 16).min(rec.data.len()); - let hex: Vec = rec.data[cs..ce].iter().map(|b| format!("{:02X}", b)).collect(); - eprintln!(" [{:04X}] {}", cs, hex.join(" ")); - } - if rec.data.len() >= 22 { - let ccr = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - let cc = ccr & 0x7FFFFFFF; - let msb = (ccr & 0x80000000) != 0; - let cm = u32::from_le_bytes([rec.data[4], rec.data[5], rec.data[6], rec.data[7]]); - let ps = u16::from_le_bytes([rec.data[8], rec.data[9]]); - let inst = u32::from_le_bytes([rec.data[18], rec.data[19], rec.data[20], rec.data[21]]); - eprintln!(" cc={} msb={} cm=0x{:08X} ps_id={} inst={}", cc, msb, cm, ps, inst); + // 테이블 포함 문단의 PARA_HEADER (level 0) + if rec.tag_id == tags::HWPTAG_PARA_HEADER && rec.level == 0 { + let mut has_table = false; + let mut nk = ri + 1; + while nk < records.len() && records[nk].level > rec.level { + if records[nk].tag_id == tags::HWPTAG_CTRL_HEADER && records[nk].data.len() >= 4 + { + let cid = u32::from_le_bytes([ + records[nk].data[0], + records[nk].data[1], + records[nk].data[2], + records[nk].data[3], + ]); + if cid == ctrl_table_id { + has_table = true; + break; } } + nk += 1; } - } - - // 레코드 시퀀스 요약 (level 0,1) - eprintln!("\n--- RECORD SEQUENCE (level 0-1) ---"); - for (ri, rec) in records.iter().enumerate() { - if rec.level <= 1 { - let extra = if rec.tag_id == tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { - let cid = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - format!(" ctrl_id=0x{:08X}({})", cid, tags::ctrl_name(cid)) - } else { String::new() }; - eprintln!(" #{:4}: L{} {} size={}{}", ri, rec.level, rec.tag_name(), rec.data.len(), extra); + if has_table { + eprintln!( + "\n--- TABLE's PARA_HEADER record #{} (level={}, size={}) ---", + ri, + rec.level, + rec.data.len() + ); + for cs in (0..rec.data.len()).step_by(16) { + let ce = (cs + 16).min(rec.data.len()); + let hex: Vec = rec.data[cs..ce] + .iter() + .map(|b| format!("{:02X}", b)) + .collect(); + eprintln!(" [{:04X}] {}", cs, hex.join(" ")); + } + if rec.data.len() >= 22 { + let ccr = u32::from_le_bytes([ + rec.data[0], + rec.data[1], + rec.data[2], + rec.data[3], + ]); + let cc = ccr & 0x7FFFFFFF; + let msb = (ccr & 0x80000000) != 0; + let cm = u32::from_le_bytes([ + rec.data[4], + rec.data[5], + rec.data[6], + rec.data[7], + ]); + let ps = u16::from_le_bytes([rec.data[8], rec.data[9]]); + let inst = u32::from_le_bytes([ + rec.data[18], + rec.data[19], + rec.data[20], + rec.data[21], + ]); + eprintln!( + " cc={} msb={} cm=0x{:08X} ps_id={} inst={}", + cc, msb, cm, ps, inst + ); + } } } } - eprintln!("\n=== BINARY RECORD COMPARISON COMPLETE ==="); + // 레코드 시퀀스 요약 (level 0,1) + eprintln!("\n--- RECORD SEQUENCE (level 0-1) ---"); + for (ri, rec) in records.iter().enumerate() { + if rec.level <= 1 { + let extra = if rec.tag_id == tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { + let cid = + u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); + format!(" ctrl_id=0x{:08X}({})", cid, tags::ctrl_name(cid)) + } else { + String::new() + }; + eprintln!( + " #{:4}: L{} {} size={}{}", + ri, + rec.level, + rec.tag_name(), + rec.data.len(), + extra + ); + } + } } - /// p2 (표 1개 붙여넣기) vs p3 (표 2개 붙여넣기) DocInfo 비교 - #[test] - fn test_docinfo_comparison_p2_p3() { - use crate::parser::record::Record; - use crate::parser::cfb_reader::CfbReader; - use crate::parser::tags; + eprintln!("\n=== BINARY RECORD COMPARISON COMPLETE ==="); +} - let files = [ - ("/app/pasts/20250130-hongbo-p2.hwp", "P2 (1 table pasted)"), - ("/app/pasts/20250130-hongbo-p3.hwp", "P3 (2 tables pasted)"), - ("/app/pasts/20250130-hongbo_saved-past-006.hwp", "OURS-006"), - ]; +/// p2 (표 1개 붙여넣기) vs p3 (표 2개 붙여넣기) DocInfo 비교 +#[test] +fn test_docinfo_comparison_p2_p3() { + use crate::parser::cfb_reader::CfbReader; + use crate::parser::record::Record; + use crate::parser::tags; - // 각 파일의 DocInfo 레코드를 비교 - let mut all_info: Vec<(String, Vec<(u16, u16, u32, Vec)>)> = Vec::new(); + let files = [ + ("/app/pasts/20250130-hongbo-p2.hwp", "P2 (1 table pasted)"), + ("/app/pasts/20250130-hongbo-p3.hwp", "P3 (2 tables pasted)"), + ("/app/pasts/20250130-hongbo_saved-past-006.hwp", "OURS-006"), + ]; - for (path, label) in &files { - let data = match std::fs::read(path) { - Ok(d) => d, - Err(_) => { eprintln!("File not found: {}", path); continue; } - }; + // 각 파일의 DocInfo 레코드를 비교 + let mut all_info: Vec<(String, Vec<(u16, u16, u32, Vec)>)> = Vec::new(); - eprintln!("\n{}", "=".repeat(100)); - eprintln!("=== {} : {} ===", label, path); + for (path, label) in &files { + let data = match std::fs::read(path) { + Ok(d) => d, + Err(_) => { + eprintln!("File not found: {}", path); + continue; + } + }; - let doc = HwpDocument::from_bytes(&data).unwrap(); - let di = &doc.document.doc_info; + eprintln!("\n{}", "=".repeat(100)); + eprintln!("=== {} : {} ===", label, path); - eprintln!(" char_shapes: {}", di.char_shapes.len()); - eprintln!(" para_shapes: {}", di.para_shapes.len()); - eprintln!(" border_fills: {}", di.border_fills.len()); - eprintln!(" bin_data_list: {}", di.bin_data_list.len()); - eprintln!(" styles: {}", di.styles.len()); - eprintln!(" tab_defs: {}", di.tab_defs.len()); - eprintln!(" numberings: {}", di.numberings.len()); - eprintln!(" font_faces: {} groups", di.font_faces.len()); - for (fi, ff) in di.font_faces.iter().enumerate() { - if !ff.is_empty() { - eprintln!(" font_faces[{}]: {} fonts", fi, ff.len()); + let doc = HwpDocument::from_bytes(&data).unwrap(); + let di = &doc.document.doc_info; + + eprintln!(" char_shapes: {}", di.char_shapes.len()); + eprintln!(" para_shapes: {}", di.para_shapes.len()); + eprintln!(" border_fills: {}", di.border_fills.len()); + eprintln!(" bin_data_list: {}", di.bin_data_list.len()); + eprintln!(" styles: {}", di.styles.len()); + eprintln!(" tab_defs: {}", di.tab_defs.len()); + eprintln!(" numberings: {}", di.numberings.len()); + eprintln!(" font_faces: {} groups", di.font_faces.len()); + for (fi, ff) in di.font_faces.iter().enumerate() { + if !ff.is_empty() { + eprintln!(" font_faces[{}]: {} fonts", fi, ff.len()); + } + } + + // ID_MAPPINGS: DocInfo 레코드 레벨에서 직접 비교 + let mut cfb = CfbReader::open(&data).unwrap(); + let di_data = cfb.read_doc_info(true).unwrap(); + let records = Record::read_all(&di_data).unwrap(); + + eprintln!("\n DocInfo records: {}", records.len()); + + // ID_MAPPINGS 레코드 찾기 (HWPTAG_ID_MAPPINGS = HWPTAG_BEGIN + 2 = 18) + let id_mappings_tag = 16 + 2; // HWPTAG_BEGIN(16) + 2 + for rec in &records { + if rec.tag_id == id_mappings_tag { + eprintln!("\n ID_MAPPINGS record (size={}):", rec.data.len()); + let count = rec.data.len() / 4; + let labels = [ + "BinData", + "KorFont", + "EnFont", + "CnFont", + "JpFont", + "OtherFont", + "SymFont", + "UsrFont", + "BorderFill", + "CharShape", + "TabDef", + "Numbering", + "Bullet", + "ParaShape", + "Style", + "MemoShape", + "TrackChange", + "TrackChangeUser", + ]; + for i in 0..count.min(18) { + let off = i * 4; + if off + 4 <= rec.data.len() { + let val = u32::from_le_bytes([ + rec.data[off], + rec.data[off + 1], + rec.data[off + 2], + rec.data[off + 3], + ]); + let name = if i < labels.len() { labels[i] } else { "???" }; + eprintln!(" [{:2}] {:16} = {}", i, name, val); + } } } + } - // ID_MAPPINGS: DocInfo 레코드 레벨에서 직접 비교 - let mut cfb = CfbReader::open(&data).unwrap(); - let di_data = cfb.read_doc_info(true).unwrap(); - let records = Record::read_all(&di_data).unwrap(); + // DocInfo 레코드 시퀀스 요약 + let mut rec_summary: std::collections::HashMap = + std::collections::HashMap::new(); + for rec in &records { + let entry = rec_summary.entry(rec.tag_id).or_insert((0, 0)); + entry.0 += 1; + entry.1 += rec.data.len(); + } + let mut sorted: Vec<_> = rec_summary.iter().collect(); + sorted.sort_by_key(|(tid, _)| **tid); + eprintln!("\n DocInfo record types:"); + for (tid, (cnt, total_size)) in &sorted { + eprintln!( + " tag={:3} ({:20}) count={:4} total_bytes={}", + tid, + tags::tag_name(**tid), + cnt, + total_size + ); + } - eprintln!("\n DocInfo records: {}", records.len()); + // 레코드 리스트 저장 + let rec_list: Vec<_> = records + .iter() + .map(|r| (r.tag_id, r.level, r.size, r.data.clone())) + .collect(); + all_info.push((label.to_string(), rec_list)); + } - // ID_MAPPINGS 레코드 찾기 (HWPTAG_ID_MAPPINGS = HWPTAG_BEGIN + 2 = 18) - let id_mappings_tag = 16 + 2; // HWPTAG_BEGIN(16) + 2 - for rec in &records { - if rec.tag_id == id_mappings_tag { - eprintln!("\n ID_MAPPINGS record (size={}):", rec.data.len()); - let count = rec.data.len() / 4; - let labels = [ - "BinData", "KorFont", "EnFont", "CnFont", "JpFont", - "OtherFont", "SymFont", "UsrFont", "BorderFill", "CharShape", - "TabDef", "Numbering", "Bullet", "ParaShape", "Style", - "MemoShape", "TrackChange", "TrackChangeUser" - ]; - for i in 0..count.min(18) { - let off = i * 4; - if off + 4 <= rec.data.len() { - let val = u32::from_le_bytes([rec.data[off], rec.data[off+1], rec.data[off+2], rec.data[off+3]]); - let name = if i < labels.len() { labels[i] } else { "???" }; - eprintln!(" [{:2}] {:16} = {}", i, name, val); - } - } - } - } + // P2 vs P3 DocInfo 레코드 차이 출력 + if all_info.len() >= 2 { + let (lbl_a, recs_a) = &all_info[0]; + let (lbl_b, recs_b) = &all_info[1]; + eprintln!("\n{}", "=".repeat(100)); + eprintln!("=== DIFF: {} vs {} ===", lbl_a, lbl_b); + eprintln!( + " {} has {} records, {} has {} records", + lbl_a, + recs_a.len(), + lbl_b, + recs_b.len() + ); - // DocInfo 레코드 시퀀스 요약 - let mut rec_summary: std::collections::HashMap = std::collections::HashMap::new(); - for rec in &records { - let entry = rec_summary.entry(rec.tag_id).or_insert((0, 0)); - entry.0 += 1; - entry.1 += rec.data.len(); - } - let mut sorted: Vec<_> = rec_summary.iter().collect(); - sorted.sort_by_key(|(tid, _)| **tid); - eprintln!("\n DocInfo record types:"); - for (tid, (cnt, total_size)) in &sorted { - eprintln!(" tag={:3} ({:20}) count={:4} total_bytes={}", tid, tags::tag_name(**tid), cnt, total_size); - } - - // 레코드 리스트 저장 - let rec_list: Vec<_> = records.iter().map(|r| (r.tag_id, r.level, r.size, r.data.clone())).collect(); - all_info.push((label.to_string(), rec_list)); - } - - // P2 vs P3 DocInfo 레코드 차이 출력 - if all_info.len() >= 2 { - let (lbl_a, recs_a) = &all_info[0]; - let (lbl_b, recs_b) = &all_info[1]; - eprintln!("\n{}", "=".repeat(100)); - eprintln!("=== DIFF: {} vs {} ===", lbl_a, lbl_b); - eprintln!(" {} has {} records, {} has {} records", - lbl_a, recs_a.len(), lbl_b, recs_b.len()); - - let max_len = recs_a.len().max(recs_b.len()); - for i in 0..max_len { - let a = recs_a.get(i); - let b = recs_b.get(i); - match (a, b) { - (Some(a), Some(b)) => { - if a.0 != b.0 || a.2 != b.2 || a.3 != b.3 { - eprintln!(" DIFF rec #{}: {} tag={}/size={} vs {} tag={}/size={}", - i, - tags::tag_name(a.0), a.0, a.3.len(), - tags::tag_name(b.0), b.0, b.3.len()); - if a.0 == b.0 && a.3.len() == b.3.len() && a.3.len() <= 256 { - // 동일 크기면 바이트 단위 차이 출력 - for j in 0..a.3.len() { - if a.3[j] != b.3[j] { - eprintln!(" byte[{}]: {:02X} vs {:02X}", j, a.3[j], b.3[j]); - } + let max_len = recs_a.len().max(recs_b.len()); + for i in 0..max_len { + let a = recs_a.get(i); + let b = recs_b.get(i); + match (a, b) { + (Some(a), Some(b)) => { + if a.0 != b.0 || a.2 != b.2 || a.3 != b.3 { + eprintln!( + " DIFF rec #{}: {} tag={}/size={} vs {} tag={}/size={}", + i, + tags::tag_name(a.0), + a.0, + a.3.len(), + tags::tag_name(b.0), + b.0, + b.3.len() + ); + if a.0 == b.0 && a.3.len() == b.3.len() && a.3.len() <= 256 { + // 동일 크기면 바이트 단위 차이 출력 + for j in 0..a.3.len() { + if a.3[j] != b.3[j] { + eprintln!(" byte[{}]: {:02X} vs {:02X}", j, a.3[j], b.3[j]); } } - if a.0 != b.0 || a.3.len() != b.3.len() { - // 완전히 다른 레코드면 hex dump - if a.3.len() <= 64 { - let hex_a: Vec = a.3.iter().map(|b| format!("{:02X}", b)).collect(); - eprintln!(" A: {}", hex_a.join(" ")); - } - if b.3.len() <= 64 { - let hex_b: Vec = b.3.iter().map(|b| format!("{:02X}", b)).collect(); - eprintln!(" B: {}", hex_b.join(" ")); - } + } + if a.0 != b.0 || a.3.len() != b.3.len() { + // 완전히 다른 레코드면 hex dump + if a.3.len() <= 64 { + let hex_a: Vec = + a.3.iter().map(|b| format!("{:02X}", b)).collect(); + eprintln!(" A: {}", hex_a.join(" ")); + } + if b.3.len() <= 64 { + let hex_b: Vec = + b.3.iter().map(|b| format!("{:02X}", b)).collect(); + eprintln!(" B: {}", hex_b.join(" ")); } } - }, - (Some(a), None) => { - eprintln!(" ONLY-IN-{}: rec #{} tag={} size={}", lbl_a, i, tags::tag_name(a.0), a.3.len()); - }, - (None, Some(b)) => { - eprintln!(" ONLY-IN-{}: rec #{} tag={} size={}", lbl_b, i, tags::tag_name(b.0), b.3.len()); - }, - _ => {} + } + } + (Some(a), None) => { + eprintln!( + " ONLY-IN-{}: rec #{} tag={} size={}", + lbl_a, + i, + tags::tag_name(a.0), + a.3.len() + ); + } + (None, Some(b)) => { + eprintln!( + " ONLY-IN-{}: rec #{} tag={} size={}", + lbl_b, + i, + tags::tag_name(b.0), + b.3.len() + ); } + _ => {} } } - - eprintln!("\n=== DOCINFO COMPARISON COMPLETE ==="); } - /// DocInfo 라운드트립 테스트: raw_stream 제거 후 직렬화→재파싱 시 데이터 보존 검증 - #[test] - fn test_docinfo_roundtrip_charshape_preservation() { - use crate::parser::record::Record; - use crate::parser::cfb_reader::CfbReader; - use crate::parser::tags; - - // 먼저 모든 관련 파일의 char_shapes 수 출력 - let check_files = [ - "/app/pasts/20250130-hongbo_saved-past.hwp", - "/app/pasts/20250130-hongbo_saved-past-002.hwp", - "/app/pasts/20250130-hongbo_saved-past-003.hwp", - "/app/pasts/20250130-hongbo_saved-past-004.hwp", - "/app/pasts/20250130-hongbo_saved-past-005.hwp", - "/app/pasts/20250130-hongbo-p2.hwp", - "/app/pasts/20250130-hongbo-p3.hwp", - ]; - eprintln!("\n=== ALL FILES: char_shapes count ==="); - for cf in &check_files { - if let Ok(d) = std::fs::read(cf) { - if let Ok(cdoc) = HwpDocument::from_bytes(&d) { - eprintln!(" {} → char_shapes={} para_shapes={} border_fills={} styles={}", - cf.split('/').last().unwrap_or(cf), - cdoc.document.doc_info.char_shapes.len(), - cdoc.document.doc_info.para_shapes.len(), - cdoc.document.doc_info.border_fills.len(), - cdoc.document.doc_info.styles.len()); - } + eprintln!("\n=== DOCINFO COMPARISON COMPLETE ==="); +} + +/// DocInfo 라운드트립 테스트: raw_stream 제거 후 직렬화→재파싱 시 데이터 보존 검증 +#[test] +fn test_docinfo_roundtrip_charshape_preservation() { + use crate::parser::cfb_reader::CfbReader; + use crate::parser::record::Record; + use crate::parser::tags; + + // 먼저 모든 관련 파일의 char_shapes 수 출력 + let check_files = [ + "/app/pasts/20250130-hongbo_saved-past.hwp", + "/app/pasts/20250130-hongbo_saved-past-002.hwp", + "/app/pasts/20250130-hongbo_saved-past-003.hwp", + "/app/pasts/20250130-hongbo_saved-past-004.hwp", + "/app/pasts/20250130-hongbo_saved-past-005.hwp", + "/app/pasts/20250130-hongbo-p2.hwp", + "/app/pasts/20250130-hongbo-p3.hwp", + ]; + eprintln!("\n=== ALL FILES: char_shapes count ==="); + for cf in &check_files { + if let Ok(d) = std::fs::read(cf) { + if let Ok(cdoc) = HwpDocument::from_bytes(&d) { + eprintln!( + " {} → char_shapes={} para_shapes={} border_fills={} styles={}", + cf.split('/').next_back().unwrap_or(cf), + cdoc.document.doc_info.char_shapes.len(), + cdoc.document.doc_info.para_shapes.len(), + cdoc.document.doc_info.border_fills.len(), + cdoc.document.doc_info.styles.len() + ); } } + } - let path = "/app/pasts/20250130-hongbo-p2.hwp"; - let data = match std::fs::read(path) { - Ok(d) => d, - Err(_) => { eprintln!("File not found: {}", path); return; } - }; - - let mut doc = HwpDocument::from_bytes(&data).unwrap(); + let path = "/app/pasts/20250130-hongbo-p2.hwp"; + let data = match std::fs::read(path) { + Ok(d) => d, + Err(_) => { + eprintln!("File not found: {}", path); + return; + } + }; + + let mut doc = HwpDocument::from_bytes(&data).unwrap(); + + let orig_cs = doc.document.doc_info.char_shapes.len(); + let orig_ps = doc.document.doc_info.para_shapes.len(); + let orig_bf = doc.document.doc_info.border_fills.len(); + let orig_st = doc.document.doc_info.styles.len(); + + eprintln!("=== P2 DocInfo 라운드트립 테스트 ==="); + eprintln!( + " Original: char_shapes={} para_shapes={} border_fills={} styles={}", + orig_cs, orig_ps, orig_bf, orig_st + ); + eprintln!( + " raw_stream present: {}", + doc.document.doc_info.raw_stream.is_some() + ); + + // 1) raw_stream이 있는 경우 → 원본 그대로 반환 + let serialized_raw = crate::serializer::doc_info::serialize_doc_info( + &doc.document.doc_info, + &doc.document.doc_properties, + ); + let raw_records = Record::read_all(&serialized_raw).unwrap(); + let raw_cs_count = raw_records + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_CHAR_SHAPE) + .count(); + eprintln!( + " With raw_stream: serialized={} bytes, CHAR_SHAPE records={}", + serialized_raw.len(), + raw_cs_count + ); + + // 2) raw_stream 제거 후 재직렬화 + doc.document.doc_info.raw_stream = None; + let serialized_no_raw = crate::serializer::doc_info::serialize_doc_info( + &doc.document.doc_info, + &doc.document.doc_properties, + ); + let no_raw_records = Record::read_all(&serialized_no_raw).unwrap(); + let no_raw_cs_count = no_raw_records + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_CHAR_SHAPE) + .count(); + eprintln!( + " Without raw_stream: serialized={} bytes, CHAR_SHAPE records={}", + serialized_no_raw.len(), + no_raw_cs_count + ); + + // 3) 재파싱 + match crate::parser::doc_info::parse_doc_info(&serialized_no_raw) { + Ok((reparsed_di, reparsed_dp)) => { + eprintln!( + " Re-parsed: char_shapes={} para_shapes={} border_fills={} styles={}", + reparsed_di.char_shapes.len(), + reparsed_di.para_shapes.len(), + reparsed_di.border_fills.len(), + reparsed_di.styles.len() + ); - let orig_cs = doc.document.doc_info.char_shapes.len(); - let orig_ps = doc.document.doc_info.para_shapes.len(); - let orig_bf = doc.document.doc_info.border_fills.len(); - let orig_st = doc.document.doc_info.styles.len(); - - eprintln!("=== P2 DocInfo 라운드트립 테스트 ==="); - eprintln!(" Original: char_shapes={} para_shapes={} border_fills={} styles={}", - orig_cs, orig_ps, orig_bf, orig_st); - eprintln!(" raw_stream present: {}", doc.document.doc_info.raw_stream.is_some()); - - // 1) raw_stream이 있는 경우 → 원본 그대로 반환 - let serialized_raw = crate::serializer::doc_info::serialize_doc_info( - &doc.document.doc_info, &doc.document.doc_properties); - let raw_records = Record::read_all(&serialized_raw).unwrap(); - let raw_cs_count = raw_records.iter().filter(|r| r.tag_id == tags::HWPTAG_CHAR_SHAPE).count(); - eprintln!(" With raw_stream: serialized={} bytes, CHAR_SHAPE records={}", serialized_raw.len(), raw_cs_count); - - // 2) raw_stream 제거 후 재직렬화 - doc.document.doc_info.raw_stream = None; - let serialized_no_raw = crate::serializer::doc_info::serialize_doc_info( - &doc.document.doc_info, &doc.document.doc_properties); - let no_raw_records = Record::read_all(&serialized_no_raw).unwrap(); - let no_raw_cs_count = no_raw_records.iter().filter(|r| r.tag_id == tags::HWPTAG_CHAR_SHAPE).count(); - eprintln!(" Without raw_stream: serialized={} bytes, CHAR_SHAPE records={}", serialized_no_raw.len(), no_raw_cs_count); - - // 3) 재파싱 - match crate::parser::doc_info::parse_doc_info(&serialized_no_raw) { - Ok((reparsed_di, reparsed_dp)) => { - eprintln!(" Re-parsed: char_shapes={} para_shapes={} border_fills={} styles={}", - reparsed_di.char_shapes.len(), reparsed_di.para_shapes.len(), - reparsed_di.border_fills.len(), reparsed_di.styles.len()); - - // 원본과 비교 - if reparsed_di.char_shapes.len() != orig_cs { - eprintln!(" *** CHAR_SHAPES LOSS: {} → {} (lost {}) ***", - orig_cs, reparsed_di.char_shapes.len(), - orig_cs as i64 - reparsed_di.char_shapes.len() as i64); - } - if reparsed_di.para_shapes.len() != orig_ps { - eprintln!(" *** PARA_SHAPES DIFF: {} → {} ***", - orig_ps, reparsed_di.para_shapes.len()); - } - if reparsed_di.border_fills.len() != orig_bf { - eprintln!(" *** BORDER_FILLS DIFF: {} → {} ***", - orig_bf, reparsed_di.border_fills.len()); - } - if reparsed_di.styles.len() != orig_st { - eprintln!(" *** STYLES DIFF: {} → {} ***", - orig_st, reparsed_di.styles.len()); - } - - assert_eq!(reparsed_di.char_shapes.len(), orig_cs, - "char_shapes 라운드트립 불일치!"); + // 원본과 비교 + if reparsed_di.char_shapes.len() != orig_cs { + eprintln!( + " *** CHAR_SHAPES LOSS: {} → {} (lost {}) ***", + orig_cs, + reparsed_di.char_shapes.len(), + orig_cs as i64 - reparsed_di.char_shapes.len() as i64 + ); } - Err(e) => { - eprintln!(" *** RE-PARSE FAILED: {} ***", e); - panic!("DocInfo re-parse failed"); + if reparsed_di.para_shapes.len() != orig_ps { + eprintln!( + " *** PARA_SHAPES DIFF: {} → {} ***", + orig_ps, + reparsed_di.para_shapes.len() + ); + } + if reparsed_di.border_fills.len() != orig_bf { + eprintln!( + " *** BORDER_FILLS DIFF: {} → {} ***", + orig_bf, + reparsed_di.border_fills.len() + ); + } + if reparsed_di.styles.len() != orig_st { + eprintln!( + " *** STYLES DIFF: {} → {} ***", + orig_st, + reparsed_di.styles.len() + ); } + + assert_eq!( + reparsed_di.char_shapes.len(), + orig_cs, + "char_shapes 라운드트립 불일치!" + ); } + Err(e) => { + eprintln!(" *** RE-PARSE FAILED: {} ***", e); + panic!("DocInfo re-parse failed"); + } + } - // 4) 레코드 수준 비교: raw_stream vs no_raw_stream - eprintln!("\n Record type comparison:"); - let mut raw_by_tag: std::collections::HashMap = std::collections::HashMap::new(); - let mut noraw_by_tag: std::collections::HashMap = std::collections::HashMap::new(); - for r in &raw_records { *raw_by_tag.entry(r.tag_id).or_default() += 1; } - for r in &no_raw_records { *noraw_by_tag.entry(r.tag_id).or_default() += 1; } + // 4) 레코드 수준 비교: raw_stream vs no_raw_stream + eprintln!("\n Record type comparison:"); + let mut raw_by_tag: std::collections::HashMap = std::collections::HashMap::new(); + let mut noraw_by_tag: std::collections::HashMap = std::collections::HashMap::new(); + for r in &raw_records { + *raw_by_tag.entry(r.tag_id).or_default() += 1; + } + for r in &no_raw_records { + *noraw_by_tag.entry(r.tag_id).or_default() += 1; + } - let mut all_tags: Vec = raw_by_tag.keys().chain(noraw_by_tag.keys()).cloned().collect(); - all_tags.sort(); - all_tags.dedup(); - for tag in &all_tags { - let raw_cnt = raw_by_tag.get(tag).unwrap_or(&0); - let noraw_cnt = noraw_by_tag.get(tag).unwrap_or(&0); - if raw_cnt != noraw_cnt { - eprintln!(" tag={} ({}): raw={} vs rebuilt={}", - tag, tags::tag_name(*tag), raw_cnt, noraw_cnt); - } + let mut all_tags: Vec = raw_by_tag + .keys() + .chain(noraw_by_tag.keys()) + .cloned() + .collect(); + all_tags.sort(); + all_tags.dedup(); + for tag in &all_tags { + let raw_cnt = raw_by_tag.get(tag).unwrap_or(&0); + let noraw_cnt = noraw_by_tag.get(tag).unwrap_or(&0); + if raw_cnt != noraw_cnt { + eprintln!( + " tag={} ({}): raw={} vs rebuilt={}", + tag, + tags::tag_name(*tag), + raw_cnt, + noraw_cnt + ); } + } - // 5) ID_MAPPINGS 상세 덤프 - eprintln!("\n === ID_MAPPINGS detail (original) ==="); - let labels = [ - "BinData", "KorFont", "EnFont", "CnFont", "JpFont", - "OtherFont", "SymFont", "UsrFont", "BorderFill", "CharShape", - "TabDef", "Numbering", "Bullet", "ParaShape", "Style", - "MemoShape", "TrackChange", "TrackChangeUser" - ]; - for r in &raw_records { - if r.tag_id == tags::HWPTAG_ID_MAPPINGS { - eprintln!(" raw ID_MAPPINGS size={} ({} u32s)", r.data.len(), r.data.len() / 4); - for i in 0..(r.data.len() / 4).min(18) { - let off = i * 4; - let val = u32::from_le_bytes([r.data[off], r.data[off+1], r.data[off+2], r.data[off+3]]); - let name = if i < labels.len() { labels[i] } else { "???" }; - eprintln!(" [{:2}] {:16} = {}", i, name, val); - } + // 5) ID_MAPPINGS 상세 덤프 + eprintln!("\n === ID_MAPPINGS detail (original) ==="); + let labels = [ + "BinData", + "KorFont", + "EnFont", + "CnFont", + "JpFont", + "OtherFont", + "SymFont", + "UsrFont", + "BorderFill", + "CharShape", + "TabDef", + "Numbering", + "Bullet", + "ParaShape", + "Style", + "MemoShape", + "TrackChange", + "TrackChangeUser", + ]; + for r in &raw_records { + if r.tag_id == tags::HWPTAG_ID_MAPPINGS { + eprintln!( + " raw ID_MAPPINGS size={} ({} u32s)", + r.data.len(), + r.data.len() / 4 + ); + for i in 0..(r.data.len() / 4).min(18) { + let off = i * 4; + let val = u32::from_le_bytes([ + r.data[off], + r.data[off + 1], + r.data[off + 2], + r.data[off + 3], + ]); + let name = if i < labels.len() { labels[i] } else { "???" }; + eprintln!(" [{:2}] {:16} = {}", i, name, val); } } - eprintln!(" === ID_MAPPINGS detail (rebuilt) ==="); - for r in &no_raw_records { - if r.tag_id == tags::HWPTAG_ID_MAPPINGS { - eprintln!(" rebuilt ID_MAPPINGS size={} ({} u32s)", r.data.len(), r.data.len() / 4); - for i in 0..(r.data.len() / 4).min(18) { - let off = i * 4; - let val = u32::from_le_bytes([r.data[off], r.data[off+1], r.data[off+2], r.data[off+3]]); - let name = if i < labels.len() { labels[i] } else { "???" }; - eprintln!(" [{:2}] {:16} = {}", i, name, val); - } + } + eprintln!(" === ID_MAPPINGS detail (rebuilt) ==="); + for r in &no_raw_records { + if r.tag_id == tags::HWPTAG_ID_MAPPINGS { + eprintln!( + " rebuilt ID_MAPPINGS size={} ({} u32s)", + r.data.len(), + r.data.len() / 4 + ); + for i in 0..(r.data.len() / 4).min(18) { + let off = i * 4; + let val = u32::from_le_bytes([ + r.data[off], + r.data[off + 1], + r.data[off + 2], + r.data[off + 3], + ]); + let name = if i < labels.len() { labels[i] } else { "???" }; + eprintln!(" [{:2}] {:16} = {}", i, name, val); } } + } - // 6) 원본 DocInfo 레코드별 크기 확인 (CHAR_SHAPE) - eprintln!("\n Original CHAR_SHAPE record sizes:"); - let mut cs_sizes: std::collections::HashMap = std::collections::HashMap::new(); - for r in &raw_records { - if r.tag_id == tags::HWPTAG_CHAR_SHAPE { - *cs_sizes.entry(r.data.len()).or_default() += 1; - } + // 6) 원본 DocInfo 레코드별 크기 확인 (CHAR_SHAPE) + eprintln!("\n Original CHAR_SHAPE record sizes:"); + let mut cs_sizes: std::collections::HashMap = std::collections::HashMap::new(); + for r in &raw_records { + if r.tag_id == tags::HWPTAG_CHAR_SHAPE { + *cs_sizes.entry(r.data.len()).or_default() += 1; } - for (sz, cnt) in &cs_sizes { - eprintln!(" size={}: {} records", sz, cnt); + } + for (sz, cnt) in &cs_sizes { + eprintln!(" size={}: {} records", sz, cnt); + } + + eprintln!("\n Rebuilt CHAR_SHAPE record sizes:"); + let mut cs_sizes2: std::collections::HashMap = std::collections::HashMap::new(); + for r in &no_raw_records { + if r.tag_id == tags::HWPTAG_CHAR_SHAPE { + *cs_sizes2.entry(r.data.len()).or_default() += 1; } + } + for (sz, cnt) in &cs_sizes2 { + eprintln!(" size={}: {} records", sz, cnt); + } - eprintln!("\n Rebuilt CHAR_SHAPE record sizes:"); - let mut cs_sizes2: std::collections::HashMap = std::collections::HashMap::new(); + // 7) PARA_SHAPE, BORDER_FILL, STYLE 레코드 크기 비교 + for check_tag in &[ + tags::HWPTAG_PARA_SHAPE, + tags::HWPTAG_BORDER_FILL, + tags::HWPTAG_STYLE, + tags::HWPTAG_TAB_DEF, + ] { + let tag_name = tags::tag_name(*check_tag); + let mut raw_sizes: std::collections::HashMap = + std::collections::HashMap::new(); + let mut rebuilt_sizes: std::collections::HashMap = + std::collections::HashMap::new(); + for r in &raw_records { + if r.tag_id == *check_tag { + *raw_sizes.entry(r.data.len()).or_default() += 1; + } + } for r in &no_raw_records { - if r.tag_id == tags::HWPTAG_CHAR_SHAPE { - *cs_sizes2.entry(r.data.len()).or_default() += 1; + if r.tag_id == *check_tag { + *rebuilt_sizes.entry(r.data.len()).or_default() += 1; } } - for (sz, cnt) in &cs_sizes2 { - eprintln!(" size={}: {} records", sz, cnt); + if raw_sizes != rebuilt_sizes { + eprintln!("\n {} SIZE MISMATCH:", tag_name); + eprintln!(" Original: {:?}", raw_sizes); + eprintln!(" Rebuilt: {:?}", rebuilt_sizes); } + } - // 7) PARA_SHAPE, BORDER_FILL, STYLE 레코드 크기 비교 - for check_tag in &[tags::HWPTAG_PARA_SHAPE, tags::HWPTAG_BORDER_FILL, tags::HWPTAG_STYLE, tags::HWPTAG_TAB_DEF] { - let tag_name = tags::tag_name(*check_tag); - let mut raw_sizes: std::collections::HashMap = std::collections::HashMap::new(); - let mut rebuilt_sizes: std::collections::HashMap = std::collections::HashMap::new(); - for r in &raw_records { - if r.tag_id == *check_tag { *raw_sizes.entry(r.data.len()).or_default() += 1; } - } - for r in &no_raw_records { - if r.tag_id == *check_tag { *rebuilt_sizes.entry(r.data.len()).or_default() += 1; } - } - if raw_sizes != rebuilt_sizes { - eprintln!("\n {} SIZE MISMATCH:", tag_name); - eprintln!(" Original: {:?}", raw_sizes); - eprintln!(" Rebuilt: {:?}", rebuilt_sizes); + // 8) 전체 레코드 수 비교 + eprintln!( + "\n Total records: original={} vs rebuilt={}", + raw_records.len(), + no_raw_records.len() + ); + + eprintln!("\n=== ROUNDTRIP TEST COMPLETE ==="); +} + +/// 007 파일 vs P2(정상) 파일의 DocInfo 레코드별 크기 비교 +#[test] +fn test_docinfo_007_vs_correct() { + use crate::parser::cfb_reader::CfbReader; + use crate::parser::record::Record; + use crate::parser::tags; + + let files = [ + ("/app/pasts/20250130-hongbo-p2.hwp", "CORRECT(P2)"), + ("/app/pasts/20250130-hongbo_saved-past-007.hwp", "OURS-007"), + ]; + + for (path, label) in &files { + let data = match std::fs::read(path) { + Ok(d) => d, + Err(_) => { + eprintln!("File not found: {}", path); + continue; } - } - - // 8) 전체 레코드 수 비교 - eprintln!("\n Total records: original={} vs rebuilt={}", raw_records.len(), no_raw_records.len()); + }; - eprintln!("\n=== ROUNDTRIP TEST COMPLETE ==="); - } + eprintln!("\n{}", "=".repeat(80)); + eprintln!("=== {} : {} ===", label, path); + + let mut cfb = CfbReader::open(&data).unwrap(); + let di_data = cfb.read_doc_info(true).unwrap(); + let records = Record::read_all(&di_data).unwrap(); + + eprintln!(" Total DocInfo records: {}", records.len()); + + // 각 레코드 타입별 크기 상세 출력 + let tag_order = [ + tags::HWPTAG_DOCUMENT_PROPERTIES, + tags::HWPTAG_ID_MAPPINGS, + tags::HWPTAG_BIN_DATA, + tags::HWPTAG_FACE_NAME, + tags::HWPTAG_BORDER_FILL, + tags::HWPTAG_CHAR_SHAPE, + tags::HWPTAG_TAB_DEF, + tags::HWPTAG_NUMBERING, + tags::HWPTAG_PARA_SHAPE, + tags::HWPTAG_STYLE, + ]; - /// 007 파일 vs P2(정상) 파일의 DocInfo 레코드별 크기 비교 - #[test] - fn test_docinfo_007_vs_correct() { - use crate::parser::record::Record; - use crate::parser::cfb_reader::CfbReader; - use crate::parser::tags; + for tag in &tag_order { + let matching: Vec<_> = records.iter().filter(|r| r.tag_id == *tag).collect(); + if matching.is_empty() { + continue; + } - let files = [ - ("/app/pasts/20250130-hongbo-p2.hwp", "CORRECT(P2)"), - ("/app/pasts/20250130-hongbo_saved-past-007.hwp", "OURS-007"), - ]; + let mut sizes: std::collections::HashMap = + std::collections::HashMap::new(); + for r in &matching { + *sizes.entry(r.data.len()).or_default() += 1; + } + let mut sorted_sizes: Vec<_> = sizes.iter().collect(); + sorted_sizes.sort_by_key(|(sz, _)| **sz); + + eprintln!( + " {} (tag={}): count={}, sizes={:?}", + tags::tag_name(*tag), + tag, + matching.len(), + sorted_sizes + .iter() + .map(|(s, c)| format!("{}b×{}", s, c)) + .collect::>() + .join(", ") + ); + } - for (path, label) in &files { - let data = match std::fs::read(path) { - Ok(d) => d, - Err(_) => { eprintln!("File not found: {}", path); continue; } - }; + // 미지원 태그도 출력 + let known_tags: std::collections::HashSet = tag_order.iter().cloned().collect(); + let mut extra_tags: std::collections::HashMap = + std::collections::HashMap::new(); + for r in &records { + if !known_tags.contains(&r.tag_id) { + *extra_tags.entry(r.tag_id).or_default() += 1; + } + } + if !extra_tags.is_empty() { + let mut sorted_extra: Vec<_> = extra_tags.iter().collect(); + sorted_extra.sort_by_key(|(t, _)| **t); + for (tag, cnt) in &sorted_extra { + eprintln!( + " [extra] {} (tag={}): count={}", + tags::tag_name(**tag), + tag, + cnt + ); + } + } - eprintln!("\n{}", "=".repeat(80)); - eprintln!("=== {} : {} ===", label, path); - - let mut cfb = CfbReader::open(&data).unwrap(); - let di_data = cfb.read_doc_info(true).unwrap(); - let records = Record::read_all(&di_data).unwrap(); - - eprintln!(" Total DocInfo records: {}", records.len()); - - // 각 레코드 타입별 크기 상세 출력 - let tag_order = [ - tags::HWPTAG_DOCUMENT_PROPERTIES, - tags::HWPTAG_ID_MAPPINGS, - tags::HWPTAG_BIN_DATA, - tags::HWPTAG_FACE_NAME, - tags::HWPTAG_BORDER_FILL, - tags::HWPTAG_CHAR_SHAPE, - tags::HWPTAG_TAB_DEF, - tags::HWPTAG_NUMBERING, - tags::HWPTAG_PARA_SHAPE, - tags::HWPTAG_STYLE, - ]; - - for tag in &tag_order { - let matching: Vec<_> = records.iter().filter(|r| r.tag_id == *tag).collect(); - if matching.is_empty() { continue; } - - let mut sizes: std::collections::HashMap = std::collections::HashMap::new(); - for r in &matching { - *sizes.entry(r.data.len()).or_default() += 1; - } - let mut sorted_sizes: Vec<_> = sizes.iter().collect(); - sorted_sizes.sort_by_key(|(sz, _)| **sz); - - eprintln!(" {} (tag={}): count={}, sizes={:?}", - tags::tag_name(*tag), tag, matching.len(), - sorted_sizes.iter().map(|(s, c)| format!("{}b×{}", s, c)).collect::>().join(", ")); - } - - // 미지원 태그도 출력 - let known_tags: std::collections::HashSet = tag_order.iter().cloned().collect(); - let mut extra_tags: std::collections::HashMap = std::collections::HashMap::new(); - for r in &records { - if !known_tags.contains(&r.tag_id) { - *extra_tags.entry(r.tag_id).or_default() += 1; - } - } - if !extra_tags.is_empty() { - let mut sorted_extra: Vec<_> = extra_tags.iter().collect(); - sorted_extra.sort_by_key(|(t, _)| **t); - for (tag, cnt) in &sorted_extra { - eprintln!(" [extra] {} (tag={}): count={}", tags::tag_name(**tag), tag, cnt); - } - } - - // ID_MAPPINGS 상세 - let labels = [ - "BinData", "KorFont", "EnFont", "CnFont", "JpFont", - "OtherFont", "SymFont", "UsrFont", "BorderFill", "CharShape", - "TabDef", "Numbering", "Bullet", "ParaShape", "Style", - "MemoShape", - ]; - for r in &records { - if r.tag_id == tags::HWPTAG_ID_MAPPINGS { - eprintln!(" ID_MAPPINGS ({} bytes, {} u32s):", r.data.len(), r.data.len() / 4); - for i in 0..(r.data.len() / 4).min(16) { - let off = i * 4; - let val = u32::from_le_bytes([r.data[off], r.data[off+1], r.data[off+2], r.data[off+3]]); - eprintln!(" [{:2}] {:16} = {}", i, labels[i.min(15)], val); - } + // ID_MAPPINGS 상세 + let labels = [ + "BinData", + "KorFont", + "EnFont", + "CnFont", + "JpFont", + "OtherFont", + "SymFont", + "UsrFont", + "BorderFill", + "CharShape", + "TabDef", + "Numbering", + "Bullet", + "ParaShape", + "Style", + "MemoShape", + ]; + for r in &records { + if r.tag_id == tags::HWPTAG_ID_MAPPINGS { + eprintln!( + " ID_MAPPINGS ({} bytes, {} u32s):", + r.data.len(), + r.data.len() / 4 + ); + for i in 0..(r.data.len() / 4).min(16) { + let off = i * 4; + let val = u32::from_le_bytes([ + r.data[off], + r.data[off + 1], + r.data[off + 2], + r.data[off + 3], + ]); + eprintln!(" [{:2}] {:16} = {}", i, labels[i.min(15)], val); } } + } - // Style 레코드 상세 (para_shape_id, char_shape_id 확인) - eprintln!(" Style records detail:"); - let mut style_idx = 0; - for r in &records { - if r.tag_id == tags::HWPTAG_STYLE { - let hex: String = r.data.iter().take(32).map(|b| format!("{:02X}", b)).collect::>().join(" "); - eprintln!(" style[{}] size={}: {}", style_idx, r.data.len(), hex); - style_idx += 1; - } + // Style 레코드 상세 (para_shape_id, char_shape_id 확인) + eprintln!(" Style records detail:"); + let mut style_idx = 0; + for r in &records { + if r.tag_id == tags::HWPTAG_STYLE { + let hex: String = r + .data + .iter() + .take(32) + .map(|b| format!("{:02X}", b)) + .collect::>() + .join(" "); + eprintln!(" style[{}] size={}: {}", style_idx, r.data.len(), hex); + style_idx += 1; } } + } - eprintln!("\n=== 007 vs CORRECT COMPARISON COMPLETE ==="); + eprintln!("\n=== 007 vs CORRECT COMPARISON COMPLETE ==="); +} + +/// 원본 HWP 파일과 저장된 HWP 파일의 DocInfo/BodyText 스트림 비교 +#[test] +fn test_compare_orig_vs_saved() { + use crate::parser::record::Record; + use crate::parser::tags; + use std::path::Path; + + let orig_path = Path::new("pasts/20250130-hongbo-p2.hwp"); + let saved_path = Path::new("pasts/20250130-hongbo_saved-rp-001.hwp"); + if !orig_path.exists() || !saved_path.exists() { + eprintln!("파일 없음 — 건너뜀"); + return; } - /// 원본 HWP 파일과 저장된 HWP 파일의 DocInfo/BodyText 스트림 비교 - #[test] - fn test_compare_orig_vs_saved() { - use std::path::Path; - use crate::parser::record::Record; - use crate::parser::tags; - - let orig_path = Path::new("pasts/20250130-hongbo-p2.hwp"); - let saved_path = Path::new("pasts/20250130-hongbo_saved-rp-001.hwp"); - if !orig_path.exists() || !saved_path.exists() { - eprintln!("파일 없음 — 건너뜀"); - return; - } - - let orig_data = std::fs::read(orig_path).unwrap(); - let saved_data = std::fs::read(saved_path).unwrap(); - - eprintln!("=== 원본 vs 저장 파일 비교 ==="); - eprintln!("원본 파일 크기: {} bytes", orig_data.len()); - eprintln!("저장 파일 크기: {} bytes", saved_data.len()); - - // 1. parse_hwp로 파싱 - let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); - let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); - - // 2. CfbReader로 raw 스트림 추출 - let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); - let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); - - // DocInfo raw bytes 비교 - let orig_di = orig_cfb.read_doc_info(orig_doc.header.compressed).unwrap(); - let saved_di = saved_cfb.read_doc_info(saved_doc.header.compressed).unwrap(); - - eprintln!("\n--- DocInfo 스트림 비교 ---"); - eprintln!("원본 DocInfo: {} bytes", orig_di.len()); - eprintln!("저장 DocInfo: {} bytes", saved_di.len()); - if orig_di == saved_di { - eprintln!("DocInfo: 동일"); - } else { - let min_len = orig_di.len().min(saved_di.len()); - let mut first_diff = None; - let mut diff_count = 0; - for i in 0..min_len { - if orig_di[i] != saved_di[i] { - if first_diff.is_none() { - first_diff = Some(i); - } - diff_count += 1; + let orig_data = std::fs::read(orig_path).unwrap(); + let saved_data = std::fs::read(saved_path).unwrap(); + + eprintln!("=== 원본 vs 저장 파일 비교 ==="); + eprintln!("원본 파일 크기: {} bytes", orig_data.len()); + eprintln!("저장 파일 크기: {} bytes", saved_data.len()); + + // 1. parse_hwp로 파싱 + let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); + let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); + + // 2. CfbReader로 raw 스트림 추출 + let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); + let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); + + // DocInfo raw bytes 비교 + let orig_di = orig_cfb.read_doc_info(orig_doc.header.compressed).unwrap(); + let saved_di = saved_cfb + .read_doc_info(saved_doc.header.compressed) + .unwrap(); + + eprintln!("\n--- DocInfo 스트림 비교 ---"); + eprintln!("원본 DocInfo: {} bytes", orig_di.len()); + eprintln!("저장 DocInfo: {} bytes", saved_di.len()); + if orig_di == saved_di { + eprintln!("DocInfo: 동일"); + } else { + let min_len = orig_di.len().min(saved_di.len()); + let mut first_diff = None; + let mut diff_count = 0; + for i in 0..min_len { + if orig_di[i] != saved_di[i] { + if first_diff.is_none() { + first_diff = Some(i); } + diff_count += 1; } - eprintln!("DocInfo: 차이 발견! 첫 차이 offset={}, 총 바이트 차이={}, 길이 차이={}", - first_diff.unwrap_or(min_len), diff_count, - (orig_di.len() as i64 - saved_di.len() as i64).abs()); } + eprintln!( + "DocInfo: 차이 발견! 첫 차이 offset={}, 총 바이트 차이={}, 길이 차이={}", + first_diff.unwrap_or(min_len), + diff_count, + (orig_di.len() as i64 - saved_di.len() as i64).abs() + ); + } - // BodyText/Section0 raw bytes 비교 - let orig_bt = orig_cfb.read_body_text_section(0, orig_doc.header.compressed, false).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, saved_doc.header.compressed, false).unwrap(); - - eprintln!("\n--- BodyText/Section0 스트림 비교 ---"); - eprintln!("원본 BodyText: {} bytes", orig_bt.len()); - eprintln!("저장 BodyText: {} bytes", saved_bt.len()); - if orig_bt == saved_bt { - eprintln!("BodyText: 동일"); - } else { - let min_len = orig_bt.len().min(saved_bt.len()); - let mut first_diff = None; - let mut diff_count = 0; - for i in 0..min_len { - if orig_bt[i] != saved_bt[i] { - if first_diff.is_none() { - first_diff = Some(i); - } - if diff_count < 10 { - eprintln!(" offset {}: orig={:02X} saved={:02X}", i, orig_bt[i], saved_bt[i]); - } - diff_count += 1; + // BodyText/Section0 raw bytes 비교 + let orig_bt = orig_cfb + .read_body_text_section(0, orig_doc.header.compressed, false) + .unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_doc.header.compressed, false) + .unwrap(); + + eprintln!("\n--- BodyText/Section0 스트림 비교 ---"); + eprintln!("원본 BodyText: {} bytes", orig_bt.len()); + eprintln!("저장 BodyText: {} bytes", saved_bt.len()); + if orig_bt == saved_bt { + eprintln!("BodyText: 동일"); + } else { + let min_len = orig_bt.len().min(saved_bt.len()); + let mut first_diff = None; + let mut diff_count = 0; + for i in 0..min_len { + if orig_bt[i] != saved_bt[i] { + if first_diff.is_none() { + first_diff = Some(i); + } + if diff_count < 10 { + eprintln!( + " offset {}: orig={:02X} saved={:02X}", + i, orig_bt[i], saved_bt[i] + ); } + diff_count += 1; } - eprintln!("BodyText: 첫 차이 offset={}, 총 바이트 차이={}, 길이 차이={}", - first_diff.unwrap_or(min_len), diff_count, - (orig_bt.len() as i64 - saved_bt.len() as i64).abs()); } + eprintln!( + "BodyText: 첫 차이 offset={}, 총 바이트 차이={}, 길이 차이={}", + first_diff.unwrap_or(min_len), + diff_count, + (orig_bt.len() as i64 - saved_bt.len() as i64).abs() + ); + } - // 3. 문단 및 컨트롤 수 비교 - eprintln!("\n--- 문단/컨트롤 수 비교 ---"); - let orig_paras = &orig_doc.sections[0].paragraphs; - let saved_paras = &saved_doc.sections[0].paragraphs; - eprintln!("원본 문단 수: {}", orig_paras.len()); - eprintln!("저장 문단 수: {}", saved_paras.len()); - - let orig_ctrl_count: usize = orig_paras.iter().map(|p| p.controls.len()).sum(); - let saved_ctrl_count: usize = saved_paras.iter().map(|p| p.controls.len()).sum(); - eprintln!("원본 컨트롤 수: {}", orig_ctrl_count); - eprintln!("저장 컨트롤 수: {}", saved_ctrl_count); - - // 원본 컨트롤 목록 - eprintln!("\n--- 원본 파일 컨트롤 목록 ---"); - for (pi, para) in orig_paras.iter().enumerate() { - for (ci, ctrl) in para.controls.iter().enumerate() { - let ctrl_type = match ctrl { - crate::model::control::Control::SectionDef(_) => "SectionDef", - crate::model::control::Control::ColumnDef(_) => "ColumnDef", - crate::model::control::Control::Table(t) => { - eprintln!(" para[{}] ctrl[{}]: Table (rows={}, cols={})", - pi, ci, t.row_count, t.col_count); - continue; - }, - crate::model::control::Control::Shape(_) => "Shape", - crate::model::control::Control::Picture(_) => "Picture", - crate::model::control::Control::Header(_) => "Header", - crate::model::control::Control::Footer(_) => "Footer", - crate::model::control::Control::Footnote(_) => "Footnote", - crate::model::control::Control::Endnote(_) => "Endnote", - crate::model::control::Control::AutoNumber(_) => "AutoNumber", - crate::model::control::Control::NewNumber(_) => "NewNumber", - crate::model::control::Control::PageNumberPos(_) => "PageNumberPos", - crate::model::control::Control::Bookmark(_) => "Bookmark", - crate::model::control::Control::Hyperlink(_) => "Hyperlink", - crate::model::control::Control::Ruby(_) => "Ruby", - crate::model::control::Control::CharOverlap(_) => "CharOverlap", - crate::model::control::Control::PageHide(_) => "PageHide", - crate::model::control::Control::HiddenComment(_) => "HiddenComment", - crate::model::control::Control::Equation(_) => "Equation", - crate::model::control::Control::Field(_) => "Field", - crate::model::control::Control::Form(_) => "Form", - crate::model::control::Control::Unknown(u) => { - eprintln!(" para[{}] ctrl[{}]: Unknown (ctrl_id=0x{:08X})", pi, ci, u.ctrl_id); - continue; - }, - }; - eprintln!(" para[{}] ctrl[{}]: {}", pi, ci, ctrl_type); - } + // 3. 문단 및 컨트롤 수 비교 + eprintln!("\n--- 문단/컨트롤 수 비교 ---"); + let orig_paras = &orig_doc.sections[0].paragraphs; + let saved_paras = &saved_doc.sections[0].paragraphs; + eprintln!("원본 문단 수: {}", orig_paras.len()); + eprintln!("저장 문단 수: {}", saved_paras.len()); + + let orig_ctrl_count: usize = orig_paras.iter().map(|p| p.controls.len()).sum(); + let saved_ctrl_count: usize = saved_paras.iter().map(|p| p.controls.len()).sum(); + eprintln!("원본 컨트롤 수: {}", orig_ctrl_count); + eprintln!("저장 컨트롤 수: {}", saved_ctrl_count); + + // 원본 컨트롤 목록 + eprintln!("\n--- 원본 파일 컨트롤 목록 ---"); + for (pi, para) in orig_paras.iter().enumerate() { + for (ci, ctrl) in para.controls.iter().enumerate() { + let ctrl_type = match ctrl { + crate::model::control::Control::SectionDef(_) => "SectionDef", + crate::model::control::Control::ColumnDef(_) => "ColumnDef", + crate::model::control::Control::Table(t) => { + eprintln!( + " para[{}] ctrl[{}]: Table (rows={}, cols={})", + pi, ci, t.row_count, t.col_count + ); + continue; + } + crate::model::control::Control::Shape(_) => "Shape", + crate::model::control::Control::Picture(_) => "Picture", + crate::model::control::Control::Header(_) => "Header", + crate::model::control::Control::Footer(_) => "Footer", + crate::model::control::Control::Footnote(_) => "Footnote", + crate::model::control::Control::Endnote(_) => "Endnote", + crate::model::control::Control::AutoNumber(_) => "AutoNumber", + crate::model::control::Control::NewNumber(_) => "NewNumber", + crate::model::control::Control::PageNumberPos(_) => "PageNumberPos", + crate::model::control::Control::Bookmark(_) => "Bookmark", + crate::model::control::Control::Hyperlink(_) => "Hyperlink", + crate::model::control::Control::Ruby(_) => "Ruby", + crate::model::control::Control::CharOverlap(_) => "CharOverlap", + crate::model::control::Control::PageHide(_) => "PageHide", + crate::model::control::Control::HiddenComment(_) => "HiddenComment", + crate::model::control::Control::Equation(_) => "Equation", + crate::model::control::Control::Field(_) => "Field", + crate::model::control::Control::Form(_) => "Form", + crate::model::control::Control::Unknown(u) => { + eprintln!( + " para[{}] ctrl[{}]: Unknown (ctrl_id=0x{:08X})", + pi, ci, u.ctrl_id + ); + continue; + } + }; + eprintln!(" para[{}] ctrl[{}]: {}", pi, ci, ctrl_type); } + } - // 저장 파일 컨트롤 목록 - eprintln!("\n--- 저장 파일 컨트롤 목록 ---"); - for (pi, para) in saved_paras.iter().enumerate() { - for (ci, ctrl) in para.controls.iter().enumerate() { - let ctrl_type = match ctrl { - crate::model::control::Control::SectionDef(_) => "SectionDef", - crate::model::control::Control::ColumnDef(_) => "ColumnDef", - crate::model::control::Control::Table(t) => { - eprintln!(" para[{}] ctrl[{}]: Table (rows={}, cols={})", - pi, ci, t.row_count, t.col_count); - continue; - }, - crate::model::control::Control::Shape(_) => "Shape", - crate::model::control::Control::Picture(_) => "Picture", - crate::model::control::Control::Header(_) => "Header", - crate::model::control::Control::Footer(_) => "Footer", - crate::model::control::Control::Footnote(_) => "Footnote", - crate::model::control::Control::Endnote(_) => "Endnote", - crate::model::control::Control::AutoNumber(_) => "AutoNumber", - crate::model::control::Control::NewNumber(_) => "NewNumber", - crate::model::control::Control::PageNumberPos(_) => "PageNumberPos", - crate::model::control::Control::Bookmark(_) => "Bookmark", - crate::model::control::Control::Hyperlink(_) => "Hyperlink", - crate::model::control::Control::Ruby(_) => "Ruby", - crate::model::control::Control::CharOverlap(_) => "CharOverlap", - crate::model::control::Control::PageHide(_) => "PageHide", - crate::model::control::Control::HiddenComment(_) => "HiddenComment", - crate::model::control::Control::Equation(_) => "Equation", - crate::model::control::Control::Field(_) => "Field", - crate::model::control::Control::Form(_) => "Form", - crate::model::control::Control::Unknown(u) => { - eprintln!(" para[{}] ctrl[{}]: Unknown (ctrl_id=0x{:08X})", pi, ci, u.ctrl_id); - continue; - }, - }; - eprintln!(" para[{}] ctrl[{}]: {}", pi, ci, ctrl_type); - } + // 저장 파일 컨트롤 목록 + eprintln!("\n--- 저장 파일 컨트롤 목록 ---"); + for (pi, para) in saved_paras.iter().enumerate() { + for (ci, ctrl) in para.controls.iter().enumerate() { + let ctrl_type = match ctrl { + crate::model::control::Control::SectionDef(_) => "SectionDef", + crate::model::control::Control::ColumnDef(_) => "ColumnDef", + crate::model::control::Control::Table(t) => { + eprintln!( + " para[{}] ctrl[{}]: Table (rows={}, cols={})", + pi, ci, t.row_count, t.col_count + ); + continue; + } + crate::model::control::Control::Shape(_) => "Shape", + crate::model::control::Control::Picture(_) => "Picture", + crate::model::control::Control::Header(_) => "Header", + crate::model::control::Control::Footer(_) => "Footer", + crate::model::control::Control::Footnote(_) => "Footnote", + crate::model::control::Control::Endnote(_) => "Endnote", + crate::model::control::Control::AutoNumber(_) => "AutoNumber", + crate::model::control::Control::NewNumber(_) => "NewNumber", + crate::model::control::Control::PageNumberPos(_) => "PageNumberPos", + crate::model::control::Control::Bookmark(_) => "Bookmark", + crate::model::control::Control::Hyperlink(_) => "Hyperlink", + crate::model::control::Control::Ruby(_) => "Ruby", + crate::model::control::Control::CharOverlap(_) => "CharOverlap", + crate::model::control::Control::PageHide(_) => "PageHide", + crate::model::control::Control::HiddenComment(_) => "HiddenComment", + crate::model::control::Control::Equation(_) => "Equation", + crate::model::control::Control::Field(_) => "Field", + crate::model::control::Control::Form(_) => "Form", + crate::model::control::Control::Unknown(u) => { + eprintln!( + " para[{}] ctrl[{}]: Unknown (ctrl_id=0x{:08X})", + pi, ci, u.ctrl_id + ); + continue; + } + }; + eprintln!(" para[{}] ctrl[{}]: {}", pi, ci, ctrl_type); } + } - // 4. 저장 파일 Section0의 마지막 20개 레코드 분석 - eprintln!("\n--- 저장 파일 Section0: 마지막 20개 레코드 ---"); - let saved_recs = Record::read_all(&saved_bt).unwrap(); - let orig_recs = Record::read_all(&orig_bt).unwrap(); - eprintln!("원본 레코드 수: {}", orig_recs.len()); - eprintln!("저장 레코드 수: {}", saved_recs.len()); - - let start = if saved_recs.len() > 20 { saved_recs.len() - 20 } else { 0 }; - for i in start..saved_recs.len() { - let r = &saved_recs[i]; - let tag = tags::tag_name(r.tag_id); - // CTRL_HEADER인 경우 ctrl_id 표시 - if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes(r.data[0..4].try_into().unwrap()); - let ctrl = tags::ctrl_name(ctrl_id); - eprintln!(" [{}] {} L{} {}B ctrl={} {:02X?}", - i, tag, r.level, r.data.len(), ctrl, - &r.data[..r.data.len().min(32)]); - } else { - eprintln!(" [{}] {} L{} {}B {:02X?}", - i, tag, r.level, r.data.len(), - &r.data[..r.data.len().min(32)]); - } + // 4. 저장 파일 Section0의 마지막 20개 레코드 분석 + eprintln!("\n--- 저장 파일 Section0: 마지막 20개 레코드 ---"); + let saved_recs = Record::read_all(&saved_bt).unwrap(); + let orig_recs = Record::read_all(&orig_bt).unwrap(); + eprintln!("원본 레코드 수: {}", orig_recs.len()); + eprintln!("저장 레코드 수: {}", saved_recs.len()); + + let start = if saved_recs.len() > 20 { + saved_recs.len() - 20 + } else { + 0 + }; + for i in start..saved_recs.len() { + let r = &saved_recs[i]; + let tag = tags::tag_name(r.tag_id); + // CTRL_HEADER인 경우 ctrl_id 표시 + if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes(r.data[0..4].try_into().unwrap()); + let ctrl = tags::ctrl_name(ctrl_id); + eprintln!( + " [{}] {} L{} {}B ctrl={} {:02X?}", + i, + tag, + r.level, + r.data.len(), + ctrl, + &r.data[..r.data.len().min(32)] + ); + } else { + eprintln!( + " [{}] {} L{} {}B {:02X?}", + i, + tag, + r.level, + r.data.len(), + &r.data[..r.data.len().min(32)] + ); } + } - // 원본의 마지막 20개 레코드도 비교 - eprintln!("\n--- 원본 파일 Section0: 마지막 20개 레코드 ---"); - let start = if orig_recs.len() > 20 { orig_recs.len() - 20 } else { 0 }; - for i in start..orig_recs.len() { - let r = &orig_recs[i]; - let tag = tags::tag_name(r.tag_id); - if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes(r.data[0..4].try_into().unwrap()); - let ctrl = tags::ctrl_name(ctrl_id); - eprintln!(" [{}] {} L{} {}B ctrl={} {:02X?}", - i, tag, r.level, r.data.len(), ctrl, - &r.data[..r.data.len().min(32)]); - } else { - eprintln!(" [{}] {} L{} {}B {:02X?}", - i, tag, r.level, r.data.len(), - &r.data[..r.data.len().min(32)]); - } + // 원본의 마지막 20개 레코드도 비교 + eprintln!("\n--- 원본 파일 Section0: 마지막 20개 레코드 ---"); + let start = if orig_recs.len() > 20 { + orig_recs.len() - 20 + } else { + 0 + }; + for i in start..orig_recs.len() { + let r = &orig_recs[i]; + let tag = tags::tag_name(r.tag_id); + if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes(r.data[0..4].try_into().unwrap()); + let ctrl = tags::ctrl_name(ctrl_id); + eprintln!( + " [{}] {} L{} {}B ctrl={} {:02X?}", + i, + tag, + r.level, + r.data.len(), + ctrl, + &r.data[..r.data.len().min(32)] + ); + } else { + eprintln!( + " [{}] {} L{} {}B {:02X?}", + i, + tag, + r.level, + r.data.len(), + &r.data[..r.data.len().min(32)] + ); } + } - // 레코드 전체 비교: 첫 차이 위치 찾기 - eprintln!("\n--- 레코드 비교 (전체) ---"); - let max_recs = orig_recs.len().max(saved_recs.len()); - let mut first_rec_diff = None; - let mut total_diffs = 0; - for i in 0..max_recs { - if i >= orig_recs.len() { - if first_rec_diff.is_none() { first_rec_diff = Some(i); } - total_diffs += 1; - if total_diffs <= 15 { - eprintln!(" [{}] 원본에 없음, 저장: {} L{} {}B", - i, tags::tag_name(saved_recs[i].tag_id), saved_recs[i].level, saved_recs[i].data.len()); - } - continue; - } - if i >= saved_recs.len() { - if first_rec_diff.is_none() { first_rec_diff = Some(i); } - total_diffs += 1; - if total_diffs <= 15 { - eprintln!(" [{}] 저장에 없음, 원본: {} L{} {}B", - i, tags::tag_name(orig_recs[i].tag_id), orig_recs[i].level, orig_recs[i].data.len()); - } - continue; + // 레코드 전체 비교: 첫 차이 위치 찾기 + eprintln!("\n--- 레코드 비교 (전체) ---"); + let max_recs = orig_recs.len().max(saved_recs.len()); + let mut first_rec_diff = None; + let mut total_diffs = 0; + for i in 0..max_recs { + if i >= orig_recs.len() { + if first_rec_diff.is_none() { + first_rec_diff = Some(i); + } + total_diffs += 1; + if total_diffs <= 15 { + eprintln!( + " [{}] 원본에 없음, 저장: {} L{} {}B", + i, + tags::tag_name(saved_recs[i].tag_id), + saved_recs[i].level, + saved_recs[i].data.len() + ); } - let o = &orig_recs[i]; - let s = &saved_recs[i]; - if o.tag_id != s.tag_id || o.level != s.level || o.data != s.data { - if first_rec_diff.is_none() { first_rec_diff = Some(i); } - total_diffs += 1; - if total_diffs <= 15 { - let otag = tags::tag_name(o.tag_id); - let stag = tags::tag_name(s.tag_id); - eprintln!(" [{}] 차이:", i); - eprintln!(" 원본: {} L{} {}B {:02X?}", otag, o.level, o.data.len(), &o.data[..o.data.len().min(40)]); - eprintln!(" 저장: {} L{} {}B {:02X?}", stag, s.level, s.data.len(), &s.data[..s.data.len().min(40)]); - } + continue; + } + if i >= saved_recs.len() { + if first_rec_diff.is_none() { + first_rec_diff = Some(i); + } + total_diffs += 1; + if total_diffs <= 15 { + eprintln!( + " [{}] 저장에 없음, 원본: {} L{} {}B", + i, + tags::tag_name(orig_recs[i].tag_id), + orig_recs[i].level, + orig_recs[i].data.len() + ); } + continue; } - eprintln!("첫 차이 레코드 인덱스: {:?}, 총 차이 레코드: {}", first_rec_diff, total_diffs); - if total_diffs > 15 { - eprintln!(" (15개 이후 생략, 총 {}개 차이)", total_diffs); + let o = &orig_recs[i]; + let s = &saved_recs[i]; + if o.tag_id != s.tag_id || o.level != s.level || o.data != s.data { + if first_rec_diff.is_none() { + first_rec_diff = Some(i); + } + total_diffs += 1; + if total_diffs <= 15 { + let otag = tags::tag_name(o.tag_id); + let stag = tags::tag_name(s.tag_id); + eprintln!(" [{}] 차이:", i); + eprintln!( + " 원본: {} L{} {}B {:02X?}", + otag, + o.level, + o.data.len(), + &o.data[..o.data.len().min(40)] + ); + eprintln!( + " 저장: {} L{} {}B {:02X?}", + stag, + s.level, + s.data.len(), + &s.data[..s.data.len().min(40)] + ); + } } - - eprintln!("\n=== 원본 vs 저장 파일 비교 완료 ==="); + } + eprintln!( + "첫 차이 레코드 인덱스: {:?}, 총 차이 레코드: {}", + first_rec_diff, total_diffs + ); + if total_diffs > 15 { + eprintln!(" (15개 이후 생략, 총 {}개 차이)", total_diffs); } + eprintln!("\n=== 원본 vs 저장 파일 비교 완료 ==="); +} + +#[test] +fn test_pasted_table_structure_analysis() { + use crate::parser::cfb_reader::CfbReader; + use crate::parser::record::Record; + use crate::parser::tags; + use std::path::Path; + + let orig_path = Path::new("pasts/20250130-hongbo-p2.hwp"); + let saved_path = Path::new("pasts/20250130-hongbo_saved-rp-003.hwp"); + if !orig_path.exists() || !saved_path.exists() { + eprintln!("파일 없음 — 건너뜀"); + return; + } - #[test] - fn test_pasted_table_structure_analysis() { - use std::path::Path; - use crate::parser::record::Record; - use crate::parser::tags; - use crate::parser::cfb_reader::CfbReader; - - let orig_path = Path::new("pasts/20250130-hongbo-p2.hwp"); - let saved_path = Path::new("pasts/20250130-hongbo_saved-rp-003.hwp"); - if !orig_path.exists() || !saved_path.exists() { - eprintln!("파일 없음 — 건너뜀"); - return; - } - - let orig_data = std::fs::read(orig_path).unwrap(); - let saved_data = std::fs::read(saved_path).unwrap(); - - // Parse file headers to get compression flags - let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); - let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); - - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== PASTED TABLE STRUCTURE ANALYSIS ==="); - eprintln!("Original: {} ({} bytes)", orig_path.display(), orig_data.len()); - eprintln!("Saved: {} ({} bytes)", saved_path.display(), saved_data.len()); - eprintln!("{}", "=".repeat(120)); - - // Read raw Section0 bytes - let mut orig_cfb = CfbReader::open(&orig_data).unwrap(); - let mut saved_cfb = CfbReader::open(&saved_data).unwrap(); - let orig_bt = orig_cfb.read_body_text_section(0, orig_doc.header.compressed, false).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, saved_doc.header.compressed, false).unwrap(); - - let orig_recs = Record::read_all(&orig_bt).unwrap(); - let saved_recs = Record::read_all(&saved_bt).unwrap(); - - eprintln!("\nOriginal records: {}", orig_recs.len()); - eprintln!("Saved records: {}", saved_recs.len()); - - // Helper: hex dump with printable ASCII - fn hex_dump(data: &[u8], max_bytes: usize) -> String { - let show = data.len().min(max_bytes); - let mut s = String::new(); - for (i, chunk) in data[..show].chunks(16).enumerate() { - s.push_str(&format!(" {:04X}: ", i * 16)); - for b in chunk { - s.push_str(&format!("{:02X} ", b)); - } - // Pad for alignment - for _ in 0..(16 - chunk.len()) { - s.push_str(" "); - } - s.push_str(" |"); - for b in chunk { - if *b >= 0x20 && *b < 0x7F { - s.push(*b as char); - } else { - s.push('.'); - } + let orig_data = std::fs::read(orig_path).unwrap(); + let saved_data = std::fs::read(saved_path).unwrap(); + + // Parse file headers to get compression flags + let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); + let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); + + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== PASTED TABLE STRUCTURE ANALYSIS ==="); + eprintln!( + "Original: {} ({} bytes)", + orig_path.display(), + orig_data.len() + ); + eprintln!( + "Saved: {} ({} bytes)", + saved_path.display(), + saved_data.len() + ); + eprintln!("{}", "=".repeat(120)); + + // Read raw Section0 bytes + let mut orig_cfb = CfbReader::open(&orig_data).unwrap(); + let mut saved_cfb = CfbReader::open(&saved_data).unwrap(); + let orig_bt = orig_cfb + .read_body_text_section(0, orig_doc.header.compressed, false) + .unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_doc.header.compressed, false) + .unwrap(); + + let orig_recs = Record::read_all(&orig_bt).unwrap(); + let saved_recs = Record::read_all(&saved_bt).unwrap(); + + eprintln!("\nOriginal records: {}", orig_recs.len()); + eprintln!("Saved records: {}", saved_recs.len()); + + // Helper: hex dump with printable ASCII + fn hex_dump(data: &[u8], max_bytes: usize) -> String { + let show = data.len().min(max_bytes); + let mut s = String::new(); + for (i, chunk) in data[..show].chunks(16).enumerate() { + s.push_str(&format!(" {:04X}: ", i * 16)); + for b in chunk { + s.push_str(&format!("{:02X} ", b)); + } + // Pad for alignment + for _ in 0..(16 - chunk.len()) { + s.push_str(" "); + } + s.push_str(" |"); + for b in chunk { + if *b >= 0x20 && *b < 0x7F { + s.push(*b as char); + } else { + s.push('.'); } - s.push_str("|\n"); } - if data.len() > max_bytes { - s.push_str(&format!(" ... ({} more bytes)\n", data.len() - max_bytes)); - } - s + s.push_str("|\n"); } - - // Helper: read ctrl_id from CTRL_HEADER record data - fn get_ctrl_id(data: &[u8]) -> u32 { - if data.len() >= 4 { - u32::from_le_bytes([data[0], data[1], data[2], data[3]]) - } else { - 0 - } + if data.len() > max_bytes { + s.push_str(&format!( + " ... ({} more bytes)\n", + data.len() - max_bytes + )); } + s + } - // Helper: check if ctrl_id is table ("tbl " = 0x74626C20 big-endian) - // In file: DWORD LE → bytes [0x20, 0x6C, 0x62, 0x74] - // u32::from_le_bytes gives 0x74626C20 - fn is_table_ctrl(data: &[u8]) -> bool { - if data.len() >= 4 { - let ctrl_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); - ctrl_id == tags::CTRL_TABLE - } else { - false - } + // Helper: read ctrl_id from CTRL_HEADER record data + fn get_ctrl_id(data: &[u8]) -> u32 { + if data.len() >= 4 { + u32::from_le_bytes([data[0], data[1], data[2], data[3]]) + } else { + 0 } + } - // Struct to hold a table's record cluster - struct TableCluster { - ctrl_header_idx: usize, - ctrl_header: Record, - table_rec: Option<(usize, Record)>, - list_headers: Vec<(usize, Record)>, - para_headers: Vec<(usize, Record)>, - // All records in this table's scope - all_records: Vec<(usize, Record)>, + // Helper: check if ctrl_id is table ("tbl " = 0x74626C20 big-endian) + // In file: DWORD LE → bytes [0x20, 0x6C, 0x62, 0x74] + // u32::from_le_bytes gives 0x74626C20 + fn is_table_ctrl(data: &[u8]) -> bool { + if data.len() >= 4 { + let ctrl_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); + ctrl_id == tags::CTRL_TABLE + } else { + false } + } - // Find table clusters in a record list - fn find_table_clusters(recs: &[Record]) -> Vec { - let mut clusters = Vec::new(); - let mut i = 0; - while i < recs.len() { - if recs[i].tag_id == tags::HWPTAG_CTRL_HEADER && is_table_ctrl(&recs[i].data) { - let ctrl_level = recs[i].level; - let mut cluster = TableCluster { - ctrl_header_idx: i, - ctrl_header: recs[i].clone(), - table_rec: None, - list_headers: Vec::new(), - para_headers: Vec::new(), - all_records: vec![(i, recs[i].clone())], - }; - // Collect all child records (level > ctrl_level) - let mut j = i + 1; - while j < recs.len() && recs[j].level > ctrl_level { - cluster.all_records.push((j, recs[j].clone())); - if recs[j].tag_id == tags::HWPTAG_TABLE && cluster.table_rec.is_none() { - cluster.table_rec = Some((j, recs[j].clone())); - } - if recs[j].tag_id == tags::HWPTAG_LIST_HEADER { - cluster.list_headers.push((j, recs[j].clone())); - } - if recs[j].tag_id == tags::HWPTAG_PARA_HEADER { - cluster.para_headers.push((j, recs[j].clone())); - } - j += 1; + // Struct to hold a table's record cluster + struct TableCluster { + ctrl_header_idx: usize, + ctrl_header: Record, + table_rec: Option<(usize, Record)>, + list_headers: Vec<(usize, Record)>, + para_headers: Vec<(usize, Record)>, + // All records in this table's scope + all_records: Vec<(usize, Record)>, + } + + // Find table clusters in a record list + fn find_table_clusters(recs: &[Record]) -> Vec { + let mut clusters = Vec::new(); + let mut i = 0; + while i < recs.len() { + if recs[i].tag_id == tags::HWPTAG_CTRL_HEADER && is_table_ctrl(&recs[i].data) { + let ctrl_level = recs[i].level; + let mut cluster = TableCluster { + ctrl_header_idx: i, + ctrl_header: recs[i].clone(), + table_rec: None, + list_headers: Vec::new(), + para_headers: Vec::new(), + all_records: vec![(i, recs[i].clone())], + }; + // Collect all child records (level > ctrl_level) + let mut j = i + 1; + while j < recs.len() && recs[j].level > ctrl_level { + cluster.all_records.push((j, recs[j].clone())); + if recs[j].tag_id == tags::HWPTAG_TABLE && cluster.table_rec.is_none() { + cluster.table_rec = Some((j, recs[j].clone())); } - clusters.push(cluster); - i = j; - } else { - i += 1; + if recs[j].tag_id == tags::HWPTAG_LIST_HEADER { + cluster.list_headers.push((j, recs[j].clone())); + } + if recs[j].tag_id == tags::HWPTAG_PARA_HEADER { + cluster.para_headers.push((j, recs[j].clone())); + } + j += 1; } + clusters.push(cluster); + i = j; + } else { + i += 1; } - clusters } + clusters + } - // Debug: list all CTRL_HEADER records and their ctrl_ids - eprintln!("\n--- All CTRL_HEADER records in Original ---"); - for (i, r) in orig_recs.iter().enumerate() { - if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes(r.data[0..4].try_into().unwrap()); - let ctrl_bytes = &r.data[0..4]; - let is_tbl = ctrl_id == tags::CTRL_TABLE; - eprintln!(" [{}] CTRL_HEADER L{} {}B ctrl_id=0x{:08X} bytes=[{:02X} {:02X} {:02X} {:02X}] name={} is_table={}", + // Debug: list all CTRL_HEADER records and their ctrl_ids + eprintln!("\n--- All CTRL_HEADER records in Original ---"); + for (i, r) in orig_recs.iter().enumerate() { + if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes(r.data[0..4].try_into().unwrap()); + let ctrl_bytes = &r.data[0..4]; + let is_tbl = ctrl_id == tags::CTRL_TABLE; + eprintln!(" [{}] CTRL_HEADER L{} {}B ctrl_id=0x{:08X} bytes=[{:02X} {:02X} {:02X} {:02X}] name={} is_table={}", i, r.level, r.data.len(), ctrl_id, ctrl_bytes[0], ctrl_bytes[1], ctrl_bytes[2], ctrl_bytes[3], tags::ctrl_name(ctrl_id), is_tbl); - } } - eprintln!("\n--- All CTRL_HEADER records in Saved ---"); - for (i, r) in saved_recs.iter().enumerate() { - if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes(r.data[0..4].try_into().unwrap()); - let ctrl_bytes = &r.data[0..4]; - let is_tbl = ctrl_id == tags::CTRL_TABLE; - eprintln!(" [{}] CTRL_HEADER L{} {}B ctrl_id=0x{:08X} bytes=[{:02X} {:02X} {:02X} {:02X}] name={} is_table={}", + } + eprintln!("\n--- All CTRL_HEADER records in Saved ---"); + for (i, r) in saved_recs.iter().enumerate() { + if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes(r.data[0..4].try_into().unwrap()); + let ctrl_bytes = &r.data[0..4]; + let is_tbl = ctrl_id == tags::CTRL_TABLE; + eprintln!(" [{}] CTRL_HEADER L{} {}B ctrl_id=0x{:08X} bytes=[{:02X} {:02X} {:02X} {:02X}] name={} is_table={}", i, r.level, r.data.len(), ctrl_id, ctrl_bytes[0], ctrl_bytes[1], ctrl_bytes[2], ctrl_bytes[3], tags::ctrl_name(ctrl_id), is_tbl); - } } + } - let orig_tables = find_table_clusters(&orig_recs); - let saved_tables = find_table_clusters(&saved_recs); - - eprintln!("\n--- Table Count ---"); - eprintln!("Original tables: {}", orig_tables.len()); - eprintln!("Saved tables: {}", saved_tables.len()); - - // Print summary of each table - fn print_table_summary(label: &str, tables: &[TableCluster]) { - eprintln!("\n--- {} Table Summary ---", label); - for (ti, t) in tables.iter().enumerate() { - let ctrl_id = get_ctrl_id(&t.ctrl_header.data); - let ctrl_id_bytes = ctrl_id.to_le_bytes(); - let ctrl_str: String = ctrl_id_bytes.iter().rev().map(|b| { - if *b >= 0x20 && *b < 0x7F { *b as char } else { '?' } - }).collect(); - eprintln!(" Table[{}] at rec[{}]: ctrl_id=0x{:08X} '{}' level={} total_children={} cells(LIST_HEADER)={} paras(PARA_HEADER)={}", + let orig_tables = find_table_clusters(&orig_recs); + let saved_tables = find_table_clusters(&saved_recs); + + eprintln!("\n--- Table Count ---"); + eprintln!("Original tables: {}", orig_tables.len()); + eprintln!("Saved tables: {}", saved_tables.len()); + + // Print summary of each table + fn print_table_summary(label: &str, tables: &[TableCluster]) { + eprintln!("\n--- {} Table Summary ---", label); + for (ti, t) in tables.iter().enumerate() { + let ctrl_id = get_ctrl_id(&t.ctrl_header.data); + let ctrl_id_bytes = ctrl_id.to_le_bytes(); + let ctrl_str: String = ctrl_id_bytes + .iter() + .rev() + .map(|b| { + if *b >= 0x20 && *b < 0x7F { + *b as char + } else { + '?' + } + }) + .collect(); + eprintln!(" Table[{}] at rec[{}]: ctrl_id=0x{:08X} '{}' level={} total_children={} cells(LIST_HEADER)={} paras(PARA_HEADER)={}", ti, t.ctrl_header_idx, ctrl_id, ctrl_str, t.ctrl_header.level, t.all_records.len() - 1, t.list_headers.len(), t.para_headers.len() ); - // Table record info - if let Some((idx, ref tr)) = t.table_rec { - eprintln!(" TABLE record at [{}]: size={} bytes", idx, tr.data.len()); - if tr.data.len() >= 8 { - let flags = u32::from_le_bytes(tr.data[0..4].try_into().unwrap()); - let nrows = u16::from_le_bytes(tr.data[4..6].try_into().unwrap()); - let ncols = u16::from_le_bytes(tr.data[6..8].try_into().unwrap()); - eprintln!(" TABLE: flags=0x{:08X} nrows={} ncols={} (expected cells={})", - flags, nrows, ncols, nrows as u32 * ncols as u32); - } - } else { - eprintln!(" TABLE record: MISSING!"); + // Table record info + if let Some((idx, ref tr)) = t.table_rec { + eprintln!( + " TABLE record at [{}]: size={} bytes", + idx, + tr.data.len() + ); + if tr.data.len() >= 8 { + let flags = u32::from_le_bytes(tr.data[0..4].try_into().unwrap()); + let nrows = u16::from_le_bytes(tr.data[4..6].try_into().unwrap()); + let ncols = u16::from_le_bytes(tr.data[6..8].try_into().unwrap()); + eprintln!( + " TABLE: flags=0x{:08X} nrows={} ncols={} (expected cells={})", + flags, + nrows, + ncols, + nrows as u32 * ncols as u32 + ); } + } else { + eprintln!(" TABLE record: MISSING!"); } } + } + + print_table_summary("Original", &orig_tables); + print_table_summary("Saved", &saved_tables); + + // Detailed analysis of each table + fn dump_table_detail(label: &str, t: &TableCluster) { + eprintln!( + "\n === {} Table at rec[{}] DETAILED ===", + label, t.ctrl_header_idx + ); + + // 1. CTRL_HEADER full dump + eprintln!( + "\n [CTRL_HEADER] rec[{}] level={} size={} bytes:", + t.ctrl_header_idx, + t.ctrl_header.level, + t.ctrl_header.data.len() + ); + eprintln!("{}", hex_dump(&t.ctrl_header.data, 256)); + + // Parse CTRL_HEADER fields + if t.ctrl_header.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes(t.ctrl_header.data[0..4].try_into().unwrap()); + eprintln!(" ctrl_id = 0x{:08X}", ctrl_id); + } + if t.ctrl_header.data.len() >= 8 { + let obj_attr = u32::from_le_bytes(t.ctrl_header.data[4..8].try_into().unwrap()); + eprintln!(" obj_attr = 0x{:08X}", obj_attr); + let vert_offset = obj_attr & 0x3; + let horiz_offset = (obj_attr >> 2) & 0x3; + let vert_rel = (obj_attr >> 4) & 0x3; + let horiz_rel = (obj_attr >> 7) & 0x3; + let flow_with_text = (obj_attr >> 10) & 0x1; + let allow_overlap = (obj_attr >> 11) & 0x1; + let wid_criterion = (obj_attr >> 12) & 0x7; + let hgt_criterion = (obj_attr >> 15) & 0x3; + let protect_size = (obj_attr >> 17) & 0x1; + let text_flow = (obj_attr >> 21) & 0x7; + let text_arrange = (obj_attr >> 24) & 0x3; + eprintln!( + " vert_offset={} horiz_offset={} vert_rel={} horiz_rel={}", + vert_offset, horiz_offset, vert_rel, horiz_rel + ); + eprintln!( + " flow_with_text={} allow_overlap={} wid_criterion={} hgt_criterion={}", + flow_with_text, allow_overlap, wid_criterion, hgt_criterion + ); + eprintln!( + " protect_size={} text_flow={} text_arrange={}", + protect_size, text_flow, text_arrange + ); + } + if t.ctrl_header.data.len() >= 12 { + let vert_pos = u32::from_le_bytes(t.ctrl_header.data[8..12].try_into().unwrap()); + eprintln!(" vert_offset_value = {} hwpunit", vert_pos); + } + if t.ctrl_header.data.len() >= 16 { + let horiz_pos = u32::from_le_bytes(t.ctrl_header.data[12..16].try_into().unwrap()); + eprintln!(" horiz_offset_value = {} hwpunit", horiz_pos); + } + if t.ctrl_header.data.len() >= 20 { + let width = u32::from_le_bytes(t.ctrl_header.data[16..20].try_into().unwrap()); + eprintln!( + " width = {} hwpunit ({:.2} mm)", + width, + width as f64 / 7200.0 * 25.4 + ); + } + if t.ctrl_header.data.len() >= 24 { + let height = u32::from_le_bytes(t.ctrl_header.data[20..24].try_into().unwrap()); + eprintln!( + " height = {} hwpunit ({:.2} mm)", + height, + height as f64 / 7200.0 * 25.4 + ); + } + if t.ctrl_header.data.len() >= 28 { + let zorder = i32::from_le_bytes(t.ctrl_header.data[24..28].try_into().unwrap()); + eprintln!(" z_order = {}", zorder); + } - print_table_summary("Original", &orig_tables); - print_table_summary("Saved", &saved_tables); + // 2. TABLE record full dump + if let Some((idx, ref tr)) = t.table_rec { + eprintln!( + "\n [TABLE] rec[{}] level={} size={} bytes:", + idx, + tr.level, + tr.data.len() + ); + eprintln!("{}", hex_dump(&tr.data, 512)); + + // Parse TABLE fields + if tr.data.len() >= 8 { + let flags = u32::from_le_bytes(tr.data[0..4].try_into().unwrap()); + let nrows = u16::from_le_bytes(tr.data[4..6].try_into().unwrap()); + let ncols = u16::from_le_bytes(tr.data[6..8].try_into().unwrap()); + eprintln!(" flags=0x{:08X} nrows={} ncols={}", flags, nrows, ncols); + + // Cell spacing (4 bytes) + if tr.data.len() >= 12 { + let cell_spacing = u32::from_le_bytes(tr.data[8..12].try_into().unwrap()); + eprintln!(" cell_spacing={}", cell_spacing); + } + + // Margins: left, right, top, bottom (each 2 bytes) + if tr.data.len() >= 20 { + let ml = u16::from_le_bytes(tr.data[12..14].try_into().unwrap()); + let mr = u16::from_le_bytes(tr.data[14..16].try_into().unwrap()); + let mt = u16::from_le_bytes(tr.data[16..18].try_into().unwrap()); + let mb = u16::from_le_bytes(tr.data[18..20].try_into().unwrap()); + eprintln!( + " margins: left={} right={} top={} bottom={}", + ml, mr, mt, mb + ); + } - // Detailed analysis of each table - fn dump_table_detail(label: &str, t: &TableCluster) { - eprintln!("\n === {} Table at rec[{}] DETAILED ===", label, t.ctrl_header_idx); + // Row sizes (nrows * 2 bytes starting at offset 20) + let row_sizes_offset = 20; + let row_sizes_end = row_sizes_offset + (nrows as usize * 2); + if tr.data.len() >= row_sizes_end { + let mut row_sizes = Vec::new(); + for r in 0..nrows as usize { + let off = row_sizes_offset + r * 2; + let rs = u16::from_le_bytes(tr.data[off..off + 2].try_into().unwrap()); + row_sizes.push(rs); + } + eprintln!(" row_sizes: {:?}", row_sizes); + } - // 1. CTRL_HEADER full dump - eprintln!("\n [CTRL_HEADER] rec[{}] level={} size={} bytes:", - t.ctrl_header_idx, t.ctrl_header.level, t.ctrl_header.data.len()); - eprintln!("{}", hex_dump(&t.ctrl_header.data, 256)); + // Border fill ID after row sizes + let bf_offset = row_sizes_end; + if tr.data.len() >= bf_offset + 2 { + let bf_id = + u16::from_le_bytes(tr.data[bf_offset..bf_offset + 2].try_into().unwrap()); + eprintln!(" border_fill_id={}", bf_id); + } - // Parse CTRL_HEADER fields - if t.ctrl_header.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes(t.ctrl_header.data[0..4].try_into().unwrap()); - eprintln!(" ctrl_id = 0x{:08X}", ctrl_id); - } - if t.ctrl_header.data.len() >= 8 { - let obj_attr = u32::from_le_bytes(t.ctrl_header.data[4..8].try_into().unwrap()); - eprintln!(" obj_attr = 0x{:08X}", obj_attr); - let vert_offset = (obj_attr >> 0) & 0x3; - let horiz_offset = (obj_attr >> 2) & 0x3; - let vert_rel = (obj_attr >> 4) & 0x3; - let horiz_rel = (obj_attr >> 7) & 0x3; - let flow_with_text = (obj_attr >> 10) & 0x1; - let allow_overlap = (obj_attr >> 11) & 0x1; - let wid_criterion = (obj_attr >> 12) & 0x7; - let hgt_criterion = (obj_attr >> 15) & 0x3; - let protect_size = (obj_attr >> 17) & 0x1; - let text_flow = (obj_attr >> 21) & 0x7; - let text_arrange = (obj_attr >> 24) & 0x3; - eprintln!(" vert_offset={} horiz_offset={} vert_rel={} horiz_rel={}", vert_offset, horiz_offset, vert_rel, horiz_rel); - eprintln!(" flow_with_text={} allow_overlap={} wid_criterion={} hgt_criterion={}", flow_with_text, allow_overlap, wid_criterion, hgt_criterion); - eprintln!(" protect_size={} text_flow={} text_arrange={}", protect_size, text_flow, text_arrange); + // Remaining bytes after all parsed fields + let parsed_end = bf_offset + 2; + if tr.data.len() > parsed_end { + eprintln!( + " remaining {} bytes after parsed fields:", + tr.data.len() - parsed_end + ); + eprintln!("{}", hex_dump(&tr.data[parsed_end..], 128)); + } } - if t.ctrl_header.data.len() >= 12 { - let vert_pos = u32::from_le_bytes(t.ctrl_header.data[8..12].try_into().unwrap()); - eprintln!(" vert_offset_value = {} hwpunit", vert_pos); + } + + // 3. LIST_HEADER records (cells) - first 5 + let cell_count = t.list_headers.len().min(5); + eprintln!( + "\n [LIST_HEADER / Cells] total={}, showing first {}:", + t.list_headers.len(), + cell_count + ); + for ci in 0..cell_count { + let (idx, ref lh) = t.list_headers[ci]; + eprintln!( + "\n Cell[{}] LIST_HEADER rec[{}] level={} size={} bytes:", + ci, + idx, + lh.level, + lh.data.len() + ); + eprintln!("{}", hex_dump(&lh.data, 256)); + + // Parse LIST_HEADER cell fields + if lh.data.len() >= 2 { + let num_paras = u16::from_le_bytes(lh.data[0..2].try_into().unwrap()); + eprintln!(" num_paras = {}", num_paras); + } + if lh.data.len() >= 6 { + let prop = u32::from_le_bytes(lh.data[2..6].try_into().unwrap()); + eprintln!(" property = 0x{:08X}", prop); + } + // Cell-specific fields (after list header base) + // The cell list header typically has: nParagraphs(2), property(4), + // then cell-specific: col_addr(2), row_addr(2), col_span(2), row_span(2), + // width(4), height(4), margins(4*2=8), border_fill_id(2) + if lh.data.len() >= 8 { + let col_addr = u16::from_le_bytes(lh.data[6..8].try_into().unwrap()); + eprintln!(" col_addr = {}", col_addr); + } + if lh.data.len() >= 10 { + let row_addr = u16::from_le_bytes(lh.data[8..10].try_into().unwrap()); + eprintln!(" row_addr = {}", row_addr); + } + if lh.data.len() >= 12 { + let col_span = u16::from_le_bytes(lh.data[10..12].try_into().unwrap()); + eprintln!(" col_span = {}", col_span); + } + if lh.data.len() >= 14 { + let row_span = u16::from_le_bytes(lh.data[12..14].try_into().unwrap()); + eprintln!(" row_span = {}", row_span); + } + if lh.data.len() >= 18 { + let width = u32::from_le_bytes(lh.data[14..18].try_into().unwrap()); + eprintln!( + " width = {} hwpunit ({:.2} mm)", + width, + width as f64 / 7200.0 * 25.4 + ); } - if t.ctrl_header.data.len() >= 16 { - let horiz_pos = u32::from_le_bytes(t.ctrl_header.data[12..16].try_into().unwrap()); - eprintln!(" horiz_offset_value = {} hwpunit", horiz_pos); + if lh.data.len() >= 22 { + let height = u32::from_le_bytes(lh.data[18..22].try_into().unwrap()); + eprintln!( + " height = {} hwpunit ({:.2} mm)", + height, + height as f64 / 7200.0 * 25.4 + ); } - if t.ctrl_header.data.len() >= 20 { - let width = u32::from_le_bytes(t.ctrl_header.data[16..20].try_into().unwrap()); - eprintln!(" width = {} hwpunit ({:.2} mm)", width, width as f64 / 7200.0 * 25.4); + // Margins + if lh.data.len() >= 30 { + let ml = u16::from_le_bytes(lh.data[22..24].try_into().unwrap()); + let mr = u16::from_le_bytes(lh.data[24..26].try_into().unwrap()); + let mt = u16::from_le_bytes(lh.data[26..28].try_into().unwrap()); + let mb = u16::from_le_bytes(lh.data[28..30].try_into().unwrap()); + eprintln!( + " margins: left={} right={} top={} bottom={}", + ml, mr, mt, mb + ); } - if t.ctrl_header.data.len() >= 24 { - let height = u32::from_le_bytes(t.ctrl_header.data[20..24].try_into().unwrap()); - eprintln!(" height = {} hwpunit ({:.2} mm)", height, height as f64 / 7200.0 * 25.4); + if lh.data.len() >= 32 { + let bf_id = u16::from_le_bytes(lh.data[30..32].try_into().unwrap()); + eprintln!(" border_fill_id = {}", bf_id); } - if t.ctrl_header.data.len() >= 28 { - let zorder = i32::from_le_bytes(t.ctrl_header.data[24..28].try_into().unwrap()); - eprintln!(" z_order = {}", zorder); + if lh.data.len() > 32 { + eprintln!(" remaining {} bytes:", lh.data.len() - 32); + eprintln!("{}", hex_dump(&lh.data[32..], 64)); } + } - // 2. TABLE record full dump - if let Some((idx, ref tr)) = t.table_rec { - eprintln!("\n [TABLE] rec[{}] level={} size={} bytes:", - idx, tr.level, tr.data.len()); - eprintln!("{}", hex_dump(&tr.data, 512)); - - // Parse TABLE fields - if tr.data.len() >= 8 { - let flags = u32::from_le_bytes(tr.data[0..4].try_into().unwrap()); - let nrows = u16::from_le_bytes(tr.data[4..6].try_into().unwrap()); - let ncols = u16::from_le_bytes(tr.data[6..8].try_into().unwrap()); - eprintln!(" flags=0x{:08X} nrows={} ncols={}", flags, nrows, ncols); - - // Cell spacing (4 bytes) - if tr.data.len() >= 12 { - let cell_spacing = u32::from_le_bytes(tr.data[8..12].try_into().unwrap()); - eprintln!(" cell_spacing={}", cell_spacing); - } - - // Margins: left, right, top, bottom (each 2 bytes) - if tr.data.len() >= 20 { - let ml = u16::from_le_bytes(tr.data[12..14].try_into().unwrap()); - let mr = u16::from_le_bytes(tr.data[14..16].try_into().unwrap()); - let mt = u16::from_le_bytes(tr.data[16..18].try_into().unwrap()); - let mb = u16::from_le_bytes(tr.data[18..20].try_into().unwrap()); - eprintln!(" margins: left={} right={} top={} bottom={}", ml, mr, mt, mb); - } - - // Row sizes (nrows * 2 bytes starting at offset 20) - let row_sizes_offset = 20; - let row_sizes_end = row_sizes_offset + (nrows as usize * 2); - if tr.data.len() >= row_sizes_end { - let mut row_sizes = Vec::new(); - for r in 0..nrows as usize { - let off = row_sizes_offset + r * 2; - let rs = u16::from_le_bytes(tr.data[off..off+2].try_into().unwrap()); - row_sizes.push(rs); - } - eprintln!(" row_sizes: {:?}", row_sizes); - } - - // Border fill ID after row sizes - let bf_offset = row_sizes_end; - if tr.data.len() >= bf_offset + 2 { - let bf_id = u16::from_le_bytes(tr.data[bf_offset..bf_offset+2].try_into().unwrap()); - eprintln!(" border_fill_id={}", bf_id); - } + // 4. PARA_HEADER records - first 5 + let para_count = t.para_headers.len().min(5); + eprintln!( + "\n [PARA_HEADER] total={}, showing first {}:", + t.para_headers.len(), + para_count + ); + for pi in 0..para_count { + let (idx, ref ph) = t.para_headers[pi]; + eprintln!( + "\n Para[{}] PARA_HEADER rec[{}] level={} size={} bytes:", + pi, + idx, + ph.level, + ph.data.len() + ); + eprintln!("{}", hex_dump(&ph.data, 128)); - // Remaining bytes after all parsed fields - let parsed_end = bf_offset + 2; - if tr.data.len() > parsed_end { - eprintln!(" remaining {} bytes after parsed fields:", tr.data.len() - parsed_end); - eprintln!("{}", hex_dump(&tr.data[parsed_end..], 128)); - } - } + // Parse PARA_HEADER fields + if ph.data.len() >= 4 { + let nchars = u32::from_le_bytes(ph.data[0..4].try_into().unwrap()); + let n_char_shapes = if ph.data.len() >= 6 { + u16::from_le_bytes(ph.data[4..6].try_into().unwrap()) + } else { + 0 + }; + let n_line_segs = if ph.data.len() >= 8 { + u16::from_le_bytes(ph.data[6..8].try_into().unwrap()) + } else { + 0 + }; + let n_range_tags = if ph.data.len() >= 10 { + u16::from_le_bytes(ph.data[8..10].try_into().unwrap()) + } else { + 0 + }; + let n_controls = if ph.data.len() >= 12 { + u16::from_le_bytes(ph.data[10..12].try_into().unwrap()) + } else { + 0 + }; + let para_shape_id = if ph.data.len() >= 14 { + u16::from_le_bytes(ph.data[12..14].try_into().unwrap()) + } else { + 0 + }; + let style_id = if ph.data.len() >= 15 { ph.data[14] } else { 0 }; + eprintln!( + " nchars={} n_char_shapes={} n_line_segs={} n_range_tags={} n_controls={}", + nchars, n_char_shapes, n_line_segs, n_range_tags, n_controls + ); + eprintln!( + " para_shape_id={} style_id={}", + para_shape_id, style_id + ); } + } - // 3. LIST_HEADER records (cells) - first 5 - let cell_count = t.list_headers.len().min(5); - eprintln!("\n [LIST_HEADER / Cells] total={}, showing first {}:", t.list_headers.len(), cell_count); - for ci in 0..cell_count { - let (idx, ref lh) = t.list_headers[ci]; - eprintln!("\n Cell[{}] LIST_HEADER rec[{}] level={} size={} bytes:", ci, idx, lh.level, lh.data.len()); - eprintln!("{}", hex_dump(&lh.data, 256)); + // 5. Full record type breakdown + let mut tag_counts: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for (_, rec) in &t.all_records { + *tag_counts.entry(rec.tag_id).or_insert(0) += 1; + } + eprintln!("\n Record type breakdown:"); + for (tag, count) in &tag_counts { + eprintln!( + " {} (tag={}): {} records", + tags::tag_name(*tag), + tag, + count + ); + } + } - // Parse LIST_HEADER cell fields - if lh.data.len() >= 2 { - let num_paras = u16::from_le_bytes(lh.data[0..2].try_into().unwrap()); - eprintln!(" num_paras = {}", num_paras); - } - if lh.data.len() >= 6 { - let prop = u32::from_le_bytes(lh.data[2..6].try_into().unwrap()); - eprintln!(" property = 0x{:08X}", prop); - } - // Cell-specific fields (after list header base) - // The cell list header typically has: nParagraphs(2), property(4), - // then cell-specific: col_addr(2), row_addr(2), col_span(2), row_span(2), - // width(4), height(4), margins(4*2=8), border_fill_id(2) - if lh.data.len() >= 8 { - let col_addr = u16::from_le_bytes(lh.data[6..8].try_into().unwrap()); - eprintln!(" col_addr = {}", col_addr); - } - if lh.data.len() >= 10 { - let row_addr = u16::from_le_bytes(lh.data[8..10].try_into().unwrap()); - eprintln!(" row_addr = {}", row_addr); - } - if lh.data.len() >= 12 { - let col_span = u16::from_le_bytes(lh.data[10..12].try_into().unwrap()); - eprintln!(" col_span = {}", col_span); - } - if lh.data.len() >= 14 { - let row_span = u16::from_le_bytes(lh.data[12..14].try_into().unwrap()); - eprintln!(" row_span = {}", row_span); - } - if lh.data.len() >= 18 { - let width = u32::from_le_bytes(lh.data[14..18].try_into().unwrap()); - eprintln!(" width = {} hwpunit ({:.2} mm)", width, width as f64 / 7200.0 * 25.4); - } - if lh.data.len() >= 22 { - let height = u32::from_le_bytes(lh.data[18..22].try_into().unwrap()); - eprintln!(" height = {} hwpunit ({:.2} mm)", height, height as f64 / 7200.0 * 25.4); - } - // Margins - if lh.data.len() >= 30 { - let ml = u16::from_le_bytes(lh.data[22..24].try_into().unwrap()); - let mr = u16::from_le_bytes(lh.data[24..26].try_into().unwrap()); - let mt = u16::from_le_bytes(lh.data[26..28].try_into().unwrap()); - let mb = u16::from_le_bytes(lh.data[28..30].try_into().unwrap()); - eprintln!(" margins: left={} right={} top={} bottom={}", ml, mr, mt, mb); - } - if lh.data.len() >= 32 { - let bf_id = u16::from_le_bytes(lh.data[30..32].try_into().unwrap()); - eprintln!(" border_fill_id = {}", bf_id); - } - if lh.data.len() > 32 { - eprintln!(" remaining {} bytes:", lh.data.len() - 32); - eprintln!("{}", hex_dump(&lh.data[32..], 64)); - } - } + // Dump all original tables + for (ti, t) in orig_tables.iter().enumerate() { + eprintln!("\n{}", "=".repeat(100)); + eprintln!("ORIGINAL Table[{}]", ti); + dump_table_detail("ORIG", t); + } - // 4. PARA_HEADER records - first 5 - let para_count = t.para_headers.len().min(5); - eprintln!("\n [PARA_HEADER] total={}, showing first {}:", t.para_headers.len(), para_count); - for pi in 0..para_count { - let (idx, ref ph) = t.para_headers[pi]; - eprintln!("\n Para[{}] PARA_HEADER rec[{}] level={} size={} bytes:", pi, idx, ph.level, ph.data.len()); - eprintln!("{}", hex_dump(&ph.data, 128)); + // Dump all saved tables + for (ti, t) in saved_tables.iter().enumerate() { + eprintln!("\n{}", "=".repeat(100)); + eprintln!("SAVED Table[{}]", ti); + dump_table_detail("SAVED", t); + } - // Parse PARA_HEADER fields - if ph.data.len() >= 4 { - let nchars = u32::from_le_bytes(ph.data[0..4].try_into().unwrap()); - let n_char_shapes = if ph.data.len() >= 6 { u16::from_le_bytes(ph.data[4..6].try_into().unwrap()) } else { 0 }; - let n_line_segs = if ph.data.len() >= 8 { u16::from_le_bytes(ph.data[6..8].try_into().unwrap()) } else { 0 }; - let n_range_tags = if ph.data.len() >= 10 { u16::from_le_bytes(ph.data[8..10].try_into().unwrap()) } else { 0 }; - let n_controls = if ph.data.len() >= 12 { u16::from_le_bytes(ph.data[10..12].try_into().unwrap()) } else { 0 }; - let para_shape_id = if ph.data.len() >= 14 { u16::from_le_bytes(ph.data[12..14].try_into().unwrap()) } else { 0 }; - let style_id = if ph.data.len() >= 15 { ph.data[14] } else { 0 }; - eprintln!(" nchars={} n_char_shapes={} n_line_segs={} n_range_tags={} n_controls={}", - nchars, n_char_shapes, n_line_segs, n_range_tags, n_controls); - eprintln!(" para_shape_id={} style_id={}", para_shape_id, style_id); - } - } - - // 5. Full record type breakdown - let mut tag_counts: std::collections::BTreeMap = std::collections::BTreeMap::new(); - for (_, rec) in &t.all_records { - *tag_counts.entry(rec.tag_id).or_insert(0) += 1; - } - eprintln!("\n Record type breakdown:"); - for (tag, count) in &tag_counts { - eprintln!(" {} (tag={}): {} records", tags::tag_name(*tag), tag, count); - } - } + // Special comparison: last saved table (pasted) vs first original table + if !saved_tables.is_empty() && !orig_tables.is_empty() { + let pasted = saved_tables.last().unwrap(); + let orig_first = &orig_tables[0]; - // Dump all original tables - for (ti, t) in orig_tables.iter().enumerate() { - eprintln!("\n{}", "=".repeat(100)); - eprintln!("ORIGINAL Table[{}]", ti); - dump_table_detail("ORIG", t); - } - - // Dump all saved tables - for (ti, t) in saved_tables.iter().enumerate() { - eprintln!("\n{}", "=".repeat(100)); - eprintln!("SAVED Table[{}]", ti); - dump_table_detail("SAVED", t); - } - - // Special comparison: last saved table (pasted) vs first original table - if !saved_tables.is_empty() && !orig_tables.is_empty() { - let pasted = saved_tables.last().unwrap(); - let orig_first = &orig_tables[0]; - - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== COMPARISON: PASTED TABLE (last saved) vs FIRST ORIGINAL TABLE ==="); - eprintln!("{}", "=".repeat(120)); - - // Compare CTRL_HEADER - eprintln!("\n--- CTRL_HEADER comparison ---"); - eprintln!("ORIG size: {} bytes", orig_first.ctrl_header.data.len()); - eprintln!("PASTED size: {} bytes", pasted.ctrl_header.data.len()); - if orig_first.ctrl_header.data == pasted.ctrl_header.data { - eprintln!("CTRL_HEADER: IDENTICAL"); - } else { - let min_len = orig_first.ctrl_header.data.len().min(pasted.ctrl_header.data.len()); - let mut diffs = Vec::new(); - for i in 0..min_len { - if orig_first.ctrl_header.data[i] != pasted.ctrl_header.data[i] { - diffs.push((i, orig_first.ctrl_header.data[i], pasted.ctrl_header.data[i])); - } - } - eprintln!("CTRL_HEADER byte diffs ({}):", diffs.len()); - for (off, a, b) in &diffs { - eprintln!(" offset {}: orig=0x{:02X} pasted=0x{:02X}", off, a, b); - } - if orig_first.ctrl_header.data.len() != pasted.ctrl_header.data.len() { - eprintln!(" SIZE DIFFERENCE: orig={} pasted={}", - orig_first.ctrl_header.data.len(), pasted.ctrl_header.data.len()); - } - } + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== COMPARISON: PASTED TABLE (last saved) vs FIRST ORIGINAL TABLE ==="); + eprintln!("{}", "=".repeat(120)); - // Compare TABLE record - eprintln!("\n--- TABLE record comparison ---"); - match (&orig_first.table_rec, &pasted.table_rec) { - (Some((_, ref ot)), Some((_, ref pt))) => { - eprintln!("ORIG TABLE size: {} bytes", ot.data.len()); - eprintln!("PASTED TABLE size: {} bytes", pt.data.len()); - if ot.data == pt.data { - eprintln!("TABLE: IDENTICAL"); - } else { - let min_len = ot.data.len().min(pt.data.len()); - let mut diffs = Vec::new(); - for i in 0..min_len { - if ot.data[i] != pt.data[i] { - diffs.push((i, ot.data[i], pt.data[i])); - } - } - eprintln!("TABLE byte diffs ({}):", diffs.len()); - for (off, a, b) in &diffs { - eprintln!(" offset {}: orig=0x{:02X} pasted=0x{:02X}", off, a, b); - } - if ot.data.len() != pt.data.len() { - eprintln!(" SIZE DIFFERENCE: orig={} pasted={}", ot.data.len(), pt.data.len()); - } - } - } - _ => { - eprintln!("One or both TABLE records MISSING!"); + // Compare CTRL_HEADER + eprintln!("\n--- CTRL_HEADER comparison ---"); + eprintln!("ORIG size: {} bytes", orig_first.ctrl_header.data.len()); + eprintln!("PASTED size: {} bytes", pasted.ctrl_header.data.len()); + if orig_first.ctrl_header.data == pasted.ctrl_header.data { + eprintln!("CTRL_HEADER: IDENTICAL"); + } else { + let min_len = orig_first + .ctrl_header + .data + .len() + .min(pasted.ctrl_header.data.len()); + let mut diffs = Vec::new(); + for i in 0..min_len { + if orig_first.ctrl_header.data[i] != pasted.ctrl_header.data[i] { + diffs.push(( + i, + orig_first.ctrl_header.data[i], + pasted.ctrl_header.data[i], + )); } } + eprintln!("CTRL_HEADER byte diffs ({}):", diffs.len()); + for (off, a, b) in &diffs { + eprintln!(" offset {}: orig=0x{:02X} pasted=0x{:02X}", off, a, b); + } + if orig_first.ctrl_header.data.len() != pasted.ctrl_header.data.len() { + eprintln!( + " SIZE DIFFERENCE: orig={} pasted={}", + orig_first.ctrl_header.data.len(), + pasted.ctrl_header.data.len() + ); + } + } - // Compare LIST_HEADER records (cells) - eprintln!("\n--- Cell LIST_HEADER comparison ---"); - eprintln!("ORIG cells: {}", orig_first.list_headers.len()); - eprintln!("PASTED cells: {}", pasted.list_headers.len()); - let compare_count = orig_first.list_headers.len().min(pasted.list_headers.len()).min(10); - for ci in 0..compare_count { - let (_, ref olh) = orig_first.list_headers[ci]; - let (_, ref plh) = pasted.list_headers[ci]; - if olh.data == plh.data { - eprintln!(" Cell[{}]: IDENTICAL ({} bytes)", ci, olh.data.len()); + // Compare TABLE record + eprintln!("\n--- TABLE record comparison ---"); + match (&orig_first.table_rec, &pasted.table_rec) { + (Some((_, ref ot)), Some((_, ref pt))) => { + eprintln!("ORIG TABLE size: {} bytes", ot.data.len()); + eprintln!("PASTED TABLE size: {} bytes", pt.data.len()); + if ot.data == pt.data { + eprintln!("TABLE: IDENTICAL"); } else { - let min_len = olh.data.len().min(plh.data.len()); + let min_len = ot.data.len().min(pt.data.len()); let mut diffs = Vec::new(); for i in 0..min_len { - if olh.data[i] != plh.data[i] { - diffs.push((i, olh.data[i], plh.data[i])); + if ot.data[i] != pt.data[i] { + diffs.push((i, ot.data[i], pt.data[i])); } } - eprintln!(" Cell[{}]: {} byte diffs, orig_size={} pasted_size={}", - ci, diffs.len(), olh.data.len(), plh.data.len()); - for (off, a, b) in diffs.iter().take(5) { - eprintln!(" offset {}: orig=0x{:02X} pasted=0x{:02X}", off, a, b); + eprintln!("TABLE byte diffs ({}):", diffs.len()); + for (off, a, b) in &diffs { + eprintln!(" offset {}: orig=0x{:02X} pasted=0x{:02X}", off, a, b); } - } - } - - // Compare record sequences - eprintln!("\n--- Record sequence comparison ---"); - eprintln!("ORIG records in table: {}", orig_first.all_records.len()); - eprintln!("PASTED records in table: {}", pasted.all_records.len()); - let seq_len = orig_first.all_records.len().min(pasted.all_records.len()); - let mut first_mismatch = None; - for i in 0..seq_len { - let (_, ref orec) = orig_first.all_records[i]; - let (_, ref prec) = pasted.all_records[i]; - if orec.tag_id != prec.tag_id || orec.level != prec.level { - if first_mismatch.is_none() { - first_mismatch = Some(i); + if ot.data.len() != pt.data.len() { + eprintln!( + " SIZE DIFFERENCE: orig={} pasted={}", + ot.data.len(), + pt.data.len() + ); } - eprintln!(" [{}] MISMATCH: orig={}(tag={},L{},{}B) vs pasted={}(tag={},L{},{}B)", - i, - tags::tag_name(orec.tag_id), orec.tag_id, orec.level, orec.data.len(), - tags::tag_name(prec.tag_id), prec.tag_id, prec.level, prec.data.len()); } } - if first_mismatch.is_none() && orig_first.all_records.len() == pasted.all_records.len() { - eprintln!(" Record sequences: IDENTICAL structure"); + _ => { + eprintln!("One or both TABLE records MISSING!"); } } - // Check level integrity of pasted table - if !saved_tables.is_empty() { - let pasted = saved_tables.last().unwrap(); - eprintln!("\n--- Level Integrity Check (Pasted Table) ---"); - let mut prev_level: i32 = -1; - let mut issues = 0; - for (idx, rec) in &pasted.all_records { - let curr_level = rec.level as i32; - if prev_level >= 0 && curr_level > prev_level + 1 { - issues += 1; - if issues <= 10 { - eprintln!(" LEVEL JUMP at [{}]: prev={} curr={} tag={} size={}", - idx, prev_level, curr_level, tags::tag_name(rec.tag_id), rec.data.len()); + // Compare LIST_HEADER records (cells) + eprintln!("\n--- Cell LIST_HEADER comparison ---"); + eprintln!("ORIG cells: {}", orig_first.list_headers.len()); + eprintln!("PASTED cells: {}", pasted.list_headers.len()); + let compare_count = orig_first + .list_headers + .len() + .min(pasted.list_headers.len()) + .min(10); + for ci in 0..compare_count { + let (_, ref olh) = orig_first.list_headers[ci]; + let (_, ref plh) = pasted.list_headers[ci]; + if olh.data == plh.data { + eprintln!(" Cell[{}]: IDENTICAL ({} bytes)", ci, olh.data.len()); + } else { + let min_len = olh.data.len().min(plh.data.len()); + let mut diffs = Vec::new(); + for i in 0..min_len { + if olh.data[i] != plh.data[i] { + diffs.push((i, olh.data[i], plh.data[i])); } } - prev_level = curr_level; + eprintln!( + " Cell[{}]: {} byte diffs, orig_size={} pasted_size={}", + ci, + diffs.len(), + olh.data.len(), + plh.data.len() + ); + for (off, a, b) in diffs.iter().take(5) { + eprintln!(" offset {}: orig=0x{:02X} pasted=0x{:02X}", off, a, b); + } + } + } + + // Compare record sequences + eprintln!("\n--- Record sequence comparison ---"); + eprintln!("ORIG records in table: {}", orig_first.all_records.len()); + eprintln!("PASTED records in table: {}", pasted.all_records.len()); + let seq_len = orig_first.all_records.len().min(pasted.all_records.len()); + let mut first_mismatch = None; + for i in 0..seq_len { + let (_, ref orec) = orig_first.all_records[i]; + let (_, ref prec) = pasted.all_records[i]; + if orec.tag_id != prec.tag_id || orec.level != prec.level { + if first_mismatch.is_none() { + first_mismatch = Some(i); + } + eprintln!( + " [{}] MISMATCH: orig={}(tag={},L{},{}B) vs pasted={}(tag={},L{},{}B)", + i, + tags::tag_name(orec.tag_id), + orec.tag_id, + orec.level, + orec.data.len(), + tags::tag_name(prec.tag_id), + prec.tag_id, + prec.level, + prec.data.len() + ); } - eprintln!(" Total level issues: {}", issues); } + if first_mismatch.is_none() && orig_first.all_records.len() == pasted.all_records.len() { + eprintln!(" Record sequences: IDENTICAL structure"); + } + } - // Context: dump records around the pasted table - if !saved_tables.is_empty() { - let pasted = saved_tables.last().unwrap(); - let start_idx = if pasted.ctrl_header_idx > 5 { pasted.ctrl_header_idx - 5 } else { 0 }; - let end_idx = (pasted.ctrl_header_idx + pasted.all_records.len() + 5).min(saved_recs.len()); - - eprintln!("\n--- Context: Records around pasted table (rec[{}..{}]) ---", start_idx, end_idx); - for i in start_idx..end_idx { - let r = &saved_recs[i]; - let marker = if i == pasted.ctrl_header_idx { " <<< PASTED TABLE START" } - else if i == pasted.ctrl_header_idx + pasted.all_records.len() - 1 { " <<< PASTED TABLE END" } - else { "" }; - if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes(r.data[0..4].try_into().unwrap()); - eprintln!(" [{}] {} L{} {}B ctrl=0x{:08X} ({}){}", - i, tags::tag_name(r.tag_id), r.level, r.data.len(), - ctrl_id, tags::ctrl_name(ctrl_id), marker); - } else { - eprintln!(" [{}] {} L{} {}B{}", - i, tags::tag_name(r.tag_id), r.level, r.data.len(), marker); + // Check level integrity of pasted table + if !saved_tables.is_empty() { + let pasted = saved_tables.last().unwrap(); + eprintln!("\n--- Level Integrity Check (Pasted Table) ---"); + let mut prev_level: i32 = -1; + let mut issues = 0; + for (idx, rec) in &pasted.all_records { + let curr_level = rec.level as i32; + if prev_level >= 0 && curr_level > prev_level + 1 { + issues += 1; + if issues <= 10 { + eprintln!( + " LEVEL JUMP at [{}]: prev={} curr={} tag={} size={}", + idx, + prev_level, + curr_level, + tags::tag_name(rec.tag_id), + rec.data.len() + ); } } + prev_level = curr_level; } + eprintln!(" Total level issues: {}", issues); + } - // Check all records after the last table in saved to see if there's corruption - if !saved_tables.is_empty() { - let pasted = saved_tables.last().unwrap(); - let after_idx = pasted.ctrl_header_idx + pasted.all_records.len(); - if after_idx < saved_recs.len() { - eprintln!("\n--- Records AFTER pasted table ({} remaining) ---", saved_recs.len() - after_idx); - for i in after_idx..(after_idx + 20).min(saved_recs.len()) { - let r = &saved_recs[i]; - if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes(r.data[0..4].try_into().unwrap()); - eprintln!(" [{}] {} L{} {}B ctrl=0x{:08X} ({})", - i, tags::tag_name(r.tag_id), r.level, r.data.len(), - ctrl_id, tags::ctrl_name(ctrl_id)); - } else { - eprintln!(" [{}] {} L{} {}B first16: {:02X?}", - i, tags::tag_name(r.tag_id), r.level, r.data.len(), - &r.data[..r.data.len().min(16)]); - } - } + // Context: dump records around the pasted table + if !saved_tables.is_empty() { + let pasted = saved_tables.last().unwrap(); + let start_idx = if pasted.ctrl_header_idx > 5 { + pasted.ctrl_header_idx - 5 + } else { + 0 + }; + let end_idx = (pasted.ctrl_header_idx + pasted.all_records.len() + 5).min(saved_recs.len()); + + eprintln!( + "\n--- Context: Records around pasted table (rec[{}..{}]) ---", + start_idx, end_idx + ); + for i in start_idx..end_idx { + let r = &saved_recs[i]; + let marker = if i == pasted.ctrl_header_idx { + " <<< PASTED TABLE START" + } else if i == pasted.ctrl_header_idx + pasted.all_records.len() - 1 { + " <<< PASTED TABLE END" } else { - eprintln!("\n--- No records after pasted table (table is last content) ---"); + "" + }; + if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes(r.data[0..4].try_into().unwrap()); + eprintln!( + " [{}] {} L{} {}B ctrl=0x{:08X} ({}){}", + i, + tags::tag_name(r.tag_id), + r.level, + r.data.len(), + ctrl_id, + tags::ctrl_name(ctrl_id), + marker + ); + } else { + eprintln!( + " [{}] {} L{} {}B{}", + i, + tags::tag_name(r.tag_id), + r.level, + r.data.len(), + marker + ); } } + } - // Overall record comparison: saved vs original - eprintln!("\n--- Overall Record Count by Type ---"); - let mut orig_counts: std::collections::BTreeMap = std::collections::BTreeMap::new(); - let mut saved_counts: std::collections::BTreeMap = std::collections::BTreeMap::new(); - for r in &orig_recs { - *orig_counts.entry(r.tag_id).or_insert(0) += 1; - } - for r in &saved_recs { - *saved_counts.entry(r.tag_id).or_insert(0) += 1; - } - let all_tags: std::collections::BTreeSet = orig_counts.keys().chain(saved_counts.keys()).copied().collect(); - eprintln!("{:<25} {:>6} {:>6} {:>6}", "Tag", "Orig", "Saved", "Diff"); - for tag in &all_tags { - let oc = orig_counts.get(tag).copied().unwrap_or(0); - let sc = saved_counts.get(tag).copied().unwrap_or(0); - let diff = sc as i64 - oc as i64; - if diff != 0 { - eprintln!("{:<25} {:>6} {:>6} {:>+6}", tags::tag_name(*tag), oc, sc, diff); + // Check all records after the last table in saved to see if there's corruption + if !saved_tables.is_empty() { + let pasted = saved_tables.last().unwrap(); + let after_idx = pasted.ctrl_header_idx + pasted.all_records.len(); + if after_idx < saved_recs.len() { + eprintln!( + "\n--- Records AFTER pasted table ({} remaining) ---", + saved_recs.len() - after_idx + ); + for i in after_idx..(after_idx + 20).min(saved_recs.len()) { + let r = &saved_recs[i]; + if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes(r.data[0..4].try_into().unwrap()); + eprintln!( + " [{}] {} L{} {}B ctrl=0x{:08X} ({})", + i, + tags::tag_name(r.tag_id), + r.level, + r.data.len(), + ctrl_id, + tags::ctrl_name(ctrl_id) + ); + } else { + eprintln!( + " [{}] {} L{} {}B first16: {:02X?}", + i, + tags::tag_name(r.tag_id), + r.level, + r.data.len(), + &r.data[..r.data.len().min(16)] + ); + } } + } else { + eprintln!("\n--- No records after pasted table (table is last content) ---"); } - - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== PASTED TABLE STRUCTURE ANALYSIS COMPLETE ==="); - eprintln!("{}", "=".repeat(120)); } - #[test] - fn test_table3_deep_comparison() { - use std::path::Path; - use crate::parser::record::Record; - use crate::parser::tags; - use crate::parser::cfb_reader::CfbReader; - - let orig_path = Path::new("pasts/20250130-hongbo-p2.hwp"); - let saved_path = Path::new("pasts/20250130-hongbo_saved-rp-003.hwp"); - if !orig_path.exists() || !saved_path.exists() { - eprintln!("파일 없음 — 건너뜀"); - return; + // Overall record comparison: saved vs original + eprintln!("\n--- Overall Record Count by Type ---"); + let mut orig_counts: std::collections::BTreeMap = std::collections::BTreeMap::new(); + let mut saved_counts: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for r in &orig_recs { + *orig_counts.entry(r.tag_id).or_insert(0) += 1; + } + for r in &saved_recs { + *saved_counts.entry(r.tag_id).or_insert(0) += 1; + } + let all_tags: std::collections::BTreeSet = orig_counts + .keys() + .chain(saved_counts.keys()) + .copied() + .collect(); + eprintln!("{:<25} {:>6} {:>6} {:>6}", "Tag", "Orig", "Saved", "Diff"); + for tag in &all_tags { + let oc = orig_counts.get(tag).copied().unwrap_or(0); + let sc = saved_counts.get(tag).copied().unwrap_or(0); + let diff = sc as i64 - oc as i64; + if diff != 0 { + eprintln!( + "{:<25} {:>6} {:>6} {:>+6}", + tags::tag_name(*tag), + oc, + sc, + diff + ); } + } - let orig_data = std::fs::read(orig_path).unwrap(); - let saved_data = std::fs::read(saved_path).unwrap(); - let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); - let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); - - let mut orig_cfb = CfbReader::open(&orig_data).unwrap(); - let mut saved_cfb = CfbReader::open(&saved_data).unwrap(); - let orig_bt = orig_cfb.read_body_text_section(0, orig_doc.header.compressed, false).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, saved_doc.header.compressed, false).unwrap(); - - let orig_recs = Record::read_all(&orig_bt).unwrap(); - let saved_recs = Record::read_all(&saved_bt).unwrap(); + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== PASTED TABLE STRUCTURE ANALYSIS COMPLETE ==="); + eprintln!("{}", "=".repeat(120)); +} + +#[test] +fn test_table3_deep_comparison() { + use crate::parser::cfb_reader::CfbReader; + use crate::parser::record::Record; + use crate::parser::tags; + use std::path::Path; + + let orig_path = Path::new("pasts/20250130-hongbo-p2.hwp"); + let saved_path = Path::new("pasts/20250130-hongbo_saved-rp-003.hwp"); + if !orig_path.exists() || !saved_path.exists() { + eprintln!("파일 없음 — 건너뜀"); + return; + } - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== TABLE[3] DEEP COMPARISON (21x7 table) ==="); - eprintln!("{}", "=".repeat(120)); + let orig_data = std::fs::read(orig_path).unwrap(); + let saved_data = std::fs::read(saved_path).unwrap(); + let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); + let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); + + let mut orig_cfb = CfbReader::open(&orig_data).unwrap(); + let mut saved_cfb = CfbReader::open(&saved_data).unwrap(); + let orig_bt = orig_cfb + .read_body_text_section(0, orig_doc.header.compressed, false) + .unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_doc.header.compressed, false) + .unwrap(); + + let orig_recs = Record::read_all(&orig_bt).unwrap(); + let saved_recs = Record::read_all(&saved_bt).unwrap(); + + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== TABLE[3] DEEP COMPARISON (21x7 table) ==="); + eprintln!("{}", "=".repeat(120)); + + fn is_table_ctrl(data: &[u8]) -> bool { + if data.len() >= 4 { + let ctrl_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); + ctrl_id == tags::CTRL_TABLE + } else { + false + } + } - fn is_table_ctrl(data: &[u8]) -> bool { - if data.len() >= 4 { - let ctrl_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); - ctrl_id == tags::CTRL_TABLE - } else { - false + // Find the 4th table (index 3) in each file + fn find_nth_table(recs: &[Record], n: usize) -> Option<(usize, usize)> { + let mut table_count = 0; + let mut i = 0; + while i < recs.len() { + if recs[i].tag_id == tags::HWPTAG_CTRL_HEADER && is_table_ctrl(&recs[i].data) { + if table_count == n { + let ctrl_level = recs[i].level; + let start = i; + let mut j = i + 1; + while j < recs.len() && recs[j].level > ctrl_level { + j += 1; + } + return Some((start, j)); + } + table_count += 1; } + i += 1; } + None + } - // Find the 4th table (index 3) in each file - fn find_nth_table(recs: &[Record], n: usize) -> Option<(usize, usize)> { - let mut table_count = 0; - let mut i = 0; - while i < recs.len() { - if recs[i].tag_id == tags::HWPTAG_CTRL_HEADER && is_table_ctrl(&recs[i].data) { - if table_count == n { - let ctrl_level = recs[i].level; - let start = i; - let mut j = i + 1; - while j < recs.len() && recs[j].level > ctrl_level { - j += 1; - } - return Some((start, j)); - } - table_count += 1; + let (orig_start, orig_end) = + find_nth_table(&orig_recs, 3).expect("Original Table[3] not found"); + let (saved_start, saved_end) = + find_nth_table(&saved_recs, 3).expect("Saved Table[3] not found"); + + let orig_table_recs = &orig_recs[orig_start..orig_end]; + let saved_table_recs = &saved_recs[saved_start..saved_end]; + + eprintln!( + "Original Table[3]: recs[{}..{}] ({} records)", + orig_start, + orig_end, + orig_table_recs.len() + ); + eprintln!( + "Saved Table[3]: recs[{}..{}] ({} records)", + saved_start, + saved_end, + saved_table_recs.len() + ); + + // Compare TABLE record flags + let orig_tbl = orig_table_recs + .iter() + .find(|r| r.tag_id == tags::HWPTAG_TABLE) + .unwrap(); + let saved_tbl = saved_table_recs + .iter() + .find(|r| r.tag_id == tags::HWPTAG_TABLE) + .unwrap(); + eprintln!( + "\nOriginal TABLE flags: 0x{:08X}", + u32::from_le_bytes(orig_tbl.data[0..4].try_into().unwrap()) + ); + eprintln!( + "Saved TABLE flags: 0x{:08X}", + u32::from_le_bytes(saved_tbl.data[0..4].try_into().unwrap()) + ); + let orig_flags = u32::from_le_bytes(orig_tbl.data[0..4].try_into().unwrap()); + let saved_flags = u32::from_le_bytes(saved_tbl.data[0..4].try_into().unwrap()); + let diff_bits = orig_flags ^ saved_flags; + eprintln!("Flags diff bits: 0x{:08X}", diff_bits); + // bit 1 = split page by cell, bit 2 = repeat header + eprintln!( + " bit 1 (split_page_by_cell): orig={} saved={}", + (orig_flags >> 1) & 1, + (saved_flags >> 1) & 1 + ); + eprintln!( + " bit 2 (repeat_header): orig={} saved={}", + (orig_flags >> 2) & 1, + (saved_flags >> 2) & 1 + ); + eprintln!( + " bit 3 (?): orig={} saved={}", + (orig_flags >> 3) & 1, + (saved_flags >> 3) & 1 + ); + + // Compare record-by-record, finding first divergence + eprintln!("\n--- Record-by-record comparison ---"); + let min_len = orig_table_recs.len().min(saved_table_recs.len()); + let mut first_diverge = None; + let mut extra_saved_recs = Vec::new(); + + // Use alignment: match tag+level sequences + let mut oi = 0usize; + let mut si = 0usize; + let mut matched = 0; + let mut mismatched = 0; + + while oi < orig_table_recs.len() && si < saved_table_recs.len() { + let o = &orig_table_recs[oi]; + let s = &saved_table_recs[si]; + if o.tag_id == s.tag_id && o.level == s.level { + // Same tag and level - compare data + if o.data != s.data && mismatched < 20 { + eprintln!( + " [O{}/S{}] {} L{}: DATA DIFFERS (orig={}B, saved={}B)", + orig_start + oi, + saved_start + si, + tags::tag_name(o.tag_id), + o.level, + o.data.len(), + s.data.len() + ); + // Show specific differences for important records + if o.tag_id == tags::HWPTAG_PARA_TEXT { + eprintln!( + " ORIG PARA_TEXT: {:02X?}", + &o.data[..o.data.len().min(40)] + ); + eprintln!( + " SAVED PARA_TEXT: {:02X?}", + &s.data[..s.data.len().min(40)] + ); } - i += 1; + mismatched += 1; } - None - } - - let (orig_start, orig_end) = find_nth_table(&orig_recs, 3).expect("Original Table[3] not found"); - let (saved_start, saved_end) = find_nth_table(&saved_recs, 3).expect("Saved Table[3] not found"); - - let orig_table_recs = &orig_recs[orig_start..orig_end]; - let saved_table_recs = &saved_recs[saved_start..saved_end]; - - eprintln!("Original Table[3]: recs[{}..{}] ({} records)", orig_start, orig_end, orig_table_recs.len()); - eprintln!("Saved Table[3]: recs[{}..{}] ({} records)", saved_start, saved_end, saved_table_recs.len()); - - // Compare TABLE record flags - let orig_tbl = orig_table_recs.iter().find(|r| r.tag_id == tags::HWPTAG_TABLE).unwrap(); - let saved_tbl = saved_table_recs.iter().find(|r| r.tag_id == tags::HWPTAG_TABLE).unwrap(); - eprintln!("\nOriginal TABLE flags: 0x{:08X}", u32::from_le_bytes(orig_tbl.data[0..4].try_into().unwrap())); - eprintln!("Saved TABLE flags: 0x{:08X}", u32::from_le_bytes(saved_tbl.data[0..4].try_into().unwrap())); - let orig_flags = u32::from_le_bytes(orig_tbl.data[0..4].try_into().unwrap()); - let saved_flags = u32::from_le_bytes(saved_tbl.data[0..4].try_into().unwrap()); - let diff_bits = orig_flags ^ saved_flags; - eprintln!("Flags diff bits: 0x{:08X}", diff_bits); - // bit 1 = split page by cell, bit 2 = repeat header - eprintln!(" bit 1 (split_page_by_cell): orig={} saved={}", (orig_flags >> 1) & 1, (saved_flags >> 1) & 1); - eprintln!(" bit 2 (repeat_header): orig={} saved={}", (orig_flags >> 2) & 1, (saved_flags >> 2) & 1); - eprintln!(" bit 3 (?): orig={} saved={}", (orig_flags >> 3) & 1, (saved_flags >> 3) & 1); - - // Compare record-by-record, finding first divergence - eprintln!("\n--- Record-by-record comparison ---"); - let min_len = orig_table_recs.len().min(saved_table_recs.len()); - let mut first_diverge = None; - let mut extra_saved_recs = Vec::new(); - - // Use alignment: match tag+level sequences - let mut oi = 0usize; - let mut si = 0usize; - let mut matched = 0; - let mut mismatched = 0; - - while oi < orig_table_recs.len() && si < saved_table_recs.len() { - let o = &orig_table_recs[oi]; - let s = &saved_table_recs[si]; - if o.tag_id == s.tag_id && o.level == s.level { - // Same tag and level - compare data - if o.data != s.data && mismatched < 20 { - eprintln!(" [O{}/S{}] {} L{}: DATA DIFFERS (orig={}B, saved={}B)", - orig_start + oi, saved_start + si, - tags::tag_name(o.tag_id), o.level, - o.data.len(), s.data.len()); - // Show specific differences for important records - if o.tag_id == tags::HWPTAG_PARA_TEXT { - eprintln!(" ORIG PARA_TEXT: {:02X?}", &o.data[..o.data.len().min(40)]); - eprintln!(" SAVED PARA_TEXT: {:02X?}", &s.data[..s.data.len().min(40)]); - } - mismatched += 1; - } - matched += 1; - oi += 1; + matched += 1; + oi += 1; + si += 1; + } else { + // Divergence found + if first_diverge.is_none() { + first_diverge = Some((oi, si)); + eprintln!( + "\n FIRST DIVERGENCE at orig[{}]/saved[{}]:", + orig_start + oi, + saved_start + si + ); + eprintln!( + " ORIG: {} L{} {}B", + tags::tag_name(o.tag_id), + o.level, + o.data.len() + ); + eprintln!( + " SAVED: {} L{} {}B", + tags::tag_name(s.tag_id), + s.level, + s.data.len() + ); + } + + // Try to re-align: is the saved record an insertion? + // Check if orig[oi] matches saved[si+1] + if si + 1 < saved_table_recs.len() + && orig_table_recs[oi].tag_id == saved_table_recs[si + 1].tag_id + && orig_table_recs[oi].level == saved_table_recs[si + 1].level + { + extra_saved_recs.push((saved_start + si, s.clone())); si += 1; - } else { - // Divergence found - if first_diverge.is_none() { - first_diverge = Some((oi, si)); - eprintln!("\n FIRST DIVERGENCE at orig[{}]/saved[{}]:", orig_start + oi, saved_start + si); - eprintln!(" ORIG: {} L{} {}B", tags::tag_name(o.tag_id), o.level, o.data.len()); - eprintln!(" SAVED: {} L{} {}B", tags::tag_name(s.tag_id), s.level, s.data.len()); - } - - // Try to re-align: is the saved record an insertion? - // Check if orig[oi] matches saved[si+1] - if si + 1 < saved_table_recs.len() - && orig_table_recs[oi].tag_id == saved_table_recs[si + 1].tag_id - && orig_table_recs[oi].level == saved_table_recs[si + 1].level - { - extra_saved_recs.push((saved_start + si, s.clone())); - si += 1; - continue; - } - // Check if saved[si] matches orig[oi+1] - if oi + 1 < orig_table_recs.len() - && orig_table_recs[oi + 1].tag_id == saved_table_recs[si].tag_id - && orig_table_recs[oi + 1].level == saved_table_recs[si].level - { - eprintln!(" ORIG record [{}] has no match in saved (deleted?)", orig_start + oi); - oi += 1; - continue; - } - // Both advance + continue; + } + // Check if saved[si] matches orig[oi+1] + if oi + 1 < orig_table_recs.len() + && orig_table_recs[oi + 1].tag_id == saved_table_recs[si].tag_id + && orig_table_recs[oi + 1].level == saved_table_recs[si].level + { + eprintln!( + " ORIG record [{}] has no match in saved (deleted?)", + orig_start + oi + ); oi += 1; - si += 1; + continue; } - } - - // Print remaining saved records - while si < saved_table_recs.len() { - extra_saved_recs.push((saved_start + si, saved_table_recs[si].clone())); + // Both advance + oi += 1; si += 1; } + } + + // Print remaining saved records + while si < saved_table_recs.len() { + extra_saved_recs.push((saved_start + si, saved_table_recs[si].clone())); + si += 1; + } - eprintln!("\nMatched: {}, Data diffs: {}", matched, mismatched); - eprintln!("Extra records in saved: {}", extra_saved_recs.len()); - if !extra_saved_recs.is_empty() { - eprintln!("\n Extra records in saved (not in original):"); - for (idx, rec) in extra_saved_recs.iter().take(30) { - eprintln!(" [{}] {} L{} {}B", idx, tags::tag_name(rec.tag_id), rec.level, rec.data.len()); - if rec.tag_id == tags::HWPTAG_PARA_TEXT { - // Decode as UTF-16LE text - let text: String = rec.data.chunks(2).filter_map(|c| { + eprintln!("\nMatched: {}, Data diffs: {}", matched, mismatched); + eprintln!("Extra records in saved: {}", extra_saved_recs.len()); + if !extra_saved_recs.is_empty() { + eprintln!("\n Extra records in saved (not in original):"); + for (idx, rec) in extra_saved_recs.iter().take(30) { + eprintln!( + " [{}] {} L{} {}B", + idx, + tags::tag_name(rec.tag_id), + rec.level, + rec.data.len() + ); + if rec.tag_id == tags::HWPTAG_PARA_TEXT { + // Decode as UTF-16LE text + let text: String = rec + .data + .chunks(2) + .filter_map(|c| { if c.len() == 2 { let code = u16::from_le_bytes([c[0], c[1]]); - if code == 0x000D || code == 0x000A { return Some('\n'); } - if code < 0x20 { return None; } + if code == 0x000D || code == 0x000A { + return Some('\n'); + } + if code < 0x20 { + return None; + } char::from_u32(code as u32) - } else { None } - }).collect(); - eprintln!(" text: '{}'", &text[..text.len().min(80)]); - } - } - } - - // Now compare record types breakdown - let mut orig_types: std::collections::BTreeMap = std::collections::BTreeMap::new(); - let mut saved_types: std::collections::BTreeMap = std::collections::BTreeMap::new(); - for r in orig_table_recs { *orig_types.entry(r.tag_id).or_insert(0) += 1; } - for r in saved_table_recs { *saved_types.entry(r.tag_id).or_insert(0) += 1; } - eprintln!("\n--- Record type breakdown for Table[3] ---"); - eprintln!("{:<25} {:>6} {:>6} {:>6}", "Tag", "Orig", "Saved", "Diff"); - let all_tags: std::collections::BTreeSet = orig_types.keys().chain(saved_types.keys()).copied().collect(); - for tag in &all_tags { - let oc = orig_types.get(tag).copied().unwrap_or(0); - let sc = saved_types.get(tag).copied().unwrap_or(0); - let diff = sc as i64 - oc as i64; - eprintln!("{:<25} {:>6} {:>6} {:>+6}", tags::tag_name(*tag), oc, sc, diff); - } - - // Check cells with different nParagraphs - eprintln!("\n--- Cells (LIST_HEADER) paragraph count comparison ---"); - let orig_cells: Vec<&Record> = orig_table_recs.iter() - .filter(|r| r.tag_id == tags::HWPTAG_LIST_HEADER).collect(); - let saved_cells: Vec<&Record> = saved_table_recs.iter() - .filter(|r| r.tag_id == tags::HWPTAG_LIST_HEADER).collect(); - eprintln!("Original cells: {}, Saved cells: {}", orig_cells.len(), saved_cells.len()); - - let cell_count = orig_cells.len().min(saved_cells.len()); - let mut cells_with_diff = Vec::new(); - for ci in 0..cell_count { - let o_nparas = if orig_cells[ci].data.len() >= 2 { - u16::from_le_bytes(orig_cells[ci].data[0..2].try_into().unwrap()) - } else { 0 }; - let s_nparas = if saved_cells[ci].data.len() >= 2 { - u16::from_le_bytes(saved_cells[ci].data[0..2].try_into().unwrap()) - } else { 0 }; - if o_nparas != s_nparas { - cells_with_diff.push((ci, o_nparas, s_nparas)); - } - } - if cells_with_diff.is_empty() { - eprintln!("All cells have same nParagraphs!"); - } else { - eprintln!("Cells with different nParagraphs:"); - for (ci, o, s) in &cells_with_diff { - eprintln!(" Cell[{}]: orig={} saved={} (diff={})", ci, o, s, *s as i32 - *o as i32); + } else { + None + } + }) + .collect(); + eprintln!(" text: '{}'", &text[..text.len().min(80)]); } } + } - // Check if saved cells have different data - eprintln!("\n--- Cell data comparison (first diff bytes) ---"); - for ci in 0..cell_count { - if orig_cells[ci].data != saved_cells[ci].data { - let min_len = orig_cells[ci].data.len().min(saved_cells[ci].data.len()); - let mut diffs = Vec::new(); - for i in 0..min_len { - if orig_cells[ci].data[i] != saved_cells[ci].data[i] { - diffs.push((i, orig_cells[ci].data[i], saved_cells[ci].data[i])); - } + // Now compare record types breakdown + let mut orig_types: std::collections::BTreeMap = std::collections::BTreeMap::new(); + let mut saved_types: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for r in orig_table_recs { + *orig_types.entry(r.tag_id).or_insert(0) += 1; + } + for r in saved_table_recs { + *saved_types.entry(r.tag_id).or_insert(0) += 1; + } + eprintln!("\n--- Record type breakdown for Table[3] ---"); + eprintln!("{:<25} {:>6} {:>6} {:>6}", "Tag", "Orig", "Saved", "Diff"); + let all_tags: std::collections::BTreeSet = orig_types + .keys() + .chain(saved_types.keys()) + .copied() + .collect(); + for tag in &all_tags { + let oc = orig_types.get(tag).copied().unwrap_or(0); + let sc = saved_types.get(tag).copied().unwrap_or(0); + let diff = sc as i64 - oc as i64; + eprintln!( + "{:<25} {:>6} {:>6} {:>+6}", + tags::tag_name(*tag), + oc, + sc, + diff + ); + } + + // Check cells with different nParagraphs + eprintln!("\n--- Cells (LIST_HEADER) paragraph count comparison ---"); + let orig_cells: Vec<&Record> = orig_table_recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_LIST_HEADER) + .collect(); + let saved_cells: Vec<&Record> = saved_table_recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_LIST_HEADER) + .collect(); + eprintln!( + "Original cells: {}, Saved cells: {}", + orig_cells.len(), + saved_cells.len() + ); + + let cell_count = orig_cells.len().min(saved_cells.len()); + let mut cells_with_diff = Vec::new(); + for ci in 0..cell_count { + let o_nparas = if orig_cells[ci].data.len() >= 2 { + u16::from_le_bytes(orig_cells[ci].data[0..2].try_into().unwrap()) + } else { + 0 + }; + let s_nparas = if saved_cells[ci].data.len() >= 2 { + u16::from_le_bytes(saved_cells[ci].data[0..2].try_into().unwrap()) + } else { + 0 + }; + if o_nparas != s_nparas { + cells_with_diff.push((ci, o_nparas, s_nparas)); + } + } + if cells_with_diff.is_empty() { + eprintln!("All cells have same nParagraphs!"); + } else { + eprintln!("Cells with different nParagraphs:"); + for (ci, o, s) in &cells_with_diff { + eprintln!( + " Cell[{}]: orig={} saved={} (diff={})", + ci, + o, + s, + *s as i32 - *o as i32 + ); + } + } + + // Check if saved cells have different data + eprintln!("\n--- Cell data comparison (first diff bytes) ---"); + for ci in 0..cell_count { + if orig_cells[ci].data != saved_cells[ci].data { + let min_len = orig_cells[ci].data.len().min(saved_cells[ci].data.len()); + let mut diffs = Vec::new(); + for i in 0..min_len { + if orig_cells[ci].data[i] != saved_cells[ci].data[i] { + diffs.push((i, orig_cells[ci].data[i], saved_cells[ci].data[i])); } - if !diffs.is_empty() || orig_cells[ci].data.len() != saved_cells[ci].data.len() { - eprintln!(" Cell[{}]: {} byte diffs, size orig={} saved={}", - ci, diffs.len(), orig_cells[ci].data.len(), saved_cells[ci].data.len()); - for (off, a, b) in diffs.iter().take(5) { - eprintln!(" offset {}: orig=0x{:02X} saved=0x{:02X}", off, a, b); - } + } + if !diffs.is_empty() || orig_cells[ci].data.len() != saved_cells[ci].data.len() { + eprintln!( + " Cell[{}]: {} byte diffs, size orig={} saved={}", + ci, + diffs.len(), + orig_cells[ci].data.len(), + saved_cells[ci].data.len() + ); + for (off, a, b) in diffs.iter().take(5) { + eprintln!(" offset {}: orig=0x{:02X} saved=0x{:02X}", off, a, b); } } } - - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== TABLE[3] DEEP COMPARISON COMPLETE ==="); - eprintln!("{}", "=".repeat(120)); } - #[test] - fn test_model_table3_cell_text_check() { - use std::path::Path; + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== TABLE[3] DEEP COMPARISON COMPLETE ==="); + eprintln!("{}", "=".repeat(120)); +} - let orig_path = Path::new("pasts/20250130-hongbo-p2.hwp"); - let saved_path = Path::new("pasts/20250130-hongbo_saved-rp-003.hwp"); - if !orig_path.exists() || !saved_path.exists() { - eprintln!("파일 없음 — 건너뜀"); - return; - } - - let orig_data = std::fs::read(orig_path).unwrap(); - let saved_data = std::fs::read(saved_path).unwrap(); - let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); - let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); +#[test] +fn test_model_table3_cell_text_check() { + use std::path::Path; - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== MODEL-LEVEL TABLE[3] CELL PARAGRAPH CHECK ==="); - eprintln!("{}", "=".repeat(120)); + let orig_path = Path::new("pasts/20250130-hongbo-p2.hwp"); + let saved_path = Path::new("pasts/20250130-hongbo_saved-rp-003.hwp"); + if !orig_path.exists() || !saved_path.exists() { + eprintln!("파일 없음 — 건너뜀"); + return; + } - // Find Table[3] in the model (it should be the 4th table control) - fn find_tables(paras: &[crate::model::paragraph::Paragraph]) -> Vec<(usize, usize, &crate::model::table::Table)> { - let mut tables = Vec::new(); - for (pi, para) in paras.iter().enumerate() { - for (ci, ctrl) in para.controls.iter().enumerate() { - if let crate::model::control::Control::Table(t) = ctrl { - tables.push((pi, ci, t.as_ref())); - } + let orig_data = std::fs::read(orig_path).unwrap(); + let saved_data = std::fs::read(saved_path).unwrap(); + let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); + let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); + + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== MODEL-LEVEL TABLE[3] CELL PARAGRAPH CHECK ==="); + eprintln!("{}", "=".repeat(120)); + + // Find Table[3] in the model (it should be the 4th table control) + fn find_tables( + paras: &[crate::model::paragraph::Paragraph], + ) -> Vec<(usize, usize, &crate::model::table::Table)> { + let mut tables = Vec::new(); + for (pi, para) in paras.iter().enumerate() { + for (ci, ctrl) in para.controls.iter().enumerate() { + if let crate::model::control::Control::Table(t) = ctrl { + tables.push((pi, ci, t.as_ref())); } } - tables } + tables + } - let orig_tables = find_tables(&orig_doc.sections[0].paragraphs); - let saved_tables = find_tables(&saved_doc.sections[0].paragraphs); + let orig_tables = find_tables(&orig_doc.sections[0].paragraphs); + let saved_tables = find_tables(&saved_doc.sections[0].paragraphs); - eprintln!("Original tables: {}", orig_tables.len()); - eprintln!("Saved tables: {}", saved_tables.len()); + eprintln!("Original tables: {}", orig_tables.len()); + eprintln!("Saved tables: {}", saved_tables.len()); - if orig_tables.len() > 3 && saved_tables.len() > 3 { - let (_, _, orig_t3) = orig_tables[3]; - let (_, _, saved_t3) = saved_tables[3]; + if orig_tables.len() > 3 && saved_tables.len() > 3 { + let (_, _, orig_t3) = orig_tables[3]; + let (_, _, saved_t3) = saved_tables[3]; - eprintln!("\nOriginal Table[3]: rows={} cols={} cells={}", orig_t3.row_count, orig_t3.col_count, orig_t3.cells.len()); - eprintln!("Saved Table[3]: rows={} cols={} cells={}", saved_t3.row_count, saved_t3.col_count, saved_t3.cells.len()); + eprintln!( + "\nOriginal Table[3]: rows={} cols={} cells={}", + orig_t3.row_count, + orig_t3.col_count, + orig_t3.cells.len() + ); + eprintln!( + "Saved Table[3]: rows={} cols={} cells={}", + saved_t3.row_count, + saved_t3.col_count, + saved_t3.cells.len() + ); - // Check paragraphs in cells - let mut orig_para_with_text = 0; - let mut orig_para_without_text = 0; - let mut orig_para_with_flag = 0; - let mut saved_para_with_text = 0; - let mut saved_para_without_text = 0; - let mut saved_para_with_flag = 0; + // Check paragraphs in cells + let mut orig_para_with_text = 0; + let mut orig_para_without_text = 0; + let mut orig_para_with_flag = 0; + let mut saved_para_with_text = 0; + let mut saved_para_without_text = 0; + let mut saved_para_with_flag = 0; - eprintln!("\n--- Original Table[3] cell paragraphs ---"); - for (ci, cell) in orig_t3.cells.iter().enumerate() { - for (pi, para) in cell.paragraphs.iter().enumerate() { - let has_text = !para.text.is_empty(); - if has_text { orig_para_with_text += 1; } else { orig_para_without_text += 1; } - if para.has_para_text { orig_para_with_flag += 1; } - if !has_text && !para.has_para_text { - // Only show the first few empty paragraphs - if ci < 3 { - eprintln!(" cell[{}] para[{}]: text='{}' has_para_text={} char_count={} controls={}", + eprintln!("\n--- Original Table[3] cell paragraphs ---"); + for (ci, cell) in orig_t3.cells.iter().enumerate() { + for (pi, para) in cell.paragraphs.iter().enumerate() { + let has_text = !para.text.is_empty(); + if has_text { + orig_para_with_text += 1; + } else { + orig_para_without_text += 1; + } + if para.has_para_text { + orig_para_with_flag += 1; + } + if !has_text && !para.has_para_text { + // Only show the first few empty paragraphs + if ci < 3 { + eprintln!(" cell[{}] para[{}]: text='{}' has_para_text={} char_count={} controls={}", ci, pi, ¶.text[..para.text.len().min(20)], para.has_para_text, para.char_count, para.controls.len()); - } } } } + } - eprintln!("\n--- Saved Table[3] cell paragraphs ---"); - for (ci, cell) in saved_t3.cells.iter().enumerate() { - for (pi, para) in cell.paragraphs.iter().enumerate() { - let has_text = !para.text.is_empty(); - if has_text { saved_para_with_text += 1; } else { saved_para_without_text += 1; } - if para.has_para_text { saved_para_with_flag += 1; } - if ci < 3 { - eprintln!(" cell[{}] para[{}]: text='{}' has_para_text={} char_count={} controls={}", - ci, pi, ¶.text[..para.text.len().min(40)], - para.has_para_text, para.char_count, para.controls.len()); - } + eprintln!("\n--- Saved Table[3] cell paragraphs ---"); + for (ci, cell) in saved_t3.cells.iter().enumerate() { + for (pi, para) in cell.paragraphs.iter().enumerate() { + let has_text = !para.text.is_empty(); + if has_text { + saved_para_with_text += 1; + } else { + saved_para_without_text += 1; + } + if para.has_para_text { + saved_para_with_flag += 1; + } + if ci < 3 { + eprintln!( + " cell[{}] para[{}]: text='{}' has_para_text={} char_count={} controls={}", + ci, + pi, + ¶.text[..para.text.len().min(40)], + para.has_para_text, + para.char_count, + para.controls.len() + ); } } + } - eprintln!("\n--- Summary ---"); - eprintln!("Original: {} with text, {} without text, {} with has_para_text flag", - orig_para_with_text, orig_para_without_text, orig_para_with_flag); - eprintln!("Saved: {} with text, {} without text, {} with has_para_text flag", - saved_para_with_text, saved_para_without_text, saved_para_with_flag); + eprintln!("\n--- Summary ---"); + eprintln!( + "Original: {} with text, {} without text, {} with has_para_text flag", + orig_para_with_text, orig_para_without_text, orig_para_with_flag + ); + eprintln!( + "Saved: {} with text, {} without text, {} with has_para_text flag", + saved_para_with_text, saved_para_without_text, saved_para_with_flag + ); - // Find cells where text content differs - let cell_count = orig_t3.cells.len().min(saved_t3.cells.len()); - let mut text_diff_cells = Vec::new(); - for ci in 0..cell_count { - let opara_count = orig_t3.cells[ci].paragraphs.len(); - let spara_count = saved_t3.cells[ci].paragraphs.len(); - if opara_count != spara_count { - text_diff_cells.push((ci, format!("para count differs: {} vs {}", opara_count, spara_count))); - continue; - } - for pi in 0..opara_count { - let op = &orig_t3.cells[ci].paragraphs[pi]; - let sp = &saved_t3.cells[ci].paragraphs[pi]; - if op.text != sp.text || op.has_para_text != sp.has_para_text { - text_diff_cells.push((ci, format!( + // Find cells where text content differs + let cell_count = orig_t3.cells.len().min(saved_t3.cells.len()); + let mut text_diff_cells = Vec::new(); + for ci in 0..cell_count { + let opara_count = orig_t3.cells[ci].paragraphs.len(); + let spara_count = saved_t3.cells[ci].paragraphs.len(); + if opara_count != spara_count { + text_diff_cells.push(( + ci, + format!("para count differs: {} vs {}", opara_count, spara_count), + )); + continue; + } + for pi in 0..opara_count { + let op = &orig_t3.cells[ci].paragraphs[pi]; + let sp = &saved_t3.cells[ci].paragraphs[pi]; + if op.text != sp.text || op.has_para_text != sp.has_para_text { + text_diff_cells.push(( + ci, + format!( "para[{}]: orig_text='{}' orig_flag={} saved_text='{}' saved_flag={}", - pi, &op.text[..op.text.len().min(30)], op.has_para_text, - &sp.text[..sp.text.len().min(30)], sp.has_para_text))); - } + pi, + &op.text[..op.text.len().min(30)], + op.has_para_text, + &sp.text[..sp.text.len().min(30)], + sp.has_para_text + ), + )); } } - eprintln!("\nCells with text/flag differences: {}", text_diff_cells.len()); - for (ci, desc) in text_diff_cells.iter().take(20) { - eprintln!(" cell[{}]: {}", ci, desc); - } } - - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== MODEL-LEVEL CHECK COMPLETE ==="); - eprintln!("{}", "=".repeat(120)); + eprintln!( + "\nCells with text/flag differences: {}", + text_diff_cells.len() + ); + for (ci, desc) in text_diff_cells.iter().take(20) { + eprintln!(" cell[{}]: {}", ci, desc); + } } - #[test] - fn test_roundtrip_empty_cell_corruption() { - use std::path::Path; - use crate::parser::record::Record; - use crate::parser::tags; - - let orig_path = Path::new("pasts/20250130-hongbo-p2.hwp"); - if !orig_path.exists() { - eprintln!("파일 없음 — 건너뜀"); - return; - } + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== MODEL-LEVEL CHECK COMPLETE ==="); + eprintln!("{}", "=".repeat(120)); +} + +#[test] +fn test_roundtrip_empty_cell_corruption() { + use crate::parser::record::Record; + use crate::parser::tags; + use std::path::Path; + + let orig_path = Path::new("pasts/20250130-hongbo-p2.hwp"); + if !orig_path.exists() { + eprintln!("파일 없음 — 건너뜀"); + return; + } - let orig_data = std::fs::read(orig_path).unwrap(); - let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); + let orig_data = std::fs::read(orig_path).unwrap(); + let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== ROUND-TRIP EMPTY CELL CORRUPTION TEST ==="); - eprintln!("{}", "=".repeat(120)); + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== ROUND-TRIP EMPTY CELL CORRUPTION TEST ==="); + eprintln!("{}", "=".repeat(120)); - // Find Table[3] in the original model - fn find_tables(paras: &[crate::model::paragraph::Paragraph]) -> Vec<(usize, usize, &crate::model::table::Table)> { - let mut tables = Vec::new(); - for (pi, para) in paras.iter().enumerate() { - for (ci, ctrl) in para.controls.iter().enumerate() { - if let crate::model::control::Control::Table(t) = ctrl { - tables.push((pi, ci, t.as_ref())); - } + // Find Table[3] in the original model + fn find_tables( + paras: &[crate::model::paragraph::Paragraph], + ) -> Vec<(usize, usize, &crate::model::table::Table)> { + let mut tables = Vec::new(); + for (pi, para) in paras.iter().enumerate() { + for (ci, ctrl) in para.controls.iter().enumerate() { + if let crate::model::control::Control::Table(t) = ctrl { + tables.push((pi, ci, t.as_ref())); } } - tables } + tables + } - let orig_tables = find_tables(&orig_doc.sections[0].paragraphs); - assert!(orig_tables.len() > 3, "Need at least 4 tables"); - let (_, _, orig_t3) = orig_tables[3]; - - // Check original model: char_count values for empty cells - eprintln!("\n--- Original Table[3] empty cell char_count/msb/text analysis ---"); - let mut empty_cell_count = 0; - let mut empty_cell_char_counts = std::collections::HashMap::new(); - for (ci, cell) in orig_t3.cells.iter().enumerate() { - for (pi, para) in cell.paragraphs.iter().enumerate() { - if para.text.is_empty() && !para.has_para_text { - empty_cell_count += 1; - *empty_cell_char_counts.entry(para.char_count).or_insert(0) += 1; - if ci < 5 { - eprintln!(" cell[{}] para[{}]: text='{}' has_para_text={} char_count={} char_count_msb={} controls={} raw_header_extra_len={}", + let orig_tables = find_tables(&orig_doc.sections[0].paragraphs); + assert!(orig_tables.len() > 3, "Need at least 4 tables"); + let (_, _, orig_t3) = orig_tables[3]; + + // Check original model: char_count values for empty cells + eprintln!("\n--- Original Table[3] empty cell char_count/msb/text analysis ---"); + let mut empty_cell_count = 0; + let mut empty_cell_char_counts = std::collections::HashMap::new(); + for (ci, cell) in orig_t3.cells.iter().enumerate() { + for (pi, para) in cell.paragraphs.iter().enumerate() { + if para.text.is_empty() && !para.has_para_text { + empty_cell_count += 1; + *empty_cell_char_counts.entry(para.char_count).or_insert(0) += 1; + if ci < 5 { + eprintln!(" cell[{}] para[{}]: text='{}' has_para_text={} char_count={} char_count_msb={} controls={} raw_header_extra_len={}", ci, pi, para.text, para.has_para_text, para.char_count, para.char_count_msb, para.controls.len(), para.raw_header_extra.len()); - if para.raw_header_extra.len() >= 10 { - eprintln!(" raw_header_extra: {:02x?}", ¶.raw_header_extra); - } + if para.raw_header_extra.len() >= 10 { + eprintln!(" raw_header_extra: {:02x?}", ¶.raw_header_extra); } } } } - eprintln!("\nEmpty cells: {}", empty_cell_count); - for (cc, count) in &empty_cell_char_counts { - eprintln!(" char_count={}: {} cells", cc, count); - } - - // Now do a round-trip: serialize from model (bypassing raw_stream) - eprintln!("\n--- Round-trip: serialize section from model ---"); - // Build records manually from the model paragraphs (bypassing raw_stream check) - let mut records_from_model = Vec::new(); - for para in &orig_doc.sections[0].paragraphs { - crate::serializer::body_text::serialize_paragraph_list( - std::slice::from_ref(para), 0, &mut records_from_model - ); - } - let serialized = crate::serializer::record_writer::write_records(&records_from_model); - eprintln!("Serialized section size: {} bytes", serialized.len()); - - // Parse the serialized records - let records = Record::read_all(&serialized).unwrap(); - eprintln!("Total records: {}", records.len()); + } + eprintln!("\nEmpty cells: {}", empty_cell_count); + for (cc, count) in &empty_cell_char_counts { + eprintln!(" char_count={}: {} cells", cc, count); + } - // Count PARA_TEXT records - let para_text_count = records.iter().filter(|r| r.tag_id == tags::HWPTAG_PARA_TEXT).count(); - let para_header_count = records.iter().filter(|r| r.tag_id == tags::HWPTAG_PARA_HEADER).count(); - eprintln!("PARA_HEADER: {}", para_header_count); - eprintln!("PARA_TEXT: {}", para_text_count); - - // Also count from original raw_stream for comparison - let orig_raw = orig_doc.sections[0].raw_stream.as_ref().unwrap(); - let orig_records = Record::read_all(orig_raw).unwrap(); - let orig_pt_count = orig_records.iter().filter(|r| r.tag_id == tags::HWPTAG_PARA_TEXT).count(); - let orig_ph_count = orig_records.iter().filter(|r| r.tag_id == tags::HWPTAG_PARA_HEADER).count(); - eprintln!("\nOriginal raw:"); - eprintln!(" PARA_HEADER: {}", orig_ph_count); - eprintln!(" PARA_TEXT: {}", orig_pt_count); - - eprintln!("\nDelta:"); - eprintln!(" PARA_HEADER: {} (expected 0)", para_header_count as i32 - orig_ph_count as i32); - eprintln!(" PARA_TEXT: {} (expected 0)", para_text_count as i32 - orig_pt_count as i32); - - // Check specific records inside Table[3] area - // Find all CTRL_HEADER records with Table ctrl_id - let mut table_idx = 0; - for (ri, rec) in records.iter().enumerate() { - if rec.tag_id == tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - if ctrl_id == tags::CTRL_TABLE { - if table_idx == 3 { - // Found Table[3] in serialized output - count children - let table_level = rec.level; - let mut child_pt_count = 0; - let mut child_ph_count = 0; - for child_rec in &records[ri+1..] { - if child_rec.level <= table_level { break; } - if child_rec.tag_id == tags::HWPTAG_PARA_TEXT { child_pt_count += 1; } - if child_rec.tag_id == tags::HWPTAG_PARA_HEADER { child_ph_count += 1; } + // Now do a round-trip: serialize from model (bypassing raw_stream) + eprintln!("\n--- Round-trip: serialize section from model ---"); + // Build records manually from the model paragraphs (bypassing raw_stream check) + let mut records_from_model = Vec::new(); + for para in &orig_doc.sections[0].paragraphs { + crate::serializer::body_text::serialize_paragraph_list( + std::slice::from_ref(para), + 0, + &mut records_from_model, + ); + } + let serialized = crate::serializer::record_writer::write_records(&records_from_model); + eprintln!("Serialized section size: {} bytes", serialized.len()); + + // Parse the serialized records + let records = Record::read_all(&serialized).unwrap(); + eprintln!("Total records: {}", records.len()); + + // Count PARA_TEXT records + let para_text_count = records + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_PARA_TEXT) + .count(); + let para_header_count = records + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_PARA_HEADER) + .count(); + eprintln!("PARA_HEADER: {}", para_header_count); + eprintln!("PARA_TEXT: {}", para_text_count); + + // Also count from original raw_stream for comparison + let orig_raw = orig_doc.sections[0].raw_stream.as_ref().unwrap(); + let orig_records = Record::read_all(orig_raw).unwrap(); + let orig_pt_count = orig_records + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_PARA_TEXT) + .count(); + let orig_ph_count = orig_records + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_PARA_HEADER) + .count(); + eprintln!("\nOriginal raw:"); + eprintln!(" PARA_HEADER: {}", orig_ph_count); + eprintln!(" PARA_TEXT: {}", orig_pt_count); + + eprintln!("\nDelta:"); + eprintln!( + " PARA_HEADER: {} (expected 0)", + para_header_count as i32 - orig_ph_count as i32 + ); + eprintln!( + " PARA_TEXT: {} (expected 0)", + para_text_count as i32 - orig_pt_count as i32 + ); + + // Check specific records inside Table[3] area + // Find all CTRL_HEADER records with Table ctrl_id + let mut table_idx = 0; + for (ri, rec) in records.iter().enumerate() { + if rec.tag_id == tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); + if ctrl_id == tags::CTRL_TABLE { + if table_idx == 3 { + // Found Table[3] in serialized output - count children + let table_level = rec.level; + let mut child_pt_count = 0; + let mut child_ph_count = 0; + for child_rec in &records[ri + 1..] { + if child_rec.level <= table_level { + break; + } + if child_rec.tag_id == tags::HWPTAG_PARA_TEXT { + child_pt_count += 1; } - eprintln!("\nSerialized Table[3] children:"); - eprintln!(" PARA_HEADER: {}", child_ph_count); - eprintln!(" PARA_TEXT: {}", child_pt_count); - - // Do the same for original - let mut orig_table_idx2 = 0; - for (ori, orec) in orig_records.iter().enumerate() { - if orec.tag_id == tags::HWPTAG_CTRL_HEADER && orec.data.len() >= 4 { - let cid = u32::from_le_bytes([orec.data[0], orec.data[1], orec.data[2], orec.data[3]]); - if cid == tags::CTRL_TABLE { - if orig_table_idx2 == 3 { - let olevel = orec.level; - let mut o_pt = 0; - let mut o_ph = 0; - for child_rec in &orig_records[ori+1..] { - if child_rec.level <= olevel { break; } - if child_rec.tag_id == tags::HWPTAG_PARA_TEXT { o_pt += 1; } - if child_rec.tag_id == tags::HWPTAG_PARA_HEADER { o_ph += 1; } + if child_rec.tag_id == tags::HWPTAG_PARA_HEADER { + child_ph_count += 1; + } + } + eprintln!("\nSerialized Table[3] children:"); + eprintln!(" PARA_HEADER: {}", child_ph_count); + eprintln!(" PARA_TEXT: {}", child_pt_count); + + // Do the same for original + let mut orig_table_idx2 = 0; + for (ori, orec) in orig_records.iter().enumerate() { + if orec.tag_id == tags::HWPTAG_CTRL_HEADER && orec.data.len() >= 4 { + let cid = u32::from_le_bytes([ + orec.data[0], + orec.data[1], + orec.data[2], + orec.data[3], + ]); + if cid == tags::CTRL_TABLE { + if orig_table_idx2 == 3 { + let olevel = orec.level; + let mut o_pt = 0; + let mut o_ph = 0; + for child_rec in &orig_records[ori + 1..] { + if child_rec.level <= olevel { + break; + } + if child_rec.tag_id == tags::HWPTAG_PARA_TEXT { + o_pt += 1; + } + if child_rec.tag_id == tags::HWPTAG_PARA_HEADER { + o_ph += 1; } - eprintln!("\nOriginal Table[3] children:"); - eprintln!(" PARA_HEADER: {}", o_ph); - eprintln!(" PARA_TEXT: {}", o_pt); - break; } - orig_table_idx2 += 1; + eprintln!("\nOriginal Table[3] children:"); + eprintln!(" PARA_HEADER: {}", o_ph); + eprintln!(" PARA_TEXT: {}", o_pt); + break; } + orig_table_idx2 += 1; } } + } - // Check each PARA_TEXT in serialized Table[3] - look for 5-space entries - eprintln!("\nSerialized Table[3] PARA_TEXT analysis:"); - let mut five_space_count = 0; - for child_rec in &records[ri+1..] { - if child_rec.level <= table_level { break; } - if child_rec.tag_id == tags::HWPTAG_PARA_TEXT { - let data = &child_rec.data; - // 5 spaces + terminator = [0x20,0x00, 0x20,0x00, 0x20,0x00, 0x20,0x00, 0x20,0x00, 0x0D,0x00] - if data.len() == 12 { - let is_five_spaces = data == &[0x20,0x00, 0x20,0x00, 0x20,0x00, 0x20,0x00, 0x20,0x00, 0x0D,0x00]; - if is_five_spaces { - five_space_count += 1; - } else { - eprintln!(" 12-byte PARA_TEXT (not 5-spaces): {:02x?}", data); - } + // Check each PARA_TEXT in serialized Table[3] - look for 5-space entries + eprintln!("\nSerialized Table[3] PARA_TEXT analysis:"); + let mut five_space_count = 0; + for child_rec in &records[ri + 1..] { + if child_rec.level <= table_level { + break; + } + if child_rec.tag_id == tags::HWPTAG_PARA_TEXT { + let data = &child_rec.data; + // 5 spaces + terminator = [0x20,0x00, 0x20,0x00, 0x20,0x00, 0x20,0x00, 0x20,0x00, 0x0D,0x00] + if data.len() == 12 { + let is_five_spaces = data + == &[ + 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, + 0x0D, 0x00, + ]; + if is_five_spaces { + five_space_count += 1; + } else { + eprintln!(" 12-byte PARA_TEXT (not 5-spaces): {:02x?}", data); } } } - eprintln!(" 5-space PARA_TEXT entries: {}", five_space_count); - - break; } - table_idx += 1; + eprintln!(" 5-space PARA_TEXT entries: {}", five_space_count); + + break; } + table_idx += 1; } } + } - // Check model-level data that was used for serialization - eprintln!("\n--- Model data used for serialization ---"); - let tables_check = find_tables(&orig_doc.sections[0].paragraphs); - if tables_check.len() > 3 { - let (_, _, t3) = tables_check[3]; - let mut model_empty = 0; - let mut model_with_text = 0; - for cell in &t3.cells { - for para in &cell.paragraphs { - if para.text.is_empty() && !para.has_para_text { - model_empty += 1; - } else { - model_with_text += 1; - } + // Check model-level data that was used for serialization + eprintln!("\n--- Model data used for serialization ---"); + let tables_check = find_tables(&orig_doc.sections[0].paragraphs); + if tables_check.len() > 3 { + let (_, _, t3) = tables_check[3]; + let mut model_empty = 0; + let mut model_with_text = 0; + for cell in &t3.cells { + for para in &cell.paragraphs { + if para.text.is_empty() && !para.has_para_text { + model_empty += 1; + } else { + model_with_text += 1; } } - eprintln!("Model Table[3] paragraphs (from original parse):"); - eprintln!(" Empty paragraphs (text='' && has_para_text=false): {}", model_empty); - eprintln!(" Paragraphs with text or has_para_text: {}", model_with_text); } - - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== ROUND-TRIP TEST COMPLETE ==="); - eprintln!("{}", "=".repeat(120)); + eprintln!("Model Table[3] paragraphs (from original parse):"); + eprintln!( + " Empty paragraphs (text='' && has_para_text=false): {}", + model_empty + ); + eprintln!( + " Paragraphs with text or has_para_text: {}", + model_with_text + ); } - #[test] - fn test_saved_file_table_flags_and_origin() { - use std::path::Path; + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== ROUND-TRIP TEST COMPLETE ==="); + eprintln!("{}", "=".repeat(120)); +} - let orig_path = Path::new("pasts/20250130-hongbo-p2.hwp"); - let saved_path = Path::new("pasts/20250130-hongbo_saved-rp-003.hwp"); - if !orig_path.exists() || !saved_path.exists() { - eprintln!("파일 없음 — 건너뜀"); - return; - } +#[test] +fn test_saved_file_table_flags_and_origin() { + use std::path::Path; - let orig_data = std::fs::read(orig_path).unwrap(); - let saved_data = std::fs::read(saved_path).unwrap(); - let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); - let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); + let orig_path = Path::new("pasts/20250130-hongbo-p2.hwp"); + let saved_path = Path::new("pasts/20250130-hongbo_saved-rp-003.hwp"); + if !orig_path.exists() || !saved_path.exists() { + eprintln!("파일 없음 — 건너뜀"); + return; + } - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== SAVED FILE TABLE FLAGS AND ORIGIN ANALYSIS ==="); - eprintln!("{}", "=".repeat(120)); + let orig_data = std::fs::read(orig_path).unwrap(); + let saved_data = std::fs::read(saved_path).unwrap(); + let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); + let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); - fn find_tables(paras: &[crate::model::paragraph::Paragraph]) -> Vec<(usize, usize, &crate::model::table::Table)> { - let mut tables = Vec::new(); - for (pi, para) in paras.iter().enumerate() { - for (ci, ctrl) in para.controls.iter().enumerate() { - if let crate::model::control::Control::Table(t) = ctrl { - tables.push((pi, ci, t.as_ref())); - } + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== SAVED FILE TABLE FLAGS AND ORIGIN ANALYSIS ==="); + eprintln!("{}", "=".repeat(120)); + + fn find_tables( + paras: &[crate::model::paragraph::Paragraph], + ) -> Vec<(usize, usize, &crate::model::table::Table)> { + let mut tables = Vec::new(); + for (pi, para) in paras.iter().enumerate() { + for (ci, ctrl) in para.controls.iter().enumerate() { + if let crate::model::control::Control::Table(t) = ctrl { + tables.push((pi, ci, t.as_ref())); } } - tables } + tables + } - let orig_tables = find_tables(&orig_doc.sections[0].paragraphs); - let saved_tables = find_tables(&saved_doc.sections[0].paragraphs); - - eprintln!("\nOriginal tables: {} | Saved tables: {}", orig_tables.len(), saved_tables.len()); - - // Compare all tables: rows, cols, cells, flags, para positions - for i in 0..orig_tables.len().max(saved_tables.len()) { - eprintln!("\n--- Table[{}] ---", i); - if i < orig_tables.len() { - let (pi, ci, t) = orig_tables[i]; - let total_paras: usize = t.cells.iter().map(|c| c.paragraphs.len()).sum(); - let text_paras: usize = t.cells.iter().map(|c| c.paragraphs.iter().filter(|p| !p.text.is_empty()).count()).sum(); - eprintln!(" Original: para_pos={} ctrl_pos={} rows={} cols={} cells={} total_paras={} text_paras={} flags=0x{:08x} page_break={:?}", + let orig_tables = find_tables(&orig_doc.sections[0].paragraphs); + let saved_tables = find_tables(&saved_doc.sections[0].paragraphs); + + eprintln!( + "\nOriginal tables: {} | Saved tables: {}", + orig_tables.len(), + saved_tables.len() + ); + + // Compare all tables: rows, cols, cells, flags, para positions + for i in 0..orig_tables.len().max(saved_tables.len()) { + eprintln!("\n--- Table[{}] ---", i); + if i < orig_tables.len() { + let (pi, ci, t) = orig_tables[i]; + let total_paras: usize = t.cells.iter().map(|c| c.paragraphs.len()).sum(); + let text_paras: usize = t + .cells + .iter() + .map(|c| c.paragraphs.iter().filter(|p| !p.text.is_empty()).count()) + .sum(); + eprintln!(" Original: para_pos={} ctrl_pos={} rows={} cols={} cells={} total_paras={} text_paras={} flags=0x{:08x} page_break={:?}", pi, ci, t.row_count, t.col_count, t.cells.len(), total_paras, text_paras, t.attr, t.page_break); - } else { - eprintln!(" Original: MISSING"); - } - if i < saved_tables.len() { - let (pi, ci, t) = saved_tables[i]; - let total_paras: usize = t.cells.iter().map(|c| c.paragraphs.len()).sum(); - let text_paras: usize = t.cells.iter().map(|c| c.paragraphs.iter().filter(|p| !p.text.is_empty()).count()).sum(); - eprintln!(" Saved: para_pos={} ctrl_pos={} rows={} cols={} cells={} total_paras={} text_paras={} flags=0x{:08x} page_break={:?}", + } else { + eprintln!(" Original: MISSING"); + } + if i < saved_tables.len() { + let (pi, ci, t) = saved_tables[i]; + let total_paras: usize = t.cells.iter().map(|c| c.paragraphs.len()).sum(); + let text_paras: usize = t + .cells + .iter() + .map(|c| c.paragraphs.iter().filter(|p| !p.text.is_empty()).count()) + .sum(); + eprintln!(" Saved: para_pos={} ctrl_pos={} rows={} cols={} cells={} total_paras={} text_paras={} flags=0x{:08x} page_break={:?}", pi, ci, t.row_count, t.col_count, t.cells.len(), total_paras, text_paras, t.attr, t.page_break); - } else { - eprintln!(" Saved: MISSING"); - } + } else { + eprintln!(" Saved: MISSING"); } + } - // Detailed check of Table[3]: compare cell-by-cell - if orig_tables.len() > 3 && saved_tables.len() > 3 { - let (_, _, ot) = orig_tables[3]; - let (_, _, st) = saved_tables[3]; - - eprintln!("\n--- Table[3] cell-by-cell comparison ---"); - let cell_count = ot.cells.len().min(st.cells.len()); - let mut diffs = Vec::new(); - for ci in 0..cell_count { - let oc = &ot.cells[ci]; - let sc = &st.cells[ci]; - let mut cell_diffs = Vec::new(); - - // Compare cell structure - if oc.col != sc.col || oc.row != sc.row || oc.col_span != sc.col_span || oc.row_span != sc.row_span { - cell_diffs.push(format!("position: ({},{}) cs={}x{} vs ({},{}) cs={}x{}", - oc.col, oc.row, oc.col_span, oc.row_span, - sc.col, sc.row, sc.col_span, sc.row_span)); - } - if oc.width != sc.width || oc.height != sc.height { - cell_diffs.push(format!("size: {}x{} vs {}x{}", oc.width, oc.height, sc.width, sc.height)); - } - if oc.paragraphs.len() != sc.paragraphs.len() { - cell_diffs.push(format!("para_count: {} vs {}", oc.paragraphs.len(), sc.paragraphs.len())); - } - // Compare paragraph text - for pi in 0..oc.paragraphs.len().min(sc.paragraphs.len()) { - let op = &oc.paragraphs[pi]; - let sp = &sc.paragraphs[pi]; - if op.text != sp.text { - cell_diffs.push(format!("para[{}] text: '{}' vs '{}'", pi, - &op.text[..op.text.len().min(30)], - &sp.text[..sp.text.len().min(30)])); - } - if op.char_count != sp.char_count { - cell_diffs.push(format!("para[{}] char_count: {} vs {}", pi, op.char_count, sp.char_count)); - } - if op.has_para_text != sp.has_para_text { - cell_diffs.push(format!("para[{}] has_para_text: {} vs {}", pi, op.has_para_text, sp.has_para_text)); - } - } - - if !cell_diffs.is_empty() { - diffs.push((ci, cell_diffs)); - } - } + // Detailed check of Table[3]: compare cell-by-cell + if orig_tables.len() > 3 && saved_tables.len() > 3 { + let (_, _, ot) = orig_tables[3]; + let (_, _, st) = saved_tables[3]; - eprintln!("Cells with differences: {} out of {}", diffs.len(), cell_count); - for (ci, cell_diffs) in &diffs { - eprintln!(" cell[{}] (row={}, col={}):", ci, ot.cells[*ci].row, ot.cells[*ci].col); - for d in cell_diffs { - eprintln!(" {}", d); - } + eprintln!("\n--- Table[3] cell-by-cell comparison ---"); + let cell_count = ot.cells.len().min(st.cells.len()); + let mut diffs = Vec::new(); + for ci in 0..cell_count { + let oc = &ot.cells[ci]; + let sc = &st.cells[ci]; + let mut cell_diffs = Vec::new(); + + // Compare cell structure + if oc.col != sc.col + || oc.row != sc.row + || oc.col_span != sc.col_span + || oc.row_span != sc.row_span + { + cell_diffs.push(format!( + "position: ({},{}) cs={}x{} vs ({},{}) cs={}x{}", + oc.col, + oc.row, + oc.col_span, + oc.row_span, + sc.col, + sc.row, + sc.col_span, + sc.row_span + )); + } + if oc.width != sc.width || oc.height != sc.height { + cell_diffs.push(format!( + "size: {}x{} vs {}x{}", + oc.width, oc.height, sc.width, sc.height + )); + } + if oc.paragraphs.len() != sc.paragraphs.len() { + cell_diffs.push(format!( + "para_count: {} vs {}", + oc.paragraphs.len(), + sc.paragraphs.len() + )); + } + // Compare paragraph text + for pi in 0..oc.paragraphs.len().min(sc.paragraphs.len()) { + let op = &oc.paragraphs[pi]; + let sp = &sc.paragraphs[pi]; + if op.text != sp.text { + cell_diffs.push(format!( + "para[{}] text: '{}' vs '{}'", + pi, + &op.text[..op.text.len().min(30)], + &sp.text[..sp.text.len().min(30)] + )); + } + if op.char_count != sp.char_count { + cell_diffs.push(format!( + "para[{}] char_count: {} vs {}", + pi, op.char_count, sp.char_count + )); + } + if op.has_para_text != sp.has_para_text { + cell_diffs.push(format!( + "para[{}] has_para_text: {} vs {}", + pi, op.has_para_text, sp.has_para_text + )); + } + } + + if !cell_diffs.is_empty() { + diffs.push((ci, cell_diffs)); + } + } + + eprintln!( + "Cells with differences: {} out of {}", + diffs.len(), + cell_count + ); + for (ci, cell_diffs) in &diffs { + eprintln!( + " cell[{}] (row={}, col={}):", + ci, ot.cells[*ci].row, ot.cells[*ci].col + ); + for d in cell_diffs { + eprintln!(" {}", d); } + } - // Show the specific text content of first 5 differing cells - eprintln!("\n--- First 5 differing cells detail ---"); - for (ci, _) in diffs.iter().take(5) { - let oc = &ot.cells[*ci]; - let sc = &st.cells[*ci]; - eprintln!(" cell[{}] (row={}, col={}):", ci, oc.row, oc.col); - for pi in 0..oc.paragraphs.len().min(sc.paragraphs.len()) { - let op = &oc.paragraphs[pi]; - let sp = &sc.paragraphs[pi]; - eprintln!(" orig para[{}]: text={:?} char_count={} msb={} has_pt={} char_offsets={:?} char_shapes_len={}", + // Show the specific text content of first 5 differing cells + eprintln!("\n--- First 5 differing cells detail ---"); + for (ci, _) in diffs.iter().take(5) { + let oc = &ot.cells[*ci]; + let sc = &st.cells[*ci]; + eprintln!(" cell[{}] (row={}, col={}):", ci, oc.row, oc.col); + for pi in 0..oc.paragraphs.len().min(sc.paragraphs.len()) { + let op = &oc.paragraphs[pi]; + let sp = &sc.paragraphs[pi]; + eprintln!(" orig para[{}]: text={:?} char_count={} msb={} has_pt={} char_offsets={:?} char_shapes_len={}", pi, &op.text, op.char_count, op.char_count_msb, op.has_para_text, &op.char_offsets, op.char_shapes.len()); - eprintln!(" saved para[{}]: text={:?} char_count={} msb={} has_pt={} char_offsets={:?} char_shapes_len={}", + eprintln!(" saved para[{}]: text={:?} char_count={} msb={} has_pt={} char_offsets={:?} char_shapes_len={}", pi, &sp.text, sp.char_count, sp.char_count_msb, sp.has_para_text, &sp.char_offsets, sp.char_shapes.len()); - } } } + } - // Also check: which paragraph does Table[3] belong to, and what else changed in the document? - eprintln!("\n--- Document-level comparison ---"); - let orig_para_count = orig_doc.sections[0].paragraphs.len(); - let saved_para_count = saved_doc.sections[0].paragraphs.len(); - eprintln!("Section[0] paragraph count: orig={} saved={}", orig_para_count, saved_para_count); - - // Check non-table paragraphs for text differences - let min_paras = orig_para_count.min(saved_para_count); - let mut non_table_diffs = 0; - for pi in 0..min_paras { - let op = &orig_doc.sections[0].paragraphs[pi]; - let sp = &saved_doc.sections[0].paragraphs[pi]; - if op.text != sp.text || op.controls.len() != sp.controls.len() { - non_table_diffs += 1; - if non_table_diffs <= 5 { - eprintln!(" para[{}] differs: orig_text_len={} saved_text_len={} orig_ctrls={} saved_ctrls={}", + // Also check: which paragraph does Table[3] belong to, and what else changed in the document? + eprintln!("\n--- Document-level comparison ---"); + let orig_para_count = orig_doc.sections[0].paragraphs.len(); + let saved_para_count = saved_doc.sections[0].paragraphs.len(); + eprintln!( + "Section[0] paragraph count: orig={} saved={}", + orig_para_count, saved_para_count + ); + + // Check non-table paragraphs for text differences + let min_paras = orig_para_count.min(saved_para_count); + let mut non_table_diffs = 0; + for pi in 0..min_paras { + let op = &orig_doc.sections[0].paragraphs[pi]; + let sp = &saved_doc.sections[0].paragraphs[pi]; + if op.text != sp.text || op.controls.len() != sp.controls.len() { + non_table_diffs += 1; + if non_table_diffs <= 5 { + eprintln!(" para[{}] differs: orig_text_len={} saved_text_len={} orig_ctrls={} saved_ctrls={}", pi, op.text.len(), sp.text.len(), op.controls.len(), sp.controls.len()); - } } } - eprintln!("Non-table paragraph differences: {}", non_table_diffs); - if saved_para_count > orig_para_count { - eprintln!("Extra paragraphs in saved: {}", saved_para_count - orig_para_count); - for pi in orig_para_count..saved_para_count { - let sp = &saved_doc.sections[0].paragraphs[pi]; - eprintln!(" para[{}]: text_len={} controls={}", pi, sp.text.len(), sp.controls.len()); - } + } + eprintln!("Non-table paragraph differences: {}", non_table_diffs); + if saved_para_count > orig_para_count { + eprintln!( + "Extra paragraphs in saved: {}", + saved_para_count - orig_para_count + ); + for pi in orig_para_count..saved_para_count { + let sp = &saved_doc.sections[0].paragraphs[pi]; + eprintln!( + " para[{}]: text_len={} controls={}", + pi, + sp.text.len(), + sp.controls.len() + ); } + } - // Detailed check of para[8] and para[10] - eprintln!("\n--- Detailed check of para[8] and para[10] ---"); - for pi in [8, 9, 10, 11] { - if pi < orig_doc.sections[0].paragraphs.len() { - let p = &orig_doc.sections[0].paragraphs[pi]; - eprintln!(" ORIG para[{}]: text_len={} text={:?} ctrls={} ctrl_types={:?}", - pi, p.text.len(), &p.text.chars().take(30).collect::(), - p.controls.len(), - p.controls.iter().map(|c| match c { + // Detailed check of para[8] and para[10] + eprintln!("\n--- Detailed check of para[8] and para[10] ---"); + for pi in [8, 9, 10, 11] { + if pi < orig_doc.sections[0].paragraphs.len() { + let p = &orig_doc.sections[0].paragraphs[pi]; + eprintln!( + " ORIG para[{}]: text_len={} text={:?} ctrls={} ctrl_types={:?}", + pi, + p.text.len(), + &p.text.chars().take(30).collect::(), + p.controls.len(), + p.controls + .iter() + .map(|c| match c { crate::model::control::Control::Table(_) => "Table", crate::model::control::Control::Shape(_) => "Shape", crate::model::control::Control::Footnote(_) => "Footnote", @@ -4558,14 +6083,21 @@ crate::model::control::Control::ColumnDef(_) => "ColumnDef", crate::model::control::Control::Picture(_) => "Picture", _ => "Other", - }).collect::>()); - } - if pi < saved_doc.sections[0].paragraphs.len() { - let p = &saved_doc.sections[0].paragraphs[pi]; - eprintln!(" SAVED para[{}]: text_len={} text={:?} ctrls={} ctrl_types={:?}", - pi, p.text.len(), &p.text.chars().take(30).collect::(), - p.controls.len(), - p.controls.iter().map(|c| match c { + }) + .collect::>() + ); + } + if pi < saved_doc.sections[0].paragraphs.len() { + let p = &saved_doc.sections[0].paragraphs[pi]; + eprintln!( + " SAVED para[{}]: text_len={} text={:?} ctrls={} ctrl_types={:?}", + pi, + p.text.len(), + &p.text.chars().take(30).collect::(), + p.controls.len(), + p.controls + .iter() + .map(|c| match c { crate::model::control::Control::Table(_) => "Table", crate::model::control::Control::Shape(_) => "Shape", crate::model::control::Control::Footnote(_) => "Footnote", @@ -4576,1460 +6108,2051 @@ crate::model::control::Control::ColumnDef(_) => "ColumnDef", crate::model::control::Control::Picture(_) => "Picture", _ => "Other", - }).collect::>()); - } + }) + .collect::>() + ); } + } - // Check if Table[3] in saved is the same table (same col/row structure) as original - // Or if it's a newly created table from paste - eprintln!("\n--- Table[3] structural identity check ---"); - if orig_tables.len() > 3 && saved_tables.len() > 3 { - let (_, _, ot) = orig_tables[3]; - let (_, _, st) = saved_tables[3]; - eprintln!(" Same row_count: {} ({}=={})", ot.row_count == st.row_count, ot.row_count, st.row_count); - eprintln!(" Same col_count: {} ({}=={})", ot.col_count == st.col_count, ot.col_count, st.col_count); - eprintln!(" Same cell count: {} ({}=={})", ot.cells.len() == st.cells.len(), ot.cells.len(), st.cells.len()); - eprintln!(" Same attr: {} (0x{:08x}==0x{:08x})", ot.attr == st.attr, ot.attr, st.attr); - eprintln!(" Same border_fill_id: {} ({}=={})", ot.border_fill_id == st.border_fill_id, ot.border_fill_id, st.border_fill_id); + // Check if Table[3] in saved is the same table (same col/row structure) as original + // Or if it's a newly created table from paste + eprintln!("\n--- Table[3] structural identity check ---"); + if orig_tables.len() > 3 && saved_tables.len() > 3 { + let (_, _, ot) = orig_tables[3]; + let (_, _, st) = saved_tables[3]; + eprintln!( + " Same row_count: {} ({}=={})", + ot.row_count == st.row_count, + ot.row_count, + st.row_count + ); + eprintln!( + " Same col_count: {} ({}=={})", + ot.col_count == st.col_count, + ot.col_count, + st.col_count + ); + eprintln!( + " Same cell count: {} ({}=={})", + ot.cells.len() == st.cells.len(), + ot.cells.len(), + st.cells.len() + ); + eprintln!( + " Same attr: {} (0x{:08x}==0x{:08x})", + ot.attr == st.attr, + ot.attr, + st.attr + ); + eprintln!( + " Same border_fill_id: {} ({}=={})", + ot.border_fill_id == st.border_fill_id, + ot.border_fill_id, + st.border_fill_id + ); - // Compare cells with text - are the actual text values the same? - let mut text_match_count = 0; - let mut text_mismatch_count = 0; - for ci in 0..ot.cells.len().min(st.cells.len()) { - for pi in 0..ot.cells[ci].paragraphs.len().min(st.cells[ci].paragraphs.len()) { - let op = &ot.cells[ci].paragraphs[pi]; - let sp = &st.cells[ci].paragraphs[pi]; - if !op.text.is_empty() && op.text == sp.text { - text_match_count += 1; - } else if !op.text.is_empty() && op.text != sp.text { - text_mismatch_count += 1; - } + // Compare cells with text - are the actual text values the same? + let mut text_match_count = 0; + let mut text_mismatch_count = 0; + for ci in 0..ot.cells.len().min(st.cells.len()) { + for pi in 0..ot.cells[ci] + .paragraphs + .len() + .min(st.cells[ci].paragraphs.len()) + { + let op = &ot.cells[ci].paragraphs[pi]; + let sp = &st.cells[ci].paragraphs[pi]; + if !op.text.is_empty() && op.text == sp.text { + text_match_count += 1; + } else if !op.text.is_empty() && op.text != sp.text { + text_mismatch_count += 1; } } - eprintln!(" Cells with original text preserved: {}", text_match_count); - eprintln!(" Cells with original text changed: {}", text_mismatch_count); } - - eprintln!("\n{}", "=".repeat(120)); + eprintln!(" Cells with original text preserved: {}", text_match_count); + eprintln!( + " Cells with original text changed: {}", + text_mismatch_count + ); } - /// 재직렬화 격리 테스트: paste 없이 raw_stream 제거만으로 레코드 수 비교 - #[test] - fn test_roundtrip_isolation_no_paste() { - use crate::parser::record::Record; - use crate::parser::tags; - - let orig_path = "pasts/20250130-hongbo-p2.hwp"; - if !std::path::Path::new(orig_path).exists() { - eprintln!("SKIP: 파일 없음"); - return; - } + eprintln!("\n{}", "=".repeat(120)); +} - let orig_data = std::fs::read(orig_path).unwrap(); - let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); +/// 재직렬화 격리 테스트: paste 없이 raw_stream 제거만으로 레코드 수 비교 +#[test] +fn test_roundtrip_isolation_no_paste() { + use crate::parser::record::Record; + use crate::parser::tags; - // Step 1: Re-serialize WITHOUT paste (just clear raw_stream) - doc.document.sections[0].raw_stream = None; - let saved_data = doc.export_hwp_native().unwrap(); - eprintln!("원본: {} bytes, 재직렬화(no paste): {} bytes", orig_data.len(), saved_data.len()); + let orig_path = "pasts/20250130-hongbo-p2.hwp"; + if !std::path::Path::new(orig_path).exists() { + eprintln!("SKIP: 파일 없음"); + return; + } - // Step 2: Re-parse the saved file - let doc2 = HwpDocument::from_bytes(&saved_data); - match &doc2 { - Ok(d) => eprintln!("재파싱 성공: {} sections, {} paragraphs", - d.document().sections.len(), - d.document().sections[0].paragraphs.len()), - Err(e) => eprintln!("재파싱 실패: {:?}", e), + let orig_data = std::fs::read(orig_path).unwrap(); + let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); + + // Step 1: Re-serialize WITHOUT paste (just clear raw_stream) + doc.document.sections[0].raw_stream = None; + let saved_data = doc.export_hwp_native().unwrap(); + eprintln!( + "원본: {} bytes, 재직렬화(no paste): {} bytes", + orig_data.len(), + saved_data.len() + ); + + // Step 2: Re-parse the saved file + let doc2 = HwpDocument::from_bytes(&saved_data); + match &doc2 { + Ok(d) => eprintln!( + "재파싱 성공: {} sections, {} paragraphs", + d.document().sections.len(), + d.document().sections[0].paragraphs.len() + ), + Err(e) => eprintln!("재파싱 실패: {:?}", e), + } + assert!(doc2.is_ok(), "재직렬화 파일 파싱 실패"); + + // Step 3: Compare record counts + let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); + let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); + let orig_bt = orig_cfb + .read_body_text_section(0, orig_doc.header.compressed, false) + .unwrap(); + let orig_recs = Record::read_all(&orig_bt).unwrap(); + + let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); + let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_doc.header.compressed, false) + .unwrap(); + let saved_recs = Record::read_all(&saved_bt).unwrap(); + + eprintln!("\n=== Record count comparison (no paste) ==="); + eprintln!("Original records: {}", orig_recs.len()); + eprintln!("Saved records: {}", saved_recs.len()); + + let count_tag = |recs: &[Record], tag: u16| recs.iter().filter(|r| r.tag_id == tag).count(); + + let tags_to_check: [(u16, &str); 7] = [ + (tags::HWPTAG_PARA_HEADER, "PARA_HEADER"), + (tags::HWPTAG_PARA_TEXT, "PARA_TEXT"), + (tags::HWPTAG_PARA_CHAR_SHAPE, "PARA_CHAR_SHAPE"), + (tags::HWPTAG_PARA_LINE_SEG, "PARA_LINE_SEG"), + (tags::HWPTAG_CTRL_HEADER, "CTRL_HEADER"), + (tags::HWPTAG_LIST_HEADER, "LIST_HEADER"), + (tags::HWPTAG_TABLE, "TABLE"), + ]; + + let mut any_diff = false; + for (tag, name) in &tags_to_check { + let orig_cnt = count_tag(&orig_recs, *tag); + let saved_cnt = count_tag(&saved_recs, *tag); + let diff = saved_cnt as i64 - orig_cnt as i64; + if diff != 0 { + eprintln!( + " {}: {} → {} ({}{}) ← DIFF", + name, + orig_cnt, + saved_cnt, + if diff > 0 { "+" } else { "" }, + diff + ); + any_diff = true; + } else { + eprintln!(" {}: {} (동일)", name, orig_cnt); } - assert!(doc2.is_ok(), "재직렬화 파일 파싱 실패"); - - // Step 3: Compare record counts - let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); - let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); - let orig_bt = orig_cfb.read_body_text_section(0, orig_doc.header.compressed, false).unwrap(); - let orig_recs = Record::read_all(&orig_bt).unwrap(); - - let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); - let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, saved_doc.header.compressed, false).unwrap(); - let saved_recs = Record::read_all(&saved_bt).unwrap(); - - eprintln!("\n=== Record count comparison (no paste) ==="); - eprintln!("Original records: {}", orig_recs.len()); - eprintln!("Saved records: {}", saved_recs.len()); - - let count_tag = |recs: &[Record], tag: u16| recs.iter().filter(|r| r.tag_id == tag).count(); + } - let tags_to_check: [(u16, &str); 7] = [ - (tags::HWPTAG_PARA_HEADER, "PARA_HEADER"), - (tags::HWPTAG_PARA_TEXT, "PARA_TEXT"), - (tags::HWPTAG_PARA_CHAR_SHAPE, "PARA_CHAR_SHAPE"), - (tags::HWPTAG_PARA_LINE_SEG, "PARA_LINE_SEG"), - (tags::HWPTAG_CTRL_HEADER, "CTRL_HEADER"), - (tags::HWPTAG_LIST_HEADER, "LIST_HEADER"), - (tags::HWPTAG_TABLE, "TABLE"), - ]; + if !any_diff { + eprintln!("\n모든 레코드 타입 동일 ✓"); + } - let mut any_diff = false; - for (tag, name) in &tags_to_check { - let orig_cnt = count_tag(&orig_recs, *tag); - let saved_cnt = count_tag(&saved_recs, *tag); - let diff = saved_cnt as i64 - orig_cnt as i64; - if diff != 0 { - eprintln!(" {}: {} → {} ({}{}) ← DIFF", name, orig_cnt, saved_cnt, - if diff > 0 { "+" } else { "" }, diff); - any_diff = true; + // Step 4: Check that PARA_HEADER char_count matches PARA_TEXT existence + eprintln!("\n=== PARA_HEADER/PARA_TEXT consistency check ==="); + let mut inconsistencies = 0; + let mut i = 0; + while i < saved_recs.len() { + if saved_recs[i].tag_id == tags::HWPTAG_PARA_HEADER { + let ph_data = &saved_recs[i].data; + let ph_level = saved_recs[i].level; + let nchars = if ph_data.len() >= 4 { + u32::from_le_bytes([ph_data[0], ph_data[1], ph_data[2], ph_data[3]]) & 0x7FFFFFFF } else { - eprintln!(" {}: {} (동일)", name, orig_cnt); + 0 + }; + // Check next record + let has_text = i + 1 < saved_recs.len() + && saved_recs[i + 1].tag_id == tags::HWPTAG_PARA_TEXT + && saved_recs[i + 1].level == ph_level + 1; + if nchars > 1 && !has_text { + eprintln!( + " rec[{}] PARA_HEADER nchars={} but NO PARA_TEXT follows!", + i, nchars + ); + inconsistencies += 1; } - } - - if !any_diff { - eprintln!("\n모든 레코드 타입 동일 ✓"); - } - - // Step 4: Check that PARA_HEADER char_count matches PARA_TEXT existence - eprintln!("\n=== PARA_HEADER/PARA_TEXT consistency check ==="); - let mut inconsistencies = 0; - let mut i = 0; - while i < saved_recs.len() { - if saved_recs[i].tag_id == tags::HWPTAG_PARA_HEADER { - let ph_data = &saved_recs[i].data; - let ph_level = saved_recs[i].level; - let nchars = if ph_data.len() >= 4 { - u32::from_le_bytes([ph_data[0], ph_data[1], ph_data[2], ph_data[3]]) & 0x7FFFFFFF - } else { 0 }; - // Check next record - let has_text = i + 1 < saved_recs.len() - && saved_recs[i+1].tag_id == tags::HWPTAG_PARA_TEXT - && saved_recs[i+1].level == ph_level + 1; - if nchars > 1 && !has_text { - eprintln!(" rec[{}] PARA_HEADER nchars={} but NO PARA_TEXT follows!", i, nchars); - inconsistencies += 1; - } - if nchars <= 1 && has_text { - let pt_size = saved_recs[i+1].data.len(); - eprintln!(" rec[{}] PARA_HEADER nchars={} but HAS PARA_TEXT ({}B) — might be OK (terminator only)", i, nchars, pt_size); - } + if nchars <= 1 && has_text { + let pt_size = saved_recs[i + 1].data.len(); + eprintln!(" rec[{}] PARA_HEADER nchars={} but HAS PARA_TEXT ({}B) — might be OK (terminator only)", i, nchars, pt_size); } - i += 1; } - eprintln!(" Total inconsistencies: {}", inconsistencies); + i += 1; + } + eprintln!(" Total inconsistencies: {}", inconsistencies); +} + +/// 테이블 paste 후 재직렬화 유효성 검증 +#[test] +fn test_paste_table_then_export_validation() { + use crate::parser::record::Record; + use crate::parser::tags; + + let orig_path = "pasts/20250130-hongbo-p2.hwp"; + if !std::path::Path::new(orig_path).exists() { + eprintln!("SKIP: 파일 없음"); + return; } - /// 테이블 paste 후 재직렬화 유효성 검증 - #[test] - fn test_paste_table_then_export_validation() { - use crate::parser::record::Record; - use crate::parser::tags; - - let orig_path = "pasts/20250130-hongbo-p2.hwp"; - if !std::path::Path::new(orig_path).exists() { - eprintln!("SKIP: 파일 없음"); + let orig_data = std::fs::read(orig_path).unwrap(); + let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); + + // 원본 레코드 수 저장 + let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); + let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); + let orig_bt = orig_cfb + .read_body_text_section(0, orig_doc.header.compressed, false) + .unwrap(); + let orig_recs = Record::read_all(&orig_bt).unwrap(); + let orig_para_text_count = orig_recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_PARA_TEXT) + .count(); + let orig_para_count = orig_recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_PARA_HEADER) + .count(); + eprintln!( + "원본: {} records, {} PARA_HEADER, {} PARA_TEXT", + orig_recs.len(), + orig_para_count, + orig_para_text_count + ); + + // 간단한 HTML 테이블 paste + let simple_table_html = r#"
Cell ACell B
Cell CCell D
"#; + let last_para = doc.document.sections[0].paragraphs.len() - 1; + let result = doc.paste_html_native(0, last_para, 0, simple_table_html); + match &result { + Ok(r) => eprintln!("Paste result: {}", r), + Err(e) => { + eprintln!("Paste failed: {:?}", e); return; } + } - let orig_data = std::fs::read(orig_path).unwrap(); - let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); - - // 원본 레코드 수 저장 - let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); - let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); - let orig_bt = orig_cfb.read_body_text_section(0, orig_doc.header.compressed, false).unwrap(); - let orig_recs = Record::read_all(&orig_bt).unwrap(); - let orig_para_text_count = orig_recs.iter().filter(|r| r.tag_id == tags::HWPTAG_PARA_TEXT).count(); - let orig_para_count = orig_recs.iter().filter(|r| r.tag_id == tags::HWPTAG_PARA_HEADER).count(); - eprintln!("원본: {} records, {} PARA_HEADER, {} PARA_TEXT", orig_recs.len(), orig_para_count, orig_para_text_count); - - // 간단한 HTML 테이블 paste - let simple_table_html = r#"
Cell ACell B
Cell CCell D
"#; - let last_para = doc.document.sections[0].paragraphs.len() - 1; - let result = doc.paste_html_native(0, last_para, 0, simple_table_html); - match &result { - Ok(r) => eprintln!("Paste result: {}", r), - Err(e) => { eprintln!("Paste failed: {:?}", e); return; }, + // Export + let saved_data = doc.export_hwp_native().unwrap(); + eprintln!("재직렬화(with paste): {} bytes", saved_data.len()); + + // Re-parse + let doc2 = HwpDocument::from_bytes(&saved_data); + match &doc2 { + Ok(d) => eprintln!( + "재파싱 성공: {} sections, {} paragraphs", + d.document().sections.len(), + d.document().sections[0].paragraphs.len() + ), + Err(e) => { + eprintln!("재파싱 실패: {:?}", e); + // 실패시에도 record-level 분석 진행 } + } - // Export - let saved_data = doc.export_hwp_native().unwrap(); - eprintln!("재직렬화(with paste): {} bytes", saved_data.len()); + // Record level 분석 + let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); + let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_doc.header.compressed, false) + .unwrap(); + let saved_recs = Record::read_all(&saved_bt).unwrap(); + + eprintln!("\n=== Record count comparison (with paste) ==="); + let count_tag = |recs: &[Record], tag: u16| recs.iter().filter(|r| r.tag_id == tag).count(); + let tags_to_check: [(u16, &str); 7] = [ + (tags::HWPTAG_PARA_HEADER, "PARA_HEADER"), + (tags::HWPTAG_PARA_TEXT, "PARA_TEXT"), + (tags::HWPTAG_PARA_CHAR_SHAPE, "PARA_CHAR_SHAPE"), + (tags::HWPTAG_PARA_LINE_SEG, "PARA_LINE_SEG"), + (tags::HWPTAG_CTRL_HEADER, "CTRL_HEADER"), + (tags::HWPTAG_LIST_HEADER, "LIST_HEADER"), + (tags::HWPTAG_TABLE, "TABLE"), + ]; + for (tag, name) in &tags_to_check { + let orig_cnt = count_tag(&orig_recs, *tag); + let saved_cnt = count_tag(&saved_recs, *tag); + let diff = saved_cnt as i64 - orig_cnt as i64; + eprintln!( + " {}: {} → {} ({}{}){}", + name, + orig_cnt, + saved_cnt, + if diff > 0 { "+" } else { "" }, + diff, + if diff != 0 { " ← DIFF" } else { "" } + ); + } - // Re-parse - let doc2 = HwpDocument::from_bytes(&saved_data); - match &doc2 { - Ok(d) => eprintln!("재파싱 성공: {} sections, {} paragraphs", - d.document().sections.len(), - d.document().sections[0].paragraphs.len()), - Err(e) => { - eprintln!("재파싱 실패: {:?}", e); - // 실패시에도 record-level 분석 진행 - } - } + // PARA_HEADER/PARA_TEXT consistency + eprintln!("\n=== Consistency check ==="); + let mut issues = 0; + let mut i = 0; + while i < saved_recs.len() { + if saved_recs[i].tag_id == tags::HWPTAG_PARA_HEADER { + let ph_data = &saved_recs[i].data; + let ph_level = saved_recs[i].level; + let nchars = if ph_data.len() >= 4 { + u32::from_le_bytes([ph_data[0], ph_data[1], ph_data[2], ph_data[3]]) & 0x7FFFFFFF + } else { + 0 + }; - // Record level 분석 - let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); - let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, saved_doc.header.compressed, false).unwrap(); - let saved_recs = Record::read_all(&saved_bt).unwrap(); + // numCharShapes from para_header + let n_cs = if ph_data.len() >= 14 { + u16::from_le_bytes([ph_data[12], ph_data[13]]) + } else { + 0 + }; - eprintln!("\n=== Record count comparison (with paste) ==="); - let count_tag = |recs: &[Record], tag: u16| recs.iter().filter(|r| r.tag_id == tag).count(); - let tags_to_check: [(u16, &str); 7] = [ - (tags::HWPTAG_PARA_HEADER, "PARA_HEADER"), - (tags::HWPTAG_PARA_TEXT, "PARA_TEXT"), - (tags::HWPTAG_PARA_CHAR_SHAPE, "PARA_CHAR_SHAPE"), - (tags::HWPTAG_PARA_LINE_SEG, "PARA_LINE_SEG"), - (tags::HWPTAG_CTRL_HEADER, "CTRL_HEADER"), - (tags::HWPTAG_LIST_HEADER, "LIST_HEADER"), - (tags::HWPTAG_TABLE, "TABLE"), - ]; - for (tag, name) in &tags_to_check { - let orig_cnt = count_tag(&orig_recs, *tag); - let saved_cnt = count_tag(&saved_recs, *tag); - let diff = saved_cnt as i64 - orig_cnt as i64; - eprintln!(" {}: {} → {} ({}{}){}", - name, orig_cnt, saved_cnt, - if diff > 0 { "+" } else { "" }, diff, - if diff != 0 { " ← DIFF" } else { "" }); - } - - // PARA_HEADER/PARA_TEXT consistency - eprintln!("\n=== Consistency check ==="); - let mut issues = 0; - let mut i = 0; - while i < saved_recs.len() { - if saved_recs[i].tag_id == tags::HWPTAG_PARA_HEADER { - let ph_data = &saved_recs[i].data; - let ph_level = saved_recs[i].level; - let nchars = if ph_data.len() >= 4 { - u32::from_le_bytes([ph_data[0], ph_data[1], ph_data[2], ph_data[3]]) & 0x7FFFFFFF - } else { 0 }; - - // numCharShapes from para_header - let n_cs = if ph_data.len() >= 14 { - u16::from_le_bytes([ph_data[12], ph_data[13]]) - } else { 0 }; - - // Count actual PARA_CHAR_SHAPE entries - let mut actual_cs = 0u32; - let mut j = i + 1; - while j < saved_recs.len() && saved_recs[j].level > ph_level { - if saved_recs[j].tag_id == tags::HWPTAG_PARA_CHAR_SHAPE && saved_recs[j].level == ph_level + 1 { - actual_cs = (saved_recs[j].data.len() / 8) as u32; - } - j += 1; + // Count actual PARA_CHAR_SHAPE entries + let mut actual_cs = 0u32; + let mut j = i + 1; + while j < saved_recs.len() && saved_recs[j].level > ph_level { + if saved_recs[j].tag_id == tags::HWPTAG_PARA_CHAR_SHAPE + && saved_recs[j].level == ph_level + 1 + { + actual_cs = (saved_recs[j].data.len() / 8) as u32; } + j += 1; + } - if n_cs as u32 != actual_cs && actual_cs > 0 { - eprintln!(" rec[{}] PARA_HEADER: numCharShapes={} but actual PARA_CHAR_SHAPE entries={}", i, n_cs, actual_cs); - issues += 1; - } + if n_cs as u32 != actual_cs && actual_cs > 0 { + eprintln!( + " rec[{}] PARA_HEADER: numCharShapes={} but actual PARA_CHAR_SHAPE entries={}", + i, n_cs, actual_cs + ); + issues += 1; + } - // Check if nchars > 1 but no PARA_TEXT - let has_text = i + 1 < saved_recs.len() - && saved_recs[i+1].tag_id == tags::HWPTAG_PARA_TEXT - && saved_recs[i+1].level == ph_level + 1; - if nchars > 1 && !has_text { - eprintln!(" rec[{}] PARA_HEADER nchars={} but NO PARA_TEXT!", i, nchars); - issues += 1; - } + // Check if nchars > 1 but no PARA_TEXT + let has_text = i + 1 < saved_recs.len() + && saved_recs[i + 1].tag_id == tags::HWPTAG_PARA_TEXT + && saved_recs[i + 1].level == ph_level + 1; + if nchars > 1 && !has_text { + eprintln!( + " rec[{}] PARA_HEADER nchars={} but NO PARA_TEXT!", + i, nchars + ); + issues += 1; } - i += 1; } - eprintln!(" Total issues: {}", issues); - - // Dump pasted table records - eprintln!("\n=== Pasted table structure ==="); - let tables: Vec = saved_recs.iter().enumerate() - .filter(|(_, r)| r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 && { + i += 1; + } + eprintln!(" Total issues: {}", issues); + + // Dump pasted table records + eprintln!("\n=== Pasted table structure ==="); + let tables: Vec = saved_recs + .iter() + .enumerate() + .filter(|(_, r)| { + r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 && { let ctrl_id = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); ctrl_id == tags::CTRL_TABLE - }) - .map(|(i, _)| i) - .collect(); - eprintln!("Total tables: {}", tables.len()); - if let Some(&last_tbl_idx) = tables.last() { - let tbl_level = saved_recs[last_tbl_idx].level; - let mut end = last_tbl_idx + 1; - while end < saved_recs.len() && saved_recs[end].level > tbl_level { - end += 1; - } - eprintln!("Last (pasted) table: rec[{}..{}] ({} records)", last_tbl_idx, end, end - last_tbl_idx); - for ri in last_tbl_idx..end.min(last_tbl_idx + 40) { - let r = &saved_recs[ri]; - let tag_name = tags::tag_name(r.tag_id); - eprintln!(" [{}] {} L{} {}B", ri, tag_name, r.level, r.data.len()); - } - } - - // Check TABLE record extra bytes in original tables - eprintln!("\n=== Original TABLE record sizes ==="); - for (ri, r) in orig_recs.iter().enumerate() { - if r.tag_id == tags::HWPTAG_TABLE { - let data = &r.data; - if data.len() >= 8 { - let nrows = u16::from_le_bytes([data[4], data[5]]); - let ncols = u16::from_le_bytes([data[6], data[7]]); - let expected_min = 4 + 2 + 2 + 2 + 8 + (nrows as usize) * 2 + 2; - let extra = data.len().saturating_sub(expected_min); - let extra_bytes: Vec = data[expected_min..].to_vec(); - eprintln!(" rec[{}] TABLE {}B (nrows={} ncols={} expected_min={} extra={} extra_bytes={:02X?})", - ri, data.len(), nrows, ncols, expected_min, extra, extra_bytes); - } } + }) + .map(|(i, _)| i) + .collect(); + eprintln!("Total tables: {}", tables.len()); + if let Some(&last_tbl_idx) = tables.last() { + let tbl_level = saved_recs[last_tbl_idx].level; + let mut end = last_tbl_idx + 1; + while end < saved_recs.len() && saved_recs[end].level > tbl_level { + end += 1; + } + eprintln!( + "Last (pasted) table: rec[{}..{}] ({} records)", + last_tbl_idx, + end, + end - last_tbl_idx + ); + for ri in last_tbl_idx..end.min(last_tbl_idx + 40) { + let r = &saved_recs[ri]; + let tag_name = tags::tag_name(r.tag_id); + eprintln!(" [{}] {} L{} {}B", ri, tag_name, r.level, r.data.len()); } + } - // 저장 (수동 확인용) - let out_dir = std::path::Path::new("output"); - if out_dir.exists() { - std::fs::write(out_dir.join("hongbo_paste_test.hwp"), &saved_data).unwrap(); - eprintln!("\n저장: output/hongbo_paste_test.hwp"); + // Check TABLE record extra bytes in original tables + eprintln!("\n=== Original TABLE record sizes ==="); + for (ri, r) in orig_recs.iter().enumerate() { + if r.tag_id == tags::HWPTAG_TABLE { + let data = &r.data; + if data.len() >= 8 { + let nrows = u16::from_le_bytes([data[4], data[5]]); + let ncols = u16::from_le_bytes([data[6], data[7]]); + let expected_min = 4 + 2 + 2 + 2 + 8 + (nrows as usize) * 2 + 2; + let extra = data.len().saturating_sub(expected_min); + let extra_bytes: Vec = data[expected_min..].to_vec(); + eprintln!(" rec[{}] TABLE {}B (nrows={} ncols={} expected_min={} extra={} extra_bytes={:02X?})", + ri, data.len(), nrows, ncols, expected_min, extra, extra_bytes); + } } } - /// DocInfo CharShape 수 추적: 파싱 → convertToEditable → paste → export - #[test] - fn test_trace_charshape_loss() { - use crate::parser::record::Record; - use crate::parser::tags; - - let orig_path = "pasts/20250130-hongbo-p2.hwp"; - if !std::path::Path::new(orig_path).exists() { - eprintln!("SKIP: 파일 없음"); - return; - } + // 저장 (수동 확인용) + let out_dir = std::path::Path::new("output"); + if out_dir.exists() { + std::fs::write(out_dir.join("hongbo_paste_test.hwp"), &saved_data).unwrap(); + eprintln!("\n저장: output/hongbo_paste_test.hwp"); + } +} + +/// DocInfo CharShape 수 추적: 파싱 → convertToEditable → paste → export +#[test] +fn test_trace_charshape_loss() { + use crate::parser::record::Record; + use crate::parser::tags; + + let orig_path = "pasts/20250130-hongbo-p2.hwp"; + if !std::path::Path::new(orig_path).exists() { + eprintln!("SKIP: 파일 없음"); + return; + } - let orig_data = std::fs::read(orig_path).unwrap(); - let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); + let orig_data = std::fs::read(orig_path).unwrap(); + let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); - // Helper: count CHAR_SHAPE records in raw_stream - fn count_cs_in_raw(raw: &Option>) -> usize { - match raw { - Some(data) => { - let records = Record::read_all(data).unwrap_or_default(); - records.iter().filter(|r| r.tag_id == tags::HWPTAG_CHAR_SHAPE).count() - } - None => 0, - } - } - - // Step 1: After parsing - let model_cs_1 = doc.document().doc_info.char_shapes.len(); - let raw_cs_1 = count_cs_in_raw(&doc.document().doc_info.raw_stream); - let is_dist = doc.document().header.distribution; - eprintln!("Step 1 (after parse): model={} raw={} distribution={}", model_cs_1, raw_cs_1, is_dist); - - // Step 2: After convert_to_editable - let converted = doc.convert_to_editable_native().unwrap(); - let model_cs_2 = doc.document().doc_info.char_shapes.len(); - let raw_cs_2 = count_cs_in_raw(&doc.document().doc_info.raw_stream); - eprintln!("Step 2 (after convert): model={} raw={} result={}", model_cs_2, raw_cs_2, converted); - - // Step 3: Export without paste - let saved_no_paste = doc.export_hwp_native().unwrap(); - let doc_np = crate::parser::parse_hwp(&saved_no_paste).unwrap(); - eprintln!("Step 3 (export no paste): model_cs={}", doc_np.doc_info.char_shapes.len()); - - // Step 4: Paste simple table - let last_para = doc.document.sections[0].paragraphs.len() - 1; - let _ = doc.paste_html_native(0, last_para, 0, - r#"
AB
"#); - let model_cs_4 = doc.document().doc_info.char_shapes.len(); - let raw_cs_4 = count_cs_in_raw(&doc.document().doc_info.raw_stream); - eprintln!("Step 4 (after paste): model={} raw={}", model_cs_4, raw_cs_4); - - // Step 5: Export after paste - let saved_with_paste = doc.export_hwp_native().unwrap(); - let doc_wp = crate::parser::parse_hwp(&saved_with_paste).unwrap(); - eprintln!("Step 5 (export with paste): model_cs={}", doc_wp.doc_info.char_shapes.len()); - - // Assertions - assert_eq!(model_cs_1, raw_cs_1, "Model vs raw after parse should match"); - } - - /// rp-006 BodyText 레코드 분석: dangling CharShape/ParaShape 참조 검출 - #[test] - fn test_rp006_dangling_references() { - use crate::parser::cfb_reader::{CfbReader, decompress_stream}; - use crate::parser::record::Record; - use crate::parser::tags; - - let saved_path = "pasts/20250130-hongbo_saved-rp-006.hwp"; - if !std::path::Path::new(saved_path).exists() { - eprintln!("SKIP: rp-006 파일 없음"); - return; + // Helper: count CHAR_SHAPE records in raw_stream + fn count_cs_in_raw(raw: &Option>) -> usize { + match raw { + Some(data) => { + let records = Record::read_all(data).unwrap_or_default(); + records + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_CHAR_SHAPE) + .count() + } + None => 0, } + } - let saved_bytes = std::fs::read(saved_path).unwrap(); - let mut cfb = CfbReader::open(&saved_bytes).expect("CFB 열기 실패"); - - // DocInfo: CharShape/ParaShape 총 수 - let doc_info_data = cfb.read_doc_info(true).expect("DocInfo 읽기 실패"); - let doc_recs = Record::read_all(&doc_info_data).unwrap(); - - let cs_count = doc_recs.iter().filter(|r| r.tag_id == tags::HWPTAG_CHAR_SHAPE).count(); - let ps_count = doc_recs.iter().filter(|r| r.tag_id == tags::HWPTAG_PARA_SHAPE).count(); - let bf_count = doc_recs.iter().filter(|r| r.tag_id == tags::HWPTAG_BORDER_FILL).count(); - eprintln!("\n=== rp-006 DocInfo: CharShape={}, ParaShape={}, BorderFill={} ===", cs_count, ps_count, bf_count); - - // BodyText Section0 - let body_data = cfb.read_body_text_section(0, true, false).expect("BodyText 읽기 실패"); - let body_recs = Record::read_all(&body_data).unwrap(); - eprintln!("BodyText 레코드 총 수: {}", body_recs.len()); - - // 모든 PARA_HEADER에서 para_shape_id 추출 - let mut dangling_ps = Vec::new(); - let mut dangling_cs = Vec::new(); - let mut dangling_bf = Vec::new(); - let mut para_idx = 0; + // Step 1: After parsing + let model_cs_1 = doc.document().doc_info.char_shapes.len(); + let raw_cs_1 = count_cs_in_raw(&doc.document().doc_info.raw_stream); + let is_dist = doc.document().header.distribution; + eprintln!( + "Step 1 (after parse): model={} raw={} distribution={}", + model_cs_1, raw_cs_1, is_dist + ); + + // Step 2: After convert_to_editable + let converted = doc.convert_to_editable_native().unwrap(); + let model_cs_2 = doc.document().doc_info.char_shapes.len(); + let raw_cs_2 = count_cs_in_raw(&doc.document().doc_info.raw_stream); + eprintln!( + "Step 2 (after convert): model={} raw={} result={}", + model_cs_2, raw_cs_2, converted + ); + + // Step 3: Export without paste + let saved_no_paste = doc.export_hwp_native().unwrap(); + let doc_np = crate::parser::parse_hwp(&saved_no_paste).unwrap(); + eprintln!( + "Step 3 (export no paste): model_cs={}", + doc_np.doc_info.char_shapes.len() + ); + + // Step 4: Paste simple table + let last_para = doc.document.sections[0].paragraphs.len() - 1; + let _ = doc.paste_html_native( + 0, + last_para, + 0, + r#"
AB
"#, + ); + let model_cs_4 = doc.document().doc_info.char_shapes.len(); + let raw_cs_4 = count_cs_in_raw(&doc.document().doc_info.raw_stream); + eprintln!( + "Step 4 (after paste): model={} raw={}", + model_cs_4, raw_cs_4 + ); + + // Step 5: Export after paste + let saved_with_paste = doc.export_hwp_native().unwrap(); + let doc_wp = crate::parser::parse_hwp(&saved_with_paste).unwrap(); + eprintln!( + "Step 5 (export with paste): model_cs={}", + doc_wp.doc_info.char_shapes.len() + ); + + // Assertions + assert_eq!( + model_cs_1, raw_cs_1, + "Model vs raw after parse should match" + ); +} + +/// rp-006 BodyText 레코드 분석: dangling CharShape/ParaShape 참조 검출 +#[test] +fn test_rp006_dangling_references() { + use crate::parser::cfb_reader::{decompress_stream, CfbReader}; + use crate::parser::record::Record; + use crate::parser::tags; + + let saved_path = "pasts/20250130-hongbo_saved-rp-006.hwp"; + if !std::path::Path::new(saved_path).exists() { + eprintln!("SKIP: rp-006 파일 없음"); + return; + } - for (ri, rec) in body_recs.iter().enumerate() { - if rec.tag_id == tags::HWPTAG_PARA_HEADER && rec.data.len() >= 10 { - let ps_id = u16::from_le_bytes([rec.data[8], rec.data[9]]) as usize; - if ps_id >= ps_count { - dangling_ps.push((para_idx, ri, ps_id)); - } - para_idx += 1; - } - // PARA_CHAR_SHAPE: 각 4바이트 쌍 (start_pos u32 + char_shape_id u32) - if rec.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { - let mut pos = 0; - while pos + 8 <= rec.data.len() { - let cs_id = u32::from_le_bytes([ - rec.data[pos + 4], rec.data[pos + 5], - rec.data[pos + 6], rec.data[pos + 7], - ]) as usize; - if cs_id >= cs_count { - dangling_cs.push((ri, pos / 8, cs_id)); - } - pos += 8; + let saved_bytes = std::fs::read(saved_path).unwrap(); + let mut cfb = CfbReader::open(&saved_bytes).expect("CFB 열기 실패"); + + // DocInfo: CharShape/ParaShape 총 수 + let doc_info_data = cfb.read_doc_info(true).expect("DocInfo 읽기 실패"); + let doc_recs = Record::read_all(&doc_info_data).unwrap(); + + let cs_count = doc_recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_CHAR_SHAPE) + .count(); + let ps_count = doc_recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_PARA_SHAPE) + .count(); + let bf_count = doc_recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_BORDER_FILL) + .count(); + eprintln!( + "\n=== rp-006 DocInfo: CharShape={}, ParaShape={}, BorderFill={} ===", + cs_count, ps_count, bf_count + ); + + // BodyText Section0 + let body_data = cfb + .read_body_text_section(0, true, false) + .expect("BodyText 읽기 실패"); + let body_recs = Record::read_all(&body_data).unwrap(); + eprintln!("BodyText 레코드 총 수: {}", body_recs.len()); + + // 모든 PARA_HEADER에서 para_shape_id 추출 + let mut dangling_ps = Vec::new(); + let mut dangling_cs = Vec::new(); + let mut dangling_bf = Vec::new(); + let mut para_idx = 0; + + for (ri, rec) in body_recs.iter().enumerate() { + if rec.tag_id == tags::HWPTAG_PARA_HEADER && rec.data.len() >= 10 { + let ps_id = u16::from_le_bytes([rec.data[8], rec.data[9]]) as usize; + if ps_id >= ps_count { + dangling_ps.push((para_idx, ri, ps_id)); + } + para_idx += 1; + } + // PARA_CHAR_SHAPE: 각 4바이트 쌍 (start_pos u32 + char_shape_id u32) + if rec.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { + let mut pos = 0; + while pos + 8 <= rec.data.len() { + let cs_id = u32::from_le_bytes([ + rec.data[pos + 4], + rec.data[pos + 5], + rec.data[pos + 6], + rec.data[pos + 7], + ]) as usize; + if cs_id >= cs_count { + dangling_cs.push((ri, pos / 8, cs_id)); } + pos += 8; } - // LIST_HEADER의 border_fill_id (셀) 및 TABLE의 border_fill_id - if rec.tag_id == tags::HWPTAG_LIST_HEADER && rec.data.len() >= 34 { - let bf_id = u16::from_le_bytes([rec.data[32], rec.data[33]]) as usize; - if bf_id > 0 && bf_id - 1 >= bf_count { - dangling_bf.push((ri, "LIST_HEADER", bf_id)); - } + } + // LIST_HEADER의 border_fill_id (셀) 및 TABLE의 border_fill_id + if rec.tag_id == tags::HWPTAG_LIST_HEADER && rec.data.len() >= 34 { + let bf_id = u16::from_le_bytes([rec.data[32], rec.data[33]]) as usize; + if bf_id > bf_count { + dangling_bf.push((ri, "LIST_HEADER", bf_id)); } } + } - eprintln!("\n--- Dangling ParaShape References ---"); - if dangling_ps.is_empty() { - eprintln!(" None found (OK)"); - } else { - for (pi, ri, ps_id) in &dangling_ps { - eprintln!(" para[{}] rec[{}]: para_shape_id={} >= max {}", pi, ri, ps_id, ps_count); - } + eprintln!("\n--- Dangling ParaShape References ---"); + if dangling_ps.is_empty() { + eprintln!(" None found (OK)"); + } else { + for (pi, ri, ps_id) in &dangling_ps { + eprintln!( + " para[{}] rec[{}]: para_shape_id={} >= max {}", + pi, ri, ps_id, ps_count + ); } + } - eprintln!("\n--- Dangling CharShape References ---"); - if dangling_cs.is_empty() { - eprintln!(" None found (OK)"); - } else { - for (ri, entry, cs_id) in &dangling_cs { - eprintln!(" rec[{}] entry[{}]: char_shape_id={} >= max {}", ri, entry, cs_id, cs_count); - } + eprintln!("\n--- Dangling CharShape References ---"); + if dangling_cs.is_empty() { + eprintln!(" None found (OK)"); + } else { + for (ri, entry, cs_id) in &dangling_cs { + eprintln!( + " rec[{}] entry[{}]: char_shape_id={} >= max {}", + ri, entry, cs_id, cs_count + ); + } + } + + eprintln!("\n--- Dangling BorderFill References ---"); + if dangling_bf.is_empty() { + eprintln!(" None found (OK)"); + } else { + for (ri, source, bf_id) in &dangling_bf { + eprintln!( + " rec[{}] {}: border_fill_id={} >= max {}", + ri, source, bf_id, bf_count + ); } + } - eprintln!("\n--- Dangling BorderFill References ---"); - if dangling_bf.is_empty() { - eprintln!(" None found (OK)"); + // 마지막 TABLE + 셀들의 레코드 덤프 (붙여넣기된 표 추정) + eprintln!("\n--- Last 100 records (pasted table area) ---"); + let start = if body_recs.len() > 100 { + body_recs.len() - 100 + } else { + 0 + }; + for (i, rec) in body_recs[start..].iter().enumerate() { + let indent = " ".repeat(rec.level as usize); + let tag_name = tags::tag_name(rec.tag_id); + let extra = if rec.tag_id == tags::HWPTAG_PARA_HEADER && rec.data.len() >= 12 { + let cc = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); + let cm = u32::from_le_bytes([rec.data[4], rec.data[5], rec.data[6], rec.data[7]]); + let ps_id = u16::from_le_bytes([rec.data[8], rec.data[9]]); + let style_id = rec.data[10]; + let char_count = cc & 0x7FFFFFFF; + let msb = cc >> 31; + format!( + " char_count={} msb={} ctrl_mask=0x{:08X} ps_id={} style_id={}", + char_count, msb, cm, ps_id, style_id + ) + } else if rec.tag_id == tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); + let rev_bytes: Vec = rec.data[0..4].iter().rev().cloned().collect(); + let ctrl_str = String::from_utf8_lossy(&rev_bytes); + format!(" ctrl_id=0x{:08X}('{}')", ctrl_id, ctrl_str) + } else if rec.tag_id == tags::HWPTAG_TABLE && rec.data.len() >= 8 { + let nrows = u16::from_le_bytes([rec.data[4], rec.data[5]]); + let ncols = u16::from_le_bytes([rec.data[6], rec.data[7]]); + format!(" rows={} cols={}", nrows, ncols) + } else if rec.tag_id == tags::HWPTAG_LIST_HEADER && rec.data.len() >= 8 { + let nparas = u16::from_le_bytes([rec.data[0], rec.data[1]]); + format!(" n_paras={}", nparas) + } else if rec.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { + let n_entries = rec.data.len() / 8; + let mut ids: Vec = Vec::new(); + let mut pos = 0; + while pos + 8 <= rec.data.len() { + ids.push(u32::from_le_bytes([ + rec.data[pos + 4], + rec.data[pos + 5], + rec.data[pos + 6], + rec.data[pos + 7], + ])); + pos += 8; + } + format!(" entries={} cs_ids={:?}", n_entries, ids) } else { - for (ri, source, bf_id) in &dangling_bf { - eprintln!(" rec[{}] {}: border_fill_id={} >= max {}", ri, source, bf_id, bf_count); - } + String::new() + }; + eprintln!( + " rec[{}] {}L{} {} ({}B){}", + start + i, + indent, + rec.level, + tag_name, + rec.data.len(), + extra + ); + } + + // Summary assertion + let total_dangling = dangling_cs.len() + dangling_ps.len(); + if total_dangling > 0 { + eprintln!("\n*** FOUND {} DANGLING REFERENCES ***", total_dangling); + } +} + +/// template 파일 비교: step1 (원본 2x2표) vs step1-p (HWP 붙여넣기) vs step1_saved (우리 뷰어 붙여넣기) +#[test] +fn test_template_comparison() { + use crate::parser::cfb_reader::CfbReader; + use crate::parser::record::Record; + use crate::parser::tags; + + let files = [ + ( + "step1_saved (뷰어 저장/손상)", + "template/empty-step1_saved.hwp", + ), + ( + "step1_saved-a (HWP 다른이름저장/정상)", + "template/empty-step1_saved-a.hwp", + ), + ]; + + for (label, path) in &files { + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 파일 없음", path); + continue; } - // 마지막 TABLE + 셀들의 레코드 덤프 (붙여넣기된 표 추정) - eprintln!("\n--- Last 100 records (pasted table area) ---"); - let start = if body_recs.len() > 100 { body_recs.len() - 100 } else { 0 }; - for (i, rec) in body_recs[start..].iter().enumerate() { + let bytes = std::fs::read(path).unwrap(); + let mut cfb = CfbReader::open(&bytes).unwrap_or_else(|_| panic!("{} CFB 열기 실패", label)); + + // DocInfo 분석 + let doc_info_data = cfb.read_doc_info(true).expect("DocInfo 읽기 실패"); + let doc_recs = Record::read_all(&doc_info_data).unwrap(); + + let cs_count = doc_recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_CHAR_SHAPE) + .count(); + let ps_count = doc_recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_PARA_SHAPE) + .count(); + let bf_count = doc_recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_BORDER_FILL) + .count(); + let style_count = doc_recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_STYLE) + .count(); + + eprintln!("\n{}", "=".repeat(80)); + eprintln!(" {} ({} bytes)", label, bytes.len()); + eprintln!( + " DocInfo: CS={} PS={} BF={} Style={}", + cs_count, ps_count, bf_count, style_count + ); + + // BodyText Section0 분석 + let body_data = cfb + .read_body_text_section(0, true, false) + .expect("BodyText 읽기 실패"); + let body_recs = Record::read_all(&body_data).unwrap(); + + eprintln!( + " BodyText: {} records, {} bytes", + body_recs.len(), + body_data.len() + ); + + // 전체 레코드 덤프 + eprintln!("\n --- ALL RECORDS ---"); + for (i, rec) in body_recs.iter().enumerate() { let indent = " ".repeat(rec.level as usize); let tag_name = tags::tag_name(rec.tag_id); let extra = if rec.tag_id == tags::HWPTAG_PARA_HEADER && rec.data.len() >= 12 { let cc = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); let cm = u32::from_le_bytes([rec.data[4], rec.data[5], rec.data[6], rec.data[7]]); let ps_id = u16::from_le_bytes([rec.data[8], rec.data[9]]); - let style_id = rec.data[10]; + let style = rec.data[10]; let char_count = cc & 0x7FFFFFFF; let msb = cc >> 31; - format!(" char_count={} msb={} ctrl_mask=0x{:08X} ps_id={} style_id={}", - char_count, msb, cm, ps_id, style_id) + format!( + " cc={} msb={} cm=0x{:08X} ps={} st={}", + char_count, msb, cm, ps_id, style + ) } else if rec.tag_id == tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - let rev_bytes: Vec = rec.data[0..4].iter().rev().cloned().collect(); - let ctrl_str = String::from_utf8_lossy(&rev_bytes); - format!(" ctrl_id=0x{:08X}('{}')", ctrl_id, ctrl_str) + let ctrl_id = + u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); + let rev: Vec = rec.data[0..4].iter().rev().cloned().collect(); + let ctrl_str = String::from_utf8_lossy(&rev); + if rec.data.len() >= 8 { + let attr = + u32::from_le_bytes([rec.data[4], rec.data[5], rec.data[6], rec.data[7]]); + format!(" '{}' attr=0x{:08X}", ctrl_str, attr) + } else { + format!(" '{}'", ctrl_str) + } } else if rec.tag_id == tags::HWPTAG_TABLE && rec.data.len() >= 8 { + let attr = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); let nrows = u16::from_le_bytes([rec.data[4], rec.data[5]]); let ncols = u16::from_le_bytes([rec.data[6], rec.data[7]]); - format!(" rows={} cols={}", nrows, ncols) - } else if rec.tag_id == tags::HWPTAG_LIST_HEADER && rec.data.len() >= 8 { + format!(" attr=0x{:08X} {}x{}", attr, nrows, ncols) + } else if rec.tag_id == tags::HWPTAG_LIST_HEADER && rec.data.len() >= 2 { let nparas = u16::from_le_bytes([rec.data[0], rec.data[1]]); - format!(" n_paras={}", nparas) + format!(" nparas={}", nparas) + } else if rec.tag_id == tags::HWPTAG_PARA_TEXT { + // 첫 20바이트 hex 덤프 + let hex: String = rec + .data + .iter() + .take(20) + .map(|b| format!("{:02X}", b)) + .collect::>() + .join(" "); + format!(" [{}{}]", hex, if rec.data.len() > 20 { "..." } else { "" }) } else if rec.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { - let n_entries = rec.data.len() / 8; - let mut ids: Vec = Vec::new(); + let mut ids = Vec::new(); let mut pos = 0; while pos + 8 <= rec.data.len() { - ids.push(u32::from_le_bytes([ - rec.data[pos + 4], rec.data[pos + 5], - rec.data[pos + 6], rec.data[pos + 7], - ])); + let cs_id = u32::from_le_bytes([ + rec.data[pos + 4], + rec.data[pos + 5], + rec.data[pos + 6], + rec.data[pos + 7], + ]); + ids.push(cs_id); pos += 8; } - format!(" entries={} cs_ids={:?}", n_entries, ids) + format!(" cs_ids={:?}", ids) } else { String::new() }; - eprintln!(" rec[{}] {}L{} {} ({}B){}", start + i, indent, rec.level, tag_name, rec.data.len(), extra); - } - - // Summary assertion - let total_dangling = dangling_cs.len() + dangling_ps.len(); - if total_dangling > 0 { - eprintln!("\n*** FOUND {} DANGLING REFERENCES ***", total_dangling); + eprintln!( + " rec[{:3}] {}L{} {} ({}B){}", + i, + indent, + rec.level, + tag_name, + rec.data.len(), + extra + ); } - } - - /// template 파일 비교: step1 (원본 2x2표) vs step1-p (HWP 붙여넣기) vs step1_saved (우리 뷰어 붙여넣기) - #[test] - fn test_template_comparison() { - use crate::parser::cfb_reader::CfbReader; - use crate::parser::record::Record; - use crate::parser::tags; - - let files = [ - ("step1_saved (뷰어 저장/손상)", "template/empty-step1_saved.hwp"), - ("step1_saved-a (HWP 다른이름저장/정상)", "template/empty-step1_saved-a.hwp"), - ]; - for (label, path) in &files { - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 파일 없음", path); - continue; + // CTRL_HEADER 바이트 덤프 (tbl 컨트롤만) + eprintln!("\n --- TABLE CTRL_HEADER hex dump ---"); + for (i, rec) in body_recs.iter().enumerate() { + if rec.tag_id == tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { + let ctrl_id = + u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); + if ctrl_id == 0x6C626174 { + // 'tbl ' + let hex: String = rec + .data + .iter() + .map(|b| format!("{:02X}", b)) + .collect::>() + .join(" "); + eprintln!( + " rec[{:3}] CTRL_HEADER(tbl) {}B: {}", + i, + rec.data.len(), + hex + ); + } } + if rec.tag_id == tags::HWPTAG_TABLE { + let hex: String = rec + .data + .iter() + .map(|b| format!("{:02X}", b)) + .collect::>() + .join(" "); + eprintln!(" rec[{:3}] TABLE {}B: {}", i, rec.data.len(), hex); + } + if rec.tag_id == tags::HWPTAG_LIST_HEADER { + let hex: String = rec + .data + .iter() + .map(|b| format!("{:02X}", b)) + .collect::>() + .join(" "); + eprintln!(" rec[{:3}] LIST_HEADER {}B: {}", i, rec.data.len(), hex); + } + } - let bytes = std::fs::read(path).unwrap(); - let mut cfb = CfbReader::open(&bytes).expect(&format!("{} CFB 열기 실패", label)); - - // DocInfo 분석 - let doc_info_data = cfb.read_doc_info(true).expect("DocInfo 읽기 실패"); - let doc_recs = Record::read_all(&doc_info_data).unwrap(); + eprintln!("\n{}", "=".repeat(80)); + } +} - let cs_count = doc_recs.iter().filter(|r| r.tag_id == tags::HWPTAG_CHAR_SHAPE).count(); - let ps_count = doc_recs.iter().filter(|r| r.tag_id == tags::HWPTAG_PARA_SHAPE).count(); - let bf_count = doc_recs.iter().filter(|r| r.tag_id == tags::HWPTAG_BORDER_FILL).count(); - let style_count = doc_recs.iter().filter(|r| r.tag_id == tags::HWPTAG_STYLE).count(); +/// 손상 HWP vs 정상 HWP 종합 비교 (DocInfo ID_MAPPINGS + BodyText Section 0 전체 레코드) +#[test] +fn test_complex_comparison() { + use crate::parser::cfb_reader::CfbReader; + use crate::parser::record::Record; + use crate::parser::tags; - eprintln!("\n{}", "=".repeat(80)); - eprintln!(" {} ({} bytes)", label, bytes.len()); - eprintln!(" DocInfo: CS={} PS={} BF={} Style={}", cs_count, ps_count, bf_count, style_count); + let damaged_path = "template/20250130-hongbo_saved_err.hwp"; + let fixed_path = "template/111111.hwp"; - // BodyText Section0 분석 - let body_data = cfb.read_body_text_section(0, true, false).expect("BodyText 읽기 실패"); - let body_recs = Record::read_all(&body_data).unwrap(); + if !std::path::Path::new(damaged_path).exists() { + eprintln!("SKIP: {} not found", damaged_path); + return; + } + if !std::path::Path::new(fixed_path).exists() { + eprintln!("SKIP: {} not found", fixed_path); + return; + } - eprintln!(" BodyText: {} records, {} bytes", body_recs.len(), body_data.len()); + let damaged_bytes = std::fs::read(damaged_path).unwrap(); + let fixed_bytes = std::fs::read(fixed_path).unwrap(); - // 전체 레코드 덤프 - eprintln!("\n --- ALL RECORDS ---"); - for (i, rec) in body_recs.iter().enumerate() { - let indent = " ".repeat(rec.level as usize); - let tag_name = tags::tag_name(rec.tag_id); - let extra = if rec.tag_id == tags::HWPTAG_PARA_HEADER && rec.data.len() >= 12 { - let cc = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - let cm = u32::from_le_bytes([rec.data[4], rec.data[5], rec.data[6], rec.data[7]]); - let ps_id = u16::from_le_bytes([rec.data[8], rec.data[9]]); - let style = rec.data[10]; - let char_count = cc & 0x7FFFFFFF; - let msb = cc >> 31; - format!(" cc={} msb={} cm=0x{:08X} ps={} st={}", char_count, msb, cm, ps_id, style) - } else if rec.tag_id == tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - let rev: Vec = rec.data[0..4].iter().rev().cloned().collect(); - let ctrl_str = String::from_utf8_lossy(&rev); - if rec.data.len() >= 8 { - let attr = u32::from_le_bytes([rec.data[4], rec.data[5], rec.data[6], rec.data[7]]); - format!(" '{}' attr=0x{:08X}", ctrl_str, attr) - } else { - format!(" '{}'", ctrl_str) - } - } else if rec.tag_id == tags::HWPTAG_TABLE && rec.data.len() >= 8 { - let attr = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - let nrows = u16::from_le_bytes([rec.data[4], rec.data[5]]); - let ncols = u16::from_le_bytes([rec.data[6], rec.data[7]]); - format!(" attr=0x{:08X} {}x{}", attr, nrows, ncols) - } else if rec.tag_id == tags::HWPTAG_LIST_HEADER && rec.data.len() >= 2 { - let nparas = u16::from_le_bytes([rec.data[0], rec.data[1]]); - format!(" nparas={}", nparas) - } else if rec.tag_id == tags::HWPTAG_PARA_TEXT { - // 첫 20바이트 hex 덤프 - let hex: String = rec.data.iter().take(20) - .map(|b| format!("{:02X}", b)).collect::>().join(" "); - format!(" [{}{}]", hex, if rec.data.len() > 20 { "..." } else { "" }) - } else if rec.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { - let mut ids = Vec::new(); - let mut pos = 0; - while pos + 8 <= rec.data.len() { - let cs_id = u32::from_le_bytes([ - rec.data[pos + 4], rec.data[pos + 5], - rec.data[pos + 6], rec.data[pos + 7], - ]); - ids.push(cs_id); - pos += 8; - } - format!(" cs_ids={:?}", ids) - } else { - String::new() - }; - eprintln!(" rec[{:3}] {}L{} {} ({}B){}", i, indent, rec.level, tag_name, rec.data.len(), extra); - } + let mut damaged_cfb = CfbReader::open(&damaged_bytes).expect("damaged CFB open failed"); + let mut fixed_cfb = CfbReader::open(&fixed_bytes).expect("fixed CFB open failed"); - // CTRL_HEADER 바이트 덤프 (tbl 컨트롤만) - eprintln!("\n --- TABLE CTRL_HEADER hex dump ---"); - for (i, rec) in body_recs.iter().enumerate() { - if rec.tag_id == tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - if ctrl_id == 0x6C626174 { // 'tbl ' - let hex: String = rec.data.iter() - .map(|b| format!("{:02X}", b)).collect::>().join(" "); - eprintln!(" rec[{:3}] CTRL_HEADER(tbl) {}B: {}", i, rec.data.len(), hex); - } - } - if rec.tag_id == tags::HWPTAG_TABLE { - let hex: String = rec.data.iter() - .map(|b| format!("{:02X}", b)).collect::>().join(" "); - eprintln!(" rec[{:3}] TABLE {}B: {}", i, rec.data.len(), hex); - } - if rec.tag_id == tags::HWPTAG_LIST_HEADER { - let hex: String = rec.data.iter() - .map(|b| format!("{:02X}", b)).collect::>().join(" "); - eprintln!(" rec[{:3}] LIST_HEADER {}B: {}", i, rec.data.len(), hex); - } - } + eprintln!("\n{}", "=".repeat(90)); + eprintln!(" COMPLEX COMPARISON: Damaged vs Fixed HWP"); + eprintln!( + " Damaged: {} ({} bytes)", + damaged_path, + damaged_bytes.len() + ); + eprintln!(" Fixed: {} ({} bytes)", fixed_path, fixed_bytes.len()); + eprintln!("{}", "=".repeat(90)); - eprintln!("\n{}", "=".repeat(80)); - } + // ===================================================================== + // Part 1: DocInfo - ID_MAPPINGS counts comparison + // ===================================================================== + eprintln!("\n{}", "=".repeat(90)); + eprintln!(" PART 1: DocInfo ID_MAPPINGS Comparison"); + eprintln!("{}", "=".repeat(90)); + + let damaged_di = damaged_cfb + .read_doc_info(true) + .expect("damaged DocInfo read failed"); + let fixed_di = fixed_cfb + .read_doc_info(true) + .expect("fixed DocInfo read failed"); + + let damaged_di_recs = Record::read_all(&damaged_di).unwrap(); + let fixed_di_recs = Record::read_all(&fixed_di).unwrap(); + + eprintln!( + " Damaged DocInfo: {} records, {} bytes", + damaged_di_recs.len(), + damaged_di.len() + ); + eprintln!( + " Fixed DocInfo: {} records, {} bytes", + fixed_di_recs.len(), + fixed_di.len() + ); + + // Count records by tag type + let tag_types_of_interest: Vec<(u16, &str)> = vec![ + (tags::HWPTAG_BIN_DATA, "BinData"), + (tags::HWPTAG_FACE_NAME, "FaceName"), + (tags::HWPTAG_BORDER_FILL, "BorderFill"), + (tags::HWPTAG_CHAR_SHAPE, "CharShape"), + (tags::HWPTAG_TAB_DEF, "TabDef"), + (tags::HWPTAG_NUMBERING, "Numbering"), + (tags::HWPTAG_BULLET, "Bullet"), + (tags::HWPTAG_PARA_SHAPE, "ParaShape"), + (tags::HWPTAG_STYLE, "Style"), + ]; + + eprintln!( + "\n {:<20} {:>10} {:>10} {:>10}", + "Record Type", "Damaged", "Fixed", "Diff" + ); + eprintln!(" {}", "-".repeat(55)); + let mut docinfo_diff_count = 0; + for (tag_id, name) in &tag_types_of_interest { + let d_cnt = damaged_di_recs + .iter() + .filter(|r| r.tag_id == *tag_id) + .count(); + let f_cnt = fixed_di_recs.iter().filter(|r| r.tag_id == *tag_id).count(); + let diff = f_cnt as i64 - d_cnt as i64; + let marker = if diff != 0 { " <== DIFF" } else { "" }; + if diff != 0 { + docinfo_diff_count += 1; + } + eprintln!( + " {:<20} {:>10} {:>10} {:>+10}{}", + name, d_cnt, f_cnt, diff, marker + ); } - /// 손상 HWP vs 정상 HWP 종합 비교 (DocInfo ID_MAPPINGS + BodyText Section 0 전체 레코드) - #[test] - fn test_complex_comparison() { - use crate::parser::cfb_reader::CfbReader; - use crate::parser::record::Record; - use crate::parser::tags; - - let damaged_path = "template/20250130-hongbo_saved_err.hwp"; - let fixed_path = "template/111111.hwp"; - - if !std::path::Path::new(damaged_path).exists() { - eprintln!("SKIP: {} not found", damaged_path); - return; - } - if !std::path::Path::new(fixed_path).exists() { - eprintln!("SKIP: {} not found", fixed_path); - return; - } - - let damaged_bytes = std::fs::read(damaged_path).unwrap(); - let fixed_bytes = std::fs::read(fixed_path).unwrap(); - - let mut damaged_cfb = CfbReader::open(&damaged_bytes).expect("damaged CFB open failed"); - let mut fixed_cfb = CfbReader::open(&fixed_bytes).expect("fixed CFB open failed"); - - eprintln!("\n{}", "=".repeat(90)); - eprintln!(" COMPLEX COMPARISON: Damaged vs Fixed HWP"); - eprintln!(" Damaged: {} ({} bytes)", damaged_path, damaged_bytes.len()); - eprintln!(" Fixed: {} ({} bytes)", fixed_path, fixed_bytes.len()); - eprintln!("{}", "=".repeat(90)); - - // ===================================================================== - // Part 1: DocInfo - ID_MAPPINGS counts comparison - // ===================================================================== - eprintln!("\n{}", "=".repeat(90)); - eprintln!(" PART 1: DocInfo ID_MAPPINGS Comparison"); - eprintln!("{}", "=".repeat(90)); - - let damaged_di = damaged_cfb.read_doc_info(true).expect("damaged DocInfo read failed"); - let fixed_di = fixed_cfb.read_doc_info(true).expect("fixed DocInfo read failed"); - - let damaged_di_recs = Record::read_all(&damaged_di).unwrap(); - let fixed_di_recs = Record::read_all(&fixed_di).unwrap(); - - eprintln!(" Damaged DocInfo: {} records, {} bytes", damaged_di_recs.len(), damaged_di.len()); - eprintln!(" Fixed DocInfo: {} records, {} bytes", fixed_di_recs.len(), fixed_di.len()); - - // Count records by tag type - let tag_types_of_interest: Vec<(u16, &str)> = vec![ - (tags::HWPTAG_BIN_DATA, "BinData"), - (tags::HWPTAG_FACE_NAME, "FaceName"), - (tags::HWPTAG_BORDER_FILL, "BorderFill"), - (tags::HWPTAG_CHAR_SHAPE, "CharShape"), - (tags::HWPTAG_TAB_DEF, "TabDef"), - (tags::HWPTAG_NUMBERING, "Numbering"), - (tags::HWPTAG_BULLET, "Bullet"), - (tags::HWPTAG_PARA_SHAPE, "ParaShape"), - (tags::HWPTAG_STYLE, "Style"), - ]; + // ID_MAPPINGS record comparison + let id_mappings_field_names = [ + "BinData", + "Font_Korean", + "Font_English", + "Font_Hanja", + "Font_Japanese", + "Font_Other", + "Font_Symbol", + "Font_User", + "BorderFill", + "CharShape", + "TabDef", + "Numbering", + "Bullet", + "ParaShape", + "Style", + "MemoShape", + "Field16", + "Field17", + "Field18", + "Field19", + ]; + + let damaged_idm = damaged_di_recs + .iter() + .find(|r| r.tag_id == tags::HWPTAG_ID_MAPPINGS); + let fixed_idm = fixed_di_recs + .iter() + .find(|r| r.tag_id == tags::HWPTAG_ID_MAPPINGS); + + if let (Some(d_rec), Some(f_rec)) = (damaged_idm, fixed_idm) { + eprintln!( + "\n ID_MAPPINGS record: damaged={}B, fixed={}B", + d_rec.data.len(), + f_rec.data.len() + ); + let max_fields = (d_rec.data.len().max(f_rec.data.len())) / 4; + let max_fields = max_fields.min(20); - eprintln!("\n {:<20} {:>10} {:>10} {:>10}", "Record Type", "Damaged", "Fixed", "Diff"); - eprintln!(" {}", "-".repeat(55)); - let mut docinfo_diff_count = 0; - for (tag_id, name) in &tag_types_of_interest { - let d_cnt = damaged_di_recs.iter().filter(|r| r.tag_id == *tag_id).count(); - let f_cnt = fixed_di_recs.iter().filter(|r| r.tag_id == *tag_id).count(); - let diff = f_cnt as i64 - d_cnt as i64; + eprintln!( + " {:<5} {:<20} {:>10} {:>10} {:>10}", + "Idx", "Field", "Damaged", "Fixed", "Diff" + ); + eprintln!(" {}", "-".repeat(60)); + + for i in 0..max_fields { + let d_val = if i * 4 + 4 <= d_rec.data.len() { + u32::from_le_bytes([ + d_rec.data[i * 4], + d_rec.data[i * 4 + 1], + d_rec.data[i * 4 + 2], + d_rec.data[i * 4 + 3], + ]) + } else { + 0 + }; + let f_val = if i * 4 + 4 <= f_rec.data.len() { + u32::from_le_bytes([ + f_rec.data[i * 4], + f_rec.data[i * 4 + 1], + f_rec.data[i * 4 + 2], + f_rec.data[i * 4 + 3], + ]) + } else { + 0 + }; + let diff = f_val as i64 - d_val as i64; + let name = if i < id_mappings_field_names.len() { + id_mappings_field_names[i] + } else { + "???" + }; let marker = if diff != 0 { " <== DIFF" } else { "" }; - if diff != 0 { docinfo_diff_count += 1; } - eprintln!(" {:<20} {:>10} {:>10} {:>+10}{}", name, d_cnt, f_cnt, diff, marker); - } - - // ID_MAPPINGS record comparison - let id_mappings_field_names = [ - "BinData", "Font_Korean", "Font_English", "Font_Hanja", - "Font_Japanese", "Font_Other", "Font_Symbol", "Font_User", - "BorderFill", "CharShape", "TabDef", "Numbering", - "Bullet", "ParaShape", "Style", "MemoShape", - "Field16", "Field17", "Field18", "Field19", - ]; - - let damaged_idm = damaged_di_recs.iter().find(|r| r.tag_id == tags::HWPTAG_ID_MAPPINGS); - let fixed_idm = fixed_di_recs.iter().find(|r| r.tag_id == tags::HWPTAG_ID_MAPPINGS); - - if let (Some(d_rec), Some(f_rec)) = (damaged_idm, fixed_idm) { - eprintln!("\n ID_MAPPINGS record: damaged={}B, fixed={}B", d_rec.data.len(), f_rec.data.len()); - let max_fields = (d_rec.data.len().max(f_rec.data.len())) / 4; - let max_fields = max_fields.min(20); - - eprintln!(" {:<5} {:<20} {:>10} {:>10} {:>10}", "Idx", "Field", "Damaged", "Fixed", "Diff"); - eprintln!(" {}", "-".repeat(60)); - - for i in 0..max_fields { - let d_val = if i * 4 + 4 <= d_rec.data.len() { - u32::from_le_bytes([d_rec.data[i*4], d_rec.data[i*4+1], d_rec.data[i*4+2], d_rec.data[i*4+3]]) - } else { 0 }; - let f_val = if i * 4 + 4 <= f_rec.data.len() { - u32::from_le_bytes([f_rec.data[i*4], f_rec.data[i*4+1], f_rec.data[i*4+2], f_rec.data[i*4+3]]) - } else { 0 }; - let diff = f_val as i64 - d_val as i64; - let name = if i < id_mappings_field_names.len() { id_mappings_field_names[i] } else { "???" }; - let marker = if diff != 0 { " <== DIFF" } else { "" }; - if diff != 0 { docinfo_diff_count += 1; } - eprintln!(" [{:>2}] {:<20} {:>10} {:>10} {:>+10}{}", i, name, d_val, f_val, diff, marker); + if diff != 0 { + docinfo_diff_count += 1; } - } else { - eprintln!(" ERROR: ID_MAPPINGS record not found in one or both files!"); + eprintln!( + " [{:>2}] {:<20} {:>10} {:>10} {:>+10}{}", + i, name, d_val, f_val, diff, marker + ); } + } else { + eprintln!(" ERROR: ID_MAPPINGS record not found in one or both files!"); + } - // ===================================================================== - // Part 2: BodyText Section 0 - every record comparison - // ===================================================================== - eprintln!("\n{}", "=".repeat(90)); - eprintln!(" PART 2: BodyText Section 0 - Record-by-Record Comparison"); - eprintln!("{}", "=".repeat(90)); - - let damaged_bt = damaged_cfb.read_body_text_section(0, true, false).expect("damaged BodyText read failed"); - let fixed_bt = fixed_cfb.read_body_text_section(0, true, false).expect("fixed BodyText read failed"); - - let damaged_bt_recs = Record::read_all(&damaged_bt).unwrap(); - let fixed_bt_recs = Record::read_all(&fixed_bt).unwrap(); - - eprintln!(" Damaged BodyText: {} records, {} bytes", damaged_bt_recs.len(), damaged_bt.len()); - eprintln!(" Fixed BodyText: {} records, {} bytes", fixed_bt_recs.len(), fixed_bt.len()); - - let max_recs = damaged_bt_recs.len().max(fixed_bt_recs.len()); - let mut body_diff_count = 0; - - eprintln!("\n --- Record-by-record comparison ---"); - eprintln!(" {:<6} {:<25} {:<6} {:<8} | {:<25} {:<6} {:<8} | {}", - "Idx", "Damaged Tag", "Lvl", "Size", "Fixed Tag", "Lvl", "Size", "Differences"); - eprintln!(" {}", "-".repeat(120)); - - for i in 0..max_recs { - let d_rec = damaged_bt_recs.get(i); - let f_rec = fixed_bt_recs.get(i); - - match (d_rec, f_rec) { - (Some(d), Some(f)) => { - let d_tag_name = tags::tag_name(d.tag_id); - let f_tag_name = tags::tag_name(f.tag_id); - let mut diffs: Vec = Vec::new(); - - if d.tag_id != f.tag_id { - diffs.push(format!("tag: {}!={}", d.tag_id, f.tag_id)); - } - if d.level != f.level { - diffs.push(format!("level: {}!={}", d.level, f.level)); - } - if d.data.len() != f.data.len() { - diffs.push(format!("size: {}!={}", d.data.len(), f.data.len())); - } - if d.data != f.data { - diffs.push("bytes differ".to_string()); - } - - // PARA_HEADER detailed comparison - if d.tag_id == tags::HWPTAG_PARA_HEADER && f.tag_id == tags::HWPTAG_PARA_HEADER { - if d.data.len() >= 11 && f.data.len() >= 11 { - let d_cc_raw = u32::from_le_bytes([d.data[0], d.data[1], d.data[2], d.data[3]]); - let f_cc_raw = u32::from_le_bytes([f.data[0], f.data[1], f.data[2], f.data[3]]); - let d_char_count = d_cc_raw & 0x7FFFFFFF; - let f_char_count = f_cc_raw & 0x7FFFFFFF; - let d_msb = d_cc_raw >> 31; - let f_msb = f_cc_raw >> 31; - let d_cm = u32::from_le_bytes([d.data[4], d.data[5], d.data[6], d.data[7]]); - let f_cm = u32::from_le_bytes([f.data[4], f.data[5], f.data[6], f.data[7]]); - let d_ps_id = u16::from_le_bytes([d.data[8], d.data[9]]); - let f_ps_id = u16::from_le_bytes([f.data[8], f.data[9]]); - let d_style = d.data[10]; - let f_style = f.data[10]; - - if d_char_count != f_char_count { - diffs.push(format!("char_count: {}!={}", d_char_count, f_char_count)); - } - if d_msb != f_msb { - diffs.push(format!("msb: {}!={}", d_msb, f_msb)); - } - if d_cm != f_cm { - diffs.push(format!("ctrl_mask: 0x{:08X}!=0x{:08X}", d_cm, f_cm)); - } - if d_ps_id != f_ps_id { - diffs.push(format!("para_shape_id: {}!={}", d_ps_id, f_ps_id)); - } - if d_style != f_style { - diffs.push(format!("style_id: {}!={}", d_style, f_style)); - } + // ===================================================================== + // Part 2: BodyText Section 0 - every record comparison + // ===================================================================== + eprintln!("\n{}", "=".repeat(90)); + eprintln!(" PART 2: BodyText Section 0 - Record-by-Record Comparison"); + eprintln!("{}", "=".repeat(90)); + + let damaged_bt = damaged_cfb + .read_body_text_section(0, true, false) + .expect("damaged BodyText read failed"); + let fixed_bt = fixed_cfb + .read_body_text_section(0, true, false) + .expect("fixed BodyText read failed"); + + let damaged_bt_recs = Record::read_all(&damaged_bt).unwrap(); + let fixed_bt_recs = Record::read_all(&fixed_bt).unwrap(); + + eprintln!( + " Damaged BodyText: {} records, {} bytes", + damaged_bt_recs.len(), + damaged_bt.len() + ); + eprintln!( + " Fixed BodyText: {} records, {} bytes", + fixed_bt_recs.len(), + fixed_bt.len() + ); + + let max_recs = damaged_bt_recs.len().max(fixed_bt_recs.len()); + let mut body_diff_count = 0; + + eprintln!("\n --- Record-by-record comparison ---"); + eprintln!( + " {:<6} {:<25} {:<6} {:<8} | {:<25} {:<6} {:<8} | Differences", + "Idx", "Damaged Tag", "Lvl", "Size", "Fixed Tag", "Lvl", "Size" + ); + eprintln!(" {}", "-".repeat(120)); + + for i in 0..max_recs { + let d_rec = damaged_bt_recs.get(i); + let f_rec = fixed_bt_recs.get(i); + + match (d_rec, f_rec) { + (Some(d), Some(f)) => { + let d_tag_name = tags::tag_name(d.tag_id); + let f_tag_name = tags::tag_name(f.tag_id); + let mut diffs: Vec = Vec::new(); + + if d.tag_id != f.tag_id { + diffs.push(format!("tag: {}!={}", d.tag_id, f.tag_id)); + } + if d.level != f.level { + diffs.push(format!("level: {}!={}", d.level, f.level)); + } + if d.data.len() != f.data.len() { + diffs.push(format!("size: {}!={}", d.data.len(), f.data.len())); + } + if d.data != f.data { + diffs.push("bytes differ".to_string()); + } + + // PARA_HEADER detailed comparison + if d.tag_id == tags::HWPTAG_PARA_HEADER && f.tag_id == tags::HWPTAG_PARA_HEADER { + if d.data.len() >= 11 && f.data.len() >= 11 { + let d_cc_raw = + u32::from_le_bytes([d.data[0], d.data[1], d.data[2], d.data[3]]); + let f_cc_raw = + u32::from_le_bytes([f.data[0], f.data[1], f.data[2], f.data[3]]); + let d_char_count = d_cc_raw & 0x7FFFFFFF; + let f_char_count = f_cc_raw & 0x7FFFFFFF; + let d_msb = d_cc_raw >> 31; + let f_msb = f_cc_raw >> 31; + let d_cm = u32::from_le_bytes([d.data[4], d.data[5], d.data[6], d.data[7]]); + let f_cm = u32::from_le_bytes([f.data[4], f.data[5], f.data[6], f.data[7]]); + let d_ps_id = u16::from_le_bytes([d.data[8], d.data[9]]); + let f_ps_id = u16::from_le_bytes([f.data[8], f.data[9]]); + let d_style = d.data[10]; + let f_style = f.data[10]; + + if d_char_count != f_char_count { + diffs.push(format!("char_count: {}!={}", d_char_count, f_char_count)); + } + if d_msb != f_msb { + diffs.push(format!("msb: {}!={}", d_msb, f_msb)); + } + if d_cm != f_cm { + diffs.push(format!("ctrl_mask: 0x{:08X}!=0x{:08X}", d_cm, f_cm)); + } + if d_ps_id != f_ps_id { + diffs.push(format!("para_shape_id: {}!={}", d_ps_id, f_ps_id)); + } + if d_style != f_style { + diffs.push(format!("style_id: {}!={}", d_style, f_style)); } } - - let diff_str = if diffs.is_empty() { - "OK".to_string() - } else { - body_diff_count += 1; - format!("DIFF: {}", diffs.join(", ")) - }; - - // Always print if there is a difference; for matching records print a compact line - if !diffs.is_empty() { - eprintln!(" [{:>4}] {:<25} L{:<4} {:>6}B | {:<25} L{:<4} {:>6}B | {}", - i, d_tag_name, d.level, d.data.len(), - f_tag_name, f.level, f.data.len(), diff_str); - } else { - eprintln!(" [{:>4}] {:<25} L{:<4} {:>6}B | {:<25} L{:<4} {:>6}B | OK", - i, d_tag_name, d.level, d.data.len(), - f_tag_name, f.level, f.data.len()); - } - } - (Some(d), None) => { - body_diff_count += 1; - let d_tag_name = tags::tag_name(d.tag_id); - eprintln!(" [{:>4}] {:<25} L{:<4} {:>6}B | {:<25} | ONLY IN DAMAGED", - i, d_tag_name, d.level, d.data.len(), "---"); } - (None, Some(f)) => { + + let diff_str = if diffs.is_empty() { + "OK".to_string() + } else { body_diff_count += 1; - let f_tag_name = tags::tag_name(f.tag_id); - eprintln!(" [{:>4}] {:<25} | {:<25} L{:<4} {:>6}B | ONLY IN FIXED", - i, "---", f_tag_name, f.level, f.data.len()); + format!("DIFF: {}", diffs.join(", ")) + }; + + // Always print if there is a difference; for matching records print a compact line + if !diffs.is_empty() { + eprintln!( + " [{:>4}] {:<25} L{:<4} {:>6}B | {:<25} L{:<4} {:>6}B | {}", + i, + d_tag_name, + d.level, + d.data.len(), + f_tag_name, + f.level, + f.data.len(), + diff_str + ); + } else { + eprintln!( + " [{:>4}] {:<25} L{:<4} {:>6}B | {:<25} L{:<4} {:>6}B | OK", + i, + d_tag_name, + d.level, + d.data.len(), + f_tag_name, + f.level, + f.data.len() + ); } - (None, None) => {} } + (Some(d), None) => { + body_diff_count += 1; + let d_tag_name = tags::tag_name(d.tag_id); + eprintln!( + " [{:>4}] {:<25} L{:<4} {:>6}B | {:<25} | ONLY IN DAMAGED", + i, + d_tag_name, + d.level, + d.data.len(), + "---" + ); + } + (None, Some(f)) => { + body_diff_count += 1; + let f_tag_name = tags::tag_name(f.tag_id); + eprintln!( + " [{:>4}] {:<25} | {:<25} L{:<4} {:>6}B | ONLY IN FIXED", + i, + "---", + f_tag_name, + f.level, + f.data.len() + ); + } + (None, None) => {} } + } - // ===================================================================== - // Part 3: TABLE/CTRL_HEADER raw bytes comparison - // ===================================================================== - eprintln!("\n{}", "=".repeat(90)); - eprintln!(" PART 3: TABLE / CTRL_HEADER Raw Bytes Comparison"); - eprintln!("{}", "=".repeat(90)); - - // Collect TABLE and CTRL_HEADER records from both files - let interesting_tags = [tags::HWPTAG_TABLE, tags::HWPTAG_CTRL_HEADER]; - - let damaged_interesting: Vec<(usize, &Record)> = damaged_bt_recs.iter().enumerate() - .filter(|(_, r)| interesting_tags.contains(&r.tag_id)) - .collect(); - let fixed_interesting: Vec<(usize, &Record)> = fixed_bt_recs.iter().enumerate() - .filter(|(_, r)| interesting_tags.contains(&r.tag_id)) - .collect(); - - // Match records by index position in the record stream - let max_interesting = damaged_interesting.len().max(fixed_interesting.len()); - - for j in 0..max_interesting { - let d_item = damaged_interesting.get(j); - let f_item = fixed_interesting.get(j); - - match (d_item, f_item) { - (Some(&(d_idx, d_rec)), Some(&(f_idx, f_rec))) => { - let d_tag_name = tags::tag_name(d_rec.tag_id); - let f_tag_name = tags::tag_name(f_rec.tag_id); - - // For CTRL_HEADER, show the ctrl type string - let d_ctrl_type = if d_rec.tag_id == tags::HWPTAG_CTRL_HEADER && d_rec.data.len() >= 4 { + // ===================================================================== + // Part 3: TABLE/CTRL_HEADER raw bytes comparison + // ===================================================================== + eprintln!("\n{}", "=".repeat(90)); + eprintln!(" PART 3: TABLE / CTRL_HEADER Raw Bytes Comparison"); + eprintln!("{}", "=".repeat(90)); + + // Collect TABLE and CTRL_HEADER records from both files + let interesting_tags = [tags::HWPTAG_TABLE, tags::HWPTAG_CTRL_HEADER]; + + let damaged_interesting: Vec<(usize, &Record)> = damaged_bt_recs + .iter() + .enumerate() + .filter(|(_, r)| interesting_tags.contains(&r.tag_id)) + .collect(); + let fixed_interesting: Vec<(usize, &Record)> = fixed_bt_recs + .iter() + .enumerate() + .filter(|(_, r)| interesting_tags.contains(&r.tag_id)) + .collect(); + + // Match records by index position in the record stream + let max_interesting = damaged_interesting.len().max(fixed_interesting.len()); + + for j in 0..max_interesting { + let d_item = damaged_interesting.get(j); + let f_item = fixed_interesting.get(j); + + match (d_item, f_item) { + (Some(&(d_idx, d_rec)), Some(&(f_idx, f_rec))) => { + let d_tag_name = tags::tag_name(d_rec.tag_id); + let f_tag_name = tags::tag_name(f_rec.tag_id); + + // For CTRL_HEADER, show the ctrl type string + let d_ctrl_type = + if d_rec.tag_id == tags::HWPTAG_CTRL_HEADER && d_rec.data.len() >= 4 { let rev: Vec = d_rec.data[0..4].iter().rev().cloned().collect(); format!(" '{}'", String::from_utf8_lossy(&rev)) - } else { String::new() }; - let f_ctrl_type = if f_rec.tag_id == tags::HWPTAG_CTRL_HEADER && f_rec.data.len() >= 4 { + } else { + String::new() + }; + let f_ctrl_type = + if f_rec.tag_id == tags::HWPTAG_CTRL_HEADER && f_rec.data.len() >= 4 { let rev: Vec = f_rec.data[0..4].iter().rev().cloned().collect(); format!(" '{}'", String::from_utf8_lossy(&rev)) - } else { String::new() }; + } else { + String::new() + }; - let same = d_rec.data == f_rec.data; - eprintln!("\n Pair {}: damaged[{}] {}{} ({}B) vs fixed[{}] {}{} ({}B) => {}", - j, d_idx, d_tag_name, d_ctrl_type, d_rec.data.len(), - f_idx, f_tag_name, f_ctrl_type, f_rec.data.len(), - if same { "IDENTICAL" } else { "DIFFERENT" }); + let same = d_rec.data == f_rec.data; + eprintln!( + "\n Pair {}: damaged[{}] {}{} ({}B) vs fixed[{}] {}{} ({}B) => {}", + j, + d_idx, + d_tag_name, + d_ctrl_type, + d_rec.data.len(), + f_idx, + f_tag_name, + f_ctrl_type, + f_rec.data.len(), + if same { "IDENTICAL" } else { "DIFFERENT" } + ); - if !same { - body_diff_count += 1; - // Show byte-level diff - let max_len = d_rec.data.len().max(f_rec.data.len()); - let mut diff_positions: Vec = Vec::new(); - for pos in 0..max_len { - let d_byte = d_rec.data.get(pos); - let f_byte = f_rec.data.get(pos); - if d_byte != f_byte { - diff_positions.push(pos); - } + if !same { + body_diff_count += 1; + // Show byte-level diff + let max_len = d_rec.data.len().max(f_rec.data.len()); + let mut diff_positions: Vec = Vec::new(); + for pos in 0..max_len { + let d_byte = d_rec.data.get(pos); + let f_byte = f_rec.data.get(pos); + if d_byte != f_byte { + diff_positions.push(pos); + } + } + eprintln!( + " {} byte(s) differ at positions: {:?}", + diff_positions.len(), + if diff_positions.len() <= 30 { + &diff_positions[..] + } else { + &diff_positions[..30] + } + ); + + // Hex dump of first 80 bytes for both + let dump_len = 80.min(max_len); + let d_hex: String = d_rec + .data + .iter() + .take(dump_len) + .map(|b| format!("{:02X}", b)) + .collect::>() + .join(" "); + let f_hex: String = f_rec + .data + .iter() + .take(dump_len) + .map(|b| format!("{:02X}", b)) + .collect::>() + .join(" "); + eprintln!( + " Damaged (first {}B): {}{}", + dump_len, + d_hex, + if d_rec.data.len() > dump_len { + "..." + } else { + "" + } + ); + eprintln!( + " Fixed (first {}B): {}{}", + dump_len, + f_hex, + if f_rec.data.len() > dump_len { + "..." + } else { + "" } - eprintln!(" {} byte(s) differ at positions: {:?}", - diff_positions.len(), - if diff_positions.len() <= 30 { &diff_positions[..] } else { &diff_positions[..30] }); - - // Hex dump of first 80 bytes for both - let dump_len = 80.min(max_len); - let d_hex: String = d_rec.data.iter().take(dump_len) - .map(|b| format!("{:02X}", b)).collect::>().join(" "); - let f_hex: String = f_rec.data.iter().take(dump_len) - .map(|b| format!("{:02X}", b)).collect::>().join(" "); - eprintln!(" Damaged (first {}B): {}{}", dump_len, d_hex, - if d_rec.data.len() > dump_len { "..." } else { "" }); - eprintln!(" Fixed (first {}B): {}{}", dump_len, f_hex, - if f_rec.data.len() > dump_len { "..." } else { "" }); - } - } - (Some(&(d_idx, d_rec)), None) => { - let d_tag_name = tags::tag_name(d_rec.tag_id); - eprintln!("\n Pair {}: damaged[{}] {} ({}B) -- NO MATCH IN FIXED", - j, d_idx, d_tag_name, d_rec.data.len()); - } - (None, Some(&(f_idx, f_rec))) => { - let f_tag_name = tags::tag_name(f_rec.tag_id); - eprintln!("\n Pair {}: -- NO MATCH IN DAMAGED -- fixed[{}] {} ({}B)", - j, f_idx, f_tag_name, f_rec.data.len()); + ); } - _ => {} } + (Some(&(d_idx, d_rec)), None) => { + let d_tag_name = tags::tag_name(d_rec.tag_id); + eprintln!( + "\n Pair {}: damaged[{}] {} ({}B) -- NO MATCH IN FIXED", + j, + d_idx, + d_tag_name, + d_rec.data.len() + ); + } + (None, Some(&(f_idx, f_rec))) => { + let f_tag_name = tags::tag_name(f_rec.tag_id); + eprintln!( + "\n Pair {}: -- NO MATCH IN DAMAGED -- fixed[{}] {} ({}B)", + j, + f_idx, + f_tag_name, + f_rec.data.len() + ); + } + _ => {} } - - // ===================================================================== - // Summary - // ===================================================================== - eprintln!("\n{}", "=".repeat(90)); - eprintln!(" SUMMARY"); - eprintln!(" DocInfo differences: {}", docinfo_diff_count); - eprintln!(" BodyText differences: {}", body_diff_count); - eprintln!(" Total records: damaged={}, fixed={}", damaged_bt_recs.len(), fixed_bt_recs.len()); - eprintln!("{}", "=".repeat(90)); } + // ===================================================================== + // Summary + // ===================================================================== + eprintln!("\n{}", "=".repeat(90)); + eprintln!(" SUMMARY"); + eprintln!(" DocInfo differences: {}", docinfo_diff_count); + eprintln!(" BodyText differences: {}", body_diff_count); + eprintln!( + " Total records: damaged={}, fixed={}", + damaged_bt_recs.len(), + fixed_bt_recs.len() + ); + eprintln!("{}", "=".repeat(90)); +} + +/// rp-004 저장 파일의 BodyText 레코드를 분석하여 붙여넣기된 표의 구조적 문제를 찾는다. +#[test] +fn test_rp004_bodytext_table_analysis() { + use crate::parser::record::Record; + use crate::parser::tags; + + let orig_path = "pasts/20250130-hongbo-p2.hwp"; + let saved_path = "pasts/20250130-hongbo_saved-rp-004.hwp"; + + if !std::path::Path::new(orig_path).exists() { + eprintln!("SKIP: 원본 파일 없음 ({})", orig_path); + return; + } + if !std::path::Path::new(saved_path).exists() { + eprintln!("SKIP: 저장 파일 없음 ({})", saved_path); + return; + } - /// rp-004 저장 파일의 BodyText 레코드를 분석하여 붙여넣기된 표의 구조적 문제를 찾는다. - #[test] - fn test_rp004_bodytext_table_analysis() { - use crate::parser::record::Record; - use crate::parser::tags; - - let orig_path = "pasts/20250130-hongbo-p2.hwp"; - let saved_path = "pasts/20250130-hongbo_saved-rp-004.hwp"; - - if !std::path::Path::new(orig_path).exists() { - eprintln!("SKIP: 원본 파일 없음 ({})", orig_path); - return; - } - if !std::path::Path::new(saved_path).exists() { - eprintln!("SKIP: 저장 파일 없음 ({})", saved_path); - return; - } - - // ============================================================ - // 1. 두 파일 파싱 - // ============================================================ - let orig_data = std::fs::read(orig_path).unwrap(); - let saved_data = std::fs::read(saved_path).unwrap(); - eprintln!("원본 파일 크기: {} bytes", orig_data.len()); - eprintln!("저장 파일 크기: {} bytes", saved_data.len()); - - let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); - let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); - - eprintln!("원본: sections={}, compressed={}", orig_doc.sections.len(), orig_doc.header.compressed); - eprintln!("저장: sections={}, compressed={}", saved_doc.sections.len(), saved_doc.header.compressed); - - // ============================================================ - // 2. BodyText Section[0] raw stream 읽기 및 Record 스캔 - // ============================================================ - let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); - let orig_bt = orig_cfb.read_body_text_section(0, orig_doc.header.compressed, false).unwrap(); - let orig_recs = Record::read_all(&orig_bt).unwrap(); - eprintln!("\n원본 BodyText Section[0]: {} bytes, {} records", orig_bt.len(), orig_recs.len()); - - let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, saved_doc.header.compressed, false).unwrap(); - let saved_recs = Record::read_all(&saved_bt).unwrap(); - eprintln!("저장 BodyText Section[0]: {} bytes, {} records", saved_bt.len(), saved_recs.len()); - - // ============================================================ - // 2a. 모든 레코드 목록 출력 (저장 파일) - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== 저장 파일 전체 레코드 목록 (Section[0]) ==="); - eprintln!("{}", "=".repeat(120)); - for (i, r) in saved_recs.iter().enumerate() { - let tname = tags::tag_name(r.tag_id); - let extra = if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); - let ctrl_bytes = [r.data[0], r.data[1], r.data[2], r.data[3]]; - let ctrl_str: String = ctrl_bytes.iter().rev().map(|&b| { - if b >= 0x20 && b <= 0x7e { b as char } else { '.' } - }).collect(); - format!(" ctrl_id=0x{:08X} \"{}\" ({})", ctrl_id, ctrl_str, tags::ctrl_name(ctrl_id)) - } else if r.tag_id == tags::HWPTAG_PARA_HEADER && r.data.len() >= 4 { - let nchars = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); - let nchars_val = nchars & 0x7FFFFFFF; - let control_mask = if r.data.len() >= 8 { - u32::from_le_bytes([r.data[4], r.data[5], r.data[6], r.data[7]]) - } else { 0 }; - format!(" char_count={} (raw=0x{:08X}) control_mask=0x{:08X}", nchars_val, nchars, control_mask) - } else if r.tag_id == tags::HWPTAG_TABLE && r.data.len() >= 8 { - let flags = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); - let nrows = u16::from_le_bytes([r.data[4], r.data[5]]); - let ncols = u16::from_le_bytes([r.data[6], r.data[7]]); - format!(" flags=0x{:08X} rows={} cols={}", flags, nrows, ncols) - } else if r.tag_id == tags::HWPTAG_LIST_HEADER && r.data.len() >= 4 { - let nparas = u16::from_le_bytes([r.data[0], r.data[1]]); - let flags = u16::from_le_bytes([r.data[2], r.data[3]]); - format!(" nparas={} flags=0x{:04X}", nparas, flags) + // ============================================================ + // 1. 두 파일 파싱 + // ============================================================ + let orig_data = std::fs::read(orig_path).unwrap(); + let saved_data = std::fs::read(saved_path).unwrap(); + eprintln!("원본 파일 크기: {} bytes", orig_data.len()); + eprintln!("저장 파일 크기: {} bytes", saved_data.len()); + + let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); + let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); + + eprintln!( + "원본: sections={}, compressed={}", + orig_doc.sections.len(), + orig_doc.header.compressed + ); + eprintln!( + "저장: sections={}, compressed={}", + saved_doc.sections.len(), + saved_doc.header.compressed + ); + + // ============================================================ + // 2. BodyText Section[0] raw stream 읽기 및 Record 스캔 + // ============================================================ + let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); + let orig_bt = orig_cfb + .read_body_text_section(0, orig_doc.header.compressed, false) + .unwrap(); + let orig_recs = Record::read_all(&orig_bt).unwrap(); + eprintln!( + "\n원본 BodyText Section[0]: {} bytes, {} records", + orig_bt.len(), + orig_recs.len() + ); + + let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_doc.header.compressed, false) + .unwrap(); + let saved_recs = Record::read_all(&saved_bt).unwrap(); + eprintln!( + "저장 BodyText Section[0]: {} bytes, {} records", + saved_bt.len(), + saved_recs.len() + ); + + // ============================================================ + // 2a. 모든 레코드 목록 출력 (저장 파일) + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== 저장 파일 전체 레코드 목록 (Section[0]) ==="); + eprintln!("{}", "=".repeat(120)); + for (i, r) in saved_recs.iter().enumerate() { + let tname = tags::tag_name(r.tag_id); + let extra = if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); + let ctrl_bytes = [r.data[0], r.data[1], r.data[2], r.data[3]]; + let ctrl_str: String = ctrl_bytes + .iter() + .rev() + .map(|&b| { + if b >= 0x20 && b <= 0x7e { + b as char + } else { + '.' + } + }) + .collect(); + format!( + " ctrl_id=0x{:08X} \"{}\" ({})", + ctrl_id, + ctrl_str, + tags::ctrl_name(ctrl_id) + ) + } else if r.tag_id == tags::HWPTAG_PARA_HEADER && r.data.len() >= 4 { + let nchars = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); + let nchars_val = nchars & 0x7FFFFFFF; + let control_mask = if r.data.len() >= 8 { + u32::from_le_bytes([r.data[4], r.data[5], r.data[6], r.data[7]]) } else { - String::new() + 0 }; - eprintln!(" [{:4}] tag=0x{:04X} {:30} L{:<3} {:6}B{}", - i, r.tag_id, tname, r.level, r.data.len(), extra); - } + format!( + " char_count={} (raw=0x{:08X}) control_mask=0x{:08X}", + nchars_val, nchars, control_mask + ) + } else if r.tag_id == tags::HWPTAG_TABLE && r.data.len() >= 8 { + let flags = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); + let nrows = u16::from_le_bytes([r.data[4], r.data[5]]); + let ncols = u16::from_le_bytes([r.data[6], r.data[7]]); + format!(" flags=0x{:08X} rows={} cols={}", flags, nrows, ncols) + } else if r.tag_id == tags::HWPTAG_LIST_HEADER && r.data.len() >= 4 { + let nparas = u16::from_le_bytes([r.data[0], r.data[1]]); + let flags = u16::from_le_bytes([r.data[2], r.data[3]]); + format!(" nparas={} flags=0x{:04X}", nparas, flags) + } else { + String::new() + }; + eprintln!( + " [{:4}] tag=0x{:04X} {:30} L{:<3} {:6}B{}", + i, + r.tag_id, + tname, + r.level, + r.data.len(), + extra + ); + } - // ============================================================ - // 2b. 레코드 타입별 카운트 비교 - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== 레코드 타입별 카운트 비교 (원본 vs 저장) ==="); - eprintln!("{}", "=".repeat(120)); - let count_tag = |recs: &[Record], tag: u16| recs.iter().filter(|r| r.tag_id == tag).count(); - let tags_to_check: [(u16, &str); 10] = [ - (tags::HWPTAG_PARA_HEADER, "PARA_HEADER"), - (tags::HWPTAG_PARA_TEXT, "PARA_TEXT"), - (tags::HWPTAG_PARA_CHAR_SHAPE, "PARA_CHAR_SHAPE"), - (tags::HWPTAG_PARA_LINE_SEG, "PARA_LINE_SEG"), - (tags::HWPTAG_PARA_RANGE_TAG, "PARA_RANGE_TAG"), - (tags::HWPTAG_CTRL_HEADER, "CTRL_HEADER"), - (tags::HWPTAG_LIST_HEADER, "LIST_HEADER"), - (tags::HWPTAG_TABLE, "TABLE"), - (tags::HWPTAG_CTRL_DATA, "CTRL_DATA"), - (tags::HWPTAG_PAGE_DEF, "PAGE_DEF"), - ]; - for (tag, name) in &tags_to_check { - let orig_cnt = count_tag(&orig_recs, *tag); - let saved_cnt = count_tag(&saved_recs, *tag); - let diff = saved_cnt as i64 - orig_cnt as i64; - eprintln!(" {:25} orig={:4} saved={:4} diff={}{:+}{}", - name, orig_cnt, saved_cnt, - if diff != 0 { "<<< " } else { "" }, - diff, - if diff != 0 { " >>>" } else { "" }); - } - - // ============================================================ - // 2c. 표(Table) 분석 - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== 표(Table) 분석 ==="); - eprintln!("{}", "=".repeat(120)); + // ============================================================ + // 2b. 레코드 타입별 카운트 비교 + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== 레코드 타입별 카운트 비교 (원본 vs 저장) ==="); + eprintln!("{}", "=".repeat(120)); + let count_tag = |recs: &[Record], tag: u16| recs.iter().filter(|r| r.tag_id == tag).count(); + let tags_to_check: [(u16, &str); 10] = [ + (tags::HWPTAG_PARA_HEADER, "PARA_HEADER"), + (tags::HWPTAG_PARA_TEXT, "PARA_TEXT"), + (tags::HWPTAG_PARA_CHAR_SHAPE, "PARA_CHAR_SHAPE"), + (tags::HWPTAG_PARA_LINE_SEG, "PARA_LINE_SEG"), + (tags::HWPTAG_PARA_RANGE_TAG, "PARA_RANGE_TAG"), + (tags::HWPTAG_CTRL_HEADER, "CTRL_HEADER"), + (tags::HWPTAG_LIST_HEADER, "LIST_HEADER"), + (tags::HWPTAG_TABLE, "TABLE"), + (tags::HWPTAG_CTRL_DATA, "CTRL_DATA"), + (tags::HWPTAG_PAGE_DEF, "PAGE_DEF"), + ]; + for (tag, name) in &tags_to_check { + let orig_cnt = count_tag(&orig_recs, *tag); + let saved_cnt = count_tag(&saved_recs, *tag); + let diff = saved_cnt as i64 - orig_cnt as i64; + eprintln!( + " {:25} orig={:4} saved={:4} diff={}{:+}{}", + name, + orig_cnt, + saved_cnt, + if diff != 0 { "<<< " } else { "" }, + diff, + if diff != 0 { " >>>" } else { "" } + ); + } - // 원본의 표 찾기 - let orig_tables: Vec = orig_recs.iter().enumerate() - .filter(|(_, r)| r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 && { + // ============================================================ + // 2c. 표(Table) 분석 + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== 표(Table) 분석 ==="); + eprintln!("{}", "=".repeat(120)); + + // 원본의 표 찾기 + let orig_tables: Vec = orig_recs + .iter() + .enumerate() + .filter(|(_, r)| { + r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 && { let ctrl_id = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); ctrl_id == tags::CTRL_TABLE - }) - .map(|(i, _)| i) - .collect(); + } + }) + .map(|(i, _)| i) + .collect(); - let saved_tables: Vec = saved_recs.iter().enumerate() - .filter(|(_, r)| r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 && { + let saved_tables: Vec = saved_recs + .iter() + .enumerate() + .filter(|(_, r)| { + r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 && { let ctrl_id = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); ctrl_id == tags::CTRL_TABLE - }) - .map(|(i, _)| i) - .collect(); + } + }) + .map(|(i, _)| i) + .collect(); - eprintln!("원본 표 개수: {}", orig_tables.len()); - eprintln!("저장 표 개수: {}", saved_tables.len()); + eprintln!("원본 표 개수: {}", orig_tables.len()); + eprintln!("저장 표 개수: {}", saved_tables.len()); - // 각 표의 구조 분석 함수 - let analyze_table = |recs: &[Record], tbl_start: usize, label: &str| { - let tbl_level = recs[tbl_start].level; - let mut tbl_end = tbl_start + 1; - while tbl_end < recs.len() && recs[tbl_end].level > tbl_level { - tbl_end += 1; - } - let tbl_record_count = tbl_end - tbl_start; + // 각 표의 구조 분석 함수 + let analyze_table = |recs: &[Record], tbl_start: usize, label: &str| { + let tbl_level = recs[tbl_start].level; + let mut tbl_end = tbl_start + 1; + while tbl_end < recs.len() && recs[tbl_end].level > tbl_level { + tbl_end += 1; + } + let tbl_record_count = tbl_end - tbl_start; - eprintln!("\n--- {} (rec[{}..{}], {} records) ---", label, tbl_start, tbl_end, tbl_record_count); + eprintln!( + "\n--- {} (rec[{}..{}], {} records) ---", + label, tbl_start, tbl_end, tbl_record_count + ); - // CTRL_HEADER 바이트 덤프 (처음 최대 50바이트) - let ctrl_hdr = &recs[tbl_start]; - let dump_len = ctrl_hdr.data.len().min(50); - eprintln!(" CTRL_HEADER ({}B): {:02X?}", ctrl_hdr.data.len(), &ctrl_hdr.data[..dump_len]); + // CTRL_HEADER 바이트 덤프 (처음 최대 50바이트) + let ctrl_hdr = &recs[tbl_start]; + let dump_len = ctrl_hdr.data.len().min(50); + eprintln!( + " CTRL_HEADER ({}B): {:02X?}", + ctrl_hdr.data.len(), + &ctrl_hdr.data[..dump_len] + ); - // TABLE 레코드 찾기 - let mut table_rec_idx = None; - let mut list_headers: Vec = Vec::new(); + // TABLE 레코드 찾기 + let mut table_rec_idx = None; + let mut list_headers: Vec = Vec::new(); - for ri in tbl_start+1..tbl_end { - if recs[ri].tag_id == tags::HWPTAG_TABLE && recs[ri].level == tbl_level + 1 { - table_rec_idx = Some(ri); - } - if recs[ri].tag_id == tags::HWPTAG_LIST_HEADER && recs[ri].level == tbl_level + 1 { - list_headers.push(ri); - } + for ri in tbl_start + 1..tbl_end { + if recs[ri].tag_id == tags::HWPTAG_TABLE && recs[ri].level == tbl_level + 1 { + table_rec_idx = Some(ri); } + if recs[ri].tag_id == tags::HWPTAG_LIST_HEADER && recs[ri].level == tbl_level + 1 { + list_headers.push(ri); + } + } - if let Some(tri) = table_rec_idx { - let td = &recs[tri].data; - let dump_len2 = td.len().min(80); - eprintln!(" TABLE record (rec[{}], {}B): {:02X?}", tri, td.len(), &td[..dump_len2]); - if td.len() >= 8 { - let flags = u32::from_le_bytes([td[0], td[1], td[2], td[3]]); - let nrows = u16::from_le_bytes([td[4], td[5]]); - let ncols = u16::from_le_bytes([td[6], td[7]]); - eprintln!(" flags=0x{:08X} rows={} cols={} (expected cells={})", flags, nrows, ncols, nrows as u32 * ncols as u32); + if let Some(tri) = table_rec_idx { + let td = &recs[tri].data; + let dump_len2 = td.len().min(80); + eprintln!( + " TABLE record (rec[{}], {}B): {:02X?}", + tri, + td.len(), + &td[..dump_len2] + ); + if td.len() >= 8 { + let flags = u32::from_le_bytes([td[0], td[1], td[2], td[3]]); + let nrows = u16::from_le_bytes([td[4], td[5]]); + let ncols = u16::from_le_bytes([td[6], td[7]]); + eprintln!( + " flags=0x{:08X} rows={} cols={} (expected cells={})", + flags, + nrows, + ncols, + nrows as u32 * ncols as u32 + ); - if td.len() >= 10 { - let border_fill_id = u16::from_le_bytes([td[8], td[9]]); - eprintln!(" border_fill_id={}", border_fill_id); - } - if td.len() > 10 { - eprintln!(" remaining bytes (offset 10..): {:02X?}", &td[10..td.len().min(80)]); - } + if td.len() >= 10 { + let border_fill_id = u16::from_le_bytes([td[8], td[9]]); + eprintln!(" border_fill_id={}", border_fill_id); + } + if td.len() > 10 { + eprintln!( + " remaining bytes (offset 10..): {:02X?}", + &td[10..td.len().min(80)] + ); } - } else { - eprintln!(" TABLE record: NOT FOUND!"); } + } else { + eprintln!(" TABLE record: NOT FOUND!"); + } - // 각 셀(LIST_HEADER) 분석 - eprintln!(" 셀 개수 (LIST_HEADER at tbl_level+1): {}", list_headers.len()); - for (ci, &lhi) in list_headers.iter().enumerate() { - let lh = &recs[lhi]; - let dump_len3 = lh.data.len().min(40); - eprintln!(" Cell[{}] LIST_HEADER (rec[{}], {}B): {:02X?}", ci, lhi, lh.data.len(), &lh.data[..dump_len3]); + // 각 셀(LIST_HEADER) 분석 + eprintln!( + " 셀 개수 (LIST_HEADER at tbl_level+1): {}", + list_headers.len() + ); + for (ci, &lhi) in list_headers.iter().enumerate() { + let lh = &recs[lhi]; + let dump_len3 = lh.data.len().min(40); + eprintln!( + " Cell[{}] LIST_HEADER (rec[{}], {}B): {:02X?}", + ci, + lhi, + lh.data.len(), + &lh.data[..dump_len3] + ); - if lh.data.len() >= 4 { - let nparas = u16::from_le_bytes([lh.data[0], lh.data[1]]); - let flags = u16::from_le_bytes([lh.data[2], lh.data[3]]); - eprintln!(" nparas={} flags=0x{:04X}", nparas, flags); - } + if lh.data.len() >= 4 { + let nparas = u16::from_le_bytes([lh.data[0], lh.data[1]]); + let flags = u16::from_le_bytes([lh.data[2], lh.data[3]]); + eprintln!(" nparas={} flags=0x{:04X}", nparas, flags); + } - // 이 셀에 속하는 PARA_HEADER 찾기 - let cell_level = lh.level; - let next_boundary = if ci + 1 < list_headers.len() { - list_headers[ci + 1] - } else { - tbl_end - }; + // 이 셀에 속하는 PARA_HEADER 찾기 + let cell_level = lh.level; + let next_boundary = if ci + 1 < list_headers.len() { + list_headers[ci + 1] + } else { + tbl_end + }; - let mut para_count = 0; - for ri2 in lhi+1..next_boundary { - if recs[ri2].tag_id == tags::HWPTAG_PARA_HEADER && recs[ri2].level == cell_level + 1 { - let ph = &recs[ri2]; - let nchars = if ph.data.len() >= 4 { - u32::from_le_bytes([ph.data[0], ph.data[1], ph.data[2], ph.data[3]]) & 0x7FFFFFFF - } else { 0 }; - let control_mask = if ph.data.len() >= 8 { - u32::from_le_bytes([ph.data[4], ph.data[5], ph.data[6], ph.data[7]]) - } else { 0 }; - eprintln!(" PARA[{}] (rec[{}]) char_count={} control_mask=0x{:08X}", para_count, ri2, nchars, control_mask); - para_count += 1; - } + let mut para_count = 0; + for ri2 in lhi + 1..next_boundary { + if recs[ri2].tag_id == tags::HWPTAG_PARA_HEADER && recs[ri2].level == cell_level + 1 + { + let ph = &recs[ri2]; + let nchars = if ph.data.len() >= 4 { + u32::from_le_bytes([ph.data[0], ph.data[1], ph.data[2], ph.data[3]]) + & 0x7FFFFFFF + } else { + 0 + }; + let control_mask = if ph.data.len() >= 8 { + u32::from_le_bytes([ph.data[4], ph.data[5], ph.data[6], ph.data[7]]) + } else { + 0 + }; + eprintln!( + " PARA[{}] (rec[{}]) char_count={} control_mask=0x{:08X}", + para_count, ri2, nchars, control_mask + ); + para_count += 1; } } + } - (tbl_start, tbl_end, tbl_record_count) - }; + (tbl_start, tbl_end, tbl_record_count) + }; - // 원본 표 분석 - for (ti, &tbl_start) in orig_tables.iter().enumerate() { - analyze_table(&orig_recs, tbl_start, &format!("원본 표[{}]", ti)); - } + // 원본 표 분석 + for (ti, &tbl_start) in orig_tables.iter().enumerate() { + analyze_table(&orig_recs, tbl_start, &format!("원본 표[{}]", ti)); + } - // 저장 표 분석 - for (ti, &tbl_start) in saved_tables.iter().enumerate() { - analyze_table(&saved_recs, tbl_start, &format!("저장 표[{}]", ti)); - } + // 저장 표 분석 + for (ti, &tbl_start) in saved_tables.iter().enumerate() { + analyze_table(&saved_recs, tbl_start, &format!("저장 표[{}]", ti)); + } - // ============================================================ - // 2d. 마지막 표 (붙여넣기된 것) 전체 레코드 덤프 - // ============================================================ - if let Some(&last_tbl_idx) = saved_tables.last() { - let tbl_level = saved_recs[last_tbl_idx].level; - let mut tbl_end = last_tbl_idx + 1; - while tbl_end < saved_recs.len() && saved_recs[tbl_end].level > tbl_level { - tbl_end += 1; - } - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== 마지막(붙여넣기) 표: rec[{}..{}] 전체 레코드 덤프 ===", last_tbl_idx, tbl_end); - eprintln!("{}", "=".repeat(120)); - for ri in last_tbl_idx..tbl_end { - let r = &saved_recs[ri]; - let tname = tags::tag_name(r.tag_id); - let dump_len = r.data.len().min(64); - let extra_info = if r.tag_id == tags::HWPTAG_PARA_HEADER && r.data.len() >= 4 { - let nchars = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); - let nchars_val = nchars & 0x7FFFFFFF; - let control_mask = if r.data.len() >= 8 { - u32::from_le_bytes([r.data[4], r.data[5], r.data[6], r.data[7]]) - } else { 0 }; - format!(" | char_count={} control_mask=0x{:08X}", nchars_val, control_mask) - } else if r.tag_id == tags::HWPTAG_PARA_TEXT { - let u16_chars: Vec = r.data.chunks(2) - .filter(|c| c.len() == 2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect(); - let text = String::from_utf16_lossy(&u16_chars); - let preview: String = text.chars().take(40).collect(); - format!(" | text_preview=\"{}\"", preview) + // ============================================================ + // 2d. 마지막 표 (붙여넣기된 것) 전체 레코드 덤프 + // ============================================================ + if let Some(&last_tbl_idx) = saved_tables.last() { + let tbl_level = saved_recs[last_tbl_idx].level; + let mut tbl_end = last_tbl_idx + 1; + while tbl_end < saved_recs.len() && saved_recs[tbl_end].level > tbl_level { + tbl_end += 1; + } + eprintln!("\n{}", "=".repeat(120)); + eprintln!( + "=== 마지막(붙여넣기) 표: rec[{}..{}] 전체 레코드 덤프 ===", + last_tbl_idx, tbl_end + ); + eprintln!("{}", "=".repeat(120)); + for ri in last_tbl_idx..tbl_end { + let r = &saved_recs[ri]; + let tname = tags::tag_name(r.tag_id); + let dump_len = r.data.len().min(64); + let extra_info = if r.tag_id == tags::HWPTAG_PARA_HEADER && r.data.len() >= 4 { + let nchars = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); + let nchars_val = nchars & 0x7FFFFFFF; + let control_mask = if r.data.len() >= 8 { + u32::from_le_bytes([r.data[4], r.data[5], r.data[6], r.data[7]]) } else { - String::new() + 0 }; - eprintln!(" [{:4}] {:30} L{:<3} {:6}B data[..{}]: {:02X?}{}", - ri, tname, r.level, r.data.len(), dump_len, &r.data[..dump_len], extra_info); - } + format!( + " | char_count={} control_mask=0x{:08X}", + nchars_val, control_mask + ) + } else if r.tag_id == tags::HWPTAG_PARA_TEXT { + let u16_chars: Vec = r + .data + .chunks(2) + .filter(|c| c.len() == 2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let text = String::from_utf16_lossy(&u16_chars); + let preview: String = text.chars().take(40).collect(); + format!(" | text_preview=\"{}\"", preview) + } else { + String::new() + }; + eprintln!( + " [{:4}] {:30} L{:<3} {:6}B data[..{}]: {:02X?}{}", + ri, + tname, + r.level, + r.data.len(), + dump_len, + &r.data[..dump_len], + extra_info + ); } + } - // ============================================================ - // 3. 문단 일관성 검사 (PARA_HEADER <-> PARA_TEXT) - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== 문단 일관성 검사 (저장 파일) ==="); - eprintln!("{}", "=".repeat(120)); - let mut mismatch_count = 0; - let mut para_idx = 0; - let mut i = 0; - while i < saved_recs.len() { - if saved_recs[i].tag_id == tags::HWPTAG_PARA_HEADER { - let ph = &saved_recs[i]; - let ph_level = ph.level; - let nchars = if ph.data.len() >= 4 { - u32::from_le_bytes([ph.data[0], ph.data[1], ph.data[2], ph.data[3]]) & 0x7FFFFFFF - } else { 0 }; - let control_mask = if ph.data.len() >= 8 { - u32::from_le_bytes([ph.data[4], ph.data[5], ph.data[6], ph.data[7]]) - } else { 0 }; - - // 다음 레코드가 PARA_TEXT인지 확인 - let has_text = i + 1 < saved_recs.len() - && saved_recs[i + 1].tag_id == tags::HWPTAG_PARA_TEXT - && saved_recs[i + 1].level == ph_level + 1; + // ============================================================ + // 3. 문단 일관성 검사 (PARA_HEADER <-> PARA_TEXT) + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== 문단 일관성 검사 (저장 파일) ==="); + eprintln!("{}", "=".repeat(120)); + let mut mismatch_count = 0; + let mut para_idx = 0; + let mut i = 0; + while i < saved_recs.len() { + if saved_recs[i].tag_id == tags::HWPTAG_PARA_HEADER { + let ph = &saved_recs[i]; + let ph_level = ph.level; + let nchars = if ph.data.len() >= 4 { + u32::from_le_bytes([ph.data[0], ph.data[1], ph.data[2], ph.data[3]]) & 0x7FFFFFFF + } else { + 0 + }; + let control_mask = if ph.data.len() >= 8 { + u32::from_le_bytes([ph.data[4], ph.data[5], ph.data[6], ph.data[7]]) + } else { + 0 + }; - if has_text { - let pt = &saved_recs[i + 1]; - let pt_byte_len = pt.data.len(); - let expected_byte_len = (nchars as usize) * 2; + // 다음 레코드가 PARA_TEXT인지 확인 + let has_text = i + 1 < saved_recs.len() + && saved_recs[i + 1].tag_id == tags::HWPTAG_PARA_TEXT + && saved_recs[i + 1].level == ph_level + 1; - if pt_byte_len != expected_byte_len { - eprintln!(" MISMATCH para[{}] rec[{}]: char_count={} => expected PARA_TEXT={}B, actual={}B (diff={})", + if has_text { + let pt = &saved_recs[i + 1]; + let pt_byte_len = pt.data.len(); + let expected_byte_len = (nchars as usize) * 2; + + if pt_byte_len != expected_byte_len { + eprintln!(" MISMATCH para[{}] rec[{}]: char_count={} => expected PARA_TEXT={}B, actual={}B (diff={})", para_idx, i, nchars, expected_byte_len, pt_byte_len, pt_byte_len as i64 - expected_byte_len as i64); - // 텍스트 미리보기 - let u16_chars: Vec = pt.data.chunks(2) - .filter(|c| c.len() == 2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect(); - let text = String::from_utf16_lossy(&u16_chars); - let preview: String = text.chars().take(50).collect(); - eprintln!(" text_preview: \"{}\"", preview); - mismatch_count += 1; - } - } else if nchars > 1 { - eprintln!(" MISSING PARA_TEXT para[{}] rec[{}]: char_count={} control_mask=0x{:08X} but NO PARA_TEXT follows (next tag={})", + // 텍스트 미리보기 + let u16_chars: Vec = pt + .data + .chunks(2) + .filter(|c| c.len() == 2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let text = String::from_utf16_lossy(&u16_chars); + let preview: String = text.chars().take(50).collect(); + eprintln!(" text_preview: \"{}\"", preview); + mismatch_count += 1; + } + } else if nchars > 1 { + eprintln!(" MISSING PARA_TEXT para[{}] rec[{}]: char_count={} control_mask=0x{:08X} but NO PARA_TEXT follows (next tag={})", para_idx, i, nchars, control_mask, if i + 1 < saved_recs.len() { format!("0x{:04X} ({})", saved_recs[i+1].tag_id, tags::tag_name(saved_recs[i+1].tag_id)) } else { "EOF".to_string() } ); + mismatch_count += 1; + } else if nchars == 0 { + // char_count=0인 PARA_HEADER (빈 문단) 확인 + if has_text { + eprintln!( + " UNEXPECTED para[{}] rec[{}]: char_count=0 but PARA_TEXT exists ({}B)", + para_idx, + i, + saved_recs[i + 1].data.len() + ); mismatch_count += 1; - } else if nchars == 0 { - // char_count=0인 PARA_HEADER (빈 문단) 확인 - if has_text { - eprintln!(" UNEXPECTED para[{}] rec[{}]: char_count=0 but PARA_TEXT exists ({}B)", - para_idx, i, saved_recs[i+1].data.len()); - mismatch_count += 1; - } } - - para_idx += 1; } - i += 1; + + para_idx += 1; } - eprintln!("\n 총 문단 수: {}", para_idx); - eprintln!(" 불일치 개수: {}", mismatch_count); + i += 1; + } + eprintln!("\n 총 문단 수: {}", para_idx); + eprintln!(" 불일치 개수: {}", mismatch_count); + + // ============================================================ + // 3b. 원본 파일도 동일 검사 (비교용) + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== 문단 일관성 검사 (원본 파일) ==="); + eprintln!("{}", "=".repeat(120)); + let mut orig_mismatch_count = 0; + let mut orig_para_idx = 0; + i = 0; + while i < orig_recs.len() { + if orig_recs[i].tag_id == tags::HWPTAG_PARA_HEADER { + let ph = &orig_recs[i]; + let ph_level = ph.level; + let nchars = if ph.data.len() >= 4 { + u32::from_le_bytes([ph.data[0], ph.data[1], ph.data[2], ph.data[3]]) & 0x7FFFFFFF + } else { + 0 + }; + let control_mask = if ph.data.len() >= 8 { + u32::from_le_bytes([ph.data[4], ph.data[5], ph.data[6], ph.data[7]]) + } else { + 0 + }; - // ============================================================ - // 3b. 원본 파일도 동일 검사 (비교용) - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== 문단 일관성 검사 (원본 파일) ==="); - eprintln!("{}", "=".repeat(120)); - let mut orig_mismatch_count = 0; - let mut orig_para_idx = 0; - i = 0; - while i < orig_recs.len() { - if orig_recs[i].tag_id == tags::HWPTAG_PARA_HEADER { - let ph = &orig_recs[i]; - let ph_level = ph.level; - let nchars = if ph.data.len() >= 4 { - u32::from_le_bytes([ph.data[0], ph.data[1], ph.data[2], ph.data[3]]) & 0x7FFFFFFF - } else { 0 }; - let control_mask = if ph.data.len() >= 8 { - u32::from_le_bytes([ph.data[4], ph.data[5], ph.data[6], ph.data[7]]) - } else { 0 }; - - let has_text = i + 1 < orig_recs.len() - && orig_recs[i + 1].tag_id == tags::HWPTAG_PARA_TEXT - && orig_recs[i + 1].level == ph_level + 1; + let has_text = i + 1 < orig_recs.len() + && orig_recs[i + 1].tag_id == tags::HWPTAG_PARA_TEXT + && orig_recs[i + 1].level == ph_level + 1; - if has_text { - let pt = &orig_recs[i + 1]; - let pt_byte_len = pt.data.len(); - let expected_byte_len = (nchars as usize) * 2; + if has_text { + let pt = &orig_recs[i + 1]; + let pt_byte_len = pt.data.len(); + let expected_byte_len = (nchars as usize) * 2; - if pt_byte_len != expected_byte_len { - eprintln!(" MISMATCH para[{}] rec[{}]: char_count={} => expected PARA_TEXT={}B, actual={}B (diff={})", + if pt_byte_len != expected_byte_len { + eprintln!(" MISMATCH para[{}] rec[{}]: char_count={} => expected PARA_TEXT={}B, actual={}B (diff={})", orig_para_idx, i, nchars, expected_byte_len, pt_byte_len, pt_byte_len as i64 - expected_byte_len as i64); - orig_mismatch_count += 1; - } - } else if nchars > 1 { - eprintln!(" MISSING PARA_TEXT para[{}] rec[{}]: char_count={} control_mask=0x{:08X}", - orig_para_idx, i, nchars, control_mask); orig_mismatch_count += 1; } - - orig_para_idx += 1; + } else if nchars > 1 { + eprintln!( + " MISSING PARA_TEXT para[{}] rec[{}]: char_count={} control_mask=0x{:08X}", + orig_para_idx, i, nchars, control_mask + ); + orig_mismatch_count += 1; } - i += 1; - } - eprintln!("\n 총 문단 수: {}", orig_para_idx); - eprintln!(" 불일치 개수: {}", orig_mismatch_count); - // ============================================================ - // 요약 - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== 요약 ==="); - eprintln!("{}", "=".repeat(120)); - eprintln!("원본 표: {}개, 저장 표: {}개 (차이: {})", - orig_tables.len(), saved_tables.len(), - saved_tables.len() as i64 - orig_tables.len() as i64); - eprintln!("원본 레코드: {}개, 저장 레코드: {}개 (차이: {})", - orig_recs.len(), saved_recs.len(), - saved_recs.len() as i64 - orig_recs.len() as i64); - eprintln!("원본 문단 불일치: {}, 저장 문단 불일치: {}", - orig_mismatch_count, mismatch_count); - } - - /// CharShape 보존 검증: 붙여넣기 후 내보내기 시 원본 CharShape가 모두 보존되는지 확인 - #[test] - fn test_charshape_preservation_after_paste() { - use crate::parser::tags; - use crate::parser::record::Record; - - // raw_stream에서 특정 tag의 레코드 수 세기 - fn count_tag_in_raw(raw: &[u8], target_tag: u16) -> usize { - Record::read_all(raw).unwrap_or_default() - .iter().filter(|r| r.tag_id == target_tag).count() - } - - let orig_path = "pasts/20250130-hongbo-p2.hwp"; - if !std::path::Path::new(orig_path).exists() { - eprintln!("SKIP: 파일 없음"); - return; + orig_para_idx += 1; } + i += 1; + } + eprintln!("\n 총 문단 수: {}", orig_para_idx); + eprintln!(" 불일치 개수: {}", orig_mismatch_count); + + // ============================================================ + // 요약 + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== 요약 ==="); + eprintln!("{}", "=".repeat(120)); + eprintln!( + "원본 표: {}개, 저장 표: {}개 (차이: {})", + orig_tables.len(), + saved_tables.len(), + saved_tables.len() as i64 - orig_tables.len() as i64 + ); + eprintln!( + "원본 레코드: {}개, 저장 레코드: {}개 (차이: {})", + orig_recs.len(), + saved_recs.len(), + saved_recs.len() as i64 - orig_recs.len() as i64 + ); + eprintln!( + "원본 문단 불일치: {}, 저장 문단 불일치: {}", + orig_mismatch_count, mismatch_count + ); +} + +/// CharShape 보존 검증: 붙여넣기 후 내보내기 시 원본 CharShape가 모두 보존되는지 확인 +#[test] +fn test_charshape_preservation_after_paste() { + use crate::parser::record::Record; + use crate::parser::tags; + + // raw_stream에서 특정 tag의 레코드 수 세기 + fn count_tag_in_raw(raw: &[u8], target_tag: u16) -> usize { + Record::read_all(raw) + .unwrap_or_default() + .iter() + .filter(|r| r.tag_id == target_tag) + .count() + } - let orig_data = std::fs::read(orig_path).unwrap(); - let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); - - // 원본 CharShape 개수 확인 - let orig_cs_count = doc.document.doc_info.char_shapes.len(); - let orig_ps_count = doc.document.doc_info.para_shapes.len(); - eprintln!("원본 CharShape: {}, ParaShape: {}", orig_cs_count, orig_ps_count); - - // raw_stream에서 CharShape 레코드 개수 확인 - let orig_raw_cs = doc.document.doc_info.raw_stream.as_ref() - .map(|raw| count_tag_in_raw(raw, tags::HWPTAG_CHAR_SHAPE)) - .unwrap_or(0); - eprintln!("원본 raw_stream CharShape 레코드: {}", orig_raw_cs); - assert_eq!(orig_cs_count, orig_raw_cs, "모델과 raw_stream의 CharShape 개수 불일치"); - - // HTML 테이블 붙여넣기 - let table_html = r#"
Bold ARed B
Cell CItalic D
"#; - let last_para = doc.document.sections[0].paragraphs.len() - 1; - doc.paste_html_native(0, last_para, 0, table_html).unwrap(); - - // 붙여넣기 후 CharShape 개수 확인 - let post_cs_count = doc.document.doc_info.char_shapes.len(); - let post_ps_count = doc.document.doc_info.para_shapes.len(); - eprintln!("붙여넣기 후 CharShape: {}, ParaShape: {}", post_cs_count, post_ps_count); - assert!(post_cs_count >= orig_cs_count, - "CharShape 개수 감소! {} → {}", orig_cs_count, post_cs_count); - - // raw_stream CharShape 레코드 확인 - let post_raw_cs = doc.document.doc_info.raw_stream.as_ref() - .map(|raw| count_tag_in_raw(raw, tags::HWPTAG_CHAR_SHAPE)) - .unwrap_or(0); - eprintln!("붙여넣기 후 raw_stream CharShape 레코드: {}", post_raw_cs); - assert!(post_raw_cs >= orig_raw_cs, - "raw_stream CharShape 감소! {} → {}", orig_raw_cs, post_raw_cs); - assert_eq!(post_cs_count, post_raw_cs, - "붙여넣기 후 모델({})과 raw_stream({})의 CharShape 불일치", post_cs_count, post_raw_cs); - - // 내보내기 후 재파싱하여 CharShape 확인 - let saved_data = doc.export_hwp_native().unwrap(); - let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); - let saved_cs_count = saved_doc.doc_info.char_shapes.len(); - let saved_ps_count = saved_doc.doc_info.para_shapes.len(); - eprintln!("재파싱 CharShape: {}, ParaShape: {}", saved_cs_count, saved_ps_count); - assert!(saved_cs_count >= orig_cs_count, - "저장 후 CharShape 감소! 원본 {} → 저장 {}", orig_cs_count, saved_cs_count); + let orig_path = "pasts/20250130-hongbo-p2.hwp"; + if !std::path::Path::new(orig_path).exists() { + eprintln!("SKIP: 파일 없음"); + return; + } - // 모든 PARA_CHAR_SHAPE가 유효한 CharShape ID를 참조하는지 확인 - let mut max_cs_id: u32 = 0; - for section in &saved_doc.sections { - for para in §ion.paragraphs { - for cs_ref in ¶.char_shapes { - if cs_ref.char_shape_id > max_cs_id { - max_cs_id = cs_ref.char_shape_id; - } + let orig_data = std::fs::read(orig_path).unwrap(); + let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); + + // 원본 CharShape 개수 확인 + let orig_cs_count = doc.document.doc_info.char_shapes.len(); + let orig_ps_count = doc.document.doc_info.para_shapes.len(); + eprintln!( + "원본 CharShape: {}, ParaShape: {}", + orig_cs_count, orig_ps_count + ); + + // raw_stream에서 CharShape 레코드 개수 확인 + let orig_raw_cs = doc + .document + .doc_info + .raw_stream + .as_ref() + .map(|raw| count_tag_in_raw(raw, tags::HWPTAG_CHAR_SHAPE)) + .unwrap_or(0); + eprintln!("원본 raw_stream CharShape 레코드: {}", orig_raw_cs); + assert_eq!( + orig_cs_count, orig_raw_cs, + "모델과 raw_stream의 CharShape 개수 불일치" + ); + + // HTML 테이블 붙여넣기 + let table_html = r#"
Bold ARed B
Cell CItalic D
"#; + let last_para = doc.document.sections[0].paragraphs.len() - 1; + doc.paste_html_native(0, last_para, 0, table_html).unwrap(); + + // 붙여넣기 후 CharShape 개수 확인 + let post_cs_count = doc.document.doc_info.char_shapes.len(); + let post_ps_count = doc.document.doc_info.para_shapes.len(); + eprintln!( + "붙여넣기 후 CharShape: {}, ParaShape: {}", + post_cs_count, post_ps_count + ); + assert!( + post_cs_count >= orig_cs_count, + "CharShape 개수 감소! {} → {}", + orig_cs_count, + post_cs_count + ); + + // raw_stream CharShape 레코드 확인 + let post_raw_cs = doc + .document + .doc_info + .raw_stream + .as_ref() + .map(|raw| count_tag_in_raw(raw, tags::HWPTAG_CHAR_SHAPE)) + .unwrap_or(0); + eprintln!("붙여넣기 후 raw_stream CharShape 레코드: {}", post_raw_cs); + assert!( + post_raw_cs >= orig_raw_cs, + "raw_stream CharShape 감소! {} → {}", + orig_raw_cs, + post_raw_cs + ); + assert_eq!( + post_cs_count, post_raw_cs, + "붙여넣기 후 모델({})과 raw_stream({})의 CharShape 불일치", + post_cs_count, post_raw_cs + ); + + // 내보내기 후 재파싱하여 CharShape 확인 + let saved_data = doc.export_hwp_native().unwrap(); + let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); + let saved_cs_count = saved_doc.doc_info.char_shapes.len(); + let saved_ps_count = saved_doc.doc_info.para_shapes.len(); + eprintln!( + "재파싱 CharShape: {}, ParaShape: {}", + saved_cs_count, saved_ps_count + ); + assert!( + saved_cs_count >= orig_cs_count, + "저장 후 CharShape 감소! 원본 {} → 저장 {}", + orig_cs_count, + saved_cs_count + ); + + // 모든 PARA_CHAR_SHAPE가 유효한 CharShape ID를 참조하는지 확인 + let mut max_cs_id: u32 = 0; + for section in &saved_doc.sections { + for para in §ion.paragraphs { + for cs_ref in ¶.char_shapes { + if cs_ref.char_shape_id > max_cs_id { + max_cs_id = cs_ref.char_shape_id; } - for ctrl in ¶.controls { - if let Control::Table(tbl) = ctrl { - for cell in &tbl.cells { - for cp in &cell.paragraphs { - for cs_ref in &cp.char_shapes { - if cs_ref.char_shape_id > max_cs_id { - max_cs_id = cs_ref.char_shape_id; - } + } + for ctrl in ¶.controls { + if let Control::Table(tbl) = ctrl { + for cell in &tbl.cells { + for cp in &cell.paragraphs { + for cs_ref in &cp.char_shapes { + if cs_ref.char_shape_id > max_cs_id { + max_cs_id = cs_ref.char_shape_id; } } } @@ -6037,65 +8160,94 @@ } } } - eprintln!("최대 CharShape ID 참조: {}, 사용 가능 범위: 0..{}", max_cs_id, saved_cs_count); - assert!((max_cs_id as usize) < saved_cs_count, - "CharShape ID {} 참조 but 가용 개수 {}! (dangling reference)", max_cs_id, saved_cs_count); - - eprintln!("=== CharShape 보존 검증 통과 ==="); + } + eprintln!( + "최대 CharShape ID 참조: {}, 사용 가능 범위: 0..{}", + max_cs_id, saved_cs_count + ); + assert!( + (max_cs_id as usize) < saved_cs_count, + "CharShape ID {} 참조 but 가용 개수 {}! (dangling reference)", + max_cs_id, + saved_cs_count + ); + + eprintln!("=== CharShape 보존 검증 통과 ==="); +} + +/// rp-005 저장 파일과 원본을 비교하여 붙여넣기된 표의 구조를 깊이 분석한다. +/// DocInfo 일관성, 표 구조, 문단 char_count vs PARA_TEXT 길이, CharShape ID 유효성 검사. +#[test] +fn test_rp005_pasted_table_analysis() { + use crate::parser::record::Record; + use crate::parser::tags; + use std::collections::HashMap; + + let orig_path = "pasts/20250130-hongbo-p2.hwp"; + let saved_path = "pasts/20250130-hongbo_saved-rp-005.hwp"; + + if !std::path::Path::new(orig_path).exists() { + eprintln!("SKIP: 원본 파일 없음 ({})", orig_path); + return; + } + if !std::path::Path::new(saved_path).exists() { + eprintln!("SKIP: 저장 파일 없음 ({})", saved_path); + return; } - /// rp-005 저장 파일과 원본을 비교하여 붙여넣기된 표의 구조를 깊이 분석한다. - /// DocInfo 일관성, 표 구조, 문단 char_count vs PARA_TEXT 길이, CharShape ID 유효성 검사. - #[test] - fn test_rp005_pasted_table_analysis() { - use crate::parser::record::Record; - use crate::parser::tags; - use std::collections::HashMap; - - let orig_path = "pasts/20250130-hongbo-p2.hwp"; - let saved_path = "pasts/20250130-hongbo_saved-rp-005.hwp"; - - if !std::path::Path::new(orig_path).exists() { - eprintln!("SKIP: 원본 파일 없음 ({})", orig_path); - return; - } - if !std::path::Path::new(saved_path).exists() { - eprintln!("SKIP: 저장 파일 없음 ({})", saved_path); - return; - } - - let orig_data = std::fs::read(orig_path).unwrap(); - let saved_data = std::fs::read(saved_path).unwrap(); - eprintln!("원본 파일 크기: {} bytes", orig_data.len()); - eprintln!("저장 파일 크기: {} bytes", saved_data.len()); - - // ============================================================ - // 1. 두 파일 파싱 (고수준 IR) - // ============================================================ - let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); - let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); - - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== 1. DocInfo 비교 ==="); - eprintln!("{}", "=".repeat(120)); - - let orig_cs = orig_doc.doc_info.char_shapes.len(); - let saved_cs = saved_doc.doc_info.char_shapes.len(); - let orig_ps = orig_doc.doc_info.para_shapes.len(); - let saved_ps = saved_doc.doc_info.para_shapes.len(); - let orig_bf = orig_doc.doc_info.border_fills.len(); - let saved_bf = saved_doc.doc_info.border_fills.len(); - let orig_st = orig_doc.doc_info.styles.len(); - let saved_st = saved_doc.doc_info.styles.len(); - - eprintln!(" CharShape: orig={:<5} saved={:<5} diff={:+}", orig_cs, saved_cs, saved_cs as i64 - orig_cs as i64); - eprintln!(" ParaShape: orig={:<5} saved={:<5} diff={:+}", orig_ps, saved_ps, saved_ps as i64 - orig_ps as i64); - eprintln!(" BorderFill: orig={:<5} saved={:<5} diff={:+}", orig_bf, saved_bf, saved_bf as i64 - orig_bf as i64); - eprintln!(" Styles: orig={:<5} saved={:<5} diff={:+}", orig_st, saved_st, saved_st as i64 - orig_st as i64); - - // ID_MAPPINGS 일관성 (raw DocInfo 스트림에서 직접 파싱) - eprintln!("\n--- ID_MAPPINGS consistency check ---"); - let check_id_mappings = |raw: &[u8], label: &str, cs_count: usize, ps_count: usize, bf_count: usize| { + let orig_data = std::fs::read(orig_path).unwrap(); + let saved_data = std::fs::read(saved_path).unwrap(); + eprintln!("원본 파일 크기: {} bytes", orig_data.len()); + eprintln!("저장 파일 크기: {} bytes", saved_data.len()); + + // ============================================================ + // 1. 두 파일 파싱 (고수준 IR) + // ============================================================ + let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); + let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); + + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== 1. DocInfo 비교 ==="); + eprintln!("{}", "=".repeat(120)); + + let orig_cs = orig_doc.doc_info.char_shapes.len(); + let saved_cs = saved_doc.doc_info.char_shapes.len(); + let orig_ps = orig_doc.doc_info.para_shapes.len(); + let saved_ps = saved_doc.doc_info.para_shapes.len(); + let orig_bf = orig_doc.doc_info.border_fills.len(); + let saved_bf = saved_doc.doc_info.border_fills.len(); + let orig_st = orig_doc.doc_info.styles.len(); + let saved_st = saved_doc.doc_info.styles.len(); + + eprintln!( + " CharShape: orig={:<5} saved={:<5} diff={:+}", + orig_cs, + saved_cs, + saved_cs as i64 - orig_cs as i64 + ); + eprintln!( + " ParaShape: orig={:<5} saved={:<5} diff={:+}", + orig_ps, + saved_ps, + saved_ps as i64 - orig_ps as i64 + ); + eprintln!( + " BorderFill: orig={:<5} saved={:<5} diff={:+}", + orig_bf, + saved_bf, + saved_bf as i64 - orig_bf as i64 + ); + eprintln!( + " Styles: orig={:<5} saved={:<5} diff={:+}", + orig_st, + saved_st, + saved_st as i64 - orig_st as i64 + ); + + // ID_MAPPINGS 일관성 (raw DocInfo 스트림에서 직접 파싱) + eprintln!("\n--- ID_MAPPINGS consistency check ---"); + let check_id_mappings = + |raw: &[u8], label: &str, cs_count: usize, ps_count: usize, bf_count: usize| { let recs = Record::read_all(raw).unwrap(); let idm_rec = recs.iter().find(|r| r.tag_id == tags::HWPTAG_ID_MAPPINGS); if let Some(idm) = idm_rec { @@ -6108,2313 +8260,3479 @@ let cs_map = u32::from_le_bytes([d[36], d[37], d[38], d[39]]); let ps_map = u32::from_le_bytes([d[52], d[53], d[54], d[55]]); - let bf_actual = recs.iter().filter(|r| r.tag_id == tags::HWPTAG_BORDER_FILL).count(); - let cs_actual = recs.iter().filter(|r| r.tag_id == tags::HWPTAG_CHAR_SHAPE).count(); - let ps_actual = recs.iter().filter(|r| r.tag_id == tags::HWPTAG_PARA_SHAPE).count(); + let bf_actual = recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_BORDER_FILL) + .count(); + let cs_actual = recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_CHAR_SHAPE) + .count(); + let ps_actual = recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_PARA_SHAPE) + .count(); - eprintln!(" [{}] ID_MAPPINGS: BorderFill={}, CharShape={}, ParaShape={}", label, bf_map, cs_map, ps_map); - eprintln!(" [{}] actual recs: BorderFill={}, CharShape={}, ParaShape={}", label, bf_actual, cs_actual, ps_actual); - eprintln!(" [{}] model count: BorderFill={}, CharShape={}, ParaShape={}", label, bf_count, ps_count, cs_count); + eprintln!( + " [{}] ID_MAPPINGS: BorderFill={}, CharShape={}, ParaShape={}", + label, bf_map, cs_map, ps_map + ); + eprintln!( + " [{}] actual recs: BorderFill={}, CharShape={}, ParaShape={}", + label, bf_actual, cs_actual, ps_actual + ); + eprintln!( + " [{}] model count: BorderFill={}, CharShape={}, ParaShape={}", + label, bf_count, ps_count, cs_count + ); let bf_ok = bf_map as usize == bf_actual && bf_actual == bf_count; let cs_ok = cs_map as usize == cs_actual && cs_actual == cs_count; let ps_ok = ps_map as usize == ps_actual && ps_actual == ps_count; - eprintln!(" [{}] consistency: BF={} CS={} PS={}", label, + eprintln!( + " [{}] consistency: BF={} CS={} PS={}", + label, if bf_ok { "OK" } else { "MISMATCH!" }, if cs_ok { "OK" } else { "MISMATCH!" }, - if ps_ok { "OK" } else { "MISMATCH!" }); + if ps_ok { "OK" } else { "MISMATCH!" } + ); } } else { eprintln!(" [{}] ID_MAPPINGS not found!", label); } }; - if let Some(ref raw) = orig_doc.doc_info.raw_stream { - check_id_mappings(raw, "orig", orig_cs, orig_ps, orig_bf); - } - if let Some(ref raw) = saved_doc.doc_info.raw_stream { - check_id_mappings(raw, "saved", saved_cs, saved_ps, saved_bf); - } - - // ============================================================ - // 2. BodyText raw records 읽기 - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== 2. BodyText 레코드 분석 ==="); - eprintln!("{}", "=".repeat(120)); - - let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); - let orig_bt = orig_cfb.read_body_text_section(0, orig_doc.header.compressed, false).unwrap(); - let orig_recs = Record::read_all(&orig_bt).unwrap(); - - let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, saved_doc.header.compressed, false).unwrap(); - let saved_recs = Record::read_all(&saved_bt).unwrap(); - - eprintln!(" 원본 BodyText: {} bytes, {} records", orig_bt.len(), orig_recs.len()); - eprintln!(" 저장 BodyText: {} bytes, {} records", saved_bt.len(), saved_recs.len()); + if let Some(ref raw) = orig_doc.doc_info.raw_stream { + check_id_mappings(raw, "orig", orig_cs, orig_ps, orig_bf); + } + if let Some(ref raw) = saved_doc.doc_info.raw_stream { + check_id_mappings(raw, "saved", saved_cs, saved_ps, saved_bf); + } - // ============================================================ - // 3. 모든 표 찾기 - // ============================================================ - let find_tables = |recs: &[Record]| -> Vec { - recs.iter().enumerate() - .filter(|(_, r)| { - r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 && { - let ctrl_id = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); - ctrl_id == tags::CTRL_TABLE - } - }) - .map(|(i, _)| i) - .collect() - }; + // ============================================================ + // 2. BodyText raw records 읽기 + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== 2. BodyText 레코드 분석 ==="); + eprintln!("{}", "=".repeat(120)); + + let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); + let orig_bt = orig_cfb + .read_body_text_section(0, orig_doc.header.compressed, false) + .unwrap(); + let orig_recs = Record::read_all(&orig_bt).unwrap(); + + let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_doc.header.compressed, false) + .unwrap(); + let saved_recs = Record::read_all(&saved_bt).unwrap(); + + eprintln!( + " 원본 BodyText: {} bytes, {} records", + orig_bt.len(), + orig_recs.len() + ); + eprintln!( + " 저장 BodyText: {} bytes, {} records", + saved_bt.len(), + saved_recs.len() + ); + + // ============================================================ + // 3. 모든 표 찾기 + // ============================================================ + let find_tables = |recs: &[Record]| -> Vec { + recs.iter() + .enumerate() + .filter(|(_, r)| { + r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 && { + let ctrl_id = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); + ctrl_id == tags::CTRL_TABLE + } + }) + .map(|(i, _)| i) + .collect() + }; + + let orig_tables = find_tables(&orig_recs); + let saved_tables = find_tables(&saved_recs); + + eprintln!("\n 원본 표 개수: {}", orig_tables.len()); + eprintln!(" 저장 표 개수: {}", saved_tables.len()); + eprintln!( + " 차이: {:+}", + saved_tables.len() as i64 - orig_tables.len() as i64 + ); + + // 붙여넣기된 표 = 저장에만 있는 표 (인덱스가 원본 표 개수 이상인 것) + let pasted_table_indices: Vec = if saved_tables.len() > orig_tables.len() { + saved_tables[orig_tables.len()..].to_vec() + } else { + vec![] + }; + eprintln!(" 붙여넣기된 표 시작 인덱스: {:?}", pasted_table_indices); + + // ============================================================ + // 4. 표 구조 분석 함수 + // ============================================================ + let analyze_table_deep = |recs: &[Record], tbl_start: usize, label: &str, cs_count: usize| { + let tbl_level = recs[tbl_start].level; + let mut tbl_end = tbl_start + 1; + while tbl_end < recs.len() && recs[tbl_end].level > tbl_level { + tbl_end += 1; + } + + eprintln!( + "\n --- {} (rec[{}..{}], {} records, level={}) ---", + label, + tbl_start, + tbl_end, + tbl_end - tbl_start, + tbl_level + ); - let orig_tables = find_tables(&orig_recs); - let saved_tables = find_tables(&saved_recs); + // CTRL_HEADER dump + let ctrl_hdr = &recs[tbl_start]; + let ctrl_id = u32::from_le_bytes([ + ctrl_hdr.data[0], + ctrl_hdr.data[1], + ctrl_hdr.data[2], + ctrl_hdr.data[3], + ]); + let ctrl_bytes = [ + ctrl_hdr.data[3], + ctrl_hdr.data[2], + ctrl_hdr.data[1], + ctrl_hdr.data[0], + ]; + let ctrl_str: String = ctrl_bytes + .iter() + .map(|&b| { + if b >= 0x20 && b <= 0x7e { + b as char + } else { + '.' + } + }) + .collect(); + let dump_len = ctrl_hdr.data.len().min(64); + eprintln!( + " CTRL_HEADER ({}B): ctrl_id=0x{:08X} \"{}\"", + ctrl_hdr.data.len(), + ctrl_id, + ctrl_str + ); + eprintln!( + " data[..{}]: {:02X?}", + dump_len, + &ctrl_hdr.data[..dump_len] + ); - eprintln!("\n 원본 표 개수: {}", orig_tables.len()); - eprintln!(" 저장 표 개수: {}", saved_tables.len()); - eprintln!(" 차이: {:+}", saved_tables.len() as i64 - orig_tables.len() as i64); + // TABLE record + let mut table_rec_idx = None; + let mut list_headers: Vec = Vec::new(); + + for ri in tbl_start + 1..tbl_end { + if recs[ri].tag_id == tags::HWPTAG_TABLE && recs[ri].level == tbl_level + 1 { + table_rec_idx = Some(ri); + } + if recs[ri].tag_id == tags::HWPTAG_LIST_HEADER && recs[ri].level == tbl_level + 1 { + list_headers.push(ri); + } + } + + if let Some(tri) = table_rec_idx { + let td = &recs[tri].data; + eprintln!(" TABLE record (rec[{}], {}B):", tri, td.len()); + let dump_len2 = td.len().min(80); + eprintln!(" data[..{}]: {:02X?}", dump_len2, &td[..dump_len2]); + if td.len() >= 8 { + let flags = u32::from_le_bytes([td[0], td[1], td[2], td[3]]); + let nrows = u16::from_le_bytes([td[4], td[5]]); + let ncols = u16::from_le_bytes([td[6], td[7]]); + eprintln!( + " flags=0x{:08X} rows={} cols={} (expected_cells={})", + flags, + nrows, + ncols, + nrows as u32 * ncols as u32 + ); - // 붙여넣기된 표 = 저장에만 있는 표 (인덱스가 원본 표 개수 이상인 것) - let pasted_table_indices: Vec = if saved_tables.len() > orig_tables.len() { - saved_tables[orig_tables.len()..].to_vec() - } else { - vec![] - }; - eprintln!(" 붙여넣기된 표 시작 인덱스: {:?}", pasted_table_indices); - - // ============================================================ - // 4. 표 구조 분석 함수 - // ============================================================ - let analyze_table_deep = |recs: &[Record], tbl_start: usize, label: &str, cs_count: usize| { - let tbl_level = recs[tbl_start].level; - let mut tbl_end = tbl_start + 1; - while tbl_end < recs.len() && recs[tbl_end].level > tbl_level { - tbl_end += 1; - } - - eprintln!("\n --- {} (rec[{}..{}], {} records, level={}) ---", label, tbl_start, tbl_end, tbl_end - tbl_start, tbl_level); - - // CTRL_HEADER dump - let ctrl_hdr = &recs[tbl_start]; - let ctrl_id = u32::from_le_bytes([ctrl_hdr.data[0], ctrl_hdr.data[1], ctrl_hdr.data[2], ctrl_hdr.data[3]]); - let ctrl_bytes = [ctrl_hdr.data[3], ctrl_hdr.data[2], ctrl_hdr.data[1], ctrl_hdr.data[0]]; - let ctrl_str: String = ctrl_bytes.iter().map(|&b| if b >= 0x20 && b <= 0x7e { b as char } else { '.' }).collect(); - let dump_len = ctrl_hdr.data.len().min(64); - eprintln!(" CTRL_HEADER ({}B): ctrl_id=0x{:08X} \"{}\"", ctrl_hdr.data.len(), ctrl_id, ctrl_str); - eprintln!(" data[..{}]: {:02X?}", dump_len, &ctrl_hdr.data[..dump_len]); - - // TABLE record - let mut table_rec_idx = None; - let mut list_headers: Vec = Vec::new(); - - for ri in tbl_start+1..tbl_end { - if recs[ri].tag_id == tags::HWPTAG_TABLE && recs[ri].level == tbl_level + 1 { - table_rec_idx = Some(ri); - } - if recs[ri].tag_id == tags::HWPTAG_LIST_HEADER && recs[ri].level == tbl_level + 1 { - list_headers.push(ri); - } - } - - if let Some(tri) = table_rec_idx { - let td = &recs[tri].data; - eprintln!(" TABLE record (rec[{}], {}B):", tri, td.len()); - let dump_len2 = td.len().min(80); - eprintln!(" data[..{}]: {:02X?}", dump_len2, &td[..dump_len2]); - if td.len() >= 8 { - let flags = u32::from_le_bytes([td[0], td[1], td[2], td[3]]); - let nrows = u16::from_le_bytes([td[4], td[5]]); - let ncols = u16::from_le_bytes([td[6], td[7]]); - eprintln!(" flags=0x{:08X} rows={} cols={} (expected_cells={})", flags, nrows, ncols, nrows as u32 * ncols as u32); - - // Cell spacing, padding - if td.len() >= 10 { - let cell_spacing = u16::from_le_bytes([td[8], td[9]]); - eprintln!(" cell_spacing={}", cell_spacing); - } - // padding: left, right, top, bottom (u16 each) at offset 10..18 - if td.len() >= 18 { - let pad_l = u16::from_le_bytes([td[10], td[11]]); - let pad_r = u16::from_le_bytes([td[12], td[13]]); - let pad_t = u16::from_le_bytes([td[14], td[15]]); - let pad_b = u16::from_le_bytes([td[16], td[17]]); - eprintln!(" padding: L={} R={} T={} B={}", pad_l, pad_r, pad_t, pad_b); - } - // Row sizes - if td.len() >= 18 + nrows as usize * 2 { - let mut row_sizes = Vec::new(); - for r in 0..nrows as usize { - let off = 18 + r * 2; - let rs = u16::from_le_bytes([td[off], td[off+1]]); - row_sizes.push(rs); - } - eprintln!(" row_sizes: {:?}", row_sizes); - } - // border_fill_id - let bf_off = 18 + nrows as usize * 2; - if td.len() >= bf_off + 2 { - let bf_id = u16::from_le_bytes([td[bf_off], td[bf_off+1]]); - eprintln!(" border_fill_id={}", bf_id); + // Cell spacing, padding + if td.len() >= 10 { + let cell_spacing = u16::from_le_bytes([td[8], td[9]]); + eprintln!(" cell_spacing={}", cell_spacing); + } + // padding: left, right, top, bottom (u16 each) at offset 10..18 + if td.len() >= 18 { + let pad_l = u16::from_le_bytes([td[10], td[11]]); + let pad_r = u16::from_le_bytes([td[12], td[13]]); + let pad_t = u16::from_le_bytes([td[14], td[15]]); + let pad_b = u16::from_le_bytes([td[16], td[17]]); + eprintln!( + " padding: L={} R={} T={} B={}", + pad_l, pad_r, pad_t, pad_b + ); + } + // Row sizes + if td.len() >= 18 + nrows as usize * 2 { + let mut row_sizes = Vec::new(); + for r in 0..nrows as usize { + let off = 18 + r * 2; + let rs = u16::from_le_bytes([td[off], td[off + 1]]); + row_sizes.push(rs); } + eprintln!(" row_sizes: {:?}", row_sizes); } - } else { - eprintln!(" TABLE record: NOT FOUND!"); - } - - // LIST_HEADER (cells) and their paragraphs - eprintln!(" 셀 개수 (LIST_HEADER): {}", list_headers.len()); - - let mut cell_issues: Vec = Vec::new(); - - for (ci, &lhi) in list_headers.iter().enumerate() { - let lh = &recs[lhi]; - let cell_level = lh.level; - let dump_len3 = lh.data.len().min(48); - eprintln!("\n Cell[{}] LIST_HEADER (rec[{}], {}B, level={}):", ci, lhi, lh.data.len(), cell_level); - eprintln!(" data[..{}]: {:02X?}", dump_len3, &lh.data[..dump_len3]); - - if lh.data.len() >= 4 { - let nparas = u16::from_le_bytes([lh.data[0], lh.data[1]]); - let flags = u16::from_le_bytes([lh.data[2], lh.data[3]]); - eprintln!(" nparas={} flags=0x{:04X}", nparas, flags); - } - // Cell-specific data: col, row, col_span, row_span, width, height at offsets in LIST_HEADER - // After the generic LIST_HEADER (first ~14 bytes): col(u16) row(u16) col_span(u16) row_span(u16) width(u32) height(u32) padding(u16x4) border_fill_id(u16) - if lh.data.len() >= 34 { - let col_addr = u16::from_le_bytes([lh.data[14], lh.data[15]]); - let row_addr = u16::from_le_bytes([lh.data[16], lh.data[17]]); - let col_span = u16::from_le_bytes([lh.data[18], lh.data[19]]); - let row_span = u16::from_le_bytes([lh.data[20], lh.data[21]]); - let width = u32::from_le_bytes([lh.data[22], lh.data[23], lh.data[24], lh.data[25]]); - let height = u32::from_le_bytes([lh.data[26], lh.data[27], lh.data[28], lh.data[29]]); - eprintln!(" cell: col={} row={} col_span={} row_span={} width={} height={}", col_addr, row_addr, col_span, row_span, width, height); - - let bf_id = u16::from_le_bytes([lh.data[32], lh.data[33]]); + // border_fill_id + let bf_off = 18 + nrows as usize * 2; + if td.len() >= bf_off + 2 { + let bf_id = u16::from_le_bytes([td[bf_off], td[bf_off + 1]]); eprintln!(" border_fill_id={}", bf_id); } + } + } else { + eprintln!(" TABLE record: NOT FOUND!"); + } - // Find paragraphs belonging to this cell - let next_boundary = if ci + 1 < list_headers.len() { - list_headers[ci + 1] - } else { - tbl_end - }; - - let mut para_count = 0; - for ri2 in lhi+1..next_boundary { - if recs[ri2].tag_id == tags::HWPTAG_PARA_HEADER && recs[ri2].level == cell_level + 1 { - let ph = &recs[ri2]; - let raw_char_count = if ph.data.len() >= 4 { - u32::from_le_bytes([ph.data[0], ph.data[1], ph.data[2], ph.data[3]]) - } else { 0 }; - let char_count = raw_char_count & 0x7FFFFFFF; - let msb = raw_char_count >> 31; - let control_mask = if ph.data.len() >= 8 { - u32::from_le_bytes([ph.data[4], ph.data[5], ph.data[6], ph.data[7]]) - } else { 0 }; - let para_shape_id = if ph.data.len() >= 10 { - u16::from_le_bytes([ph.data[8], ph.data[9]]) - } else { 0 }; - let style_id = if ph.data.len() >= 11 { ph.data[10] } else { 0 }; - let num_char_shapes = if ph.data.len() >= 14 { - u16::from_le_bytes([ph.data[12], ph.data[13]]) - } else { 0 }; - - eprintln!(" PARA[{}] (rec[{}]): char_count={} (msb={}) control_mask=0x{:08X} para_shape_id={} style_id={} numCharShapes={}", - para_count, ri2, char_count, msb, control_mask, para_shape_id, style_id, num_char_shapes); - - // para_shape_id validity - if (para_shape_id as usize) >= saved_ps { - let msg = format!("Cell[{}] PARA[{}] rec[{}]: para_shape_id={} >= para_shapes.len()={}", ci, para_count, ri2, para_shape_id, saved_ps); - eprintln!(" *** INVALID para_shape_id: {} ***", msg); - cell_issues.push(msg); - } - - // PARA_TEXT check - let has_text = ri2 + 1 < next_boundary - && recs[ri2 + 1].tag_id == tags::HWPTAG_PARA_TEXT - && recs[ri2 + 1].level == cell_level + 2; - - if has_text { - let pt = &recs[ri2 + 1]; - let pt_u16_count = pt.data.len() / 2; - let expected_u16 = char_count as usize; - let u16_chars: Vec = pt.data.chunks(2) - .filter(|c| c.len() == 2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect(); - let text = String::from_utf16_lossy(&u16_chars); - let preview: String = text.chars().take(60).collect(); + // LIST_HEADER (cells) and their paragraphs + eprintln!(" 셀 개수 (LIST_HEADER): {}", list_headers.len()); - if pt_u16_count != expected_u16 { - let msg = format!("Cell[{}] PARA[{}] rec[{}]: char_count={} but PARA_TEXT has {} u16 units (diff={})", - ci, para_count, ri2, char_count, pt_u16_count, pt_u16_count as i64 - expected_u16 as i64); - eprintln!(" *** MISMATCH: {} ***", msg); - cell_issues.push(msg); - } - eprintln!(" PARA_TEXT ({}B, {} u16): \"{}\"", pt.data.len(), pt_u16_count, preview); - } else if char_count > 0 { - // char_count > 0 but no PARA_TEXT (might have char_count=1 for empty para end marker only in HEADER) - if char_count > 1 { - let msg = format!("Cell[{}] PARA[{}] rec[{}]: char_count={} but NO PARA_TEXT", ci, para_count, ri2, char_count); - eprintln!(" *** MISSING PARA_TEXT: {} ***", msg); - cell_issues.push(msg); - } - } + let mut cell_issues: Vec = Vec::new(); - // PARA_CHAR_SHAPE check - // Look for PARA_CHAR_SHAPE following PARA_TEXT (or PARA_HEADER if no text) - let mut pcs_idx = None; - for ri3 in ri2+1..next_boundary { - if recs[ri3].level <= cell_level + 1 { break; } // left this para's children - if recs[ri3].tag_id == tags::HWPTAG_PARA_CHAR_SHAPE && recs[ri3].level == cell_level + 2 { - pcs_idx = Some(ri3); - break; - } - } + for (ci, &lhi) in list_headers.iter().enumerate() { + let lh = &recs[lhi]; + let cell_level = lh.level; + let dump_len3 = lh.data.len().min(48); + eprintln!( + "\n Cell[{}] LIST_HEADER (rec[{}], {}B, level={}):", + ci, + lhi, + lh.data.len(), + cell_level + ); + eprintln!( + " data[..{}]: {:02X?}", + dump_len3, + &lh.data[..dump_len3] + ); - if let Some(pcs_ri) = pcs_idx { - let pcs = &recs[pcs_ri]; - let num_entries = pcs.data.len() / 8; - eprintln!(" PARA_CHAR_SHAPE (rec[{}], {}B, {} entries):", pcs_ri, pcs.data.len(), num_entries); - - for ei in 0..num_entries { - let off = ei * 8; - if off + 8 <= pcs.data.len() { - let start_pos = u32::from_le_bytes([pcs.data[off], pcs.data[off+1], pcs.data[off+2], pcs.data[off+3]]); - let cs_id = u32::from_le_bytes([pcs.data[off+4], pcs.data[off+5], pcs.data[off+6], pcs.data[off+7]]); - let valid = (cs_id as usize) < cs_count; - eprintln!(" [{}] start_pos={} char_shape_id={} {}", - ei, start_pos, cs_id, if valid { "OK" } else { "*** INVALID ***" }); - if !valid { - cell_issues.push(format!("Cell[{}] PARA[{}] PARA_CHAR_SHAPE entry[{}]: char_shape_id={} >= {} (invalid)", - ci, para_count, ei, cs_id, cs_count)); - } - } - } - } else if num_char_shapes > 0 { - eprintln!(" PARA_CHAR_SHAPE: NOT FOUND (numCharShapes={})", num_char_shapes); - } + if lh.data.len() >= 4 { + let nparas = u16::from_le_bytes([lh.data[0], lh.data[1]]); + let flags = u16::from_le_bytes([lh.data[2], lh.data[3]]); + eprintln!(" nparas={} flags=0x{:04X}", nparas, flags); + } + // Cell-specific data: col, row, col_span, row_span, width, height at offsets in LIST_HEADER + // After the generic LIST_HEADER (first ~14 bytes): col(u16) row(u16) col_span(u16) row_span(u16) width(u32) height(u32) padding(u16x4) border_fill_id(u16) + if lh.data.len() >= 34 { + let col_addr = u16::from_le_bytes([lh.data[14], lh.data[15]]); + let row_addr = u16::from_le_bytes([lh.data[16], lh.data[17]]); + let col_span = u16::from_le_bytes([lh.data[18], lh.data[19]]); + let row_span = u16::from_le_bytes([lh.data[20], lh.data[21]]); + let width = + u32::from_le_bytes([lh.data[22], lh.data[23], lh.data[24], lh.data[25]]); + let height = + u32::from_le_bytes([lh.data[26], lh.data[27], lh.data[28], lh.data[29]]); + eprintln!( + " cell: col={} row={} col_span={} row_span={} width={} height={}", + col_addr, row_addr, col_span, row_span, width, height + ); - para_count += 1; - } - } + let bf_id = u16::from_le_bytes([lh.data[32], lh.data[33]]); + eprintln!(" border_fill_id={}", bf_id); } - cell_issues - }; - - // ============================================================ - // 5. 모든 표 분석 - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== 3. 표(Table) 상세 분석 ==="); - eprintln!("{}", "=".repeat(120)); - - for (ti, &tbl_start) in orig_tables.iter().enumerate() { - analyze_table_deep(&orig_recs, tbl_start, &format!("원본 표[{}]", ti), orig_cs); - } - - let mut all_pasted_issues: Vec = Vec::new(); - for (ti, &tbl_start) in saved_tables.iter().enumerate() { - let is_pasted = pasted_table_indices.contains(&tbl_start); - let label = if is_pasted { - format!("저장 표[{}] (*** PASTED ***)", ti) + // Find paragraphs belonging to this cell + let next_boundary = if ci + 1 < list_headers.len() { + list_headers[ci + 1] } else { - format!("저장 표[{}]", ti) + tbl_end }; - let issues = analyze_table_deep(&saved_recs, tbl_start, &label, saved_cs); - if is_pasted { - all_pasted_issues.extend(issues); - } - } - // ============================================================ - // 6. 전체 저장 파일 문단 일관성 검사 - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== 4. 전체 문단 일관성 검사 (저장 파일) ==="); - eprintln!("{}", "=".repeat(120)); + let mut para_count = 0; + for ri2 in lhi + 1..next_boundary { + if recs[ri2].tag_id == tags::HWPTAG_PARA_HEADER && recs[ri2].level == cell_level + 1 + { + let ph = &recs[ri2]; + let raw_char_count = if ph.data.len() >= 4 { + u32::from_le_bytes([ph.data[0], ph.data[1], ph.data[2], ph.data[3]]) + } else { + 0 + }; + let char_count = raw_char_count & 0x7FFFFFFF; + let msb = raw_char_count >> 31; + let control_mask = if ph.data.len() >= 8 { + u32::from_le_bytes([ph.data[4], ph.data[5], ph.data[6], ph.data[7]]) + } else { + 0 + }; + let para_shape_id = if ph.data.len() >= 10 { + u16::from_le_bytes([ph.data[8], ph.data[9]]) + } else { + 0 + }; + let style_id = if ph.data.len() >= 11 { ph.data[10] } else { 0 }; + let num_char_shapes = if ph.data.len() >= 14 { + u16::from_le_bytes([ph.data[12], ph.data[13]]) + } else { + 0 + }; - let mut total_paras = 0u32; - let mut char_count_mismatches = 0u32; - let mut missing_para_text = 0u32; - let mut invalid_cs_refs = 0u32; - let mut invalid_ps_refs = 0u32; - let mut max_cs_id: u32 = 0; - let mut max_ps_id: u16 = 0; + eprintln!(" PARA[{}] (rec[{}]): char_count={} (msb={}) control_mask=0x{:08X} para_shape_id={} style_id={} numCharShapes={}", + para_count, ri2, char_count, msb, control_mask, para_shape_id, style_id, num_char_shapes); - let mut i = 0; - while i < saved_recs.len() { - if saved_recs[i].tag_id == tags::HWPTAG_PARA_HEADER { - let ph = &saved_recs[i]; - let ph_level = ph.level; - let raw_char_count = if ph.data.len() >= 4 { - u32::from_le_bytes([ph.data[0], ph.data[1], ph.data[2], ph.data[3]]) - } else { 0 }; - let char_count = raw_char_count & 0x7FFFFFFF; - let control_mask = if ph.data.len() >= 8 { - u32::from_le_bytes([ph.data[4], ph.data[5], ph.data[6], ph.data[7]]) - } else { 0 }; - let para_shape_id = if ph.data.len() >= 10 { - u16::from_le_bytes([ph.data[8], ph.data[9]]) - } else { 0 }; - let style_id = if ph.data.len() >= 11 { ph.data[10] } else { 0 }; - - // para_shape_id validity - if (para_shape_id as usize) >= saved_ps { - eprintln!(" *** para[{}] rec[{}]: para_shape_id={} >= {} (INVALID) ***", - total_paras, i, para_shape_id, saved_ps); - invalid_ps_refs += 1; - } - if para_shape_id > max_ps_id { - max_ps_id = para_shape_id; - } - - // PARA_TEXT check - let has_text = i + 1 < saved_recs.len() - && saved_recs[i + 1].tag_id == tags::HWPTAG_PARA_TEXT - && saved_recs[i + 1].level == ph_level + 1; + // para_shape_id validity + if (para_shape_id as usize) >= saved_ps { + let msg = format!( + "Cell[{}] PARA[{}] rec[{}]: para_shape_id={} >= para_shapes.len()={}", + ci, para_count, ri2, para_shape_id, saved_ps + ); + eprintln!(" *** INVALID para_shape_id: {} ***", msg); + cell_issues.push(msg); + } - if has_text { - let pt = &saved_recs[i + 1]; - let pt_u16_count = pt.data.len() / 2; - let expected_u16 = char_count as usize; + // PARA_TEXT check + let has_text = ri2 + 1 < next_boundary + && recs[ri2 + 1].tag_id == tags::HWPTAG_PARA_TEXT + && recs[ri2 + 1].level == cell_level + 2; - if pt_u16_count != expected_u16 { - eprintln!(" MISMATCH para[{}] rec[{}]: char_count={} but PARA_TEXT has {} u16 (diff={})", - total_paras, i, char_count, pt_u16_count, pt_u16_count as i64 - expected_u16 as i64); - let u16_chars: Vec = pt.data.chunks(2) + if has_text { + let pt = &recs[ri2 + 1]; + let pt_u16_count = pt.data.len() / 2; + let expected_u16 = char_count as usize; + let u16_chars: Vec = pt + .data + .chunks(2) .filter(|c| c.len() == 2) .map(|c| u16::from_le_bytes([c[0], c[1]])) .collect(); let text = String::from_utf16_lossy(&u16_chars); let preview: String = text.chars().take(60).collect(); - eprintln!(" text_preview: \"{}\"", preview); - char_count_mismatches += 1; - } - } else if char_count > 1 { - eprintln!(" MISSING PARA_TEXT para[{}] rec[{}]: char_count={} control_mask=0x{:08X}", - total_paras, i, char_count, control_mask); - missing_para_text += 1; - } - // PARA_CHAR_SHAPE check for all paragraphs - for ri3 in i+1..saved_recs.len() { - if saved_recs[ri3].level <= ph_level { break; } - if saved_recs[ri3].tag_id == tags::HWPTAG_PARA_CHAR_SHAPE && saved_recs[ri3].level == ph_level + 1 { - let pcs = &saved_recs[ri3]; - let num_entries = pcs.data.len() / 8; - for ei in 0..num_entries { - let off = ei * 8; - if off + 8 <= pcs.data.len() { - let cs_id = u32::from_le_bytes([pcs.data[off+4], pcs.data[off+5], pcs.data[off+6], pcs.data[off+7]]); - if cs_id > max_cs_id { - max_cs_id = cs_id; - } - if (cs_id as usize) >= saved_cs { - eprintln!(" *** para[{}] rec[{}] PARA_CHAR_SHAPE entry[{}]: char_shape_id={} >= {} (INVALID) ***", - total_paras, i, ei, cs_id, saved_cs); - invalid_cs_refs += 1; - } - } + if pt_u16_count != expected_u16 { + let msg = format!("Cell[{}] PARA[{}] rec[{}]: char_count={} but PARA_TEXT has {} u16 units (diff={})", + ci, para_count, ri2, char_count, pt_u16_count, pt_u16_count as i64 - expected_u16 as i64); + eprintln!(" *** MISMATCH: {} ***", msg); + cell_issues.push(msg); + } + eprintln!( + " PARA_TEXT ({}B, {} u16): \"{}\"", + pt.data.len(), + pt_u16_count, + preview + ); + } else if char_count > 0 { + // char_count > 0 but no PARA_TEXT (might have char_count=1 for empty para end marker only in HEADER) + if char_count > 1 { + let msg = format!( + "Cell[{}] PARA[{}] rec[{}]: char_count={} but NO PARA_TEXT", + ci, para_count, ri2, char_count + ); + eprintln!(" *** MISSING PARA_TEXT: {} ***", msg); + cell_issues.push(msg); } - break; } - } - total_paras += 1; - } - i += 1; - } - - // ============================================================ - // 7. 원본 파일도 동일 검사 (비교용) - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== 5. 전체 문단 일관성 검사 (원본 파일) ==="); - eprintln!("{}", "=".repeat(120)); - - let mut orig_total_paras = 0u32; - let mut orig_char_count_mismatches = 0u32; - let mut orig_invalid_cs_refs = 0u32; - let mut orig_invalid_ps_refs = 0u32; - - i = 0; - while i < orig_recs.len() { - if orig_recs[i].tag_id == tags::HWPTAG_PARA_HEADER { - let ph = &orig_recs[i]; - let ph_level = ph.level; - let raw_char_count = if ph.data.len() >= 4 { - u32::from_le_bytes([ph.data[0], ph.data[1], ph.data[2], ph.data[3]]) - } else { 0 }; - let char_count = raw_char_count & 0x7FFFFFFF; - let para_shape_id = if ph.data.len() >= 10 { - u16::from_le_bytes([ph.data[8], ph.data[9]]) - } else { 0 }; - - if (para_shape_id as usize) >= orig_ps { - eprintln!(" *** orig para[{}] rec[{}]: para_shape_id={} >= {} (INVALID) ***", - orig_total_paras, i, para_shape_id, orig_ps); - orig_invalid_ps_refs += 1; - } - - let has_text = i + 1 < orig_recs.len() - && orig_recs[i + 1].tag_id == tags::HWPTAG_PARA_TEXT - && orig_recs[i + 1].level == ph_level + 1; - - if has_text { - let pt = &orig_recs[i + 1]; - let pt_u16_count = pt.data.len() / 2; - if pt_u16_count != char_count as usize { - eprintln!(" MISMATCH orig para[{}] rec[{}]: char_count={} but PARA_TEXT has {} u16", - orig_total_paras, i, char_count, pt_u16_count); - orig_char_count_mismatches += 1; + // PARA_CHAR_SHAPE check + // Look for PARA_CHAR_SHAPE following PARA_TEXT (or PARA_HEADER if no text) + let mut pcs_idx = None; + for ri3 in ri2 + 1..next_boundary { + if recs[ri3].level <= cell_level + 1 { + break; + } // left this para's children + if recs[ri3].tag_id == tags::HWPTAG_PARA_CHAR_SHAPE + && recs[ri3].level == cell_level + 2 + { + pcs_idx = Some(ri3); + break; + } } - } - // PARA_CHAR_SHAPE - for ri3 in i+1..orig_recs.len() { - if orig_recs[ri3].level <= ph_level { break; } - if orig_recs[ri3].tag_id == tags::HWPTAG_PARA_CHAR_SHAPE && orig_recs[ri3].level == ph_level + 1 { - let pcs = &orig_recs[ri3]; + if let Some(pcs_ri) = pcs_idx { + let pcs = &recs[pcs_ri]; let num_entries = pcs.data.len() / 8; + eprintln!( + " PARA_CHAR_SHAPE (rec[{}], {}B, {} entries):", + pcs_ri, + pcs.data.len(), + num_entries + ); + for ei in 0..num_entries { let off = ei * 8; if off + 8 <= pcs.data.len() { - let cs_id = u32::from_le_bytes([pcs.data[off+4], pcs.data[off+5], pcs.data[off+6], pcs.data[off+7]]); - if (cs_id as usize) >= orig_cs { - eprintln!(" *** orig para[{}] rec[{}] PARA_CHAR_SHAPE entry[{}]: char_shape_id={} >= {} (INVALID) ***", - orig_total_paras, i, ei, cs_id, orig_cs); - orig_invalid_cs_refs += 1; + let start_pos = u32::from_le_bytes([ + pcs.data[off], + pcs.data[off + 1], + pcs.data[off + 2], + pcs.data[off + 3], + ]); + let cs_id = u32::from_le_bytes([ + pcs.data[off + 4], + pcs.data[off + 5], + pcs.data[off + 6], + pcs.data[off + 7], + ]); + let valid = (cs_id as usize) < cs_count; + eprintln!( + " [{}] start_pos={} char_shape_id={} {}", + ei, + start_pos, + cs_id, + if valid { "OK" } else { "*** INVALID ***" } + ); + if !valid { + cell_issues.push(format!("Cell[{}] PARA[{}] PARA_CHAR_SHAPE entry[{}]: char_shape_id={} >= {} (invalid)", + ci, para_count, ei, cs_id, cs_count)); } } } - break; + } else if num_char_shapes > 0 { + eprintln!( + " PARA_CHAR_SHAPE: NOT FOUND (numCharShapes={})", + num_char_shapes + ); } - } - orig_total_paras += 1; + para_count += 1; + } } - i += 1; } - // ============================================================ - // 8. 레코드 타입별 카운트 비교 - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== 6. 레코드 타입별 카운트 비교 ==="); - eprintln!("{}", "=".repeat(120)); - let count_tag = |recs: &[Record], tag: u16| recs.iter().filter(|r| r.tag_id == tag).count(); - let tags_to_check: [(u16, &str); 11] = [ - (tags::HWPTAG_PARA_HEADER, "PARA_HEADER"), - (tags::HWPTAG_PARA_TEXT, "PARA_TEXT"), - (tags::HWPTAG_PARA_CHAR_SHAPE, "PARA_CHAR_SHAPE"), - (tags::HWPTAG_PARA_LINE_SEG, "PARA_LINE_SEG"), - (tags::HWPTAG_PARA_RANGE_TAG, "PARA_RANGE_TAG"), - (tags::HWPTAG_CTRL_HEADER, "CTRL_HEADER"), - (tags::HWPTAG_LIST_HEADER, "LIST_HEADER"), - (tags::HWPTAG_TABLE, "TABLE"), - (tags::HWPTAG_CTRL_DATA, "CTRL_DATA"), - (tags::HWPTAG_PAGE_DEF, "PAGE_DEF"), - (tags::HWPTAG_SHAPE_COMPONENT, "SHAPE_COMPONENT"), - ]; - for (tag, name) in &tags_to_check { - let orig_cnt = count_tag(&orig_recs, *tag); - let saved_cnt = count_tag(&saved_recs, *tag); - let diff = saved_cnt as i64 - orig_cnt as i64; - eprintln!(" {:25} orig={:4} saved={:4} diff={}{:+}{}", - name, orig_cnt, saved_cnt, - if diff != 0 { "<<< " } else { "" }, - diff, - if diff != 0 { " >>>" } else { "" }); - } - - // ============================================================ - // 9. 요약 - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!("=== SUMMARY ==="); - eprintln!("{}", "=".repeat(120)); - eprintln!(" 원본: tables={}, paragraphs={}, char_count_mismatches={}, invalid_cs_refs={}, invalid_ps_refs={}", - orig_tables.len(), orig_total_paras, orig_char_count_mismatches, orig_invalid_cs_refs, orig_invalid_ps_refs); - eprintln!(" 저장: tables={}, paragraphs={}, char_count_mismatches={}, invalid_cs_refs={}, invalid_ps_refs={}, missing_para_text={}", - saved_tables.len(), total_paras, char_count_mismatches, invalid_cs_refs, invalid_ps_refs, missing_para_text); - eprintln!(" 붙여넣기 표 issues: {}", all_pasted_issues.len()); - for (idx, issue) in all_pasted_issues.iter().enumerate() { - eprintln!(" [{}] {}", idx, issue); - } - eprintln!(" max CharShape ID referenced: {} (available: 0..{})", max_cs_id, saved_cs); - eprintln!(" max ParaShape ID referenced: {} (available: 0..{})", max_ps_id, saved_ps); + cell_issues + }; - // Assertions - // We do NOT assert zero mismatches here because the purpose is analysis/reporting. - // But we flag truly fatal issues. - if invalid_cs_refs > 0 { - eprintln!("\n *** FATAL: {} invalid CharShape ID references in saved file ***", invalid_cs_refs); - } - if invalid_ps_refs > 0 { - eprintln!("\n *** FATAL: {} invalid ParaShape ID references in saved file ***", invalid_ps_refs); - } - if char_count_mismatches > 0 { - eprintln!("\n *** WARNING: {} char_count vs PARA_TEXT mismatches in saved file ***", char_count_mismatches); - } + // ============================================================ + // 5. 모든 표 분석 + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== 3. 표(Table) 상세 분석 ==="); + eprintln!("{}", "=".repeat(120)); - eprintln!("\n=== test_rp005_pasted_table_analysis complete ==="); + for (ti, &tbl_start) in orig_tables.iter().enumerate() { + analyze_table_deep(&orig_recs, tbl_start, &format!("원본 표[{}]", ti), orig_cs); } - /// 3개 HWP 파일 비교: empty-step2 원본, HWP 프로그램 붙여넣기, 뷰어 붙여넣기(손상) - /// - /// DocInfo ID_MAPPINGS, BodyText 레코드 전체 덤프, 레코드별 차이 비교 - #[test] - fn test_step2_comparison() { - use crate::parser::cfb_reader::CfbReader; - use crate::parser::record::Record; - use crate::parser::tags; - - // ============================================================ - // 파일 로드 - // ============================================================ - let files: Vec<(&str, &str)> = vec![ - ("template/empty-step2.hwp", "ORIGINAL"), - ("template/empty-step2-p.hwp", "HWP_PASTE (VALID)"), - ("template/empty-step2_saved_err.hwp", "VIEWER_PASTE (DAMAGED)"), - ]; - - struct FileData { - label: String, - path: String, - doc_info_records: Vec, - body_records: Vec, - body_raw_len: usize, - } - - let mut all_files: Vec = Vec::new(); - - for (path, label) in &files { - let bytes = std::fs::read(path) - .unwrap_or_else(|e| panic!("파일 읽기 실패: {} - {}", path, e)); - eprintln!("\n=== Loading {} ({}) - {} bytes ===", label, path, bytes.len()); - - let mut cfb = CfbReader::open(&bytes) - .unwrap_or_else(|e| panic!("CFB 열기 실패: {} - {}", path, e)); - - // DocInfo (compressed=true) - let doc_info_data = cfb.read_doc_info(true) - .unwrap_or_else(|e| panic!("DocInfo 읽기 실패: {} - {}", path, e)); - let doc_info_records = Record::read_all(&doc_info_data) - .unwrap_or_else(|e| panic!("DocInfo 레코드 파싱 실패: {} - {}", path, e)); - - // BodyText Section 0 (compressed=true, distribution=false) - let body_data = cfb.read_body_text_section(0, true, false) - .unwrap_or_else(|e| panic!("BodyText 읽기 실패: {} - {}", path, e)); - let body_raw_len = body_data.len(); - let body_records = Record::read_all(&body_data) - .unwrap_or_else(|e| panic!("BodyText 레코드 파싱 실패: {} - {}", path, e)); - - all_files.push(FileData { - label: label.to_string(), - path: path.to_string(), - doc_info_records, - body_records, - body_raw_len, - }); + let mut all_pasted_issues: Vec = Vec::new(); + for (ti, &tbl_start) in saved_tables.iter().enumerate() { + let is_pasted = pasted_table_indices.contains(&tbl_start); + let label = if is_pasted { + format!("저장 표[{}] (*** PASTED ***)", ti) + } else { + format!("저장 표[{}]", ti) + }; + let issues = analyze_table_deep(&saved_recs, tbl_start, &label, saved_cs); + if is_pasted { + all_pasted_issues.extend(issues); } + } - // ============================================================ - // Helper functions - // ============================================================ - - fn read_u32_le(data: &[u8], offset: usize) -> u32 { - if offset + 4 <= data.len() { - u32::from_le_bytes([data[offset], data[offset+1], data[offset+2], data[offset+3]]) + // ============================================================ + // 6. 전체 저장 파일 문단 일관성 검사 + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== 4. 전체 문단 일관성 검사 (저장 파일) ==="); + eprintln!("{}", "=".repeat(120)); + + let mut total_paras = 0u32; + let mut char_count_mismatches = 0u32; + let mut missing_para_text = 0u32; + let mut invalid_cs_refs = 0u32; + let mut invalid_ps_refs = 0u32; + let mut max_cs_id: u32 = 0; + let mut max_ps_id: u16 = 0; + + let mut i = 0; + while i < saved_recs.len() { + if saved_recs[i].tag_id == tags::HWPTAG_PARA_HEADER { + let ph = &saved_recs[i]; + let ph_level = ph.level; + let raw_char_count = if ph.data.len() >= 4 { + u32::from_le_bytes([ph.data[0], ph.data[1], ph.data[2], ph.data[3]]) } else { 0 - } - } - - fn read_u16_le(data: &[u8], offset: usize) -> u16 { - if offset + 2 <= data.len() { - u16::from_le_bytes([data[offset], data[offset+1]]) + }; + let char_count = raw_char_count & 0x7FFFFFFF; + let control_mask = if ph.data.len() >= 8 { + u32::from_le_bytes([ph.data[4], ph.data[5], ph.data[6], ph.data[7]]) } else { 0 - } - } - - fn ctrl_id_string(data: &[u8]) -> String { - if data.len() >= 4 { - // ctrl_id stored as LE u32, but represents big-endian character ordering - let id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); - let be_bytes = id.to_be_bytes(); - let ascii: String = be_bytes.iter().map(|&b| { - if b.is_ascii_graphic() || b == b' ' { b as char } else { '.' } - }).collect(); - format!("\"{}\" (0x{:08X})", ascii, id) + }; + let para_shape_id = if ph.data.len() >= 10 { + u16::from_le_bytes([ph.data[8], ph.data[9]]) } else { - format!("(data too short: {} bytes)", data.len()) - } - } + 0 + }; + let style_id = if ph.data.len() >= 11 { ph.data[10] } else { 0 }; - fn hex_preview(data: &[u8], max: usize) -> String { - let show = std::cmp::min(data.len(), max); - let hex: Vec = data[..show].iter().map(|b| format!("{:02x}", b)).collect(); - let mut s = hex.join(" "); - if data.len() > max { - s.push_str(&format!(" ...({} more)", data.len() - max)); + // para_shape_id validity + if (para_shape_id as usize) >= saved_ps { + eprintln!( + " *** para[{}] rec[{}]: para_shape_id={} >= {} (INVALID) ***", + total_paras, i, para_shape_id, saved_ps + ); + invalid_ps_refs += 1; } - s - } - - // ============================================================ - // 1. DocInfo ID_MAPPINGS summary for each file - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!(" PART 1: DocInfo ID_MAPPINGS Summary"); - eprintln!("{}", "=".repeat(120)); - - let id_map_field_names = [ - "bin_data_count", // 0 - "font_han", // 1 - "font_eng", // 2 - "font_hanja", // 3 - "font_jpn", // 4 - "font_other", // 5 - "font_symbol", // 6 - "font_user", // 7 - "border_fill_count", // 8 - "char_shape_count", // 9 - "tab_def_count", // 10 - "numbering_count", // 11 - "bullet_count", // 12 - "para_shape_count", // 13 - "style_count", // 14 - "memo_shape_count", // 15 - ]; - - for fd in &all_files { - eprintln!("\n--- {} ({}) ---", fd.label, fd.path); - eprintln!(" DocInfo records: {}", fd.doc_info_records.len()); - - if let Some(id_rec) = fd.doc_info_records.iter().find(|r| r.tag_id == tags::HWPTAG_ID_MAPPINGS) { - eprintln!(" ID_MAPPINGS record size: {} bytes", id_rec.data.len()); - let num_values = id_rec.data.len() / 4; - for i in 0..std::cmp::min(num_values, id_map_field_names.len()) { - let val = read_u32_le(&id_rec.data, i * 4); - eprintln!(" [{:>2}] {:<25} = {}", i, id_map_field_names[i], val); - } - - // Highlight key counts - let cs = if num_values > 9 { read_u32_le(&id_rec.data, 9 * 4) } else { 0 }; - let ps = if num_values > 13 { read_u32_le(&id_rec.data, 13 * 4) } else { 0 }; - let bf = if num_values > 8 { read_u32_le(&id_rec.data, 8 * 4) } else { 0 }; - eprintln!(" >>> CharShape(CS)={}, ParaShape(PS)={}, BorderFill(BF)={}", cs, ps, bf); - } else { - eprintln!(" WARNING: No ID_MAPPINGS record found!"); + if para_shape_id > max_ps_id { + max_ps_id = para_shape_id; } - } - - // ============================================================ - // 2. BodyText record full dump for each file - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!(" PART 2: BodyText Section0 - Full Record Dump (ALL files)"); - eprintln!("{}", "=".repeat(120)); - - for fd in &all_files { - eprintln!("\n{}", "#".repeat(100)); - eprintln!("### {} ({}) ###", fd.label, fd.path); - eprintln!("### BodyText decompressed: {} bytes, records: {} ###", - fd.body_raw_len, fd.body_records.len()); - eprintln!("{}", "#".repeat(100)); - // Total bytes in records - let total_data_bytes: usize = fd.body_records.iter().map(|r| r.data.len()).sum(); - eprintln!(" Total record data bytes: {}", total_data_bytes); + // PARA_TEXT check + let has_text = i + 1 < saved_recs.len() + && saved_recs[i + 1].tag_id == tags::HWPTAG_PARA_TEXT + && saved_recs[i + 1].level == ph_level + 1; - eprintln!("\n{:<5} {:<5} {:<25} {:>8} {}", "Idx", "Lvl", "Tag", "Size", "Details"); - eprintln!("{:-<120}", ""); + if has_text { + let pt = &saved_recs[i + 1]; + let pt_u16_count = pt.data.len() / 2; + let expected_u16 = char_count as usize; - for (i, rec) in fd.body_records.iter().enumerate() { - let indent = " ".repeat(std::cmp::min(rec.level as usize, 8)); - let tag_str = format!("{}{}", indent, tags::tag_name(rec.tag_id)); - - let mut details = String::new(); + if pt_u16_count != expected_u16 { + eprintln!(" MISMATCH para[{}] rec[{}]: char_count={} but PARA_TEXT has {} u16 (diff={})", + total_paras, i, char_count, pt_u16_count, pt_u16_count as i64 - expected_u16 as i64); + let u16_chars: Vec = pt + .data + .chunks(2) + .filter(|c| c.len() == 2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let text = String::from_utf16_lossy(&u16_chars); + let preview: String = text.chars().take(60).collect(); + eprintln!(" text_preview: \"{}\"", preview); + char_count_mismatches += 1; + } + } else if char_count > 1 { + eprintln!( + " MISSING PARA_TEXT para[{}] rec[{}]: char_count={} control_mask=0x{:08X}", + total_paras, i, char_count, control_mask + ); + missing_para_text += 1; + } - // PARA_HEADER details - if rec.tag_id == tags::HWPTAG_PARA_HEADER { - if rec.data.len() >= 22 { - // PARA_HEADER layout: - // u32 nChars (bit31=MSB) - // u32 controlMask - // u16 paraShapeId - // u8 styleId (or u16) - // u8 breakType - // u16 charShapeCount (?) - // ... - let raw_char_count = read_u32_le(&rec.data, 0); - let msb = (raw_char_count >> 31) & 1; - let char_count = raw_char_count & 0x7FFFFFFF; - let control_mask = read_u32_le(&rec.data, 4); - let para_shape_id = read_u16_le(&rec.data, 8); - let style_id = rec.data[10]; - details = format!("char_count={} msb={} control_mask=0x{:08X} para_shape_id={} style_id={}", - char_count, msb, control_mask, para_shape_id, style_id); - } else { - details = format!("(short data: {} bytes) hex={}", rec.data.len(), hex_preview(&rec.data, 32)); - } - } - // CTRL_HEADER details - else if rec.tag_id == tags::HWPTAG_CTRL_HEADER { - details = format!("ctrl={} hex={}", ctrl_id_string(&rec.data), hex_preview(&rec.data, 40)); - } - // TABLE details - else if rec.tag_id == tags::HWPTAG_TABLE { - if rec.data.len() >= 8 { - let attr = read_u32_le(&rec.data, 0); - let rows = read_u16_le(&rec.data, 4); - let cols = read_u16_le(&rec.data, 6); - details = format!("attr=0x{:08X} rows={} cols={} hex={}", - attr, rows, cols, hex_preview(&rec.data, 40)); - } else { - details = format!("hex={}", hex_preview(&rec.data, 40)); - } + // PARA_CHAR_SHAPE check for all paragraphs + for ri3 in i + 1..saved_recs.len() { + if saved_recs[ri3].level <= ph_level { + break; } - // LIST_HEADER details - else if rec.tag_id == tags::HWPTAG_LIST_HEADER { - if rec.data.len() >= 6 { - let para_count = read_u16_le(&rec.data, 0); - let attr = read_u32_le(&rec.data, 2); - details = format!("paraCount={} attr=0x{:08X} hex={}", - para_count, attr, hex_preview(&rec.data, 40)); - } else { - details = format!("hex={}", hex_preview(&rec.data, 40)); - } - } - // PARA_TEXT: show char codes - else if rec.tag_id == tags::HWPTAG_PARA_TEXT { - // UTF-16LE text - let mut chars_preview = String::new(); - let max_chars = 30; - let mut n = 0; - let mut pos = 0; - while pos + 1 < rec.data.len() && n < max_chars { - let code = u16::from_le_bytes([rec.data[pos], rec.data[pos+1]]); - if code == 0x000D { chars_preview.push_str("\\r"); } - else if code == 0x000A { chars_preview.push_str("\\n"); } - else if code == 0x000B { chars_preview.push_str("{CTRL}"); } - else if code == 0x0002 { chars_preview.push_str("{SECD}"); } - else if code == 0x0003 { chars_preview.push_str("{FLD_BEGIN}"); } - else if code == 0x0004 { chars_preview.push_str("{FLD_END}"); } - else if code == 0x0008 { chars_preview.push_str("{INLINE}"); } - else if code < 0x0020 { - chars_preview.push_str(&format!("{{0x{:04X}}}", code)); - } - else if let Some(ch) = char::from_u32(code as u32) { - chars_preview.push(ch); - } else { - chars_preview.push_str(&format!("{{0x{:04X}}}", code)); - } - pos += 2; - // Extended control chars take 16 bytes total (skip the inline data) - if code == 0x000B || code == 0x0002 || code == 0x0003 || code == 0x0008 { - pos += 14; // skip 14 more bytes (total 16 for extended char) + if saved_recs[ri3].tag_id == tags::HWPTAG_PARA_CHAR_SHAPE + && saved_recs[ri3].level == ph_level + 1 + { + let pcs = &saved_recs[ri3]; + let num_entries = pcs.data.len() / 8; + for ei in 0..num_entries { + let off = ei * 8; + if off + 8 <= pcs.data.len() { + let cs_id = u32::from_le_bytes([ + pcs.data[off + 4], + pcs.data[off + 5], + pcs.data[off + 6], + pcs.data[off + 7], + ]); + if cs_id > max_cs_id { + max_cs_id = cs_id; + } + if (cs_id as usize) >= saved_cs { + eprintln!(" *** para[{}] rec[{}] PARA_CHAR_SHAPE entry[{}]: char_shape_id={} >= {} (INVALID) ***", + total_paras, i, ei, cs_id, saved_cs); + invalid_cs_refs += 1; + } } - n += 1; - } - details = format!("text=\"{}\" hex={}", chars_preview, hex_preview(&rec.data, 32)); - } - // PARA_CHAR_SHAPE - else if rec.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { - // pairs of (u32 pos, u32 charShapeId) - let n_pairs = rec.data.len() / 8; - let mut pairs_str = String::new(); - for p in 0..std::cmp::min(n_pairs, 8) { - let pos_val = read_u32_le(&rec.data, p * 8); - let cs_id = read_u32_le(&rec.data, p * 8 + 4); - if !pairs_str.is_empty() { pairs_str.push_str(", "); } - pairs_str.push_str(&format!("pos{}=>CS{}", pos_val, cs_id)); - } - if n_pairs > 8 { pairs_str.push_str(&format!(" ...({} more)", n_pairs - 8)); } - details = format!("[{}]", pairs_str); - } - // SHAPE_COMPONENT - else if rec.tag_id == tags::HWPTAG_SHAPE_COMPONENT { - details = format!("hex={}", hex_preview(&rec.data, 40)); - } - // SHAPE_COMPONENT_PICTURE - else if rec.tag_id == tags::HWPTAG_SHAPE_COMPONENT_PICTURE { - details = format!("hex={}", hex_preview(&rec.data, 40)); - } - // PAGE_DEF - else if rec.tag_id == tags::HWPTAG_PAGE_DEF { - if rec.data.len() >= 40 { - let w = read_u32_le(&rec.data, 0); - let h = read_u32_le(&rec.data, 4); - details = format!("width={} height={} (hwpunit)", w, h); } + break; } - // FOOTNOTE_SHAPE - else if rec.tag_id == tags::HWPTAG_FOOTNOTE_SHAPE { - details = format!("hex={}", hex_preview(&rec.data, 32)); - } - // PAGE_BORDER_FILL - else if rec.tag_id == tags::HWPTAG_PAGE_BORDER_FILL { - details = format!("hex={}", hex_preview(&rec.data, 32)); - } - // Default: show hex for smaller records - else if rec.data.len() <= 64 { - details = format!("hex={}", hex_preview(&rec.data, 48)); - } else { - details = format!("hex={}", hex_preview(&rec.data, 32)); - } - - eprintln!("{:<5} {:<5} {:<25} {:>8} {}", - i, rec.level, tag_str, rec.size, details); } - } - - // ============================================================ - // 3. Side-by-side comparison: HWP_PASTE (VALID) vs VIEWER_PASTE (DAMAGED) - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!(" PART 3: Side-by-Side Comparison - HWP_PASTE vs VIEWER_PASTE"); - eprintln!("{}", "=".repeat(120)); - - let valid = &all_files[1]; // HWP_PASTE - let damaged = &all_files[2]; // VIEWER_PASTE - - eprintln!("\n VALID (HWP_PASTE): {} records, {} data bytes", - valid.body_records.len(), valid.body_raw_len); - eprintln!(" DAMAGED (VIEWER_PASTE): {} records, {} data bytes", - damaged.body_records.len(), damaged.body_raw_len); - - let max_recs = std::cmp::max(valid.body_records.len(), damaged.body_records.len()); - let mut total_diffs = 0; - - eprintln!("\n{:<5} | {:<30} {:>4} {:>6} | {:<30} {:>4} {:>6} | {}", - "#", "VALID Tag", "Lvl", "Size", "DAMAGED Tag", "Lvl", "Size", "Status"); - eprintln!("{:-<130}", ""); - for i in 0..max_recs { - let have_valid = i < valid.body_records.len(); - let have_damaged = i < damaged.body_records.len(); + total_paras += 1; + } + i += 1; + } - let (v_tag_str, v_lvl, v_size) = if have_valid { - let r = &valid.body_records[i]; - (format!("{}", tags::tag_name(r.tag_id)), r.level, r.size) + // ============================================================ + // 7. 원본 파일도 동일 검사 (비교용) + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== 5. 전체 문단 일관성 검사 (원본 파일) ==="); + eprintln!("{}", "=".repeat(120)); + + let mut orig_total_paras = 0u32; + let mut orig_char_count_mismatches = 0u32; + let mut orig_invalid_cs_refs = 0u32; + let mut orig_invalid_ps_refs = 0u32; + + i = 0; + while i < orig_recs.len() { + if orig_recs[i].tag_id == tags::HWPTAG_PARA_HEADER { + let ph = &orig_recs[i]; + let ph_level = ph.level; + let raw_char_count = if ph.data.len() >= 4 { + u32::from_le_bytes([ph.data[0], ph.data[1], ph.data[2], ph.data[3]]) } else { - ("---".to_string(), 0u16, 0u32) + 0 }; - - let (d_tag_str, d_lvl, d_size) = if have_damaged { - let r = &damaged.body_records[i]; - (format!("{}", tags::tag_name(r.tag_id)), r.level, r.size) + let char_count = raw_char_count & 0x7FFFFFFF; + let para_shape_id = if ph.data.len() >= 10 { + u16::from_le_bytes([ph.data[8], ph.data[9]]) } else { - ("---".to_string(), 0u16, 0u32) + 0 }; - let status = if !have_valid { - total_diffs += 1; - "EXTRA_IN_DAMAGED" - } else if !have_damaged { - total_diffs += 1; - "MISSING_IN_DAMAGED" - } else { - let rv = &valid.body_records[i]; - let rd = &damaged.body_records[i]; - if rv.tag_id != rd.tag_id { - total_diffs += 1; - "TAG_DIFF" - } else if rv.level != rd.level { - total_diffs += 1; - "LEVEL_DIFF" - } else if rv.data != rd.data { - total_diffs += 1; - "DATA_DIFF" - } else { - "OK" - } - }; + if (para_shape_id as usize) >= orig_ps { + eprintln!( + " *** orig para[{}] rec[{}]: para_shape_id={} >= {} (INVALID) ***", + orig_total_paras, i, para_shape_id, orig_ps + ); + orig_invalid_ps_refs += 1; + } - // For diffs, always print. For OK, also print (full dump requested). - let marker = if status != "OK" { ">>>" } else { " " }; - eprintln!("{} {:<5} | {:<30} {:>4} {:>6} | {:<30} {:>4} {:>6} | {}", - marker, i, v_tag_str, v_lvl, v_size, d_tag_str, d_lvl, d_size, status); - - // For diffs, show detailed comparison - if status != "OK" && have_valid && have_damaged { - let rv = &valid.body_records[i]; - let rd = &damaged.body_records[i]; - - // PARA_HEADER diff details - if rv.tag_id == tags::HWPTAG_PARA_HEADER || rd.tag_id == tags::HWPTAG_PARA_HEADER { - if rv.tag_id == tags::HWPTAG_PARA_HEADER && rd.tag_id == tags::HWPTAG_PARA_HEADER { - let v_char = read_u32_le(&rv.data, 0); - let d_char = read_u32_le(&rd.data, 0); - let v_mask = read_u32_le(&rv.data, 4); - let d_mask = read_u32_le(&rd.data, 4); - let v_ps = read_u16_le(&rv.data, 8); - let d_ps = read_u16_le(&rd.data, 8); - let v_st = if rv.data.len() > 10 { rv.data[10] } else { 0 }; - let d_st = if rd.data.len() > 10 { rd.data[10] } else { 0 }; - eprintln!(" PARA_HEADER diff: char_count {}={} vs {}={}, mask 0x{:08X} vs 0x{:08X}, ps {} vs {}, style {} vs {}", - if v_char != d_char { "DIFF" } else { "same" }, v_char & 0x7FFFFFFF, - if v_char != d_char { "DIFF" } else { "same" }, d_char & 0x7FFFFFFF, - v_mask, d_mask, v_ps, d_ps, v_st, d_st); - } - } + let has_text = i + 1 < orig_recs.len() + && orig_recs[i + 1].tag_id == tags::HWPTAG_PARA_TEXT + && orig_recs[i + 1].level == ph_level + 1; - // CTRL_HEADER diff details - if rv.tag_id == tags::HWPTAG_CTRL_HEADER && rd.tag_id == tags::HWPTAG_CTRL_HEADER { - eprintln!(" VALID ctrl={} DAMAGED ctrl={}", - ctrl_id_string(&rv.data), ctrl_id_string(&rd.data)); + if has_text { + let pt = &orig_recs[i + 1]; + let pt_u16_count = pt.data.len() / 2; + if pt_u16_count != char_count as usize { + eprintln!( + " MISMATCH orig para[{}] rec[{}]: char_count={} but PARA_TEXT has {} u16", + orig_total_paras, i, char_count, pt_u16_count + ); + orig_char_count_mismatches += 1; } + } - // Show hex of both - eprintln!(" VALID hex: {}", hex_preview(&rv.data, 48)); - eprintln!(" DAMAGED hex: {}", hex_preview(&rd.data, 48)); - - // Show first byte diff position - let min_len = std::cmp::min(rv.data.len(), rd.data.len()); - if let Some(pos) = (0..min_len).find(|&j| rv.data[j] != rd.data[j]) { - eprintln!(" First byte diff at offset {}: VALID=0x{:02x} DAMAGED=0x{:02x}", - pos, rv.data[pos], rd.data[pos]); + // PARA_CHAR_SHAPE + for ri3 in i + 1..orig_recs.len() { + if orig_recs[ri3].level <= ph_level { + break; } - if rv.data.len() != rd.data.len() { - eprintln!(" Size diff: VALID={} DAMAGED={}", rv.data.len(), rd.data.len()); + if orig_recs[ri3].tag_id == tags::HWPTAG_PARA_CHAR_SHAPE + && orig_recs[ri3].level == ph_level + 1 + { + let pcs = &orig_recs[ri3]; + let num_entries = pcs.data.len() / 8; + for ei in 0..num_entries { + let off = ei * 8; + if off + 8 <= pcs.data.len() { + let cs_id = u32::from_le_bytes([ + pcs.data[off + 4], + pcs.data[off + 5], + pcs.data[off + 6], + pcs.data[off + 7], + ]); + if (cs_id as usize) >= orig_cs { + eprintln!(" *** orig para[{}] rec[{}] PARA_CHAR_SHAPE entry[{}]: char_shape_id={} >= {} (INVALID) ***", + orig_total_paras, i, ei, cs_id, orig_cs); + orig_invalid_cs_refs += 1; + } + } + } + break; } } + + orig_total_paras += 1; } + i += 1; + } - // ============================================================ - // 4. DocInfo comparison: VALID vs DAMAGED - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!(" PART 4: DocInfo Comparison - HWP_PASTE vs VIEWER_PASTE"); - eprintln!("{}", "=".repeat(120)); + // ============================================================ + // 8. 레코드 타입별 카운트 비교 + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== 6. 레코드 타입별 카운트 비교 ==="); + eprintln!("{}", "=".repeat(120)); + let count_tag = |recs: &[Record], tag: u16| recs.iter().filter(|r| r.tag_id == tag).count(); + let tags_to_check: [(u16, &str); 11] = [ + (tags::HWPTAG_PARA_HEADER, "PARA_HEADER"), + (tags::HWPTAG_PARA_TEXT, "PARA_TEXT"), + (tags::HWPTAG_PARA_CHAR_SHAPE, "PARA_CHAR_SHAPE"), + (tags::HWPTAG_PARA_LINE_SEG, "PARA_LINE_SEG"), + (tags::HWPTAG_PARA_RANGE_TAG, "PARA_RANGE_TAG"), + (tags::HWPTAG_CTRL_HEADER, "CTRL_HEADER"), + (tags::HWPTAG_LIST_HEADER, "LIST_HEADER"), + (tags::HWPTAG_TABLE, "TABLE"), + (tags::HWPTAG_CTRL_DATA, "CTRL_DATA"), + (tags::HWPTAG_PAGE_DEF, "PAGE_DEF"), + (tags::HWPTAG_SHAPE_COMPONENT, "SHAPE_COMPONENT"), + ]; + for (tag, name) in &tags_to_check { + let orig_cnt = count_tag(&orig_recs, *tag); + let saved_cnt = count_tag(&saved_recs, *tag); + let diff = saved_cnt as i64 - orig_cnt as i64; + eprintln!( + " {:25} orig={:4} saved={:4} diff={}{:+}{}", + name, + orig_cnt, + saved_cnt, + if diff != 0 { "<<< " } else { "" }, + diff, + if diff != 0 { " >>>" } else { "" } + ); + } - let v_doc = &all_files[1].doc_info_records; - let d_doc = &all_files[2].doc_info_records; - eprintln!("\n VALID DocInfo records: {}", v_doc.len()); - eprintln!(" DAMAGED DocInfo records: {}", d_doc.len()); - - let max_doc = std::cmp::max(v_doc.len(), d_doc.len()); - let mut doc_diffs = 0; - for i in 0..max_doc { - let have_v = i < v_doc.len(); - let have_d = i < d_doc.len(); - let status = if !have_v { "EXTRA_IN_DAMAGED" } - else if !have_d { "MISSING_IN_DAMAGED" } - else if v_doc[i].tag_id != d_doc[i].tag_id { "TAG_DIFF" } - else if v_doc[i].level != d_doc[i].level { "LEVEL_DIFF" } - else if v_doc[i].data != d_doc[i].data { "DATA_DIFF" } - else { "OK" }; - - if status != "OK" { - doc_diffs += 1; - let v_str = if have_v { - format!("{} lvl={} sz={}", tags::tag_name(v_doc[i].tag_id), v_doc[i].level, v_doc[i].size) - } else { "---".to_string() }; - let d_str = if have_d { - format!("{} lvl={} sz={}", tags::tag_name(d_doc[i].tag_id), d_doc[i].level, d_doc[i].size) - } else { "---".to_string() }; - eprintln!(" [{}] {} | VALID: {} | DAMAGED: {} |", - i, status, v_str, d_str); - } - } - if doc_diffs == 0 { - eprintln!(" DocInfo records are IDENTICAL between VALID and DAMAGED"); - } else { - eprintln!(" Total DocInfo differences: {}", doc_diffs); - } + // ============================================================ + // 9. 요약 + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!("=== SUMMARY ==="); + eprintln!("{}", "=".repeat(120)); + eprintln!(" 원본: tables={}, paragraphs={}, char_count_mismatches={}, invalid_cs_refs={}, invalid_ps_refs={}", + orig_tables.len(), orig_total_paras, orig_char_count_mismatches, orig_invalid_cs_refs, orig_invalid_ps_refs); + eprintln!(" 저장: tables={}, paragraphs={}, char_count_mismatches={}, invalid_cs_refs={}, invalid_ps_refs={}, missing_para_text={}", + saved_tables.len(), total_paras, char_count_mismatches, invalid_cs_refs, invalid_ps_refs, missing_para_text); + eprintln!(" 붙여넣기 표 issues: {}", all_pasted_issues.len()); + for (idx, issue) in all_pasted_issues.iter().enumerate() { + eprintln!(" [{}] {}", idx, issue); + } + eprintln!( + " max CharShape ID referenced: {} (available: 0..{})", + max_cs_id, saved_cs + ); + eprintln!( + " max ParaShape ID referenced: {} (available: 0..{})", + max_ps_id, saved_ps + ); + + // Assertions + // We do NOT assert zero mismatches here because the purpose is analysis/reporting. + // But we flag truly fatal issues. + if invalid_cs_refs > 0 { + eprintln!( + "\n *** FATAL: {} invalid CharShape ID references in saved file ***", + invalid_cs_refs + ); + } + if invalid_ps_refs > 0 { + eprintln!( + "\n *** FATAL: {} invalid ParaShape ID references in saved file ***", + invalid_ps_refs + ); + } + if char_count_mismatches > 0 { + eprintln!( + "\n *** WARNING: {} char_count vs PARA_TEXT mismatches in saved file ***", + char_count_mismatches + ); + } - // ============================================================ - // 5. Summary - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!(" SUMMARY"); - eprintln!("{}", "=".repeat(120)); + eprintln!("\n=== test_rp005_pasted_table_analysis complete ==="); +} + +/// 3개 HWP 파일 비교: empty-step2 원본, HWP 프로그램 붙여넣기, 뷰어 붙여넣기(손상) +/// +/// DocInfo ID_MAPPINGS, BodyText 레코드 전체 덤프, 레코드별 차이 비교 +#[test] +fn test_step2_comparison() { + use crate::parser::cfb_reader::CfbReader; + use crate::parser::record::Record; + use crate::parser::tags; + + // ============================================================ + // 파일 로드 + // ============================================================ + let files: Vec<(&str, &str)> = vec![ + ("template/empty-step2.hwp", "ORIGINAL"), + ("template/empty-step2-p.hwp", "HWP_PASTE (VALID)"), + ( + "template/empty-step2_saved_err.hwp", + "VIEWER_PASTE (DAMAGED)", + ), + ]; + + struct FileData { + label: String, + path: String, + doc_info_records: Vec, + body_records: Vec, + body_raw_len: usize, + } - for fd in &all_files { - let total_data: usize = fd.body_records.iter().map(|r| r.data.len()).sum(); - eprintln!(" {:<25} DocInfo recs={:<5} BodyText recs={:<5} body_bytes={:<8} data_bytes={}", - fd.label, fd.doc_info_records.len(), fd.body_records.len(), fd.body_raw_len, total_data); - } + let mut all_files: Vec = Vec::new(); - eprintln!("\n BodyText record-by-record diffs (VALID vs DAMAGED): {}", total_diffs); - eprintln!(" DocInfo record-by-record diffs (VALID vs DAMAGED): {}", doc_diffs); + for (path, label) in &files { + let bytes = + std::fs::read(path).unwrap_or_else(|e| panic!("파일 읽기 실패: {} - {}", path, e)); + eprintln!( + "\n=== Loading {} ({}) - {} bytes ===", + label, + path, + bytes.len() + ); - eprintln!("\n=== test_step2_comparison complete ==="); + let mut cfb = + CfbReader::open(&bytes).unwrap_or_else(|e| panic!("CFB 열기 실패: {} - {}", path, e)); + + // DocInfo (compressed=true) + let doc_info_data = cfb + .read_doc_info(true) + .unwrap_or_else(|e| panic!("DocInfo 읽기 실패: {} - {}", path, e)); + let doc_info_records = Record::read_all(&doc_info_data) + .unwrap_or_else(|e| panic!("DocInfo 레코드 파싱 실패: {} - {}", path, e)); + + // BodyText Section 0 (compressed=true, distribution=false) + let body_data = cfb + .read_body_text_section(0, true, false) + .unwrap_or_else(|e| panic!("BodyText 읽기 실패: {} - {}", path, e)); + let body_raw_len = body_data.len(); + let body_records = Record::read_all(&body_data) + .unwrap_or_else(|e| panic!("BodyText 레코드 파싱 실패: {} - {}", path, e)); + + all_files.push(FileData { + label: label.to_string(), + path: path.to_string(), + doc_info_records, + body_records, + body_raw_len, + }); } - #[test] - fn test_step2_paste_area() { - use crate::parser::cfb_reader::CfbReader; - use crate::parser::record::Record; - use crate::parser::tags; - - // ============================================================ - // Helper functions - // ============================================================ - fn read_u32_le(data: &[u8], offset: usize) -> u32 { - if offset + 4 <= data.len() { - u32::from_le_bytes([data[offset], data[offset+1], data[offset+2], data[offset+3]]) - } else { 0 } + // ============================================================ + // Helper functions + // ============================================================ + + fn read_u32_le(data: &[u8], offset: usize) -> u32 { + if offset + 4 <= data.len() { + u32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]) + } else { + 0 } - fn read_u16_le(data: &[u8], offset: usize) -> u16 { - if offset + 2 <= data.len() { - u16::from_le_bytes([data[offset], data[offset+1]]) - } else { 0 } + } + + fn read_u16_le(data: &[u8], offset: usize) -> u16 { + if offset + 2 <= data.len() { + u16::from_le_bytes([data[offset], data[offset + 1]]) + } else { + 0 } - fn hex_dump(data: &[u8], max: usize) -> String { - let show = std::cmp::min(data.len(), max); - let hex: Vec = data[..show].iter().map(|b| format!("{:02x}", b)).collect(); - let mut s = hex.join(" "); - if data.len() > max { s.push_str(&format!(" ...({} more)", data.len() - max)); } - s + } + + fn ctrl_id_string(data: &[u8]) -> String { + if data.len() >= 4 { + // ctrl_id stored as LE u32, but represents big-endian character ordering + let id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); + let be_bytes = id.to_be_bytes(); + let ascii: String = be_bytes + .iter() + .map(|&b| { + if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + } + }) + .collect(); + format!("\"{}\" (0x{:08X})", ascii, id) + } else { + format!("(data too short: {} bytes)", data.len()) } - fn hex_full(data: &[u8]) -> String { - data.iter().map(|b| format!("{:02x}", b)).collect::>().join(" ") + } + + fn hex_preview(data: &[u8], max: usize) -> String { + let show = std::cmp::min(data.len(), max); + let hex: Vec = data[..show].iter().map(|b| format!("{:02x}", b)).collect(); + let mut s = hex.join(" "); + if data.len() > max { + s.push_str(&format!(" ...({} more)", data.len() - max)); } - fn utf16le_decode(data: &[u8]) -> String { - let mut result = String::new(); - let mut pos = 0; - while pos + 1 < data.len() { - let code = u16::from_le_bytes([data[pos], data[pos+1]]); - if code == 0x000D { result.push_str("\\r"); } - else if code == 0x000A { result.push_str("\\n"); } - else if code == 0x000B { result.push_str("{CTRL}"); pos += 14; } - else if code == 0x0002 { result.push_str("{SECD}"); pos += 14; } - else if code == 0x0003 { result.push_str("{FLD_BEGIN}"); pos += 14; } - else if code == 0x0004 { result.push_str("{FLD_END}"); pos += 14; } - else if code == 0x0008 { result.push_str("{INLINE}"); pos += 14; } - else if code < 0x0020 { result.push_str(&format!("{{0x{:04X}}}", code)); } - else if let Some(ch) = char::from_u32(code as u32) { result.push(ch); } - else { result.push_str(&format!("{{0x{:04X}}}", code)); } - pos += 2; + s + } + + // ============================================================ + // 1. DocInfo ID_MAPPINGS summary for each file + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!(" PART 1: DocInfo ID_MAPPINGS Summary"); + eprintln!("{}", "=".repeat(120)); + + let id_map_field_names = [ + "bin_data_count", // 0 + "font_han", // 1 + "font_eng", // 2 + "font_hanja", // 3 + "font_jpn", // 4 + "font_other", // 5 + "font_symbol", // 6 + "font_user", // 7 + "border_fill_count", // 8 + "char_shape_count", // 9 + "tab_def_count", // 10 + "numbering_count", // 11 + "bullet_count", // 12 + "para_shape_count", // 13 + "style_count", // 14 + "memo_shape_count", // 15 + ]; + + for fd in &all_files { + eprintln!("\n--- {} ({}) ---", fd.label, fd.path); + eprintln!(" DocInfo records: {}", fd.doc_info_records.len()); + + if let Some(id_rec) = fd + .doc_info_records + .iter() + .find(|r| r.tag_id == tags::HWPTAG_ID_MAPPINGS) + { + eprintln!(" ID_MAPPINGS record size: {} bytes", id_rec.data.len()); + let num_values = id_rec.data.len() / 4; + for i in 0..std::cmp::min(num_values, id_map_field_names.len()) { + let val = read_u32_le(&id_rec.data, i * 4); + eprintln!(" [{:>2}] {:<25} = {}", i, id_map_field_names[i], val); } - result + + // Highlight key counts + let cs = if num_values > 9 { + read_u32_le(&id_rec.data, 9 * 4) + } else { + 0 + }; + let ps = if num_values > 13 { + read_u32_le(&id_rec.data, 13 * 4) + } else { + 0 + }; + let bf = if num_values > 8 { + read_u32_le(&id_rec.data, 8 * 4) + } else { + 0 + }; + eprintln!( + " >>> CharShape(CS)={}, ParaShape(PS)={}, BorderFill(BF)={}", + cs, ps, bf + ); + } else { + eprintln!(" WARNING: No ID_MAPPINGS record found!"); } + } - // ============================================================ - // Print detailed record info - // ============================================================ - fn print_record_detail(label: &str, idx: usize, rec: &Record) { - let tag_name = tags::tag_name(rec.tag_id); - eprintln!(" [{:>3}] lvl={} tag={:<20} size={:<6} tag_id={}", idx, rec.level, tag_name, rec.size, rec.tag_id); + // ============================================================ + // 2. BodyText record full dump for each file + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!(" PART 2: BodyText Section0 - Full Record Dump (ALL files)"); + eprintln!("{}", "=".repeat(120)); + + for fd in &all_files { + eprintln!("\n{}", "#".repeat(100)); + eprintln!("### {} ({}) ###", fd.label, fd.path); + eprintln!( + "### BodyText decompressed: {} bytes, records: {} ###", + fd.body_raw_len, + fd.body_records.len() + ); + eprintln!("{}", "#".repeat(100)); + + // Total bytes in records + let total_data_bytes: usize = fd.body_records.iter().map(|r| r.data.len()).sum(); + eprintln!(" Total record data bytes: {}", total_data_bytes); + + eprintln!( + "\n{:<5} {:<5} {:<25} {:>8} Details", + "Idx", "Lvl", "Tag", "Size" + ); + eprintln!("{:-<120}", ""); + + for (i, rec) in fd.body_records.iter().enumerate() { + let indent = " ".repeat(std::cmp::min(rec.level as usize, 8)); + let tag_str = format!("{}{}", indent, tags::tag_name(rec.tag_id)); + + let mut details = String::new(); + // PARA_HEADER details if rec.tag_id == tags::HWPTAG_PARA_HEADER { if rec.data.len() >= 22 { - let raw_nchars = read_u32_le(&rec.data, 0); - let msb = (raw_nchars >> 31) & 1; - let char_count = raw_nchars & 0x7FFFFFFF; + // PARA_HEADER layout: + // u32 nChars (bit31=MSB) + // u32 controlMask + // u16 paraShapeId + // u8 styleId (or u16) + // u8 breakType + // u16 charShapeCount (?) + // ... + let raw_char_count = read_u32_le(&rec.data, 0); + let msb = (raw_char_count >> 31) & 1; + let char_count = raw_char_count & 0x7FFFFFFF; let control_mask = read_u32_le(&rec.data, 4); let para_shape_id = read_u16_le(&rec.data, 8); let style_id = rec.data[10]; - let break_type = rec.data[11]; - let num_char_shapes = read_u16_le(&rec.data, 12); - let num_range_tags = read_u16_le(&rec.data, 14); - let num_line_segs = read_u16_le(&rec.data, 16); - let para_inst_id = read_u32_le(&rec.data, 18); - eprintln!(" PARA_HEADER: char_count={} msb={} control_mask=0x{:08X}", char_count, msb, control_mask); - eprintln!(" para_shape_id={} style_id={} break_type={}", para_shape_id, style_id, break_type); - eprintln!(" numCharShapes={} numRangeTags={} numLineSegs={} paraInstId={}", - num_char_shapes, num_range_tags, num_line_segs, para_inst_id); - eprintln!(" full hex: {}", hex_full(&rec.data)); + details = format!( + "char_count={} msb={} control_mask=0x{:08X} para_shape_id={} style_id={}", + char_count, msb, control_mask, para_shape_id, style_id + ); } else { - eprintln!(" PARA_HEADER (short {}b): {}", rec.data.len(), hex_full(&rec.data)); - } - } - else if rec.tag_id == tags::HWPTAG_PARA_TEXT { - let show_bytes = std::cmp::min(rec.data.len(), 100); - eprintln!(" PARA_TEXT hex(first {}): {}", show_bytes, hex_dump(&rec.data, 100)); - eprintln!(" PARA_TEXT decoded: \"{}\"", utf16le_decode(&rec.data)); - } - else if rec.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { - let n_pairs = rec.data.len() / 8; - for p in 0..n_pairs { - let pos_val = read_u32_le(&rec.data, p * 8); - let cs_id = read_u32_le(&rec.data, p * 8 + 4); - eprintln!(" PARA_CHAR_SHAPE[{}]: pos={} => CS_id={}", p, pos_val, cs_id); - } - eprintln!(" full hex: {}", hex_full(&rec.data)); - } - else if rec.tag_id == tags::HWPTAG_PARA_LINE_SEG { - eprintln!(" PARA_LINE_SEG hex: {}", hex_full(&rec.data)); - // Decode line seg entries (each 36 bytes) - let entry_size = 36; - let n_entries = rec.data.len() / entry_size; - for e in 0..n_entries { - let off = e * entry_size; - let text_start = read_u32_le(&rec.data, off); - let y_pos = read_u32_le(&rec.data, off + 4) as i32; - let height = read_u32_le(&rec.data, off + 8); - let text_height = read_u32_le(&rec.data, off + 12); - let baseline = read_u32_le(&rec.data, off + 16); - let spacing = read_u32_le(&rec.data, off + 20); - let x_pos = read_u32_le(&rec.data, off + 24) as i32; - let seg_width = read_u32_le(&rec.data, off + 28); - let tag_flags = read_u32_le(&rec.data, off + 32); - eprintln!(" seg[{}]: textStart={} yPos={} h={} textH={} baseline={} spacing={} xPos={} segW={} flags=0x{:08X}", - e, text_start, y_pos, height, text_height, baseline, spacing, x_pos, seg_width, tag_flags); + details = format!( + "(short data: {} bytes) hex={}", + rec.data.len(), + hex_preview(&rec.data, 32) + ); } } + // CTRL_HEADER details else if rec.tag_id == tags::HWPTAG_CTRL_HEADER { - if rec.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - let be = ctrl_id.to_be_bytes(); - let ascii: String = be.iter().map(|&b| if b.is_ascii_graphic() || b == b' ' { b as char } else { '.' }).collect(); - eprintln!(" CTRL_HEADER: ctrl=\"{}\" (0x{:08X})", ascii, ctrl_id); - } - eprintln!(" full hex: {}", hex_dump(&rec.data, 80)); + details = format!( + "ctrl={} hex={}", + ctrl_id_string(&rec.data), + hex_preview(&rec.data, 40) + ); } + // TABLE details else if rec.tag_id == tags::HWPTAG_TABLE { if rec.data.len() >= 8 { let attr = read_u32_le(&rec.data, 0); let rows = read_u16_le(&rec.data, 4); let cols = read_u16_le(&rec.data, 6); - eprintln!(" TABLE: attr=0x{:08X} rows={} cols={}", attr, rows, cols); - if rec.data.len() >= 10 { - let cell_spacing = read_u16_le(&rec.data, 8); - eprintln!(" cellSpacing={}", cell_spacing); - } - // border fill id per row: after cell_spacing(2) + padding(2*rows for row sizes) - let row_sizes_start = 10; - for r in 0..rows as usize { - if row_sizes_start + (r + 1) * 2 <= rec.data.len() { - let rs = read_u16_le(&rec.data, row_sizes_start + r * 2); - eprintln!(" rowSize[{}]={}", r, rs); - } - } + details = format!( + "attr=0x{:08X} rows={} cols={} hex={}", + attr, + rows, + cols, + hex_preview(&rec.data, 40) + ); + } else { + details = format!("hex={}", hex_preview(&rec.data, 40)); } - eprintln!(" full hex(40): {}", hex_dump(&rec.data, 40)); - eprintln!(" full hex(all): {}", hex_full(&rec.data)); } + // LIST_HEADER details else if rec.tag_id == tags::HWPTAG_LIST_HEADER { if rec.data.len() >= 6 { let para_count = read_u16_le(&rec.data, 0); let attr = read_u32_le(&rec.data, 2); - eprintln!(" LIST_HEADER: paraCount={} attr=0x{:08X}", para_count, attr); - if rec.data.len() >= 47 { - // Cell-specific fields (for table cells) - let col_addr = read_u16_le(&rec.data, 8); - let row_addr = read_u16_le(&rec.data, 10); - let col_span = read_u16_le(&rec.data, 12); - let row_span = read_u16_le(&rec.data, 14); - let cell_w = read_u32_le(&rec.data, 16); - let cell_h = read_u32_le(&rec.data, 20); - let padding_l = read_u16_le(&rec.data, 24); - let padding_r = read_u16_le(&rec.data, 26); - let padding_t = read_u16_le(&rec.data, 28); - let padding_b = read_u16_le(&rec.data, 30); - let border_fill_id = read_u16_le(&rec.data, 32); - let cell_w2 = read_u32_le(&rec.data, 34); - eprintln!(" col={} row={} colSpan={} rowSpan={}", col_addr, row_addr, col_span, row_span); - eprintln!(" cellW={} cellH={} pad=({},{},{},{}) borderFillId={} cellW2={}", - cell_w, cell_h, padding_l, padding_r, padding_t, padding_b, border_fill_id, cell_w2); - } - } - eprintln!(" full hex: {}", hex_full(&rec.data)); - } - else { - eprintln!(" hex: {}", hex_dump(&rec.data, 60)); - } - } - - // ============================================================ - // Load files - // ============================================================ - let files: Vec<(&str, &str)> = vec![ - ("template/empty-step2-p.hwp", "VALID"), - ("template/empty-step2_saved_err.hwp", "DAMAGED"), - ]; - - struct FileData { - label: String, - body_records: Vec, - body_raw_len: usize, - } - - let mut all_files: Vec = Vec::new(); - - for (path, label) in &files { - let bytes = std::fs::read(path) - .unwrap_or_else(|e| panic!("File read failed: {} - {}", path, e)); - let mut cfb = CfbReader::open(&bytes) - .unwrap_or_else(|e| panic!("CFB open failed: {} - {}", path, e)); - let body_data = cfb.read_body_text_section(0, true, false) - .unwrap_or_else(|e| panic!("BodyText read failed: {} - {}", path, e)); - let body_raw_len = body_data.len(); - let body_records = Record::read_all(&body_data) - .unwrap_or_else(|e| panic!("Record parse failed: {} - {}", path, e)); - let rec_count = body_records.len(); - all_files.push(FileData { label: label.to_string(), body_records, body_raw_len }); - eprintln!("[{}] {} loaded: {} bytes decompressed, {} records", label, path, body_raw_len, rec_count); - } - - let valid_recs = &all_files[0].body_records; - let damaged_recs = &all_files[1].body_records; - - // ============================================================ - // Find the pasted table area: PARA_HEADER with control_mask containing 0x800 - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!(" Finding pasted table PARA_HEADER (control_mask has bit 0x800 = table control)"); - eprintln!("{}", "=".repeat(120)); - - let mut valid_table_start: Option = None; - let mut damaged_table_start: Option = None; - - for (i, rec) in valid_recs.iter().enumerate() { - if rec.tag_id == tags::HWPTAG_PARA_HEADER && rec.data.len() >= 8 { - let mask = read_u32_le(&rec.data, 4); - if mask & 0x800 != 0 { - eprintln!(" VALID: pasted table PARA_HEADER found at record {} (control_mask=0x{:08X})", i, mask); - if valid_table_start.is_none() { - valid_table_start = Some(i); - } + details = format!( + "paraCount={} attr=0x{:08X} hex={}", + para_count, + attr, + hex_preview(&rec.data, 40) + ); + } else { + details = format!("hex={}", hex_preview(&rec.data, 40)); } } - } - for (i, rec) in damaged_recs.iter().enumerate() { - if rec.tag_id == tags::HWPTAG_PARA_HEADER && rec.data.len() >= 8 { - let mask = read_u32_le(&rec.data, 4); - if mask & 0x800 != 0 { - eprintln!(" DAMAGED: pasted table PARA_HEADER found at record {} (control_mask=0x{:08X})", i, mask); - if damaged_table_start.is_none() { - damaged_table_start = Some(i); + // PARA_TEXT: show char codes + else if rec.tag_id == tags::HWPTAG_PARA_TEXT { + // UTF-16LE text + let mut chars_preview = String::new(); + let max_chars = 30; + let mut n = 0; + let mut pos = 0; + while pos + 1 < rec.data.len() && n < max_chars { + let code = u16::from_le_bytes([rec.data[pos], rec.data[pos + 1]]); + if code == 0x000D { + chars_preview.push_str("\\r"); + } else if code == 0x000A { + chars_preview.push_str("\\n"); + } else if code == 0x000B { + chars_preview.push_str("{CTRL}"); + } else if code == 0x0002 { + chars_preview.push_str("{SECD}"); + } else if code == 0x0003 { + chars_preview.push_str("{FLD_BEGIN}"); + } else if code == 0x0004 { + chars_preview.push_str("{FLD_END}"); + } else if code == 0x0008 { + chars_preview.push_str("{INLINE}"); + } else if code < 0x0020 { + chars_preview.push_str(&format!("{{0x{:04X}}}", code)); + } else if let Some(ch) = char::from_u32(code as u32) { + chars_preview.push(ch); + } else { + chars_preview.push_str(&format!("{{0x{:04X}}}", code)); } + pos += 2; + // Extended control chars take 16 bytes total (skip the inline data) + if code == 0x000B || code == 0x0002 || code == 0x0003 || code == 0x0008 { + pos += 14; // skip 14 more bytes (total 16 for extended char) + } + n += 1; } + details = format!( + "text=\"{}\" hex={}", + chars_preview, + hex_preview(&rec.data, 32) + ); } - } - - // Also find CTRL_HEADER with "tbl " to identify the second table - eprintln!("\n Scanning for ALL CTRL_HEADER 'tbl ' records:"); - for (i, rec) in valid_recs.iter().enumerate() { - if rec.tag_id == tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - if ctrl_id == 0x7462_6C20 { // " lbt" = "tbl " in big endian display - eprintln!(" VALID: tbl CTRL_HEADER at record {}", i); + // PARA_CHAR_SHAPE + else if rec.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { + // pairs of (u32 pos, u32 charShapeId) + let n_pairs = rec.data.len() / 8; + let mut pairs_str = String::new(); + for p in 0..std::cmp::min(n_pairs, 8) { + let pos_val = read_u32_le(&rec.data, p * 8); + let cs_id = read_u32_le(&rec.data, p * 8 + 4); + if !pairs_str.is_empty() { + pairs_str.push_str(", "); + } + pairs_str.push_str(&format!("pos{}=>CS{}", pos_val, cs_id)); } + if n_pairs > 8 { + pairs_str.push_str(&format!(" ...({} more)", n_pairs - 8)); + } + details = format!("[{}]", pairs_str); } - } - for (i, rec) in damaged_recs.iter().enumerate() { - if rec.tag_id == tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - if ctrl_id == 0x7462_6C20 { - eprintln!(" DAMAGED: tbl CTRL_HEADER at record {}", i); + // SHAPE_COMPONENT + else if rec.tag_id == tags::HWPTAG_SHAPE_COMPONENT { + details = format!("hex={}", hex_preview(&rec.data, 40)); + } + // SHAPE_COMPONENT_PICTURE + else if rec.tag_id == tags::HWPTAG_SHAPE_COMPONENT_PICTURE { + details = format!("hex={}", hex_preview(&rec.data, 40)); + } + // PAGE_DEF + else if rec.tag_id == tags::HWPTAG_PAGE_DEF { + if rec.data.len() >= 40 { + let w = read_u32_le(&rec.data, 0); + let h = read_u32_le(&rec.data, 4); + details = format!("width={} height={} (hwpunit)", w, h); } } - } - - // ============================================================ - // Dump records around the pasted table area in both files - // ============================================================ - // Use the second table start if found, otherwise start from first table para header - let v_start = valid_table_start.unwrap_or(30); - let d_start = damaged_table_start.unwrap_or(30); + // FOOTNOTE_SHAPE + else if rec.tag_id == tags::HWPTAG_FOOTNOTE_SHAPE { + details = format!("hex={}", hex_preview(&rec.data, 32)); + } + // PAGE_BORDER_FILL + else if rec.tag_id == tags::HWPTAG_PAGE_BORDER_FILL { + details = format!("hex={}", hex_preview(&rec.data, 32)); + } + // Default: show hex for smaller records + else if rec.data.len() <= 64 { + details = format!("hex={}", hex_preview(&rec.data, 48)); + } else { + details = format!("hex={}", hex_preview(&rec.data, 32)); + } - // Print a generous range: from 4 records before the table para to end or +200 records - let v_range_start = if v_start >= 4 { v_start - 4 } else { 0 }; - let d_range_start = if d_start >= 4 { d_start - 4 } else { 0 }; - let v_range_end = std::cmp::min(valid_recs.len(), v_start + 200); - let d_range_end = std::cmp::min(damaged_recs.len(), d_start + 200); + eprintln!( + "{:<5} {:<5} {:<25} {:>8} {}", + i, rec.level, tag_str, rec.size, details + ); + } + } - eprintln!("\n{}", "=".repeat(120)); - eprintln!(" VALID FILE: Records {} - {} (around pasted table)", v_range_start, v_range_end - 1); - eprintln!("{}", "=".repeat(120)); + // ============================================================ + // 3. Side-by-side comparison: HWP_PASTE (VALID) vs VIEWER_PASTE (DAMAGED) + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!(" PART 3: Side-by-Side Comparison - HWP_PASTE vs VIEWER_PASTE"); + eprintln!("{}", "=".repeat(120)); + + let valid = &all_files[1]; // HWP_PASTE + let damaged = &all_files[2]; // VIEWER_PASTE + + eprintln!( + "\n VALID (HWP_PASTE): {} records, {} data bytes", + valid.body_records.len(), + valid.body_raw_len + ); + eprintln!( + " DAMAGED (VIEWER_PASTE): {} records, {} data bytes", + damaged.body_records.len(), + damaged.body_raw_len + ); + + let max_recs = std::cmp::max(valid.body_records.len(), damaged.body_records.len()); + let mut total_diffs = 0; + + eprintln!( + "\n{:<5} | {:<30} {:>4} {:>6} | {:<30} {:>4} {:>6} | Status", + "#", "VALID Tag", "Lvl", "Size", "DAMAGED Tag", "Lvl", "Size" + ); + eprintln!("{:-<130}", ""); + + for i in 0..max_recs { + let have_valid = i < valid.body_records.len(); + let have_damaged = i < damaged.body_records.len(); + + let (v_tag_str, v_lvl, v_size) = if have_valid { + let r = &valid.body_records[i]; + (format!("{}", tags::tag_name(r.tag_id)), r.level, r.size) + } else { + ("---".to_string(), 0u16, 0u32) + }; - for i in v_range_start..v_range_end { - print_record_detail("VALID", i, &valid_recs[i]); - } + let (d_tag_str, d_lvl, d_size) = if have_damaged { + let r = &damaged.body_records[i]; + (format!("{}", tags::tag_name(r.tag_id)), r.level, r.size) + } else { + ("---".to_string(), 0u16, 0u32) + }; - eprintln!("\n{}", "=".repeat(120)); - eprintln!(" DAMAGED FILE: Records {} - {} (around pasted table)", d_range_start, d_range_end - 1); - eprintln!("{}", "=".repeat(120)); + let status = if !have_valid { + total_diffs += 1; + "EXTRA_IN_DAMAGED" + } else if !have_damaged { + total_diffs += 1; + "MISSING_IN_DAMAGED" + } else { + let rv = &valid.body_records[i]; + let rd = &damaged.body_records[i]; + if rv.tag_id != rd.tag_id { + total_diffs += 1; + "TAG_DIFF" + } else if rv.level != rd.level { + total_diffs += 1; + "LEVEL_DIFF" + } else if rv.data != rd.data { + total_diffs += 1; + "DATA_DIFF" + } else { + "OK" + } + }; - for i in d_range_start..d_range_end { - print_record_detail("DAMAGED", i, &damaged_recs[i]); - } + // For diffs, always print. For OK, also print (full dump requested). + let marker = if status != "OK" { ">>>" } else { " " }; + eprintln!( + "{} {:<5} | {:<30} {:>4} {:>6} | {:<30} {:>4} {:>6} | {}", + marker, i, v_tag_str, v_lvl, v_size, d_tag_str, d_lvl, d_size, status + ); - // ============================================================ - // Side-by-side comparison of matching region - // ============================================================ - eprintln!("\n{}", "=".repeat(120)); - eprintln!(" SIDE-BY-SIDE: VALID[{}..] vs DAMAGED[{}..] - first 60 records", v_start, d_start); - eprintln!("{}", "=".repeat(120)); + // For diffs, show detailed comparison + if status != "OK" && have_valid && have_damaged { + let rv = &valid.body_records[i]; + let rd = &damaged.body_records[i]; + + // PARA_HEADER diff details + if rv.tag_id == tags::HWPTAG_PARA_HEADER || rd.tag_id == tags::HWPTAG_PARA_HEADER { + if rv.tag_id == tags::HWPTAG_PARA_HEADER && rd.tag_id == tags::HWPTAG_PARA_HEADER { + let v_char = read_u32_le(&rv.data, 0); + let d_char = read_u32_le(&rd.data, 0); + let v_mask = read_u32_le(&rv.data, 4); + let d_mask = read_u32_le(&rd.data, 4); + let v_ps = read_u16_le(&rv.data, 8); + let d_ps = read_u16_le(&rd.data, 8); + let v_st = if rv.data.len() > 10 { rv.data[10] } else { 0 }; + let d_st = if rd.data.len() > 10 { rd.data[10] } else { 0 }; + eprintln!(" PARA_HEADER diff: char_count {}={} vs {}={}, mask 0x{:08X} vs 0x{:08X}, ps {} vs {}, style {} vs {}", + if v_char != d_char { "DIFF" } else { "same" }, v_char & 0x7FFFFFFF, + if v_char != d_char { "DIFF" } else { "same" }, d_char & 0x7FFFFFFF, + v_mask, d_mask, v_ps, d_ps, v_st, d_st); + } + } - let compare_count = 60; - for offset in 0..compare_count { - let vi = v_start + offset; - let di = d_start + offset; - if vi >= valid_recs.len() && di >= damaged_recs.len() { break; } + // CTRL_HEADER diff details + if rv.tag_id == tags::HWPTAG_CTRL_HEADER && rd.tag_id == tags::HWPTAG_CTRL_HEADER { + eprintln!( + " VALID ctrl={} DAMAGED ctrl={}", + ctrl_id_string(&rv.data), + ctrl_id_string(&rd.data) + ); + } - let have_v = vi < valid_recs.len(); - let have_d = di < damaged_recs.len(); + // Show hex of both + eprintln!(" VALID hex: {}", hex_preview(&rv.data, 48)); + eprintln!(" DAMAGED hex: {}", hex_preview(&rd.data, 48)); - let (v_tag, v_lvl, v_sz) = if have_v { - (tags::tag_name(valid_recs[vi].tag_id).to_string(), valid_recs[vi].level, valid_recs[vi].size) - } else { ("---".to_string(), 0u16, 0u32) }; + // Show first byte diff position + let min_len = std::cmp::min(rv.data.len(), rd.data.len()); + if let Some(pos) = (0..min_len).find(|&j| rv.data[j] != rd.data[j]) { + eprintln!( + " First byte diff at offset {}: VALID=0x{:02x} DAMAGED=0x{:02x}", + pos, rv.data[pos], rd.data[pos] + ); + } + if rv.data.len() != rd.data.len() { + eprintln!( + " Size diff: VALID={} DAMAGED={}", + rv.data.len(), + rd.data.len() + ); + } + } + } - let (d_tag, d_lvl, d_sz) = if have_d { - (tags::tag_name(damaged_recs[di].tag_id).to_string(), damaged_recs[di].level, damaged_recs[di].size) - } else { ("---".to_string(), 0u16, 0u32) }; + // ============================================================ + // 4. DocInfo comparison: VALID vs DAMAGED + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!(" PART 4: DocInfo Comparison - HWP_PASTE vs VIEWER_PASTE"); + eprintln!("{}", "=".repeat(120)); + + let v_doc = &all_files[1].doc_info_records; + let d_doc = &all_files[2].doc_info_records; + eprintln!("\n VALID DocInfo records: {}", v_doc.len()); + eprintln!(" DAMAGED DocInfo records: {}", d_doc.len()); + + let max_doc = std::cmp::max(v_doc.len(), d_doc.len()); + let mut doc_diffs = 0; + for i in 0..max_doc { + let have_v = i < v_doc.len(); + let have_d = i < d_doc.len(); + let status = if !have_v { + "EXTRA_IN_DAMAGED" + } else if !have_d { + "MISSING_IN_DAMAGED" + } else if v_doc[i].tag_id != d_doc[i].tag_id { + "TAG_DIFF" + } else if v_doc[i].level != d_doc[i].level { + "LEVEL_DIFF" + } else if v_doc[i].data != d_doc[i].data { + "DATA_DIFF" + } else { + "OK" + }; - let status = if !have_v || !have_d { "MISSING" } - else if valid_recs[vi].tag_id != damaged_recs[di].tag_id { "TAG_DIFF" } - else if valid_recs[vi].level != damaged_recs[di].level { "LVL_DIFF" } - else if valid_recs[vi].data != damaged_recs[di].data { "DATA_DIFF" } - else { "OK" }; + if status != "OK" { + doc_diffs += 1; + let v_str = if have_v { + format!( + "{} lvl={} sz={}", + tags::tag_name(v_doc[i].tag_id), + v_doc[i].level, + v_doc[i].size + ) + } else { + "---".to_string() + }; + let d_str = if have_d { + format!( + "{} lvl={} sz={}", + tags::tag_name(d_doc[i].tag_id), + d_doc[i].level, + d_doc[i].size + ) + } else { + "---".to_string() + }; + eprintln!( + " [{}] {} | VALID: {} | DAMAGED: {} |", + i, status, v_str, d_str + ); + } + } + if doc_diffs == 0 { + eprintln!(" DocInfo records are IDENTICAL between VALID and DAMAGED"); + } else { + eprintln!(" Total DocInfo differences: {}", doc_diffs); + } - let marker = if status != "OK" { ">>>" } else { " " }; - eprintln!("{} off={:<3} V[{:>3}] {:<20} lvl={} sz={:<5} | D[{:>3}] {:<20} lvl={} sz={:<5} | {}", - marker, offset, vi, v_tag, v_lvl, v_sz, di, d_tag, d_lvl, d_sz, status); + // ============================================================ + // 5. Summary + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!(" SUMMARY"); + eprintln!("{}", "=".repeat(120)); + + for fd in &all_files { + let total_data: usize = fd.body_records.iter().map(|r| r.data.len()).sum(); + eprintln!( + " {:<25} DocInfo recs={:<5} BodyText recs={:<5} body_bytes={:<8} data_bytes={}", + fd.label, + fd.doc_info_records.len(), + fd.body_records.len(), + fd.body_raw_len, + total_data + ); + } - if status != "OK" && have_v && have_d { - // Show critical details for differing records - let vr = &valid_recs[vi]; - let dr = &damaged_recs[di]; + eprintln!( + "\n BodyText record-by-record diffs (VALID vs DAMAGED): {}", + total_diffs + ); + eprintln!( + " DocInfo record-by-record diffs (VALID vs DAMAGED): {}", + doc_diffs + ); + + eprintln!("\n=== test_step2_comparison complete ==="); +} + +#[test] +fn test_step2_paste_area() { + use crate::parser::cfb_reader::CfbReader; + use crate::parser::record::Record; + use crate::parser::tags; + + // ============================================================ + // Helper functions + // ============================================================ + fn read_u32_le(data: &[u8], offset: usize) -> u32 { + if offset + 4 <= data.len() { + u32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]) + } else { + 0 + } + } + fn read_u16_le(data: &[u8], offset: usize) -> u16 { + if offset + 2 <= data.len() { + u16::from_le_bytes([data[offset], data[offset + 1]]) + } else { + 0 + } + } + fn hex_dump(data: &[u8], max: usize) -> String { + let show = std::cmp::min(data.len(), max); + let hex: Vec = data[..show].iter().map(|b| format!("{:02x}", b)).collect(); + let mut s = hex.join(" "); + if data.len() > max { + s.push_str(&format!(" ...({} more)", data.len() - max)); + } + s + } + fn hex_full(data: &[u8]) -> String { + data.iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(" ") + } + fn utf16le_decode(data: &[u8]) -> String { + let mut result = String::new(); + let mut pos = 0; + while pos + 1 < data.len() { + let code = u16::from_le_bytes([data[pos], data[pos + 1]]); + if code == 0x000D { + result.push_str("\\r"); + } else if code == 0x000A { + result.push_str("\\n"); + } else if code == 0x000B { + result.push_str("{CTRL}"); + pos += 14; + } else if code == 0x0002 { + result.push_str("{SECD}"); + pos += 14; + } else if code == 0x0003 { + result.push_str("{FLD_BEGIN}"); + pos += 14; + } else if code == 0x0004 { + result.push_str("{FLD_END}"); + pos += 14; + } else if code == 0x0008 { + result.push_str("{INLINE}"); + pos += 14; + } else if code < 0x0020 { + result.push_str(&format!("{{0x{:04X}}}", code)); + } else if let Some(ch) = char::from_u32(code as u32) { + result.push(ch); + } else { + result.push_str(&format!("{{0x{:04X}}}", code)); + } + pos += 2; + } + result + } - if vr.tag_id == tags::HWPTAG_PARA_HEADER && dr.tag_id == tags::HWPTAG_PARA_HEADER { - let v_cc = read_u32_le(&vr.data, 0); - let d_cc = read_u32_le(&dr.data, 0); - let v_mask = read_u32_le(&vr.data, 4); - let d_mask = read_u32_le(&dr.data, 4); - let v_ps = read_u16_le(&vr.data, 8); - let d_ps = read_u16_le(&dr.data, 8); - eprintln!(" V: cc={} mask=0x{:08X} ps={} D: cc={} mask=0x{:08X} ps={}", - v_cc & 0x7FFFFFFF, v_mask, v_ps, d_cc & 0x7FFFFFFF, d_mask, d_ps); - } + // ============================================================ + // Print detailed record info + // ============================================================ + fn print_record_detail(label: &str, idx: usize, rec: &Record) { + let tag_name = tags::tag_name(rec.tag_id); + eprintln!( + " [{:>3}] lvl={} tag={:<20} size={:<6} tag_id={}", + idx, rec.level, tag_name, rec.size, rec.tag_id + ); - if vr.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE && dr.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { - let v_pairs = vr.data.len() / 8; - let d_pairs = dr.data.len() / 8; - eprintln!(" V pairs: {} D pairs: {}", v_pairs, d_pairs); - for p in 0..std::cmp::max(v_pairs, d_pairs) { - let v_str = if p < v_pairs { - format!("pos{}=>CS{}", read_u32_le(&vr.data, p*8), read_u32_le(&vr.data, p*8+4)) - } else { "---".to_string() }; - let d_str = if p < d_pairs { - format!("pos{}=>CS{}", read_u32_le(&dr.data, p*8), read_u32_le(&dr.data, p*8+4)) - } else { "---".to_string() }; - eprintln!(" [{}] V: {} D: {}", p, v_str, d_str); + if rec.tag_id == tags::HWPTAG_PARA_HEADER { + if rec.data.len() >= 22 { + let raw_nchars = read_u32_le(&rec.data, 0); + let msb = (raw_nchars >> 31) & 1; + let char_count = raw_nchars & 0x7FFFFFFF; + let control_mask = read_u32_le(&rec.data, 4); + let para_shape_id = read_u16_le(&rec.data, 8); + let style_id = rec.data[10]; + let break_type = rec.data[11]; + let num_char_shapes = read_u16_le(&rec.data, 12); + let num_range_tags = read_u16_le(&rec.data, 14); + let num_line_segs = read_u16_le(&rec.data, 16); + let para_inst_id = read_u32_le(&rec.data, 18); + eprintln!( + " PARA_HEADER: char_count={} msb={} control_mask=0x{:08X}", + char_count, msb, control_mask + ); + eprintln!( + " para_shape_id={} style_id={} break_type={}", + para_shape_id, style_id, break_type + ); + eprintln!( + " numCharShapes={} numRangeTags={} numLineSegs={} paraInstId={}", + num_char_shapes, num_range_tags, num_line_segs, para_inst_id + ); + eprintln!(" full hex: {}", hex_full(&rec.data)); + } else { + eprintln!( + " PARA_HEADER (short {}b): {}", + rec.data.len(), + hex_full(&rec.data) + ); + } + } else if rec.tag_id == tags::HWPTAG_PARA_TEXT { + let show_bytes = std::cmp::min(rec.data.len(), 100); + eprintln!( + " PARA_TEXT hex(first {}): {}", + show_bytes, + hex_dump(&rec.data, 100) + ); + eprintln!( + " PARA_TEXT decoded: \"{}\"", + utf16le_decode(&rec.data) + ); + } else if rec.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { + let n_pairs = rec.data.len() / 8; + for p in 0..n_pairs { + let pos_val = read_u32_le(&rec.data, p * 8); + let cs_id = read_u32_le(&rec.data, p * 8 + 4); + eprintln!( + " PARA_CHAR_SHAPE[{}]: pos={} => CS_id={}", + p, pos_val, cs_id + ); + } + eprintln!(" full hex: {}", hex_full(&rec.data)); + } else if rec.tag_id == tags::HWPTAG_PARA_LINE_SEG { + eprintln!(" PARA_LINE_SEG hex: {}", hex_full(&rec.data)); + // Decode line seg entries (each 36 bytes) + let entry_size = 36; + let n_entries = rec.data.len() / entry_size; + for e in 0..n_entries { + let off = e * entry_size; + let text_start = read_u32_le(&rec.data, off); + let y_pos = read_u32_le(&rec.data, off + 4) as i32; + let height = read_u32_le(&rec.data, off + 8); + let text_height = read_u32_le(&rec.data, off + 12); + let baseline = read_u32_le(&rec.data, off + 16); + let spacing = read_u32_le(&rec.data, off + 20); + let x_pos = read_u32_le(&rec.data, off + 24) as i32; + let seg_width = read_u32_le(&rec.data, off + 28); + let tag_flags = read_u32_le(&rec.data, off + 32); + eprintln!(" seg[{}]: textStart={} yPos={} h={} textH={} baseline={} spacing={} xPos={} segW={} flags=0x{:08X}", + e, text_start, y_pos, height, text_height, baseline, spacing, x_pos, seg_width, tag_flags); + } + } else if rec.tag_id == tags::HWPTAG_CTRL_HEADER { + if rec.data.len() >= 4 { + let ctrl_id = + u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); + let be = ctrl_id.to_be_bytes(); + let ascii: String = be + .iter() + .map(|&b| { + if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + } + }) + .collect(); + eprintln!( + " CTRL_HEADER: ctrl=\"{}\" (0x{:08X})", + ascii, ctrl_id + ); + } + eprintln!(" full hex: {}", hex_dump(&rec.data, 80)); + } else if rec.tag_id == tags::HWPTAG_TABLE { + if rec.data.len() >= 8 { + let attr = read_u32_le(&rec.data, 0); + let rows = read_u16_le(&rec.data, 4); + let cols = read_u16_le(&rec.data, 6); + eprintln!( + " TABLE: attr=0x{:08X} rows={} cols={}", + attr, rows, cols + ); + if rec.data.len() >= 10 { + let cell_spacing = read_u16_le(&rec.data, 8); + eprintln!(" cellSpacing={}", cell_spacing); + } + // border fill id per row: after cell_spacing(2) + padding(2*rows for row sizes) + let row_sizes_start = 10; + for r in 0..rows as usize { + if row_sizes_start + (r + 1) * 2 <= rec.data.len() { + let rs = read_u16_le(&rec.data, row_sizes_start + r * 2); + eprintln!(" rowSize[{}]={}", r, rs); } } - - if vr.tag_id == tags::HWPTAG_LIST_HEADER && dr.tag_id == tags::HWPTAG_LIST_HEADER { - let v_pc = read_u16_le(&vr.data, 0); - let d_pc = read_u16_le(&dr.data, 0); - let v_bf = if vr.data.len() >= 34 { read_u16_le(&vr.data, 32) } else { 0 }; - let d_bf = if dr.data.len() >= 34 { read_u16_le(&dr.data, 32) } else { 0 }; - eprintln!(" V: paraCount={} borderFillId={} D: paraCount={} borderFillId={}", - v_pc, v_bf, d_pc, d_bf); - if vr.data.len() >= 14 && dr.data.len() >= 14 { - let v_col = read_u16_le(&vr.data, 8); - let v_row = read_u16_le(&vr.data, 10); - let d_col = read_u16_le(&dr.data, 8); - let d_row = read_u16_le(&dr.data, 10); - eprintln!(" V: col={} row={} D: col={} row={}", v_col, v_row, d_col, d_row); - } + } + eprintln!(" full hex(40): {}", hex_dump(&rec.data, 40)); + eprintln!(" full hex(all): {}", hex_full(&rec.data)); + } else if rec.tag_id == tags::HWPTAG_LIST_HEADER { + if rec.data.len() >= 6 { + let para_count = read_u16_le(&rec.data, 0); + let attr = read_u32_le(&rec.data, 2); + eprintln!( + " LIST_HEADER: paraCount={} attr=0x{:08X}", + para_count, attr + ); + if rec.data.len() >= 47 { + // Cell-specific fields (for table cells) + let col_addr = read_u16_le(&rec.data, 8); + let row_addr = read_u16_le(&rec.data, 10); + let col_span = read_u16_le(&rec.data, 12); + let row_span = read_u16_le(&rec.data, 14); + let cell_w = read_u32_le(&rec.data, 16); + let cell_h = read_u32_le(&rec.data, 20); + let padding_l = read_u16_le(&rec.data, 24); + let padding_r = read_u16_le(&rec.data, 26); + let padding_t = read_u16_le(&rec.data, 28); + let padding_b = read_u16_le(&rec.data, 30); + let border_fill_id = read_u16_le(&rec.data, 32); + let cell_w2 = read_u32_le(&rec.data, 34); + eprintln!( + " col={} row={} colSpan={} rowSpan={}", + col_addr, row_addr, col_span, row_span + ); + eprintln!( + " cellW={} cellH={} pad=({},{},{},{}) borderFillId={} cellW2={}", + cell_w, + cell_h, + padding_l, + padding_r, + padding_t, + padding_b, + border_fill_id, + cell_w2 + ); } + } + eprintln!(" full hex: {}", hex_full(&rec.data)); + } else { + eprintln!(" hex: {}", hex_dump(&rec.data, 60)); + } + } - // Show hex diff - eprintln!(" V hex: {}", hex_dump(&vr.data, 60)); - eprintln!(" D hex: {}", hex_dump(&dr.data, 60)); + // ============================================================ + // Load files + // ============================================================ + let files: Vec<(&str, &str)> = vec![ + ("template/empty-step2-p.hwp", "VALID"), + ("template/empty-step2_saved_err.hwp", "DAMAGED"), + ]; + + struct FileData { + label: String, + body_records: Vec, + body_raw_len: usize, + } + + let mut all_files: Vec = Vec::new(); + + for (path, label) in &files { + let bytes = + std::fs::read(path).unwrap_or_else(|e| panic!("File read failed: {} - {}", path, e)); + let mut cfb = + CfbReader::open(&bytes).unwrap_or_else(|e| panic!("CFB open failed: {} - {}", path, e)); + let body_data = cfb + .read_body_text_section(0, true, false) + .unwrap_or_else(|e| panic!("BodyText read failed: {} - {}", path, e)); + let body_raw_len = body_data.len(); + let body_records = Record::read_all(&body_data) + .unwrap_or_else(|e| panic!("Record parse failed: {} - {}", path, e)); + let rec_count = body_records.len(); + all_files.push(FileData { + label: label.to_string(), + body_records, + body_raw_len, + }); + eprintln!( + "[{}] {} loaded: {} bytes decompressed, {} records", + label, path, body_raw_len, rec_count + ); + } - // Find first diff byte - let min_len = std::cmp::min(vr.data.len(), dr.data.len()); - if let Some(pos) = (0..min_len).find(|&j| vr.data[j] != dr.data[j]) { - eprintln!(" First diff at byte {}: V=0x{:02x} D=0x{:02x}", pos, vr.data[pos], dr.data[pos]); + let valid_recs = &all_files[0].body_records; + let damaged_recs = &all_files[1].body_records; + + // ============================================================ + // Find the pasted table area: PARA_HEADER with control_mask containing 0x800 + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!(" Finding pasted table PARA_HEADER (control_mask has bit 0x800 = table control)"); + eprintln!("{}", "=".repeat(120)); + + let mut valid_table_start: Option = None; + let mut damaged_table_start: Option = None; + + for (i, rec) in valid_recs.iter().enumerate() { + if rec.tag_id == tags::HWPTAG_PARA_HEADER && rec.data.len() >= 8 { + let mask = read_u32_le(&rec.data, 4); + if mask & 0x800 != 0 { + eprintln!( + " VALID: pasted table PARA_HEADER found at record {} (control_mask=0x{:08X})", + i, mask + ); + if valid_table_start.is_none() { + valid_table_start = Some(i); } } } - - eprintln!("\n=== test_step2_paste_area complete ==="); } - - #[test] - fn test_simple_text_insert_and_save() { - // template/empty.hwp 로드 → 텍스트 삽입 → 저장 - let path = "template/empty.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; + for (i, rec) in damaged_recs.iter().enumerate() { + if rec.tag_id == tags::HWPTAG_PARA_HEADER && rec.data.len() >= 8 { + let mask = read_u32_le(&rec.data, 4); + if mask & 0x800 != 0 { + eprintln!(" DAMAGED: pasted table PARA_HEADER found at record {} (control_mask=0x{:08X})", i, mask); + if damaged_table_start.is_none() { + damaged_table_start = Some(i); + } + } } + } - let data = std::fs::read(path).unwrap(); - let mut doc = HwpDocument::from_bytes(&data).unwrap(); - - eprintln!("=== 단순 텍스트 삽입 + 저장 테스트 ==="); - eprintln!("원본: {} bytes, {}페이지, {}개 구역", - data.len(), doc.page_count(), - doc.document.sections.len()); - - // 첫 번째 구역, 첫 번째 문단에 텍스트 삽입 - let section = &doc.document.sections[0]; - eprintln!("문단 수: {}", section.paragraphs.len()); - for (i, p) in section.paragraphs.iter().enumerate() { - eprintln!(" 문단[{}]: text='{}' controls={} line_segs={}", - i, p.text, p.controls.len(), p.line_segs.len()); + // Also find CTRL_HEADER with "tbl " to identify the second table + eprintln!("\n Scanning for ALL CTRL_HEADER 'tbl ' records:"); + for (i, rec) in valid_recs.iter().enumerate() { + if rec.tag_id == tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); + if ctrl_id == 0x7462_6C20 { + // " lbt" = "tbl " in big endian display + eprintln!(" VALID: tbl CTRL_HEADER at record {}", i); + } } + } + for (i, rec) in damaged_recs.iter().enumerate() { + if rec.tag_id == tags::HWPTAG_CTRL_HEADER && rec.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); + if ctrl_id == 0x7462_6C20 { + eprintln!(" DAMAGED: tbl CTRL_HEADER at record {}", i); + } + } + } - // "가나다라마바사아" 삽입 - let result = doc.insert_text_native(0, 0, 0, "가나다라마바사아"); - assert!(result.is_ok(), "텍스트 삽입 실패: {:?}", result.err()); - eprintln!("텍스트 삽입 결과: {}", result.unwrap()); - - // 삽입 후 상태 확인 - let section = &doc.document.sections[0]; - eprintln!("삽입 후 문단[0]: text='{}'", section.paragraphs[0].text); - - // HWP 내보내기 - let saved = doc.export_hwp_native(); - assert!(saved.is_ok(), "HWP 내보내기 실패: {:?}", saved.err()); - let saved_data = saved.unwrap(); - eprintln!("저장된 파일: {} bytes", saved_data.len()); - - // output/ 폴더에 저장 - let _ = std::fs::create_dir_all("output"); - std::fs::write("output/empty_with_text.hwp", &saved_data).unwrap(); - eprintln!("output/empty_with_text.hwp 저장 완료"); + // ============================================================ + // Dump records around the pasted table area in both files + // ============================================================ + // Use the second table start if found, otherwise start from first table para header + let v_start = valid_table_start.unwrap_or(30); + let d_start = damaged_table_start.unwrap_or(30); + + // Print a generous range: from 4 records before the table para to end or +200 records + let v_range_start = if v_start >= 4 { v_start - 4 } else { 0 }; + let d_range_start = if d_start >= 4 { d_start - 4 } else { 0 }; + let v_range_end = std::cmp::min(valid_recs.len(), v_start + 200); + let d_range_end = std::cmp::min(damaged_recs.len(), d_start + 200); + + eprintln!("\n{}", "=".repeat(120)); + eprintln!( + " VALID FILE: Records {} - {} (around pasted table)", + v_range_start, + v_range_end - 1 + ); + eprintln!("{}", "=".repeat(120)); + + for i in v_range_start..v_range_end { + print_record_detail("VALID", i, &valid_recs[i]); + } - // 저장된 파일 재파싱 검증 - let doc2 = HwpDocument::from_bytes(&saved_data); - assert!(doc2.is_ok(), "저장된 파일 재파싱 실패: {:?}", doc2.err()); - let doc2 = doc2.unwrap(); - eprintln!("재파싱 성공: {}페이지", doc2.page_count()); + eprintln!("\n{}", "=".repeat(120)); + eprintln!( + " DAMAGED FILE: Records {} - {} (around pasted table)", + d_range_start, + d_range_end - 1 + ); + eprintln!("{}", "=".repeat(120)); - let section2 = &doc2.document.sections[0]; - eprintln!("재파싱 문단[0]: text='{}'", section2.paragraphs[0].text); - assert!(section2.paragraphs[0].text.contains("가나다라마바사아"), - "저장된 파일에 삽입한 텍스트가 없음"); + for i in d_range_start..d_range_end { + print_record_detail("DAMAGED", i, &damaged_recs[i]); } - #[test] - fn test_empty_save_analysis() { - use crate::parser::cfb_reader::CfbReader; - use crate::parser::record::Record; - use crate::parser::tags; - use crate::parser::header; - use std::collections::BTreeMap; - - let orig_path = "template/empty.hwp"; - let saved_path = "output/empty_with_text.hwp"; + // ============================================================ + // Side-by-side comparison of matching region + // ============================================================ + eprintln!("\n{}", "=".repeat(120)); + eprintln!( + " SIDE-BY-SIDE: VALID[{}..] vs DAMAGED[{}..] - first 60 records", + v_start, d_start + ); + eprintln!("{}", "=".repeat(120)); + + let compare_count = 60; + for offset in 0..compare_count { + let vi = v_start + offset; + let di = d_start + offset; + if vi >= valid_recs.len() && di >= damaged_recs.len() { + break; + } + + let have_v = vi < valid_recs.len(); + let have_d = di < damaged_recs.len(); + + let (v_tag, v_lvl, v_sz) = if have_v { + ( + tags::tag_name(valid_recs[vi].tag_id).to_string(), + valid_recs[vi].level, + valid_recs[vi].size, + ) + } else { + ("---".to_string(), 0u16, 0u32) + }; - if !std::path::Path::new(orig_path).exists() { - eprintln!("SKIP: {} 없음", orig_path); - return; - } - if !std::path::Path::new(saved_path).exists() { - // 먼저 저장 파일을 생성한다 - eprintln!("output/empty_with_text.hwp 없음 - 생성 시도..."); - let data = std::fs::read(orig_path).unwrap(); - let mut doc = HwpDocument::from_bytes(&data).unwrap(); - let _ = doc.insert_text_native(0, 0, 0, "가나다라마바사아"); - let saved = doc.export_hwp_native().unwrap(); - let _ = std::fs::create_dir_all("output"); - std::fs::write(saved_path, &saved).unwrap(); - eprintln!("output/empty_with_text.hwp 생성 완료"); - } - - let orig_data = std::fs::read(orig_path) - .unwrap_or_else(|e| panic!("원본 파일 읽기 실패: {}", e)); - let saved_data = std::fs::read(saved_path) - .unwrap_or_else(|e| panic!("저장 파일 읽기 실패: {}", e)); - - println!("\n{}", "=".repeat(80)); - println!(" EMPTY HWP vs SAVED-WITH-TEXT HWP ANALYSIS"); - println!(" Original: {} ({} bytes)", orig_path, orig_data.len()); - println!(" Saved: {} ({} bytes)", saved_path, saved_data.len()); - println!("{}", "=".repeat(80)); - - // ============================================================ - // 1. FILE SIZE COMPARISON - // ============================================================ - println!("\n--- 1. FILE SIZE COMPARISON ---"); - println!("Original: {} bytes", orig_data.len()); - println!("Saved: {} bytes", saved_data.len()); - println!("Diff: {} bytes", saved_data.len() as i64 - orig_data.len() as i64); - - // ============================================================ - // 2. CFB STREAM LIST AND SIZES - // ============================================================ - println!("\n--- 2. CFB STREAM LIST AND SIZES ---"); - - let orig_cfb = CfbReader::open(&orig_data).expect("원본 CFB 열기 실패"); - let saved_cfb = CfbReader::open(&saved_data).expect("저장 CFB 열기 실패"); - - let orig_entries: BTreeMap = orig_cfb - .list_all_entries() - .into_iter() - .map(|(path, size, is_stream)| (path, (size, is_stream))) - .collect(); + let (d_tag, d_lvl, d_sz) = if have_d { + ( + tags::tag_name(damaged_recs[di].tag_id).to_string(), + damaged_recs[di].level, + damaged_recs[di].size, + ) + } else { + ("---".to_string(), 0u16, 0u32) + }; - let saved_entries: BTreeMap = saved_cfb - .list_all_entries() - .into_iter() - .map(|(path, size, is_stream)| (path, (size, is_stream))) - .collect(); + let status = if !have_v || !have_d { + "MISSING" + } else if valid_recs[vi].tag_id != damaged_recs[di].tag_id { + "TAG_DIFF" + } else if valid_recs[vi].level != damaged_recs[di].level { + "LVL_DIFF" + } else if valid_recs[vi].data != damaged_recs[di].data { + "DATA_DIFF" + } else { + "OK" + }; - println!("\n{:<40} {:>10} {:>10} {:>8}", "Path", "Orig Size", "Saved Size", "Type"); - println!("{:-<72}", ""); - - let all_paths: std::collections::BTreeSet<&String> = - orig_entries.keys().chain(saved_entries.keys()).collect(); - - for path in &all_paths { - let orig_info = orig_entries.get(*path); - let saved_info = saved_entries.get(*path); - let orig_size = orig_info.map(|(s, _)| format!("{}", s)).unwrap_or_else(|| "---".to_string()); - let saved_size = saved_info.map(|(s, _)| format!("{}", s)).unwrap_or_else(|| "---".to_string()); - let type_str = orig_info.or(saved_info).map(|(_, is)| if *is { "stream" } else { "storage" }).unwrap_or("?"); - let marker = if orig_info.is_none() { - " [NEW]" - } else if saved_info.is_none() { - " [MISSING]" - } else if orig_info.map(|(s,_)| s) != saved_info.map(|(s,_)| s) { - " [CHANGED]" - } else { - "" - }; - println!("{:<40} {:>10} {:>10} {:>8}{}", path, orig_size, saved_size, type_str, marker); - } + let marker = if status != "OK" { ">>>" } else { " " }; + eprintln!( + "{} off={:<3} V[{:>3}] {:<20} lvl={} sz={:<5} | D[{:>3}] {:<20} lvl={} sz={:<5} | {}", + marker, offset, vi, v_tag, v_lvl, v_sz, di, d_tag, d_lvl, d_sz, status + ); - // ============================================================ - // 3. FileHeader COMPARISON - // ============================================================ - println!("\n--- 3. FileHeader COMPARISON ---"); - let mut orig_cfb2 = CfbReader::open(&orig_data).unwrap(); - let mut saved_cfb2 = CfbReader::open(&saved_data).unwrap(); + if status != "OK" && have_v && have_d { + // Show critical details for differing records + let vr = &valid_recs[vi]; + let dr = &damaged_recs[di]; + + if vr.tag_id == tags::HWPTAG_PARA_HEADER && dr.tag_id == tags::HWPTAG_PARA_HEADER { + let v_cc = read_u32_le(&vr.data, 0); + let d_cc = read_u32_le(&dr.data, 0); + let v_mask = read_u32_le(&vr.data, 4); + let d_mask = read_u32_le(&dr.data, 4); + let v_ps = read_u16_le(&vr.data, 8); + let d_ps = read_u16_le(&dr.data, 8); + eprintln!( + " V: cc={} mask=0x{:08X} ps={} D: cc={} mask=0x{:08X} ps={}", + v_cc & 0x7FFFFFFF, + v_mask, + v_ps, + d_cc & 0x7FFFFFFF, + d_mask, + d_ps + ); + } - let orig_header_raw = orig_cfb2.read_file_header().unwrap(); - let saved_header_raw = saved_cfb2.read_file_header().unwrap(); + if vr.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE + && dr.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE + { + let v_pairs = vr.data.len() / 8; + let d_pairs = dr.data.len() / 8; + eprintln!(" V pairs: {} D pairs: {}", v_pairs, d_pairs); + for p in 0..std::cmp::max(v_pairs, d_pairs) { + let v_str = if p < v_pairs { + format!( + "pos{}=>CS{}", + read_u32_le(&vr.data, p * 8), + read_u32_le(&vr.data, p * 8 + 4) + ) + } else { + "---".to_string() + }; + let d_str = if p < d_pairs { + format!( + "pos{}=>CS{}", + read_u32_le(&dr.data, p * 8), + read_u32_le(&dr.data, p * 8 + 4) + ) + } else { + "---".to_string() + }; + eprintln!(" [{}] V: {} D: {}", p, v_str, d_str); + } + } - let orig_fh = header::parse_file_header(&orig_header_raw).unwrap(); - let saved_fh = header::parse_file_header(&saved_header_raw).unwrap(); + if vr.tag_id == tags::HWPTAG_LIST_HEADER && dr.tag_id == tags::HWPTAG_LIST_HEADER { + let v_pc = read_u16_le(&vr.data, 0); + let d_pc = read_u16_le(&dr.data, 0); + let v_bf = if vr.data.len() >= 34 { + read_u16_le(&vr.data, 32) + } else { + 0 + }; + let d_bf = if dr.data.len() >= 34 { + read_u16_le(&dr.data, 32) + } else { + 0 + }; + eprintln!( + " V: paraCount={} borderFillId={} D: paraCount={} borderFillId={}", + v_pc, v_bf, d_pc, d_bf + ); + if vr.data.len() >= 14 && dr.data.len() >= 14 { + let v_col = read_u16_le(&vr.data, 8); + let v_row = read_u16_le(&vr.data, 10); + let d_col = read_u16_le(&dr.data, 8); + let d_row = read_u16_le(&dr.data, 10); + eprintln!( + " V: col={} row={} D: col={} row={}", + v_col, v_row, d_col, d_row + ); + } + } - println!("Original: version={}.{}.{}.{} flags=0x{:08X} compressed={} encrypted={} distribution={}", - orig_fh.version.major, orig_fh.version.minor, orig_fh.version.build, orig_fh.version.revision, - orig_fh.flags.raw, orig_fh.flags.compressed, orig_fh.flags.encrypted, orig_fh.flags.distribution); - println!("Saved: version={}.{}.{}.{} flags=0x{:08X} compressed={} encrypted={} distribution={}", - saved_fh.version.major, saved_fh.version.minor, saved_fh.version.build, saved_fh.version.revision, - saved_fh.flags.raw, saved_fh.flags.compressed, saved_fh.flags.encrypted, saved_fh.flags.distribution); + // Show hex diff + eprintln!(" V hex: {}", hex_dump(&vr.data, 60)); + eprintln!(" D hex: {}", hex_dump(&dr.data, 60)); - if orig_header_raw == saved_header_raw { - println!("FileHeaders are IDENTICAL."); - } else { - println!("FileHeaders DIFFER:"); - for i in 0..std::cmp::max(orig_header_raw.len(), saved_header_raw.len()) { - let ob = orig_header_raw.get(i).copied(); - let sb = saved_header_raw.get(i).copied(); - if ob != sb { - println!(" offset {:#06x}: orig=0x{:02X} saved=0x{:02X}", - i, ob.unwrap_or(0), sb.unwrap_or(0)); - } + // Find first diff byte + let min_len = std::cmp::min(vr.data.len(), dr.data.len()); + if let Some(pos) = (0..min_len).find(|&j| vr.data[j] != dr.data[j]) { + eprintln!( + " First diff at byte {}: V=0x{:02x} D=0x{:02x}", + pos, vr.data[pos], dr.data[pos] + ); } } + } - // ============================================================ - // 4. DocInfo STREAM COMPARISON (byte-level) - // ============================================================ - println!("\n--- 4. DocInfo STREAM COMPARISON (byte-level) ---"); - let orig_compressed = orig_fh.flags.compressed; - let saved_compressed = saved_fh.flags.compressed; + eprintln!("\n=== test_step2_paste_area complete ==="); +} - let mut orig_cfb3 = CfbReader::open(&orig_data).unwrap(); - let mut saved_cfb3 = CfbReader::open(&saved_data).unwrap(); +#[test] +fn test_simple_text_insert_and_save() { + // template/empty.hwp 로드 → 텍스트 삽입 → 저장 + let path = "template/empty.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } - let orig_docinfo = orig_cfb3.read_doc_info(orig_compressed).unwrap(); - let saved_docinfo = saved_cfb3.read_doc_info(saved_compressed).unwrap(); + let data = std::fs::read(path).unwrap(); + let mut doc = HwpDocument::from_bytes(&data).unwrap(); + + eprintln!("=== 단순 텍스트 삽입 + 저장 테스트 ==="); + eprintln!( + "원본: {} bytes, {}페이지, {}개 구역", + data.len(), + doc.page_count(), + doc.document.sections.len() + ); + + // 첫 번째 구역, 첫 번째 문단에 텍스트 삽입 + let section = &doc.document.sections[0]; + eprintln!("문단 수: {}", section.paragraphs.len()); + for (i, p) in section.paragraphs.iter().enumerate() { + eprintln!( + " 문단[{}]: text='{}' controls={} line_segs={}", + i, + p.text, + p.controls.len(), + p.line_segs.len() + ); + } - println!("Original DocInfo (decompressed): {} bytes", orig_docinfo.len()); - println!("Saved DocInfo (decompressed): {} bytes", saved_docinfo.len()); + // "가나다라마바사아" 삽입 + let result = doc.insert_text_native(0, 0, 0, "가나다라마바사아"); + assert!(result.is_ok(), "텍스트 삽입 실패: {:?}", result.err()); + eprintln!("텍스트 삽입 결과: {}", result.unwrap()); + + // 삽입 후 상태 확인 + let section = &doc.document.sections[0]; + eprintln!("삽입 후 문단[0]: text='{}'", section.paragraphs[0].text); + + // HWP 내보내기 + let saved = doc.export_hwp_native(); + assert!(saved.is_ok(), "HWP 내보내기 실패: {:?}", saved.err()); + let saved_data = saved.unwrap(); + eprintln!("저장된 파일: {} bytes", saved_data.len()); + + // output/ 폴더에 저장 + let _ = std::fs::create_dir_all("output"); + std::fs::write("output/empty_with_text.hwp", &saved_data).unwrap(); + eprintln!("output/empty_with_text.hwp 저장 완료"); + + // 저장된 파일 재파싱 검증 + let doc2 = HwpDocument::from_bytes(&saved_data); + assert!(doc2.is_ok(), "저장된 파일 재파싱 실패: {:?}", doc2.err()); + let doc2 = doc2.unwrap(); + eprintln!("재파싱 성공: {}페이지", doc2.page_count()); + + let section2 = &doc2.document.sections[0]; + eprintln!("재파싱 문단[0]: text='{}'", section2.paragraphs[0].text); + assert!( + section2.paragraphs[0].text.contains("가나다라마바사아"), + "저장된 파일에 삽입한 텍스트가 없음" + ); +} + +#[test] +fn test_empty_save_analysis() { + use crate::parser::cfb_reader::CfbReader; + use crate::parser::header; + use crate::parser::record::Record; + use crate::parser::tags; + use std::collections::BTreeMap; + + let orig_path = "template/empty.hwp"; + let saved_path = "output/empty_with_text.hwp"; + + if !std::path::Path::new(orig_path).exists() { + eprintln!("SKIP: {} 없음", orig_path); + return; + } + if !std::path::Path::new(saved_path).exists() { + // 먼저 저장 파일을 생성한다 + eprintln!("output/empty_with_text.hwp 없음 - 생성 시도..."); + let data = std::fs::read(orig_path).unwrap(); + let mut doc = HwpDocument::from_bytes(&data).unwrap(); + let _ = doc.insert_text_native(0, 0, 0, "가나다라마바사아"); + let saved = doc.export_hwp_native().unwrap(); + let _ = std::fs::create_dir_all("output"); + std::fs::write(saved_path, &saved).unwrap(); + eprintln!("output/empty_with_text.hwp 생성 완료"); + } - if orig_docinfo == saved_docinfo { - println!("DocInfo streams are IDENTICAL."); + let orig_data = + std::fs::read(orig_path).unwrap_or_else(|e| panic!("원본 파일 읽기 실패: {}", e)); + let saved_data = + std::fs::read(saved_path).unwrap_or_else(|e| panic!("저장 파일 읽기 실패: {}", e)); + + println!("\n{}", "=".repeat(80)); + println!(" EMPTY HWP vs SAVED-WITH-TEXT HWP ANALYSIS"); + println!(" Original: {} ({} bytes)", orig_path, orig_data.len()); + println!(" Saved: {} ({} bytes)", saved_path, saved_data.len()); + println!("{}", "=".repeat(80)); + + // ============================================================ + // 1. FILE SIZE COMPARISON + // ============================================================ + println!("\n--- 1. FILE SIZE COMPARISON ---"); + println!("Original: {} bytes", orig_data.len()); + println!("Saved: {} bytes", saved_data.len()); + println!( + "Diff: {} bytes", + saved_data.len() as i64 - orig_data.len() as i64 + ); + + // ============================================================ + // 2. CFB STREAM LIST AND SIZES + // ============================================================ + println!("\n--- 2. CFB STREAM LIST AND SIZES ---"); + + let orig_cfb = CfbReader::open(&orig_data).expect("원본 CFB 열기 실패"); + let saved_cfb = CfbReader::open(&saved_data).expect("저장 CFB 열기 실패"); + + let orig_entries: BTreeMap = orig_cfb + .list_all_entries() + .into_iter() + .map(|(path, size, is_stream)| (path, (size, is_stream))) + .collect(); + + let saved_entries: BTreeMap = saved_cfb + .list_all_entries() + .into_iter() + .map(|(path, size, is_stream)| (path, (size, is_stream))) + .collect(); + + println!( + "\n{:<40} {:>10} {:>10} {:>8}", + "Path", "Orig Size", "Saved Size", "Type" + ); + println!("{:-<72}", ""); + + let all_paths: std::collections::BTreeSet<&String> = + orig_entries.keys().chain(saved_entries.keys()).collect(); + + for path in &all_paths { + let orig_info = orig_entries.get(*path); + let saved_info = saved_entries.get(*path); + let orig_size = orig_info + .map(|(s, _)| format!("{}", s)) + .unwrap_or_else(|| "---".to_string()); + let saved_size = saved_info + .map(|(s, _)| format!("{}", s)) + .unwrap_or_else(|| "---".to_string()); + let type_str = orig_info + .or(saved_info) + .map(|(_, is)| if *is { "stream" } else { "storage" }) + .unwrap_or("?"); + let marker = if orig_info.is_none() { + " [NEW]" + } else if saved_info.is_none() { + " [MISSING]" + } else if orig_info.map(|(s, _)| s) != saved_info.map(|(s, _)| s) { + " [CHANGED]" } else { - println!("DocInfo streams DIFFER!"); - let min_len = std::cmp::min(orig_docinfo.len(), saved_docinfo.len()); - let mut diff_count = 0; - let mut first_diff_pos = None; - for i in 0..min_len { - if orig_docinfo[i] != saved_docinfo[i] { - if diff_count < 20 { - println!(" offset {:#06x}: orig=0x{:02X} saved=0x{:02X}", i, orig_docinfo[i], saved_docinfo[i]); - } - if first_diff_pos.is_none() { - first_diff_pos = Some(i); - } - diff_count += 1; - } + "" + }; + println!( + "{:<40} {:>10} {:>10} {:>8}{}", + path, orig_size, saved_size, type_str, marker + ); + } + + // ============================================================ + // 3. FileHeader COMPARISON + // ============================================================ + println!("\n--- 3. FileHeader COMPARISON ---"); + let mut orig_cfb2 = CfbReader::open(&orig_data).unwrap(); + let mut saved_cfb2 = CfbReader::open(&saved_data).unwrap(); + + let orig_header_raw = orig_cfb2.read_file_header().unwrap(); + let saved_header_raw = saved_cfb2.read_file_header().unwrap(); + + let orig_fh = header::parse_file_header(&orig_header_raw).unwrap(); + let saved_fh = header::parse_file_header(&saved_header_raw).unwrap(); + + println!( + "Original: version={}.{}.{}.{} flags=0x{:08X} compressed={} encrypted={} distribution={}", + orig_fh.version.major, + orig_fh.version.minor, + orig_fh.version.build, + orig_fh.version.revision, + orig_fh.flags.raw, + orig_fh.flags.compressed, + orig_fh.flags.encrypted, + orig_fh.flags.distribution + ); + println!( + "Saved: version={}.{}.{}.{} flags=0x{:08X} compressed={} encrypted={} distribution={}", + saved_fh.version.major, + saved_fh.version.minor, + saved_fh.version.build, + saved_fh.version.revision, + saved_fh.flags.raw, + saved_fh.flags.compressed, + saved_fh.flags.encrypted, + saved_fh.flags.distribution + ); + + if orig_header_raw == saved_header_raw { + println!("FileHeaders are IDENTICAL."); + } else { + println!("FileHeaders DIFFER:"); + for i in 0..std::cmp::max(orig_header_raw.len(), saved_header_raw.len()) { + let ob = orig_header_raw.get(i).copied(); + let sb = saved_header_raw.get(i).copied(); + if ob != sb { + println!( + " offset {:#06x}: orig=0x{:02X} saved=0x{:02X}", + i, + ob.unwrap_or(0), + sb.unwrap_or(0) + ); } - if orig_docinfo.len() != saved_docinfo.len() { - println!(" Size difference: orig={} saved={} (diff={})", - orig_docinfo.len(), saved_docinfo.len(), - saved_docinfo.len() as i64 - orig_docinfo.len() as i64); + } + } + + // ============================================================ + // 4. DocInfo STREAM COMPARISON (byte-level) + // ============================================================ + println!("\n--- 4. DocInfo STREAM COMPARISON (byte-level) ---"); + let orig_compressed = orig_fh.flags.compressed; + let saved_compressed = saved_fh.flags.compressed; + + let mut orig_cfb3 = CfbReader::open(&orig_data).unwrap(); + let mut saved_cfb3 = CfbReader::open(&saved_data).unwrap(); + + let orig_docinfo = orig_cfb3.read_doc_info(orig_compressed).unwrap(); + let saved_docinfo = saved_cfb3.read_doc_info(saved_compressed).unwrap(); + + println!( + "Original DocInfo (decompressed): {} bytes", + orig_docinfo.len() + ); + println!( + "Saved DocInfo (decompressed): {} bytes", + saved_docinfo.len() + ); + + if orig_docinfo == saved_docinfo { + println!("DocInfo streams are IDENTICAL."); + } else { + println!("DocInfo streams DIFFER!"); + let min_len = std::cmp::min(orig_docinfo.len(), saved_docinfo.len()); + let mut diff_count = 0; + let mut first_diff_pos = None; + for i in 0..min_len { + if orig_docinfo[i] != saved_docinfo[i] { + if diff_count < 20 { + println!( + " offset {:#06x}: orig=0x{:02X} saved=0x{:02X}", + i, orig_docinfo[i], saved_docinfo[i] + ); + } + if first_diff_pos.is_none() { + first_diff_pos = Some(i); + } + diff_count += 1; } - println!(" Total differing bytes: {} (first at offset {:?})", diff_count, first_diff_pos); + } + if orig_docinfo.len() != saved_docinfo.len() { + println!( + " Size difference: orig={} saved={} (diff={})", + orig_docinfo.len(), + saved_docinfo.len(), + saved_docinfo.len() as i64 - orig_docinfo.len() as i64 + ); + } + println!( + " Total differing bytes: {} (first at offset {:?})", + diff_count, first_diff_pos + ); - // Parse DocInfo records for comparison - println!("\n --- DocInfo Record-by-Record ---"); - let orig_di_records = Record::read_all(&orig_docinfo).unwrap_or_default(); - let saved_di_records = Record::read_all(&saved_docinfo).unwrap_or_default(); - println!(" Original DocInfo records: {}", orig_di_records.len()); - println!(" Saved DocInfo records: {}", saved_di_records.len()); + // Parse DocInfo records for comparison + println!("\n --- DocInfo Record-by-Record ---"); + let orig_di_records = Record::read_all(&orig_docinfo).unwrap_or_default(); + let saved_di_records = Record::read_all(&saved_docinfo).unwrap_or_default(); + println!(" Original DocInfo records: {}", orig_di_records.len()); + println!(" Saved DocInfo records: {}", saved_di_records.len()); - let max_di = std::cmp::max(orig_di_records.len(), saved_di_records.len()); - for i in 0..max_di { - let orig_r = orig_di_records.get(i); - let saved_r = saved_di_records.get(i); - let matches = match (orig_r, saved_r) { - (Some(o), Some(s)) => o.tag_id == s.tag_id && o.level == s.level && o.size == s.size && o.data == s.data, - _ => false, - }; - if !matches { - let orig_str = orig_r.map(|r| format!("{} lvl={} sz={}", r.tag_name(), r.level, r.size)) - .unwrap_or_else(|| "---".to_string()); - let saved_str = saved_r.map(|r| format!("{} lvl={} sz={}", r.tag_name(), r.level, r.size)) - .unwrap_or_else(|| "---".to_string()); - println!(" [{}] ORIG: {:<40} SAVED: {}", i, orig_str, saved_str); - // If tags match but data differs, show data diff - if let (Some(o), Some(s)) = (orig_r, saved_r) { - if o.tag_id == s.tag_id && o.data != s.data { - let show = std::cmp::min(40, std::cmp::max(o.data.len(), s.data.len())); - print!(" orig data: "); - for b in &o.data[..std::cmp::min(show, o.data.len())] { print!("{:02x} ", b); } - println!(); - print!(" saved data: "); - for b in &s.data[..std::cmp::min(show, s.data.len())] { print!("{:02x} ", b); } - println!(); + let max_di = std::cmp::max(orig_di_records.len(), saved_di_records.len()); + for i in 0..max_di { + let orig_r = orig_di_records.get(i); + let saved_r = saved_di_records.get(i); + let matches = match (orig_r, saved_r) { + (Some(o), Some(s)) => { + o.tag_id == s.tag_id + && o.level == s.level + && o.size == s.size + && o.data == s.data + } + _ => false, + }; + if !matches { + let orig_str = orig_r + .map(|r| format!("{} lvl={} sz={}", r.tag_name(), r.level, r.size)) + .unwrap_or_else(|| "---".to_string()); + let saved_str = saved_r + .map(|r| format!("{} lvl={} sz={}", r.tag_name(), r.level, r.size)) + .unwrap_or_else(|| "---".to_string()); + println!(" [{}] ORIG: {:<40} SAVED: {}", i, orig_str, saved_str); + // If tags match but data differs, show data diff + if let (Some(o), Some(s)) = (orig_r, saved_r) { + if o.tag_id == s.tag_id && o.data != s.data { + let show = std::cmp::min(40, std::cmp::max(o.data.len(), s.data.len())); + print!(" orig data: "); + for b in &o.data[..std::cmp::min(show, o.data.len())] { + print!("{:02x} ", b); } + println!(); + print!(" saved data: "); + for b in &s.data[..std::cmp::min(show, s.data.len())] { + print!("{:02x} ", b); + } + println!(); } } } } + } - // ============================================================ - // 5. BodyText/Section0 RECORD-BY-RECORD COMPARISON - // ============================================================ - println!("\n--- 5. BodyText/Section0 RECORD-BY-RECORD COMPARISON ---"); - let mut orig_cfb4 = CfbReader::open(&orig_data).unwrap(); - let mut saved_cfb4 = CfbReader::open(&saved_data).unwrap(); - - let orig_section = orig_cfb4.read_body_text_section(0, orig_compressed, false).unwrap(); - let saved_section = saved_cfb4.read_body_text_section(0, saved_compressed, false).unwrap(); - - println!("Original Section0 (decompressed): {} bytes", orig_section.len()); - println!("Saved Section0 (decompressed): {} bytes", saved_section.len()); - - let orig_records = Record::read_all(&orig_section).unwrap(); - let saved_records = Record::read_all(&saved_section).unwrap(); - - println!("Original records: {}", orig_records.len()); - println!("Saved records: {}", saved_records.len()); + // ============================================================ + // 5. BodyText/Section0 RECORD-BY-RECORD COMPARISON + // ============================================================ + println!("\n--- 5. BodyText/Section0 RECORD-BY-RECORD COMPARISON ---"); + let mut orig_cfb4 = CfbReader::open(&orig_data).unwrap(); + let mut saved_cfb4 = CfbReader::open(&saved_data).unwrap(); + + let orig_section = orig_cfb4 + .read_body_text_section(0, orig_compressed, false) + .unwrap(); + let saved_section = saved_cfb4 + .read_body_text_section(0, saved_compressed, false) + .unwrap(); + + println!( + "Original Section0 (decompressed): {} bytes", + orig_section.len() + ); + println!( + "Saved Section0 (decompressed): {} bytes", + saved_section.len() + ); + + let orig_records = Record::read_all(&orig_section).unwrap(); + let saved_records = Record::read_all(&saved_section).unwrap(); + + println!("Original records: {}", orig_records.len()); + println!("Saved records: {}", saved_records.len()); + + // Helper functions + fn hex_dump_n(data: &[u8], max: usize) -> String { + let show = std::cmp::min(data.len(), max); + let hex: Vec = data[..show].iter().map(|b| format!("{:02x}", b)).collect(); + let mut result = hex.join(" "); + if data.len() > max { + result.push_str(&format!(" ...({} more)", data.len() - max)); + } + result + } - // Helper functions - fn hex_dump_n(data: &[u8], max: usize) -> String { - let show = std::cmp::min(data.len(), max); - let hex: Vec = data[..show].iter().map(|b| format!("{:02x}", b)).collect(); - let mut result = hex.join(" "); - if data.len() > max { - result.push_str(&format!(" ...({} more)", data.len() - max)); - } - result + fn decode_para_header(data: &[u8]) -> String { + if data.len() < 8 { + return format!("(too short: {} bytes)", data.len()); + } + // PARA_HEADER structure: + // 0-3: nCharCount (lower 31 bits = char count, bit 31 = char_count_msb) + // 4-7: controlMask (u32) + // 8-9: paraShapeId (u16) + // 10: paraStyleId (u8) + // 11: columnSplit (u8) + // 12-13: charShapeCount (u16) + // 14-15: rangeTagCount (u16) + // 16-17: lineAlignCount (u16) + // 18-19: instanceID (u16) + let raw = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); + let char_count = raw & 0x7FFFFFFF; + let char_count_msb = (raw >> 31) & 1; + let ctrl_mask = u32::from_le_bytes([data[4], data[5], data[6], data[7]]); + let mut result = format!( + "char_count={} msb={} ctrl_mask=0x{:08X}", + char_count, char_count_msb, ctrl_mask + ); + if data.len() >= 10 { + let para_shape_id = u16::from_le_bytes([data[8], data[9]]); + result.push_str(&format!(" paraShapeId={}", para_shape_id)); } - - fn decode_para_header(data: &[u8]) -> String { - if data.len() < 8 { - return format!("(too short: {} bytes)", data.len()); - } - // PARA_HEADER structure: - // 0-3: nCharCount (lower 31 bits = char count, bit 31 = char_count_msb) - // 4-7: controlMask (u32) - // 8-9: paraShapeId (u16) - // 10: paraStyleId (u8) - // 11: columnSplit (u8) - // 12-13: charShapeCount (u16) - // 14-15: rangeTagCount (u16) - // 16-17: lineAlignCount (u16) - // 18-19: instanceID (u16) - let raw = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); - let char_count = raw & 0x7FFFFFFF; - let char_count_msb = (raw >> 31) & 1; - let ctrl_mask = u32::from_le_bytes([data[4], data[5], data[6], data[7]]); - let mut result = format!("char_count={} msb={} ctrl_mask=0x{:08X}", char_count, char_count_msb, ctrl_mask); - if data.len() >= 10 { - let para_shape_id = u16::from_le_bytes([data[8], data[9]]); - result.push_str(&format!(" paraShapeId={}", para_shape_id)); - } - if data.len() >= 11 { - result.push_str(&format!(" paraStyleId={}", data[10])); - } - if data.len() >= 14 { - let cs_count = u16::from_le_bytes([data[12], data[13]]); - result.push_str(&format!(" charShapeCount={}", cs_count)); - } - if data.len() >= 16 { - let rt_count = u16::from_le_bytes([data[14], data[15]]); - result.push_str(&format!(" rangeTagCount={}", rt_count)); - } - if data.len() >= 18 { - let la_count = u16::from_le_bytes([data[16], data[17]]); - result.push_str(&format!(" lineAlignCount={}", la_count)); - } - if data.len() >= 20 { - let inst_id = u16::from_le_bytes([data[18], data[19]]); - result.push_str(&format!(" instanceID={}", inst_id)); - } - result + if data.len() >= 11 { + result.push_str(&format!(" paraStyleId={}", data[10])); + } + if data.len() >= 14 { + let cs_count = u16::from_le_bytes([data[12], data[13]]); + result.push_str(&format!(" charShapeCount={}", cs_count)); } + if data.len() >= 16 { + let rt_count = u16::from_le_bytes([data[14], data[15]]); + result.push_str(&format!(" rangeTagCount={}", rt_count)); + } + if data.len() >= 18 { + let la_count = u16::from_le_bytes([data[16], data[17]]); + result.push_str(&format!(" lineAlignCount={}", la_count)); + } + if data.len() >= 20 { + let inst_id = u16::from_le_bytes([data[18], data[19]]); + result.push_str(&format!(" instanceID={}", inst_id)); + } + result + } - fn decode_para_text(data: &[u8]) -> String { - // UTF-16LE text stream with control chars - let mut result = String::new(); - let mut i = 0; - let mut char_pos = 0; - while i + 1 < data.len() { - let ch = u16::from_le_bytes([data[i], data[i + 1]]); - match ch { - // Extended controls take 8 WCHARs (16 bytes) - 0x0001 | 0x0002 | 0x0003 | 0x000B | 0x000C | 0x000D | 0x000E | 0x000F - | 0x0004 | 0x0005 | 0x0006 | 0x0007 | 0x0008 | 0x0009 | 0x000A => { - let name = match ch { - 0x0002 => "SEC/COL", - 0x0003 => "FIELD_BEGIN", - 0x0004 => "FIELD_END", - 0x0008 => "INLINE", - 0x000B => "EXT_CTRL", - 0x000D => "PARA_BREAK", - 0x000A => "LINE_BREAK", - _ => "CTRL", - }; - result.push_str(&format!("[{}@{}]", name, char_pos)); - // Extended controls (2,3,11,12,13,14,15) occupy 8 WCHARs - if ch >= 1 && ch <= 9 { - // These chars occupy 8 WCHARs (16 bytes) - i += 16; - char_pos += 8; - } else { - i += 2; - char_pos += 1; - } - } - _ => { - if let Some(c) = char::from_u32(ch as u32) { - result.push(c); - } else { - result.push_str(&format!("\\u{:04X}", ch)); - } + fn decode_para_text(data: &[u8]) -> String { + // UTF-16LE text stream with control chars + let mut result = String::new(); + let mut i = 0; + let mut char_pos = 0; + while i + 1 < data.len() { + let ch = u16::from_le_bytes([data[i], data[i + 1]]); + match ch { + // Extended controls take 8 WCHARs (16 bytes) + 0x0001..=0x000F => { + let name = match ch { + 0x0002 => "SEC/COL", + 0x0003 => "FIELD_BEGIN", + 0x0004 => "FIELD_END", + 0x0008 => "INLINE", + 0x000B => "EXT_CTRL", + 0x000D => "PARA_BREAK", + 0x000A => "LINE_BREAK", + _ => "CTRL", + }; + result.push_str(&format!("[{}@{}]", name, char_pos)); + // Extended controls (2,3,11,12,13,14,15) occupy 8 WCHARs + if ch >= 1 && ch <= 9 { + // These chars occupy 8 WCHARs (16 bytes) + i += 16; + char_pos += 8; + } else { i += 2; char_pos += 1; } } + _ => { + if let Some(c) = char::from_u32(ch as u32) { + result.push(c); + } else { + result.push_str(&format!("\\u{:04X}", ch)); + } + i += 2; + char_pos += 1; + } } - result } + result + } - fn decode_para_char_shape(data: &[u8]) -> String { - // Array of (position: u32, charShapeId: u32) pairs - let pair_count = data.len() / 8; - let mut result = format!("{} pairs: ", pair_count); - for p in 0..pair_count { - let off = p * 8; - if off + 8 > data.len() { break; } - let pos = u32::from_le_bytes([data[off], data[off+1], data[off+2], data[off+3]]); - let id = u32::from_le_bytes([data[off+4], data[off+5], data[off+6], data[off+7]]); - if p > 0 { result.push_str(", "); } - result.push_str(&format!("(pos={}, id={})", pos, id)); + fn decode_para_char_shape(data: &[u8]) -> String { + // Array of (position: u32, charShapeId: u32) pairs + let pair_count = data.len() / 8; + let mut result = format!("{} pairs: ", pair_count); + for p in 0..pair_count { + let off = p * 8; + if off + 8 > data.len() { + break; } - result - } - - fn decode_para_line_seg(data: &[u8]) -> String { - // Each line segment is 36 bytes: - // textStartPos(4) + lineVPos(4) + lineHPos(4) + lineHeight(4) - // + textPartHeight(4) + distBaseline(4) + lineSpacing(4) + colStartPos(4) + segWidth(4) - // Some versions use 32 bytes per segment - let seg_size = if data.len() % 36 == 0 { 36 } else if data.len() % 32 == 0 { 32 } else { 36 }; - let seg_count = if seg_size > 0 { data.len() / seg_size } else { 0 }; - let mut result = format!("{} segments ({}B each): ", seg_count, seg_size); - for s in 0..std::cmp::min(seg_count, 4) { - let off = s * seg_size; - if off + 16 > data.len() { break; } - let text_start = u32::from_le_bytes([data[off], data[off+1], data[off+2], data[off+3]]); - let line_vpos = i32::from_le_bytes([data[off+4], data[off+5], data[off+6], data[off+7]]); - let line_hpos = i32::from_le_bytes([data[off+8], data[off+9], data[off+10], data[off+11]]); - let line_height = u32::from_le_bytes([data[off+12], data[off+13], data[off+14], data[off+15]]); - if s > 0 { result.push_str(", "); } - result.push_str(&format!("[start={} v={} h={} h={}]", text_start, line_vpos, line_hpos, line_height)); - } - if seg_count > 4 { - result.push_str(&format!(" ...({} more)", seg_count - 4)); + let pos = u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]); + let id = + u32::from_le_bytes([data[off + 4], data[off + 5], data[off + 6], data[off + 7]]); + if p > 0 { + result.push_str(", "); } - result + result.push_str(&format!("(pos={}, id={})", pos, id)); } + result + } - fn decode_ctrl_header(data: &[u8]) -> String { - if data.len() < 4 { - return format!("(too short: {} bytes)", data.len()); + fn decode_para_line_seg(data: &[u8]) -> String { + // Each line segment is 36 bytes: + // textStartPos(4) + lineVPos(4) + lineHPos(4) + lineHeight(4) + // + textPartHeight(4) + distBaseline(4) + lineSpacing(4) + colStartPos(4) + segWidth(4) + // Some versions use 32 bytes per segment + let seg_size = if data.len().is_multiple_of(36) { + 36 + } else if data.len().is_multiple_of(32) { + 32 + } else { + 36 + }; + let seg_count = if seg_size > 0 { + data.len() / seg_size + } else { + 0 + }; + let mut result = format!("{} segments ({}B each): ", seg_count, seg_size); + for s in 0..std::cmp::min(seg_count, 4) { + let off = s * seg_size; + if off + 16 > data.len() { + break; } - let ctrl_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); - let be_bytes = ctrl_id.to_be_bytes(); - let ascii: String = be_bytes.iter().map(|&b| { - if b.is_ascii_graphic() || b == b' ' { b as char } else { '.' } - }).collect(); - format!("ctrl_id=0x{:08X} \"{}\" ({})", ctrl_id, ascii, tags::ctrl_name(ctrl_id)) + let text_start = + u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]); + let line_vpos = + i32::from_le_bytes([data[off + 4], data[off + 5], data[off + 6], data[off + 7]]); + let line_hpos = + i32::from_le_bytes([data[off + 8], data[off + 9], data[off + 10], data[off + 11]]); + let line_height = u32::from_le_bytes([ + data[off + 12], + data[off + 13], + data[off + 14], + data[off + 15], + ]); + if s > 0 { + result.push_str(", "); + } + result.push_str(&format!( + "[start={} v={} h={} h={}]", + text_start, line_vpos, line_hpos, line_height + )); } + if seg_count > 4 { + result.push_str(&format!(" ...({} more)", seg_count - 4)); + } + result + } - // Print all records with decoded details - println!("\n {:<4} {:<22} {:>3} {:>6} | {:<22} {:>3} {:>6} | Status", - "#", "Orig Tag", "Lvl", "Size", "Saved Tag", "Lvl", "Size"); - println!(" {:-<110}", ""); - - let max_recs = std::cmp::max(orig_records.len(), saved_records.len()); - for i in 0..max_recs { - let orig_r = orig_records.get(i); - let saved_r = saved_records.get(i); - - let orig_str = orig_r.map(|r| format!("{:<22} {:>3} {:>6}", r.tag_name(), r.level, r.size)) - .unwrap_or_else(|| format!("{:<22} {:>3} {:>6}", "---", "", "")); - let saved_str = saved_r.map(|r| format!("{:<22} {:>3} {:>6}", r.tag_name(), r.level, r.size)) - .unwrap_or_else(|| format!("{:<22} {:>3} {:>6}", "---", "", "")); + fn decode_ctrl_header(data: &[u8]) -> String { + if data.len() < 4 { + return format!("(too short: {} bytes)", data.len()); + } + let ctrl_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); + let be_bytes = ctrl_id.to_be_bytes(); + let ascii: String = be_bytes + .iter() + .map(|&b| { + if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + } + }) + .collect(); + format!( + "ctrl_id=0x{:08X} \"{}\" ({})", + ctrl_id, + ascii, + tags::ctrl_name(ctrl_id) + ) + } - let status = match (orig_r, saved_r) { - (Some(o), Some(s)) => { - if o.tag_id == s.tag_id && o.level == s.level && o.size == s.size && o.data == s.data { - "OK" - } else if o.tag_id == s.tag_id && o.level == s.level && o.size == s.size { - "DATA_DIFF" - } else if o.tag_id == s.tag_id && o.level == s.level { - "SIZE_DIFF" - } else if o.tag_id == s.tag_id { - "LEVEL_DIFF" - } else { - "TAG_DIFF" - } + // Print all records with decoded details + println!( + "\n {:<4} {:<22} {:>3} {:>6} | {:<22} {:>3} {:>6} | Status", + "#", "Orig Tag", "Lvl", "Size", "Saved Tag", "Lvl", "Size" + ); + println!(" {:-<110}", ""); + + let max_recs = std::cmp::max(orig_records.len(), saved_records.len()); + for i in 0..max_recs { + let orig_r = orig_records.get(i); + let saved_r = saved_records.get(i); + + let orig_str = orig_r + .map(|r| format!("{:<22} {:>3} {:>6}", r.tag_name(), r.level, r.size)) + .unwrap_or_else(|| format!("{:<22} {:>3} {:>6}", "---", "", "")); + let saved_str = saved_r + .map(|r| format!("{:<22} {:>3} {:>6}", r.tag_name(), r.level, r.size)) + .unwrap_or_else(|| format!("{:<22} {:>3} {:>6}", "---", "", "")); + + let status = match (orig_r, saved_r) { + (Some(o), Some(s)) => { + if o.tag_id == s.tag_id + && o.level == s.level + && o.size == s.size + && o.data == s.data + { + "OK" + } else if o.tag_id == s.tag_id && o.level == s.level && o.size == s.size { + "DATA_DIFF" + } else if o.tag_id == s.tag_id && o.level == s.level { + "SIZE_DIFF" + } else if o.tag_id == s.tag_id { + "LEVEL_DIFF" + } else { + "TAG_DIFF" } - (Some(_), None) => "ORIG_ONLY", - (None, Some(_)) => "SAVED_ONLY", - (None, None) => "???", - }; + } + (Some(_), None) => "ORIG_ONLY", + (None, Some(_)) => "SAVED_ONLY", + (None, None) => "???", + }; - // Always print, even OK, so we can see the full record layout - println!(" {:<4} {} | {} | {}", i, orig_str, saved_str, status); - - // Decode details for non-OK records - if status != "OK" { - // Show decoded info for both - for (label, rec) in [("ORIG", orig_r), ("SAVED", saved_r)] { - if let Some(r) = rec { - let detail = match r.tag_id { - t if t == tags::HWPTAG_PARA_HEADER => decode_para_header(&r.data), - t if t == tags::HWPTAG_PARA_TEXT => { - let text_decoded = decode_para_text(&r.data); - format!("hex[0..40]: {} text: {}", hex_dump_n(&r.data, 40), text_decoded) - } - t if t == tags::HWPTAG_PARA_CHAR_SHAPE => decode_para_char_shape(&r.data), - t if t == tags::HWPTAG_PARA_LINE_SEG => decode_para_line_seg(&r.data), - t if t == tags::HWPTAG_CTRL_HEADER => decode_ctrl_header(&r.data), - _ => format!("hex: {}", hex_dump_n(&r.data, 40)), - }; - println!(" {}: {}", label, detail); - } + // Always print, even OK, so we can see the full record layout + println!(" {:<4} {} | {} | {}", i, orig_str, saved_str, status); + + // Decode details for non-OK records + if status != "OK" { + // Show decoded info for both + for (label, rec) in [("ORIG", orig_r), ("SAVED", saved_r)] { + if let Some(r) = rec { + let detail = match r.tag_id { + t if t == tags::HWPTAG_PARA_HEADER => decode_para_header(&r.data), + t if t == tags::HWPTAG_PARA_TEXT => { + let text_decoded = decode_para_text(&r.data); + format!( + "hex[0..40]: {} text: {}", + hex_dump_n(&r.data, 40), + text_decoded + ) + } + t if t == tags::HWPTAG_PARA_CHAR_SHAPE => decode_para_char_shape(&r.data), + t if t == tags::HWPTAG_PARA_LINE_SEG => decode_para_line_seg(&r.data), + t if t == tags::HWPTAG_CTRL_HEADER => decode_ctrl_header(&r.data), + _ => format!("hex: {}", hex_dump_n(&r.data, 40)), + }; + println!(" {}: {}", label, detail); } } } + } + + // ============================================================ + // 6. Record SUMMARY & STATISTICS + // ============================================================ + println!("\n--- 6. RECORD SUMMARY ---"); + println!("\nOriginal record types:"); + let mut orig_tag_counts: BTreeMap = BTreeMap::new(); + for r in &orig_records { + *orig_tag_counts.entry(r.tag_id).or_insert(0) += 1; + } + for (tag, count) in &orig_tag_counts { + println!(" {:>3} ({:<22}): {}", tag, tags::tag_name(*tag), count); + } + + println!("\nSaved record types:"); + let mut saved_tag_counts: BTreeMap = BTreeMap::new(); + for r in &saved_records { + *saved_tag_counts.entry(r.tag_id).or_insert(0) += 1; + } + for (tag, count) in &saved_tag_counts { + println!(" {:>3} ({:<22}): {}", tag, tags::tag_name(*tag), count); + } - // ============================================================ - // 6. Record SUMMARY & STATISTICS - // ============================================================ - println!("\n--- 6. RECORD SUMMARY ---"); - println!("\nOriginal record types:"); - let mut orig_tag_counts: BTreeMap = BTreeMap::new(); - for r in &orig_records { - *orig_tag_counts.entry(r.tag_id).or_insert(0) += 1; + // ============================================================ + // 7. PARA_HEADER DETAIL for ALL paragraphs + // ============================================================ + println!("\n--- 7. ALL PARA_HEADER DETAILS ---"); + println!("\n Original paragraphs:"); + for (i, r) in orig_records.iter().enumerate() { + if r.tag_id == tags::HWPTAG_PARA_HEADER { + println!( + " [{}] lvl={} sz={}: {}", + i, + r.level, + r.size, + decode_para_header(&r.data) + ); + println!(" hex: {}", hex_dump_n(&r.data, 40)); } - for (tag, count) in &orig_tag_counts { - println!(" {:>3} ({:<22}): {}", tag, tags::tag_name(*tag), count); + } + println!("\n Saved paragraphs:"); + for (i, r) in saved_records.iter().enumerate() { + if r.tag_id == tags::HWPTAG_PARA_HEADER { + println!( + " [{}] lvl={} sz={}: {}", + i, + r.level, + r.size, + decode_para_header(&r.data) + ); + println!(" hex: {}", hex_dump_n(&r.data, 40)); } + } - println!("\nSaved record types:"); - let mut saved_tag_counts: BTreeMap = BTreeMap::new(); - for r in &saved_records { - *saved_tag_counts.entry(r.tag_id).or_insert(0) += 1; + // ============================================================ + // 8. PARA_TEXT DETAIL (hex + decoded) + // ============================================================ + println!("\n--- 8. ALL PARA_TEXT DETAILS ---"); + println!("\n Original PARA_TEXT:"); + for (i, r) in orig_records.iter().enumerate() { + if r.tag_id == tags::HWPTAG_PARA_TEXT { + println!(" [{}] lvl={} sz={}", i, r.level, r.size); + println!(" hex: {}", hex_dump_n(&r.data, 60)); + println!(" decoded: {}", decode_para_text(&r.data)); } - for (tag, count) in &saved_tag_counts { - println!(" {:>3} ({:<22}): {}", tag, tags::tag_name(*tag), count); + } + println!("\n Saved PARA_TEXT:"); + for (i, r) in saved_records.iter().enumerate() { + if r.tag_id == tags::HWPTAG_PARA_TEXT { + println!(" [{}] lvl={} sz={}", i, r.level, r.size); + println!(" hex: {}", hex_dump_n(&r.data, 60)); + println!(" decoded: {}", decode_para_text(&r.data)); } + } - // ============================================================ - // 7. PARA_HEADER DETAIL for ALL paragraphs - // ============================================================ - println!("\n--- 7. ALL PARA_HEADER DETAILS ---"); - println!("\n Original paragraphs:"); - for (i, r) in orig_records.iter().enumerate() { - if r.tag_id == tags::HWPTAG_PARA_HEADER { - println!(" [{}] lvl={} sz={}: {}", i, r.level, r.size, decode_para_header(&r.data)); - println!(" hex: {}", hex_dump_n(&r.data, 40)); - } + // ============================================================ + // 9. PARA_CHAR_SHAPE DETAIL + // ============================================================ + println!("\n--- 9. ALL PARA_CHAR_SHAPE DETAILS ---"); + println!("\n Original PARA_CHAR_SHAPE:"); + for (i, r) in orig_records.iter().enumerate() { + if r.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { + println!( + " [{}] lvl={} sz={}: {}", + i, + r.level, + r.size, + decode_para_char_shape(&r.data) + ); + println!(" hex: {}", hex_dump_n(&r.data, 40)); } - println!("\n Saved paragraphs:"); - for (i, r) in saved_records.iter().enumerate() { - if r.tag_id == tags::HWPTAG_PARA_HEADER { - println!(" [{}] lvl={} sz={}: {}", i, r.level, r.size, decode_para_header(&r.data)); - println!(" hex: {}", hex_dump_n(&r.data, 40)); - } + } + println!("\n Saved PARA_CHAR_SHAPE:"); + for (i, r) in saved_records.iter().enumerate() { + if r.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { + println!( + " [{}] lvl={} sz={}: {}", + i, + r.level, + r.size, + decode_para_char_shape(&r.data) + ); + println!(" hex: {}", hex_dump_n(&r.data, 40)); } + } - // ============================================================ - // 8. PARA_TEXT DETAIL (hex + decoded) - // ============================================================ - println!("\n--- 8. ALL PARA_TEXT DETAILS ---"); - println!("\n Original PARA_TEXT:"); - for (i, r) in orig_records.iter().enumerate() { - if r.tag_id == tags::HWPTAG_PARA_TEXT { - println!(" [{}] lvl={} sz={}", i, r.level, r.size); - println!(" hex: {}", hex_dump_n(&r.data, 60)); - println!(" decoded: {}", decode_para_text(&r.data)); - } + // ============================================================ + // 10. PARA_LINE_SEG DETAIL + // ============================================================ + println!("\n--- 10. ALL PARA_LINE_SEG DETAILS ---"); + println!("\n Original PARA_LINE_SEG:"); + for (i, r) in orig_records.iter().enumerate() { + if r.tag_id == tags::HWPTAG_PARA_LINE_SEG { + println!( + " [{}] lvl={} sz={}: {}", + i, + r.level, + r.size, + decode_para_line_seg(&r.data) + ); + println!(" hex: {}", hex_dump_n(&r.data, 60)); } - println!("\n Saved PARA_TEXT:"); - for (i, r) in saved_records.iter().enumerate() { - if r.tag_id == tags::HWPTAG_PARA_TEXT { - println!(" [{}] lvl={} sz={}", i, r.level, r.size); - println!(" hex: {}", hex_dump_n(&r.data, 60)); - println!(" decoded: {}", decode_para_text(&r.data)); - } + } + println!("\n Saved PARA_LINE_SEG:"); + for (i, r) in saved_records.iter().enumerate() { + if r.tag_id == tags::HWPTAG_PARA_LINE_SEG { + println!( + " [{}] lvl={} sz={}: {}", + i, + r.level, + r.size, + decode_para_line_seg(&r.data) + ); + println!(" hex: {}", hex_dump_n(&r.data, 60)); } + } - // ============================================================ - // 9. PARA_CHAR_SHAPE DETAIL - // ============================================================ - println!("\n--- 9. ALL PARA_CHAR_SHAPE DETAILS ---"); - println!("\n Original PARA_CHAR_SHAPE:"); - for (i, r) in orig_records.iter().enumerate() { - if r.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { - println!(" [{}] lvl={} sz={}: {}", i, r.level, r.size, decode_para_char_shape(&r.data)); - println!(" hex: {}", hex_dump_n(&r.data, 40)); - } + // ============================================================ + // 11. CTRL_HEADER DETAIL + // ============================================================ + println!("\n--- 11. ALL CTRL_HEADER DETAILS ---"); + println!("\n Original CTRL_HEADER:"); + for (i, r) in orig_records.iter().enumerate() { + if r.tag_id == tags::HWPTAG_CTRL_HEADER { + println!( + " [{}] lvl={} sz={}: {}", + i, + r.level, + r.size, + decode_ctrl_header(&r.data) + ); + println!(" hex: {}", hex_dump_n(&r.data, 60)); } - println!("\n Saved PARA_CHAR_SHAPE:"); - for (i, r) in saved_records.iter().enumerate() { - if r.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { - println!(" [{}] lvl={} sz={}: {}", i, r.level, r.size, decode_para_char_shape(&r.data)); - println!(" hex: {}", hex_dump_n(&r.data, 40)); - } + } + println!("\n Saved CTRL_HEADER:"); + for (i, r) in saved_records.iter().enumerate() { + if r.tag_id == tags::HWPTAG_CTRL_HEADER { + println!( + " [{}] lvl={} sz={}: {}", + i, + r.level, + r.size, + decode_ctrl_header(&r.data) + ); + println!(" hex: {}", hex_dump_n(&r.data, 60)); } + } - // ============================================================ - // 10. PARA_LINE_SEG DETAIL - // ============================================================ - println!("\n--- 10. ALL PARA_LINE_SEG DETAILS ---"); - println!("\n Original PARA_LINE_SEG:"); - for (i, r) in orig_records.iter().enumerate() { - if r.tag_id == tags::HWPTAG_PARA_LINE_SEG { - println!(" [{}] lvl={} sz={}: {}", i, r.level, r.size, decode_para_line_seg(&r.data)); - println!(" hex: {}", hex_dump_n(&r.data, 60)); + // ============================================================ + // 12. BinData STREAMS + // ============================================================ + println!("\n--- 12. BinData STREAMS ---"); + let orig_bins: Vec<_> = orig_entries + .keys() + .filter(|k| k.starts_with("/BinData/")) + .collect(); + let saved_bins: Vec<_> = saved_entries + .keys() + .filter(|k| k.starts_with("/BinData/")) + .collect(); + println!("Original BinData: {} streams", orig_bins.len()); + for p in &orig_bins { + println!(" {} (size: {})", p, orig_entries[*p].0); + } + println!("Saved BinData: {} streams", saved_bins.len()); + for p in &saved_bins { + println!(" {} (size: {})", p, saved_entries[*p].0); + } + + // ============================================================ + // 13. FULL RAW HEX DUMP of Section0 for small files + // ============================================================ + if saved_section.len() <= 2000 { + println!( + "\n--- 13. FULL RAW HEX DUMP (Saved Section0, {} bytes) ---", + saved_section.len() + ); + for chunk_start in (0..saved_section.len()).step_by(32) { + let chunk_end = std::cmp::min(chunk_start + 32, saved_section.len()); + print!(" {:06x}: ", chunk_start); + for i in chunk_start..chunk_end { + print!("{:02x} ", saved_section[i]); + } + // ASCII view + print!(" "); + for i in chunk_start..chunk_end { + let b = saved_section[i]; + if b >= 0x20 && b < 0x7f { + print!("{}", b as char); + } else { + print!("."); + } } + println!(); } - println!("\n Saved PARA_LINE_SEG:"); - for (i, r) in saved_records.iter().enumerate() { - if r.tag_id == tags::HWPTAG_PARA_LINE_SEG { - println!(" [{}] lvl={} sz={}: {}", i, r.level, r.size, decode_para_line_seg(&r.data)); - println!(" hex: {}", hex_dump_n(&r.data, 60)); - } - } - - // ============================================================ - // 11. CTRL_HEADER DETAIL - // ============================================================ - println!("\n--- 11. ALL CTRL_HEADER DETAILS ---"); - println!("\n Original CTRL_HEADER:"); - for (i, r) in orig_records.iter().enumerate() { - if r.tag_id == tags::HWPTAG_CTRL_HEADER { - println!(" [{}] lvl={} sz={}: {}", i, r.level, r.size, decode_ctrl_header(&r.data)); - println!(" hex: {}", hex_dump_n(&r.data, 60)); - } - } - println!("\n Saved CTRL_HEADER:"); - for (i, r) in saved_records.iter().enumerate() { - if r.tag_id == tags::HWPTAG_CTRL_HEADER { - println!(" [{}] lvl={} sz={}: {}", i, r.level, r.size, decode_ctrl_header(&r.data)); - println!(" hex: {}", hex_dump_n(&r.data, 60)); - } - } - - // ============================================================ - // 12. BinData STREAMS - // ============================================================ - println!("\n--- 12. BinData STREAMS ---"); - let orig_bins: Vec<_> = orig_entries.keys().filter(|k| k.starts_with("/BinData/")).collect(); - let saved_bins: Vec<_> = saved_entries.keys().filter(|k| k.starts_with("/BinData/")).collect(); - println!("Original BinData: {} streams", orig_bins.len()); - for p in &orig_bins { - println!(" {} (size: {})", p, orig_entries[*p].0); - } - println!("Saved BinData: {} streams", saved_bins.len()); - for p in &saved_bins { - println!(" {} (size: {})", p, saved_entries[*p].0); - } - - // ============================================================ - // 13. FULL RAW HEX DUMP of Section0 for small files - // ============================================================ - if saved_section.len() <= 2000 { - println!("\n--- 13. FULL RAW HEX DUMP (Saved Section0, {} bytes) ---", saved_section.len()); - for chunk_start in (0..saved_section.len()).step_by(32) { - let chunk_end = std::cmp::min(chunk_start + 32, saved_section.len()); - print!(" {:06x}: ", chunk_start); - for i in chunk_start..chunk_end { - print!("{:02x} ", saved_section[i]); - } - // ASCII view - print!(" "); - for i in chunk_start..chunk_end { - let b = saved_section[i]; - if b >= 0x20 && b < 0x7f { - print!("{}", b as char); - } else { - print!("."); - } + } + + if orig_section.len() <= 2000 { + println!( + "\n--- 13b. FULL RAW HEX DUMP (Original Section0, {} bytes) ---", + orig_section.len() + ); + for chunk_start in (0..orig_section.len()).step_by(32) { + let chunk_end = std::cmp::min(chunk_start + 32, orig_section.len()); + print!(" {:06x}: ", chunk_start); + for i in chunk_start..chunk_end { + print!("{:02x} ", orig_section[i]); + } + print!(" "); + for i in chunk_start..chunk_end { + let b = orig_section[i]; + if b >= 0x20 && b < 0x7f { + print!("{}", b as char); + } else { + print!("."); } - println!(); } + println!(); } + } - if orig_section.len() <= 2000 { - println!("\n--- 13b. FULL RAW HEX DUMP (Original Section0, {} bytes) ---", orig_section.len()); - for chunk_start in (0..orig_section.len()).step_by(32) { - let chunk_end = std::cmp::min(chunk_start + 32, orig_section.len()); - print!(" {:06x}: ", chunk_start); - for i in chunk_start..chunk_end { - print!("{:02x} ", orig_section[i]); - } - print!(" "); - for i in chunk_start..chunk_end { - let b = orig_section[i]; - if b >= 0x20 && b < 0x7f { - print!("{}", b as char); - } else { - print!("."); + // ============================================================ + // 14. DIAGNOSIS SUMMARY + // ============================================================ + println!("\n{}", "=".repeat(80)); + println!(" DIAGNOSIS SUMMARY"); + println!("{}", "=".repeat(80)); + + let mut issues: Vec = Vec::new(); + + // Check record count differences + if orig_records.len() != saved_records.len() { + issues.push(format!( + "Record count differs: orig={} saved={}", + orig_records.len(), + saved_records.len() + )); + } + + // Check for PARA_HEADER with invalid char_count_msb + for (i, r) in saved_records.iter().enumerate() { + if r.tag_id == tags::HWPTAG_PARA_HEADER && r.data.len() >= 4 { + let raw = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); + let msb = (raw >> 31) & 1; + let char_count = raw & 0x7FFFFFFF; + if msb != 0 && msb != 1 { + issues.push(format!("Record {}: PARA_HEADER invalid msb={}", i, msb)); + } + // Check if char_count matches PARA_TEXT length + if let Some(text_rec) = saved_records.get(i + 1) { + if text_rec.tag_id == tags::HWPTAG_PARA_TEXT { + let text_wchars = text_rec.data.len() / 2; + // The char_count includes control character widths + // (extended ctrls = 8 WCHARs each) + if char_count == 0 && text_wchars > 0 { + issues.push(format!( + "Record {}: PARA_HEADER char_count=0 but PARA_TEXT has {} WCHARs", + i, text_wchars + )); } } - println!(); } } + } + + // Check PARA_LINE_SEG size validity + for (i, r) in saved_records.iter().enumerate() { + if r.tag_id == tags::HWPTAG_PARA_LINE_SEG { + if r.data.len() % 36 != 0 && r.data.len() % 32 != 0 { + issues.push(format!( + "Record {}: PARA_LINE_SEG invalid size {} (not multiple of 32 or 36)", + i, + r.data.len() + )); + } + } + } + + // Check for data differences in matching records + let min_count = std::cmp::min(orig_records.len(), saved_records.len()); + let mut diff_records: Vec = Vec::new(); + for i in 0..min_count { + if orig_records[i].tag_id == saved_records[i].tag_id + && orig_records[i].data != saved_records[i].data + { + diff_records.push(i); + } + } + if !diff_records.is_empty() { + issues.push(format!( + "Records with data differences (same tag): {:?}", + diff_records + )); + } - // ============================================================ - // 14. DIAGNOSIS SUMMARY - // ============================================================ - println!("\n{}", "=".repeat(80)); - println!(" DIAGNOSIS SUMMARY"); - println!("{}", "=".repeat(80)); + // Check for missing or extra records + if orig_records.len() > saved_records.len() { + issues.push(format!( + "Saved file is MISSING {} records from original", + orig_records.len() - saved_records.len() + )); + } else if saved_records.len() > orig_records.len() { + issues.push(format!( + "Saved file has {} EXTRA records compared to original", + saved_records.len() - orig_records.len() + )); + } - let mut issues: Vec = Vec::new(); + // DocInfo differences + if orig_docinfo != saved_docinfo { + issues.push(format!( + "DocInfo streams differ: orig={} bytes, saved={} bytes", + orig_docinfo.len(), + saved_docinfo.len() + )); + } - // Check record count differences - if orig_records.len() != saved_records.len() { - issues.push(format!("Record count differs: orig={} saved={}", orig_records.len(), saved_records.len())); + if issues.is_empty() { + println!("\n No obvious issues found."); + } else { + println!("\n Found {} potential issues:", issues.len()); + for (i, issue) in issues.iter().enumerate() { + println!(" {}. {}", i + 1, issue); } + } - // Check for PARA_HEADER with invalid char_count_msb - for (i, r) in saved_records.iter().enumerate() { - if r.tag_id == tags::HWPTAG_PARA_HEADER && r.data.len() >= 4 { - let raw = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); - let msb = (raw >> 31) & 1; - let char_count = raw & 0x7FFFFFFF; - if msb != 0 && msb != 1 { - issues.push(format!("Record {}: PARA_HEADER invalid msb={}", i, msb)); + println!("\n Analysis complete."); +} + +#[test] +fn test_text_insert_detailed_diff() { + // 텍스트 삽입 후 저장된 파일의 모든 레코드를 원본과 상세 비교 + let path = "template/empty.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } + + let orig_data = std::fs::read(path).unwrap(); + let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); + + // 텍스트 삽입 + let _ = doc.insert_text_native(0, 0, 0, "가나다라마바사아"); + let saved = doc.export_hwp_native().unwrap(); + + // 레코드 파싱 + use crate::parser::record::Record; + let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); + let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); + let orig_bt = orig_cfb + .read_body_text_section(0, orig_doc.header.compressed, false) + .unwrap(); + let orig_recs = Record::read_all(&orig_bt).unwrap(); + + let saved_doc = crate::parser::parse_hwp(&saved).unwrap(); + let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved).unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_doc.header.compressed, false) + .unwrap(); + let saved_recs = Record::read_all(&saved_bt).unwrap(); + + eprintln!("\n=== 원본 레코드 ==="); + for (i, r) in orig_recs.iter().enumerate() { + eprintln!( + "[{:2}] tag={:3} (0x{:04x}) level={} size={}", + i, + r.tag_id, + r.tag_id, + r.level, + r.data.len() + ); + } + eprintln!("\n=== 저장 레코드 ==="); + for (i, r) in saved_recs.iter().enumerate() { + eprintln!( + "[{:2}] tag={:3} (0x{:04x}) level={} size={}", + i, + r.tag_id, + r.tag_id, + r.level, + r.data.len() + ); + } + + // 레코드별 상세 비교 + let max = orig_recs.len().max(saved_recs.len()); + eprintln!("\n=== 바이트 비교 ==="); + for i in 0..max { + let o = orig_recs.get(i); + let s = saved_recs.get(i); + match (o, s) { + (Some(or), Some(sr)) => { + if or.tag_id != sr.tag_id || or.level != sr.level || or.data != sr.data { + eprintln!( + "DIFF [{}]: tag={}/{} level={}/{} size={}/{}", + i, + or.tag_id, + sr.tag_id, + or.level, + sr.level, + or.data.len(), + sr.data.len() + ); + // HWP 레코드 태그 이름 매핑 + let tag_name = match or.tag_id { + 66 => "PARA_HEADER", + 67 => "PARA_TEXT", + 68 => "PARA_CHAR_SHAPE", + 69 => "PARA_LINE_SEG", + 70 => "CTRL_HEADER", + 71 => "LIST_HEADER", + _ => "UNKNOWN", + }; + eprintln!(" Record type: {}", tag_name); + + // 전체 데이터 헥스 덤프 + let orig_hex: Vec = + or.data.iter().map(|b| format!("{:02x}", b)).collect(); + let save_hex: Vec = + sr.data.iter().map(|b| format!("{:02x}", b)).collect(); + eprintln!(" ORIG[{}]: {}", or.data.len(), orig_hex.join(" ")); + eprintln!(" SAVE[{}]: {}", sr.data.len(), save_hex.join(" ")); + + // 바이트별 차이 표시 + let min_len = or.data.len().min(sr.data.len()); + for pos in 0..min_len { + if or.data[pos] != sr.data[pos] { + eprintln!( + " Byte {}: 0x{:02x} → 0x{:02x}", + pos, or.data[pos], sr.data[pos] + ); + } + } + if or.data.len() != sr.data.len() { + eprintln!( + " Size diff: {} → {} (delta {})", + or.data.len(), + sr.data.len(), + sr.data.len() as i64 - or.data.len() as i64 + ); + } + } else { + eprintln!( + "OK [{}]: tag={} level={} size={}", + i, + or.tag_id, + or.level, + or.data.len() + ); } - // Check if char_count matches PARA_TEXT length - if let Some(text_rec) = saved_records.get(i + 1) { - if text_rec.tag_id == tags::HWPTAG_PARA_TEXT { - let text_wchars = text_rec.data.len() / 2; - // The char_count includes control character widths - // (extended ctrls = 8 WCHARs each) - if char_count == 0 && text_wchars > 0 { - issues.push(format!("Record {}: PARA_HEADER char_count=0 but PARA_TEXT has {} WCHARs", i, text_wchars)); + } + (Some(or), None) => eprintln!("MISSING [{}]: tag={}", i, or.tag_id), + (None, Some(sr)) => eprintln!("EXTRA [{}]: tag={}", i, sr.tag_id), + _ => {} + } + } +} + +#[test] +fn test_roundtrip_no_edit() { + // 편집 없이 raw_stream 무효화 → 재직렬화 → 저장 + // 재직렬화 자체에 문제가 있는지 분리 확인 + let path = "template/empty.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } + + let orig_data = std::fs::read(path).unwrap(); + let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); + + // raw_stream 무효화 (재직렬화 유도) + doc.document.sections[0].raw_stream = None; + + let saved = doc.export_hwp_native().unwrap(); + let _ = std::fs::create_dir_all("output"); + std::fs::write("output/empty_roundtrip.hwp", &saved).unwrap(); + eprintln!("output/empty_roundtrip.hwp 저장 ({} bytes)", saved.len()); + + // 재파싱 검증 + let doc2 = HwpDocument::from_bytes(&saved); + assert!(doc2.is_ok(), "재파싱 실패: {:?}", doc2.err()); + + // 레코드별 비교 + use crate::parser::record::Record; + let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); + let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); + let orig_bt = orig_cfb + .read_body_text_section(0, orig_doc.header.compressed, false) + .unwrap(); + let orig_recs = Record::read_all(&orig_bt).unwrap(); + + let saved_doc = crate::parser::parse_hwp(&saved).unwrap(); + let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved).unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_doc.header.compressed, false) + .unwrap(); + let saved_recs = Record::read_all(&saved_bt).unwrap(); + + eprintln!( + "원본 레코드: {}, 재직렬화 레코드: {}", + orig_recs.len(), + saved_recs.len() + ); + + let max = orig_recs.len().max(saved_recs.len()); + for i in 0..max { + let o = orig_recs.get(i); + let s = saved_recs.get(i); + match (o, s) { + (Some(or), Some(sr)) => { + if or.tag_id != sr.tag_id || or.level != sr.level || or.data != sr.data { + eprintln!( + "DIFF [{}]: tag={}/{} level={}/{} size={}/{}", + i, + or.tag_id, + sr.tag_id, + or.level, + sr.level, + or.data.len(), + sr.data.len() + ); + if or.data != sr.data { + let show = or.data.len().min(sr.data.len()).min(36); + eprintln!(" ORIG: {:02x?}", &or.data[..show]); + eprintln!(" SAVE: {:02x?}", &sr.data[..show]); + // 첫 번째 다른 바이트 위치 + for (pos, (a, b)) in or.data.iter().zip(sr.data.iter()).enumerate() { + if a != b { + eprintln!( + " First diff at byte {}: 0x{:02x} vs 0x{:02x}", + pos, a, b + ); + break; + } } } } } + (Some(or), None) => eprintln!("MISSING in saved [{}]: tag={}", i, or.tag_id), + (None, Some(sr)) => eprintln!("EXTRA in saved [{}]: tag={}", i, sr.tag_id), + _ => {} } + } + eprintln!("비교 완료"); +} + +#[test] +fn test_empty_hwp_editing_area() { + // template/empty.hwp의 편집 영역, 캐럿 위치, LineSeg 값을 분석 + use crate::model::page::PageAreas; + + let path = "template/empty.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } - // Check PARA_LINE_SEG size validity - for (i, r) in saved_records.iter().enumerate() { - if r.tag_id == tags::HWPTAG_PARA_LINE_SEG { - if r.data.len() % 36 != 0 && r.data.len() % 32 != 0 { - issues.push(format!("Record {}: PARA_LINE_SEG invalid size {} (not multiple of 32 or 36)", i, r.data.len())); - } - } - } - - // Check for data differences in matching records - let min_count = std::cmp::min(orig_records.len(), saved_records.len()); - let mut diff_records: Vec = Vec::new(); - for i in 0..min_count { - if orig_records[i].tag_id == saved_records[i].tag_id - && orig_records[i].data != saved_records[i].data - { - diff_records.push(i); - } - } - if !diff_records.is_empty() { - issues.push(format!("Records with data differences (same tag): {:?}", diff_records)); + let data = std::fs::read(path).unwrap(); + let doc = HwpDocument::from_bytes(&data).unwrap(); + + eprintln!("\n{}", "=".repeat(70)); + eprintln!(" EMPTY HWP 편집 영역 분석"); + eprintln!("{}", "=".repeat(70)); + + // 1. DocProperties 캐럿 정보 + let props = &doc.document.doc_properties; + eprintln!("\n--- DocProperties 캐럿 정보 ---"); + eprintln!(" caret_list_id: {}", props.caret_list_id); + eprintln!(" caret_para_id: {}", props.caret_para_id); + eprintln!(" caret_char_pos: {}", props.caret_char_pos); + + // 2. PageDef (용지 설정) + let section = &doc.document.sections[0]; + let page_def = §ion.section_def.page_def; + eprintln!("\n--- PageDef (용지 설정) ---"); + eprintln!( + " width: {} HWPUNIT ({:.1}mm)", + page_def.width, + page_def.width as f64 / 283.46 + ); + eprintln!( + " height: {} HWPUNIT ({:.1}mm)", + page_def.height, + page_def.height as f64 / 283.46 + ); + eprintln!( + " margin_left: {} HWPUNIT ({:.1}mm)", + page_def.margin_left, + page_def.margin_left as f64 / 283.46 + ); + eprintln!( + " margin_right: {} HWPUNIT ({:.1}mm)", + page_def.margin_right, + page_def.margin_right as f64 / 283.46 + ); + eprintln!( + " margin_top: {} HWPUNIT ({:.1}mm)", + page_def.margin_top, + page_def.margin_top as f64 / 283.46 + ); + eprintln!( + " margin_bottom: {} HWPUNIT ({:.1}mm)", + page_def.margin_bottom, + page_def.margin_bottom as f64 / 283.46 + ); + eprintln!( + " margin_header: {} HWPUNIT ({:.1}mm)", + page_def.margin_header, + page_def.margin_header as f64 / 283.46 + ); + eprintln!( + " margin_footer: {} HWPUNIT ({:.1}mm)", + page_def.margin_footer, + page_def.margin_footer as f64 / 283.46 + ); + eprintln!( + " margin_gutter: {} HWPUNIT ({:.1}mm)", + page_def.margin_gutter, + page_def.margin_gutter as f64 / 283.46 + ); + eprintln!(" landscape: {}", page_def.landscape); + + // 3. PageAreas (계산된 편집 영역) + let areas = PageAreas::from_page_def(page_def); + eprintln!("\n--- PageAreas (계산된 영역) ---"); + eprintln!( + " header_area: left={} top={} right={} bottom={}", + areas.header_area.left, + areas.header_area.top, + areas.header_area.right, + areas.header_area.bottom + ); + eprintln!( + " body_area: left={} top={} right={} bottom={}", + areas.body_area.left, areas.body_area.top, areas.body_area.right, areas.body_area.bottom + ); + eprintln!( + " body_area size: width={} height={}", + areas.body_area.right - areas.body_area.left, + areas.body_area.bottom - areas.body_area.top + ); + eprintln!( + " footer_area: left={} top={} right={} bottom={}", + areas.footer_area.left, + areas.footer_area.top, + areas.footer_area.right, + areas.footer_area.bottom + ); + + // 4. 모든 문단의 LineSeg 정보 + eprintln!("\n--- 문단별 LineSeg 정보 ---"); + for (pi, para) in section.paragraphs.iter().enumerate() { + eprintln!( + " 문단[{}]: text='{}' char_count={} controls={}", + pi, + para.text, + para.char_count, + para.controls.len() + ); + eprintln!( + " char_shapes: {:?}", + para.char_shapes + .iter() + .map(|cs| (cs.start_pos, cs.char_shape_id)) + .collect::>() + ); + for (li, ls) in para.line_segs.iter().enumerate() { + eprintln!(" LineSeg[{}]:", li); + eprintln!(" text_start: {}", ls.text_start); + eprintln!( + " vertical_pos: {} ({:.1}mm)", + ls.vertical_pos, + ls.vertical_pos as f64 / 283.46 + ); + eprintln!( + " line_height: {} ({:.1}mm)", + ls.line_height, + ls.line_height as f64 / 283.46 + ); + eprintln!( + " text_height: {} ({:.1}mm)", + ls.text_height, + ls.text_height as f64 / 283.46 + ); + eprintln!( + " baseline_distance: {} ({:.1}mm)", + ls.baseline_distance, + ls.baseline_distance as f64 / 283.46 + ); + eprintln!( + " line_spacing: {} ({:.1}mm)", + ls.line_spacing, + ls.line_spacing as f64 / 283.46 + ); + eprintln!( + " column_start: {} ({:.1}mm)", + ls.column_start, + ls.column_start as f64 / 283.46 + ); + eprintln!( + " segment_width: {} ({:.1}mm)", + ls.segment_width, + ls.segment_width as f64 / 283.46 + ); + eprintln!( + " tag: 0x{:08x} (first_of_page={} first_of_col={})", + ls.tag, + ls.is_first_line_of_page(), + ls.is_first_line_of_column() + ); } + } - // Check for missing or extra records - if orig_records.len() > saved_records.len() { - issues.push(format!("Saved file is MISSING {} records from original", orig_records.len() - saved_records.len())); - } else if saved_records.len() > orig_records.len() { - issues.push(format!("Saved file has {} EXTRA records compared to original", saved_records.len() - orig_records.len())); + // 5. 편집 영역 첫 줄 캐럿 위치 분석 + if let Some(first_para) = section.paragraphs.first() { + if let Some(first_ls) = first_para.line_segs.first() { + eprintln!("\n--- 편집 영역 첫 줄 캐럿 위치 분석 ---"); + eprintln!(" body_area.top (계산값): {}", areas.body_area.top); + eprintln!(" LineSeg.vertical_pos (실제): {}", first_ls.vertical_pos); + eprintln!( + " 차이: {}", + first_ls.vertical_pos - areas.body_area.top + ); + eprintln!(" body_area.left (계산값): {}", areas.body_area.left); + eprintln!(" LineSeg.column_start (실제): {}", first_ls.column_start); + eprintln!( + " 차이: {}", + first_ls.column_start - areas.body_area.left + ); + let body_width = areas.body_area.right - areas.body_area.left; + eprintln!(" body_area.width (계산값): {}", body_width); + eprintln!(" LineSeg.segment_width (실제): {}", first_ls.segment_width); + eprintln!( + " 차이: {}", + first_ls.segment_width - body_width + ); } + } - // DocInfo differences - if orig_docinfo != saved_docinfo { - issues.push(format!("DocInfo streams differ: orig={} bytes, saved={} bytes", - orig_docinfo.len(), saved_docinfo.len())); + // 6. ParaShape 정보 (첫 문단의 줄간격 등) + if let Some(first_para) = section.paragraphs.first() { + let ps_id = first_para.para_shape_id as usize; + if ps_id < doc.document.doc_info.para_shapes.len() { + let ps = &doc.document.doc_info.para_shapes[ps_id]; + eprintln!("\n--- ParaShape[{}] (첫 문단 문단모양) ---", ps_id); + eprintln!(" line_spacing_type: {:?}", ps.line_spacing_type); + eprintln!(" line_spacing: {}", ps.line_spacing); + eprintln!(" line_spacing_v2: {}", ps.line_spacing_v2); + eprintln!(" margin_left: {}", ps.margin_left); + eprintln!(" margin_right: {}", ps.margin_right); } + } - if issues.is_empty() { - println!("\n No obvious issues found."); - } else { - println!("\n Found {} potential issues:", issues.len()); - for (i, issue) in issues.iter().enumerate() { - println!(" {}. {}", i + 1, issue); + // 7. CharShape 정보 (첫 문단의 글자 크기) + if let Some(first_para) = section.paragraphs.first() { + if let Some(first_cs) = first_para.char_shapes.first() { + let cs_id = first_cs.char_shape_id as usize; + if cs_id < doc.document.doc_info.char_shapes.len() { + let cs = &doc.document.doc_info.char_shapes[cs_id]; + eprintln!("\n--- CharShape[{}] (첫 문단 글자모양) ---", cs_id); + eprintln!( + " base_size: {} ({:.1}pt)", + cs.base_size, + cs.base_size as f64 / 100.0 + ); } } - - println!("\n Analysis complete."); } - #[test] - fn test_text_insert_detailed_diff() { - // 텍스트 삽입 후 저장된 파일의 모든 레코드를 원본과 상세 비교 - let path = "template/empty.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; - } + eprintln!("\n{}", "=".repeat(70)); +} - let orig_data = std::fs::read(path).unwrap(); - let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); +#[test] +fn test_save_text_only() { + // 단계 2: 빈 HWP에 텍스트만 삽입 → 저장 → 바이트 비교 + use crate::parser::record::Record; + use crate::parser::tags; - // 텍스트 삽입 - doc.insert_text_native(0, 0, 0, "가나다라마바사아"); - let saved = doc.export_hwp_native().unwrap(); + let path = "template/empty.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } - // 레코드 파싱 - use crate::parser::record::Record; - let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); - let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); - let orig_bt = orig_cfb.read_body_text_section(0, orig_doc.header.compressed, false).unwrap(); - let orig_recs = Record::read_all(&orig_bt).unwrap(); + let orig_data = std::fs::read(path).unwrap(); - let saved_doc = crate::parser::parse_hwp(&saved).unwrap(); - let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, saved_doc.header.compressed, false).unwrap(); - let saved_recs = Record::read_all(&saved_bt).unwrap(); + // 테스트 케이스: (파일명, 삽입 텍스트) + let test_cases = vec![ + ("save_test_korean.hwp", "가나다라마바사아"), + ("save_test_english.hwp", "Hello World"), + ("save_test_mixed.hwp", "안녕 Hello 123 !@#"), + ]; - eprintln!("\n=== 원본 레코드 ==="); - for (i, r) in orig_recs.iter().enumerate() { - eprintln!("[{:2}] tag={:3} (0x{:04x}) level={} size={}", i, r.tag_id, r.tag_id, r.level, r.data.len()); - } - eprintln!("\n=== 저장 레코드 ==="); - for (i, r) in saved_recs.iter().enumerate() { - eprintln!("[{:2}] tag={:3} (0x{:04x}) level={} size={}", i, r.tag_id, r.tag_id, r.level, r.data.len()); - } + for (filename, text) in &test_cases { + eprintln!("\n{}", "=".repeat(60)); + eprintln!(" 테스트: {} → '{}'", filename, text); + eprintln!("{}", "=".repeat(60)); - // 레코드별 상세 비교 - let max = orig_recs.len().max(saved_recs.len()); - eprintln!("\n=== 바이트 비교 ==="); - for i in 0..max { - let o = orig_recs.get(i); - let s = saved_recs.get(i); - match (o, s) { - (Some(or), Some(sr)) => { - if or.tag_id != sr.tag_id || or.level != sr.level || or.data != sr.data { - eprintln!("DIFF [{}]: tag={}/{} level={}/{} size={}/{}", - i, or.tag_id, sr.tag_id, or.level, sr.level, or.data.len(), sr.data.len()); - // HWP 레코드 태그 이름 매핑 - let tag_name = match or.tag_id { - 66 => "PARA_HEADER", - 67 => "PARA_TEXT", - 68 => "PARA_CHAR_SHAPE", - 69 => "PARA_LINE_SEG", - 70 => "CTRL_HEADER", - 71 => "LIST_HEADER", - _ => "UNKNOWN", - }; - eprintln!(" Record type: {}", tag_name); - - // 전체 데이터 헥스 덤프 - let orig_hex: Vec = or.data.iter().map(|b| format!("{:02x}", b)).collect(); - let save_hex: Vec = sr.data.iter().map(|b| format!("{:02x}", b)).collect(); - eprintln!(" ORIG[{}]: {}", or.data.len(), orig_hex.join(" ")); - eprintln!(" SAVE[{}]: {}", sr.data.len(), save_hex.join(" ")); - - // 바이트별 차이 표시 - let min_len = or.data.len().min(sr.data.len()); - for pos in 0..min_len { - if or.data[pos] != sr.data[pos] { - eprintln!(" Byte {}: 0x{:02x} → 0x{:02x}", pos, or.data[pos], sr.data[pos]); - } - } - if or.data.len() != sr.data.len() { - eprintln!(" Size diff: {} → {} (delta {})", or.data.len(), sr.data.len(), - sr.data.len() as i64 - or.data.len() as i64); - } - } else { - eprintln!("OK [{}]: tag={} level={} size={}", i, or.tag_id, or.level, or.data.len()); - } - } - (Some(or), None) => eprintln!("MISSING [{}]: tag={}", i, or.tag_id), - (None, Some(sr)) => eprintln!("EXTRA [{}]: tag={}", i, sr.tag_id), - _ => {} - } - } - } + let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); - #[test] - fn test_roundtrip_no_edit() { - // 편집 없이 raw_stream 무효화 → 재직렬화 → 저장 - // 재직렬화 자체에 문제가 있는지 분리 확인 - let path = "template/empty.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; - } + // 텍스트 삽입 (첫 구역, 첫 문단, 캐럿 위치 0) + let result = doc.insert_text_native(0, 0, 0, text); + assert!(result.is_ok(), "텍스트 삽입 실패: {:?}", result.err()); - let orig_data = std::fs::read(path).unwrap(); - let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); + // 삽입 후 문단 상태 확인 + let para = &doc.document.sections[0].paragraphs[0]; + eprintln!( + " 삽입 후: text='{}' char_count={}", + para.text, para.char_count + ); + eprintln!(" char_offsets: {:?}", ¶.char_offsets); + eprintln!( + " char_shapes: {:?}", + para.char_shapes + .iter() + .map(|cs| (cs.start_pos, cs.char_shape_id)) + .collect::>() + ); + for (i, ls) in para.line_segs.iter().enumerate() { + eprintln!(" LineSeg[{}]: text_start={} vpos={} lh={} th={} bd={} ls={} cs={} sw={} tag=0x{:08x}", + i, ls.text_start, ls.vertical_pos, ls.line_height, ls.text_height, + ls.baseline_distance, ls.line_spacing, ls.column_start, ls.segment_width, ls.tag); + } - // raw_stream 무효화 (재직렬화 유도) - doc.document.sections[0].raw_stream = None; + // HWP 저장 + let saved = doc.export_hwp_native(); + assert!(saved.is_ok(), "HWP 저장 실패: {:?}", saved.err()); + let saved_data = saved.unwrap(); - let saved = doc.export_hwp_native().unwrap(); + // 파일 출력 let _ = std::fs::create_dir_all("output"); - std::fs::write("output/empty_roundtrip.hwp", &saved).unwrap(); - eprintln!("output/empty_roundtrip.hwp 저장 ({} bytes)", saved.len()); + let out_path = format!("output/{}", filename); + std::fs::write(&out_path, &saved_data).unwrap(); + eprintln!(" 저장: {} ({} bytes)", out_path, saved_data.len()); // 재파싱 검증 - let doc2 = HwpDocument::from_bytes(&saved); + let doc2 = HwpDocument::from_bytes(&saved_data); assert!(doc2.is_ok(), "재파싱 실패: {:?}", doc2.err()); + let doc2 = doc2.unwrap(); + let para2 = &doc2.document.sections[0].paragraphs[0]; + eprintln!( + " 재파싱: text='{}' char_count={}", + para2.text, para2.char_count + ); + assert!( + para2.text.contains(text), + "재파싱 텍스트 불일치: expected '{}', got '{}'", + text, + para2.text + ); + + // 캐럿 위치 검증 + let caret = &doc2.document.doc_properties; + eprintln!( + " 캐럿: list_id={} para_id={} char_pos={}", + caret.caret_list_id, caret.caret_para_id, caret.caret_char_pos + ); + // 삽입 후 캐럿은 텍스트 마지막 글자 뒤여야 함 + let expected_caret_pos = 16u32 + + text + .chars() + .map(|c| if (c as u32) > 0xFFFF { 2u32 } else { 1u32 }) + .sum::(); + assert_eq!( + caret.caret_char_pos, expected_caret_pos, + "캐럿 위치 불일치: expected {} got {}", + expected_caret_pos, caret.caret_char_pos + ); - // 레코드별 비교 - use crate::parser::record::Record; + // BodyText 레코드 비교 (원본 vs 저장) let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); - let orig_bt = orig_cfb.read_body_text_section(0, orig_doc.header.compressed, false).unwrap(); + let orig_bt = orig_cfb + .read_body_text_section(0, orig_doc.header.compressed, false) + .unwrap(); let orig_recs = Record::read_all(&orig_bt).unwrap(); - let saved_doc = crate::parser::parse_hwp(&saved).unwrap(); - let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, saved_doc.header.compressed, false).unwrap(); + let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); + let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_doc.header.compressed, false) + .unwrap(); let saved_recs = Record::read_all(&saved_bt).unwrap(); - eprintln!("원본 레코드: {}, 재직렬화 레코드: {}", orig_recs.len(), saved_recs.len()); + eprintln!( + "\n --- 레코드 비교 (원본: {} / 저장: {}) ---", + orig_recs.len(), + saved_recs.len() + ); + let tag_name = |id: u16| -> &str { + match id { + 66 => "PARA_HEADER", + 67 => "PARA_TEXT", + 68 => "PARA_CHAR_SHAPE", + 69 => "PARA_LINE_SEG", + 70 => "CTRL_HEADER", + 71 => "LIST_HEADER", + _ => "OTHER", + } + }; let max = orig_recs.len().max(saved_recs.len()); for i in 0..max { @@ -8422,1622 +11740,2035 @@ let s = saved_recs.get(i); match (o, s) { (Some(or), Some(sr)) => { - if or.tag_id != sr.tag_id || or.level != sr.level || or.data != sr.data { - eprintln!("DIFF [{}]: tag={}/{} level={}/{} size={}/{}", - i, or.tag_id, sr.tag_id, or.level, sr.level, or.data.len(), sr.data.len()); - if or.data != sr.data { - let show = or.data.len().min(sr.data.len()).min(36); - eprintln!(" ORIG: {:02x?}", &or.data[..show]); - eprintln!(" SAVE: {:02x?}", &sr.data[..show]); - // 첫 번째 다른 바이트 위치 - for (pos, (a, b)) in or.data.iter().zip(sr.data.iter()).enumerate() { - if a != b { - eprintln!(" First diff at byte {}: 0x{:02x} vs 0x{:02x}", pos, a, b); - break; - } - } - } + let same = or.tag_id == sr.tag_id && or.level == sr.level && or.data == sr.data; + let status = if same { "OK " } else { "DIFF" }; + eprintln!( + " [{}] {} tag={:3}({}) level={}/{} size={}/{}", + i, + status, + or.tag_id, + tag_name(or.tag_id), + or.level, + sr.level, + or.data.len(), + sr.data.len() + ); + if !same { + let show = or.data.len().min(sr.data.len()).min(48); + let orig_hex: String = or.data[..show] + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(" "); + let save_hex: String = sr.data[..show] + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(" "); + eprintln!(" ORIG: {}", orig_hex); + eprintln!(" SAVE: {}", save_hex); } } - (Some(or), None) => eprintln!("MISSING in saved [{}]: tag={}", i, or.tag_id), - (None, Some(sr)) => eprintln!("EXTRA in saved [{}]: tag={}", i, sr.tag_id), + (Some(or), None) => eprintln!( + " [{}] MISSING tag={}({})", + i, + or.tag_id, + tag_name(or.tag_id) + ), + (None, Some(sr)) => eprintln!( + " [{}] EXTRA tag={}({})", + i, + sr.tag_id, + tag_name(sr.tag_id) + ), _ => {} } } - eprintln!("비교 완료"); } - - #[test] - fn test_empty_hwp_editing_area() { - // template/empty.hwp의 편집 영역, 캐럿 위치, LineSeg 값을 분석 - use crate::model::page::PageAreas; - - let path = "template/empty.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; - } - - let data = std::fs::read(path).unwrap(); - let doc = HwpDocument::from_bytes(&data).unwrap(); - - eprintln!("\n{}", "=".repeat(70)); - eprintln!(" EMPTY HWP 편집 영역 분석"); - eprintln!("{}", "=".repeat(70)); - - // 1. DocProperties 캐럿 정보 - let props = &doc.document.doc_properties; - eprintln!("\n--- DocProperties 캐럿 정보 ---"); - eprintln!(" caret_list_id: {}", props.caret_list_id); - eprintln!(" caret_para_id: {}", props.caret_para_id); - eprintln!(" caret_char_pos: {}", props.caret_char_pos); - - // 2. PageDef (용지 설정) - let section = &doc.document.sections[0]; - let page_def = §ion.section_def.page_def; - eprintln!("\n--- PageDef (용지 설정) ---"); - eprintln!(" width: {} HWPUNIT ({:.1}mm)", page_def.width, page_def.width as f64 / 283.46); - eprintln!(" height: {} HWPUNIT ({:.1}mm)", page_def.height, page_def.height as f64 / 283.46); - eprintln!(" margin_left: {} HWPUNIT ({:.1}mm)", page_def.margin_left, page_def.margin_left as f64 / 283.46); - eprintln!(" margin_right: {} HWPUNIT ({:.1}mm)", page_def.margin_right, page_def.margin_right as f64 / 283.46); - eprintln!(" margin_top: {} HWPUNIT ({:.1}mm)", page_def.margin_top, page_def.margin_top as f64 / 283.46); - eprintln!(" margin_bottom: {} HWPUNIT ({:.1}mm)", page_def.margin_bottom, page_def.margin_bottom as f64 / 283.46); - eprintln!(" margin_header: {} HWPUNIT ({:.1}mm)", page_def.margin_header, page_def.margin_header as f64 / 283.46); - eprintln!(" margin_footer: {} HWPUNIT ({:.1}mm)", page_def.margin_footer, page_def.margin_footer as f64 / 283.46); - eprintln!(" margin_gutter: {} HWPUNIT ({:.1}mm)", page_def.margin_gutter, page_def.margin_gutter as f64 / 283.46); - eprintln!(" landscape: {}", page_def.landscape); - - // 3. PageAreas (계산된 편집 영역) - let areas = PageAreas::from_page_def(page_def); - eprintln!("\n--- PageAreas (계산된 영역) ---"); - eprintln!(" header_area: left={} top={} right={} bottom={}", areas.header_area.left, areas.header_area.top, areas.header_area.right, areas.header_area.bottom); - eprintln!(" body_area: left={} top={} right={} bottom={}", areas.body_area.left, areas.body_area.top, areas.body_area.right, areas.body_area.bottom); - eprintln!(" body_area size: width={} height={}", areas.body_area.right - areas.body_area.left, areas.body_area.bottom - areas.body_area.top); - eprintln!(" footer_area: left={} top={} right={} bottom={}", areas.footer_area.left, areas.footer_area.top, areas.footer_area.right, areas.footer_area.bottom); - - // 4. 모든 문단의 LineSeg 정보 - eprintln!("\n--- 문단별 LineSeg 정보 ---"); - for (pi, para) in section.paragraphs.iter().enumerate() { - eprintln!(" 문단[{}]: text='{}' char_count={} controls={}", pi, para.text, para.char_count, para.controls.len()); - eprintln!(" char_shapes: {:?}", para.char_shapes.iter().map(|cs| (cs.start_pos, cs.char_shape_id)).collect::>()); - for (li, ls) in para.line_segs.iter().enumerate() { - eprintln!(" LineSeg[{}]:", li); - eprintln!(" text_start: {}", ls.text_start); - eprintln!(" vertical_pos: {} ({:.1}mm)", ls.vertical_pos, ls.vertical_pos as f64 / 283.46); - eprintln!(" line_height: {} ({:.1}mm)", ls.line_height, ls.line_height as f64 / 283.46); - eprintln!(" text_height: {} ({:.1}mm)", ls.text_height, ls.text_height as f64 / 283.46); - eprintln!(" baseline_distance: {} ({:.1}mm)", ls.baseline_distance, ls.baseline_distance as f64 / 283.46); - eprintln!(" line_spacing: {} ({:.1}mm)", ls.line_spacing, ls.line_spacing as f64 / 283.46); - eprintln!(" column_start: {} ({:.1}mm)", ls.column_start, ls.column_start as f64 / 283.46); - eprintln!(" segment_width: {} ({:.1}mm)", ls.segment_width, ls.segment_width as f64 / 283.46); - eprintln!(" tag: 0x{:08x} (first_of_page={} first_of_col={})", - ls.tag, ls.is_first_line_of_page(), ls.is_first_line_of_column()); - } - } - - // 5. 편집 영역 첫 줄 캐럿 위치 분석 - if let Some(first_para) = section.paragraphs.first() { - if let Some(first_ls) = first_para.line_segs.first() { - eprintln!("\n--- 편집 영역 첫 줄 캐럿 위치 분석 ---"); - eprintln!(" body_area.top (계산값): {}", areas.body_area.top); - eprintln!(" LineSeg.vertical_pos (실제): {}", first_ls.vertical_pos); - eprintln!(" 차이: {}", first_ls.vertical_pos - areas.body_area.top); - eprintln!(" body_area.left (계산값): {}", areas.body_area.left); - eprintln!(" LineSeg.column_start (실제): {}", first_ls.column_start); - eprintln!(" 차이: {}", first_ls.column_start - areas.body_area.left); - let body_width = areas.body_area.right - areas.body_area.left; - eprintln!(" body_area.width (계산값): {}", body_width); - eprintln!(" LineSeg.segment_width (실제): {}", first_ls.segment_width); - eprintln!(" 차이: {}", first_ls.segment_width - body_width); - } - } - - // 6. ParaShape 정보 (첫 문단의 줄간격 등) - if let Some(first_para) = section.paragraphs.first() { - let ps_id = first_para.para_shape_id as usize; - if ps_id < doc.document.doc_info.para_shapes.len() { - let ps = &doc.document.doc_info.para_shapes[ps_id]; - eprintln!("\n--- ParaShape[{}] (첫 문단 문단모양) ---", ps_id); - eprintln!(" line_spacing_type: {:?}", ps.line_spacing_type); - eprintln!(" line_spacing: {}", ps.line_spacing); - eprintln!(" line_spacing_v2: {}", ps.line_spacing_v2); - eprintln!(" margin_left: {}", ps.margin_left); - eprintln!(" margin_right: {}", ps.margin_right); - } - } - - // 7. CharShape 정보 (첫 문단의 글자 크기) - if let Some(first_para) = section.paragraphs.first() { - if let Some(first_cs) = first_para.char_shapes.first() { - let cs_id = first_cs.char_shape_id as usize; - if cs_id < doc.document.doc_info.char_shapes.len() { - let cs = &doc.document.doc_info.char_shapes[cs_id]; - eprintln!("\n--- CharShape[{}] (첫 문단 글자모양) ---", cs_id); - eprintln!(" base_size: {} ({:.1}pt)", cs.base_size, cs.base_size as f64 / 100.0); - } - } - } - - eprintln!("\n{}", "=".repeat(70)); + eprintln!("\n=== 단계 2 텍스트 저장 검증 완료 ==="); +} + +#[test] +fn test_analyze_reference_table() { + // 참조 파일 분석: HWP 프로그램으로 표 1개만 삽입한 파일 + use crate::parser::cfb_reader::LenientCfbReader; + use crate::parser::record::Record; + use crate::parser::tags; + + let path = "output/1by1-table.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; } - #[test] - fn test_save_text_only() { - // 단계 2: 빈 HWP에 텍스트만 삽입 → 저장 → 바이트 비교 - use crate::parser::record::Record; - use crate::parser::tags; - - let path = "template/empty.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; - } - - let orig_data = std::fs::read(path).unwrap(); - - // 테스트 케이스: (파일명, 삽입 텍스트) - let test_cases = vec![ - ("save_test_korean.hwp", "가나다라마바사아"), - ("save_test_english.hwp", "Hello World"), - ("save_test_mixed.hwp", "안녕 Hello 123 !@#"), - ]; - - for (filename, text) in &test_cases { - eprintln!("\n{}", "=".repeat(60)); - eprintln!(" 테스트: {} → '{}'", filename, text); - eprintln!("{}", "=".repeat(60)); - - let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); - - // 텍스트 삽입 (첫 구역, 첫 문단, 캐럿 위치 0) - let result = doc.insert_text_native(0, 0, 0, text); - assert!(result.is_ok(), "텍스트 삽입 실패: {:?}", result.err()); - - // 삽입 후 문단 상태 확인 - let para = &doc.document.sections[0].paragraphs[0]; - eprintln!(" 삽입 후: text='{}' char_count={}", para.text, para.char_count); - eprintln!(" char_offsets: {:?}", ¶.char_offsets); - eprintln!(" char_shapes: {:?}", para.char_shapes.iter().map(|cs| (cs.start_pos, cs.char_shape_id)).collect::>()); - for (i, ls) in para.line_segs.iter().enumerate() { - eprintln!(" LineSeg[{}]: text_start={} vpos={} lh={} th={} bd={} ls={} cs={} sw={} tag=0x{:08x}", - i, ls.text_start, ls.vertical_pos, ls.line_height, ls.text_height, - ls.baseline_distance, ls.line_spacing, ls.column_start, ls.segment_width, ls.tag); + let data = std::fs::read(path).unwrap(); + + // 표준 cfb 크레이트로 열기 시도, 실패하면 lenient 리더 사용 + let doc = match HwpDocument::from_bytes(&data) { + Ok(d) => d, + Err(e) => { + eprintln!(" 표준 파서 실패 ({}), LenientCfbReader로 분석합니다.", e); + // LenientCfbReader로 직접 스트림 추출 후 분석 + let lcfb = LenientCfbReader::open(&data).unwrap(); + + eprintln!("\n [LenientCFB 엔트리 목록]"); + for (name, start, size, otype) in lcfb.list_entries() { + let tname = match otype { + 1 => "storage", + 2 => "stream", + 5 => "root", + _ => "?", + }; + eprintln!( + " {:20} start={:5} size={:8} type={}", + name, start, size, tname + ); } - // HWP 저장 - let saved = doc.export_hwp_native(); - assert!(saved.is_ok(), "HWP 저장 실패: {:?}", saved.err()); - let saved_data = saved.unwrap(); + // FileHeader 읽기 + let fh = lcfb.read_stream("FileHeader").unwrap(); + let compressed = fh.len() >= 37 && (fh[36] & 0x01) != 0; + eprintln!( + "\n FileHeader: {} bytes, compressed={}", + fh.len(), + compressed + ); - // 파일 출력 - let _ = std::fs::create_dir_all("output"); - let out_path = format!("output/{}", filename); - std::fs::write(&out_path, &saved_data).unwrap(); - eprintln!(" 저장: {} ({} bytes)", out_path, saved_data.len()); - - // 재파싱 검증 - let doc2 = HwpDocument::from_bytes(&saved_data); - assert!(doc2.is_ok(), "재파싱 실패: {:?}", doc2.err()); - let doc2 = doc2.unwrap(); - let para2 = &doc2.document.sections[0].paragraphs[0]; - eprintln!(" 재파싱: text='{}' char_count={}", para2.text, para2.char_count); - assert!(para2.text.contains(text), "재파싱 텍스트 불일치: expected '{}', got '{}'", text, para2.text); - - // 캐럿 위치 검증 - let caret = &doc2.document.doc_properties; - eprintln!(" 캐럿: list_id={} para_id={} char_pos={}", caret.caret_list_id, caret.caret_para_id, caret.caret_char_pos); - // 삽입 후 캐럿은 텍스트 마지막 글자 뒤여야 함 - let expected_caret_pos = 16u32 + text.chars().map(|c| if (c as u32) > 0xFFFF { 2u32 } else { 1u32 }).sum::(); - assert_eq!(caret.caret_char_pos, expected_caret_pos, - "캐럿 위치 불일치: expected {} got {}", expected_caret_pos, caret.caret_char_pos); - - // BodyText 레코드 비교 (원본 vs 저장) - let orig_doc = crate::parser::parse_hwp(&orig_data).unwrap(); - let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); - let orig_bt = orig_cfb.read_body_text_section(0, orig_doc.header.compressed, false).unwrap(); - let orig_recs = Record::read_all(&orig_bt).unwrap(); - - let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); - let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, saved_doc.header.compressed, false).unwrap(); - let saved_recs = Record::read_all(&saved_bt).unwrap(); - - eprintln!("\n --- 레코드 비교 (원본: {} / 저장: {}) ---", orig_recs.len(), saved_recs.len()); - let tag_name = |id: u16| -> &str { - match id { - 66 => "PARA_HEADER", - 67 => "PARA_TEXT", - 68 => "PARA_CHAR_SHAPE", - 69 => "PARA_LINE_SEG", - 70 => "CTRL_HEADER", - 71 => "LIST_HEADER", - _ => "OTHER", - } - }; + // DocInfo 읽기 & 파싱 + let di_data = lcfb.read_doc_info(compressed).unwrap(); + let di_recs = Record::read_all(&di_data).unwrap(); + eprintln!( + " DocInfo: {} bytes → {} 레코드", + di_data.len(), + di_recs.len() + ); - let max = orig_recs.len().max(saved_recs.len()); - for i in 0..max { - let o = orig_recs.get(i); - let s = saved_recs.get(i); - match (o, s) { - (Some(or), Some(sr)) => { - let same = or.tag_id == sr.tag_id && or.level == sr.level && or.data == sr.data; - let status = if same { "OK " } else { "DIFF" }; - eprintln!(" [{}] {} tag={:3}({}) level={}/{} size={}/{}", - i, status, or.tag_id, tag_name(or.tag_id), - or.level, sr.level, or.data.len(), sr.data.len()); - if !same { - let show = or.data.len().min(sr.data.len()).min(48); - let orig_hex: String = or.data[..show].iter().map(|b| format!("{:02x}", b)).collect::>().join(" "); - let save_hex: String = sr.data[..show].iter().map(|b| format!("{:02x}", b)).collect::>().join(" "); - eprintln!(" ORIG: {}", orig_hex); - eprintln!(" SAVE: {}", save_hex); + // DocProperties (첫 번째 레코드) + if let Some(dp_rec) = di_recs.first() { + if dp_rec.tag_id == tags::HWPTAG_DOCUMENT_PROPERTIES && dp_rec.data.len() >= 26 { + let d = &dp_rec.data; + let caret_list_id = u32::from_le_bytes([d[14], d[15], d[16], d[17]]); + let caret_para_id = u32::from_le_bytes([d[18], d[19], d[20], d[21]]); + let caret_char_pos = u32::from_le_bytes([d[22], d[23], d[24], d[25]]); + eprintln!("\n [캐럿 위치 (raw)]"); + eprintln!(" caret_list_id: {}", caret_list_id); + eprintln!(" caret_para_id: {}", caret_para_id); + eprintln!(" caret_char_pos: {}", caret_char_pos); + } + } + + // ID_MAPPINGS (두 번째 레코드) + if di_recs.len() > 1 && di_recs[1].tag_id == tags::HWPTAG_ID_MAPPINGS { + let d = &di_recs[1].data; + if d.len() >= 72 { + eprintln!("\n [ID_MAPPINGS]"); + let labels = [ + "bin_data", + "font_kr", + "font_en", + "font_cn", + "font_jp", + "font_etc", + "font_sym", + "font_usr", + "border_fill", + "char_shape", + "tab_def", + "numbering", + "bullet", + "para_shape", + "style", + "memo_shape", + "trackchange", + "trackchange_author", + ]; + for (i, label) in labels.iter().enumerate() { + let off = i * 4; + let val = u32::from_le_bytes([d[off], d[off + 1], d[off + 2], d[off + 3]]); + if val > 0 { + eprintln!(" {:20}: {}", label, val); } } - (Some(or), None) => eprintln!(" [{}] MISSING tag={}({})", i, or.tag_id, tag_name(or.tag_id)), - (None, Some(sr)) => eprintln!(" [{}] EXTRA tag={}({})", i, sr.tag_id, tag_name(sr.tag_id)), - _ => {} } } - } - eprintln!("\n=== 단계 2 텍스트 저장 검증 완료 ==="); - } - - #[test] - fn test_analyze_reference_table() { - // 참조 파일 분석: HWP 프로그램으로 표 1개만 삽입한 파일 - use crate::parser::record::Record; - use crate::parser::tags; - use crate::parser::cfb_reader::LenientCfbReader; - - let path = "output/1by1-table.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; - } - - let data = std::fs::read(path).unwrap(); - - // 표준 cfb 크레이트로 열기 시도, 실패하면 lenient 리더 사용 - let doc = match HwpDocument::from_bytes(&data) { - Ok(d) => d, - Err(e) => { - eprintln!(" 표준 파서 실패 ({}), LenientCfbReader로 분석합니다.", e); - // LenientCfbReader로 직접 스트림 추출 후 분석 - let lcfb = LenientCfbReader::open(&data).unwrap(); - - eprintln!("\n [LenientCFB 엔트리 목록]"); - for (name, start, size, otype) in lcfb.list_entries() { - let tname = match otype { 1 => "storage", 2 => "stream", 5 => "root", _ => "?" }; - eprintln!(" {:20} start={:5} size={:8} type={}", name, start, size, tname); - } - - // FileHeader 읽기 - let fh = lcfb.read_stream("FileHeader").unwrap(); - let compressed = fh.len() >= 37 && (fh[36] & 0x01) != 0; - eprintln!("\n FileHeader: {} bytes, compressed={}", fh.len(), compressed); - - // DocInfo 읽기 & 파싱 - let di_data = lcfb.read_doc_info(compressed).unwrap(); - let di_recs = Record::read_all(&di_data).unwrap(); - eprintln!(" DocInfo: {} bytes → {} 레코드", di_data.len(), di_recs.len()); - - // DocProperties (첫 번째 레코드) - if let Some(dp_rec) = di_recs.first() { - if dp_rec.tag_id == tags::HWPTAG_DOCUMENT_PROPERTIES && dp_rec.data.len() >= 26 { - let d = &dp_rec.data; - let caret_list_id = u32::from_le_bytes([d[14], d[15], d[16], d[17]]); - let caret_para_id = u32::from_le_bytes([d[18], d[19], d[20], d[21]]); - let caret_char_pos = u32::from_le_bytes([d[22], d[23], d[24], d[25]]); - eprintln!("\n [캐럿 위치 (raw)]"); - eprintln!(" caret_list_id: {}", caret_list_id); - eprintln!(" caret_para_id: {}", caret_para_id); - eprintln!(" caret_char_pos: {}", caret_char_pos); - } - } - - // ID_MAPPINGS (두 번째 레코드) - if di_recs.len() > 1 && di_recs[1].tag_id == tags::HWPTAG_ID_MAPPINGS { - let d = &di_recs[1].data; - if d.len() >= 72 { - eprintln!("\n [ID_MAPPINGS]"); - let labels = ["bin_data", "font_kr", "font_en", "font_cn", "font_jp", - "font_etc", "font_sym", "font_usr", "border_fill", "char_shape", - "tab_def", "numbering", "bullet", "para_shape", "style", - "memo_shape", "trackchange", "trackchange_author"]; - for (i, label) in labels.iter().enumerate() { - let off = i * 4; - let val = u32::from_le_bytes([d[off], d[off+1], d[off+2], d[off+3]]); - if val > 0 { - eprintln!(" {:20}: {}", label, val); - } - } - } - } - - // BorderFill 레코드 덤프 - eprintln!("\n [DocInfo BORDER_FILL 레코드]"); - for (i, r) in di_recs.iter().enumerate() { - if r.tag_id == tags::HWPTAG_BORDER_FILL { - eprintln!(" [{:2}] BORDER_FILL size={} data: {:02x?}", - i, r.data.len(), &r.data[..r.data.len().min(60)]); - } - } - - // BodyText/Section0 읽기 - let bt_data = lcfb.read_body_text_section(0, compressed).unwrap(); - let bt_recs = Record::read_all(&bt_data).unwrap(); - eprintln!("\n [BodyText 레코드 덤프] ({} 개)", bt_recs.len()); - for (i, r) in bt_recs.iter().enumerate() { - let tname = tags::tag_name(r.tag_id); - let mut extra = String::new(); - if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { - let cid = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); - extra = format!(" ctrl='{}'", tags::ctrl_name(cid)); - } - eprintln!(" [{:2}] tag={:3}({:22}) level={} size={}{}", - i, r.tag_id, tname, r.level, r.data.len(), extra); - // 주요 레코드 데이터 덤프 - if matches!(r.tag_id, - 71 | 72 | 77 | // CTRL_HEADER, LIST_HEADER, TABLE - 66 | 67 | 68 | 69 // PARA_HEADER, PARA_TEXT, PARA_CHAR_SHAPE, PARA_LINE_SEG - ) { - let show = r.data.len().min(80); - eprintln!(" data[..{}]: {:02x?}", show, &r.data[..show]); - } - } - - // empty.hwp와 비교 - let empty_path = "template/empty.hwp"; - if std::path::Path::new(empty_path).exists() { - let empty_data = std::fs::read(empty_path).unwrap(); - let empty_parsed = crate::parser::parse_hwp(&empty_data).unwrap(); - let mut empty_cfb = crate::parser::cfb_reader::CfbReader::open(&empty_data).unwrap(); - let empty_bt = empty_cfb.read_body_text_section(0, empty_parsed.header.compressed, false).unwrap(); - let empty_recs = Record::read_all(&empty_bt).unwrap(); - eprintln!("\n [비교] empty.hwp={} 개, roundtrip.hwp={} 개 → 추가={} 개", - empty_recs.len(), bt_recs.len(), bt_recs.len() as i32 - empty_recs.len() as i32); + // BorderFill 레코드 덤프 + eprintln!("\n [DocInfo BORDER_FILL 레코드]"); + for (i, r) in di_recs.iter().enumerate() { + if r.tag_id == tags::HWPTAG_BORDER_FILL { + eprintln!( + " [{:2}] BORDER_FILL size={} data: {:02x?}", + i, + r.data.len(), + &r.data[..r.data.len().min(60)] + ); } - - eprintln!("\n=== 참조 파일 분석 완료 (LenientCfbReader) ==="); - return; } - }; - let doc = doc; - - eprintln!("\n{}", "=".repeat(60)); - eprintln!(" 참조 파일 분석: {}", path); - eprintln!("{}", "=".repeat(60)); - - // 1. 캐럿 위치 정보 - let dp = &doc.document.doc_properties; - eprintln!("\n [캐럿 위치]"); - eprintln!(" caret_list_id: {}", dp.caret_list_id); - eprintln!(" caret_para_id: {}", dp.caret_para_id); - eprintln!(" caret_char_pos: {}", dp.caret_char_pos); - // 2. 섹션/문단 구조 - for (si, sec) in doc.document.sections.iter().enumerate() { - eprintln!("\n [섹션 {}] 문단 수: {}", si, sec.paragraphs.len()); - for (pi, para) in sec.paragraphs.iter().enumerate() { - eprintln!(" 문단[{}]: text='{}' char_count={} controls={} char_offsets={:?}", - pi, para.text, para.char_count, para.controls.len(), para.char_offsets); - eprintln!(" control_mask=0x{:08X} para_shape_id={} style_id={}", - para.control_mask, para.para_shape_id, para.style_id); - eprintln!(" char_shapes: {:?}", para.char_shapes); - eprintln!(" line_segs: {:?}", para.line_segs); - eprintln!(" raw_header_extra({} bytes): {:02x?}", - para.raw_header_extra.len(), - ¶.raw_header_extra[..para.raw_header_extra.len().min(20)]); + // BodyText/Section0 읽기 + let bt_data = lcfb.read_body_text_section(0, compressed).unwrap(); + let bt_recs = Record::read_all(&bt_data).unwrap(); - for (ci, ctrl) in para.controls.iter().enumerate() { - match ctrl { - crate::model::control::Control::SectionDef(_) => - eprintln!(" ctrl[{}]: SectionDef", ci), - crate::model::control::Control::ColumnDef(_) => - eprintln!(" ctrl[{}]: ColumnDef", ci), - crate::model::control::Control::Table(t) => { - eprintln!(" ctrl[{}]: Table {}x{} cells={} attr=0x{:08X}", - ci, t.row_count, t.col_count, t.cells.len(), t.attr); - eprintln!(" raw_ctrl_data({} bytes): {:02x?}", - t.raw_ctrl_data.len(), &t.raw_ctrl_data[..t.raw_ctrl_data.len().min(40)]); - eprintln!(" raw_table_record_attr=0x{:08X}", t.raw_table_record_attr); - eprintln!(" raw_table_record_extra({} bytes): {:02x?}", - t.raw_table_record_extra.len(), &t.raw_table_record_extra[..t.raw_table_record_extra.len().min(20)]); - eprintln!(" padding: l={} r={} t={} b={}", t.padding.left, t.padding.right, t.padding.top, t.padding.bottom); - eprintln!(" cell_spacing={} border_fill_id={} row_sizes={:?}", - t.cell_spacing, t.border_fill_id, t.row_sizes); - for (celli, cell) in t.cells.iter().enumerate() { - eprintln!(" cell[{}]: col={} row={} span={}x{} w={} h={} bfid={}", - celli, cell.col, cell.row, cell.col_span, cell.row_span, - cell.width, cell.height, cell.border_fill_id); - eprintln!(" padding: l={} r={} t={} b={}", - cell.padding.left, cell.padding.right, cell.padding.top, cell.padding.bottom); - eprintln!(" list_header_width_ref={} raw_list_extra({} bytes): {:02x?}", - cell.list_header_width_ref, cell.raw_list_extra.len(), - &cell.raw_list_extra[..cell.raw_list_extra.len().min(20)]); - for (cpi, cp) in cell.paragraphs.iter().enumerate() { - eprintln!(" para[{}]: text='{}' cc={} cs={:?} ls={:?}", - cpi, cp.text, cp.char_count, cp.char_shapes, cp.line_segs); - eprintln!(" control_mask=0x{:08X} raw_header_extra({} bytes): {:02x?}", - cp.control_mask, cp.raw_header_extra.len(), - &cp.raw_header_extra[..cp.raw_header_extra.len().min(20)]); - } - } - }, - _ => eprintln!(" ctrl[{}]: {:?}", ci, std::mem::discriminant(ctrl)), - } - } + eprintln!("\n [BodyText 레코드 덤프] ({} 개)", bt_recs.len()); + for (i, r) in bt_recs.iter().enumerate() { + let tname = tags::tag_name(r.tag_id); + let mut extra = String::new(); + if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { + let cid = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); + extra = format!(" ctrl='{}'", tags::ctrl_name(cid)); + } + eprintln!( + " [{:2}] tag={:3}({:22}) level={} size={}{}", + i, + r.tag_id, + tname, + r.level, + r.data.len(), + extra + ); + // 주요 레코드 데이터 덤프 + if matches!( + r.tag_id, + 71 | 72 | 77 | // CTRL_HEADER, LIST_HEADER, TABLE + 66 | 67 | 68 | 69 // PARA_HEADER, PARA_TEXT, PARA_CHAR_SHAPE, PARA_LINE_SEG + ) { + let show = r.data.len().min(80); + eprintln!(" data[..{}]: {:02x?}", show, &r.data[..show]); + } + } + + // empty.hwp와 비교 + let empty_path = "template/empty.hwp"; + if std::path::Path::new(empty_path).exists() { + let empty_data = std::fs::read(empty_path).unwrap(); + let empty_parsed = crate::parser::parse_hwp(&empty_data).unwrap(); + let mut empty_cfb = + crate::parser::cfb_reader::CfbReader::open(&empty_data).unwrap(); + let empty_bt = empty_cfb + .read_body_text_section(0, empty_parsed.header.compressed, false) + .unwrap(); + let empty_recs = Record::read_all(&empty_bt).unwrap(); + eprintln!( + "\n [비교] empty.hwp={} 개, roundtrip.hwp={} 개 → 추가={} 개", + empty_recs.len(), + bt_recs.len(), + bt_recs.len() as i32 - empty_recs.len() as i32 + ); } - } - // 3. BodyText 레코드 덤프 - let mut cfb = crate::parser::cfb_reader::CfbReader::open(&data).unwrap(); - let parsed = crate::parser::parse_hwp(&data).unwrap(); - let bt = cfb.read_body_text_section(0, parsed.header.compressed, false).unwrap(); - let recs = Record::read_all(&bt).unwrap(); - - eprintln!("\n [BodyText 레코드 덤프] ({} 개)", recs.len()); - for (i, r) in recs.iter().enumerate() { - let tname = tags::tag_name(r.tag_id); - let mut extra = String::new(); - if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { - let cid = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); - extra = format!(" ctrl='{}'", tags::ctrl_name(cid)); - } - eprintln!(" [{:2}] tag={:3}({:22}) level={} size={}{}", - i, r.tag_id, tname, r.level, r.data.len(), extra); - // CTRL_HEADER, TABLE, LIST_HEADER 데이터 덤프 - if matches!(r.tag_id, 71 | 72 | 77) { // CTRL_HEADER, LIST_HEADER, TABLE - let show = r.data.len().min(60); - eprintln!(" data[..{}]: {:02x?}", show, &r.data[..show]); - } - } - - // 4. 원본 empty.hwp 레코드 수 비교 - let empty_path = "template/empty.hwp"; - if std::path::Path::new(empty_path).exists() { - let empty_data = std::fs::read(empty_path).unwrap(); - let empty_parsed = crate::parser::parse_hwp(&empty_data).unwrap(); - let mut empty_cfb = crate::parser::cfb_reader::CfbReader::open(&empty_data).unwrap(); - let empty_bt = empty_cfb.read_body_text_section(0, empty_parsed.header.compressed, false).unwrap(); - let empty_recs = Record::read_all(&empty_bt).unwrap(); - eprintln!("\n [비교] empty.hwp={} 개, roundtrip.hwp={} 개 → 차이={} 개", - empty_recs.len(), recs.len(), recs.len() as i32 - empty_recs.len() as i32); - } - - // 5. DocInfo 분석 - eprintln!("\n [DocInfo]"); - eprintln!(" bin_data_count: {}", doc.document.doc_info.bin_data_list.len()); - eprintln!(" border_fill_count: {}", doc.document.doc_info.border_fills.len()); - eprintln!(" char_shape_count: {}", doc.document.doc_info.char_shapes.len()); - eprintln!(" para_shape_count: {}", doc.document.doc_info.para_shapes.len()); - - // 6. BorderFill 상세 분석 - eprintln!("\n [BorderFill 상세]"); - for (bi, bf) in doc.document.doc_info.border_fills.iter().enumerate() { - eprintln!(" bf[{}]: borders=[{:?}, {:?}, {:?}, {:?}] diag={:?}", - bi, bf.borders[0], bf.borders[1], bf.borders[2], bf.borders[3], bf.diagonal); - eprintln!(" attr={} fill={:?}", bf.attr, bf.fill); - if let Some(ref raw) = bf.raw_data { - let show = raw.len().min(60); - eprintln!(" raw_data({} bytes): {:02x?}", raw.len(), &raw[..show]); - } - } - - eprintln!("\n=== 참조 파일 분석 완료 ==="); - } - - #[test] - fn test_save_table_1x1() { - // 단계 3: 빈 HWP에 1×1 표 삽입 → 저장 - // 참조: output/1by1-table.hwp (HWP 프로그램으로 생성한 1x1 표) - use crate::model::table::{Table, Cell}; - use crate::model::control::Control; - use crate::model::Padding; - use crate::parser::record::Record; - - let path = "template/empty.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); + eprintln!("\n=== 참조 파일 분석 완료 (LenientCfbReader) ==="); return; } + }; + let doc = doc; - let orig_data = std::fs::read(path).unwrap(); - let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); - - eprintln!("\n{}", "=".repeat(60)); - eprintln!(" 단계 3: 1×1 표 삽입 → 저장 (참조파일 기반)"); - eprintln!("{}", "=".repeat(60)); - - // 참조 파일의 값을 사용하여 표 생성 - // cell_width=41954, cell_height=282 (참조 파일 기준) - let table_width: u32 = 41954; // 참조 파일과 동일 - let table_height: u32 = 1282; // 참조 파일과 동일 - let cell_width: u32 = 41954; - let cell_height: u32 = 282; - - // 셀 내부 문단: 빈 문단 (CR만, char_count=1, MSB set) - let cell_seg_width = 40932; // 참조: cell_width - 패딩(510+510) - 2 - let cell_para = Paragraph { - text: String::new(), - char_count: 1, - char_count_msb: true, // 참조: 0x80000001 - control_mask: 0, - para_shape_id: 0, // empty.hwp의 기존 para_shape 사용 - style_id: 0, - char_shapes: vec![crate::model::paragraph::CharShapeRef { - start_pos: 0, - char_shape_id: 0, // empty.hwp의 기존 char_shape 사용 - }], - line_segs: vec![LineSeg { - text_start: 0, - vertical_pos: 0, - line_height: 1000, - text_height: 1000, - baseline_distance: 850, - line_spacing: 600, - column_start: 0, - segment_width: cell_seg_width, - tag: 0x00060000, - }], - has_para_text: false, // 빈 문단: PARA_TEXT 없음 - raw_header_extra: vec![0x01,0x00,0x00,0x00, 0x01,0x00, 0x00,0x00,0x00,0x00], - ..Default::default() - }; - - let cell = Cell { - col: 0, row: 0, col_span: 1, row_span: 1, - width: cell_width, - height: cell_height, - border_fill_id: 1, - padding: Padding { left: 510, right: 510, top: 141, bottom: 141 }, // 참조값 - list_header_width_ref: 0, - // raw_list_extra: 참조파일의 13바이트 (width + zeros) - raw_list_extra: { - let mut v = Vec::new(); - v.extend_from_slice(&cell_width.to_le_bytes()); // [e2,a3,00,00] - v.extend_from_slice(&[0u8; 9]); // zeros - v - }, - paragraphs: vec![cell_para], - ..Default::default() - }; - - // CommonObjAttr 바이너리 생성 (참조 파일의 raw_ctrl_data 38바이트) - let raw_ctrl_data = { - let mut v = Vec::new(); - v.extend_from_slice(&0u32.to_le_bytes()); // y_offset = 0 - v.extend_from_slice(&0u32.to_le_bytes()); // x_offset = 0 - v.extend_from_slice(&table_width.to_le_bytes()); // width - v.extend_from_slice(&table_height.to_le_bytes()); // height - v.extend_from_slice(&1u32.to_le_bytes()); // z_order = 1 - v.extend_from_slice(&283u16.to_le_bytes()); // margin_left - v.extend_from_slice(&283u16.to_le_bytes()); // margin_right - v.extend_from_slice(&283u16.to_le_bytes()); // margin_top - v.extend_from_slice(&283u16.to_le_bytes()); // margin_bottom - v.extend_from_slice(&0x7C1E9738u32.to_le_bytes()); // instance_id - v.extend_from_slice(&0u32.to_le_bytes()); // unknown1 - v.extend_from_slice(&0u16.to_le_bytes()); // unknown2 - v - }; - - // DocInfo에 실선 테두리 BorderFill 추가 (참조: bf[0]) - use crate::model::style::{BorderFill, BorderLine, BorderLineType, DiagonalLine, Fill}; - let solid_border = BorderLine { line_type: BorderLineType::Solid, width: 1, color: 0 }; - let new_bf = BorderFill { - raw_data: None, - attr: 0, - borders: [solid_border, solid_border, solid_border, solid_border], - diagonal: DiagonalLine { diagonal_type: 1, width: 0, color: 0 }, - fill: Fill::default(), - }; - doc.document.doc_info.border_fills.push(new_bf); - let table_bf_id = doc.document.doc_info.border_fills.len() as u16; // 1-based ID - - let table = Table { - attr: 0x082A2210, // 참조: CommonObjAttr flags - row_count: 1, - col_count: 1, - cell_spacing: 0, - padding: Padding { left: 510, right: 510, top: 141, bottom: 141 }, // 참조값 - row_sizes: vec![1], - border_fill_id: table_bf_id, - cells: { - // cell의 border_fill_id도 갱신 - let mut c = cell; - c.border_fill_id = table_bf_id; - vec![c] - }, - raw_ctrl_data, - raw_table_record_attr: 6, // 참조: attr=6 - raw_table_record_extra: vec![0x00, 0x00], // 참조: 2바이트 - ..Default::default() - }; - eprintln!(" DocInfo: border_fill_count={}, table_bf_id={}", doc.document.doc_info.border_fills.len(), table_bf_id); - - // 첫 번째 문단에 Table 컨트롤 추가 - { - let para = &mut doc.document.sections[0].paragraphs[0]; - para.controls.push(Control::Table(Box::new(table))); - para.ctrl_data_records.push(None); - para.char_count += 8; // 표 제어문자 8 code units - para.control_mask = 0x00000804; // 참조: 표가 있는 문단의 control_mask - - // 표가 있는 문단의 segment_width는 0 (참조 파일) - if let Some(ls) = para.line_segs.first_mut() { - ls.segment_width = 0; - } - } - - // 두 번째 빈 문단 추가 (HWP는 표 삽입 시 아래에 빈 문단을 자동 추가) - let empty_para = Paragraph { - text: String::new(), - char_count: 1, // CR만 - char_count_msb: true, // 참조: 0x80000001 - control_mask: 0, - para_shape_id: 0, // empty.hwp의 기존 para_shape 사용 - style_id: 0, - char_shapes: vec![crate::model::paragraph::CharShapeRef { - start_pos: 0, - char_shape_id: 0, - }], - line_segs: vec![LineSeg { - text_start: 0, - vertical_pos: 1848, // 참조: 표 아래 위치 - line_height: 1000, - text_height: 1000, - baseline_distance: 850, - line_spacing: 600, - column_start: 0, - segment_width: 42520, // 참조: 편집 영역 전체 너비 - tag: 0x00060000, - }], - has_para_text: false, - raw_header_extra: vec![0x01,0x00,0x00,0x00, 0x01,0x00, 0x00,0x00,0x00,0x00], - ..Default::default() - }; - doc.document.sections[0].paragraphs.push(empty_para); + eprintln!("\n{}", "=".repeat(60)); + eprintln!(" 참조 파일 분석: {}", path); + eprintln!("{}", "=".repeat(60)); - // raw_stream 무효화 (재직렬화 유도) - doc.document.sections[0].raw_stream = None; + // 1. 캐럿 위치 정보 + let dp = &doc.document.doc_properties; + eprintln!("\n [캐럿 위치]"); + eprintln!(" caret_list_id: {}", dp.caret_list_id); + eprintln!(" caret_para_id: {}", dp.caret_para_id); + eprintln!(" caret_char_pos: {}", dp.caret_char_pos); - // 캐럿 위치: 두 번째 문단(표 아래 빈 줄) 시작 - doc.document.doc_properties.caret_list_id = 1; // 문단 인덱스 1 - doc.document.doc_properties.caret_para_id = 0; - doc.document.doc_properties.caret_char_pos = 0; - doc.document.doc_info.raw_stream = None; - doc.document.doc_properties.raw_data = None; + // 2. 섹션/문단 구조 + for (si, sec) in doc.document.sections.iter().enumerate() { + eprintln!("\n [섹션 {}] 문단 수: {}", si, sec.paragraphs.len()); + for (pi, para) in sec.paragraphs.iter().enumerate() { + eprintln!( + " 문단[{}]: text='{}' char_count={} controls={} char_offsets={:?}", + pi, + para.text, + para.char_count, + para.controls.len(), + para.char_offsets + ); + eprintln!( + " control_mask=0x{:08X} para_shape_id={} style_id={}", + para.control_mask, para.para_shape_id, para.style_id + ); + eprintln!(" char_shapes: {:?}", para.char_shapes); + eprintln!(" line_segs: {:?}", para.line_segs); + eprintln!( + " raw_header_extra({} bytes): {:02x?}", + para.raw_header_extra.len(), + ¶.raw_header_extra[..para.raw_header_extra.len().min(20)] + ); - let para = &doc.document.sections[0].paragraphs[0]; - eprintln!(" 문단[0]: text='{}' char_count={} controls={} seg_width={}", - para.text, para.char_count, para.controls.len(), - para.line_segs.first().map(|ls| ls.segment_width).unwrap_or(-1)); - let para1 = &doc.document.sections[0].paragraphs[1]; - eprintln!(" 문단[1]: text='{}' char_count={} vpos={}", - para1.text, para1.char_count, - para1.line_segs.first().map(|ls| ls.vertical_pos).unwrap_or(-1)); + for (ci, ctrl) in para.controls.iter().enumerate() { + match ctrl { + crate::model::control::Control::SectionDef(_) => { + eprintln!(" ctrl[{}]: SectionDef", ci) + } + crate::model::control::Control::ColumnDef(_) => { + eprintln!(" ctrl[{}]: ColumnDef", ci) + } + crate::model::control::Control::Table(t) => { + eprintln!( + " ctrl[{}]: Table {}x{} cells={} attr=0x{:08X}", + ci, + t.row_count, + t.col_count, + t.cells.len(), + t.attr + ); + eprintln!( + " raw_ctrl_data({} bytes): {:02x?}", + t.raw_ctrl_data.len(), + &t.raw_ctrl_data[..t.raw_ctrl_data.len().min(40)] + ); + eprintln!( + " raw_table_record_attr=0x{:08X}", + t.raw_table_record_attr + ); + eprintln!( + " raw_table_record_extra({} bytes): {:02x?}", + t.raw_table_record_extra.len(), + &t.raw_table_record_extra[..t.raw_table_record_extra.len().min(20)] + ); + eprintln!( + " padding: l={} r={} t={} b={}", + t.padding.left, t.padding.right, t.padding.top, t.padding.bottom + ); + eprintln!( + " cell_spacing={} border_fill_id={} row_sizes={:?}", + t.cell_spacing, t.border_fill_id, t.row_sizes + ); + for (celli, cell) in t.cells.iter().enumerate() { + eprintln!( + " cell[{}]: col={} row={} span={}x{} w={} h={} bfid={}", + celli, + cell.col, + cell.row, + cell.col_span, + cell.row_span, + cell.width, + cell.height, + cell.border_fill_id + ); + eprintln!( + " padding: l={} r={} t={} b={}", + cell.padding.left, + cell.padding.right, + cell.padding.top, + cell.padding.bottom + ); + eprintln!(" list_header_width_ref={} raw_list_extra({} bytes): {:02x?}", + cell.list_header_width_ref, cell.raw_list_extra.len(), + &cell.raw_list_extra[..cell.raw_list_extra.len().min(20)]); + for (cpi, cp) in cell.paragraphs.iter().enumerate() { + eprintln!( + " para[{}]: text='{}' cc={} cs={:?} ls={:?}", + cpi, cp.text, cp.char_count, cp.char_shapes, cp.line_segs + ); + eprintln!(" control_mask=0x{:08X} raw_header_extra({} bytes): {:02x?}", + cp.control_mask, cp.raw_header_extra.len(), + &cp.raw_header_extra[..cp.raw_header_extra.len().min(20)]); + } + } + } + _ => eprintln!(" ctrl[{}]: {:?}", ci, std::mem::discriminant(ctrl)), + } + } + } + } - // HWP 저장 - let saved = doc.export_hwp_native(); - assert!(saved.is_ok(), "HWP 저장 실패: {:?}", saved.err()); - let saved_data = saved.unwrap(); + // 3. BodyText 레코드 덤프 + let mut cfb = crate::parser::cfb_reader::CfbReader::open(&data).unwrap(); + let parsed = crate::parser::parse_hwp(&data).unwrap(); + let bt = cfb + .read_body_text_section(0, parsed.header.compressed, false) + .unwrap(); + let recs = Record::read_all(&bt).unwrap(); + + eprintln!("\n [BodyText 레코드 덤프] ({} 개)", recs.len()); + for (i, r) in recs.iter().enumerate() { + let tname = tags::tag_name(r.tag_id); + let mut extra = String::new(); + if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { + let cid = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); + extra = format!(" ctrl='{}'", tags::ctrl_name(cid)); + } + eprintln!( + " [{:2}] tag={:3}({:22}) level={} size={}{}", + i, + r.tag_id, + tname, + r.level, + r.data.len(), + extra + ); + // CTRL_HEADER, TABLE, LIST_HEADER 데이터 덤프 + if matches!(r.tag_id, 71 | 72 | 77) { + // CTRL_HEADER, LIST_HEADER, TABLE + let show = r.data.len().min(60); + eprintln!(" data[..{}]: {:02x?}", show, &r.data[..show]); + } + } - let _ = std::fs::create_dir_all("output"); - std::fs::write("output/save_test_table_1x1.hwp", &saved_data).unwrap(); - eprintln!(" 저장: output/save_test_table_1x1.hwp ({} bytes)", saved_data.len()); + // 4. 원본 empty.hwp 레코드 수 비교 + let empty_path = "template/empty.hwp"; + if std::path::Path::new(empty_path).exists() { + let empty_data = std::fs::read(empty_path).unwrap(); + let empty_parsed = crate::parser::parse_hwp(&empty_data).unwrap(); + let mut empty_cfb = crate::parser::cfb_reader::CfbReader::open(&empty_data).unwrap(); + let empty_bt = empty_cfb + .read_body_text_section(0, empty_parsed.header.compressed, false) + .unwrap(); + let empty_recs = Record::read_all(&empty_bt).unwrap(); + eprintln!( + "\n [비교] empty.hwp={} 개, roundtrip.hwp={} 개 → 차이={} 개", + empty_recs.len(), + recs.len(), + recs.len() as i32 - empty_recs.len() as i32 + ); + } - // 재파싱 검증 - let doc2 = HwpDocument::from_bytes(&saved_data); - assert!(doc2.is_ok(), "재파싱 실패: {:?}", doc2.err()); - let doc2 = doc2.unwrap(); + // 5. DocInfo 분석 + eprintln!("\n [DocInfo]"); + eprintln!( + " bin_data_count: {}", + doc.document.doc_info.bin_data_list.len() + ); + eprintln!( + " border_fill_count: {}", + doc.document.doc_info.border_fills.len() + ); + eprintln!( + " char_shape_count: {}", + doc.document.doc_info.char_shapes.len() + ); + eprintln!( + " para_shape_count: {}", + doc.document.doc_info.para_shapes.len() + ); + + // 6. BorderFill 상세 분석 + eprintln!("\n [BorderFill 상세]"); + for (bi, bf) in doc.document.doc_info.border_fills.iter().enumerate() { + eprintln!( + " bf[{}]: borders=[{:?}, {:?}, {:?}, {:?}] diag={:?}", + bi, bf.borders[0], bf.borders[1], bf.borders[2], bf.borders[3], bf.diagonal + ); + eprintln!(" attr={} fill={:?}", bf.attr, bf.fill); + if let Some(ref raw) = bf.raw_data { + let show = raw.len().min(60); + eprintln!(" raw_data({} bytes): {:02x?}", raw.len(), &raw[..show]); + } + } - // 표 컨트롤 존재 검증 - let para2 = &doc2.document.sections[0].paragraphs[0]; - eprintln!(" 재파싱: text='{}' char_count={} controls={}", para2.text, para2.char_count, para2.controls.len()); - let table_found = para2.controls.iter().any(|c| matches!(c, Control::Table(_))); - assert!(table_found, "재파싱된 문서에 표 컨트롤이 없음"); - - // 표 내용 검증 - if let Some(Control::Table(t)) = para2.controls.iter().find(|c| matches!(c, Control::Table(_))) { - eprintln!(" 표: {}×{} cells={}", t.row_count, t.col_count, t.cells.len()); - for (ci, cell) in t.cells.iter().enumerate() { - eprintln!(" 셀[{}]: col={} row={} w={} h={} text='{}'", - ci, cell.col, cell.row, cell.width, cell.height, - cell.paragraphs.first().map(|p| p.text.as_str()).unwrap_or("")); - } - assert_eq!(t.row_count, 1); - assert_eq!(t.col_count, 1); - assert_eq!(t.cells.len(), 1); - assert_eq!(t.cells[0].paragraphs.len(), 1); - // 빈 셀 확인 (참조 파일 기반) - assert_eq!(t.cells[0].paragraphs[0].char_count, 1); // CR만 - } - - // 두 번째 문단 (표 아래 빈 줄) 검증 - assert!(doc2.document.sections[0].paragraphs.len() >= 2, - "표 아래 빈 문단이 없음"); - let para_below = &doc2.document.sections[0].paragraphs[1]; - eprintln!(" 문단[1]: char_count={} controls={}", para_below.char_count, para_below.controls.len()); - - // 저장 레코드 덤프 (참조 파일과 비교) - let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); - let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, saved_doc.header.compressed, false).unwrap(); - let saved_recs = Record::read_all(&saved_bt).unwrap(); + eprintln!("\n=== 참조 파일 분석 완료 ==="); +} + +#[test] +fn test_save_table_1x1() { + // 단계 3: 빈 HWP에 1×1 표 삽입 → 저장 + // 참조: output/1by1-table.hwp (HWP 프로그램으로 생성한 1x1 표) + use crate::model::control::Control; + use crate::model::table::{Cell, Table}; + use crate::model::Padding; + use crate::parser::record::Record; + + let path = "template/empty.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } - eprintln!("\n --- 저장 레코드 덤프 ({} 개) ---", saved_recs.len()); - use crate::parser::tags as t; - for (i, r) in saved_recs.iter().enumerate() { - let tname = t::tag_name(r.tag_id); - let mut extra = String::new(); - if r.tag_id == t::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { - let cid = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); - extra = format!(" ctrl='{}'", t::ctrl_name(cid)); - } - eprintln!(" [{:2}] tag={:3}({:22}) level={} size={}{}", - i, r.tag_id, tname, r.level, r.data.len(), extra); + let orig_data = std::fs::read(path).unwrap(); + let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); + + eprintln!("\n{}", "=".repeat(60)); + eprintln!(" 단계 3: 1×1 표 삽입 → 저장 (참조파일 기반)"); + eprintln!("{}", "=".repeat(60)); + + // 참조 파일의 값을 사용하여 표 생성 + // cell_width=41954, cell_height=282 (참조 파일 기준) + let table_width: u32 = 41954; // 참조 파일과 동일 + let table_height: u32 = 1282; // 참조 파일과 동일 + let cell_width: u32 = 41954; + let cell_height: u32 = 282; + + // 셀 내부 문단: 빈 문단 (CR만, char_count=1, MSB set) + let cell_seg_width = 40932; // 참조: cell_width - 패딩(510+510) - 2 + let cell_para = Paragraph { + text: String::new(), + char_count: 1, + char_count_msb: true, // 참조: 0x80000001 + control_mask: 0, + para_shape_id: 0, // empty.hwp의 기존 para_shape 사용 + style_id: 0, + char_shapes: vec![crate::model::paragraph::CharShapeRef { + start_pos: 0, + char_shape_id: 0, // empty.hwp의 기존 char_shape 사용 + }], + line_segs: vec![LineSeg { + text_start: 0, + vertical_pos: 0, + line_height: 1000, + text_height: 1000, + baseline_distance: 850, + line_spacing: 600, + column_start: 0, + segment_width: cell_seg_width, + tag: 0x00060000, + }], + has_para_text: false, // 빈 문단: PARA_TEXT 없음 + raw_header_extra: vec![0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00], + ..Default::default() + }; + + let cell = Cell { + col: 0, + row: 0, + col_span: 1, + row_span: 1, + width: cell_width, + height: cell_height, + border_fill_id: 1, + padding: Padding { + left: 510, + right: 510, + top: 141, + bottom: 141, + }, // 참조값 + list_header_width_ref: 0, + // raw_list_extra: 참조파일의 13바이트 (width + zeros) + raw_list_extra: { + let mut v = Vec::new(); + v.extend_from_slice(&cell_width.to_le_bytes()); // [e2,a3,00,00] + v.extend_from_slice(&[0u8; 9]); // zeros + v + }, + paragraphs: vec![cell_para], + ..Default::default() + }; + + // CommonObjAttr 바이너리 생성 (참조 파일의 raw_ctrl_data 38바이트) + let raw_ctrl_data = { + let mut v = Vec::new(); + v.extend_from_slice(&0u32.to_le_bytes()); // y_offset = 0 + v.extend_from_slice(&0u32.to_le_bytes()); // x_offset = 0 + v.extend_from_slice(&table_width.to_le_bytes()); // width + v.extend_from_slice(&table_height.to_le_bytes()); // height + v.extend_from_slice(&1u32.to_le_bytes()); // z_order = 1 + v.extend_from_slice(&283u16.to_le_bytes()); // margin_left + v.extend_from_slice(&283u16.to_le_bytes()); // margin_right + v.extend_from_slice(&283u16.to_le_bytes()); // margin_top + v.extend_from_slice(&283u16.to_le_bytes()); // margin_bottom + v.extend_from_slice(&0x7C1E9738u32.to_le_bytes()); // instance_id + v.extend_from_slice(&0u32.to_le_bytes()); // unknown1 + v.extend_from_slice(&0u16.to_le_bytes()); // unknown2 + v + }; + + // DocInfo에 실선 테두리 BorderFill 추가 (참조: bf[0]) + use crate::model::style::{BorderFill, BorderLine, BorderLineType, DiagonalLine, Fill}; + let solid_border = BorderLine { + line_type: BorderLineType::Solid, + width: 1, + color: 0, + }; + let new_bf = BorderFill { + raw_data: None, + attr: 0, + borders: [solid_border, solid_border, solid_border, solid_border], + diagonal: DiagonalLine { + diagonal_type: 1, + width: 0, + color: 0, + }, + fill: Fill::default(), + }; + doc.document.doc_info.border_fills.push(new_bf); + let table_bf_id = doc.document.doc_info.border_fills.len() as u16; // 1-based ID + + let table = Table { + attr: 0x082A2210, // 참조: CommonObjAttr flags + row_count: 1, + col_count: 1, + cell_spacing: 0, + padding: Padding { + left: 510, + right: 510, + top: 141, + bottom: 141, + }, // 참조값 + row_sizes: vec![1], + border_fill_id: table_bf_id, + cells: { + // cell의 border_fill_id도 갱신 + let mut c = cell; + c.border_fill_id = table_bf_id; + vec![c] + }, + raw_ctrl_data, + raw_table_record_attr: 6, // 참조: attr=6 + raw_table_record_extra: vec![0x00, 0x00], // 참조: 2바이트 + ..Default::default() + }; + eprintln!( + " DocInfo: border_fill_count={}, table_bf_id={}", + doc.document.doc_info.border_fills.len(), + table_bf_id + ); + + // 첫 번째 문단에 Table 컨트롤 추가 + { + let para = &mut doc.document.sections[0].paragraphs[0]; + para.controls.push(Control::Table(Box::new(table))); + para.ctrl_data_records.push(None); + para.char_count += 8; // 표 제어문자 8 code units + para.control_mask = 0x00000804; // 참조: 표가 있는 문단의 control_mask + + // 표가 있는 문단의 segment_width는 0 (참조 파일) + if let Some(ls) = para.line_segs.first_mut() { + ls.segment_width = 0; } + } - // 참조 파일과 레코드 수 비교 - eprintln!("\n [참조 비교] 참조=21개, 저장={}개", saved_recs.len()); + // 두 번째 빈 문단 추가 (HWP는 표 삽입 시 아래에 빈 문단을 자동 추가) + let empty_para = Paragraph { + text: String::new(), + char_count: 1, // CR만 + char_count_msb: true, // 참조: 0x80000001 + control_mask: 0, + para_shape_id: 0, // empty.hwp의 기존 para_shape 사용 + style_id: 0, + char_shapes: vec![crate::model::paragraph::CharShapeRef { + start_pos: 0, + char_shape_id: 0, + }], + line_segs: vec![LineSeg { + text_start: 0, + vertical_pos: 1848, // 참조: 표 아래 위치 + line_height: 1000, + text_height: 1000, + baseline_distance: 850, + line_spacing: 600, + column_start: 0, + segment_width: 42520, // 참조: 편집 영역 전체 너비 + tag: 0x00060000, + }], + has_para_text: false, + raw_header_extra: vec![0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00], + ..Default::default() + }; + doc.document.sections[0].paragraphs.push(empty_para); + + // raw_stream 무효화 (재직렬화 유도) + doc.document.sections[0].raw_stream = None; + + // 캐럿 위치: 두 번째 문단(표 아래 빈 줄) 시작 + doc.document.doc_properties.caret_list_id = 1; // 문단 인덱스 1 + doc.document.doc_properties.caret_para_id = 0; + doc.document.doc_properties.caret_char_pos = 0; + doc.document.doc_info.raw_stream = None; + doc.document.doc_properties.raw_data = None; + + let para = &doc.document.sections[0].paragraphs[0]; + eprintln!( + " 문단[0]: text='{}' char_count={} controls={} seg_width={}", + para.text, + para.char_count, + para.controls.len(), + para.line_segs + .first() + .map(|ls| ls.segment_width) + .unwrap_or(-1) + ); + let para1 = &doc.document.sections[0].paragraphs[1]; + eprintln!( + " 문단[1]: text='{}' char_count={} vpos={}", + para1.text, + para1.char_count, + para1 + .line_segs + .first() + .map(|ls| ls.vertical_pos) + .unwrap_or(-1) + ); + + // HWP 저장 + let saved = doc.export_hwp_native(); + assert!(saved.is_ok(), "HWP 저장 실패: {:?}", saved.err()); + let saved_data = saved.unwrap(); + + let _ = std::fs::create_dir_all("output"); + std::fs::write("output/save_test_table_1x1.hwp", &saved_data).unwrap(); + eprintln!( + " 저장: output/save_test_table_1x1.hwp ({} bytes)", + saved_data.len() + ); + + // 재파싱 검증 + let doc2 = HwpDocument::from_bytes(&saved_data); + assert!(doc2.is_ok(), "재파싱 실패: {:?}", doc2.err()); + let doc2 = doc2.unwrap(); + + // 표 컨트롤 존재 검증 + let para2 = &doc2.document.sections[0].paragraphs[0]; + eprintln!( + " 재파싱: text='{}' char_count={} controls={}", + para2.text, + para2.char_count, + para2.controls.len() + ); + let table_found = para2 + .controls + .iter() + .any(|c| matches!(c, Control::Table(_))); + assert!(table_found, "재파싱된 문서에 표 컨트롤이 없음"); + + // 표 내용 검증 + if let Some(Control::Table(t)) = para2 + .controls + .iter() + .find(|c| matches!(c, Control::Table(_))) + { + eprintln!( + " 표: {}×{} cells={}", + t.row_count, + t.col_count, + t.cells.len() + ); + for (ci, cell) in t.cells.iter().enumerate() { + eprintln!( + " 셀[{}]: col={} row={} w={} h={} text='{}'", + ci, + cell.col, + cell.row, + cell.width, + cell.height, + cell.paragraphs + .first() + .map(|p| p.text.as_str()) + .unwrap_or("") + ); + } + assert_eq!(t.row_count, 1); + assert_eq!(t.col_count, 1); + assert_eq!(t.cells.len(), 1); + assert_eq!(t.cells[0].paragraphs.len(), 1); + // 빈 셀 확인 (참조 파일 기반) + assert_eq!(t.cells[0].paragraphs[0].char_count, 1); // CR만 + } - eprintln!("\n=== 단계 3 표 저장 검증 완료 ==="); + // 두 번째 문단 (표 아래 빈 줄) 검증 + assert!( + doc2.document.sections[0].paragraphs.len() >= 2, + "표 아래 빈 문단이 없음" + ); + let para_below = &doc2.document.sections[0].paragraphs[1]; + eprintln!( + " 문단[1]: char_count={} controls={}", + para_below.char_count, + para_below.controls.len() + ); + + // 저장 레코드 덤프 (참조 파일과 비교) + let saved_doc = crate::parser::parse_hwp(&saved_data).unwrap(); + let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_doc.header.compressed, false) + .unwrap(); + let saved_recs = Record::read_all(&saved_bt).unwrap(); + + eprintln!("\n --- 저장 레코드 덤프 ({} 개) ---", saved_recs.len()); + use crate::parser::tags as t; + for (i, r) in saved_recs.iter().enumerate() { + let tname = t::tag_name(r.tag_id); + let mut extra = String::new(); + if r.tag_id == t::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { + let cid = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); + extra = format!(" ctrl='{}'", t::ctrl_name(cid)); + } + eprintln!( + " [{:2}] tag={:3}({:22}) level={} size={}{}", + i, + r.tag_id, + tname, + r.level, + r.data.len(), + extra + ); } - /// 단계 4-1: HWP 프로그램으로 생성한 이미지 참조 파일 분석 - /// output/pic-01-as-text.hwp: 빈 문서 → 이미지 1개 → 글자처리로 삽입 - #[test] - fn test_analyze_reference_picture() { - use crate::parser::record::Record; - use crate::parser::tags; - use crate::parser::cfb_reader::LenientCfbReader; - use crate::model::control::Control; + // 참조 파일과 레코드 수 비교 + eprintln!("\n [참조 비교] 참조=21개, 저장={}개", saved_recs.len()); + + eprintln!("\n=== 단계 3 표 저장 검증 완료 ==="); +} + +/// 단계 4-1: HWP 프로그램으로 생성한 이미지 참조 파일 분석 +/// output/pic-01-as-text.hwp: 빈 문서 → 이미지 1개 → 글자처리로 삽입 +#[test] +fn test_analyze_reference_picture() { + use crate::model::control::Control; + use crate::parser::cfb_reader::LenientCfbReader; + use crate::parser::record::Record; + use crate::parser::tags; + + let path = "output/pic-01-as-text.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } - let path = "output/pic-01-as-text.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; - } + let data = std::fs::read(path).unwrap(); + + eprintln!("\n{}", "=".repeat(60)); + eprintln!(" 이미지 참조 파일 분석: {}", path); + eprintln!(" 파일 크기: {} bytes", data.len()); + eprintln!("{}", "=".repeat(60)); + + // 표준 파서 시도, 실패 시 LenientCfbReader + let doc = match HwpDocument::from_bytes(&data) { + Ok(d) => d, + Err(e) => { + eprintln!(" 표준 파서 실패 ({}), LenientCfbReader로 분석합니다.", e); + let lcfb = LenientCfbReader::open(&data).unwrap(); + + eprintln!("\n [LenientCFB 엔트리 목록]"); + for (name, start, size, otype) in lcfb.list_entries() { + let tname = match otype { + 1 => "storage", + 2 => "stream", + 5 => "root", + _ => "?", + }; + eprintln!( + " {:30} start={:5} size={:8} type={}", + name, start, size, tname + ); + } - let data = std::fs::read(path).unwrap(); + // FileHeader + let fh = lcfb.read_stream("FileHeader").unwrap(); + let compressed = fh.len() >= 37 && (fh[36] & 0x01) != 0; + eprintln!( + "\n FileHeader: {} bytes, compressed={}", + fh.len(), + compressed + ); - eprintln!("\n{}", "=".repeat(60)); - eprintln!(" 이미지 참조 파일 분석: {}", path); - eprintln!(" 파일 크기: {} bytes", data.len()); - eprintln!("{}", "=".repeat(60)); + // DocInfo + let di_data = lcfb.read_doc_info(compressed).unwrap(); + let di_recs = Record::read_all(&di_data).unwrap(); + eprintln!( + " DocInfo: {} bytes → {} 레코드", + di_data.len(), + di_recs.len() + ); - // 표준 파서 시도, 실패 시 LenientCfbReader - let doc = match HwpDocument::from_bytes(&data) { - Ok(d) => d, - Err(e) => { - eprintln!(" 표준 파서 실패 ({}), LenientCfbReader로 분석합니다.", e); - let lcfb = LenientCfbReader::open(&data).unwrap(); - - eprintln!("\n [LenientCFB 엔트리 목록]"); - for (name, start, size, otype) in lcfb.list_entries() { - let tname = match otype { 1 => "storage", 2 => "stream", 5 => "root", _ => "?" }; - eprintln!(" {:30} start={:5} size={:8} type={}", name, start, size, tname); - } - - // FileHeader - let fh = lcfb.read_stream("FileHeader").unwrap(); - let compressed = fh.len() >= 37 && (fh[36] & 0x01) != 0; - eprintln!("\n FileHeader: {} bytes, compressed={}", fh.len(), compressed); - - // DocInfo - let di_data = lcfb.read_doc_info(compressed).unwrap(); - let di_recs = Record::read_all(&di_data).unwrap(); - eprintln!(" DocInfo: {} bytes → {} 레코드", di_data.len(), di_recs.len()); - - // DocProperties (캐럿 위치) - if let Some(dp_rec) = di_recs.first() { - if dp_rec.tag_id == tags::HWPTAG_DOCUMENT_PROPERTIES && dp_rec.data.len() >= 26 { - let d = &dp_rec.data; - let caret_list_id = u32::from_le_bytes([d[14], d[15], d[16], d[17]]); - let caret_para_id = u32::from_le_bytes([d[18], d[19], d[20], d[21]]); - let caret_char_pos = u32::from_le_bytes([d[22], d[23], d[24], d[25]]); - eprintln!("\n [캐럿 위치 (raw)]"); - eprintln!(" caret_list_id: {}", caret_list_id); - eprintln!(" caret_para_id: {}", caret_para_id); - eprintln!(" caret_char_pos: {}", caret_char_pos); - } - } - - // ID_MAPPINGS - if di_recs.len() > 1 && di_recs[1].tag_id == tags::HWPTAG_ID_MAPPINGS { - let d = &di_recs[1].data; - if d.len() >= 72 { - eprintln!("\n [ID_MAPPINGS]"); - let labels = ["bin_data", "font_kr", "font_en", "font_cn", "font_jp", - "font_etc", "font_sym", "font_usr", "border_fill", "char_shape", - "tab_def", "numbering", "bullet", "para_shape", "style", - "memo_shape", "trackchange", "trackchange_author"]; - for (i, label) in labels.iter().enumerate() { - let off = i * 4; - let val = u32::from_le_bytes([d[off], d[off+1], d[off+2], d[off+3]]); - if val > 0 { - eprintln!(" {:20}: {}", label, val); - } + // DocProperties (캐럿 위치) + if let Some(dp_rec) = di_recs.first() { + if dp_rec.tag_id == tags::HWPTAG_DOCUMENT_PROPERTIES && dp_rec.data.len() >= 26 { + let d = &dp_rec.data; + let caret_list_id = u32::from_le_bytes([d[14], d[15], d[16], d[17]]); + let caret_para_id = u32::from_le_bytes([d[18], d[19], d[20], d[21]]); + let caret_char_pos = u32::from_le_bytes([d[22], d[23], d[24], d[25]]); + eprintln!("\n [캐럿 위치 (raw)]"); + eprintln!(" caret_list_id: {}", caret_list_id); + eprintln!(" caret_para_id: {}", caret_para_id); + eprintln!(" caret_char_pos: {}", caret_char_pos); + } + } + + // ID_MAPPINGS + if di_recs.len() > 1 && di_recs[1].tag_id == tags::HWPTAG_ID_MAPPINGS { + let d = &di_recs[1].data; + if d.len() >= 72 { + eprintln!("\n [ID_MAPPINGS]"); + let labels = [ + "bin_data", + "font_kr", + "font_en", + "font_cn", + "font_jp", + "font_etc", + "font_sym", + "font_usr", + "border_fill", + "char_shape", + "tab_def", + "numbering", + "bullet", + "para_shape", + "style", + "memo_shape", + "trackchange", + "trackchange_author", + ]; + for (i, label) in labels.iter().enumerate() { + let off = i * 4; + let val = u32::from_le_bytes([d[off], d[off + 1], d[off + 2], d[off + 3]]); + if val > 0 { + eprintln!(" {:20}: {}", label, val); } } } + } - // BIN_DATA 레코드 덤프 - eprintln!("\n [DocInfo BIN_DATA 레코드]"); - for (i, r) in di_recs.iter().enumerate() { - if r.tag_id == tags::HWPTAG_BIN_DATA { - eprintln!(" [{:2}] BIN_DATA size={} data: {:02x?}", - i, r.data.len(), &r.data[..r.data.len().min(60)]); - } + // BIN_DATA 레코드 덤프 + eprintln!("\n [DocInfo BIN_DATA 레코드]"); + for (i, r) in di_recs.iter().enumerate() { + if r.tag_id == tags::HWPTAG_BIN_DATA { + eprintln!( + " [{:2}] BIN_DATA size={} data: {:02x?}", + i, + r.data.len(), + &r.data[..r.data.len().min(60)] + ); } + } - // BodyText - let bt_data = lcfb.read_body_text_section(0, compressed).unwrap(); - let bt_recs = Record::read_all(&bt_data).unwrap(); + // BodyText + let bt_data = lcfb.read_body_text_section(0, compressed).unwrap(); + let bt_recs = Record::read_all(&bt_data).unwrap(); - eprintln!("\n [BodyText 레코드 덤프] ({} 개)", bt_recs.len()); - for (i, r) in bt_recs.iter().enumerate() { - let tname = tags::tag_name(r.tag_id); - let mut extra = String::new(); - if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { - let cid = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); - extra = format!(" ctrl='{}'", tags::ctrl_name(cid)); - } - eprintln!(" [{:2}] tag={:3}({:25}) level={} size={}{}", - i, r.tag_id, tname, r.level, r.data.len(), extra); - // 주요 레코드 데이터 덤프 - if matches!(r.tag_id, - 66 | 67 | 68 | 69 | 71 | // PARA_HEADER, TEXT, CHAR_SHAPE, LINE_SEG, CTRL_HEADER + eprintln!("\n [BodyText 레코드 덤프] ({} 개)", bt_recs.len()); + for (i, r) in bt_recs.iter().enumerate() { + let tname = tags::tag_name(r.tag_id); + let mut extra = String::new(); + if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { + let cid = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); + extra = format!(" ctrl='{}'", tags::ctrl_name(cid)); + } + eprintln!( + " [{:2}] tag={:3}({:25}) level={} size={}{}", + i, + r.tag_id, + tname, + r.level, + r.data.len(), + extra + ); + // 주요 레코드 데이터 덤프 + if matches!( + r.tag_id, + 66 | 67 | 68 | 69 | 71 | // PARA_HEADER, TEXT, CHAR_SHAPE, LINE_SEG, CTRL_HEADER 76 | 79 // SHAPE_COMPONENT, SHAPE_COMPONENT_PICTURE - ) { - let show = r.data.len().min(100); - eprintln!(" data[..{}]: {:02x?}", show, &r.data[..show]); - if r.data.len() > 100 { - eprintln!(" total: {} bytes", r.data.len()); - } + ) { + let show = r.data.len().min(100); + eprintln!(" data[..{}]: {:02x?}", show, &r.data[..show]); + if r.data.len() > 100 { + eprintln!(" total: {} bytes", r.data.len()); } } + } - // BinData 스트림 확인 - eprintln!("\n [BinData 스트림]"); - for (name, _start, size, otype) in lcfb.list_entries() { - if *otype == 2 && name.contains("BIN") { - eprintln!(" {} size={}", name, size); - if let Ok(stream) = lcfb.read_stream(&name) { - let sig_show = stream.len().min(16); - eprintln!(" sig[..{}]: {:02x?}", sig_show, &stream[..sig_show]); - } + // BinData 스트림 확인 + eprintln!("\n [BinData 스트림]"); + for (name, _start, size, otype) in lcfb.list_entries() { + if *otype == 2 && name.contains("BIN") { + eprintln!(" {} size={}", name, size); + if let Ok(stream) = lcfb.read_stream(&name) { + let sig_show = stream.len().min(16); + eprintln!(" sig[..{}]: {:02x?}", sig_show, &stream[..sig_show]); } } - - // empty.hwp 비교 - let empty_path = "template/empty.hwp"; - if std::path::Path::new(empty_path).exists() { - let empty_data = std::fs::read(empty_path).unwrap(); - let empty_parsed = crate::parser::parse_hwp(&empty_data).unwrap(); - let mut empty_cfb = crate::parser::cfb_reader::CfbReader::open(&empty_data).unwrap(); - let empty_bt = empty_cfb.read_body_text_section(0, empty_parsed.header.compressed, false).unwrap(); - let empty_recs = Record::read_all(&empty_bt).unwrap(); - eprintln!("\n [비교] empty.hwp={} 개, pic-01.hwp={} 개 → 차이={} 개", - empty_recs.len(), bt_recs.len(), bt_recs.len() as i32 - empty_recs.len() as i32); - } - - eprintln!("\n=== 참조 파일 분석 완료 (LenientCfbReader) ==="); - return; } - }; - - // === 표준 파서 성공 경로 === - // 1. 캐럿 위치 - let dp = &doc.document.doc_properties; - eprintln!("\n [캐럿 위치]"); - eprintln!(" caret_list_id: {}", dp.caret_list_id); - eprintln!(" caret_para_id: {}", dp.caret_para_id); - eprintln!(" caret_char_pos: {}", dp.caret_char_pos); - - // 2. BinData 목록 - eprintln!("\n [BinData 목록] ({} 개)", doc.document.doc_info.bin_data_list.len()); - for (i, bd) in doc.document.doc_info.bin_data_list.iter().enumerate() { - eprintln!(" [{}] attr=0x{:04X} type={:?} storage_id={} ext={:?} compression={:?} status={:?}", - i, bd.attr, bd.data_type, bd.storage_id, bd.extension, bd.compression, bd.status); - if let Some(ref raw) = bd.raw_data { - let show = raw.len().min(60); - eprintln!(" raw_data({} bytes): {:02x?}", raw.len(), &raw[..show]); + // empty.hwp 비교 + let empty_path = "template/empty.hwp"; + if std::path::Path::new(empty_path).exists() { + let empty_data = std::fs::read(empty_path).unwrap(); + let empty_parsed = crate::parser::parse_hwp(&empty_data).unwrap(); + let mut empty_cfb = + crate::parser::cfb_reader::CfbReader::open(&empty_data).unwrap(); + let empty_bt = empty_cfb + .read_body_text_section(0, empty_parsed.header.compressed, false) + .unwrap(); + let empty_recs = Record::read_all(&empty_bt).unwrap(); + eprintln!( + "\n [비교] empty.hwp={} 개, pic-01.hwp={} 개 → 차이={} 개", + empty_recs.len(), + bt_recs.len(), + bt_recs.len() as i32 - empty_recs.len() as i32 + ); } + + eprintln!("\n=== 참조 파일 분석 완료 (LenientCfbReader) ==="); + return; + } + }; + + // === 표준 파서 성공 경로 === + + // 1. 캐럿 위치 + let dp = &doc.document.doc_properties; + eprintln!("\n [캐럿 위치]"); + eprintln!(" caret_list_id: {}", dp.caret_list_id); + eprintln!(" caret_para_id: {}", dp.caret_para_id); + eprintln!(" caret_char_pos: {}", dp.caret_char_pos); + + // 2. BinData 목록 + eprintln!( + "\n [BinData 목록] ({} 개)", + doc.document.doc_info.bin_data_list.len() + ); + for (i, bd) in doc.document.doc_info.bin_data_list.iter().enumerate() { + eprintln!( + " [{}] attr=0x{:04X} type={:?} storage_id={} ext={:?} compression={:?} status={:?}", + i, bd.attr, bd.data_type, bd.storage_id, bd.extension, bd.compression, bd.status + ); + if let Some(ref raw) = bd.raw_data { + let show = raw.len().min(60); + eprintln!( + " raw_data({} bytes): {:02x?}", + raw.len(), + &raw[..show] + ); } + } - // 3. BinDataContent 목록 - eprintln!("\n [BinDataContent 목록] ({} 개)", doc.document.bin_data_content.len()); - for (i, bc) in doc.document.bin_data_content.iter().enumerate() { - eprintln!(" [{}] id={} ext='{}' data_size={} bytes", - i, bc.id, bc.extension, bc.data.len()); - if bc.data.len() >= 8 { - let sig = &bc.data[..8]; - let format = if sig[0..2] == [0xFF, 0xD8] { "JPEG" } - else if sig[0..4] == [0x89, 0x50, 0x4E, 0x47] { "PNG" } - else if sig[0..2] == [0x42, 0x4D] { "BMP" } - else if sig[0..4] == [0x47, 0x49, 0x46, 0x38] { "GIF" } - else { "Unknown" }; - eprintln!(" 시그니처: {:02x?} → {}", &sig[..4], format); - } + // 3. BinDataContent 목록 + eprintln!( + "\n [BinDataContent 목록] ({} 개)", + doc.document.bin_data_content.len() + ); + for (i, bc) in doc.document.bin_data_content.iter().enumerate() { + eprintln!( + " [{}] id={} ext='{}' data_size={} bytes", + i, + bc.id, + bc.extension, + bc.data.len() + ); + if bc.data.len() >= 8 { + let sig = &bc.data[..8]; + let format = if sig[0..2] == [0xFF, 0xD8] { + "JPEG" + } else if sig[0..4] == [0x89, 0x50, 0x4E, 0x47] { + "PNG" + } else if sig[0..2] == [0x42, 0x4D] { + "BMP" + } else if sig[0..4] == [0x47, 0x49, 0x46, 0x38] { + "GIF" + } else { + "Unknown" + }; + eprintln!(" 시그니처: {:02x?} → {}", &sig[..4], format); } + } - // 4. 섹션/문단 구조 상세 - for (si, sec) in doc.document.sections.iter().enumerate() { - eprintln!("\n [섹션 {}] 문단 수: {}", si, sec.paragraphs.len()); - for (pi, para) in sec.paragraphs.iter().enumerate() { - eprintln!(" 문단[{}]: text='{}' char_count={} msb={} controls={} char_offsets={:?}", - pi, para.text, para.char_count, para.char_count_msb, para.controls.len(), para.char_offsets); - eprintln!(" control_mask=0x{:08X} para_shape_id={} style_id={}", - para.control_mask, para.para_shape_id, para.style_id); - eprintln!(" char_shapes: {:?}", para.char_shapes); - eprintln!(" line_segs: {:?}", para.line_segs); - eprintln!(" raw_header_extra({} bytes): {:02x?}", - para.raw_header_extra.len(), - ¶.raw_header_extra[..para.raw_header_extra.len().min(30)]); + // 4. 섹션/문단 구조 상세 + for (si, sec) in doc.document.sections.iter().enumerate() { + eprintln!("\n [섹션 {}] 문단 수: {}", si, sec.paragraphs.len()); + for (pi, para) in sec.paragraphs.iter().enumerate() { + eprintln!( + " 문단[{}]: text='{}' char_count={} msb={} controls={} char_offsets={:?}", + pi, + para.text, + para.char_count, + para.char_count_msb, + para.controls.len(), + para.char_offsets + ); + eprintln!( + " control_mask=0x{:08X} para_shape_id={} style_id={}", + para.control_mask, para.para_shape_id, para.style_id + ); + eprintln!(" char_shapes: {:?}", para.char_shapes); + eprintln!(" line_segs: {:?}", para.line_segs); + eprintln!( + " raw_header_extra({} bytes): {:02x?}", + para.raw_header_extra.len(), + ¶.raw_header_extra[..para.raw_header_extra.len().min(30)] + ); - for (ci, ctrl) in para.controls.iter().enumerate() { - match ctrl { - Control::SectionDef(_) => eprintln!(" ctrl[{}]: SectionDef", ci), - Control::ColumnDef(_) => eprintln!(" ctrl[{}]: ColumnDef", ci), - Control::Picture(pic) => { - eprintln!(" ctrl[{}]: Picture", ci); - eprintln!(" CommonObjAttr:"); - eprintln!(" ctrl_id: 0x{:08X}", pic.common.ctrl_id); - eprintln!(" attr: 0x{:08X}", pic.common.attr); - eprintln!(" vertical_offset: {}", pic.common.vertical_offset); - eprintln!(" horizontal_offset: {}", pic.common.horizontal_offset); - eprintln!(" width: {}", pic.common.width); - eprintln!(" height: {}", pic.common.height); - eprintln!(" z_order: {}", pic.common.z_order); - eprintln!(" margin: L={} R={} T={} B={}", - pic.common.margin.left, pic.common.margin.right, - pic.common.margin.top, pic.common.margin.bottom); - eprintln!(" instance_id: 0x{:08X}", pic.common.instance_id); - eprintln!(" description: '{}'", pic.common.description); - eprintln!(" raw_extra({} bytes): {:02x?}", - pic.common.raw_extra.len(), &pic.common.raw_extra[..pic.common.raw_extra.len().min(40)]); - eprintln!(" ShapeComponentAttr:"); - eprintln!(" ctrl_id: 0x{:08X}", pic.shape_attr.ctrl_id); - eprintln!(" is_two_ctrl_id: {}", pic.shape_attr.is_two_ctrl_id); - eprintln!(" offset: ({}, {})", pic.shape_attr.offset_x, pic.shape_attr.offset_y); - eprintln!(" group_level: {}", pic.shape_attr.group_level); - eprintln!(" local_file_version: {}", pic.shape_attr.local_file_version); - eprintln!(" original: {}×{}", pic.shape_attr.original_width, pic.shape_attr.original_height); - eprintln!(" current: {}×{}", pic.shape_attr.current_width, pic.shape_attr.current_height); - eprintln!(" flip: 0x{:08X}", pic.shape_attr.flip); - eprintln!(" rotation_angle: {}", pic.shape_attr.rotation_angle); - eprintln!(" raw_rendering({} bytes): {:02x?}", - pic.shape_attr.raw_rendering.len(), - &pic.shape_attr.raw_rendering[..pic.shape_attr.raw_rendering.len().min(80)]); - eprintln!(" PictureData:"); - eprintln!(" border_color: 0x{:08X}", pic.border_color); - eprintln!(" border_width: {}", pic.border_width); - eprintln!(" border_x: {:?}", pic.border_x); - eprintln!(" border_y: {:?}", pic.border_y); - eprintln!(" crop: L={} T={} R={} B={}", pic.crop.left, pic.crop.top, pic.crop.right, pic.crop.bottom); - eprintln!(" padding: L={} R={} T={} B={}", - pic.padding.left, pic.padding.right, pic.padding.top, pic.padding.bottom); - eprintln!(" image_attr: brightness={} contrast={} effect={:?} bin_data_id={}", + for (ci, ctrl) in para.controls.iter().enumerate() { + match ctrl { + Control::SectionDef(_) => eprintln!(" ctrl[{}]: SectionDef", ci), + Control::ColumnDef(_) => eprintln!(" ctrl[{}]: ColumnDef", ci), + Control::Picture(pic) => { + eprintln!(" ctrl[{}]: Picture", ci); + eprintln!(" CommonObjAttr:"); + eprintln!(" ctrl_id: 0x{:08X}", pic.common.ctrl_id); + eprintln!(" attr: 0x{:08X}", pic.common.attr); + eprintln!(" vertical_offset: {}", pic.common.vertical_offset); + eprintln!( + " horizontal_offset: {}", + pic.common.horizontal_offset + ); + eprintln!(" width: {}", pic.common.width); + eprintln!(" height: {}", pic.common.height); + eprintln!(" z_order: {}", pic.common.z_order); + eprintln!( + " margin: L={} R={} T={} B={}", + pic.common.margin.left, + pic.common.margin.right, + pic.common.margin.top, + pic.common.margin.bottom + ); + eprintln!(" instance_id: 0x{:08X}", pic.common.instance_id); + eprintln!(" description: '{}'", pic.common.description); + eprintln!( + " raw_extra({} bytes): {:02x?}", + pic.common.raw_extra.len(), + &pic.common.raw_extra[..pic.common.raw_extra.len().min(40)] + ); + eprintln!(" ShapeComponentAttr:"); + eprintln!(" ctrl_id: 0x{:08X}", pic.shape_attr.ctrl_id); + eprintln!(" is_two_ctrl_id: {}", pic.shape_attr.is_two_ctrl_id); + eprintln!( + " offset: ({}, {})", + pic.shape_attr.offset_x, pic.shape_attr.offset_y + ); + eprintln!(" group_level: {}", pic.shape_attr.group_level); + eprintln!( + " local_file_version: {}", + pic.shape_attr.local_file_version + ); + eprintln!( + " original: {}×{}", + pic.shape_attr.original_width, pic.shape_attr.original_height + ); + eprintln!( + " current: {}×{}", + pic.shape_attr.current_width, pic.shape_attr.current_height + ); + eprintln!(" flip: 0x{:08X}", pic.shape_attr.flip); + eprintln!(" rotation_angle: {}", pic.shape_attr.rotation_angle); + eprintln!( + " raw_rendering({} bytes): {:02x?}", + pic.shape_attr.raw_rendering.len(), + &pic.shape_attr.raw_rendering + [..pic.shape_attr.raw_rendering.len().min(80)] + ); + eprintln!(" PictureData:"); + eprintln!(" border_color: 0x{:08X}", pic.border_color); + eprintln!(" border_width: {}", pic.border_width); + eprintln!(" border_x: {:?}", pic.border_x); + eprintln!(" border_y: {:?}", pic.border_y); + eprintln!( + " crop: L={} T={} R={} B={}", + pic.crop.left, pic.crop.top, pic.crop.right, pic.crop.bottom + ); + eprintln!( + " padding: L={} R={} T={} B={}", + pic.padding.left, + pic.padding.right, + pic.padding.top, + pic.padding.bottom + ); + eprintln!(" image_attr: brightness={} contrast={} effect={:?} bin_data_id={}", pic.image_attr.brightness, pic.image_attr.contrast, pic.image_attr.effect, pic.image_attr.bin_data_id); - eprintln!(" border_opacity: {}", pic.border_opacity); - eprintln!(" instance_id: {}", pic.instance_id); - eprintln!(" raw_picture_extra({} bytes): {:02x?}", - pic.raw_picture_extra.len(), &pic.raw_picture_extra[..pic.raw_picture_extra.len().min(40)]); - }, - _ => eprintln!(" ctrl[{}]: {:?}", ci, std::mem::discriminant(ctrl)), + eprintln!(" border_opacity: {}", pic.border_opacity); + eprintln!(" instance_id: {}", pic.instance_id); + eprintln!( + " raw_picture_extra({} bytes): {:02x?}", + pic.raw_picture_extra.len(), + &pic.raw_picture_extra[..pic.raw_picture_extra.len().min(40)] + ); } + _ => eprintln!(" ctrl[{}]: {:?}", ci, std::mem::discriminant(ctrl)), } } } + } - // 5. BodyText 레코드 덤프 - let parsed_doc = crate::parser::parse_hwp(&data).unwrap(); - let mut cfb = crate::parser::cfb_reader::CfbReader::open(&data).unwrap(); - let bt_data = cfb.read_body_text_section(0, parsed_doc.header.compressed, false).unwrap(); - let bt_recs = Record::read_all(&bt_data).unwrap(); - - eprintln!("\n [BodyText 레코드 덤프] ({} 개)", bt_recs.len()); - for (i, r) in bt_recs.iter().enumerate() { - let tname = tags::tag_name(r.tag_id); - let mut extra = String::new(); - if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { - let cid = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); - extra = format!(" ctrl='{}'", tags::ctrl_name(cid)); - } - eprintln!(" [{:2}] tag={:3}({:25}) level={} size={}{}", - i, r.tag_id, tname, r.level, r.data.len(), extra); - // 주요 레코드 데이터 상세 덤프 - if matches!(r.tag_id, - 66 | 67 | 68 | 69 | 71 | // PARA_HEADER, TEXT, CHAR_SHAPE, LINE_SEG, CTRL_HEADER + // 5. BodyText 레코드 덤프 + let parsed_doc = crate::parser::parse_hwp(&data).unwrap(); + let mut cfb = crate::parser::cfb_reader::CfbReader::open(&data).unwrap(); + let bt_data = cfb + .read_body_text_section(0, parsed_doc.header.compressed, false) + .unwrap(); + let bt_recs = Record::read_all(&bt_data).unwrap(); + + eprintln!("\n [BodyText 레코드 덤프] ({} 개)", bt_recs.len()); + for (i, r) in bt_recs.iter().enumerate() { + let tname = tags::tag_name(r.tag_id); + let mut extra = String::new(); + if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { + let cid = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); + extra = format!(" ctrl='{}'", tags::ctrl_name(cid)); + } + eprintln!( + " [{:2}] tag={:3}({:25}) level={} size={}{}", + i, + r.tag_id, + tname, + r.level, + r.data.len(), + extra + ); + // 주요 레코드 데이터 상세 덤프 + if matches!( + r.tag_id, + 66 | 67 | 68 | 69 | 71 | // PARA_HEADER, TEXT, CHAR_SHAPE, LINE_SEG, CTRL_HEADER 76 | 85 // SHAPE_COMPONENT, SHAPE_COMPONENT_PICTURE (tag 85) - ) { - let show = r.data.len().min(120); - eprintln!(" data[..{}]: {:02x?}", show, &r.data[..show]); - if r.data.len() > 120 { - eprintln!(" total: {} bytes", r.data.len()); - } + ) { + let show = r.data.len().min(120); + eprintln!(" data[..{}]: {:02x?}", show, &r.data[..show]); + if r.data.len() > 120 { + eprintln!(" total: {} bytes", r.data.len()); } } - - // 6. empty.hwp 비교 - let empty_path = "template/empty.hwp"; - if std::path::Path::new(empty_path).exists() { - let empty_data = std::fs::read(empty_path).unwrap(); - let empty_parsed = crate::parser::parse_hwp(&empty_data).unwrap(); - let mut empty_cfb = crate::parser::cfb_reader::CfbReader::open(&empty_data).unwrap(); - let empty_bt = empty_cfb.read_body_text_section(0, empty_parsed.header.compressed, false).unwrap(); - let empty_recs = Record::read_all(&empty_bt).unwrap(); - eprintln!("\n [비교] empty.hwp={} 개, pic-01.hwp={} 개 → 차이={} 개", - empty_recs.len(), bt_recs.len(), bt_recs.len() as i32 - empty_recs.len() as i32); - } - - // 7. DocInfo 상세 - eprintln!("\n [DocInfo]"); - eprintln!(" bin_data_count: {}", doc.document.doc_info.bin_data_list.len()); - eprintln!(" border_fill_count: {}", doc.document.doc_info.border_fills.len()); - eprintln!(" char_shape_count: {}", doc.document.doc_info.char_shapes.len()); - eprintln!(" para_shape_count: {}", doc.document.doc_info.para_shapes.len()); - - // 8. CFB 스트림 목록 - let streams = cfb.list_streams(); - eprintln!("\n [CFB 스트림 목록]"); - for s in &streams { - eprintln!(" {}", s); - } - - eprintln!("\n=== 이미지 참조 파일 분석 완료 ==="); } - /// 단계 4-2: 빈 HWP에 이미지 삽입 후 저장 검증 - /// 참조: output/pic-01-as-text.hwp (HWP 프로그램으로 생성, 3tigers.jpg 글자처리 삽입) - #[test] - fn test_save_picture() { - use crate::parser::record::Record; - use crate::parser::tags; - use crate::model::control::Control; - use crate::model::bin_data::{BinData, BinDataType, BinDataCompression, BinDataStatus, BinDataContent}; - use crate::model::paragraph::{CharShapeRef, LineSeg, Paragraph}; - - eprintln!("\n=== 단계 4-2: 이미지 저장 검증 시작 ==="); - - // 1. 참조 파일에서 Picture 구조 및 이미지 추출 - let ref_path = "output/pic-01-as-text.hwp"; - if !std::path::Path::new(ref_path).exists() { - eprintln!("SKIP: {} 없음", ref_path); - return; - } - let ref_data = std::fs::read(ref_path).unwrap(); - let ref_doc = HwpDocument::from_bytes(&ref_data).unwrap(); - - // 참조 파일에서 Picture 컨트롤 추출 - let ref_pic = ref_doc.document.sections[0].paragraphs[0].controls.iter() - .find_map(|c| if let Control::Picture(p) = c { Some(p) } else { None }) - .expect("참조 파일에 Picture 컨트롤 없음"); - - let ref_bindata = &ref_doc.document.doc_info.bin_data_list[0]; - let ref_bincontent = &ref_doc.document.bin_data_content[0]; - - let pic_width = ref_pic.common.width; - let pic_height = ref_pic.common.height; - eprintln!(" 참조 Picture: {}×{} bin_data_id={} image={} bytes", - pic_width, pic_height, ref_pic.image_attr.bin_data_id, ref_bincontent.data.len()); - eprintln!(" 참조 캐럿: list_id={} para_id={} char_pos={}", - ref_doc.document.doc_properties.caret_list_id, - ref_doc.document.doc_properties.caret_para_id, - ref_doc.document.doc_properties.caret_char_pos); - - // 2. empty.hwp 로드 - let empty_path = "template/empty.hwp"; - assert!(std::path::Path::new(empty_path).exists(), "template/empty.hwp 없음"); + // 6. empty.hwp 비교 + let empty_path = "template/empty.hwp"; + if std::path::Path::new(empty_path).exists() { let empty_data = std::fs::read(empty_path).unwrap(); - let mut doc = HwpDocument::from_bytes(&empty_data).unwrap(); - - // 3. DocInfo에 BinData 추가 - // 참조 파일: attr=0x0001 (Embedding), status=NotAccessed - let bin_data_entry = BinData { - attr: ref_bindata.attr, - data_type: BinDataType::Embedding, - compression: BinDataCompression::Default, - status: BinDataStatus::NotAccessed, // 참조 파일과 동일 - storage_id: 1, - extension: Some(ref_bincontent.extension.clone()), - raw_data: None, - ..Default::default() - }; - doc.document.doc_info.bin_data_list.push(bin_data_entry); + let empty_parsed = crate::parser::parse_hwp(&empty_data).unwrap(); + let mut empty_cfb = crate::parser::cfb_reader::CfbReader::open(&empty_data).unwrap(); + let empty_bt = empty_cfb + .read_body_text_section(0, empty_parsed.header.compressed, false) + .unwrap(); + let empty_recs = Record::read_all(&empty_bt).unwrap(); + eprintln!( + "\n [비교] empty.hwp={} 개, pic-01.hwp={} 개 → 차이={} 개", + empty_recs.len(), + bt_recs.len(), + bt_recs.len() as i32 - empty_recs.len() as i32 + ); + } - // BinDataContent 추가 - doc.document.bin_data_content.push(BinDataContent { - id: 1, - data: ref_bincontent.data.clone(), - extension: ref_bincontent.extension.clone(), - }); + // 7. DocInfo 상세 + eprintln!("\n [DocInfo]"); + eprintln!( + " bin_data_count: {}", + doc.document.doc_info.bin_data_list.len() + ); + eprintln!( + " border_fill_count: {}", + doc.document.doc_info.border_fills.len() + ); + eprintln!( + " char_shape_count: {}", + doc.document.doc_info.char_shapes.len() + ); + eprintln!( + " para_shape_count: {}", + doc.document.doc_info.para_shapes.len() + ); + + // 8. CFB 스트림 목록 + let streams = cfb.list_streams(); + eprintln!("\n [CFB 스트림 목록]"); + for s in &streams { + eprintln!(" {}", s); + } - // 4. Picture 컨트롤 구성 (참조 파일의 정확한 값 사용) - let picture = crate::model::image::Picture { - common: ref_pic.common.clone(), - shape_attr: ref_pic.shape_attr.clone(), - border_color: ref_pic.border_color, - border_width: ref_pic.border_width, - border_attr: ref_pic.border_attr.clone(), - border_x: ref_pic.border_x, - border_y: ref_pic.border_y, - crop: ref_pic.crop.clone(), - padding: ref_pic.padding.clone(), - image_attr: ref_pic.image_attr.clone(), - border_opacity: ref_pic.border_opacity, - instance_id: ref_pic.instance_id, - raw_picture_extra: ref_pic.raw_picture_extra.clone(), - caption: None, - }; + eprintln!("\n=== 이미지 참조 파일 분석 완료 ==="); +} + +/// 단계 4-2: 빈 HWP에 이미지 삽입 후 저장 검증 +/// 참조: output/pic-01-as-text.hwp (HWP 프로그램으로 생성, 3tigers.jpg 글자처리 삽입) +#[test] +fn test_save_picture() { + use crate::model::bin_data::{ + BinData, BinDataCompression, BinDataContent, BinDataStatus, BinDataType, + }; + use crate::model::control::Control; + use crate::model::paragraph::{CharShapeRef, LineSeg, Paragraph}; + use crate::parser::record::Record; + use crate::parser::tags; + + eprintln!("\n=== 단계 4-2: 이미지 저장 검증 시작 ==="); + + // 1. 참조 파일에서 Picture 구조 및 이미지 추출 + let ref_path = "output/pic-01-as-text.hwp"; + if !std::path::Path::new(ref_path).exists() { + eprintln!("SKIP: {} 없음", ref_path); + return; + } + let ref_data = std::fs::read(ref_path).unwrap(); + let ref_doc = HwpDocument::from_bytes(&ref_data).unwrap(); + + // 참조 파일에서 Picture 컨트롤 추출 + let ref_pic = ref_doc.document.sections[0].paragraphs[0] + .controls + .iter() + .find_map(|c| { + if let Control::Picture(p) = c { + Some(p) + } else { + None + } + }) + .expect("참조 파일에 Picture 컨트롤 없음"); + + let ref_bindata = &ref_doc.document.doc_info.bin_data_list[0]; + let ref_bincontent = &ref_doc.document.bin_data_content[0]; + + let pic_width = ref_pic.common.width; + let pic_height = ref_pic.common.height; + eprintln!( + " 참조 Picture: {}×{} bin_data_id={} image={} bytes", + pic_width, + pic_height, + ref_pic.image_attr.bin_data_id, + ref_bincontent.data.len() + ); + eprintln!( + " 참조 캐럿: list_id={} para_id={} char_pos={}", + ref_doc.document.doc_properties.caret_list_id, + ref_doc.document.doc_properties.caret_para_id, + ref_doc.document.doc_properties.caret_char_pos + ); + + // 2. empty.hwp 로드 + let empty_path = "template/empty.hwp"; + assert!( + std::path::Path::new(empty_path).exists(), + "template/empty.hwp 없음" + ); + let empty_data = std::fs::read(empty_path).unwrap(); + let mut doc = HwpDocument::from_bytes(&empty_data).unwrap(); + + // 3. DocInfo에 BinData 추가 + // 참조 파일: attr=0x0001 (Embedding), status=NotAccessed + let bin_data_entry = BinData { + attr: ref_bindata.attr, + data_type: BinDataType::Embedding, + compression: BinDataCompression::Default, + status: BinDataStatus::NotAccessed, // 참조 파일과 동일 + storage_id: 1, + extension: Some(ref_bincontent.extension.clone()), + raw_data: None, + ..Default::default() + }; + doc.document.doc_info.bin_data_list.push(bin_data_entry); + + // BinDataContent 추가 + doc.document.bin_data_content.push(BinDataContent { + id: 1, + data: ref_bincontent.data.clone(), + extension: ref_bincontent.extension.clone(), + }); + + // 4. Picture 컨트롤 구성 (참조 파일의 정확한 값 사용) + let picture = crate::model::image::Picture { + common: ref_pic.common.clone(), + shape_attr: ref_pic.shape_attr.clone(), + border_color: ref_pic.border_color, + border_width: ref_pic.border_width, + border_attr: ref_pic.border_attr.clone(), + border_x: ref_pic.border_x, + border_y: ref_pic.border_y, + crop: ref_pic.crop.clone(), + padding: ref_pic.padding.clone(), + image_attr: ref_pic.image_attr.clone(), + border_opacity: ref_pic.border_opacity, + instance_id: ref_pic.instance_id, + raw_picture_extra: ref_pic.raw_picture_extra.clone(), + caption: None, + }; + + // 5. 문단 구성 (참조 파일: 단일 문단에 SectionDef + ColumnDef + Picture) + let first_para = &doc.document.sections[0].paragraphs[0]; + let existing_controls: Vec = first_para.controls.clone(); + + // 참조: char_count=25 (msb=true), control_mask=0x00000804 + // PARA_TEXT: secd(0~7) + cold(8~15) + gso(16~23) + CR(24) = 25 chars + let mut new_controls = existing_controls; + new_controls.push(Control::Picture(Box::new(picture))); + + // 참조 문단의 정확한 값 사용 + let ref_para = &ref_doc.document.sections[0].paragraphs[0]; + let pic_para = Paragraph { + text: String::new(), + char_count: 25, // secd(8) + cold(8) + gso(8) + CR(1) = 25 + char_count_msb: true, // 참조: msb=true + control_mask: 0x00000804, + para_shape_id: first_para.para_shape_id, // empty.hwp 기본값 사용 + style_id: first_para.style_id, + raw_break_type: ref_para.raw_break_type, // 참조: 0x03 + char_shapes: vec![CharShapeRef { + start_pos: 0, + char_shape_id: first_para + .char_shapes + .first() + .map(|cs| cs.char_shape_id) + .unwrap_or(0), + }], + line_segs: vec![LineSeg { + text_start: 0, + vertical_pos: 0, + line_height: ref_para.line_segs[0].line_height, // 참조: 14775 (=이미지 높이) + text_height: ref_para.line_segs[0].text_height, + baseline_distance: ref_para.line_segs[0].baseline_distance, + line_spacing: ref_para.line_segs[0].line_spacing, + column_start: 0, + segment_width: ref_para.line_segs[0].segment_width, // 참조: 42520 + tag: ref_para.line_segs[0].tag, // 참조: 0x00060000 + }], + has_para_text: true, + controls: new_controls, + raw_header_extra: first_para.raw_header_extra.clone(), + ..Default::default() + }; + + // 참조: 문단 1개만 (참조 파일에는 두 번째 문단이 없음) + doc.document.sections[0].paragraphs = vec![pic_para]; + + // 6. raw_stream 무효화 (재직렬화) + doc.document.sections[0].raw_stream = None; + doc.document.doc_info.raw_stream = None; + doc.document.doc_properties.raw_data = None; + + // 캐럿 위치 (참조: list_id=0, para_id=0, char_pos=24) + doc.document.doc_properties.caret_list_id = 0; + doc.document.doc_properties.caret_para_id = 0; + doc.document.doc_properties.caret_char_pos = 24; + + // 7. 저장 + let saved = doc.export_hwp_native(); + assert!(saved.is_ok(), "HWP 저장 실패: {:?}", saved.err()); + let saved_data = saved.unwrap(); + + let _ = std::fs::create_dir_all("output"); + std::fs::write("output/save_test_picture.hwp", &saved_data).unwrap(); + eprintln!( + " 저장: output/save_test_picture.hwp ({} bytes)", + saved_data.len() + ); + + // 8. 재파싱 검증 + let doc2 = HwpDocument::from_bytes(&saved_data); + assert!(doc2.is_ok(), "재파싱 실패: {:?}", doc2.err()); + let doc2 = doc2.unwrap(); + + // Picture 컨트롤 존재 검증 + let para2 = &doc2.document.sections[0].paragraphs[0]; + eprintln!( + " 재파싱: char_count={} msb={} controls={}", + para2.char_count, + para2.char_count_msb, + para2.controls.len() + ); + let pic_found = para2 + .controls + .iter() + .any(|c| matches!(c, Control::Picture(_))); + assert!(pic_found, "재파싱된 문서에 Picture 컨트롤이 없음"); + + // Picture 속성 검증 + if let Some(Control::Picture(p)) = para2 + .controls + .iter() + .find(|c| matches!(c, Control::Picture(_))) + { + eprintln!( + " Picture: {}×{} bin_data_id={}", + p.common.width, p.common.height, p.image_attr.bin_data_id + ); + eprintln!(" border_x={:?} border_y={:?}", p.border_x, p.border_y); + eprintln!( + " crop: L={} T={} R={} B={}", + p.crop.left, p.crop.top, p.crop.right, p.crop.bottom + ); + eprintln!( + " shape_attr: ctrl_id=0x{:08X} two={} orig={}×{} cur={}×{}", + p.shape_attr.ctrl_id, + p.shape_attr.is_two_ctrl_id, + p.shape_attr.original_width, + p.shape_attr.original_height, + p.shape_attr.current_width, + p.shape_attr.current_height + ); + assert_eq!(p.image_attr.bin_data_id, 1); + assert_eq!(p.common.width, pic_width); + assert_eq!(p.common.height, pic_height); + } - // 5. 문단 구성 (참조 파일: 단일 문단에 SectionDef + ColumnDef + Picture) - let first_para = &doc.document.sections[0].paragraphs[0]; - let existing_controls: Vec = first_para.controls.clone(); - - // 참조: char_count=25 (msb=true), control_mask=0x00000804 - // PARA_TEXT: secd(0~7) + cold(8~15) + gso(16~23) + CR(24) = 25 chars - let mut new_controls = existing_controls; - new_controls.push(Control::Picture(Box::new(picture))); - - // 참조 문단의 정확한 값 사용 - let ref_para = &ref_doc.document.sections[0].paragraphs[0]; - let pic_para = Paragraph { - text: String::new(), - char_count: 25, // secd(8) + cold(8) + gso(8) + CR(1) = 25 - char_count_msb: true, // 참조: msb=true - control_mask: 0x00000804, - para_shape_id: first_para.para_shape_id, // empty.hwp 기본값 사용 - style_id: first_para.style_id, - raw_break_type: ref_para.raw_break_type, // 참조: 0x03 - char_shapes: vec![CharShapeRef { - start_pos: 0, - char_shape_id: first_para.char_shapes.first() - .map(|cs| cs.char_shape_id).unwrap_or(0), - }], - line_segs: vec![LineSeg { - text_start: 0, - vertical_pos: 0, - line_height: ref_para.line_segs[0].line_height, // 참조: 14775 (=이미지 높이) - text_height: ref_para.line_segs[0].text_height, - baseline_distance: ref_para.line_segs[0].baseline_distance, - line_spacing: ref_para.line_segs[0].line_spacing, - column_start: 0, - segment_width: ref_para.line_segs[0].segment_width, // 참조: 42520 - tag: ref_para.line_segs[0].tag, // 참조: 0x00060000 - }], - has_para_text: true, - controls: new_controls, - raw_header_extra: first_para.raw_header_extra.clone(), - ..Default::default() + // BinData 검증 + assert_eq!( + doc2.document.doc_info.bin_data_list.len(), + 1, + "BinData 없음" + ); + assert_eq!( + doc2.document.doc_info.bin_data_list[0].data_type, + BinDataType::Embedding + ); + assert_eq!(doc2.document.doc_info.bin_data_list[0].storage_id, 1); + + // BinDataContent 검증 + assert_eq!( + doc2.document.bin_data_content.len(), + 1, + "BinDataContent 없음" + ); + assert_eq!( + doc2.document.bin_data_content[0].data.len(), + ref_bincontent.data.len(), + "이미지 데이터 크기 불일치" + ); + + // 캐럿 위치 검증 + eprintln!( + " 캐럿: list_id={} para_id={} char_pos={}", + doc2.document.doc_properties.caret_list_id, + doc2.document.doc_properties.caret_para_id, + doc2.document.doc_properties.caret_char_pos + ); + + // 9. 저장 레코드 덤프 (참조 파일과 비교) + let saved_parsed = crate::parser::parse_hwp(&saved_data).unwrap(); + let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_parsed.header.compressed, false) + .unwrap(); + let saved_recs = Record::read_all(&saved_bt).unwrap(); + + // 참조 파일 레코드도 덤프 + let ref_parsed = crate::parser::parse_hwp(&ref_data).unwrap(); + let mut ref_cfb = crate::parser::cfb_reader::CfbReader::open(&ref_data).unwrap(); + let ref_bt = ref_cfb + .read_body_text_section(0, ref_parsed.header.compressed, false) + .unwrap(); + let ref_recs = Record::read_all(&ref_bt).unwrap(); + + eprintln!( + "\n --- 레코드 비교 (참조={} 개, 저장={} 개) ---", + ref_recs.len(), + saved_recs.len() + ); + let max_recs = ref_recs.len().max(saved_recs.len()); + for i in 0..max_recs { + let ref_info = if i < ref_recs.len() { + let r = &ref_recs[i]; + format!( + "tag={:3}({:22}) lv={} sz={}", + r.tag_id, + tags::tag_name(r.tag_id), + r.level, + r.data.len() + ) + } else { + "---".to_string() }; - - // 참조: 문단 1개만 (참조 파일에는 두 번째 문단이 없음) - doc.document.sections[0].paragraphs = vec![pic_para]; - - // 6. raw_stream 무효화 (재직렬화) - doc.document.sections[0].raw_stream = None; - doc.document.doc_info.raw_stream = None; - doc.document.doc_properties.raw_data = None; - - // 캐럿 위치 (참조: list_id=0, para_id=0, char_pos=24) - doc.document.doc_properties.caret_list_id = 0; - doc.document.doc_properties.caret_para_id = 0; - doc.document.doc_properties.caret_char_pos = 24; - - // 7. 저장 - let saved = doc.export_hwp_native(); - assert!(saved.is_ok(), "HWP 저장 실패: {:?}", saved.err()); - let saved_data = saved.unwrap(); - - let _ = std::fs::create_dir_all("output"); - std::fs::write("output/save_test_picture.hwp", &saved_data).unwrap(); - eprintln!(" 저장: output/save_test_picture.hwp ({} bytes)", saved_data.len()); - - // 8. 재파싱 검증 - let doc2 = HwpDocument::from_bytes(&saved_data); - assert!(doc2.is_ok(), "재파싱 실패: {:?}", doc2.err()); - let doc2 = doc2.unwrap(); - - // Picture 컨트롤 존재 검증 - let para2 = &doc2.document.sections[0].paragraphs[0]; - eprintln!(" 재파싱: char_count={} msb={} controls={}", - para2.char_count, para2.char_count_msb, para2.controls.len()); - let pic_found = para2.controls.iter().any(|c| matches!(c, Control::Picture(_))); - assert!(pic_found, "재파싱된 문서에 Picture 컨트롤이 없음"); - - // Picture 속성 검증 - if let Some(Control::Picture(p)) = para2.controls.iter().find(|c| matches!(c, Control::Picture(_))) { - eprintln!(" Picture: {}×{} bin_data_id={}", - p.common.width, p.common.height, p.image_attr.bin_data_id); - eprintln!(" border_x={:?} border_y={:?}", p.border_x, p.border_y); - eprintln!(" crop: L={} T={} R={} B={}", p.crop.left, p.crop.top, p.crop.right, p.crop.bottom); - eprintln!(" shape_attr: ctrl_id=0x{:08X} two={} orig={}×{} cur={}×{}", - p.shape_attr.ctrl_id, p.shape_attr.is_two_ctrl_id, - p.shape_attr.original_width, p.shape_attr.original_height, - p.shape_attr.current_width, p.shape_attr.current_height); - assert_eq!(p.image_attr.bin_data_id, 1); - assert_eq!(p.common.width, pic_width); - assert_eq!(p.common.height, pic_height); - } - - // BinData 검증 - assert_eq!(doc2.document.doc_info.bin_data_list.len(), 1, "BinData 없음"); - assert_eq!(doc2.document.doc_info.bin_data_list[0].data_type, BinDataType::Embedding); - assert_eq!(doc2.document.doc_info.bin_data_list[0].storage_id, 1); - - // BinDataContent 검증 - assert_eq!(doc2.document.bin_data_content.len(), 1, "BinDataContent 없음"); - assert_eq!(doc2.document.bin_data_content[0].data.len(), ref_bincontent.data.len(), - "이미지 데이터 크기 불일치"); - - // 캐럿 위치 검증 - eprintln!(" 캐럿: list_id={} para_id={} char_pos={}", - doc2.document.doc_properties.caret_list_id, - doc2.document.doc_properties.caret_para_id, - doc2.document.doc_properties.caret_char_pos); - - // 9. 저장 레코드 덤프 (참조 파일과 비교) - let saved_parsed = crate::parser::parse_hwp(&saved_data).unwrap(); - let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, saved_parsed.header.compressed, false).unwrap(); - let saved_recs = Record::read_all(&saved_bt).unwrap(); - - // 참조 파일 레코드도 덤프 - let ref_parsed = crate::parser::parse_hwp(&ref_data).unwrap(); - let mut ref_cfb = crate::parser::cfb_reader::CfbReader::open(&ref_data).unwrap(); - let ref_bt = ref_cfb.read_body_text_section(0, ref_parsed.header.compressed, false).unwrap(); - let ref_recs = Record::read_all(&ref_bt).unwrap(); - - eprintln!("\n --- 레코드 비교 (참조={} 개, 저장={} 개) ---", ref_recs.len(), saved_recs.len()); - let max_recs = ref_recs.len().max(saved_recs.len()); - for i in 0..max_recs { - let ref_info = if i < ref_recs.len() { - let r = &ref_recs[i]; - format!("tag={:3}({:22}) lv={} sz={}", r.tag_id, tags::tag_name(r.tag_id), r.level, r.data.len()) - } else { "---".to_string() }; - let saved_info = if i < saved_recs.len() { - let r = &saved_recs[i]; - format!("tag={:3}({:22}) lv={} sz={}", r.tag_id, tags::tag_name(r.tag_id), r.level, r.data.len()) - } else { "---".to_string() }; - let match_mark = if i < ref_recs.len() && i < saved_recs.len() { - let r = &ref_recs[i]; - let s = &saved_recs[i]; - if r.tag_id == s.tag_id && r.level == s.level && r.data.len() == s.data.len() { - if r.data == s.data { "==" } else { "~=" } - } else { "!=" } - } else { "!=" }; - eprintln!(" [{:2}] {} {} | {}", i, match_mark, ref_info, saved_info); - } - - // 주요 레코드 바이트 비교 - for i in 0..ref_recs.len().min(saved_recs.len()) { + let saved_info = if i < saved_recs.len() { + let r = &saved_recs[i]; + format!( + "tag={:3}({:22}) lv={} sz={}", + r.tag_id, + tags::tag_name(r.tag_id), + r.level, + r.data.len() + ) + } else { + "---".to_string() + }; + let match_mark = if i < ref_recs.len() && i < saved_recs.len() { let r = &ref_recs[i]; let s = &saved_recs[i]; - if r.tag_id == s.tag_id && r.data != s.data { - eprintln!("\n [차이 상세] 레코드 {}: {}", i, tags::tag_name(r.tag_id)); - let max_show = r.data.len().max(s.data.len()).min(120); - eprintln!(" 참조: {:02x?}", &r.data[..r.data.len().min(max_show)]); - eprintln!(" 저장: {:02x?}", &s.data[..s.data.len().min(max_show)]); - // 첫 번째 차이 위치 - for j in 0..r.data.len().min(s.data.len()) { - if r.data[j] != s.data[j] { - eprintln!(" 첫 차이: offset {} (참조=0x{:02x}, 저장=0x{:02x})", j, r.data[j], s.data[j]); - break; - } + if r.tag_id == s.tag_id && r.level == s.level && r.data.len() == s.data.len() { + if r.data == s.data { + "==" + } else { + "~=" } + } else { + "!=" } - } + } else { + "!=" + }; + eprintln!(" [{:2}] {} {} | {}", i, match_mark, ref_info, saved_info); + } - // CFB 스트림 목록 확인 - let streams = saved_cfb.list_streams(); - eprintln!("\n --- CFB 스트림 목록 ---"); - for s in &streams { - eprintln!(" {}", s); + // 주요 레코드 바이트 비교 + for i in 0..ref_recs.len().min(saved_recs.len()) { + let r = &ref_recs[i]; + let s = &saved_recs[i]; + if r.tag_id == s.tag_id && r.data != s.data { + eprintln!("\n [차이 상세] 레코드 {}: {}", i, tags::tag_name(r.tag_id)); + let max_show = r.data.len().max(s.data.len()).min(120); + eprintln!(" 참조: {:02x?}", &r.data[..r.data.len().min(max_show)]); + eprintln!(" 저장: {:02x?}", &s.data[..s.data.len().min(max_show)]); + // 첫 번째 차이 위치 + for j in 0..r.data.len().min(s.data.len()) { + if r.data[j] != s.data[j] { + eprintln!( + " 첫 차이: offset {} (참조=0x{:02x}, 저장=0x{:02x})", + j, r.data[j], s.data[j] + ); + break; + } + } } - let has_bindata = streams.iter().any(|s| s.contains("BinData") || s.contains("BIN")); - assert!(has_bindata, "BinData 스트림이 없음"); - - eprintln!("\n=== 단계 4-2 이미지 저장 검증 완료 ==="); } - /// 추가 검증: 표 안에 이미지 삽입 — 참조 파일 분석 - /// output/pic-in-tb-01.hwp: 빈 문서 → 1×1 표 → 셀 안에 이미지 삽입 - #[test] - fn test_analyze_pic_in_table() { - use crate::parser::record::Record; - use crate::parser::tags; - use crate::parser::cfb_reader::LenientCfbReader; - use crate::model::control::Control; + // CFB 스트림 목록 확인 + let streams = saved_cfb.list_streams(); + eprintln!("\n --- CFB 스트림 목록 ---"); + for s in &streams { + eprintln!(" {}", s); + } + let has_bindata = streams + .iter() + .any(|s| s.contains("BinData") || s.contains("BIN")); + assert!(has_bindata, "BinData 스트림이 없음"); + + eprintln!("\n=== 단계 4-2 이미지 저장 검증 완료 ==="); +} + +/// 추가 검증: 표 안에 이미지 삽입 — 참조 파일 분석 +/// output/pic-in-tb-01.hwp: 빈 문서 → 1×1 표 → 셀 안에 이미지 삽입 +#[test] +fn test_analyze_pic_in_table() { + use crate::model::control::Control; + use crate::parser::cfb_reader::LenientCfbReader; + use crate::parser::record::Record; + use crate::parser::tags; + + let path = "output/pic-in-tb-01.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } - let path = "output/pic-in-tb-01.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; - } + let data = std::fs::read(path).unwrap(); + + eprintln!("\n{}", "=".repeat(70)); + eprintln!(" 표 안 이미지 참조 파일 분석: {}", path); + eprintln!(" 파일 크기: {} bytes", data.len()); + eprintln!("{}", "=".repeat(70)); + + let doc = match HwpDocument::from_bytes(&data) { + Ok(d) => d, + Err(e) => { + eprintln!(" 표준 파서 실패 ({}), LenientCfbReader로 분석합니다.", e); + let lcfb = LenientCfbReader::open(&data).unwrap(); + + eprintln!("\n [LenientCFB 엔트리]"); + for (name, start, size, otype) in lcfb.list_entries() { + let tname = match otype { + 1 => "storage", + 2 => "stream", + 5 => "root", + _ => "?", + }; + eprintln!( + " {:30} start={:5} size={:8} type={}", + name, start, size, tname + ); + } - let data = std::fs::read(path).unwrap(); + let fh = lcfb.read_stream("FileHeader").unwrap(); + let compressed = fh.len() >= 37 && (fh[36] & 0x01) != 0; - eprintln!("\n{}", "=".repeat(70)); - eprintln!(" 표 안 이미지 참조 파일 분석: {}", path); - eprintln!(" 파일 크기: {} bytes", data.len()); - eprintln!("{}", "=".repeat(70)); + // DocInfo + let di_data = lcfb.read_doc_info(compressed).unwrap(); + let di_recs = Record::read_all(&di_data).unwrap(); + + // 캐럿 위치 + if let Some(dp_rec) = di_recs.first() { + if dp_rec.tag_id == tags::HWPTAG_DOCUMENT_PROPERTIES && dp_rec.data.len() >= 26 { + let d = &dp_rec.data; + eprintln!("\n [캐럿 위치]"); + eprintln!( + " list_id={} para_id={} char_pos={}", + u32::from_le_bytes([d[14], d[15], d[16], d[17]]), + u32::from_le_bytes([d[18], d[19], d[20], d[21]]), + u32::from_le_bytes([d[22], d[23], d[24], d[25]]) + ); + } + } - let doc = match HwpDocument::from_bytes(&data) { - Ok(d) => d, - Err(e) => { - eprintln!(" 표준 파서 실패 ({}), LenientCfbReader로 분석합니다.", e); - let lcfb = LenientCfbReader::open(&data).unwrap(); - - eprintln!("\n [LenientCFB 엔트리]"); - for (name, start, size, otype) in lcfb.list_entries() { - let tname = match otype { 1 => "storage", 2 => "stream", 5 => "root", _ => "?" }; - eprintln!(" {:30} start={:5} size={:8} type={}", name, start, size, tname); - } - - let fh = lcfb.read_stream("FileHeader").unwrap(); - let compressed = fh.len() >= 37 && (fh[36] & 0x01) != 0; - - // DocInfo - let di_data = lcfb.read_doc_info(compressed).unwrap(); - let di_recs = Record::read_all(&di_data).unwrap(); - - // 캐럿 위치 - if let Some(dp_rec) = di_recs.first() { - if dp_rec.tag_id == tags::HWPTAG_DOCUMENT_PROPERTIES && dp_rec.data.len() >= 26 { - let d = &dp_rec.data; - eprintln!("\n [캐럿 위치]"); - eprintln!(" list_id={} para_id={} char_pos={}", - u32::from_le_bytes([d[14], d[15], d[16], d[17]]), - u32::from_le_bytes([d[18], d[19], d[20], d[21]]), - u32::from_le_bytes([d[22], d[23], d[24], d[25]])); - } - } - - // ID_MAPPINGS - if di_recs.len() > 1 && di_recs[1].tag_id == tags::HWPTAG_ID_MAPPINGS { - let d = &di_recs[1].data; - if d.len() >= 72 { - eprintln!("\n [ID_MAPPINGS]"); - let labels = ["bin_data", "font_kr", "font_en", "font_cn", "font_jp", - "font_etc", "font_sym", "font_usr", "border_fill", "char_shape", - "tab_def", "numbering", "bullet", "para_shape", "style", - "memo_shape", "trackchange", "trackchange_author"]; - for (i, label) in labels.iter().enumerate() { - let off = i * 4; - let val = u32::from_le_bytes([d[off], d[off+1], d[off+2], d[off+3]]); - if val > 0 { eprintln!(" {:20}: {}", label, val); } + // ID_MAPPINGS + if di_recs.len() > 1 && di_recs[1].tag_id == tags::HWPTAG_ID_MAPPINGS { + let d = &di_recs[1].data; + if d.len() >= 72 { + eprintln!("\n [ID_MAPPINGS]"); + let labels = [ + "bin_data", + "font_kr", + "font_en", + "font_cn", + "font_jp", + "font_etc", + "font_sym", + "font_usr", + "border_fill", + "char_shape", + "tab_def", + "numbering", + "bullet", + "para_shape", + "style", + "memo_shape", + "trackchange", + "trackchange_author", + ]; + for (i, label) in labels.iter().enumerate() { + let off = i * 4; + let val = u32::from_le_bytes([d[off], d[off + 1], d[off + 2], d[off + 3]]); + if val > 0 { + eprintln!(" {:20}: {}", label, val); } } } + } - // BIN_DATA 레코드 - eprintln!("\n [DocInfo BIN_DATA 레코드]"); - for (i, r) in di_recs.iter().enumerate() { - if r.tag_id == tags::HWPTAG_BIN_DATA { - eprintln!(" [{:2}] BIN_DATA size={} data: {:02x?}", - i, r.data.len(), &r.data[..r.data.len().min(60)]); - } + // BIN_DATA 레코드 + eprintln!("\n [DocInfo BIN_DATA 레코드]"); + for (i, r) in di_recs.iter().enumerate() { + if r.tag_id == tags::HWPTAG_BIN_DATA { + eprintln!( + " [{:2}] BIN_DATA size={} data: {:02x?}", + i, + r.data.len(), + &r.data[..r.data.len().min(60)] + ); } + } - // BodyText - let bt_data = lcfb.read_body_text_section(0, compressed).unwrap(); - let bt_recs = Record::read_all(&bt_data).unwrap(); + // BodyText + let bt_data = lcfb.read_body_text_section(0, compressed).unwrap(); + let bt_recs = Record::read_all(&bt_data).unwrap(); - eprintln!("\n [BodyText 레코드] ({} 개)", bt_recs.len()); - for (i, r) in bt_recs.iter().enumerate() { - let tname = tags::tag_name(r.tag_id); - let mut extra = String::new(); - if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { - let cid = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); - extra = format!(" ctrl='{}'", tags::ctrl_name(cid)); - } - eprintln!(" [{:2}] tag={:3}({:22}) level={} size={}{}", - i, r.tag_id, tname, r.level, r.data.len(), extra); - if matches!(r.tag_id, 66 | 67 | 68 | 69 | 71 | 72 | 76 | 77 | 85) { - let show = r.data.len().min(100); - eprintln!(" data[..{}]: {:02x?}", show, &r.data[..show]); - if r.data.len() > 100 { eprintln!(" total: {} bytes", r.data.len()); } + eprintln!("\n [BodyText 레코드] ({} 개)", bt_recs.len()); + for (i, r) in bt_recs.iter().enumerate() { + let tname = tags::tag_name(r.tag_id); + let mut extra = String::new(); + if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { + let cid = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); + extra = format!(" ctrl='{}'", tags::ctrl_name(cid)); + } + eprintln!( + " [{:2}] tag={:3}({:22}) level={} size={}{}", + i, + r.tag_id, + tname, + r.level, + r.data.len(), + extra + ); + if matches!(r.tag_id, 66 | 67 | 68 | 69 | 71 | 72 | 76 | 77 | 85) { + let show = r.data.len().min(100); + eprintln!(" data[..{}]: {:02x?}", show, &r.data[..show]); + if r.data.len() > 100 { + eprintln!(" total: {} bytes", r.data.len()); } } - - eprintln!("\n=== 분석 완료 (LenientCfbReader) ==="); - return; } - }; - // === 표준 파서 성공 === - - // 1. 캐럿 위치 - let dp = &doc.document.doc_properties; - eprintln!("\n [캐럿 위치]"); - eprintln!(" list_id={} para_id={} char_pos={}", dp.caret_list_id, dp.caret_para_id, dp.caret_char_pos); - - // 2. BinData - eprintln!("\n [BinData] ({} 개)", doc.document.doc_info.bin_data_list.len()); - for (i, bd) in doc.document.doc_info.bin_data_list.iter().enumerate() { - eprintln!(" [{}] attr=0x{:04X} type={:?} storage_id={} ext={:?}", - i, bd.attr, bd.data_type, bd.storage_id, bd.extension); - } - eprintln!(" [BinDataContent] ({} 개)", doc.document.bin_data_content.len()); - for (i, bc) in doc.document.bin_data_content.iter().enumerate() { - eprintln!(" [{}] id={} ext='{}' size={}", i, bc.id, bc.extension, bc.data.len()); + eprintln!("\n=== 분석 완료 (LenientCfbReader) ==="); + return; } + }; + + // === 표준 파서 성공 === + + // 1. 캐럿 위치 + let dp = &doc.document.doc_properties; + eprintln!("\n [캐럿 위치]"); + eprintln!( + " list_id={} para_id={} char_pos={}", + dp.caret_list_id, dp.caret_para_id, dp.caret_char_pos + ); + + // 2. BinData + eprintln!( + "\n [BinData] ({} 개)", + doc.document.doc_info.bin_data_list.len() + ); + for (i, bd) in doc.document.doc_info.bin_data_list.iter().enumerate() { + eprintln!( + " [{}] attr=0x{:04X} type={:?} storage_id={} ext={:?}", + i, bd.attr, bd.data_type, bd.storage_id, bd.extension + ); + } + eprintln!( + " [BinDataContent] ({} 개)", + doc.document.bin_data_content.len() + ); + for (i, bc) in doc.document.bin_data_content.iter().enumerate() { + eprintln!( + " [{}] id={} ext='{}' size={}", + i, + bc.id, + bc.extension, + bc.data.len() + ); + } - // 3. 문단/컨트롤 구조 (재귀적) - for (si, sec) in doc.document.sections.iter().enumerate() { - eprintln!("\n [섹션 {}] 문단: {}", si, sec.paragraphs.len()); - for (pi, para) in sec.paragraphs.iter().enumerate() { - eprintln!(" 문단[{}]: cc={} msb={} ctrls={} mask=0x{:08X} ps={} ss={}", - pi, para.char_count, para.char_count_msb, para.controls.len(), - para.control_mask, para.para_shape_id, para.style_id); - eprintln!(" cs={:?} ls={:?}", para.char_shapes, para.line_segs); - eprintln!(" raw_header_extra({} bytes): {:02x?}", - para.raw_header_extra.len(), ¶.raw_header_extra[..para.raw_header_extra.len().min(20)]); - - for (ci, ctrl) in para.controls.iter().enumerate() { - match ctrl { - Control::SectionDef(_) => eprintln!(" ctrl[{}]: SectionDef", ci), - Control::ColumnDef(_) => eprintln!(" ctrl[{}]: ColumnDef", ci), - Control::Table(t) => { - eprintln!(" ctrl[{}]: Table {}×{} cells={} bfid={} attr=0x{:08X}", - ci, t.row_count, t.col_count, t.cells.len(), t.border_fill_id, t.attr); - eprintln!(" padding: l={} r={} t={} b={}", t.padding.left, t.padding.right, t.padding.top, t.padding.bottom); - eprintln!(" cell_spacing={} row_sizes={:?}", t.cell_spacing, t.row_sizes); - eprintln!(" raw_ctrl_data({} bytes): {:02x?}", t.raw_ctrl_data.len(), &t.raw_ctrl_data[..t.raw_ctrl_data.len().min(40)]); - for (celli, cell) in t.cells.iter().enumerate() { - eprintln!(" cell[{}]: col={} row={} span={}×{} w={} h={} bfid={}", - celli, cell.col, cell.row, cell.col_span, cell.row_span, - cell.width, cell.height, cell.border_fill_id); - eprintln!(" padding: l={} r={} t={} b={} paras={}", - cell.padding.left, cell.padding.right, cell.padding.top, cell.padding.bottom, - cell.paragraphs.len()); - // 셀 내 문단/컨트롤 - for (cpi, cp) in cell.paragraphs.iter().enumerate() { - eprintln!(" para[{}]: cc={} msb={} ctrls={} mask=0x{:08X}", - cpi, cp.char_count, cp.char_count_msb, cp.controls.len(), cp.control_mask); - eprintln!(" cs={:?}", cp.char_shapes); - eprintln!(" ls={:?}", cp.line_segs); - for (cci, cctrl) in cp.controls.iter().enumerate() { - match cctrl { - Control::Picture(pic) => { - eprintln!(" ctrl[{}]: Picture {}×{} bid={}", - cci, pic.common.width, pic.common.height, pic.image_attr.bin_data_id); - eprintln!(" attr=0x{:08X} z={} margins=({},{},{},{})", + // 3. 문단/컨트롤 구조 (재귀적) + for (si, sec) in doc.document.sections.iter().enumerate() { + eprintln!("\n [섹션 {}] 문단: {}", si, sec.paragraphs.len()); + for (pi, para) in sec.paragraphs.iter().enumerate() { + eprintln!( + " 문단[{}]: cc={} msb={} ctrls={} mask=0x{:08X} ps={} ss={}", + pi, + para.char_count, + para.char_count_msb, + para.controls.len(), + para.control_mask, + para.para_shape_id, + para.style_id + ); + eprintln!(" cs={:?} ls={:?}", para.char_shapes, para.line_segs); + eprintln!( + " raw_header_extra({} bytes): {:02x?}", + para.raw_header_extra.len(), + ¶.raw_header_extra[..para.raw_header_extra.len().min(20)] + ); + + for (ci, ctrl) in para.controls.iter().enumerate() { + match ctrl { + Control::SectionDef(_) => eprintln!(" ctrl[{}]: SectionDef", ci), + Control::ColumnDef(_) => eprintln!(" ctrl[{}]: ColumnDef", ci), + Control::Table(t) => { + eprintln!( + " ctrl[{}]: Table {}×{} cells={} bfid={} attr=0x{:08X}", + ci, + t.row_count, + t.col_count, + t.cells.len(), + t.border_fill_id, + t.attr + ); + eprintln!( + " padding: l={} r={} t={} b={}", + t.padding.left, t.padding.right, t.padding.top, t.padding.bottom + ); + eprintln!( + " cell_spacing={} row_sizes={:?}", + t.cell_spacing, t.row_sizes + ); + eprintln!( + " raw_ctrl_data({} bytes): {:02x?}", + t.raw_ctrl_data.len(), + &t.raw_ctrl_data[..t.raw_ctrl_data.len().min(40)] + ); + for (celli, cell) in t.cells.iter().enumerate() { + eprintln!( + " cell[{}]: col={} row={} span={}×{} w={} h={} bfid={}", + celli, + cell.col, + cell.row, + cell.col_span, + cell.row_span, + cell.width, + cell.height, + cell.border_fill_id + ); + eprintln!( + " padding: l={} r={} t={} b={} paras={}", + cell.padding.left, + cell.padding.right, + cell.padding.top, + cell.padding.bottom, + cell.paragraphs.len() + ); + // 셀 내 문단/컨트롤 + for (cpi, cp) in cell.paragraphs.iter().enumerate() { + eprintln!( + " para[{}]: cc={} msb={} ctrls={} mask=0x{:08X}", + cpi, + cp.char_count, + cp.char_count_msb, + cp.controls.len(), + cp.control_mask + ); + eprintln!(" cs={:?}", cp.char_shapes); + eprintln!(" ls={:?}", cp.line_segs); + for (cci, cctrl) in cp.controls.iter().enumerate() { + match cctrl { + Control::Picture(pic) => { + eprintln!( + " ctrl[{}]: Picture {}×{} bid={}", + cci, + pic.common.width, + pic.common.height, + pic.image_attr.bin_data_id + ); + eprintln!(" attr=0x{:08X} z={} margins=({},{},{},{})", pic.common.attr, pic.common.z_order, pic.common.margin.left, pic.common.margin.right, pic.common.margin.top, pic.common.margin.bottom); - eprintln!(" shape: ctrl_id=0x{:08X} two={} orig={}×{} cur={}×{}", + eprintln!(" shape: ctrl_id=0x{:08X} two={} orig={}×{} cur={}×{}", pic.shape_attr.ctrl_id, pic.shape_attr.is_two_ctrl_id, pic.shape_attr.original_width, pic.shape_attr.original_height, pic.shape_attr.current_width, pic.shape_attr.current_height); - eprintln!(" border_x={:?} border_y={:?}", - pic.border_x, pic.border_y); - eprintln!(" crop: l={} t={} r={} b={}", - pic.crop.left, pic.crop.top, pic.crop.right, pic.crop.bottom); - eprintln!(" raw_extra({} bytes) raw_rendering({} bytes) raw_pic_extra({} bytes)", + eprintln!( + " border_x={:?} border_y={:?}", + pic.border_x, pic.border_y + ); + eprintln!( + " crop: l={} t={} r={} b={}", + pic.crop.left, + pic.crop.top, + pic.crop.right, + pic.crop.bottom + ); + eprintln!(" raw_extra({} bytes) raw_rendering({} bytes) raw_pic_extra({} bytes)", pic.common.raw_extra.len(), pic.shape_attr.raw_rendering.len(), pic.raw_picture_extra.len()); - }, - _ => eprintln!(" ctrl[{}]: {:?}", cci, std::mem::discriminant(cctrl)), } + _ => eprintln!( + " ctrl[{}]: {:?}", + cci, + std::mem::discriminant(cctrl) + ), } } } - }, - Control::Picture(pic) => { - eprintln!(" ctrl[{}]: Picture {}×{} bid={}", - ci, pic.common.width, pic.common.height, pic.image_attr.bin_data_id); - }, - _ => eprintln!(" ctrl[{}]: {:?}", ci, std::mem::discriminant(ctrl)), + } + } + Control::Picture(pic) => { + eprintln!( + " ctrl[{}]: Picture {}×{} bid={}", + ci, pic.common.width, pic.common.height, pic.image_attr.bin_data_id + ); } + _ => eprintln!(" ctrl[{}]: {:?}", ci, std::mem::discriminant(ctrl)), } } } + } - // 4. BodyText 레코드 덤프 - let parsed = crate::parser::parse_hwp(&data).unwrap(); - let mut cfb = crate::parser::cfb_reader::CfbReader::open(&data).unwrap(); - let bt = cfb.read_body_text_section(0, parsed.header.compressed, false).unwrap(); - let recs = Record::read_all(&bt).unwrap(); - - eprintln!("\n [BodyText 레코드] ({} 개)", recs.len()); - for (i, r) in recs.iter().enumerate() { - let tname = tags::tag_name(r.tag_id); - let mut extra = String::new(); - if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { - let cid = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); - extra = format!(" ctrl='{}'", tags::ctrl_name(cid)); - } - eprintln!(" [{:2}] tag={:3}({:22}) level={} size={}{}", - i, r.tag_id, tname, r.level, r.data.len(), extra); - if matches!(r.tag_id, 66 | 67 | 68 | 69 | 71 | 72 | 76 | 77 | 85) { - let show = r.data.len().min(100); - eprintln!(" data[..{}]: {:02x?}", show, &r.data[..show]); - if r.data.len() > 100 { eprintln!(" total: {} bytes", r.data.len()); } + // 4. BodyText 레코드 덤프 + let parsed = crate::parser::parse_hwp(&data).unwrap(); + let mut cfb = crate::parser::cfb_reader::CfbReader::open(&data).unwrap(); + let bt = cfb + .read_body_text_section(0, parsed.header.compressed, false) + .unwrap(); + let recs = Record::read_all(&bt).unwrap(); + + eprintln!("\n [BodyText 레코드] ({} 개)", recs.len()); + for (i, r) in recs.iter().enumerate() { + let tname = tags::tag_name(r.tag_id); + let mut extra = String::new(); + if r.tag_id == tags::HWPTAG_CTRL_HEADER && r.data.len() >= 4 { + let cid = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); + extra = format!(" ctrl='{}'", tags::ctrl_name(cid)); + } + eprintln!( + " [{:2}] tag={:3}({:22}) level={} size={}{}", + i, + r.tag_id, + tname, + r.level, + r.data.len(), + extra + ); + if matches!(r.tag_id, 66 | 67 | 68 | 69 | 71 | 72 | 76 | 77 | 85) { + let show = r.data.len().min(100); + eprintln!(" data[..{}]: {:02x?}", show, &r.data[..show]); + if r.data.len() > 100 { + eprintln!(" total: {} bytes", r.data.len()); } } + } - // 5. 비교 - let empty_path = "template/empty.hwp"; - if std::path::Path::new(empty_path).exists() { - let empty_data = std::fs::read(empty_path).unwrap(); - let empty_parsed = crate::parser::parse_hwp(&empty_data).unwrap(); - let mut empty_cfb = crate::parser::cfb_reader::CfbReader::open(&empty_data).unwrap(); - let empty_bt = empty_cfb.read_body_text_section(0, empty_parsed.header.compressed, false).unwrap(); - let empty_recs = Record::read_all(&empty_bt).unwrap(); - eprintln!("\n [비교] empty.hwp={} 개, pic-in-tb={} 개 → 차이={} 개", - empty_recs.len(), recs.len(), recs.len() as i32 - empty_recs.len() as i32); - } + // 5. 비교 + let empty_path = "template/empty.hwp"; + if std::path::Path::new(empty_path).exists() { + let empty_data = std::fs::read(empty_path).unwrap(); + let empty_parsed = crate::parser::parse_hwp(&empty_data).unwrap(); + let mut empty_cfb = crate::parser::cfb_reader::CfbReader::open(&empty_data).unwrap(); + let empty_bt = empty_cfb + .read_body_text_section(0, empty_parsed.header.compressed, false) + .unwrap(); + let empty_recs = Record::read_all(&empty_bt).unwrap(); + eprintln!( + "\n [비교] empty.hwp={} 개, pic-in-tb={} 개 → 차이={} 개", + empty_recs.len(), + recs.len(), + recs.len() as i32 - empty_recs.len() as i32 + ); + } - // 6. 라운드트립 검증 - eprintln!("\n [라운드트립 검증]"); - let mut doc_mut = HwpDocument::from_bytes(&data).unwrap(); - for sec in &mut doc_mut.document.sections { - sec.raw_stream = None; - } - doc_mut.document.doc_info.raw_stream = None; - doc_mut.document.doc_properties.raw_data = None; + // 6. 라운드트립 검증 + eprintln!("\n [라운드트립 검증]"); + let mut doc_mut = HwpDocument::from_bytes(&data).unwrap(); + for sec in &mut doc_mut.document.sections { + sec.raw_stream = None; + } + doc_mut.document.doc_info.raw_stream = None; + doc_mut.document.doc_properties.raw_data = None; - let saved = doc_mut.export_hwp_native(); - match saved { - Ok(saved_data) => { - let _ = std::fs::create_dir_all("output"); - std::fs::write("output/roundtrip_pic_in_tb.hwp", &saved_data).unwrap(); - eprintln!(" 저장: output/roundtrip_pic_in_tb.hwp ({} bytes)", saved_data.len()); - - // 재파싱 - match HwpDocument::from_bytes(&saved_data) { - Ok(doc2) => { - eprintln!(" 재파싱 성공 ✓"); - - // 레코드 비교 - let saved_parsed = crate::parser::parse_hwp(&saved_data).unwrap(); - let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, saved_parsed.header.compressed, false).unwrap(); - let saved_recs = Record::read_all(&saved_bt).unwrap(); - - eprintln!(" 레코드: 원본={} 저장={}", recs.len(), saved_recs.len()); - - let max = recs.len().max(saved_recs.len()); - let mut diff_count = 0; - for i in 0..max { - if i < recs.len() && i < saved_recs.len() { - let o = &recs[i]; - let s = &saved_recs[i]; - let mark = if o.tag_id == s.tag_id && o.level == s.level && o.data == s.data { "==" } - else if o.tag_id == s.tag_id && o.level == s.level { "~=" } - else { "!=" }; - if mark != "==" { - diff_count += 1; - if diff_count <= 10 { - eprintln!(" [{:2}] {} {}(lv{} sz{}) vs {}(lv{} sz{})", - i, mark, tags::tag_name(o.tag_id), o.level, o.data.len(), - tags::tag_name(s.tag_id), s.level, s.data.len()); - } + let saved = doc_mut.export_hwp_native(); + match saved { + Ok(saved_data) => { + let _ = std::fs::create_dir_all("output"); + std::fs::write("output/roundtrip_pic_in_tb.hwp", &saved_data).unwrap(); + eprintln!( + " 저장: output/roundtrip_pic_in_tb.hwp ({} bytes)", + saved_data.len() + ); + + // 재파싱 + match HwpDocument::from_bytes(&saved_data) { + Ok(doc2) => { + eprintln!(" 재파싱 성공 ✓"); + + // 레코드 비교 + let saved_parsed = crate::parser::parse_hwp(&saved_data).unwrap(); + let mut saved_cfb = + crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_parsed.header.compressed, false) + .unwrap(); + let saved_recs = Record::read_all(&saved_bt).unwrap(); + + eprintln!(" 레코드: 원본={} 저장={}", recs.len(), saved_recs.len()); + + let max = recs.len().max(saved_recs.len()); + let mut diff_count = 0; + for i in 0..max { + if i < recs.len() && i < saved_recs.len() { + let o = &recs[i]; + let s = &saved_recs[i]; + let mark = + if o.tag_id == s.tag_id && o.level == s.level && o.data == s.data { + "==" + } else if o.tag_id == s.tag_id && o.level == s.level { + "~=" + } else { + "!=" + }; + if mark != "==" { + diff_count += 1; + if diff_count <= 10 { + eprintln!( + " [{:2}] {} {}(lv{} sz{}) vs {}(lv{} sz{})", + i, + mark, + tags::tag_name(o.tag_id), + o.level, + o.data.len(), + tags::tag_name(s.tag_id), + s.level, + s.data.len() + ); } } } - if diff_count > 10 { eprintln!(" ... 외 {} 개", diff_count - 10); } - eprintln!(" 일치: {}/{} ({}%)", - max.saturating_sub(diff_count), max, - if max > 0 { (max.saturating_sub(diff_count)) * 100 / max } else { 100 }); - - // 표 안 이미지 보존 확인 - let mut pic_in_cell = false; - for sec in &doc2.document.sections { - for para in &sec.paragraphs { - for ctrl in ¶.controls { - if let Control::Table(t) = ctrl { - for cell in &t.cells { - for cp in &cell.paragraphs { - for cc in &cp.controls { - if matches!(cc, Control::Picture(_)) { - pic_in_cell = true; - } + } + if diff_count > 10 { + eprintln!(" ... 외 {} 개", diff_count - 10); + } + eprintln!( + " 일치: {}/{} ({}%)", + max.saturating_sub(diff_count), + max, + if max > 0 { + (max.saturating_sub(diff_count)) * 100 / max + } else { + 100 + } + ); + + // 표 안 이미지 보존 확인 + let mut pic_in_cell = false; + for sec in &doc2.document.sections { + for para in &sec.paragraphs { + for ctrl in ¶.controls { + if let Control::Table(t) = ctrl { + for cell in &t.cells { + for cp in &cell.paragraphs { + for cc in &cp.controls { + if matches!(cc, Control::Picture(_)) { + pic_in_cell = true; } } } @@ -10045,964 +13776,1342 @@ } } } - eprintln!(" 표 안 이미지 보존: {}", if pic_in_cell { "✓" } else { "✗" }); - assert!(pic_in_cell, "라운드트립 후 표 안 이미지가 사라짐!"); } - Err(e) => eprintln!(" 재파싱 실패: {}", e), + eprintln!( + " 표 안 이미지 보존: {}", + if pic_in_cell { "✓" } else { "✗" } + ); + assert!(pic_in_cell, "라운드트립 후 표 안 이미지가 사라짐!"); } + Err(e) => eprintln!(" 재파싱 실패: {}", e), } - Err(e) => eprintln!(" 저장 실패: {}", e), } - - eprintln!("\n=== 표 안 이미지 참조 파일 분석 완료 ==="); + Err(e) => eprintln!(" 저장 실패: {}", e), } - /// 단계 5: 기타 컨트롤 라운드트립 검증 - /// 여러 샘플 파일에서 Header/Footer/Footnote/Endnote/Shape/Bookmark 라운드트립 - #[test] - fn test_roundtrip_all_controls() { - use crate::parser::record::Record; - use crate::parser::tags; - use crate::model::control::Control; - - let samples = [ - "samples/k-water-rfp.hwp", - "samples/20250130-hongbo.hwp", - "samples/hwp-multi-001.hwp", - "samples/hwp-multi-002.hwp", - "samples/2010-01-06.hwp", - ]; - - eprintln!("\n{}", "=".repeat(70)); - eprintln!(" 단계 5: 기타 컨트롤 라운드트립 검증"); - eprintln!("{}", "=".repeat(70)); + eprintln!("\n=== 표 안 이미지 참조 파일 분석 완료 ==="); +} + +/// 단계 5: 기타 컨트롤 라운드트립 검증 +/// 여러 샘플 파일에서 Header/Footer/Footnote/Endnote/Shape/Bookmark 라운드트립 +#[test] +fn test_roundtrip_all_controls() { + use crate::model::control::Control; + use crate::parser::record::Record; + use crate::parser::tags; + + let samples = [ + "samples/k-water-rfp.hwp", + "samples/20250130-hongbo.hwp", + "samples/hwp-multi-001.hwp", + "samples/hwp-multi-002.hwp", + "samples/2010-01-06.hwp", + ]; + + eprintln!("\n{}", "=".repeat(70)); + eprintln!(" 단계 5: 기타 컨트롤 라운드트립 검증"); + eprintln!("{}", "=".repeat(70)); + + for sample_path in &samples { + if !std::path::Path::new(sample_path).exists() { + eprintln!("\n SKIP: {} 없음", sample_path); + continue; + } - for sample_path in &samples { - if !std::path::Path::new(sample_path).exists() { - eprintln!("\n SKIP: {} 없음", sample_path); + let orig_data = std::fs::read(sample_path).unwrap(); + let doc = match HwpDocument::from_bytes(&orig_data) { + Ok(d) => d, + Err(e) => { + eprintln!("\n SKIP: {} 파싱 실패: {}", sample_path, e); continue; } + }; - let orig_data = std::fs::read(sample_path).unwrap(); - let doc = match HwpDocument::from_bytes(&orig_data) { - Ok(d) => d, - Err(e) => { - eprintln!("\n SKIP: {} 파싱 실패: {}", sample_path, e); - continue; - } - }; - - // 컨트롤 종류 카운트 - let mut ctrl_counts = std::collections::HashMap::new(); - for sec in &doc.document.sections { - fn count_controls(paras: &[crate::model::paragraph::Paragraph], counts: &mut std::collections::HashMap) { - for para in paras { - for ctrl in ¶.controls { - let name = match ctrl { - Control::SectionDef(_) => "SectionDef", - Control::ColumnDef(_) => "ColumnDef", - Control::Table(t) => { - // 표 안의 컨트롤도 카운트 - for cell in &t.cells { - count_controls(&cell.paragraphs, counts); - } - "Table" - }, - Control::Picture(_) => "Picture", - Control::Shape(_) => "Shape", - Control::Header(h) => { - count_controls(&h.paragraphs, counts); - "Header" - }, - Control::Footer(f) => { - count_controls(&f.paragraphs, counts); - "Footer" - }, - Control::Footnote(f) => { - count_controls(&f.paragraphs, counts); - "Footnote" - }, - Control::Endnote(e) => { - count_controls(&e.paragraphs, counts); - "Endnote" - }, - Control::HiddenComment(_) => "HiddenComment", - Control::AutoNumber(_) => "AutoNumber", - Control::NewNumber(_) => "NewNumber", - Control::PageNumberPos(_) => "PageNumberPos", - Control::Bookmark(_) => "Bookmark", - _ => "Other", - }; - *counts.entry(name.to_string()).or_insert(0) += 1; - } + // 컨트롤 종류 카운트 + let mut ctrl_counts = std::collections::HashMap::new(); + for sec in &doc.document.sections { + fn count_controls( + paras: &[crate::model::paragraph::Paragraph], + counts: &mut std::collections::HashMap, + ) { + for para in paras { + for ctrl in ¶.controls { + let name = match ctrl { + Control::SectionDef(_) => "SectionDef", + Control::ColumnDef(_) => "ColumnDef", + Control::Table(t) => { + // 표 안의 컨트롤도 카운트 + for cell in &t.cells { + count_controls(&cell.paragraphs, counts); + } + "Table" + } + Control::Picture(_) => "Picture", + Control::Shape(_) => "Shape", + Control::Header(h) => { + count_controls(&h.paragraphs, counts); + "Header" + } + Control::Footer(f) => { + count_controls(&f.paragraphs, counts); + "Footer" + } + Control::Footnote(f) => { + count_controls(&f.paragraphs, counts); + "Footnote" + } + Control::Endnote(e) => { + count_controls(&e.paragraphs, counts); + "Endnote" + } + Control::HiddenComment(_) => "HiddenComment", + Control::AutoNumber(_) => "AutoNumber", + Control::NewNumber(_) => "NewNumber", + Control::PageNumberPos(_) => "PageNumberPos", + Control::Bookmark(_) => "Bookmark", + _ => "Other", + }; + *counts.entry(name.to_string()).or_insert(0) += 1; } } - count_controls(&sec.paragraphs, &mut ctrl_counts); } + count_controls(&sec.paragraphs, &mut ctrl_counts); + } - // 관심 대상 컨트롤만 필터링 - let target_ctrls = ["Header", "Footer", "Footnote", "Endnote", "Shape", "Bookmark", "Picture", "Table"]; - let has_target = target_ctrls.iter().any(|t| ctrl_counts.contains_key(*t)); + // 관심 대상 컨트롤만 필터링 + let target_ctrls = [ + "Header", "Footer", "Footnote", "Endnote", "Shape", "Bookmark", "Picture", "Table", + ]; + let has_target = target_ctrls.iter().any(|t| ctrl_counts.contains_key(*t)); + + eprintln!("\n --- {} ---", sample_path); + eprintln!( + " 섹션: {} 문단: {}", + doc.document.sections.len(), + doc.document + .sections + .iter() + .map(|s| s.paragraphs.len()) + .sum::() + ); + eprintln!(" 컨트롤: {:?}", ctrl_counts); + + if !has_target { + eprintln!(" → 대상 컨트롤 없음, 건너뜀"); + continue; + } - eprintln!("\n --- {} ---", sample_path); - eprintln!(" 섹션: {} 문단: {}", - doc.document.sections.len(), - doc.document.sections.iter().map(|s| s.paragraphs.len()).sum::()); - eprintln!(" 컨트롤: {:?}", ctrl_counts); + // 라운드트립: 원본 → 재직렬화 → 저장 → 재파싱 + let mut doc_mut = doc; + for sec in &mut doc_mut.document.sections { + sec.raw_stream = None; + } + doc_mut.document.doc_info.raw_stream = None; + doc_mut.document.doc_properties.raw_data = None; - if !has_target { - eprintln!(" → 대상 컨트롤 없음, 건너뜀"); + let saved = match doc_mut.export_hwp_native() { + Ok(d) => d, + Err(e) => { + eprintln!(" 저장 실패: {}", e); continue; } + }; - // 라운드트립: 원본 → 재직렬화 → 저장 → 재파싱 - let mut doc_mut = doc; - for sec in &mut doc_mut.document.sections { - sec.raw_stream = None; + // 재파싱 + let doc2 = match HwpDocument::from_bytes(&saved) { + Ok(d) => d, + Err(e) => { + eprintln!(" 재파싱 실패: {}", e); + // 저장 파일 기록 (디버그용) + let fname = format!( + "output/roundtrip_fail_{}.hwp", + std::path::Path::new(sample_path) + .file_stem() + .unwrap() + .to_str() + .unwrap() + ); + let _ = std::fs::create_dir_all("output"); + std::fs::write(&fname, &saved).unwrap(); + eprintln!(" 디버그 파일: {} ({} bytes)", fname, saved.len()); + continue; } - doc_mut.document.doc_info.raw_stream = None; - doc_mut.document.doc_properties.raw_data = None; - - let saved = match doc_mut.export_hwp_native() { - Ok(d) => d, - Err(e) => { - eprintln!(" 저장 실패: {}", e); - continue; - } - }; - - // 재파싱 - let doc2 = match HwpDocument::from_bytes(&saved) { - Ok(d) => d, - Err(e) => { - eprintln!(" 재파싱 실패: {}", e); - // 저장 파일 기록 (디버그용) - let fname = format!("output/roundtrip_fail_{}.hwp", - std::path::Path::new(sample_path).file_stem().unwrap().to_str().unwrap()); - let _ = std::fs::create_dir_all("output"); - std::fs::write(&fname, &saved).unwrap(); - eprintln!(" 디버그 파일: {} ({} bytes)", fname, saved.len()); - continue; - } - }; + }; - // 재파싱 후 컨트롤 카운트 비교 - let mut ctrl_counts2 = std::collections::HashMap::new(); - for sec in &doc2.document.sections { - fn count_controls2(paras: &[crate::model::paragraph::Paragraph], counts: &mut std::collections::HashMap) { - for para in paras { - for ctrl in ¶.controls { - let name = match ctrl { - Control::SectionDef(_) => "SectionDef", - Control::ColumnDef(_) => "ColumnDef", - Control::Table(t) => { - for cell in &t.cells { - count_controls2(&cell.paragraphs, counts); - } - "Table" - }, - Control::Picture(_) => "Picture", - Control::Shape(_) => "Shape", - Control::Header(h) => { - count_controls2(&h.paragraphs, counts); - "Header" - }, - Control::Footer(f) => { - count_controls2(&f.paragraphs, counts); - "Footer" - }, - Control::Footnote(f) => { - count_controls2(&f.paragraphs, counts); - "Footnote" - }, - Control::Endnote(e) => { - count_controls2(&e.paragraphs, counts); - "Endnote" - }, - Control::HiddenComment(_) => "HiddenComment", - Control::AutoNumber(_) => "AutoNumber", - Control::NewNumber(_) => "NewNumber", - Control::PageNumberPos(_) => "PageNumberPos", - Control::Bookmark(_) => "Bookmark", - _ => "Other", - }; - *counts.entry(name.to_string()).or_insert(0) += 1; - } + // 재파싱 후 컨트롤 카운트 비교 + let mut ctrl_counts2 = std::collections::HashMap::new(); + for sec in &doc2.document.sections { + fn count_controls2( + paras: &[crate::model::paragraph::Paragraph], + counts: &mut std::collections::HashMap, + ) { + for para in paras { + for ctrl in ¶.controls { + let name = match ctrl { + Control::SectionDef(_) => "SectionDef", + Control::ColumnDef(_) => "ColumnDef", + Control::Table(t) => { + for cell in &t.cells { + count_controls2(&cell.paragraphs, counts); + } + "Table" + } + Control::Picture(_) => "Picture", + Control::Shape(_) => "Shape", + Control::Header(h) => { + count_controls2(&h.paragraphs, counts); + "Header" + } + Control::Footer(f) => { + count_controls2(&f.paragraphs, counts); + "Footer" + } + Control::Footnote(f) => { + count_controls2(&f.paragraphs, counts); + "Footnote" + } + Control::Endnote(e) => { + count_controls2(&e.paragraphs, counts); + "Endnote" + } + Control::HiddenComment(_) => "HiddenComment", + Control::AutoNumber(_) => "AutoNumber", + Control::NewNumber(_) => "NewNumber", + Control::PageNumberPos(_) => "PageNumberPos", + Control::Bookmark(_) => "Bookmark", + _ => "Other", + }; + *counts.entry(name.to_string()).or_insert(0) += 1; } } - count_controls2(&sec.paragraphs, &mut ctrl_counts2); } + count_controls2(&sec.paragraphs, &mut ctrl_counts2); + } - // 대상 컨트롤별 보존 여부 확인 - let mut all_match = true; - for target in &target_ctrls { - let orig_count = ctrl_counts.get(*target).copied().unwrap_or(0); - let saved_count = ctrl_counts2.get(*target).copied().unwrap_or(0); - if orig_count > 0 || saved_count > 0 { - let status = if orig_count == saved_count { "✓" } else { "✗" }; - eprintln!(" {:12} 원본={:2} 저장={:2} {}", target, orig_count, saved_count, status); - if orig_count != saved_count { all_match = false; } + // 대상 컨트롤별 보존 여부 확인 + let mut all_match = true; + for target in &target_ctrls { + let orig_count = ctrl_counts.get(*target).copied().unwrap_or(0); + let saved_count = ctrl_counts2.get(*target).copied().unwrap_or(0); + if orig_count > 0 || saved_count > 0 { + let status = if orig_count == saved_count { + "✓" + } else { + "✗" + }; + eprintln!( + " {:12} 원본={:2} 저장={:2} {}", + target, orig_count, saved_count, status + ); + if orig_count != saved_count { + all_match = false; } } + } - // 레코드 수 비교 (섹션 0) - let orig_parsed = crate::parser::parse_hwp(&orig_data).unwrap(); - let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); - let orig_bt = orig_cfb.read_body_text_section(0, orig_parsed.header.compressed, false).unwrap(); - let orig_recs = Record::read_all(&orig_bt).unwrap(); + // 레코드 수 비교 (섹션 0) + let orig_parsed = crate::parser::parse_hwp(&orig_data).unwrap(); + let mut orig_cfb = crate::parser::cfb_reader::CfbReader::open(&orig_data).unwrap(); + let orig_bt = orig_cfb + .read_body_text_section(0, orig_parsed.header.compressed, false) + .unwrap(); + let orig_recs = Record::read_all(&orig_bt).unwrap(); - let saved_parsed = crate::parser::parse_hwp(&saved).unwrap(); - let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, saved_parsed.header.compressed, false).unwrap(); - let saved_recs = Record::read_all(&saved_bt).unwrap(); + let saved_parsed = crate::parser::parse_hwp(&saved).unwrap(); + let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved).unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_parsed.header.compressed, false) + .unwrap(); + let saved_recs = Record::read_all(&saved_bt).unwrap(); - eprintln!(" 레코드: 원본={} 저장={} {}", - orig_recs.len(), saved_recs.len(), - if orig_recs.len() == saved_recs.len() { "✓" } else { "✗" }); + eprintln!( + " 레코드: 원본={} 저장={} {}", + orig_recs.len(), + saved_recs.len(), + if orig_recs.len() == saved_recs.len() { + "✓" + } else { + "✗" + } + ); - // 레코드 차이 요약 (다른 레코드만) - let mut diff_count = 0; - let max_recs = orig_recs.len().min(saved_recs.len()); - for i in 0..max_recs { - let o = &orig_recs[i]; - let s = &saved_recs[i]; - if o.tag_id != s.tag_id || o.level != s.level || o.data != s.data { - diff_count += 1; - if diff_count <= 5 { - let match_type = if o.tag_id != s.tag_id || o.level != s.level { "구조" } else { "데이터" }; - eprintln!(" DIFF[{:3}] {} {} lv{} sz{} vs {} lv{} sz{}", - i, match_type, tags::tag_name(o.tag_id), o.level, o.data.len(), - tags::tag_name(s.tag_id), s.level, s.data.len()); - } + // 레코드 차이 요약 (다른 레코드만) + let mut diff_count = 0; + let max_recs = orig_recs.len().min(saved_recs.len()); + for i in 0..max_recs { + let o = &orig_recs[i]; + let s = &saved_recs[i]; + if o.tag_id != s.tag_id || o.level != s.level || o.data != s.data { + diff_count += 1; + if diff_count <= 5 { + let match_type = if o.tag_id != s.tag_id || o.level != s.level { + "구조" + } else { + "데이터" + }; + eprintln!( + " DIFF[{:3}] {} {} lv{} sz{} vs {} lv{} sz{}", + i, + match_type, + tags::tag_name(o.tag_id), + o.level, + o.data.len(), + tags::tag_name(s.tag_id), + s.level, + s.data.len() + ); } } - if diff_count > 5 { - eprintln!(" ... 외 {} 개 차이", diff_count - 5); + } + if diff_count > 5 { + eprintln!(" ... 외 {} 개 차이", diff_count - 5); + } + eprintln!( + " 일치: {}/{} 레코드 ({}%)", + max_recs - diff_count, + max_recs, + if max_recs > 0 { + (max_recs - diff_count) * 100 / max_recs + } else { + 100 } - eprintln!(" 일치: {}/{} 레코드 ({}%)", max_recs - diff_count, max_recs, - if max_recs > 0 { (max_recs - diff_count) * 100 / max_recs } else { 100 }); + ); - if all_match { - eprintln!(" → 라운드트립 성공 ✓"); - } + if all_match { + eprintln!(" → 라운드트립 성공 ✓"); } - - eprintln!("\n=== 단계 5 기타 컨트롤 라운드트립 검증 완료 ==="); } - /// 추가 검증: 빈 HWP → 1×1 표 → 셀 안에 이미지 삽입 (FROM SCRATCH) - /// 참조: output/pic-in-tb-01.hwp (HWP 프로그램으로 생성) - #[test] - fn test_save_pic_in_table() { - use crate::parser::record::Record; - use crate::parser::tags; - use crate::model::control::Control; - use crate::model::table::{Table, Cell}; - use crate::model::Padding; - use crate::model::bin_data::{BinData, BinDataType, BinDataCompression, BinDataStatus, BinDataContent}; - use crate::model::paragraph::{CharShapeRef, LineSeg, Paragraph}; - - eprintln!("\n=== 추가 검증: 표 안 이미지 저장 (FROM SCRATCH) ==="); - - // 1. 참조 파일에서 Table, Picture, BinData 구조 추출 - let ref_path = "output/pic-in-tb-01.hwp"; - if !std::path::Path::new(ref_path).exists() { - eprintln!("SKIP: {} 없음", ref_path); - return; + eprintln!("\n=== 단계 5 기타 컨트롤 라운드트립 검증 완료 ==="); +} + +/// 추가 검증: 빈 HWP → 1×1 표 → 셀 안에 이미지 삽입 (FROM SCRATCH) +/// 참조: output/pic-in-tb-01.hwp (HWP 프로그램으로 생성) +#[test] +fn test_save_pic_in_table() { + use crate::model::bin_data::{ + BinData, BinDataCompression, BinDataContent, BinDataStatus, BinDataType, + }; + use crate::model::control::Control; + use crate::model::paragraph::{CharShapeRef, LineSeg, Paragraph}; + use crate::model::table::{Cell, Table}; + use crate::model::Padding; + use crate::parser::record::Record; + use crate::parser::tags; + + eprintln!("\n=== 추가 검증: 표 안 이미지 저장 (FROM SCRATCH) ==="); + + // 1. 참조 파일에서 Table, Picture, BinData 구조 추출 + let ref_path = "output/pic-in-tb-01.hwp"; + if !std::path::Path::new(ref_path).exists() { + eprintln!("SKIP: {} 없음", ref_path); + return; + } + let ref_data = std::fs::read(ref_path).unwrap(); + let ref_doc = HwpDocument::from_bytes(&ref_data).unwrap(); + + // 참조 파일에서 Table 컨트롤 추출 + let ref_table = ref_doc.document.sections[0].paragraphs[0] + .controls + .iter() + .find_map(|c| { + if let Control::Table(t) = c { + Some(t) + } else { + None + } + }) + .expect("참조 파일에 Table 컨트롤 없음"); + + // 참조 파일에서 셀 안 Picture 컨트롤 추출 + let ref_pic = ref_table.cells[0].paragraphs[0] + .controls + .iter() + .find_map(|c| { + if let Control::Picture(p) = c { + Some(p) + } else { + None + } + }) + .expect("참조 파일 셀 안에 Picture 컨트롤 없음"); + + let ref_bindata = &ref_doc.document.doc_info.bin_data_list[0]; + let ref_bincontent = &ref_doc.document.bin_data_content[0]; + let ref_cell = &ref_table.cells[0]; + let ref_cell_para = &ref_cell.paragraphs[0]; + let ref_para0 = &ref_doc.document.sections[0].paragraphs[0]; + let ref_para1 = &ref_doc.document.sections[0].paragraphs[1]; + + eprintln!( + " 참조 Table: {}×{} bfid={} attr=0x{:08X}", + ref_table.row_count, ref_table.col_count, ref_table.border_fill_id, ref_table.attr + ); + eprintln!( + " 참조 Cell: col={} row={} w={} h={} bfid={}", + ref_cell.col, ref_cell.row, ref_cell.width, ref_cell.height, ref_cell.border_fill_id + ); + eprintln!( + " 참조 Cell 문단: cc={} msb={} mask=0x{:08X} ctrls={}", + ref_cell_para.char_count, + ref_cell_para.char_count_msb, + ref_cell_para.control_mask, + ref_cell_para.controls.len() + ); + eprintln!( + " 참조 Picture: {}×{} bid={} z={}", + ref_pic.common.width, + ref_pic.common.height, + ref_pic.image_attr.bin_data_id, + ref_pic.common.z_order + ); + eprintln!( + " 참조 캐럿: list_id={} para_id={} char_pos={}", + ref_doc.document.doc_properties.caret_list_id, + ref_doc.document.doc_properties.caret_para_id, + ref_doc.document.doc_properties.caret_char_pos + ); + + // 2. empty.hwp 로드 + let empty_path = "template/empty.hwp"; + assert!( + std::path::Path::new(empty_path).exists(), + "template/empty.hwp 없음" + ); + let empty_data = std::fs::read(empty_path).unwrap(); + let mut doc = HwpDocument::from_bytes(&empty_data).unwrap(); + + // 3. DocInfo에 BinData 추가 + let bin_data_entry = BinData { + attr: ref_bindata.attr, + data_type: BinDataType::Embedding, + compression: BinDataCompression::Default, + status: BinDataStatus::NotAccessed, + storage_id: 1, + extension: Some(ref_bincontent.extension.clone()), + raw_data: None, + ..Default::default() + }; + doc.document.doc_info.bin_data_list.push(bin_data_entry); + + // BinDataContent 추가 + doc.document.bin_data_content.push(BinDataContent { + id: 1, + data: ref_bincontent.data.clone(), + extension: ref_bincontent.extension.clone(), + }); + + // 4. DocInfo에 BorderFill 추가 (표 테두리용) + use crate::model::style::{BorderFill, BorderLine, BorderLineType, DiagonalLine, Fill}; + let solid_border = BorderLine { + line_type: BorderLineType::Solid, + width: 1, + color: 0, + }; + let new_bf = BorderFill { + raw_data: None, + attr: 0, + borders: [solid_border, solid_border, solid_border, solid_border], + diagonal: DiagonalLine { + diagonal_type: 1, + width: 0, + color: 0, + }, + fill: Fill::default(), + }; + doc.document.doc_info.border_fills.push(new_bf); + let table_bf_id = doc.document.doc_info.border_fills.len() as u16; + eprintln!( + " DocInfo: border_fill_count={}, table_bf_id={}", + doc.document.doc_info.border_fills.len(), + table_bf_id + ); + + // 5. Picture 컨트롤 구성 (참조 파일의 정확한 값 사용) + let picture = crate::model::image::Picture { + common: ref_pic.common.clone(), + shape_attr: ref_pic.shape_attr.clone(), + border_color: ref_pic.border_color, + border_width: ref_pic.border_width, + border_attr: ref_pic.border_attr.clone(), + border_x: ref_pic.border_x, + border_y: ref_pic.border_y, + crop: ref_pic.crop.clone(), + padding: ref_pic.padding.clone(), + image_attr: ref_pic.image_attr.clone(), + border_opacity: ref_pic.border_opacity, + instance_id: ref_pic.instance_id, + raw_picture_extra: ref_pic.raw_picture_extra.clone(), + caption: None, + }; + + // 6. 셀 내부 문단 구성 (cc=9: gso(8)+CR(1), mask=0x00000800) + let cell_para = Paragraph { + text: String::new(), + char_count: ref_cell_para.char_count, // 9 + char_count_msb: ref_cell_para.char_count_msb, // true + control_mask: ref_cell_para.control_mask, // 0x00000800 + para_shape_id: 0, + style_id: 0, + raw_break_type: ref_cell_para.raw_break_type, + char_shapes: vec![CharShapeRef { + start_pos: 0, + char_shape_id: 0, + }], + line_segs: vec![LineSeg { + text_start: ref_cell_para.line_segs[0].text_start, + vertical_pos: ref_cell_para.line_segs[0].vertical_pos, + line_height: ref_cell_para.line_segs[0].line_height, // 15600 (= image height) + text_height: ref_cell_para.line_segs[0].text_height, + baseline_distance: ref_cell_para.line_segs[0].baseline_distance, + line_spacing: ref_cell_para.line_segs[0].line_spacing, + column_start: ref_cell_para.line_segs[0].column_start, + segment_width: ref_cell_para.line_segs[0].segment_width, // 40932 + tag: ref_cell_para.line_segs[0].tag, + }], + has_para_text: true, // gso 제어문자 있으므로 PARA_TEXT 필요 + controls: vec![Control::Picture(Box::new(picture))], + raw_header_extra: ref_cell_para.raw_header_extra.clone(), + ..Default::default() + }; + + // 7. Cell 구성 + let cell = Cell { + col: ref_cell.col, + row: ref_cell.row, + col_span: ref_cell.col_span, + row_span: ref_cell.row_span, + width: ref_cell.width, + height: ref_cell.height, + border_fill_id: table_bf_id, + padding: Padding { + left: ref_cell.padding.left, + right: ref_cell.padding.right, + top: ref_cell.padding.top, + bottom: ref_cell.padding.bottom, + }, + list_header_width_ref: ref_cell.list_header_width_ref, + raw_list_extra: ref_cell.raw_list_extra.clone(), + paragraphs: vec![cell_para], + ..Default::default() + }; + + // 8. Table 구성 + let table = Table { + attr: ref_table.attr, + row_count: ref_table.row_count, + col_count: ref_table.col_count, + cell_spacing: ref_table.cell_spacing, + padding: Padding { + left: ref_table.padding.left, + right: ref_table.padding.right, + top: ref_table.padding.top, + bottom: ref_table.padding.bottom, + }, + row_sizes: ref_table.row_sizes.clone(), + border_fill_id: table_bf_id, + cells: vec![cell], + raw_ctrl_data: ref_table.raw_ctrl_data.clone(), + raw_table_record_attr: ref_table.raw_table_record_attr, + raw_table_record_extra: ref_table.raw_table_record_extra.clone(), + ..Default::default() + }; + + // 9. 첫 번째 문단에 Table 컨트롤 추가 + { + let para = &mut doc.document.sections[0].paragraphs[0]; + para.controls.push(Control::Table(Box::new(table))); + para.ctrl_data_records.push(None); + para.char_count += 8; // 표 제어문자 8 code units + para.control_mask = ref_para0.control_mask; // 0x00000804 + + // 표가 있는 문단의 segment_width는 0 (참조 파일) + if let Some(ls) = para.line_segs.first_mut() { + ls.segment_width = 0; } - let ref_data = std::fs::read(ref_path).unwrap(); - let ref_doc = HwpDocument::from_bytes(&ref_data).unwrap(); - - // 참조 파일에서 Table 컨트롤 추출 - let ref_table = ref_doc.document.sections[0].paragraphs[0].controls.iter() - .find_map(|c| if let Control::Table(t) = c { Some(t) } else { None }) - .expect("참조 파일에 Table 컨트롤 없음"); - - // 참조 파일에서 셀 안 Picture 컨트롤 추출 - let ref_pic = ref_table.cells[0].paragraphs[0].controls.iter() - .find_map(|c| if let Control::Picture(p) = c { Some(p) } else { None }) - .expect("참조 파일 셀 안에 Picture 컨트롤 없음"); - - let ref_bindata = &ref_doc.document.doc_info.bin_data_list[0]; - let ref_bincontent = &ref_doc.document.bin_data_content[0]; - let ref_cell = &ref_table.cells[0]; - let ref_cell_para = &ref_cell.paragraphs[0]; - let ref_para0 = &ref_doc.document.sections[0].paragraphs[0]; - let ref_para1 = &ref_doc.document.sections[0].paragraphs[1]; - - eprintln!(" 참조 Table: {}×{} bfid={} attr=0x{:08X}", - ref_table.row_count, ref_table.col_count, ref_table.border_fill_id, ref_table.attr); - eprintln!(" 참조 Cell: col={} row={} w={} h={} bfid={}", - ref_cell.col, ref_cell.row, ref_cell.width, ref_cell.height, ref_cell.border_fill_id); - eprintln!(" 참조 Cell 문단: cc={} msb={} mask=0x{:08X} ctrls={}", - ref_cell_para.char_count, ref_cell_para.char_count_msb, - ref_cell_para.control_mask, ref_cell_para.controls.len()); - eprintln!(" 참조 Picture: {}×{} bid={} z={}", - ref_pic.common.width, ref_pic.common.height, - ref_pic.image_attr.bin_data_id, ref_pic.common.z_order); - eprintln!(" 참조 캐럿: list_id={} para_id={} char_pos={}", - ref_doc.document.doc_properties.caret_list_id, - ref_doc.document.doc_properties.caret_para_id, - ref_doc.document.doc_properties.caret_char_pos); - - // 2. empty.hwp 로드 - let empty_path = "template/empty.hwp"; - assert!(std::path::Path::new(empty_path).exists(), "template/empty.hwp 없음"); - let empty_data = std::fs::read(empty_path).unwrap(); - let mut doc = HwpDocument::from_bytes(&empty_data).unwrap(); - - // 3. DocInfo에 BinData 추가 - let bin_data_entry = BinData { - attr: ref_bindata.attr, - data_type: BinDataType::Embedding, - compression: BinDataCompression::Default, - status: BinDataStatus::NotAccessed, - storage_id: 1, - extension: Some(ref_bincontent.extension.clone()), - raw_data: None, - ..Default::default() - }; - doc.document.doc_info.bin_data_list.push(bin_data_entry); - - // BinDataContent 추가 - doc.document.bin_data_content.push(BinDataContent { - id: 1, - data: ref_bincontent.data.clone(), - extension: ref_bincontent.extension.clone(), - }); - - // 4. DocInfo에 BorderFill 추가 (표 테두리용) - use crate::model::style::{BorderFill, BorderLine, BorderLineType, DiagonalLine, Fill}; - let solid_border = BorderLine { line_type: BorderLineType::Solid, width: 1, color: 0 }; - let new_bf = BorderFill { - raw_data: None, - attr: 0, - borders: [solid_border, solid_border, solid_border, solid_border], - diagonal: DiagonalLine { diagonal_type: 1, width: 0, color: 0 }, - fill: Fill::default(), - }; - doc.document.doc_info.border_fills.push(new_bf); - let table_bf_id = doc.document.doc_info.border_fills.len() as u16; - eprintln!(" DocInfo: border_fill_count={}, table_bf_id={}", doc.document.doc_info.border_fills.len(), table_bf_id); - - // 5. Picture 컨트롤 구성 (참조 파일의 정확한 값 사용) - let picture = crate::model::image::Picture { - common: ref_pic.common.clone(), - shape_attr: ref_pic.shape_attr.clone(), - border_color: ref_pic.border_color, - border_width: ref_pic.border_width, - border_attr: ref_pic.border_attr.clone(), - border_x: ref_pic.border_x, - border_y: ref_pic.border_y, - crop: ref_pic.crop.clone(), - padding: ref_pic.padding.clone(), - image_attr: ref_pic.image_attr.clone(), - border_opacity: ref_pic.border_opacity, - instance_id: ref_pic.instance_id, - raw_picture_extra: ref_pic.raw_picture_extra.clone(), - caption: None, - }; - - // 6. 셀 내부 문단 구성 (cc=9: gso(8)+CR(1), mask=0x00000800) - let cell_para = Paragraph { - text: String::new(), - char_count: ref_cell_para.char_count, // 9 - char_count_msb: ref_cell_para.char_count_msb, // true - control_mask: ref_cell_para.control_mask, // 0x00000800 - para_shape_id: 0, - style_id: 0, - raw_break_type: ref_cell_para.raw_break_type, - char_shapes: vec![CharShapeRef { - start_pos: 0, - char_shape_id: 0, - }], - line_segs: vec![LineSeg { - text_start: ref_cell_para.line_segs[0].text_start, - vertical_pos: ref_cell_para.line_segs[0].vertical_pos, - line_height: ref_cell_para.line_segs[0].line_height, // 15600 (= image height) - text_height: ref_cell_para.line_segs[0].text_height, - baseline_distance: ref_cell_para.line_segs[0].baseline_distance, - line_spacing: ref_cell_para.line_segs[0].line_spacing, - column_start: ref_cell_para.line_segs[0].column_start, - segment_width: ref_cell_para.line_segs[0].segment_width, // 40932 - tag: ref_cell_para.line_segs[0].tag, - }], - has_para_text: true, // gso 제어문자 있으므로 PARA_TEXT 필요 - controls: vec![Control::Picture(Box::new(picture))], - raw_header_extra: ref_cell_para.raw_header_extra.clone(), - ..Default::default() - }; - - // 7. Cell 구성 - let cell = Cell { - col: ref_cell.col, - row: ref_cell.row, - col_span: ref_cell.col_span, - row_span: ref_cell.row_span, - width: ref_cell.width, - height: ref_cell.height, - border_fill_id: table_bf_id, - padding: Padding { - left: ref_cell.padding.left, - right: ref_cell.padding.right, - top: ref_cell.padding.top, - bottom: ref_cell.padding.bottom, - }, - list_header_width_ref: ref_cell.list_header_width_ref, - raw_list_extra: ref_cell.raw_list_extra.clone(), - paragraphs: vec![cell_para], - ..Default::default() - }; - - // 8. Table 구성 - let table = Table { - attr: ref_table.attr, - row_count: ref_table.row_count, - col_count: ref_table.col_count, - cell_spacing: ref_table.cell_spacing, - padding: Padding { - left: ref_table.padding.left, - right: ref_table.padding.right, - top: ref_table.padding.top, - bottom: ref_table.padding.bottom, - }, - row_sizes: ref_table.row_sizes.clone(), - border_fill_id: table_bf_id, - cells: vec![cell], - raw_ctrl_data: ref_table.raw_ctrl_data.clone(), - raw_table_record_attr: ref_table.raw_table_record_attr, - raw_table_record_extra: ref_table.raw_table_record_extra.clone(), - ..Default::default() - }; - - // 9. 첫 번째 문단에 Table 컨트롤 추가 - { - let para = &mut doc.document.sections[0].paragraphs[0]; - para.controls.push(Control::Table(Box::new(table))); - para.ctrl_data_records.push(None); - para.char_count += 8; // 표 제어문자 8 code units - para.control_mask = ref_para0.control_mask; // 0x00000804 - - // 표가 있는 문단의 segment_width는 0 (참조 파일) - if let Some(ls) = para.line_segs.first_mut() { - ls.segment_width = 0; - } - } - - // 10. 두 번째 빈 문단 추가 (표 아래) - let empty_para = Paragraph { - text: String::new(), - char_count: ref_para1.char_count, // 1 - char_count_msb: ref_para1.char_count_msb, // true - control_mask: ref_para1.control_mask, // 0 - para_shape_id: 0, - style_id: 0, - raw_break_type: ref_para1.raw_break_type, - char_shapes: vec![CharShapeRef { - start_pos: 0, - char_shape_id: 0, - }], - line_segs: vec![LineSeg { - text_start: ref_para1.line_segs[0].text_start, - vertical_pos: ref_para1.line_segs[0].vertical_pos, // 16448 - line_height: ref_para1.line_segs[0].line_height, - text_height: ref_para1.line_segs[0].text_height, - baseline_distance: ref_para1.line_segs[0].baseline_distance, - line_spacing: ref_para1.line_segs[0].line_spacing, - column_start: ref_para1.line_segs[0].column_start, - segment_width: ref_para1.line_segs[0].segment_width, - tag: ref_para1.line_segs[0].tag, - }], - has_para_text: false, - raw_header_extra: ref_para1.raw_header_extra.clone(), - ..Default::default() - }; - doc.document.sections[0].paragraphs.push(empty_para); - - // 11. raw_stream 무효화 (재직렬화) - doc.document.sections[0].raw_stream = None; - doc.document.doc_info.raw_stream = None; - doc.document.doc_properties.raw_data = None; - - // 캐럿 위치 (참조: list_id=0, para_id=1, char_pos=0) - doc.document.doc_properties.caret_list_id = ref_doc.document.doc_properties.caret_list_id; - doc.document.doc_properties.caret_para_id = ref_doc.document.doc_properties.caret_para_id; - doc.document.doc_properties.caret_char_pos = ref_doc.document.doc_properties.caret_char_pos; - - let para = &doc.document.sections[0].paragraphs[0]; - eprintln!(" 구성 문단[0]: cc={} ctrls={} mask=0x{:08X} seg_w={}", - para.char_count, para.controls.len(), para.control_mask, - para.line_segs.first().map(|ls| ls.segment_width).unwrap_or(-1)); - let para1 = &doc.document.sections[0].paragraphs[1]; - eprintln!(" 구성 문단[1]: cc={} vpos={}", - para1.char_count, - para1.line_segs.first().map(|ls| ls.vertical_pos).unwrap_or(-1)); - - // 12. 저장 - let saved = doc.export_hwp_native(); - assert!(saved.is_ok(), "HWP 저장 실패: {:?}", saved.err()); - let saved_data = saved.unwrap(); - - let _ = std::fs::create_dir_all("output"); - std::fs::write("output/save_test_pic_in_table.hwp", &saved_data).unwrap(); - eprintln!(" 저장: output/save_test_pic_in_table.hwp ({} bytes)", saved_data.len()); - - // 13. 재파싱 검증 - let doc2 = HwpDocument::from_bytes(&saved_data); - assert!(doc2.is_ok(), "재파싱 실패: {:?}", doc2.err()); - let doc2 = doc2.unwrap(); - - // 표 컨트롤 존재 검증 - let para2 = &doc2.document.sections[0].paragraphs[0]; - let table_found = para2.controls.iter().any(|c| matches!(c, Control::Table(_))); - assert!(table_found, "재파싱된 문서에 표 컨트롤이 없음"); - - // 표 안 이미지 보존 검증 - let mut pic_in_cell = false; - if let Some(Control::Table(t)) = para2.controls.iter().find(|c| matches!(c, Control::Table(_))) { - eprintln!(" 재파싱 표: {}×{} cells={}", t.row_count, t.col_count, t.cells.len()); - assert_eq!(t.row_count, 1); - assert_eq!(t.col_count, 1); - assert_eq!(t.cells.len(), 1); + } - for cp in &t.cells[0].paragraphs { - for cc in &cp.controls { - if let Control::Picture(p) = cc { - pic_in_cell = true; - eprintln!(" 셀 안 Picture: {}×{} bid={}", - p.common.width, p.common.height, p.image_attr.bin_data_id); - assert_eq!(p.image_attr.bin_data_id, ref_pic.image_attr.bin_data_id); - assert_eq!(p.common.width, ref_pic.common.width); - assert_eq!(p.common.height, ref_pic.common.height); - } + // 10. 두 번째 빈 문단 추가 (표 아래) + let empty_para = Paragraph { + text: String::new(), + char_count: ref_para1.char_count, // 1 + char_count_msb: ref_para1.char_count_msb, // true + control_mask: ref_para1.control_mask, // 0 + para_shape_id: 0, + style_id: 0, + raw_break_type: ref_para1.raw_break_type, + char_shapes: vec![CharShapeRef { + start_pos: 0, + char_shape_id: 0, + }], + line_segs: vec![LineSeg { + text_start: ref_para1.line_segs[0].text_start, + vertical_pos: ref_para1.line_segs[0].vertical_pos, // 16448 + line_height: ref_para1.line_segs[0].line_height, + text_height: ref_para1.line_segs[0].text_height, + baseline_distance: ref_para1.line_segs[0].baseline_distance, + line_spacing: ref_para1.line_segs[0].line_spacing, + column_start: ref_para1.line_segs[0].column_start, + segment_width: ref_para1.line_segs[0].segment_width, + tag: ref_para1.line_segs[0].tag, + }], + has_para_text: false, + raw_header_extra: ref_para1.raw_header_extra.clone(), + ..Default::default() + }; + doc.document.sections[0].paragraphs.push(empty_para); + + // 11. raw_stream 무효화 (재직렬화) + doc.document.sections[0].raw_stream = None; + doc.document.doc_info.raw_stream = None; + doc.document.doc_properties.raw_data = None; + + // 캐럿 위치 (참조: list_id=0, para_id=1, char_pos=0) + doc.document.doc_properties.caret_list_id = ref_doc.document.doc_properties.caret_list_id; + doc.document.doc_properties.caret_para_id = ref_doc.document.doc_properties.caret_para_id; + doc.document.doc_properties.caret_char_pos = ref_doc.document.doc_properties.caret_char_pos; + + let para = &doc.document.sections[0].paragraphs[0]; + eprintln!( + " 구성 문단[0]: cc={} ctrls={} mask=0x{:08X} seg_w={}", + para.char_count, + para.controls.len(), + para.control_mask, + para.line_segs + .first() + .map(|ls| ls.segment_width) + .unwrap_or(-1) + ); + let para1 = &doc.document.sections[0].paragraphs[1]; + eprintln!( + " 구성 문단[1]: cc={} vpos={}", + para1.char_count, + para1 + .line_segs + .first() + .map(|ls| ls.vertical_pos) + .unwrap_or(-1) + ); + + // 12. 저장 + let saved = doc.export_hwp_native(); + assert!(saved.is_ok(), "HWP 저장 실패: {:?}", saved.err()); + let saved_data = saved.unwrap(); + + let _ = std::fs::create_dir_all("output"); + std::fs::write("output/save_test_pic_in_table.hwp", &saved_data).unwrap(); + eprintln!( + " 저장: output/save_test_pic_in_table.hwp ({} bytes)", + saved_data.len() + ); + + // 13. 재파싱 검증 + let doc2 = HwpDocument::from_bytes(&saved_data); + assert!(doc2.is_ok(), "재파싱 실패: {:?}", doc2.err()); + let doc2 = doc2.unwrap(); + + // 표 컨트롤 존재 검증 + let para2 = &doc2.document.sections[0].paragraphs[0]; + let table_found = para2 + .controls + .iter() + .any(|c| matches!(c, Control::Table(_))); + assert!(table_found, "재파싱된 문서에 표 컨트롤이 없음"); + + // 표 안 이미지 보존 검증 + let mut pic_in_cell = false; + if let Some(Control::Table(t)) = para2 + .controls + .iter() + .find(|c| matches!(c, Control::Table(_))) + { + eprintln!( + " 재파싱 표: {}×{} cells={}", + t.row_count, + t.col_count, + t.cells.len() + ); + assert_eq!(t.row_count, 1); + assert_eq!(t.col_count, 1); + assert_eq!(t.cells.len(), 1); + + for cp in &t.cells[0].paragraphs { + for cc in &cp.controls { + if let Control::Picture(p) = cc { + pic_in_cell = true; + eprintln!( + " 셀 안 Picture: {}×{} bid={}", + p.common.width, p.common.height, p.image_attr.bin_data_id + ); + assert_eq!(p.image_attr.bin_data_id, ref_pic.image_attr.bin_data_id); + assert_eq!(p.common.width, ref_pic.common.width); + assert_eq!(p.common.height, ref_pic.common.height); } } } - assert!(pic_in_cell, "재파싱 후 표 안 이미지가 없음"); - - // BinData 검증 - assert_eq!(doc2.document.doc_info.bin_data_list.len(), 1, "BinData 없음"); - assert_eq!(doc2.document.doc_info.bin_data_list[0].data_type, BinDataType::Embedding); - assert_eq!(doc2.document.bin_data_content.len(), 1, "BinDataContent 없음"); - assert_eq!(doc2.document.bin_data_content[0].data.len(), ref_bincontent.data.len(), - "이미지 데이터 크기 불일치"); - - // 두 번째 문단 검증 - assert!(doc2.document.sections[0].paragraphs.len() >= 2, "표 아래 빈 문단이 없음"); - - // 캐럿 위치 검증 - eprintln!(" 캐럿: list_id={} para_id={} char_pos={}", - doc2.document.doc_properties.caret_list_id, - doc2.document.doc_properties.caret_para_id, - doc2.document.doc_properties.caret_char_pos); - - // 14. 참조 파일과 레코드 비교 - let saved_parsed = crate::parser::parse_hwp(&saved_data).unwrap(); - let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); - let saved_bt = saved_cfb.read_body_text_section(0, saved_parsed.header.compressed, false).unwrap(); - let saved_recs = Record::read_all(&saved_bt).unwrap(); - - let ref_parsed = crate::parser::parse_hwp(&ref_data).unwrap(); - let mut ref_cfb = crate::parser::cfb_reader::CfbReader::open(&ref_data).unwrap(); - let ref_bt = ref_cfb.read_body_text_section(0, ref_parsed.header.compressed, false).unwrap(); - let ref_recs = Record::read_all(&ref_bt).unwrap(); - - eprintln!("\n --- 레코드 비교 (참조={} 개, 저장={} 개) ---", ref_recs.len(), saved_recs.len()); - let max_recs = ref_recs.len().max(saved_recs.len()); - let mut diff_count = 0; - for i in 0..max_recs { - let ref_info = if i < ref_recs.len() { - let r = &ref_recs[i]; - format!("tag={:3}({:22}) lv={} sz={}", r.tag_id, tags::tag_name(r.tag_id), r.level, r.data.len()) - } else { "---".to_string() }; - let saved_info = if i < saved_recs.len() { - let r = &saved_recs[i]; - format!("tag={:3}({:22}) lv={} sz={}", r.tag_id, tags::tag_name(r.tag_id), r.level, r.data.len()) - } else { "---".to_string() }; - let match_mark = if i < ref_recs.len() && i < saved_recs.len() { - let r = &ref_recs[i]; - let s = &saved_recs[i]; - if r.tag_id == s.tag_id && r.level == s.level && r.data == s.data { "==" } - else if r.tag_id == s.tag_id && r.level == s.level { "~=" } - else { "!=" } - } else { "!=" }; - if match_mark != "==" { diff_count += 1; } - eprintln!(" [{:2}] {} {} | {}", i, match_mark, ref_info, saved_info); - } - - // 차이 상세 - for i in 0..ref_recs.len().min(saved_recs.len()) { + } + assert!(pic_in_cell, "재파싱 후 표 안 이미지가 없음"); + + // BinData 검증 + assert_eq!( + doc2.document.doc_info.bin_data_list.len(), + 1, + "BinData 없음" + ); + assert_eq!( + doc2.document.doc_info.bin_data_list[0].data_type, + BinDataType::Embedding + ); + assert_eq!( + doc2.document.bin_data_content.len(), + 1, + "BinDataContent 없음" + ); + assert_eq!( + doc2.document.bin_data_content[0].data.len(), + ref_bincontent.data.len(), + "이미지 데이터 크기 불일치" + ); + + // 두 번째 문단 검증 + assert!( + doc2.document.sections[0].paragraphs.len() >= 2, + "표 아래 빈 문단이 없음" + ); + + // 캐럿 위치 검증 + eprintln!( + " 캐럿: list_id={} para_id={} char_pos={}", + doc2.document.doc_properties.caret_list_id, + doc2.document.doc_properties.caret_para_id, + doc2.document.doc_properties.caret_char_pos + ); + + // 14. 참조 파일과 레코드 비교 + let saved_parsed = crate::parser::parse_hwp(&saved_data).unwrap(); + let mut saved_cfb = crate::parser::cfb_reader::CfbReader::open(&saved_data).unwrap(); + let saved_bt = saved_cfb + .read_body_text_section(0, saved_parsed.header.compressed, false) + .unwrap(); + let saved_recs = Record::read_all(&saved_bt).unwrap(); + + let ref_parsed = crate::parser::parse_hwp(&ref_data).unwrap(); + let mut ref_cfb = crate::parser::cfb_reader::CfbReader::open(&ref_data).unwrap(); + let ref_bt = ref_cfb + .read_body_text_section(0, ref_parsed.header.compressed, false) + .unwrap(); + let ref_recs = Record::read_all(&ref_bt).unwrap(); + + eprintln!( + "\n --- 레코드 비교 (참조={} 개, 저장={} 개) ---", + ref_recs.len(), + saved_recs.len() + ); + let max_recs = ref_recs.len().max(saved_recs.len()); + let mut diff_count = 0; + for i in 0..max_recs { + let ref_info = if i < ref_recs.len() { + let r = &ref_recs[i]; + format!( + "tag={:3}({:22}) lv={} sz={}", + r.tag_id, + tags::tag_name(r.tag_id), + r.level, + r.data.len() + ) + } else { + "---".to_string() + }; + let saved_info = if i < saved_recs.len() { + let r = &saved_recs[i]; + format!( + "tag={:3}({:22}) lv={} sz={}", + r.tag_id, + tags::tag_name(r.tag_id), + r.level, + r.data.len() + ) + } else { + "---".to_string() + }; + let match_mark = if i < ref_recs.len() && i < saved_recs.len() { let r = &ref_recs[i]; let s = &saved_recs[i]; - if r.tag_id == s.tag_id && r.data != s.data { - eprintln!("\n [차이 상세] 레코드 {}: {}", i, tags::tag_name(r.tag_id)); - let max_show = r.data.len().max(s.data.len()).min(120); - eprintln!(" 참조: {:02x?}", &r.data[..r.data.len().min(max_show)]); - eprintln!(" 저장: {:02x?}", &s.data[..s.data.len().min(max_show)]); - for j in 0..r.data.len().min(s.data.len()) { - if r.data[j] != s.data[j] { - eprintln!(" 첫 차이: offset {} (참조=0x{:02x}, 저장=0x{:02x})", j, r.data[j], s.data[j]); - break; - } - } + if r.tag_id == s.tag_id && r.level == s.level && r.data == s.data { + "==" + } else if r.tag_id == s.tag_id && r.level == s.level { + "~=" + } else { + "!=" } + } else { + "!=" + }; + if match_mark != "==" { + diff_count += 1; } + eprintln!(" [{:2}] {} {} | {}", i, match_mark, ref_info, saved_info); + } - eprintln!(" 일치: {}/{} 레코드", max_recs - diff_count, max_recs); - - // CFB 스트림 확인 - let streams = saved_cfb.list_streams(); - let has_bindata = streams.iter().any(|s| s.contains("BinData") || s.contains("BIN")); - assert!(has_bindata, "BinData 스트림이 없음"); - - eprintln!("\n=== 표 안 이미지 저장 검증 완료 ==="); - } - - /// 타스크 41 단계 1: 기존 HWP에 프로그래밍 방식으로 2×2 표 삽입 → 저장 - /// 직렬화 코드 자체의 정상 동작을 먼저 확인 - #[test] - fn test_inject_table_into_existing() { - use crate::model::table::{Table, Cell}; - use crate::model::control::Control; - use crate::model::Padding; - use crate::model::paragraph::{CharShapeRef, LineSeg, Paragraph}; - - let path = "samples/20250130-hongbo.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; - } - - eprintln!("\n{}", "=".repeat(60)); - eprintln!(" 타스크 41 단계 1: 기존 HWP에 2×2 표 삽입"); - eprintln!("{}", "=".repeat(60)); - - let orig_data = std::fs::read(path).unwrap(); + // 차이 상세 + for i in 0..ref_recs.len().min(saved_recs.len()) { + let r = &ref_recs[i]; + let s = &saved_recs[i]; + if r.tag_id == s.tag_id && r.data != s.data { + eprintln!("\n [차이 상세] 레코드 {}: {}", i, tags::tag_name(r.tag_id)); + let max_show = r.data.len().max(s.data.len()).min(120); + eprintln!(" 참조: {:02x?}", &r.data[..r.data.len().min(max_show)]); + eprintln!(" 저장: {:02x?}", &s.data[..s.data.len().min(max_show)]); + for j in 0..r.data.len().min(s.data.len()) { + if r.data[j] != s.data[j] { + eprintln!( + " 첫 차이: offset {} (참조=0x{:02x}, 저장=0x{:02x})", + j, r.data[j], s.data[j] + ); + break; + } + } + } + } - let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); + eprintln!(" 일치: {}/{} 레코드", max_recs - diff_count, max_recs); + + // CFB 스트림 확인 + let streams = saved_cfb.list_streams(); + let has_bindata = streams + .iter() + .any(|s| s.contains("BinData") || s.contains("BIN")); + assert!(has_bindata, "BinData 스트림이 없음"); + + eprintln!("\n=== 표 안 이미지 저장 검증 완료 ==="); +} + +/// 타스크 41 단계 1: 기존 HWP에 프로그래밍 방식으로 2×2 표 삽입 → 저장 +/// 직렬화 코드 자체의 정상 동작을 먼저 확인 +#[test] +fn test_inject_table_into_existing() { + use crate::model::control::Control; + use crate::model::paragraph::{CharShapeRef, LineSeg, Paragraph}; + use crate::model::table::{Cell, Table}; + use crate::model::Padding; + + let path = "samples/20250130-hongbo.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } - let sec = &doc.document.sections[0]; - let orig_para_count = sec.paragraphs.len(); - eprintln!(" 원본: {} 문단, {} 컨트롤", - orig_para_count, - sec.paragraphs.iter().map(|p| p.controls.len()).sum::()); - - // 캐럿 위치 확인 - let caret_list_id = doc.document.doc_properties.caret_list_id; - let caret_para_id = doc.document.doc_properties.caret_para_id; - let caret_char_pos = doc.document.doc_properties.caret_char_pos; - eprintln!(" 캐럿 위치: list_id={}, para_id={}, char_pos={}", - caret_list_id, caret_para_id, caret_char_pos); - - // 삽입 위치: 캐럿이 가리키는 문단 - let insert_para_idx = caret_para_id as usize; - assert!(insert_para_idx < orig_para_count, - "캐럿 para_id({})가 문단 범위({})를 초과", insert_para_idx, orig_para_count); - eprintln!(" 삽입 위치: 문단[{}] (캐럿 기반)", insert_para_idx); - - // 삽입 위치 근처 문단 구조 출력 - let start = if insert_para_idx > 2 { insert_para_idx - 2 } else { 0 }; - let end = (insert_para_idx + 4).min(orig_para_count); - for i in start..end { - let p = &sec.paragraphs[i]; - let ctrl_types: Vec<&str> = p.controls.iter().map(|c| match c { + eprintln!("\n{}", "=".repeat(60)); + eprintln!(" 타스크 41 단계 1: 기존 HWP에 2×2 표 삽입"); + eprintln!("{}", "=".repeat(60)); + + let orig_data = std::fs::read(path).unwrap(); + + let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); + + let sec = &doc.document.sections[0]; + let orig_para_count = sec.paragraphs.len(); + eprintln!( + " 원본: {} 문단, {} 컨트롤", + orig_para_count, + sec.paragraphs + .iter() + .map(|p| p.controls.len()) + .sum::() + ); + + // 캐럿 위치 확인 + let caret_list_id = doc.document.doc_properties.caret_list_id; + let caret_para_id = doc.document.doc_properties.caret_para_id; + let caret_char_pos = doc.document.doc_properties.caret_char_pos; + eprintln!( + " 캐럿 위치: list_id={}, para_id={}, char_pos={}", + caret_list_id, caret_para_id, caret_char_pos + ); + + // 삽입 위치: 캐럿이 가리키는 문단 + let insert_para_idx = caret_para_id as usize; + assert!( + insert_para_idx < orig_para_count, + "캐럿 para_id({})가 문단 범위({})를 초과", + insert_para_idx, + orig_para_count + ); + eprintln!(" 삽입 위치: 문단[{}] (캐럿 기반)", insert_para_idx); + + // 삽입 위치 근처 문단 구조 출력 + let start = if insert_para_idx > 2 { + insert_para_idx - 2 + } else { + 0 + }; + let end = (insert_para_idx + 4).min(orig_para_count); + for i in start..end { + let p = &sec.paragraphs[i]; + let ctrl_types: Vec<&str> = p + .controls + .iter() + .map(|c| match c { Control::Table(_) => "Table", Control::Picture(_) => "Picture", _ => "Other", - }).collect(); - let marker = if i == insert_para_idx { " ← 캐럿" } else { "" }; - eprintln!(" 문단[{}]: cc={} mask=0x{:08X} text='{}' ctrls={:?}{}", - i, p.char_count, p.control_mask, - if p.text.len() > 30 { &p.text[..30] } else { &p.text }, - ctrl_types, marker); - } - - // === 방법: 기존 표 문단을 복제하여 삽입 (직렬화 문제 격리) === - // 문단[2]의 표를 그대로 복제 - let source_para_idx = 2; - let table_para = doc.document.sections[0].paragraphs[source_para_idx].clone(); - eprintln!(" 복제 원본: 문단[{}] cc={} controls={}", - source_para_idx, table_para.char_count, table_para.controls.len()); - if let Some(Control::Table(t)) = table_para.controls.first() { - eprintln!(" 표: {}×{} cells={} attr=0x{:08X}", - t.row_count, t.col_count, t.cells.len(), t.attr); - } - - // 캐럿 위치 뒤에 표 문단 삽입 - doc.document.sections[0].paragraphs.insert(insert_para_idx + 1, table_para); - - // 기존 콘텐츠 사이 삽입 → 빈 문단 불필요 (기존 문단이 이어짐) - - // raw_stream 무효화: 섹션만 (DocInfo raw 유지 → 손상 방지) - doc.document.sections[0].raw_stream = None; - - eprintln!(" 수정: {} 문단 (원본 {} + 표 문단 1개)", - doc.document.sections[0].paragraphs.len(), orig_para_count); - - // 저장 - let saved = doc.export_hwp_native(); - assert!(saved.is_ok(), "HWP 저장 실패: {:?}", saved.err()); - let saved_data = saved.unwrap(); - - let _ = std::fs::create_dir_all("output"); - std::fs::write("output/save_test_table_inject.hwp", &saved_data).unwrap(); - eprintln!(" 저장: output/save_test_table_inject.hwp ({} bytes)", saved_data.len()); - - // 재파싱 검증 - let doc2 = HwpDocument::from_bytes(&saved_data); - assert!(doc2.is_ok(), "재파싱 실패: {:?}", doc2.err()); - let doc2 = doc2.unwrap(); + }) + .collect(); + let marker = if i == insert_para_idx { + " ← 캐럿" + } else { + "" + }; + eprintln!( + " 문단[{}]: cc={} mask=0x{:08X} text='{}' ctrls={:?}{}", + i, + p.char_count, + p.control_mask, + if p.text.len() > 30 { + &p.text[..30] + } else { + &p.text + }, + ctrl_types, + marker + ); + } - // 문단 수 검증 (표 문단 = +1) - let new_para_count = doc2.document.sections[0].paragraphs.len(); - eprintln!(" 재파싱: {} 문단", new_para_count); - assert_eq!(new_para_count, orig_para_count + 1, "문단 수 불일치"); + // === 방법: 기존 표 문단을 복제하여 삽입 (직렬화 문제 격리) === + // 문단[2]의 표를 그대로 복제 + let source_para_idx = 2; + let table_para = doc.document.sections[0].paragraphs[source_para_idx].clone(); + eprintln!( + " 복제 원본: 문단[{}] cc={} controls={}", + source_para_idx, + table_para.char_count, + table_para.controls.len() + ); + if let Some(Control::Table(t)) = table_para.controls.first() { + eprintln!( + " 표: {}×{} cells={} attr=0x{:08X}", + t.row_count, + t.col_count, + t.cells.len(), + t.attr + ); + } - // 삽입된 표 검증 (캐럿 문단 다음 위치) - let table_para_idx = insert_para_idx + 1; - let injected = &doc2.document.sections[0].paragraphs[table_para_idx]; - let table_found = injected.controls.iter().any(|c| matches!(c, Control::Table(_))); - assert!(table_found, "삽입된 표 컨트롤이 없음 (문단[{}])", table_para_idx); + // 캐럿 위치 뒤에 표 문단 삽입 + doc.document.sections[0] + .paragraphs + .insert(insert_para_idx + 1, table_para); + + // 기존 콘텐츠 사이 삽입 → 빈 문단 불필요 (기존 문단이 이어짐) + + // raw_stream 무효화: 섹션만 (DocInfo raw 유지 → 손상 방지) + doc.document.sections[0].raw_stream = None; + + eprintln!( + " 수정: {} 문단 (원본 {} + 표 문단 1개)", + doc.document.sections[0].paragraphs.len(), + orig_para_count + ); + + // 저장 + let saved = doc.export_hwp_native(); + assert!(saved.is_ok(), "HWP 저장 실패: {:?}", saved.err()); + let saved_data = saved.unwrap(); + + let _ = std::fs::create_dir_all("output"); + std::fs::write("output/save_test_table_inject.hwp", &saved_data).unwrap(); + eprintln!( + " 저장: output/save_test_table_inject.hwp ({} bytes)", + saved_data.len() + ); + + // 재파싱 검증 + let doc2 = HwpDocument::from_bytes(&saved_data); + assert!(doc2.is_ok(), "재파싱 실패: {:?}", doc2.err()); + let doc2 = doc2.unwrap(); + + // 문단 수 검증 (표 문단 = +1) + let new_para_count = doc2.document.sections[0].paragraphs.len(); + eprintln!(" 재파싱: {} 문단", new_para_count); + assert_eq!(new_para_count, orig_para_count + 1, "문단 수 불일치"); + + // 삽입된 표 검증 (캐럿 문단 다음 위치) + let table_para_idx = insert_para_idx + 1; + let injected = &doc2.document.sections[0].paragraphs[table_para_idx]; + let table_found = injected + .controls + .iter() + .any(|c| matches!(c, Control::Table(_))); + assert!( + table_found, + "삽입된 표 컨트롤이 없음 (문단[{}])", + table_para_idx + ); + + if let Some(Control::Table(t)) = injected + .controls + .iter() + .find(|c| matches!(c, Control::Table(_))) + { + eprintln!( + " 복제 표: {}×{} cells={} attr=0x{:08X}", + t.row_count, + t.col_count, + t.cells.len(), + t.attr + ); + } - if let Some(Control::Table(t)) = injected.controls.iter().find(|c| matches!(c, Control::Table(_))) { - eprintln!(" 복제 표: {}×{} cells={} attr=0x{:08X}", - t.row_count, t.col_count, t.cells.len(), t.attr); + // 기존 컨트롤 보존 검증 + let orig_doc = HwpDocument::from_bytes(&orig_data).unwrap(); + let mut orig_tables = 0; + let mut orig_pics = 0; + for para in &orig_doc.document.sections[0].paragraphs { + for ctrl in ¶.controls { + match ctrl { + Control::Table(_) => orig_tables += 1, + Control::Picture(_) => orig_pics += 1, + _ => {} + } + } + } + let mut new_tables = 0; + let mut new_pics = 0; + for para in &doc2.document.sections[0].paragraphs { + for ctrl in ¶.controls { + match ctrl { + Control::Table(_) => new_tables += 1, + Control::Picture(_) => new_pics += 1, + _ => {} + } } + } + eprintln!( + " 컨트롤 보존: Table {}→{}, Picture {}→{}", + orig_tables, new_tables, orig_pics, new_pics + ); + assert_eq!(new_tables, orig_tables + 1, "표 개수 불일치"); + assert_eq!(new_pics, orig_pics, "이미지 개수 변경됨"); + + eprintln!("\n=== 타스크 41 단계 1 완료 ==="); + + // === 진단: 저장된 파일에서 삽입된 표 제거 후 재저장 === + eprintln!("\n [진단] 표 제거 후 재저장..."); + let mut doc3 = HwpDocument::from_bytes(&saved_data).unwrap(); + let para_count_before = doc3.document.sections[0].paragraphs.len(); + // 삽입된 표 문단 제거 (index = insert_para_idx + 1 = 9) + doc3.document.sections[0] + .paragraphs + .remove(insert_para_idx + 1); + doc3.document.sections[0].raw_stream = None; + let saved3 = doc3.export_hwp_native().unwrap(); + std::fs::write("output/save_test_table_removed.hwp", &saved3).unwrap(); + eprintln!( + " [진단] 표 제거: {} → {} 문단, output/save_test_table_removed.hwp ({} bytes)", + para_count_before, + doc3.document.sections[0].paragraphs.len(), + saved3.len() + ); +} + +/// 진단: 복제 표 vs parse_table_html 표의 raw_ctrl_data 및 직렬화 바이트 비교 +#[test] +fn test_diag_clone_vs_parsed_table() { + use crate::model::control::Control; + use crate::parser::record::Record; + use crate::parser::tags; + + let path = "samples/20250130-hongbo.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } - // 기존 컨트롤 보존 검증 - let orig_doc = HwpDocument::from_bytes(&orig_data).unwrap(); - let mut orig_tables = 0; - let mut orig_pics = 0; - for para in &orig_doc.document.sections[0].paragraphs { - for ctrl in ¶.controls { - match ctrl { - Control::Table(_) => orig_tables += 1, - Control::Picture(_) => orig_pics += 1, - _ => {} + eprintln!("\n{}", "=".repeat(60)); + eprintln!(" 진단: 복제 표 vs parse_table_html 표 비교"); + eprintln!("{}", "=".repeat(60)); + + let orig_data = std::fs::read(path).unwrap(); + + // === A: 복제 표 (정상 동작) === + let doc_a = HwpDocument::from_bytes(&orig_data).unwrap(); + let clone_para = doc_a.document.sections[0].paragraphs[2].clone(); + + // === B: parse_table_html 표 (내용 사라짐) === + let mut doc_b = HwpDocument::from_bytes(&orig_data).unwrap(); + let table_html = r#"
테스트A 
 테스트D
"#; + let mut parsed_paras = Vec::new(); + doc_b.parse_table_html(&mut parsed_paras, table_html); + let parsed_para = &parsed_paras[0]; + + // 문단 헤더 비교 + eprintln!("\n [문단 헤더 비교]"); + eprintln!( + " 복제: cc={} msb={} cm=0x{:08X} ps={} sid={} rhe={:02x?}", + clone_para.char_count, + clone_para.char_count_msb, + clone_para.control_mask, + clone_para.para_shape_id, + clone_para.style_id, + &clone_para.raw_header_extra + ); + eprintln!( + " 생성: cc={} msb={} cm=0x{:08X} ps={} sid={} rhe={:02x?}", + parsed_para.char_count, + parsed_para.char_count_msb, + parsed_para.control_mask, + parsed_para.para_shape_id, + parsed_para.style_id, + &parsed_para.raw_header_extra + ); + + // raw_ctrl_data 비교 + if let Some(Control::Table(ref t_a)) = clone_para.controls.first() { + if let Some(Control::Table(ref t_b)) = parsed_para.controls.first() { + eprintln!("\n [table.attr 비교]"); + eprintln!(" 복제: attr=0x{:08X}", t_a.attr); + eprintln!(" 생성: attr=0x{:08X}", t_b.attr); + + eprintln!("\n [raw_ctrl_data 비교] (CommonObjAttr after attr)"); + eprintln!( + " 복제 ({} bytes): {:02x?}", + t_a.raw_ctrl_data.len(), + &t_a.raw_ctrl_data + ); + eprintln!( + " 생성 ({} bytes): {:02x?}", + t_b.raw_ctrl_data.len(), + &t_b.raw_ctrl_data + ); + + // 필드별 해석 + fn read_i32(d: &[u8], o: usize) -> i32 { + if o + 4 <= d.len() { + i32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]) + } else { + 0 } } - } - let mut new_tables = 0; - let mut new_pics = 0; - for para in &doc2.document.sections[0].paragraphs { - for ctrl in ¶.controls { - match ctrl { - Control::Table(_) => new_tables += 1, - Control::Picture(_) => new_pics += 1, - _ => {} + fn read_u32(d: &[u8], o: usize) -> u32 { + if o + 4 <= d.len() { + u32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]) + } else { + 0 } } - } - eprintln!(" 컨트롤 보존: Table {}→{}, Picture {}→{}", - orig_tables, new_tables, orig_pics, new_pics); - assert_eq!(new_tables, orig_tables + 1, "표 개수 불일치"); - assert_eq!(new_pics, orig_pics, "이미지 개수 변경됨"); + fn read_i16(d: &[u8], o: usize) -> i16 { + if o + 2 <= d.len() { + i16::from_le_bytes([d[o], d[o + 1]]) + } else { + 0 + } + } + fn read_u16(d: &[u8], o: usize) -> u16 { + if o + 2 <= d.len() { + u16::from_le_bytes([d[o], d[o + 1]]) + } else { + 0 + } + } + + for (label, d) in [ + ("복제", t_a.raw_ctrl_data.as_slice()), + ("생성", t_b.raw_ctrl_data.as_slice()), + ] { + eprintln!("\n [{}] CommonObjAttr 필드:", label); + eprintln!(" [0..4] vert_offset = {}", read_i32(d, 0)); + eprintln!(" [4..8] horz_offset = {}", read_i32(d, 4)); + eprintln!(" [8..12] width = {}", read_u32(d, 8)); + eprintln!(" [12..16] height = {}", read_u32(d, 12)); + eprintln!(" [16..20] z_order = {}", read_i32(d, 16)); + eprintln!(" [20..22] margin_l = {}", read_i16(d, 20)); + eprintln!(" [22..24] margin_r = {}", read_i16(d, 22)); + eprintln!(" [24..26] margin_t = {}", read_i16(d, 24)); + eprintln!(" [26..28] margin_b = {}", read_i16(d, 26)); + eprintln!(" [28..32] inst_id = 0x{:08X}", read_u32(d, 28)); + eprintln!(" [32..34] desc_len = {}", read_u16(d, 32)); + if d.len() > 34 { + eprintln!(" [34..] extra = {:02x?}", &d[34..]); + } + } + + // 직렬화 바이트 비교 (CTRL_HEADER + TABLE + cells) + eprintln!("\n [직렬화 레코드 비교]"); + let mut recs_a: Vec = Vec::new(); + crate::serializer::control::serialize_control( + &clone_para.controls[0], + 1, + None, + &mut recs_a, + ); + let mut recs_b: Vec = Vec::new(); + crate::serializer::control::serialize_control( + &parsed_para.controls[0], + 1, + None, + &mut recs_b, + ); - eprintln!("\n=== 타스크 41 단계 1 완료 ==="); + eprintln!( + " 복제: {} 레코드, 생성: {} 레코드", + recs_a.len(), + recs_b.len() + ); - // === 진단: 저장된 파일에서 삽입된 표 제거 후 재저장 === - eprintln!("\n [진단] 표 제거 후 재저장..."); - let mut doc3 = HwpDocument::from_bytes(&saved_data).unwrap(); - let para_count_before = doc3.document.sections[0].paragraphs.len(); - // 삽입된 표 문단 제거 (index = insert_para_idx + 1 = 9) - doc3.document.sections[0].paragraphs.remove(insert_para_idx + 1); - doc3.document.sections[0].raw_stream = None; - let saved3 = doc3.export_hwp_native().unwrap(); - std::fs::write("output/save_test_table_removed.hwp", &saved3).unwrap(); - eprintln!(" [진단] 표 제거: {} → {} 문단, output/save_test_table_removed.hwp ({} bytes)", - para_count_before, doc3.document.sections[0].paragraphs.len(), saved3.len()); - } + // 처음 5개 레코드 비교 + let max_show = recs_a.len().max(recs_b.len()).min(10); + for i in 0..max_show { + let a_info = if i < recs_a.len() { + format!( + "{:22} lv={} sz={}", + tags::tag_name(recs_a[i].tag_id), + recs_a[i].level, + recs_a[i].data.len() + ) + } else { + "---".to_string() + }; + let b_info = if i < recs_b.len() { + format!( + "{:22} lv={} sz={}", + tags::tag_name(recs_b[i].tag_id), + recs_b[i].level, + recs_b[i].data.len() + ) + } else { + "---".to_string() + }; + let status = if i < recs_a.len() && i < recs_b.len() { + if recs_a[i].tag_id == recs_b[i].tag_id && recs_a[i].data == recs_b[i].data { + "==" + } else if recs_a[i].tag_id == recs_b[i].tag_id { + "~=" + } else { + "!=" + } + } else { + "!=" + }; + eprintln!(" [{:2}] {} | {} | {}", i, status, a_info, b_info); - /// 진단: 복제 표 vs parse_table_html 표의 raw_ctrl_data 및 직렬화 바이트 비교 - #[test] - fn test_diag_clone_vs_parsed_table() { - use crate::model::control::Control; - use crate::parser::record::Record; - use crate::parser::tags; + // 데이터 차이 상세 + if i < recs_a.len() + && i < recs_b.len() + && recs_a[i].tag_id == recs_b[i].tag_id + && recs_a[i].data != recs_b[i].data + { + let max_d = recs_a[i].data.len().max(recs_b[i].data.len()).min(80); + eprintln!( + " 복제: {:02x?}", + &recs_a[i].data[..recs_a[i].data.len().min(max_d)] + ); + eprintln!( + " 생성: {:02x?}", + &recs_b[i].data[..recs_b[i].data.len().min(max_d)] + ); + } + } - let path = "samples/20250130-hongbo.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; + // raw_table_record_attr 비교 + eprintln!("\n [TABLE record attr 비교]"); + eprintln!(" 복제: tbl_rec_attr=0x{:08X}", t_a.raw_table_record_attr); + eprintln!(" 생성: tbl_rec_attr=0x{:08X}", t_b.raw_table_record_attr); } + } - eprintln!("\n{}", "=".repeat(60)); - eprintln!(" 진단: 복제 표 vs parse_table_html 표 비교"); - eprintln!("{}", "=".repeat(60)); - - let orig_data = std::fs::read(path).unwrap(); - - // === A: 복제 표 (정상 동작) === - let doc_a = HwpDocument::from_bytes(&orig_data).unwrap(); - let clone_para = doc_a.document.sections[0].paragraphs[2].clone(); - - // === B: parse_table_html 표 (내용 사라짐) === - let mut doc_b = HwpDocument::from_bytes(&orig_data).unwrap(); - let table_html = r#"
테스트A 
 테스트D
"#; - let mut parsed_paras = Vec::new(); - doc_b.parse_table_html(&mut parsed_paras, table_html); - let parsed_para = &parsed_paras[0]; - - // 문단 헤더 비교 - eprintln!("\n [문단 헤더 비교]"); - eprintln!(" 복제: cc={} msb={} cm=0x{:08X} ps={} sid={} rhe={:02x?}", - clone_para.char_count, clone_para.char_count_msb, - clone_para.control_mask, clone_para.para_shape_id, clone_para.style_id, - &clone_para.raw_header_extra); - eprintln!(" 생성: cc={} msb={} cm=0x{:08X} ps={} sid={} rhe={:02x?}", - parsed_para.char_count, parsed_para.char_count_msb, - parsed_para.control_mask, parsed_para.para_shape_id, parsed_para.style_id, - &parsed_para.raw_header_extra); - - // raw_ctrl_data 비교 - if let Some(Control::Table(ref t_a)) = clone_para.controls.first() { - if let Some(Control::Table(ref t_b)) = parsed_para.controls.first() { - eprintln!("\n [table.attr 비교]"); - eprintln!(" 복제: attr=0x{:08X}", t_a.attr); - eprintln!(" 생성: attr=0x{:08X}", t_b.attr); - - eprintln!("\n [raw_ctrl_data 비교] (CommonObjAttr after attr)"); - eprintln!(" 복제 ({} bytes): {:02x?}", t_a.raw_ctrl_data.len(), &t_a.raw_ctrl_data); - eprintln!(" 생성 ({} bytes): {:02x?}", t_b.raw_ctrl_data.len(), &t_b.raw_ctrl_data); - - // 필드별 해석 - fn read_i32(d: &[u8], o: usize) -> i32 { - if o + 4 <= d.len() { i32::from_le_bytes([d[o],d[o+1],d[o+2],d[o+3]]) } else { 0 } - } - fn read_u32(d: &[u8], o: usize) -> u32 { - if o + 4 <= d.len() { u32::from_le_bytes([d[o],d[o+1],d[o+2],d[o+3]]) } else { 0 } - } - fn read_i16(d: &[u8], o: usize) -> i16 { - if o + 2 <= d.len() { i16::from_le_bytes([d[o],d[o+1]]) } else { 0 } - } - fn read_u16(d: &[u8], o: usize) -> u16 { - if o + 2 <= d.len() { u16::from_le_bytes([d[o],d[o+1]]) } else { 0 } - } - - for (label, d) in [("복제", t_a.raw_ctrl_data.as_slice()), ("생성", t_b.raw_ctrl_data.as_slice())] { - eprintln!("\n [{}] CommonObjAttr 필드:", label); - eprintln!(" [0..4] vert_offset = {}", read_i32(d, 0)); - eprintln!(" [4..8] horz_offset = {}", read_i32(d, 4)); - eprintln!(" [8..12] width = {}", read_u32(d, 8)); - eprintln!(" [12..16] height = {}", read_u32(d, 12)); - eprintln!(" [16..20] z_order = {}", read_i32(d, 16)); - eprintln!(" [20..22] margin_l = {}", read_i16(d, 20)); - eprintln!(" [22..24] margin_r = {}", read_i16(d, 22)); - eprintln!(" [24..26] margin_t = {}", read_i16(d, 24)); - eprintln!(" [26..28] margin_b = {}", read_i16(d, 26)); - eprintln!(" [28..32] inst_id = 0x{:08X}", read_u32(d, 28)); - eprintln!(" [32..34] desc_len = {}", read_u16(d, 32)); - if d.len() > 34 { - eprintln!(" [34..] extra = {:02x?}", &d[34..]); - } - } - - // 직렬화 바이트 비교 (CTRL_HEADER + TABLE + cells) - eprintln!("\n [직렬화 레코드 비교]"); - let mut recs_a: Vec = Vec::new(); - crate::serializer::control::serialize_control( - &clone_para.controls[0], 1, None, &mut recs_a); - let mut recs_b: Vec = Vec::new(); - crate::serializer::control::serialize_control( - &parsed_para.controls[0], 1, None, &mut recs_b); - - eprintln!(" 복제: {} 레코드, 생성: {} 레코드", recs_a.len(), recs_b.len()); - - // 처음 5개 레코드 비교 - let max_show = recs_a.len().max(recs_b.len()).min(10); - for i in 0..max_show { - let a_info = if i < recs_a.len() { - format!("{:22} lv={} sz={}", tags::tag_name(recs_a[i].tag_id), recs_a[i].level, recs_a[i].data.len()) - } else { "---".to_string() }; - let b_info = if i < recs_b.len() { - format!("{:22} lv={} sz={}", tags::tag_name(recs_b[i].tag_id), recs_b[i].level, recs_b[i].data.len()) - } else { "---".to_string() }; - let status = if i < recs_a.len() && i < recs_b.len() { - if recs_a[i].tag_id == recs_b[i].tag_id && recs_a[i].data == recs_b[i].data { "==" } - else if recs_a[i].tag_id == recs_b[i].tag_id { "~=" } - else { "!=" } - } else { "!=" }; - eprintln!(" [{:2}] {} | {} | {}", i, status, a_info, b_info); - - // 데이터 차이 상세 - if i < recs_a.len() && i < recs_b.len() && recs_a[i].tag_id == recs_b[i].tag_id && recs_a[i].data != recs_b[i].data { - let max_d = recs_a[i].data.len().max(recs_b[i].data.len()).min(80); - eprintln!(" 복제: {:02x?}", &recs_a[i].data[..recs_a[i].data.len().min(max_d)]); - eprintln!(" 생성: {:02x?}", &recs_b[i].data[..recs_b[i].data.len().min(max_d)]); - } - } - - // raw_table_record_attr 비교 - eprintln!("\n [TABLE record attr 비교]"); - eprintln!(" 복제: tbl_rec_attr=0x{:08X}", t_a.raw_table_record_attr); - eprintln!(" 생성: tbl_rec_attr=0x{:08X}", t_b.raw_table_record_attr); - } - } - - // === 전체 문단 직렬화 비교 (PARA_HEADER + PARA_TEXT + ... + CTRL_HEADER + ...) === - eprintln!("\n [전체 문단 직렬화 비교]"); - let mut full_recs_a: Vec = Vec::new(); - crate::serializer::body_text::serialize_paragraph_list( - std::slice::from_ref(&clone_para), 0, &mut full_recs_a); - let mut full_recs_b: Vec = Vec::new(); - crate::serializer::body_text::serialize_paragraph_list( - std::slice::from_ref(parsed_para), 0, &mut full_recs_b); - - eprintln!(" 복제 전체: {} 레코드, 생성 전체: {} 레코드", full_recs_a.len(), full_recs_b.len()); - let max = full_recs_a.len().max(full_recs_b.len()); - for i in 0..max { - let a = full_recs_a.get(i); - let b = full_recs_b.get(i); - let a_info = a.map(|r| format!("{:22} lv={} sz={}", tags::tag_name(r.tag_id), r.level, r.data.len())) - .unwrap_or_else(|| "---".to_string()); - let b_info = b.map(|r| format!("{:22} lv={} sz={}", tags::tag_name(r.tag_id), r.level, r.data.len())) - .unwrap_or_else(|| "---".to_string()); - let status = match (a, b) { - (Some(ra), Some(rb)) if ra.tag_id == rb.tag_id && ra.data == rb.data => "==", - (Some(ra), Some(rb)) if ra.tag_id == rb.tag_id => "~=", - _ => "!!", - }; - eprintln!(" [{:2}] {} | {} | {}", i, status, a_info, b_info); + // === 전체 문단 직렬화 비교 (PARA_HEADER + PARA_TEXT + ... + CTRL_HEADER + ...) === + eprintln!("\n [전체 문단 직렬화 비교]"); + let mut full_recs_a: Vec = Vec::new(); + crate::serializer::body_text::serialize_paragraph_list( + std::slice::from_ref(&clone_para), + 0, + &mut full_recs_a, + ); + let mut full_recs_b: Vec = Vec::new(); + crate::serializer::body_text::serialize_paragraph_list( + std::slice::from_ref(parsed_para), + 0, + &mut full_recs_b, + ); + + eprintln!( + " 복제 전체: {} 레코드, 생성 전체: {} 레코드", + full_recs_a.len(), + full_recs_b.len() + ); + let max = full_recs_a.len().max(full_recs_b.len()); + for i in 0..max { + let a = full_recs_a.get(i); + let b = full_recs_b.get(i); + let a_info = a + .map(|r| { + format!( + "{:22} lv={} sz={}", + tags::tag_name(r.tag_id), + r.level, + r.data.len() + ) + }) + .unwrap_or_else(|| "---".to_string()); + let b_info = b + .map(|r| { + format!( + "{:22} lv={} sz={}", + tags::tag_name(r.tag_id), + r.level, + r.data.len() + ) + }) + .unwrap_or_else(|| "---".to_string()); + let status = match (a, b) { + (Some(ra), Some(rb)) if ra.tag_id == rb.tag_id && ra.data == rb.data => "==", + (Some(ra), Some(rb)) if ra.tag_id == rb.tag_id => "~=", + _ => "!!", + }; + eprintln!(" [{:2}] {} | {} | {}", i, status, a_info, b_info); - // PARA_HEADER와 PARA_TEXT 데이터 상세 - if let (Some(ra), Some(rb)) = (a, b) { - if ra.tag_id == rb.tag_id && ra.data != rb.data { - if ra.tag_id == tags::HWPTAG_PARA_HEADER || ra.tag_id == tags::HWPTAG_PARA_TEXT { - eprintln!(" 복제: {:02x?}", &ra.data[..ra.data.len().min(60)]); - eprintln!(" 생성: {:02x?}", &rb.data[..rb.data.len().min(60)]); - } + // PARA_HEADER와 PARA_TEXT 데이터 상세 + if let (Some(ra), Some(rb)) = (a, b) { + if ra.tag_id == rb.tag_id && ra.data != rb.data { + if ra.tag_id == tags::HWPTAG_PARA_HEADER || ra.tag_id == tags::HWPTAG_PARA_TEXT { + eprintln!(" 복제: {:02x?}", &ra.data[..ra.data.len().min(60)]); + eprintln!(" 생성: {:02x?}", &rb.data[..rb.data.len().min(60)]); } } } - - eprintln!("\n=== 진단 완료 ==="); } - /// 타스크 41 단계 3: parse_table_html()로 생성한 표를 기존 문서에 삽입 → 저장 → 검증 - /// DIFF-1~8 수정 사항이 모두 반영된 통합 테스트 - #[test] - fn test_parse_table_html_save() { - use crate::model::control::Control; + eprintln!("\n=== 진단 완료 ==="); +} - let path = "samples/20250130-hongbo.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; - } +/// 타스크 41 단계 3: parse_table_html()로 생성한 표를 기존 문서에 삽입 → 저장 → 검증 +/// DIFF-1~8 수정 사항이 모두 반영된 통합 테스트 +#[test] +fn test_parse_table_html_save() { + use crate::model::control::Control; - eprintln!("\n{}", "=".repeat(60)); - eprintln!(" 타스크 41 단계 3: parse_table_html 표 삽입 저장 검증"); - eprintln!("{}", "=".repeat(60)); + let path = "samples/20250130-hongbo.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } - let orig_data = std::fs::read(path).unwrap(); - let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); + eprintln!("\n{}", "=".repeat(60)); + eprintln!(" 타스크 41 단계 3: parse_table_html 표 삽입 저장 검증"); + eprintln!("{}", "=".repeat(60)); + + let orig_data = std::fs::read(path).unwrap(); + let mut doc = HwpDocument::from_bytes(&orig_data).unwrap(); - let orig_para_count = doc.document.sections[0].paragraphs.len(); - let caret_para_id = doc.document.doc_properties.caret_para_id as usize; - eprintln!(" 원본: {} 문단, 캐럿 위치: 문단[{}]", orig_para_count, caret_para_id); + let orig_para_count = doc.document.sections[0].paragraphs.len(); + let caret_para_id = doc.document.doc_properties.caret_para_id as usize; + eprintln!( + " 원본: {} 문단, 캐럿 위치: 문단[{}]", + orig_para_count, caret_para_id + ); - // HTML 표 생성 (2×2, 빈 셀 포함) - let table_html = r#" + // HTML 표 생성 (2×2, 빈 셀 포함) + let table_html = r#"
@@ -11013,1441 +15122,1887 @@
테스트 셀 A  
"#; - // parse_table_html으로 표 문단 생성 - let mut table_paragraphs = Vec::new(); - doc.parse_table_html(&mut table_paragraphs, table_html); - assert_eq!(table_paragraphs.len(), 1, "표 문단 1개 생성"); - - let table_para = &table_paragraphs[0]; - eprintln!(" 표 문단: cc={} msb={} cm=0x{:08X} cs={} ls={}", - table_para.char_count, table_para.char_count_msb, - table_para.control_mask, table_para.char_shapes.len(), table_para.line_segs.len()); - - // DIFF 검증 - if let Some(Control::Table(ref tbl)) = table_para.controls.first() { - eprintln!(" 표: {}×{} cells={} attr=0x{:08X}", tbl.row_count, tbl.col_count, tbl.cells.len(), tbl.attr); - eprintln!(" DIFF-5: tbl_rec_attr=0x{:08X}", tbl.raw_table_record_attr); - assert_eq!(tbl.raw_table_record_attr, 0x04000006, "DIFF-5: 셀분리금지 항상 설정"); - - // DIFF-7: instance_id - let inst = u32::from_le_bytes([ - tbl.raw_ctrl_data[28], tbl.raw_ctrl_data[29], - tbl.raw_ctrl_data[30], tbl.raw_ctrl_data[31], - ]); - eprintln!(" DIFF-7: instance_id=0x{:08X}", inst); - assert_ne!(inst, 0, "DIFF-7: instance_id != 0"); - - // DIFF-1: 빈 셀 검증 - for (i, cell) in tbl.cells.iter().enumerate() { - let p = &cell.paragraphs[0]; - eprintln!(" 셀[{}]({},{}): cc={} text='{}' cs={} ls={} has_pt={}", - i, cell.row, cell.col, p.char_count, - if p.text.len() > 20 { &p.text[..20] } else { &p.text }, - p.char_shapes.len(), p.line_segs.len(), p.has_para_text); - - // DIFF-2: 모든 셀 문단은 char_shapes가 있어야 함 - assert!(!p.char_shapes.is_empty(), "DIFF-2: 셀[{}] char_shapes 비어있음", i); - // DIFF-3: para_shape_id=0 (기본 본문 스타일) - assert_eq!(p.para_shape_id, 0, "DIFF-3: 셀[{}] para_shape_id=0", i); - // DIFF-6: line_segs의 tag - if !p.line_segs.is_empty() { - assert_eq!(p.line_segs[0].tag, 0x00060000, "DIFF-6: 셀[{}] line_seg tag", i); - assert!(p.line_segs[0].segment_width > 0, "DIFF-6: 셀[{}] seg_width > 0", i); - } - } - - // DIFF-1: 빈 셀 (셀[1], 셀[2]) 확인 - assert_eq!(tbl.cells[1].paragraphs[0].char_count, 1, "DIFF-1: 빈 셀[1] cc=1"); - assert!(tbl.cells[1].paragraphs[0].text.is_empty(), "DIFF-1: 빈 셀[1] text empty"); - assert_eq!(tbl.cells[2].paragraphs[0].char_count, 1, "DIFF-1: 빈 셀[2] cc=1"); - } - - // DIFF-8: 표 컨테이너 문단 LineSeg - assert!(!table_para.line_segs.is_empty(), "DIFF-8: 표 문단 line_segs 비어있음"); - eprintln!(" DIFF-8: line_seg h={} tw={} seg_w={} tag=0x{:08X}", - table_para.line_segs[0].line_height, - table_para.line_segs[0].text_height, - table_para.line_segs[0].segment_width, - table_para.line_segs[0].tag); - assert!(table_para.line_segs[0].line_height > 0, "DIFF-8: line_height > 0"); - assert!(table_para.line_segs[0].segment_width > 0, "DIFF-8: seg_width > 0"); - assert_eq!(table_para.line_segs[0].tag, 0x00060000, "DIFF-8: tag=0x00060000"); - - // 삽입 및 저장 - doc.document.sections[0].paragraphs.insert(caret_para_id + 1, table_paragraphs.remove(0)); - doc.document.sections[0].raw_stream = None; - - let saved = doc.export_hwp_native(); - assert!(saved.is_ok(), "저장 실패: {:?}", saved.err()); - let saved_data = saved.unwrap(); - - let _ = std::fs::create_dir_all("output"); - std::fs::write("output/save_test_parsed_table.hwp", &saved_data).unwrap(); - eprintln!(" 저장: output/save_test_parsed_table.hwp ({} bytes)", saved_data.len()); + // parse_table_html으로 표 문단 생성 + let mut table_paragraphs = Vec::new(); + doc.parse_table_html(&mut table_paragraphs, table_html); + assert_eq!(table_paragraphs.len(), 1, "표 문단 1개 생성"); + + let table_para = &table_paragraphs[0]; + eprintln!( + " 표 문단: cc={} msb={} cm=0x{:08X} cs={} ls={}", + table_para.char_count, + table_para.char_count_msb, + table_para.control_mask, + table_para.char_shapes.len(), + table_para.line_segs.len() + ); + + // DIFF 검증 + if let Some(Control::Table(ref tbl)) = table_para.controls.first() { + eprintln!( + " 표: {}×{} cells={} attr=0x{:08X}", + tbl.row_count, + tbl.col_count, + tbl.cells.len(), + tbl.attr + ); + eprintln!(" DIFF-5: tbl_rec_attr=0x{:08X}", tbl.raw_table_record_attr); + assert_eq!( + tbl.raw_table_record_attr, 0x04000006, + "DIFF-5: 셀분리금지 항상 설정" + ); - // 재파싱 검증 - let doc2 = HwpDocument::from_bytes(&saved_data); - assert!(doc2.is_ok(), "재파싱 실패: {:?}", doc2.err()); - let doc2 = doc2.unwrap(); - let new_para_count = doc2.document.sections[0].paragraphs.len(); - eprintln!(" 재파싱: {} 문단 (원본 {} + 1)", new_para_count, orig_para_count); - assert_eq!(new_para_count, orig_para_count + 1); + // DIFF-7: instance_id + let inst = u32::from_le_bytes([ + tbl.raw_ctrl_data[28], + tbl.raw_ctrl_data[29], + tbl.raw_ctrl_data[30], + tbl.raw_ctrl_data[31], + ]); + eprintln!(" DIFF-7: instance_id=0x{:08X}", inst); + assert_ne!(inst, 0, "DIFF-7: instance_id != 0"); + + // DIFF-1: 빈 셀 검증 + for (i, cell) in tbl.cells.iter().enumerate() { + let p = &cell.paragraphs[0]; + eprintln!( + " 셀[{}]({},{}): cc={} text='{}' cs={} ls={} has_pt={}", + i, + cell.row, + cell.col, + p.char_count, + if p.text.len() > 20 { + &p.text[..20] + } else { + &p.text + }, + p.char_shapes.len(), + p.line_segs.len(), + p.has_para_text + ); - // 삽입된 표 확인 - let injected = &doc2.document.sections[0].paragraphs[caret_para_id + 1]; - assert!(injected.controls.iter().any(|c| matches!(c, Control::Table(_))), - "삽입된 표 컨트롤 없음"); + // DIFF-2: 모든 셀 문단은 char_shapes가 있어야 함 + assert!( + !p.char_shapes.is_empty(), + "DIFF-2: 셀[{}] char_shapes 비어있음", + i + ); + // DIFF-3: para_shape_id=0 (기본 본문 스타일) + assert_eq!(p.para_shape_id, 0, "DIFF-3: 셀[{}] para_shape_id=0", i); + // DIFF-6: line_segs의 tag + if !p.line_segs.is_empty() { + assert_eq!( + p.line_segs[0].tag, 0x00060000, + "DIFF-6: 셀[{}] line_seg tag", + i + ); + assert!( + p.line_segs[0].segment_width > 0, + "DIFF-6: 셀[{}] seg_width > 0", + i + ); + } + } - eprintln!("\n=== 타스크 41 단계 3 완료 ==="); - eprintln!(" output/save_test_parsed_table.hwp 를 HWP 프로그램에서 확인해 주세요"); + // DIFF-1: 빈 셀 (셀[1], 셀[2]) 확인 + assert_eq!( + tbl.cells[1].paragraphs[0].char_count, 1, + "DIFF-1: 빈 셀[1] cc=1" + ); + assert!( + tbl.cells[1].paragraphs[0].text.is_empty(), + "DIFF-1: 빈 셀[1] text empty" + ); + assert_eq!( + tbl.cells[2].paragraphs[0].char_count, 1, + "DIFF-1: 빈 셀[2] cc=1" + ); } - /// 진단: k-water-rfp.hwp 전체 문단의 char_count_msb 패턴 분석 - #[test] - fn test_diag_msb_pattern_kwater() { - use crate::model::control::Control; - - let path = "samples/k-water-rfp.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; - } + // DIFF-8: 표 컨테이너 문단 LineSeg + assert!( + !table_para.line_segs.is_empty(), + "DIFF-8: 표 문단 line_segs 비어있음" + ); + eprintln!( + " DIFF-8: line_seg h={} tw={} seg_w={} tag=0x{:08X}", + table_para.line_segs[0].line_height, + table_para.line_segs[0].text_height, + table_para.line_segs[0].segment_width, + table_para.line_segs[0].tag + ); + assert!( + table_para.line_segs[0].line_height > 0, + "DIFF-8: line_height > 0" + ); + assert!( + table_para.line_segs[0].segment_width > 0, + "DIFF-8: seg_width > 0" + ); + assert_eq!( + table_para.line_segs[0].tag, 0x00060000, + "DIFF-8: tag=0x00060000" + ); + + // 삽입 및 저장 + doc.document.sections[0] + .paragraphs + .insert(caret_para_id + 1, table_paragraphs.remove(0)); + doc.document.sections[0].raw_stream = None; + + let saved = doc.export_hwp_native(); + assert!(saved.is_ok(), "저장 실패: {:?}", saved.err()); + let saved_data = saved.unwrap(); + + let _ = std::fs::create_dir_all("output"); + std::fs::write("output/save_test_parsed_table.hwp", &saved_data).unwrap(); + eprintln!( + " 저장: output/save_test_parsed_table.hwp ({} bytes)", + saved_data.len() + ); + + // 재파싱 검증 + let doc2 = HwpDocument::from_bytes(&saved_data); + assert!(doc2.is_ok(), "재파싱 실패: {:?}", doc2.err()); + let doc2 = doc2.unwrap(); + let new_para_count = doc2.document.sections[0].paragraphs.len(); + eprintln!( + " 재파싱: {} 문단 (원본 {} + 1)", + new_para_count, orig_para_count + ); + assert_eq!(new_para_count, orig_para_count + 1); + + // 삽입된 표 확인 + let injected = &doc2.document.sections[0].paragraphs[caret_para_id + 1]; + assert!( + injected + .controls + .iter() + .any(|c| matches!(c, Control::Table(_))), + "삽입된 표 컨트롤 없음" + ); + + eprintln!("\n=== 타스크 41 단계 3 완료 ==="); + eprintln!(" output/save_test_parsed_table.hwp 를 HWP 프로그램에서 확인해 주세요"); +} + +/// 진단: k-water-rfp.hwp 전체 문단의 char_count_msb 패턴 분석 +#[test] +fn test_diag_msb_pattern_kwater() { + use crate::model::control::Control; + + let path = "samples/k-water-rfp.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } - let data = std::fs::read(path).unwrap(); - let doc = HwpDocument::from_bytes(&data).unwrap(); + let data = std::fs::read(path).unwrap(); + let doc = HwpDocument::from_bytes(&data).unwrap(); - eprintln!("\n{}", "=".repeat(70)); - eprintln!(" k-water-rfp.hwp MSB 패턴 분석"); - eprintln!("{}", "=".repeat(70)); + eprintln!("\n{}", "=".repeat(70)); + eprintln!(" k-water-rfp.hwp MSB 패턴 분석"); + eprintln!("{}", "=".repeat(70)); - for (si, section) in doc.document.sections.iter().enumerate() { - let para_count = section.paragraphs.len(); - eprintln!("\n Section {} ({} paragraphs)", si, para_count); - eprintln!(" {:>4} | {:>5} | {:>3} | {:>5} | {:>3} | {:>8} | {}", - "idx", "cc", "msb", "psid", "sid", "ctrl", "text_preview"); - eprintln!(" {}", "-".repeat(65)); + for (si, section) in doc.document.sections.iter().enumerate() { + let para_count = section.paragraphs.len(); + eprintln!("\n Section {} ({} paragraphs)", si, para_count); + eprintln!( + " {:>4} | {:>5} | {:>3} | {:>5} | {:>3} | {:>8} | text_preview", + "idx", "cc", "msb", "psid", "sid", "ctrl" + ); + eprintln!(" {}", "-".repeat(65)); - for (pi, para) in section.paragraphs.iter().enumerate() { - let is_last = pi == para_count - 1; - let ctrl_info = if para.controls.is_empty() { - String::new() - } else { - let ctrl_names: Vec<&str> = para.controls.iter().map(|c| match c { + for (pi, para) in section.paragraphs.iter().enumerate() { + let is_last = pi == para_count - 1; + let ctrl_info = if para.controls.is_empty() { + String::new() + } else { + let ctrl_names: Vec<&str> = para + .controls + .iter() + .map(|c| match c { Control::Table(_) => "TABLE", Control::SectionDef(_) => "SECD", Control::ColumnDef(_) => "COLD", Control::Shape(_) => "SHAPE", Control::Picture(_) => "PIC", _ => "OTHER", - }).collect(); - ctrl_names.join(",") - }; - - let text_preview: String = para.text.chars().take(30).collect(); - let msb_mark = if para.char_count_msb { "T" } else { "F" }; - let last_mark = if is_last { " " } else { "" }; + }) + .collect(); + ctrl_names.join(",") + }; - eprintln!(" {:>4} | {:>5} | {:>3} | {:>5} | {:>3} | {:>8} | {}{}", - pi, para.char_count, msb_mark, - para.para_shape_id, para.style_id, - ctrl_info, text_preview, last_mark); + let text_preview: String = para.text.chars().take(30).collect(); + let msb_mark = if para.char_count_msb { "T" } else { "F" }; + let last_mark = if is_last { " " } else { "" }; + + eprintln!( + " {:>4} | {:>5} | {:>3} | {:>5} | {:>3} | {:>8} | {}{}", + pi, + para.char_count, + msb_mark, + para.para_shape_id, + para.style_id, + ctrl_info, + text_preview, + last_mark + ); - // 컨트롤 내부 문단도 출력 - for ctrl in ¶.controls { - match ctrl { - Control::Table(tbl) => { - for (ci, cell) in tbl.cells.iter().enumerate() { - for (cpi, cp) in cell.paragraphs.iter().enumerate() { - let cp_msb = if cp.char_count_msb { "T" } else { "F" }; - let cp_last = cpi == cell.paragraphs.len() - 1; - let cp_text: String = cp.text.chars().take(20).collect(); - eprintln!(" cell[{}].p[{}]: cc={} msb={} psid={} sid={} text='{}'{}", + // 컨트롤 내부 문단도 출력 + for ctrl in ¶.controls { + match ctrl { + Control::Table(tbl) => { + for (ci, cell) in tbl.cells.iter().enumerate() { + for (cpi, cp) in cell.paragraphs.iter().enumerate() { + let cp_msb = if cp.char_count_msb { "T" } else { "F" }; + let cp_last = cpi == cell.paragraphs.len() - 1; + let cp_text: String = cp.text.chars().take(20).collect(); + eprintln!(" cell[{}].p[{}]: cc={} msb={} psid={} sid={} text='{}'{}", ci, cpi, cp.char_count, cp_msb, cp.para_shape_id, cp.style_id, cp_text, if cp_last { " " } else { "" }); - } } } - Control::Shape(s) => { - // ShapeObject enum 에서 drawing.text_box 접근 - let tb_opt = match s.as_ref() { - crate::model::shape::ShapeObject::Line(l) => l.drawing.text_box.as_ref(), - crate::model::shape::ShapeObject::Rectangle(r) => r.drawing.text_box.as_ref(), - crate::model::shape::ShapeObject::Ellipse(e) => e.drawing.text_box.as_ref(), - crate::model::shape::ShapeObject::Arc(a) => a.drawing.text_box.as_ref(), - crate::model::shape::ShapeObject::Polygon(p) => p.drawing.text_box.as_ref(), - crate::model::shape::ShapeObject::Curve(c) => c.drawing.text_box.as_ref(), - _ => None, - }; - if let Some(tb) = tb_opt { - for (tpi, tp) in tb.paragraphs.iter().enumerate() { - let tp_msb = if tp.char_count_msb { "T" } else { "F" }; - let tp_last = tpi == tb.paragraphs.len() - 1; - let tp_text: String = tp.text.chars().take(20).collect(); - eprintln!(" textbox.p[{}]: cc={} msb={} psid={} text='{}'{}", - tpi, tp.char_count, tp_msb, - tp.para_shape_id, tp_text, - if tp_last { " " } else { "" }); - } + } + Control::Shape(s) => { + // ShapeObject enum 에서 drawing.text_box 접근 + let tb_opt = match s.as_ref() { + crate::model::shape::ShapeObject::Line(l) => { + l.drawing.text_box.as_ref() + } + crate::model::shape::ShapeObject::Rectangle(r) => { + r.drawing.text_box.as_ref() + } + crate::model::shape::ShapeObject::Ellipse(e) => { + e.drawing.text_box.as_ref() + } + crate::model::shape::ShapeObject::Arc(a) => a.drawing.text_box.as_ref(), + crate::model::shape::ShapeObject::Polygon(p) => { + p.drawing.text_box.as_ref() + } + crate::model::shape::ShapeObject::Curve(c) => { + c.drawing.text_box.as_ref() + } + _ => None, + }; + if let Some(tb) = tb_opt { + for (tpi, tp) in tb.paragraphs.iter().enumerate() { + let tp_msb = if tp.char_count_msb { "T" } else { "F" }; + let tp_last = tpi == tb.paragraphs.len() - 1; + let tp_text: String = tp.text.chars().take(20).collect(); + eprintln!( + " textbox.p[{}]: cc={} msb={} psid={} text='{}'{}", + tpi, + tp.char_count, + tp_msb, + tp.para_shape_id, + tp_text, + if tp_last { " " } else { "" } + ); } } - _ => {} } + _ => {} } } } + } - // 통계 집계 - eprintln!("\n{}", "=".repeat(70)); - eprintln!(" MSB 패턴 통계"); - eprintln!("{}", "=".repeat(70)); + // 통계 집계 + eprintln!("\n{}", "=".repeat(70)); + eprintln!(" MSB 패턴 통계"); + eprintln!("{}", "=".repeat(70)); - for (si, section) in doc.document.sections.iter().enumerate() { - let para_count = section.paragraphs.len(); - let mut msb_true_count = 0; - let mut msb_false_count = 0; - let mut last_para_msb = false; - let mut mid_para_msb_true = Vec::new(); // MSB=T인 중간 문단 + for (si, section) in doc.document.sections.iter().enumerate() { + let para_count = section.paragraphs.len(); + let mut msb_true_count = 0; + let mut msb_false_count = 0; + let mut last_para_msb = false; + let mut mid_para_msb_true = Vec::new(); // MSB=T인 중간 문단 - for (pi, para) in section.paragraphs.iter().enumerate() { - if para.char_count_msb { - msb_true_count += 1; - if pi < para_count - 1 { - mid_para_msb_true.push(pi); - } - } else { - msb_false_count += 1; - } - if pi == para_count - 1 { - last_para_msb = para.char_count_msb; + for (pi, para) in section.paragraphs.iter().enumerate() { + if para.char_count_msb { + msb_true_count += 1; + if pi < para_count - 1 { + mid_para_msb_true.push(pi); } + } else { + msb_false_count += 1; + } + if pi == para_count - 1 { + last_para_msb = para.char_count_msb; } + } - eprintln!(" Section {}: total={} MSB_T={} MSB_F={} last_msb={}", - si, para_count, msb_true_count, msb_false_count, - if last_para_msb { "T" } else { "F" }); + eprintln!( + " Section {}: total={} MSB_T={} MSB_F={} last_msb={}", + si, + para_count, + msb_true_count, + msb_false_count, + if last_para_msb { "T" } else { "F" } + ); - if !mid_para_msb_true.is_empty() { - eprintln!(" ** 중간 문단에서 MSB=T: {:?}", mid_para_msb_true); - for &pi in &mid_para_msb_true { - let para = §ion.paragraphs[pi]; - let ctrl_info: Vec<&str> = para.controls.iter().map(|c| match c { + if !mid_para_msb_true.is_empty() { + eprintln!(" ** 중간 문단에서 MSB=T: {:?}", mid_para_msb_true); + for &pi in &mid_para_msb_true { + let para = §ion.paragraphs[pi]; + let ctrl_info: Vec<&str> = para + .controls + .iter() + .map(|c| match c { Control::Table(_) => "TABLE", Control::Shape(_) => "SHAPE", Control::Picture(_) => "PIC", Control::SectionDef(_) => "SECD", _ => "OTHER", - }).collect(); - eprintln!(" para[{}]: cc={} ctrl=[{}] psid={} sid={}", - pi, para.char_count, ctrl_info.join(","), - para.para_shape_id, para.style_id); - } + }) + .collect(); + eprintln!( + " para[{}]: cc={} ctrl=[{}] psid={} sid={}", + pi, + para.char_count, + ctrl_info.join(","), + para.para_shape_id, + para.style_id + ); } } } +} + +/// 엔터 2회 후 저장 시 파일 손상 재현 진단 테스트 +#[test] +fn test_diag_double_enter_save() { + use crate::parser::cfb_reader::CfbReader; + use crate::parser::record::Record; + use crate::parser::tags; + + let path = "samples/20250130-hongbo.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } - /// 엔터 2회 후 저장 시 파일 손상 재현 진단 테스트 - #[test] - fn test_diag_double_enter_save() { - use crate::parser::record::Record; - use crate::parser::cfb_reader::CfbReader; - use crate::parser::tags; - - let path = "samples/20250130-hongbo.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; + let data = std::fs::read(path).unwrap(); + let mut doc = HwpDocument::from_bytes(&data).unwrap(); + + eprintln!("=== 엔터 2회 후 저장 파일 손상 진단 ==="); + let section = &doc.document.sections[0]; + eprintln!("원본 문단 수: {}", section.paragraphs.len()); + + // 텍스트가 있고 컨트롤이 없는 문단 찾기 + let mut target_para = 0; + for (i, p) in section.paragraphs.iter().enumerate() { + eprintln!( + " 문단[{}]: text_len={} cc={} ctrl={} has_pt={}", + i, + p.text.chars().count(), + p.char_count, + p.controls.len(), + p.has_para_text + ); + if p.text.chars().count() >= 10 && p.controls.is_empty() && target_para == 0 { + target_para = i; } + } + assert!(target_para > 0, "텍스트가 있는 문단을 찾을 수 없음"); + let para = §ion.paragraphs[target_para]; + let text_len = para.text.chars().count(); + eprintln!( + "\n대상 문단[{}]: text_len={} cc={} controls={} has_para_text={}", + target_para, + text_len, + para.char_count, + para.controls.len(), + para.has_para_text + ); + eprintln!( + " text(앞40)='{}'", + para.text.chars().take(40).collect::() + ); + + let split_offset = 4; // 4번째 글자 뒤에서 분할 (사용자 시나리오) + + // === 엔터 1회 === + let result1 = doc.split_paragraph_native(0, target_para, split_offset); + assert!(result1.is_ok(), "1차 분할 실패: {:?}", result1.err()); + eprintln!("\n--- 1차 분할 (offset={}) ---", split_offset); + + let section = &doc.document.sections[0]; + for i in target_para..=(target_para + 1).min(section.paragraphs.len() - 1) { + let p = §ion.paragraphs[i]; + eprintln!( + " 문단[{}]: cc={} text_len={} controls={} has_para_text={} line_segs={}", + i, + p.char_count, + p.text.chars().count(), + p.controls.len(), + p.has_para_text, + p.line_segs.len() + ); + } - let data = std::fs::read(path).unwrap(); - let mut doc = HwpDocument::from_bytes(&data).unwrap(); - - eprintln!("=== 엔터 2회 후 저장 파일 손상 진단 ==="); - let section = &doc.document.sections[0]; - eprintln!("원본 문단 수: {}", section.paragraphs.len()); + // 1회 분할 후 저장 테스트 + let saved1 = doc.export_hwp_native(); + assert!(saved1.is_ok(), "1차 저장 실패"); + let saved1_data = saved1.unwrap(); + let parse1 = HwpDocument::from_bytes(&saved1_data); + eprintln!( + "1회 분할 후 저장+재파싱: {}", + if parse1.is_ok() { "성공" } else { "실패" } + ); + + // === 엔터 2회 (새 문단의 시작에서 다시 분할) === + let new_para_idx = target_para + 1; + let result2 = doc.split_paragraph_native(0, new_para_idx, 0); + assert!(result2.is_ok(), "2차 분할 실패: {:?}", result2.err()); + eprintln!("\n--- 2차 분할 (문단[{}], offset=0) ---", new_para_idx); + + let section = &doc.document.sections[0]; + eprintln!("문단 수: {}", section.paragraphs.len()); + for i in target_para..=(target_para + 2).min(section.paragraphs.len() - 1) { + let p = §ion.paragraphs[i]; + eprintln!( + " 문단[{}]: cc={} text_len={} controls={} has_para_text={} raw_extra_len={}", + i, + p.char_count, + p.text.chars().count(), + p.controls.len(), + p.has_para_text, + p.raw_header_extra.len() + ); + } - // 텍스트가 있고 컨트롤이 없는 문단 찾기 - let mut target_para = 0; - for (i, p) in section.paragraphs.iter().enumerate() { - eprintln!(" 문단[{}]: text_len={} cc={} ctrl={} has_pt={}", - i, p.text.chars().count(), p.char_count, p.controls.len(), p.has_para_text); - if p.text.chars().count() >= 10 && p.controls.is_empty() && target_para == 0 { - target_para = i; + // 2회 분할 후 저장 테스트 + let saved2 = doc.export_hwp_native(); + assert!(saved2.is_ok(), "2차 저장 실패"); + let saved2_data = saved2.unwrap(); + + let _ = std::fs::create_dir_all("output"); + std::fs::write("output/diag_double_enter.hwp", &saved2_data).unwrap(); + eprintln!( + "\noutput/diag_double_enter.hwp 저장 ({} bytes)", + saved2_data.len() + ); + + // 재파싱 테스트 + let parse2 = HwpDocument::from_bytes(&saved2_data); + eprintln!( + "2회 분할 후 저장+재파싱: {}", + if parse2.is_ok() { "성공" } else { "실패" } + ); + + // 직렬화된 Section0 레코드 분석 - 분할 영역 주변만 상세 출력 + eprintln!("\n=== Section0 직렬화 레코드 분석 (level 0만, 분할 영역) ==="); + let section_bytes = crate::serializer::body_text::serialize_section(&doc.document.sections[0]); + let recs = Record::read_all(§ion_bytes).unwrap(); + let mut top_para_idx = 0; + for (ri, rec) in recs.iter().enumerate() { + if rec.tag_id == tags::HWPTAG_PARA_HEADER && rec.level == 0 { + let cc_raw = u32::from_le_bytes(rec.data[0..4].try_into().unwrap()); + let cc = cc_raw & 0x7FFFFFFF; + let msb = cc_raw & 0x80000000 != 0; + let ctrl_mask = u32::from_le_bytes(rec.data[4..8].try_into().unwrap()); + // 분할 영역 (target_para-1 ~ target_para+4) 표시 + if top_para_idx >= target_para.saturating_sub(1) && top_para_idx <= target_para + 4 { + eprintln!( + "rec[{}] PARA_HEADER(L0): model_para={} cc={} msb={} ctrl=0x{:08X}", + ri, top_para_idx, cc, msb, ctrl_mask + ); } - } - assert!(target_para > 0, "텍스트가 있는 문단을 찾을 수 없음"); - let para = §ion.paragraphs[target_para]; - let text_len = para.text.chars().count(); - eprintln!("\n대상 문단[{}]: text_len={} cc={} controls={} has_para_text={}", - target_para, text_len, para.char_count, para.controls.len(), para.has_para_text); - eprintln!(" text(앞40)='{}'", para.text.chars().take(40).collect::()); - - let split_offset = 4; // 4번째 글자 뒤에서 분할 (사용자 시나리오) - - // === 엔터 1회 === - let result1 = doc.split_paragraph_native(0, target_para, split_offset); - assert!(result1.is_ok(), "1차 분할 실패: {:?}", result1.err()); - eprintln!("\n--- 1차 분할 (offset={}) ---", split_offset); - - let section = &doc.document.sections[0]; - for i in target_para..=(target_para+1).min(section.paragraphs.len()-1) { - let p = §ion.paragraphs[i]; - eprintln!(" 문단[{}]: cc={} text_len={} controls={} has_para_text={} line_segs={}", - i, p.char_count, p.text.chars().count(), p.controls.len(), p.has_para_text, p.line_segs.len()); - } - - // 1회 분할 후 저장 테스트 - let saved1 = doc.export_hwp_native(); - assert!(saved1.is_ok(), "1차 저장 실패"); - let saved1_data = saved1.unwrap(); - let parse1 = HwpDocument::from_bytes(&saved1_data); - eprintln!("1회 분할 후 저장+재파싱: {}", if parse1.is_ok() { "성공" } else { "실패" }); - - // === 엔터 2회 (새 문단의 시작에서 다시 분할) === - let new_para_idx = target_para + 1; - let result2 = doc.split_paragraph_native(0, new_para_idx, 0); - assert!(result2.is_ok(), "2차 분할 실패: {:?}", result2.err()); - eprintln!("\n--- 2차 분할 (문단[{}], offset=0) ---", new_para_idx); - - let section = &doc.document.sections[0]; - eprintln!("문단 수: {}", section.paragraphs.len()); - for i in target_para..=(target_para+2).min(section.paragraphs.len()-1) { - let p = §ion.paragraphs[i]; - eprintln!(" 문단[{}]: cc={} text_len={} controls={} has_para_text={} raw_extra_len={}", - i, p.char_count, p.text.chars().count(), p.controls.len(), p.has_para_text, p.raw_header_extra.len()); - } - - // 2회 분할 후 저장 테스트 - let saved2 = doc.export_hwp_native(); - assert!(saved2.is_ok(), "2차 저장 실패"); - let saved2_data = saved2.unwrap(); - - let _ = std::fs::create_dir_all("output"); - std::fs::write("output/diag_double_enter.hwp", &saved2_data).unwrap(); - eprintln!("\noutput/diag_double_enter.hwp 저장 ({} bytes)", saved2_data.len()); - - // 재파싱 테스트 - let parse2 = HwpDocument::from_bytes(&saved2_data); - eprintln!("2회 분할 후 저장+재파싱: {}", if parse2.is_ok() { "성공" } else { "실패" }); - - // 직렬화된 Section0 레코드 분석 - 분할 영역 주변만 상세 출력 - eprintln!("\n=== Section0 직렬화 레코드 분석 (level 0만, 분할 영역) ==="); - let section_bytes = crate::serializer::body_text::serialize_section( - &doc.document.sections[0]); - let recs = Record::read_all(§ion_bytes).unwrap(); - let mut top_para_idx = 0; - for (ri, rec) in recs.iter().enumerate() { - if rec.tag_id == tags::HWPTAG_PARA_HEADER && rec.level == 0 { - let cc_raw = u32::from_le_bytes(rec.data[0..4].try_into().unwrap()); - let cc = cc_raw & 0x7FFFFFFF; - let msb = cc_raw & 0x80000000 != 0; - let ctrl_mask = u32::from_le_bytes(rec.data[4..8].try_into().unwrap()); - // 분할 영역 (target_para-1 ~ target_para+4) 표시 - if top_para_idx >= target_para.saturating_sub(1) && top_para_idx <= target_para + 4 { - eprintln!("rec[{}] PARA_HEADER(L0): model_para={} cc={} msb={} ctrl=0x{:08X}", - ri, top_para_idx, cc, msb, ctrl_mask); - } - top_para_idx += 1; - } else if rec.tag_id == tags::HWPTAG_PARA_TEXT && rec.level == 1 { - // 바로 앞의 PARA_HEADER가 분할 영역이면 표시 - if top_para_idx > target_para.saturating_sub(1) && top_para_idx <= target_para + 5 { - let code_units = rec.data.len() / 2; - eprintln!("rec[{}] PARA_TEXT(L1): {} code_units ({} bytes)", - ri, code_units, rec.data.len()); - } - } else if rec.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE && rec.level == 1 { - if top_para_idx > target_para.saturating_sub(1) && top_para_idx <= target_para + 5 { - let entries = rec.data.len() / 8; - eprintln!("rec[{}] PARA_CHAR_SHAPE(L1): {} entries", ri, entries); - } - } else if rec.tag_id == tags::HWPTAG_PARA_LINE_SEG && rec.level == 1 { - if top_para_idx > target_para.saturating_sub(1) && top_para_idx <= target_para + 5 { - let entries = rec.data.len() / 36; - eprintln!("rec[{}] PARA_LINE_SEG(L1): {} entries", ri, entries); - } + top_para_idx += 1; + } else if rec.tag_id == tags::HWPTAG_PARA_TEXT && rec.level == 1 { + // 바로 앞의 PARA_HEADER가 분할 영역이면 표시 + if top_para_idx > target_para.saturating_sub(1) && top_para_idx <= target_para + 5 { + let code_units = rec.data.len() / 2; + eprintln!( + "rec[{}] PARA_TEXT(L1): {} code_units ({} bytes)", + ri, + code_units, + rec.data.len() + ); + } + } else if rec.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE && rec.level == 1 { + if top_para_idx > target_para.saturating_sub(1) && top_para_idx <= target_para + 5 { + let entries = rec.data.len() / 8; + eprintln!("rec[{}] PARA_CHAR_SHAPE(L1): {} entries", ri, entries); + } + } else if rec.tag_id == tags::HWPTAG_PARA_LINE_SEG && rec.level == 1 { + if top_para_idx > target_para.saturating_sub(1) && top_para_idx <= target_para + 5 { + let entries = rec.data.len() / 36; + eprintln!("rec[{}] PARA_LINE_SEG(L1): {} entries", ri, entries); } } - eprintln!("총 top-level 문단: {}", top_para_idx); + } + eprintln!("총 top-level 문단: {}", top_para_idx); - if parse2.is_err() { - panic!("2회 분할 후 저장된 파일 재파싱 실패!"); - } + if parse2.is_err() { + panic!("2회 분할 후 저장된 파일 재파싱 실패!"); } +} - #[test] - fn test_textbox_render_tree_debug() { - use std::path::Path; - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; +#[test] +fn test_textbox_render_tree_debug() { + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; + use std::path::Path; - let path = Path::new("samples/img-start-001.hwp"); - if !path.exists() { - eprintln!("img-start-001.hwp 없음 — 건너뜀"); - return; - } + let path = Path::new("samples/img-start-001.hwp"); + if !path.exists() { + eprintln!("img-start-001.hwp 없음 — 건너뜀"); + return; + } - let data = std::fs::read(path).unwrap(); - let mut doc = HwpDocument::from_bytes(&data).unwrap(); - doc.convert_to_editable_native(); + let data = std::fs::read(path).unwrap(); + let mut doc = HwpDocument::from_bytes(&data).unwrap(); + let _ = doc.convert_to_editable_native(); - // 문서 구조 확인: Shape 컨트롤 찾기 - let mut shape_found = false; - for (si, sec) in doc.document.sections.iter().enumerate() { - for (pi, para) in sec.paragraphs.iter().enumerate() { - for (ci, ctrl) in para.controls.iter().enumerate() { - if let Control::Shape(shape) = ctrl { - let has_textbox = match shape.as_ref() { - crate::model::shape::ShapeObject::Rectangle(r) => r.drawing.text_box.is_some(), - crate::model::shape::ShapeObject::Ellipse(e) => e.drawing.text_box.is_some(), - crate::model::shape::ShapeObject::Polygon(p) => p.drawing.text_box.is_some(), - crate::model::shape::ShapeObject::Curve(c) => c.drawing.text_box.is_some(), - _ => false, + // 문서 구조 확인: Shape 컨트롤 찾기 + let mut shape_found = false; + for (si, sec) in doc.document.sections.iter().enumerate() { + for (pi, para) in sec.paragraphs.iter().enumerate() { + for (ci, ctrl) in para.controls.iter().enumerate() { + if let Control::Shape(shape) = ctrl { + let has_textbox = match shape.as_ref() { + crate::model::shape::ShapeObject::Rectangle(r) => { + r.drawing.text_box.is_some() + } + crate::model::shape::ShapeObject::Ellipse(e) => { + e.drawing.text_box.is_some() + } + crate::model::shape::ShapeObject::Polygon(p) => { + p.drawing.text_box.is_some() + } + crate::model::shape::ShapeObject::Curve(c) => c.drawing.text_box.is_some(), + _ => false, + }; + if has_textbox { + let tb = get_textbox_from_shape(shape.as_ref()).unwrap(); + let drawing = match shape.as_ref() { + crate::model::shape::ShapeObject::Rectangle(r) => Some(&r.drawing), + crate::model::shape::ShapeObject::Ellipse(e) => Some(&e.drawing), + crate::model::shape::ShapeObject::Polygon(p) => Some(&p.drawing), + crate::model::shape::ShapeObject::Curve(c) => Some(&c.drawing), + _ => None, }; - if has_textbox { - let tb = get_textbox_from_shape(shape.as_ref()).unwrap(); - let drawing = match shape.as_ref() { - crate::model::shape::ShapeObject::Rectangle(r) => Some(&r.drawing), - crate::model::shape::ShapeObject::Ellipse(e) => Some(&e.drawing), - crate::model::shape::ShapeObject::Polygon(p) => Some(&p.drawing), - crate::model::shape::ShapeObject::Curve(c) => Some(&c.drawing), - _ => None, - }; - eprintln!("Shape 발견: sec={} para={} ctrl={} type={:?} textbox_paras={}", - si, pi, ci, - match shape.as_ref() { - crate::model::shape::ShapeObject::Rectangle(_) => "Rectangle", - crate::model::shape::ShapeObject::Ellipse(_) => "Ellipse", - crate::model::shape::ShapeObject::Polygon(_) => "Polygon", - crate::model::shape::ShapeObject::Curve(_) => "Curve", - _ => "Other", - }, - tb.paragraphs.len(), + eprintln!( + "Shape 발견: sec={} para={} ctrl={} type={:?} textbox_paras={}", + si, + pi, + ci, + match shape.as_ref() { + crate::model::shape::ShapeObject::Rectangle(_) => "Rectangle", + crate::model::shape::ShapeObject::Ellipse(_) => "Ellipse", + crate::model::shape::ShapeObject::Polygon(_) => "Polygon", + crate::model::shape::ShapeObject::Curve(_) => "Curve", + _ => "Other", + }, + tb.paragraphs.len(), + ); + if let Some(d) = drawing { + eprintln!(" fill_type={:?}", d.fill.fill_type); + let sa = &d.shape_attr; + eprintln!( + " shape_attr: orig_w={} orig_h={} cur_w={} cur_h={}", + sa.original_width, + sa.original_height, + sa.current_width, + sa.current_height ); - if let Some(d) = drawing { - eprintln!(" fill_type={:?}", d.fill.fill_type); - let sa = &d.shape_attr; - eprintln!(" shape_attr: orig_w={} orig_h={} cur_w={} cur_h={}", - sa.original_width, sa.original_height, sa.current_width, sa.current_height); - if let Some(ref tb) = d.text_box { - eprintln!(" textbox margins: left={} right={} top={} bottom={} max_w={}", - tb.margin_left, tb.margin_right, tb.margin_top, tb.margin_bottom, tb.max_width); - } - if let Some(ref g) = d.fill.gradient { - eprintln!(" gradient: type={} angle={} cx={} cy={} blur={} colors={:?} positions={:?}", + if let Some(ref tb) = d.text_box { + eprintln!( + " textbox margins: left={} right={} top={} bottom={} max_w={}", + tb.margin_left, + tb.margin_right, + tb.margin_top, + tb.margin_bottom, + tb.max_width + ); + } + if let Some(ref g) = d.fill.gradient { + eprintln!(" gradient: type={} angle={} cx={} cy={} blur={} colors={:?} positions={:?}", g.gradient_type, g.angle, g.center_x, g.center_y, g.blur, g.colors.iter().map(|c| format!("#{:06X}", c)).collect::>(), g.positions, ); - } } - let common = match shape.as_ref() { - crate::model::shape::ShapeObject::Rectangle(r) => Some(&r.common), - crate::model::shape::ShapeObject::Ellipse(e) => Some(&e.common), - crate::model::shape::ShapeObject::Polygon(p) => Some(&p.common), - crate::model::shape::ShapeObject::Curve(c) => Some(&c.common), - _ => None, - }; - if let Some(c) = common { - eprintln!(" common: width={} height={} treat_as_char={} horz_rel={:?} vert_rel={:?} h_off={} v_off={}", + } + let common = match shape.as_ref() { + crate::model::shape::ShapeObject::Rectangle(r) => Some(&r.common), + crate::model::shape::ShapeObject::Ellipse(e) => Some(&e.common), + crate::model::shape::ShapeObject::Polygon(p) => Some(&p.common), + crate::model::shape::ShapeObject::Curve(c) => Some(&c.common), + _ => None, + }; + if let Some(c) = common { + eprintln!(" common: width={} height={} treat_as_char={} horz_rel={:?} vert_rel={:?} h_off={} v_off={}", c.width, c.height, c.treat_as_char, c.horz_rel_to, c.vert_rel_to, c.horizontal_offset, c.vertical_offset); - } - for (tpi, tp) in tb.paragraphs.iter().enumerate() { - let text: String = tp.text.chars().take(30).collect(); - eprintln!(" tb_para[{}]: text={:?} total_chars={}", tpi, text, tp.text.chars().count()); - } - shape_found = true; } + for (tpi, tp) in tb.paragraphs.iter().enumerate() { + let text: String = tp.text.chars().take(30).collect(); + eprintln!( + " tb_para[{}]: text={:?} total_chars={}", + tpi, + text, + tp.text.chars().count() + ); + } + shape_found = true; } } } } - assert!(shape_found, "글상자가 있는 Shape 컨트롤을 찾지 못했습니다"); - - // 모든 문단 내용 덤프 - eprintln!("\n=== 문단 목록 (섹션 0) ==="); - let sec = &doc.document.sections[0]; - for (pi, para) in sec.paragraphs.iter().enumerate() { - let text: String = para.text.chars().take(60).collect(); - let ctrl_types: Vec = para.controls.iter().map(|c| match c { + } + assert!(shape_found, "글상자가 있는 Shape 컨트롤을 찾지 못했습니다"); + + // 모든 문단 내용 덤프 + eprintln!("\n=== 문단 목록 (섹션 0) ==="); + let sec = &doc.document.sections[0]; + for (pi, para) in sec.paragraphs.iter().enumerate() { + let text: String = para.text.chars().take(60).collect(); + let ctrl_types: Vec = para + .controls + .iter() + .map(|c| match c { Control::Table(_) => "Table".to_string(), - Control::Shape(s) => format!("Shape({:?})", match s.as_ref() { - crate::model::shape::ShapeObject::Rectangle(_) => "Rect", - crate::model::shape::ShapeObject::Ellipse(_) => "Ellipse", - crate::model::shape::ShapeObject::Line(_) => "Line", - _ => "Other", - }), + Control::Shape(s) => format!( + "Shape({:?})", + match s.as_ref() { + crate::model::shape::ShapeObject::Rectangle(_) => "Rect", + crate::model::shape::ShapeObject::Ellipse(_) => "Ellipse", + crate::model::shape::ShapeObject::Line(_) => "Line", + _ => "Other", + } + ), Control::SectionDef(_) => "SectionDef".to_string(), Control::ColumnDef(_) => "ColumnDef".to_string(), _ => "Other".to_string(), - }).collect(); - eprintln!(" para[{}]: text_len={} line_segs={} char_shapes={} ctrls={:?} text={:?}", - pi, para.text.chars().count(), para.line_segs.len(), para.char_shapes.len(), ctrl_types, text); - } - - // 렌더 트리에서 TextRun의 cell context 확인 - let page_count = doc.page_count(); - eprintln!("\n페이지 수: {}", page_count); + }) + .collect(); + eprintln!( + " para[{}]: text_len={} line_segs={} char_shapes={} ctrls={:?} text={:?}", + pi, + para.text.chars().count(), + para.line_segs.len(), + para.char_shapes.len(), + ctrl_types, + text + ); + } - fn count_textruns(node: &RenderNode, body_runs: &mut Vec, cell_runs: &mut Vec) { - if let RenderNodeType::TextRun(ref tr) = node.node_type { - let (ppi, ci, cei, cpi) = tr.cell_context.as_ref().map_or( - (None, None, None, None), - |ctx| ( - Some(ctx.parent_para_index), - Some(ctx.path[0].control_index), - Some(ctx.path[0].cell_index), - Some(ctx.path[0].cell_para_index), - ), - ); - let info = format!( + // 렌더 트리에서 TextRun의 cell context 확인 + let page_count = doc.page_count(); + eprintln!("\n페이지 수: {}", page_count); + + fn count_textruns(node: &RenderNode, body_runs: &mut Vec, cell_runs: &mut Vec) { + if let RenderNodeType::TextRun(ref tr) = node.node_type { + let (ppi, ci, cei, cpi) = + tr.cell_context + .as_ref() + .map_or((None, None, None, None), |ctx| { + ( + Some(ctx.parent_para_index), + Some(ctx.path[0].control_index), + Some(ctx.path[0].cell_index), + Some(ctx.path[0].cell_para_index), + ) + }); + let info = format!( "text={:?} sec={:?} para={:?} char_start={:?} ppi={:?} ci={:?} cei={:?} cpi={:?} bbox=({:.1},{:.1},{:.1},{:.1})", tr.text.chars().take(15).collect::(), tr.section_index, tr.para_index, tr.char_start, ppi, ci, cei, cpi, node.bbox.x, node.bbox.y, node.bbox.width, node.bbox.height, ); - if tr.cell_context.is_some() { - cell_runs.push(info); - } else { - body_runs.push(info); - } - } - for child in &node.children { - count_textruns(child, body_runs, cell_runs); + if tr.cell_context.is_some() { + cell_runs.push(info); + } else { + body_runs.push(info); } } - - for page in 0..page_count { - let tree = doc.build_page_tree(page as u32).unwrap(); - let mut body_runs = Vec::new(); - let mut cell_runs = Vec::new(); - count_textruns(&tree.root, &mut body_runs, &mut cell_runs); - eprintln!("\n--- 페이지 {} ---", page); - eprintln!("본문 TextRun: {}개", body_runs.len()); - for r in &body_runs { - eprintln!(" [body] {}", r); - } - eprintln!("셀/글상자 TextRun: {}개", cell_runs.len()); - for r in &cell_runs { - eprintln!(" [cell] {}", r); - } + for child in &node.children { + count_textruns(child, body_runs, cell_runs); } } - /// 타스크66: 텍스트+Table(treat_as_char) 혼합 문단의 인라인 렌더링 검증 - /// treat_as_char 표는 텍스트와 같은 줄에 인라인 배치되어야 함 - #[test] - fn test_task66_table_text_mixed_paragraph_rendering() { - use crate::renderer::composer::compose_paragraph; - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; - - let path = "samples/img-start-001.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; + for page in 0..page_count { + let tree = doc.build_page_tree(page as u32).unwrap(); + let mut body_runs = Vec::new(); + let mut cell_runs = Vec::new(); + count_textruns(&tree.root, &mut body_runs, &mut cell_runs); + eprintln!("\n--- 페이지 {} ---", page); + eprintln!("본문 TextRun: {}개", body_runs.len()); + for r in &body_runs { + eprintln!(" [body] {}", r); } + eprintln!("셀/글상자 TextRun: {}개", cell_runs.len()); + for r in &cell_runs { + eprintln!(" [cell] {}", r); + } + } +} + +/// 타스크66: 텍스트+Table(treat_as_char) 혼합 문단의 인라인 렌더링 검증 +/// treat_as_char 표는 텍스트와 같은 줄에 인라인 배치되어야 함 +#[test] +fn test_task66_table_text_mixed_paragraph_rendering() { + use crate::renderer::composer::compose_paragraph; + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; + + let path = "samples/img-start-001.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } - let data = std::fs::read(path).unwrap(); - let doc = HwpDocument::from_bytes(&data).unwrap(); - - // para[1]: 텍스트와 Table 컨트롤이 공존하는 문단 - let para1 = &doc.document.sections[0].paragraphs[1]; - assert!(!para1.text.is_empty(), "para[1]에 텍스트가 있어야 함"); - let has_treat_as_char_table = para1.controls.iter().any(|c| { - matches!(c, Control::Table(t) if t.attr & 0x01 != 0) - }); - assert!(has_treat_as_char_table, "para[1]에 treat_as_char Table이 있어야 함"); - - // compose: 2개 줄 이상 - let composed = compose_paragraph(para1); - assert!(composed.lines.len() >= 2, "최소 2줄 이상이어야 함"); - let line1_text: String = composed.lines[1].runs.iter().map(|r| r.text.as_str()).collect(); - assert!(line1_text.contains("주관부서"), "두 번째 줄에 '주관부서' 텍스트가 있어야 함"); - - // pagination: 블록형 treat_as_char 표(2+ line_segs)는 PageItem::Table로 emit - // truly inline(1 line_seg + 텍스트)만 FullParagraph로 처리 - assert!(para1.line_segs.len() >= 2, "para[1]은 2+ line_segs (블록형 treat_as_char)"); - let mut found_block_table = false; - let mut found_partial_para = false; - for pr in doc.pagination.iter() { - for page in &pr.pages { - for col in &page.column_contents { - for item in &col.items { - match item { - crate::renderer::pagination::PageItem::Table { para_index, .. } if *para_index == 1 => { - found_block_table = true; - } - crate::renderer::pagination::PageItem::PartialParagraph { para_index, .. } if *para_index == 1 => { - found_partial_para = true; - } - _ => {} + let data = std::fs::read(path).unwrap(); + let doc = HwpDocument::from_bytes(&data).unwrap(); + + // para[1]: 텍스트와 Table 컨트롤이 공존하는 문단 + let para1 = &doc.document.sections[0].paragraphs[1]; + assert!(!para1.text.is_empty(), "para[1]에 텍스트가 있어야 함"); + let has_treat_as_char_table = para1 + .controls + .iter() + .any(|c| matches!(c, Control::Table(t) if t.attr & 0x01 != 0)); + assert!( + has_treat_as_char_table, + "para[1]에 treat_as_char Table이 있어야 함" + ); + + // compose: 2개 줄 이상 + let composed = compose_paragraph(para1); + assert!(composed.lines.len() >= 2, "최소 2줄 이상이어야 함"); + let line1_text: String = composed.lines[1] + .runs + .iter() + .map(|r| r.text.as_str()) + .collect(); + assert!( + line1_text.contains("주관부서"), + "두 번째 줄에 '주관부서' 텍스트가 있어야 함" + ); + + // pagination: 블록형 treat_as_char 표(2+ line_segs)는 PageItem::Table로 emit + // truly inline(1 line_seg + 텍스트)만 FullParagraph로 처리 + assert!( + para1.line_segs.len() >= 2, + "para[1]은 2+ line_segs (블록형 treat_as_char)" + ); + let mut found_block_table = false; + let mut found_partial_para = false; + for pr in doc.pagination.iter() { + for page in &pr.pages { + for col in &page.column_contents { + for item in &col.items { + match item { + crate::renderer::pagination::PageItem::Table { para_index, .. } + if *para_index == 1 => + { + found_block_table = true; + } + crate::renderer::pagination::PageItem::PartialParagraph { + para_index, + .. + } if *para_index == 1 => { + found_partial_para = true; } + _ => {} } } } } - assert!(found_block_table, "블록형 treat_as_char 표는 PageItem::Table로 emit되어야 함"); - assert!(found_partial_para, "블록형 treat_as_char 표의 텍스트는 PartialParagraph로 emit되어야 함"); - - // 렌더 트리: Table과 TextRun이 모두 존재해야 함 - let tree = doc.build_page_tree(0).unwrap(); - fn find_table_and_text(node: &RenderNode, table_found: &mut bool, text_found: &mut bool) { - match &node.node_type { - RenderNodeType::Table(_) => { - *table_found = true; - } - RenderNodeType::TextRun(ref tr) => { - if tr.para_index == Some(1) && tr.cell_context.is_none() && !tr.text.is_empty() { - *text_found = true; - } + } + assert!( + found_block_table, + "블록형 treat_as_char 표는 PageItem::Table로 emit되어야 함" + ); + assert!( + found_partial_para, + "블록형 treat_as_char 표의 텍스트는 PartialParagraph로 emit되어야 함" + ); + + // 렌더 트리: Table과 TextRun이 모두 존재해야 함 + let tree = doc.build_page_tree(0).unwrap(); + fn find_table_and_text(node: &RenderNode, table_found: &mut bool, text_found: &mut bool) { + match &node.node_type { + RenderNodeType::Table(_) => { + *table_found = true; + } + RenderNodeType::TextRun(ref tr) => { + if tr.para_index == Some(1) && tr.cell_context.is_none() && !tr.text.is_empty() { + *text_found = true; } - _ => {} } - for child in &node.children { - find_table_and_text(child, table_found, text_found); - } - } - let mut table_found = false; - let mut text_found = false; - find_table_and_text(&tree.root, &mut table_found, &mut text_found); - assert!(table_found, "렌더 트리에 표가 있어야 함"); - assert!(text_found, "렌더 트리에 para[1] 텍스트가 있어야 함"); - - // SVG: 개별 문자가 요소로 출력되는지 확인 - let svg = doc.render_page_svg_native(0).unwrap(); - assert!(svg.contains("주"), "SVG에 '주' 문자가 포함되어야 함"); - assert!(svg.contains("【"), "SVG에 '【' 문자가 포함되어야 함"); - } - - /// 타스크 76: hwp-multi-001.hwp 2페이지에 그룹 이미지 3장이 존재하는지 검증 - #[test] - fn test_task76_multi_001_group_images() { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; - - let path = "samples/hwp-multi-001.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; + _ => {} } - - let data = std::fs::read(path).unwrap(); - let doc = HwpDocument::from_bytes(&data).unwrap(); - assert!(doc.page_count() >= 2, "최소 2페이지 이상이어야 함"); - - // 2페이지 렌더 트리에서 Image 노드 수 확인 - let tree = doc.build_page_tree(1).unwrap(); - fn count_images(node: &RenderNode) -> usize { - let mut count = match &node.node_type { - RenderNodeType::Image(_) => 1, - _ => 0, - }; - for child in &node.children { - count += count_images(child); - } - count + for child in &node.children { + find_table_and_text(child, table_found, text_found); } - let image_count = count_images(&tree.root); - assert!(image_count >= 3, - "hwp-multi-001.hwp 2페이지에 Image 노드가 3개 이상이어야 함 (실제: {})", image_count); + } + let mut table_found = false; + let mut text_found = false; + find_table_and_text(&tree.root, &mut table_found, &mut text_found); + assert!(table_found, "렌더 트리에 표가 있어야 함"); + assert!(text_found, "렌더 트리에 para[1] 텍스트가 있어야 함"); + + // SVG: 개별 문자가 요소로 출력되는지 확인 + let svg = doc.render_page_svg_native(0).unwrap(); + assert!(svg.contains("주"), "SVG에 '주' 문자가 포함되어야 함"); + assert!(svg.contains("【"), "SVG에 '【' 문자가 포함되어야 함"); +} + +/// 타스크 76: hwp-multi-001.hwp 2페이지에 그룹 이미지 3장이 존재하는지 검증 +#[test] +fn test_task76_multi_001_group_images() { + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; + + let path = "samples/hwp-multi-001.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; } - /// 타스크 76: hwp-3.0-HWPML.hwp 1페이지 배경 이미지가 body clip 바깥에 위치하는지 검증 - #[test] - fn test_task76_background_image_outside_body_clip() { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; + let data = std::fs::read(path).unwrap(); + let doc = HwpDocument::from_bytes(&data).unwrap(); + assert!(doc.page_count() >= 2, "최소 2페이지 이상이어야 함"); - let path = "samples/hwp-3.0-HWPML.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; + // 2페이지 렌더 트리에서 Image 노드 수 확인 + let tree = doc.build_page_tree(1).unwrap(); + fn count_images(node: &RenderNode) -> usize { + let mut count = match &node.node_type { + RenderNodeType::Image(_) => 1, + _ => 0, + }; + for child in &node.children { + count += count_images(child); } + count + } + let image_count = count_images(&tree.root); + assert!( + image_count >= 3, + "hwp-multi-001.hwp 2페이지에 Image 노드가 3개 이상이어야 함 (실제: {})", + image_count + ); +} + +/// 타스크 76: hwp-3.0-HWPML.hwp 1페이지 배경 이미지가 body clip 바깥에 위치하는지 검증 +#[test] +fn test_task76_background_image_outside_body_clip() { + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; + + let path = "samples/hwp-3.0-HWPML.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } - let data = std::fs::read(path).unwrap(); - let doc = HwpDocument::from_bytes(&data).unwrap(); + let data = std::fs::read(path).unwrap(); + let doc = HwpDocument::from_bytes(&data).unwrap(); - let tree = doc.build_page_tree(0).unwrap(); + let tree = doc.build_page_tree(0).unwrap(); - // root의 직접 자식(Body 바깥)에 Image 노드가 있어야 함 - let root_image_count = tree.root.children.iter().filter(|child| { + // root의 직접 자식(Body 바깥)에 Image 노드가 있어야 함 + let root_image_count = tree + .root + .children + .iter() + .filter(|child| { matches!(&child.node_type, RenderNodeType::Image(_)) - || child.children.iter().any(|c| matches!(&c.node_type, RenderNodeType::Image(_))) - }).count(); - assert!(root_image_count >= 1, - "배경 이미지가 body clip 바깥(root 직접 자식)에 있어야 함 (실제: {})", root_image_count); - - // 배경 이미지 좌표 검증: (0, 0) 근처 - fn find_root_image(node: &RenderNode) -> Option<(f64, f64)> { - if let RenderNodeType::Image(_) = &node.node_type { - return Some((node.bbox.x, node.bbox.y)); - } - for child in &node.children { - if let Some(pos) = find_root_image(child) { - return Some(pos); - } - } - None - } - for child in &tree.root.children { - if let Some((x, y)) = find_root_image(child) { - assert!(x.abs() < 1.0 && y.abs() < 1.0, - "배경 이미지는 (0,0) 근처여야 함 (실제: ({:.1}, {:.1}))", x, y); - break; - } - } + || child + .children + .iter() + .any(|c| matches!(&c.node_type, RenderNodeType::Image(_))) + }) + .count(); + assert!( + root_image_count >= 1, + "배경 이미지가 body clip 바깥(root 직접 자식)에 있어야 함 (실제: {})", + root_image_count + ); + + // 배경 이미지 좌표 검증: (0, 0) 근처 + fn find_root_image(node: &RenderNode) -> Option<(f64, f64)> { + if let RenderNodeType::Image(_) = &node.node_type { + return Some((node.bbox.x, node.bbox.y)); + } + for child in &node.children { + if let Some(pos) = find_root_image(child) { + return Some(pos); + } + } + None } - - /// 타스크 76: hwp-img-001.hwp에 독립 이미지 4장이 존재하는지 검증 - #[test] - fn test_task76_img_001_four_pictures() { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; - - let path = "samples/hwp-img-001.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; + for child in &tree.root.children { + if let Some((x, y)) = find_root_image(child) { + assert!( + x.abs() < 1.0 && y.abs() < 1.0, + "배경 이미지는 (0,0) 근처여야 함 (실제: ({:.1}, {:.1}))", + x, + y + ); + break; } + } +} - let data = std::fs::read(path).unwrap(); - let doc = HwpDocument::from_bytes(&data).unwrap(); +/// 타스크 76: hwp-img-001.hwp에 독립 이미지 4장이 존재하는지 검증 +#[test] +fn test_task76_img_001_four_pictures() { + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; - let tree = doc.build_page_tree(0).unwrap(); - fn count_images(node: &RenderNode) -> usize { - let mut count = match &node.node_type { - RenderNodeType::Image(_) => 1, - _ => 0, - }; - for child in &node.children { - count += count_images(child); - } - count - } - let image_count = count_images(&tree.root); - assert_eq!(image_count, 4, - "hwp-img-001.hwp에 Image 노드가 4개여야 함 (실제: {})", image_count); + let path = "samples/hwp-img-001.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; } - /// 타스크 77: 이미지 셀 행이 인트라-로우 분할되지 않고 다음 페이지로 이동하는지 검증 - #[test] - fn test_task77_image_cell_no_intra_row_split() { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; + let data = std::fs::read(path).unwrap(); + let doc = HwpDocument::from_bytes(&data).unwrap(); - let path = "samples/20250130-hongbo.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; + let tree = doc.build_page_tree(0).unwrap(); + fn count_images(node: &RenderNode) -> usize { + let mut count = match &node.node_type { + RenderNodeType::Image(_) => 1, + _ => 0, + }; + for child in &node.children { + count += count_images(child); } + count + } + let image_count = count_images(&tree.root); + assert_eq!( + image_count, 4, + "hwp-img-001.hwp에 Image 노드가 4개여야 함 (실제: {})", + image_count + ); +} + +/// 타스크 77: 이미지 셀 행이 인트라-로우 분할되지 않고 다음 페이지로 이동하는지 검증 +#[test] +fn test_task77_image_cell_no_intra_row_split() { + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; + + let path = "samples/20250130-hongbo.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } - let data = std::fs::read(path).unwrap(); - let doc = HwpDocument::from_bytes(&data).unwrap(); - - // 표6(4행×1열)의 PartialTable 페이지네이션 검증 - // 행2(이미지 셀)는 인트라-로우 분할되지 않아야 함 - // 표6의 para_index는 29 (task78에서 para[25] GSO 파싱 정상화 후) - let table_para_index = 29; - let mut found_table_pages: Vec<(usize, usize, f64, f64)> = Vec::new(); - for pr in &doc.pagination { - for page in &pr.pages { - for col in &page.column_contents { - for item in &col.items { - if let crate::renderer::pagination::PageItem::PartialTable { - para_index, start_row, end_row, - split_start_content_offset, split_end_content_limit, .. - } = item { - if *para_index == table_para_index { - found_table_pages.push((*start_row, *end_row, *split_start_content_offset, *split_end_content_limit)); - } + let data = std::fs::read(path).unwrap(); + let doc = HwpDocument::from_bytes(&data).unwrap(); + + // 표6(4행×1열)의 PartialTable 페이지네이션 검증 + // 행2(이미지 셀)는 인트라-로우 분할되지 않아야 함 + // 표6의 para_index는 29 (task78에서 para[25] GSO 파싱 정상화 후) + let table_para_index = 29; + let mut found_table_pages: Vec<(usize, usize, f64, f64)> = Vec::new(); + for pr in &doc.pagination { + for page in &pr.pages { + for col in &page.column_contents { + for item in &col.items { + if let crate::renderer::pagination::PageItem::PartialTable { + para_index, + start_row, + end_row, + split_start_content_offset, + split_end_content_limit, + .. + } = item + { + if *para_index == table_para_index { + found_table_pages.push(( + *start_row, + *end_row, + *split_start_content_offset, + *split_end_content_limit, + )); } } } } } + } - assert_eq!(found_table_pages.len(), 2, "표6이 2페이지에 걸쳐야 함"); + assert_eq!(found_table_pages.len(), 2, "표6이 2페이지에 걸쳐야 함"); - // 첫 번째 페이지: rows 0..2 (행0, 행1만), split_end=0 (인트라-로우 분할 없음) - let (s1, e1, _ss1, se1) = found_table_pages[0]; - assert_eq!(s1, 0, "첫 번째 PartialTable 시작 행"); - assert_eq!(e1, 2, "첫 번째 PartialTable 끝 행 (행2 미포함)"); - assert_eq!(se1, 0.0, "인트라-로우 분할 없어야 함"); + // 첫 번째 페이지: rows 0..2 (행0, 행1만), split_end=0 (인트라-로우 분할 없음) + let (s1, e1, _ss1, se1) = found_table_pages[0]; + assert_eq!(s1, 0, "첫 번째 PartialTable 시작 행"); + assert_eq!(e1, 2, "첫 번째 PartialTable 끝 행 (행2 미포함)"); + assert_eq!(se1, 0.0, "인트라-로우 분할 없어야 함"); - // 두 번째 페이지: rows 2..4 (행2, 행3), split_start=0 (연속 오프셋 없음) - let (s2, e2, ss2, _se2) = found_table_pages[1]; - assert_eq!(s2, 2, "두 번째 PartialTable 시작 행"); - assert_eq!(e2, 4, "두 번째 PartialTable 끝 행"); - assert_eq!(ss2, 0.0, "연속 오프셋 없어야 함"); + // 두 번째 페이지: rows 2..4 (행2, 행3), split_start=0 (연속 오프셋 없음) + let (s2, e2, ss2, _se2) = found_table_pages[1]; + assert_eq!(s2, 2, "두 번째 PartialTable 시작 행"); + assert_eq!(e2, 4, "두 번째 PartialTable 끝 행"); + assert_eq!(ss2, 0.0, "연속 오프셋 없어야 함"); - // 두 페이지 모두에서 이미지가 렌더링되는지 확인 - fn find_images(node: &RenderNode) -> Vec { - let mut ids = Vec::new(); - if let RenderNodeType::Image(img) = &node.node_type { - ids.push(img.bin_data_id); - } - for child in &node.children { - ids.extend(find_images(child)); - } - ids + // 두 페이지 모두에서 이미지가 렌더링되는지 확인 + fn find_images(node: &RenderNode) -> Vec { + let mut ids = Vec::new(); + if let RenderNodeType::Image(img) = &node.node_type { + ids.push(img.bin_data_id); } - - // 표6이 있는 페이지에서 이미지 확인 (PAGE 2, PAGE 3) - let page_count = doc.page_count(); - let mut pages_with_table30_images: Vec> = Vec::new(); - for pi in 0..page_count { - let tree = doc.build_page_tree(pi).unwrap(); - let images = find_images(&tree.root); - // bin_data_id=6 (셀0 그림3) 또는 bin_data_id=1 (셀2 그림4) - let table30_imgs: Vec = images.into_iter() - .filter(|&id| id == 6 || id == 1) - .collect(); - if !table30_imgs.is_empty() { - pages_with_table30_images.push(table30_imgs); - } + for child in &node.children { + ids.extend(find_images(child)); } - - assert_eq!(pages_with_table30_images.len(), 2, - "표6 이미지가 2개 페이지에 분산되어야 함"); - assert!(pages_with_table30_images[0].contains(&6), - "첫 번째 페이지에 셀0 이미지(bin_data_id=6) 있어야 함"); - assert!(pages_with_table30_images[1].contains(&1), - "두 번째 페이지에 셀2 이미지(bin_data_id=1) 있어야 함"); + ids } - #[test] - fn test_task78_rectangle_textbox_inline_images() { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; - use crate::model::shape::ShapeObject; - - let path = "samples/20250130-hongbo.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; + // 표6이 있는 페이지에서 이미지 확인 (PAGE 2, PAGE 3) + let page_count = doc.page_count(); + let mut pages_with_table30_images: Vec> = Vec::new(); + for pi in 0..page_count { + let tree = doc.build_page_tree(pi).unwrap(); + let images = find_images(&tree.root); + // bin_data_id=6 (셀0 그림3) 또는 bin_data_id=1 (셀2 그림4) + let table30_imgs: Vec = images + .into_iter() + .filter(|&id| id == 6 || id == 1) + .collect(); + if !table30_imgs.is_empty() { + pages_with_table30_images.push(table30_imgs); } + } - let data = std::fs::read(path).unwrap(); - let doc = HwpDocument::from_bytes(&data).unwrap(); + assert_eq!( + pages_with_table30_images.len(), + 2, + "표6 이미지가 2개 페이지에 분산되어야 함" + ); + assert!( + pages_with_table30_images[0].contains(&6), + "첫 번째 페이지에 셀0 이미지(bin_data_id=6) 있어야 함" + ); + assert!( + pages_with_table30_images[1].contains(&1), + "두 번째 페이지에 셀2 이미지(bin_data_id=1) 있어야 함" + ); +} + +#[test] +fn test_task78_rectangle_textbox_inline_images() { + use crate::model::shape::ShapeObject; + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; + + let path = "samples/20250130-hongbo.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } - // para[25]의 GSO 컨트롤이 Rectangle (Group이 아닌)으로 파싱되는지 검증 - let section = &doc.document.sections[0]; - let para25 = §ion.paragraphs[25]; - assert_eq!(para25.controls.len(), 1, "para[25]에 컨트롤 1개 있어야 함"); - - if let Control::Shape(shape) = ¶25.controls[0] { - if let ShapeObject::Rectangle(rect) = shape.as_ref() { - // Rectangle으로 올바르게 파싱됨 - assert!(rect.common.treat_as_char, "treat_as_char=true"); - // TextBox가 있어야 함 - assert!(rect.drawing.text_box.is_some(), "Rectangle에 TextBox가 있어야 함"); - let tb = rect.drawing.text_box.as_ref().unwrap(); - assert!(!tb.paragraphs.is_empty(), "TextBox에 문단이 있어야 함"); - // TextBox 문단에 인라인 Picture 컨트롤 2개 - let pic_count: usize = tb.paragraphs.iter() - .flat_map(|p| &p.controls) - .filter(|c| matches!(c, Control::Picture(_))) - .count(); - assert_eq!(pic_count, 2, "TextBox에 인라인 Picture 2개 있어야 함"); - } else { - panic!("para[25]의 컨트롤이 Rectangle이어야 함 (Group이 아닌)"); - } + let data = std::fs::read(path).unwrap(); + let doc = HwpDocument::from_bytes(&data).unwrap(); + + // para[25]의 GSO 컨트롤이 Rectangle (Group이 아닌)으로 파싱되는지 검증 + let section = &doc.document.sections[0]; + let para25 = §ion.paragraphs[25]; + assert_eq!(para25.controls.len(), 1, "para[25]에 컨트롤 1개 있어야 함"); + + if let Control::Shape(shape) = ¶25.controls[0] { + if let ShapeObject::Rectangle(rect) = shape.as_ref() { + // Rectangle으로 올바르게 파싱됨 + assert!(rect.common.treat_as_char, "treat_as_char=true"); + // TextBox가 있어야 함 + assert!( + rect.drawing.text_box.is_some(), + "Rectangle에 TextBox가 있어야 함" + ); + let tb = rect.drawing.text_box.as_ref().unwrap(); + assert!(!tb.paragraphs.is_empty(), "TextBox에 문단이 있어야 함"); + // TextBox 문단에 인라인 Picture 컨트롤 2개 + let pic_count: usize = tb + .paragraphs + .iter() + .flat_map(|p| &p.controls) + .filter(|c| matches!(c, Control::Picture(_))) + .count(); + assert_eq!(pic_count, 2, "TextBox에 인라인 Picture 2개 있어야 함"); } else { - panic!("para[25]의 컨트롤이 Shape이어야 함"); + panic!("para[25]의 컨트롤이 Rectangle이어야 함 (Group이 아닌)"); } + } else { + panic!("para[25]의 컨트롤이 Shape이어야 함"); + } - // 페이지 2 렌더 트리에서 이미지 2개 렌더링 확인 - fn find_images(node: &RenderNode) -> Vec { - let mut ids = Vec::new(); - if let RenderNodeType::Image(img) = &node.node_type { - ids.push(img.bin_data_id); - } - for child in &node.children { - ids.extend(find_images(child)); - } - ids + // 페이지 2 렌더 트리에서 이미지 2개 렌더링 확인 + fn find_images(node: &RenderNode) -> Vec { + let mut ids = Vec::new(); + if let RenderNodeType::Image(img) = &node.node_type { + ids.push(img.bin_data_id); } - - let tree = doc.build_page_tree(1).unwrap(); // 페이지 2 (인덱스 1) - let images = find_images(&tree.root); - assert!(images.len() >= 2, "페이지 2에 이미지 2개 이상 렌더링되어야 함 (실제: {}개)", images.len()); + for child in &node.children { + ids.extend(find_images(child)); + } + ids } - /// 타스크 79: 투명선 표시 기능 — show_transparent_borders=true 시 추가 Line 노드 생성 검증 - #[test] - fn test_task79_transparent_border_lines() { - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; - use crate::model::style::BorderLineType; + let tree = doc.build_page_tree(1).unwrap(); // 페이지 2 (인덱스 1) + let images = find_images(&tree.root); + assert!( + images.len() >= 2, + "페이지 2에 이미지 2개 이상 렌더링되어야 함 (실제: {}개)", + images.len() + ); +} + +/// 타스크 79: 투명선 표시 기능 — show_transparent_borders=true 시 추가 Line 노드 생성 검증 +#[test] +fn test_task79_transparent_border_lines() { + use crate::model::style::BorderLineType; + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; + + fn count_lines(node: &RenderNode) -> usize { + let mut count = 0; + if matches!(&node.node_type, RenderNodeType::Line(_)) { + count += 1; + } + for child in &node.children { + count += count_lines(child); + } + count + } - fn count_lines(node: &RenderNode) -> usize { - let mut count = 0; - if matches!(&node.node_type, RenderNodeType::Line(_)) { - count += 1; - } - for child in &node.children { - count += count_lines(child); - } - count + // 여러 표 포함 파일로 검증 + let files = [ + "samples/table-001.hwp", + "samples/hwp_table_test.hwp", + "samples/table-complex.hwp", + "samples/hwpers_test4_complex_table.hwp", + "samples/table-ipc.hwp", + ]; + + let mut tested = false; + for path in &files { + if !std::path::Path::new(path).exists() { + continue; } + let data = std::fs::read(path).unwrap(); + let mut doc = HwpDocument::from_bytes(&data).unwrap(); - // 여러 표 포함 파일로 검증 - let files = [ - "samples/table-001.hwp", - "samples/hwp_table_test.hwp", - "samples/table-complex.hwp", - "samples/hwpers_test4_complex_table.hwp", - "samples/table-ipc.hwp", - ]; - - let mut tested = false; - for path in &files { - if !std::path::Path::new(path).exists() { - continue; - } - let data = std::fs::read(path).unwrap(); - let mut doc = HwpDocument::from_bytes(&data).unwrap(); - - // 문서 내 None 테두리 존재 여부 확인 - let has_none_border = doc.document.doc_info.border_fills.iter().any(|bf| - bf.borders.iter().any(|b| b.line_type == BorderLineType::None) - ); - - // 투명선 OFF - doc.show_transparent_borders = false; - let tree_off = doc.build_page_tree(0).unwrap(); - let lines_off = count_lines(&tree_off.root); + // 문서 내 None 테두리 존재 여부 확인 + let has_none_border = doc.document.doc_info.border_fills.iter().any(|bf| { + bf.borders + .iter() + .any(|b| b.line_type == BorderLineType::None) + }); - // 투명선 ON - doc.show_transparent_borders = true; - let tree_on = doc.build_page_tree(0).unwrap(); - let lines_on = count_lines(&tree_on.root); + // 투명선 OFF + doc.show_transparent_borders = false; + let tree_off = doc.build_page_tree(0).unwrap(); + let lines_off = count_lines(&tree_off.root); - // 회귀 없음: ON >= OFF - assert!(lines_on >= lines_off, - "{}: 투명선 ON({})이 OFF({}) 이상이어야 함", path, lines_on, lines_off); + // 투명선 ON + doc.show_transparent_borders = true; + let tree_on = doc.build_page_tree(0).unwrap(); + let lines_on = count_lines(&tree_on.root); - // SVG 렌더링 정상 확인 - let svg = doc.render_page_svg_native(0).unwrap(); - assert!(svg.contains("= OFF + assert!( + lines_on >= lines_off, + "{}: 투명선 ON({})이 OFF({}) 이상이어야 함", + path, + lines_on, + lines_off + ); - eprintln!("{}: OFF={} ON={} (+{}) has_none_border={}", - path, lines_off, lines_on, lines_on - lines_off, has_none_border); - tested = true; - } - assert!(tested, "테스트할 수 있는 파일이 없음"); + // SVG 렌더링 정상 확인 + let svg = doc.render_page_svg_native(0).unwrap(); + assert!(svg.contains("= 0x80000000 { + continue; + } - let path = "samples/table-001.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} not found", path); - return; - } - let data = std::fs::read(path).unwrap(); - let doc = HwpDocument::from_bytes(&data).unwrap(); - let dpi = 96.0; + let comp = compose_paragraph(&cell.paragraphs[0]); + if comp.lines.is_empty() { + continue; + } - let mut checked = 0; - for sec in &doc.document.sections { - for para in &sec.paragraphs { - for ctrl in ¶.controls { - if let Control::Table(table) = ctrl { - for cell in &table.cells { - // 단일 행, 단일 문단, 유효한 높이만 검증 - if cell.row_span != 1 { continue; } - if cell.paragraphs.len() != 1 { continue; } - if cell.height == 0 || cell.height >= 0x80000000 { continue; } - - let comp = compose_paragraph(&cell.paragraphs[0]); - if comp.lines.is_empty() { continue; } - - let pad_top = if cell.padding.top != 0 { - crate::renderer::hwpunit_to_px(cell.padding.top as i32, dpi) - } else { - crate::renderer::hwpunit_to_px(table.padding.top as i32, dpi) - }; - let pad_bottom = if cell.padding.bottom != 0 { - crate::renderer::hwpunit_to_px(cell.padding.bottom as i32, dpi) - } else { - crate::renderer::hwpunit_to_px(table.padding.bottom as i32, dpi) - }; + let pad_top = if cell.padding.top != 0 { + crate::renderer::hwpunit_to_px(cell.padding.top as i32, dpi) + } else { + crate::renderer::hwpunit_to_px(table.padding.top as i32, dpi) + }; + let pad_bottom = if cell.padding.bottom != 0 { + crate::renderer::hwpunit_to_px(cell.padding.bottom as i32, dpi) + } else { + crate::renderer::hwpunit_to_px(table.padding.bottom as i32, dpi) + }; - // 마지막 줄 line_spacing 제외 - let lc = comp.lines.len(); - let content: f64 = comp.lines.iter().enumerate().map(|(i, line)| { + // 마지막 줄 line_spacing 제외 + let lc = comp.lines.len(); + let content: f64 = comp + .lines + .iter() + .enumerate() + .map(|(i, line)| { let h = crate::renderer::hwpunit_to_px(line.line_height, dpi); if i + 1 < lc { h + crate::renderer::hwpunit_to_px(line.line_spacing, dpi) - } else { h } - }).sum(); + } else { + h + } + }) + .sum(); - let required = content + pad_top + pad_bottom; - let declared = crate::renderer::hwpunit_to_px(cell.height as i32, dpi); + let required = content + pad_top + pad_bottom; + let declared = crate::renderer::hwpunit_to_px(cell.height as i32, dpi); - // 우리 계산값이 HWP 선언값 이하여야 함 (2px 허용) - assert!(required <= declared + 2.0, + // 우리 계산값이 HWP 선언값 이하여야 함 (2px 허용) + assert!(required <= declared + 2.0, "Cell row={} col={}: required={:.1}px > declared={:.1}px (diff={:.1}px)", cell.row, cell.col, required, declared, required - declared); - checked += 1; - } + checked += 1; } } } } - eprintln!("task80: {}개 셀 높이 검증 통과", checked); - assert!(checked > 0, "검증할 셀이 없음"); } + eprintln!("task80: {}개 셀 높이 검증 통과", checked); + assert!(checked > 0, "검증할 셀이 없음"); +} + +/// 타스크 81: table-004.hwp의 세로쓰기 셀 파싱 및 렌더 트리 검증 +#[test] +fn test_task81_vertical_cell_text() { + let path = "samples/table-004.hwp"; + if !std::path::Path::new(path).exists() { + return; + } + let data = std::fs::read(path).unwrap(); + let doc = HwpDocument::from_bytes(&data).unwrap(); - - - /// 타스크 81: table-004.hwp의 세로쓰기 셀 파싱 및 렌더 트리 검증 - #[test] - fn test_task81_vertical_cell_text() { - let path = "samples/table-004.hwp"; - if !std::path::Path::new(path).exists() { return; } - let data = std::fs::read(path).unwrap(); - let doc = HwpDocument::from_bytes(&data).unwrap(); - - // 1. 파서 검증: text_direction=2인 셀이 3개 존재 - let mut vertical_cells = Vec::new(); - for sec in &doc.document.sections { - for para in &sec.paragraphs { - for ctrl in ¶.controls { - if let crate::model::control::Control::Table(table) = ctrl { - for cell in &table.cells { - if cell.text_direction != 0 { - vertical_cells.push((cell.text_direction, cell.row, cell.col)); - } + // 1. 파서 검증: text_direction=2인 셀이 3개 존재 + let mut vertical_cells = Vec::new(); + for sec in &doc.document.sections { + for para in &sec.paragraphs { + for ctrl in ¶.controls { + if let crate::model::control::Control::Table(table) = ctrl { + for cell in &table.cells { + if cell.text_direction != 0 { + vertical_cells.push((cell.text_direction, cell.row, cell.col)); } } } } } - assert_eq!(vertical_cells.len(), 3, "세로쓰기 셀이 3개여야 함"); - for (td, _r, _c) in &vertical_cells { - assert_eq!(*td, 2, "text_direction은 2(영문세움)이어야 함"); - } - - // 2. 렌더 트리 검증: SVG 내보내기로 세로 배치 확인 - let dpi = 96.0; - let styles = crate::renderer::style_resolver::resolve_styles(&doc.document.doc_info, dpi); - let engine = crate::renderer::layout::LayoutEngine::new(dpi); - - // pagination → render tree - assert!(!doc.pagination.is_empty(), "pagination 결과가 비어있으면 안 됨"); - let pr = &doc.pagination[0]; - assert!(!pr.pages.is_empty(), "페이지가 비어있으면 안 됨"); - - let section = &doc.document.sections[0]; - let composed: Vec<_> = section.paragraphs.iter() - .map(crate::renderer::composer::compose_paragraph) - .collect(); - let sec_mt = doc.measured_tables.get(0).map(|v| v.as_slice()).unwrap_or(&[]); - let tree = engine.build_render_tree( - &pr.pages[0], - §ion.paragraphs, - §ion.paragraphs, - §ion.paragraphs, - &composed, - &styles, - §ion.section_def.footnote_shape, - &doc.document.bin_data_content, - None, - sec_mt, - Some(§ion.section_def.page_border_fill), - section.section_def.outline_numbering_id, - &[], - ); - - // 렌더 트리에서 text_direction != 0인 TableCell 노드 찾기 - fn find_vertical_cells(node: &crate::renderer::render_tree::RenderNode) -> Vec<&crate::renderer::render_tree::RenderNode> { - let mut result = Vec::new(); - if let crate::renderer::render_tree::RenderNodeType::TableCell(ref tc) = node.node_type { - if tc.text_direction != 0 { - result.push(node); - } - } - for child in &node.children { - result.extend(find_vertical_cells(child)); - } - result - } + } + assert_eq!(vertical_cells.len(), 3, "세로쓰기 셀이 3개여야 함"); + for (td, _r, _c) in &vertical_cells { + assert_eq!(*td, 2, "text_direction은 2(영문세움)이어야 함"); + } - let vc_nodes = find_vertical_cells(&tree.root); - assert!(vc_nodes.len() >= 3, "렌더 트리에 세로쓰기 셀이 3개 이상이어야 함, found: {}", vc_nodes.len()); + // 2. 렌더 트리 검증: SVG 내보내기로 세로 배치 확인 + let dpi = 96.0; + let styles = crate::renderer::style_resolver::resolve_styles(&doc.document.doc_info, dpi); + let engine = crate::renderer::layout::LayoutEngine::new(dpi); + + // pagination → render tree + assert!( + !doc.pagination.is_empty(), + "pagination 결과가 비어있으면 안 됨" + ); + let pr = &doc.pagination[0]; + assert!(!pr.pages.is_empty(), "페이지가 비어있으면 안 됨"); + + let section = &doc.document.sections[0]; + let composed: Vec<_> = section + .paragraphs + .iter() + .map(crate::renderer::composer::compose_paragraph) + .collect(); + let sec_mt = doc + .measured_tables + .first() + .map(|v| v.as_slice()) + .unwrap_or(&[]); + let tree = engine.build_render_tree( + &pr.pages[0], + §ion.paragraphs, + §ion.paragraphs, + §ion.paragraphs, + &composed, + &styles, + §ion.section_def.footnote_shape, + &doc.document.bin_data_content, + None, + sec_mt, + Some(§ion.section_def.page_border_fill), + section.section_def.outline_numbering_id, + &[], + ); + + // 렌더 트리에서 text_direction != 0인 TableCell 노드 찾기 + fn find_vertical_cells( + node: &crate::renderer::render_tree::RenderNode, + ) -> Vec<&crate::renderer::render_tree::RenderNode> { + let mut result = Vec::new(); + if let crate::renderer::render_tree::RenderNodeType::TableCell(ref tc) = node.node_type { + if tc.text_direction != 0 { + result.push(node); + } + } + for child in &node.children { + result.extend(find_vertical_cells(child)); + } + result + } - // 각 세로쓰기 셀의 TextRun이 세로 방향으로 배치되었는지 확인 - for vc in &vc_nodes { - let mut run_ys: Vec = Vec::new(); - for line_node in &vc.children { - if let crate::renderer::render_tree::RenderNodeType::TextLine(_) = &line_node.node_type { - for run_node in &line_node.children { - if let crate::renderer::render_tree::RenderNodeType::TextRun(ref tr) = run_node.node_type { - if !tr.text.trim().is_empty() { - run_ys.push(run_node.bbox.y); - } + let vc_nodes = find_vertical_cells(&tree.root); + assert!( + vc_nodes.len() >= 3, + "렌더 트리에 세로쓰기 셀이 3개 이상이어야 함, found: {}", + vc_nodes.len() + ); + + // 각 세로쓰기 셀의 TextRun이 세로 방향으로 배치되었는지 확인 + for vc in &vc_nodes { + let mut run_ys: Vec = Vec::new(); + for line_node in &vc.children { + if let crate::renderer::render_tree::RenderNodeType::TextLine(_) = &line_node.node_type + { + for run_node in &line_node.children { + if let crate::renderer::render_tree::RenderNodeType::TextRun(ref tr) = + run_node.node_type + { + if !tr.text.trim().is_empty() { + run_ys.push(run_node.bbox.y); } } } } - // y좌표가 순차 증가해야 세로 배치 - assert!(run_ys.len() >= 2, "세로쓰기 셀에 TextRun이 2개 이상이어야 함"); - for i in 1..run_ys.len() { - assert!(run_ys[i] > run_ys[i - 1], - "세로쓰기 글자의 y좌표가 순차 증가해야 함: y[{}]={} <= y[{}]={}", - i, run_ys[i], i - 1, run_ys[i - 1]); - } - } - } - - /// 표 바운딩박스 조회 테스트 - #[test] - fn test_get_table_bbox() { - use std::path::Path; - - let path = Path::new("samples/hwp_table_test.hwp"); - if !path.exists() { - eprintln!("hwp_table_test.hwp 없음 — 건너뜀"); - return; } - - let data = std::fs::read(path).unwrap(); - let doc = HwpDocument::from_bytes(&data).unwrap(); - - let result = doc.get_table_bbox_native(0, 3, 0); - assert!(result.is_ok(), "표 bbox 조회 실패: {:?}", result.err()); - - let json = result.unwrap(); - assert!(json.contains("pageIndex"), "pageIndex 필드 존재 확인"); - assert!(json.contains("width"), "width 필드 존재 확인"); - assert!(json.contains("height"), "height 필드 존재 확인"); - eprintln!("표 bbox: {}", json); - } - - /// 표 컨트롤 삭제 테스트 (wasm_api 내부 접근) - #[test] - fn test_delete_table_control() { - use std::path::Path; - - let path = Path::new("samples/hwp_table_test.hwp"); - if !path.exists() { - eprintln!("hwp_table_test.hwp 없음 — 건너뜀"); - return; + // y좌표가 순차 증가해야 세로 배치 + assert!( + run_ys.len() >= 2, + "세로쓰기 셀에 TextRun이 2개 이상이어야 함" + ); + for i in 1..run_ys.len() { + assert!( + run_ys[i] > run_ys[i - 1], + "세로쓰기 글자의 y좌표가 순차 증가해야 함: y[{}]={} <= y[{}]={}", + i, + run_ys[i], + i - 1, + run_ys[i - 1] + ); } - - let data = std::fs::read(path).unwrap(); - let mut doc = HwpDocument::from_bytes(&data).unwrap(); - let _ = doc.convert_to_editable_native(); - - // 삭제 전 컨트롤 수 확인 - let before_count = doc.document.sections[0].paragraphs[3].controls.len(); - assert!(before_count > 0, "테스트 파일에 표가 없음"); - - // 삭제 전 char_count - let before_char_count = doc.document.sections[0].paragraphs[3].char_count; - - // 표 bbox 조회 성공 확인 - let bbox_result = doc.get_table_bbox_native(0, 3, 0); - assert!(bbox_result.is_ok(), "삭제 전 bbox 조회 실패"); - - // 표 삭제 - let result = doc.delete_table_control_native(0, 3, 0); - assert!(result.is_ok(), "표 삭제 실패: {:?}", result.err()); - - // 삭제 후 컨트롤 수 감소 확인 - let after_count = doc.document.sections[0].paragraphs[3].controls.len(); - assert_eq!(after_count, before_count - 1, "컨트롤 수 감소 확인"); - - // char_count가 8 감소했는지 확인 - let after_char_count = doc.document.sections[0].paragraphs[3].char_count; - assert_eq!(after_char_count, before_char_count - 8, "char_count 8 감소 확인"); - - eprintln!("표 삭제: 컨트롤 {}→{}, char_count {}→{}", before_count, after_count, before_char_count, after_char_count); } +} - #[test] - /// B6: 표 구조 변경 후 저장 시 빈 셀 문단의 PARA_TEXT/char_count/LineSeg 검증 - fn test_table_modification_empty_cell_serialization() { - use std::path::Path; - use crate::parser::record::Record; +/// 표 바운딩박스 조회 테스트 +#[test] +fn test_get_table_bbox() { + use std::path::Path; - let path = Path::new("samples/hwp_table_test.hwp"); - if !path.exists() { - eprintln!("hwp_table_test.hwp 없음 — 건너뜀"); - return; - } + let path = Path::new("samples/hwp_table_test.hwp"); + if !path.exists() { + eprintln!("hwp_table_test.hwp 없음 — 건너뜀"); + return; + } - let data = std::fs::read(path).unwrap(); + let data = std::fs::read(path).unwrap(); + let doc = HwpDocument::from_bytes(&data).unwrap(); - // 행 추가 후 내보내기 - let mut doc = HwpDocument::from_bytes(&data).unwrap(); - doc.insert_table_row_native(0, 3, 0, 0, true).unwrap(); - let exported = doc.export_hwp_native().unwrap(); + let result = doc.get_table_bbox_native(0, 3, 0); + assert!(result.is_ok(), "표 bbox 조회 실패: {:?}", result.err()); - // 재파싱 - let parsed = crate::parser::parse_hwp(&exported).unwrap(); - let mut cfb = crate::parser::cfb_reader::CfbReader::open(&exported).unwrap(); - let bt = cfb.read_body_text_section(0, parsed.header.compressed, false).unwrap(); - let recs = Record::read_all(&bt).unwrap(); + let json = result.unwrap(); + assert!(json.contains("pageIndex"), "pageIndex 필드 존재 확인"); + assert!(json.contains("width"), "width 필드 존재 확인"); + assert!(json.contains("height"), "height 필드 존재 확인"); + eprintln!("표 bbox: {}", json); +} - // 표 범위 내 PARA_HEADER → PARA_TEXT 패턴 검사 - // cc=1인 문단(빈 셀)은 PARA_TEXT가 없어야 한다 - let mut empty_cell_count = 0; - let mut violation_count = 0; +/// 표 컨트롤 삭제 테스트 (wasm_api 내부 접근) +#[test] +fn test_delete_table_control() { + use std::path::Path; - for (i, rec) in recs.iter().enumerate() { - if rec.tag_id == crate::parser::tags::HWPTAG_PARA_HEADER && rec.data.len() >= 4 { - let cc_raw = u32::from_le_bytes(rec.data[0..4].try_into().unwrap()); - let cc = cc_raw & 0x7FFFFFFF; + let path = Path::new("samples/hwp_table_test.hwp"); + if !path.exists() { + eprintln!("hwp_table_test.hwp 없음 — 건너뜀"); + return; + } - // 빈 문단 (cc == 0 또는 1) - if cc <= 1 { - empty_cell_count += 1; + let data = std::fs::read(path).unwrap(); + let mut doc = HwpDocument::from_bytes(&data).unwrap(); + let _ = doc.convert_to_editable_native(); + + // 삭제 전 컨트롤 수 확인 + let before_count = doc.document.sections[0].paragraphs[3].controls.len(); + assert!(before_count > 0, "테스트 파일에 표가 없음"); + + // 삭제 전 char_count + let before_char_count = doc.document.sections[0].paragraphs[3].char_count; + + // 표 bbox 조회 성공 확인 + let bbox_result = doc.get_table_bbox_native(0, 3, 0); + assert!(bbox_result.is_ok(), "삭제 전 bbox 조회 실패"); + + // 표 삭제 + let result = doc.delete_table_control_native(0, 3, 0); + assert!(result.is_ok(), "표 삭제 실패: {:?}", result.err()); + + // 삭제 후 컨트롤 수 감소 확인 + let after_count = doc.document.sections[0].paragraphs[3].controls.len(); + assert_eq!(after_count, before_count - 1, "컨트롤 수 감소 확인"); + + // char_count가 8 감소했는지 확인 + let after_char_count = doc.document.sections[0].paragraphs[3].char_count; + assert_eq!( + after_char_count, + before_char_count - 8, + "char_count 8 감소 확인" + ); + + eprintln!( + "표 삭제: 컨트롤 {}→{}, char_count {}→{}", + before_count, after_count, before_char_count, after_char_count + ); +} + +#[test] +/// B6: 표 구조 변경 후 저장 시 빈 셀 문단의 PARA_TEXT/char_count/LineSeg 검증 +fn test_table_modification_empty_cell_serialization() { + use crate::parser::record::Record; + use std::path::Path; + + let path = Path::new("samples/hwp_table_test.hwp"); + if !path.exists() { + eprintln!("hwp_table_test.hwp 없음 — 건너뜀"); + return; + } - // 다음 레코드가 PARA_TEXT이면 안 됨 - if i + 1 < recs.len() && recs[i + 1].tag_id == crate::parser::tags::HWPTAG_PARA_TEXT { - violation_count += 1; - eprintln!("!! 위반: rec[{}] cc={} 다음에 PARA_TEXT({}B) 존재", - i, cc, recs[i + 1].data.len()); - } + let data = std::fs::read(path).unwrap(); + + // 행 추가 후 내보내기 + let mut doc = HwpDocument::from_bytes(&data).unwrap(); + doc.insert_table_row_native(0, 3, 0, 0, true).unwrap(); + let exported = doc.export_hwp_native().unwrap(); + + // 재파싱 + let parsed = crate::parser::parse_hwp(&exported).unwrap(); + let mut cfb = crate::parser::cfb_reader::CfbReader::open(&exported).unwrap(); + let bt = cfb + .read_body_text_section(0, parsed.header.compressed, false) + .unwrap(); + let recs = Record::read_all(&bt).unwrap(); + + // 표 범위 내 PARA_HEADER → PARA_TEXT 패턴 검사 + // cc=1인 문단(빈 셀)은 PARA_TEXT가 없어야 한다 + let mut empty_cell_count = 0; + let mut violation_count = 0; + + for (i, rec) in recs.iter().enumerate() { + if rec.tag_id == crate::parser::tags::HWPTAG_PARA_HEADER && rec.data.len() >= 4 { + let cc_raw = u32::from_le_bytes(rec.data[0..4].try_into().unwrap()); + let cc = cc_raw & 0x7FFFFFFF; + + // 빈 문단 (cc == 0 또는 1) + if cc <= 1 { + empty_cell_count += 1; + + // 다음 레코드가 PARA_TEXT이면 안 됨 + if i + 1 < recs.len() && recs[i + 1].tag_id == crate::parser::tags::HWPTAG_PARA_TEXT + { + violation_count += 1; + eprintln!( + "!! 위반: rec[{}] cc={} 다음에 PARA_TEXT({}B) 존재", + i, + cc, + recs[i + 1].data.len() + ); + } - // cc=0이면 안 됨 (HWP 스펙: 최소 cc=1) - if cc == 0 { - eprintln!("!! 위반: rec[{}] cc=0 (HWP 스펙 위반, 최소 1이어야 함)", i); - violation_count += 1; - } + // cc=0이면 안 됨 (HWP 스펙: 최소 cc=1) + if cc == 0 { + eprintln!("!! 위반: rec[{}] cc=0 (HWP 스펙 위반, 최소 1이어야 함)", i); + violation_count += 1; + } - // PARA_LINE_SEG가 존재해야 함 — PARA_CHAR_SHAPE 다음에 - let mut has_line_seg = false; - for j in (i + 1)..recs.len() { - if recs[j].tag_id == crate::parser::tags::HWPTAG_PARA_HEADER - || recs[j].level <= rec.level - { - break; - } - if recs[j].tag_id == crate::parser::tags::HWPTAG_PARA_LINE_SEG { - has_line_seg = true; - break; - } + // PARA_LINE_SEG가 존재해야 함 — PARA_CHAR_SHAPE 다음에 + let mut has_line_seg = false; + for j in (i + 1)..recs.len() { + if recs[j].tag_id == crate::parser::tags::HWPTAG_PARA_HEADER + || recs[j].level <= rec.level + { + break; } - if !has_line_seg { - eprintln!("!! 위반: rec[{}] cc={} PARA_LINE_SEG 없음", i, cc); - violation_count += 1; + if recs[j].tag_id == crate::parser::tags::HWPTAG_PARA_LINE_SEG { + has_line_seg = true; + break; } } + if !has_line_seg { + eprintln!("!! 위반: rec[{}] cc={} PARA_LINE_SEG 없음", i, cc); + violation_count += 1; + } } } - - eprintln!("빈 문단 수: {}, 위반: {}", empty_cell_count, violation_count); - assert!(empty_cell_count > 0, "빈 셀 문단이 없음 — 테스트 유효성 확인 필요"); - assert_eq!(violation_count, 0, "빈 셀 문단 직렬화 위반이 {}건 발견됨", violation_count); } - #[test] - fn test_task105_nested_table_path_api() { - let data = std::fs::read("samples/inner-table-01.hwp").unwrap(); - let doc = HwpDocument::from_bytes(&data).unwrap(); - - // 1. hitTest로 중첩 표 셀의 cellPath 확인 - let page_count = doc.page_count(); - eprintln!("페이지 수: {}", page_count); - - // 문서 구조 확인: 중첩 표 위치 - let sec = &doc.document.sections[0]; - for (pi, para) in sec.paragraphs.iter().enumerate() { - for (ci, ctrl) in para.controls.iter().enumerate() { - if let Control::Table(t) = ctrl { - eprintln!("문단[{}] 컨트롤[{}]: 표 {}행x{}열 셀{}개", pi, ci, t.row_count, t.col_count, t.cells.len()); - for (cell_idx, cell) in t.cells.iter().enumerate() { - for (cp_idx, cp) in cell.paragraphs.iter().enumerate() { - for (cci, cctrl) in cp.controls.iter().enumerate() { - if let Control::Table(nt) = cctrl { - eprintln!(" 셀[{}] 문단[{}] 컨트롤[{}]: 중첩 표 {}행x{}열 셀{}개", - cell_idx, cp_idx, cci, nt.row_count, nt.col_count, nt.cells.len()); - } + eprintln!( + "빈 문단 수: {}, 위반: {}", + empty_cell_count, violation_count + ); + assert!( + empty_cell_count > 0, + "빈 셀 문단이 없음 — 테스트 유효성 확인 필요" + ); + assert_eq!( + violation_count, 0, + "빈 셀 문단 직렬화 위반이 {}건 발견됨", + violation_count + ); +} + +#[test] +fn test_task105_nested_table_path_api() { + let data = std::fs::read("samples/inner-table-01.hwp").unwrap(); + let doc = HwpDocument::from_bytes(&data).unwrap(); + + // 1. hitTest로 중첩 표 셀의 cellPath 확인 + let page_count = doc.page_count(); + eprintln!("페이지 수: {}", page_count); + + // 문서 구조 확인: 중첩 표 위치 + let sec = &doc.document.sections[0]; + for (pi, para) in sec.paragraphs.iter().enumerate() { + for (ci, ctrl) in para.controls.iter().enumerate() { + if let Control::Table(t) = ctrl { + eprintln!( + "문단[{}] 컨트롤[{}]: 표 {}행x{}열 셀{}개", + pi, + ci, + t.row_count, + t.col_count, + t.cells.len() + ); + for (cell_idx, cell) in t.cells.iter().enumerate() { + for (cp_idx, cp) in cell.paragraphs.iter().enumerate() { + for (cci, cctrl) in cp.controls.iter().enumerate() { + if let Control::Table(nt) = cctrl { + eprintln!( + " 셀[{}] 문단[{}] 컨트롤[{}]: 중첩 표 {}행x{}열 셀{}개", + cell_idx, + cp_idx, + cci, + nt.row_count, + nt.col_count, + nt.cells.len() + ); } } } } } } + } - // 렌더 트리에서 중첩 표 TextRun 찾기 - use crate::renderer::render_tree::{RenderNode, RenderNodeType}; - fn find_nested_run(node: &RenderNode) -> Option<(usize, Vec<(usize, usize, usize)>)> { - if let RenderNodeType::TextRun(ref tr) = node.node_type { - if let Some(ref ctx) = tr.cell_context { - if ctx.path.len() >= 2 { - let path: Vec<(usize, usize, usize)> = ctx.path.iter() - .map(|e| (e.control_index, e.cell_index, e.cell_para_index)) - .collect(); - return Some((ctx.parent_para_index, path)); - } + // 렌더 트리에서 중첩 표 TextRun 찾기 + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; + fn find_nested_run(node: &RenderNode) -> Option<(usize, Vec<(usize, usize, usize)>)> { + if let RenderNodeType::TextRun(ref tr) = node.node_type { + if let Some(ref ctx) = tr.cell_context { + if ctx.path.len() >= 2 { + let path: Vec<(usize, usize, usize)> = ctx + .path + .iter() + .map(|e| (e.control_index, e.cell_index, e.cell_para_index)) + .collect(); + return Some((ctx.parent_para_index, path)); } } - for child in &node.children { - if let Some(r) = find_nested_run(child) { - return Some(r); - } - } - None - } - - // 모든 페이지에서 중첩 TextRun 탐색 - let mut nested = None; - for page in 0..page_count { - let tree = doc.build_page_tree(page as u32).unwrap(); - fn dump_runs(node: &RenderNode, page: u32) { - if let RenderNodeType::TextRun(ref tr) = node.node_type { - let ctx_info = tr.cell_context.as_ref().map(|ctx| { - format!("ppi={}, path_len={}, path={:?}", ctx.parent_para_index, ctx.path.len(), - ctx.path.iter().map(|e| (e.control_index, e.cell_index, e.cell_para_index)).collect::>()) - }).unwrap_or_else(|| "None".to_string()); - eprintln!(" p{} TextRun: text={:?} ctx={}", page, tr.text.chars().take(10).collect::(), ctx_info); - } - for child in &node.children { dump_runs(child, page); } - } - dump_runs(&tree.root, page as u32); - if nested.is_none() { nested = find_nested_run(&tree.root); } - } - assert!(nested.is_some(), "중첩 표 TextRun이 있어야 합니다"); - let (parent_para, path) = nested.unwrap(); - eprintln!("중첩 표 경로: parent_para={}, path={:?}", parent_para, path); - - // 2. resolve_table_by_path로 중첩 표 접근 - let table = doc.resolve_table_by_path(0, parent_para, &path); - assert!(table.is_ok(), "resolve_table_by_path 실패: {:?}", table.err()); - let table = table.unwrap(); - eprintln!("중첩 표: {}행 x {}열, 셀 {}개", table.row_count, table.col_count, table.cells.len()); - - // 3. resolve_cell_by_path로 셀 접근 - let cell = doc.resolve_cell_by_path(0, parent_para, &path); - assert!(cell.is_ok(), "resolve_cell_by_path 실패: {:?}", cell.err()); - - // 4. getCellInfoByPath 경로 API - let path_json = format!("[{}]", path.iter().map(|(ci, cei, cpi)| { - format!("{{\"controlIndex\":{},\"cellIndex\":{},\"cellParaIndex\":{}}}", ci, cei, cpi) - }).collect::>().join(",")); - eprintln!("path_json: {}", path_json); - - let cell_info = doc.get_cell_info_by_path_native(0, parent_para, &path_json); - assert!(cell_info.is_ok(), "getCellInfoByPath 실패: {:?}", cell_info.err()); - eprintln!("셀 정보: {}", cell_info.unwrap()); - - // 5. getTableDimensionsByPath 경로 API - let dims = doc.get_table_dimensions_by_path_native(0, parent_para, &path_json); - assert!(dims.is_ok(), "getTableDimensionsByPath 실패: {:?}", dims.err()); - eprintln!("표 차원: {}", dims.unwrap()); - - // 6. getCursorRectByPath 경로 API - let cursor = doc.get_cursor_rect_by_path_native(0, parent_para, &path_json, 0); - assert!(cursor.is_ok(), "getCursorRectByPath 실패: {:?}", cursor.err()); - eprintln!("커서 위치: {}", cursor.unwrap()); - - // 7. getTableCellBboxesByPath 경로 API - let bboxes = doc.get_table_cell_bboxes_by_path_native(0, parent_para, &path_json); - assert!(bboxes.is_ok(), "getTableCellBboxesByPath 실패: {:?}", bboxes.err()); - eprintln!("셀 bbox: {}", bboxes.unwrap()); - - // 8. hitTest에서 cellPath 포함 확인 - let hit_json = doc.hit_test_native(0, 400.0, 600.0); - if let Ok(ref json) = hit_json { - eprintln!("hitTest 결과: {}", json); - if json.contains("cellPath") { - eprintln!("✓ hitTest에 cellPath 포함됨"); - } else { - eprintln!("✗ hitTest에 cellPath 없음 — 본문 영역 클릭일 수 있음"); + } + for child in &node.children { + if let Some(r) = find_nested_run(child) { + return Some(r); } } + None } - #[test] - fn test_task110_multi_column_reflow_diag() { - let path = "samples/basic/KTX.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; - } - let data = std::fs::read(path).unwrap(); - let mut doc = HwpDocument::from_bytes(&data).unwrap(); - - eprintln!("=== KTX.hwp 다단 리플로우 진단 ==="); - eprintln!("페이지 수: {}", doc.page_count()); - eprintln!("구역 수: {}", doc.document.sections.len()); - - // ColumnDef 확인 - { - let section = &doc.document.sections[0]; - let column_def = HwpDocument::find_initial_column_def(§ion.paragraphs); - eprintln!("ColumnDef: count={}, same_width={}, widths={:?}, gaps={:?}", - column_def.column_count, column_def.same_width, - column_def.widths, column_def.gaps); - - // PageLayoutInfo 확인 - let layout = crate::renderer::page_layout::PageLayoutInfo::from_page_def( - §ion.section_def.page_def, &column_def, doc.dpi); - eprintln!("column_areas 수: {}", layout.column_areas.len()); - for (i, ca) in layout.column_areas.iter().enumerate() { - let w_hu = crate::renderer::px_to_hwpunit(ca.width, doc.dpi); - eprintln!(" column_areas[{}]: x={:.1} w={:.1}px ({}hu)", i, ca.x, ca.width, w_hu); + // 모든 페이지에서 중첩 TextRun 탐색 + let mut nested = None; + for page in 0..page_count { + let tree = doc.build_page_tree(page as u32).unwrap(); + fn dump_runs(node: &RenderNode, page: u32) { + if let RenderNodeType::TextRun(ref tr) = node.node_type { + let ctx_info = tr + .cell_context + .as_ref() + .map(|ctx| { + format!( + "ppi={}, path_len={}, path={:?}", + ctx.parent_para_index, + ctx.path.len(), + ctx.path + .iter() + .map(|e| (e.control_index, e.cell_index, e.cell_para_index)) + .collect::>() + ) + }) + .unwrap_or_else(|| "None".to_string()); + eprintln!( + " p{} TextRun: text={:?} ctx={}", + page, + tr.text.chars().take(10).collect::(), + ctx_info + ); } - - // para_column_map 확인 - let map = &doc.para_column_map; - eprintln!("para_column_map 구역 수: {}", map.len()); - if !map.is_empty() && !map[0].is_empty() { - eprintln!("para_column_map[0] 길이: {}", map[0].len()); - for (pi, &ci) in map[0].iter().enumerate() { - let seg_w = section.paragraphs.get(pi) - .and_then(|p| p.line_segs.first()) - .map(|ls| ls.segment_width).unwrap_or(0); - eprintln!(" para[{}] → col_idx={}, seg_w={}", pi, ci, seg_w); - } - } else { - eprintln!("para_column_map[0] 비어있음!"); + for child in &node.children { + dump_runs(child, page); } - - // 본문 전체 너비 (단일 단) 비교 - let layout_single = crate::renderer::page_layout::PageLayoutInfo::from_page_def( - §ion.section_def.page_def, - &crate::model::page::ColumnDef::default(), - doc.dpi); - let body_w_hu = crate::renderer::px_to_hwpunit(layout_single.column_areas[0].width, doc.dpi); - eprintln!("단일 단 body width: {:.1}px ({}hu)", layout_single.column_areas[0].width, body_w_hu); } + dump_runs(&tree.root, page as u32); + if nested.is_none() { + nested = find_nested_run(&tree.root); + } + } + assert!(nested.is_some(), "중첩 표 TextRun이 있어야 합니다"); + let (parent_para, path) = nested.unwrap(); + eprintln!("중첩 표 경로: parent_para={}, path={:?}", parent_para, path); + + // 2. resolve_table_by_path로 중첩 표 접근 + let table = doc.resolve_table_by_path(0, parent_para, &path); + assert!( + table.is_ok(), + "resolve_table_by_path 실패: {:?}", + table.err() + ); + let table = table.unwrap(); + eprintln!( + "중첩 표: {}행 x {}열, 셀 {}개", + table.row_count, + table.col_count, + table.cells.len() + ); + + // 3. resolve_cell_by_path로 셀 접근 + let cell = doc.resolve_cell_by_path(0, parent_para, &path); + assert!(cell.is_ok(), "resolve_cell_by_path 실패: {:?}", cell.err()); + + // 4. getCellInfoByPath 경로 API + let path_json = format!( + "[{}]", + path.iter() + .map(|(ci, cei, cpi)| { + format!( + "{{\"controlIndex\":{},\"cellIndex\":{},\"cellParaIndex\":{}}}", + ci, cei, cpi + ) + }) + .collect::>() + .join(",") + ); + eprintln!("path_json: {}", path_json); + + let cell_info = doc.get_cell_info_by_path_native(0, parent_para, &path_json); + assert!( + cell_info.is_ok(), + "getCellInfoByPath 실패: {:?}", + cell_info.err() + ); + eprintln!("셀 정보: {}", cell_info.unwrap()); + + // 5. getTableDimensionsByPath 경로 API + let dims = doc.get_table_dimensions_by_path_native(0, parent_para, &path_json); + assert!( + dims.is_ok(), + "getTableDimensionsByPath 실패: {:?}", + dims.err() + ); + eprintln!("표 차원: {}", dims.unwrap()); + + // 6. getCursorRectByPath 경로 API + let cursor = doc.get_cursor_rect_by_path_native(0, parent_para, &path_json, 0); + assert!( + cursor.is_ok(), + "getCursorRectByPath 실패: {:?}", + cursor.err() + ); + eprintln!("커서 위치: {}", cursor.unwrap()); + + // 7. getTableCellBboxesByPath 경로 API + let bboxes = doc.get_table_cell_bboxes_by_path_native(0, parent_para, &path_json); + assert!( + bboxes.is_ok(), + "getTableCellBboxesByPath 실패: {:?}", + bboxes.err() + ); + eprintln!("셀 bbox: {}", bboxes.unwrap()); + + // 8. hitTest에서 cellPath 포함 확인 + let hit_json = doc.hit_test_native(0, 400.0, 600.0); + if let Ok(ref json) = hit_json { + eprintln!("hitTest 결과: {}", json); + if json.contains("cellPath") { + eprintln!("✓ hitTest에 cellPath 포함됨"); + } else { + eprintln!("✗ hitTest에 cellPath 없음 — 본문 영역 클릭일 수 있음"); + } + } +} + +#[test] +fn test_task110_multi_column_reflow_diag() { + let path = "samples/basic/KTX.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; + } + let data = std::fs::read(path).unwrap(); + let mut doc = HwpDocument::from_bytes(&data).unwrap(); - // SVG 내보내기: 편집 전 - let svg_before = doc.render_page_svg_native(0).unwrap(); - std::fs::write("output/ktx_before_edit.svg", &svg_before).ok(); - eprintln!("\n편집 전 SVG: output/ktx_before_edit.svg ({} bytes)", svg_before.len()); + eprintln!("=== KTX.hwp 다단 리플로우 진단 ==="); + eprintln!("페이지 수: {}", doc.page_count()); + eprintln!("구역 수: {}", doc.document.sections.len()); - // 문단 1에 텍스트 삽입 - let result = doc.insert_text_native(0, 1, 0, "테스트입력 "); - eprintln!("insert_text 결과: {:?}", result); + // ColumnDef 확인 + { + let section = &doc.document.sections[0]; + let column_def = HwpDocument::find_initial_column_def(§ion.paragraphs); + eprintln!( + "ColumnDef: count={}, same_width={}, widths={:?}, gaps={:?}", + column_def.column_count, column_def.same_width, column_def.widths, column_def.gaps + ); + + // PageLayoutInfo 확인 + let layout = crate::renderer::page_layout::PageLayoutInfo::from_page_def( + §ion.section_def.page_def, + &column_def, + doc.dpi, + ); + eprintln!("column_areas 수: {}", layout.column_areas.len()); + for (i, ca) in layout.column_areas.iter().enumerate() { + let w_hu = crate::renderer::px_to_hwpunit(ca.width, doc.dpi); + eprintln!( + " column_areas[{}]: x={:.1} w={:.1}px ({}hu)", + i, ca.x, ca.width, w_hu + ); + } - // 편집 후 line_segs 확인 - let para1 = &doc.document.sections[0].paragraphs[1]; - eprintln!("편집 후 para[1] line_segs:"); - for (i, ls) in para1.line_segs.iter().enumerate() { - eprintln!(" line[{}]: seg_w={} text_start={}", i, ls.segment_width, ls.text_start); + // para_column_map 확인 + let map = &doc.para_column_map; + eprintln!("para_column_map 구역 수: {}", map.len()); + if !map.is_empty() && !map[0].is_empty() { + eprintln!("para_column_map[0] 길이: {}", map[0].len()); + for (pi, &ci) in map[0].iter().enumerate() { + let seg_w = section + .paragraphs + .get(pi) + .and_then(|p| p.line_segs.first()) + .map(|ls| ls.segment_width) + .unwrap_or(0); + eprintln!(" para[{}] → col_idx={}, seg_w={}", pi, ci, seg_w); + } + } else { + eprintln!("para_column_map[0] 비어있음!"); } - // SVG 내보내기: 편집 후 - let svg_after = doc.render_page_svg_native(0).unwrap(); - std::fs::write("output/ktx_after_edit.svg", &svg_after).ok(); - eprintln!("편집 후 SVG: output/ktx_after_edit.svg ({} bytes)", svg_after.len()); + // 본문 전체 너비 (단일 단) 비교 + let layout_single = crate::renderer::page_layout::PageLayoutInfo::from_page_def( + §ion.section_def.page_def, + &crate::model::page::ColumnDef::default(), + doc.dpi, + ); + let body_w_hu = + crate::renderer::px_to_hwpunit(layout_single.column_areas[0].width, doc.dpi); + eprintln!( + "단일 단 body width: {:.1}px ({}hu)", + layout_single.column_areas[0].width, body_w_hu + ); } - /// tb-err-003.hwp: 저장→로드→재저장 시 control_mask/has_para_text 보정 검증 - #[test] - fn test_diag_tb_err_003() { - use crate::model::control::Control; - use crate::serializer::body_text::serialize_section; - use crate::parser::body_text::parse_body_text_section; + // SVG 내보내기: 편집 전 + let svg_before = doc.render_page_svg_native(0).unwrap(); + std::fs::write("output/ktx_before_edit.svg", &svg_before).ok(); + eprintln!( + "\n편집 전 SVG: output/ktx_before_edit.svg ({} bytes)", + svg_before.len() + ); + + // 문단 1에 텍스트 삽입 + let result = doc.insert_text_native(0, 1, 0, "테스트입력 "); + eprintln!("insert_text 결과: {:?}", result); + + // 편집 후 line_segs 확인 + let para1 = &doc.document.sections[0].paragraphs[1]; + eprintln!("편집 후 para[1] line_segs:"); + for (i, ls) in para1.line_segs.iter().enumerate() { + eprintln!( + " line[{}]: seg_w={} text_start={}", + i, ls.segment_width, ls.text_start + ); + } - // 두 파일 모두 분석 - let files = vec!["saved/tb-err-003.hwp", "saved/tb-err-003-s.hwp"]; - for path in &files { + // SVG 내보내기: 편집 후 + let svg_after = doc.render_page_svg_native(0).unwrap(); + std::fs::write("output/ktx_after_edit.svg", &svg_after).ok(); + eprintln!( + "편집 후 SVG: output/ktx_after_edit.svg ({} bytes)", + svg_after.len() + ); +} + +/// tb-err-003.hwp: 저장→로드→재저장 시 control_mask/has_para_text 보정 검증 +#[test] +fn test_diag_tb_err_003() { + use crate::model::control::Control; + use crate::parser::body_text::parse_body_text_section; + use crate::serializer::body_text::serialize_section; + + // 두 파일 모두 분석 + let files = vec!["saved/tb-err-003.hwp", "saved/tb-err-003-s.hwp"]; + for path in &files { if !std::path::Path::new(path).exists() { eprintln!("SKIP: {} 없음", path); continue; @@ -12461,12 +17016,20 @@ eprintln!("섹션 수: {}", doc.sections.len()); for (si, section) in doc.sections.iter().enumerate() { - eprintln!("\n--- Section {} (문단 {}개) ---", si, section.paragraphs.len()); + eprintln!( + "\n--- Section {} (문단 {}개) ---", + si, + section.paragraphs.len() + ); for (pi, para) in section.paragraphs.iter().enumerate() { - let ctrl_types: Vec = para.controls.iter().map(|c| match c { - Control::Table(t) => format!("Table({}x{})", t.row_count, t.col_count), - _ => format!("{:?}", std::mem::discriminant(c)), - }).collect(); + let ctrl_types: Vec = para + .controls + .iter() + .map(|c| match c { + Control::Table(t) => format!("Table({}x{})", t.row_count, t.col_count), + _ => format!("{:?}", std::mem::discriminant(c)), + }) + .collect(); eprintln!(" 문단[{}]: text={:?} char_count={} msb={} ctrl_mask=0x{:08X} controls=[{}] line_segs={} has_para_text={} raw_header_extra({})={:02x?}", pi, ¶.text.chars().take(30).collect::(), para.char_count, para.char_count_msb, para.control_mask, @@ -12474,23 +17037,51 @@ para.raw_header_extra.len(), ¶.raw_header_extra); for (ci, ctrl) in para.controls.iter().enumerate() { if let Control::Table(t) = ctrl { - eprintln!("\n 문단[{}] 컨트롤[{}]: 표 {}행×{}열 (셀 {}개)", pi, ci, t.row_count, t.col_count, t.cells.len()); + eprintln!( + "\n 문단[{}] 컨트롤[{}]: 표 {}행×{}열 (셀 {}개)", + pi, + ci, + t.row_count, + t.col_count, + t.cells.len() + ); eprintln!(" row_sizes: {:?}", t.row_sizes); eprintln!(" raw_table_record_attr: 0x{:08X}", t.raw_table_record_attr); - eprintln!(" raw_table_record_extra ({} bytes): {:02x?}", t.raw_table_record_extra.len(), &t.raw_table_record_extra); + eprintln!( + " raw_table_record_extra ({} bytes): {:02x?}", + t.raw_table_record_extra.len(), + &t.raw_table_record_extra + ); // 각 셀 상세 for (cell_idx, cell) in t.cells.iter().enumerate() { - eprintln!(" 셀[{}]: col={} row={} cs={} rs={} w={} h={} bfid={} paras={}", - cell_idx, cell.col, cell.row, cell.col_span, cell.row_span, - cell.width, cell.height, cell.border_fill_id, cell.paragraphs.len()); - eprintln!(" raw_list_extra ({} bytes): {:02x?}", cell.raw_list_extra.len(), &cell.raw_list_extra); + eprintln!( + " 셀[{}]: col={} row={} cs={} rs={} w={} h={} bfid={} paras={}", + cell_idx, + cell.col, + cell.row, + cell.col_span, + cell.row_span, + cell.width, + cell.height, + cell.border_fill_id, + cell.paragraphs.len() + ); + eprintln!( + " raw_list_extra ({} bytes): {:02x?}", + cell.raw_list_extra.len(), + &cell.raw_list_extra + ); for (pp, para) in cell.paragraphs.iter().enumerate() { eprintln!(" para[{}]: text={:?} char_count={} msb={} line_segs={} char_shapes={} has_para_text={}", pp, ¶.text.chars().take(20).collect::(), para.char_count, para.char_count_msb, para.line_segs.len(), para.char_shapes.len(), para.has_para_text); - eprintln!(" raw_header_extra ({} bytes): {:02x?}", para.raw_header_extra.len(), ¶.raw_header_extra); + eprintln!( + " raw_header_extra ({} bytes): {:02x?}", + para.raw_header_extra.len(), + ¶.raw_header_extra + ); } } @@ -12498,21 +17089,56 @@ eprintln!("\n --- row_sizes 검증 ---"); for r in 0..t.row_count { let actual_count = t.cells.iter().filter(|c| c.row == r).count(); - let expected = if (r as usize) < t.row_sizes.len() { t.row_sizes[r as usize] } else { -1 }; - let match_str = if actual_count as i16 == expected { "OK" } else { "*** MISMATCH ***" }; - eprintln!(" 행[{}]: row_sizes={} 실제셀수={} {}", r, expected, actual_count, match_str); + let expected = if (r as usize) < t.row_sizes.len() { + t.row_sizes[r as usize] + } else { + -1 + }; + let match_str = if actual_count as i16 == expected { + "OK" + } else { + "*** MISMATCH ***" + }; + eprintln!( + " 행[{}]: row_sizes={} 실제셀수={} {}", + r, expected, actual_count, match_str + ); } // col_count 검증: 셀들의 최대 col+col_span - let max_col_extent = t.cells.iter().map(|c| c.col + c.col_span).max().unwrap_or(0); - eprintln!(" col_count={} 최대열범위={} {}", - t.col_count, max_col_extent, - if t.col_count == max_col_extent { "OK" } else { "*** MISMATCH ***" }); + let max_col_extent = t + .cells + .iter() + .map(|c| c.col + c.col_span) + .max() + .unwrap_or(0); + eprintln!( + " col_count={} 최대열범위={} {}", + t.col_count, + max_col_extent, + if t.col_count == max_col_extent { + "OK" + } else { + "*** MISMATCH ***" + } + ); - let max_row_extent = t.cells.iter().map(|c| c.row + c.row_span).max().unwrap_or(0); - eprintln!(" row_count={} 최대행범위={} {}", - t.row_count, max_row_extent, - if t.row_count == max_row_extent { "OK" } else { "*** MISMATCH ***" }); + let max_row_extent = t + .cells + .iter() + .map(|c| c.row + c.row_span) + .max() + .unwrap_or(0); + eprintln!( + " row_count={} 최대행범위={} {}", + t.row_count, + max_row_extent, + if t.row_count == max_row_extent { + "OK" + } else { + "*** MISMATCH ***" + } + ); } } } @@ -12520,11 +17146,19 @@ // 직렬화 → 재파싱 검증 (raw_stream이 있으면 원본 그대로이므로, 없는 것처럼 재직렬화) use crate::serializer::record_writer::write_records; let mut records = Vec::new(); - crate::serializer::body_text::serialize_paragraph_list(§ion.paragraphs, 0, &mut records); + crate::serializer::body_text::serialize_paragraph_list( + §ion.paragraphs, + 0, + &mut records, + ); let serialized = write_records(&records); match parse_body_text_section(&serialized) { Ok(reparsed) => { - eprintln!("\n 직렬화→재파싱: OK ({} → {} 문단)", section.paragraphs.len(), reparsed.paragraphs.len()); + eprintln!( + "\n 직렬화→재파싱: OK ({} → {} 문단)", + section.paragraphs.len(), + reparsed.paragraphs.len() + ); // 재파싱된 문단의 control_mask 검증 for (pi, para) in reparsed.paragraphs.iter().enumerate() { let expected_mask: u32 = para.controls.iter().fold(0u32, |mask, ctrl| { @@ -12551,3103 +17185,4109 @@ } for (ci, ctrl) in para.controls.iter().enumerate() { if let Control::Table(t2) = ctrl { - eprintln!(" 재파싱 표[{},{}]: {}행×{}열 (셀 {}개) row_sizes={:?}", - pi, ci, t2.row_count, t2.col_count, t2.cells.len(), t2.row_sizes); + eprintln!( + " 재파싱 표[{},{}]: {}행×{}열 (셀 {}개) row_sizes={:?}", + pi, + ci, + t2.row_count, + t2.col_count, + t2.cells.len(), + t2.row_sizes + ); } - } - } - } - Err(e) => { - eprintln!("\n *** 재파싱 실패: {} ***", e); - } - } - } - } // for files loop - } - - #[test] - fn test_task110_treatise_diag() { - let path = "samples/basic/treatise sample.hwp"; - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 없음", path); - return; - } - let data = std::fs::read(path).unwrap(); - let mut doc = HwpDocument::from_bytes(&data).unwrap(); - - eprintln!("=== treatise sample.hwp 다단 구조 진단 ==="); - eprintln!("구역 수: {}", doc.document.sections.len()); - - for (sec_idx, section) in doc.document.sections.iter().enumerate() { - eprintln!("\n--- 구역 {} ---", sec_idx); - eprintln!("문단 수: {}", section.paragraphs.len()); - - // ColumnDef 확인 - let column_def = HwpDocument::find_initial_column_def(§ion.paragraphs); - eprintln!("initial ColumnDef: count={}, same_width={}, spacing={}, widths={:?}, gaps={:?}", - column_def.column_count, column_def.same_width, - column_def.spacing, - column_def.widths, column_def.gaps); - // 2단 ColumnDef 검색 - if section.paragraphs.len() > 14 { - let cd2 = HwpDocument::find_column_def_for_paragraph(§ion.paragraphs, 14); - eprintln!("para[14] ColumnDef: count={}, same_width={}, spacing={}, widths={:?}, gaps={:?}", - cd2.column_count, cd2.same_width, cd2.spacing, cd2.widths, cd2.gaps); - let layout2 = crate::renderer::page_layout::PageLayoutInfo::from_page_def( - §ion.section_def.page_def, &cd2, doc.dpi); - for (i, ca) in layout2.column_areas.iter().enumerate() { - let w_hu = crate::renderer::px_to_hwpunit(ca.width, doc.dpi); - eprintln!(" 2단 column_areas[{}]: x={:.1}px w={:.1}px ({}hu)", i, ca.x, ca.width, w_hu); - } - } - - // PageLayoutInfo 확인 - let layout = crate::renderer::page_layout::PageLayoutInfo::from_page_def( - §ion.section_def.page_def, &column_def, doc.dpi); - eprintln!("column_areas 수: {}", layout.column_areas.len()); - for (i, ca) in layout.column_areas.iter().enumerate() { - let w_hu = crate::renderer::px_to_hwpunit(ca.width, doc.dpi); - eprintln!(" column_areas[{}]: x={:.1}px w={:.1}px ({}hu)", i, ca.x, ca.width, w_hu); - } - - // para_column_map 확인 - let map = &doc.para_column_map; - if sec_idx < map.len() && !map[sec_idx].is_empty() { - eprintln!("para_column_map[{}] 길이: {}", sec_idx, map[sec_idx].len()); - for (pi, &ci) in map[sec_idx].iter().enumerate() { - let seg_w = section.paragraphs.get(pi) - .and_then(|p| p.line_segs.first()) - .map(|ls| ls.segment_width).unwrap_or(0); - eprintln!(" para[{}] → col_idx={}, first_line seg_w={}", pi, ci, seg_w); - } - } else { - eprintln!("para_column_map[{}] 비어있음!", sec_idx); - } - - // 첫 10개 문단의 첫 줄 segment_width - eprintln!("첫 10개 문단 segment_width:"); - for pi in 0..std::cmp::min(10, section.paragraphs.len()) { - let para = §ion.paragraphs[pi]; - let seg_w = para.line_segs.first().map(|ls| ls.segment_width).unwrap_or(0); - let text_preview: String = para.text.chars().take(30).collect(); - eprintln!(" para[{}]: seg_w={}, text={:?}", pi, seg_w, text_preview); - } - } - - // 편집 시뮬레이션: 구역0, 문단1, 오프셋0에 "X" 삽입 - eprintln!("\n=== 편집 시뮬레이션: insert_text_native(0, 1, 0, \"X\") ==="); - let result = doc.insert_text_native(0, 1, 0, "X"); - eprintln!("insert_text 결과: {:?}", result); - - // 편집 후 문단1의 첫 줄 segment_width 확인 - let para1 = &doc.document.sections[0].paragraphs[1]; - eprintln!("편집 후 para[1] line_segs:"); - for (i, ls) in para1.line_segs.iter().enumerate() { - eprintln!(" line[{}]: seg_w={} text_start={} line_height={}", - i, ls.segment_width, ls.text_start, ls.line_height); - } - - // available_width 비교: 단 너비 vs 페이지 너비 - let section = &doc.document.sections[0]; - let column_def = HwpDocument::find_initial_column_def(§ion.paragraphs); - let layout = crate::renderer::page_layout::PageLayoutInfo::from_page_def( - §ion.section_def.page_def, &column_def, doc.dpi); - let layout_single = crate::renderer::page_layout::PageLayoutInfo::from_page_def( - §ion.section_def.page_def, - &crate::model::page::ColumnDef::default(), - doc.dpi); - - let col_w_hu = if !layout.column_areas.is_empty() { - crate::renderer::px_to_hwpunit(layout.column_areas[0].width, doc.dpi) - } else { 0 }; - let page_w_hu = if !layout_single.column_areas.is_empty() { - crate::renderer::px_to_hwpunit(layout_single.column_areas[0].width, doc.dpi) - } else { 0 }; - - let actual_seg_w = para1.line_segs.first().map(|ls| ls.segment_width).unwrap_or(0); - eprintln!("\n=== para[1] available_width 비교 (1단 영역) ==="); - eprintln!("단 너비 (column_areas[0]): {}hu", col_w_hu); - eprintln!("페이지 전체 너비 (단일 단): {}hu", page_w_hu); - eprintln!("실제 seg_w: {}hu", actual_seg_w); - - let diff_col = (actual_seg_w as i64 - col_w_hu as i64).abs(); - let diff_page = (actual_seg_w as i64 - page_w_hu as i64).abs(); - if diff_col < diff_page { - eprintln!("→ seg_w가 단 너비에 가까움 (차이: {}hu)", diff_col); - } else { - eprintln!("→ seg_w가 페이지 너비에 가까움 (차이: {}hu)", diff_page); - } - - // 2단 영역 편집 시뮬레이션: para[14] (col_idx=1, 2단 영역) - eprintln!("\n=== 2단 영역 편집: insert_text_native(0, 14, 0, \"Y\") ==="); - let col_idx_14_before = doc.para_column_map.get(0) - .and_then(|m| m.get(14)).copied().unwrap_or(0); - eprintln!("편집 전 para[14] col_idx: {}", col_idx_14_before); - - let result2 = doc.insert_text_native(0, 14, 0, "Y"); - eprintln!("insert_text 결과: {:?}", result2); - - let para14 = &doc.document.sections[0].paragraphs[14]; - eprintln!("편집 후 para[14] line_segs:"); - for (i, ls) in para14.line_segs.iter().enumerate() { - eprintln!(" line[{}]: seg_w={} text_start={}", i, ls.segment_width, ls.text_start); - } - - // find_column_def_for_paragraph 결과 확인 - let cd_for_14 = HwpDocument::find_column_def_for_paragraph( - &doc.document.sections[0].paragraphs, 14); - eprintln!("para[14]에 적용되는 ColumnDef: count={}, same_width={}, widths={:?}", - cd_for_14.column_count, cd_for_14.same_width, cd_for_14.widths); - - let layout14 = crate::renderer::page_layout::PageLayoutInfo::from_page_def( - &doc.document.sections[0].section_def.page_def, &cd_for_14, doc.dpi); - eprintln!("layout14 column_areas:"); - for (i, ca) in layout14.column_areas.iter().enumerate() { - let w_hu = crate::renderer::px_to_hwpunit(ca.width, doc.dpi); - eprintln!(" [{}]: x={:.1}px w={:.1}px ({}hu)", i, ca.x, ca.width, w_hu); - } - - let seg_w_14 = para14.line_segs.first().map(|ls| ls.segment_width).unwrap_or(0); - let orig_seg_w_14 = 22960i32; // 편집 전 원본 seg_w - eprintln!("\n=== para[14] 결과 비교 ==="); - eprintln!("원본 seg_w: {}hu", orig_seg_w_14); - eprintln!("편집 후 seg_w: {}hu", seg_w_14); - eprintln!("페이지 전체 너비: {}hu", page_w_hu); - if (seg_w_14 - orig_seg_w_14).abs() < 1000 { - eprintln!("→ 올바름: 2단 너비로 리플로우됨"); - } else if (seg_w_14 as i64 - page_w_hu as i64).abs() < 1000 { - eprintln!("→ 오류: 1단 전체 너비로 리플로우됨!"); - } else { - eprintln!("→ 알수없는 너비: {}hu", seg_w_14); - } - - // === 양쪽 정렬 진단: 원본 2단 문단의 LineSeg 데이터 === - eprintln!("\n=== 양쪽 정렬 진단: 2단 문단 LineSeg 분석 ==="); - // 원본 데이터 재로드 (편집 전) - let data2 = std::fs::read(path).unwrap(); - let doc2 = HwpDocument::from_bytes(&data2).unwrap(); - let section2 = &doc2.document.sections[0]; - - // 2단 영역의 모든 문단의 LineSeg column_start, segment_width 출력 - for pi in 9..std::cmp::min(20, section2.paragraphs.len()) { - let para = §ion2.paragraphs[pi]; - let text_preview: String = para.text.chars().take(40).collect(); - eprintln!("\npara[{}]: text={:?}", pi, text_preview); - eprintln!(" line_segs 수: {}", para.line_segs.len()); - for (li, ls) in para.line_segs.iter().enumerate() { - eprintln!(" line[{}]: seg_w={} col_start={} text_start={} vpos={} line_h={} line_sp={}", - li, ls.segment_width, ls.column_start, ls.text_start, - ls.vertical_pos, ls.line_height, ls.line_spacing); - } - // 문단 정렬 확인 - let ps = doc2.styles.para_styles.get(para.para_shape_id as usize); - if let Some(ps) = ps { - eprintln!(" alignment: {:?}", ps.alignment); - } - } - - // 페이지네이션 결과 확인: 2단 문단이 어떤 단에 배치되는지 - eprintln!("\n=== 페이지네이션 결과 분석 ==="); - let paginator = crate::renderer::pagination::Paginator::new(doc2.dpi); - let composed2: Vec<_> = section2.paragraphs.iter() - .map(|p| crate::renderer::composer::compose_paragraph(p)) - .collect(); - // 2단 ColumnDef 찾기 (para[9]+ 영역) - let cd_for_9 = HwpDocument::find_column_def_for_paragraph(§ion2.paragraphs, 9); - eprintln!("para[9]+ ColumnDef: count={}", cd_for_9.column_count); - - // 페이지네이션 실행 (전체 섹션) - let (pag_result, measured_sec) = paginator.paginate( - §ion2.paragraphs, &composed2, &doc2.styles, - §ion2.section_def.page_def, - &crate::model::page::ColumnDef::default(), // 초기 ColumnDef - 0); - - // 측정 높이 진단 - eprintln!("\n=== 문단별 측정 높이 (para 0~20) ==="); - let mut zone1_sum: f64 = 0.0; - for pi in 0..std::cmp::min(20, section2.paragraphs.len()) { - let h = measured_sec.get_paragraph_height(pi).unwrap_or(0.0); - let mp = measured_sec.get_measured_paragraph(pi); - let sp_b = mp.map(|m| m.spacing_before).unwrap_or(0.0); - let sp_a = mp.map(|m| m.spacing_after).unwrap_or(0.0); - let lh_sum: f64 = mp.map(|m| m.line_heights.iter().sum()).unwrap_or(0.0); - let line_ct = mp.map(|m| m.line_heights.len()).unwrap_or(0); - eprintln!(" para[{}] h={:.2}px (sp_b={:.2} + lines({})={:.2} + sp_a={:.2})", pi, h, sp_b, line_ct, lh_sum, sp_a); - if pi < 9 { zone1_sum += h; } - } - eprintln!(" zone1(para 0-8) sum={:.2}px", zone1_sum); - let layout1 = crate::renderer::page_layout::PageLayoutInfo::from_page_def( - §ion2.section_def.page_def, &crate::model::page::ColumnDef::default(), doc2.dpi); - eprintln!("body_area.height={:.1}px, available_body_height={:.1}px", - layout1.body_area.height, layout1.available_body_height()); - - for (pg_idx, page) in pag_result.pages.iter().enumerate() { - eprintln!("\n페이지 {} (단 수: {}):", pg_idx, page.column_contents.len()); - for col_content in &page.column_contents { - eprintln!(" 단 {} (zone_y_offset={:.1}):", col_content.column_index, col_content.zone_y_offset); - for item in &col_content.items { - match item { - crate::renderer::pagination::PageItem::FullParagraph { para_index } => { - eprintln!(" FullParagraph(para={})", para_index); - } - crate::renderer::pagination::PageItem::PartialParagraph { para_index, start_line, end_line } => { - eprintln!(" PartialParagraph(para={}, lines={}..{})", para_index, start_line, end_line); - } - crate::renderer::pagination::PageItem::Table { para_index, control_index } => { - eprintln!(" Table(para={}, ctrl={})", para_index, control_index); - } - _ => { - eprintln!(" 기타 항목"); - } - } - } - } - } - - // 검증: 페이지 0에 1단 + 2단 존이 공존해야 함 (다단 설정 나누기) - let page0 = &pag_result.pages[0]; - let has_zone_offset = page0.column_contents.iter() - .any(|cc| cc.zone_y_offset > 0.0); - assert!(has_zone_offset, - "페이지 0에 zone_y_offset > 0인 ColumnContent가 있어야 함 (1단+2단 공존)"); - let has_multi_col = page0.column_contents.iter() - .any(|cc| cc.column_index > 0); - assert!(has_multi_col, - "페이지 0에 column_index > 0인 ColumnContent가 있어야 함 (2단 렌더링)"); - - // === 페이지 1 높이 오버플로 진단 === - if pag_result.pages.len() > 1 { - let page1 = &pag_result.pages[1]; - let avail = page1.layout.available_body_height(); - eprintln!("\n=== 페이지 1 높이 오버플로 진단 ==="); - eprintln!("available_body_height={:.2}px", avail); - eprintln!("body_area: y={:.2}, h={:.2}, bottom={:.2}", - page1.layout.body_area.y, page1.layout.body_area.height, - page1.layout.body_area.y + page1.layout.body_area.height); - - for col_content in &page1.column_contents { - eprintln!("\n 단 {} (zone_y_offset={:.1}):", col_content.column_index, col_content.zone_y_offset); - let mut cumulative: f64 = 0.0; - for item in &col_content.items { - match item { - crate::renderer::pagination::PageItem::FullParagraph { para_index } => { - let h = measured_sec.get_paragraph_height(*para_index).unwrap_or(0.0); - cumulative += h; - let mp = measured_sec.get_measured_paragraph(*para_index); - let sp_b = mp.map(|m| m.spacing_before).unwrap_or(0.0); - let sp_a = mp.map(|m| m.spacing_after).unwrap_or(0.0); - let lh_sum: f64 = mp.map(|m| m.line_heights.iter().sum()).unwrap_or(0.0); - let line_ct = mp.map(|m| m.line_heights.len()).unwrap_or(0); - eprintln!(" FullParagraph(para={}) h={:.2}px (sp_b={:.2} + lines({})={:.2} + sp_a={:.2}) cum={:.2}", - para_index, h, sp_b, line_ct, lh_sum, sp_a, cumulative); - } - crate::renderer::pagination::PageItem::PartialParagraph { para_index, start_line, end_line } => { - let mp = measured_sec.get_measured_paragraph(*para_index); - let (part_h, sp_b, sp_a, lh_sum) = if let Some(mp) = mp { - let sp_b = if *start_line == 0 { mp.spacing_before } else { 0.0 }; - let sp_a = if *end_line >= mp.line_heights.len() { mp.spacing_after } else { 0.0 }; - let safe_s = (*start_line).min(mp.line_heights.len()); - let safe_e = (*end_line).min(mp.line_heights.len()); - let lh: f64 = mp.line_heights[safe_s..safe_e].iter().sum(); - (sp_b + lh + sp_a, sp_b, sp_a, lh) - } else { - (0.0, 0.0, 0.0, 0.0) - }; - cumulative += part_h; - eprintln!(" PartialParagraph(para={}, lines={}..{}) h={:.2}px (sp_b={:.2} + lines={:.2} + sp_a={:.2}) cum={:.2}", - para_index, start_line, end_line, part_h, sp_b, lh_sum, sp_a, cumulative); - } - crate::renderer::pagination::PageItem::Table { para_index, control_index } => { - let h = measured_sec.get_paragraph_height(*para_index).unwrap_or(0.0); - cumulative += h; - eprintln!(" Table(para={}, ctrl={}) h={:.2}px cum={:.2}", - para_index, control_index, h, cumulative); - } - _ => { - eprintln!(" 기타 항목"); - } - } - } - let overflow = cumulative - avail; - if overflow > 0.0 { - eprintln!(" *** 오버플로: {:.2}px (누적 {:.2} > 가용 {:.2})", overflow, cumulative, avail); - } else { - eprintln!(" 여유: {:.2}px (누적 {:.2} <= 가용 {:.2})", -overflow, cumulative, avail); - } - } - } - } - - /// 엔터키 후 저장 시 파일 손상 진단: blanK2020 원본 vs 손상 파일 비교 - #[test] - fn test_blank2020_enter_corruption_diagnosis() { - use crate::parser::cfb_reader::CfbReader; - use crate::parser::record::Record; - use crate::parser::tags; - - let files = [ - ("blanK2020 원본", "saved/blanK2020.hwp"), - ("blanK2020 엔터후저장(손상)", "saved/blanK2020_enter_saved_currupt.hwp"), - ]; - - for (label, path) in &files { - if !std::path::Path::new(path).exists() { - eprintln!("SKIP: {} 파일 없음", path); - continue; - } - - let bytes = std::fs::read(path).unwrap(); - let mut cfb = CfbReader::open(&bytes).expect(&format!("{} CFB 열기 실패", label)); - - eprintln!("\n{}", "=".repeat(80)); - eprintln!(" {} ({} bytes)", label, bytes.len()); - - // DocInfo - let doc_info_data = cfb.read_doc_info(true).expect("DocInfo 읽기 실패"); - let doc_recs = Record::read_all(&doc_info_data).unwrap(); - let cs_count = doc_recs.iter().filter(|r| r.tag_id == tags::HWPTAG_CHAR_SHAPE).count(); - let ps_count = doc_recs.iter().filter(|r| r.tag_id == tags::HWPTAG_PARA_SHAPE).count(); - eprintln!(" DocInfo: CS={} PS={} records={} bytes={}", cs_count, ps_count, doc_recs.len(), doc_info_data.len()); - - // BodyText Section0 - let body_data = cfb.read_body_text_section(0, true, false).expect("BodyText 읽기 실패"); - let body_recs = Record::read_all(&body_data).unwrap(); - eprintln!(" BodyText: {} records, {} bytes", body_recs.len(), body_data.len()); - - for (i, rec) in body_recs.iter().enumerate() { - let indent = " ".repeat(rec.level as usize); - let tag_name = tags::tag_name(rec.tag_id); - - let extra = if rec.tag_id == tags::HWPTAG_PARA_HEADER { - let cc = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - let cm = u32::from_le_bytes([rec.data[4], rec.data[5], rec.data[6], rec.data[7]]); - let ps_id = u16::from_le_bytes([rec.data[8], rec.data[9]]); - let char_count = cc & 0x7FFFFFFF; - let msb = cc >> 31; - format!(" cc={} msb={} cm=0x{:08X} ps={} data_len={} raw_extra={}", - char_count, msb, cm, ps_id, rec.data.len(), - rec.data.iter().skip(12).map(|b| format!("{:02X}", b)).collect::>().join(" ")) - } else if rec.tag_id == tags::HWPTAG_PARA_LINE_SEG { - let mut segs_info = String::new(); - let mut pos = 0; - let mut seg_idx = 0; - while pos + 36 <= rec.data.len() { - let lh = i32::from_le_bytes([rec.data[pos+8], rec.data[pos+9], rec.data[pos+10], rec.data[pos+11]]); - let th = i32::from_le_bytes([rec.data[pos+12], rec.data[pos+13], rec.data[pos+14], rec.data[pos+15]]); - let sw = i32::from_le_bytes([rec.data[pos+28], rec.data[pos+29], rec.data[pos+30], rec.data[pos+31]]); - let tag = u32::from_le_bytes([rec.data[pos+32], rec.data[pos+33], rec.data[pos+34], rec.data[pos+35]]); - segs_info += &format!(" [seg{}: lh={} th={} sw={} tag=0x{:08X}]", seg_idx, lh, th, sw, tag); - seg_idx += 1; - pos += 36; - } - segs_info - } else if rec.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { - let mut ids = Vec::new(); - let mut pos = 0; - while pos + 8 <= rec.data.len() { - let cs_id = u32::from_le_bytes([rec.data[pos+4], rec.data[pos+5], rec.data[pos+6], rec.data[pos+7]]); - ids.push(cs_id); - pos += 8; - } - format!(" cs_ids={:?}", ids) - } else if rec.tag_id == tags::HWPTAG_PARA_TEXT { - let hex: String = rec.data.iter().take(20).map(|b| format!("{:02X}", b)).collect::>().join(" "); - format!(" [{}]", hex) - } else { - String::new() - }; - - eprintln!(" rec[{:3}] {}L{} {} ({}B){}", i, indent, rec.level, tag_name, rec.data.len(), extra); - } - } - - // 추가: 우리 파서로 로드 → split → export → 다시 레코드 비교 - let blank_path = "saved/blanK2020.hwp"; - if std::path::Path::new(blank_path).exists() { - eprintln!("\n{}", "=".repeat(80)); - eprintln!(" === split_at 라운드트립 테스트 ==="); - let bytes = std::fs::read(blank_path).unwrap(); - let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); - doc.convert_to_editable_native().unwrap(); - - // 원본 문단 정보 - let para = &doc.document.sections[0].paragraphs[0]; - eprintln!(" 원본 para[0]: text='{}' cc={} raw_header_extra({} bytes): {:02x?}", - para.text, para.char_count, para.raw_header_extra.len(), ¶.raw_header_extra); - eprintln!(" 원본 para[0] line_segs[0].tag = 0x{:08X}", para.line_segs.first().map(|ls| ls.tag).unwrap_or(0)); - - // 엔터 (split at 0) - let result = doc.split_paragraph_native(0, 0, 0); - eprintln!(" split result: {:?}", result); - - // 분할 후 문단 정보 - for (i, p) in doc.document.sections[0].paragraphs.iter().enumerate() { - eprintln!(" split 후 para[{}]: text='{}' cc={} has_para_text={} raw_header_extra({} bytes): {:02x?}", - i, p.text, p.char_count, p.has_para_text, p.raw_header_extra.len(), &p.raw_header_extra); - if let Some(ls) = p.line_segs.first() { - eprintln!(" line_seg: lh={} th={} bd={} sw={} tag=0x{:08X}", - ls.line_height, ls.text_height, ls.baseline_distance, ls.segment_width, ls.tag); - } - } - - // export - let exported = doc.export_hwp_native().unwrap(); - eprintln!(" exported: {} bytes", exported.len()); - - // re-parse exported - let mut cfb2 = CfbReader::open(&exported).expect("재파싱 CFB 열기 실패"); - let body2 = cfb2.read_body_text_section(0, true, false).expect("재파싱 BodyText 실패"); - let recs2 = Record::read_all(&body2).unwrap(); - eprintln!(" 재파싱 BodyText: {} records, {} bytes", recs2.len(), body2.len()); - - for (i, rec) in recs2.iter().enumerate() { - let tag_name = tags::tag_name(rec.tag_id); - if rec.tag_id == tags::HWPTAG_PARA_HEADER { - eprintln!(" re-rec[{:3}] L{} {} ({}B) raw_extra={}", - i, rec.level, tag_name, rec.data.len(), - rec.data.iter().skip(12).map(|b| format!("{:02X}", b)).collect::>().join(" ")); - } else if rec.tag_id == tags::HWPTAG_PARA_LINE_SEG { - let mut pos = 0; - while pos + 36 <= rec.data.len() { - let tag = u32::from_le_bytes([rec.data[pos+32], rec.data[pos+33], rec.data[pos+34], rec.data[pos+35]]); - eprintln!(" re-rec[{:3}] L{} {} ({}B) tag=0x{:08X}", - i, rec.level, tag_name, rec.data.len(), tag); - pos += 36; - } - } - } - } - } - - /// 빈 문단에서 반복 Enter + getCursorRect 동작 검증 - #[test] - fn test_repeated_enter_on_empty_paragraph() { - let bytes = std::fs::read("saved/blank2010.hwp").expect("blank2010.hwp 읽기 실패"); - let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); - doc.convert_to_editable_native().unwrap(); - doc.paginate(); - - // 1. 텍스트 입력 - let result = doc.insert_text_native(0, 0, 0, "테스트").unwrap(); - println!("Insert: {}", result); - - // 2. 첫 번째 Enter (텍스트 끝에서) - let result1 = doc.split_paragraph_native(0, 0, 3).unwrap(); - println!("Split 1 (para=0, offset=3): {}", result1); - assert!(result1.contains("\"ok\":true")); - assert_eq!(doc.document.sections[0].paragraphs.len(), 2); - - // getCursorRect para 1, offset 0 - let rect1 = doc.get_cursor_rect_native(0, 1, 0); - println!("CursorRect(0,1,0): {:?}", rect1); - assert!(rect1.is_ok(), "빈 문단(para=1) 커서 실패: {:?}", rect1.err()); - - // 3. 두 번째 Enter (빈 문단에서) - let result2 = doc.split_paragraph_native(0, 1, 0).unwrap(); - println!("Split 2 (para=1, offset=0): {}", result2); - assert!(result2.contains("\"ok\":true")); - assert!(result2.contains("\"paraIdx\":2")); - assert_eq!(doc.document.sections[0].paragraphs.len(), 3); - - let rect2 = doc.get_cursor_rect_native(0, 2, 0); - println!("CursorRect(0,2,0): {:?}", rect2); - assert!(rect2.is_ok(), "빈 문단(para=2) 커서 실패: {:?}", rect2.err()); - - // 4. 세 번째 Enter - let result3 = doc.split_paragraph_native(0, 2, 0).unwrap(); - println!("Split 3 (para=2, offset=0): {}", result3); - assert!(result3.contains("\"ok\":true")); - - let rect3 = doc.get_cursor_rect_native(0, 3, 0); - println!("CursorRect(0,3,0): {:?}", rect3); - assert!(rect3.is_ok(), "빈 문단(para=3) 커서 실패: {:?}", rect3.err()); - - // y좌표 순증 검증 - let parse_y = |json: &str| -> f64 { - let y_start = json.find("\"y\":").unwrap() + 4; - let y_end = json[y_start..].find(|c: char| c == ',' || c == '}').unwrap(); - json[y_start..y_start + y_end].parse::().unwrap() - }; - let y1 = parse_y(&rect1.unwrap()); - let y2 = parse_y(&rect2.unwrap()); - let y3 = parse_y(&rect3.unwrap()); - println!("y좌표: y1={:.1}, y2={:.1}, y3={:.1}", y1, y2, y3); - assert!(y2 > y1, "para2 y({:.1}) > para1 y({:.1})", y2, y1); - assert!(y3 > y2, "para3 y({:.1}) > para2 y({:.1})", y3, y2); - } - - /// 강제 줄바꿈(\n) 삽입 후 getCursorRect가 두 번째 줄 좌표를 반환하는지 검증 - #[test] - fn test_cursor_rect_after_line_break() { - let bytes = std::fs::read("saved/blank2010.hwp").expect("blank2010.hwp 읽기 실패"); - let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); - doc.convert_to_editable_native().unwrap(); - doc.paginate(); - - // "가나다라마바" 입력 - doc.insert_text_native(0, 0, 0, "가나다라마바").unwrap(); - - // offset 3에 \n 삽입 → "가나다\n라마바" - doc.insert_text_native(0, 0, 3, "\n").unwrap(); - - // offset 3 → \n 이전 (첫 줄) - let rect_before = doc.get_cursor_rect_native(0, 0, 3); - assert!(rect_before.is_ok(), "offset 3 커서 실패: {:?}", rect_before.err()); - - // offset 4 → \n 이후 (두 번째 줄) - let rect_after = doc.get_cursor_rect_native(0, 0, 4); - assert!(rect_after.is_ok(), "offset 4 커서 실패: {:?}", rect_after.err()); - - let parse_y = |json: &str| -> f64 { - let y_start = json.find("\"y\":").unwrap() + 4; - let y_end = json[y_start..].find(|c: char| c == ',' || c == '}').unwrap(); - json[y_start..y_start + y_end].parse::().unwrap() - }; - let y_before = parse_y(&rect_before.unwrap()); - let y_after = parse_y(&rect_after.unwrap()); - assert!(y_after > y_before, - "줄바꿈 후 커서 y({:.1})가 줄바꿈 전 y({:.1})보다 커야 함", y_after, y_before); - } - - /// 텍스트 끝에 \n 삽입 후 빈 두 번째 줄에서 getCursorRect 검증 - #[test] - fn test_cursor_rect_after_line_break_at_end() { - let bytes = std::fs::read("saved/blank2010.hwp").expect("blank2010.hwp 읽기 실패"); - let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); - doc.convert_to_editable_native().unwrap(); - doc.paginate(); - - // "가나다라" 입력 후 끝에 \n 삽입 → "가나다라\n" - doc.insert_text_native(0, 0, 0, "가나다라").unwrap(); - doc.insert_text_native(0, 0, 4, "\n").unwrap(); - - let para = &doc.document.sections[0].paragraphs[0]; - assert!(para.line_segs.len() >= 2, "line_segs가 2개 이상이어야 함"); - - // composed lines 순서 검증: 첫 줄=텍스트, 둘째 줄=빈 줄 - let comp = &doc.composed[0][0]; - assert_eq!(comp.lines.len(), 2); - assert!(comp.lines[0].has_line_break, "첫 줄에 line_break 플래그 있어야 함"); - assert_eq!(comp.lines[1].runs.len(), 0, "둘째 줄은 빈 줄이어야 함"); - - // offset 4 → \n 위치 (첫 줄 끝) - let rect_at_newline = doc.get_cursor_rect_native(0, 0, 4); - assert!(rect_at_newline.is_ok()); - - // offset 5 → \n 직후, 빈 두 번째 줄 - let rect_after = doc.get_cursor_rect_native(0, 0, 5); - assert!(rect_after.is_ok(), "빈 줄 offset 5 커서 실패: {:?}", rect_after.err()); - - let parse_y = |json: &str| -> f64 { - let y_start = json.find("\"y\":").unwrap() + 4; - let y_end = json[y_start..].find(|c: char| c == ',' || c == '}').unwrap(); - json[y_start..y_start + y_end].parse::().unwrap() - }; - let y_newline = parse_y(&rect_at_newline.unwrap()); - let y_after = parse_y(&rect_after.unwrap()); - assert!(y_after > y_newline, - "빈 줄 커서 y({:.1})가 첫 줄 y({:.1})보다 커야 함", y_after, y_newline); - } - - // ── Event Sourcing + Batch Mode 테스트 ── + } + } + } + Err(e) => { + eprintln!("\n *** 재파싱 실패: {} ***", e); + } + } + } + } // for files loop +} - /// 편집 가능한 빈 문서 생성 헬퍼 (blank 템플릿 기반) - fn create_editable_doc() -> HwpDocument { - let mut doc = HwpDocument::create_empty(); - doc.create_blank_document_native().unwrap(); - doc +#[test] +fn test_task110_treatise_diag() { + let path = "samples/basic/treatise sample.hwp"; + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 없음", path); + return; } + let data = std::fs::read(path).unwrap(); + let mut doc = HwpDocument::from_bytes(&data).unwrap(); - #[test] - fn test_single_command_emits_event() { - let mut doc = create_editable_doc(); - assert!(doc.event_log.is_empty()); + eprintln!("=== treatise sample.hwp 다단 구조 진단 ==="); + eprintln!("구역 수: {}", doc.document.sections.len()); - let result = doc.insert_text_native(0, 0, 0, "Hello"); - assert!(result.is_ok(), "insert_text_native failed: {:?}", result); - assert_eq!(doc.event_log.len(), 1); - - let json = doc.event_log[0].to_json(); - assert!(json.contains("\"type\":\"TextInserted\"")); - assert!(json.contains("\"section\":0")); - assert!(json.contains("\"para\":0")); - assert!(json.contains("\"offset\":0")); - assert!(json.contains("\"len\":5")); - } + for (sec_idx, section) in doc.document.sections.iter().enumerate() { + eprintln!("\n--- 구역 {} ---", sec_idx); + eprintln!("문단 수: {}", section.paragraphs.len()); - #[test] - fn test_batch_mode_events_collected() { - let mut doc = create_editable_doc(); + // ColumnDef 확인 + let column_def = HwpDocument::find_initial_column_def(§ion.paragraphs); + eprintln!( + "initial ColumnDef: count={}, same_width={}, spacing={}, widths={:?}, gaps={:?}", + column_def.column_count, + column_def.same_width, + column_def.spacing, + column_def.widths, + column_def.gaps + ); + // 2단 ColumnDef 검색 + if section.paragraphs.len() > 14 { + let cd2 = HwpDocument::find_column_def_for_paragraph(§ion.paragraphs, 14); + eprintln!( + "para[14] ColumnDef: count={}, same_width={}, spacing={}, widths={:?}, gaps={:?}", + cd2.column_count, cd2.same_width, cd2.spacing, cd2.widths, cd2.gaps + ); + let layout2 = crate::renderer::page_layout::PageLayoutInfo::from_page_def( + §ion.section_def.page_def, + &cd2, + doc.dpi, + ); + for (i, ca) in layout2.column_areas.iter().enumerate() { + let w_hu = crate::renderer::px_to_hwpunit(ca.width, doc.dpi); + eprintln!( + " 2단 column_areas[{}]: x={:.1}px w={:.1}px ({}hu)", + i, ca.x, ca.width, w_hu + ); + } + } - let r = doc.begin_batch_native(); - assert!(r.is_ok()); - assert!(doc.batch_mode); - assert!(doc.event_log.is_empty()); + // PageLayoutInfo 확인 + let layout = crate::renderer::page_layout::PageLayoutInfo::from_page_def( + §ion.section_def.page_def, + &column_def, + doc.dpi, + ); + eprintln!("column_areas 수: {}", layout.column_areas.len()); + for (i, ca) in layout.column_areas.iter().enumerate() { + let w_hu = crate::renderer::px_to_hwpunit(ca.width, doc.dpi); + eprintln!( + " column_areas[{}]: x={:.1}px w={:.1}px ({}hu)", + i, ca.x, ca.width, w_hu + ); + } - // Batch 중 여러 편집 - let r1 = doc.insert_text_native(0, 0, 0, "Hello"); - assert!(r1.is_ok(), "1st insert failed: {:?}", r1); - let r2 = doc.insert_text_native(0, 0, 5, " World"); - assert!(r2.is_ok(), "2nd insert failed: {:?}", r2); + // para_column_map 확인 + let map = &doc.para_column_map; + if sec_idx < map.len() && !map[sec_idx].is_empty() { + eprintln!("para_column_map[{}] 길이: {}", sec_idx, map[sec_idx].len()); + for (pi, &ci) in map[sec_idx].iter().enumerate() { + let seg_w = section + .paragraphs + .get(pi) + .and_then(|p| p.line_segs.first()) + .map(|ls| ls.segment_width) + .unwrap_or(0); + eprintln!( + " para[{}] → col_idx={}, first_line seg_w={}", + pi, ci, seg_w + ); + } + } else { + eprintln!("para_column_map[{}] 비어있음!", sec_idx); + } - assert_eq!(doc.event_log.len(), 2); - assert!(doc.event_log[0].to_json().contains("\"type\":\"TextInserted\"")); - assert!(doc.event_log[1].to_json().contains("\"type\":\"TextInserted\"")); + // 첫 10개 문단의 첫 줄 segment_width + eprintln!("첫 10개 문단 segment_width:"); + for pi in 0..std::cmp::min(10, section.paragraphs.len()) { + let para = §ion.paragraphs[pi]; + let seg_w = para + .line_segs + .first() + .map(|ls| ls.segment_width) + .unwrap_or(0); + let text_preview: String = para.text.chars().take(30).collect(); + eprintln!(" para[{}]: seg_w={}, text={:?}", pi, seg_w, text_preview); + } } - #[test] - fn test_end_batch_returns_events_and_clears() { - let mut doc = create_editable_doc(); - - let _ = doc.begin_batch_native(); - let r = doc.insert_text_native(0, 0, 0, "Test"); - assert!(r.is_ok(), "insert failed: {:?}", r); - assert_eq!(doc.event_log.len(), 1); - - let result = doc.end_batch_native(); - assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.contains("\"ok\":true")); - assert!(json.contains("\"events\":[")); - assert!(json.contains("\"type\":\"TextInserted\"")); - - // end_batch 후 event_log 비워짐 + batch_mode 해제 - assert!(!doc.batch_mode); - assert!(doc.event_log.is_empty()); + // 편집 시뮬레이션: 구역0, 문단1, 오프셋0에 "X" 삽입 + eprintln!("\n=== 편집 시뮬레이션: insert_text_native(0, 1, 0, \"X\") ==="); + let result = doc.insert_text_native(0, 1, 0, "X"); + eprintln!("insert_text 결과: {:?}", result); + + // 편집 후 문단1의 첫 줄 segment_width 확인 + let para1 = &doc.document.sections[0].paragraphs[1]; + eprintln!("편집 후 para[1] line_segs:"); + for (i, ls) in para1.line_segs.iter().enumerate() { + eprintln!( + " line[{}]: seg_w={} text_start={} line_height={}", + i, ls.segment_width, ls.text_start, ls.line_height + ); } - #[test] - fn test_batch_multiple_edit_types() { - let mut doc = create_editable_doc(); - - let _ = doc.begin_batch_native(); - let r1 = doc.insert_text_native(0, 0, 0, "Hello World"); - assert!(r1.is_ok(), "insert failed: {:?}", r1); - let r2 = doc.delete_text_native(0, 0, 5, 6); - assert!(r2.is_ok(), "delete failed: {:?}", r2); - - assert_eq!(doc.event_log.len(), 2); - assert!(doc.event_log[0].to_json().contains("\"type\":\"TextInserted\"")); - assert!(doc.event_log[1].to_json().contains("\"type\":\"TextDeleted\"")); + // available_width 비교: 단 너비 vs 페이지 너비 + let section = &doc.document.sections[0]; + let column_def = HwpDocument::find_initial_column_def(§ion.paragraphs); + let layout = crate::renderer::page_layout::PageLayoutInfo::from_page_def( + §ion.section_def.page_def, + &column_def, + doc.dpi, + ); + let layout_single = crate::renderer::page_layout::PageLayoutInfo::from_page_def( + §ion.section_def.page_def, + &crate::model::page::ColumnDef::default(), + doc.dpi, + ); + + let col_w_hu = if !layout.column_areas.is_empty() { + crate::renderer::px_to_hwpunit(layout.column_areas[0].width, doc.dpi) + } else { + 0 + }; + let page_w_hu = if !layout_single.column_areas.is_empty() { + crate::renderer::px_to_hwpunit(layout_single.column_areas[0].width, doc.dpi) + } else { + 0 + }; + + let actual_seg_w = para1 + .line_segs + .first() + .map(|ls| ls.segment_width) + .unwrap_or(0); + eprintln!("\n=== para[1] available_width 비교 (1단 영역) ==="); + eprintln!("단 너비 (column_areas[0]): {}hu", col_w_hu); + eprintln!("페이지 전체 너비 (단일 단): {}hu", page_w_hu); + eprintln!("실제 seg_w: {}hu", actual_seg_w); + + let diff_col = (actual_seg_w as i64 - col_w_hu as i64).abs(); + let diff_page = (actual_seg_w as i64 - page_w_hu as i64).abs(); + if diff_col < diff_page { + eprintln!("→ seg_w가 단 너비에 가까움 (차이: {}hu)", diff_col); + } else { + eprintln!("→ seg_w가 페이지 너비에 가까움 (차이: {}hu)", diff_page); + } - let result = doc.end_batch_native(); - assert!(result.is_ok()); - // 종료 후 paginate 실행되므로 페이지 수 유효 - assert!(doc.page_count() >= 1); + // 2단 영역 편집 시뮬레이션: para[14] (col_idx=1, 2단 영역) + eprintln!("\n=== 2단 영역 편집: insert_text_native(0, 14, 0, \"Y\") ==="); + let col_idx_14_before = doc + .para_column_map + .first() + .and_then(|m| m.get(14)) + .copied() + .unwrap_or(0); + eprintln!("편집 전 para[14] col_idx: {}", col_idx_14_before); + + let result2 = doc.insert_text_native(0, 14, 0, "Y"); + eprintln!("insert_text 결과: {:?}", result2); + + let para14 = &doc.document.sections[0].paragraphs[14]; + eprintln!("편집 후 para[14] line_segs:"); + for (i, ls) in para14.line_segs.iter().enumerate() { + eprintln!( + " line[{}]: seg_w={} text_start={}", + i, ls.segment_width, ls.text_start + ); } - #[test] - fn test_serialize_event_log_format() { - let mut doc = create_editable_doc(); - let r = doc.insert_text_native(0, 0, 0, "A"); - assert!(r.is_ok(), "insert failed: {:?}", r); + // find_column_def_for_paragraph 결과 확인 + let cd_for_14 = + HwpDocument::find_column_def_for_paragraph(&doc.document.sections[0].paragraphs, 14); + eprintln!( + "para[14]에 적용되는 ColumnDef: count={}, same_width={}, widths={:?}", + cd_for_14.column_count, cd_for_14.same_width, cd_for_14.widths + ); + + let layout14 = crate::renderer::page_layout::PageLayoutInfo::from_page_def( + &doc.document.sections[0].section_def.page_def, + &cd_for_14, + doc.dpi, + ); + eprintln!("layout14 column_areas:"); + for (i, ca) in layout14.column_areas.iter().enumerate() { + let w_hu = crate::renderer::px_to_hwpunit(ca.width, doc.dpi); + eprintln!( + " [{}]: x={:.1}px w={:.1}px ({}hu)", + i, ca.x, ca.width, w_hu + ); + } - let json = doc.serialize_event_log(); - assert!(json.starts_with("{\"ok\":true,\"events\":[")); - assert!(json.ends_with("]}")); - assert!(json.contains("\"type\":\"TextInserted\"")); + let seg_w_14 = para14 + .line_segs + .first() + .map(|ls| ls.segment_width) + .unwrap_or(0); + let orig_seg_w_14 = 22960i32; // 편집 전 원본 seg_w + eprintln!("\n=== para[14] 결과 비교 ==="); + eprintln!("원본 seg_w: {}hu", orig_seg_w_14); + eprintln!("편집 후 seg_w: {}hu", seg_w_14); + eprintln!("페이지 전체 너비: {}hu", page_w_hu); + if (seg_w_14 - orig_seg_w_14).abs() < 1000 { + eprintln!("→ 올바름: 2단 너비로 리플로우됨"); + } else if (seg_w_14 as i64 - page_w_hu as i64).abs() < 1000 { + eprintln!("→ 오류: 1단 전체 너비로 리플로우됨!"); + } else { + eprintln!("→ 알수없는 너비: {}hu", seg_w_14); } - #[test] - fn test_find_next_editable_control_bookreview() { - let data = std::fs::read("samples/basic/BookReview.hwp") - .expect("BookReview.hwp not found"); - let doc = HwpDocument::from_bytes(&data).unwrap(); + // === 양쪽 정렬 진단: 원본 2단 문단의 LineSeg 데이터 === + eprintln!("\n=== 양쪽 정렬 진단: 2단 문단 LineSeg 분석 ==="); + // 원본 데이터 재로드 (편집 전) + let data2 = std::fs::read(path).unwrap(); + let doc2 = HwpDocument::from_bytes(&data2).unwrap(); + let section2 = &doc2.document.sections[0]; + + // 2단 영역의 모든 문단의 LineSeg column_start, segment_width 출력 + for pi in 9..std::cmp::min(20, section2.paragraphs.len()) { + let para = §ion2.paragraphs[pi]; + let text_preview: String = para.text.chars().take(40).collect(); + eprintln!("\npara[{}]: text={:?}", pi, text_preview); + eprintln!(" line_segs 수: {}", para.line_segs.len()); + for (li, ls) in para.line_segs.iter().enumerate() { + eprintln!( + " line[{}]: seg_w={} col_start={} text_start={} vpos={} line_h={} line_sp={}", + li, + ls.segment_width, + ls.column_start, + ls.text_start, + ls.vertical_pos, + ls.line_height, + ls.line_spacing + ); + } + // 문단 정렬 확인 + let ps = doc2.styles.para_styles.get(para.para_shape_id as usize); + if let Some(ps) = ps { + eprintln!(" alignment: {:?}", ps.alignment); + } + } - // Section 1, Para 0: controls 0-8 중 textbox는 ci=3,4,5,6,7,8 - // ci=3에서 앞으로 → ci=4 (textbox) - let r = doc.find_next_editable_control_native(1, 0, 3, 1); - println!("sec1 para0 ci=3 → next: {}", r); - assert!(r.contains("\"type\":\"textbox\"")); - assert!(r.contains("\"ci\":4")); - - // ci=8에서 앞으로 → 같은 문단에 더 이상 없음 → 다음 문단/섹션 - let r = doc.find_next_editable_control_native(1, 0, 8, 1); - println!("sec1 para0 ci=8 → next: {}", r); - // section 1에 paragraph가 1개뿐이므로 다음 섹션도 없음 → none - assert!(r.contains("\"type\":\"none\"")); - - // ci=3에서 뒤로 → 같은 문단에 ci=3 이전 textbox 없음 → 이전 섹션 - let r = doc.find_next_editable_control_native(1, 0, 3, -1); - println!("sec1 para0 ci=3 → prev: {}", r); - // section 0의 마지막에서 편집 가능한 위치 - assert!(r.contains("\"sec\":0")); - - // Section 0에서 앞으로: section 0의 마지막 문단에서 section 1로 이동 - let sec0_paras = doc.core.document.sections[0].paragraphs.len(); - let r = doc.find_next_editable_control_native(0, sec0_paras - 1, -1, 1); - println!("sec0 last_para body → next: {}", r); - - // ci=5에서 앞으로 → ci=6 - let r = doc.find_next_editable_control_native(1, 0, 5, 1); - println!("sec1 para0 ci=5 → next: {}", r); - assert!(r.contains("\"ci\":6")); - - // ci=6에서 앞으로 → ci=7 - let r = doc.find_next_editable_control_native(1, 0, 6, 1); - println!("sec1 para0 ci=6 → next: {}", r); - assert!(r.contains("\"ci\":7")); - - // ci=7에서 앞으로 → ci=8 - let r = doc.find_next_editable_control_native(1, 0, 7, 1); - println!("sec1 para0 ci=7 → next: {}", r); - assert!(r.contains("\"ci\":8")); - - // ci=8에서 뒤로 → ci=7 - let r = doc.find_next_editable_control_native(1, 0, 8, -1); - println!("sec1 para0 ci=8 → prev: {}", r); - assert!(r.contains("\"ci\":7")); - } - - #[test] - fn test_superscript_in_new_document() { - // 새 문서 생성 → 텍스트 입력 → 숫자 삽입 → 위첨자 적용 → 이후 글자 정상 확인 - let mut doc = HwpDocument::create_empty(); - doc.create_blank_document_native().unwrap(); - - // 1. "가나다라마바사" 입력 (실제로는 한 번에 삽입) - let _ = doc.insert_text_native(0, 0, 0, "가나다라마바사"); + // 페이지네이션 결과 확인: 2단 문단이 어떤 단에 배치되는지 + eprintln!("\n=== 페이지네이션 결과 분석 ==="); + let paginator = crate::renderer::pagination::Paginator::new(doc2.dpi); + let composed2: Vec<_> = section2 + .paragraphs + .iter() + .map(|p| crate::renderer::composer::compose_paragraph(p)) + .collect(); + // 2단 ColumnDef 찾기 (para[9]+ 영역) + let cd_for_9 = HwpDocument::find_column_def_for_paragraph(§ion2.paragraphs, 9); + eprintln!("para[9]+ ColumnDef: count={}", cd_for_9.column_count); + + // 페이지네이션 실행 (전체 섹션) + let (pag_result, measured_sec) = paginator.paginate( + §ion2.paragraphs, + &composed2, + &doc2.styles, + §ion2.section_def.page_def, + &crate::model::page::ColumnDef::default(), // 초기 ColumnDef + 0, + ); + + // 측정 높이 진단 + eprintln!("\n=== 문단별 측정 높이 (para 0~20) ==="); + let mut zone1_sum: f64 = 0.0; + for pi in 0..std::cmp::min(20, section2.paragraphs.len()) { + let h = measured_sec.get_paragraph_height(pi).unwrap_or(0.0); + let mp = measured_sec.get_measured_paragraph(pi); + let sp_b = mp.map(|m| m.spacing_before).unwrap_or(0.0); + let sp_a = mp.map(|m| m.spacing_after).unwrap_or(0.0); + let lh_sum: f64 = mp.map(|m| m.line_heights.iter().sum()).unwrap_or(0.0); + let line_ct = mp.map(|m| m.line_heights.len()).unwrap_or(0); + eprintln!( + " para[{}] h={:.2}px (sp_b={:.2} + lines({})={:.2} + sp_a={:.2})", + pi, h, sp_b, line_ct, lh_sum, sp_a + ); + if pi < 9 { + zone1_sum += h; + } + } + eprintln!(" zone1(para 0-8) sum={:.2}px", zone1_sum); + let layout1 = crate::renderer::page_layout::PageLayoutInfo::from_page_def( + §ion2.section_def.page_def, + &crate::model::page::ColumnDef::default(), + doc2.dpi, + ); + eprintln!( + "body_area.height={:.1}px, available_body_height={:.1}px", + layout1.body_area.height, + layout1.available_body_height() + ); + + for (pg_idx, page) in pag_result.pages.iter().enumerate() { + eprintln!( + "\n페이지 {} (단 수: {}):", + pg_idx, + page.column_contents.len() + ); + for col_content in &page.column_contents { + eprintln!( + " 단 {} (zone_y_offset={:.1}):", + col_content.column_index, col_content.zone_y_offset + ); + for item in &col_content.items { + match item { + crate::renderer::pagination::PageItem::FullParagraph { para_index } => { + eprintln!(" FullParagraph(para={})", para_index); + } + crate::renderer::pagination::PageItem::PartialParagraph { + para_index, + start_line, + end_line, + } => { + eprintln!( + " PartialParagraph(para={}, lines={}..{})", + para_index, start_line, end_line + ); + } + crate::renderer::pagination::PageItem::Table { + para_index, + control_index, + } => { + eprintln!(" Table(para={}, ctrl={})", para_index, control_index); + } + _ => { + eprintln!(" 기타 항목"); + } + } + } + } + } - let para = &doc.document.sections[0].paragraphs[0]; - eprintln!("Step1: text='{}' char_offsets={:?} char_shapes={:?}", - para.text, - para.char_offsets, - para.char_shapes.iter().map(|cs| (cs.start_pos, cs.char_shape_id)).collect::>(), + // 검증: 페이지 0에 1단 + 2단 존이 공존해야 함 (다단 설정 나누기) + let page0 = &pag_result.pages[0]; + let has_zone_offset = page0 + .column_contents + .iter() + .any(|cc| cc.zone_y_offset > 0.0); + assert!( + has_zone_offset, + "페이지 0에 zone_y_offset > 0인 ColumnContent가 있어야 함 (1단+2단 공존)" + ); + let has_multi_col = page0.column_contents.iter().any(|cc| cc.column_index > 0); + assert!( + has_multi_col, + "페이지 0에 column_index > 0인 ColumnContent가 있어야 함 (2단 렌더링)" + ); + + // === 페이지 1 높이 오버플로 진단 === + if pag_result.pages.len() > 1 { + let page1 = &pag_result.pages[1]; + let avail = page1.layout.available_body_height(); + eprintln!("\n=== 페이지 1 높이 오버플로 진단 ==="); + eprintln!("available_body_height={:.2}px", avail); + eprintln!( + "body_area: y={:.2}, h={:.2}, bottom={:.2}", + page1.layout.body_area.y, + page1.layout.body_area.height, + page1.layout.body_area.y + page1.layout.body_area.height ); - // 2. 위치 2에 "123" 삽입 → "가나123다라마바사" - let _ = doc.insert_text_native(0, 0, 2, "123"); + for col_content in &page1.column_contents { + eprintln!( + "\n 단 {} (zone_y_offset={:.1}):", + col_content.column_index, col_content.zone_y_offset + ); + let mut cumulative: f64 = 0.0; + for item in &col_content.items { + match item { + crate::renderer::pagination::PageItem::FullParagraph { para_index } => { + let h = measured_sec + .get_paragraph_height(*para_index) + .unwrap_or(0.0); + cumulative += h; + let mp = measured_sec.get_measured_paragraph(*para_index); + let sp_b = mp.map(|m| m.spacing_before).unwrap_or(0.0); + let sp_a = mp.map(|m| m.spacing_after).unwrap_or(0.0); + let lh_sum: f64 = mp.map(|m| m.line_heights.iter().sum()).unwrap_or(0.0); + let line_ct = mp.map(|m| m.line_heights.len()).unwrap_or(0); + eprintln!(" FullParagraph(para={}) h={:.2}px (sp_b={:.2} + lines({})={:.2} + sp_a={:.2}) cum={:.2}", + para_index, h, sp_b, line_ct, lh_sum, sp_a, cumulative); + } + crate::renderer::pagination::PageItem::PartialParagraph { + para_index, + start_line, + end_line, + } => { + let mp = measured_sec.get_measured_paragraph(*para_index); + let (part_h, sp_b, sp_a, lh_sum) = if let Some(mp) = mp { + let sp_b = if *start_line == 0 { + mp.spacing_before + } else { + 0.0 + }; + let sp_a = if *end_line >= mp.line_heights.len() { + mp.spacing_after + } else { + 0.0 + }; + let safe_s = (*start_line).min(mp.line_heights.len()); + let safe_e = (*end_line).min(mp.line_heights.len()); + let lh: f64 = mp.line_heights[safe_s..safe_e].iter().sum(); + (sp_b + lh + sp_a, sp_b, sp_a, lh) + } else { + (0.0, 0.0, 0.0, 0.0) + }; + cumulative += part_h; + eprintln!(" PartialParagraph(para={}, lines={}..{}) h={:.2}px (sp_b={:.2} + lines={:.2} + sp_a={:.2}) cum={:.2}", + para_index, start_line, end_line, part_h, sp_b, lh_sum, sp_a, cumulative); + } + crate::renderer::pagination::PageItem::Table { + para_index, + control_index, + } => { + let h = measured_sec + .get_paragraph_height(*para_index) + .unwrap_or(0.0); + cumulative += h; + eprintln!( + " Table(para={}, ctrl={}) h={:.2}px cum={:.2}", + para_index, control_index, h, cumulative + ); + } + _ => { + eprintln!(" 기타 항목"); + } + } + } + let overflow = cumulative - avail; + if overflow > 0.0 { + eprintln!( + " *** 오버플로: {:.2}px (누적 {:.2} > 가용 {:.2})", + overflow, cumulative, avail + ); + } else { + eprintln!( + " 여유: {:.2}px (누적 {:.2} <= 가용 {:.2})", + -overflow, cumulative, avail + ); + } + } + } +} + +/// 엔터키 후 저장 시 파일 손상 진단: blanK2020 원본 vs 손상 파일 비교 +#[test] +fn test_blank2020_enter_corruption_diagnosis() { + use crate::parser::cfb_reader::CfbReader; + use crate::parser::record::Record; + use crate::parser::tags; + + let files = [ + ("blanK2020 원본", "saved/blanK2020.hwp"), + ( + "blanK2020 엔터후저장(손상)", + "saved/blanK2020_enter_saved_currupt.hwp", + ), + ]; + + for (label, path) in &files { + if !std::path::Path::new(path).exists() { + eprintln!("SKIP: {} 파일 없음", path); + continue; + } - let para = &doc.document.sections[0].paragraphs[0]; - eprintln!("Step2: text='{}' char_offsets={:?} char_shapes={:?}", - para.text, - para.char_offsets, - para.char_shapes.iter().map(|cs| (cs.start_pos, cs.char_shape_id)).collect::>(), - ); + let bytes = std::fs::read(path).unwrap(); + let mut cfb = CfbReader::open(&bytes).unwrap_or_else(|_| panic!("{} CFB 열기 실패", label)); - // 3. "123" (chars 2-5)에 위첨자 적용 - let result = doc.apply_char_format_native(0, 0, 2, 5, r#"{"superscript":true}"#); - assert!(result.is_ok(), "위첨자 적용 실패: {:?}", result.err()); + eprintln!("\n{}", "=".repeat(80)); + eprintln!(" {} ({} bytes)", label, bytes.len()); - let para = &doc.document.sections[0].paragraphs[0]; - eprintln!("Step3: text='{}' char_offsets={:?} char_shapes={:?}", - para.text, - para.char_offsets, - para.char_shapes.iter().map(|cs| (cs.start_pos, cs.char_shape_id)).collect::>(), + // DocInfo + let doc_info_data = cfb.read_doc_info(true).expect("DocInfo 읽기 실패"); + let doc_recs = Record::read_all(&doc_info_data).unwrap(); + let cs_count = doc_recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_CHAR_SHAPE) + .count(); + let ps_count = doc_recs + .iter() + .filter(|r| r.tag_id == tags::HWPTAG_PARA_SHAPE) + .count(); + eprintln!( + " DocInfo: CS={} PS={} records={} bytes={}", + cs_count, + ps_count, + doc_recs.len(), + doc_info_data.len() ); - // 검증: char_shapes가 3개여야 함 (원본, 위첨자, 원본) - assert!(para.char_shapes.len() >= 3, - "char_shapes should have at least 3 segments, got {}: {:?}", - para.char_shapes.len(), - para.char_shapes.iter().map(|cs| (cs.start_pos, cs.char_shape_id)).collect::>(), + // BodyText Section0 + let body_data = cfb + .read_body_text_section(0, true, false) + .expect("BodyText 읽기 실패"); + let body_recs = Record::read_all(&body_data).unwrap(); + eprintln!( + " BodyText: {} records, {} bytes", + body_recs.len(), + body_data.len() ); - // 위첨자가 적용된 CharShape와 원본 CharShape가 다른 ID인지 확인 - let original_id = para.char_shapes[0].char_shape_id; - let superscript_id = para.char_shapes[1].char_shape_id; - assert_ne!(original_id, superscript_id, "위첨자 CharShape ID는 원본과 달라야 함"); - - // 마지막 세그먼트는 원본 ID로 복원되어야 함 - let last_id = para.char_shapes.last().unwrap().char_shape_id; - assert_eq!(last_id, original_id, "위첨자 이후 원본 ID로 복원되어야 함"); + for (i, rec) in body_recs.iter().enumerate() { + let indent = " ".repeat(rec.level as usize); + let tag_name = tags::tag_name(rec.tag_id); - // 위첨자 CharShape의 superscript 필드 확인 - let sup_cs = &doc.document.doc_info.char_shapes[superscript_id as usize]; - assert!(sup_cs.superscript, "위첨자 CharShape의 superscript가 true여야 함"); + let extra = if rec.tag_id == tags::HWPTAG_PARA_HEADER { + let cc = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); + let cm = u32::from_le_bytes([rec.data[4], rec.data[5], rec.data[6], rec.data[7]]); + let ps_id = u16::from_le_bytes([rec.data[8], rec.data[9]]); + let char_count = cc & 0x7FFFFFFF; + let msb = cc >> 31; + format!( + " cc={} msb={} cm=0x{:08X} ps={} data_len={} raw_extra={}", + char_count, + msb, + cm, + ps_id, + rec.data.len(), + rec.data + .iter() + .skip(12) + .map(|b| format!("{:02X}", b)) + .collect::>() + .join(" ") + ) + } else if rec.tag_id == tags::HWPTAG_PARA_LINE_SEG { + let mut segs_info = String::new(); + let mut pos = 0; + let mut seg_idx = 0; + while pos + 36 <= rec.data.len() { + let lh = i32::from_le_bytes([ + rec.data[pos + 8], + rec.data[pos + 9], + rec.data[pos + 10], + rec.data[pos + 11], + ]); + let th = i32::from_le_bytes([ + rec.data[pos + 12], + rec.data[pos + 13], + rec.data[pos + 14], + rec.data[pos + 15], + ]); + let sw = i32::from_le_bytes([ + rec.data[pos + 28], + rec.data[pos + 29], + rec.data[pos + 30], + rec.data[pos + 31], + ]); + let tag = u32::from_le_bytes([ + rec.data[pos + 32], + rec.data[pos + 33], + rec.data[pos + 34], + rec.data[pos + 35], + ]); + segs_info += &format!( + " [seg{}: lh={} th={} sw={} tag=0x{:08X}]", + seg_idx, lh, th, sw, tag + ); + seg_idx += 1; + pos += 36; + } + segs_info + } else if rec.tag_id == tags::HWPTAG_PARA_CHAR_SHAPE { + let mut ids = Vec::new(); + let mut pos = 0; + while pos + 8 <= rec.data.len() { + let cs_id = u32::from_le_bytes([ + rec.data[pos + 4], + rec.data[pos + 5], + rec.data[pos + 6], + rec.data[pos + 7], + ]); + ids.push(cs_id); + pos += 8; + } + format!(" cs_ids={:?}", ids) + } else if rec.tag_id == tags::HWPTAG_PARA_TEXT { + let hex: String = rec + .data + .iter() + .take(20) + .map(|b| format!("{:02X}", b)) + .collect::>() + .join(" "); + format!(" [{}]", hex) + } else { + String::new() + }; - // 원본 CharShape의 superscript 필드 확인 - let orig_cs = &doc.document.doc_info.char_shapes[original_id as usize]; - assert!(!orig_cs.superscript, "원본 CharShape의 superscript가 false여야 함"); + eprintln!( + " rec[{:3}] {}L{} {} ({}B){}", + i, + indent, + rec.level, + tag_name, + rec.data.len(), + extra + ); + } } - /// Task 227: 빈 문서에서 텍스트 입력 → 전체선택 → 복사 → End → 붙여넣기 시 - /// 새 페이지 생성 버그 재현 및 원인 분석 - #[test] - fn test_task227_blank_doc_copy_paste_bug() { - let mut doc = HwpDocument::create_empty(); - let result = doc.create_blank_document_native(); - assert!(result.is_ok(), "빈 문서 생성 실패"); + // 추가: 우리 파서로 로드 → split → export → 다시 레코드 비교 + let blank_path = "saved/blanK2020.hwp"; + if std::path::Path::new(blank_path).exists() { + eprintln!("\n{}", "=".repeat(80)); + eprintln!(" === split_at 라운드트립 테스트 ==="); + let bytes = std::fs::read(blank_path).unwrap(); + let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); + doc.convert_to_editable_native().unwrap(); - // 1. 빈 문서의 문단 수 확인 - let para_count = doc.document.sections[0].paragraphs.len(); - eprintln!("[Task227] 빈 문서 문단 수: {}", para_count); - for (i, p) in doc.document.sections[0].paragraphs.iter().enumerate() { - eprintln!(" 문단[{}]: text={:?}, chars={}, controls={}, has_para_text={}", - i, p.text, p.text.chars().count(), p.controls.len(), p.has_para_text); - } + // 원본 문단 정보 + let para = &doc.document.sections[0].paragraphs[0]; + eprintln!( + " 원본 para[0]: text='{}' cc={} raw_header_extra({} bytes): {:02x?}", + para.text, + para.char_count, + para.raw_header_extra.len(), + ¶.raw_header_extra + ); + eprintln!( + " 원본 para[0] line_segs[0].tag = 0x{:08X}", + para.line_segs.first().map(|ls| ls.tag).unwrap_or(0) + ); - // 2. 텍스트 삽입 - let result = doc.insert_text_native(0, 0, 0, "abcdefg"); - assert!(result.is_ok(), "텍스트 삽입 실패"); + // 엔터 (split at 0) + let result = doc.split_paragraph_native(0, 0, 0); + eprintln!(" split result: {:?}", result); - let para_count_after_insert = doc.document.sections[0].paragraphs.len(); - eprintln!("[Task227] 텍스트 삽입 후 문단 수: {}", para_count_after_insert); + // 분할 후 문단 정보 for (i, p) in doc.document.sections[0].paragraphs.iter().enumerate() { - eprintln!(" 문단[{}]: text={:?}, chars={}, controls={}, has_para_text={}", - i, p.text, p.text.chars().count(), p.controls.len(), p.has_para_text); + eprintln!(" split 후 para[{}]: text='{}' cc={} has_para_text={} raw_header_extra({} bytes): {:02x?}", + i, p.text, p.char_count, p.has_para_text, p.raw_header_extra.len(), &p.raw_header_extra); + if let Some(ls) = p.line_segs.first() { + eprintln!( + " line_seg: lh={} th={} bd={} sw={} tag=0x{:08X}", + ls.line_height, ls.text_height, ls.baseline_distance, ls.segment_width, ls.tag + ); + } } - // 3. 전체 선택 시뮬레이션: start=(0,0,0), end=(last_para, last_char) - let last_para = para_count_after_insert - 1; - let last_char = doc.document.sections[0].paragraphs[last_para].text.chars().count(); - eprintln!("[Task227] 전체 선택: start=(0,0), end=({},{})", last_para, last_char); - - // 4. 복사 - let result = doc.copy_selection_native(0, 0, 0, last_para, last_char); - assert!(result.is_ok(), "복사 실패: {:?}", result.err()); - let clip_text = doc.get_clipboard_text_native(); - eprintln!("[Task227] 클립보드 텍스트: {:?}", clip_text); + // export + let exported = doc.export_hwp_native().unwrap(); + eprintln!(" exported: {} bytes", exported.len()); + + // re-parse exported + let mut cfb2 = CfbReader::open(&exported).expect("재파싱 CFB 열기 실패"); + let body2 = cfb2 + .read_body_text_section(0, true, false) + .expect("재파싱 BodyText 실패"); + let recs2 = Record::read_all(&body2).unwrap(); + eprintln!( + " 재파싱 BodyText: {} records, {} bytes", + recs2.len(), + body2.len() + ); - // 클립보드 문단 수 확인 - if let Some(ref clip) = doc.clipboard { - eprintln!("[Task227] 클립보드 문단 수: {}", clip.paragraphs.len()); - for (i, p) in clip.paragraphs.iter().enumerate() { - eprintln!(" 클립[{}]: text={:?}, chars={}, controls={}", - i, p.text, p.text.chars().count(), p.controls.len()); + for (i, rec) in recs2.iter().enumerate() { + let tag_name = tags::tag_name(rec.tag_id); + if rec.tag_id == tags::HWPTAG_PARA_HEADER { + eprintln!( + " re-rec[{:3}] L{} {} ({}B) raw_extra={}", + i, + rec.level, + tag_name, + rec.data.len(), + rec.data + .iter() + .skip(12) + .map(|b| format!("{:02X}", b)) + .collect::>() + .join(" ") + ); + } else if rec.tag_id == tags::HWPTAG_PARA_LINE_SEG { + let mut pos = 0; + while pos + 36 <= rec.data.len() { + let tag = u32::from_le_bytes([ + rec.data[pos + 32], + rec.data[pos + 33], + rec.data[pos + 34], + rec.data[pos + 35], + ]); + eprintln!( + " re-rec[{:3}] L{} {} ({}B) tag=0x{:08X}", + i, + rec.level, + tag_name, + rec.data.len(), + tag + ); + pos += 36; + } } } + } +} + +/// 빈 문단에서 반복 Enter + getCursorRect 동작 검증 +#[test] +fn test_repeated_enter_on_empty_paragraph() { + let bytes = std::fs::read("saved/blank2010.hwp").expect("blank2010.hwp 읽기 실패"); + let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); + doc.convert_to_editable_native().unwrap(); + doc.paginate(); + + // 1. 텍스트 입력 + let result = doc.insert_text_native(0, 0, 0, "테스트").unwrap(); + println!("Insert: {}", result); + + // 2. 첫 번째 Enter (텍스트 끝에서) + let result1 = doc.split_paragraph_native(0, 0, 3).unwrap(); + println!("Split 1 (para=0, offset=3): {}", result1); + assert!(result1.contains("\"ok\":true")); + assert_eq!(doc.document.sections[0].paragraphs.len(), 2); + + // getCursorRect para 1, offset 0 + let rect1 = doc.get_cursor_rect_native(0, 1, 0); + println!("CursorRect(0,1,0): {:?}", rect1); + assert!( + rect1.is_ok(), + "빈 문단(para=1) 커서 실패: {:?}", + rect1.err() + ); + + // 3. 두 번째 Enter (빈 문단에서) + let result2 = doc.split_paragraph_native(0, 1, 0).unwrap(); + println!("Split 2 (para=1, offset=0): {}", result2); + assert!(result2.contains("\"ok\":true")); + assert!(result2.contains("\"paraIdx\":2")); + assert_eq!(doc.document.sections[0].paragraphs.len(), 3); + + let rect2 = doc.get_cursor_rect_native(0, 2, 0); + println!("CursorRect(0,2,0): {:?}", rect2); + assert!( + rect2.is_ok(), + "빈 문단(para=2) 커서 실패: {:?}", + rect2.err() + ); + + // 4. 세 번째 Enter + let result3 = doc.split_paragraph_native(0, 2, 0).unwrap(); + println!("Split 3 (para=2, offset=0): {}", result3); + assert!(result3.contains("\"ok\":true")); + + let rect3 = doc.get_cursor_rect_native(0, 3, 0); + println!("CursorRect(0,3,0): {:?}", rect3); + assert!( + rect3.is_ok(), + "빈 문단(para=3) 커서 실패: {:?}", + rect3.err() + ); + + // y좌표 순증 검증 + let parse_y = |json: &str| -> f64 { + let y_start = json.find("\"y\":").unwrap() + 4; + let y_end = json[y_start..] + .find(|c: char| c == ',' || c == '}') + .unwrap(); + json[y_start..y_start + y_end].parse::().unwrap() + }; + let y1 = parse_y(&rect1.unwrap()); + let y2 = parse_y(&rect2.unwrap()); + let y3 = parse_y(&rect3.unwrap()); + println!("y좌표: y1={:.1}, y2={:.1}, y3={:.1}", y1, y2, y3); + assert!(y2 > y1, "para2 y({:.1}) > para1 y({:.1})", y2, y1); + assert!(y3 > y2, "para3 y({:.1}) > para2 y({:.1})", y3, y2); +} + +/// 강제 줄바꿈(\n) 삽입 후 getCursorRect가 두 번째 줄 좌표를 반환하는지 검증 +#[test] +fn test_cursor_rect_after_line_break() { + let bytes = std::fs::read("saved/blank2010.hwp").expect("blank2010.hwp 읽기 실패"); + let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); + doc.convert_to_editable_native().unwrap(); + doc.paginate(); + + // "가나다라마바" 입력 + doc.insert_text_native(0, 0, 0, "가나다라마바").unwrap(); + + // offset 3에 \n 삽입 → "가나다\n라마바" + doc.insert_text_native(0, 0, 3, "\n").unwrap(); + + // offset 3 → \n 이전 (첫 줄) + let rect_before = doc.get_cursor_rect_native(0, 0, 3); + assert!( + rect_before.is_ok(), + "offset 3 커서 실패: {:?}", + rect_before.err() + ); + + // offset 4 → \n 이후 (두 번째 줄) + let rect_after = doc.get_cursor_rect_native(0, 0, 4); + assert!( + rect_after.is_ok(), + "offset 4 커서 실패: {:?}", + rect_after.err() + ); + + let parse_y = |json: &str| -> f64 { + let y_start = json.find("\"y\":").unwrap() + 4; + let y_end = json[y_start..] + .find(|c: char| c == ',' || c == '}') + .unwrap(); + json[y_start..y_start + y_end].parse::().unwrap() + }; + let y_before = parse_y(&rect_before.unwrap()); + let y_after = parse_y(&rect_after.unwrap()); + assert!( + y_after > y_before, + "줄바꿈 후 커서 y({:.1})가 줄바꿈 전 y({:.1})보다 커야 함", + y_after, + y_before + ); +} + +/// 텍스트 끝에 \n 삽입 후 빈 두 번째 줄에서 getCursorRect 검증 +#[test] +fn test_cursor_rect_after_line_break_at_end() { + let bytes = std::fs::read("saved/blank2010.hwp").expect("blank2010.hwp 읽기 실패"); + let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); + doc.convert_to_editable_native().unwrap(); + doc.paginate(); + + // "가나다라" 입력 후 끝에 \n 삽입 → "가나다라\n" + doc.insert_text_native(0, 0, 0, "가나다라").unwrap(); + doc.insert_text_native(0, 0, 4, "\n").unwrap(); + + let para = &doc.document.sections[0].paragraphs[0]; + assert!(para.line_segs.len() >= 2, "line_segs가 2개 이상이어야 함"); + + // composed lines 순서 검증: 첫 줄=텍스트, 둘째 줄=빈 줄 + let comp = &doc.composed[0][0]; + assert_eq!(comp.lines.len(), 2); + assert!( + comp.lines[0].has_line_break, + "첫 줄에 line_break 플래그 있어야 함" + ); + assert_eq!(comp.lines[1].runs.len(), 0, "둘째 줄은 빈 줄이어야 함"); + + // offset 4 → \n 위치 (첫 줄 끝) + let rect_at_newline = doc.get_cursor_rect_native(0, 0, 4); + assert!(rect_at_newline.is_ok()); + + // offset 5 → \n 직후, 빈 두 번째 줄 + let rect_after = doc.get_cursor_rect_native(0, 0, 5); + assert!( + rect_after.is_ok(), + "빈 줄 offset 5 커서 실패: {:?}", + rect_after.err() + ); + + let parse_y = |json: &str| -> f64 { + let y_start = json.find("\"y\":").unwrap() + 4; + let y_end = json[y_start..] + .find(|c: char| c == ',' || c == '}') + .unwrap(); + json[y_start..y_start + y_end].parse::().unwrap() + }; + let y_newline = parse_y(&rect_at_newline.unwrap()); + let y_after = parse_y(&rect_after.unwrap()); + assert!( + y_after > y_newline, + "빈 줄 커서 y({:.1})가 첫 줄 y({:.1})보다 커야 함", + y_after, + y_newline + ); +} + +// ── Event Sourcing + Batch Mode 테스트 ── + +/// 편집 가능한 빈 문서 생성 헬퍼 (blank 템플릿 기반) +fn create_editable_doc() -> HwpDocument { + let mut doc = HwpDocument::create_empty(); + doc.create_blank_document_native().unwrap(); + doc +} + +#[test] +fn test_single_command_emits_event() { + let mut doc = create_editable_doc(); + assert!(doc.event_log.is_empty()); + + let result = doc.insert_text_native(0, 0, 0, "Hello"); + assert!(result.is_ok(), "insert_text_native failed: {:?}", result); + assert_eq!(doc.event_log.len(), 1); + + let json = doc.event_log[0].to_json(); + assert!(json.contains("\"type\":\"TextInserted\"")); + assert!(json.contains("\"section\":0")); + assert!(json.contains("\"para\":0")); + assert!(json.contains("\"offset\":0")); + assert!(json.contains("\"len\":5")); +} + +#[test] +fn test_batch_mode_events_collected() { + let mut doc = create_editable_doc(); + + let r = doc.begin_batch_native(); + assert!(r.is_ok()); + assert!(doc.batch_mode); + assert!(doc.event_log.is_empty()); + + // Batch 중 여러 편집 + let r1 = doc.insert_text_native(0, 0, 0, "Hello"); + assert!(r1.is_ok(), "1st insert failed: {:?}", r1); + let r2 = doc.insert_text_native(0, 0, 5, " World"); + assert!(r2.is_ok(), "2nd insert failed: {:?}", r2); + + assert_eq!(doc.event_log.len(), 2); + assert!(doc.event_log[0] + .to_json() + .contains("\"type\":\"TextInserted\"")); + assert!(doc.event_log[1] + .to_json() + .contains("\"type\":\"TextInserted\"")); +} + +#[test] +fn test_end_batch_returns_events_and_clears() { + let mut doc = create_editable_doc(); + + let _ = doc.begin_batch_native(); + let r = doc.insert_text_native(0, 0, 0, "Test"); + assert!(r.is_ok(), "insert failed: {:?}", r); + assert_eq!(doc.event_log.len(), 1); + + let result = doc.end_batch_native(); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"ok\":true")); + assert!(json.contains("\"events\":[")); + assert!(json.contains("\"type\":\"TextInserted\"")); + + // end_batch 후 event_log 비워짐 + batch_mode 해제 + assert!(!doc.batch_mode); + assert!(doc.event_log.is_empty()); +} + +#[test] +fn test_batch_multiple_edit_types() { + let mut doc = create_editable_doc(); + + let _ = doc.begin_batch_native(); + let r1 = doc.insert_text_native(0, 0, 0, "Hello World"); + assert!(r1.is_ok(), "insert failed: {:?}", r1); + let r2 = doc.delete_text_native(0, 0, 5, 6); + assert!(r2.is_ok(), "delete failed: {:?}", r2); + + assert_eq!(doc.event_log.len(), 2); + assert!(doc.event_log[0] + .to_json() + .contains("\"type\":\"TextInserted\"")); + assert!(doc.event_log[1] + .to_json() + .contains("\"type\":\"TextDeleted\"")); + + let result = doc.end_batch_native(); + assert!(result.is_ok()); + // 종료 후 paginate 실행되므로 페이지 수 유효 + assert!(doc.page_count() >= 1); +} + +#[test] +fn test_serialize_event_log_format() { + let mut doc = create_editable_doc(); + let r = doc.insert_text_native(0, 0, 0, "A"); + assert!(r.is_ok(), "insert failed: {:?}", r); + + let json = doc.serialize_event_log(); + assert!(json.starts_with("{\"ok\":true,\"events\":[")); + assert!(json.ends_with("]}")); + assert!(json.contains("\"type\":\"TextInserted\"")); +} + +#[test] +fn test_find_next_editable_control_bookreview() { + let data = std::fs::read("samples/basic/BookReview.hwp").expect("BookReview.hwp not found"); + let doc = HwpDocument::from_bytes(&data).unwrap(); + + // Section 1, Para 0: controls 0-8 중 textbox는 ci=3,4,5,6,7,8 + // ci=3에서 앞으로 → ci=4 (textbox) + let r = doc.find_next_editable_control_native(1, 0, 3, 1); + println!("sec1 para0 ci=3 → next: {}", r); + assert!(r.contains("\"type\":\"textbox\"")); + assert!(r.contains("\"ci\":4")); + + // ci=8에서 앞으로 → 같은 문단에 더 이상 없음 → 다음 문단/섹션 + let r = doc.find_next_editable_control_native(1, 0, 8, 1); + println!("sec1 para0 ci=8 → next: {}", r); + // section 1에 paragraph가 1개뿐이므로 다음 섹션도 없음 → none + assert!(r.contains("\"type\":\"none\"")); + + // ci=3에서 뒤로 → 같은 문단에 ci=3 이전 textbox 없음 → 이전 섹션 + let r = doc.find_next_editable_control_native(1, 0, 3, -1); + println!("sec1 para0 ci=3 → prev: {}", r); + // section 0의 마지막에서 편집 가능한 위치 + assert!(r.contains("\"sec\":0")); + + // Section 0에서 앞으로: section 0의 마지막 문단에서 section 1로 이동 + let sec0_paras = doc.core.document.sections[0].paragraphs.len(); + let r = doc.find_next_editable_control_native(0, sec0_paras - 1, -1, 1); + println!("sec0 last_para body → next: {}", r); + + // ci=5에서 앞으로 → ci=6 + let r = doc.find_next_editable_control_native(1, 0, 5, 1); + println!("sec1 para0 ci=5 → next: {}", r); + assert!(r.contains("\"ci\":6")); + + // ci=6에서 앞으로 → ci=7 + let r = doc.find_next_editable_control_native(1, 0, 6, 1); + println!("sec1 para0 ci=6 → next: {}", r); + assert!(r.contains("\"ci\":7")); + + // ci=7에서 앞으로 → ci=8 + let r = doc.find_next_editable_control_native(1, 0, 7, 1); + println!("sec1 para0 ci=7 → next: {}", r); + assert!(r.contains("\"ci\":8")); + + // ci=8에서 뒤로 → ci=7 + let r = doc.find_next_editable_control_native(1, 0, 8, -1); + println!("sec1 para0 ci=8 → prev: {}", r); + assert!(r.contains("\"ci\":7")); +} + +#[test] +fn test_superscript_in_new_document() { + // 새 문서 생성 → 텍스트 입력 → 숫자 삽입 → 위첨자 적용 → 이후 글자 정상 확인 + let mut doc = HwpDocument::create_empty(); + doc.create_blank_document_native().unwrap(); + + // 1. "가나다라마바사" 입력 (실제로는 한 번에 삽입) + let _ = doc.insert_text_native(0, 0, 0, "가나다라마바사"); + + let para = &doc.document.sections[0].paragraphs[0]; + eprintln!( + "Step1: text='{}' char_offsets={:?} char_shapes={:?}", + para.text, + para.char_offsets, + para.char_shapes + .iter() + .map(|cs| (cs.start_pos, cs.char_shape_id)) + .collect::>(), + ); + + // 2. 위치 2에 "123" 삽입 → "가나123다라마바사" + let _ = doc.insert_text_native(0, 0, 2, "123"); + + let para = &doc.document.sections[0].paragraphs[0]; + eprintln!( + "Step2: text='{}' char_offsets={:?} char_shapes={:?}", + para.text, + para.char_offsets, + para.char_shapes + .iter() + .map(|cs| (cs.start_pos, cs.char_shape_id)) + .collect::>(), + ); + + // 3. "123" (chars 2-5)에 위첨자 적용 + let result = doc.apply_char_format_native(0, 0, 2, 5, r#"{"superscript":true}"#); + assert!(result.is_ok(), "위첨자 적용 실패: {:?}", result.err()); + + let para = &doc.document.sections[0].paragraphs[0]; + eprintln!( + "Step3: text='{}' char_offsets={:?} char_shapes={:?}", + para.text, + para.char_offsets, + para.char_shapes + .iter() + .map(|cs| (cs.start_pos, cs.char_shape_id)) + .collect::>(), + ); + + // 검증: char_shapes가 3개여야 함 (원본, 위첨자, 원본) + assert!( + para.char_shapes.len() >= 3, + "char_shapes should have at least 3 segments, got {}: {:?}", + para.char_shapes.len(), + para.char_shapes + .iter() + .map(|cs| (cs.start_pos, cs.char_shape_id)) + .collect::>(), + ); + + // 위첨자가 적용된 CharShape와 원본 CharShape가 다른 ID인지 확인 + let original_id = para.char_shapes[0].char_shape_id; + let superscript_id = para.char_shapes[1].char_shape_id; + assert_ne!( + original_id, superscript_id, + "위첨자 CharShape ID는 원본과 달라야 함" + ); + + // 마지막 세그먼트는 원본 ID로 복원되어야 함 + let last_id = para.char_shapes.last().unwrap().char_shape_id; + assert_eq!(last_id, original_id, "위첨자 이후 원본 ID로 복원되어야 함"); + + // 위첨자 CharShape의 superscript 필드 확인 + let sup_cs = &doc.document.doc_info.char_shapes[superscript_id as usize]; + assert!( + sup_cs.superscript, + "위첨자 CharShape의 superscript가 true여야 함" + ); + + // 원본 CharShape의 superscript 필드 확인 + let orig_cs = &doc.document.doc_info.char_shapes[original_id as usize]; + assert!( + !orig_cs.superscript, + "원본 CharShape의 superscript가 false여야 함" + ); +} + +/// Task 227: 빈 문서에서 텍스트 입력 → 전체선택 → 복사 → End → 붙여넣기 시 +/// 새 페이지 생성 버그 재현 및 원인 분석 +#[test] +fn test_task227_blank_doc_copy_paste_bug() { + let mut doc = HwpDocument::create_empty(); + let result = doc.create_blank_document_native(); + assert!(result.is_ok(), "빈 문서 생성 실패"); + + // 1. 빈 문서의 문단 수 확인 + let para_count = doc.document.sections[0].paragraphs.len(); + eprintln!("[Task227] 빈 문서 문단 수: {}", para_count); + for (i, p) in doc.document.sections[0].paragraphs.iter().enumerate() { + eprintln!( + " 문단[{}]: text={:?}, chars={}, controls={}, has_para_text={}", + i, + p.text, + p.text.chars().count(), + p.controls.len(), + p.has_para_text + ); + } - // 5. End 키 시뮬레이션: 커서를 문단 0의 텍스트 끝으로 이동 - // (원래 커서는 문단 0, offset 7 = "abcdefg" 끝) - let paste_offset = doc.document.sections[0].paragraphs[0].text.chars().count(); - eprintln!("[Task227] 붙여넣기 위치: para=0, offset={}", paste_offset); - - // 6. 붙여넣기 - let result = doc.paste_internal_native(0, 0, paste_offset); - assert!(result.is_ok(), "붙여넣기 실패: {:?}", result.err()); - let json = result.unwrap(); - eprintln!("[Task227] 붙여넣기 결과: {}", json); + // 2. 텍스트 삽입 + let result = doc.insert_text_native(0, 0, 0, "abcdefg"); + assert!(result.is_ok(), "텍스트 삽입 실패"); + + let para_count_after_insert = doc.document.sections[0].paragraphs.len(); + eprintln!( + "[Task227] 텍스트 삽입 후 문단 수: {}", + para_count_after_insert + ); + for (i, p) in doc.document.sections[0].paragraphs.iter().enumerate() { + eprintln!( + " 문단[{}]: text={:?}, chars={}, controls={}, has_para_text={}", + i, + p.text, + p.text.chars().count(), + p.controls.len(), + p.has_para_text + ); + } - // 7. 결과 확인 - let final_para_count = doc.document.sections[0].paragraphs.len(); - eprintln!("[Task227] 붙여넣기 후 문단 수: {}", final_para_count); - for (i, p) in doc.document.sections[0].paragraphs.iter().enumerate() { - eprintln!(" 문단[{}]: text={:?}, chars={}", i, p.text, p.text.chars().count()); + // 3. 전체 선택 시뮬레이션: start=(0,0,0), end=(last_para, last_char) + let last_para = para_count_after_insert - 1; + let last_char = doc.document.sections[0].paragraphs[last_para] + .text + .chars() + .count(); + eprintln!( + "[Task227] 전체 선택: start=(0,0), end=({},{})", + last_para, last_char + ); + + // 4. 복사 + let result = doc.copy_selection_native(0, 0, 0, last_para, last_char); + assert!(result.is_ok(), "복사 실패: {:?}", result.err()); + let clip_text = doc.get_clipboard_text_native(); + eprintln!("[Task227] 클립보드 텍스트: {:?}", clip_text); + + // 클립보드 문단 수 확인 + if let Some(ref clip) = doc.clipboard { + eprintln!("[Task227] 클립보드 문단 수: {}", clip.paragraphs.len()); + for (i, p) in clip.paragraphs.iter().enumerate() { + eprintln!( + " 클립[{}]: text={:?}, chars={}, controls={}", + i, + p.text, + p.text.chars().count(), + p.controls.len() + ); } - - let page_count = doc.page_count(); - eprintln!("[Task227] 붙여넣기 후 페이지 수: {}", page_count); - - // 기대: 1개 문단, 1 페이지 - assert_eq!(final_para_count, 1, "문단 수가 1이어야 함 (실제: {})", final_para_count); - assert_eq!(page_count, 1, "페이지 수가 1이어야 함 (실제: {})", page_count); } - /// Task 228: h-pen-01.hwp 형광펜 데이터 분석 - #[test] - fn test_task228_highlight_data_analysis() { - let data = std::fs::read("samples/h-pen-01.hwp").expect("파일 읽기 실패"); - let doc = crate::parser::parse_hwp(&data).expect("파싱 실패"); + // 5. End 키 시뮬레이션: 커서를 문단 0의 텍스트 끝으로 이동 + // (원래 커서는 문단 0, offset 7 = "abcdefg" 끝) + let paste_offset = doc.document.sections[0].paragraphs[0].text.chars().count(); + eprintln!("[Task227] 붙여넣기 위치: para=0, offset={}", paste_offset); + + // 6. 붙여넣기 + let result = doc.paste_internal_native(0, 0, paste_offset); + assert!(result.is_ok(), "붙여넣기 실패: {:?}", result.err()); + let json = result.unwrap(); + eprintln!("[Task227] 붙여넣기 결과: {}", json); + + // 7. 결과 확인 + let final_para_count = doc.document.sections[0].paragraphs.len(); + eprintln!("[Task227] 붙여넣기 후 문단 수: {}", final_para_count); + for (i, p) in doc.document.sections[0].paragraphs.iter().enumerate() { + eprintln!( + " 문단[{}]: text={:?}, chars={}", + i, + p.text, + p.text.chars().count() + ); + } - // CharShape shade_color 확인 - eprintln!("[Task228] CharShape 수: {}", doc.doc_info.char_shapes.len()); - for (i, cs) in doc.doc_info.char_shapes.iter().enumerate() { - eprintln!(" CS[{}]: shade_color=0x{:06X}", i, cs.shade_color); - } + let page_count = doc.page_count(); + eprintln!("[Task227] 붙여넣기 후 페이지 수: {}", page_count); + + // 기대: 1개 문단, 1 페이지 + assert_eq!( + final_para_count, 1, + "문단 수가 1이어야 함 (실제: {})", + final_para_count + ); + assert_eq!( + page_count, 1, + "페이지 수가 1이어야 함 (실제: {})", + page_count + ); +} + +/// Task 228: h-pen-01.hwp 형광펜 데이터 분석 +#[test] +fn test_task228_highlight_data_analysis() { + let data = std::fs::read("samples/h-pen-01.hwp").expect("파일 읽기 실패"); + let doc = crate::parser::parse_hwp(&data).expect("파싱 실패"); + + // CharShape shade_color 확인 + eprintln!("[Task228] CharShape 수: {}", doc.doc_info.char_shapes.len()); + for (i, cs) in doc.doc_info.char_shapes.iter().enumerate() { + eprintln!(" CS[{}]: shade_color=0x{:06X}", i, cs.shade_color); + } - // 문단별 range_tags 확인 - for (si, section) in doc.sections.iter().enumerate() { - for (pi, para) in section.paragraphs.iter().enumerate() { - if !para.range_tags.is_empty() { - eprintln!("[Task228] 구역[{}] 문단[{}] range_tags:", si, pi); - for rt in ¶.range_tags { - let tag_type = (rt.tag >> 24) & 0xFF; - let tag_data = rt.tag & 0x00FFFFFF; - eprintln!(" start={}, end={}, tag=0x{:08X} (type={}, data=0x{:06X})", - rt.start, rt.end, rt.tag, tag_type, tag_data); - } - } - // char_shapes 참조 확인 - for csr in ¶.char_shapes { - let cs_id = csr.char_shape_id as usize; - if cs_id < doc.doc_info.char_shapes.len() { - let sc = doc.doc_info.char_shapes[cs_id].shade_color; - if sc != 0xFFFFFF && sc != 0x00FFFFFF { - eprintln!(" 문단[{}] char_shape_ref: start={}, cs_id={}, shade_color=0x{:06X}", - pi, csr.start_pos, cs_id, sc); - } + // 문단별 range_tags 확인 + for (si, section) in doc.sections.iter().enumerate() { + for (pi, para) in section.paragraphs.iter().enumerate() { + if !para.range_tags.is_empty() { + eprintln!("[Task228] 구역[{}] 문단[{}] range_tags:", si, pi); + for rt in ¶.range_tags { + let tag_type = (rt.tag >> 24) & 0xFF; + let tag_data = rt.tag & 0x00FFFFFF; + eprintln!( + " start={}, end={}, tag=0x{:08X} (type={}, data=0x{:06X})", + rt.start, rt.end, rt.tag, tag_type, tag_data + ); + } + } + // char_shapes 참조 확인 + for csr in ¶.char_shapes { + let cs_id = csr.char_shape_id as usize; + if cs_id < doc.doc_info.char_shapes.len() { + let sc = doc.doc_info.char_shapes[cs_id].shade_color; + if sc != 0xFFFFFF && sc != 0x00FFFFFF { + eprintln!( + " 문단[{}] char_shape_ref: start={}, cs_id={}, shade_color=0x{:06X}", + pi, csr.start_pos, cs_id, sc + ); } } } } } - - /// Task 228: 형광펜 렌더링 - 페이지 트리에 Rectangle 노드 확인 - #[test] - fn test_task228_highlight_render_tree() { - let data = std::fs::read("samples/h-pen-01.hwp").expect("파일 읽기 실패"); - let mut doc = crate::DocumentCore::from_bytes(&data).expect("파싱 실패"); - let svg = doc.render_page_svg_native(0).expect("SVG 렌더링 실패"); - // 형광펜 사각형 색상이 SVG에 포함되어야 함 - assert!(svg.contains("#ad71a1"), "2번째 문단 형광펜 색상(#ad71a1)이 SVG에 없음"); - assert!(svg.contains("#ffff65"), "3번째 문단 형광펜 색상(#ffff65)이 SVG에 없음"); - eprintln!("[Task228 RenderTree] SVG에 형광펜 색상 확인됨"); - } - - /// Task 229: field-01.hwp 필드 컨트롤 파싱 분석 - #[test] - fn test_task229_field_parsing() { - use crate::model::control::{Control, FieldType}; - - let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); - let doc = crate::parser::parse_hwp(&data).expect("파싱 실패"); - - let mut field_count = 0; - let mut unknown_count = 0; - - for (si, section) in doc.sections.iter().enumerate() { - for (pi, para) in section.paragraphs.iter().enumerate() { - for (ci, ctrl) in para.controls.iter().enumerate() { - match ctrl { - Control::Field(f) => { - field_count += 1; - eprintln!( +} + +/// Task 228: 형광펜 렌더링 - 페이지 트리에 Rectangle 노드 확인 +#[test] +fn test_task228_highlight_render_tree() { + let data = std::fs::read("samples/h-pen-01.hwp").expect("파일 읽기 실패"); + let mut doc = crate::DocumentCore::from_bytes(&data).expect("파싱 실패"); + let svg = doc.render_page_svg_native(0).expect("SVG 렌더링 실패"); + // 형광펜 사각형 색상이 SVG에 포함되어야 함 + assert!( + svg.contains("#ad71a1"), + "2번째 문단 형광펜 색상(#ad71a1)이 SVG에 없음" + ); + assert!( + svg.contains("#ffff65"), + "3번째 문단 형광펜 색상(#ffff65)이 SVG에 없음" + ); + eprintln!("[Task228 RenderTree] SVG에 형광펜 색상 확인됨"); +} + +/// Task 229: field-01.hwp 필드 컨트롤 파싱 분석 +#[test] +fn test_task229_field_parsing() { + use crate::model::control::{Control, FieldType}; + + let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); + let doc = crate::parser::parse_hwp(&data).expect("파싱 실패"); + + let mut field_count = 0; + let mut unknown_count = 0; + + for (si, section) in doc.sections.iter().enumerate() { + for (pi, para) in section.paragraphs.iter().enumerate() { + for (ci, ctrl) in para.controls.iter().enumerate() { + match ctrl { + Control::Field(f) => { + field_count += 1; + eprintln!( "[Task229] 구역[{}] 문단[{}] 컨트롤[{}]: Field type={:?}, command=\"{}\", id={}, props=0x{:08X}", si, pi, ci, f.field_type, f.command, f.field_id, f.properties ); - } - Control::Unknown(u) => { - let id_bytes = u.ctrl_id.to_be_bytes(); - if id_bytes[0] == b'%' { - unknown_count += 1; - eprintln!( + } + Control::Unknown(u) => { + let id_bytes = u.ctrl_id.to_be_bytes(); + if id_bytes[0] == b'%' { + unknown_count += 1; + eprintln!( "[Task229] 구역[{}] 문단[{}] 컨트롤[{}]: Unknown 필드 ctrl_id=0x{:08X} ({})", si, pi, ci, u.ctrl_id, String::from_utf8_lossy(&id_bytes) ); - } } - _ => {} } + _ => {} } } } + } - eprintln!("[Task229] 총 필드: {}, Unknown 필드: {}", field_count, unknown_count); - assert!(field_count > 0, "필드 컨트롤이 파싱되어야 함"); - assert_eq!(unknown_count, 0, "모든 필드가 파싱되어야 함 (Unknown 없어야 함)"); - - // 필드 범위 추적 검증 - let mut total_field_ranges = 0; - for (si, section) in doc.sections.iter().enumerate() { - for (pi, para) in section.paragraphs.iter().enumerate() { - if !para.field_ranges.is_empty() { - eprintln!("[Task229] 구역[{}] 문단[{}] text=\"{}\" (len={})", si, pi, para.text, para.text.chars().count()); - } - for fr in ¶.field_ranges { - total_field_ranges += 1; - let field_text: String = para.text.chars() - .skip(fr.start_char_idx) - .take(fr.end_char_idx - fr.start_char_idx) - .collect(); - let field_type = match ¶.controls[fr.control_idx] { - Control::Field(f) => format!("{:?}", f.field_type), - _ => "N/A".to_string(), - }; - eprintln!( + eprintln!( + "[Task229] 총 필드: {}, Unknown 필드: {}", + field_count, unknown_count + ); + assert!(field_count > 0, "필드 컨트롤이 파싱되어야 함"); + assert_eq!( + unknown_count, 0, + "모든 필드가 파싱되어야 함 (Unknown 없어야 함)" + ); + + // 필드 범위 추적 검증 + let mut total_field_ranges = 0; + for (si, section) in doc.sections.iter().enumerate() { + for (pi, para) in section.paragraphs.iter().enumerate() { + if !para.field_ranges.is_empty() { + eprintln!( + "[Task229] 구역[{}] 문단[{}] text=\"{}\" (len={})", + si, + pi, + para.text, + para.text.chars().count() + ); + } + for fr in ¶.field_ranges { + total_field_ranges += 1; + let field_text: String = para + .text + .chars() + .skip(fr.start_char_idx) + .take(fr.end_char_idx - fr.start_char_idx) + .collect(); + let field_type = match ¶.controls[fr.control_idx] { + Control::Field(f) => format!("{:?}", f.field_type), + _ => "N/A".to_string(), + }; + eprintln!( "[Task229] 구역[{}] 문단[{}] field_range: chars[{}..{}] ctrl[{}] type={} text=\"{}\"", si, pi, fr.start_char_idx, fr.end_char_idx, fr.control_idx, field_type, field_text ); - } } } - eprintln!("[Task229] 총 필드 범위: {}", total_field_ranges); - assert_eq!(total_field_ranges, field_count, "필드 수와 필드 범위 수가 일치해야 함"); } + eprintln!("[Task229] 총 필드 범위: {}", total_field_ranges); + assert_eq!( + total_field_ranges, field_count, + "필드 수와 필드 범위 수가 일치해야 함" + ); +} + +#[test] +fn test_task229_field_roundtrip() { + use crate::model::control::{Control, FieldType}; + + // 원본 파싱 + let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); + let doc1 = crate::parser::parse_hwp(&data).expect("파싱 실패"); + + // 직렬화 → 재파싱 + let saved = crate::serializer::serialize_hwp(&doc1).expect("직렬화 실패"); + let doc2 = crate::parser::parse_hwp(&saved).expect("재파싱 실패"); + + // 필드 컨트롤 비교 + let fields1: Vec<_> = doc1 + .sections + .iter() + .flat_map(|s| &s.paragraphs) + .flat_map(|p| p.controls.iter()) + .filter_map(|c| { + if let Control::Field(f) = c { + Some(f) + } else { + None + } + }) + .collect(); + let fields2: Vec<_> = doc2 + .sections + .iter() + .flat_map(|s| &s.paragraphs) + .flat_map(|p| p.controls.iter()) + .filter_map(|c| { + if let Control::Field(f) = c { + Some(f) + } else { + None + } + }) + .collect(); - #[test] - fn test_task229_field_roundtrip() { - use crate::model::control::{Control, FieldType}; - - // 원본 파싱 - let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); - let doc1 = crate::parser::parse_hwp(&data).expect("파싱 실패"); - - // 직렬화 → 재파싱 - let saved = crate::serializer::serialize_hwp(&doc1).expect("직렬화 실패"); - let doc2 = crate::parser::parse_hwp(&saved).expect("재파싱 실패"); - - // 필드 컨트롤 비교 - let fields1: Vec<_> = doc1.sections.iter().flat_map(|s| &s.paragraphs) - .flat_map(|p| p.controls.iter()) - .filter_map(|c| if let Control::Field(f) = c { Some(f) } else { None }) - .collect(); - let fields2: Vec<_> = doc2.sections.iter().flat_map(|s| &s.paragraphs) - .flat_map(|p| p.controls.iter()) - .filter_map(|c| if let Control::Field(f) = c { Some(f) } else { None }) - .collect(); - - assert_eq!(fields1.len(), fields2.len(), "필드 수 불일치"); - for (i, (f1, f2)) in fields1.iter().zip(fields2.iter()).enumerate() { - assert_eq!(f1.field_type, f2.field_type, "필드[{}] 타입 불일치", i); - assert_eq!(f1.ctrl_id, f2.ctrl_id, "필드[{}] ctrl_id 불일치", i); - } + assert_eq!(fields1.len(), fields2.len(), "필드 수 불일치"); + for (i, (f1, f2)) in fields1.iter().zip(fields2.iter()).enumerate() { + assert_eq!(f1.field_type, f2.field_type, "필드[{}] 타입 불일치", i); + assert_eq!(f1.ctrl_id, f2.ctrl_id, "필드[{}] ctrl_id 불일치", i); } +} - #[test] - fn test_task229_field_svg_guide_text() { - use crate::model::control::Control; +#[test] +fn test_task229_field_svg_guide_text() { + use crate::model::control::Control; - let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); - let doc = crate::parser::parse_hwp(&data).expect("파싱 실패"); + let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); + let doc = crate::parser::parse_hwp(&data).expect("파싱 실패"); - // 글상자(Shape) 내 ClickHere 필드 검증 - let mut shape_field_count = 0usize; - for sec in &doc.sections { - for para in &sec.paragraphs { - for ctrl in ¶.controls { - if let Control::Shape(s) = ctrl { - if let Some(drawing) = s.drawing() { - if let Some(tb) = &drawing.text_box { - for tb_para in &tb.paragraphs { - shape_field_count += tb_para.field_ranges.len(); - } + // 글상자(Shape) 내 ClickHere 필드 검증 + let mut shape_field_count = 0usize; + for sec in &doc.sections { + for para in &sec.paragraphs { + for ctrl in ¶.controls { + if let Control::Shape(s) = ctrl { + if let Some(drawing) = s.drawing() { + if let Some(tb) = &drawing.text_box { + for tb_para in &tb.paragraphs { + shape_field_count += tb_para.field_ranges.len(); } } } } } } - assert!(shape_field_count >= 5, "글상자 내 필드가 5개 이상이어야 함 (실제: {})", shape_field_count); - - let mut hwp_doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); - let svg = hwp_doc.render_page_svg_native(0).expect("SVG 렌더링 실패"); - - // SVG에 안내문 텍스트가 빨간색 기울임체로 렌더링되는지 확인 - assert!(svg.contains("ff0000"), "SVG에 빨간색(#ff0000) 텍스트가 있어야 함"); - assert!(svg.contains("italic"), "SVG에 기울임체 텍스트가 있어야 함"); - assert!(svg.contains(">여"), "SVG에 '여' 글자가 있어야 함"); - assert!(svg.contains(">입"), "SVG에 '입' 글자가 있어야 함"); } - - // ─── Task 230: 필드 WASM API 테스트 ───────────────────────── - - #[test] - fn test_task230_get_field_list() { - let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); - let hwp_doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); - - let json = hwp_doc.get_field_list_json(); - eprintln!("[Task230] getFieldList: {}", json); - - // JSON 배열이어야 함 - assert!(json.starts_with('[') && json.ends_with(']'), "JSON 배열이어야 함"); - // 최소 6개 필드 (본문 5 + 글상자 내 5 + 기타) - let field_count = json.matches("\"fieldId\"").count(); - assert!(field_count >= 6, "필드가 6개 이상이어야 함 (실제: {})", field_count); - // ClickHere 필드 포함 확인 - assert!(json.contains("\"clickhere\""), "ClickHere 필드가 있어야 함"); + assert!( + shape_field_count >= 5, + "글상자 내 필드가 5개 이상이어야 함 (실제: {})", + shape_field_count + ); + + let mut hwp_doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); + let svg = hwp_doc.render_page_svg_native(0).expect("SVG 렌더링 실패"); + + // SVG에 안내문 텍스트가 빨간색 기울임체로 렌더링되는지 확인 + assert!( + svg.contains("ff0000"), + "SVG에 빨간색(#ff0000) 텍스트가 있어야 함" + ); + assert!(svg.contains("italic"), "SVG에 기울임체 텍스트가 있어야 함"); + assert!(svg.contains(">여"), "SVG에 '여' 글자가 있어야 함"); + assert!(svg.contains(">입"), "SVG에 '입' 글자가 있어야 함"); +} + +// ─── Task 230: 필드 WASM API 테스트 ───────────────────────── + +#[test] +fn test_task230_get_field_list() { + let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); + let hwp_doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); + + let json = hwp_doc.get_field_list_json(); + eprintln!("[Task230] getFieldList: {}", json); + + // JSON 배열이어야 함 + assert!( + json.starts_with('[') && json.ends_with(']'), + "JSON 배열이어야 함" + ); + // 최소 6개 필드 (본문 5 + 글상자 내 5 + 기타) + let field_count = json.matches("\"fieldId\"").count(); + assert!( + field_count >= 6, + "필드가 6개 이상이어야 함 (실제: {})", + field_count + ); + // ClickHere 필드 포함 확인 + assert!(json.contains("\"clickhere\""), "ClickHere 필드가 있어야 함"); +} + +#[test] +fn test_task230_get_field_value() { + let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); + let hwp_doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); + + // 필드 목록에서 첫 번째 필드 ID 추출 + let json = hwp_doc.get_field_list_json(); + let fields = hwp_doc.collect_all_fields(); + assert!(!fields.is_empty(), "필드가 있어야 함"); + + let first_field = &fields[0]; + eprintln!( + "[Task230] 첫 번째 필드: id={}, type={:?}, name={:?}, value='{}'", + first_field.field.field_id, + first_field.field.field_type, + first_field.field.field_name(), + first_field.value + ); + + // field_id로 조회 + let result = hwp_doc + .get_field_value_by_id(first_field.field.field_id) + .expect("필드 값 조회 실패"); + assert!(result.contains("\"ok\":true"), "조회 성공이어야 함"); + + // 이름으로 조회 + if let Some(name) = first_field.field.field_name() { + let result = hwp_doc + .get_field_value_by_name(name) + .expect("이름으로 필드 값 조회 실패"); + assert!(result.contains("\"ok\":true"), "이름 조회 성공이어야 함"); } - - #[test] - fn test_task230_get_field_value() { - let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); - let hwp_doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); - - // 필드 목록에서 첫 번째 필드 ID 추출 - let json = hwp_doc.get_field_list_json(); - let fields = hwp_doc.collect_all_fields(); - assert!(!fields.is_empty(), "필드가 있어야 함"); - - let first_field = &fields[0]; - eprintln!("[Task230] 첫 번째 필드: id={}, type={:?}, name={:?}, value='{}'", - first_field.field.field_id, first_field.field.field_type, - first_field.field.field_name(), first_field.value); - - // field_id로 조회 - let result = hwp_doc.get_field_value_by_id(first_field.field.field_id) - .expect("필드 값 조회 실패"); - assert!(result.contains("\"ok\":true"), "조회 성공이어야 함"); - - // 이름으로 조회 - if let Some(name) = first_field.field.field_name() { - let result = hwp_doc.get_field_value_by_name(name) - .expect("이름으로 필드 값 조회 실패"); - assert!(result.contains("\"ok\":true"), "이름 조회 성공이어야 함"); - } +} + +#[test] +fn test_task230_set_field_value() { + let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); + let mut hwp_doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); + + let fields = hwp_doc.collect_all_fields(); + // 빈 ClickHere 필드 찾기 (value가 빈 것) + let empty_field = fields + .iter() + .find(|f| { + f.field.field_type == crate::model::control::FieldType::ClickHere && f.value.is_empty() + }) + .expect("빈 ClickHere 필드가 있어야 함"); + + let field_id = empty_field.field.field_id; + eprintln!( + "[Task230] 빈 필드에 값 설정: id={}, name={:?}", + field_id, + empty_field.field.field_name() + ); + + // 값 설정 + let result = hwp_doc + .set_field_value_by_id(field_id, "테스트 입력값") + .expect("필드 값 설정 실패"); + eprintln!("[Task230] setFieldValue 결과: {}", result); + assert!(result.contains("\"ok\":true"), "설정 성공이어야 함"); + assert!(result.contains("테스트 입력값"), "새 값이 포함되어야 함"); + + // 값이 변경되었는지 확인 + let check = hwp_doc + .get_field_value_by_id(field_id) + .expect("변경 후 조회 실패"); + assert!(check.contains("테스트 입력값"), "변경된 값이 반영되어야 함"); + + // SVG 렌더링에서 변경된 값이 보이는지 확인 + let svg = hwp_doc.render_page_svg_native(0).expect("SVG 렌더링 실패"); + // "테스트 입력값"의 개별 글자가 SVG에 포함되어야 함 + assert!( + svg.contains(">테") || svg.contains("테스트"), + "SVG에 변경된 텍스트가 있어야 함" + ); +} + +#[test] +fn test_task231_field_survives_text_insert() { + let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); + let mut doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); + + // Section 0, Para 7: 빈 누름틀 필드 (start=7, end=7) + let info_before = doc.get_field_info_at(0, 7, 7); + eprintln!("[Before] field_info_at(0,7,7): {}", info_before); + assert!( + info_before.contains("\"inField\":true"), + "삽입 전 필드가 있어야 함" + ); + + // 필드 위치(charOffset=7)에 "A" 삽입 + let result = doc + .insert_text_native(0, 7, 7, "A") + .expect("텍스트 삽입 실패"); + eprintln!("[After insert] result: {}", result); + + // 삽입 후 커서 위치(charOffset=8)에서 필드 확인 + let info_after = doc.get_field_info_at(0, 7, 8); + eprintln!("[After] field_info_at(0,7,8): {}", info_after); + assert!( + info_after.contains("\"inField\":true"), + "삽입 후에도 필드가 있어야 함" + ); + + // 필드 시작 위치에서도 확인 + let info_start = doc.get_field_info_at(0, 7, 7); + eprintln!("[After] field_info_at(0,7,7): {}", info_start); + assert!( + info_start.contains("\"inField\":true"), + "삽입 후 필드 시작도 감지되어야 함" + ); + + // field_ranges 직접 확인 + let para = &doc.document.sections[0].paragraphs[7]; + eprintln!("[After] field_ranges: {:?}", para.field_ranges); + assert!( + !para.field_ranges.is_empty(), + "field_ranges가 비어있으면 안됨" + ); + let fr = ¶.field_ranges[0]; + assert_eq!(fr.start_char_idx, 7, "필드 시작은 7"); + assert_eq!(fr.end_char_idx, 8, "필드 끝은 8 (1글자 삽입 후)"); +} + +/// IME 조합 사이클 시뮬레이션: delete→insert 반복 시 필드가 사라지지 않는지 검증 +#[test] +fn test_task231_field_survives_ime_cycle() { + let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); + let mut doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); + + // Section 0, Para 7: 빈 누름틀 필드 (start=7, end=7) + let info = doc.get_field_info_at(0, 7, 7); + assert!(info.contains("\"inField\":true"), "초기 필드 존재 확인"); + + // IME 1단계: "ㅁ" 삽입 (compositionLength=0이므로 삭제 없음) + doc.insert_text_native(0, 7, 7, "ㅁ").expect("삽입 실패"); + let fr = &doc.document.sections[0].paragraphs[7].field_ranges[0]; + assert_eq!( + (fr.start_char_idx, fr.end_char_idx), + (7, 8), + "1단계 후 필드 범위" + ); + + // IME 2단계: "ㅁ" 삭제 → "마" 삽입 (delete→insert cycle) + doc.delete_text_native(0, 7, 7, 1).expect("삭제 실패"); + // *** 핵심: 삭제 후 필드가 비어도 field_ranges가 유지되어야 함 *** + let para = &doc.document.sections[0].paragraphs[7]; + eprintln!("[After delete] field_ranges: {:?}", para.field_ranges); + assert!( + !para.field_ranges.is_empty(), + "삭제 후에도 빈 필드 범위가 유지되어야 함" + ); + let fr = ¶.field_ranges[0]; + assert_eq!( + (fr.start_char_idx, fr.end_char_idx), + (7, 7), + "삭제 후 빈 필드" + ); + + doc.insert_text_native(0, 7, 7, "마").expect("삽입 실패"); + let fr = &doc.document.sections[0].paragraphs[7].field_ranges[0]; + assert_eq!( + (fr.start_char_idx, fr.end_char_idx), + (7, 8), + "2단계 후 필드 범위" + ); + + // IME 3단계: "마" 삭제 → "만" 삽입 + doc.delete_text_native(0, 7, 7, 1).expect("삭제 실패"); + assert!( + !doc.document.sections[0].paragraphs[7] + .field_ranges + .is_empty(), + "3단계 삭제 후 필드 유지" + ); + doc.insert_text_native(0, 7, 7, "만").expect("삽입 실패"); + let fr = &doc.document.sections[0].paragraphs[7].field_ranges[0]; + assert_eq!( + (fr.start_char_idx, fr.end_char_idx), + (7, 8), + "3단계 후 필드 범위" + ); + + // IME 완료 후 필드 정보 확인 + let info = doc.get_field_info_at(0, 7, 8); + assert!( + info.contains("\"inField\":true"), + "IME 완료 후 필드 내 커서 확인" + ); +} + +/// getClickHereProps가 유효한 JSON을 반환하는지 검증 +#[test] +fn test_task231_get_click_here_props() { + let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); + let doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); + + let result = doc.get_click_here_props(1584999796); + eprintln!("[getClickHereProps] {}", result); + // 유효한 JSON인지 확인 + assert!(result.contains("\"ok\":true"), "ok=true 이어야 함"); + assert!( + result.contains("\"guide\":\""), + "guide 필드가 따옴표로 감싸져야 함" + ); + assert!(result.contains("여기에 입력"), "안내문이 포함되어야 함"); + // JSON 구조 검증 (따옴표 포함) + assert!(result.starts_with("{\"ok\":true,"), "JSON 시작 구조"); +} + +/// updateClickHereProps 후 field_name() 매핑이 동작하는지 검증 +#[test] +fn test_task231_update_click_here_props_name_mapping() { + let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); + let mut doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); + let field_id = 1584999796u32; + + // 초기 상태: command에 Name 키 없음, CTRL_DATA에서 "회사명" 로드 + let para = &doc.document.sections[0].paragraphs[7]; + if let crate::model::control::Control::Field(f) = ¶.controls[0] { + assert_eq!(f.field_name(), Some("회사명"), "초기: CTRL_DATA 필드 이름"); + assert_eq!( + f.ctrl_data_name.as_deref(), + Some("회사명"), + "초기: ctrl_data_name" + ); + assert_eq!( + f.extract_wstring_value("Name:"), + None, + "초기: command에 Name 키 없음" + ); } - #[test] - fn test_task230_set_field_value() { - let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); - let mut hwp_doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); - - let fields = hwp_doc.collect_all_fields(); - // 빈 ClickHere 필드 찾기 (value가 빈 것) - let empty_field = fields.iter().find(|f| { - f.field.field_type == crate::model::control::FieldType::ClickHere && f.value.is_empty() - }).expect("빈 ClickHere 필드가 있어야 함"); - - let field_id = empty_field.field.field_id; - eprintln!("[Task230] 빈 필드에 값 설정: id={}, name={:?}", - field_id, empty_field.field.field_name()); - - // 값 설정 - let result = hwp_doc.set_field_value_by_id(field_id, "테스트 입력값") - .expect("필드 값 설정 실패"); - eprintln!("[Task230] setFieldValue 결과: {}", result); - assert!(result.contains("\"ok\":true"), "설정 성공이어야 함"); - assert!(result.contains("테스트 입력값"), "새 값이 포함되어야 함"); - - // 값이 변경되었는지 확인 - let check = hwp_doc.get_field_value_by_id(field_id) - .expect("변경 후 조회 실패"); - assert!(check.contains("테스트 입력값"), "변경된 값이 반영되어야 함"); - - // SVG 렌더링에서 변경된 값이 보이는지 확인 - let svg = hwp_doc.render_page_svg_native(0).expect("SVG 렌더링 실패"); - // "테스트 입력값"의 개별 글자가 SVG에 포함되어야 함 - assert!(svg.contains(">테") || svg.contains("테스트"), - "SVG에 변경된 텍스트가 있어야 함"); - } - - #[test] - fn test_task231_field_survives_text_insert() { - let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); - let mut doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); - - // Section 0, Para 7: 빈 누름틀 필드 (start=7, end=7) - let info_before = doc.get_field_info_at(0, 7, 7); - eprintln!("[Before] field_info_at(0,7,7): {}", info_before); - assert!(info_before.contains("\"inField\":true"), "삽입 전 필드가 있어야 함"); - - // 필드 위치(charOffset=7)에 "A" 삽입 - let result = doc.insert_text_native(0, 7, 7, "A").expect("텍스트 삽입 실패"); - eprintln!("[After insert] result: {}", result); - - // 삽입 후 커서 위치(charOffset=8)에서 필드 확인 - let info_after = doc.get_field_info_at(0, 7, 8); - eprintln!("[After] field_info_at(0,7,8): {}", info_after); - assert!(info_after.contains("\"inField\":true"), "삽입 후에도 필드가 있어야 함"); - - // 필드 시작 위치에서도 확인 - let info_start = doc.get_field_info_at(0, 7, 7); - eprintln!("[After] field_info_at(0,7,7): {}", info_start); - assert!(info_start.contains("\"inField\":true"), "삽입 후 필드 시작도 감지되어야 함"); - - // field_ranges 직접 확인 - let para = &doc.document.sections[0].paragraphs[7]; - eprintln!("[After] field_ranges: {:?}", para.field_ranges); - assert!(!para.field_ranges.is_empty(), "field_ranges가 비어있으면 안됨"); - let fr = ¶.field_ranges[0]; - assert_eq!(fr.start_char_idx, 7, "필드 시작은 7"); - assert_eq!(fr.end_char_idx, 8, "필드 끝은 8 (1글자 삽입 후)"); - } - - /// IME 조합 사이클 시뮬레이션: delete→insert 반복 시 필드가 사라지지 않는지 검증 - #[test] - fn test_task231_field_survives_ime_cycle() { - let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); - let mut doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); - - // Section 0, Para 7: 빈 누름틀 필드 (start=7, end=7) - let info = doc.get_field_info_at(0, 7, 7); - assert!(info.contains("\"inField\":true"), "초기 필드 존재 확인"); - - // IME 1단계: "ㅁ" 삽입 (compositionLength=0이므로 삭제 없음) - doc.insert_text_native(0, 7, 7, "ㅁ").expect("삽입 실패"); - let fr = &doc.document.sections[0].paragraphs[7].field_ranges[0]; - assert_eq!((fr.start_char_idx, fr.end_char_idx), (7, 8), "1단계 후 필드 범위"); - - // IME 2단계: "ㅁ" 삭제 → "마" 삽입 (delete→insert cycle) - doc.delete_text_native(0, 7, 7, 1).expect("삭제 실패"); - // *** 핵심: 삭제 후 필드가 비어도 field_ranges가 유지되어야 함 *** - let para = &doc.document.sections[0].paragraphs[7]; - eprintln!("[After delete] field_ranges: {:?}", para.field_ranges); - assert!(!para.field_ranges.is_empty(), "삭제 후에도 빈 필드 범위가 유지되어야 함"); - let fr = ¶.field_ranges[0]; - assert_eq!((fr.start_char_idx, fr.end_char_idx), (7, 7), "삭제 후 빈 필드"); - - doc.insert_text_native(0, 7, 7, "마").expect("삽입 실패"); - let fr = &doc.document.sections[0].paragraphs[7].field_ranges[0]; - assert_eq!((fr.start_char_idx, fr.end_char_idx), (7, 8), "2단계 후 필드 범위"); - - // IME 3단계: "마" 삭제 → "만" 삽입 - doc.delete_text_native(0, 7, 7, 1).expect("삭제 실패"); - assert!(!doc.document.sections[0].paragraphs[7].field_ranges.is_empty(), "3단계 삭제 후 필드 유지"); - doc.insert_text_native(0, 7, 7, "만").expect("삽입 실패"); - let fr = &doc.document.sections[0].paragraphs[7].field_ranges[0]; - assert_eq!((fr.start_char_idx, fr.end_char_idx), (7, 8), "3단계 후 필드 범위"); - - // IME 완료 후 필드 정보 확인 - let info = doc.get_field_info_at(0, 7, 8); - assert!(info.contains("\"inField\":true"), "IME 완료 후 필드 내 커서 확인"); - } - - /// getClickHereProps가 유효한 JSON을 반환하는지 검증 - #[test] - fn test_task231_get_click_here_props() { - let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); - let doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); - - let result = doc.get_click_here_props(1584999796); - eprintln!("[getClickHereProps] {}", result); - // 유효한 JSON인지 확인 - assert!(result.contains("\"ok\":true"), "ok=true 이어야 함"); - assert!(result.contains("\"guide\":\""), "guide 필드가 따옴표로 감싸져야 함"); - assert!(result.contains("여기에 입력"), "안내문이 포함되어야 함"); - // JSON 구조 검증 (따옴표 포함) - assert!(result.starts_with("{\"ok\":true,"), "JSON 시작 구조"); - } - - /// updateClickHereProps 후 field_name() 매핑이 동작하는지 검증 - #[test] - fn test_task231_update_click_here_props_name_mapping() { - let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); - let mut doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); - let field_id = 1584999796u32; - - // 초기 상태: command에 Name 키 없음, CTRL_DATA에서 "회사명" 로드 - let para = &doc.document.sections[0].paragraphs[7]; - if let crate::model::control::Control::Field(f) = ¶.controls[0] { - assert_eq!(f.field_name(), Some("회사명"), "초기: CTRL_DATA 필드 이름"); - assert_eq!(f.ctrl_data_name.as_deref(), Some("회사명"), "초기: ctrl_data_name"); - assert_eq!(f.extract_wstring_value("Name:"), None, "초기: command에 Name 키 없음"); - } + // 필드 이름을 "목차1"로 설정 + let result = doc.update_click_here_props(field_id, "여기에 입력", "", "목차1", true); + assert!(result.contains("\"ok\":true"), "업데이트 성공"); - // 필드 이름을 "목차1"로 설정 - let result = doc.update_click_here_props(field_id, "여기에 입력", "", "목차1", true); - assert!(result.contains("\"ok\":true"), "업데이트 성공"); + // 업데이트 후: 이름은 ctrl_data_name에만, command에는 Name: 없음 + let para = &doc.document.sections[0].paragraphs[7]; + if let crate::model::control::Control::Field(f) = ¶.controls[0] { + eprintln!("[After update] command: {:?}", f.command); + assert_eq!( + f.field_name(), + Some("목차1"), + "업데이트 후: ctrl_data_name 우선" + ); + assert_eq!( + f.ctrl_data_name.as_deref(), + Some("목차1"), + "ctrl_data_name 설정됨" + ); + assert_eq!( + f.extract_wstring_value("Name:"), + None, + "command에 Name: 없음 (한컴 호환)" + ); + assert_eq!(f.guide_text(), Some("여기에 입력"), "안내문 유지됨"); + } - // 업데이트 후: 이름은 ctrl_data_name에만, command에는 Name: 없음 - let para = &doc.document.sections[0].paragraphs[7]; - if let crate::model::control::Control::Field(f) = ¶.controls[0] { - eprintln!("[After update] command: {:?}", f.command); - assert_eq!(f.field_name(), Some("목차1"), "업데이트 후: ctrl_data_name 우선"); - assert_eq!(f.ctrl_data_name.as_deref(), Some("목차1"), "ctrl_data_name 설정됨"); - assert_eq!(f.extract_wstring_value("Name:"), None, "command에 Name: 없음 (한컴 호환)"); - assert_eq!(f.guide_text(), Some("여기에 입력"), "안내문 유지됨"); - } - - // getFieldValueByName으로 새 이름 조회 가능 - let val = doc.get_field_value_by_name("목차1"); - eprintln!("[ByName] 목차1: {:?}", val); - assert!(val.is_ok(), "새 이름으로 조회 가능"); - - // getClickHereProps에서 name이 비어있지 않은지 확인 - let props = doc.get_click_here_props(field_id); - eprintln!("[Props after] {}", props); - assert!(props.contains("\"name\":\"목차1\""), "props에 새 이름 표시"); - } - - /// [진단용] HWP 파일의 모든 ClickHere 필드 command + CTRL_DATA 덤프 - #[test] - fn diag_dump_all_clickhere_commands() { - // field-01.hwp와 saved/field-02.hwp 비교 - for path in &["samples/field-01.hwp", "saved/field-02.hwp"] { - eprintln!("\n=== {} ===", path); - let Ok(data) = std::fs::read(path) else { - eprintln!(" (파일 없음)"); - continue; - }; - let Ok(doc) = HwpDocument::from_bytes(&data) else { - eprintln!(" (파싱 실패)"); - continue; - }; - dump_clickhere_fields(&doc); - } + // getFieldValueByName으로 새 이름 조회 가능 + let val = doc.get_field_value_by_name("목차1"); + eprintln!("[ByName] 목차1: {:?}", val); + assert!(val.is_ok(), "새 이름으로 조회 가능"); + + // getClickHereProps에서 name이 비어있지 않은지 확인 + let props = doc.get_click_here_props(field_id); + eprintln!("[Props after] {}", props); + assert!(props.contains("\"name\":\"목차1\""), "props에 새 이름 표시"); +} + +/// [진단용] HWP 파일의 모든 ClickHere 필드 command + CTRL_DATA 덤프 +#[test] +fn diag_dump_all_clickhere_commands() { + // field-01.hwp와 saved/field-02.hwp 비교 + for path in &["samples/field-01.hwp", "saved/field-02.hwp"] { + eprintln!("\n=== {} ===", path); + let Ok(data) = std::fs::read(path) else { + eprintln!(" (파일 없음)"); + continue; + }; + let Ok(doc) = HwpDocument::from_bytes(&data) else { + eprintln!(" (파싱 실패)"); + continue; + }; + dump_clickhere_fields(&doc); } +} - fn dump_clickhere_fields(doc: &HwpDocument) { - use crate::model::control::{Control, FieldType}; - for (si, sec) in doc.document.sections.iter().enumerate() { - for (pi, para) in sec.paragraphs.iter().enumerate() { - for (ci, ctrl) in para.controls.iter().enumerate() { - if let Control::Field(f) = ctrl { - if f.field_type == FieldType::ClickHere { - eprintln!("[sec={} para={} ctrl={}] id={} ctrl_data_name={:?}", - si, pi, ci, f.field_id, f.ctrl_data_name); - eprintln!(" command={:?}", f.command); - // CTRL_DATA 확인 - if let Some(Some(cd)) = para.ctrl_data_records.get(ci) { - eprintln!(" CTRL_DATA({} bytes): {:02x?}", cd.len(), &cd[..cd.len().min(24)]); - } else { - eprintln!(" CTRL_DATA: None"); - } +fn dump_clickhere_fields(doc: &HwpDocument) { + use crate::model::control::{Control, FieldType}; + for (si, sec) in doc.document.sections.iter().enumerate() { + for (pi, para) in sec.paragraphs.iter().enumerate() { + for (ci, ctrl) in para.controls.iter().enumerate() { + if let Control::Field(f) = ctrl { + if f.field_type == FieldType::ClickHere { + eprintln!( + "[sec={} para={} ctrl={}] id={} ctrl_data_name={:?}", + si, pi, ci, f.field_id, f.ctrl_data_name + ); + eprintln!(" command={:?}", f.command); + // CTRL_DATA 확인 + if let Some(Some(cd)) = para.ctrl_data_records.get(ci) { + eprintln!( + " CTRL_DATA({} bytes): {:02x?}", + cd.len(), + &cd[..cd.len().min(24)] + ); + } else { + eprintln!(" CTRL_DATA: None"); } } } } } } - - /// 필드 직렬화 라운드트립: 저장 후 다시 읽으면 필드가 보존되는지 검증 - #[test] - fn test_task231_field_roundtrip() { - let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); - let doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); - - // 저장 (직렬화 → CFB 바이트) - let saved = doc.core.export_hwp_native().expect("저장 실패"); - - // 다시 읽기 - let doc2 = HwpDocument::from_bytes(&saved).expect("다시 읽기 실패"); - - use crate::model::control::{Control, FieldType}; - // sec=0 para=7의 필드 확인 - let para = &doc2.document.sections[0].paragraphs[7]; - let ctrl = ¶.controls[0]; - if let Control::Field(f) = ctrl { - assert_eq!(f.field_type, FieldType::ClickHere); - assert_eq!(f.field_id, 1584999796); - assert!(f.command.contains("Direction:wstring:6:여기에 입력"), "command 보존: {:?}", f.command); - assert_eq!(f.ctrl_data_name.as_deref(), Some("회사명"), "CTRL_DATA 필드 이름 보존"); - eprintln!("[roundtrip] id={} command={:?} ctrl_data_name={:?}", f.field_id, f.command, f.ctrl_data_name); - } else { - panic!("sec=0 para=7 ctrl=0이 Field가 아님: {:?}", ctrl); - } - // field_ranges 보존 확인 - let orig_para = &doc.document.sections[0].paragraphs[7]; - eprintln!("[roundtrip] orig field_ranges={:?}", orig_para.field_ranges); - eprintln!("[roundtrip] reload field_ranges={:?}", para.field_ranges); - assert_eq!(para.field_ranges.len(), orig_para.field_ranges.len(), "field_ranges 개수 보존"); - for (i, (a, b)) in orig_para.field_ranges.iter().zip(para.field_ranges.iter()).enumerate() { - assert_eq!(a.start_char_idx, b.start_char_idx, "field_range[{}].start 보존", i); - assert_eq!(a.end_char_idx, b.end_char_idx, "field_range[{}].end 보존", i); - assert_eq!(a.control_idx, b.control_idx, "field_range[{}].ctrl_idx 보존", i); - } +} + +/// 필드 직렬화 라운드트립: 저장 후 다시 읽으면 필드가 보존되는지 검증 +#[test] +fn test_task231_field_roundtrip() { + let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); + let doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); + + // 저장 (직렬화 → CFB 바이트) + let saved = doc.core.export_hwp_native().expect("저장 실패"); + + // 다시 읽기 + let doc2 = HwpDocument::from_bytes(&saved).expect("다시 읽기 실패"); + + use crate::model::control::{Control, FieldType}; + // sec=0 para=7의 필드 확인 + let para = &doc2.document.sections[0].paragraphs[7]; + let ctrl = ¶.controls[0]; + if let Control::Field(f) = ctrl { + assert_eq!(f.field_type, FieldType::ClickHere); + assert_eq!(f.field_id, 1584999796); + assert!( + f.command.contains("Direction:wstring:6:여기에 입력"), + "command 보존: {:?}", + f.command + ); + assert_eq!( + f.ctrl_data_name.as_deref(), + Some("회사명"), + "CTRL_DATA 필드 이름 보존" + ); + eprintln!( + "[roundtrip] id={} command={:?} ctrl_data_name={:?}", + f.field_id, f.command, f.ctrl_data_name + ); + } else { + panic!("sec=0 para=7 ctrl=0이 Field가 아님: {:?}", ctrl); } + // field_ranges 보존 확인 + let orig_para = &doc.document.sections[0].paragraphs[7]; + eprintln!("[roundtrip] orig field_ranges={:?}", orig_para.field_ranges); + eprintln!("[roundtrip] reload field_ranges={:?}", para.field_ranges); + assert_eq!( + para.field_ranges.len(), + orig_para.field_ranges.len(), + "field_ranges 개수 보존" + ); + for (i, (a, b)) in orig_para + .field_ranges + .iter() + .zip(para.field_ranges.iter()) + .enumerate() + { + assert_eq!( + a.start_char_idx, b.start_char_idx, + "field_range[{}].start 보존", + i + ); + assert_eq!( + a.end_char_idx, b.end_char_idx, + "field_range[{}].end 보존", + i + ); + assert_eq!( + a.control_idx, b.control_idx, + "field_range[{}].ctrl_idx 보존", + i + ); + } +} + +/// [진단] 직렬화된 PARA_TEXT에서 FIELD_BEGIN/END 순서 확인 +#[test] +fn diag_para_text_field_markers() { + let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); + let doc = HwpDocument::from_bytes(&data).expect("파싱"); + let sec = &doc.document.sections[0]; + + for (pi, para) in sec.paragraphs.iter().enumerate() { + let has_field = para + .controls + .iter() + .any(|c| matches!(c, crate::model::control::Control::Field(_))); + if !has_field { + continue; + } - /// [진단] 직렬화된 PARA_TEXT에서 FIELD_BEGIN/END 순서 확인 - #[test] - fn diag_para_text_field_markers() { - let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); - let doc = HwpDocument::from_bytes(&data).expect("파싱"); - let sec = &doc.document.sections[0]; - - for (pi, para) in sec.paragraphs.iter().enumerate() { - let has_field = para.controls.iter().any(|c| - matches!(c, crate::model::control::Control::Field(_))); - if !has_field { continue; } - - let serialized = crate::serializer::body_text::test_serialize_para_text(para); + let serialized = crate::serializer::body_text::test_serialize_para_text(para); - eprintln!("\n[para={}] text={:?}", pi, para.text); - eprintln!(" field_ranges={:?}", para.field_ranges); - eprintln!(" char_offsets={:?}", para.char_offsets); + eprintln!("\n[para={}] text={:?}", pi, para.text); + eprintln!(" field_ranges={:?}", para.field_ranges); + eprintln!(" char_offsets={:?}", para.char_offsets); - // 직렬화된 바이트에서 컨트롤 문자 위치 추출 - let code_units: Vec = serialized.chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect(); + // 직렬화된 바이트에서 컨트롤 문자 위치 추출 + let code_units: Vec = serialized + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); - eprintln!(" serialized code_units({}):", code_units.len()); - let mut pos = 0; - while pos < code_units.len() { - let cu = code_units[pos]; - if cu == 0x0003 { - let ctrl_id = if pos + 4 < code_units.len() { - (code_units[pos+1] as u32) | ((code_units[pos+2] as u32) << 16) - } else { 0 }; - eprintln!(" [{}] FIELD_BEGIN ctrl_id=0x{:08x}", pos, ctrl_id); - pos += 8; - } else if cu == 0x0004 { - let ctrl_id = if pos + 4 < code_units.len() { - (code_units[pos+1] as u32) | ((code_units[pos+2] as u32) << 16) - } else { 0 }; - eprintln!(" [{}] FIELD_END ctrl_id=0x{:08x}", pos, ctrl_id); + eprintln!(" serialized code_units({}):", code_units.len()); + let mut pos = 0; + while pos < code_units.len() { + let cu = code_units[pos]; + if cu == 0x0003 { + let ctrl_id = if pos + 4 < code_units.len() { + (code_units[pos + 1] as u32) | ((code_units[pos + 2] as u32) << 16) + } else { + 0 + }; + eprintln!(" [{}] FIELD_BEGIN ctrl_id=0x{:08x}", pos, ctrl_id); + pos += 8; + } else if cu == 0x0004 { + let ctrl_id = if pos + 4 < code_units.len() { + (code_units[pos + 1] as u32) | ((code_units[pos + 2] as u32) << 16) + } else { + 0 + }; + eprintln!(" [{}] FIELD_END ctrl_id=0x{:08x}", pos, ctrl_id); + pos += 8; + } else if cu == 0x000D { + eprintln!(" [{}] PARA_END", pos); + pos += 1; + } else if cu == 0x000A { + eprintln!(" [{}] NEWLINE", pos); + pos += 1; + } else if cu == 0x0009 { + eprintln!(" [{}] TAB", pos); + pos += 8; + } else if cu < 0x0020 { + eprintln!(" [{}] CTRL 0x{:04x}", pos, cu); + if cu >= 0x0008 { pos += 8; - } else if cu == 0x000D { - eprintln!(" [{}] PARA_END", pos); + } else { pos += 1; - } else if cu == 0x000A { - eprintln!(" [{}] NEWLINE", pos); + } + } else { + // 일반 문자: 연속 출력 + let start = pos; + while pos < code_units.len() && code_units[pos] >= 0x0020 { pos += 1; - } else if cu == 0x0009 { - eprintln!(" [{}] TAB", pos); - pos += 8; - } else if cu < 0x0020 { - eprintln!(" [{}] CTRL 0x{:04x}", pos, cu); - if cu >= 0x0008 { pos += 8; } else { pos += 1; } - } else { - // 일반 문자: 연속 출력 - let start = pos; - while pos < code_units.len() && code_units[pos] >= 0x0020 { - pos += 1; - } - let text = String::from_utf16_lossy(&code_units[start..pos]); - eprintln!(" [{}..{}] TEXT {:?}", start, pos, text); } + let text = String::from_utf16_lossy(&code_units[start..pos]); + eprintln!(" [{}..{}] TEXT {:?}", start, pos, text); } } } +} - /// 필드 이름만 변경 후 저장 → 안내문이 보존되는지 검증 - #[test] - fn test_task231_field_name_change_preserves_guide() { - let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); - let mut doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); - let field_id = 1584999796u32; // "이메일" 필드가 아닌 "회사명" 필드 - - // 변경 전 상태 - let props_before = doc.get_click_here_props(field_id); - eprintln!("[before] {}", props_before); - - // 필드 이름만 변경 (안내문, 메모는 그대로) - let result = doc.update_click_here_props(field_id, "여기에 입력", "", "회사명1", true); - eprintln!("[update] {}", result); - - // 변경 후 command 확인 - { - use crate::model::control::{Control, FieldType}; - let para = &doc.document.sections[0].paragraphs[7]; - if let Control::Field(f) = ¶.controls[0] { - eprintln!("[after update] command={:?}", f.command); - eprintln!("[after update] ctrl_data_name={:?}", f.ctrl_data_name); - } - } +/// 필드 이름만 변경 후 저장 → 안내문이 보존되는지 검증 +#[test] +fn test_task231_field_name_change_preserves_guide() { + let data = std::fs::read("samples/field-01.hwp").expect("파일 읽기 실패"); + let mut doc = HwpDocument::from_bytes(&data).expect("HwpDocument 생성 실패"); + let field_id = 1584999796u32; // "이메일" 필드가 아닌 "회사명" 필드 - // 저장 - let saved = doc.core.export_hwp_native().expect("저장 실패"); + // 변경 전 상태 + let props_before = doc.get_click_here_props(field_id); + eprintln!("[before] {}", props_before); - // 다시 읽기 - let doc2 = HwpDocument::from_bytes(&saved).expect("다시 읽기 실패"); + // 필드 이름만 변경 (안내문, 메모는 그대로) + let result = doc.update_click_here_props(field_id, "여기에 입력", "", "회사명1", true); + eprintln!("[update] {}", result); + // 변경 후 command 확인 + { use crate::model::control::{Control, FieldType}; - let para = &doc2.document.sections[0].paragraphs[7]; + let para = &doc.document.sections[0].paragraphs[7]; if let Control::Field(f) = ¶.controls[0] { - eprintln!("[reloaded] command={:?}", f.command); - eprintln!("[reloaded] ctrl_data_name={:?}", f.ctrl_data_name); - eprintln!("[reloaded] guide_text={:?}", f.guide_text()); - eprintln!("[reloaded] field_name={:?}", f.field_name()); - assert_eq!(f.field_id, field_id, "field_id 보존"); - assert_eq!(f.guide_text(), Some("여기에 입력"), "안내문 보존"); - assert_eq!(f.ctrl_data_name.as_deref(), Some("회사명1"), "변경된 필드 이름"); - } else { - panic!("필드가 아님"); + eprintln!("[after update] command={:?}", f.command); + eprintln!("[after update] ctrl_data_name={:?}", f.ctrl_data_name); } + } - // getClickHereProps로도 확인 - let props_after = doc2.get_click_here_props(field_id); - eprintln!("[reloaded props] {}", props_after); - assert!(props_after.contains("\"guide\":\"여기에 입력\""), "안내문 보존"); - assert!(props_after.contains("\"name\":\"회사명1\""), "변경된 이름"); + // 저장 + let saved = doc.core.export_hwp_native().expect("저장 실패"); + + // 다시 읽기 + let doc2 = HwpDocument::from_bytes(&saved).expect("다시 읽기 실패"); + + use crate::model::control::{Control, FieldType}; + let para = &doc2.document.sections[0].paragraphs[7]; + if let Control::Field(f) = ¶.controls[0] { + eprintln!("[reloaded] command={:?}", f.command); + eprintln!("[reloaded] ctrl_data_name={:?}", f.ctrl_data_name); + eprintln!("[reloaded] guide_text={:?}", f.guide_text()); + eprintln!("[reloaded] field_name={:?}", f.field_name()); + assert_eq!(f.field_id, field_id, "field_id 보존"); + assert_eq!(f.guide_text(), Some("여기에 입력"), "안내문 보존"); + assert_eq!( + f.ctrl_data_name.as_deref(), + Some("회사명1"), + "변경된 필드 이름" + ); + } else { + panic!("필드가 아님"); } - /// [진단] field-06.hwp (우리가 저장) vs field-01.hwp (원본) vs field-01-h.hwp (한컴 저장 참조) - /// 누름틀 필드의 CTRL_HEADER / CTRL_DATA 비교 - #[test] - fn diag_field06_vs_reference() { - use crate::model::control::{Control, FieldType}; - use crate::parser::record::Record; - use crate::parser::tags; + // getClickHereProps로도 확인 + let props_after = doc2.get_click_here_props(field_id); + eprintln!("[reloaded props] {}", props_after); + assert!( + props_after.contains("\"guide\":\"여기에 입력\""), + "안내문 보존" + ); + assert!(props_after.contains("\"name\":\"회사명1\""), "변경된 이름"); +} + +/// [진단] field-06.hwp (우리가 저장) vs field-01.hwp (원본) vs field-01-h.hwp (한컴 저장 참조) +/// 누름틀 필드의 CTRL_HEADER / CTRL_DATA 비교 +#[test] +fn diag_field06_vs_reference() { + use crate::model::control::{Control, FieldType}; + use crate::parser::record::Record; + use crate::parser::tags; + + let files: &[(&str, &str)] = &[ + ("samples/field-01.hwp", "ORIGINAL"), + ("saved/field-01-h.hwp", "HANCOM_REF"), + ("saved/field-06.hwp", "OUR_SAVED"), + ]; + + for (path, label) in files { + eprintln!("\n{}", "=".repeat(60)); + eprintln!("=== {} ({}) ===", label, path); + eprintln!("{}", "=".repeat(60)); - let files: &[(&str, &str)] = &[ - ("samples/field-01.hwp", "ORIGINAL"), - ("saved/field-01-h.hwp", "HANCOM_REF"), - ("saved/field-06.hwp", "OUR_SAVED"), - ]; + let Ok(data) = std::fs::read(path) else { + eprintln!(" (파일 없음 - 건너뜀)"); + continue; + }; + let Ok(doc) = HwpDocument::from_bytes(&data) else { + eprintln!(" (파싱 실패)"); + continue; + }; - for (path, label) in files { - eprintln!("\n{}", "=".repeat(60)); - eprintln!("=== {} ({}) ===", label, path); - eprintln!("{}", "=".repeat(60)); + // 1) 파싱된 모델에서 ClickHere 필드 정보 출력 + for (si, sec) in doc.document.sections.iter().enumerate() { + for (pi, para) in sec.paragraphs.iter().enumerate() { + for (ci, ctrl) in para.controls.iter().enumerate() { + if let Control::Field(f) = ctrl { + if f.field_type != FieldType::ClickHere { + continue; + } + eprintln!("\n [sec={} para={} ctrl={}]", si, pi, ci); + eprintln!(" field_type: {:?}", f.field_type); + eprintln!( + " ctrl_id: 0x{:08x} ({})", + f.ctrl_id, + String::from_utf8_lossy(&f.ctrl_id.to_le_bytes()) + ); + eprintln!(" field_id: {} (0x{:08x})", f.field_id, f.field_id); + eprintln!(" properties: 0x{:08x} ({})", f.properties, f.properties); + eprintln!( + " extra_properties: 0x{:02x} ({})", + f.extra_properties, f.extra_properties + ); + eprintln!(" command({} chars): {:?}", f.command.len(), f.command); + eprintln!(" ctrl_data_name: {:?}", f.ctrl_data_name); + eprintln!(" guide_text: {:?}", f.guide_text()); + eprintln!(" field_name: {:?}", f.field_name()); + eprintln!(" memo_text: {:?}", f.memo_text()); - let Ok(data) = std::fs::read(path) else { - eprintln!(" (파일 없음 - 건너뜀)"); - continue; - }; - let Ok(doc) = HwpDocument::from_bytes(&data) else { - eprintln!(" (파싱 실패)"); - continue; - }; + // command를 UTF-16LE 바이트로 덤프 + let cmd_utf16: Vec = f.command.encode_utf16().collect(); + eprintln!(" command UTF-16 len: {}", cmd_utf16.len()); - // 1) 파싱된 모델에서 ClickHere 필드 정보 출력 - for (si, sec) in doc.document.sections.iter().enumerate() { - for (pi, para) in sec.paragraphs.iter().enumerate() { - for (ci, ctrl) in para.controls.iter().enumerate() { - if let Control::Field(f) = ctrl { - if f.field_type != FieldType::ClickHere { - continue; - } - eprintln!("\n [sec={} para={} ctrl={}]", si, pi, ci); - eprintln!(" field_type: {:?}", f.field_type); - eprintln!(" ctrl_id: 0x{:08x} ({})", f.ctrl_id, - String::from_utf8_lossy(&f.ctrl_id.to_le_bytes())); - eprintln!(" field_id: {} (0x{:08x})", f.field_id, f.field_id); - eprintln!(" properties: 0x{:08x} ({})", f.properties, f.properties); - eprintln!(" extra_properties: 0x{:02x} ({})", f.extra_properties, f.extra_properties); - eprintln!(" command({} chars): {:?}", f.command.len(), f.command); - eprintln!(" ctrl_data_name: {:?}", f.ctrl_data_name); - eprintln!(" guide_text: {:?}", f.guide_text()); - eprintln!(" field_name: {:?}", f.field_name()); - eprintln!(" memo_text: {:?}", f.memo_text()); - - // command를 UTF-16LE 바이트로 덤프 - let cmd_utf16: Vec = f.command.encode_utf16().collect(); - eprintln!(" command UTF-16 len: {}", cmd_utf16.len()); - - // CTRL_DATA 원본 바이트 덤프 - if let Some(Some(cd)) = para.ctrl_data_records.get(ci) { - eprintln!(" CTRL_DATA({} bytes): {:02x?}", cd.len(), cd); - // 필드 이름 파싱 상세 - if cd.len() >= 12 { - let name_len = u16::from_le_bytes([cd[10], cd[11]]) as usize; - eprintln!(" CTRL_DATA header(0..10): {:02x?}", &cd[..10]); - eprintln!(" CTRL_DATA name_len: {}", name_len); - if name_len > 0 && cd.len() >= 12 + name_len * 2 { - let wchars: Vec = cd[12..12 + name_len * 2] - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect(); - let name = String::from_utf16_lossy(&wchars); - eprintln!(" CTRL_DATA name: {:?}", name); - } - // 이름 이후 남은 바이트 - let after_name = 12 + name_len * 2; - if cd.len() > after_name { - eprintln!(" CTRL_DATA after_name({} bytes): {:02x?}", - cd.len() - after_name, &cd[after_name..]); - } + // CTRL_DATA 원본 바이트 덤프 + if let Some(Some(cd)) = para.ctrl_data_records.get(ci) { + eprintln!(" CTRL_DATA({} bytes): {:02x?}", cd.len(), cd); + // 필드 이름 파싱 상세 + if cd.len() >= 12 { + let name_len = u16::from_le_bytes([cd[10], cd[11]]) as usize; + eprintln!(" CTRL_DATA header(0..10): {:02x?}", &cd[..10]); + eprintln!(" CTRL_DATA name_len: {}", name_len); + if name_len > 0 && cd.len() >= 12 + name_len * 2 { + let wchars: Vec = cd[12..12 + name_len * 2] + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let name = String::from_utf16_lossy(&wchars); + eprintln!(" CTRL_DATA name: {:?}", name); + } + // 이름 이후 남은 바이트 + let after_name = 12 + name_len * 2; + if cd.len() > after_name { + eprintln!( + " CTRL_DATA after_name({} bytes): {:02x?}", + cd.len() - after_name, + &cd[after_name..] + ); } - } else { - eprintln!(" CTRL_DATA: None"); } + } else { + eprintln!(" CTRL_DATA: None"); + } - // 직렬화 결과: 우리가 쓰는 CTRL_HEADER 데이터 생성 - let ser_cmd_utf16: Vec = f.command.encode_utf16().collect(); - let ser_cmd_len = ser_cmd_utf16.len(); - let mut ser_data = Vec::new(); - ser_data.extend_from_slice(&f.ctrl_id.to_le_bytes()); - ser_data.extend_from_slice(&f.properties.to_le_bytes()); - ser_data.push(f.extra_properties); - ser_data.extend_from_slice(&(ser_cmd_len as u16).to_le_bytes()); - for ch in &ser_cmd_utf16 { - ser_data.extend_from_slice(&ch.to_le_bytes()); - } - ser_data.extend_from_slice(&f.field_id.to_le_bytes()); - eprintln!(" SERIALIZED CTRL_HEADER({} bytes): {:02x?}", - ser_data.len(), &ser_data[..ser_data.len().min(80)]); - if ser_data.len() > 80 { - eprintln!(" ... (truncated, {} more bytes)", ser_data.len() - 80); - } + // 직렬화 결과: 우리가 쓰는 CTRL_HEADER 데이터 생성 + let ser_cmd_utf16: Vec = f.command.encode_utf16().collect(); + let ser_cmd_len = ser_cmd_utf16.len(); + let mut ser_data = Vec::new(); + ser_data.extend_from_slice(&f.ctrl_id.to_le_bytes()); + ser_data.extend_from_slice(&f.properties.to_le_bytes()); + ser_data.push(f.extra_properties); + ser_data.extend_from_slice(&(ser_cmd_len as u16).to_le_bytes()); + for ch in &ser_cmd_utf16 { + ser_data.extend_from_slice(&ch.to_le_bytes()); + } + ser_data.extend_from_slice(&f.field_id.to_le_bytes()); + eprintln!( + " SERIALIZED CTRL_HEADER({} bytes): {:02x?}", + ser_data.len(), + &ser_data[..ser_data.len().min(80)] + ); + if ser_data.len() > 80 { + eprintln!(" ... (truncated, {} more bytes)", ser_data.len() - 80); } } } } + } - // 2) 원본 바이너리에서 직접 레코드 읽어 CTRL_HEADER 비교 - eprintln!("\n --- Raw records from BodyText/Section0 ---"); - let mut cfb = match crate::parser::cfb_reader::CfbReader::open(&data) { - Ok(c) => c, - Err(e) => { - eprintln!(" CFB open error: {:?}", e); - continue; - } - }; - - let section_data = match cfb.read_body_text_section(0, true, false) { - Ok(d) => d, - Err(e) => { - eprintln!(" Section read error: {:?}", e); - continue; - } - }; + // 2) 원본 바이너리에서 직접 레코드 읽어 CTRL_HEADER 비교 + eprintln!("\n --- Raw records from BodyText/Section0 ---"); + let mut cfb = match crate::parser::cfb_reader::CfbReader::open(&data) { + Ok(c) => c, + Err(e) => { + eprintln!(" CFB open error: {:?}", e); + continue; + } + }; - let records = match Record::read_all(§ion_data) { - Ok(r) => r, - Err(e) => { - eprintln!(" Record parse error: {:?}", e); - continue; - } - }; + let section_data = match cfb.read_body_text_section(0, true, false) { + Ok(d) => d, + Err(e) => { + eprintln!(" Section read error: {:?}", e); + continue; + } + }; - // CTRL_HEADER 중 필드인 것만 찾기 - for (ri, rec) in records.iter().enumerate() { - if rec.tag_id != tags::HWPTAG_CTRL_HEADER || rec.data.len() < 4 { - continue; - } - let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - if !tags::is_field_ctrl_id(ctrl_id) { - continue; - } - let ctrl_id_bytes = ctrl_id.to_le_bytes(); - let ctrl_id_str = String::from_utf8_lossy(&ctrl_id_bytes); - eprintln!("\n [raw rec={}] CTRL_HEADER ctrl_id=0x{:08x}({}) level={} size={}", - ri, ctrl_id, ctrl_id_str, rec.level, rec.size); - eprintln!(" raw data({} bytes): {:02x?}", rec.data.len(), - &rec.data[..rec.data.len().min(120)]); - if rec.data.len() > 120 { - eprintln!(" ... (truncated, {} more bytes)", rec.data.len() - 120); - } + let records = match Record::read_all(§ion_data) { + Ok(r) => r, + Err(e) => { + eprintln!(" Record parse error: {:?}", e); + continue; + } + }; - // 바로 다음 레코드가 CTRL_DATA인지 확인 - if ri + 1 < records.len() && records[ri + 1].tag_id == tags::HWPTAG_CTRL_DATA { - let cd = &records[ri + 1]; - eprintln!(" CTRL_DATA[rec={}]({} bytes): {:02x?}", - ri + 1, cd.data.len(), &cd.data[..cd.data.len().min(80)]); - } + // CTRL_HEADER 중 필드인 것만 찾기 + for (ri, rec) in records.iter().enumerate() { + if rec.tag_id != tags::HWPTAG_CTRL_HEADER || rec.data.len() < 4 { + continue; + } + let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); + if !tags::is_field_ctrl_id(ctrl_id) { + continue; + } + let ctrl_id_bytes = ctrl_id.to_le_bytes(); + let ctrl_id_str = String::from_utf8_lossy(&ctrl_id_bytes); + eprintln!( + "\n [raw rec={}] CTRL_HEADER ctrl_id=0x{:08x}({}) level={} size={}", + ri, ctrl_id, ctrl_id_str, rec.level, rec.size + ); + eprintln!( + " raw data({} bytes): {:02x?}", + rec.data.len(), + &rec.data[..rec.data.len().min(120)] + ); + if rec.data.len() > 120 { + eprintln!(" ... (truncated, {} more bytes)", rec.data.len() - 120); + } + + // 바로 다음 레코드가 CTRL_DATA인지 확인 + if ri + 1 < records.len() && records[ri + 1].tag_id == tags::HWPTAG_CTRL_DATA { + let cd = &records[ri + 1]; + eprintln!( + " CTRL_DATA[rec={}]({} bytes): {:02x?}", + ri + 1, + cd.data.len(), + &cd.data[..cd.data.len().min(80)] + ); } } - - eprintln!("\n\n=== COMPARISON SUMMARY ==="); - eprintln!("Check above for differences in:"); - eprintln!(" - CTRL_HEADER raw data sizes and content"); - eprintln!(" - CTRL_DATA presence and content"); - eprintln!(" - command string differences"); - eprintln!(" - field_id / properties / extra_properties differences"); } - #[test] - fn diag_field07_vs_field03h() { - use crate::model::control::{Control, FieldType}; - use crate::parser::record::Record; - use crate::parser::tags; - - let files: &[(&str, &str)] = &[ - ("samples/field-01.hwp", "ORIGINAL"), - ("saved/field-03-h.hwp", "HANCOM_SAVED"), - ("saved/field-07.hwp", "OUR_SAVED"), - ]; + eprintln!("\n\n=== COMPARISON SUMMARY ==="); + eprintln!("Check above for differences in:"); + eprintln!(" - CTRL_HEADER raw data sizes and content"); + eprintln!(" - CTRL_DATA presence and content"); + eprintln!(" - command string differences"); + eprintln!(" - field_id / properties / extra_properties differences"); +} + +#[test] +fn diag_field07_vs_field03h() { + use crate::model::control::{Control, FieldType}; + use crate::parser::record::Record; + use crate::parser::tags; + + let files: &[(&str, &str)] = &[ + ("samples/field-01.hwp", "ORIGINAL"), + ("saved/field-03-h.hwp", "HANCOM_SAVED"), + ("saved/field-07.hwp", "OUR_SAVED"), + ]; + + // ============================================================ + // PHASE 1: High-level model comparison + // ============================================================ + for (path, label) in files { + eprintln!("\n{}", "=".repeat(70)); + eprintln!("=== PHASE 1: MODEL — {} ({}) ===", label, path); + eprintln!("{}", "=".repeat(70)); - // ============================================================ - // PHASE 1: High-level model comparison - // ============================================================ - for (path, label) in files { - eprintln!("\n{}", "=".repeat(70)); - eprintln!("=== PHASE 1: MODEL — {} ({}) ===", label, path); - eprintln!("{}", "=".repeat(70)); + let Ok(data) = std::fs::read(path) else { + eprintln!(" (파일 없음 - 건너뜀)"); + continue; + }; + let Ok(doc) = HwpDocument::from_bytes(&data) else { + eprintln!(" (파싱 실패)"); + continue; + }; - let Ok(data) = std::fs::read(path) else { - eprintln!(" (파일 없음 - 건너뜀)"); - continue; - }; - let Ok(doc) = HwpDocument::from_bytes(&data) else { - eprintln!(" (파싱 실패)"); - continue; - }; + // Section-level summary + for (si, sec) in doc.document.sections.iter().enumerate() { + eprintln!("\n Section {}: {} paragraphs", si, sec.paragraphs.len()); + eprintln!( + " section_def page_def: {}x{}", + sec.section_def.page_def.width, sec.section_def.page_def.height + ); - // Section-level summary - for (si, sec) in doc.document.sections.iter().enumerate() { - eprintln!("\n Section {}: {} paragraphs", si, sec.paragraphs.len()); - eprintln!(" section_def page_def: {}x{}", sec.section_def.page_def.width, sec.section_def.page_def.height); + for (pi, para) in sec.paragraphs.iter().enumerate() { + // Find paragraphs with ClickHere fields (E-Mail) + let has_email_field = para.controls.iter().any(|c| { + if let Control::Field(f) = c { + f.field_type == FieldType::ClickHere + && (f.command.contains("메일") + || f.command.contains("mail") + || f.command.contains("Mail") + || f.field_name().map_or(false, |n| { + n.contains("메일") || n.contains("mail") || n.contains("Mail") + }) + || f.ctrl_data_name.as_ref().map_or(false, |n| { + n.contains("메일") || n.contains("mail") || n.contains("Mail") + })) + } else { + false + } + }); - for (pi, para) in sec.paragraphs.iter().enumerate() { - // Find paragraphs with ClickHere fields (E-Mail) - let has_email_field = para.controls.iter().any(|c| { - if let Control::Field(f) = c { - f.field_type == FieldType::ClickHere && - (f.command.contains("메일") || f.command.contains("mail") || f.command.contains("Mail") || - f.field_name().map_or(false, |n| n.contains("메일") || n.contains("mail") || n.contains("Mail")) || - f.ctrl_data_name.as_ref().map_or(false, |n| n.contains("메일") || n.contains("mail") || n.contains("Mail"))) + if !has_email_field { + // Brief summary for non-email paragraphs + eprintln!( + " para[{}]: char_count={} control_mask=0x{:08x} text={:?} controls={}", + pi, + para.char_count, + para.control_mask, + if para.text.len() > 40 { + format!("{}...", ¶.text[..40]) } else { - false - } - }); + para.text.clone() + }, + para.controls.len() + ); + continue; + } - if !has_email_field { - // Brief summary for non-email paragraphs - eprintln!(" para[{}]: char_count={} control_mask=0x{:08x} text={:?} controls={}", - pi, para.char_count, para.control_mask, - if para.text.len() > 40 { format!("{}...", ¶.text[..40]) } else { para.text.clone() }, - para.controls.len()); - continue; - } + // DETAILED dump for E-Mail paragraph + eprintln!("\n *** E-MAIL PARAGRAPH [sec={} para={}] ***", si, pi); + eprintln!( + " char_count: {} (0x{:08x})", + para.char_count, para.char_count + ); + eprintln!(" char_count_msb: {}", para.char_count_msb); + eprintln!(" control_mask: 0x{:08x}", para.control_mask); + eprintln!(" para_shape_id: {}", para.para_shape_id); + eprintln!(" style_id: {}", para.style_id); + eprintln!(" column_type: {:?}", para.column_type); + eprintln!(" raw_break_type: 0x{:02x}", para.raw_break_type); + eprintln!( + " raw_header_extra({} bytes): {:02x?}", + para.raw_header_extra.len(), + para.raw_header_extra + ); + eprintln!(" has_para_text: {}", para.has_para_text); + eprintln!( + " text({} chars): {:?}", + para.text.chars().count(), + para.text + ); + eprintln!( + " char_offsets({} entries): {:?}", + para.char_offsets.len(), + para.char_offsets + ); + + // PARA_CHAR_SHAPE + eprintln!(" char_shapes({} entries):", para.char_shapes.len()); + for (i, cs) in para.char_shapes.iter().enumerate() { + eprintln!( + " [{}] pos={} shape_id={}", + i, cs.start_pos, cs.char_shape_id + ); + } - // DETAILED dump for E-Mail paragraph - eprintln!("\n *** E-MAIL PARAGRAPH [sec={} para={}] ***", si, pi); - eprintln!(" char_count: {} (0x{:08x})", para.char_count, para.char_count); - eprintln!(" char_count_msb: {}", para.char_count_msb); - eprintln!(" control_mask: 0x{:08x}", para.control_mask); - eprintln!(" para_shape_id: {}", para.para_shape_id); - eprintln!(" style_id: {}", para.style_id); - eprintln!(" column_type: {:?}", para.column_type); - eprintln!(" raw_break_type: 0x{:02x}", para.raw_break_type); - eprintln!(" raw_header_extra({} bytes): {:02x?}", para.raw_header_extra.len(), para.raw_header_extra); - eprintln!(" has_para_text: {}", para.has_para_text); - eprintln!(" text({} chars): {:?}", para.text.chars().count(), para.text); - eprintln!(" char_offsets({} entries): {:?}", para.char_offsets.len(), para.char_offsets); - - // PARA_CHAR_SHAPE - eprintln!(" char_shapes({} entries):", para.char_shapes.len()); - for (i, cs) in para.char_shapes.iter().enumerate() { - eprintln!(" [{}] pos={} shape_id={}", i, cs.start_pos, cs.char_shape_id); - } - - // PARA_LINE_SEG - eprintln!(" line_segs({} entries):", para.line_segs.len()); - for (i, ls) in para.line_segs.iter().enumerate() { - eprintln!(" [{}] start={} vpos={} height={} text_h={} baseline={} spacing={} col_start={} seg_w={} tag=0x{:08x}", + // PARA_LINE_SEG + eprintln!(" line_segs({} entries):", para.line_segs.len()); + for (i, ls) in para.line_segs.iter().enumerate() { + eprintln!(" [{}] start={} vpos={} height={} text_h={} baseline={} spacing={} col_start={} seg_w={} tag=0x{:08x}", i, ls.text_start, ls.vertical_pos, ls.line_height, ls.text_height, ls.baseline_distance, ls.line_spacing, ls.column_start, ls.segment_width, ls.tag); - } + } - // PARA_RANGE_TAG - eprintln!(" range_tags({} entries):", para.range_tags.len()); - for (i, rt) in para.range_tags.iter().enumerate() { - eprintln!(" [{}] start={} end={} tag=0x{:08x}", i, rt.start, rt.end, rt.tag); - } + // PARA_RANGE_TAG + eprintln!(" range_tags({} entries):", para.range_tags.len()); + for (i, rt) in para.range_tags.iter().enumerate() { + eprintln!( + " [{}] start={} end={} tag=0x{:08x}", + i, rt.start, rt.end, rt.tag + ); + } - // field_ranges - eprintln!(" field_ranges({} entries):", para.field_ranges.len()); - for (i, fr) in para.field_ranges.iter().enumerate() { - eprintln!(" [{}] start_char={} end_char={} ctrl_idx={}", i, fr.start_char_idx, fr.end_char_idx, fr.control_idx); - } + // field_ranges + eprintln!(" field_ranges({} entries):", para.field_ranges.len()); + for (i, fr) in para.field_ranges.iter().enumerate() { + eprintln!( + " [{}] start_char={} end_char={} ctrl_idx={}", + i, fr.start_char_idx, fr.end_char_idx, fr.control_idx + ); + } - // Controls + CTRL_DATA - eprintln!(" controls({} entries):", para.controls.len()); - for (ci, ctrl) in para.controls.iter().enumerate() { - if let Control::Field(f) = ctrl { - eprintln!(" [{}] FIELD: type={:?} ctrl_id=0x{:08x}({}) field_id={} props=0x{:08x} extra=0x{:02x}", + // Controls + CTRL_DATA + eprintln!(" controls({} entries):", para.controls.len()); + for (ci, ctrl) in para.controls.iter().enumerate() { + if let Control::Field(f) = ctrl { + eprintln!(" [{}] FIELD: type={:?} ctrl_id=0x{:08x}({}) field_id={} props=0x{:08x} extra=0x{:02x}", ci, f.field_type, f.ctrl_id, String::from_utf8_lossy(&f.ctrl_id.to_le_bytes()), f.field_id, f.properties, f.extra_properties); - eprintln!(" command({} chars): {:?}", f.command.len(), f.command); - eprintln!(" ctrl_data_name: {:?}", f.ctrl_data_name); - eprintln!(" field_name(): {:?}", f.field_name()); - eprintln!(" guide_text(): {:?}", f.guide_text()); - } else { - eprintln!(" [{}] {:?}", ci, std::mem::discriminant(ctrl)); - } + eprintln!( + " command({} chars): {:?}", + f.command.len(), + f.command + ); + eprintln!(" ctrl_data_name: {:?}", f.ctrl_data_name); + eprintln!(" field_name(): {:?}", f.field_name()); + eprintln!(" guide_text(): {:?}", f.guide_text()); + } else { + eprintln!(" [{}] {:?}", ci, std::mem::discriminant(ctrl)); + } - // CTRL_DATA - if let Some(Some(cd)) = para.ctrl_data_records.get(ci) { - eprintln!(" CTRL_DATA[{}]({} bytes): {:02x?}", ci, cd.len(), cd); - } else { - eprintln!(" CTRL_DATA[{}]: None", ci); - } + // CTRL_DATA + if let Some(Some(cd)) = para.ctrl_data_records.get(ci) { + eprintln!(" CTRL_DATA[{}]({} bytes): {:02x?}", ci, cd.len(), cd); + } else { + eprintln!(" CTRL_DATA[{}]: None", ci); } + } - // Serialize PARA_TEXT and dump - let text_data = crate::serializer::body_text::test_serialize_para_text(para); - eprintln!(" SERIALIZED PARA_TEXT({} bytes = {} code units):", text_data.len(), text_data.len() / 2); - // Hex dump in lines of 32 bytes - for chunk_start in (0..text_data.len()).step_by(32) { - let chunk_end = (chunk_start + 32).min(text_data.len()); - let chunk = &text_data[chunk_start..chunk_end]; - eprint!(" {:04x}: ", chunk_start); - for b in chunk { - eprint!("{:02x} ", b); - } - // Also show as u16 code units - eprint!(" | "); - for pair in chunk.chunks_exact(2) { - let cu = u16::from_le_bytes([pair[0], pair[1]]); - if cu >= 0x20 && cu < 0x7F { - eprint!("{} ", cu as u8 as char); - } else if cu >= 0xAC00 && cu <= 0xD7A3 { - eprint!("K "); // Korean - } else { - eprint!("{:04x} ", cu); - } + // Serialize PARA_TEXT and dump + let text_data = crate::serializer::body_text::test_serialize_para_text(para); + eprintln!( + " SERIALIZED PARA_TEXT({} bytes = {} code units):", + text_data.len(), + text_data.len() / 2 + ); + // Hex dump in lines of 32 bytes + for chunk_start in (0..text_data.len()).step_by(32) { + let chunk_end = (chunk_start + 32).min(text_data.len()); + let chunk = &text_data[chunk_start..chunk_end]; + eprint!(" {:04x}: ", chunk_start); + for b in chunk { + eprint!("{:02x} ", b); + } + // Also show as u16 code units + eprint!(" | "); + for pair in chunk.chunks_exact(2) { + let cu = u16::from_le_bytes([pair[0], pair[1]]); + if cu >= 0x20 && cu < 0x7F { + eprint!("{} ", cu as u8 as char); + } else if cu >= 0xAC00 && cu <= 0xD7A3 { + eprint!("K "); // Korean + } else { + eprint!("{:04x} ", cu); } - eprintln!(); } + eprintln!(); } } } + } + + // ============================================================ + // PHASE 2: Raw binary record comparison + // ============================================================ + for (path, label) in files { + eprintln!("\n{}", "=".repeat(70)); + eprintln!("=== PHASE 2: RAW RECORDS — {} ({}) ===", label, path); + eprintln!("{}", "=".repeat(70)); - // ============================================================ - // PHASE 2: Raw binary record comparison - // ============================================================ - for (path, label) in files { - eprintln!("\n{}", "=".repeat(70)); - eprintln!("=== PHASE 2: RAW RECORDS — {} ({}) ===", label, path); - eprintln!("{}", "=".repeat(70)); + let Ok(data) = std::fs::read(path) else { + eprintln!(" (파일 없음 - 건너뜀)"); + continue; + }; - let Ok(data) = std::fs::read(path) else { - eprintln!(" (파일 없음 - 건너뜀)"); + let mut cfb = match crate::parser::cfb_reader::CfbReader::open(&data) { + Ok(c) => c, + Err(e) => { + eprintln!(" CFB open error: {:?}", e); continue; - }; + } + }; + + let section_data = match cfb.read_body_text_section(0, true, false) { + Ok(d) => d, + Err(e) => { + eprintln!(" Section read error: {:?}", e); + continue; + } + }; + + let records = match Record::read_all(§ion_data) { + Ok(r) => r, + Err(e) => { + eprintln!(" Record parse error: {:?}", e); + continue; + } + }; + + eprintln!(" Total records: {}", records.len()); + + // Find the E-Mail paragraph by scanning for field CTRL_HEADER with email-related command + // First pass: identify all PARA_HEADER positions + let mut para_starts: Vec = Vec::new(); + for (ri, rec) in records.iter().enumerate() { + if rec.tag_id == tags::HWPTAG_PARA_HEADER { + para_starts.push(ri); + } + } + eprintln!( + " Paragraph count (from PARA_HEADER records): {}", + para_starts.len() + ); - let mut cfb = match crate::parser::cfb_reader::CfbReader::open(&data) { - Ok(c) => c, - Err(e) => { - eprintln!(" CFB open error: {:?}", e); - continue; - } + // For each paragraph, check if it has a field CTRL_HEADER with email command + for (pi, ¶_start) in para_starts.iter().enumerate() { + let para_end = if pi + 1 < para_starts.len() { + para_starts[pi + 1] + } else { + records.len() }; + let para_records = &records[para_start..para_end]; - let section_data = match cfb.read_body_text_section(0, true, false) { - Ok(d) => d, - Err(e) => { - eprintln!(" Section read error: {:?}", e); - continue; + // Check for email-related field + let has_email = para_records.iter().any(|rec| { + if rec.tag_id != tags::HWPTAG_CTRL_HEADER || rec.data.len() < 11 { + return false; } - }; - - let records = match Record::read_all(§ion_data) { - Ok(r) => r, - Err(e) => { - eprintln!(" Record parse error: {:?}", e); - continue; + let ctrl_id = + u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); + if !tags::is_field_ctrl_id(ctrl_id) { + return false; + } + // Check command string for email + if rec.data.len() >= 11 { + let cmd_len = u16::from_le_bytes([rec.data[9], rec.data[10]]) as usize; + if cmd_len > 0 && rec.data.len() >= 11 + cmd_len * 2 { + let wchars: Vec = rec.data[11..11 + cmd_len * 2] + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let cmd = String::from_utf16_lossy(&wchars); + return cmd.contains("메일") + || cmd.contains("mail") + || cmd.contains("Mail"); + } } - }; - - eprintln!(" Total records: {}", records.len()); + false + }); - // Find the E-Mail paragraph by scanning for field CTRL_HEADER with email-related command - // First pass: identify all PARA_HEADER positions - let mut para_starts: Vec = Vec::new(); - for (ri, rec) in records.iter().enumerate() { - if rec.tag_id == tags::HWPTAG_PARA_HEADER { - para_starts.push(ri); - } + if !has_email { + continue; } - eprintln!(" Paragraph count (from PARA_HEADER records): {}", para_starts.len()); - // For each paragraph, check if it has a field CTRL_HEADER with email command - for (pi, ¶_start) in para_starts.iter().enumerate() { - let para_end = if pi + 1 < para_starts.len() { para_starts[pi + 1] } else { records.len() }; - let para_records = &records[para_start..para_end]; + eprintln!( + "\n *** RAW E-MAIL PARAGRAPH [para_idx={}] (records {}..{}) ***", + pi, para_start, para_end + ); - // Check for email-related field - let has_email = para_records.iter().any(|rec| { - if rec.tag_id != tags::HWPTAG_CTRL_HEADER || rec.data.len() < 11 { - return false; - } - let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - if !tags::is_field_ctrl_id(ctrl_id) { - return false; - } - // Check command string for email - if rec.data.len() >= 11 { - let cmd_len = u16::from_le_bytes([rec.data[9], rec.data[10]]) as usize; - if cmd_len > 0 && rec.data.len() >= 11 + cmd_len * 2 { - let wchars: Vec = rec.data[11..11 + cmd_len * 2] - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect(); - let cmd = String::from_utf16_lossy(&wchars); - return cmd.contains("메일") || cmd.contains("mail") || cmd.contains("Mail"); + for (offset, rec) in para_records.iter().enumerate() { + let ri = para_start + offset; + let tag_name = tags::tag_name(rec.tag_id); + eprintln!( + "\n [rec={}] {} (tag=0x{:04x}) level={} size={}", + ri, + tag_name, + rec.tag_id, + rec.level, + rec.data.len() + ); + + match rec.tag_id { + tags::HWPTAG_PARA_HEADER => { + eprintln!(" raw({} bytes): {:02x?}", rec.data.len(), rec.data); + if rec.data.len() >= 12 { + let char_count_raw = u32::from_le_bytes([ + rec.data[0], + rec.data[1], + rec.data[2], + rec.data[3], + ]); + let control_mask = u32::from_le_bytes([ + rec.data[4], + rec.data[5], + rec.data[6], + rec.data[7], + ]); + let para_shape_id = u16::from_le_bytes([rec.data[8], rec.data[9]]); + let style_id = rec.data[10]; + let break_type = rec.data[11]; + eprintln!( + " char_count_raw=0x{:08x} (count={}, msb={})", + char_count_raw, + char_count_raw & 0x7FFFFFFF, + char_count_raw >> 31 + ); + eprintln!(" control_mask=0x{:08x}", control_mask); + eprintln!( + " para_shape_id={} style_id={} break_type=0x{:02x}", + para_shape_id, style_id, break_type + ); + } + if rec.data.len() >= 18 { + let num_cs = u16::from_le_bytes([rec.data[12], rec.data[13]]); + let num_rt = u16::from_le_bytes([rec.data[14], rec.data[15]]); + let num_ls = u16::from_le_bytes([rec.data[16], rec.data[17]]); + eprintln!( + " numCharShapes={} numRangeTags={} numLineSegs={}", + num_cs, num_rt, num_ls + ); + } + if rec.data.len() >= 22 { + let instance_id = u32::from_le_bytes([ + rec.data[18], + rec.data[19], + rec.data[20], + rec.data[21], + ]); + eprintln!(" instanceId={}", instance_id); + } + if rec.data.len() > 22 { + eprintln!( + " extra bytes after instanceId: {:02x?}", + &rec.data[22..] + ); } } - false - }); - - if !has_email { - continue; - } - - eprintln!("\n *** RAW E-MAIL PARAGRAPH [para_idx={}] (records {}..{}) ***", pi, para_start, para_end); - - for (offset, rec) in para_records.iter().enumerate() { - let ri = para_start + offset; - let tag_name = tags::tag_name(rec.tag_id); - eprintln!("\n [rec={}] {} (tag=0x{:04x}) level={} size={}", - ri, tag_name, rec.tag_id, rec.level, rec.data.len()); - - match rec.tag_id { - tags::HWPTAG_PARA_HEADER => { - eprintln!(" raw({} bytes): {:02x?}", rec.data.len(), rec.data); - if rec.data.len() >= 12 { - let char_count_raw = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - let control_mask = u32::from_le_bytes([rec.data[4], rec.data[5], rec.data[6], rec.data[7]]); - let para_shape_id = u16::from_le_bytes([rec.data[8], rec.data[9]]); - let style_id = rec.data[10]; - let break_type = rec.data[11]; - eprintln!(" char_count_raw=0x{:08x} (count={}, msb={})", - char_count_raw, char_count_raw & 0x7FFFFFFF, char_count_raw >> 31); - eprintln!(" control_mask=0x{:08x}", control_mask); - eprintln!(" para_shape_id={} style_id={} break_type=0x{:02x}", para_shape_id, style_id, break_type); - } - if rec.data.len() >= 18 { - let num_cs = u16::from_le_bytes([rec.data[12], rec.data[13]]); - let num_rt = u16::from_le_bytes([rec.data[14], rec.data[15]]); - let num_ls = u16::from_le_bytes([rec.data[16], rec.data[17]]); - eprintln!(" numCharShapes={} numRangeTags={} numLineSegs={}", num_cs, num_rt, num_ls); - } - if rec.data.len() >= 22 { - let instance_id = u32::from_le_bytes([rec.data[18], rec.data[19], rec.data[20], rec.data[21]]); - eprintln!(" instanceId={}", instance_id); - } - if rec.data.len() > 22 { - eprintln!(" extra bytes after instanceId: {:02x?}", &rec.data[22..]); + tags::HWPTAG_PARA_TEXT => { + eprintln!( + " raw({} bytes = {} code units):", + rec.data.len(), + rec.data.len() / 2 + ); + for chunk_start in (0..rec.data.len()).step_by(32) { + let chunk_end = (chunk_start + 32).min(rec.data.len()); + let chunk = &rec.data[chunk_start..chunk_end]; + eprint!(" {:04x}: ", chunk_start); + for b in chunk { + eprint!("{:02x} ", b); } - } - tags::HWPTAG_PARA_TEXT => { - eprintln!(" raw({} bytes = {} code units):", rec.data.len(), rec.data.len() / 2); - for chunk_start in (0..rec.data.len()).step_by(32) { - let chunk_end = (chunk_start + 32).min(rec.data.len()); - let chunk = &rec.data[chunk_start..chunk_end]; - eprint!(" {:04x}: ", chunk_start); - for b in chunk { - eprint!("{:02x} ", b); - } - eprint!(" | "); - for pair in chunk.chunks_exact(2) { - let cu = u16::from_le_bytes([pair[0], pair[1]]); - if cu >= 0x20 && cu < 0x7F { - eprint!("{} ", cu as u8 as char); - } else if cu >= 0xAC00 && cu <= 0xD7A3 { - eprint!("K "); - } else { - eprint!("{:04x} ", cu); - } + eprint!(" | "); + for pair in chunk.chunks_exact(2) { + let cu = u16::from_le_bytes([pair[0], pair[1]]); + if cu >= 0x20 && cu < 0x7F { + eprint!("{} ", cu as u8 as char); + } else if cu >= 0xAC00 && cu <= 0xD7A3 { + eprint!("K "); + } else { + eprint!("{:04x} ", cu); } - eprintln!(); } + eprintln!(); } - tags::HWPTAG_PARA_CHAR_SHAPE => { - eprintln!(" raw({} bytes, {} entries):", rec.data.len(), rec.data.len() / 8); - for i in (0..rec.data.len()).step_by(8) { - if i + 8 <= rec.data.len() { - let pos = u32::from_le_bytes([rec.data[i], rec.data[i+1], rec.data[i+2], rec.data[i+3]]); - let id = u32::from_le_bytes([rec.data[i+4], rec.data[i+5], rec.data[i+6], rec.data[i+7]]); - eprintln!(" pos={} shape_id={}", pos, id); - } + } + tags::HWPTAG_PARA_CHAR_SHAPE => { + eprintln!( + " raw({} bytes, {} entries):", + rec.data.len(), + rec.data.len() / 8 + ); + for i in (0..rec.data.len()).step_by(8) { + if i + 8 <= rec.data.len() { + let pos = u32::from_le_bytes([ + rec.data[i], + rec.data[i + 1], + rec.data[i + 2], + rec.data[i + 3], + ]); + let id = u32::from_le_bytes([ + rec.data[i + 4], + rec.data[i + 5], + rec.data[i + 6], + rec.data[i + 7], + ]); + eprintln!(" pos={} shape_id={}", pos, id); } } - tags::HWPTAG_PARA_LINE_SEG => { - eprintln!(" raw({} bytes, {} entries):", rec.data.len(), rec.data.len() / 36); - for i in (0..rec.data.len()).step_by(36) { - if i + 36 <= rec.data.len() { - let d = &rec.data[i..i+36]; - let text_start = u32::from_le_bytes([d[0], d[1], d[2], d[3]]); - let vpos = i32::from_le_bytes([d[4], d[5], d[6], d[7]]); - let height = i32::from_le_bytes([d[8], d[9], d[10], d[11]]); - let text_h = i32::from_le_bytes([d[12], d[13], d[14], d[15]]); - let baseline = i32::from_le_bytes([d[16], d[17], d[18], d[19]]); - let spacing = i32::from_le_bytes([d[20], d[21], d[22], d[23]]); - let col_start = i32::from_le_bytes([d[24], d[25], d[26], d[27]]); - let seg_w = i32::from_le_bytes([d[28], d[29], d[30], d[31]]); - let tag_val = u32::from_le_bytes([d[32], d[33], d[34], d[35]]); - eprintln!(" start={} vpos={} h={} th={} bl={} sp={} cs={} sw={} tag=0x{:08x}", + } + tags::HWPTAG_PARA_LINE_SEG => { + eprintln!( + " raw({} bytes, {} entries):", + rec.data.len(), + rec.data.len() / 36 + ); + for i in (0..rec.data.len()).step_by(36) { + if i + 36 <= rec.data.len() { + let d = &rec.data[i..i + 36]; + let text_start = u32::from_le_bytes([d[0], d[1], d[2], d[3]]); + let vpos = i32::from_le_bytes([d[4], d[5], d[6], d[7]]); + let height = i32::from_le_bytes([d[8], d[9], d[10], d[11]]); + let text_h = i32::from_le_bytes([d[12], d[13], d[14], d[15]]); + let baseline = i32::from_le_bytes([d[16], d[17], d[18], d[19]]); + let spacing = i32::from_le_bytes([d[20], d[21], d[22], d[23]]); + let col_start = i32::from_le_bytes([d[24], d[25], d[26], d[27]]); + let seg_w = i32::from_le_bytes([d[28], d[29], d[30], d[31]]); + let tag_val = u32::from_le_bytes([d[32], d[33], d[34], d[35]]); + eprintln!(" start={} vpos={} h={} th={} bl={} sp={} cs={} sw={} tag=0x{:08x}", text_start, vpos, height, text_h, baseline, spacing, col_start, seg_w, tag_val); - } } } - tags::HWPTAG_PARA_RANGE_TAG => { - eprintln!(" raw({} bytes, {} entries):", rec.data.len(), rec.data.len() / 12); - for i in (0..rec.data.len()).step_by(12) { - if i + 12 <= rec.data.len() { - let start = u32::from_le_bytes([rec.data[i], rec.data[i+1], rec.data[i+2], rec.data[i+3]]); - let end = u32::from_le_bytes([rec.data[i+4], rec.data[i+5], rec.data[i+6], rec.data[i+7]]); - let tag_val = u32::from_le_bytes([rec.data[i+8], rec.data[i+9], rec.data[i+10], rec.data[i+11]]); - eprintln!(" start={} end={} tag=0x{:08x}", start, end, tag_val); - } + } + tags::HWPTAG_PARA_RANGE_TAG => { + eprintln!( + " raw({} bytes, {} entries):", + rec.data.len(), + rec.data.len() / 12 + ); + for i in (0..rec.data.len()).step_by(12) { + if i + 12 <= rec.data.len() { + let start = u32::from_le_bytes([ + rec.data[i], + rec.data[i + 1], + rec.data[i + 2], + rec.data[i + 3], + ]); + let end = u32::from_le_bytes([ + rec.data[i + 4], + rec.data[i + 5], + rec.data[i + 6], + rec.data[i + 7], + ]); + let tag_val = u32::from_le_bytes([ + rec.data[i + 8], + rec.data[i + 9], + rec.data[i + 10], + rec.data[i + 11], + ]); + eprintln!( + " start={} end={} tag=0x{:08x}", + start, end, tag_val + ); } } - tags::HWPTAG_CTRL_HEADER => { - if rec.data.len() >= 4 { - let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - let ctrl_id_bytes = ctrl_id.to_le_bytes(); - let ctrl_id_str = String::from_utf8_lossy(&ctrl_id_bytes); - eprintln!(" ctrl_id=0x{:08x}({})", ctrl_id, ctrl_id_str); - - if tags::is_field_ctrl_id(ctrl_id) { - eprintln!(" *** FIELD CTRL_HEADER DETAIL ***"); - eprintln!(" full raw({} bytes): {:02x?}", rec.data.len(), rec.data); - if rec.data.len() >= 11 { - let props = u32::from_le_bytes([rec.data[4], rec.data[5], rec.data[6], rec.data[7]]); - let extra = rec.data[8]; - let cmd_len = u16::from_le_bytes([rec.data[9], rec.data[10]]) as usize; - eprintln!(" properties=0x{:08x} extra_properties=0x{:02x} command_len={}", props, extra, cmd_len); - if cmd_len > 0 && rec.data.len() >= 11 + cmd_len * 2 { - let wchars: Vec = rec.data[11..11 + cmd_len * 2] - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect(); - let cmd = String::from_utf16_lossy(&wchars); - eprintln!(" command: {:?}", cmd); - } - let field_id_offset = 11 + cmd_len * 2; - if rec.data.len() >= field_id_offset + 4 { - let field_id = u32::from_le_bytes([ - rec.data[field_id_offset], rec.data[field_id_offset+1], - rec.data[field_id_offset+2], rec.data[field_id_offset+3]]); - eprintln!(" field_id={} (0x{:08x})", field_id, field_id); - } - // Any bytes after field_id? - let expected_end = field_id_offset + 4; - if rec.data.len() > expected_end { - eprintln!(" *** EXTRA BYTES AFTER field_id ({} bytes): {:02x?}", + } + tags::HWPTAG_CTRL_HEADER => { + if rec.data.len() >= 4 { + let ctrl_id = u32::from_le_bytes([ + rec.data[0], + rec.data[1], + rec.data[2], + rec.data[3], + ]); + let ctrl_id_bytes = ctrl_id.to_le_bytes(); + let ctrl_id_str = String::from_utf8_lossy(&ctrl_id_bytes); + eprintln!(" ctrl_id=0x{:08x}({})", ctrl_id, ctrl_id_str); + + if tags::is_field_ctrl_id(ctrl_id) { + eprintln!(" *** FIELD CTRL_HEADER DETAIL ***"); + eprintln!( + " full raw({} bytes): {:02x?}", + rec.data.len(), + rec.data + ); + if rec.data.len() >= 11 { + let props = u32::from_le_bytes([ + rec.data[4], + rec.data[5], + rec.data[6], + rec.data[7], + ]); + let extra = rec.data[8]; + let cmd_len = + u16::from_le_bytes([rec.data[9], rec.data[10]]) as usize; + eprintln!(" properties=0x{:08x} extra_properties=0x{:02x} command_len={}", props, extra, cmd_len); + if cmd_len > 0 && rec.data.len() >= 11 + cmd_len * 2 { + let wchars: Vec = rec.data[11..11 + cmd_len * 2] + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let cmd = String::from_utf16_lossy(&wchars); + eprintln!(" command: {:?}", cmd); + } + let field_id_offset = 11 + cmd_len * 2; + if rec.data.len() >= field_id_offset + 4 { + let field_id = u32::from_le_bytes([ + rec.data[field_id_offset], + rec.data[field_id_offset + 1], + rec.data[field_id_offset + 2], + rec.data[field_id_offset + 3], + ]); + eprintln!( + " field_id={} (0x{:08x})", + field_id, field_id + ); + } + // Any bytes after field_id? + let expected_end = field_id_offset + 4; + if rec.data.len() > expected_end { + eprintln!(" *** EXTRA BYTES AFTER field_id ({} bytes): {:02x?}", rec.data.len() - expected_end, &rec.data[expected_end..]); - } } - } else { - eprintln!(" raw({} bytes): {:02x?}", rec.data.len(), - &rec.data[..rec.data.len().min(80)]); } + } else { + eprintln!( + " raw({} bytes): {:02x?}", + rec.data.len(), + &rec.data[..rec.data.len().min(80)] + ); } } - tags::HWPTAG_CTRL_DATA => { - eprintln!(" raw({} bytes): {:02x?}", rec.data.len(), rec.data); - // Parse CTRL_DATA for field: typically has name - if rec.data.len() >= 12 { - eprintln!(" header(0..10): {:02x?}", &rec.data[..10]); - let name_len = u16::from_le_bytes([rec.data[10], rec.data[11]]) as usize; - eprintln!(" name_len={}", name_len); - if name_len > 0 && rec.data.len() >= 12 + name_len * 2 { - let wchars: Vec = rec.data[12..12 + name_len * 2] - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect(); - let name = String::from_utf16_lossy(&wchars); - eprintln!(" name: {:?}", name); - } + } + tags::HWPTAG_CTRL_DATA => { + eprintln!(" raw({} bytes): {:02x?}", rec.data.len(), rec.data); + // Parse CTRL_DATA for field: typically has name + if rec.data.len() >= 12 { + eprintln!(" header(0..10): {:02x?}", &rec.data[..10]); + let name_len = + u16::from_le_bytes([rec.data[10], rec.data[11]]) as usize; + eprintln!(" name_len={}", name_len); + if name_len > 0 && rec.data.len() >= 12 + name_len * 2 { + let wchars: Vec = rec.data[12..12 + name_len * 2] + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let name = String::from_utf16_lossy(&wchars); + eprintln!(" name: {:?}", name); } } - _ => { - eprintln!(" raw({} bytes): {:02x?}", rec.data.len(), - &rec.data[..rec.data.len().min(40)]); - } + } + _ => { + eprintln!( + " raw({} bytes): {:02x?}", + rec.data.len(), + &rec.data[..rec.data.len().min(40)] + ); } } } } + } - // ============================================================ - // PHASE 3: TAB extended data comparison - // ============================================================ - eprintln!("\n{}", "=".repeat(70)); - eprintln!("=== PHASE 3: TAB EXTENDED DATA COMPARISON ==="); - eprintln!("{}", "=".repeat(70)); + // ============================================================ + // PHASE 3: TAB extended data comparison + // ============================================================ + eprintln!("\n{}", "=".repeat(70)); + eprintln!("=== PHASE 3: TAB EXTENDED DATA COMPARISON ==="); + eprintln!("{}", "=".repeat(70)); - for (path, label) in files { - eprintln!("\n --- {} ({}) ---", label, path); + for (path, label) in files { + eprintln!("\n --- {} ({}) ---", label, path); - let Ok(data) = std::fs::read(path) else { - eprintln!(" (파일 없음 - 건너뜀)"); - continue; - }; + let Ok(data) = std::fs::read(path) else { + eprintln!(" (파일 없음 - 건너뜀)"); + continue; + }; - let mut cfb = match crate::parser::cfb_reader::CfbReader::open(&data) { - Ok(c) => c, - Err(e) => { - eprintln!(" CFB open error: {:?}", e); - continue; - } - }; + let mut cfb = match crate::parser::cfb_reader::CfbReader::open(&data) { + Ok(c) => c, + Err(e) => { + eprintln!(" CFB open error: {:?}", e); + continue; + } + }; - let section_data = match cfb.read_body_text_section(0, true, false) { - Ok(d) => d, - Err(e) => { - eprintln!(" Section read error: {:?}", e); - continue; - } - }; + let section_data = match cfb.read_body_text_section(0, true, false) { + Ok(d) => d, + Err(e) => { + eprintln!(" Section read error: {:?}", e); + continue; + } + }; - let records = match Record::read_all(§ion_data) { - Ok(r) => r, - Err(e) => { - eprintln!(" Record parse error: {:?}", e); - continue; - } - }; + let records = match Record::read_all(§ion_data) { + Ok(r) => r, + Err(e) => { + eprintln!(" Record parse error: {:?}", e); + continue; + } + }; - // Find all PARA_TEXT records and check TAB extended data - let mut tab_count = 0; - let mut tab_zeroed = 0; - let mut tab_nonzero = 0; - for rec in &records { - if rec.tag_id != tags::HWPTAG_PARA_TEXT { continue; } - // Scan for TAB characters (0x0009) - let code_units: Vec = rec.data.chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect(); - for (i, &cu) in code_units.iter().enumerate() { - if cu == 0x0009 && i + 7 < code_units.len() { - tab_count += 1; - let ext: Vec = code_units[i+1..i+8].to_vec(); - let all_zero = ext.iter().all(|&x| x == 0); - if all_zero { - tab_zeroed += 1; - } else { - tab_nonzero += 1; - eprintln!(" TAB at cu_offset={}: extended data = {:04x?}", i, ext); - } + // Find all PARA_TEXT records and check TAB extended data + let mut tab_count = 0; + let mut tab_zeroed = 0; + let mut tab_nonzero = 0; + for rec in &records { + if rec.tag_id != tags::HWPTAG_PARA_TEXT { + continue; + } + // Scan for TAB characters (0x0009) + let code_units: Vec = rec + .data + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + for (i, &cu) in code_units.iter().enumerate() { + if cu == 0x0009 && i + 7 < code_units.len() { + tab_count += 1; + let ext: Vec = code_units[i + 1..i + 8].to_vec(); + let all_zero = ext.iter().all(|&x| x == 0); + if all_zero { + tab_zeroed += 1; + } else { + tab_nonzero += 1; + eprintln!(" TAB at cu_offset={}: extended data = {:04x?}", i, ext); } } } - eprintln!(" TABs total={} zeroed={} nonzero={}", tab_count, tab_zeroed, tab_nonzero); } + eprintln!( + " TABs total={} zeroed={} nonzero={}", + tab_count, tab_zeroed, tab_nonzero + ); + } - // ============================================================ - // PHASE 4: Full section record count comparison - // ============================================================ - eprintln!("\n{}", "=".repeat(70)); - eprintln!("=== PHASE 4: SECTION RECORD SUMMARY ==="); - eprintln!("{}", "=".repeat(70)); - - for (path, label) in files { - eprintln!("\n --- {} ({}) ---", label, path); - - let Ok(data) = std::fs::read(path) else { - eprintln!(" (파일 없음 - 건너뜀)"); - continue; - }; + // ============================================================ + // PHASE 4: Full section record count comparison + // ============================================================ + eprintln!("\n{}", "=".repeat(70)); + eprintln!("=== PHASE 4: SECTION RECORD SUMMARY ==="); + eprintln!("{}", "=".repeat(70)); - let mut cfb = match crate::parser::cfb_reader::CfbReader::open(&data) { - Ok(c) => c, - Err(e) => { - eprintln!(" CFB open error: {:?}", e); - continue; - } - }; + for (path, label) in files { + eprintln!("\n --- {} ({}) ---", label, path); - let section_data = match cfb.read_body_text_section(0, true, false) { - Ok(d) => d, - Err(e) => { - eprintln!(" Section read error: {:?}", e); - continue; - } - }; + let Ok(data) = std::fs::read(path) else { + eprintln!(" (파일 없음 - 건너뜀)"); + continue; + }; - let records = match Record::read_all(§ion_data) { - Ok(r) => r, - Err(e) => { - eprintln!(" Record parse error: {:?}", e); - continue; - } - }; + let mut cfb = match crate::parser::cfb_reader::CfbReader::open(&data) { + Ok(c) => c, + Err(e) => { + eprintln!(" CFB open error: {:?}", e); + continue; + } + }; - // Count by tag type - use std::collections::BTreeMap; - let mut tag_counts: BTreeMap = BTreeMap::new(); - for rec in &records { - *tag_counts.entry(rec.tag_id).or_insert(0) += 1; + let section_data = match cfb.read_body_text_section(0, true, false) { + Ok(d) => d, + Err(e) => { + eprintln!(" Section read error: {:?}", e); + continue; } - for (tag_id, count) in &tag_counts { - eprintln!(" {} (0x{:04x}): {} records", tags::tag_name(*tag_id), tag_id, count); + }; + + let records = match Record::read_all(§ion_data) { + Ok(r) => r, + Err(e) => { + eprintln!(" Record parse error: {:?}", e); + continue; } - eprintln!(" Total: {} records", records.len()); - } + }; - // ============================================================ - // PHASE 5: Byte-level diff of serialized vs original PARA_TEXT for E-Mail paragraph - // ============================================================ - eprintln!("\n{}", "=".repeat(70)); - eprintln!("=== PHASE 5: SERIALIZED vs ORIGINAL PARA_TEXT DIFF ==="); - eprintln!("{}", "=".repeat(70)); + // Count by tag type + use std::collections::BTreeMap; + let mut tag_counts: BTreeMap = BTreeMap::new(); + for rec in &records { + *tag_counts.entry(rec.tag_id).or_insert(0) += 1; + } + for (tag_id, count) in &tag_counts { + eprintln!( + " {} (0x{:04x}): {} records", + tags::tag_name(*tag_id), + tag_id, + count + ); + } + eprintln!(" Total: {} records", records.len()); + } - // Compare our serialized output for saved/field-07.hwp against its raw PARA_TEXT - if let Ok(data) = std::fs::read("saved/field-07.hwp") { - if let Ok(doc) = HwpDocument::from_bytes(&data) { - // Find the E-Mail paragraph - for sec in &doc.document.sections { - for para in &sec.paragraphs { - let has_email = para.controls.iter().any(|c| { - if let Control::Field(f) = c { - f.field_type == FieldType::ClickHere && - (f.command.contains("메일") || f.command.contains("mail") || f.command.contains("Mail") || - f.ctrl_data_name.as_ref().map_or(false, |n| n.contains("메일") || n.contains("mail") || n.contains("Mail"))) - } else { false } - }); - if !has_email { continue; } + // ============================================================ + // PHASE 5: Byte-level diff of serialized vs original PARA_TEXT for E-Mail paragraph + // ============================================================ + eprintln!("\n{}", "=".repeat(70)); + eprintln!("=== PHASE 5: SERIALIZED vs ORIGINAL PARA_TEXT DIFF ==="); + eprintln!("{}", "=".repeat(70)); + + // Compare our serialized output for saved/field-07.hwp against its raw PARA_TEXT + if let Ok(data) = std::fs::read("saved/field-07.hwp") { + if let Ok(doc) = HwpDocument::from_bytes(&data) { + // Find the E-Mail paragraph + for sec in &doc.document.sections { + for para in &sec.paragraphs { + let has_email = para.controls.iter().any(|c| { + if let Control::Field(f) = c { + f.field_type == FieldType::ClickHere + && (f.command.contains("메일") + || f.command.contains("mail") + || f.command.contains("Mail") + || f.ctrl_data_name.as_ref().map_or(false, |n| { + n.contains("메일") + || n.contains("mail") + || n.contains("Mail") + })) + } else { + false + } + }); + if !has_email { + continue; + } - // Re-serialize - let serialized = crate::serializer::body_text::test_serialize_para_text(para); - eprintln!(" Serialized PARA_TEXT: {} bytes", serialized.len()); + // Re-serialize + let serialized = crate::serializer::body_text::test_serialize_para_text(para); + eprintln!(" Serialized PARA_TEXT: {} bytes", serialized.len()); - // Get original raw - let mut cfb = crate::parser::cfb_reader::CfbReader::open(&data).unwrap(); - let section_data = cfb.read_body_text_section(0, true, false).unwrap(); - let records = Record::read_all(§ion_data).unwrap(); + // Get original raw + let mut cfb = crate::parser::cfb_reader::CfbReader::open(&data).unwrap(); + let section_data = cfb.read_body_text_section(0, true, false).unwrap(); + let records = Record::read_all(§ion_data).unwrap(); - // Find the matching PARA_TEXT - let mut para_starts: Vec = Vec::new(); - for (ri, rec) in records.iter().enumerate() { - if rec.tag_id == tags::HWPTAG_PARA_HEADER { para_starts.push(ri); } + // Find the matching PARA_TEXT + let mut para_starts: Vec = Vec::new(); + for (ri, rec) in records.iter().enumerate() { + if rec.tag_id == tags::HWPTAG_PARA_HEADER { + para_starts.push(ri); } - for (pi, &ps) in para_starts.iter().enumerate() { - let pe = if pi+1 < para_starts.len() { para_starts[pi+1] } else { records.len() }; - let has_field = records[ps..pe].iter().any(|r| { - if r.tag_id != tags::HWPTAG_CTRL_HEADER || r.data.len() < 11 { return false; } - let cid = u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); - if !tags::is_field_ctrl_id(cid) { return false; } - let cl = u16::from_le_bytes([r.data[9], r.data[10]]) as usize; - if cl > 0 && r.data.len() >= 11 + cl*2 { - let w: Vec = r.data[11..11+cl*2].chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])).collect(); - let cmd = String::from_utf16_lossy(&w); - cmd.contains("메일") || cmd.contains("mail") || cmd.contains("Mail") - } else { false } - }); - if !has_field { continue; } - - // Find PARA_TEXT in this paragraph - for rec in &records[ps..pe] { - if rec.tag_id == tags::HWPTAG_PARA_TEXT { - let original = &rec.data; - eprintln!(" Original PARA_TEXT: {} bytes", original.len()); - - if serialized.len() != original.len() { - eprintln!(" *** SIZE MISMATCH: serialized={} vs original={} ***", - serialized.len(), original.len()); - } + } + for (pi, &ps) in para_starts.iter().enumerate() { + let pe = if pi + 1 < para_starts.len() { + para_starts[pi + 1] + } else { + records.len() + }; + let has_field = records[ps..pe].iter().any(|r| { + if r.tag_id != tags::HWPTAG_CTRL_HEADER || r.data.len() < 11 { + return false; + } + let cid = + u32::from_le_bytes([r.data[0], r.data[1], r.data[2], r.data[3]]); + if !tags::is_field_ctrl_id(cid) { + return false; + } + let cl = u16::from_le_bytes([r.data[9], r.data[10]]) as usize; + if cl > 0 && r.data.len() >= 11 + cl * 2 { + let w: Vec = r.data[11..11 + cl * 2] + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let cmd = String::from_utf16_lossy(&w); + cmd.contains("메일") || cmd.contains("mail") || cmd.contains("Mail") + } else { + false + } + }); + if !has_field { + continue; + } + + // Find PARA_TEXT in this paragraph + for rec in &records[ps..pe] { + if rec.tag_id == tags::HWPTAG_PARA_TEXT { + let original = &rec.data; + eprintln!(" Original PARA_TEXT: {} bytes", original.len()); + + if serialized.len() != original.len() { + eprintln!( + " *** SIZE MISMATCH: serialized={} vs original={} ***", + serialized.len(), + original.len() + ); + } - // Find byte-level differences - let min_len = serialized.len().min(original.len()); - let mut diff_count = 0; - for i in 0..min_len { - if serialized[i] != original[i] { - if diff_count < 30 { - eprintln!(" DIFF at byte {}: serialized=0x{:02x} original=0x{:02x}", + // Find byte-level differences + let min_len = serialized.len().min(original.len()); + let mut diff_count = 0; + for i in 0..min_len { + if serialized[i] != original[i] { + if diff_count < 30 { + eprintln!(" DIFF at byte {}: serialized=0x{:02x} original=0x{:02x}", i, serialized[i], original[i]); - } - diff_count += 1; } + diff_count += 1; } - if diff_count > 30 { - eprintln!(" ... and {} more differences", diff_count - 30); - } - eprintln!(" Total byte differences: {}", diff_count); - break; } + if diff_count > 30 { + eprintln!(" ... and {} more differences", diff_count - 30); + } + eprintln!(" Total byte differences: {}", diff_count); + break; } - break; } + break; } } } } - - eprintln!("\n=== DIAGNOSTIC COMPLETE ==="); } - /// [진단] field-10.hwp (우리 저장) vs field-10-2010.hwp (한컴 2010 저장) 비교 - /// 한컴에서 page 2 ClickHere 필드의 안내문이 빈 문자열로 표시되는 원인 분석 - #[test] - fn diag_field10_comparison() { - use crate::model::control::{Control, FieldType}; + eprintln!("\n=== DIAGNOSTIC COMPLETE ==="); +} - let files: &[(&str, &str)] = &[ - ("saved/field-10.hwp", "OUR_SAVED"), - ("saved/field-10-2010.hwp", "HANCOM_2010"), - ]; +/// [진단] field-10.hwp (우리 저장) vs field-10-2010.hwp (한컴 2010 저장) 비교 +/// 한컴에서 page 2 ClickHere 필드의 안내문이 빈 문자열로 표시되는 원인 분석 +#[test] +fn diag_field10_comparison() { + use crate::model::control::{Control, FieldType}; - for (path, label) in files { - eprintln!("\n{}", "=".repeat(70)); - eprintln!("=== {} ({}) ===", label, path); - eprintln!("{}", "=".repeat(70)); + let files: &[(&str, &str)] = &[ + ("saved/field-10.hwp", "OUR_SAVED"), + ("saved/field-10-2010.hwp", "HANCOM_2010"), + ]; - let Ok(data) = std::fs::read(path) else { - eprintln!(" (파일 없음 - 건너뜀)"); - continue; - }; - let Ok(doc) = HwpDocument::from_bytes(&data) else { - eprintln!(" (파싱 실패)"); - continue; - }; + for (path, label) in files { + eprintln!("\n{}", "=".repeat(70)); + eprintln!("=== {} ({}) ===", label, path); + eprintln!("{}", "=".repeat(70)); - eprintln!(" Sections: {}", doc.document.sections.len()); + let Ok(data) = std::fs::read(path) else { + eprintln!(" (파일 없음 - 건너뜀)"); + continue; + }; + let Ok(doc) = HwpDocument::from_bytes(&data) else { + eprintln!(" (파싱 실패)"); + continue; + }; - for (si, sec) in doc.document.sections.iter().enumerate() { - eprintln!("\n --- Section {} ({} paragraphs) ---", si, sec.paragraphs.len()); + eprintln!(" Sections: {}", doc.document.sections.len()); - // 1) 섹션 최상위 문단의 ClickHere 필드 - for (pi, para) in sec.paragraphs.iter().enumerate() { - diag_field10_print_clickhere_in_para( - &format!("sec={} para={}", si, pi), - para, - ); + for (si, sec) in doc.document.sections.iter().enumerate() { + eprintln!( + "\n --- Section {} ({} paragraphs) ---", + si, + sec.paragraphs.len() + ); - // 2) 표 셀 내부 문단 - for (ci, ctrl) in para.controls.iter().enumerate() { - if let Control::Table(t) = ctrl { - for (cell_i, cell) in t.cells.iter().enumerate() { - for (cp, cpara) in cell.paragraphs.iter().enumerate() { - diag_field10_print_clickhere_in_para( - &format!("sec={} para={} table_ctrl={} cell={} cell_para={}", - si, pi, ci, cell_i, cp), - cpara, - ); - // 표 셀 안의 표/글상자도 확인 (중첩) - for (cci, cctrl) in cpara.controls.iter().enumerate() { - diag_field10_check_nested( + // 1) 섹션 최상위 문단의 ClickHere 필드 + for (pi, para) in sec.paragraphs.iter().enumerate() { + diag_field10_print_clickhere_in_para(&format!("sec={} para={}", si, pi), para); + + // 2) 표 셀 내부 문단 + for (ci, ctrl) in para.controls.iter().enumerate() { + if let Control::Table(t) = ctrl { + for (cell_i, cell) in t.cells.iter().enumerate() { + for (cp, cpara) in cell.paragraphs.iter().enumerate() { + diag_field10_print_clickhere_in_para( + &format!( + "sec={} para={} table_ctrl={} cell={} cell_para={}", + si, pi, ci, cell_i, cp + ), + cpara, + ); + // 표 셀 안의 표/글상자도 확인 (중첩) + for (cci, cctrl) in cpara.controls.iter().enumerate() { + diag_field10_check_nested( &format!("sec={} para={} table_ctrl={} cell={} cell_para={} nested_ctrl={}", si, pi, ci, cell_i, cp, cci), cctrl, ); - } } } } - // 3) 글상자(Shape) 내부 문단 - if let Control::Shape(s) = ctrl { - if let Some(drawing) = s.drawing() { - if let Some(tb) = &drawing.text_box { - for (tp, tpara) in tb.paragraphs.iter().enumerate() { - diag_field10_print_clickhere_in_para( - &format!("sec={} para={} shape_ctrl={} textbox_para={}", - si, pi, ci, tp), - tpara, - ); - // 글상자 안의 표/글상자도 확인 - for (tci, tctrl) in tpara.controls.iter().enumerate() { - diag_field10_check_nested( + } + // 3) 글상자(Shape) 내부 문단 + if let Control::Shape(s) = ctrl { + if let Some(drawing) = s.drawing() { + if let Some(tb) = &drawing.text_box { + for (tp, tpara) in tb.paragraphs.iter().enumerate() { + diag_field10_print_clickhere_in_para( + &format!( + "sec={} para={} shape_ctrl={} textbox_para={}", + si, pi, ci, tp + ), + tpara, + ); + // 글상자 안의 표/글상자도 확인 + for (tci, tctrl) in tpara.controls.iter().enumerate() { + diag_field10_check_nested( &format!("sec={} para={} shape_ctrl={} textbox_para={} nested_ctrl={}", si, pi, ci, tp, tci), tctrl, ); - } } } } } - // 4) Picture 내부 (caption 등은 별도, Picture는 보통 텍스트 없음) } + // 4) Picture 내부 (caption 등은 별도, Picture는 보통 텍스트 없음) } } + } + + // Raw record 분석: CTRL_HEADER에서 필드 레코드 추출 + eprintln!("\n --- Raw CTRL_HEADER records (all sections) ---"); + let mut cfb = match crate::parser::cfb_reader::CfbReader::open(&data) { + Ok(c) => c, + Err(e) => { + eprintln!(" CFB open error: {:?}", e); + continue; + } + }; + for sec_idx in 0..doc.document.sections.len() { + let Ok(section_data) = cfb.read_body_text_section(sec_idx as u32, true, false) else { + eprintln!(" Section {} read error", sec_idx); + continue; + }; + let Ok(records) = crate::parser::record::Record::read_all(§ion_data) else { + eprintln!(" Section {} record parse error", sec_idx); + continue; + }; - // Raw record 분석: CTRL_HEADER에서 필드 레코드 추출 - eprintln!("\n --- Raw CTRL_HEADER records (all sections) ---"); - let mut cfb = match crate::parser::cfb_reader::CfbReader::open(&data) { - Ok(c) => c, - Err(e) => { - eprintln!(" CFB open error: {:?}", e); + let mut field_count = 0; + for (ri, rec) in records.iter().enumerate() { + if rec.tag_id != crate::parser::tags::HWPTAG_CTRL_HEADER || rec.data.len() < 4 { continue; } - }; - for sec_idx in 0..doc.document.sections.len() { - let Ok(section_data) = cfb.read_body_text_section(sec_idx as u32, true, false) else { - eprintln!(" Section {} read error", sec_idx); + let ctrl_id = + u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); + if !crate::parser::tags::is_field_ctrl_id(ctrl_id) { continue; - }; - let Ok(records) = crate::parser::record::Record::read_all(§ion_data) else { - eprintln!(" Section {} record parse error", sec_idx); - continue; - }; - - let mut field_count = 0; - for (ri, rec) in records.iter().enumerate() { - if rec.tag_id != crate::parser::tags::HWPTAG_CTRL_HEADER || rec.data.len() < 4 { - continue; - } - let ctrl_id = u32::from_le_bytes([rec.data[0], rec.data[1], rec.data[2], rec.data[3]]); - if !crate::parser::tags::is_field_ctrl_id(ctrl_id) { - continue; - } - let ctrl_id_bytes = ctrl_id.to_le_bytes(); - let ctrl_id_str = String::from_utf8_lossy(&ctrl_id_bytes); - // ClickHere 필드인지 확인: ctrl_id == '%clk' = 0x25636c6b - let is_clickhere = ctrl_id == crate::parser::tags::FIELD_CLICKHERE; + } + let ctrl_id_bytes = ctrl_id.to_le_bytes(); + let ctrl_id_str = String::from_utf8_lossy(&ctrl_id_bytes); + // ClickHere 필드인지 확인: ctrl_id == '%clk' = 0x25636c6b + let is_clickhere = ctrl_id == crate::parser::tags::FIELD_CLICKHERE; - if is_clickhere { - field_count += 1; - eprintln!("\n [raw sec={} rec={}] ClickHere CTRL_HEADER ctrl_id=0x{:08x}({}) size={}", + if is_clickhere { + field_count += 1; + eprintln!("\n [raw sec={} rec={}] ClickHere CTRL_HEADER ctrl_id=0x{:08x}({}) size={}", sec_idx, ri, ctrl_id, ctrl_id_str, rec.data.len()); - // 처음 120바이트 출력 - let dump_len = rec.data.len().min(200); - eprintln!(" raw({} bytes): {:02x?}", rec.data.len(), &rec.data[..dump_len]); - if rec.data.len() > dump_len { - eprintln!(" ... ({} more bytes)", rec.data.len() - dump_len); - } + // 처음 120바이트 출력 + let dump_len = rec.data.len().min(200); + eprintln!( + " raw({} bytes): {:02x?}", + rec.data.len(), + &rec.data[..dump_len] + ); + if rec.data.len() > dump_len { + eprintln!(" ... ({} more bytes)", rec.data.len() - dump_len); + } - // command 문자열 추출 시도: offset 9에 command_len(u16), 이후 UTF-16LE - if rec.data.len() >= 11 { - let cmd_len = u16::from_le_bytes([rec.data[9], rec.data[10]]) as usize; - let cmd_byte_start = 11; - let cmd_byte_end = cmd_byte_start + cmd_len * 2; - if rec.data.len() >= cmd_byte_end { - let wchars: Vec = rec.data[cmd_byte_start..cmd_byte_end] - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect(); - let cmd = String::from_utf16_lossy(&wchars); - eprintln!(" command({} chars): {:?}", cmd.len(), cmd); - - // set:N 값 추출 - if let Some(set_start) = cmd.find("set:") { - let rest = &cmd[set_start + 4..]; - if let Some(colon) = rest.find(':') { - let n_str = &rest[..colon]; - eprintln!(" set:N value: {:?}", n_str); - } + // command 문자열 추출 시도: offset 9에 command_len(u16), 이후 UTF-16LE + if rec.data.len() >= 11 { + let cmd_len = u16::from_le_bytes([rec.data[9], rec.data[10]]) as usize; + let cmd_byte_start = 11; + let cmd_byte_end = cmd_byte_start + cmd_len * 2; + if rec.data.len() >= cmd_byte_end { + let wchars: Vec = rec.data[cmd_byte_start..cmd_byte_end] + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let cmd = String::from_utf16_lossy(&wchars); + eprintln!(" command({} chars): {:?}", cmd.len(), cmd); + + // set:N 값 추출 + if let Some(set_start) = cmd.find("set:") { + let rest = &cmd[set_start + 4..]; + if let Some(colon) = rest.find(':') { + let n_str = &rest[..colon]; + eprintln!(" set:N value: {:?}", n_str); } } } + } - // CTRL_DATA 확인 - if ri + 1 < records.len() && records[ri + 1].tag_id == crate::parser::tags::HWPTAG_CTRL_DATA { - let cd = &records[ri + 1]; - eprintln!(" CTRL_DATA[rec={}]({} bytes): {:02x?}", - ri + 1, cd.data.len(), &cd.data[..cd.data.len().min(80)]); - } + // CTRL_DATA 확인 + if ri + 1 < records.len() + && records[ri + 1].tag_id == crate::parser::tags::HWPTAG_CTRL_DATA + { + let cd = &records[ri + 1]; + eprintln!( + " CTRL_DATA[rec={}]({} bytes): {:02x?}", + ri + 1, + cd.data.len(), + &cd.data[..cd.data.len().min(80)] + ); } } - if field_count == 0 { - eprintln!(" [sec={}] No ClickHere fields in raw records", sec_idx); - } + } + if field_count == 0 { + eprintln!(" [sec={}] No ClickHere fields in raw records", sec_idx); } } - - eprintln!("\n\n{}", "=".repeat(70)); - eprintln!("=== COMPARISON SUMMARY ==="); - eprintln!("{}", "=".repeat(70)); - eprintln!("Compare the command strings, set:N values, guide_text, memo_text"); - eprintln!("between OUR_SAVED and HANCOM_2010 for page 2 fields."); - eprintln!("Look for: trailing spaces, empty guide text, different set:N counts"); } - fn diag_field10_print_clickhere_in_para( - location: &str, - para: &crate::model::paragraph::Paragraph, - ) { - use crate::model::control::{Control, FieldType}; + eprintln!("\n\n{}", "=".repeat(70)); + eprintln!("=== COMPARISON SUMMARY ==="); + eprintln!("{}", "=".repeat(70)); + eprintln!("Compare the command strings, set:N values, guide_text, memo_text"); + eprintln!("between OUR_SAVED and HANCOM_2010 for page 2 fields."); + eprintln!("Look for: trailing spaces, empty guide text, different set:N counts"); +} - for (ci, ctrl) in para.controls.iter().enumerate() { - if let Control::Field(f) = ctrl { - if f.field_type != FieldType::ClickHere { - continue; - } - eprintln!("\n [{} ctrl={}] ClickHere", location, ci); - eprintln!(" field_id: {} (0x{:08x})", f.field_id, f.field_id); - eprintln!(" ctrl_id: 0x{:08x} ({})", f.ctrl_id, - String::from_utf8_lossy(&f.ctrl_id.to_le_bytes())); - eprintln!(" properties: 0x{:08x} ({})", f.properties, f.properties); - eprintln!(" extra_properties: 0x{:02x} ({})", f.extra_properties, f.extra_properties); - eprintln!(" command_len(bytes): {}", f.command.len()); - eprintln!(" command_len(chars): {}", f.command.chars().count()); - eprintln!(" command: {:?}", f.command); - - // command의 각 바이트를 escape하여 trailing space 등 확인 - let escaped: String = f.command.chars().map(|c| { - if c == ' ' { "·".to_string() } - else if c == '\t' { "\\t".to_string() } - else if c == '\n' { "\\n".to_string() } - else if c == '\r' { "\\r".to_string() } - else { c.to_string() } - }).collect(); - eprintln!(" command(escaped): {}", escaped); - - // set:N 값 추출 - if let Some(set_start) = f.command.find("set:") { - let rest = &f.command[set_start + 4..]; - if let Some(colon) = rest.find(':') { - let n_str = &rest[..colon]; - eprintln!(" set:N value: {:?} (parsed: {:?})", n_str, n_str.parse::().ok()); - } - } - - // guide/memo 추출 결과 - eprintln!(" guide_text(): {:?}", f.guide_text()); - eprintln!(" memo_text(): {:?}", f.memo_text()); - eprintln!(" field_name(): {:?}", f.field_name()); - eprintln!(" ctrl_data_name: {:?}", f.ctrl_data_name); - - // extract_wstring_value 상세 (Direction/HelpState/Name) - for key in &["Direction:", "HelpState:", "Name:"] { - let val = f.extract_wstring_value(key); - eprintln!(" extract_wstring_value({:?}): {:?}", key, val); - } - - // CTRL_DATA 원본 바이트 - if let Some(Some(cd)) = para.ctrl_data_records.get(ci) { - eprintln!(" CTRL_DATA({} bytes): {:02x?}", cd.len(), &cd[..cd.len().min(80)]); - if cd.len() >= 12 { - let name_len = u16::from_le_bytes([cd[10], cd[11]]) as usize; - eprintln!(" CTRL_DATA name_len: {}", name_len); - if name_len > 0 && cd.len() >= 12 + name_len * 2 { - let wchars: Vec = cd[12..12 + name_len * 2] - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect(); - let name = String::from_utf16_lossy(&wchars); - eprintln!(" CTRL_DATA name: {:?}", name); - } +fn diag_field10_print_clickhere_in_para(location: &str, para: &crate::model::paragraph::Paragraph) { + use crate::model::control::{Control, FieldType}; + + for (ci, ctrl) in para.controls.iter().enumerate() { + if let Control::Field(f) = ctrl { + if f.field_type != FieldType::ClickHere { + continue; + } + eprintln!("\n [{} ctrl={}] ClickHere", location, ci); + eprintln!(" field_id: {} (0x{:08x})", f.field_id, f.field_id); + eprintln!( + " ctrl_id: 0x{:08x} ({})", + f.ctrl_id, + String::from_utf8_lossy(&f.ctrl_id.to_le_bytes()) + ); + eprintln!(" properties: 0x{:08x} ({})", f.properties, f.properties); + eprintln!( + " extra_properties: 0x{:02x} ({})", + f.extra_properties, f.extra_properties + ); + eprintln!(" command_len(bytes): {}", f.command.len()); + eprintln!(" command_len(chars): {}", f.command.chars().count()); + eprintln!(" command: {:?}", f.command); + + // command의 각 바이트를 escape하여 trailing space 등 확인 + let escaped: String = f + .command + .chars() + .map(|c| { + if c == ' ' { + "·".to_string() + } else if c == '\t' { + "\\t".to_string() + } else if c == '\n' { + "\\n".to_string() + } else if c == '\r' { + "\\r".to_string() + } else { + c.to_string() } - } else { - eprintln!(" CTRL_DATA: None"); + }) + .collect(); + eprintln!(" command(escaped): {}", escaped); + + // set:N 값 추출 + if let Some(set_start) = f.command.find("set:") { + let rest = &f.command[set_start + 4..]; + if let Some(colon) = rest.find(':') { + let n_str = &rest[..colon]; + eprintln!( + " set:N value: {:?} (parsed: {:?})", + n_str, + n_str.parse::().ok() + ); } + } - // UTF-16 command 길이 (직렬화 시 사용) - let cmd_utf16: Vec = f.command.encode_utf16().collect(); - eprintln!(" command UTF-16 len: {}", cmd_utf16.len()); + // guide/memo 추출 결과 + eprintln!(" guide_text(): {:?}", f.guide_text()); + eprintln!(" memo_text(): {:?}", f.memo_text()); + eprintln!(" field_name(): {:?}", f.field_name()); + eprintln!(" ctrl_data_name: {:?}", f.ctrl_data_name); + + // extract_wstring_value 상세 (Direction/HelpState/Name) + for key in &["Direction:", "HelpState:", "Name:"] { + let val = f.extract_wstring_value(key); + eprintln!(" extract_wstring_value({:?}): {:?}", key, val); } - } - } - fn diag_field10_check_nested( - location: &str, - ctrl: &crate::model::control::Control, - ) { - use crate::model::control::Control; - match ctrl { - Control::Table(t) => { - for (cell_i, cell) in t.cells.iter().enumerate() { - for (cp, cpara) in cell.paragraphs.iter().enumerate() { - diag_field10_print_clickhere_in_para( - &format!("{} nested_table cell={} para={}", location, cell_i, cp), - cpara, - ); + // CTRL_DATA 원본 바이트 + if let Some(Some(cd)) = para.ctrl_data_records.get(ci) { + eprintln!( + " CTRL_DATA({} bytes): {:02x?}", + cd.len(), + &cd[..cd.len().min(80)] + ); + if cd.len() >= 12 { + let name_len = u16::from_le_bytes([cd[10], cd[11]]) as usize; + eprintln!(" CTRL_DATA name_len: {}", name_len); + if name_len > 0 && cd.len() >= 12 + name_len * 2 { + let wchars: Vec = cd[12..12 + name_len * 2] + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let name = String::from_utf16_lossy(&wchars); + eprintln!(" CTRL_DATA name: {:?}", name); } } + } else { + eprintln!(" CTRL_DATA: None"); } - Control::Shape(s) => { - if let Some(drawing) = s.drawing() { - if let Some(tb) = &drawing.text_box { - for (tp, tpara) in tb.paragraphs.iter().enumerate() { - diag_field10_print_clickhere_in_para( - &format!("{} nested_shape textbox_para={}", location, tp), - tpara, - ); - } + + // UTF-16 command 길이 (직렬화 시 사용) + let cmd_utf16: Vec = f.command.encode_utf16().collect(); + eprintln!(" command UTF-16 len: {}", cmd_utf16.len()); + } + } +} + +fn diag_field10_check_nested(location: &str, ctrl: &crate::model::control::Control) { + use crate::model::control::Control; + match ctrl { + Control::Table(t) => { + for (cell_i, cell) in t.cells.iter().enumerate() { + for (cp, cpara) in cell.paragraphs.iter().enumerate() { + diag_field10_print_clickhere_in_para( + &format!("{} nested_table cell={} para={}", location, cell_i, cp), + cpara, + ); + } + } + } + Control::Shape(s) => { + if let Some(drawing) = s.drawing() { + if let Some(tb) = &drawing.text_box { + for (tp, tpara) in tb.paragraphs.iter().enumerate() { + diag_field10_print_clickhere_in_para( + &format!("{} nested_shape textbox_para={}", location, tp), + tpara, + ); } } } - _ => {} } + _ => {} } - - - #[test] - fn diag_raw_tail_dump() { - for path in &[ - "samples/field-01.hwp", - "samples/field-01-memo.hwp", - "saved/field-01-h.hwp", - "saved/field-10.hwp", - "saved/field-10-2010.hwp", - ] { - let data = match std::fs::read(path) { - Ok(d) => d, - Err(_) => { eprintln!("[SKIP] {}", path); continue; } - }; - let doc = HwpDocument::from_bytes(&data).expect("파싱"); - eprintln!("\n=== {} ===", path); - for (si, sec) in doc.document.sections.iter().enumerate() { - fn check_para(si: usize, loc: &str, para: &crate::model::paragraph::Paragraph) { - for ctrl in ¶.controls { - if let crate::model::control::Control::Field(f) = ctrl { - eprintln!(" [sec={} {}] field_type={:?} ctrl_id=0x{:08x} field_id=0x{:08x} memo_index={:02x?}", +} + +#[test] +fn diag_raw_tail_dump() { + for path in &[ + "samples/field-01.hwp", + "samples/field-01-memo.hwp", + "saved/field-01-h.hwp", + "saved/field-10.hwp", + "saved/field-10-2010.hwp", + ] { + let data = match std::fs::read(path) { + Ok(d) => d, + Err(_) => { + eprintln!("[SKIP] {}", path); + continue; + } + }; + let doc = HwpDocument::from_bytes(&data).expect("파싱"); + eprintln!("\n=== {} ===", path); + for (si, sec) in doc.document.sections.iter().enumerate() { + fn check_para(si: usize, loc: &str, para: &crate::model::paragraph::Paragraph) { + for ctrl in ¶.controls { + if let crate::model::control::Control::Field(f) = ctrl { + eprintln!(" [sec={} {}] field_type={:?} ctrl_id=0x{:08x} field_id=0x{:08x} memo_index={:02x?}", si, loc, f.field_type, f.ctrl_id, f.field_id, f.memo_index); - } } } - for (pi, para) in sec.paragraphs.iter().enumerate() { - check_para(si, &format!("para={}", pi), para); - for ctrl in ¶.controls { - match ctrl { - crate::model::control::Control::Table(t) => { - for (ci, cell) in t.cells.iter().enumerate() { - for (cpi, cp) in cell.paragraphs.iter().enumerate() { - check_para(si, &format!("tbl cell={} para={}", ci, cpi), cp); - } + } + for (pi, para) in sec.paragraphs.iter().enumerate() { + check_para(si, &format!("para={}", pi), para); + for ctrl in ¶.controls { + match ctrl { + crate::model::control::Control::Table(t) => { + for (ci, cell) in t.cells.iter().enumerate() { + for (cpi, cp) in cell.paragraphs.iter().enumerate() { + check_para(si, &format!("tbl cell={} para={}", ci, cpi), cp); } } - crate::model::control::Control::Shape(s) => { - if let Some(tb) = s.drawing().and_then(|d| d.text_box.as_ref()) { - for (tpi, tp) in tb.paragraphs.iter().enumerate() { - check_para(si, &format!("shape para={}", tpi), tp); - } + } + crate::model::control::Control::Shape(s) => { + if let Some(tb) = s.drawing().and_then(|d| d.text_box.as_ref()) { + for (tpi, tp) in tb.paragraphs.iter().enumerate() { + check_para(si, &format!("shape para={}", tpi), tp); } } - _ => {} } + _ => {} } } } } } - - #[test] - fn diag_memo_controls() { - for path in &["samples/field-01.hwp", "samples/field-01-memo.hwp"] { - let data = std::fs::read(path).expect("read"); - let doc = HwpDocument::from_bytes(&data).expect("parse"); - eprintln!("\n=== {} ===", path); - for (si, sec) in doc.document.sections.iter().enumerate() { - for (pi, para) in sec.paragraphs.iter().enumerate() { - if para.controls.is_empty() { continue; } - eprint!(" [sec={} para={}] controls:", si, pi); - for ctrl in ¶.controls { - let name = match ctrl { - crate::model::control::Control::SectionDef(_) => "SectionDef", - crate::model::control::Control::ColumnDef(_) => "ColumnDef", - crate::model::control::Control::Table(_) => "Table", - crate::model::control::Control::Shape(_) => "Shape", - crate::model::control::Control::Picture(_) => "Picture", - crate::model::control::Control::Header(_) => "Header", - crate::model::control::Control::Footer(_) => "Footer", - crate::model::control::Control::Footnote(_) => "Footnote", - crate::model::control::Control::Endnote(_) => "Endnote", - crate::model::control::Control::AutoNumber(_) => "AutoNumber", - crate::model::control::Control::NewNumber(_) => "NewNumber", - crate::model::control::Control::PageNumberPos(_) => "PageNumPos", - crate::model::control::Control::PageHide(_) => "PageHide", - crate::model::control::Control::Bookmark(_) => "Bookmark", - crate::model::control::Control::Hyperlink(_) => "Hyperlink", - crate::model::control::Control::Ruby(_) => "Ruby", - crate::model::control::Control::CharOverlap(_) => "CharOverlap", - crate::model::control::Control::HiddenComment(_) => "HiddenComment", - crate::model::control::Control::Field(f) => { - eprint!(" Field({:?},id=0x{:08x},props=0x{:08x},extra=0x{:02x},memo={},guide={:?},memo_text={:?})", +} + +#[test] +fn diag_memo_controls() { + for path in &["samples/field-01.hwp", "samples/field-01-memo.hwp"] { + let data = std::fs::read(path).expect("read"); + let doc = HwpDocument::from_bytes(&data).expect("parse"); + eprintln!("\n=== {} ===", path); + for (si, sec) in doc.document.sections.iter().enumerate() { + for (pi, para) in sec.paragraphs.iter().enumerate() { + if para.controls.is_empty() { + continue; + } + eprint!(" [sec={} para={}] controls:", si, pi); + for ctrl in ¶.controls { + let name = match ctrl { + crate::model::control::Control::SectionDef(_) => "SectionDef", + crate::model::control::Control::ColumnDef(_) => "ColumnDef", + crate::model::control::Control::Table(_) => "Table", + crate::model::control::Control::Shape(_) => "Shape", + crate::model::control::Control::Picture(_) => "Picture", + crate::model::control::Control::Header(_) => "Header", + crate::model::control::Control::Footer(_) => "Footer", + crate::model::control::Control::Footnote(_) => "Footnote", + crate::model::control::Control::Endnote(_) => "Endnote", + crate::model::control::Control::AutoNumber(_) => "AutoNumber", + crate::model::control::Control::NewNumber(_) => "NewNumber", + crate::model::control::Control::PageNumberPos(_) => "PageNumPos", + crate::model::control::Control::PageHide(_) => "PageHide", + crate::model::control::Control::Bookmark(_) => "Bookmark", + crate::model::control::Control::Hyperlink(_) => "Hyperlink", + crate::model::control::Control::Ruby(_) => "Ruby", + crate::model::control::Control::CharOverlap(_) => "CharOverlap", + crate::model::control::Control::HiddenComment(_) => "HiddenComment", + crate::model::control::Control::Field(f) => { + eprint!(" Field({:?},id=0x{:08x},props=0x{:08x},extra=0x{:02x},memo={},guide={:?},memo_text={:?})", f.field_type, f.field_id, f.properties, f.extra_properties, f.memo_index, f.guide_text(), f.memo_text()); - continue; - } - crate::model::control::Control::Equation(_) => "Equation", - crate::model::control::Control::Form(_) => "Form", - crate::model::control::Control::Unknown(u) => { - eprint!(" Unknown(0x{:08x})", u.ctrl_id); - continue; - } - }; - eprint!(" {}", name); - } - eprintln!(); - // field_ranges 정보 - let chars: Vec = para.text.chars().collect(); - for (fri, fr) in para.field_ranges.iter().enumerate() { - let field_text: String = if fr.start_char_idx < fr.end_char_idx && fr.end_char_idx <= chars.len() { + continue; + } + crate::model::control::Control::Equation(_) => "Equation", + crate::model::control::Control::Form(_) => "Form", + crate::model::control::Control::Unknown(u) => { + eprint!(" Unknown(0x{:08x})", u.ctrl_id); + continue; + } + }; + eprint!(" {}", name); + } + eprintln!(); + // field_ranges 정보 + let chars: Vec = para.text.chars().collect(); + for (fri, fr) in para.field_ranges.iter().enumerate() { + let field_text: String = + if fr.start_char_idx < fr.end_char_idx && fr.end_char_idx <= chars.len() { chars[fr.start_char_idx..fr.end_char_idx].iter().collect() - } else { String::new() }; - eprintln!(" field_range[{}]: ctrl_idx={} start={} end={} text={:?}", fri, fr.control_idx, fr.start_char_idx, fr.end_char_idx, field_text); - } - eprintln!(" para.text({} chars): {:?}", chars.len(), ¶.text[..para.text.len().min(80)]); + } else { + String::new() + }; + eprintln!( + " field_range[{}]: ctrl_idx={} start={} end={} text={:?}", + fri, fr.control_idx, fr.start_char_idx, fr.end_char_idx, field_text + ); } + eprintln!( + " para.text({} chars): {:?}", + chars.len(), + ¶.text[..para.text.len().min(80)] + ); } } } +} + +/// 13페이지 엔터 후 페이지 전파 범위 분석 +#[test] +fn test_page13_enter_propagation() { + use crate::renderer::pagination::PageItem; + + let bytes = std::fs::read("samples/kps-ai.hwp").expect("kps-ai.hwp 읽기 실패"); + let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); + doc.convert_to_editable_native().unwrap(); + doc.paginate(); + + let pages_before = doc.pagination[0].pages.len(); + + // 분할 전: 각 페이지의 첫 번째/마지막 아이템의 para_index 기록 + let mut before_pages: Vec<(usize, usize, usize)> = Vec::new(); // (first_pi, last_pi, item_count) + for page in &doc.pagination[0].pages { + let items = &page.column_contents[0].items; + let first = items + .first() + .map(|it| match it { + PageItem::FullParagraph { para_index } + | PageItem::Table { para_index, .. } + | PageItem::PartialParagraph { para_index, .. } + | PageItem::PartialTable { para_index, .. } + | PageItem::Shape { para_index, .. } => *para_index, + }) + .unwrap_or(0); + let last = items + .last() + .map(|it| match it { + PageItem::FullParagraph { para_index } + | PageItem::Table { para_index, .. } + | PageItem::PartialParagraph { para_index, .. } + | PageItem::PartialTable { para_index, .. } + | PageItem::Shape { para_index, .. } => *para_index, + }) + .unwrap_or(0); + before_pages.push((first, last, items.len())); + } - /// 13페이지 엔터 후 페이지 전파 범위 분석 - #[test] - fn test_page13_enter_propagation() { - use crate::renderer::pagination::PageItem; - - let bytes = std::fs::read("samples/kps-ai.hwp").expect("kps-ai.hwp 읽기 실패"); - let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); - doc.convert_to_editable_native().unwrap(); - doc.paginate(); - - let pages_before = doc.pagination[0].pages.len(); - - // 분할 전: 각 페이지의 첫 번째/마지막 아이템의 para_index 기록 - let mut before_pages: Vec<(usize, usize, usize)> = Vec::new(); // (first_pi, last_pi, item_count) - for page in &doc.pagination[0].pages { - let items = &page.column_contents[0].items; - let first = items.first().map(|it| match it { - PageItem::FullParagraph { para_index } | - PageItem::Table { para_index, .. } | - PageItem::PartialParagraph { para_index, .. } | - PageItem::PartialTable { para_index, .. } | - PageItem::Shape { para_index, .. } => *para_index, - }).unwrap_or(0); - let last = items.last().map(|it| match it { - PageItem::FullParagraph { para_index } | - PageItem::Table { para_index, .. } | - PageItem::PartialParagraph { para_index, .. } | - PageItem::PartialTable { para_index, .. } | - PageItem::Shape { para_index, .. } => *para_index, - }).unwrap_or(0); - before_pages.push((first, last, items.len())); - } - - // page 13 (idx=12)의 pi=199 앞에서 엔터 - eprintln!("=== splitParagraph(0, 199, 0) ==="); - let result = doc.split_paragraph_native(0, 199, 0).unwrap(); - assert!(result.contains("\"ok\":true")); - - let pages_after = doc.pagination[0].pages.len(); - eprintln!("pages: {} → {}", pages_before, pages_after); - - // 분할 후: 각 페이지 비교 - let mut last_diff_page = 0; - for (pidx, page) in doc.pagination[0].pages.iter().enumerate() { - let items = &page.column_contents[0].items; - let first = items.first().map(|it| match it { - PageItem::FullParagraph { para_index } | - PageItem::Table { para_index, .. } | - PageItem::PartialParagraph { para_index, .. } | - PageItem::PartialTable { para_index, .. } | - PageItem::Shape { para_index, .. } => *para_index, - }).unwrap_or(0); - let last = items.last().map(|it| match it { - PageItem::FullParagraph { para_index } | - PageItem::Table { para_index, .. } | - PageItem::PartialParagraph { para_index, .. } | - PageItem::PartialTable { para_index, .. } | - PageItem::Shape { para_index, .. } => *para_index, - }).unwrap_or(0); - - let before = before_pages.get(pidx); - let changed = before.map(|b| b.0 != first || b.1 != last || b.2 != items.len()).unwrap_or(true); - - if changed { - last_diff_page = pidx; - let before_str = before.map(|b| format!("pi={}-{} ({}items)", b.0, b.1, b.2)) - .unwrap_or_else(|| "(신규)".to_string()); - eprintln!(" page {:2}: {} → pi={}-{} ({}items) ← CHANGED", - pidx + 1, before_str, first, last, items.len()); - } - } - eprintln!("전파 범위: page 13 ~ page {} (총 {} 페이지 영향)", - last_diff_page + 1, last_diff_page + 1 - 12); - - // 저장 후 재로드와 비교 - eprintln!("\n=== 저장 후 재로드 비교 ==="); - let exported = doc.export_hwp_native().unwrap(); - let mut doc2 = HwpDocument::from_bytes(&exported).unwrap(); - doc2.convert_to_editable_native().unwrap(); - doc2.paginate(); - - let pages_reload = doc2.pagination[0].pages.len(); - eprintln!("재로드 pages: {}", pages_reload); - - let mut diff_count = 0; - for pidx in 0..doc.pagination[0].pages.len().max(doc2.pagination[0].pages.len()) { - let items1 = doc.pagination[0].pages.get(pidx).map(|p| &p.column_contents[0].items); - let items2 = doc2.pagination[0].pages.get(pidx).map(|p| &p.column_contents[0].items); - - let pi1_first = items1.and_then(|i| i.first()).map(|it| match it { - PageItem::FullParagraph { para_index } | - PageItem::Table { para_index, .. } | - PageItem::PartialParagraph { para_index, .. } | - PageItem::PartialTable { para_index, .. } | - PageItem::Shape { para_index, .. } => *para_index, - }); - let pi2_first = items2.and_then(|i| i.first()).map(|it| match it { - PageItem::FullParagraph { para_index } | - PageItem::Table { para_index, .. } | - PageItem::PartialParagraph { para_index, .. } | - PageItem::PartialTable { para_index, .. } | - PageItem::Shape { para_index, .. } => *para_index, - }); - let count1 = items1.map(|i| i.len()).unwrap_or(0); - let count2 = items2.map(|i| i.len()).unwrap_or(0); + // page 13 (idx=12)의 pi=199 앞에서 엔터 + eprintln!("=== splitParagraph(0, 199, 0) ==="); + let result = doc.split_paragraph_native(0, 199, 0).unwrap(); + assert!(result.contains("\"ok\":true")); + + let pages_after = doc.pagination[0].pages.len(); + eprintln!("pages: {} → {}", pages_before, pages_after); + + // 분할 후: 각 페이지 비교 + let mut last_diff_page = 0; + for (pidx, page) in doc.pagination[0].pages.iter().enumerate() { + let items = &page.column_contents[0].items; + let first = items + .first() + .map(|it| match it { + PageItem::FullParagraph { para_index } + | PageItem::Table { para_index, .. } + | PageItem::PartialParagraph { para_index, .. } + | PageItem::PartialTable { para_index, .. } + | PageItem::Shape { para_index, .. } => *para_index, + }) + .unwrap_or(0); + let last = items + .last() + .map(|it| match it { + PageItem::FullParagraph { para_index } + | PageItem::Table { para_index, .. } + | PageItem::PartialParagraph { para_index, .. } + | PageItem::PartialTable { para_index, .. } + | PageItem::Shape { para_index, .. } => *para_index, + }) + .unwrap_or(0); - if pi1_first != pi2_first || count1 != count2 { - diff_count += 1; - eprintln!(" page {:2}: 편집={:?}({}items) vs 재로드={:?}({}items)", - pidx + 1, pi1_first, count1, pi2_first, count2); - } - } - if diff_count == 0 { - eprintln!(" 차이 없음 — 편집 결과와 재로드 결과 일치"); - } else { - eprintln!(" {} 페이지에서 차이 발견", diff_count); + let before = before_pages.get(pidx); + let changed = before + .map(|b| b.0 != first || b.1 != last || b.2 != items.len()) + .unwrap_or(true); + + if changed { + last_diff_page = pidx; + let before_str = before + .map(|b| format!("pi={}-{} ({}items)", b.0, b.1, b.2)) + .unwrap_or_else(|| "(신규)".to_string()); + eprintln!( + " page {:2}: {} → pi={}-{} ({}items) ← CHANGED", + pidx + 1, + before_str, + first, + last, + items.len() + ); } } - - /// 12페이지 각 문단에서 엔터 후 13페이지 표 배치 검증 - #[test] - fn test_page12_enter_table_placement_scan() { - use crate::renderer::pagination::PageItem; - - // 12페이지의 각 문단 끝에서 엔터를 입력하는 시나리오 - for split_pi in [194, 196] { - let bytes = std::fs::read("samples/kps-ai.hwp").expect("kps-ai.hwp 읽기 실패"); - let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); - doc.convert_to_editable_native().unwrap(); - doc.paginate(); - - let text_len = doc.document.sections[0].paragraphs[split_pi].text.chars().count(); - let offset = text_len; // 문단 끝에서 분할 - - eprintln!("\n=== split pi={} offset={} ===", split_pi, offset); - - // 분할 전 page 13 (idx=12) 확인 - let table_pi_before = 198; // 원래 pi=198의 표 - let p13_before = &doc.pagination[0].pages[12]; - let has_table_before = p13_before.column_contents[0].items.iter() - .any(|it| matches!(it, PageItem::Table { para_index, .. } if *para_index == table_pi_before)); - eprintln!(" before: pi={} table on page 13: {}", table_pi_before, has_table_before); - - let result = doc.split_paragraph_native(0, split_pi, offset).unwrap(); - assert!(result.contains("\"ok\":true"), "split failed at pi={}: {}", split_pi, result); - - let pages_after = doc.pagination[0].pages.len(); - let table_pi_after = if split_pi < table_pi_before { table_pi_before + 1 } else { table_pi_before }; - - // 분할 후: 표가 어느 페이지에 있는지 탐색 - let mut table_page = None; - for (pidx, page) in doc.pagination[0].pages.iter().enumerate() { - for item in &page.column_contents[0].items { - if matches!(item, PageItem::Table { para_index, .. } if *para_index == table_pi_after) { - table_page = Some(pidx); - } - } - } - eprintln!(" after: pi={} table on page {} (total {})", - table_pi_after, table_page.map(|p| p + 1).unwrap_or(0), pages_after); - - // 페이지 12-15 내용 출력 - for pidx in 11..15.min(pages_after) { - let p = &doc.pagination[0].pages[pidx]; - eprintln!(" page {} items:", pidx + 1); - for item in &p.column_contents[0].items { - match item { - PageItem::Table { para_index, control_index } => { - let text = &doc.document.sections[0].paragraphs[*para_index].text; - eprintln!(" Table pi={} ci={} text='{}'", para_index, control_index, - &text[..text.len().min(30)]); - } - PageItem::FullParagraph { para_index } => { - let text = &doc.document.sections[0].paragraphs[*para_index].text; - let display: String = if text.is_empty() { "(빈)".to_string() } else { text.chars().take(40).collect() }; - eprintln!(" FullPara pi={} '{}'", para_index, display); - } - _ => eprintln!(" {:?}", item), - } - } - } + eprintln!( + "전파 범위: page 13 ~ page {} (총 {} 페이지 영향)", + last_diff_page + 1, + last_diff_page + 1 - 12 + ); + + // 저장 후 재로드와 비교 + eprintln!("\n=== 저장 후 재로드 비교 ==="); + let exported = doc.export_hwp_native().unwrap(); + let mut doc2 = HwpDocument::from_bytes(&exported).unwrap(); + doc2.convert_to_editable_native().unwrap(); + doc2.paginate(); + + let pages_reload = doc2.pagination[0].pages.len(); + eprintln!("재로드 pages: {}", pages_reload); + + let mut diff_count = 0; + for pidx in 0..doc.pagination[0] + .pages + .len() + .max(doc2.pagination[0].pages.len()) + { + let items1 = doc.pagination[0] + .pages + .get(pidx) + .map(|p| &p.column_contents[0].items); + let items2 = doc2.pagination[0] + .pages + .get(pidx) + .map(|p| &p.column_contents[0].items); + + let pi1_first = items1.and_then(|i| i.first()).map(|it| match it { + PageItem::FullParagraph { para_index } + | PageItem::Table { para_index, .. } + | PageItem::PartialParagraph { para_index, .. } + | PageItem::PartialTable { para_index, .. } + | PageItem::Shape { para_index, .. } => *para_index, + }); + let pi2_first = items2.and_then(|i| i.first()).map(|it| match it { + PageItem::FullParagraph { para_index } + | PageItem::Table { para_index, .. } + | PageItem::PartialParagraph { para_index, .. } + | PageItem::PartialTable { para_index, .. } + | PageItem::Shape { para_index, .. } => *para_index, + }); + let count1 = items1.map(|i| i.len()).unwrap_or(0); + let count2 = items2.map(|i| i.len()).unwrap_or(0); + + if pi1_first != pi2_first || count1 != count2 { + diff_count += 1; + eprintln!( + " page {:2}: 편집={:?}({}items) vs 재로드={:?}({}items)", + pidx + 1, + pi1_first, + count1, + pi2_first, + count2 + ); } } + if diff_count == 0 { + eprintln!(" 차이 없음 — 편집 결과와 재로드 결과 일치"); + } else { + eprintln!(" {} 페이지에서 차이 발견", diff_count); + } +} - /// 12페이지 엔터 후 13페이지의 표 배치 검증 - #[test] - fn test_page12_enter_table_placement() { - use crate::renderer::pagination::PageItem; +/// 12페이지 각 문단에서 엔터 후 13페이지 표 배치 검증 +#[test] +fn test_page12_enter_table_placement_scan() { + use crate::renderer::pagination::PageItem; + // 12페이지의 각 문단 끝에서 엔터를 입력하는 시나리오 + for split_pi in [194, 196] { let bytes = std::fs::read("samples/kps-ai.hwp").expect("kps-ai.hwp 읽기 실패"); let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); doc.convert_to_editable_native().unwrap(); doc.paginate(); - let pages_before = doc.pagination[0].pages.len(); - eprintln!(" pages_before = {}", pages_before); + let text_len = doc.document.sections[0].paragraphs[split_pi] + .text + .chars() + .count(); + let offset = text_len; // 문단 끝에서 분할 - // page 12 (idx=11) 내용 확인 - let p12 = &doc.pagination[0].pages[11]; - eprintln!(" page 12 items:"); - for item in &p12.column_contents[0].items { - eprintln!(" {:?}", item); - } + eprintln!("\n=== split pi={} offset={} ===", split_pi, offset); - // page 13 (idx=12): pi=197(text), pi=198(table), pi=199(text) + // 분할 전 page 13 (idx=12) 확인 + let table_pi_before = 198; // 원래 pi=198의 표 let p13_before = &doc.pagination[0].pages[12]; - eprintln!(" page 13 items (before):"); - for item in &p13_before.column_contents[0].items { - eprintln!(" {:?}", item); - } - // pi=198 표가 page 13에 있는지 확인 - let has_table_198_on_p13 = p13_before.column_contents[0].items.iter() - .any(|it| matches!(it, PageItem::Table { para_index: 198, .. })); - assert!(has_table_198_on_p13, "수정 전: pi=198 표가 page 13에 있어야 함"); + let has_table_before = p13_before.column_contents[0].items.iter().any( + |it| matches!(it, PageItem::Table { para_index, .. } if *para_index == table_pi_before), + ); + eprintln!( + " before: pi={} table on page 13: {}", + table_pi_before, has_table_before + ); - // pi=199 앞에서 엔터 (pi=199를 분할하여 빈 문단 삽입) - let result = doc.split_paragraph_native(0, 199, 0).unwrap(); - assert!(result.contains("\"ok\":true"), "split failed: {}", result); + let result = doc.split_paragraph_native(0, split_pi, offset).unwrap(); + assert!( + result.contains("\"ok\":true"), + "split failed at pi={}: {}", + split_pi, + result + ); let pages_after = doc.pagination[0].pages.len(); - eprintln!(" pages_after = {}", pages_after); + let table_pi_after = if split_pi < table_pi_before { + table_pi_before + 1 + } else { + table_pi_before + }; - // page 13 (idx=12): pi=198 표가 여전히 page 13에 있어야 함 - if doc.pagination[0].pages.len() > 12 { - let p13_after = &doc.pagination[0].pages[12]; - eprintln!(" page 13 items (after):"); - for item in &p13_after.column_contents[0].items { - eprintln!(" {:?}", item); + // 분할 후: 표가 어느 페이지에 있는지 탐색 + let mut table_page = None; + for (pidx, page) in doc.pagination[0].pages.iter().enumerate() { + for item in &page.column_contents[0].items { + if matches!(item, PageItem::Table { para_index, .. } if *para_index == table_pi_after) + { + table_page = Some(pidx); + } } - let has_table_198_after = p13_after.column_contents[0].items.iter() - .any(|it| matches!(it, PageItem::Table { para_index: 198, .. })); + } + eprintln!( + " after: pi={} table on page {} (total {})", + table_pi_after, + table_page.map(|p| p + 1).unwrap_or(0), + pages_after + ); - // page 14도 확인 - if doc.pagination[0].pages.len() > 13 { - let p14_after = &doc.pagination[0].pages[13]; - eprintln!(" page 14 items (after):"); - for item in &p14_after.column_contents[0].items { - eprintln!(" {:?}", item); + // 페이지 12-15 내용 출력 + for pidx in 11..15.min(pages_after) { + let p = &doc.pagination[0].pages[pidx]; + eprintln!(" page {} items:", pidx + 1); + for item in &p.column_contents[0].items { + match item { + PageItem::Table { + para_index, + control_index, + } => { + let text = &doc.document.sections[0].paragraphs[*para_index].text; + eprintln!( + " Table pi={} ci={} text='{}'", + para_index, + control_index, + &text[..text.len().min(30)] + ); + } + PageItem::FullParagraph { para_index } => { + let text = &doc.document.sections[0].paragraphs[*para_index].text; + let display: String = if text.is_empty() { + "(빈)".to_string() + } else { + text.chars().take(40).collect() + }; + eprintln!(" FullPara pi={} '{}'", para_index, display); + } + _ => eprintln!(" {:?}", item), } } - - assert!(has_table_198_after, - "pi=198 표가 page 13에 있어야 하지만 다음 페이지로 밀려남"); } } +} + +/// 12페이지 엔터 후 13페이지의 표 배치 검증 +#[test] +fn test_page12_enter_table_placement() { + use crate::renderer::pagination::PageItem; + + let bytes = std::fs::read("samples/kps-ai.hwp").expect("kps-ai.hwp 읽기 실패"); + let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); + doc.convert_to_editable_native().unwrap(); + doc.paginate(); + + let pages_before = doc.pagination[0].pages.len(); + eprintln!(" pages_before = {}", pages_before); + + // page 12 (idx=11) 내용 확인 + let p12 = &doc.pagination[0].pages[11]; + eprintln!(" page 12 items:"); + for item in &p12.column_contents[0].items { + eprintln!(" {:?}", item); + } - /// 문단 분할 후 페이지 수가 과도하게 증가하지 않는지 검증 - /// (measure_section_selective의 off-by-one 인덱싱 버그 회귀 방지) - #[test] - fn test_split_paragraph_page_count_stability() { - let bytes = std::fs::read("samples/kps-ai.hwp").expect("kps-ai.hwp 읽기 실패"); - let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); - doc.convert_to_editable_native().unwrap(); - doc.paginate(); - - let pages_before = doc.pagination.iter().map(|r| r.pages.len()).sum::(); - eprintln!(" pages_before = {}", pages_before); - - // pi=199 앞에서 엔터 (offset=0으로 분할) - let result = doc.split_paragraph_native(0, 199, 0).unwrap(); - assert!(result.contains("\"ok\":true"), "split failed: {}", result); + // page 13 (idx=12): pi=197(text), pi=198(table), pi=199(text) + let p13_before = &doc.pagination[0].pages[12]; + eprintln!(" page 13 items (before):"); + for item in &p13_before.column_contents[0].items { + eprintln!(" {:?}", item); + } + // pi=198 표가 page 13에 있는지 확인 + let has_table_198_on_p13 = p13_before.column_contents[0].items.iter().any(|it| { + matches!( + it, + PageItem::Table { + para_index: 198, + .. + } + ) + }); + assert!( + has_table_198_on_p13, + "수정 전: pi=198 표가 page 13에 있어야 함" + ); + + // pi=199 앞에서 엔터 (pi=199를 분할하여 빈 문단 삽입) + let result = doc.split_paragraph_native(0, 199, 0).unwrap(); + assert!(result.contains("\"ok\":true"), "split failed: {}", result); + + let pages_after = doc.pagination[0].pages.len(); + eprintln!(" pages_after = {}", pages_after); + + // page 13 (idx=12): pi=198 표가 여전히 page 13에 있어야 함 + if doc.pagination[0].pages.len() > 12 { + let p13_after = &doc.pagination[0].pages[12]; + eprintln!(" page 13 items (after):"); + for item in &p13_after.column_contents[0].items { + eprintln!(" {:?}", item); + } + let has_table_198_after = p13_after.column_contents[0].items.iter().any(|it| { + matches!( + it, + PageItem::Table { + para_index: 198, + .. + } + ) + }); - let pages_after = doc.pagination.iter().map(|r| r.pages.len()).sum::(); - eprintln!(" pages_after = {}", pages_after); + // page 14도 확인 + if doc.pagination[0].pages.len() > 13 { + let p14_after = &doc.pagination[0].pages[13]; + eprintln!(" page 14 items (after):"); + for item in &p14_after.column_contents[0].items { + eprintln!(" {:?}", item); + } + } - // 한 줄 추가이므로 페이지 수 증가는 최대 2 이내여야 함 - let delta = pages_after as i64 - pages_before as i64; - eprintln!(" delta = {}", delta); assert!( - delta <= 2, - "문단 분할 후 페이지 수가 {}에서 {}로 {}만큼 증가 (최대 2 예상)", - pages_before, pages_after, delta + has_table_198_after, + "pi=198 표가 page 13에 있어야 하지만 다음 페이지로 밀려남" ); } - - /// 논리적 오프셋: 인라인 TAC 표 뒤에서 텍스트 삽입 검증 - #[test] - fn test_logical_offset_insert_after_inline_table() { - let bytes = std::fs::read("saved/blank2010.hwp").expect("blank2010.hwp 읽기 실패"); - let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); - doc.convert_to_editable_native().unwrap(); - doc.paginate(); - - // Enter로 새 문단 생성 (기존 컨트롤이 있는 pi=0 대신 깨끗한 pi=1 사용) - doc.insert_text_native(0, 0, 0, "test").unwrap(); - doc.split_paragraph_native(0, 0, 4).unwrap(); - - // pi=1에 "abc" 입력 - doc.insert_text_native(0, 1, 0, "abc").unwrap(); - let para = &doc.document.sections[0].paragraphs[1]; - assert_eq!(para.text, "abc"); - eprintln!(" pi=1 controls={} (표 삽입 전)", para.controls.len()); - - // offset=3 위치에 인라인 TAC 2×2 표 삽입 - let result = doc.create_table_ex_native(0, 1, 3, 2, 2, true, Some(&[6777, 6777])).unwrap(); - eprintln!(" createTableEx result: {}", result); - // logicalOffset: "abc"(3) + [표](1) = 4 - assert!(result.contains("\"logicalOffset\":4"), "logicalOffset=4 예상: {}", result); - - let para = &doc.document.sections[0].paragraphs[1]; - eprintln!(" text='{}' controls={} char_offsets={:?}", para.text, para.controls.len(), para.char_offsets); - - // 논리적 길이: "abc"(3) + [표](1) = 4 - let logical_len = crate::document_core::helpers::logical_paragraph_length(para); - eprintln!(" 논리적 길이: {}", logical_len); - assert_eq!(logical_len, 4, "논리적 길이 4 예상, 실제: {}", logical_len); - - // 논리적 offset 4에 "XYZ" 삽입 → 표 뒤에 삽입되어야 함 - let (text_off, after_ctrl) = crate::document_core::helpers::logical_to_text_offset(para, 4); - eprintln!(" logical 4 → text_off={} after_ctrl={}", text_off, after_ctrl); - assert_eq!(text_off, 3, "text_off=3 예상 (abc 뒤)"); - - doc.insert_text_native(0, 1, text_off, "XYZ").unwrap(); - let para = &doc.document.sections[0].paragraphs[1]; - assert_eq!(para.text, "abcXYZ", "표 뒤에 XYZ 삽입 예상, 실제: '{}'", para.text); - eprintln!(" 삽입 후 text='{}' ✓", para.text); - - // 논리적 길이: "abcXYZ"(6) + [표](1) = 7 - let logical_len2 = crate::document_core::helpers::logical_paragraph_length(para); - assert_eq!(logical_len2, 7, "논리적 길이 7 예상, 실제: {}", logical_len2); - - // logical offset 변환 검증 (삽입 후: "abcXYZ" + [표at3]) - // a(0) b(1) c(2) [표](3) X(4) Y(5) Z(6) - let (t0, _) = crate::document_core::helpers::logical_to_text_offset(para, 0); - let (t3, _) = crate::document_core::helpers::logical_to_text_offset(para, 3); - let (t4, _) = crate::document_core::helpers::logical_to_text_offset(para, 4); - let (t7, _) = crate::document_core::helpers::logical_to_text_offset(para, 7); - eprintln!(" logical→text: 0→{} 3→{} 4→{} 7→{}", t0, t3, t4, t7); - assert_eq!(t0, 0, "logical 0 → text 0"); - assert_eq!(t3, 3, "logical 3 → text 3 (표 위치, [표] = ctrl at text pos 3)"); - assert_eq!(t4, 3 + 1, "logical 4 → text 4 (X, 표 뒤 첫 텍스트)"); - assert_eq!(t7, 6, "logical 7 → text 6 (끝)"); - - // ── 핵심 검증: charOffset > text_len으로 직접 삽입 ── - // 새 문서에서 "가나다" + [표] 구조 생성, charOffset=4로 삽입 - doc.split_paragraph_native(0, 1, 6).unwrap(); // pi=2 생성 - doc.insert_text_native(0, 2, 0, "가나다").unwrap(); - doc.create_table_ex_native(0, 2, 3, 1, 1, true, Some(&[5000])).unwrap(); - let para2 = &doc.document.sections[0].paragraphs[2]; - let tl = para2.text.chars().count(); - eprintln!(" pi=2: text='{}' len={} controls={}", para2.text, tl, para2.controls.len()); - // charOffset=4 (> text_len=3) → 표 뒤에 삽입 - doc.insert_text_native(0, 2, 4, "라마바").unwrap(); - let para2 = &doc.document.sections[0].paragraphs[2]; - eprintln!(" charOffset=4 삽입 후: '{}'", para2.text); - assert_eq!(para2.text, "가나다라마바", "표 뒤에 '라마바' 삽입, 실제: '{}'", para2.text); - - eprintln!(" 논리적 오프셋 테스트 통과 ✓"); - } - - /// createTableEx: 빈 문서에서 인라인 TAC 표를 생성하여 tac-case-001.hwp와 동일한 구조 검증 - #[test] - fn test_create_inline_tac_table() { - let bytes = std::fs::read("saved/blank2010.hwp").expect("blank2010.hwp 읽기 실패"); - let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); - doc.convert_to_editable_native().unwrap(); - doc.paginate(); - - // 1. pi=0에 "TC #20" 입력 - doc.insert_text_native(0, 0, 0, "TC #20").unwrap(); - // 2. Enter → pi=1 생성 - doc.split_paragraph_native(0, 0, 6).unwrap(); - // 3. pi=1에 "tacglkj 표 3 배치 시작" 입력 - doc.insert_text_native(0, 1, 0, "tacglkj 표 3 배치 시작").unwrap(); - - let text_len = doc.document.sections[0].paragraphs[1].text.chars().count(); - eprintln!(" pi=1 text='{}' len={}", doc.document.sections[0].paragraphs[1].text, text_len); - - // 4. pi=1, char_offset=text_len 위치에 인라인 TAC 2×2 표 생성 - // 열 폭: 6777 HU × 2 = 13554 HU (tac-case-001.hwp과 동일) - let result = doc.create_table_ex_native(0, 1, text_len, 2, 2, true, Some(&[6777, 6777])).unwrap(); - eprintln!(" createTableEx result: {}", result); - assert!(result.contains("\"ok\":true"), "createTableEx 실패: {}", result); - - // 5. 표 뒤에 "4 tacglkj 표 다음" 텍스트 추가 - let para = &doc.document.sections[0].paragraphs[1]; - let new_text_offset = para.text.chars().count(); - doc.insert_text_native(0, 1, new_text_offset, "4 tacglkj 표 다음").unwrap(); - - // 6. 검증 - let para = &doc.document.sections[0].paragraphs[1]; - eprintln!(" pi=1 final text='{}' controls={}", para.text, para.controls.len()); - - // 표가 controls에 추가되었는지 - assert_eq!(para.controls.len(), 1, "pi=1에 표 컨트롤 1개 예상"); - if let crate::model::control::Control::Table(t) = ¶.controls[0] { - assert!(t.common.treat_as_char, "treat_as_char=true 예상"); - assert_eq!(t.row_count, 2, "행 수 2 예상"); - assert_eq!(t.col_count, 2, "열 수 2 예상"); - eprintln!(" 표: {}×{} tac={} width={} height={}", - t.row_count, t.col_count, t.common.treat_as_char, t.common.width, t.common.height); - } else { - panic!("pi=1의 첫 컨트롤이 Table이 아님"); - } - - // 셀에 텍스트 입력 - doc.insert_text_in_cell_native(0, 1, 0, 0, 0, 0, "1").unwrap(); - doc.insert_text_in_cell_native(0, 1, 0, 1, 0, 0, "2").unwrap(); - doc.insert_text_in_cell_native(0, 1, 0, 2, 0, 0, "3 tacglkj").unwrap(); - doc.insert_text_in_cell_native(0, 1, 0, 3, 0, 0, "4 tacglkj").unwrap(); - - // Enter → pi=2 - let pi1_len = doc.document.sections[0].paragraphs[1].text.chars().count(); - doc.split_paragraph_native(0, 1, pi1_len).unwrap(); - // pi=2에 텍스트 - doc.insert_text_native(0, 2, 0, "tacglkj 가나 옮").unwrap(); - - // 페이지네이션 - doc.paginate(); - let page_count: usize = doc.pagination.iter().map(|r| r.pages.len()).sum(); - eprintln!(" 최종 페이지 수: {}", page_count); - assert_eq!(page_count, 1, "1페이지 문서 예상"); - - // 텍스트에 표가 포함된 인라인 배치 확인 - let para = &doc.document.sections[0].paragraphs[1]; - assert!(!para.text.is_empty(), "pi=1에 텍스트가 있어야 함"); - assert_eq!(para.controls.len(), 1, "pi=1에 인라인 표 1개"); - - // is_tac_table_inline 확인 - let seg_w = para.line_segs.first().map(|s| s.segment_width).unwrap_or(0); - if let crate::model::control::Control::Table(t) = ¶.controls[0] { - let is_inline = crate::renderer::height_measurer::is_tac_table_inline( - t, seg_w, ¶.text, ¶.controls); - eprintln!(" is_tac_table_inline: {} (seg_w={})", is_inline, seg_w); - assert!(is_inline, "인라인 TAC 표로 판별되어야 함"); - } - - eprintln!(" 인라인 TAC 표 생성 테스트 통과"); - } - - #[test] - fn test_extract_thumbnail_with_preview() { - // PrvImage가 있는 HWP 파일 테스트 - let data = std::fs::read("samples/biz_plan.hwp").expect("biz_plan.hwp 읽기 실패"); - let result = crate::parser::extract_thumbnail_only(&data); - if let Some(ref r) = result { - eprintln!(" biz_plan.hwp 썸네일: format={}, size={}bytes, {}x{}", r.format, r.data.len(), r.width, r.height); - eprintln!(" 매직 바이트: {:02x?}", &r.data[..std::cmp::min(16, r.data.len())]); - } else { - eprintln!(" biz_plan.hwp 썸네일: None"); - } - // PrvImage 유무와 상관없이 패닉하지 않아야 함 +} + +/// 문단 분할 후 페이지 수가 과도하게 증가하지 않는지 검증 +/// (measure_section_selective의 off-by-one 인덱싱 버그 회귀 방지) +#[test] +fn test_split_paragraph_page_count_stability() { + let bytes = std::fs::read("samples/kps-ai.hwp").expect("kps-ai.hwp 읽기 실패"); + let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); + doc.convert_to_editable_native().unwrap(); + doc.paginate(); + + let pages_before = doc.pagination.iter().map(|r| r.pages.len()).sum::(); + eprintln!(" pages_before = {}", pages_before); + + // pi=199 앞에서 엔터 (offset=0으로 분할) + let result = doc.split_paragraph_native(0, 199, 0).unwrap(); + assert!(result.contains("\"ok\":true"), "split failed: {}", result); + + let pages_after = doc.pagination.iter().map(|r| r.pages.len()).sum::(); + eprintln!(" pages_after = {}", pages_after); + + // 한 줄 추가이므로 페이지 수 증가는 최대 2 이내여야 함 + let delta = pages_after as i64 - pages_before as i64; + eprintln!(" delta = {}", delta); + assert!( + delta <= 2, + "문단 분할 후 페이지 수가 {}에서 {}로 {}만큼 증가 (최대 2 예상)", + pages_before, + pages_after, + delta + ); +} + +/// 논리적 오프셋: 인라인 TAC 표 뒤에서 텍스트 삽입 검증 +#[test] +fn test_logical_offset_insert_after_inline_table() { + let bytes = std::fs::read("saved/blank2010.hwp").expect("blank2010.hwp 읽기 실패"); + let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); + doc.convert_to_editable_native().unwrap(); + doc.paginate(); + + // Enter로 새 문단 생성 (기존 컨트롤이 있는 pi=0 대신 깨끗한 pi=1 사용) + doc.insert_text_native(0, 0, 0, "test").unwrap(); + doc.split_paragraph_native(0, 0, 4).unwrap(); + + // pi=1에 "abc" 입력 + doc.insert_text_native(0, 1, 0, "abc").unwrap(); + let para = &doc.document.sections[0].paragraphs[1]; + assert_eq!(para.text, "abc"); + eprintln!(" pi=1 controls={} (표 삽입 전)", para.controls.len()); + + // offset=3 위치에 인라인 TAC 2×2 표 삽입 + let result = doc + .create_table_ex_native(0, 1, 3, 2, 2, true, Some(&[6777, 6777])) + .unwrap(); + eprintln!(" createTableEx result: {}", result); + // logicalOffset: "abc"(3) + [표](1) = 4 + assert!( + result.contains("\"logicalOffset\":4"), + "logicalOffset=4 예상: {}", + result + ); + + let para = &doc.document.sections[0].paragraphs[1]; + eprintln!( + " text='{}' controls={} char_offsets={:?}", + para.text, + para.controls.len(), + para.char_offsets + ); + + // 논리적 길이: "abc"(3) + [표](1) = 4 + let logical_len = crate::document_core::helpers::logical_paragraph_length(para); + eprintln!(" 논리적 길이: {}", logical_len); + assert_eq!(logical_len, 4, "논리적 길이 4 예상, 실제: {}", logical_len); + + // 논리적 offset 4에 "XYZ" 삽입 → 표 뒤에 삽입되어야 함 + let (text_off, after_ctrl) = crate::document_core::helpers::logical_to_text_offset(para, 4); + eprintln!( + " logical 4 → text_off={} after_ctrl={}", + text_off, after_ctrl + ); + assert_eq!(text_off, 3, "text_off=3 예상 (abc 뒤)"); + + doc.insert_text_native(0, 1, text_off, "XYZ").unwrap(); + let para = &doc.document.sections[0].paragraphs[1]; + assert_eq!( + para.text, "abcXYZ", + "표 뒤에 XYZ 삽입 예상, 실제: '{}'", + para.text + ); + eprintln!(" 삽입 후 text='{}' ✓", para.text); + + // 논리적 길이: "abcXYZ"(6) + [표](1) = 7 + let logical_len2 = crate::document_core::helpers::logical_paragraph_length(para); + assert_eq!( + logical_len2, 7, + "논리적 길이 7 예상, 실제: {}", + logical_len2 + ); + + // logical offset 변환 검증 (삽입 후: "abcXYZ" + [표at3]) + // a(0) b(1) c(2) [표](3) X(4) Y(5) Z(6) + let (t0, _) = crate::document_core::helpers::logical_to_text_offset(para, 0); + let (t3, _) = crate::document_core::helpers::logical_to_text_offset(para, 3); + let (t4, _) = crate::document_core::helpers::logical_to_text_offset(para, 4); + let (t7, _) = crate::document_core::helpers::logical_to_text_offset(para, 7); + eprintln!(" logical→text: 0→{} 3→{} 4→{} 7→{}", t0, t3, t4, t7); + assert_eq!(t0, 0, "logical 0 → text 0"); + assert_eq!( + t3, 3, + "logical 3 → text 3 (표 위치, [표] = ctrl at text pos 3)" + ); + assert_eq!(t4, 3 + 1, "logical 4 → text 4 (X, 표 뒤 첫 텍스트)"); + assert_eq!(t7, 6, "logical 7 → text 6 (끝)"); + + // ── 핵심 검증: charOffset > text_len으로 직접 삽입 ── + // 새 문서에서 "가나다" + [표] 구조 생성, charOffset=4로 삽입 + doc.split_paragraph_native(0, 1, 6).unwrap(); // pi=2 생성 + doc.insert_text_native(0, 2, 0, "가나다").unwrap(); + doc.create_table_ex_native(0, 2, 3, 1, 1, true, Some(&[5000])) + .unwrap(); + let para2 = &doc.document.sections[0].paragraphs[2]; + let tl = para2.text.chars().count(); + eprintln!( + " pi=2: text='{}' len={} controls={}", + para2.text, + tl, + para2.controls.len() + ); + // charOffset=4 (> text_len=3) → 표 뒤에 삽입 + doc.insert_text_native(0, 2, 4, "라마바").unwrap(); + let para2 = &doc.document.sections[0].paragraphs[2]; + eprintln!(" charOffset=4 삽입 후: '{}'", para2.text); + assert_eq!( + para2.text, "가나다라마바", + "표 뒤에 '라마바' 삽입, 실제: '{}'", + para2.text + ); + + eprintln!(" 논리적 오프셋 테스트 통과 ✓"); +} + +/// createTableEx: 빈 문서에서 인라인 TAC 표를 생성하여 tac-case-001.hwp와 동일한 구조 검증 +#[test] +fn test_create_inline_tac_table() { + let bytes = std::fs::read("saved/blank2010.hwp").expect("blank2010.hwp 읽기 실패"); + let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); + doc.convert_to_editable_native().unwrap(); + doc.paginate(); + + // 1. pi=0에 "TC #20" 입력 + doc.insert_text_native(0, 0, 0, "TC #20").unwrap(); + // 2. Enter → pi=1 생성 + doc.split_paragraph_native(0, 0, 6).unwrap(); + // 3. pi=1에 "tacglkj 표 3 배치 시작" 입력 + doc.insert_text_native(0, 1, 0, "tacglkj 표 3 배치 시작") + .unwrap(); + + let text_len = doc.document.sections[0].paragraphs[1].text.chars().count(); + eprintln!( + " pi=1 text='{}' len={}", + doc.document.sections[0].paragraphs[1].text, text_len + ); + + // 4. pi=1, char_offset=text_len 위치에 인라인 TAC 2×2 표 생성 + // 열 폭: 6777 HU × 2 = 13554 HU (tac-case-001.hwp과 동일) + let result = doc + .create_table_ex_native(0, 1, text_len, 2, 2, true, Some(&[6777, 6777])) + .unwrap(); + eprintln!(" createTableEx result: {}", result); + assert!( + result.contains("\"ok\":true"), + "createTableEx 실패: {}", + result + ); + + // 5. 표 뒤에 "4 tacglkj 표 다음" 텍스트 추가 + let para = &doc.document.sections[0].paragraphs[1]; + let new_text_offset = para.text.chars().count(); + doc.insert_text_native(0, 1, new_text_offset, "4 tacglkj 표 다음") + .unwrap(); + + // 6. 검증 + let para = &doc.document.sections[0].paragraphs[1]; + eprintln!( + " pi=1 final text='{}' controls={}", + para.text, + para.controls.len() + ); + + // 표가 controls에 추가되었는지 + assert_eq!(para.controls.len(), 1, "pi=1에 표 컨트롤 1개 예상"); + if let crate::model::control::Control::Table(t) = ¶.controls[0] { + assert!(t.common.treat_as_char, "treat_as_char=true 예상"); + assert_eq!(t.row_count, 2, "행 수 2 예상"); + assert_eq!(t.col_count, 2, "열 수 2 예상"); + eprintln!( + " 표: {}×{} tac={} width={} height={}", + t.row_count, t.col_count, t.common.treat_as_char, t.common.width, t.common.height + ); + } else { + panic!("pi=1의 첫 컨트롤이 Table이 아님"); } - #[test] - fn test_extract_thumbnail_without_preview() { - // 잘못된 데이터에서는 None 반환 - let result = crate::parser::extract_thumbnail_only(&[0u8; 100]); - assert!(result.is_none(), "잘못된 데이터에서는 None이어야 함"); - - // 빈 바이트에서도 패닉하지 않아야 함 - let result = crate::parser::extract_thumbnail_only(&[]); - assert!(result.is_none(), "빈 데이터에서는 None이어야 함"); - eprintln!(" 잘못된/빈 데이터 썸네일: None (정상)"); + // 셀에 텍스트 입력 + doc.insert_text_in_cell_native(0, 1, 0, 0, 0, 0, "1") + .unwrap(); + doc.insert_text_in_cell_native(0, 1, 0, 1, 0, 0, "2") + .unwrap(); + doc.insert_text_in_cell_native(0, 1, 0, 2, 0, 0, "3 tacglkj") + .unwrap(); + doc.insert_text_in_cell_native(0, 1, 0, 3, 0, 0, "4 tacglkj") + .unwrap(); + + // Enter → pi=2 + let pi1_len = doc.document.sections[0].paragraphs[1].text.chars().count(); + doc.split_paragraph_native(0, 1, pi1_len).unwrap(); + // pi=2에 텍스트 + doc.insert_text_native(0, 2, 0, "tacglkj 가나 옮").unwrap(); + + // 페이지네이션 + doc.paginate(); + let page_count: usize = doc.pagination.iter().map(|r| r.pages.len()).sum(); + eprintln!(" 최종 페이지 수: {}", page_count); + assert_eq!(page_count, 1, "1페이지 문서 예상"); + + // 텍스트에 표가 포함된 인라인 배치 확인 + let para = &doc.document.sections[0].paragraphs[1]; + assert!(!para.text.is_empty(), "pi=1에 텍스트가 있어야 함"); + assert_eq!(para.controls.len(), 1, "pi=1에 인라인 표 1개"); + + // is_tac_table_inline 확인 + let seg_w = para.line_segs.first().map(|s| s.segment_width).unwrap_or(0); + if let crate::model::control::Control::Table(t) = ¶.controls[0] { + let is_inline = crate::renderer::height_measurer::is_tac_table_inline( + t, + seg_w, + ¶.text, + ¶.controls, + ); + eprintln!(" is_tac_table_inline: {} (seg_w={})", is_inline, seg_w); + assert!(is_inline, "인라인 TAC 표로 판별되어야 함"); } + eprintln!(" 인라인 TAC 표 생성 테스트 통과"); +} + +#[test] +fn test_extract_thumbnail_with_preview() { + // PrvImage가 있는 HWP 파일 테스트 + let data = std::fs::read("samples/biz_plan.hwp").expect("biz_plan.hwp 읽기 실패"); + let result = crate::parser::extract_thumbnail_only(&data); + if let Some(ref r) = result { + eprintln!( + " biz_plan.hwp 썸네일: format={}, size={}bytes, {}x{}", + r.format, + r.data.len(), + r.width, + r.height + ); + eprintln!( + " 매직 바이트: {:02x?}", + &r.data[..std::cmp::min(16, r.data.len())] + ); + } else { + eprintln!(" biz_plan.hwp 썸네일: None"); + } + // PrvImage 유무와 상관없이 패닉하지 않아야 함 +} + +#[test] +fn test_extract_thumbnail_without_preview() { + // 잘못된 데이터에서는 None 반환 + let result = crate::parser::extract_thumbnail_only(&[0u8; 100]); + assert!(result.is_none(), "잘못된 데이터에서는 None이어야 함"); + + // 빈 바이트에서도 패닉하지 않아야 함 + let result = crate::parser::extract_thumbnail_only(&[]); + assert!(result.is_none(), "빈 데이터에서는 None이어야 함"); + eprintln!(" 잘못된/빈 데이터 썸네일: None (정상)"); +} diff --git a/src/wmf/converter/bitmap.rs b/src/wmf/converter/bitmap.rs index 64a10902..65378e7a 100644 --- a/src/wmf/converter/bitmap.rs +++ b/src/wmf/converter/bitmap.rs @@ -288,14 +288,21 @@ impl From<(ColorRef, HatchStyle)> for Bitmap { color_important: 0, }), colors: Colors::RGBTriple(vec![ - RGBTriple { red: 0, green: 0, blue: 0 }, + RGBTriple { + red: 0, + green: 0, + blue: 0, + }, RGBTriple { red: color_ref.red, green: color_ref.green, blue: color_ref.blue, }, ]), - bitmap_buffer: BitmapBuffer { undefined_space: vec![], a_data }, + bitmap_buffer: BitmapBuffer { + undefined_space: vec![], + a_data, + }, } .into() } @@ -306,13 +313,16 @@ impl DeviceIndependentBitmap { // nothing to do. if matches!( self.colors, - crate::wmf::parser::Colors::Null - | crate::wmf::parser::Colors::PaletteIndices(_) + crate::wmf::parser::Colors::Null | crate::wmf::parser::Colors::PaletteIndices(_) ) { return self; } - let Self { dib_header_info, colors, bitmap_buffer } = self; + let Self { + dib_header_info, + colors, + bitmap_buffer, + } = self; let bit_count = dib_header_info.bit_count(); let palette: Vec<_> = match colors { Colors::RGBTriple(values) => values @@ -329,8 +339,8 @@ impl DeviceIndependentBitmap { let new_bit_count = crate::wmf::parser::BitCount::BI_BITCOUNT_5; let new_line_bits = dib_header_info.width() * (new_bit_count as usize); let new_line_bytes = ((new_line_bits + 31) / 32) * 4; - let new_line_padding = new_line_bytes - - dib_header_info.width() * (new_bit_count as usize / 8); + let new_line_padding = + new_line_bytes - dib_header_info.width() * (new_bit_count as usize / 8); let line_bits = dib_header_info.width() * (bit_count as usize); let line_bytes = ((line_bits + 31) / 32) * 4; @@ -338,9 +348,8 @@ impl DeviceIndependentBitmap { let mut new_data = vec![]; for _ in 0..dib_header_info.height() { - let mut reader = BitReader::new( - &bitmap_buffer.a_data[position..(position + line_bytes)], - ); + let mut reader = + BitReader::new(&bitmap_buffer.a_data[position..(position + line_bytes)]); for _ in 0..dib_header_info.width() { let Some(idx) = reader.read_bits(bit_count as u8) else { @@ -406,7 +415,11 @@ struct BitReader<'a> { impl<'a> BitReader<'a> { fn new(data: &'a [u8]) -> Self { - BitReader { data, byte_index: 0, bit_index: 0 } + BitReader { + data, + byte_index: 0, + bit_index: 0, + } } fn read_bits(&mut self, num_bits: u8) -> Option { diff --git a/src/wmf/converter/mod.rs b/src/wmf/converter/mod.rs index 530852f3..4ec19567 100644 --- a/src/wmf/converter/mod.rs +++ b/src/wmf/converter/mod.rs @@ -15,7 +15,9 @@ pub enum ConvertError { #[snafu(display("parse error: {source}"))] ParseError { source: ParseError }, #[snafu(display("play error: {source}"))] - PlayError { source: crate::wmf::converter::PlayError }, + PlayError { + source: crate::wmf::converter::PlayError, + }, } impl From for ConvertError { @@ -52,7 +54,10 @@ where err(level = tracing::Level::ERROR, Display), ))] pub fn run(self) -> Result, ConvertError> { - let Self { mut buffer, mut player } = self; + let Self { + mut buffer, + mut player, + } = self; let buf = &mut buffer; let (header, _) = MetafileHeader::parse(buf)?; @@ -71,12 +76,10 @@ where continue; } - let (record_function, c) = - read_u16_from_le_bytes(buf).map_err(ParseError::from)?; + let (record_function, c) = read_u16_from_le_bytes(buf).map_err(ParseError::from)?; record_size.consume(c); - let Some(record_type) = RecordType::from_repr(record_function) - else { + let Some(record_type) = RecordType::from_repr(record_function) else { debug!( record_function = %format!("{record_function:#06X}"), "record_function is not match any RecordType", @@ -95,78 +98,44 @@ where match record_type { // bitmap record RecordType::META_BITBLT => { - let record = - META_BITBLT::parse(buf, record_size, record_function)?; + let record = META_BITBLT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.bit_blt(record_number, record)?; } RecordType::META_DIBBITBLT => { - let record = META_DIBBITBLT::parse( - buf, - record_size, - record_function, - )?; + let record = META_DIBBITBLT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = player.device_independent_bitmap_bit_blt( - record_number, - record, - )?; + player = player.device_independent_bitmap_bit_blt(record_number, record)?; } RecordType::META_DIBSTRETCHBLT => { - let record = META_DIBSTRETCHBLT::parse( - buf, - record_size, - record_function, - )?; + let record = META_DIBSTRETCHBLT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = player.device_independent_bitmap_stretch_blt( - record_number, - record, - )?; + player = player.device_independent_bitmap_stretch_blt(record_number, record)?; } RecordType::META_SETDIBTODEV => { - let record = META_SETDIBTODEV::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETDIBTODEV::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = player.set_device_independent_bitmap_to_dev( - record_number, - record, - )?; + player = player.set_device_independent_bitmap_to_dev(record_number, record)?; } RecordType::META_STRETCHBLT => { - let record = META_STRETCHBLT::parse( - buf, - record_size, - record_function, - )?; + let record = META_STRETCHBLT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.stretch_blt(record_number, record)?; } RecordType::META_STRETCHDIB => { - let record = META_STRETCHDIB::parse( - buf, - record_size, - record_function, - )?; + let record = META_STRETCHDIB::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = player.stretch_device_independent_bitmap( - record_number, - record, - )?; + player = player.stretch_device_independent_bitmap(record_number, record)?; } // control record RecordType::META_EOF => { - let record = - META_EOF::parse(buf, record_size, record_function)?; + let record = META_EOF::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.eof(record_number, record)?; @@ -174,626 +143,389 @@ where } // drawing record RecordType::META_ARC => { - let record = - META_ARC::parse(buf, record_size, record_function)?; + let record = META_ARC::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.arc(record_number, record)?; } RecordType::META_CHORD => { - let record = - META_CHORD::parse(buf, record_size, record_function)?; + let record = META_CHORD::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.chord(record_number, record)?; } RecordType::META_ELLIPSE => { - let record = - META_ELLIPSE::parse(buf, record_size, record_function)?; + let record = META_ELLIPSE::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.ellipse(record_number, record)?; } RecordType::META_EXTFLOODFILL => { - let record = META_EXTFLOODFILL::parse( - buf, - record_size, - record_function, - )?; + let record = META_EXTFLOODFILL::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.ext_flood_fill(record_number, record)?; } RecordType::META_EXTTEXTOUT => { - let record = META_EXTTEXTOUT::parse( - buf, - record_size, - record_function, - )?; + let record = META_EXTTEXTOUT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.ext_text_out(record_number, record)?; } RecordType::META_FILLREGION => { - let record = META_FILLREGION::parse( - buf, - record_size, - record_function, - )?; + let record = META_FILLREGION::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.fill_region(record_number, record)?; } RecordType::META_FLOODFILL => { - let record = META_FLOODFILL::parse( - buf, - record_size, - record_function, - )?; + let record = META_FLOODFILL::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.flood_fill(record_number, record)?; } RecordType::META_FRAMEREGION => { - let record = META_FRAMEREGION::parse( - buf, - record_size, - record_function, - )?; + let record = META_FRAMEREGION::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.frame_region(record_number, record)?; } RecordType::META_INVERTREGION => { - let record = META_INVERTREGION::parse( - buf, - record_size, - record_function, - )?; + let record = META_INVERTREGION::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.invert_region(record_number, record)?; } RecordType::META_LINETO => { - let record = - META_LINETO::parse(buf, record_size, record_function)?; + let record = META_LINETO::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.line_to(record_number, record)?; } RecordType::META_PAINTREGION => { - let record = META_PAINTREGION::parse( - buf, - record_size, - record_function, - )?; + let record = META_PAINTREGION::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.paint_region(record_number, record)?; } RecordType::META_PATBLT => { - let record = - META_PATBLT::parse(buf, record_size, record_function)?; + let record = META_PATBLT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.pat_blt(record_number, record)?; } RecordType::META_PIE => { - let record = - META_PIE::parse(buf, record_size, record_function)?; + let record = META_PIE::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.pie(record_number, record)?; } RecordType::META_POLYLINE => { - let record = META_POLYLINE::parse( - buf, - record_size, - record_function, - )?; + let record = META_POLYLINE::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.polyline(record_number, record)?; } RecordType::META_POLYGON => { - let record = - META_POLYGON::parse(buf, record_size, record_function)?; + let record = META_POLYGON::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.polygon(record_number, record)?; } RecordType::META_POLYPOLYGON => { - let record = META_POLYPOLYGON::parse( - buf, - record_size, - record_function, - )?; + let record = META_POLYPOLYGON::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.poly_polygon(record_number, record)?; } RecordType::META_RECTANGLE => { - let record = META_RECTANGLE::parse( - buf, - record_size, - record_function, - )?; + let record = META_RECTANGLE::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.rectangle(record_number, record)?; } RecordType::META_ROUNDRECT => { - let record = META_ROUNDRECT::parse( - buf, - record_size, - record_function, - )?; + let record = META_ROUNDRECT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.round_rect(record_number, record)?; } RecordType::META_SETPIXEL => { - let record = META_SETPIXEL::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETPIXEL::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.set_pixel(record_number, record)?; } RecordType::META_TEXTOUT => { - let record = - META_TEXTOUT::parse(buf, record_size, record_function)?; + let record = META_TEXTOUT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.text_out(record_number, record)?; } // object record RecordType::META_CREATEBRUSHINDIRECT => { - let record = META_CREATEBRUSHINDIRECT::parse( - buf, - record_size, - record_function, - )?; + let record = + META_CREATEBRUSHINDIRECT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.create_brush_indirect(record_number, record)?; + player = player.create_brush_indirect(record_number, record)?; } RecordType::META_CREATEFONTINDIRECT => { - let record = META_CREATEFONTINDIRECT::parse( - buf, - record_size, - record_function, - )?; + let record = META_CREATEFONTINDIRECT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.create_font_indirect(record_number, record)?; + player = player.create_font_indirect(record_number, record)?; } RecordType::META_CREATEPALETTE => { - let record = META_CREATEPALETTE::parse( - buf, - record_size, - record_function, - )?; + let record = META_CREATEPALETTE::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.create_palette(record_number, record)?; } RecordType::META_CREATEPATTERNBRUSH => { - let record = META_CREATEPATTERNBRUSH::parse( - buf, - record_size, - record_function, - )?; + let record = META_CREATEPATTERNBRUSH::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.create_pattern_brush(record_number, record)?; + player = player.create_pattern_brush(record_number, record)?; } RecordType::META_CREATEPENINDIRECT => { - let record = META_CREATEPENINDIRECT::parse( - buf, - record_size, - record_function, - )?; + let record = META_CREATEPENINDIRECT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.create_pen_indirect(record_number, record)?; + player = player.create_pen_indirect(record_number, record)?; } RecordType::META_CREATEREGION => { - let record = META_CREATEREGION::parse( - buf, - record_size, - record_function, - )?; + let record = META_CREATEREGION::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.create_region(record_number, record)?; } RecordType::META_DELETEOBJECT => { - let record = META_DELETEOBJECT::parse( - buf, - record_size, - record_function, - )?; + let record = META_DELETEOBJECT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.delete_object(record_number, record)?; } RecordType::META_DIBCREATEPATTERNBRUSH => { - let record = META_DIBCREATEPATTERNBRUSH::parse( - buf, - record_size, - record_function, - )?; + let record = + META_DIBCREATEPATTERNBRUSH::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player - .create_device_independent_bitmap_pattern_brush( - record_number, - record, - )?; + .create_device_independent_bitmap_pattern_brush(record_number, record)?; } RecordType::META_SELECTCLIPREGION => { - let record = META_SELECTCLIPREGION::parse( - buf, - record_size, - record_function, - )?; + let record = META_SELECTCLIPREGION::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.select_clip_region(record_number, record)?; + player = player.select_clip_region(record_number, record)?; } RecordType::META_SELECTOBJECT => { - let record = META_SELECTOBJECT::parse( - buf, - record_size, - record_function, - )?; + let record = META_SELECTOBJECT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.select_object(record_number, record)?; } RecordType::META_SELECTPALETTE => { - let record = META_SELECTPALETTE::parse( - buf, - record_size, - record_function, - )?; + let record = META_SELECTPALETTE::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.select_palette(record_number, record)?; } // state record RecordType::META_ANIMATEPALETTE => { - let record = META_ANIMATEPALETTE::parse( - buf, - record_size, - record_function, - )?; + let record = META_ANIMATEPALETTE::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.animate_palette(record_number, record)?; } RecordType::META_EXCLUDECLIPRECT => { - let record = META_EXCLUDECLIPRECT::parse( - buf, - record_size, - record_function, - )?; + let record = META_EXCLUDECLIPRECT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.exclude_clip_rect(record_number, record)?; } RecordType::META_INTERSECTCLIPRECT => { - let record = META_INTERSECTCLIPRECT::parse( - buf, - record_size, - record_function, - )?; + let record = META_INTERSECTCLIPRECT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.intersect_clip_rect(record_number, record)?; + player = player.intersect_clip_rect(record_number, record)?; } RecordType::META_MOVETO => { - let record = - META_MOVETO::parse(buf, record_size, record_function)?; + let record = META_MOVETO::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.move_to(record_number, record)?; } RecordType::META_OFFSETCLIPRGN => { - let record = META_OFFSETCLIPRGN::parse( - buf, - record_size, - record_function, - )?; + let record = META_OFFSETCLIPRGN::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.offset_clip_region(record_number, record)?; + player = player.offset_clip_region(record_number, record)?; } RecordType::META_OFFSETVIEWPORTORG => { - let record = META_OFFSETVIEWPORTORG::parse( - buf, - record_size, - record_function, - )?; + let record = META_OFFSETVIEWPORTORG::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.offset_viewport_origin(record_number, record)?; + player = player.offset_viewport_origin(record_number, record)?; } RecordType::META_OFFSETWINDOWORG => { - let record = META_OFFSETWINDOWORG::parse( - buf, - record_size, - record_function, - )?; + let record = META_OFFSETWINDOWORG::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.offset_window_origin(record_number, record)?; + player = player.offset_window_origin(record_number, record)?; } RecordType::META_REALIZEPALETTE => { - let record = META_REALIZEPALETTE::parse( - buf, - record_size, - record_function, - )?; + let record = META_REALIZEPALETTE::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.realize_palette(record_number, record)?; } RecordType::META_RESIZEPALETTE => { - let record = META_RESIZEPALETTE::parse( - buf, - record_size, - record_function, - )?; + let record = META_RESIZEPALETTE::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.resize_palette(record_number, record)?; } RecordType::META_RESTOREDC => { - let record = META_RESTOREDC::parse( - buf, - record_size, - record_function, - )?; + let record = META_RESTOREDC::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.restore_device_context(record_number, record)?; + player = player.restore_device_context(record_number, record)?; } RecordType::META_SAVEDC => { - let record = - META_SAVEDC::parse(buf, record_size, record_function)?; + let record = META_SAVEDC::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.save_device_context(record_number, record)?; + player = player.save_device_context(record_number, record)?; } RecordType::META_SCALEVIEWPORTEXT => { - let record = META_SCALEVIEWPORTEXT::parse( - buf, - record_size, - record_function, - )?; + let record = META_SCALEVIEWPORTEXT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.scale_viewport_ext(record_number, record)?; + player = player.scale_viewport_ext(record_number, record)?; } RecordType::META_SCALEWINDOWEXT => { - let record = META_SCALEWINDOWEXT::parse( - buf, - record_size, - record_function, - )?; + let record = META_SCALEWINDOWEXT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.scale_window_ext(record_number, record)?; } RecordType::META_SETBKCOLOR => { - let record = META_SETBKCOLOR::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETBKCOLOR::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.set_bk_color(record_number, record)?; } RecordType::META_SETBKMODE => { - let record = META_SETBKMODE::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETBKMODE::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.set_bk_mode(record_number, record)?; } RecordType::META_SETLAYOUT => { - let record = META_SETLAYOUT::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETLAYOUT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.set_layout(record_number, record)?; } RecordType::META_SETMAPMODE => { - let record = META_SETMAPMODE::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETMAPMODE::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.set_map_mode(record_number, record)?; } RecordType::META_SETMAPPERFLAGS => { - let record = META_SETMAPPERFLAGS::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETMAPPERFLAGS::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.set_mapper_flags(record_number, record)?; } RecordType::META_SETPALENTRIES => { - let record = META_SETPALENTRIES::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETPALENTRIES::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.set_pal_entries(record_number, record)?; } RecordType::META_SETPOLYFILLMODE => { - let record = META_SETPOLYFILLMODE::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETPOLYFILLMODE::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.set_polyfill_mode(record_number, record)?; } RecordType::META_SETRELABS => { - let record = META_SETRELABS::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETRELABS::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.set_relabs(record_number, record)?; } RecordType::META_SETROP2 => { - let record = - META_SETROP2::parse(buf, record_size, record_function)?; + let record = META_SETROP2::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.set_raster_operation(record_number, record)?; + player = player.set_raster_operation(record_number, record)?; } RecordType::META_SETSTRETCHBLTMODE => { - let record = META_SETSTRETCHBLTMODE::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETSTRETCHBLTMODE::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.set_stretch_blt_mode(record_number, record)?; + player = player.set_stretch_blt_mode(record_number, record)?; } RecordType::META_SETTEXTALIGN => { - let record = META_SETTEXTALIGN::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETTEXTALIGN::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.set_text_align(record_number, record)?; } RecordType::META_SETTEXTCHAREXTRA => { - let record = META_SETTEXTCHAREXTRA::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETTEXTCHAREXTRA::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.set_text_char_extra(record_number, record)?; + player = player.set_text_char_extra(record_number, record)?; } RecordType::META_SETTEXTCOLOR => { - let record = META_SETTEXTCOLOR::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETTEXTCOLOR::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.set_text_color(record_number, record)?; } RecordType::META_SETTEXTJUSTIFICATION => { - let record = META_SETTEXTJUSTIFICATION::parse( - buf, - record_size, - record_function, - )?; + let record = + META_SETTEXTJUSTIFICATION::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.set_text_justification(record_number, record)?; + player = player.set_text_justification(record_number, record)?; } RecordType::META_SETVIEWPORTEXT => { - let record = META_SETVIEWPORTEXT::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETVIEWPORTEXT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.set_viewport_ext(record_number, record)?; } RecordType::META_SETVIEWPORTORG => { - let record = META_SETVIEWPORTORG::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETVIEWPORTORG::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); - player = - player.set_viewport_origin(record_number, record)?; + player = player.set_viewport_origin(record_number, record)?; } RecordType::META_SETWINDOWEXT => { - let record = META_SETWINDOWEXT::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETWINDOWEXT::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.set_window_ext(record_number, record)?; } RecordType::META_SETWINDOWORG => { - let record = META_SETWINDOWORG::parse( - buf, - record_size, - record_function, - )?; + let record = META_SETWINDOWORG::parse(buf, record_size, record_function)?; debug!(%record_number, ?record); player = player.set_window_origin(record_number, record)?; } // escape record RecordType::META_ESCAPE => { - let (buf, _) = - read_variable(buf, record_size.remaining_bytes()) - .map_err(ParseError::from)?; - - match META_ESCAPE::parse( - &mut buf.as_slice(), - record_size, - record_function, - ) { + let (buf, _) = read_variable(buf, record_size.remaining_bytes()) + .map_err(ParseError::from)?; + + match META_ESCAPE::parse(&mut buf.as_slice(), record_size, record_function) { Ok(record) => { debug!(%record_number, ?record); player = player.escape(record_number, record)?; diff --git a/src/wmf/converter/player.rs b/src/wmf/converter/player.rs index 36c433c5..9a116830 100644 --- a/src/wmf/converter/player.rs +++ b/src/wmf/converter/player.rs @@ -25,11 +25,7 @@ pub trait Player: Sized { // . /// Render [`META_BITBLT`](crate::wmf::parser::META_BITBLT) record. - fn bit_blt( - self, - record_number: usize, - record: META_BITBLT, - ) -> Result; + fn bit_blt(self, record_number: usize, record: META_BITBLT) -> Result; /// Render [`META_DIBBITBLT`](crate::wmf::parser::META_DIBBITBLT) record. fn device_independent_bitmap_bit_blt( self, @@ -49,11 +45,7 @@ pub trait Player: Sized { record: META_SETDIBTODEV, ) -> Result; /// Render [`META_STRETCHBLT`](crate::wmf::parser::META_STRETCHBLT) record. - fn stretch_blt( - self, - record_number: usize, - record: META_STRETCHBLT, - ) -> Result; + fn stretch_blt(self, record_number: usize, record: META_STRETCHBLT) -> Result; /// Render [`META_STRETCHDIB`](crate::wmf::parser::META_STRETCHDIB) record. fn stretch_device_independent_bitmap( self, @@ -68,17 +60,9 @@ pub trait Player: Sized { // . /// Render [`META_EOF`](crate::wmf::parser::META_EOF) record. - fn eof( - self, - record_number: usize, - record: META_EOF, - ) -> Result; + fn eof(self, record_number: usize, record: META_EOF) -> Result; /// Render [`MetafileHeader`](crate::wmf::parser::MetafileHeader) record. - fn header( - self, - record_number: usize, - header: MetafileHeader, - ) -> Result; + fn header(self, record_number: usize, header: MetafileHeader) -> Result; // . // . @@ -87,23 +71,11 @@ pub trait Player: Sized { // . /// Render [`META_ARC`](crate::wmf::parser::META_ARC) record. - fn arc( - self, - record_number: usize, - record: META_ARC, - ) -> Result; + fn arc(self, record_number: usize, record: META_ARC) -> Result; /// Render [`META_CHORD`](crate::wmf::parser::META_CHORD) record. - fn chord( - self, - record_number: usize, - record: META_CHORD, - ) -> Result; + fn chord(self, record_number: usize, record: META_CHORD) -> Result; /// Render [`META_ELLIPSE`](crate::wmf::parser::META_ELLIPSE) record. - fn ellipse( - self, - record_number: usize, - record: META_ELLIPSE, - ) -> Result; + fn ellipse(self, record_number: usize, record: META_ELLIPSE) -> Result; /// Render [`META_EXTFLOODFILL`](crate::wmf::parser::META_EXTFLOODFILL) record. fn ext_flood_fill( self, @@ -111,23 +83,12 @@ pub trait Player: Sized { record: META_EXTFLOODFILL, ) -> Result; /// Render [`META_EXTTEXTOUT`](crate::wmf::parser::META_EXTTEXTOUT) record. - fn ext_text_out( - self, - record_number: usize, - record: META_EXTTEXTOUT, - ) -> Result; + fn ext_text_out(self, record_number: usize, record: META_EXTTEXTOUT) + -> Result; /// Render [`META_FILLREGION`](crate::wmf::parser::META_FILLREGION) record. - fn fill_region( - self, - record_number: usize, - record: META_FILLREGION, - ) -> Result; + fn fill_region(self, record_number: usize, record: META_FILLREGION) -> Result; /// Render [`META_FLOODFILL`](crate::wmf::parser::META_FLOODFILL) record. - fn flood_fill( - self, - record_number: usize, - record: META_FLOODFILL, - ) -> Result; + fn flood_fill(self, record_number: usize, record: META_FLOODFILL) -> Result; /// Render [`META_FRAMEREGION`](crate::wmf::parser::META_FRAMEREGION) record. fn frame_region( self, @@ -141,11 +102,7 @@ pub trait Player: Sized { record: META_INVERTREGION, ) -> Result; /// Render [`META_LINETO`](crate::wmf::parser::META_LINETO) record. - fn line_to( - self, - record_number: usize, - record: META_LINETO, - ) -> Result; + fn line_to(self, record_number: usize, record: META_LINETO) -> Result; /// Render [`META_PAINTREGION`](crate::wmf::parser::META_PAINTREGION) record. fn paint_region( self, @@ -153,29 +110,13 @@ pub trait Player: Sized { record: META_PAINTREGION, ) -> Result; /// Render [`META_PATBLT`](crate::wmf::parser::META_PATBLT) record. - fn pat_blt( - self, - record_number: usize, - record: META_PATBLT, - ) -> Result; + fn pat_blt(self, record_number: usize, record: META_PATBLT) -> Result; /// Render [`META_PIE`](crate::wmf::parser::META_PIE) record. - fn pie( - self, - record_number: usize, - record: META_PIE, - ) -> Result; + fn pie(self, record_number: usize, record: META_PIE) -> Result; /// Render [`META_POLYLINE`](crate::wmf::parser::META_POLYLINE) record. - fn polyline( - self, - record_number: usize, - record: META_POLYLINE, - ) -> Result; + fn polyline(self, record_number: usize, record: META_POLYLINE) -> Result; /// Render [`META_POLYGON`](crate::wmf::parser::META_POLYGON) record. - fn polygon( - self, - record_number: usize, - record: META_POLYGON, - ) -> Result; + fn polygon(self, record_number: usize, record: META_POLYGON) -> Result; /// Render [`META_POLYPOLYGON`](crate::wmf::parser::META_POLYPOLYGON) record. fn poly_polygon( self, @@ -183,23 +124,11 @@ pub trait Player: Sized { record: META_POLYPOLYGON, ) -> Result; /// Render [`META_RECTANGLE`](crate::wmf::parser::META_RECTANGLE) record. - fn rectangle( - self, - record_number: usize, - record: META_RECTANGLE, - ) -> Result; + fn rectangle(self, record_number: usize, record: META_RECTANGLE) -> Result; /// Render [`META_ROUNDRECT`](crate::wmf::parser::META_ROUNDRECT) record. - fn round_rect( - self, - record_number: usize, - record: META_ROUNDRECT, - ) -> Result; + fn round_rect(self, record_number: usize, record: META_ROUNDRECT) -> Result; /// Render [`META_SETPIXEL`](crate::wmf::parser::META_SETPIXEL) record. - fn set_pixel( - self, - record_number: usize, - record: META_SETPIXEL, - ) -> Result; + fn set_pixel(self, record_number: usize, record: META_SETPIXEL) -> Result; /// Render [`META_TEXTOUT`](crate::wmf::parser::META_TEXTOUT) record. fn text_out( self, @@ -310,11 +239,7 @@ pub trait Player: Sized { record: META_INTERSECTCLIPRECT, ) -> Result; /// Render [`META_MOVETO`](crate::wmf::parser::META_MOVETO) record. - fn move_to( - self, - record_number: usize, - record: META_MOVETO, - ) -> Result; + fn move_to(self, record_number: usize, record: META_MOVETO) -> Result; /// Render [`META_OFFSETCLIPRGN`](crate::wmf::parser::META_OFFSETCLIPRGN) record. fn offset_clip_region( self, @@ -375,29 +300,15 @@ pub trait Player: Sized { record: META_SCALEWINDOWEXT, ) -> Result; /// Render [`META_SETBKCOLOR`](crate::wmf::parser::META_SETBKCOLOR) record. - fn set_bk_color( - self, - record_number: usize, - record: META_SETBKCOLOR, - ) -> Result; + fn set_bk_color(self, record_number: usize, record: META_SETBKCOLOR) + -> Result; /// Render [`META_SETBKMODE`](crate::wmf::parser::META_SETBKMODE) record. - fn set_bk_mode( - self, - record_number: usize, - record: META_SETBKMODE, - ) -> Result; + fn set_bk_mode(self, record_number: usize, record: META_SETBKMODE) -> Result; /// Render [`META_SETLAYOUT`](crate::wmf::parser::META_SETLAYOUT) record. - fn set_layout( - self, - record_number: usize, - record: META_SETLAYOUT, - ) -> Result; + fn set_layout(self, record_number: usize, record: META_SETLAYOUT) -> Result; /// Render [`META_SETMAPMODE`](crate::wmf::parser::META_SETMAPMODE) record. - fn set_map_mode( - self, - record_number: usize, - record: META_SETMAPMODE, - ) -> Result; + fn set_map_mode(self, record_number: usize, record: META_SETMAPMODE) + -> Result; /// Render [`META_SETMAPPERFLAGS`](crate::wmf::parser::META_SETMAPPERFLAGS) /// record. fn set_mapper_flags( @@ -419,11 +330,7 @@ pub trait Player: Sized { record: META_SETPOLYFILLMODE, ) -> Result; /// Render [`META_SETRELABS`](crate::wmf::parser::META_SETRELABS) record. - fn set_relabs( - self, - record_number: usize, - record: META_SETRELABS, - ) -> Result; + fn set_relabs(self, record_number: usize, record: META_SETRELABS) -> Result; /// Render [`META_SETROP2`](crate::wmf::parser::META_SETROP2) record. fn set_raster_operation( self, @@ -496,9 +403,5 @@ pub trait Player: Sized { // . /// Render [`META_ESCAPE`](crate::wmf::parser::META_ESCAPE) record. - fn escape( - self, - record_number: usize, - record: META_ESCAPE, - ) -> Result; + fn escape(self, record_number: usize, record: META_ESCAPE) -> Result; } diff --git a/src/wmf/converter/svg/device_context.rs b/src/wmf/converter/svg/device_context.rs index b7d2c8c2..e33110f1 100644 --- a/src/wmf/converter/svg/device_context.rs +++ b/src/wmf/converter/svg/device_context.rs @@ -113,18 +113,12 @@ impl DeviceContext { self } - pub fn text_align_horizontal( - mut self, - text_align_horizontal: TextAlignmentMode, - ) -> Self { + pub fn text_align_horizontal(mut self, text_align_horizontal: TextAlignmentMode) -> Self { self.text_align_horizontal = text_align_horizontal; self } - pub fn text_align_vertical( - mut self, - text_align_vertical: VerticalTextAlignmentMode, - ) -> Self { + pub fn text_align_vertical(mut self, text_align_vertical: VerticalTextAlignmentMode) -> Self { self.text_align_vertical = text_align_vertical; self } @@ -180,20 +174,16 @@ impl DeviceContext { } pub fn point_s_to_absolute_point(&self, point: &PointS) -> PointS { - let x = (f32::from((point.x - self.window.origin_x).abs()) - / self.window.scale_x) as i16; - let y = (f32::from((point.y - self.window.origin_y).abs()) - / self.window.scale_y) as i16; + let x = (f32::from((point.x - self.window.origin_x).abs()) / self.window.scale_x) as i16; + let y = (f32::from((point.y - self.window.origin_y).abs()) / self.window.scale_y) as i16; PointS { x, y } } pub fn point_s_to_relative_point(&self, point: &PointS) -> PointS { - let x = (f32::from((point.x - self.window.origin_x).abs()) - / self.window.scale_x) as i16 + let x = (f32::from((point.x - self.window.origin_x).abs()) / self.window.scale_x) as i16 + self.drawing_position.x; - let y = (f32::from((point.y - self.window.origin_y).abs()) - / self.window.scale_y) as i16 + let y = (f32::from((point.y - self.window.origin_y).abs()) / self.window.scale_y) as i16 + self.drawing_position.y; PointS { x, y } diff --git a/src/wmf/converter/svg/mod.rs b/src/wmf/converter/svg/mod.rs index 46c06e05..d35a7b5e 100644 --- a/src/wmf/converter/svg/mod.rs +++ b/src/wmf/converter/svg/mod.rs @@ -66,7 +66,12 @@ impl crate::wmf::converter::Player for SVGPlayer { err(level = tracing::Level::ERROR, Display), ))] fn generate(self) -> Result, PlayError> { - let Self { context_current, definitions, elements, .. } = self; + let Self { + context_current, + definitions, + elements, + .. + } = self; let (x, y, width, height) = context_current.window.as_view_box(); let mut document = Node::new("svg") @@ -99,11 +104,7 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn bit_blt( - mut self, - record_number: usize, - record: META_BITBLT, - ) -> Result { + fn bit_blt(mut self, record_number: usize, record: META_BITBLT) -> Result { let operator = match record { META_BITBLT::WithBitmap { raster_operation, @@ -114,13 +115,8 @@ impl crate::wmf::converter::Player for SVGPlayer { target, .. } => { - let mut operator = TernaryRasterOperator::new( - raster_operation, - x_dest, - y_dest, - height, - width, - ); + let mut operator = + TernaryRasterOperator::new(raster_operation, x_dest, y_dest, height, width); if raster_operation.use_selected_brush() { operator = operator.brush(self.selected_brush().clone()); @@ -140,13 +136,8 @@ impl crate::wmf::converter::Player for SVGPlayer { x_dest, .. } => { - let mut operator = TernaryRasterOperator::new( - raster_operation, - x_dest, - y_dest, - height, - width, - ); + let mut operator = + TernaryRasterOperator::new(raster_operation, x_dest, y_dest, height, width); if raster_operation.use_selected_brush() { operator = operator.brush(self.selected_brush().clone()); @@ -157,9 +148,11 @@ impl crate::wmf::converter::Player for SVGPlayer { }; let Some(elem) = - operator.run(&mut self.definitions).map_err(|err| { - PlayError::InvalidRecord { cause: err.to_string() } - })? + operator + .run(&mut self.definitions) + .map_err(|err| PlayError::InvalidRecord { + cause: err.to_string(), + })? else { return Ok(self); }; @@ -189,13 +182,8 @@ impl crate::wmf::converter::Player for SVGPlayer { target, .. } => { - let mut operator = TernaryRasterOperator::new( - raster_operation, - x_dest, - y_dest, - height, - width, - ); + let mut operator = + TernaryRasterOperator::new(raster_operation, x_dest, y_dest, height, width); if raster_operation.use_selected_brush() { operator = operator.brush(self.selected_brush().clone()); @@ -215,13 +203,8 @@ impl crate::wmf::converter::Player for SVGPlayer { x_dest, .. } => { - let mut operator = TernaryRasterOperator::new( - raster_operation, - x_dest, - y_dest, - height, - width, - ); + let mut operator = + TernaryRasterOperator::new(raster_operation, x_dest, y_dest, height, width); if raster_operation.use_selected_brush() { operator = operator.brush(self.selected_brush().clone()); @@ -232,9 +215,11 @@ impl crate::wmf::converter::Player for SVGPlayer { }; let Some(elem) = - operator.run(&mut self.definitions).map_err(|err| { - PlayError::InvalidRecord { cause: err.to_string() } - })? + operator + .run(&mut self.definitions) + .map_err(|err| PlayError::InvalidRecord { + cause: err.to_string(), + })? else { return Ok(self); }; @@ -307,9 +292,11 @@ impl crate::wmf::converter::Player for SVGPlayer { }; let Some(elem) = - operator.run(&mut self.definitions).map_err(|err| { - PlayError::InvalidRecord { cause: err.to_string() } - })? + operator + .run(&mut self.definitions) + .map_err(|err| PlayError::InvalidRecord { + cause: err.to_string(), + })? else { return Ok(self); }; @@ -396,9 +383,11 @@ impl crate::wmf::converter::Player for SVGPlayer { }; let Some(elem) = - operator.run(&mut self.definitions).map_err(|err| { - PlayError::InvalidRecord { cause: err.to_string() } - })? + operator + .run(&mut self.definitions) + .map_err(|err| PlayError::InvalidRecord { + cause: err.to_string(), + })? else { return Ok(self); }; @@ -428,13 +417,8 @@ impl crate::wmf::converter::Player for SVGPlayer { .. } = record; - let mut operator = TernaryRasterOperator::new( - raster_operation, - x_dst, - y_dst, - dest_height, - dest_width, - ); + let mut operator = + TernaryRasterOperator::new(raster_operation, x_dst, y_dst, dest_height, dest_width); if raster_operation.use_selected_brush() { operator = operator.brush(self.selected_brush().clone()); @@ -445,9 +429,11 @@ impl crate::wmf::converter::Player for SVGPlayer { } let Some(elem) = - operator.run(&mut self.definitions).map_err(|err| { - PlayError::InvalidRecord { cause: err.to_string() } - })? + operator + .run(&mut self.definitions) + .map_err(|err| PlayError::InvalidRecord { + cause: err.to_string(), + })? else { return Ok(self); }; @@ -476,20 +462,19 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn header( - mut self, - record_number: usize, - header: MetafileHeader, - ) -> Result { + fn header(mut self, record_number: usize, header: MetafileHeader) -> Result { let (placeable, header) = match header { MetafileHeader::StartsWithHeader(header) => (None, header), - MetafileHeader::StartsWithPlaceable(placeable, header) => { - (Some(placeable), header) - } + MetafileHeader::StartsWithPlaceable(placeable, header) => (Some(placeable), header), }; if let Some(placeable) = placeable { - let Rect { left, top, right, bottom } = placeable.bounding_box; + let Rect { + left, + top, + right, + bottom, + } = placeable.bounding_box; self.context_current = self .context_current @@ -497,8 +482,9 @@ impl crate::wmf::converter::Player for SVGPlayer { .window_ext(right - left, bottom - top); } - self.context_current = - self.context_current.create_object_table(header.number_of_objects); + self.context_current = self + .context_current + .create_object_table(header.number_of_objects); Ok(self) } @@ -514,28 +500,22 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn arc( - mut self, - record_number: usize, - record: META_ARC, - ) -> Result { + fn arc(mut self, record_number: usize, record: META_ARC) -> Result { let stroke = Stroke::from(self.selected_pen().clone()); let start = { - let point = - self.context_current.point_s_to_absolute_point(&PointS { - x: record.x_start_arc, - y: record.y_start_arc, - }); + let point = self.context_current.point_s_to_absolute_point(&PointS { + x: record.x_start_arc, + y: record.y_start_arc, + }); self.context_current = self.context_current.extend_window(&point); point }; let end = { - let point = - self.context_current.point_s_to_absolute_point(&PointS { - x: record.x_end_arc, - y: record.y_end_arc, - }); + let point = self.context_current.point_s_to_absolute_point(&PointS { + x: record.x_end_arc, + y: record.y_end_arc, + }); self.context_current = self.context_current.extend_window(&point); point @@ -582,11 +562,7 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn chord( - mut self, - record_number: usize, - record: META_CHORD, - ) -> Result { + fn chord(mut self, record_number: usize, record: META_CHORD) -> Result { // Calculate ellipse center and radii from bounding rectangle let rx = (record.right_rect - record.left_rect) / 2; let ry = (record.bottom_rect - record.top_rect) / 2; @@ -652,11 +628,7 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn ellipse( - mut self, - record_number: usize, - record: META_ELLIPSE, - ) -> Result { + fn ellipse(mut self, record_number: usize, record: META_ELLIPSE) -> Result { let (rx, ry) = ( (record.right_rect - record.left_rect) / 2, (record.bottom_rect - record.top_rect) / 2, @@ -682,11 +654,10 @@ impl crate::wmf::converter::Player for SVGPlayer { }; let fill_rule = self.context_current.poly_fill_rule(); let point = { - let point = - self.context_current.point_s_to_absolute_point(&PointS { - x: record.left_rect + rx, - y: record.top_rect + ry, - }); + let point = self.context_current.point_s_to_absolute_point(&PointS { + x: record.left_rect + rx, + y: record.top_rect + ry, + }); self.context_current = self.context_current.extend_window(&point); point @@ -734,9 +705,12 @@ impl crate::wmf::converter::Player for SVGPlayer { use unicode_width::UnicodeWidthStr; let font = &self.object_selected.font; - let text_content = record.into_utf8(font.charset).map_err(|err| { - PlayError::InvalidRecord { cause: err.to_string() } - })?; + let text_content = + record + .into_utf8(font.charset) + .map_err(|err| PlayError::InvalidRecord { + cause: err.to_string(), + })?; let point = { let point = PointS { x: if self.context_current.text_align_update_cp { @@ -758,8 +732,9 @@ impl crate::wmf::converter::Player for SVGPlayer { (em as f64 * 0.8) as i16 } VerticalTextAlignmentMode::VTA_BASELINE - | VerticalTextAlignmentMode::VTA_BOTTOM - if font.height < 0 => { + | VerticalTextAlignmentMode::VTA_BOTTOM + if font.height < 0 => + { -font.height } _ => 0, @@ -781,51 +756,59 @@ impl crate::wmf::converter::Player for SVGPlayer { record.rectangle, ) { let tl = { - let point = PointS { x: rect.left, y: rect.top }; + let point = PointS { + x: rect.left, + y: rect.top, + }; let point = if self.context_current.text_align_update_cp { self.context_current.point_s_to_relative_point(&point) } else { self.context_current.point_s_to_absolute_point(&point) }; - self.context_current = - self.context_current.extend_window(&point); + self.context_current = self.context_current.extend_window(&point); point }; let tr = { - let point = PointS { x: rect.right, y: rect.top }; + let point = PointS { + x: rect.right, + y: rect.top, + }; let point = if self.context_current.text_align_update_cp { self.context_current.point_s_to_relative_point(&point) } else { self.context_current.point_s_to_absolute_point(&point) }; - self.context_current = - self.context_current.extend_window(&point); + self.context_current = self.context_current.extend_window(&point); point }; let bl = { - let point = PointS { x: rect.left, y: rect.bottom }; + let point = PointS { + x: rect.left, + y: rect.bottom, + }; let point = if self.context_current.text_align_update_cp { self.context_current.point_s_to_relative_point(&point) } else { self.context_current.point_s_to_absolute_point(&point) }; - self.context_current = - self.context_current.extend_window(&point); + self.context_current = self.context_current.extend_window(&point); point }; let br = { - let point = PointS { x: rect.right, y: rect.bottom }; + let point = PointS { + x: rect.right, + y: rect.bottom, + }; let point = if self.context_current.text_align_update_cp { self.context_current.point_s_to_relative_point(&point) } else { self.context_current.point_s_to_absolute_point(&point) }; - self.context_current = - self.context_current.extend_window(&point); + self.context_current = self.context_current.extend_window(&point); point }; @@ -864,8 +847,7 @@ impl crate::wmf::converter::Player for SVGPlayer { let mut tspan = Node::new("tspan").add(Node::new_text(s)); if dx != 0 { - let excess_dx = (font.height.abs() / 2) - * i16::try_from(s.width()).unwrap_or(0); + let excess_dx = (font.height.abs() / 2) * i16::try_from(s.width()).unwrap_or(0); let dx = core::cmp::max(dx - excess_dx, 0); tspan = tspan.set("dx", dx); @@ -875,8 +857,7 @@ impl crate::wmf::converter::Player for SVGPlayer { } } - let (mut text, mut styles) = - self.object_selected.font.set_props(text, &point); + let (mut text, mut styles) = self.object_selected.font.set_props(text, &point); if let Some(shape_inside) = shape_inside { styles.push(shape_inside); @@ -887,9 +868,11 @@ impl crate::wmf::converter::Player for SVGPlayer { } if self.context_current.text_align_update_cp { - let dx = (font.height.abs() / 2) - * i16::try_from(text_content.width()).unwrap_or(0); - let point = PointS { x: point.x + dx, y: point.y }; + let dx = (font.height.abs() / 2) * i16::try_from(text_content.width()).unwrap_or(0); + let point = PointS { + x: point.x + dx, + y: point.y, + }; self.context_current = self.context_current.drawing_position(point); } @@ -905,7 +888,8 @@ impl crate::wmf::converter::Player for SVGPlayer { self.selected_brush().clone() }; - self.definitions.push(brush.as_filter().set("id", id.as_str())); + self.definitions + .push(brush.as_filter().set("id", id.as_str())); text = text.set("filter", url_string(format!("#{id}").as_str())); } @@ -920,11 +904,7 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn fill_region( - self, - record_number: usize, - record: META_FILLREGION, - ) -> Result { + fn fill_region(self, record_number: usize, record: META_FILLREGION) -> Result { info!("META_FILLREGION: not implemented"); Ok(self) } @@ -934,11 +914,7 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn flood_fill( - self, - record_number: usize, - record: META_FLOODFILL, - ) -> Result { + fn flood_fill(self, record_number: usize, record: META_FLOODFILL) -> Result { info!("META_FLOODFILL: not implemented"); Ok(self) } @@ -976,18 +952,13 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn line_to( - mut self, - record_number: usize, - record: META_LINETO, - ) -> Result { + fn line_to(mut self, record_number: usize, record: META_LINETO) -> Result { let stroke = Stroke::from(self.selected_pen().clone()); let point = { - let point = - self.context_current.point_s_to_absolute_point(&PointS { - x: record.x, - y: record.y, - }); + let point = self.context_current.point_s_to_absolute_point(&PointS { + x: record.x, + y: record.y, + }); self.context_current = self.context_current.extend_window(&point); point @@ -996,8 +967,7 @@ impl crate::wmf::converter::Player for SVGPlayer { let data = Data::new() .move_to(format!( "{} {}", - self.context_current.drawing_position.x, - self.context_current.drawing_position.y + self.context_current.drawing_position.x, self.context_current.drawing_position.y )) .line_to(format!("{} {}", point.x, point.y)); let path = Node::new("path").set("fill", "none").set("d", data); @@ -1028,11 +998,7 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn pat_blt( - mut self, - record_number: usize, - record: META_PATBLT, - ) -> Result { + fn pat_blt(mut self, record_number: usize, record: META_PATBLT) -> Result { if record.width == 0 || record.height == 0 { info!( %record.width, @@ -1072,11 +1038,7 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn pie( - mut self, - record_number: usize, - record: META_PIE, - ) -> Result { + fn pie(mut self, record_number: usize, record: META_PIE) -> Result { let brush = self.selected_brush(); let stroke = Stroke::from(brush.clone()); let fill = match Fill::from(brush.clone()) { @@ -1092,8 +1054,7 @@ impl crate::wmf::converter::Player for SVGPlayer { (record.right_rect - record.left_rect) / 2, (record.bottom_rect - record.top_rect) / 2, ); - let (center_x, center_y) = - (record.left_rect + rx, record.top_rect + ry); + let (center_x, center_y) = (record.left_rect + rx, record.top_rect + ry); let ellipse = Node::new("ellipse") .set("fill", fill.as_str()) @@ -1106,30 +1067,27 @@ impl crate::wmf::converter::Player for SVGPlayer { let stroke = Stroke::from(self.selected_pen().clone()); let p1 = { - let point = - self.context_current.point_s_to_absolute_point(&PointS { - x: record.x_radial1, - y: record.y_radial1, - }); + let point = self.context_current.point_s_to_absolute_point(&PointS { + x: record.x_radial1, + y: record.y_radial1, + }); self.context_current = self.context_current.extend_window(&point); point }; let center = { - let point = - self.context_current.point_s_to_absolute_point(&PointS { - x: center_x, - y: center_y, - }); + let point = self.context_current.point_s_to_absolute_point(&PointS { + x: center_x, + y: center_y, + }); self.context_current = self.context_current.extend_window(&point); point }; let p2 = { - let point = - self.context_current.point_s_to_absolute_point(&PointS { - x: record.x_radial2, - y: record.y_radial2, - }); + let point = self.context_current.point_s_to_absolute_point(&PointS { + x: record.x_radial2, + y: record.y_radial2, + }); self.context_current = self.context_current.extend_window(&point); point @@ -1154,11 +1112,7 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn polyline( - mut self, - record_number: usize, - record: META_POLYLINE, - ) -> Result { + fn polyline(mut self, record_number: usize, record: META_POLYLINE) -> Result { let stroke = Stroke::from(self.selected_pen().clone()); let Some(point) = record.a_points.first() else { return Err(PlayError::InvalidRecord { @@ -1172,8 +1126,7 @@ impl crate::wmf::converter::Player for SVGPlayer { point }; - let mut data = - Data::new().move_to(format!("{} {}", coordinate.x, coordinate.y)); + let mut data = Data::new().move_to(format!("{} {}", coordinate.x, coordinate.y)); for i in 1..record.number_of_points { let Some(point) = record.a_points.get(i as usize) else { @@ -1183,10 +1136,8 @@ impl crate::wmf::converter::Player for SVGPlayer { }; coordinate = { - let point = - self.context_current.point_s_to_absolute_point(point); - self.context_current = - self.context_current.extend_window(&point); + let point = self.context_current.point_s_to_absolute_point(point); + self.context_current = self.context_current.extend_window(&point); point }; @@ -1196,8 +1147,7 @@ impl crate::wmf::converter::Player for SVGPlayer { let path = Node::new("path").set("fill", "none").set("d", data); let path = stroke.set_props(path); - self.context_current = - self.context_current.drawing_position(coordinate); + self.context_current = self.context_current.drawing_position(coordinate); self.push_element(record_number, path); Ok(self) @@ -1208,11 +1158,7 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn polygon( - mut self, - record_number: usize, - record: META_POLYGON, - ) -> Result { + fn polygon(mut self, record_number: usize, record: META_POLYGON) -> Result { if record.number_of_points == 0 { info!(%record.number_of_points, "polygon has no points"); return Ok(self); @@ -1239,10 +1185,8 @@ impl crate::wmf::converter::Player for SVGPlayer { }; let point = { - let point = - self.context_current.point_s_to_absolute_point(point); - self.context_current = - self.context_current.extend_window(&point); + let point = self.context_current.point_s_to_absolute_point(point); + self.context_current = self.context_current.extend_window(&point); point }; @@ -1285,8 +1229,7 @@ impl crate::wmf::converter::Player for SVGPlayer { let mut current_point_index = 0; for i in 0..record.poly_polygon.number_of_polygons { - let Some(points_of_polygon) = - record.poly_polygon.a_points_per_polygon.get(i as usize) + let Some(points_of_polygon) = record.poly_polygon.a_points_per_polygon.get(i as usize) else { return Err(PlayError::InvalidRecord { cause: format!("aPointsPerPolygon[{i}] is not defined"), @@ -1298,17 +1241,13 @@ impl crate::wmf::converter::Player for SVGPlayer { for _ in 0..*points_of_polygon { let Some(point) = a_point.pop_front() else { return Err(PlayError::InvalidRecord { - cause: format!( - "aPoints[{current_point_index}] is not defined" - ), + cause: format!("aPoints[{current_point_index}] is not defined"), }); }; let point = { - let point = - self.context_current.point_s_to_absolute_point(&point); - self.context_current = - self.context_current.extend_window(&point); + let point = self.context_current.point_s_to_absolute_point(&point); + self.context_current = self.context_current.extend_window(&point); point }; @@ -1349,21 +1288,19 @@ impl crate::wmf::converter::Player for SVGPlayer { }; let fill_rule = self.context_current.poly_fill_rule(); let tl = { - let point = - self.context_current.point_s_to_absolute_point(&PointS { - x: record.left_rect, - y: record.top_rect, - }); + let point = self.context_current.point_s_to_absolute_point(&PointS { + x: record.left_rect, + y: record.top_rect, + }); self.context_current = self.context_current.extend_window(&point); point }; let br = { - let point = - self.context_current.point_s_to_absolute_point(&PointS { - x: record.right_rect, - y: record.bottom_rect, - }); + let point = self.context_current.point_s_to_absolute_point(&PointS { + x: record.right_rect, + y: record.bottom_rect, + }); self.context_current = self.context_current.extend_window(&point); point @@ -1418,11 +1355,10 @@ impl crate::wmf::converter::Player for SVGPlayer { }; let fill_rule = self.context_current.poly_fill_rule(); let point = { - let point = - self.context_current.point_s_to_absolute_point(&PointS { - x: record.left_rect, - y: record.top_rect, - }); + let point = self.context_current.point_s_to_absolute_point(&PointS { + x: record.left_rect, + y: record.top_rect, + }); self.context_current = self.context_current.extend_window(&point); point @@ -1449,11 +1385,7 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn set_pixel( - self, - record_number: usize, - record: META_SETPIXEL, - ) -> Result { + fn set_pixel(self, record_number: usize, record: META_SETPIXEL) -> Result { info!("META_SETPIXEL: not implemented"); Ok(self) } @@ -1463,15 +1395,14 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn text_out( - mut self, - record_number: usize, - record: META_TEXTOUT, - ) -> Result { + fn text_out(mut self, record_number: usize, record: META_TEXTOUT) -> Result { let font = &self.object_selected.font; - let text_content = record.into_utf8(font.charset).map_err(|err| { - PlayError::InvalidRecord { cause: err.to_string() } - })?; + let text_content = + record + .into_utf8(font.charset) + .map_err(|err| PlayError::InvalidRecord { + cause: err.to_string(), + })?; let point = { let point = PointS { x: record.x_start, @@ -1483,8 +1414,9 @@ impl crate::wmf::converter::Player for SVGPlayer { (em as f64 * 0.8) as i16 } VerticalTextAlignmentMode::VTA_BASELINE - | VerticalTextAlignmentMode::VTA_BOTTOM - if font.height < 0 => { + | VerticalTextAlignmentMode::VTA_BOTTOM + if font.height < 0 => + { -font.height } _ => 0, @@ -1597,7 +1529,9 @@ impl crate::wmf::converter::Player for SVGPlayer { record_number: usize, record: META_CREATEPENINDIRECT, ) -> Result { - self.context_current.object_table.push(GraphicsObject::Pen(record.pen)); + self.context_current + .object_table + .push(GraphicsObject::Pen(record.pen)); Ok(self) } @@ -1629,7 +1563,9 @@ impl crate::wmf::converter::Player for SVGPlayer { record_number: usize, record: META_DELETEOBJECT, ) -> Result { - self.context_current.object_table.delete(record.object_index as usize); + self.context_current + .object_table + .delete(record.object_index as usize); Ok(self) } @@ -1661,8 +1597,10 @@ impl crate::wmf::converter::Player for SVGPlayer { record_number: usize, record: META_SELECTCLIPREGION, ) -> Result { - let object = - self.context_current.object_table.get(record.region as usize); + let object = self + .context_current + .object_table + .get(record.region as usize); if let GraphicsObject::Region(region) = object { let rect = ®ion.bounding_rectangle; @@ -1726,8 +1664,10 @@ impl crate::wmf::converter::Player for SVGPlayer { record_number: usize, record: META_SELECTPALETTE, ) -> Result { - let object = - self.context_current.object_table.get(record.palette as usize); + let object = self + .context_current + .object_table + .get(record.palette as usize); let GraphicsObject::Palette(palette) = object else { return Err(PlayError::UnexpectedGraphicsObject { @@ -1783,7 +1723,13 @@ impl crate::wmf::converter::Player for SVGPlayer { record_number: usize, record: META_INTERSECTCLIPRECT, ) -> Result { - let META_INTERSECTCLIPRECT { bottom, right, top, left, .. } = record; + let META_INTERSECTCLIPRECT { + bottom, + right, + top, + left, + .. + } = record; self.context_current = self.context_current.clipping_region(Rect { left, @@ -1800,16 +1746,15 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn move_to( - mut self, - record_number: usize, - record: META_MOVETO, - ) -> Result { - let point = self + fn move_to(mut self, record_number: usize, record: META_MOVETO) -> Result { + let point = self.context_current.point_s_to_absolute_point(&PointS { + x: record.x, + y: record.y, + }); + self.context_current = self .context_current - .point_s_to_absolute_point(&PointS { x: record.x, y: record.y }); - self.context_current = - self.context_current.extend_window(&point).drawing_position(point); + .extend_window(&point) + .drawing_position(point); Ok(self) } @@ -1948,15 +1893,12 @@ impl crate::wmf::converter::Player for SVGPlayer { record_number: usize, record: META_SCALEWINDOWEXT, ) -> Result { - let scale_x = (self.context_current.window.scale_x - * f32::from(record.x_num)) + let scale_x = (self.context_current.window.scale_x * f32::from(record.x_num)) / f32::from(record.x_denom); - let scale_y = (self.context_current.window.scale_y - * f32::from(record.y_num)) + let scale_y = (self.context_current.window.scale_y * f32::from(record.y_num)) / f32::from(record.y_denom); - self.context_current = - self.context_current.window_scale(scale_x, scale_y); + self.context_current = self.context_current.window_scale(scale_x, scale_y); Ok(self) } @@ -1971,8 +1913,7 @@ impl crate::wmf::converter::Player for SVGPlayer { record_number: usize, record: META_SETBKCOLOR, ) -> Result { - self.context_current = - self.context_current.text_bk_color(record.color_ref); + self.context_current = self.context_current.text_bk_color(record.color_ref); Ok(self) } @@ -1997,11 +1938,7 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn set_layout( - self, - record_number: usize, - record: META_SETLAYOUT, - ) -> Result { + fn set_layout(self, record_number: usize, record: META_SETLAYOUT) -> Result { info!("META_SETLAYOUT: not implemented"); Ok(self) } @@ -2059,8 +1996,7 @@ impl crate::wmf::converter::Player for SVGPlayer { record_number: usize, record: META_SETPOLYFILLMODE, ) -> Result { - self.context_current = - self.context_current.poly_fill_mode(record.poly_fill_mode); + self.context_current = self.context_current.poly_fill_mode(record.poly_fill_mode); Ok(self) } @@ -2070,11 +2006,7 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn set_relabs( - self, - record_number: usize, - record: META_SETRELABS, - ) -> Result { + fn set_relabs(self, record_number: usize, record: META_SETRELABS) -> Result { info!("META_SETRELABS: reserved record and not supported"); Ok(self) } @@ -2118,14 +2050,12 @@ impl crate::wmf::converter::Player for SVGPlayer { record_number: usize, record: META_SETTEXTALIGN, ) -> Result { - let update_cp = record.text_alignment_mode - & (TextAlignmentMode::TA_UPDATECP as u16) + let update_cp = record.text_alignment_mode & (TextAlignmentMode::TA_UPDATECP as u16) == TextAlignmentMode::TA_UPDATECP as u16; - let align_horizontal = - [TextAlignmentMode::TA_CENTER, TextAlignmentMode::TA_RIGHT] - .into_iter() - .find(|a| record.text_alignment_mode & (*a as u16) == *a as u16) - .unwrap_or(TextAlignmentMode::TA_LEFT); + let align_horizontal = [TextAlignmentMode::TA_CENTER, TextAlignmentMode::TA_RIGHT] + .into_iter() + .find(|a| record.text_alignment_mode & (*a as u16) == *a as u16) + .unwrap_or(TextAlignmentMode::TA_LEFT); let align_vertical = [ VerticalTextAlignmentMode::VTA_BOTTOM, VerticalTextAlignmentMode::VTA_TOP, @@ -2167,8 +2097,7 @@ impl crate::wmf::converter::Player for SVGPlayer { record_number: usize, record: META_SETTEXTCOLOR, ) -> Result { - self.context_current = - self.context_current.text_color(record.color_ref); + self.context_current = self.context_current.text_color(record.color_ref); Ok(self) } @@ -2225,8 +2154,7 @@ impl crate::wmf::converter::Player for SVGPlayer { record_number: usize, record: META_SETWINDOWEXT, ) -> Result { - self.context_current = - self.context_current.window_ext(record.x, record.y); + self.context_current = self.context_current.window_ext(record.x, record.y); Ok(self) } @@ -2241,8 +2169,7 @@ impl crate::wmf::converter::Player for SVGPlayer { record_number: usize, record: META_SETWINDOWORG, ) -> Result { - self.context_current = - self.context_current.window_origin(record.x, record.y); + self.context_current = self.context_current.window_origin(record.x, record.y); Ok(self) } @@ -2258,11 +2185,7 @@ impl crate::wmf::converter::Player for SVGPlayer { skip(self), err(level = tracing::Level::ERROR, Display), ))] - fn escape( - self, - record_number: usize, - record: META_ESCAPE, - ) -> Result { + fn escape(self, record_number: usize, record: META_ESCAPE) -> Result { Ok(self) } } diff --git a/src/wmf/converter/svg/node.rs b/src/wmf/converter/svg/node.rs index a643f815..4a232491 100644 --- a/src/wmf/converter/svg/node.rs +++ b/src/wmf/converter/svg/node.rs @@ -56,7 +56,9 @@ impl Node { } fn escape_attr(value: impl ToString) -> String { - Self::escape_text(value).replace('"', """).replace('\'', "'") + Self::escape_text(value) + .replace('"', """) + .replace('\'', "'") } } @@ -69,9 +71,7 @@ impl core::fmt::Display for Node { "<{name} {}>{}", self.attrs .iter() - .map(|(k, v)| { - format!(r#"{k}="{}""#, Self::escape_attr(v)) - }) + .map(|(k, v)| { format!(r#"{k}="{}""#, Self::escape_attr(v)) }) .collect::>() .join(" "), self.inner diff --git a/src/wmf/converter/svg/ternary_raster_operator.rs b/src/wmf/converter/svg/ternary_raster_operator.rs index 60862455..07d4f8e9 100644 --- a/src/wmf/converter/svg/ternary_raster_operator.rs +++ b/src/wmf/converter/svg/ternary_raster_operator.rs @@ -27,14 +27,16 @@ enum Source { } impl TernaryRasterOperator { - pub fn new( - operation: TernaryRasterOperation, - x: i16, - y: i16, - height: i16, - width: i16, - ) -> Self { - Self { operation, x, y, height, width, brush: None, source: None } + pub fn new(operation: TernaryRasterOperation, x: i16, y: i16, height: i16, width: i16) -> Self { + Self { + operation, + x, + y, + height, + width, + brush: None, + source: None, + } } pub fn brush(mut self, brush: Brush) -> Self { @@ -85,8 +87,7 @@ impl TernaryRasterOperator { TernaryRasterOperation::SRCCOPY => { let bitmap = match self.source.unwrap() { Source::Bitmap16(data) => { - let bitmap = - crate::wmf::parser::DeviceIndependentBitmap::from(data); + let bitmap = crate::wmf::parser::DeviceIndependentBitmap::from(data); crate::wmf::converter::Bitmap::from(bitmap) } Source::Bitmap(data) => Bitmap::from(data), @@ -141,7 +142,17 @@ impl TernaryRasterOperator { impl From for RGBQuad { fn from(v: ColorRef) -> Self { - let ColorRef { red, green, blue, reserved } = v; - Self { red, green, blue, reserved } + let ColorRef { + red, + green, + blue, + reserved, + } = v; + Self { + red, + green, + blue, + reserved, + } } } diff --git a/src/wmf/converter/svg/util.rs b/src/wmf/converter/svg/util.rs index 73bc459b..15ce4fdb 100644 --- a/src/wmf/converter/svg/util.rs +++ b/src/wmf/converter/svg/util.rs @@ -27,22 +27,19 @@ impl Brush { pub fn as_filter(&self) -> Node { match self { Brush::DIBPatternPT { brush_hatch, .. } => { - let data = crate::wmf::converter::Bitmap::from(brush_hatch.clone()) - .as_data_url(); + let data = crate::wmf::converter::Bitmap::from(brush_hatch.clone()).as_data_url(); Node::new("filter").add(Node::new("feImage").set("href", data)) } - Brush::Hatched { color_ref, brush_hatch } => { - let data = crate::wmf::converter::Bitmap::from(( - color_ref.clone(), - *brush_hatch, - )) - .as_data_url(); + Brush::Hatched { + color_ref, + brush_hatch, + } => { + let data = crate::wmf::converter::Bitmap::from((color_ref.clone(), *brush_hatch)) + .as_data_url(); Node::new("filter").add(Node::new("feImage").set("href", data)) } Brush::Pattern { brush_hatch } => { - let bitmap = crate::wmf::parser::DeviceIndependentBitmap::from( - brush_hatch.clone(), - ); + let bitmap = crate::wmf::parser::DeviceIndependentBitmap::from(brush_hatch.clone()); let data = crate::wmf::converter::Bitmap::from(bitmap).as_data_url(); Node::new("filter").add(Node::new("feImage").set("href", data)) @@ -60,9 +57,7 @@ impl Brush { .add( Node::new("feMerge") .add(Node::new("feMergeNode").set("in", "bg")) - .add( - Node::new("feMergeNode").set("in", "SourceGraphic"), - ), + .add(Node::new("feMergeNode").set("in", "SourceGraphic")), ), Brush::Null => Node::new("filter") .set("x", "0") @@ -88,8 +83,7 @@ impl From for Fill { fn from(v: Brush) -> Self { match v { Brush::DIBPatternPT { brush_hatch, .. } => { - let data = crate::wmf::converter::Bitmap::from(brush_hatch.clone()) - .as_data_url(); + let data = crate::wmf::converter::Bitmap::from(brush_hatch.clone()).as_data_url(); let image = Node::new("image") .set("x", "0") .set("y", "0") @@ -107,7 +101,10 @@ impl From for Fill { Fill::Pattern { pattern } } - Brush::Hatched { color_ref, brush_hatch } => { + Brush::Hatched { + color_ref, + brush_hatch, + } => { let path = match brush_hatch { HatchStyle::HS_HORIZONTAL => { let data = Data::new().move_to("0 0").line_to("10 0"); @@ -173,9 +170,7 @@ impl From for Fill { Fill::Pattern { pattern } } Brush::Pattern { brush_hatch } => { - let bitmap = crate::wmf::parser::DeviceIndependentBitmap::from( - brush_hatch.clone(), - ); + let bitmap = crate::wmf::parser::DeviceIndependentBitmap::from(brush_hatch.clone()); let data = crate::wmf::converter::Bitmap::from(bitmap).as_data_url(); let image = Node::new("image") .set("x", "0") @@ -194,10 +189,12 @@ impl From for Fill { Fill::Pattern { pattern } } - Brush::Solid { color_ref } => { - Fill::Value { value: css_color_from_color_ref(&color_ref) } - } - Brush::Null => Fill::Value { value: "none".to_owned() }, + Brush::Solid { color_ref } => Fill::Value { + value: css_color_from_color_ref(&color_ref), + }, + Brush::Null => Fill::Value { + value: "none".to_owned(), + }, } } } @@ -237,7 +234,10 @@ impl Default for Stroke { impl From for Stroke { fn from(v: Pen) -> Self { if v.style.style == PenStyle::PS_NULL { - return Self { none: true, ..Default::default() }; + return Self { + none: true, + ..Default::default() + }; } let mut stroke = Self::default(); @@ -305,14 +305,22 @@ impl From for Stroke { impl From for Stroke { fn from(v: Brush) -> Self { match v { - Brush::DIBPatternPT { .. } => { - Self { none: true, ..Default::default() } - } - Brush::Hatched { color_ref, .. } | Brush::Solid { color_ref } => { - Self { color: color_ref, ..Default::default() } - } - Brush::Pattern { .. } => Self { ..Default::default() }, - Brush::Null => Self { none: true, width: 0, ..Default::default() }, + Brush::DIBPatternPT { .. } => Self { + none: true, + ..Default::default() + }, + Brush::Hatched { color_ref, .. } | Brush::Solid { color_ref } => Self { + color: color_ref, + ..Default::default() + }, + Brush::Pattern { .. } => Self { + ..Default::default() + }, + Brush::Null => Self { + none: true, + width: 0, + ..Default::default() + }, } } } @@ -357,11 +365,7 @@ impl Stroke { } impl Font { - pub fn set_props( - &self, - mut elem: Node, - point: &PointS, - ) -> (Node, Vec) { + pub fn set_props(&self, mut elem: Node, point: &PointS) -> (Node, Vec) { let mut styles = Vec::with_capacity(2); if self.italic { @@ -395,12 +399,7 @@ impl Font { if self.escapement != 0 { elem = elem.set( "transform", - format!( - "rotate({}, {} {})", - -self.escapement / 10, - point.x, - point.y - ), + format!("rotate({}, {} {})", -self.escapement / 10, point.x, point.y), ); } diff --git a/src/wmf/mod.rs b/src/wmf/mod.rs index b2aec3b5..ef5adcd7 100644 --- a/src/wmf/mod.rs +++ b/src/wmf/mod.rs @@ -13,7 +13,7 @@ unexpected_cfgs, dead_code, unused_imports, - unused_variables, + unused_variables )] // tracing 스텁 매크로 (converter/parser 모듈보다 먼저 정의해야 하위 모듈에서 사용 가능) diff --git a/src/wmf/parser/constants/enums/binary_raster_operation.rs b/src/wmf/parser/constants/enums/binary_raster_operation.rs index bcccfdae..2570937a 100644 --- a/src/wmf/parser/constants/enums/binary_raster_operation.rs +++ b/src/wmf/parser/constants/enums/binary_raster_operation.rs @@ -2,17 +2,7 @@ /// raster-operation codes. Raster-operation codes define how metafile /// processing combines the bits from the selected pen with the bits in the /// destination bitmap. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum BinaryRasterOperation { /// 0, Pixel is always 0. diff --git a/src/wmf/parser/constants/enums/bit_count.rs b/src/wmf/parser/constants/enums/bit_count.rs index 17ed320d..3b607bb9 100644 --- a/src/wmf/parser/constants/enums/bit_count.rs +++ b/src/wmf/parser/constants/enums/bit_count.rs @@ -1,16 +1,6 @@ /// The BitCount Enumeration specifies the number of bits that define each pixel /// and the maximum number of colors in a device-independent bitmap (DIB). -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum BitCount { /// The number of bits per pixel is undefined. diff --git a/src/wmf/parser/constants/enums/brush_style.rs b/src/wmf/parser/constants/enums/brush_style.rs index 88b04243..518824dc 100644 --- a/src/wmf/parser/constants/enums/brush_style.rs +++ b/src/wmf/parser/constants/enums/brush_style.rs @@ -1,17 +1,7 @@ /// The BrushStyle Enumeration specifies the different possible brush types that /// can be used in graphics operations. For more information, see the /// specification of the Brush Object. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum BrushStyle { /// A brush that paints a single, constant color, either solid or dithered. diff --git a/src/wmf/parser/constants/enums/character_set.rs b/src/wmf/parser/constants/enums/character_set.rs index 4f11b1ce..dd67a5b6 100644 --- a/src/wmf/parser/constants/enums/character_set.rs +++ b/src/wmf/parser/constants/enums/character_set.rs @@ -2,17 +2,7 @@ use crate::wmf::imports::*; /// The CharacterSet Enumeration defines the possible sets of character glyphs /// that are defined in fonts for graphics output. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u8)] pub enum CharacterSet { /// Specifies the English character set. @@ -158,8 +148,7 @@ static mut CODEPAGE_TABLE: BTreeMap = BTreeMap::new(); fn codepage_table() -> &'static BTreeMap { if !CODEPAGE_TABLE_INITIALIZED.load(core::sync::atomic::Ordering::Acquire) { - CODEPAGE_TABLE_INITIALIZED - .store(true, core::sync::atomic::Ordering::Release); + CODEPAGE_TABLE_INITIALIZED.store(true, core::sync::atomic::Ordering::Release); unsafe { // via: https://en.wikipedia.org/wiki/Code_page diff --git a/src/wmf/parser/constants/enums/color_usage.rs b/src/wmf/parser/constants/enums/color_usage.rs index 24462224..59afed85 100644 --- a/src/wmf/parser/constants/enums/color_usage.rs +++ b/src/wmf/parser/constants/enums/color_usage.rs @@ -1,16 +1,6 @@ /// The ColorUsage Enumeration specifies whether a color table exists in a /// device-independent bitmap (DIB) and how to interpret its values. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum ColorUsage { /// The color table contains RGB values specified by RGBQuad Objects diff --git a/src/wmf/parser/constants/enums/compression.rs b/src/wmf/parser/constants/enums/compression.rs index 300e7fa1..4f81b4fb 100644 --- a/src/wmf/parser/constants/enums/compression.rs +++ b/src/wmf/parser/constants/enums/compression.rs @@ -1,16 +1,6 @@ /// The Compression Enumeration specifies the type of compression for a bitmap /// image. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u32)] pub enum Compression { /// The bitmap is in uncompressed red green blue (RGB) format that is not diff --git a/src/wmf/parser/constants/enums/family_font.rs b/src/wmf/parser/constants/enums/family_font.rs index 12592dd7..b8cd5a7e 100644 --- a/src/wmf/parser/constants/enums/family_font.rs +++ b/src/wmf/parser/constants/enums/family_font.rs @@ -1,17 +1,7 @@ /// The FamilyFont Enumeration specifies the font family. Font families describe /// the look of a font in a general way. They are intended for specifying fonts /// when the exact typeface desired is not available. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u8)] pub enum FamilyFont { /// The default font is specified, which is implementation-dependent. diff --git a/src/wmf/parser/constants/enums/flood_fill.rs b/src/wmf/parser/constants/enums/flood_fill.rs index d8db676a..2614bacf 100644 --- a/src/wmf/parser/constants/enums/flood_fill.rs +++ b/src/wmf/parser/constants/enums/flood_fill.rs @@ -1,16 +1,6 @@ /// The FloodFill Enumeration specifies the type of fill operation to be /// performed. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum FloodFill { /// The fill area is bounded by the color specified by the Color member. diff --git a/src/wmf/parser/constants/enums/font_quality.rs b/src/wmf/parser/constants/enums/font_quality.rs index 4c1e46db..7db54ff9 100644 --- a/src/wmf/parser/constants/enums/font_quality.rs +++ b/src/wmf/parser/constants/enums/font_quality.rs @@ -1,16 +1,6 @@ /// The FontQuality Enumeration specifies how closely the attributes of the /// logical font match those of the physical font when rendering text. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u8)] pub enum FontQuality { /// Specifies that the character quality of the font does not matter, so diff --git a/src/wmf/parser/constants/enums/gamut_mapping_intent.rs b/src/wmf/parser/constants/enums/gamut_mapping_intent.rs index 1fb5bf7c..db6f0092 100644 --- a/src/wmf/parser/constants/enums/gamut_mapping_intent.rs +++ b/src/wmf/parser/constants/enums/gamut_mapping_intent.rs @@ -1,17 +1,7 @@ /// The GamutMappingIntent Enumeration specifies the relationship between /// logical and physical colors. (Windows NT 3.1, Windows NT 3.5, and Windows NT /// 3.51: This functionality is not supported.) -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u32)] pub enum GamutMappingIntent { /// Specifies that saturation SHOULD be maintained. Typically used for diff --git a/src/wmf/parser/constants/enums/hatch_style.rs b/src/wmf/parser/constants/enums/hatch_style.rs index a275109f..2f9af901 100644 --- a/src/wmf/parser/constants/enums/hatch_style.rs +++ b/src/wmf/parser/constants/enums/hatch_style.rs @@ -1,15 +1,5 @@ /// The HatchStyle Enumeration specifies the hatch pattern. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum HatchStyle { /// A horizontal hatch. diff --git a/src/wmf/parser/constants/enums/layout.rs b/src/wmf/parser/constants/enums/layout.rs index b67f3381..500fe666 100644 --- a/src/wmf/parser/constants/enums/layout.rs +++ b/src/wmf/parser/constants/enums/layout.rs @@ -2,17 +2,7 @@ /// which text and graphics are drawn. (Windows NT 3.1, Windows NT 3.5, Windows /// NT 3.51, Windows 95, Windows NT 4.0, Windows 98, and Windows Millennium /// Edition: This functionality is not supported.) -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum Layout { /// Sets the default horizontal layout to be left-to-right. diff --git a/src/wmf/parser/constants/enums/logical_color_space.rs b/src/wmf/parser/constants/enums/logical_color_space.rs index cc8fba4c..d1d0e24a 100644 --- a/src/wmf/parser/constants/enums/logical_color_space.rs +++ b/src/wmf/parser/constants/enums/logical_color_space.rs @@ -5,17 +5,7 @@ /// The LogicalColorSpaceV5 Enumeration is used to specify where to find color /// profile information for a DeviceIndependentBitmap (DIB) Object that has a /// header of type BitmapV5Header Object. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u32)] pub enum LogicalColorSpace { /// Color values are calibrated red green blue (RGB) values. diff --git a/src/wmf/parser/constants/enums/map_mode.rs b/src/wmf/parser/constants/enums/map_mode.rs index 0a7f257e..3e298791 100644 --- a/src/wmf/parser/constants/enums/map_mode.rs +++ b/src/wmf/parser/constants/enums/map_mode.rs @@ -12,17 +12,7 @@ /// previous example. Given the following definition of that mapping mode, /// logical coordinate (4,-5) would map to physical coordinate (0.04,0.05) in /// inches. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum MapMode { /// Each logical unit is mapped to one device pixel. Positive x is to the diff --git a/src/wmf/parser/constants/enums/metafile_escapes.rs b/src/wmf/parser/constants/enums/metafile_escapes.rs index 1499d292..a8133967 100644 --- a/src/wmf/parser/constants/enums/metafile_escapes.rs +++ b/src/wmf/parser/constants/enums/metafile_escapes.rs @@ -3,17 +3,7 @@ /// RecordType Enumeration. /// /// These values are used by Escape Record Types. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum MetafileEscapes { /// Notifies the printer driver that the application has finished writing diff --git a/src/wmf/parser/constants/enums/metafile_type.rs b/src/wmf/parser/constants/enums/metafile_type.rs index 761c3678..36ee3ffc 100644 --- a/src/wmf/parser/constants/enums/metafile_type.rs +++ b/src/wmf/parser/constants/enums/metafile_type.rs @@ -1,15 +1,5 @@ /// The MetafileType Enumeration specifies where the metafile is stored. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum MetafileType { /// Metafile is stored in memory. diff --git a/src/wmf/parser/constants/enums/metafile_version.rs b/src/wmf/parser/constants/enums/metafile_version.rs index 4e7e6fc5..8d1b2c19 100644 --- a/src/wmf/parser/constants/enums/metafile_version.rs +++ b/src/wmf/parser/constants/enums/metafile_version.rs @@ -1,16 +1,6 @@ /// The MetafileVersion Enumeration defines values that specify support for /// device-independent bitmaps (DIBs) in metafiles. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum MetafileVersion { /// DIBs are not supported. diff --git a/src/wmf/parser/constants/enums/mix_mode.rs b/src/wmf/parser/constants/enums/mix_mode.rs index 39c6f8df..4e94cd25 100644 --- a/src/wmf/parser/constants/enums/mix_mode.rs +++ b/src/wmf/parser/constants/enums/mix_mode.rs @@ -1,16 +1,6 @@ /// The MixMode Enumeration specifies the background mix mode for text, hatched /// brushes, and other nonsolid pen styles. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum MixMode { /// The background remains untouched. diff --git a/src/wmf/parser/constants/enums/mod.rs b/src/wmf/parser/constants/enums/mod.rs index c8f487c1..e2ae146a 100644 --- a/src/wmf/parser/constants/enums/mod.rs +++ b/src/wmf/parser/constants/enums/mod.rs @@ -33,13 +33,11 @@ mod stretch_mode; mod ternary_raster_operation; pub use self::{ - binary_raster_operation::*, bit_count::*, brush_style::*, character_set::*, - color_usage::*, compression::*, family_font::*, flood_fill::*, - font_quality::*, gamut_mapping_intent::*, hatch_style::*, layout::*, - logical_color_space::*, map_mode::*, metafile_escapes::*, metafile_type::*, - metafile_version::*, mix_mode::*, out_precision::*, palette_entry_flag::*, - pen_style::*, pitch_font::*, poly_fill_mode::*, post_script_cap::*, - post_script_clipping::*, post_script_feature_setting::*, - post_script_join::*, record_type::*, stretch_mode::*, + binary_raster_operation::*, bit_count::*, brush_style::*, character_set::*, color_usage::*, + compression::*, family_font::*, flood_fill::*, font_quality::*, gamut_mapping_intent::*, + hatch_style::*, layout::*, logical_color_space::*, map_mode::*, metafile_escapes::*, + metafile_type::*, metafile_version::*, mix_mode::*, out_precision::*, palette_entry_flag::*, + pen_style::*, pitch_font::*, poly_fill_mode::*, post_script_cap::*, post_script_clipping::*, + post_script_feature_setting::*, post_script_join::*, record_type::*, stretch_mode::*, ternary_raster_operation::*, }; diff --git a/src/wmf/parser/constants/enums/out_precision.rs b/src/wmf/parser/constants/enums/out_precision.rs index e4c2a724..76d45cef 100644 --- a/src/wmf/parser/constants/enums/out_precision.rs +++ b/src/wmf/parser/constants/enums/out_precision.rs @@ -2,17 +2,7 @@ /// the requirement for the font mapper to match specific font parameters, /// including height, width, character orientation, escapement, pitch, and font /// type. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u8)] pub enum OutPrecision { /// A value that specifies default behavior. diff --git a/src/wmf/parser/constants/enums/palette_entry_flag.rs b/src/wmf/parser/constants/enums/palette_entry_flag.rs index 3ff69aca..b27ea17b 100644 --- a/src/wmf/parser/constants/enums/palette_entry_flag.rs +++ b/src/wmf/parser/constants/enums/palette_entry_flag.rs @@ -1,15 +1,5 @@ /// The PaletteEntryFlag Enumeration specifies how the palette entry is used. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u8)] pub enum PaletteEntryFlag { /// Specifies that the logical palette entry be used for palette animation. diff --git a/src/wmf/parser/constants/enums/pen_style.rs b/src/wmf/parser/constants/enums/pen_style.rs index 53366174..1bd8b502 100644 --- a/src/wmf/parser/constants/enums/pen_style.rs +++ b/src/wmf/parser/constants/enums/pen_style.rs @@ -2,17 +2,7 @@ use crate::wmf::imports::*; /// The 16-bit PenStyle Enumeration is used to specify different types of pens /// that can be used in graphics operations. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum PenStyle { /// This value 0x0000 has multiple meanings: diff --git a/src/wmf/parser/constants/enums/pitch_font.rs b/src/wmf/parser/constants/enums/pitch_font.rs index 9258bcfa..0de95e40 100644 --- a/src/wmf/parser/constants/enums/pitch_font.rs +++ b/src/wmf/parser/constants/enums/pitch_font.rs @@ -4,17 +4,7 @@ /// /// In a Font Object, when a FamilyFont Enumeration value is packed into a byte /// with a PitchFont value, the result is a PitchAndFamily Object. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u8)] pub enum PitchFont { /// The default pitch, which is implementation-dependent. diff --git a/src/wmf/parser/constants/enums/poly_fill_mode.rs b/src/wmf/parser/constants/enums/poly_fill_mode.rs index 2933a636..d4de49bb 100644 --- a/src/wmf/parser/constants/enums/poly_fill_mode.rs +++ b/src/wmf/parser/constants/enums/poly_fill_mode.rs @@ -1,16 +1,6 @@ /// The PolyFillMode Enumeration specifies the method used for filling a /// polygon. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum PolyFillMode { /// Selects alternate mode (fills the area between odd-numbered and diff --git a/src/wmf/parser/constants/enums/post_script_cap.rs b/src/wmf/parser/constants/enums/post_script_cap.rs index 669d7038..02ef2b37 100644 --- a/src/wmf/parser/constants/enums/post_script_cap.rs +++ b/src/wmf/parser/constants/enums/post_script_cap.rs @@ -1,16 +1,6 @@ /// The PostScriptCap Enumeration defines line-ending types for use with a /// PostScript printer driver. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(i32)] pub enum PostScriptCap { /// Specifies that the line-ending style has not been set and that a diff --git a/src/wmf/parser/constants/enums/post_script_clipping.rs b/src/wmf/parser/constants/enums/post_script_clipping.rs index 3dc4f44d..81582dee 100644 --- a/src/wmf/parser/constants/enums/post_script_clipping.rs +++ b/src/wmf/parser/constants/enums/post_script_clipping.rs @@ -1,16 +1,6 @@ /// The PostScriptClipping Enumeration defines functions that can be applied to /// the clipping path used for PostScript output. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum PostScriptClipping { /// Saves the current PostScript clipping path. diff --git a/src/wmf/parser/constants/enums/post_script_feature_setting.rs b/src/wmf/parser/constants/enums/post_script_feature_setting.rs index 6d2b3613..7111a99a 100644 --- a/src/wmf/parser/constants/enums/post_script_feature_setting.rs +++ b/src/wmf/parser/constants/enums/post_script_feature_setting.rs @@ -2,17 +2,7 @@ /// retrieve information about specific features in a PostScript printer driver. /// (Windows NT 3.1, Windows NT 3.5, Windows NT 3.51, Windows 95, Windows 98, /// and Windows Millennium Edition: This functionality is not supported.) -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u32)] pub enum PostScriptFeatureSetting { /// Specifies the n-up printing (page layout) setting. diff --git a/src/wmf/parser/constants/enums/post_script_join.rs b/src/wmf/parser/constants/enums/post_script_join.rs index d6b2aef6..d776fd78 100644 --- a/src/wmf/parser/constants/enums/post_script_join.rs +++ b/src/wmf/parser/constants/enums/post_script_join.rs @@ -1,16 +1,6 @@ /// The PostScriptJoin Enumeration defines line-joining capabilities for use /// with a PostScript printer driver. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(i32)] pub enum PostScriptJoin { /// Specifies that the line-joining style has not been set and that a diff --git a/src/wmf/parser/constants/enums/record_type.rs b/src/wmf/parser/constants/enums/record_type.rs index 29b5252e..cb50d36b 100644 --- a/src/wmf/parser/constants/enums/record_type.rs +++ b/src/wmf/parser/constants/enums/record_type.rs @@ -1,16 +1,6 @@ /// The RecordType Enumeration defines the types of records that can be used in /// WMF metafiles. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum RecordType { /// This record specifies the end of the file, the last record in the diff --git a/src/wmf/parser/constants/enums/stretch_mode.rs b/src/wmf/parser/constants/enums/stretch_mode.rs index c6e2dfcb..95f272b6 100644 --- a/src/wmf/parser/constants/enums/stretch_mode.rs +++ b/src/wmf/parser/constants/enums/stretch_mode.rs @@ -1,17 +1,7 @@ /// The StretchMode Enumeration specifies the bitmap stretching mode, which /// defines how the system combines rows or columns of a bitmap with existing /// pixels. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum StretchMode { /// Performs a Boolean AND operation by using the color values for the diff --git a/src/wmf/parser/constants/enums/ternary_raster_operation.rs b/src/wmf/parser/constants/enums/ternary_raster_operation.rs index efdda1d7..04a389b3 100644 --- a/src/wmf/parser/constants/enums/ternary_raster_operation.rs +++ b/src/wmf/parser/constants/enums/ternary_raster_operation.rs @@ -3,17 +3,7 @@ use crate::wmf::imports::*; /// The TernaryRasterOperation Enumeration specifies ternary raster operation /// codes, which define how to combine the bits in a source bitmap with the bits /// in a destination bitmap. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u32)] pub enum TernaryRasterOperation { // cSpell:disable @@ -540,11 +530,13 @@ impl TernaryRasterOperation { const OPERAND_SOURCE_BITMAP: &'static str = "S"; pub fn use_selected_brush(&self) -> bool { - self.as_reverse_polish_notation().contains(Self::OPERAND_SELECTED_BRUSH) + self.as_reverse_polish_notation() + .contains(Self::OPERAND_SELECTED_BRUSH) } pub fn use_source(&self) -> bool { - self.as_reverse_polish_notation().contains(Self::OPERAND_SOURCE_BITMAP) + self.as_reverse_polish_notation() + .contains(Self::OPERAND_SOURCE_BITMAP) } fn as_reverse_polish_notation(self) -> String { diff --git a/src/wmf/parser/constants/flags/clip_precision.rs b/src/wmf/parser/constants/flags/clip_precision.rs index b05860db..02f7f6c9 100644 --- a/src/wmf/parser/constants/flags/clip_precision.rs +++ b/src/wmf/parser/constants/flags/clip_precision.rs @@ -1,17 +1,7 @@ /// ClipPrecision Flags specify clipping precision, which defines how to clip /// characters that are partially outside a clipping region. These flags can be /// combined to specify multiple options. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u8)] pub enum ClipPrecision { /// Specifies that default clipping MUST be used. diff --git a/src/wmf/parser/constants/flags/ext_text_out_options.rs b/src/wmf/parser/constants/flags/ext_text_out_options.rs index 5cc56c22..464bf0b8 100644 --- a/src/wmf/parser/constants/flags/ext_text_out_options.rs +++ b/src/wmf/parser/constants/flags/ext_text_out_options.rs @@ -1,16 +1,6 @@ /// ExtTextOutOptions Flags specify various characteristics of the output of /// text. These flags can be combined to specify multiple options. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum ExtTextOutOptions { /// Indicates that the background color that is defined in the playback diff --git a/src/wmf/parser/constants/flags/text_alignment_mode.rs b/src/wmf/parser/constants/flags/text_alignment_mode.rs index 997f79f3..825a0930 100644 --- a/src/wmf/parser/constants/flags/text_alignment_mode.rs +++ b/src/wmf/parser/constants/flags/text_alignment_mode.rs @@ -5,17 +5,7 @@ /// /// Horizontal text alignment is performed when the font has a horizontal /// default baseline. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum TextAlignmentMode { /// The drawing position in the playback device context MUST NOT be updated diff --git a/src/wmf/parser/constants/flags/vertical_text_alignment_mode.rs b/src/wmf/parser/constants/flags/vertical_text_alignment_mode.rs index 344f3e0e..9a09c687 100644 --- a/src/wmf/parser/constants/flags/vertical_text_alignment_mode.rs +++ b/src/wmf/parser/constants/flags/vertical_text_alignment_mode.rs @@ -6,17 +6,7 @@ /// /// Vertical text alignment is performed when the font has a vertical default /// baseline, such as Kanji. -#[derive( - Clone, - Copy, - Debug, - Eq, - Ord, - PartialEq, - PartialOrd, - strum::FromRepr, - strum::EnumIter, -)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, strum::FromRepr, strum::EnumIter)] #[repr(u16)] pub enum VerticalTextAlignmentMode { /// The reference point MUST be on the top edge of the bounding rectangle. diff --git a/src/wmf/parser/mod.rs b/src/wmf/parser/mod.rs index 690b289f..7b86ae2e 100644 --- a/src/wmf/parser/mod.rs +++ b/src/wmf/parser/mod.rs @@ -31,7 +31,9 @@ pub struct ReadError { impl ReadError { pub fn new(err: impl core::fmt::Display) -> Self { - Self { cause: err.to_string() } + Self { + cause: err.to_string(), + } } } @@ -109,9 +111,7 @@ fn bytes_into_utf8( if charset == crate::wmf::parser::CharacterSet::SYMBOL_CHARSET { Ok(bytes .iter() - .filter_map(|v| { - crate::wmf::parser::symbol_charset_table().get(v).copied() - }) + .filter_map(|v| crate::wmf::parser::symbol_charset_table().get(v).copied()) .collect::() .replace('\0', "")) } else { @@ -120,8 +120,7 @@ fn bytes_into_utf8( if had_errors { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: "Failed to decode string with specified charset" - .to_string(), + cause: "Failed to decode string with specified charset".to_string(), }); } diff --git a/src/wmf/parser/objects/graphics/brush.rs b/src/wmf/parser/objects/graphics/brush.rs index 0d82a62b..a8787903 100644 --- a/src/wmf/parser/objects/graphics/brush.rs +++ b/src/wmf/parser/objects/graphics/brush.rs @@ -29,8 +29,7 @@ impl Brush { pub fn parse( buf: &mut R, ) -> Result<(Self, usize), crate::wmf::parser::ParseError> { - let (style, mut consumed_bytes) = - crate::wmf::parser::BrushStyle::parse(buf)?; + let (style, mut consumed_bytes) = crate::wmf::parser::BrushStyle::parse(buf)?; let v = match style { crate::wmf::parser::BrushStyle::BS_DIBPATTERNPT => { use crate::wmf::parser::DeviceIndependentBitmap; @@ -39,13 +38,13 @@ impl Brush { consumed_bytes += c; let (brush_hatch, c) = - DeviceIndependentBitmap::parse_with_color_usage( - buf, - color_usage, - )?; + DeviceIndependentBitmap::parse_with_color_usage(buf, color_usage)?; consumed_bytes += c; - Self::DIBPatternPT { color_usage, brush_hatch } + Self::DIBPatternPT { + color_usage, + brush_hatch, + } } crate::wmf::parser::BrushStyle::BS_HATCHED => { let (color_ref, c) = crate::wmf::parser::ColorRef::parse(buf)?; @@ -54,7 +53,10 @@ impl Brush { let (brush_hatch, c) = crate::wmf::parser::HatchStyle::parse(buf)?; consumed_bytes += c; - Self::Hatched { color_ref, brush_hatch } + Self::Hatched { + color_ref, + brush_hatch, + } } crate::wmf::parser::BrushStyle::BS_PATTERN => { // SHOULD be ignored. diff --git a/src/wmf/parser/objects/graphics/font.rs b/src/wmf/parser/objects/graphics/font.rs index 2698049d..742ea622 100644 --- a/src/wmf/parser/objects/graphics/font.rs +++ b/src/wmf/parser/objects/graphics/font.rs @@ -181,8 +181,7 @@ impl Font { )?; // Convert bytes to UTF-8 string from specified charset - let as_charset = - crate::wmf::parser::bytes_into_utf8(&bytes[..len], charset)?; + let as_charset = crate::wmf::parser::bytes_into_utf8(&bytes[..len], charset)?; (as_latin1, as_charset) }; diff --git a/src/wmf/parser/objects/graphics/palette.rs b/src/wmf/parser/objects/graphics/palette.rs index 187a6d4f..1b035384 100644 --- a/src/wmf/parser/objects/graphics/palette.rs +++ b/src/wmf/parser/objects/graphics/palette.rs @@ -25,17 +25,13 @@ impl Palette { pub fn parse( buf: &mut R, ) -> Result<(Self, usize), crate::wmf::parser::ParseError> { - let ( - (start, start_bytes), - (number_of_entries, number_of_entries_bytes), - ) = ( + let ((start, start_bytes), (number_of_entries, number_of_entries_bytes)) = ( crate::wmf::parser::read_u16_from_le_bytes(buf)?, crate::wmf::parser::read_u16_from_le_bytes(buf)?, ); let mut consumed_bytes = start_bytes + number_of_entries_bytes; - let mut a_palette_entries = - Vec::with_capacity(number_of_entries as usize); + let mut a_palette_entries = Vec::with_capacity(number_of_entries as usize); for _ in 0..number_of_entries { let (v, c) = crate::wmf::parser::PaletteEntry::parse(buf)?; @@ -45,7 +41,11 @@ impl Palette { } Ok(( - Self { start, number_of_entries, a_palette_entries }, + Self { + start, + number_of_entries, + a_palette_entries, + }, consumed_bytes, )) } diff --git a/src/wmf/parser/objects/graphics/pen.rs b/src/wmf/parser/objects/graphics/pen.rs index 5e7da252..7ef6e848 100644 --- a/src/wmf/parser/objects/graphics/pen.rs +++ b/src/wmf/parser/objects/graphics/pen.rs @@ -22,18 +22,18 @@ impl Pen { pub fn parse( buf: &mut R, ) -> Result<(Self, usize), crate::wmf::parser::ParseError> { - let ( - (style, style_bytes), - (width, width_bytes), - (color_ref, color_ref_bytes), - ) = ( + let ((style, style_bytes), (width, width_bytes), (color_ref, color_ref_bytes)) = ( PenStyleSubsection::parse(buf)?, crate::wmf::parser::PointS::parse(buf)?, crate::wmf::parser::ColorRef::parse(buf)?, ); Ok(( - Self { style, width, color_ref }, + Self { + style, + width, + color_ref, + }, style_bytes + width_bytes + color_ref_bytes, )) } @@ -56,8 +56,7 @@ impl PenStyleSubsection { pub fn parse( buf: &mut R, ) -> Result<(Self, usize), crate::wmf::parser::ParseError> { - let (style_u16, style_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (style_u16, style_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; Ok(( Self { diff --git a/src/wmf/parser/objects/structure/bitmap16.rs b/src/wmf/parser/objects/structure/bitmap16.rs index 0c80a0fe..1b77b141 100644 --- a/src/wmf/parser/objects/structure/bitmap16.rs +++ b/src/wmf/parser/objects/structure/bitmap16.rs @@ -57,8 +57,7 @@ impl Bitmap16 { buf: &mut R, ) -> Result<(Self, usize), crate::wmf::parser::ParseError> { let (mut bitmap, mut consumed_bytes) = Self::parse_without_bits(buf)?; - let (bits, bits_bytes) = - crate::wmf::parser::read_variable(buf, bitmap.calc_length())?; + let (bits, bits_bytes) = crate::wmf::parser::read_variable(buf, bitmap.calc_length())?; bitmap.bits = bits; consumed_bytes += bits_bytes; @@ -124,8 +123,7 @@ impl Bitmap16 { } pub fn calc_length(&self) -> usize { - ((((self.width * self.bits_pixel as i16 + 15) >> 4) << 1) * self.height) - as usize + ((((self.width * self.bits_pixel as i16 + 15) >> 4) << 1) * self.height) as usize } } diff --git a/src/wmf/parser/objects/structure/bitmap_info_header/core.rs b/src/wmf/parser/objects/structure/bitmap_info_header/core.rs index bc01326d..6799c0fb 100644 --- a/src/wmf/parser/objects/structure/bitmap_info_header/core.rs +++ b/src/wmf/parser/objects/structure/bitmap_info_header/core.rs @@ -47,8 +47,7 @@ impl BitmapInfoHeaderCore { crate::wmf::parser::read_u16_from_le_bytes(buf)?, crate::wmf::parser::BitCount::parse(buf)?, ); - let consumed_bytes = - width_bytes + height_bytes + planes_bytes + bit_count_bytes; + let consumed_bytes = width_bytes + height_bytes + planes_bytes + bit_count_bytes; if planes != 0x0001 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { @@ -64,15 +63,18 @@ impl BitmapInfoHeaderCore { | crate::wmf::parser::BitCount::BI_BITCOUNT_5 ) { return Err(crate::wmf::parser::ParseError::UnexpectedEnumValue { - cause: format!( - "Invalid BitCount `{}` as Core type.", - bit_count as u16 - ), + cause: format!("Invalid BitCount `{}` as Core type.", bit_count as u16), }); } Ok(( - Self { header_size, width, height, planes, bit_count }, + Self { + header_size, + width, + height, + planes, + bit_count, + }, consumed_bytes, )) } diff --git a/src/wmf/parser/objects/structure/bitmap_info_header/mod.rs b/src/wmf/parser/objects/structure/bitmap_info_header/mod.rs index 7b241e6c..09a36f8e 100644 --- a/src/wmf/parser/objects/structure/bitmap_info_header/mod.rs +++ b/src/wmf/parser/objects/structure/bitmap_info_header/mod.rs @@ -17,20 +17,17 @@ impl BitmapInfoHeader { pub fn parse( buf: &mut R, ) -> Result<(Self, usize), crate::wmf::parser::ParseError> { - let (header_size, mut consumed_bytes) = - crate::wmf::parser::read_u32_from_le_bytes(buf)?; + let (header_size, mut consumed_bytes) = crate::wmf::parser::read_u32_from_le_bytes(buf)?; match header_size { 0x0000000C => { - let (header, c) = - BitmapInfoHeaderCore::parse(buf, header_size)?; + let (header, c) = BitmapInfoHeaderCore::parse(buf, header_size)?; consumed_bytes += c; Ok((Self::Core(header), consumed_bytes)) } 13..=40 => { - let (header, c) = - BitmapInfoHeaderInfo::parse(buf, header_size)?; + let (header, c) = BitmapInfoHeaderInfo::parse(buf, header_size)?; consumed_bytes += c; Ok((Self::Info(header), consumed_bytes)) @@ -82,10 +79,7 @@ impl BitmapInfoHeader { planes, bit_count, .. - }) => u32::from( - (((width * planes * (*bit_count as u16) + 31) & !31) / 8) - * height, - ), + }) => u32::from((((width * planes * (*bit_count as u16) + 31) & !31) / 8) * height), Self::Info(BitmapInfoHeaderInfo { width, height, @@ -116,12 +110,7 @@ impl BitmapInfoHeader { crate::wmf::parser::Compression::BI_RGB | crate::wmf::parser::Compression::BI_BITFIELDS | crate::wmf::parser::Compression::BI_CMYK => { - ((((*width as u32) - * u32::from(*planes) - * (*bit_count as u32) - + 31) - & !31) - / 8) + ((((*width as u32) * u32::from(*planes) * (*bit_count as u32) + 31) & !31) / 8) * height.unsigned_abs() } _ => *image_size, @@ -133,14 +122,22 @@ impl BitmapInfoHeader { pub fn color_used(&self) -> u32 { match self { - Self::Core(BitmapInfoHeaderCore { bit_count, .. }) => { - 2u32.pow(*bit_count as u32) - } + Self::Core(BitmapInfoHeaderCore { bit_count, .. }) => 2u32.pow(*bit_count as u32), Self::Info(BitmapInfoHeaderInfo { - bit_count, color_used, .. + bit_count, + color_used, + .. + }) + | Self::V4(BitmapInfoHeaderV4 { + bit_count, + color_used, + .. }) - | Self::V4(BitmapInfoHeaderV4 { bit_count, color_used, .. }) - | Self::V5(BitmapInfoHeaderV5 { bit_count, color_used, .. }) => { + | Self::V5(BitmapInfoHeaderV5 { + bit_count, + color_used, + .. + }) => { if *color_used == 0 && matches!( bit_count, @@ -159,9 +156,7 @@ impl BitmapInfoHeader { pub fn height(&self) -> usize { match self { - Self::Core(BitmapInfoHeaderCore { height, .. }) => { - usize::from(*height) - } + Self::Core(BitmapInfoHeaderCore { height, .. }) => usize::from(*height), Self::Info(BitmapInfoHeaderInfo { height, .. }) | Self::V4(BitmapInfoHeaderV4 { height, .. }) | Self::V5(BitmapInfoHeaderV5 { height, .. }) => *height as usize, @@ -170,9 +165,7 @@ impl BitmapInfoHeader { pub fn width(&self) -> usize { match self { - Self::Core(BitmapInfoHeaderCore { width, .. }) => { - usize::from(*width) - } + Self::Core(BitmapInfoHeaderCore { width, .. }) => usize::from(*width), Self::Info(BitmapInfoHeaderInfo { width, .. }) | Self::V4(BitmapInfoHeaderV4 { width, .. }) | Self::V5(BitmapInfoHeaderV5 { width, .. }) => *width as usize, diff --git a/src/wmf/parser/objects/structure/bitmap_info_header/v5.rs b/src/wmf/parser/objects/structure/bitmap_info_header/v5.rs index df7ae849..d77d0c11 100644 --- a/src/wmf/parser/objects/structure/bitmap_info_header/v5.rs +++ b/src/wmf/parser/objects/structure/bitmap_info_header/v5.rs @@ -185,11 +185,8 @@ impl BitmapInfoHeaderV5 { crate::wmf::parser::read_u32_from_le_bytes(buf)?, crate::wmf::parser::read_u32_from_le_bytes(buf)?, ); - let consumed_bytes = profile_data_bytes - + profile_size_bytes - + reserved_bytes - + header_bytes - + intent_bytes; + let consumed_bytes = + profile_data_bytes + profile_size_bytes + reserved_bytes + header_bytes + intent_bytes; let crate::wmf::parser::BitmapInfoHeaderV4 { header_size, diff --git a/src/wmf/parser/objects/structure/color_ref.rs b/src/wmf/parser/objects/structure/color_ref.rs index c920c4bb..438ad662 100644 --- a/src/wmf/parser/objects/structure/color_ref.rs +++ b/src/wmf/parser/objects/structure/color_ref.rs @@ -46,7 +46,12 @@ impl ColorRef { } Ok(( - Self { red, green, blue, reserved }, + Self { + red, + green, + blue, + reserved, + }, red_bytes + green_bytes + blue_bytes + reserved_bytes, )) } @@ -54,10 +59,20 @@ impl ColorRef { impl ColorRef { pub fn black() -> Self { - Self { red: 0, green: 0, blue: 0, reserved: 0 } + Self { + red: 0, + green: 0, + blue: 0, + reserved: 0, + } } pub fn white() -> Self { - Self { red: 255, green: 255, blue: 255, reserved: 0 } + Self { + red: 255, + green: 255, + blue: 255, + reserved: 0, + } } } diff --git a/src/wmf/parser/objects/structure/device_independent_bitmap.rs b/src/wmf/parser/objects/structure/device_independent_bitmap.rs index e58e12ab..9b2af64e 100644 --- a/src/wmf/parser/objects/structure/device_independent_bitmap.rs +++ b/src/wmf/parser/objects/structure/device_independent_bitmap.rs @@ -42,15 +42,17 @@ impl DeviceIndependentBitmap { // TODO: Not written in [MS-WMF] how to parse this field. let undefined_space = vec![]; - let (a_data, c) = - crate::wmf::parser::read_variable(buf, dib_header_info.size())?; + let (a_data, c) = crate::wmf::parser::read_variable(buf, dib_header_info.size())?; consumed_bytes += c; Ok(( Self { dib_header_info, colors, - bitmap_buffer: BitmapBuffer { undefined_space, a_data }, + bitmap_buffer: BitmapBuffer { + undefined_space, + a_data, + }, }, consumed_bytes, )) @@ -84,11 +86,7 @@ impl Colors { dib_header_info, crate::wmf::parser::BitmapInfoHeader::Core { .. } ) { - return Self::parse_with_core_header( - buf, - color_usage, - dib_header_info, - ); + return Self::parse_with_core_header(buf, color_usage, dib_header_info); } Self::parse_with_info_header(buf, color_usage, dib_header_info) @@ -136,14 +134,12 @@ impl Colors { crate::wmf::parser::BitCount::BI_BITCOUNT_0 => unreachable!(), crate::wmf::parser::BitCount::BI_BITCOUNT_1 | crate::wmf::parser::BitCount::BI_BITCOUNT_2 - | crate::wmf::parser::BitCount::BI_BITCOUNT_3 => { - Self::parse_from_color_usage( - buf, - color_usage, - dib_header_info.color_used() as usize, - false, - ) - } + | crate::wmf::parser::BitCount::BI_BITCOUNT_3 => Self::parse_from_color_usage( + buf, + color_usage, + dib_header_info.color_used() as usize, + false, + ), crate::wmf::parser::BitCount::BI_BITCOUNT_5 => { // ignore result let (_, bytes) = Self::parse_from_color_usage( @@ -161,19 +157,13 @@ impl Colors { match &dib_header_info { crate::wmf::parser::BitmapInfoHeader::Core(_) => unreachable!(), crate::wmf::parser::BitmapInfoHeader::Info( - crate::wmf::parser::BitmapInfoHeaderInfo { - compression, .. - }, + crate::wmf::parser::BitmapInfoHeaderInfo { compression, .. }, ) | crate::wmf::parser::BitmapInfoHeader::V4( - crate::wmf::parser::BitmapInfoHeaderV4 { - compression, .. - }, + crate::wmf::parser::BitmapInfoHeaderV4 { compression, .. }, ) | crate::wmf::parser::BitmapInfoHeader::V5( - crate::wmf::parser::BitmapInfoHeaderV5 { - compression, .. - }, + crate::wmf::parser::BitmapInfoHeaderV5 { compression, .. }, ) => match compression { crate::wmf::parser::Compression::BI_RGB => { // ignore result diff --git a/src/wmf/parser/objects/structure/log_brush.rs b/src/wmf/parser/objects/structure/log_brush.rs index cbdf7926..d6b51079 100644 --- a/src/wmf/parser/objects/structure/log_brush.rs +++ b/src/wmf/parser/objects/structure/log_brush.rs @@ -25,8 +25,7 @@ impl LogBrush { pub fn parse( buf: &mut R, ) -> Result<(Self, usize), crate::wmf::parser::ParseError> { - let (style, mut consumed_bytes) = - crate::wmf::parser::BrushStyle::parse(buf)?; + let (style, mut consumed_bytes) = crate::wmf::parser::BrushStyle::parse(buf)?; let v = match style { crate::wmf::parser::BrushStyle::BS_DIBPATTERN => { let (_, c) = crate::wmf::parser::read::(buf)?; @@ -41,16 +40,16 @@ impl LogBrush { Self::DIBPatternPT } crate::wmf::parser::BrushStyle::BS_HATCHED => { - let ( - (color_ref, color_ref_bytes), - (brush_hatch, brush_hatch_bytes), - ) = ( + let ((color_ref, color_ref_bytes), (brush_hatch, brush_hatch_bytes)) = ( crate::wmf::parser::ColorRef::parse(buf)?, crate::wmf::parser::HatchStyle::parse(buf)?, ); consumed_bytes += color_ref_bytes + brush_hatch_bytes; - Self::Hatched { color_ref, brush_hatch } + Self::Hatched { + color_ref, + brush_hatch, + } } crate::wmf::parser::BrushStyle::BS_PATTERN => { let (_, c) = crate::wmf::parser::read::(buf)?; diff --git a/src/wmf/parser/objects/structure/log_color_space.rs b/src/wmf/parser/objects/structure/log_color_space.rs index 78424b77..0d18a57f 100644 --- a/src/wmf/parser/objects/structure/log_color_space.rs +++ b/src/wmf/parser/objects/structure/log_color_space.rs @@ -91,8 +91,7 @@ impl LogColorSpace { + gamma_blue_bytes; let filename = if size as usize - consumed_bytes >= 260 { - let (bytes, filename_bytes) = - crate::wmf::parser::read_variable(buf, 260)?; + let (bytes, filename_bytes) = crate::wmf::parser::read_variable(buf, 260)?; consumed_bytes += filename_bytes; Some(String::from_utf8_lossy(&bytes).to_string()) diff --git a/src/wmf/parser/objects/structure/log_color_space_w.rs b/src/wmf/parser/objects/structure/log_color_space_w.rs index e5c22989..59d069b9 100644 --- a/src/wmf/parser/objects/structure/log_color_space_w.rs +++ b/src/wmf/parser/objects/structure/log_color_space_w.rs @@ -91,14 +91,11 @@ impl LogColorSpaceW { + gamma_blue_bytes; let filename = if size as usize - consumed_bytes >= 520 { - let (bytes, filename_bytes) = - crate::wmf::parser::read_variable(buf, 520)?; + let (bytes, filename_bytes) = crate::wmf::parser::read_variable(buf, 520)?; consumed_bytes += filename_bytes; let len = bytes.iter().position(|&c| c == 0).unwrap_or(bytes.len()); - Some(crate::wmf::parser::objects::structure::utf16le_bytes_to_string( - &bytes[..len], - )?) + Some(crate::wmf::parser::objects::structure::utf16le_bytes_to_string(&bytes[..len])?) } else { None }; diff --git a/src/wmf/parser/objects/structure/mod.rs b/src/wmf/parser/objects/structure/mod.rs index e6ddc749..c0807315 100644 --- a/src/wmf/parser/objects/structure/mod.rs +++ b/src/wmf/parser/objects/structure/mod.rs @@ -23,18 +23,15 @@ mod scan; mod size_l; pub use self::{ - bitmap16::*, bitmap_info_header::*, ciexyz::*, ciexyz_triple::*, - color_ref::*, device_independent_bitmap::*, log_brush::*, - log_color_space::*, log_color_space_w::*, palette_entry::*, - pitch_and_family::*, point_l::*, point_s::*, poly_polygon::*, rect::*, + bitmap16::*, bitmap_info_header::*, ciexyz::*, ciexyz_triple::*, color_ref::*, + device_independent_bitmap::*, log_brush::*, log_color_space::*, log_color_space_w::*, + palette_entry::*, pitch_and_family::*, point_l::*, point_s::*, poly_polygon::*, rect::*, rect_l::*, rgb_quad::*, rgb_triple::*, scan::*, size_l::*, }; use crate::wmf::imports::*; /// Convert UTF16-LE bytes to String. -fn utf16le_bytes_to_string( - bytes: &[u8], -) -> Result { +fn utf16le_bytes_to_string(bytes: &[u8]) -> Result { if !bytes.len().is_multiple_of(2) { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { cause: "Byte array length must be even".to_owned(), @@ -43,12 +40,10 @@ fn utf16le_bytes_to_string( let u16_vec = bytes .chunks_exact(2) - .map(|chunk| { - u16::from_le_bytes(chunk.try_into().expect("should be converted")) - }) + .map(|chunk| u16::from_le_bytes(chunk.try_into().expect("should be converted"))) .collect::>(); - String::from_utf16(&u16_vec).map_err(|err| { - crate::wmf::parser::ParseError::UnexpectedPattern { cause: err.to_string() } + String::from_utf16(&u16_vec).map_err(|err| crate::wmf::parser::ParseError::UnexpectedPattern { + cause: err.to_string(), }) } diff --git a/src/wmf/parser/objects/structure/palette_entry.rs b/src/wmf/parser/objects/structure/palette_entry.rs index 23dc0a9d..c44c385b 100644 --- a/src/wmf/parser/objects/structure/palette_entry.rs +++ b/src/wmf/parser/objects/structure/palette_entry.rs @@ -26,31 +26,33 @@ impl PaletteEntry { pub fn parse( buf: &mut R, ) -> Result<(Self, usize), crate::wmf::parser::ParseError> { - let ( - (red, red_bytes), - (green, green_bytes), - (blue, blue_bytes), - (values, values_bytes), - ) = ( + let ((red, red_bytes), (green, green_bytes), (blue, blue_bytes), (values, values_bytes)) = ( crate::wmf::parser::read_u8_from_le_bytes(buf)?, crate::wmf::parser::read_u8_from_le_bytes(buf)?, crate::wmf::parser::read_u8_from_le_bytes(buf)?, crate::wmf::parser::read_u8_from_le_bytes(buf)?, ); - let consumed_bytes = - red_bytes + green_bytes + blue_bytes + values_bytes; + let consumed_bytes = red_bytes + green_bytes + blue_bytes + values_bytes; let values = match values { 0x00 => None, - v => { - Some(crate::wmf::parser::PaletteEntryFlag::from_repr(v).ok_or_else( - || crate::wmf::parser::ParseError::UnexpectedEnumValue { + v => Some( + crate::wmf::parser::PaletteEntryFlag::from_repr(v).ok_or_else(|| { + crate::wmf::parser::ParseError::UnexpectedEnumValue { cause: format!("invalid value {v} as PaletteEntryFlag"), - }, - )?) - } + } + })?, + ), }; - Ok((Self { red, green, blue, values }, consumed_bytes)) + Ok(( + Self { + red, + green, + blue, + values, + }, + consumed_bytes, + )) } } diff --git a/src/wmf/parser/objects/structure/pitch_and_family.rs b/src/wmf/parser/objects/structure/pitch_and_family.rs index 6a67f98a..15e0e36a 100644 --- a/src/wmf/parser/objects/structure/pitch_and_family.rs +++ b/src/wmf/parser/objects/structure/pitch_and_family.rs @@ -23,8 +23,7 @@ impl PitchAndFamily { let (byte, consumed_bytes) = crate::wmf::parser::read_u8_from_le_bytes(buf)?; let family = byte >> 4; - let Some(family) = crate::wmf::parser::FamilyFont::from_repr(byte >> 4) - else { + let Some(family) = crate::wmf::parser::FamilyFont::from_repr(byte >> 4) else { return Err(crate::wmf::parser::ParseError::UnexpectedEnumValue { cause: format!("unexpected value as FamilyFont: {family:#04X}"), }); diff --git a/src/wmf/parser/objects/structure/poly_polygon.rs b/src/wmf/parser/objects/structure/poly_polygon.rs index 8f48b899..f756b68c 100644 --- a/src/wmf/parser/objects/structure/poly_polygon.rs +++ b/src/wmf/parser/objects/structure/poly_polygon.rs @@ -28,8 +28,7 @@ impl PolyPolygon { let (number_of_polygons, mut consumed_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; let mut number_of_points = 0; - let mut a_points_per_polygon = - Vec::with_capacity(number_of_polygons as usize); + let mut a_points_per_polygon = Vec::with_capacity(number_of_polygons as usize); let mut a_points = Vec::with_capacity(number_of_points as usize); for _ in 0..number_of_polygons { @@ -48,7 +47,11 @@ impl PolyPolygon { } Ok(( - Self { number_of_polygons, a_points_per_polygon, a_points }, + Self { + number_of_polygons, + a_points_per_polygon, + a_points, + }, consumed_bytes, )) } diff --git a/src/wmf/parser/objects/structure/rect.rs b/src/wmf/parser/objects/structure/rect.rs index e56437fc..c6a2f164 100644 --- a/src/wmf/parser/objects/structure/rect.rs +++ b/src/wmf/parser/objects/structure/rect.rs @@ -25,12 +25,7 @@ impl Rect { pub fn parse( buf: &mut R, ) -> Result<(Self, usize), crate::wmf::parser::ParseError> { - let ( - (left, left_bytes), - (top, top_bytes), - (right, right_bytes), - (bottom, bottom_bytes), - ) = ( + let ((left, left_bytes), (top, top_bytes), (right, right_bytes), (bottom, bottom_bytes)) = ( crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, @@ -38,7 +33,12 @@ impl Rect { ); Ok(( - Self { left, top, right, bottom }, + Self { + left, + top, + right, + bottom, + }, left_bytes + top_bytes + right_bytes + bottom_bytes, )) } @@ -50,7 +50,12 @@ impl Rect { let bottom = self.bottom.max(other.bottom); if left < right && bottom < top { - Some(Rect { left, top, right, bottom }) + Some(Rect { + left, + top, + right, + bottom, + }) } else { None } diff --git a/src/wmf/parser/objects/structure/rect_l.rs b/src/wmf/parser/objects/structure/rect_l.rs index a8b77af2..d7a73214 100644 --- a/src/wmf/parser/objects/structure/rect_l.rs +++ b/src/wmf/parser/objects/structure/rect_l.rs @@ -27,12 +27,7 @@ impl RectL { pub fn parse( buf: &mut R, ) -> Result<(Self, usize), crate::wmf::parser::ParseError> { - let ( - (left, left_bytes), - (top, top_bytes), - (right, right_bytes), - (bottom, bottom_bytes), - ) = ( + let ((left, left_bytes), (top, top_bytes), (right, right_bytes), (bottom, bottom_bytes)) = ( crate::wmf::parser::read_i32_from_le_bytes(buf)?, crate::wmf::parser::read_i32_from_le_bytes(buf)?, crate::wmf::parser::read_i32_from_le_bytes(buf)?, @@ -40,7 +35,12 @@ impl RectL { ); Ok(( - Self { left, top, right, bottom }, + Self { + left, + top, + right, + bottom, + }, left_bytes + top_bytes + right_bytes + bottom_bytes, )) } diff --git a/src/wmf/parser/objects/structure/rgb_quad.rs b/src/wmf/parser/objects/structure/rgb_quad.rs index c6ae86a7..d3ec6269 100644 --- a/src/wmf/parser/objects/structure/rgb_quad.rs +++ b/src/wmf/parser/objects/structure/rgb_quad.rs @@ -45,7 +45,12 @@ impl RGBQuad { } Ok(( - Self { red, green, blue, reserved }, + Self { + red, + green, + blue, + reserved, + }, red_bytes + green_bytes + blue_bytes + reserved_bytes, )) } diff --git a/src/wmf/parser/objects/structure/rgb_triple.rs b/src/wmf/parser/objects/structure/rgb_triple.rs index 32d3ad92..0469cd17 100644 --- a/src/wmf/parser/objects/structure/rgb_triple.rs +++ b/src/wmf/parser/objects/structure/rgb_triple.rs @@ -28,6 +28,9 @@ impl RGBTriple { crate::wmf::parser::read_u8_from_le_bytes(buf)?, ); - Ok((Self { red, green, blue }, red_bytes + green_bytes + blue_bytes)) + Ok(( + Self { red, green, blue }, + red_bytes + green_bytes + blue_bytes, + )) } } diff --git a/src/wmf/parser/objects/structure/scan.rs b/src/wmf/parser/objects/structure/scan.rs index 3692d3aa..df4ba6f1 100644 --- a/src/wmf/parser/objects/structure/scan.rs +++ b/src/wmf/parser/objects/structure/scan.rs @@ -68,7 +68,16 @@ impl Scan { }); } - Ok((Self { count, top, bottom, scan_lines, count2 }, consumed_bytes)) + Ok(( + Self { + count, + top, + bottom, + scan_lines, + count2, + }, + consumed_bytes, + )) } } diff --git a/src/wmf/parser/records/bitmap/bit_blt.rs b/src/wmf/parser/records/bitmap/bit_blt.rs index c0a25c03..fa997dfe 100644 --- a/src/wmf/parser/records/bitmap/bit_blt.rs +++ b/src/wmf/parser/records/bitmap/bit_blt.rs @@ -128,8 +128,7 @@ impl META_BITBLT { ); record_size.consume(raster_operation_bytes + y_src_bytes + x_src_bytes); - let bitmap_specified = - u32::from(record_size) != u32::from((record_function >> 8) + 3); + let bitmap_specified = u32::from(record_size) != u32::from((record_function >> 8) + 3); let reserved = if bitmap_specified { [0; 2] } else { @@ -148,8 +147,7 @@ impl META_BITBLT { crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, ); - record_size - .consume(height_bytes + width_bytes + y_dest_bytes + x_dest_bytes); + record_size.consume(height_bytes + width_bytes + y_dest_bytes + x_dest_bytes); let record = if bitmap_specified { let (target, c) = crate::wmf::parser::Bitmap16::parse(buf)?; diff --git a/src/wmf/parser/records/bitmap/dib_bit_blt.rs b/src/wmf/parser/records/bitmap/dib_bit_blt.rs index 8d7d1376..fa7838eb 100644 --- a/src/wmf/parser/records/bitmap/dib_bit_blt.rs +++ b/src/wmf/parser/records/bitmap/dib_bit_blt.rs @@ -132,8 +132,7 @@ impl META_DIBBITBLT { ); record_size.consume(raster_operation_bytes + y_src_bytes + x_src_bytes); - let bitmap_specified = - u32::from(record_size) != u32::from((record_function >> 8) + 3); + let bitmap_specified = u32::from(record_size) != u32::from((record_function >> 8) + 3); let reserved = if bitmap_specified { [0; 2] } else { @@ -152,15 +151,13 @@ impl META_DIBBITBLT { crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, ); - record_size - .consume(height_bytes + width_bytes + y_dest_bytes + x_dest_bytes); + record_size.consume(height_bytes + width_bytes + y_dest_bytes + x_dest_bytes); let record = if bitmap_specified { - let (target, c) = - crate::wmf::parser::DeviceIndependentBitmap::parse_with_color_usage( - buf, - crate::wmf::parser::ColorUsage::DIB_PAL_INDICES, - )?; + let (target, c) = crate::wmf::parser::DeviceIndependentBitmap::parse_with_color_usage( + buf, + crate::wmf::parser::ColorUsage::DIB_PAL_INDICES, + )?; record_size.consume(c); Self::WithBitmap { diff --git a/src/wmf/parser/records/bitmap/dib_stretch_blt.rs b/src/wmf/parser/records/bitmap/dib_stretch_blt.rs index 56a4cf63..7135dac9 100644 --- a/src/wmf/parser/records/bitmap/dib_stretch_blt.rs +++ b/src/wmf/parser/records/bitmap/dib_stretch_blt.rs @@ -152,15 +152,10 @@ impl META_DIBSTRETCHBLT { crate::wmf::parser::read_i16_from_le_bytes(buf)?, ); record_size.consume( - raster_operation_bytes - + src_height_bytes - + src_width_bytes - + y_src_bytes - + x_src_bytes, + raster_operation_bytes + src_height_bytes + src_width_bytes + y_src_bytes + x_src_bytes, ); - let bitmap_specified = - u32::from(record_size) != u32::from((record_function >> 8) + 3); + let bitmap_specified = u32::from(record_size) != u32::from((record_function >> 8) + 3); let reserved = if bitmap_specified { [0; 2] } else { @@ -179,16 +174,13 @@ impl META_DIBSTRETCHBLT { crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, ); - record_size.consume( - dest_height_bytes + dest_width_bytes + y_dest_bytes + x_dest_bytes, - ); + record_size.consume(dest_height_bytes + dest_width_bytes + y_dest_bytes + x_dest_bytes); let record = if bitmap_specified { - let (target, c) = - crate::wmf::parser::DeviceIndependentBitmap::parse_with_color_usage( - buf, - crate::wmf::parser::ColorUsage::DIB_PAL_INDICES, - )?; + let (target, c) = crate::wmf::parser::DeviceIndependentBitmap::parse_with_color_usage( + buf, + crate::wmf::parser::ColorUsage::DIB_PAL_INDICES, + )?; record_size.consume(c); Self::WithBitmap { diff --git a/src/wmf/parser/records/bitmap/mod.rs b/src/wmf/parser/records/bitmap/mod.rs index 955a2ecb..d363103d 100644 --- a/src/wmf/parser/records/bitmap/mod.rs +++ b/src/wmf/parser/records/bitmap/mod.rs @@ -9,6 +9,6 @@ mod stretch_blt; mod stretch_dib; pub use self::{ - bit_blt::*, dib_bit_blt::*, dib_stretch_blt::*, set_dib_to_dev::*, - stretch_blt::*, stretch_dib::*, + bit_blt::*, dib_bit_blt::*, dib_stretch_blt::*, set_dib_to_dev::*, stretch_blt::*, + stretch_dib::*, }; diff --git a/src/wmf/parser/records/bitmap/set_dib_to_dev.rs b/src/wmf/parser/records/bitmap/set_dib_to_dev.rs index f21fcbdd..00929072 100644 --- a/src/wmf/parser/records/bitmap/set_dib_to_dev.rs +++ b/src/wmf/parser/records/bitmap/set_dib_to_dev.rs @@ -101,10 +101,7 @@ impl META_SETDIBTODEV { ); let (dib, c) = - crate::wmf::parser::DeviceIndependentBitmap::parse_with_color_usage( - buf, - color_usage, - )?; + crate::wmf::parser::DeviceIndependentBitmap::parse_with_color_usage(buf, color_usage)?; record_size.consume(c); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; diff --git a/src/wmf/parser/records/bitmap/stretch_blt.rs b/src/wmf/parser/records/bitmap/stretch_blt.rs index b30f208d..f61ae867 100644 --- a/src/wmf/parser/records/bitmap/stretch_blt.rs +++ b/src/wmf/parser/records/bitmap/stretch_blt.rs @@ -149,15 +149,10 @@ impl META_STRETCHBLT { crate::wmf::parser::read_i16_from_le_bytes(buf)?, ); record_size.consume( - raster_operation_bytes - + src_height_bytes - + src_width_bytes - + y_src_bytes - + x_src_bytes, + raster_operation_bytes + src_height_bytes + src_width_bytes + y_src_bytes + x_src_bytes, ); - let bitmap_specified = - u32::from(record_size) != u32::from((record_function >> 8) + 3); + let bitmap_specified = u32::from(record_size) != u32::from((record_function >> 8) + 3); let reserved = if bitmap_specified { [0; 2] } else { @@ -176,9 +171,7 @@ impl META_STRETCHBLT { crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, ); - record_size.consume( - dest_height_bytes + dest_width_bytes + y_dest_bytes + x_dest_bytes, - ); + record_size.consume(dest_height_bytes + dest_width_bytes + y_dest_bytes + x_dest_bytes); let record = if bitmap_specified { let (target, c) = crate::wmf::parser::Bitmap16::parse(buf)?; diff --git a/src/wmf/parser/records/bitmap/stretch_dib.rs b/src/wmf/parser/records/bitmap/stretch_dib.rs index 78d78dd8..b6a5a070 100644 --- a/src/wmf/parser/records/bitmap/stretch_dib.rs +++ b/src/wmf/parser/records/bitmap/stretch_dib.rs @@ -115,10 +115,7 @@ impl META_STRETCHDIB { ); let (dib, c) = - crate::wmf::parser::DeviceIndependentBitmap::parse_with_color_usage( - buf, - color_usage, - )?; + crate::wmf::parser::DeviceIndependentBitmap::parse_with_color_usage(buf, color_usage)?; record_size.consume(c); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; diff --git a/src/wmf/parser/records/control/eof.rs b/src/wmf/parser/records/control/eof.rs index ce12c494..11838862 100644 --- a/src/wmf/parser/records/control/eof.rs +++ b/src/wmf/parser/records/control/eof.rs @@ -40,6 +40,9 @@ impl META_EOF { }); } - Ok(Self { record_size, record_function }) + Ok(Self { + record_size, + record_function, + }) } } diff --git a/src/wmf/parser/records/control/header.rs b/src/wmf/parser/records/control/header.rs index 572423cb..c0a8d897 100644 --- a/src/wmf/parser/records/control/header.rs +++ b/src/wmf/parser/records/control/header.rs @@ -77,8 +77,7 @@ impl META_HEADER { if number_of_members != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: "The number_of_members field should be `0x0000`" - .to_owned(), + cause: "The number_of_members field should be `0x0000`".to_owned(), }); } diff --git a/src/wmf/parser/records/control/mod.rs b/src/wmf/parser/records/control/mod.rs index cb163607..0488e515 100644 --- a/src/wmf/parser/records/control/mod.rs +++ b/src/wmf/parser/records/control/mod.rs @@ -25,8 +25,7 @@ impl MetafileHeader { pub fn parse( buf: &mut R, ) -> Result<(Self, usize), crate::wmf::parser::ParseError> { - let (mut key, mut consumed_bytes) = - crate::wmf::parser::read_u32_from_le_bytes(buf)?; + let (mut key, mut consumed_bytes) = crate::wmf::parser::read_u32_from_le_bytes(buf)?; let placeable = if key == 0x9AC6CDD7 { let (v, c) = crate::wmf::parser::META_PLACEABLE::parse(buf, key)?; diff --git a/src/wmf/parser/records/control/placeable.rs b/src/wmf/parser/records/control/placeable.rs index 18e4c47c..ed806d32 100644 --- a/src/wmf/parser/records/control/placeable.rs +++ b/src/wmf/parser/records/control/placeable.rs @@ -65,14 +65,18 @@ impl META_PLACEABLE { crate::wmf::parser::read_u32_from_le_bytes(buf)?, crate::wmf::parser::read::(buf)?, ); - let consumed_bytes = hwmf_bytes - + bounding_box_bytes - + inch_bytes - + reserved_bytes - + checksum_bytes; + let consumed_bytes = + hwmf_bytes + bounding_box_bytes + inch_bytes + reserved_bytes + checksum_bytes; Ok(( - Self { key, hwmf, bounding_box, inch, reserved, checksum }, + Self { + key, + hwmf, + bounding_box, + inch, + reserved, + checksum, + }, consumed_bytes, )) } diff --git a/src/wmf/parser/records/drawing/ellipse.rs b/src/wmf/parser/records/drawing/ellipse.rs index 5911b0bf..f7cfc855 100644 --- a/src/wmf/parser/records/drawing/ellipse.rs +++ b/src/wmf/parser/records/drawing/ellipse.rs @@ -61,12 +61,8 @@ impl META_ELLIPSE { crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, ); - record_size.consume( - bottom_rect_bytes - + right_rect_bytes - + top_rect_bytes - + left_rect_bytes, - ); + record_size + .consume(bottom_rect_bytes + right_rect_bytes + top_rect_bytes + left_rect_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; diff --git a/src/wmf/parser/records/drawing/ext_flood_fill.rs b/src/wmf/parser/records/drawing/ext_flood_fill.rs index b4867bee..a0e551c6 100644 --- a/src/wmf/parser/records/drawing/ext_flood_fill.rs +++ b/src/wmf/parser/records/drawing/ext_flood_fill.rs @@ -45,12 +45,7 @@ impl META_EXTFLOODFILL { crate::wmf::parser::RecordType::META_EXTFLOODFILL, )?; - let ( - (mode, mode_bytes), - (color_ref, color_ref_bytes), - (y, y_bytes), - (x, x_bytes), - ) = ( + let ((mode, mode_bytes), (color_ref, color_ref_bytes), (y, y_bytes), (x, x_bytes)) = ( crate::wmf::parser::FloodFill::parse(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, @@ -60,6 +55,13 @@ impl META_EXTFLOODFILL { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, mode, color_ref, y, x }) + Ok(Self { + record_size, + record_function, + mode, + color_ref, + y, + x, + }) } } diff --git a/src/wmf/parser/records/drawing/ext_text_out.rs b/src/wmf/parser/records/drawing/ext_text_out.rs index c5db8f60..752bad38 100644 --- a/src/wmf/parser/records/drawing/ext_text_out.rs +++ b/src/wmf/parser/records/drawing/ext_text_out.rs @@ -106,8 +106,7 @@ impl META_EXTTEXTOUT { fw_opts }; - let rectangle = if fw_opts - .contains(&crate::wmf::parser::ExtTextOutOptions::ETO_OPAQUE) + let rectangle = if fw_opts.contains(&crate::wmf::parser::ExtTextOutOptions::ETO_OPAQUE) || fw_opts.contains(&crate::wmf::parser::ExtTextOutOptions::ETO_CLIPPED) { let (v, c) = crate::wmf::parser::Rect::parse(buf)?; @@ -141,8 +140,7 @@ impl META_EXTTEXTOUT { } } - let (_, c) = - crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; + let (_, c) = crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; record_size.consume(c); Ok(Self { diff --git a/src/wmf/parser/records/drawing/fill_region.rs b/src/wmf/parser/records/drawing/fill_region.rs index 33dca6ae..68b0b0ba 100644 --- a/src/wmf/parser/records/drawing/fill_region.rs +++ b/src/wmf/parser/records/drawing/fill_region.rs @@ -45,6 +45,11 @@ impl META_FILLREGION { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, region, brush }) + Ok(Self { + record_size, + record_function, + region, + brush, + }) } } diff --git a/src/wmf/parser/records/drawing/flood_fill.rs b/src/wmf/parser/records/drawing/flood_fill.rs index 35ece1f0..6eb6fbab 100644 --- a/src/wmf/parser/records/drawing/flood_fill.rs +++ b/src/wmf/parser/records/drawing/flood_fill.rs @@ -41,11 +41,7 @@ impl META_FLOODFILL { crate::wmf::parser::RecordType::META_FLOODFILL, )?; - let ( - (color_ref, color_ref_bytes), - (y_start, y_start_bytes), - (x_start, x_start_bytes), - ) = ( + let ((color_ref, color_ref_bytes), (y_start, y_start_bytes), (x_start, x_start_bytes)) = ( crate::wmf::parser::ColorRef::parse(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, @@ -54,6 +50,12 @@ impl META_FLOODFILL { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, color_ref, y_start, x_start }) + Ok(Self { + record_size, + record_function, + color_ref, + y_start, + x_start, + }) } } diff --git a/src/wmf/parser/records/drawing/frame_region.rs b/src/wmf/parser/records/drawing/frame_region.rs index 562fe033..1274acb6 100644 --- a/src/wmf/parser/records/drawing/frame_region.rs +++ b/src/wmf/parser/records/drawing/frame_region.rs @@ -55,11 +55,17 @@ impl META_FRAMEREGION { crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, ); - record_size - .consume(region_bytes + brush_bytes + height_bytes + width_bytes); + record_size.consume(region_bytes + brush_bytes + height_bytes + width_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, region, brush, height, width }) + Ok(Self { + record_size, + record_function, + region, + brush, + height, + width, + }) } } diff --git a/src/wmf/parser/records/drawing/invert_region.rs b/src/wmf/parser/records/drawing/invert_region.rs index a72c2900..eb041b9b 100644 --- a/src/wmf/parser/records/drawing/invert_region.rs +++ b/src/wmf/parser/records/drawing/invert_region.rs @@ -35,12 +35,15 @@ impl META_INVERTREGION { crate::wmf::parser::RecordType::META_INVERTREGION, )?; - let (region, region_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (region, region_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(region_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, region }) + Ok(Self { + record_size, + record_function, + region, + }) } } diff --git a/src/wmf/parser/records/drawing/line_to.rs b/src/wmf/parser/records/drawing/line_to.rs index 9f885d06..8efafaf5 100644 --- a/src/wmf/parser/records/drawing/line_to.rs +++ b/src/wmf/parser/records/drawing/line_to.rs @@ -47,6 +47,11 @@ impl META_LINETO { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, y, x }) + Ok(Self { + record_size, + record_function, + y, + x, + }) } } diff --git a/src/wmf/parser/records/drawing/mod.rs b/src/wmf/parser/records/drawing/mod.rs index 2a097f30..c5d4be6f 100644 --- a/src/wmf/parser/records/drawing/mod.rs +++ b/src/wmf/parser/records/drawing/mod.rs @@ -23,9 +23,8 @@ mod set_pixel; mod text_out; pub use self::{ - arc::*, chord::*, ellipse::*, ext_flood_fill::*, ext_text_out::*, - fill_region::*, flood_fill::*, frame_region::*, invert_region::*, - line_to::*, paint_region::*, pat_blt::*, pie::*, poly_line::*, - poly_polygon::*, polygon::*, rectangle::*, round_rect::*, set_pixel::*, + arc::*, chord::*, ellipse::*, ext_flood_fill::*, ext_text_out::*, fill_region::*, + flood_fill::*, frame_region::*, invert_region::*, line_to::*, paint_region::*, pat_blt::*, + pie::*, poly_line::*, poly_polygon::*, polygon::*, rectangle::*, round_rect::*, set_pixel::*, text_out::*, }; diff --git a/src/wmf/parser/records/drawing/paint_region.rs b/src/wmf/parser/records/drawing/paint_region.rs index f28533e8..2b0af211 100644 --- a/src/wmf/parser/records/drawing/paint_region.rs +++ b/src/wmf/parser/records/drawing/paint_region.rs @@ -35,12 +35,15 @@ impl META_PAINTREGION { crate::wmf::parser::RecordType::META_PAINTREGION, )?; - let (region, region_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (region, region_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(region_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, region }) + Ok(Self { + record_size, + record_function, + region, + }) } } diff --git a/src/wmf/parser/records/drawing/pat_blt.rs b/src/wmf/parser/records/drawing/pat_blt.rs index 64323e72..54910dbc 100644 --- a/src/wmf/parser/records/drawing/pat_blt.rs +++ b/src/wmf/parser/records/drawing/pat_blt.rs @@ -65,11 +65,7 @@ impl META_PATBLT { crate::wmf::parser::read_i16_from_le_bytes(buf)?, ); record_size.consume( - raster_operation_bytes - + height_bytes - + width_bytes - + y_left_bytes - + x_left_bytes, + raster_operation_bytes + height_bytes + width_bytes + y_left_bytes + x_left_bytes, ); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; diff --git a/src/wmf/parser/records/drawing/poly_line.rs b/src/wmf/parser/records/drawing/poly_line.rs index 567af7eb..f119ff19 100644 --- a/src/wmf/parser/records/drawing/poly_line.rs +++ b/src/wmf/parser/records/drawing/poly_line.rs @@ -55,6 +55,11 @@ impl META_POLYLINE { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, number_of_points, a_points }) + Ok(Self { + record_size, + record_function, + number_of_points, + a_points, + }) } } diff --git a/src/wmf/parser/records/drawing/poly_polygon.rs b/src/wmf/parser/records/drawing/poly_polygon.rs index bd06b605..7995c9c9 100644 --- a/src/wmf/parser/records/drawing/poly_polygon.rs +++ b/src/wmf/parser/records/drawing/poly_polygon.rs @@ -37,12 +37,15 @@ impl META_POLYPOLYGON { crate::wmf::parser::RecordType::META_POLYPOLYGON, )?; - let (poly_polygon, poly_polygon_bytes) = - crate::wmf::parser::PolyPolygon::parse(buf)?; + let (poly_polygon, poly_polygon_bytes) = crate::wmf::parser::PolyPolygon::parse(buf)?; record_size.consume(poly_polygon_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, poly_polygon }) + Ok(Self { + record_size, + record_function, + poly_polygon, + }) } } diff --git a/src/wmf/parser/records/drawing/polygon.rs b/src/wmf/parser/records/drawing/polygon.rs index 0cfddb4d..b18f8de6 100644 --- a/src/wmf/parser/records/drawing/polygon.rs +++ b/src/wmf/parser/records/drawing/polygon.rs @@ -58,6 +58,11 @@ impl META_POLYGON { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, number_of_points, a_points }) + Ok(Self { + record_size, + record_function, + number_of_points, + a_points, + }) } } diff --git a/src/wmf/parser/records/drawing/rectangle.rs b/src/wmf/parser/records/drawing/rectangle.rs index 8ea4c31a..2b272142 100644 --- a/src/wmf/parser/records/drawing/rectangle.rs +++ b/src/wmf/parser/records/drawing/rectangle.rs @@ -60,12 +60,8 @@ impl META_RECTANGLE { crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, ); - record_size.consume( - bottom_rect_bytes - + right_rect_bytes - + top_rect_bytes - + left_rect_bytes, - ); + record_size + .consume(bottom_rect_bytes + right_rect_bytes + top_rect_bytes + left_rect_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; diff --git a/src/wmf/parser/records/drawing/set_pixel.rs b/src/wmf/parser/records/drawing/set_pixel.rs index 9c94f4c6..ddd9b8cd 100644 --- a/src/wmf/parser/records/drawing/set_pixel.rs +++ b/src/wmf/parser/records/drawing/set_pixel.rs @@ -49,6 +49,12 @@ impl META_SETPIXEL { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, color_ref, y, x }) + Ok(Self { + record_size, + record_function, + color_ref, + y, + x, + }) } } diff --git a/src/wmf/parser/records/drawing/text_out.rs b/src/wmf/parser/records/drawing/text_out.rs index d42fb167..9de89022 100644 --- a/src/wmf/parser/records/drawing/text_out.rs +++ b/src/wmf/parser/records/drawing/text_out.rs @@ -54,17 +54,12 @@ impl META_TEXTOUT { crate::wmf::parser::RecordType::META_TEXTOUT, )?; - let (string_length, string_length_bytes) = - crate::wmf::parser::read_i16_from_le_bytes(buf)?; + let (string_length, string_length_bytes) = crate::wmf::parser::read_i16_from_le_bytes(buf)?; record_size.consume(string_length_bytes); let string_len = string_length + (string_length % 2); - let ( - (string, string_bytes), - (y_start, y_start_bytes), - (x_start, x_start_bytes), - ) = ( + let ((string, string_bytes), (y_start, y_start_bytes), (x_start, x_start_bytes)) = ( crate::wmf::parser::read_variable(buf, string_len as usize)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, diff --git a/src/wmf/parser/records/escape/abort_doc.rs b/src/wmf/parser/records/escape/abort_doc.rs index 28a172c0..9c769154 100644 --- a/src/wmf/parser/records/escape/abort_doc.rs +++ b/src/wmf/parser/records/escape/abort_doc.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_ABORTDOC< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_ABORTDOC( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::ABORTDOC { record_size, record_function, byte_count }) + Ok(Self::ABORTDOC { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/begin_path.rs b/src/wmf/parser/records/escape/begin_path.rs index b76994ea..42085b2f 100644 --- a/src/wmf/parser/records/escape/begin_path.rs +++ b/src/wmf/parser/records/escape/begin_path.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_BEGIN_PATH< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_BEGIN_PATH( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::BEGIN_PATH { record_size, record_function, byte_count }) + Ok(Self::BEGIN_PATH { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/check_jpeg_format.rs b/src/wmf/parser/records/escape/check_jpeg_format.rs index 2a7e16fb..03f83fad 100644 --- a/src/wmf/parser/records/escape/check_jpeg_format.rs +++ b/src/wmf/parser/records/escape/check_jpeg_format.rs @@ -1,15 +1,11 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_CHECKJPEGFORMAT< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_CHECKJPEGFORMAT( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; - let (jpeg_buffer, c) = - crate::wmf::parser::read_variable(buf, byte_count as usize)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (jpeg_buffer, c) = crate::wmf::parser::read_variable(buf, byte_count as usize)?; record_size.consume(byte_count_bytes + c); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; diff --git a/src/wmf/parser/records/escape/check_png_format.rs b/src/wmf/parser/records/escape/check_png_format.rs index 0cc74523..0c65a501 100644 --- a/src/wmf/parser/records/escape/check_png_format.rs +++ b/src/wmf/parser/records/escape/check_png_format.rs @@ -1,15 +1,11 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_CHECKPNGFORMAT< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_CHECKPNGFORMAT( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; - let (png_buffer, c) = - crate::wmf::parser::read_variable(buf, byte_count as usize)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (png_buffer, c) = crate::wmf::parser::read_variable(buf, byte_count as usize)?; record_size.consume(byte_count_bytes + c); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; diff --git a/src/wmf/parser/records/escape/clip_to_path.rs b/src/wmf/parser/records/escape/clip_to_path.rs index e04ea6aa..fc4d4386 100644 --- a/src/wmf/parser/records/escape/clip_to_path.rs +++ b/src/wmf/parser/records/escape/clip_to_path.rs @@ -1,7 +1,5 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_CLIP_TO_PATH< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_CLIP_TO_PATH( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, @@ -15,22 +13,17 @@ impl crate::wmf::parser::META_ESCAPE { crate::wmf::parser::PostScriptClipping::parse(buf)?, crate::wmf::parser::read_u16_from_le_bytes(buf)?, ); - record_size - .consume(byte_count_bytes + clip_function_bytes + reserved1_bytes); + record_size.consume(byte_count_bytes + clip_function_bytes + reserved1_bytes); if byte_count != 0x0004 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0004`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0004`",), }); } if reserved1 != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The reserved1 `{reserved1:#06X}` field should be `0x0000`", - ), + cause: format!("The reserved1 `{reserved1:#06X}` field should be `0x0000`",), }); } diff --git a/src/wmf/parser/records/escape/close_channel.rs b/src/wmf/parser/records/escape/close_channel.rs index 3ee9a915..bbea4b74 100644 --- a/src/wmf/parser/records/escape/close_channel.rs +++ b/src/wmf/parser/records/escape/close_channel.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_CLOSECHANNEL< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_CLOSECHANNEL( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::CLOSECHANNEL { record_size, record_function, byte_count }) + Ok(Self::CLOSECHANNEL { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/download_face.rs b/src/wmf/parser/records/escape/download_face.rs index 2d56b3ae..1ac36a5f 100644 --- a/src/wmf/parser/records/escape/download_face.rs +++ b/src/wmf/parser/records/escape/download_face.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_DOWNLOADFACE< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_DOWNLOADFACE( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::DOWNLOADFACE { record_size, record_function, byte_count }) + Ok(Self::DOWNLOADFACE { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/download_header.rs b/src/wmf/parser/records/escape/download_header.rs index 345951d0..02df92b2 100644 --- a/src/wmf/parser/records/escape/download_header.rs +++ b/src/wmf/parser/records/escape/download_header.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_DOWNLOADHEADER< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_DOWNLOADHEADER( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::DOWNLOADHEADER { record_size, record_function, byte_count }) + Ok(Self::DOWNLOADHEADER { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/draw_pattern_rect.rs b/src/wmf/parser/records/escape/draw_pattern_rect.rs index fa7eef92..869fb694 100644 --- a/src/wmf/parser/records/escape/draw_pattern_rect.rs +++ b/src/wmf/parser/records/escape/draw_pattern_rect.rs @@ -1,7 +1,5 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_DRAWPATTERNRECT< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_DRAWPATTERNRECT( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, @@ -19,19 +17,12 @@ impl crate::wmf::parser::META_ESCAPE { crate::wmf::parser::read_u16_from_le_bytes(buf)?, crate::wmf::parser::read_u16_from_le_bytes(buf)?, ); - record_size.consume( - byte_count_bytes - + position_bytes - + size_bytes - + style_bytes - + pattern_bytes, - ); + record_size + .consume(byte_count_bytes + position_bytes + size_bytes + style_bytes + pattern_bytes); if byte_count != 0x0014 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0014`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0014`",), }); } diff --git a/src/wmf/parser/records/escape/encapsulated_postscript.rs b/src/wmf/parser/records/escape/encapsulated_postscript.rs index 626ea523..9a059fea 100644 --- a/src/wmf/parser/records/escape/encapsulated_postscript.rs +++ b/src/wmf/parser/records/escape/encapsulated_postscript.rs @@ -17,9 +17,7 @@ impl crate::wmf::parser::META_ESCAPE { crate::wmf::parser::read_u32_from_le_bytes(buf)?, crate::wmf::parser::PointL::parse(buf)?, ); - record_size.consume( - byte_count_bytes + size_bytes + version_bytes + points_bytes, - ); + record_size.consume(byte_count_bytes + size_bytes + version_bytes + points_bytes); if u32::from(byte_count) < size { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { @@ -35,8 +33,7 @@ impl crate::wmf::parser::META_ESCAPE { .expect("should be convert u32") + 4 + 4); - let (data, c) = - crate::wmf::parser::read_variable(buf, data_length as usize)?; + let (data, c) = crate::wmf::parser::read_variable(buf, data_length as usize)?; record_size.consume(c); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; diff --git a/src/wmf/parser/records/escape/end_doc.rs b/src/wmf/parser/records/escape/end_doc.rs index 529cf249..0d6c9805 100644 --- a/src/wmf/parser/records/escape/end_doc.rs +++ b/src/wmf/parser/records/escape/end_doc.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_ENDDOC< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_ENDDOC( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::ENDDOC { record_size, record_function, byte_count }) + Ok(Self::ENDDOC { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/end_path.rs b/src/wmf/parser/records/escape/end_path.rs index 74052c84..73a791e1 100644 --- a/src/wmf/parser/records/escape/end_path.rs +++ b/src/wmf/parser/records/escape/end_path.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_END_PATH< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_END_PATH( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::END_PATH { record_size, record_function, byte_count }) + Ok(Self::END_PATH { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/eps_printing.rs b/src/wmf/parser/records/escape/eps_printing.rs index 798ad1a0..3e4582cc 100644 --- a/src/wmf/parser/records/escape/eps_printing.rs +++ b/src/wmf/parser/records/escape/eps_printing.rs @@ -1,15 +1,10 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_EPSPRINTING< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_EPSPRINTING( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let ( - (byte_count, byte_count_bytes), - (set_eps_printing, set_eps_printing_bytes), - ) = ( + let ((byte_count, byte_count_bytes), (set_eps_printing, set_eps_printing_bytes)) = ( crate::wmf::parser::read_u16_from_le_bytes(buf)?, crate::wmf::parser::read_u16_from_le_bytes(buf)?, ); @@ -17,9 +12,7 @@ impl crate::wmf::parser::META_ESCAPE { if byte_count != 0x0002 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0002`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0002`",), }); } diff --git a/src/wmf/parser/records/escape/ext_text_out.rs b/src/wmf/parser/records/escape/ext_text_out.rs index a444386b..8b2e4388 100644 --- a/src/wmf/parser/records/escape/ext_text_out.rs +++ b/src/wmf/parser/records/escape/ext_text_out.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_EXTTEXTOUT< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_EXTTEXTOUT( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::EXTTEXTOUT { record_size, record_function, byte_count }) + Ok(Self::EXTTEXTOUT { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/get_color_table.rs b/src/wmf/parser/records/escape/get_color_table.rs index 48f95f73..fdcc9e0f 100644 --- a/src/wmf/parser/records/escape/get_color_table.rs +++ b/src/wmf/parser/records/escape/get_color_table.rs @@ -1,7 +1,5 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_GETCOLORTABLE< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_GETCOLORTABLE( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, diff --git a/src/wmf/parser/records/escape/get_device_units.rs b/src/wmf/parser/records/escape/get_device_units.rs index 10c71e03..3996c412 100644 --- a/src/wmf/parser/records/escape/get_device_units.rs +++ b/src/wmf/parser/records/escape/get_device_units.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_GETDEVICEUNITS< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_GETDEVICEUNITS( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::GETDEVICEUNITS { record_size, record_function, byte_count }) + Ok(Self::GETDEVICEUNITS { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/get_extended_text_metrics.rs b/src/wmf/parser/records/escape/get_extended_text_metrics.rs index bdd7e78c..377a7aa6 100644 --- a/src/wmf/parser/records/escape/get_extended_text_metrics.rs +++ b/src/wmf/parser/records/escape/get_extended_text_metrics.rs @@ -6,15 +6,12 @@ impl crate::wmf::parser::META_ESCAPE { mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } diff --git a/src/wmf/parser/records/escape/get_facename.rs b/src/wmf/parser/records/escape/get_facename.rs index 7169e8f8..ab1fd215 100644 --- a/src/wmf/parser/records/escape/get_facename.rs +++ b/src/wmf/parser/records/escape/get_facename.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_GETFACENAME< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_GETFACENAME( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::GETFACENAME { record_size, record_function, byte_count }) + Ok(Self::GETFACENAME { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/get_pair_kern_table.rs b/src/wmf/parser/records/escape/get_pair_kern_table.rs index c4d8b4f3..aa1d029d 100644 --- a/src/wmf/parser/records/escape/get_pair_kern_table.rs +++ b/src/wmf/parser/records/escape/get_pair_kern_table.rs @@ -6,20 +6,21 @@ impl crate::wmf::parser::META_ESCAPE { mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::GETPAIRKERNTABLE { record_size, record_function, byte_count }) + Ok(Self::GETPAIRKERNTABLE { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/get_phys_page_size.rs b/src/wmf/parser/records/escape/get_phys_page_size.rs index f1dcfc66..2b47b363 100644 --- a/src/wmf/parser/records/escape/get_phys_page_size.rs +++ b/src/wmf/parser/records/escape/get_phys_page_size.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_GETPHYSPAGESIZE< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_GETPHYSPAGESIZE( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::GETPHYSPAGESIZE { record_size, record_function, byte_count }) + Ok(Self::GETPHYSPAGESIZE { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/get_printing_offset.rs b/src/wmf/parser/records/escape/get_printing_offset.rs index d41d3a95..c1c3cf1b 100644 --- a/src/wmf/parser/records/escape/get_printing_offset.rs +++ b/src/wmf/parser/records/escape/get_printing_offset.rs @@ -6,20 +6,21 @@ impl crate::wmf::parser::META_ESCAPE { mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::GETPRINTINGOFFSET { record_size, record_function, byte_count }) + Ok(Self::GETPRINTINGOFFSET { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/get_ps_feature_setting.rs b/src/wmf/parser/records/escape/get_ps_feature_setting.rs index fd0e784d..2591e6a6 100644 --- a/src/wmf/parser/records/escape/get_ps_feature_setting.rs +++ b/src/wmf/parser/records/escape/get_ps_feature_setting.rs @@ -14,9 +14,7 @@ impl crate::wmf::parser::META_ESCAPE { if byte_count != 0x0004 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0004`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0004`",), }); } diff --git a/src/wmf/parser/records/escape/get_scaling_factor.rs b/src/wmf/parser/records/escape/get_scaling_factor.rs index 10d8accb..390682d8 100644 --- a/src/wmf/parser/records/escape/get_scaling_factor.rs +++ b/src/wmf/parser/records/escape/get_scaling_factor.rs @@ -6,20 +6,21 @@ impl crate::wmf::parser::META_ESCAPE { mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::GETSCALINGFACTOR { record_size, record_function, byte_count }) + Ok(Self::GETSCALINGFACTOR { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/meta_escape_enhanced_metafile.rs b/src/wmf/parser/records/escape/meta_escape_enhanced_metafile.rs index de983d03..c40d6e63 100644 --- a/src/wmf/parser/records/escape/meta_escape_enhanced_metafile.rs +++ b/src/wmf/parser/records/escape/meta_escape_enhanced_metafile.rs @@ -71,17 +71,13 @@ impl crate::wmf::parser::META_ESCAPE { if version != 0x00010000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The version `{version:#010X}` field must be `0x00010000`" - ), + cause: format!("The version `{version:#010X}` field must be `0x00010000`"), }); } if flags != 0x00000000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The flags `{version:#010X}` field must be `0x00000000`", - ), + cause: format!("The flags `{version:#010X}` field must be `0x00000000`",), }); } @@ -94,10 +90,8 @@ impl crate::wmf::parser::META_ESCAPE { }); } - let (enhanced_metafile_data, c) = crate::wmf::parser::read_variable( - buf, - enhanced_metafile_data_size as usize, - )?; + let (enhanced_metafile_data, c) = + crate::wmf::parser::read_variable(buf, enhanced_metafile_data_size as usize)?; record_size.consume(c); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; diff --git a/src/wmf/parser/records/escape/metafile_driver.rs b/src/wmf/parser/records/escape/metafile_driver.rs index 03afaeb7..ef8a5fc5 100644 --- a/src/wmf/parser/records/escape/metafile_driver.rs +++ b/src/wmf/parser/records/escape/metafile_driver.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_METAFILE_DRIVER< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_METAFILE_DRIVER( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::METAFILE_DRIVER { record_size, record_function, byte_count }) + Ok(Self::METAFILE_DRIVER { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/mod.rs b/src/wmf/parser/records/escape/mod.rs index b436c63b..6b3f6a99 100644 --- a/src/wmf/parser/records/escape/mod.rs +++ b/src/wmf/parser/records/escape/mod.rs @@ -850,8 +850,7 @@ impl META_ESCAPE { crate::wmf::parser::RecordType::META_ESCAPE, )?; - let (escape, escape_bytes) = - crate::wmf::parser::MetafileEscapes::parse(buf)?; + let (escape, escape_bytes) = crate::wmf::parser::MetafileEscapes::parse(buf)?; record_size.consume(escape_bytes); let record = match escape { @@ -862,18 +861,10 @@ impl META_ESCAPE { Self::parse_as_BEGIN_PATH(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::CHECKJPEGFORMAT => { - Self::parse_as_CHECKJPEGFORMAT( - buf, - record_size, - record_function, - )? + Self::parse_as_CHECKJPEGFORMAT(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::CHECKPNGFORMAT => { - Self::parse_as_CHECKPNGFORMAT( - buf, - record_size, - record_function, - )? + Self::parse_as_CHECKPNGFORMAT(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::CLIP_TO_PATH => { Self::parse_as_CLIP_TO_PATH(buf, record_size, record_function)? @@ -885,25 +876,13 @@ impl META_ESCAPE { Self::parse_as_DOWNLOADFACE(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::DOWNLOADHEADER => { - Self::parse_as_DOWNLOADHEADER( - buf, - record_size, - record_function, - )? + Self::parse_as_DOWNLOADHEADER(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::DRAWPATTERNRECT => { - Self::parse_as_DRAWPATTERNRECT( - buf, - record_size, - record_function, - )? + Self::parse_as_DRAWPATTERNRECT(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::ENCAPSULATED_POSTSCRIPT => { - Self::parse_as_ENCAPSULATED_POSTSCRIPT( - buf, - record_size, - record_function, - )? + Self::parse_as_ENCAPSULATED_POSTSCRIPT(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::END_PATH => { Self::parse_as_END_PATH(buf, record_size, record_function)? @@ -921,70 +900,34 @@ impl META_ESCAPE { Self::parse_as_GETCOLORTABLE(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::GETDEVICEUNITS => { - Self::parse_as_GETDEVICEUNITS( - buf, - record_size, - record_function, - )? + Self::parse_as_GETDEVICEUNITS(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::GETEXTENDEDTEXTMETRICS => { - Self::parse_as_GETEXTENDEDTEXTMETRICS( - buf, - record_size, - record_function, - )? + Self::parse_as_GETEXTENDEDTEXTMETRICS(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::GETFACENAME => { Self::parse_as_GETFACENAME(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::GETPAIRKERNTABLE => { - Self::parse_as_GETPAIRKERNTABLE( - buf, - record_size, - record_function, - )? + Self::parse_as_GETPAIRKERNTABLE(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::GETPHYSPAGESIZE => { - Self::parse_as_GETPHYSPAGESIZE( - buf, - record_size, - record_function, - )? + Self::parse_as_GETPHYSPAGESIZE(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::GETPRINTINGOFFSET => { - Self::parse_as_GETPRINTINGOFFSET( - buf, - record_size, - record_function, - )? + Self::parse_as_GETPRINTINGOFFSET(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::GET_PS_FEATURESETTING => { - Self::parse_as_GET_PS_FEATURESETTING( - buf, - record_size, - record_function, - )? + Self::parse_as_GET_PS_FEATURESETTING(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::GETSCALINGFACTOR => { - Self::parse_as_GETSCALINGFACTOR( - buf, - record_size, - record_function, - )? + Self::parse_as_GETSCALINGFACTOR(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::META_ESCAPE_ENHANCED_METAFILE => { - Self::parse_as_META_ESCAPE_ENHANCED_METAFILE( - buf, - record_size, - record_function, - )? + Self::parse_as_META_ESCAPE_ENHANCED_METAFILE(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::METAFILE_DRIVER => { - Self::parse_as_METAFILE_DRIVER( - buf, - record_size, - record_function, - )? + Self::parse_as_METAFILE_DRIVER(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::NEWFRAME => { Self::parse_as_NEWFRAME(buf, record_size, record_function)? @@ -996,56 +939,28 @@ impl META_ESCAPE { Self::parse_as_PASSTHROUGH(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::POSTSCRIPT_DATA => { - Self::parse_as_POSTSCRIPT_DATA( - buf, - record_size, - record_function, - )? + Self::parse_as_POSTSCRIPT_DATA(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::POSTSCRIPT_IDENTIFY => { - Self::parse_as_POSTSCRIPT_IDENTIFY( - buf, - record_size, - record_function, - )? + Self::parse_as_POSTSCRIPT_IDENTIFY(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::POSTSCRIPT_IGNORE => { - Self::parse_as_POSTSCRIPT_IGNORE( - buf, - record_size, - record_function, - )? + Self::parse_as_POSTSCRIPT_IGNORE(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::POSTSCRIPT_INJECTION => { - Self::parse_as_POSTSCRIPT_INJECTION( - buf, - record_size, - record_function, - )? + Self::parse_as_POSTSCRIPT_INJECTION(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::POSTSCRIPT_PASSTHROUGH => { - Self::parse_as_POSTSCRIPT_PASSTHROUGH( - buf, - record_size, - record_function, - )? + Self::parse_as_POSTSCRIPT_PASSTHROUGH(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::OPENCHANNEL => { Self::parse_as_OPENCHANNEL(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::QUERYDIBSUPPORT => { - Self::parse_as_QUERYDIBSUPPORT( - buf, - record_size, - record_function, - )? + Self::parse_as_QUERYDIBSUPPORT(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::QUERYESCSUPPORT => { - Self::parse_as_QUERYESCSUPPORT( - buf, - record_size, - record_function, - )? + Self::parse_as_QUERYESCSUPPORT(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::SETCOLORTABLE => { Self::parse_as_SETCOLORTABLE(buf, record_size, record_function)? @@ -1063,11 +978,7 @@ impl META_ESCAPE { Self::parse_as_SETMITERLIMIT(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::SPCLPASSTHROUGH2 => { - Self::parse_as_SPCLPASSTHROUGH2( - buf, - record_size, - record_function, - )? + Self::parse_as_SPCLPASSTHROUGH2(buf, record_size, record_function)? } crate::wmf::parser::MetafileEscapes::STARTDOC => { Self::parse_as_STARTDOC(buf, record_size, record_function)? diff --git a/src/wmf/parser/records/escape/new_frame.rs b/src/wmf/parser/records/escape/new_frame.rs index 3d492a17..aaa44114 100644 --- a/src/wmf/parser/records/escape/new_frame.rs +++ b/src/wmf/parser/records/escape/new_frame.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_NEWFRAME< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_NEWFRAME( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::NEWFRAME { record_size, record_function, byte_count }) + Ok(Self::NEWFRAME { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/next_band.rs b/src/wmf/parser/records/escape/next_band.rs index c6075db2..20afa4a4 100644 --- a/src/wmf/parser/records/escape/next_band.rs +++ b/src/wmf/parser/records/escape/next_band.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_NEXTBAND< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_NEXTBAND( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::NEXTBAND { record_size, record_function, byte_count }) + Ok(Self::NEXTBAND { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/open_channel.rs b/src/wmf/parser/records/escape/open_channel.rs index 3c96919e..33dc9281 100644 --- a/src/wmf/parser/records/escape/open_channel.rs +++ b/src/wmf/parser/records/escape/open_channel.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_OPENCHANNEL< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_OPENCHANNEL( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::OPENCHANNEL { record_size, record_function, byte_count }) + Ok(Self::OPENCHANNEL { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/passthrough.rs b/src/wmf/parser/records/escape/passthrough.rs index b3c14553..48220a2f 100644 --- a/src/wmf/parser/records/escape/passthrough.rs +++ b/src/wmf/parser/records/escape/passthrough.rs @@ -1,18 +1,20 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_PASSTHROUGH< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_PASSTHROUGH( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; let (data, c) = crate::wmf::parser::read_variable(buf, byte_count as usize)?; record_size.consume(byte_count_bytes + c); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::PASSTHROUGH { record_size, record_function, byte_count, data }) + Ok(Self::PASSTHROUGH { + record_size, + record_function, + byte_count, + data, + }) } } diff --git a/src/wmf/parser/records/escape/postscript_data.rs b/src/wmf/parser/records/escape/postscript_data.rs index 2a6f1f42..b18bae28 100644 --- a/src/wmf/parser/records/escape/postscript_data.rs +++ b/src/wmf/parser/records/escape/postscript_data.rs @@ -1,13 +1,10 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_POSTSCRIPT_DATA< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_POSTSCRIPT_DATA( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; let (data, c) = crate::wmf::parser::read_variable(buf, byte_count as usize)?; record_size.consume(byte_count_bytes + c); diff --git a/src/wmf/parser/records/escape/postscript_identify.rs b/src/wmf/parser/records/escape/postscript_identify.rs index 99b5f730..e4415db2 100644 --- a/src/wmf/parser/records/escape/postscript_identify.rs +++ b/src/wmf/parser/records/escape/postscript_identify.rs @@ -6,8 +6,7 @@ impl crate::wmf::parser::META_ESCAPE { mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; let (data, c) = crate::wmf::parser::read_variable(buf, byte_count as usize)?; record_size.consume(byte_count_bytes + c); diff --git a/src/wmf/parser/records/escape/postscript_ignore.rs b/src/wmf/parser/records/escape/postscript_ignore.rs index d22309d8..5621e184 100644 --- a/src/wmf/parser/records/escape/postscript_ignore.rs +++ b/src/wmf/parser/records/escape/postscript_ignore.rs @@ -6,20 +6,21 @@ impl crate::wmf::parser::META_ESCAPE { mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::POSTSCRIPT_IGNORE { record_size, record_function, byte_count }) + Ok(Self::POSTSCRIPT_IGNORE { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/postscript_injection.rs b/src/wmf/parser/records/escape/postscript_injection.rs index e4c09451..5153789c 100644 --- a/src/wmf/parser/records/escape/postscript_injection.rs +++ b/src/wmf/parser/records/escape/postscript_injection.rs @@ -6,8 +6,7 @@ impl crate::wmf::parser::META_ESCAPE { mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; let (data, c) = crate::wmf::parser::read_variable(buf, byte_count as usize)?; record_size.consume(byte_count_bytes + c); diff --git a/src/wmf/parser/records/escape/postscript_passthrough.rs b/src/wmf/parser/records/escape/postscript_passthrough.rs index 008cb296..b7282ec9 100644 --- a/src/wmf/parser/records/escape/postscript_passthrough.rs +++ b/src/wmf/parser/records/escape/postscript_passthrough.rs @@ -6,8 +6,7 @@ impl crate::wmf::parser::META_ESCAPE { mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; let (data, c) = crate::wmf::parser::read_variable(buf, byte_count as usize)?; record_size.consume(byte_count_bytes + c); diff --git a/src/wmf/parser/records/escape/query_dib_support.rs b/src/wmf/parser/records/escape/query_dib_support.rs index 1cfdaedd..bfd9a6f1 100644 --- a/src/wmf/parser/records/escape/query_dib_support.rs +++ b/src/wmf/parser/records/escape/query_dib_support.rs @@ -1,25 +1,24 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_QUERYDIBSUPPORT< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_QUERYDIBSUPPORT( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count != 0x0000 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0000`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0000`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::QUERYDIBSUPPORT { record_size, record_function, byte_count }) + Ok(Self::QUERYDIBSUPPORT { + record_size, + record_function, + byte_count, + }) } } diff --git a/src/wmf/parser/records/escape/query_esc_support.rs b/src/wmf/parser/records/escape/query_esc_support.rs index f92f3f03..0eeaf732 100644 --- a/src/wmf/parser/records/escape/query_esc_support.rs +++ b/src/wmf/parser/records/escape/query_esc_support.rs @@ -1,7 +1,5 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_QUERYESCSUPPORT< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_QUERYESCSUPPORT( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, @@ -14,9 +12,7 @@ impl crate::wmf::parser::META_ESCAPE { if byte_count != 0x0002 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0002`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0002`",), }); } diff --git a/src/wmf/parser/records/escape/set_color_table.rs b/src/wmf/parser/records/escape/set_color_table.rs index 8fb0ef18..6f980ddd 100644 --- a/src/wmf/parser/records/escape/set_color_table.rs +++ b/src/wmf/parser/records/escape/set_color_table.rs @@ -1,15 +1,11 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_SETCOLORTABLE< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_SETCOLORTABLE( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; - let (color_table, c) = - crate::wmf::parser::read_variable(buf, byte_count as usize)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (color_table, c) = crate::wmf::parser::read_variable(buf, byte_count as usize)?; record_size.consume(byte_count_bytes + c); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; diff --git a/src/wmf/parser/records/escape/set_copy_count.rs b/src/wmf/parser/records/escape/set_copy_count.rs index 03f9e1ac..c584c5b0 100644 --- a/src/wmf/parser/records/escape/set_copy_count.rs +++ b/src/wmf/parser/records/escape/set_copy_count.rs @@ -1,7 +1,5 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_SETCOPYCOUNT< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_SETCOPYCOUNT( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, @@ -14,9 +12,7 @@ impl crate::wmf::parser::META_ESCAPE { if byte_count != 0x0002 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0002`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0002`",), }); } diff --git a/src/wmf/parser/records/escape/set_line_cap.rs b/src/wmf/parser/records/escape/set_line_cap.rs index 495b31ee..37f98251 100644 --- a/src/wmf/parser/records/escape/set_line_cap.rs +++ b/src/wmf/parser/records/escape/set_line_cap.rs @@ -1,7 +1,5 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_SETLINECAP< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_SETLINECAP( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, @@ -14,14 +12,17 @@ impl crate::wmf::parser::META_ESCAPE { if byte_count != 0x0004 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0004`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0004`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::SETLINECAP { record_size, record_function, byte_count, cap }) + Ok(Self::SETLINECAP { + record_size, + record_function, + byte_count, + cap, + }) } } diff --git a/src/wmf/parser/records/escape/set_line_join.rs b/src/wmf/parser/records/escape/set_line_join.rs index 9e0516f3..bd76b879 100644 --- a/src/wmf/parser/records/escape/set_line_join.rs +++ b/src/wmf/parser/records/escape/set_line_join.rs @@ -1,7 +1,5 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_SETLINEJOIN< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_SETLINEJOIN( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, @@ -14,14 +12,17 @@ impl crate::wmf::parser::META_ESCAPE { if byte_count != 0x0004 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0004`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0004`",), }); } crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self::SETLINEJOIN { record_size, record_function, byte_count, join }) + Ok(Self::SETLINEJOIN { + record_size, + record_function, + byte_count, + join, + }) } } diff --git a/src/wmf/parser/records/escape/set_miter_limit.rs b/src/wmf/parser/records/escape/set_miter_limit.rs index 05dca9d5..8178b0c9 100644 --- a/src/wmf/parser/records/escape/set_miter_limit.rs +++ b/src/wmf/parser/records/escape/set_miter_limit.rs @@ -1,7 +1,5 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_SETMITERLIMIT< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_SETMITERLIMIT( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, @@ -18,9 +16,7 @@ impl crate::wmf::parser::META_ESCAPE { if byte_count != 0x0004 { return Err(crate::wmf::parser::ParseError::UnexpectedPattern { - cause: format!( - "The byte_count `{byte_count:#06X}` field must be `0x0004`", - ), + cause: format!("The byte_count `{byte_count:#06X}` field must be `0x0004`",), }); } diff --git a/src/wmf/parser/records/escape/spcl_passthrough2.rs b/src/wmf/parser/records/escape/spcl_passthrough2.rs index 59f96721..f329078b 100644 --- a/src/wmf/parser/records/escape/spcl_passthrough2.rs +++ b/src/wmf/parser/records/escape/spcl_passthrough2.rs @@ -6,11 +6,7 @@ impl crate::wmf::parser::META_ESCAPE { mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let ( - (byte_count, byte_count_bytes), - (reserved, reserved_bytes), - (size, size_bytes), - ) = ( + let ((byte_count, byte_count_bytes), (reserved, reserved_bytes), (size, size_bytes)) = ( crate::wmf::parser::read_u16_from_le_bytes(buf)?, crate::wmf::parser::read_u32_from_le_bytes(buf)?, crate::wmf::parser::read_u16_from_le_bytes(buf)?, diff --git a/src/wmf/parser/records/escape/startdoc.rs b/src/wmf/parser/records/escape/startdoc.rs index 5a6afdf6..25c434ea 100644 --- a/src/wmf/parser/records/escape/startdoc.rs +++ b/src/wmf/parser/records/escape/startdoc.rs @@ -1,13 +1,10 @@ impl crate::wmf::parser::META_ESCAPE { - pub(in crate::wmf::parser::records::escape) fn parse_as_STARTDOC< - R: crate::wmf::Read, - >( + pub(in crate::wmf::parser::records::escape) fn parse_as_STARTDOC( buf: &mut R, mut record_size: crate::wmf::parser::RecordSize, record_function: u16, ) -> Result { - let (byte_count, byte_count_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (byte_count, byte_count_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(byte_count_bytes); if byte_count >= 260 { @@ -19,8 +16,7 @@ impl crate::wmf::parser::META_ESCAPE { }); } - let (doc_name, c) = - crate::wmf::parser::read_variable(buf, byte_count as usize)?; + let (doc_name, c) = crate::wmf::parser::read_variable(buf, byte_count as usize)?; record_size.consume(c); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; diff --git a/src/wmf/parser/records/mod.rs b/src/wmf/parser/records/mod.rs index 84d45091..554544d9 100644 --- a/src/wmf/parser/records/mod.rs +++ b/src/wmf/parser/records/mod.rs @@ -7,9 +7,7 @@ mod escape; mod object; mod state; -pub use self::{ - bitmap::*, control::*, drawing::*, escape::*, object::*, state::*, -}; +pub use self::{bitmap::*, control::*, drawing::*, escape::*, object::*, state::*}; /// Check lower byte MUST match the lower byte of the RecordType Enumeration /// table value. @@ -34,9 +32,8 @@ fn consume_remaining_bytes( buf: &mut R, record_size: crate::wmf::parser::RecordSize, ) -> Result<(crate::wmf::imports::Vec, usize), crate::wmf::parser::ParseError> { - crate::wmf::parser::read_variable(buf, record_size.remaining_bytes()).map_err( - |err| crate::wmf::parser::ParseError::FailedReadBuffer { cause: err }, - ) + crate::wmf::parser::read_variable(buf, record_size.remaining_bytes()) + .map_err(|err| crate::wmf::parser::ParseError::FailedReadBuffer { cause: err }) } /// A 32-bit unsigned integer that defines the number of 16-bit WORD structures, @@ -50,9 +47,7 @@ impl RecordSize { skip_all, err(level = tracing::Level::ERROR, Display), ))] - pub fn parse( - buf: &mut R, - ) -> Result { + pub fn parse(buf: &mut R) -> Result { let (v, c) = crate::wmf::parser::read_u32_from_le_bytes(buf)?; Ok(Self(v, c)) diff --git a/src/wmf/parser/records/object/create_brush_indirect.rs b/src/wmf/parser/records/object/create_brush_indirect.rs index acab2f95..3895e0d7 100644 --- a/src/wmf/parser/records/object/create_brush_indirect.rs +++ b/src/wmf/parser/records/object/create_brush_indirect.rs @@ -42,19 +42,25 @@ impl META_CREATEBRUSHINDIRECT { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, log_brush }) + Ok(Self { + record_size, + record_function, + log_brush, + }) } pub fn create_brush(&self) -> crate::wmf::parser::Brush { match self.log_brush.clone() { - crate::wmf::parser::LogBrush::DIBPatternPT => { - crate::wmf::parser::Brush::Solid { - color_ref: crate::wmf::parser::ColorRef::black(), - } - } - crate::wmf::parser::LogBrush::Hatched { color_ref, brush_hatch } => { - crate::wmf::parser::Brush::Hatched { color_ref, brush_hatch } - } + crate::wmf::parser::LogBrush::DIBPatternPT => crate::wmf::parser::Brush::Solid { + color_ref: crate::wmf::parser::ColorRef::black(), + }, + crate::wmf::parser::LogBrush::Hatched { + color_ref, + brush_hatch, + } => crate::wmf::parser::Brush::Hatched { + color_ref, + brush_hatch, + }, crate::wmf::parser::LogBrush::Solid { color_ref } => { crate::wmf::parser::Brush::Solid { color_ref } } diff --git a/src/wmf/parser/records/object/create_font_indirect.rs b/src/wmf/parser/records/object/create_font_indirect.rs index 202ee7da..801fb031 100644 --- a/src/wmf/parser/records/object/create_font_indirect.rs +++ b/src/wmf/parser/records/object/create_font_indirect.rs @@ -33,8 +33,7 @@ impl META_CREATEFONTINDIRECT { crate::wmf::parser::RecordType::META_CREATEFONTINDIRECT, )?; - let (b, c) = - crate::wmf::parser::read_variable(buf, record_size.remaining_bytes())?; + let (b, c) = crate::wmf::parser::read_variable(buf, record_size.remaining_bytes())?; record_size.consume(c); let mut buffer = &b[..]; @@ -42,6 +41,10 @@ impl META_CREATEFONTINDIRECT { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, font }) + Ok(Self { + record_size, + record_function, + font, + }) } } diff --git a/src/wmf/parser/records/object/create_palette.rs b/src/wmf/parser/records/object/create_palette.rs index 30fa01a8..6d2470f7 100644 --- a/src/wmf/parser/records/object/create_palette.rs +++ b/src/wmf/parser/records/object/create_palette.rs @@ -49,6 +49,10 @@ impl META_CREATEPALETTE { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, palette }) + Ok(Self { + record_size, + record_function, + palette, + }) } } diff --git a/src/wmf/parser/records/object/create_pattern_brush.rs b/src/wmf/parser/records/object/create_pattern_brush.rs index d8da1a47..027dd923 100644 --- a/src/wmf/parser/records/object/create_pattern_brush.rs +++ b/src/wmf/parser/records/object/create_pattern_brush.rs @@ -62,10 +62,8 @@ impl META_CREATEPATTERNBRUSH { crate::wmf::parser::RecordType::META_CREATEPATTERNBRUSH, )?; - let (bitmap16, bitmap16_bytes) = - crate::wmf::parser::Bitmap16::parse_without_bits(buf)?; - let (_, ignored_bytes) = - crate::wmf::parser::read_variable(buf, 14 - bitmap16_bytes)?; + let (bitmap16, bitmap16_bytes) = crate::wmf::parser::Bitmap16::parse_without_bits(buf)?; + let (_, ignored_bytes) = crate::wmf::parser::read_variable(buf, 14 - bitmap16_bytes)?; let (reserved, reserved_bytes) = crate::wmf::parser::read::(buf)?; record_size.consume(bitmap16_bytes + ignored_bytes + reserved_bytes); @@ -75,7 +73,13 @@ impl META_CREATEPATTERNBRUSH { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, bitmap16, reserved, pattern }) + Ok(Self { + record_size, + record_function, + bitmap16, + reserved, + pattern, + }) } pub fn create_brush(&self) -> crate::wmf::parser::Brush { diff --git a/src/wmf/parser/records/object/create_pen_indirect.rs b/src/wmf/parser/records/object/create_pen_indirect.rs index e4b63eef..03759704 100644 --- a/src/wmf/parser/records/object/create_pen_indirect.rs +++ b/src/wmf/parser/records/object/create_pen_indirect.rs @@ -38,6 +38,10 @@ impl META_CREATEPENINDIRECT { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, pen }) + Ok(Self { + record_size, + record_function, + pen, + }) } } diff --git a/src/wmf/parser/records/object/create_region.rs b/src/wmf/parser/records/object/create_region.rs index 4c25ced2..abe06cb9 100644 --- a/src/wmf/parser/records/object/create_region.rs +++ b/src/wmf/parser/records/object/create_region.rs @@ -39,6 +39,10 @@ impl META_CREATEREGION { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, region }) + Ok(Self { + record_size, + record_function, + region, + }) } } diff --git a/src/wmf/parser/records/object/delete_object.rs b/src/wmf/parser/records/object/delete_object.rs index 6d99b8b9..cb1d8a10 100644 --- a/src/wmf/parser/records/object/delete_object.rs +++ b/src/wmf/parser/records/object/delete_object.rs @@ -38,12 +38,15 @@ impl META_DELETEOBJECT { crate::wmf::parser::RecordType::META_DELETEOBJECT, )?; - let (object_index, object_index_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (object_index, object_index_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(object_index_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, object_index }) + Ok(Self { + record_size, + record_function, + object_index, + }) } } diff --git a/src/wmf/parser/records/object/dib_create_pattern_brush.rs b/src/wmf/parser/records/object/dib_create_pattern_brush.rs index b15317c1..b6695773 100644 --- a/src/wmf/parser/records/object/dib_create_pattern_brush.rs +++ b/src/wmf/parser/records/object/dib_create_pattern_brush.rs @@ -63,25 +63,26 @@ impl META_DIBCREATEPATTERNBRUSH { } let (target, c) = - crate::wmf::parser::DeviceIndependentBitmap::parse_with_color_usage( - buf, - color_usage, - )?; + crate::wmf::parser::DeviceIndependentBitmap::parse_with_color_usage(buf, color_usage)?; record_size.consume(c); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, style, color_usage, target }) + Ok(Self { + record_size, + record_function, + style, + color_usage, + target, + }) } pub fn create_brush(&self) -> crate::wmf::parser::Brush { match self.style { - crate::wmf::parser::BrushStyle::BS_PATTERN => { - crate::wmf::parser::Brush::DIBPatternPT { - color_usage: crate::wmf::parser::ColorUsage::DIB_RGB_COLORS, - brush_hatch: self.target.clone(), - } - } + crate::wmf::parser::BrushStyle::BS_PATTERN => crate::wmf::parser::Brush::DIBPatternPT { + color_usage: crate::wmf::parser::ColorUsage::DIB_RGB_COLORS, + brush_hatch: self.target.clone(), + }, _ => crate::wmf::parser::Brush::DIBPatternPT { color_usage: self.color_usage, brush_hatch: self.target.clone(), diff --git a/src/wmf/parser/records/object/mod.rs b/src/wmf/parser/records/object/mod.rs index bd95590d..19674968 100644 --- a/src/wmf/parser/records/object/mod.rs +++ b/src/wmf/parser/records/object/mod.rs @@ -14,8 +14,7 @@ mod select_object; mod select_palette; pub use self::{ - create_brush_indirect::*, create_font_indirect::*, create_palette::*, - create_pattern_brush::*, create_pen_indirect::*, create_region::*, - delete_object::*, dib_create_pattern_brush::*, select_clip_region::*, - select_object::*, select_palette::*, + create_brush_indirect::*, create_font_indirect::*, create_palette::*, create_pattern_brush::*, + create_pen_indirect::*, create_region::*, delete_object::*, dib_create_pattern_brush::*, + select_clip_region::*, select_object::*, select_palette::*, }; diff --git a/src/wmf/parser/records/object/select_clip_region.rs b/src/wmf/parser/records/object/select_clip_region.rs index db267312..3c5aed90 100644 --- a/src/wmf/parser/records/object/select_clip_region.rs +++ b/src/wmf/parser/records/object/select_clip_region.rs @@ -35,12 +35,15 @@ impl META_SELECTCLIPREGION { crate::wmf::parser::RecordType::META_SELECTCLIPREGION, )?; - let (region, region_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (region, region_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(region_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, region }) + Ok(Self { + record_size, + record_function, + region, + }) } } diff --git a/src/wmf/parser/records/object/select_object.rs b/src/wmf/parser/records/object/select_object.rs index e9d5faca..c26c9f07 100644 --- a/src/wmf/parser/records/object/select_object.rs +++ b/src/wmf/parser/records/object/select_object.rs @@ -41,12 +41,15 @@ impl META_SELECTOBJECT { crate::wmf::parser::RecordType::META_SELECTOBJECT, )?; - let (object_index, object_index_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (object_index, object_index_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(object_index_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, object_index }) + Ok(Self { + record_size, + record_function, + object_index, + }) } } diff --git a/src/wmf/parser/records/object/select_palette.rs b/src/wmf/parser/records/object/select_palette.rs index 7ebec462..0fce9199 100644 --- a/src/wmf/parser/records/object/select_palette.rs +++ b/src/wmf/parser/records/object/select_palette.rs @@ -35,12 +35,15 @@ impl META_SELECTPALETTE { crate::wmf::parser::RecordType::META_SELECTPALETTE, )?; - let (palette, palette_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (palette, palette_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(palette_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, palette }) + Ok(Self { + record_size, + record_function, + palette, + }) } } diff --git a/src/wmf/parser/records/state/animate_palette.rs b/src/wmf/parser/records/state/animate_palette.rs index d61de758..30982446 100644 --- a/src/wmf/parser/records/state/animate_palette.rs +++ b/src/wmf/parser/records/state/animate_palette.rs @@ -49,6 +49,10 @@ impl META_ANIMATEPALETTE { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, palette }) + Ok(Self { + record_size, + record_function, + palette, + }) } } diff --git a/src/wmf/parser/records/state/exclude_clip_rect.rs b/src/wmf/parser/records/state/exclude_clip_rect.rs index 1ac584dd..c28d09d1 100644 --- a/src/wmf/parser/records/state/exclude_clip_rect.rs +++ b/src/wmf/parser/records/state/exclude_clip_rect.rs @@ -46,22 +46,23 @@ impl META_EXCLUDECLIPRECT { crate::wmf::parser::RecordType::META_EXCLUDECLIPRECT, )?; - let ( - (bottom, bottom_bytes), - (right, right_bytes), - (top, top_bytes), - (left, left_bytes), - ) = ( + let ((bottom, bottom_bytes), (right, right_bytes), (top, top_bytes), (left, left_bytes)) = ( crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, ); - record_size - .consume(bottom_bytes + right_bytes + top_bytes + left_bytes); + record_size.consume(bottom_bytes + right_bytes + top_bytes + left_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, bottom, right, top, left }) + Ok(Self { + record_size, + record_function, + bottom, + right, + top, + left, + }) } } diff --git a/src/wmf/parser/records/state/intersect_clip_rect.rs b/src/wmf/parser/records/state/intersect_clip_rect.rs index 793b197d..96887295 100644 --- a/src/wmf/parser/records/state/intersect_clip_rect.rs +++ b/src/wmf/parser/records/state/intersect_clip_rect.rs @@ -46,22 +46,23 @@ impl META_INTERSECTCLIPRECT { crate::wmf::parser::RecordType::META_INTERSECTCLIPRECT, )?; - let ( - (bottom, bottom_bytes), - (right, right_bytes), - (top, top_bytes), - (left, left_bytes), - ) = ( + let ((bottom, bottom_bytes), (right, right_bytes), (top, top_bytes), (left, left_bytes)) = ( crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, ); - record_size - .consume(bottom_bytes + right_bytes + top_bytes + left_bytes); + record_size.consume(bottom_bytes + right_bytes + top_bytes + left_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, bottom, right, top, left }) + Ok(Self { + record_size, + record_function, + bottom, + right, + top, + left, + }) } } diff --git a/src/wmf/parser/records/state/mod.rs b/src/wmf/parser/records/state/mod.rs index df7fb058..c5977ad4 100644 --- a/src/wmf/parser/records/state/mod.rs +++ b/src/wmf/parser/records/state/mod.rs @@ -34,13 +34,11 @@ mod set_window_ext; mod set_window_org; pub use self::{ - animate_palette::*, exclude_clip_rect::*, intersect_clip_rect::*, - move_to::*, offset_clip_rgn::*, offset_viewport_org::*, - offset_window_org::*, realize_palette::*, resize_palette::*, restore_dc::*, - save_dc::*, scale_viewport_ext::*, scale_window_ext::*, set_bk_color::*, - set_bk_mode::*, set_layout::*, set_map_mode::*, set_mapper_flags::*, - set_pal_entries::*, set_polyfill_mode::*, set_relabs::*, set_rop2::*, - set_stretch_blt_mode::*, set_text_align::*, set_text_char_extra::*, - set_text_color::*, set_text_justification::*, set_viewport_ext::*, - set_viewport_org::*, set_window_ext::*, set_window_org::*, + animate_palette::*, exclude_clip_rect::*, intersect_clip_rect::*, move_to::*, + offset_clip_rgn::*, offset_viewport_org::*, offset_window_org::*, realize_palette::*, + resize_palette::*, restore_dc::*, save_dc::*, scale_viewport_ext::*, scale_window_ext::*, + set_bk_color::*, set_bk_mode::*, set_layout::*, set_map_mode::*, set_mapper_flags::*, + set_pal_entries::*, set_polyfill_mode::*, set_relabs::*, set_rop2::*, set_stretch_blt_mode::*, + set_text_align::*, set_text_char_extra::*, set_text_color::*, set_text_justification::*, + set_viewport_ext::*, set_viewport_org::*, set_window_ext::*, set_window_org::*, }; diff --git a/src/wmf/parser/records/state/move_to.rs b/src/wmf/parser/records/state/move_to.rs index ae1218e2..5896f142 100644 --- a/src/wmf/parser/records/state/move_to.rs +++ b/src/wmf/parser/records/state/move_to.rs @@ -46,6 +46,11 @@ impl META_MOVETO { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, y, x }) + Ok(Self { + record_size, + record_function, + y, + x, + }) } } diff --git a/src/wmf/parser/records/state/offset_clip_rgn.rs b/src/wmf/parser/records/state/offset_clip_rgn.rs index 86e16775..fb21e8e5 100644 --- a/src/wmf/parser/records/state/offset_clip_rgn.rs +++ b/src/wmf/parser/records/state/offset_clip_rgn.rs @@ -46,6 +46,11 @@ impl META_OFFSETCLIPRGN { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, y_offset, x_offset }) + Ok(Self { + record_size, + record_function, + y_offset, + x_offset, + }) } } diff --git a/src/wmf/parser/records/state/offset_viewport_org.rs b/src/wmf/parser/records/state/offset_viewport_org.rs index 109cebe9..1bba6532 100644 --- a/src/wmf/parser/records/state/offset_viewport_org.rs +++ b/src/wmf/parser/records/state/offset_viewport_org.rs @@ -46,6 +46,11 @@ impl META_OFFSETVIEWPORTORG { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, y_offset, x_offset }) + Ok(Self { + record_size, + record_function, + y_offset, + x_offset, + }) } } diff --git a/src/wmf/parser/records/state/offset_window_org.rs b/src/wmf/parser/records/state/offset_window_org.rs index 8a20c6f8..1817862a 100644 --- a/src/wmf/parser/records/state/offset_window_org.rs +++ b/src/wmf/parser/records/state/offset_window_org.rs @@ -46,6 +46,11 @@ impl META_OFFSETWINDOWORG { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, y_offset, x_offset }) + Ok(Self { + record_size, + record_function, + y_offset, + x_offset, + }) } } diff --git a/src/wmf/parser/records/state/realize_palette.rs b/src/wmf/parser/records/state/realize_palette.rs index 21e5894a..01a411c9 100644 --- a/src/wmf/parser/records/state/realize_palette.rs +++ b/src/wmf/parser/records/state/realize_palette.rs @@ -34,6 +34,9 @@ impl META_REALIZEPALETTE { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function }) + Ok(Self { + record_size, + record_function, + }) } } diff --git a/src/wmf/parser/records/state/resize_palette.rs b/src/wmf/parser/records/state/resize_palette.rs index 32bf84a4..4d2f826c 100644 --- a/src/wmf/parser/records/state/resize_palette.rs +++ b/src/wmf/parser/records/state/resize_palette.rs @@ -41,6 +41,10 @@ impl META_RESIZEPALETTE { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, number_of_entries }) + Ok(Self { + record_size, + record_function, + number_of_entries, + }) } } diff --git a/src/wmf/parser/records/state/restore_dc.rs b/src/wmf/parser/records/state/restore_dc.rs index f67fbd46..42054734 100644 --- a/src/wmf/parser/records/state/restore_dc.rs +++ b/src/wmf/parser/records/state/restore_dc.rs @@ -38,12 +38,15 @@ impl META_RESTOREDC { crate::wmf::parser::RecordType::META_RESTOREDC, )?; - let (n_saved_dc, n_saved_dc_bytes) = - crate::wmf::parser::read_i16_from_le_bytes(buf)?; + let (n_saved_dc, n_saved_dc_bytes) = crate::wmf::parser::read_i16_from_le_bytes(buf)?; record_size.consume(n_saved_dc_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, n_saved_dc }) + Ok(Self { + record_size, + record_function, + n_saved_dc, + }) } } diff --git a/src/wmf/parser/records/state/save_dc.rs b/src/wmf/parser/records/state/save_dc.rs index a8a0ed83..9ca3a6bf 100644 --- a/src/wmf/parser/records/state/save_dc.rs +++ b/src/wmf/parser/records/state/save_dc.rs @@ -34,6 +34,9 @@ impl META_SAVEDC { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function }) + Ok(Self { + record_size, + record_function, + }) } } diff --git a/src/wmf/parser/records/state/scale_viewport_ext.rs b/src/wmf/parser/records/state/scale_viewport_ext.rs index 74f40da6..ce2cd4e5 100644 --- a/src/wmf/parser/records/state/scale_viewport_ext.rs +++ b/src/wmf/parser/records/state/scale_viewport_ext.rs @@ -58,8 +58,7 @@ impl META_SCALEVIEWPORTEXT { crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, ); - record_size - .consume(y_denom_bytes + y_num_bytes + x_denom_bytes + x_num_bytes); + record_size.consume(y_denom_bytes + y_num_bytes + x_denom_bytes + x_num_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; diff --git a/src/wmf/parser/records/state/scale_window_ext.rs b/src/wmf/parser/records/state/scale_window_ext.rs index 02d8a2c0..0178fb06 100644 --- a/src/wmf/parser/records/state/scale_window_ext.rs +++ b/src/wmf/parser/records/state/scale_window_ext.rs @@ -58,8 +58,7 @@ impl META_SCALEWINDOWEXT { crate::wmf::parser::read_i16_from_le_bytes(buf)?, crate::wmf::parser::read_i16_from_le_bytes(buf)?, ); - record_size - .consume(y_denom_bytes + y_num_bytes + x_denom_bytes + x_num_bytes); + record_size.consume(y_denom_bytes + y_num_bytes + x_denom_bytes + x_num_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; diff --git a/src/wmf/parser/records/state/set_bk_color.rs b/src/wmf/parser/records/state/set_bk_color.rs index 02dea4dd..2f740e10 100644 --- a/src/wmf/parser/records/state/set_bk_color.rs +++ b/src/wmf/parser/records/state/set_bk_color.rs @@ -41,6 +41,10 @@ impl META_SETBKCOLOR { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, color_ref }) + Ok(Self { + record_size, + record_function, + color_ref, + }) } } diff --git a/src/wmf/parser/records/state/set_bk_mode.rs b/src/wmf/parser/records/state/set_bk_mode.rs index 2780a3c0..266abd22 100644 --- a/src/wmf/parser/records/state/set_bk_mode.rs +++ b/src/wmf/parser/records/state/set_bk_mode.rs @@ -55,6 +55,11 @@ impl META_SETBKMODE { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, bk_mode, reserved }) + Ok(Self { + record_size, + record_function, + bk_mode, + reserved, + }) } } diff --git a/src/wmf/parser/records/state/set_layout.rs b/src/wmf/parser/records/state/set_layout.rs index ddc318de..43f4225d 100644 --- a/src/wmf/parser/records/state/set_layout.rs +++ b/src/wmf/parser/records/state/set_layout.rs @@ -48,6 +48,11 @@ impl META_SETLAYOUT { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, layout, reserved }) + Ok(Self { + record_size, + record_function, + layout, + reserved, + }) } } diff --git a/src/wmf/parser/records/state/set_map_mode.rs b/src/wmf/parser/records/state/set_map_mode.rs index fdf91e11..2f66f8c0 100644 --- a/src/wmf/parser/records/state/set_map_mode.rs +++ b/src/wmf/parser/records/state/set_map_mode.rs @@ -45,6 +45,10 @@ impl META_SETMAPMODE { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, map_mode }) + Ok(Self { + record_size, + record_function, + map_mode, + }) } } diff --git a/src/wmf/parser/records/state/set_mapper_flags.rs b/src/wmf/parser/records/state/set_mapper_flags.rs index 358d04b8..ece61283 100644 --- a/src/wmf/parser/records/state/set_mapper_flags.rs +++ b/src/wmf/parser/records/state/set_mapper_flags.rs @@ -37,12 +37,15 @@ impl META_SETMAPPERFLAGS { crate::wmf::parser::RecordType::META_SETMAPPERFLAGS, )?; - let (mapper_values, mapper_values_bytes) = - crate::wmf::parser::read_u32_from_le_bytes(buf)?; + let (mapper_values, mapper_values_bytes) = crate::wmf::parser::read_u32_from_le_bytes(buf)?; record_size.consume(mapper_values_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, mapper_values }) + Ok(Self { + record_size, + record_function, + mapper_values, + }) } } diff --git a/src/wmf/parser/records/state/set_pal_entries.rs b/src/wmf/parser/records/state/set_pal_entries.rs index f9a0f24c..e54da65e 100644 --- a/src/wmf/parser/records/state/set_pal_entries.rs +++ b/src/wmf/parser/records/state/set_pal_entries.rs @@ -40,6 +40,10 @@ impl META_SETPALENTRIES { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, palette }) + Ok(Self { + record_size, + record_function, + palette, + }) } } diff --git a/src/wmf/parser/records/state/set_polyfill_mode.rs b/src/wmf/parser/records/state/set_polyfill_mode.rs index c6ee2413..fa57ed4d 100644 --- a/src/wmf/parser/records/state/set_polyfill_mode.rs +++ b/src/wmf/parser/records/state/set_polyfill_mode.rs @@ -40,8 +40,7 @@ impl META_SETPOLYFILLMODE { crate::wmf::parser::RecordType::META_SETPOLYFILLMODE, )?; - let (poly_fill_mode, poly_fill_mode_bytes) = - crate::wmf::parser::PolyFillMode::parse(buf)?; + let (poly_fill_mode, poly_fill_mode_bytes) = crate::wmf::parser::PolyFillMode::parse(buf)?; record_size.consume(poly_fill_mode_bytes); let reserved = if record_size.byte_count() > 8 { @@ -54,6 +53,11 @@ impl META_SETPOLYFILLMODE { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, poly_fill_mode, reserved }) + Ok(Self { + record_size, + record_function, + poly_fill_mode, + reserved, + }) } } diff --git a/src/wmf/parser/records/state/set_relabs.rs b/src/wmf/parser/records/state/set_relabs.rs index c406c838..673d1d11 100644 --- a/src/wmf/parser/records/state/set_relabs.rs +++ b/src/wmf/parser/records/state/set_relabs.rs @@ -35,6 +35,9 @@ impl META_SETRELABS { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function }) + Ok(Self { + record_size, + record_function, + }) } } diff --git a/src/wmf/parser/records/state/set_rop2.rs b/src/wmf/parser/records/state/set_rop2.rs index f3617876..88d961ab 100644 --- a/src/wmf/parser/records/state/set_rop2.rs +++ b/src/wmf/parser/records/state/set_rop2.rs @@ -42,8 +42,7 @@ impl META_SETROP2 { crate::wmf::parser::RecordType::META_SETROP2, )?; - let (draw_mode, draw_mode_bytes) = - crate::wmf::parser::BinaryRasterOperation::parse(buf)?; + let (draw_mode, draw_mode_bytes) = crate::wmf::parser::BinaryRasterOperation::parse(buf)?; record_size.consume(draw_mode_bytes); let reserved = if record_size.byte_count() > 8 { @@ -57,6 +56,11 @@ impl META_SETROP2 { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, draw_mode, reserved }) + Ok(Self { + record_size, + record_function, + draw_mode, + reserved, + }) } } diff --git a/src/wmf/parser/records/state/set_stretch_blt_mode.rs b/src/wmf/parser/records/state/set_stretch_blt_mode.rs index 441373b7..964e0dac 100644 --- a/src/wmf/parser/records/state/set_stretch_blt_mode.rs +++ b/src/wmf/parser/records/state/set_stretch_blt_mode.rs @@ -40,8 +40,7 @@ impl META_SETSTRETCHBLTMODE { crate::wmf::parser::RecordType::META_SETSTRETCHBLTMODE, )?; - let (stretch_mode, stretch_mode_bytes) = - crate::wmf::parser::StretchMode::parse(buf)?; + let (stretch_mode, stretch_mode_bytes) = crate::wmf::parser::StretchMode::parse(buf)?; record_size.consume(stretch_mode_bytes); let reserved = if record_size.byte_count() > 8 { @@ -55,6 +54,11 @@ impl META_SETSTRETCHBLTMODE { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, stretch_mode, reserved }) + Ok(Self { + record_size, + record_function, + stretch_mode, + reserved, + }) } } diff --git a/src/wmf/parser/records/state/set_text_align.rs b/src/wmf/parser/records/state/set_text_align.rs index b77678b3..0fd5b472 100644 --- a/src/wmf/parser/records/state/set_text_align.rs +++ b/src/wmf/parser/records/state/set_text_align.rs @@ -56,6 +56,11 @@ impl META_SETTEXTALIGN { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, text_alignment_mode, reserved }) + Ok(Self { + record_size, + record_function, + text_alignment_mode, + reserved, + }) } } diff --git a/src/wmf/parser/records/state/set_text_char_extra.rs b/src/wmf/parser/records/state/set_text_char_extra.rs index e1048879..d891947f 100644 --- a/src/wmf/parser/records/state/set_text_char_extra.rs +++ b/src/wmf/parser/records/state/set_text_char_extra.rs @@ -40,12 +40,15 @@ impl META_SETTEXTCHAREXTRA { crate::wmf::parser::RecordType::META_SETTEXTCHAREXTRA, )?; - let (char_extra, char_extra_bytes) = - crate::wmf::parser::read_u16_from_le_bytes(buf)?; + let (char_extra, char_extra_bytes) = crate::wmf::parser::read_u16_from_le_bytes(buf)?; record_size.consume(char_extra_bytes); crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, char_extra }) + Ok(Self { + record_size, + record_function, + char_extra, + }) } } diff --git a/src/wmf/parser/records/state/set_text_color.rs b/src/wmf/parser/records/state/set_text_color.rs index a9af393e..d5b47f88 100644 --- a/src/wmf/parser/records/state/set_text_color.rs +++ b/src/wmf/parser/records/state/set_text_color.rs @@ -40,6 +40,10 @@ impl META_SETTEXTCOLOR { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, color_ref }) + Ok(Self { + record_size, + record_function, + color_ref, + }) } } diff --git a/src/wmf/parser/records/state/set_text_justification.rs b/src/wmf/parser/records/state/set_text_justification.rs index f4ff9e9f..ae36ea2f 100644 --- a/src/wmf/parser/records/state/set_text_justification.rs +++ b/src/wmf/parser/records/state/set_text_justification.rs @@ -41,10 +41,7 @@ impl META_SETTEXTJUSTIFICATION { crate::wmf::parser::RecordType::META_SETTEXTJUSTIFICATION, )?; - let ( - (break_count, break_count_bytes), - (break_extra, break_extra_bytes), - ) = ( + let ((break_count, break_count_bytes), (break_extra, break_extra_bytes)) = ( crate::wmf::parser::read_u16_from_le_bytes(buf)?, crate::wmf::parser::read_u16_from_le_bytes(buf)?, ); @@ -52,6 +49,11 @@ impl META_SETTEXTJUSTIFICATION { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, break_count, break_extra }) + Ok(Self { + record_size, + record_function, + break_count, + break_extra, + }) } } diff --git a/src/wmf/parser/records/state/set_viewport_ext.rs b/src/wmf/parser/records/state/set_viewport_ext.rs index 892184c8..d88068b4 100644 --- a/src/wmf/parser/records/state/set_viewport_ext.rs +++ b/src/wmf/parser/records/state/set_viewport_ext.rs @@ -46,6 +46,11 @@ impl META_SETVIEWPORTEXT { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, y, x }) + Ok(Self { + record_size, + record_function, + y, + x, + }) } } diff --git a/src/wmf/parser/records/state/set_viewport_org.rs b/src/wmf/parser/records/state/set_viewport_org.rs index 6d252524..60739fc2 100644 --- a/src/wmf/parser/records/state/set_viewport_org.rs +++ b/src/wmf/parser/records/state/set_viewport_org.rs @@ -46,6 +46,11 @@ impl META_SETVIEWPORTORG { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, y, x }) + Ok(Self { + record_size, + record_function, + y, + x, + }) } } diff --git a/src/wmf/parser/records/state/set_window_ext.rs b/src/wmf/parser/records/state/set_window_ext.rs index bcca2763..47653c67 100644 --- a/src/wmf/parser/records/state/set_window_ext.rs +++ b/src/wmf/parser/records/state/set_window_ext.rs @@ -46,6 +46,11 @@ impl META_SETWINDOWEXT { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, y, x }) + Ok(Self { + record_size, + record_function, + y, + x, + }) } } diff --git a/src/wmf/parser/records/state/set_window_org.rs b/src/wmf/parser/records/state/set_window_org.rs index 1edfe90d..2ae52f47 100644 --- a/src/wmf/parser/records/state/set_window_org.rs +++ b/src/wmf/parser/records/state/set_window_org.rs @@ -46,6 +46,11 @@ impl META_SETWINDOWORG { crate::wmf::parser::records::consume_remaining_bytes(buf, record_size)?; - Ok(Self { record_size, record_function, y, x }) + Ok(Self { + record_size, + record_function, + y, + x, + }) } } diff --git a/web/fonts/FONTS.md b/web/fonts/FONTS.md index 5539255c..b7f03834 100644 --- a/web/fonts/FONTS.md +++ b/web/fonts/FONTS.md @@ -6,6 +6,7 @@ ## 저작권 폰트 (Git 미포함) 로컬에 직접 배치해야 한다. 웹 배포 시 대응되는 폰트 폴백으로 대체 가능하다. +현재 웹 렌더러는 네트워크 의존성을 피하기 위해 함초롬 aliases를 번들된 Noto 계열 대체 폰트로 매핑한다. ### 한컴 폰트