Skip to content

fix(docx): break Hangul lines at eojeol boundaries, not syllables - #731

Merged
developer0hye merged 2 commits into
mainfrom
fix/docx-hangul-eojeol-wrap
Jul 31, 2026
Merged

fix(docx): break Hangul lines at eojeol boundaries, not syllables#731
developer0hye merged 2 commits into
mainfrom
fix/docx-hangul-eojeol-wrap

Conversation

@developer0hye

Copy link
Copy Markdown
Owner

Summary

Word breaks Korean at eojeol (space-delimited word) boundaries on an ordinary line, falling back to syllable breaking only to keep a justified line from going loose. We broke every syllable, so the last word of a line was split and one syllable stranded above: the contract's 제1조 ended …사항을 정 where Word ends …제반 사항을, with 20pt of room still on the line. The same file's justified paragraphs split 보관|한다 on both sides, which is what identifies syllable breaking as Word's fallback rather than its default.

Typst 0.14 offers no lever for this — text(lang: "ko"), par(linebreaks:) and text(costs:) were each measured to leave the breakpoints untouched, because typst-layout builds its ICU4X segmenters with default options and never consults Lang::KOREAN. A frame is the one thing UAX #14 cannot break through, so each qualifying eojeol is emitted inside an inline #box — the mechanism the repo already uses in the opposite direction for PowerPoint kinsoku. A no-break marker between syllables also works and is rejected: U+2060 lands in the text layer and makes the text unsearchable (#664, reproduced on a probe before choosing).

Supporting work the measurements forced:

  • Baselines. Under the fixed line box a bare frame seats its baseline at the box's bottom edge, so the frame restores the paragraph's own text edges and shifts its baseline by the descent, resolved against the token's largest run. Not one baseline moves on any Korean fixture (1151 shared baselines on the 37-page technical brief, zero added or dropped).
  • Markup leaks. Splitting runs at eojeol boundaries makes each inter-word fragment its own escaping unit, where a leading + or = became Typst markup — the + silently replaced by an enum marker, the = deleted from page and text layer. escape_typst now neutralises the whole line-leading marker set through one leading space; the character census over the brief is identical to main.
  • Width guard. Framing applies only to tokens measuring narrower than their container, with the measure taken from the page or the cell's own column, so a token too wide for a narrow column keeps today's breaking instead of taking a line of its own.

Gated to DOCX flow pages and non-justified paragraphs. Lists follow their item's alignment; floating text boxes are deliberately excluded and documented as such.

Related issue

Fixes #626

Testing

  • cargo test --workspace — green (2211 passed). 13 red-first tests: eojeol framing in paragraphs/cells/lists, the baseline-restoration arithmetic (triangulated at two font sizes), every line-leading marker through one leading space, the width guard (same token framed at 400pt, unframed at 150pt), and negative guards for justified / slide / sheet / Latin.
  • cargo +1.97 clippy --workspace --all-targets — clean; cargo fmt --check — clean; wasm check clean.
  • Corpus sweep, 377 files: only the five Korean DOCX change. Every PPTX (92) and XLSX (124) is byte-identical.
  • Text layer: pdftotext character census over the 37-page Korean brief is identical to main — zero Cf characters, no U+2060/U+200B.
  • Perf: 2.44s → 2.47s on the brief (~1.3%).

Visual impact

  • No rendered PDF change
  • Rendered PDF change or visual evidence added
  • Reason:

Visual audit

Visual comparison

GT Before After
GT Before After

Required inspection

  • Rendered all evidence at 150 DPI or higher
  • Stored progressive JPEG quality 86 assets with metadata stripped
  • Inspected matched region crops at full resolution
  • Ran the 5% fuzz pixel-difference sweep
  • Inventoried hairlines and border dash styles
  • Inventoried font weight, italic, and underline emphasis

Deviation audit

