From 53a6c91ff98efba18be1fb6059eb42afa0aa5c54 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:48:33 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(form=5Ffit):=20lay=20out=20a=20face=20?= =?UTF-8?q?no=20table=20lists=20as=20=ED=95=9C=EC=BB=B4=EB=B0=94=ED=83=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FormFit and the page estimate now take 한컴바탕's advances for a face name no table lists (a font that is not installed, say), as Hancom Office lays out a face it does not find. Before, such a face took the class averages; with no face named it still does. 한컴바탕's reference mark and ellipsis take the advances Hancom lays them out at (788 and 983 of 1024 units). A Hancom-saved fixture holds cells in three faces Hancom does not find at 10 and 30 pt. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ src/hwpx/form_fit/measure.py | 22 +++++++++++++----- .../formfit_unlisted_faces_cells.hwpx | Bin 0 -> 33421 bytes tests/test_form_fit_hancom_rules.py | 21 +++++++++++++++++ 4 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/hancom_saved/formfit_unlisted_faces_cells.hwpx diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a51509..725525de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,9 @@ - 탭 정의에 자기 탭 위치나 자동 탭이 있는 문단은 전처럼 잰다. - 쪽 수 추정에서 강제 줄 바꿈 다음 줄은 첫 줄이 아니다. 들여쓰기를 빼지 않고 다른 줄의 폭으로 잰다. 전에는 첫 줄 폭으로 재서, 들여쓴 문단에서 줄이 하나 더 생길 수 있었다. +- FormFit과 쪽 수 추정이 표에 없는 글꼴 이름(설치되지 않은 글꼴 등)을 한컴바탕의 폭으로 잰다. 한/글은 찾지 못한 글꼴을 + 한컴바탕으로 배치한다. 전에는 글자 종류의 평균 폭을 썼다. 글꼴 이름이 없으면 전처럼 평균 폭이다. + - 한컴바탕의 `※`는 788, `…`는 983(1024 단위)으로 한/글이 배치하는 폭이다. - `.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..d082fef8 100644 --- a/src/hwpx/form_fit/measure.py +++ b/src/hwpx/form_fit/measure.py @@ -138,12 +138,15 @@ # is half the em, rounded down, at the 장평, rounded half up. Bold text keeps the # regular advances. A glyph a face lacks takes the advance of the face Hancom # lays it out from (``_glyph_table.FALLBACK``), moved into the face's own units -# and rounded. A face or glyph listed nowhere falls back to the class averages, -# unrounded. +# and rounded. A face no table lists takes 한컴바탕's advances, as Hancom lays out a +# face it does not find; a glyph listed nowhere falls back to the class averages, +# unrounded, and so does every glyph when no face is named. _LAYOUT_UNIT = 4 #: Hancom's default tab stops, this far apart (HWPUNIT) from the column's left edge (the section's #: ``tabStopVal``). _DEFAULT_TAB_STOP = 4000 +#: The face Hancom lays out a face it does not find with. +_UNLISTED_FACE = "한컴바탕" _HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" _HH = "{http://www.hancom.co.kr/hwpml/2011/head}" @@ -526,8 +529,8 @@ 768, 682, 682, 725, 682, 640, 725, 768, 384, 469, 768, 640, 938, 768, 725, 640, 725, 682, 640, 768, 768, 725, 981, 682, 682, 640, 512, 341, 512, 1024, 512, 597, 512, 554, 512, 554, 554, 384, 554, 554, 298, 298, 554, 298, 853, 554, 554, 554, - 554, 426, 512, 384, 554, 554, 810, 597, 597, 469, 597, 597, 597, 810, 341, 850, - 379, 379, 276, 276, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 780, 1024, 1024, 1024, + 554, 426, 512, 384, 554, 554, 810, 597, 597, 469, 597, 597, 597, 810, 341, 983, + 379, 379, 276, 276, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 788, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 831, 831, 831, 447, 1024, 1024, 881, 441, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, 1024, @@ -1094,6 +1097,13 @@ def _design_units(face: str, ch: str | None) -> tuple[int, int] | None: return None +def _laid_out_face(face: str) -> str: + """The face whose advances *face* takes: itself when a table lists it (or none is named), else + ``_UNLISTED_FACE``.""" + + return face if not face or face in _DESIGN or face in UNITS_PER_EM else _UNLISTED_FACE + + @lru_cache(maxsize=8192) def _hangul_units(face: str, ch: str) -> tuple[int, int] | None: """``(advance, units per em)`` of the Hangul syllable *ch* in *face*: its own design advance where the @@ -1368,11 +1378,11 @@ def char_advance(ch: str, font_pt: float, style: TextStyle | None = None) -> flo if ch in _HALF_EM_SPACES and not style.use_font_space: return _laid_out_space(height, ratio, spacing) if cls == "hangul": - design, base = _hangul_units(style.hangul_face, ch), style.hangul_advance + design, base = _hangul_units(_laid_out_face(style.hangul_face), ch), style.hangul_advance elif ch in _HALF_EM_GLYPHS: design, base = (1, 2), _ADVANCE_EM[cls] else: - design, base = _design_units(style.glyph_face, ch), _ADVANCE_EM[cls] + design, base = _design_units(_laid_out_face(style.glyph_face), ch), _ADVANCE_EM[cls] if design is not None: return _laid_out(design[0], design[1], height, ratio, spacing) return base * font_pt * 100.0 * ratio / 100.0 * (1 + spacing / 100.0) diff --git a/tests/fixtures/hancom_saved/formfit_unlisted_faces_cells.hwpx b/tests/fixtures/hancom_saved/formfit_unlisted_faces_cells.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..38343b61dad1518272f9c850ee236bf9c918cda1 GIT binary patch literal 33421 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 None: + # The same in faces Hancom does not find (없는글꼴이름, Cre고딕, 가짜명조체), for A, a, 0, (, %, 가, ※ and W at + # 10 and 30 pt: Hancom lays them out as 한컴바탕, ※ at 788 of 1024 units. + doc = HwpxDocument.open(UNLISTED_FACES_CELLS) + cells = list(doc.oxml.sections[0].element.iter(f"{HP}tc")) + faces = set() + for cell in cells: + paragraph = cell.find(f"{HP}subList/{HP}p") + text = "".join(t.text or "" for t in paragraph.iter(f"{HP}t")) + ref = paragraph.find(f"{HP}run").get("charPrIDRef") + style = text_style_from_refs(doc.oxml, paragraph.get("paraPrIDRef"), [ref]) + points = int(doc.oxml.char_property(ref).attributes["height"]) / 100 + margins = cell.find(f"{HP}cellMargin") + width = int(cell.find(f"{HP}cellSz").get("width")) - int(margins.get("left")) - int(margins.get("right")) + hancom = len(paragraph.findall(f"{HP}linesegarray/{HP}lineseg")) + faces.add(style.glyph_face) + assert estimate_lines(text, width, points, style) == hancom, (style.glyph_face, f"U+{ord(text[0]):04X}", points) + assert len(cells) == 96 and faces == {"없는글꼴이름", "Cre고딕", "가짜명조체"} + + def test_cells_in_more_faces_break_where_hancom_breaks_them() -> None: # Hancom laid these documents out: each cell holds a glyph and five Hangul syllables in 한컴산뜻돋움, HY중고딕, # HY견고딕, HY신명조, 새굴림 or 한컴 말랑말랑 Regular at one of the two widths where its line count changes, at From 3de8049e42fbde28259a4579d95b32dd58a94703 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:37:26 +0900 Subject: [PATCH 2/3] Keep independent review additions at distinct changelog and test locations --- CHANGELOG.md | 6 ++--- tests/test_form_fit_hancom_rules.py | 42 ++++++++++++++++------------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25232102..e9d443f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,9 +107,6 @@ 않으면 탭은 폭이 없다. 내어쓰기 자동 탭은 둘째 줄이 시작하는 곳에 왼쪽 위치를 둔다. - 쪽 수 추정에서 강제 줄 바꿈 다음 줄은 첫 줄이 아니다. 들여쓰기를 빼지 않고 다른 줄의 폭으로 잰다. 전에는 첫 줄 폭으로 재서, 들여쓴 문단에서 줄이 하나 더 생길 수 있었다. -- FormFit과 쪽 수 추정이 표에 없는 글꼴 이름(설치되지 않은 글꼴 등)을 한컴바탕의 폭으로 잰다. 한/글은 찾지 못한 글꼴을 - 한컴바탕으로 배치한다. 전에는 글자 종류의 평균 폭을 썼다. 글꼴 이름이 없으면 전처럼 평균 폭이다. - - 한컴바탕의 `※`는 788, `…`는 983(1024 단위)으로 한/글이 배치하는 폭이다. - FormFit과 쪽 수 추정이 텍스트 내보내기가 쓰지 않는 번호 형식(`KOREAN_DIGIT`, `HANGUL` 등)의 문단 번호를 만나도 멈추지 않는다. 한/글이 재는 번호(`useInstWidth`)이면 그 숫자를 한글 한 글자 폭으로 잰다. 전에는 번호가 자기 글자 모양을 가진 문단에서 `KeyError`가 났다. @@ -129,6 +126,9 @@ 한/글은 그 도형을 문단 위가 있었을 자리에서 그만큼 위에 놓고, 그 띠에 걸린 앞 줄(아래끝이 띠 위끝보다 아래인 줄)을 띠 아래끝으로 내린 뒤 그다음 줄을 이어서 놓는다. 도형의 문단 줄은 그 뒤에 온다. 추정도 이제 그렇게 놓는다(옮긴 줄이 쪽 끝을 넘거나 그 사이에 표·개체·각주가 있으면 지원 밖). +- FormFit과 쪽 수 추정이 표에 없는 글꼴 이름(설치되지 않은 글꼴 등)을 한컴바탕의 폭으로 잰다. 한/글은 찾지 못한 글꼴을 + 한컴바탕으로 배치한다. 전에는 글자 종류의 평균 폭을 썼다. 글꼴 이름이 없으면 전처럼 평균 폭이다. + - 한컴바탕의 `※`는 788, `…`는 983(1024 단위)으로 한/글이 배치하는 폭이다. - 쪽 수 추정이 빼기 부호로 쓴 개체 오프셋(`vertOffset="-1500"`처럼, 한/글이 쓰는 부호 없는 32비트 꼴이 아닌 것)을 한/글처럼 0으로 읽는다. 한/글은 그런 값을 0으로 읽어 개체를 문단(또는 종이) 기준 자리에 그대로 그리고, 저장할 때 0으로 고쳐 쓴다. 전에는 그만큼 위(왼쪽)로 놓인 것으로 읽어 뒤 줄 자리가 한/글과 달랐다. diff --git a/tests/test_form_fit_hancom_rules.py b/tests/test_form_fit_hancom_rules.py index 91f673a0..419abdc7 100644 --- a/tests/test_form_fit_hancom_rules.py +++ b/tests/test_form_fit_hancom_rules.py @@ -72,8 +72,10 @@ MORE_FACES_HANGUL = Path(__file__).parent / "fixtures" / "hancom_saved" / "formfit_more_faces_hangul_cells.hwpx" KOPUB_FACES_CELLS = Path(__file__).parent / "fixtures" / "hancom_saved" / "formfit_kopub_faces_cells.hwpx" HFT_FACES_CELLS = Path(__file__).parent / "fixtures" / "hancom_saved" / "formfit_hft_faces_cells.hwpx" -UNLISTED_FACES_CELLS = Path(__file__).parent / "fixtures" / "hancom_saved" / "formfit_unlisted_faces_cells.hwpx" DODUM_PUNCTUATION = Path(__file__).parent / "fixtures" / "hancom_saved" / "formfit_dodum_punctuation_cells.hwpx" +UNLISTED_FACES_CELLS = Path(__file__).parent / "fixtures" / "hancom_saved" / "formfit_unlisted_faces_cells.hwpx" + + NO_BREAK_SPACES = Path(__file__).parent / "fixtures" / "hancom_saved" / "pages_no_break_spaces.hwpx" FIXED_WIDTH_SPACES = Path(__file__).parent / "fixtures" / "hancom_saved" / "pages_fixed_width_spaces.hwpx" SCRIPT_ROWS = [ @@ -234,24 +236,6 @@ def test_dodum_punctuation_and_the_exclamation_mark_break_where_hancom_breaks_th assert len(cells) == 144 -def test_cells_in_faces_no_table_lists_break_as_hancom_batang() -> None: - # The same in faces Hancom does not find (없는글꼴이름, Cre고딕, 가짜명조체), for A, a, 0, (, %, 가, ※ and W at - # 10 and 30 pt: Hancom lays them out as 한컴바탕, ※ at 788 of 1024 units. - doc = HwpxDocument.open(UNLISTED_FACES_CELLS) - cells = list(doc.oxml.sections[0].element.iter(f"{HP}tc")) - faces = set() - for cell in cells: - paragraph = cell.find(f"{HP}subList/{HP}p") - text = "".join(t.text or "" for t in paragraph.iter(f"{HP}t")) - ref = paragraph.find(f"{HP}run").get("charPrIDRef") - style = text_style_from_refs(doc.oxml, paragraph.get("paraPrIDRef"), [ref]) - points = int(doc.oxml.char_property(ref).attributes["height"]) / 100 - margins = cell.find(f"{HP}cellMargin") - width = int(cell.find(f"{HP}cellSz").get("width")) - int(margins.get("left")) - int(margins.get("right")) - hancom = len(paragraph.findall(f"{HP}linesegarray/{HP}lineseg")) - faces.add(style.glyph_face) - assert estimate_lines(text, width, points, style) == hancom, (style.glyph_face, f"U+{ord(text[0]):04X}", points) - assert len(cells) == 96 and faces == {"없는글꼴이름", "Cre고딕", "가짜명조체"} def test_cells_in_more_faces_break_where_hancom_breaks_them() -> None: @@ -309,6 +293,26 @@ def test_kopub_cells_break_where_hancom_breaks_them() -> None: for weight in ("Light", "Medium", "Bold")} +def test_cells_in_faces_no_table_lists_break_as_hancom_batang() -> None: + # The same in faces Hancom does not find (없는글꼴이름, Cre고딕, 가짜명조체), for A, a, 0, (, %, 가, ※ and W at + # 10 and 30 pt: Hancom lays them out as 한컴바탕, ※ at 788 of 1024 units. + doc = HwpxDocument.open(UNLISTED_FACES_CELLS) + cells = list(doc.oxml.sections[0].element.iter(f"{HP}tc")) + faces = set() + for cell in cells: + paragraph = cell.find(f"{HP}subList/{HP}p") + text = "".join(t.text or "" for t in paragraph.iter(f"{HP}t")) + ref = paragraph.find(f"{HP}run").get("charPrIDRef") + style = text_style_from_refs(doc.oxml, paragraph.get("paraPrIDRef"), [ref]) + points = int(doc.oxml.char_property(ref).attributes["height"]) / 100 + margins = cell.find(f"{HP}cellMargin") + width = int(cell.find(f"{HP}cellSz").get("width")) - int(margins.get("left")) - int(margins.get("right")) + hancom = len(paragraph.findall(f"{HP}linesegarray/{HP}lineseg")) + faces.add(style.glyph_face) + assert estimate_lines(text, width, points, style) == hancom, (style.glyph_face, f"U+{ord(text[0]):04X}", points) + assert len(cells) == 96 and faces == {"없는글꼴이름", "Cre고딕", "가짜명조체"} + + def test_faces_in_hancoms_own_folder_take_the_design_advances_of_their_fonts() -> None: # The faces 한/글 keeps in its own folder are measured from their fonts too: a Hangul syllable takes 900 of # 1000 units in 한컴 윤고딕 230, 850 in 한컴 백제 M, the full em in 휴먼고딕 (512 units) and HY울릉도M (1024) From f10ac8a8c371d718b7aeab5d99d415467c9cf9a3 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:38:34 +0900 Subject: [PATCH 3/3] Preserve spacing around independent font regression tests --- tests/test_form_fit_hancom_rules.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_form_fit_hancom_rules.py b/tests/test_form_fit_hancom_rules.py index 419abdc7..6c8fc847 100644 --- a/tests/test_form_fit_hancom_rules.py +++ b/tests/test_form_fit_hancom_rules.py @@ -236,8 +236,6 @@ def test_dodum_punctuation_and_the_exclamation_mark_break_where_hancom_breaks_th assert len(cells) == 144 - - def test_cells_in_more_faces_break_where_hancom_breaks_them() -> None: # Hancom laid these documents out: each cell holds a glyph and five Hangul syllables in 한컴산뜻돋움, HY중고딕, # HY견고딕, HY신명조, 새굴림 or 한컴 말랑말랑 Regular at one of the two widths where its line count changes, at