fix(docx): break Hangul lines at eojeol boundaries, not syllables - #731
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:)andtext(costs:)were each measured to leave the breakpoints untouched, because typst-layout builds its ICU4X segmenters with default options and never consultsLang::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:
+or=became Typst markup — the+silently replaced by an enum marker, the=deleted from page and text layer.escape_typstnow neutralises the whole line-leading marker set through one leading space; the character census over the brief is identical to main.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.pdftotextcharacter census over the 37-page Korean brief is identical to main — zeroCfcharacters, no U+2060/U+200B.Visual impact
Visual audit
fixassets/bugfixes/issue-626/gt.jpgassets/bugfixes/issue-626/before.jpgassets/bugfixes/issue-626/after.jpgVisual comparison
Required inspection
Deviation audit
w:bruns, 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 lineNote for reviewers:
10_research_report_kopage 2 goes 37/37 → 35/37 matched lines. That is entirely one note paragraph which Word itself breaks mid-eojeol; the fixture defines noNormalstyle, so its unstyled paragraphs inherit the Word application'swordWrap=0default, which we cannot honour untilw:wordWrapis parseable — tracked in #730 together with the native-Word one-factor probes that identified the discriminator.Checklist
Signed-off-byline🤖 Generated with Claude Code