Check Result
Page count/order Matches GT — one page each, no reordering; media box within 0.1pt (A4 rounding)
Element presence Matches GT — 21 matched lines, 0 missing, 0 extra, 0 re-wrapped; all rules, shading and footer present (GT's higher rect census is per-segment op-splitting, confirmed visually)
Position/size Remaining: #724 — table borders 0.48–0.96pt left/up (column proportions exact: divider span identical at 225.60pt); Remaining: #649 — cell text baselines 0.50–0.59pt high; Remaining: #646 — footer 0.96pt right of centre. Every other line is within one 150-DPI pixel
Rotation/flip No deviation observed — every transform axis-aligned in both traces
Fill Matches GT — header shading identical to 8 decimals; extent within 0.48pt, tracking the #724 border offset; colour shift 0.0001
Stroke/border Matches GT — five solid rules in GT, five present, no dash deviations; the apparent width difference is GT's two-pixel antialiasing vs our one-pixel snap at equal integrated ink
Text content Fixed — 제1조 ends '…필요한 제반 사항을' and 제4조 ends '…VAT 별도)으로', both matching GT (main: '…사항을 정' / '…으로 하'); page-1 text is line-for-line identical to GT; AE 5% 50,388 → 47,825
Font family/weight/style Remaining: #628 — family and weight match GT exactly (bold on precisely the two w:b runs, per-line ink mass within 2%), but pair kerning tightens V→A/A→T by 0.66/0.81pt, leaving −1.41pt across the rest of the line
Text color Matches GT — identical colour census
Alignment Remaining: #728 — the centred date takes a 0.25em auto-space at each digit→Hangul boundary Word leaves alone (+2.62pt × 3); Remaining: #646 — centred footer 0.96pt right. Left-aligned and both justified paragraphs land on GT within 0.48pt
Line/paragraph spacing Remaining: #630 — the footer baseline sits 1.92–2.10pt low, making the table→footer gap +2.69pt; all 19 other pitches are within ±0.16pt of GT
Clipping/overflow No deviation observed — no glyph crosses the text edge, no cell truncated

Note for reviewers: 10_research_report_ko page 2 goes 37/37 → 35/37 matched lines. That is entirely one note paragraph which Word itself breaks mid-eojeol; the fixture defines no Normal style, so its unstyled paragraphs inherit the Word application's wordWrap=0 default, which we cannot honour until w:wordWrap is parseable — tracked in #730 together with the native-Word one-factor probes that identified the discriminator.

Checklist

  • Commits include a Signed-off-by line
  • PR scope contains one root cause
  • Remaining visual deviations each reference an open issue

🤖 Generated with Claude Code

developer0hye and others added 2 commits July 31, 2026 14:41
Word breaks Korean at eojeol — space-delimited word — boundaries on an
ordinary line, and falls back to syllable breaking only to keep a
justified line from going loose. We broke every syllable, so the last
word of a line was split and one syllable stranded above: the contract's
제1조 ended '…사항을 정' where Word ends '…제반 사항을', with 20pt of room
still on the line. The same file's justified paragraphs split 보관|한다
on both sides, which is what identifies syllable breaking as Word's
fallback rather than its default.

Typst 0.14 offers no lever for this. text(lang: "ko"), par(linebreaks:)
and text(costs:) were each measured to leave the breakpoints untouched,
because typst-layout builds its ICU4X segmenters with default options
and never consults Lang::KOREAN. A frame is the one thing UAX #14
cannot break through, so each qualifying eojeol is emitted inside an
inline #box — the same mechanism the repo already uses in the opposite
direction to create a break for PowerPoint kinsoku. A no-break marker
between syllables would also work and is rejected: U+2060 lands in the
text layer and makes the text unsearchable, which is #664.

Under the fixed line box a bare frame would seat its baseline at the
box's bottom edge, so the frame restores the paragraph's own text edges
and shifts its baseline by the descent — resolved against the token's
largest run, which keeps a smaller run inside a word correct. Not one
baseline moves on any Korean fixture: 1151 shared baselines on the
technical brief, zero added or dropped.

Splitting runs at eojeol boundaries makes each inter-word fragment its
own escaping unit, where a leading '+' or '=' becomes Typst markup —
the '+' was silently replaced by an enum marker and the '=' deleted
from both page and text layer. escape_typst now neutralises the whole
line-leading marker set through one leading space, and the character
census over the 37-page brief is identical to main.

Framing is gated to DOCX flow pages, non-justified paragraphs, and
tokens that measure narrower than their container — the measure comes
from the page or the cell's own column, so a token too wide for a
narrow column keeps today's breaking instead of taking a line of its
own. Lists follow their item's alignment; floating text boxes are
deliberately excluded, and say so.

Measured against the native Word GT: 02_contract_ko page 1 goes 17 to
21 of 21 matched lines with the re-wrap gone, AE 5% 50,388 to 47,825.
Of 377 corpus files only the five Korean DOCX change; every PPTX and
XLSX is byte-identical. 10_research_report_ko page 2 loses two matched
lines to a paragraph Word itself breaks mid-eojeol — that fixture
defines no Normal style, so its unstyled paragraphs inherit the Word
application's wordWrap=0 default, which we cannot honour until
w:wordWrap is parseable (#730, with the native-Word probes that
identified it).

Related: #626

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Yonghye Kwon <developer.0hye@gmail.com>
…lves

Four of the #626 tests assert something that only holds once a Korean
face resolves: three need the paragraph's fixed line box to exist
before a frame can carry it, and the width-guard test needs the token
to actually measure wider than its column. CI runners carry no CJK
font, so text_advance_em and font_line_metrics_em both return None
there, the premises evaporate and the assertions fired.

They now early-return when the face they depend on is unresolvable,
the same guard the cell-content and paragraph tests already use. Each
probes the exact resolution path its premise needs, so the skip is
tied to the missing capability rather than to a platform name.

Verified on a font-equipped machine that all four still fail when the
framing or the width guard is reverted, and by stubbing both metric
helpers to None that they are precisely what stands between a
font-less run and a green module. Eleven other #626 tests name no
family and keep asserting the framing on every platform.

Related: #626

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Yonghye Kwon <developer.0hye@gmail.com>
@developer0hye
developer0hye merged commit 4638fd3 into main Jul 31, 2026
17 checks passed
@developer0hye
developer0hye deleted the fix/docx-hangul-eojeol-wrap branch July 31, 2026 06:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DOCX: Hangul lines break at syllable boundaries, splitting words Word keeps whole

1 participant