Filling official HWP forms with code: eight traps
I submit to contests often. In Korea, contest application forms and government forms are usually .hwp files. They are fixed-table templates where I only need to fill in values, but doing one by hand takes twenty minutes. Run several applications in parallel and I am certain to leave a field blank somewhere.
So I fill them programmatically. This is the list of traps I hit along the way. There is very little Korean-language material on the subject, so I learned most of them by running into them myself. HWP is the document format of Hancom Office, the word processor that Korean government agencies and contest organizers use by default.
The short version: separate the tool that fills from the tool that verifies
| Stage | Tool | Nature |
|---|---|---|
| Understand structure | hwplib --dump |
Headless; flat table-cell indices and current text |
| Fill | hwplib | Headless (Java) |
| Render verification | Hancom Office COM | ground truth |
The last row is the important one. I fill with a library and make the judgment only with Hancom.
The reason is that Hancom auto-fits page breaks based on content. Reading a cell-height value saved in the file and calculating, "This table is this many mm tall, so it fits on one page," gives the wrong answer. Only the result Hancom produces by actually opening and rendering the file is true.
And I do not fill cells through COM. I learned this by measurement. In a selected cell block, Delete does not work, so new text is appended on top of the existing template text. Four cells in an application form were contaminated. Hancom's COM interface is for render verification only.
Choosing the tool
hwplib 1.1.10 or later. It is a Java library with the group ID kr.dogfoot.
Do not use 1.1.7. It lacks addParagraph and getParagraphs. Start without knowing that, and I can spend a long time asking why adding a paragraph does not work.
I need a JDK. An existing JRE installed for another purpose does not include javac, so compilation fails. I have to install a JDK separately.
Trap 1: Creating a new paragraph wipes out all formatting
This is the first trap I hit.
Create a new Paragraph and add text, and its paraShapeId becomes 0. Indentation, bullets, alignment, and fonts all disappear. The visual skeleton of the form collapses.
Solution: clone() an existing paragraph from the template, then replace only the text. Formatting information belongs to the paragraph, so cloning carries it over.
Trap 2: Paragraphs inside one cell can have different formatting
I applied the solution to trap 1 and fell into the second one.
A cell such as a pledge section can contain multiple paragraphs, and their alignments may differ. The body can be left-aligned while the organization-name line is centered.
Copy the first paragraph over the whole cell and the centered lines all become left-aligned.
Solution: clone the ith source paragraph for the ith replacement line so it inherits that line's formatting. Only lines beyond the source paragraph count use the last paragraph's formatting. That is why matching the number of lines in the replacement text to the source paragraph count is safest.
Trap 3: The writer crashes on an empty paragraph
hwplib's text-writing path reads the first element of its character list. That means a paragraph with zero characters throws an index exception. Leave blank lines inside a cell or between body paragraphs untouched, and saving itself fails.
Solution: fill blank lines with one space. It looks the same and gets through the writer.
Trap 4: Cell indices vary by access path
This is why I should never hardcode indices in a document.
The index I get by iterating TableRightCell through COM and the flat index from hwplib's row.getCellList() diverge because of merged cells. In one measured case, the email cell was number 17 in COM and number 16 in hwplib.
Solution: before putting an index in code or documentation, measure it with that tool's own dump. Do not carry an index over from another tool.
Trap 5: Only the title remains on page 1 while the content starts on page 2
This took me the longest to diagnose.
The symptom looks like this: page 1 contains only one title line, while the entire table moves to page 2.
The cause is the form structure. Even empty, the first table was 241mm tall. That is nearly a page. Hancom also splits tables only by row. If a whole table can fit on the next page, it does not split it; it moves the whole thing.
title 11mm + table 241mm = 252mm > body area 250mm
→ table moves to page 2; page 1 has only the title
Changing the saved cell-height value cannot fix this. Hancom recalculates it from content.
The only solution is to reduce the content until the first table's actual height is below 235mm. Compress the first section to its essential figures, and the title and table can coexist on one page.
The lesson is simple: put the first page's table on a strict content diet from the start.
Trap 6: Changing cell height creates blank pages
When I need to adjust a cell height, I must adjust the entire table control height too.
Change only the cell height and leave the control height as it was, and the bounding box no longer matches. Hancom can insert blank pages or jump pages.
Solution: whenever I set a cell height, synchronize the control header's height with the sum of row heights. Applying this reduced the blank pages from eight to four.
Some numbers help with estimating height. It is about 1750 HWPUNIT per line, and I estimate 28 to 30 characters per line after allowing for indentation and bullets. Calculating from the full cell width underestimates and clips content. Overestimating creates blank pages. Hancom auto-fits in the end, so a slightly smaller estimate is safer.
Trap 7: Never assemble image insertion by hand
This is the hardest part.
Do not directly construct a hwplib ControlPicture object and fill its fields. Miss just one field, such as ctrlData, gsoId, or pictureEffect, and Hancom cuts off the whole document from that point onward. A 20-page document ends on page 2.
From a debugging perspective, that is also a useful signal. When the document cuts off at a specific point, the image assembly is wrong.
The answer is to clone an entire ControlPicture from any HWP file made directly by Hancom. Then replace only the binary item ID, dimensions, and inline setting. Every required field comes with a verified value. Any Hancom HWP containing an image can serve as the reference file.
A few detailed rules:
- Use the storage default for the BinData compression option. Insert it uncompressed and Hancom cannot read the image stream, so the document is cut off.
- Do not set
gsoIdto an arbitrary value. The writer fails type dispatch and throws while trying to castControlPicturetoControlLine. A clone carries it over, so I leave it alone. - When inserting multiple images, read the reference anew for each image and clone independently. Sharing one clone contaminates it.
- Verify with a strict parser. Extract the binary with
hwp5proc catfrompyhwp; if the PNG magic number89 50 4E 47appears, the embed is valid.
There is also a sizing issue. Hancom renders a cloned image at about 0.75 of its original size. The clone carries the reference's scale matrix, and normalizing that matrix does not resolve it.
So to fill the box width, I set the width to 1 divided by 0.75, or about 1.33 times the desired width. I also match the box cell height to the rendered size, 0.75 of the original height, to remove the space below it. I fine-tune the coefficient with a Hancom export.
I need to be careful when creating the diagram itself too. Make a diagram 200mm wide for a box that is 144mm wide, and it is reduced 28%, making the text blurry. I start at about 170mm wide and use 9 to 11pt text. 7pt is unreadable.
Trap 8: Hancom's cache creates half the wasted effort
This is the most deflating trap.
When Hancom has a file with the same name open, it does not reread it even when the disk file changes. Export a PDF in that state and I get the cached old version.
I changed the code, kept getting the cut-off version, and reread the code several times. The evidence was the byte size. The output PDFs were exactly the same size.
There are two fixes. Give the output a new filename every time. Then exit Hancom completely and reopen it. That is how I get a clean load.
What I decided not to use
I evaluated and discarded several paths. Recording why may help the next person.
LibreOffice + H2Orestart. Discarded. It behaves differently from Hancom and gives false judgments. I fall into the trap of "it works here but breaks in Hancom." It also substituted brush-script fonts.
rhwp (Rust headless renderer). It faithfully renders simple forms, including tables, double lines, and Hangul fonts. It breaks on complex forms, though. Floating objects overlap the body, and it cannot split multi-page tables, so the bottom is cut off. I can use it for quick previews, not final judgments.
Mirroring the form in Typst. This means redrawing a document that looks like the form in a different tool. I discarded it. Submitting a document that only looks identical where an official form is required is risky.
Side rules
- Do not use middle dots (·) or dashes (—, –) in output. Replace them with slashes or commas. Preserve a middle dot unchanged only when it is part of the original official name in the form.
- Build diagram images as vectors, render them to PNG, then insert them with the cloning method above.
The question that stays
I kept thinking about one thing while doing this: six of the eight traps came from Hancom not using values written in the file as-is.
It recalculates cell heights, moves whole tables, scales images to 0.75, and uses its cache even after the file changes. I cannot predict the rendered result even if I understand the file format exactly.
So this domain has one compact rule: trust the rendered result, not the file. And render only with the tool that will actually judge the submitted document.
The question to ask before doing similar work is this: Did the preview I am looking at come from the same engine that will produce the screen seen by the person receiving the submission?