# hwp-convert

HWP 5.0(바이너리/CFB)·HWPX(OWPML/ZIP+XML) ↔ Markdown/HTML **변환** 라이브러리.
Node.js 와 브라우저(ESM) 양쪽에서 동작하며, 별도의 한글(HWP) 클라이언트 의존성 없이 순수 TypeScript 만으로 처리합니다.

> **핵심**: 생성한 HWPX 가 **한컴오피스(한글)에서 실제로 열립니다.** Markdown·HTML 을 변환해도 표·이미지·서식이 보존된 채 정상 표시됩니다.

- HWPX 는 ZIP 패키지 내부에 OWPML XML 로 본문을 저장하는 개방형 포맷입니다. (참고: [HWPX 포맷 구조 살펴보기](https://tech.hancom.com/hwpxformat/))
- HWP 바이너리(CFB/OLE2) 파서는 [rhwp](https://github.com/edwardkim/rhwp) (Rust, MIT, Copyright (c) 2025-2026 Edward Kim) 의 구조를 TypeScript 로 포팅한 것입니다. 각 포팅 파일 헤더에 출처를 명시합니다.
- 이 프로젝트는 [hwpxjs](https://github.com/ssabro/hwpxjs) (MIT, Copyright (c) 2025 ssabro) 에서 **fork·확장**한 것입니다. 한컴 호환 HWPX 생성(OWPML 컨벤션 정합) 과 로컬 파일 이미지 임베드를 더했습니다. 출처·라이선스는 [LICENSE](./LICENSE) 와 [NOTICE](./NOTICE) 참조. 본 저장소도 동일한 MIT 라이선스를 따릅니다.

## 주요 기능

- **HWPX 읽기**: 패키지 inspect, 평문/HTML/Markdown 추출 (이미지 인라인/리졸버 지원)
- **HWPX 쓰기**: OWPML 패키지 규칙(첫 STORED `mimetype`, `META-INF/container.xml`/`manifest.xml`, OPF spine) 호환 .hwpx 생성
- **HWP 5.0 파싱**: CFB(OLE2) + raw deflate 컨테이너 → FileHeader / DocInfo / BodyText 풀 파서
- **HWP → HWPX 변환**: 표(셀 병합 포함), 이미지(BinData 패키징), 폰트/문단/스타일 정의 보존, `Preview/PrvText.txt` 자동 생성
- **Markdown ↔ HWPX 양방향**: MD lexer → IR / IR → MD writer
- **HTML → HWPX**: `htmlparser2` 기반 DOM → IR
- **표·이미지·스타일 보존**: 글자 모양(굵게/기울임/밑줄/색/크기), 문단 모양(정렬/들여쓰기/줄간격), 7개 언어별 폰트 그룹, 번호/글머리표 형식 문자열까지 OWPML refList 로 옮겨 라운드트립 가능
- **배경 채우기 박스**: HTML 블록(div/p/h1~6)·표 셀의 `background-color` 를 글자 음영이 아니라 문단/셀 전체 너비 채우기(OWPML borderFill `fillBrush` + paraPr `border`)로 변환 — 한컴에서 제목 띠·헤더 배경이 박스로 표시. 인라인(span 등) 배경은 글자 음영 유지
- **다단 레이아웃 → 표**: HTML `display:grid`(grid-template-columns)·`display:flex`(row) 가로 다단 컨테이너(블록 자식 2개 이상)를 N열 표로 합성해 좌우 배치 보존 — 발주처|공급처 2칼럼 등이 한글에서 세로로 무너지지 않음. 컬럼 너비는 `grid-template-columns` px 비율 반영. 인라인 자식 flex(라벨:값)·`flex-direction:column` 은 변환 안 함
- **CSS 테두리 보존**: div 박스·표 셀의 `border`(`border-width/style/color`, 면별 개별 포함)를 OWPML borderFill 4면 테두리로 반영(width px→HWP 너비 인덱스, solid/dashed/dotted/double). `border-width: 0` 인 레이아웃 래퍼·무테두리 div 엔 선이 생기지 않음(투명 유지). 발주처/공급처 박스 외곽선이 실제 출력으로 표시
- **셀 마감**: `padding`(셀 안쪽 여백)·`grid gap`(칼럼 간격→표 cellSpacing)·`vertical-align`(셀 세로 정렬, 레이아웃 셀 기본 상단) 반영. px→HWPUNIT(`px×75`)
- **용지/여백 보존 + 명시 제어**: 루트 컨테이너의 `padding`(→페이지 여백)·`max-width`/`width`(→본문폭)를 페이지 설정으로 자동 반영하고, `htmlToHwpx(html, { page })` 옵션으로 **용지 종류·방향·여백을 명시 지정**할 수 있다.
  - `page.size`: `'A4'|'A3'|'A5'|'B4'|'B5'|'Letter'|'Legal'` 또는 커스텀 `{ width, height, unit:'mm'|'hwpunit' }`
  - `page.orientation`: `'auto'`(기본, 본문폭이 세로 가용폭을 넘으면 가로) / `'portrait'` / `'landscape'`(명시 고정)
  - `page.margins`: `{ left, right, top, bottom, header, footer, gutter }`(mm)
  - 한글 네이티브 모델: 용지 치수는 물리값(세로 기준) 고정, 방향은 `landscape` 플래그(`WIDELY`=세로/`NARROWLY`=가로)로 표현. 우선순위는 **옵션 > 컨테이너 CSS > 기본값**(용지 A4 / 세로 / 한글 기본여백)으로 자동 귀결. px 단위만 인식(%·auto 무시)
- **인라인 테두리(도장박스)**: 인라인 `<span>` 의 `border` 를 글자 테두리(charPr borderFillIDRef)로 반영. 빈 박스(서명 인감란 등)는 placeholder 로 보존
- **이미지 크기/비율**: PNG/JPEG/GIF/BMP 헤더에서 원본 px 를 읽어 **비율 보존**(찌그러짐 방지) + `<img>` 의 `width`/`height`(px·%) 반영. 페이지 본문 폭 기준 % 지원
- **CLI**: `inspect` / `txt` / `html` / `md` / `hwp:txt` / `hwp:md` / `html:tpl` / `batch` / `batch:tpl` / `write:txt` / `md:hwpx` / `html:hwpx` / `convert:hwp`
- **템플릿 처리**: `{{key}}` 텍스트 치환 (CLI / 라이브러리)
- **브라우저 ESM 번들**: `dist/browser/hwp-convert.browser.mjs` (esbuild, 약 830KB, 모든 의존성 인라인)

### 변환 매트릭스

| 출발 \ 도착 | text | HTML | Markdown | HWPX | HWP |
| --- | --- | --- | --- | --- | --- |
| **HWP** | ✅ | ⚠️ HWPX 거쳐서 | ✅ `hwpToMarkdown` | ✅ `hwpToHwpx` | ─ |
| **HWPX** | ✅ `extractText` | ✅ `extractHtml` | ✅ `extractMarkdown` | ─ | ❌ |
| **Markdown** | ─ | ─ | ─ | ✅ `markdownToHwpx` | ❌ |
| **HTML** | ─ | ─ | ─ | ✅ `htmlToHwpx` | ❌ |
| **plain text** | ─ | ─ | ─ | ✅ `HwpxWriter` | ❌ |
| **PDF** | ❌ | ❌ | ❌ | ❌ | ❌ |

PDF 와 HWPX → HWP 역변환은 별도 도메인이라 미지원. PDF 가 필요하면 변환된 HWPX 를 LibreOffice (`libreoffice --headless --convert-to pdf`) 또는 헤드리스 Chrome 인쇄로 변환하시면 됩니다.

## 변환 스펙

변환 계약(옵션·기본값·단위·우선순위)입니다. 사용 예제는 아래 [라이브러리 사용](#라이브러리-사용)을 참고하세요.

### 옵션

| 함수 | 옵션 | 기본값 |
| --- | --- | --- |
| `htmlToHwpx(html, options)` | `title`, `creator`, `imageResolver`, `page` | 모두 선택 |
| `markdownToHwpx(md, options)` | `title`, `creator`, `imageResolver` | 모두 선택 (`page` 없음 — HTML 경로 전용) |
| `hwpToHwpx(bytes, options)` | `title`, `creator` | 모두 선택 |
| `page` (`PageSetupOption`) | `size`, `orientation`, `margins` | A4 / `'auto'` / 한글 기본 여백 |
| `HwpxReader.extractMarkdown(options)` | `embedImages`, `imageSrcResolver` | `embedImages: false` |
| `HwpxReader.extractHtml(options)` | `embedImages`, `imageSrcResolver` | `embedImages: false` |
| `hwpToMarkdown(bytes, options)` | `embedImages`, `imageSrcResolver` | `embedImages: false` |

라이브러리 기본은 모두 경로 참조(`embedImages: false`)입니다. CLI 는 `html` 명령만 인라인을 기본으로 하며, 자세한 내용은 [CLI](#cli)를 참고하세요.

- `page.size`: `'A4' | 'A3' | 'A5' | 'B4' | 'B5' | 'Letter' | 'Legal'` 또는 커스텀 `{ width, height, unit?: 'mm' | 'hwpunit' }`
- `page.orientation`: `'auto'`(본문폭이 세로 가용폭을 넘으면 가로) | `'portrait'` | `'landscape'`
- `page.margins`(mm): `left`, `right`, `top`, `bottom`, `header`, `footer`, `gutter` — 지정한 면만 반영

### 페이지 설정 우선순위

**API 옵션 > `@page` CSS > 컨테이너 CSS(`padding` / `max-width`) > 기본값(A4 · 세로 · 한글 기본 여백)**

필드 단위로 병합됩니다. 예를 들어 API 로 `margins.left` 만 주면 나머지 면은 `@page` → 컨테이너 → 기본값 순으로 채워집니다.

- `@page` 는 셀렉터 없는 블록만 인식하며, 뒤 블록이 앞 블록을 덮습니다(CSS cascade). `@page :first` 와 named page 는 미지원입니다.
- 용지 방향은 **치수를 바꾸지 않고 플래그로** 표현합니다. 한글 네이티브 모델과 동일하게 용지 치수는 항상 세로 기준 물리값이고, 방향은 `landscape` 속성(`WIDELY`=세로 / `NARROWLY`=가로)이 결정합니다.

### 단위

| 항목 | 값 |
| --- | --- |
| HWPUNIT | 1/7200 inch |
| CSS px → HWPUNIT | `px × 75` |
| mm → HWPUNIT | `mm × 7200 / 25.4` |
| 글자 크기 | HWPUNIT (10pt = 1000) |

`%` 와 `auto` 는 길이 값으로 인식하지 않습니다(무시하고 다음 우선순위로 넘어감). 단 `<img>` 의 `width`/`height` 는 `%` 를 본문 폭 기준으로 해석합니다.

### 이미지

- **입력**: `data:` URI 는 그대로 처리하고, `file://` · 로컬/상대 경로는 `imageResolver` 를 주입했을 때만 해석합니다(미주입 시 해당 이미지는 건너뜁니다). 코어는 Node API 에 의존하지 않으므로 브라우저에서도 동작합니다.
- **크기**: PNG/JPEG/GIF/BMP 헤더에서 원본 px 를 읽어 **비율을 보존**합니다. `width`/`height` 를 주면 그 값이 우선하고, 한쪽만 주면 나머지는 비율로 채웁니다.
- **출력(MD/HTML)**: `embedImages: true` → base64 data URI 인라인 / `imageSrcResolver` 지정 → 변환된 경로 / 기본 → `BinData/imageN.ext` 경로 참조.

### 오류

미지원 입력은 조용히 실패하지 않고 전용 에러를 던집니다 — `HwpEncryptedError`(암호화), `HwpUnsupportedError`(배포용 ViewText · HWP 3.0), `HwpInvalidFormatError`(형식 불일치). 자세한 사용법은 [오류 처리](#오류-처리)를 참고하세요.

## 설치

```bash
pnpm add hwp-convert
# or
npm i hwp-convert
```

런타임 의존성: `cfb`, `fast-xml-parser`, `jszip`, `pako`. Node.js 18+ 권장.

## 라이브러리 사용

### HWPX 파일 읽기

```ts
import HwpxReader from "hwp-convert";
import { readFile } from "node:fs/promises";

const buf = await readFile("./document.hwpx");
const reader = new HwpxReader();
await reader.loadFromArrayBuffer(
  buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
);

// 패키지 메타/매니페스트 정보
const info = await reader.getDocumentInfo();
// { metadata: { title?, creator?, created?, modified?, version?, caretPosition? },
//   summary:  { hasEncryptionInfo, contentsFiles, manifest?, spine? } }

// 텍스트 추출 (표 셀까지 재귀 탐색)
const text = await reader.extractText();

// 이미지 경로 목록 (BinData/ 내 파일 경로)
const images = await reader.listImages();
// → ["BinData/image1.png", "BinData/image2.jpg", ...]
```

### HTML 변환

```ts
const html = await reader.extractHtml({
  paragraphTag: "p",            // default "p"
  tableClassName: "hwpx-table", // default "hwpx-table"
  renderImages: true,           // default true
  renderTables: true,           // default true (rowSpan/colSpan 보존)
  renderStyles: true,           // default true (charProperties 기반 굵게/기울임/색/크기)
  embedImages: false,           // default false. true 시 data: URL 인라인
  tableHeaderFirstRow: false,   // default false. true 시 첫 행을 <th>
  imageSrcResolver: (binPath) => `/static/images/${binPath}`,
});
```

### HWP 바이너리 파싱 / 변환

```ts
import {
  parseHwp,
  hwpToText,
  hwpToHwpx,
  detectFormat,
  HwpEncryptedError,
  HwpUnsupportedError,
  HwpInvalidFormatError,
} from "hwp-convert";
import { readFile, writeFile } from "node:fs/promises";

const bytes = new Uint8Array(await readFile("./input.hwp"));

// 포맷 자동 감지: "hwp" | "hwpx" | "hwp3" | "unknown"
const fmt = detectFormat(bytes);

// 평문 추출
const text = await hwpToText(bytes);

// HwpDocument IR (header / docInfo / sections / binData) 직접 접근
const doc = parseHwp(bytes);
console.log(doc.docInfo.styles, doc.sections.length);

// HWPX 로 변환 (표·이미지·스타일·미리보기 모두 포함)
const hwpxBytes = await hwpToHwpx(bytes, { title: "변환본", creator: "hwp-convert" });
await writeFile("./output.hwpx", hwpxBytes);
```

`hwpToHwpx` 가 만든 HWPX 는 다음을 보존합니다:

- 표(`<hp:tbl>`/`<hp:tr>`/`<hp:tc>`/`<hp:subList>`) — `colSpan`/`rowSpan` 그대로
- 이미지 — `BinData/imageN.{png|jpg|...}` 패키징 + 매니페스트 등록 + `<hp:pic>`/`<hc:img binaryItemIDRef>` 인라인
- 폰트 — 7개 언어 그룹별(`HANGUL/LATIN/HANJA/JAPANESE/OTHER/SYMBOL/USER`) 실제 사용 폰트
- 글자 모양 — `<hh:charPr>` 의 `fontRef`/`textColor`/`shadeColor`/`height` + `<hh:bold>`/`<hh:italic>`/`<hh:underline>`/`<hh:strikeout>` 요소
- 문단 모양 — 정렬(`LEFT|RIGHT|CENTER|JUSTIFY|DISTRIBUTE`), 좌/우 여백, 들여쓰기, 줄간격
- 스타일 정의 — `<hh:style>` + `paraPrIDRef`/`charPrIDRef`
- 번호 매기기 — 수준별 형식 문자열(예: `^1.`, `^1)`)
- 글머리표 — 글머리 문자(●/○/■ 등)
- 미리보기 — `Preview/PrvText.txt` 자동 생성 (한컴 호환 형식, 다른 뷰어/탐색기에서 썸네일/미리보기 지원)

> 한계: `BorderFill` 의 그라데이션/이미지 채우기와 도형의 사각형/타원/호/다각형/곡선 좌표·스타일은 종류만 표시합니다(직선만 좌표 보존). 차트·OLE·글맵시는 미지원이고, 머리말/꼬리말/각주는 본문 흐름에 평탄 출력되며 별도 master page 매핑은 향후 지원 예정입니다.

### Markdown / HTML ↔ HWPX

```ts
import {
  hwpToMarkdown,
  markdownToHwpx,
  htmlToHwpx,
} from "hwp-convert";
import { readFile, writeFile } from "node:fs/promises";

// HWP → Markdown
const md = await hwpToMarkdown(new Uint8Array(await readFile("./input.hwp")));

// HWPX → Markdown
import HwpxReader from "hwp-convert";
const reader = new HwpxReader();
await reader.loadFromArrayBuffer(/* ArrayBuffer */);
const md2 = await reader.extractMarkdown({ embedImages: true });

// Markdown → HWPX (heading/bold/italic/list/table/image-data-URI)
const mdSrc = `# 제목\n\n**굵게** 그리고 *기울임*\n\n| A | B |\n| --- | --- |\n| 1 | 2 |`;
const hwpxFromMd = await markdownToHwpx(mdSrc, { title: "from-md", creator: "me" });
await writeFile("./from-md.hwpx", hwpxFromMd);

// HTML → HWPX (p/h1-6/strong/em/ul/ol/li/table/blockquote/pre/img-data-URI)
const html = `<h1>제목</h1><p>본문 <strong>강조</strong></p>`;
const hwpxFromHtml = await htmlToHwpx(html);
await writeFile("./from-html.hwpx", hwpxFromHtml);
```

이미지는 `data:` URI 인 경우만 BinData 로 임베드됩니다. 외부 URL/상대경로 이미지는 스킵 (런타임 fetch 가 필요해서 1차 포팅 범위 밖).

### 평문 → HWPX 작성

```ts
import { HwpxWriter } from "hwp-convert";
import { writeFile } from "node:fs/promises";

const writer = new HwpxWriter();
const bytes = await writer.createFromPlainText("첫 문단\n두 번째 문단", {
  title: "예시",
  creator: "홍길동",
});
await writeFile("./output.hwpx", bytes);
```

`HwpxWriter` 는 OWPML 패키지 규칙(첫 STORED `mimetype`, `META-INF/container.xml`/`manifest.xml`, OPF 매니페스트+spine, 최소 충실도의 `header.xml` 1세트)을 따르는 spec 호환 .hwpx 를 생성합니다. 한컴오피스 / LibreOffice 에서 그대로 열립니다.

### 오류 처리

```ts
import HwpxReader, {
  HwpxNotLoadedError,
  HwpxEncryptedDocumentError,
  InvalidHwpxFormatError,
  HwpEncryptedError,
  HwpUnsupportedError,
  HwpInvalidFormatError,
} from "hwp-convert";

try {
  const reader = new HwpxReader();
  await reader.loadFromArrayBuffer(buffer);
  await reader.extractText();
} catch (e) {
  if (e instanceof HwpxEncryptedDocumentError) {
    // 암호화 HWPX — 미지원
  } else if (e instanceof InvalidHwpxFormatError) {
    // 유효하지 않은 HWPX
  } else if (e instanceof HwpxNotLoadedError) {
    // loadFromArrayBuffer 미호출
  } else if (e instanceof HwpEncryptedError) {
    // 암호화 HWP — 미지원
  } else if (e instanceof HwpUnsupportedError) {
    // 배포용 ViewText / HWP 3.0 등
  } else if (e instanceof HwpInvalidFormatError) {
    // CFB 시그니처 아님
  } else {
    throw e;
  }
}
```

### 브라우저(ESM) 사용

`dist/browser/hwp-convert.browser.mjs` 는 모든 의존성을 인라인한 단일 ESM 번들입니다. `package.json` 의 `exports.browser` 조건과 `./browser` 서브패스로 매핑되어 있습니다.

번들러(Vite/webpack) 사용 시:

```ts
// 번들러가 browser 조건을 자동 선택
import { hwpToText, parseHwp } from "hwp-convert";

// 명시적으로 브라우저 빌드 지정
import { hwpToText } from "hwp-convert/browser";
```

`<script type="module">` 직접 로드:

```html
<script type="module">
  import { hwpToText, hwpToHwpx } from "https://cdn.jsdelivr.net/npm/hwp-convert/dist/browser/hwp-convert.browser.mjs";

  document.querySelector("#file").addEventListener("change", async (e) => {
    const file = e.target.files[0];
    const bytes = new Uint8Array(await file.arrayBuffer());
    const text = await hwpToText(bytes);
    document.querySelector("#out").textContent = text;
  });
</script>
```

## CLI

설치 후 `hwpconvert` 또는 `hwp-convert` 명령으로 사용할 수 있습니다.

```bash
# HWPX 검사 / 추출
hwp-convert inspect document.hwpx
hwp-convert txt document.hwpx
hwp-convert html document.hwpx > out.html
hwp-convert md document.hwpx > out.md

# 그림 처리 (기본값이 명령마다 다릅니다: md=경로 참조, html=인라인)
hwp-convert md document.hwpx --embed-images > out.md      # base64 인라인으로 켜기
hwp-convert html document.hwpx --no-embed-images > out.html # 인라인 끄기(BinData 경로 참조)

# HWP 바이너리 (자동 라우팅: txt/md 명령은 .hwp 확장자 인식)
hwp-convert txt document.hwp
hwp-convert md document.hwp
hwp-convert hwp:txt document.hwp
hwp-convert hwp:md document.hwp > out.md
hwp-convert convert:hwp document.hwp converted.hwpx

# 작성 / 변환
hwp-convert write:txt notes.txt out.hwpx           # 평문 → HWPX
hwp-convert md:hwpx notes.md out.hwpx              # Markdown → HWPX
hwp-convert html:hwpx page.html out.hwpx           # HTML → HWPX

# 일괄 처리: HWPX → HTML
hwp-convert batch ./input ./output

# 템플릿 ({{key}} 치환)
hwp-convert html:tpl template.hwpx data.json > result.html
hwp-convert batch:tpl ./templates ./data ./output
```

## 아키텍처 개요

`src/lib/` 아래 모듈 구성:

| 모듈 | 역할 |
| --- | --- |
| `hwpxReader.ts` | HWPX(ZIP+XML) 읽기 — 매니페스트/spine 으로 섹션 순서 결정, `extractText`/`extractHtml`/`listImages` |
| `writer.ts` | 평문 → HWPX (`HwpxWriter.createFromPlainText`) |
| `errors.ts` | HWPX 측 에러 클래스 (`HwpxNotLoadedError` 등) |
| `types.ts` | HWPX 측 공개 타입 |
| `hwp/index.ts` | HWP 바이너리 진입점 — `detectFormat`/`parseHwp`/`hwpToText`/`hwpToHwpx` |
| `hwp/cfbReader.ts` | CFB(OLE2) 컨테이너 + raw deflate 압축 해제 (cfb + pako) |
| `hwp/fileHeader.ts` | FileHeader 256B — 시그니처/버전/11종 플래그 |
| `hwp/record.ts` | DocInfo/BodyText 공통 4바이트 레코드 헤더(tag/level/size + 확장 size) |
| `hwp/docInfo.ts` | DocInfo 스트림 — FACE_NAME/CHAR_SHAPE/PARA_SHAPE/STYLE/BORDER_FILL/NUMBERING/BULLET/TAB_DEF/BIN_DATA |
| `hwp/bodyText.ts` | BodyText 섹션 — PARA_HEADER/PARA_TEXT/PARA_CHAR_SHAPE 와 컨트롤 문자, 계층적 nested 문단 |
| `hwp/control.ts` | 인라인 컨트롤 — 표(rowSpan/colSpan) / 그림(GSO+SHAPE_PICTURE) / 머리말·꼬리말·각주 / 필드 / 도형 / 수식(EQEDIT) |
| `hwp/binData.ts` | 임베디드 바이너리(이미지/OLE) 추출 — OLE storage prefix 보정 |
| `hwp/converter.ts` | `HwpDocument` IR → 텍스트 / HWPX 라우팅 |
| `hwp/hwpxBuilder.ts` | `HwpDocument` → HWPX 패키지 합성 (header.xml refList 풀 출력, section.xml 실 ID 참조, BinData 패키징, PrvText 생성) |
| `hwp/types.ts` | HWP IR 타입 (`HwpDocument`, `HwpSection`, `HwpParagraph`, `HwpControl` …) |
| `hwp/tags.ts` | HWPTAG_* 상수 |
| `hwp/byteReader.ts` | LE u8/u16/u32, signed, UTF-16, HWP 문자열, 바운드 체크 |

`src/cli.ts` 는 위 라이브러리를 얇게 감싼 CLI 진입점이고, `scripts/build-browser.mjs` 는 esbuild 로 브라우저 번들을 만듭니다.

## 개발

```bash
# 빌드 (tsc → dist/, esbuild → dist/browser/hwp-convert.browser.mjs)
npm run build

# 테스트 (vitest, 316 tests / 30 files: 단위 + 통합 + e2e + 견고성)
npm test
npm run test:watch
```

테스트 픽스처는 `test/fixtures/` 또는 사용자의 `~/Documents/` 에서 자동 탐지합니다. 다음 파일들이 있으면 통합 테스트가 활성화됩니다:

- `1.hwp`, `1.hwpx` — 폼 양식 (다중 셀 표)
- `여름휴가 안내문.hwp` — 이미지 임베드 (PNG + JPG)
- `공고문(안)(26.4.24.).hwp` — 표 셀 병합 (rowSpan 2/3/5)

## 알려진 제한

- **한글 2014 VP for Mac**: 생성된 HWPX 에서 그림 위치가 어긋납니다. 해당 편집기의 결함으로 확인했습니다 — 한컴오피스가 저장한 정품 HWPX 도 같은 편집기에서 동일하게 어긋나고, 같은 파일이 Mac 의 한글 뷰어에서는 정상 렌더됩니다. **그 이후 버전을 대상으로 합니다.**
- 암호화 HWP / 배포용 ViewText / HWP 3.0 미지원 — 명시적 에러 발생
- 머리말/꼬리말/각주: 본문 흐름에 평탄 paragraph 로 출력 (별도 master page 매핑은 향후)
- BorderFill 그라데이션/이미지 채우기 미보존 (단색 채우기·4면 테두리·대각선은 보존)
- 번호 매기기 수준별 시작번호: 기본 1
- 차트(CHART_DATA) / OLE / 글맵시: 미지원
- 도형: `line` 의 좌표만 보존, 사각형/타원/호/다각형/곡선은 종류만 보존

## 구현 노트

- `HwpxReader` 는 `Contents/content.hpf` 의 manifest+spine 으로 섹션 순서를 결정하며, 실패 시 `Contents/section*.xml` 알파벳 순서로 폴백합니다.
- 암호화 감지는 `META-INF/manifest.xml` 의 `encrypt|cipher` 마커에 의존합니다(휴리스틱). 복호화는 미지원입니다.
- HWP 5.0 바이너리 파서는 [rhwp](https://github.com/edwardkim/rhwp) (MIT) 의 구조를 TypeScript 로 포팅 중이며, 각 포팅 파일 헤더에 출처가 명시되어 있습니다.

## 라이센스

MIT. HWP 바이너리 파서 부분은 rhwp (MIT, Copyright (c) 2025-2026 Edward Kim) 의 코드 구조를 TypeScript 로 포팅한 것이며 동일 MIT 라이센스에서 재배포됩니다. 자세한 내용은 [LICENSE](./LICENSE) 와 각 포팅 파일의 헤더 주석을 참조해 주세요.
