Help us shape the future of Go! Take the 2026 Go Developer Survey

hwprec

package module
v0.0.0-...-19b9ffd Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 28, 2026 License: MIT Imports: 12 Imported by: 0

README

hwprec

Read HWP 5.0 documents (한글, the .hwp binary format every Korean public institution publishes in) from Go as a tree of typed records — not as text rendered into an io.Writer.

doc, err := hwprec.OpenFile("공고.hwp")
paras, err := hwprec.Paragraphs(doc.Sections[0])
for _, p := range paras {
    fmt.Println(p.Text)                     // characters only; controls are in p.Controls
    for _, t := range p.Tables {            // tables anchored in this paragraph
        for _, c := range t.Cells {         // c.Row, c.Col, c.RowSpan, c.ColSpan, c.Paragraphs
        }
    }
}

Why

Every Go program that needs to look inside a .hwp today carries its own parser under internal/: roboco-io/hwp2md (2,000 lines, exports a Markdown converter), hanpama/hwp (exports three text-dump functions), hkjang/muni (converts straight into its own document model), plus text-only extractors. None of them lets you import the record layer — the tag/level/size stream, the paragraph and cell records — and build something else on top. hwprec is that layer, with the parts that carry text and table topology decoded and everything else left as a raw Record you can decode yourself from Hancom's published specification.

Two things fell out of building it against a real 문화체육관광부 공고:

  • Cell attributes start at byte 8 of a cell's LIST_HEADER, not byte 6. A literal reading of 표 79 (표 65 list header, 6 bytes, then 표 80 cell attributes, 26 bytes) is off by two undocumented bytes. At offset 6, 406 of the fixture's 822 cells land outside their tables; at offset 8 all 822 tile their tables exactly (Σ colspan×rowspan = Σ rows×cols = 2,050 over 68 tables). 408 of those cells are merged; hwp2md hard-codes every span to 1.
  • A list's paragraphs are stored at the same record level as their LIST_HEADER, not one deeper, so in a level tree they are its following siblings. The spec's 표 79 lists them after the header without saying so.

Install

go install github.com/doxuta/hwprec/cmd/hwprec@latest

Requires Go 1.25+. One dependency: richardlehane/mscfb for the OLE2 compound file container.

Try it

git clone https://github.com/doxuta/hwprec && cd hwprec
go run ./cmd/hwprec testdata/mcst-2024-0042.hwp | head -20
문화체육관광부 공고 제2024 - 0042호
[0,0]
[0,0]
[0,1]
[0,0]
[0,1]
[0,2]
[0,3]
[1,0]
[1,1]
[1,2]
[1,3](3x1)
[2,0]
[2,1]
[2,2]
[3,0]
[3,1]
[3,2]
 ※ 근무실적 등이 우수한 경우 행정안전부 협의 및 관련 법령에 따라 연장 가능
[0,0]

Cells print as [row,col], merged cells as [row,col](rowspan×colspan), cell paragraphs indented beneath. -tables prints every table as JSON; -tree prints the record tree with tag names and sizes.

What is decoded

Record Type Notes
FileHeader stream FileHeader version, compressed / encrypted / 배포용 / KOGL flags
any record Record{Tag, Level, Data, Children} tree by level; 0xFFF extended-size escape
PARA_HEADER ParaHeader 표 58
PARA_TEXT Text{Text, Controls} 표 6: char controls are 1 UTF-16 unit, inline and extended controls are 8; each control keeps its code, offset and 12-byte info; a control not closed by its own code is an error rather than a silent desync
PARA_CHAR_SHAPE []CharShapeRun 표 61
CTRL_HEADER CtrlID() four-character id in reading order (tbl , secd, gso …)
TABLE Table 표 75: rows, cols, cell spacing
LIST_HEADER (of a table) Cell 표 65 + 표 80: address, spans, size, margins; Paragraphs decoded recursively
LIST_HEADER (anything else) Paragraph.Nested text boxes, headers/footers, footnotes, comments — paragraphs collected so no text is lost, container not typed

