From 9e22c937f957531070f3ef55b1361ff801d1da4f Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sun, 18 Oct 2026 08:47:33 +0900 Subject: [PATCH] feat(form_fit): lay out Hancom's automatic spacing between Hangul and Latin or digits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paragraph shape turning on hh:autoSpacing (eAsianEng, eAsianNum) makes Hancom put a quarter of the em, 자간 applied, between a Hangul syllable and a Latin letter or a digit right next to it. The gap goes with the character before it: that character stays on its line only with the gap after it, even where the line breaks there. FormFit counted no gap, so such paragraphs broke later than Hancom's. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ src/hwpx/form_fit/measure.py | 32 ++++++++++++++++++++++++++--- tests/test_form_fit_hancom_rules.py | 21 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e6e64d4..d26fa62d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,10 @@ 필드와 메모 필드는 한/글이 배치 정보 없이 저장해 건너뛴다). 필드의 시작·끝은 줄에 자리를 차지하지 않고 줄 위치(textpos)에서 하나에 8글자씩이며, 줄이 나뉘는 자리에 있으면 다음 줄이 그것으로 시작한다. 형광펜 표시는 자리도 줄 위치도 차지하지 않는다. 한/글이 그렇게 놓는다. 전에는 그런 문단을 건너뛰었다. +- FormFit과 쪽 수 추정이 문단 모양의 한글·영문/숫자 자동 간격(`hh:autoSpacing`의 `eAsianEng`·`eAsianNum`)을 따른다. + 한/글은 한글 음절 바로 옆의 영문 글자(또는 숫자) 사이에 em의 1/4(자간 적용, 장평은 그대로)을 넣고, 그 간격은 앞 글자에 + 붙어 줄이 그 자리에서 나뉠 때도 앞 줄 폭에 든다. 빈칸·문장 부호를 사이에 두거나 영문과 숫자 사이는 넣지 않는다. 전에는 + 간격 없이 세어 그런 문단의 줄이 한/글보다 늦게 나뉘었다. `TextStyle.auto_space_latin`·`auto_space_digit`, `auto_space_gap()`. - FormFit과 쪽 수 추정이 글꼴 폭 표에 없는 가나(히라가나·가타카나)를 그 글꼴의 한글 음절 폭으로 잡는다(함초롬바탕 972, 한컴 고딕 932). 한/글이 그렇게 놓는다. 전에는 전각(1 em)으로 셌다. - 쪽 수 추정(실험, `estimate_pages`)이 탭 정의를 한 번만 읽는다. 전에는 글자 모양을 볼 때마다 머리 정보의 탭 정의 전체를 diff --git a/src/hwpx/form_fit/measure.py b/src/hwpx/form_fit/measure.py index 05a8c81d..d1cbc594 100644 --- a/src/hwpx/form_fit/measure.py +++ b/src/hwpx/form_fit/measure.py @@ -1301,7 +1301,10 @@ class TextStyle: out when a character shape gives its scripts different faces (see :func:`glyph_face_of`). ``bold`` glyphs in a face of ``_BOLD_WIDER_FACES`` (the 한양 faces) are a twentieth of the em wider; in the others bold - keeps the regular advance. + keeps the regular advance. ``auto_space_latin`` and ``auto_space_digit`` + are the paragraph shape's automatic spacing (``hh:autoSpacing``): a + quarter of the em, 자간 applied, between a Hangul syllable and a Latin + letter or a digit right next to it (see :func:`auto_space_gap`). """ ratio: float = 100.0 @@ -1327,6 +1330,8 @@ class TextStyle: auto_tab_left: bool = False auto_tab_right: bool = False bold: bool = False + auto_space_latin: bool = False + auto_space_digit: bool = False def _line_origin(style: TextStyle, first: bool) -> float: @@ -1703,9 +1708,10 @@ def hancom_line_starts( break shrink = (inner + pending) * space * style.condense / 100.0 last = fixed if fixed is not None else char_advance(ch, size, unspaced if styles is None else _without_spacing(look)) - if (used + last - shrink > width or ch == "\t" and used + last >= width) and end > start: + gap = auto_space_gap(ch, text[end + 1], size, style, look) if end + 1 < length else 0.0 + if (used + last + gap - shrink > width or ch == "\t" and used + last >= width) and end > start: break # a tab reaching the margin itself no longer fits - used += advance + used += advance + gap inner += pending pending = 0 seen = True @@ -1756,6 +1762,24 @@ def _width_after_tab( return width +def auto_space_gap(previous: str, ch: str, font_pt: float, style: TextStyle, look: TextStyle | None = None) -> float: + """The room Hancom's automatic spacing (``style.auto_space_latin``/``auto_space_digit``) puts between + *previous* and *ch*: a quarter of the em at *font_pt*, its 자간 (*look*'s, else *style*'s) applied, when one is + a Hangul syllable and the other a Latin letter (or a digit) right next to it; else none. 장평 leaves it as it + is; a space or a mark between them, or a Latin letter beside a digit, takes none. The gap goes with the + character before it: that character fits on its line only with the gap after it, even where the line breaks + at the gap.""" + + if not (style.auto_space_latin or style.auto_space_digit): + return 0.0 + for hangul, other in ((previous, ch), (ch, previous)): + if "\uac00" <= hangul <= "\ud7a3" and other.isascii() and ( + style.auto_space_latin and other.isalpha() or style.auto_space_digit and other.isdigit()): + spacing = (look or style).spacing + return font_pt * 100.0 / 4 * (1 + spacing / 100.0) + return 0.0 + + def _spills(ch: str, previous: str) -> bool: """Whether the space *ch*, starting at or past the margin after *previous*, begins the next line: a fixed-width space does even right after a word, a space only after another space (the first one after a @@ -2434,6 +2458,8 @@ def text_style_from_refs( tab_stops=tab_stops, auto_tab_left=bool(getattr(tabs, "auto_tab_left", False)), auto_tab_right=bool(getattr(tabs, "auto_tab_right", False)), + auto_space_latin=bool(getattr(getattr(prop, "auto_spacing", None), "e_asian_eng", False)), + auto_space_digit=bool(getattr(getattr(prop, "auto_spacing", None), "e_asian_num", False)), bold=bold, ) heading = paragraph_label(root, para_pr_id_ref, label) diff --git a/tests/test_form_fit_hancom_rules.py b/tests/test_form_fit_hancom_rules.py index 9800af77..14ba9818 100644 --- a/tests/test_form_fit_hancom_rules.py +++ b/tests/test_form_fit_hancom_rules.py @@ -33,6 +33,7 @@ ) from hwpx.form_fit.measure import ( _cell_text_style, + auto_space_gap, char_advance, classify_char, font_line_height, @@ -539,6 +540,26 @@ def test_the_two_spaces_right_after_a_word_hang_past_the_margin() -> None: assert hancom_line_starts("가나다라마 바", [5200], 10, TextStyle()) == [0, 6] +def test_automatic_spacing_puts_a_quarter_em_between_hangul_and_latin_or_digits() -> None: + # hh:autoSpacing: a quarter of the em, its 자간 applied (장평 leaves it), between a Hangul syllable and a Latin + # letter (eAsianEng) or a digit (eAsianNum) right next to it; none across a space or a mark, or Latin to digit. + both = replace(TextStyle(), auto_space_latin=True, auto_space_digit=True) + assert auto_space_gap("가", "A", 10, both) == auto_space_gap("1", "가", 10, both) == 250 + assert auto_space_gap("가", "A", 10, replace(both, spacing=-10.0)) == 225 + assert auto_space_gap("가", "A", 10, replace(both, ratio=80.0)) == 250 + assert auto_space_gap("A", "1", 10, both) == auto_space_gap("가", " ", 10, both) == 0 + assert auto_space_gap("가", "(", 10, both) == auto_space_gap("가", "A", 10, TextStyle()) == 0 + assert auto_space_gap("가", "1", 10, replace(TextStyle(), auto_space_latin=True)) == 0 + + +def test_an_automatic_space_goes_with_the_character_before_it() -> None: + # "1" fits only with the gap after it, even where the line breaks at that gap. + digits = replace(TextStyle(), auto_space_digit=True) + assert hancom_line_starts("가가가1가가", [3900], 10, digits) == [0, 3] + assert hancom_line_starts("가가가1가가", [4100], 10, digits) == [0, 4] + assert hancom_line_starts("가가가1가가", [3900], 10, TextStyle()) == [0, 4] + + def test_a_narrow_cell_still_gets_lines_1440_wide() -> None: doc = HwpxDocument.new() cell = doc.add_table(1, 1, width=700 + 2 * 510).cell(0, 0)