- name
- hwp
- description
- Use this skill whenever the user wants to do anything with HWP or HWPX files. This includes reading or extracting text/tables/images from HWP files, converting HWP to markdown or other formats, creating HWPX files from markdown/text, parsing HWP binary (OLE2/CFB) or HWPX (ZIP/XML) formats, and handling Korean document processing. If the user mentions a .hwp or .hwpx file, or asks about 한글 문서 processing, use this skill.
# HWP/HWPX Processing Guide
## Overview
HWP is the native format for Hancom's "한글" word processor, widely used in South Korea. Two formats exist:
| Format | Container | Detection | Extension |
|--------|-----------|-----------|-----------|
| **HWP (Binary)** | OLE2/CFB compound file | Magic bytes `0xD0 0xCF 0x11 0xE0` | `.hwp` |
| **HWPX** | ZIP archive with XML | Magic bytes `0x50 0x4B` (PK) | `.hwpx` |
For advanced patterns used in the MDView project (Web Workers, React hooks, progress tracking), see REFERENCE.md.
## Quick Start
### Node.js / TypeScript — Read HWP Binary
```typescript
import * as CFB from 'cfb';
import pako from 'pako';
// Read HWP binary file
const data = fs.readFileSync('document.hwp');
const cfb = CFB.read(data);
// Check encryption
const fileHeader = CFB.find(cfb, '/FileHeader');
if (fileHeader) {
const flags = fileHeader.content[36] | (fileHeader.content[37] << 8);
const isEncrypted = (flags & 0x02) !== 0;
if (isEncrypted) throw new Error('Encrypted HWP files are not supported');
}
// Check compression flag
const isCompressed = (fileHeader.content[36] & 0x01) !== 0;
// Read body sections
const section = CFB.find(cfb, '/BodyText/Section0');
if (section && section.content) {
let content = section.content;
if (isCompressed) {
content = pako.inflate(new Uint8Array(content));
}
// Parse binary records from content...
}
```
### Node.js / TypeScript — Read HWPX
```typescript
import JSZip from 'jszip';
const zip = await JSZip.loadAsync(fileBuffer);
// Find section XML files
const sectionFiles = Object.keys(zip.files)
.filter(f => /Contents\/section\d+\.xml/i.test(f))
.sort();
for (const sectionFile of sectionFiles) {
const xml = await zip.file(sectionFile)!.async('string');
// Parse XML to extract text from <hp:t> tags
const textMatches = xml.match(/<hp:t[^>]*>([\s\S]*?)<\/hp:t>/g);
// ...
}
// Extract images from BinData/
const imageFiles = Object.keys(zip.files)
.filter(f => /BinData\//i.test(f) && !f.endsWith('/'));
```
### Python — Read HWP Binary
```python
import olefile
import zlib
import struct
def read_hwp(filepath):
ole = olefile.OleFileIO(filepath)
# Check encryption
header = ole.openstream('FileHeader').read()
flags = struct.unpack_from('<I', header, 36)[0]
if flags & 0x02:
raise ValueError('Encrypted HWP files are not supported')
is_compressed = bool(flags & 0x01)
# Read body sections
sections = []
for entry in ole.listdir():
path = '/'.join(entry)
if path.startswith('BodyText/Section'):
data = ole.openstream(path).read()
if is_compressed:
data = zlib.decompress(data, -15)
sections.append(data)
# Extract images from BinData
images = {}
for entry in ole.listdir():
path = '/'.join(entry)
if path.startswith('BinData/'):
img_data = ole.openstream(path).read()
try:
img_data = zlib.decompress(img_data, -15)
except zlib.error:
pass # Not compressed
images[path] = img_data
ole.close()
return sections, images
# Inline extended control chars: each occupies 8 UTF-16 code units (16 bytes).
# After reading the 2-byte char code, skip 14 more bytes.
EXTENDED_CTRL_CHARS = frozenset({
1, 2, 3, 4, 5, 6, 7, 8, 11, 12, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23
})
def extract_para_text(data):
"""Extract UTF-16 LE text from HWPTAG_PARA_TEXT record data."""
text = ''
i = 0
while i < len(data) - 1:
ch = struct.unpack_from('<H', data, i)[0]
i += 2
if ch == 0x000D:
break
elif ch == 0x0009:
text += '\t'
elif ch == 0x000A:
text += '\n'
elif ch in EXTENDED_CTRL_CHARS:
i += 14 # Skip 14 bytes of inline control data
elif ch < 0x0020:
continue
else:
text += chr(ch)
return text
def parse_records(data):
"""Parse binary records and extract text from HWPTAG_PARA_TEXT (tag_id=67)."""
texts = []
offset = 0
while offset < len(data) - 4:
header = struct.unpack_from('<I', data, offset)[0]
offset += 4
tag_id = header & 0x3FF
size = (header >> 20) & 0xFFF
if size == 0xFFF:
if offset + 4 > len(data):
break
size = struct.unpack_from('<I', data, offset)[0]
offset += 4
if offset + size > len(data):
break
record_data = data[offset:offset + size]
offset += size
if tag_id == 67 and size > 0:
text = extract_para_text(record_data)
if text.strip():
texts.append(text)
return texts
```
### Python — Read HWPX
```python
import zipfile
import xml.etree.ElementTree as ET
def read_hwpx(filepath):
with zipfile.ZipFile(filepath, 'r') as z:
# List section files
section_files = sorted([
f for f in z.namelist()
if f.startswith('Contents/section') and f.endswith('.xml')
])
text_content = []
for sf in section_files:
xml_data = z.read(sf).decode('utf-8')
root = ET.fromstring(xml_data)
# Extract text from hp:t elements
ns = {'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph'}
for t in root.iter('{http://www.hancom.co.kr/hwpml/2011/paragraph}t'):
if t.text:
text_content.append(t.text)
# Extract images
images = {
f: z.read(f) for f in z.namelist()
if f.startswith('BinData/') and not f.endswith('/')
}
return text_content, images
```
## HWP Binary Record Format
HWP binary files store content as sequential records:
```
Record Header (4 bytes):
- Tag ID: bits 0-9 (10 bits) — identifies record type
- Level: bits 10-19 (10 bits) — nesting depth
- Size: bits 20-31 (12 bits) — data size (if 0xFFF, next 4 bytes = actual size)
```
### Key Record Tag IDs
| Constant | Tag ID | Description |
|----------|--------|-------------|
| `HWPTAG_PARA_HEADER` | 66 | Paragraph header |
| `HWPTAG_PARA_TEXT` | 67 | Paragraph text (UTF-16 LE) |
| `HWPTAG_CTRL_HEADER` | 71 | Control header (tables, images) |
| `HWPTAG_LIST_HEADER` | 72 | List header (cell content) |
| `HWPTAG_TABLE` | 77 | Table definition |
### Text Encoding
Paragraph text is encoded as **UTF-16 LE** with special control characters:
| Char Code | Meaning | Type |
|-----------|---------|------|
| `0x0001-0x0008` | Inline extended controls (reserved, section, field start/end, etc.) | **Extended** — 8 UTF-16 code units total (16 bytes). Skip 14 bytes after the 2-byte char code. |
| `0x0009` | Tab | Simple |
| `0x000A` | Line break | Simple |
| `0x000B` | Drawing/table placeholder | **Extended** — skip 14 bytes after char code |
| `0x000C` | Reserved | **Extended** — skip 14 bytes after char code |
| `0x000D` | Paragraph end | Simple (terminates text) |
| `0x000E-0x0017` | Extended controls (hidden comment, header/footer, footnote, auto number, bookmark, etc.) | **Extended** — skip 14 bytes after char code |
| `0x0020` | Space | Simple |
| `0x00A0` | Non-breaking space | Simple |
| `0x00AD` | Soft hyphen | Simple |
> **CRITICAL**: Inline extended control characters (codes 1-8, 11-12, 14-23) each occupy **8 UTF-16 code units** (16 bytes total). After reading the 2-byte character code, you MUST skip the next **14 bytes** of control data. Failing to do so causes binary metadata to leak into extracted text as garbage characters (e.g., `捤獥汤捯`, `汫╨`, `ȃ`).
### Parsing Records (TypeScript)
```typescript
interface HwpRecord {
tagId: number;
level: number;
size: number;
data: Uint8Array;
}
function parseRecords(buffer: Uint8Array): HwpRecord[] {
const records: HwpRecord[] = [];
let offset = 0;
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
while (offset < buffer.length - 4) {
const header = view.getUint32(offset, true);
offset += 4;
const tagId = header & 0x3FF;
const level = (header >> 10) & 0x3FF;
let size = (header >> 20) & 0xFFF;
if (size === 0xFFF) {
size = view.getUint32(offset, true);
offset += 4;
}
const data = buffer.slice(offset, offset + size);
offset += size;
records.push({ tagId, level, size, data });
}
return records;
}
```
### Extracting Text from Records (TypeScript)
```typescript
// Inline extended control chars: each occupies 8 UTF-16 code units (16 bytes).
// After reading the 2-byte char code, skip 14 more bytes.
const EXTENDED_CTRL_CHARS = new Set([
1, 2, 3, 4, 5, 6, 7, 8, 11, 12, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23
]);
function extractParaText(data: Uint8Array): string {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
let text = '';
let i = 0;
while (i < data.length - 1) {
const ch = view.getUint16(i, true);
i += 2;
if (ch === 0x000D) break; // Paragraph end
if (ch === 0x0009) { text += '\t'; continue; }
if (ch === 0x000A) { text += '\n'; continue; }
if (EXTENDED_CTRL_CHARS.has(ch)) {
i += 14; // Skip 14 bytes of inline control data
continue;
}
if (ch < 0x0020) continue; // Other control chars
text += String.fromCharCode(ch);
}
return text;
}
```
## Creating HWPX Files
HWPX is a ZIP archive with the following structure:
```
document.hwpx
├── mimetype (uncompressed, "application/hwp+zip")
├── META-INF/
│ └── manifest.xml (file entries)
├── Contents/
│ ├── content.hpf (package manifest)
│ ├── header.xml (page layout, fonts, styles)
│ └── section0.xml (main content)
└── BinData/
├── image1.jpg (embedded images)
└── image2.png
```
### Generate HWPX (TypeScript)
```typescript
import JSZip from 'jszip';
async function generateHwpx(
markdown: string,
options: {
paperSize?: 'A4' | 'Letter' | 'B5';
orientation?: 'portrait' | 'landscape';
margins?: { top: number; bottom: number; left: number; right: number }; // mm
fontSize?: number; // pt
fontFamily?: string;
} = {}
): Promise<Blob> {
const {
paperSize = 'A4',
orientation = 'portrait',
margins = { top: 20, bottom: 20, left: 20, right: 20 },
fontSize = 10,
fontFamily = '맑은 고딕',
} = options;
// Paper sizes in 1/100mm (HWPX unit)
const paperSizes = {
A4: { w: 21000, h: 29700 },
Letter: { w: 21590, h: 27940 },
B5: { w: 17600, h: 25000 },
};
const paper = paperSizes[paperSize];
const [w, h] = orientation === 'portrait'
? [paper.w, paper.h]
: [paper.h, paper.w];
// Convert margins mm → 1/100mm
const m = {
top: margins.top * 100,
bottom: margins.bottom * 100,
left: margins.left * 100,
right: margins.right * 100,
};
GitHubで見る