From 48c4ac782ce46897f82264adeb08cb72f571a2d2 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:15:41 +0900 Subject: [PATCH] =?UTF-8?q?fix(form=5Ffit):=20lay=20bold=20glyphs=20in=20t?= =?UTF-8?q?he=20=ED=95=9C=EC=96=91=20faces=20out=20a=20twentieth=20of=20th?= =?UTF-8?q?e=20em=20wider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FormFit and the page estimate laid a bold glyph out at its regular advance in every face. Hancom Office draws bold glyphs in 한양신명조, 한양중고딕 and 한양견고딕 a twentieth of the em wider, added before it rounds the advance to whole layout units ("가" at 14 pt is 1472, not 1400); in the other faces bold keeps the regular advance. TextStyle gains `bold`, read from the character shape. A Hancom-saved fixture holds 한양신명조 regular and bold glyphs before tabs at 9 to 20 pt. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++ src/hwpx/form_fit/measure.py | 24 +++++++++++--- .../hancom_saved/formfit_bold_advances.hwpx | Bin 0 -> 19009 bytes tests/test_form_fit_hancom_rules.py | 30 ++++++++++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/hancom_saved/formfit_bold_advances.hwpx diff --git a/CHANGELOG.md b/CHANGELOG.md index 318d6426..fc68f0ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,9 @@ ### 고침 +- FormFit과 쪽 수 추정이 한양 글꼴(한양신명조·한양중고딕·한양견고딕)의 굵은 글자를 em의 1/20만큼 넓게 잡는다. 한/글은 + 그 폭을 더한 뒤 배치 단위로 반올림한다(14pt "가"가 1,400이 아니라 1,472). 전에는 굵은 글자도 보통 폭으로 셌다. 다른 + 글꼴의 굵은 글자는 전처럼 보통 폭이다. `TextStyle.bold`가 글자 모양의 굵게를 담는다. - FormFit과 쪽 수 추정에서 `!`의 반 em을 공백처럼 배치 단위(1/1800인치)로 내림한다. 9·11·13pt처럼 글자 크기의 4분의 1이 홀수인 크기에서 `!`가 4 넓게 잡혀, 그 뒤 탭 폭이 한/글보다 4 좁았다. - `.hwp`를 HWPX로 읽을 때 변경 추적 설정(`hh:trackchageConfig@flags`)을 설정 레코드의 첫 값 그대로 쓴다. 한컴오피스 2024가 diff --git a/src/hwpx/form_fit/measure.py b/src/hwpx/form_fit/measure.py index ac9db630..dea4043d 100644 --- a/src/hwpx/form_fit/measure.py +++ b/src/hwpx/form_fit/measure.py @@ -121,6 +121,9 @@ #: The glyphs Hancom lays out at half an em in every face, whatever the font's own advance (a glyph all the #: same: rounded, \uc7a5\ud3c9 and \uc790\uac04 applied as any other is). _HALF_EM_GLYPHS = frozenset("!") +#: The faces Hancom lays a bold glyph out wider in: by a twentieth of the em, added before the advance is rounded +#: to whole layout units. In the other faces a bold glyph keeps its regular advance. +_BOLD_WIDER_FACES = frozenset({"한양중고딕", "한양신명조", "한양견고딕"}) #: Pictographs Hancom draws as color emoji: 1.373 em in every face (a design advance of 1373 in 1000), however #: the face draws its other symbols. The pictograph planes past the BMP, and the BMP symbols that take the emoji #: presentation there (⚫ ⚡ ⌛ ☎ ✔ ➡ and other symbols stay the face's own). The regional indicators of a flag @@ -1212,12 +1215,15 @@ def font_line_height(face: str | None, height: int) -> int | None: @lru_cache(maxsize=4096) -def _laid_out(units: int, upem: int, height: int, ratio: float, spacing: float) -> float: +def _laid_out(units: int, upem: int, height: int, ratio: float, spacing: float, bold: bool = False) -> float: """HWPUNIT a glyph of design advance *units* (of *upem*) takes at a character - height of *height* HWPUNIT, its 자간 included.""" + height of *height* HWPUNIT, its 자간 included; *bold* in a face of + ``_BOLD_WIDER_FACES``, a twentieth of the em wider (seen at 장평 100 %).""" scaled = Fraction(units * (height // _LAYOUT_UNIT), upem) if ratio == 100: + if bold: + scaled += Fraction(height // _LAYOUT_UNIT, 20) advance = math.floor(scaled + Fraction(1, 2)) else: advance = math.floor(scaled * Fraction(ratio) / 100) @@ -1286,7 +1292,9 @@ class TextStyle: and the other glyphs take, laid out as Hancom rounds them (see :func:`char_advance`); an empty or unlisted face, or a glyph it does not list, falls back to ``hangul_advance`` (a Hangul syllable in em) and to - the class averages. + the class averages. ``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. """ ratio: float = 100.0 @@ -1309,6 +1317,7 @@ class TextStyle: tab_stops: tuple[tuple[int, str], ...] = () auto_tab_left: bool = False auto_tab_right: bool = False + bold: bool = False def _line_origin(style: TextStyle, first: bool) -> float: @@ -1491,6 +1500,7 @@ def char_advance(ch: str, font_pt: float, style: TextStyle | None = None) -> flo ratio, spacing = _scaling(ch, style) if ch in _HALF_EM_SPACES and not style.use_font_space: return _laid_out_space(height, ratio, spacing) + face = style.hangul_face if cls == "hangul" else style.glyph_face if emoji: design, base = _EMOJI_UNITS, _ADVANCE_EM[cls] elif cls == "hangul": @@ -1500,7 +1510,8 @@ def char_advance(ch: str, font_pt: float, style: TextStyle | None = None) -> flo else: 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) + bold = style.bold and not emoji and face in _BOLD_WIDER_FACES + return _laid_out(design[0], design[1], height, ratio, spacing, bold) return base * font_pt * 100.0 * ratio / 100.0 * (1 + spacing / 100.0) @@ -2302,7 +2313,7 @@ def text_style_from_refs( last run): a bullet or number label without a character shape of its own takes that one, else the text's.""" - ratio, spacing, use_font_space, hangul_face, glyph_face = 100.0, 0.0, False, "", "" + ratio, spacing, use_font_space, hangul_face, glyph_face, bold = 100.0, 0.0, False, "", "", False scripts: tuple[tuple[str, float, float], ...] = () root = _document_root(document) height, used = 1000, None @@ -2323,6 +2334,7 @@ def text_style_from_refs( if (_style_number(ratios.get(name), ratio), _style_number(spacings.get(name), spacing)) != (ratio, spacing) ) use_font_space = (getattr(run_style, "attributes", {}) or {}).get("useFontSpace") in {"1", "true"} + bold = "bold" in children hangul_face, glyph_face = _face_names(root, ref) height = int(_style_number((getattr(run_style, "attributes", {}) or {}).get("height"), 1000.0)) used = ref @@ -2339,6 +2351,7 @@ def text_style_from_refs( glyph_face=glyph_face, hangul_face=hangul_face, scripts=scripts, + bold=bold, ) breaks = getattr(prop, "break_setting", None) try: @@ -2372,6 +2385,7 @@ 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)), + bold=bold, ) heading = paragraph_label(root, para_pr_id_ref, label) if heading is None: diff --git a/tests/fixtures/hancom_saved/formfit_bold_advances.hwpx b/tests/fixtures/hancom_saved/formfit_bold_advances.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..3b056f87a7b4c104dc25f2159bfde75f4ff290c1 GIT binary patch literal 19009 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 Rq#F|WTMx!eD{;ut{{j0(P-y@F literal 0 HcmV?d00001 diff --git a/tests/test_form_fit_hancom_rules.py b/tests/test_form_fit_hancom_rules.py index 3ecbf05d..04df723a 100644 --- a/tests/test_form_fit_hancom_rules.py +++ b/tests/test_form_fit_hancom_rules.py @@ -57,6 +57,7 @@ FONT_LINE_HEIGHTS = Path(__file__).parent / "fixtures" / "hancom_saved" / "formfit_font_line_heights.hwpx" PARAGRAPH_MARGINS = Path(__file__).parent / "fixtures" / "hancom_saved" / "formfit_paragraph_margins.hwpx" PARAGRAPH_SPACING = Path(__file__).parent / "fixtures" / "hancom_saved" / "formfit_paragraph_spacing.hwpx" +BOLD_ADVANCES = Path(__file__).parent / "fixtures" / "hancom_saved" / "formfit_bold_advances.hwpx" CHARS = TextStyle(break_non_latin_word="KEEP_WORD") # Hancom 글자 단위 SPACE_RUNS = Path(__file__).parent / "fixtures" / "hancom_saved" / "formfit_space_runs.hwpx" @@ -387,6 +388,35 @@ def test_faces_in_hancoms_own_folder_take_the_design_advances_of_their_fonts() - assert glyph_advance_em("한컴 윤고딕 230", "(") == 380 / 1000 +def test_a_bold_glyph_in_a_hanyang_face_is_a_twentieth_of_the_em_wider() -> None: + # Hancom adds a twentieth of the em to a bold glyph's advance in the 한양 faces before rounding it to whole + # layout units: "가" at 14 pt is 350 + 17.5 units, 368 (1472), against 350 (1400) regular. Bold glyphs in the + # other faces keep their regular advances. + hanyang = TextStyle(hangul_face="한양신명조", glyph_face="한양신명조") + assert char_advance("가", 14, hanyang) == 1400 + assert char_advance("가", 14, replace(hanyang, bold=True)) == 1472 + assert char_advance("가", 9, replace(hanyang, bold=True)) == 900 + 44 + malgun = TextStyle(hangul_face="맑은 고딕", glyph_face="맑은 고딕") + assert char_advance("가", 14, replace(malgun, bold=True)) == char_advance("가", 14, malgun) + + +def test_bold_glyphs_take_the_advances_hancom_lays_them_out_at() -> None: + # Hancom laid out three glyphs then a tab, regular and bold, in 한양신명조 at 9 to 20 pt: each tab reaches + # the next default stop past the glyphs, so its width shows how wide they are. + document = HwpxDocument.open(BOLD_ADVANCES) + paragraphs = document.oxml.sections[0].element.findall(f"{HP}p")[1:] + bold_seen = False + for paragraph in paragraphs: + runs = paragraph.findall(f"{HP}run") + style = text_style_from_refs(document, paragraph.get("paraPrIDRef"), [runs[0].get("charPrIDRef")]) + bold_seen = bold_seen or style.bold + text = "".join(t.text or "" for t in paragraph.iter(f"{HP}t")) + size = int(document.oxml.char_property(runs[0].get("charPrIDRef")).attributes["height"]) / 100 + x = sum(char_advance(ch, size, style) for ch in text[:3]) + assert round((x // 4000 + 1) * 4000 - x) == int(paragraph.find(f".//{HP}tab").get("width")) + assert bold_seen + + def test_a_hangul_syllable_takes_its_own_advance_where_the_font_gives_it_one() -> None: # 한컴 말랑말랑 Regular's font draws the syllables with a horizontal vowel (ㅗ ㅛ ㅜ ㅠ ㅡ) 820 of 1000 units # wide and the others 880; the faces whose syllables are all one width keep it.