| 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
import * as CFB from 'cfb';
import pako from 'pako';
const data = fs.readFileSync('document.hwp');
const cfb = CFB.read(data);
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');
}
const isCompressed = (fileHeader.content[36] & 0x01) !== 0;
const section = CFB.find(cfb, '/BodyText/Section0');
if (section && section.content) {
let content = section.content;
if (isCompressed) {
content = pako.inflate(new Uint8Array(content));
}
}
Node.js / TypeScript โ Read HWPX
import JSZip from 'jszip';
const zip = await JSZip.loadAsync(fileBuffer);
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');
const textMatches = xml.match(/<hp:t[^>]*>([\s\S]*?)<\/hp:t>/g);
}
const imageFiles = Object.keys(zip.files)
.filter(f => /BinData\//i.test(f) && !f.endsWith('/'));
Python โ Read HWP Binary
import olefile
import zlib
import struct
def read_hwp(filepath):
ole = olefile.OleFileIO(filepath)
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)
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)
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
images[path] = img_data
ole.close()
return sections, images
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
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
import zipfile
import xml.etree.ElementTree as ET
def read_hwpx(filepath):
with zipfile.ZipFile(filepath, 'r') as z:
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)
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)
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)
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)
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;
if (ch === 0x0009) { text += '\t'; continue; }
if (ch === 0x000A) { text += '\n'; continue; }
if (EXTENDED_CTRL_CHARS.has(ch)) {
i += 14;
continue;
}
if (ch < 0x0020) continue;
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)
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;
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];
const m = {
top: margins.top * 100,
bottom: margins.bottom * 100,
left: margins.left * 100,
right: margins.right * 100,
};
const zip = new JSZip();
zip.file('mimetype', 'application/hwp+zip', { compression: 'STORE' });
zip.file('META-INF/manifest.xml', `<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/hwp+zip"/>
<manifest:file-entry manifest:full-path="Contents/content.hpf" manifest:media-type="application/xml"/>
<manifest:file-entry manifest:full-path="Contents/header.xml" manifest:media-type="application/xml"/>
<manifest:file-entry manifest:full-path="Contents/section0.xml" manifest:media-type="application/xml"/>
</manifest:manifest>`);
zip.file('Contents/header.xml', `<?xml version="1.0" encoding="UTF-8"?>
<hh:head xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head">
<hh:beginNum page="1"/>
<hh:refList>
<hh:fontfaces>
<hh:fontface lang="hangul">
<hh:font face="${escapeXml(fontFamily)}"/>
</hh:fontface>
</hh:fontfaces>
<hh:charProperties>
<hh:charPr>
<hh:sz val="${fontSize * 100}"/>
</hh:charPr>
</hh:charProperties>
</hh:refList>
<hh:secDef>
<hh:pageDef width="${w}" height="${h}"
marginTop="${m.top}" marginBottom="${m.bottom}"
marginLeft="${m.left}" marginRight="${m.right}"/>
</hh:secDef>
</hh:head>`);
const paragraphs = markdownToParagraphXml(markdown, fontSize);
zip.file('Contents/section0.xml', `<?xml version="1.0" encoding="UTF-8"?>
<hs:sec xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section">
${paragraphs}
</hs:sec>`);
zip.file('Contents/content.hpf', `<?xml version="1.0" encoding="UTF-8"?>
<opf:package xmlns:opf="http://www.idpf.org/2007/opf">
<opf:manifest>
<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>
<opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>
</opf:manifest>
<opf:spine>
<opf:itemref idref="section0"/>
</opf:spine>
</opf:package>`);
return await zip.generateAsync({ type: 'blob' });
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"');
}
Generate HWPX (Python)
import zipfile
import io
def generate_hwpx(text_content: str, output_path: str,
paper_size='A4', margins=(20, 20, 20, 20),
font_size=10, font_family='๋ง์ ๊ณ ๋'):
paper_sizes = {
'A4': (21000, 29700),
'Letter': (21590, 27940),
'B5': (17600, 25000),
}
w, h = paper_sizes[paper_size]
mt, mb, ml, mr = [m * 100 for m in margins]
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr(zipfile.ZipInfo('mimetype'), 'application/hwp+zip',
compress_type=zipfile.ZIP_STORED)
z.writestr('META-INF/manifest.xml', f'''<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/hwp+zip"/>
<manifest:file-entry manifest:full-path="Contents/content.hpf" manifest:media-type="application/xml"/>
<manifest:file-entry manifest:full-path="Contents/header.xml" manifest:media-type="application/xml"/>
<manifest:file-entry manifest:full-path="Contents/section0.xml" manifest:media-type="application/xml"/>
</manifest:manifest>''')
z.writestr('Contents/header.xml', f'''<?xml version="1.0" encoding="UTF-8"?>
<hh:head xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head">
<hh:secDef>
<hh:pageDef width="{w}" height="{h}"
marginTop="{mt}" marginBottom="{mb}"
marginLeft="{ml}" marginRight="{mr}"/>
</hh:secDef>
</hh:head>''')
paragraphs = []
for line in text_content.split('\n'):
escaped = line.replace('&', '&').replace('<', '<').replace('>', '>')
paragraphs.append(f' <hp:p><hp:run><hp:t>{escaped}</hp:t></hp:run></hp:p>')
section_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<hs:sec xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section"
xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
{"".join(paragraphs)}
</hs:sec>'''
z.writestr('Contents/section0.xml', section_xml)
z.writestr('Contents/content.hpf', '''<?xml version="1.0" encoding="UTF-8"?>
<opf:package xmlns:opf="http://www.idpf.org/2007/opf">
<opf:manifest>
<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>
<opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>
</opf:manifest>
</opf:package>''')
with open(output_path, 'wb') as f:
f.write(buf.getvalue())
Image Extraction
From HWP Binary (TypeScript)
import * as CFB from 'cfb';
function extractImages(cfb: CFB.CFBContainer, maxImages = 30, maxSizeKB = 200) {
const images: { name: string; data: string; mimeType: string }[] = [];
for (const entry of cfb.FileIndex) {
if (!entry.name || !entry.content) continue;
const path = CFB.utils.cfb_path(entry);
if (!path.includes('BinData/')) continue;
if (images.length >= maxImages) break;
let data = new Uint8Array(entry.content);
try {
data = pako.inflate(data);
} catch {
try { data = pako.inflateRaw(data); } catch { }
}
if (data.length > maxSizeKB * 1024) continue;
const mimeType = detectMimeType(data);
if (!mimeType) continue;
const base64 = btoa(String.fromCharCode(...data));
images.push({
name: entry.name,
data: `data:${mimeType};base64,${base64}`,
mimeType,
});
}
return images;
}
function detectMimeType(data: Uint8Array): string | null {
if (data[0] === 0xFF && data[1] === 0xD8) return 'image/jpeg';
if (data[0] === 0x89 && data[1] === 0x50) return 'image/png';
if (data[0] === 0x47 && data[1] === 0x49) return 'image/gif';
if (data[0] === 0x42 && data[1] === 0x4D) return 'image/bmp';
return null;
}
From HWPX (Python)
import zipfile
import mimetypes
def extract_hwpx_images(filepath, output_dir='./images'):
os.makedirs(output_dir, exist_ok=True)
with zipfile.ZipFile(filepath, 'r') as z:
for name in z.namelist():
if name.startswith('BinData/') and not name.endswith('/'):
data = z.read(name)
filename = os.path.basename(name)
out_path = os.path.join(output_dir, filename)
with open(out_path, 'wb') as f:
f.write(data)
print(f'Extracted: {filename} ({len(data)} bytes)')
Table Handling
HWP tables can be simple (GFM markdown) or complex (colspan/rowspan โ HTML).
Detect Complex Tables
function isComplexTable(rows: TableRow[]): boolean {
return rows.some(row =>
row.cells.some(cell => cell.colspan > 1 || cell.rowspan > 1)
);
}
Render as Markdown Table
function renderMarkdownTable(rows: TableRow[]): string {
if (rows.length === 0) return '';
const colCount = rows[0].cells.length;
const header = '| ' + rows[0].cells.map(c => c.text).join(' | ') + ' |';
const separator = '| ' + Array(colCount).fill('---').join(' | ') + ' |';
const body = rows.slice(1).map(row =>
'| ' + row.cells.map(c => c.text).join(' | ') + ' |'
).join('\n');
return `${header}\n${separator}\n${body}`;
}
Render as HTML Table (for complex layouts)
function renderHtmlTable(rows: TableRow[]): string {
let html = '<table>\n';
rows.forEach((row, i) => {
html += ' <tr>\n';
const tag = i === 0 ? 'th' : 'td';
row.cells.forEach(cell => {
const attrs: string[] = [];
if (cell.colspan > 1) attrs.push(`colspan="${cell.colspan}"`);
if (cell.rowspan > 1) attrs.push(`rowspan="${cell.rowspan}"`);
html += ` <${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>${cell.text}</${tag}>\n`;
});
html += ' </tr>\n';
});
html += '</table>';
return html;
}
Web Worker Pattern (Browser)
For non-blocking HWP processing in the browser:
const worker = new Worker(
new URL('./workers/hwp-parser.worker.ts', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e) => {
const { type, progress, message, markdown, warnings, error } = e.data;
switch (type) {
case 'progress':
console.log(`${progress}% - ${message}`);
break;
case 'complete':
console.log('Markdown:', markdown);
if (warnings?.length) console.warn('Warnings:', warnings);
break;
case 'error':
console.error('Error:', error);
break;
}
};
const fileBuffer = await file.arrayBuffer();
worker.postMessage({
type: 'parse',
data: fileBuffer,
fileName: file.name,
options: { extractImages: true, convertTables: true },
});
Command-Line Tools
hwp5txt (pyhwp)
pip install pyhwp
hwp5txt document.hwp
hwp5html document.hwp
hwp5odt --metadata document.hwp
LibreOffice (batch conversion)
libreoffice --headless --convert-to pdf document.hwp
libreoffice --headless --convert-to docx document.hwp
libreoffice --headless --convert-to pdf *.hwp
Performance & Safety Limits
When processing HWP files, apply these safeguards:
| Limit | Value | Reason |
|---|
| Max images | 30 | Prevent memory exhaustion |
| Max image size | 200 KB | Skip oversized embedded images |
| Max content size | 2 MB text | Prevent DOM/editor overload |
| Base64 chunk size | 8 KB | Avoid stack overflow in btoa() |
| Compression fallback | inflate โ inflateRaw | Handle both zlib and raw deflate |
Dependencies
JavaScript/TypeScript
| Package | Purpose |
|---|
cfb | OLE2/CFB compound file parsing (HWP binary) |
pako | DEFLATE compression/decompression |
jszip | ZIP archive creation/extraction (HWPX) |
marked | Markdown โ HTML conversion (for HWPX generation) |
file-saver | Client-side file downloads |
Python
| Package | Purpose |
|---|
olefile | OLE2 compound file parsing (HWP binary) |
pyhwp | HWP text/HTML extraction CLI |
python-hwp | HWP/HWPX parsing library |
Quick Reference
| Task | Best Tool | Language |
|---|
| Read HWP binary | cfb + pako / olefile | TS / Python |
| Read HWPX | JSZip / zipfile | TS / Python |
| Extract text | Record parser / pyhwp | TS / Python |
| Extract images | BinData/ traversal | Both |
| Extract tables | Record tree โ Markdown/HTML | TS |
| Create HWPX | JSZip / zipfile | Both |
| Batch convert | LibreOffice headless | CLI |
| Non-blocking (browser) | Web Worker | TS |
Next Steps
- For MDView-specific implementation details (React hooks, progress UI, worker communication protocol), see REFERENCE.md
- For table/image pipeline patterns, see the
table-image-pipeline skill
- For large document performance optimization, see the
large-doc-perf skill