Paragraph.Record keeps the raw subtree, so shapes, fields, footnote bodies and everything in DocInfo stay reachable as bytes.

Limitations

  • One producer, one bundled fixture. The two undocumented cell bytes and the same-level list quirk were established on testdata/mcst-2024-0042.hwp, written by 한글 5.0.5.0. A second file from a different ministry written by 한글 5.1.0.1 (566 paragraphs, 7 tables, 538 cells, 30 merged) parses with the same offsets and the same tiling identity, but is not bundled because it lists applicants' masked names and phone-number fragments. Files from other producers may differ. hwprec does not check that a cell lies inside its table; if you depend on cell bounds, check them yourself (the tests do).
  • 배포용 (distribution) documents are refused with ErrDistribution. The decryption is specified (seed → MSVC rand() array → XOR → SHA-1 hash → AES-128-ECB) and hanpama/hwp implements it, but without a fixture or a test vector it is not implemented here.
  • Password-encrypted documents are refused with ErrEncrypted.
  • DocInfo is returned raw. Character shapes, fonts, paragraph shapes, styles and numbering are not decoded, so CharShapeRun.ShapeID cannot yet be resolved to a font or size.
  • Not decoded: shapes, pictures, equations, fields, footnote/header bodies (their paragraphs are collected, their containers are not), PARA_LINE_SEG, PARA_RANGE_TAG.
  • HWPX (the XML successor format, now the default on many government sites) is a different container and out of scope.
  • Streams are assumed to be raw DEFLATE when the compressed flag is set, which is what every producer seen so far writes.

Prior art and credits

  • roboco-io/hwp2md (MIT) — its internal/parser/hwp5 was the reference for record and paragraph layouts; hwprec differs in exposing the tree, decoding cell spans and keeping control payloads out of text.
  • hanpama/hwp (MIT) — the only Go implementation of 배포용 decryption; not ported here.
  • mete0r/pyhwp — the long-standing Python reference for the format.
  • richardlehane/mscfb (Apache-2.0) — compound file reader.
  • The fixture is 문화체육관광부 공고 제2024-0042호, a government public notice excluded from copyright by 저작권법 제7조 제2호.

