HWP 5.0 Record Format¶
Working notes for the record-level binary format used in BodyText/Section*
and DocInfo streams. Distilled from pyhwp, the HWP 5.0 spec, and what
Hancom actually accepts in practice.
For how these binary records line up with their HWPX (OWPML) twins — and why the same
CharShape,BorderFill, and HWPUNIT knowledge applies to both formats — see OBJECT_MODEL.md.
Stream compression¶
BodyText/Section* and DocInfo are raw deflate (no zlib header).
import zlib
zlib.decompress(stream_bytes, -15) # decompress
zlib.compressobj(9, zlib.DEFLATED, -15) # compress
The FileHeader stream's 32-bit value at offset 36 indicates compression:
bit 0 set → streams are compressed. Standard HWP files set this bit; some
templates exported from third-party tools don't. Always check.
Record header¶
Each record begins with a little-endian uint32:
| Bits | Field |
|---|---|
| 0–9 | tag_id |
| 10–19 | level |
| 20–31 | size |
If size == 0xFFF (4095), the actual size is in the next 4 bytes,
making the header 8 bytes total. hwpkit.records tracks this via
force_extended_header on records that need the long form.
Paragraph records¶
A paragraph is this sequence of records in order:
| Tag | Name | Notes |
|---|---|---|
0x42 |
PARA_HEADER |
First 4 bytes = char count (uint32). High bit 0x80000000 is a "control paragraph" flag — preserve it when updating count. |
0x43 |
PARA_TEXT |
UTF-16LE bytes. Use \n for soft line breaks, \r only as paragraph terminator. Char count must equal len(text + "\r"). |
0x44 |
PARA_CHAR_SHAPE |
List of (start_pos, shape_id) pairs. A single (0, shape_id) entry remains valid for any new char count — Hancom inherits the shape. |
0x45 |
PARA_LINE_SEG |
Cached line layout. See GOTCHAS.md §2. |
Other tags encountered in BodyText: 0x47 CTRL_HEADER, 0x48
LIST_HEADER, 0x4D TABLE.
Empty paragraph¶
A paragraph that is "empty" in Hancom's UI has:
PARA_HEADER.chars == 1(the implicit trailing\r)- no
PARA_TEXTrecord
To fill it: update char count, insert PARA_TEXT after PARA_HEADER,
dummy-out PARA_LINE_SEG. hwpkit.records.inject_text handles all
three. A paragraph that was previously non-empty is a different
state — see GOTCHAS.md §4.
LineSeg struct (36 bytes per entry)¶
| Offset | Type | Field |
|---|---|---|
| 0 | int32 | chpos |
| 4 | int32 | y |
| 8 | int32 | height |
| 12 | int32 | height_text |
| 16 | int32 | height_baseline |
| 20 | int32 | space_below |
| 24 | int32 | x |
| 28 | int32 | width |
| 32 | uint32 | flags (default 0x00060000 = bits 17+18 = line_head + line_tail) |
Units are HWPUNIT = 1/7200 inch. Typical values: cell width 43764, line height ~1100, space_below ~332.
CharShape (DocInfo tag 0x15)¶
Body layout — first 46 bytes (the rest is color / attr / border data):
| Offset | Type | Field |
|---|---|---|
| 0 | 7 × uint16 | face_name_ids per script |
| 14 | 7 × uint8 | ratios |
| 21 | 7 × int8 | char_spacings |
| 28 | 7 × uint8 | rel_sizes |
| 35 | 7 × int8 | char_offsets |
| 42 | int32 | base_size (1/100 pt; 1100 = 11pt) |
Per-script slot order: Hangul, Latin, Hanja, Japanese, Symbol, User, Other. See GOTCHAS.md §3.
Embedded images¶
Reverse-engineered from a real multi-image HWP. Inserting a picture touches three places — the CFB container, DocInfo, and BodyText.
1. CFB container — the bytes¶
The image bytes live in a stream BinData/BIN%04d.<ext> (decimal id,
original extension preserved — e.g. BIN0003.png). Compression follows
the BIN_DATA record's attribute bits (below); with the default the
stream is raw-deflate like every other stream. Adding this stream means
splicing a node into the directory red-black tree — use
cfb.add_stream / cfb.add_storage (creates the BinData storage if
absent). See GOTCHAS.md §1.
2. DocInfo — register the binary¶
ID_MAPPINGS(tag0x11): an array ofint32counts; index 0 is the BinData count. Increment it by one per image added.BIN_DATA(tag0x12), appended after the existingBIN_DATArecords (level 1), body:
| Offset | Type | Field |
|---|---|---|
| 0 | uint16 | attr — 0x0001 = type embedding. Low nibble = type (0 link / 1 embed / 2 storage); bits 4–5 = compress (0 default / 1 force / 2 none). |
| 2 | uint16 | bin_id — matches the %04d in the stream name |
| 4 | uint16 | ext_len — extension length in UTF-16 code units |
| 6 | UTF-16LE | extension, e.g. png |
Observed: 0100 0100 0300 6a0070006700 = embed, id 1, "jpg".
3. BodyText — the picture object + its anchor¶
A picture is a GSO (general shape object). In the host paragraph:
- Inline anchor in
PARA_TEXT: an extended control occupying 8 code units (16 bytes) —[0x0B]+ ctrl-id"gso "stored reversed (20 6f 73 67) + 8 zero bytes +[0x0B]. The0x0Bcount (here 8) plus the trailing0x0Dis the paragraph'schars. ReplacePARA_LINE_SEGwith the 36-byte dummy (GOTCHAS §2). CTRL_HEADER(0x47), ctrl-id"gso "— common object position/ size attributes (HWPUNIT).SHAPE_COMPONENT(0x4C), ctrl-id"$pic", then 4×4 transform matrices as IEEE-754double(identity =00 00 00 00 00 00 f0 3f).SHAPE_COMPONENT_PICTURE(0x55), 91 bytes observed: border line/fill, 4 corner points (image rect, HWPUNIT), crop, inner margins, then thebin_id(uint16) at offset 71, then bright/contrast/ effect. Extents are pixels × 75 (see OBJECT_MODEL.md).
So: register bytes (1+2), then anchor the GSO chain (3) in a target
paragraph, with the picture record pointing back at the bin_id.