From 50b9519ee0424df40c838b0dc0409757b9abeb88 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:53:16 +0900 Subject: [PATCH] fix(form_fit): measure a label number in a format the text export does not write A paragraph whose number label has a character shape of its own and a number format the text export does not write (KOREAN_DIGIT, HANGUL, ...) stopped FormFit and the page estimate with a KeyError when Hancom measures the label (useInstWidth). Such a number now counts as one Hangul syllable, as it does when Hancom does not measure the label. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ src/hwpx/form_fit/measure.py | 7 +++++-- tests/test_layout_page_estimate.py | 23 +++++++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a51509..2734e5ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,9 @@ - 탭 정의에 자기 탭 위치나 자동 탭이 있는 문단은 전처럼 잰다. - 쪽 수 추정에서 강제 줄 바꿈 다음 줄은 첫 줄이 아니다. 들여쓰기를 빼지 않고 다른 줄의 폭으로 잰다. 전에는 첫 줄 폭으로 재서, 들여쓴 문단에서 줄이 하나 더 생길 수 있었다. +- FormFit과 쪽 수 추정이 텍스트 내보내기가 쓰지 않는 번호 형식(`KOREAN_DIGIT`, `HANGUL` 등)의 문단 번호를 만나도 + 멈추지 않는다. 한/글이 재는 번호(`useInstWidth`)이면 그 숫자를 한글 한 글자 폭으로 잰다. 전에는 번호가 자기 글자 + 모양을 가진 문단에서 `KeyError`가 났다. - `.hwp`를 열 때 그림의 투명도와 반전을 한/글이 읽는 자리에서 읽는다. 한/글은 그림 레코드의 투명도를 그림 원래 크기 뒤 마지막 바이트에서 읽고, 바이너리 항목 번호 뒤 바이트는 읽지 않는다(저장할 때 0으로 쓴다). 전에는 그 바이트를 투명도로 옮겨, 거기에 255가 든 문서의 그림이 HWPX에서 보이지 않았다. 반전(`hp:pic@reverse`)은 그림 개체 속성의 diff --git a/src/hwpx/form_fit/measure.py b/src/hwpx/form_fit/measure.py index 6aed1a63..8d7694be 100644 --- a/src/hwpx/form_fit/measure.py +++ b/src/hwpx/form_fit/measure.py @@ -2252,10 +2252,13 @@ def fill(match: "re.Match[str]") -> str: form = (head.get("numFormat") if head is not None else None) or "DIGIT" if not measured: return "1" if form in _ASCII_NUMBER_FORMATS else "\uac00" - from ..tools.exporter import _number_text + from ..tools.exporter import _NUMBER_FORMATS, _number_text start = int(_style_number(head.get("start") if head is not None else None, 1.0)) - return _number_text(start, form) if key != "N" else f"{start}." + if key == "N": + return f"{start}." + # a format the text export does not write (KOREAN_DIGIT, HANGUL, ...) counts as one syllable + return _number_text(start, form) if form in _NUMBER_FORMATS else "가" return re.sub(r"\^(\d+|N)", fill, paragraph_head.text or ""), attrs diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index f3ac879d..579d51b2 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -1003,6 +1003,29 @@ def test_without_line_caches_formfit_breaks_the_lines_the_same(name: str) -> Non _assert_like_hancom(estimate_pages(_without_caches(data)), data, HANCOM_PAGES[name]) +@pytest.mark.parametrize("number_format", ["KOREAN_DIGIT", "HANGUL"]) +def test_a_number_label_in_a_format_the_text_export_does_not_write_is_estimated(number_format: str) -> None: + # The bullet and number labels above, every number measured, in a character shape of its own and in a + # format of Hancom's own documents that the text export does not write: such a number counts as a syllable + # instead of failing the estimate. + out = io.BytesIO() + with zipfile.ZipFile(io.BytesIO((FIXTURES / "pages_bullet_and_number_labels.hwpx").read_bytes())) as source, \ + zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as target: + for info in source.infolist(): + payload = source.read(info.filename) + if info.filename == "Contents/header.xml": + payload = re.sub(rb'numFormat="[A-Z_]+"', f'numFormat="{number_format}"'.encode(), payload) + payload = re.sub(rb'useInstWidth="\w+"', b'useInstWidth="1"', payload) + payload = re.sub(rb'(]*)charPrIDRef="\d+"', rb'\1charPrIDRef="0"', payload) + target.writestr(info, payload) + + for source_bytes in (out.getvalue(), _without_caches(out.getvalue())): + estimate = estimate_pages(source_bytes) + + assert estimate.unsupported == () + assert estimate.pages >= 1 + + def test_an_unsplit_table_without_room_goes_on_alone() -> None: # The table above, 63000 tall, set not to split: with no room below the line's top, it goes alone to the # next page's top and the paragraph's lines, and those after, stay where they stood on the first page.