본 제품은 한글과컴퓨터의 한글 문서 파일(.hwp) 공개 문서를 참고하여 개발하였습니다. (Developed with reference to Hancom's published HWP file format documents, as their licence asks derived work to state.)

Built with an AI coding agent under human review, like the other repositories on this account. The offsets, counts and the tiling identity above were measured, not assumed; the tests pin every one of them.

License

MIT.

Documentation

Overview

Package hwprec reads HWP 5.0 documents (한글 워드프로세서, the .hwp binary format used by every Korean public institution) as a tree of typed records.

It exposes the layer that existing Go HWP tools keep private: the record stream itself (tag, level, size, with the 0xFFF extended-size escape), assembled into a tree by level, plus typed decoders for the records that carry text and table topology — paragraph headers, paragraph text with its control characters, character-shape runs, control headers, tables and cells. Everything else is returned as a raw Record for the caller to decode.

Not implemented: password-encrypted documents and 배포용 (distribution) documents are detected and refused, and DocInfo records are returned raw.

본 제품은 한글과컴퓨터의 한글 문서 파일(.hwp) 공개 문서를 참고하여 개발하였습니다. (This package was developed with reference to Hancom's published HWP document file format specification, as its licence requires us to state.)

Index

Constants

View Source
const (
	CtrlSectionColumnDef = 2  // extended: 구역 정의 / 단 정의
	CtrlFieldStart       = 3  // extended
	CtrlFieldEnd         = 4  // inline
	CtrlTitleMark        = 8  // inline
	CtrlTab              = 9  // inline
	CtrlLineBreak        = 10 // char
	CtrlObject           = 11 // extended: 그리기 개체 / 표
	CtrlParaBreak        = 13 // char
	CtrlHiddenComment    = 15 // extended
	CtrlHeaderFooter     = 16 // extended
	CtrlFootnote         = 17 // extended
	CtrlAutoNumber       = 18 // extended
	CtrlPageControl      = 21 // extended
	CtrlBookmark         = 22 // extended
	CtrlOverlap          = 23 // extended
	CtrlHyphen           = 24 // char
	CtrlNBSP             = 30 // char: 묶음 빈칸
	CtrlFixedSpace       = 31 // char: 고정폭 빈칸
)

Control codes from 표 6.

View Source
const (
	TagDocumentProperties  = tagBegin + 0
	TagIDMappings          = tagBegin + 1
	TagBinData             = tagBegin + 2
	TagFaceName            = tagBegin + 3
	TagBorderFill          = tagBegin + 4
	TagCharShape           = tagBegin + 5
	TagTabDef              = tagBegin + 6
	TagNumbering           = tagBegin + 7
	TagBullet              = tagBegin + 8
	TagParaShape           = tagBegin + 9
	TagStyle               = tagBegin + 10
	TagDocData             = tagBegin + 11
	TagDistributeDocData   = tagBegin + 12
	TagCompatibleDocument  = tagBegin + 14
	TagLayoutCompatibility = tagBegin + 15
	TagTrackChange         = tagBegin + 16
	TagMemoShape           = tagBegin + 76
	TagForbiddenChar       = tagBegin + 78
	TagTrackChangeAuthor   = tagBegin + 80

	TagParaHeader     = tagBegin + 50
	TagParaText       = tagBegin + 51
	TagParaCharShape  = tagBegin + 52
	TagParaLineSeg    = tagBegin + 53
	TagParaRangeTag   = tagBegin + 54
	TagCtrlHeader     = tagBegin + 55
	TagListHeader     = tagBegin + 56
	TagPageDef        = tagBegin + 57
	TagFootnoteShape  = tagBegin + 58
	TagPageBorderFill = tagBegin + 59
	TagShapeComponent = tagBegin + 60
	TagTable          = tagBegin + 61
	TagShapeLine      = tagBegin + 62
	TagShapeRectangle = tagBegin + 63
	TagShapeEllipse   = tagBegin + 64
	TagShapeArc       = tagBegin + 65
	TagShapePolygon   = tagBegin + 66
	TagShapeCurve     = tagBegin + 67
	TagShapeOLE       = tagBegin + 68
	TagShapePicture   = tagBegin + 69
	TagShapeContainer = tagBegin + 70
	TagCtrlData       = tagBegin + 71
	TagEqEdit         = tagBegin + 72
	TagShapeTextArt   = tagBegin + 74
	TagFormObject     = tagBegin + 75
	TagMemoList       = tagBegin + 77
	TagChartData      = tagBegin + 79
	TagVideoData      = tagBegin + 82
	TagShapeUnknown   = tagBegin + 99
)

Record tag IDs from the specification (표 57 and 표 7). HWPTAG_BEGIN is 0x10.

Variables

View Source
var (
	ErrNotHWP       = errors.New("hwprec: not an HWP 5.0 document")
	ErrEncrypted    = errors.New("hwprec: document is password-encrypted; not supported")
	ErrDistribution = errors.New("hwprec: 배포용 (distribution) document; decryption not implemented")
)

Functions

func CtrlID

func CtrlID(b []byte) string

CtrlID returns the four-character control id of a CTRL_HEADER payload (spec 표 64), in reading order, e.g. "tbl ", "secd", "cold", "gso ". The file stores it as a little-endian MAKE_4CHID, so bytes are reversed.

func TagName

func TagName(tag uint16) string

TagName returns the HWPTAG_ name of a tag, or "TAG_0x.." for unknown ones.

func Walk

func Walk(recs []*Record, fn func(*Record))

Walk calls fn for every record in depth-first order.

Types

type Cell

type Cell struct {
	ListHeader
	Col, Row         uint16
	ColSpan, RowSpan uint16
	Width, Height    uint32 // HWPUNIT (1/7200 inch)
	Margins          [4]uint16
	BorderFillID     uint16
	Paragraphs       []*Paragraph
}

Cell is a table cell: the LIST_HEADER of a table's cell list plus the 26-byte 셀 속성 (spec 표 80).

The cell attributes start at byte 8 of the payload, not at byte 6 as a literal reading of 표 79 (표 65 + 표 80) gives: two bytes the specification does not list sit between them. At offset 6, 406 of the 822 cells in the bundled fixture land outside their tables; at 8, all 822 tile their tables exactly. Producers seen so far: 한글 5.0.5.0.

func DecodeCell

func DecodeCell(b []byte) (Cell, error)

DecodeCell decodes a LIST_HEADER payload that belongs to a table.

type CharShapeRun

type CharShapeRun struct {
	Pos     uint32 // UTF-16 offset
	ShapeID uint32
}

CharShapeRun is one entry of HWPTAG_PARA_CHAR_SHAPE (spec 표 61): the character shape (a DocInfo CHAR_SHAPE index) that applies from Pos onward.

func DecodeParaCharShape

func DecodeParaCharShape(b []byte) ([]CharShapeRun, error)

DecodeParaCharShape decodes a PARA_CHAR_SHAPE payload.

type Control

type Control struct {
	Code uint16    // 0–31
	Pos  int       // offset in UTF-16 units, the unit PARA_CHAR_SHAPE positions use
	Info [6]uint16 // zero for char controls
}

Control is one control character found in paragraph text (spec 표 6). Char controls occupy one UTF-16 unit; inline and extended controls occupy eight: the code, six units of info (inline) or an object pointer (extended), and the code again.

func (Control) Extended

func (c Control) Extended() bool

Extended reports whether the code is an extended control (one that points at a CTRL_HEADER object). Codes 4–9 and 19–20 are inline; the rest of 1–23 are extended; 0, 10, 13 and 24–31 are single-unit char controls.

type Document

type Document struct {
	Header   FileHeader
	DocInfo  []*Record
	Sections [][]*Record
}

Document is a parsed HWP 5.0 file: its header, the DocInfo record tree and one record tree per BodyText section.

func Open

func Open(ra io.ReaderAt) (*Document, error)

Open parses an HWP 5.0 compound file. For encrypted or distribution documents it returns the header together with ErrEncrypted or ErrDistribution, so callers can still inspect the flags.

func OpenFile

func OpenFile(path string) (*Document, error)

OpenFile opens and parses the .hwp at path.

func Parse

func Parse(b []byte) (*Document, error)

Parse parses an in-memory .hwp.

type FileHeader

type FileHeader struct {
	Version        uint32 // 0xMMnnPPrr, e.g. 0x05000500 = 5.0.5.0
	Flags          uint32 // 속성 bits: 0 compressed, 1 encrypted, 2 distribution, ...
	LicenseFlags   uint32 // CCL / 공공누리 flags
	EncryptVersion uint32
	KOGLCountry    byte
}

FileHeader is the 256-byte FileHeader stream (spec 표 3).

func ParseFileHeader

func ParseFileHeader(b []byte) (FileHeader, error)

ParseFileHeader decodes the FileHeader stream.

func (FileHeader) Compressed

func (h FileHeader) Compressed() bool

func (FileHeader) DRM

func (h FileHeader) DRM() bool

func (FileHeader) Distribution

func (h FileHeader) Distribution() bool

func (FileHeader) Encrypted

func (h FileHeader) Encrypted() bool

func (FileHeader) HasHistory

func (h FileHeader) HasHistory() bool

func (FileHeader) HasScript

func (h FileHeader) HasScript() bool

func (FileHeader) KOGL

func (h FileHeader) KOGL() bool

func (FileHeader) VersionString

func (h FileHeader) VersionString() string

type ListHeader

type ListHeader struct {
	ParaCount int16
	Attrs     uint32
}

ListHeader is the leading part of every HWPTAG_LIST_HEADER (spec 표 65).

func DecodeListHeader

func DecodeListHeader(b []byte) (ListHeader, error)

DecodeListHeader decodes the six bytes every LIST_HEADER starts with.

type ParaHeader

type ParaHeader struct {
	NChars         uint32 // text length in UTF-16 code units, controls included
	ControlMask    uint32
	ParaShapeID    uint16
	StyleID        uint8
	BreakType      uint8 // 표 59: 1 section, 2 multi-column, 4 page, 8 column
	CharShapeCount uint16
	RangeTagCount  uint16
	LineSegCount   uint16
	InstanceID     uint32
	MergedTrack    uint16 // 5.0.3.2+, zero when absent
}

ParaHeader is HWPTAG_PARA_HEADER (spec 표 58).

func DecodeParaHeader

func DecodeParaHeader(b []byte) (ParaHeader, error)

DecodeParaHeader decodes a PARA_HEADER payload.

type Paragraph

type Paragraph struct {
	Header     ParaHeader
	Text       string
	Controls   []Control
	CharShapes []CharShapeRun
	Tables     []*Table
	Nested     []*Paragraph // paragraphs of text boxes, headers/footers, footnotes, comments… anchored here
	Record     *Record
}

Paragraph is a PARA_HEADER record with its text, character-shape runs and the tables anchored in it, decoded. Record keeps the raw subtree, so controls this package does not model (shapes, footnotes, fields) stay reachable.

func Paragraphs

func Paragraphs(section []*Record) ([]*Paragraph, error)

Paragraphs decodes the paragraphs of a section record tree. Table cells are decoded recursively into Cell.Paragraphs.

type Record

type Record struct {
	Tag      uint16
	Level    uint16
	Data     []byte
	Children []*Record
}

Record is one record of a DocInfo or BodyText stream (spec §4.1): a 32-bit header of TagID(10) | Level(10) | Size(12), then Size bytes of payload. Records whose Level is one deeper than the preceding record are its children; ReadRecords assembles that into Children.

func ReadRecords

func ReadRecords(b []byte) ([]*Record, error)

ReadRecords parses a decompressed record stream into a tree, using the 10-bit Level field as depth. A Size of 0xFFF means the real size follows the header as a further 32-bit little-endian value (spec 그림 46). Data slices alias b.

func (*Record) Find

func (r *Record) Find(tag uint16) *Record

Find returns the first direct child with the given tag, or nil.

func (*Record) Name

func (r *Record) Name() string

Name returns the record's tag name.

type Table

type Table struct {
	Attrs       uint32 // 표 76
	Rows        uint16
	Cols        uint16
	CellSpacing uint16 // HWPUNIT16; zero when the payload is too short
	Cells       []*Cell
}

Table is the fixed part of HWPTAG_TABLE (spec 표 75). Cells are filled in by Paragraphs from the LIST_HEADER records that follow it.

func DecodeTable

func DecodeTable(b []byte) (Table, error)

DecodeTable decodes the fixed leading fields of a TABLE payload.

type Text

type Text struct {
	Text     string    // characters only; line break → '\n', tab → '\t', 묶음 빈칸 → U+00A0
	Controls []Control // every control, in order, with its UTF-16 offset
}

Text is a decoded HWPTAG_PARA_TEXT.

func DecodeParaText

func DecodeParaText(b []byte) (Text, error)

DecodeParaText decodes a PARA_TEXT payload. It fails if an eight-unit control does not end with its own code again, which is how a wrong-width walk shows up (the payload would otherwise desynchronise silently).

Directories

Path Synopsis
cmd
hwprec command
Command hwprec prints the text, the record tree or the tables of an HWP 5.0 file.
Command hwprec prints the text, the record tree or the tables of an HWP 5.0 file.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL