From 572ed91f23152c19d25d4d8503dd7086db292223 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:24:08 +0900 Subject: [PATCH] fix(layout): tell an ideographic space typed in the text from a fixed-width one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit python-hwpx reads hp:fwSpace as U+3000, and FormFit measures U+3000 as the fixed-width space: a quarter of the em that hangs once at a line end. An ideographic space typed in the text of hp:t is another thing to Hancom: the glyph U+3000 at the face's advance (a full em in most faces), which a line may start after, and which hangs at a line end however many follow each other. The page estimate now passes it to FormFit as one of its own. A fixed-width space that starts at or past the margin begins the next line, even right after a word; FormFit hung it when the word ended exactly at the margin. The new fixture holds 30 cells Hancom laid out and saved: rows of "가나다라마" with one or two typed ideographic spaces, and with fixed-width spaces, in cells of narrowing widths. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++ src/hwpx/form_fit/measure.py | 38 ++++++++++++++---- src/hwpx/layout/pages.py | 8 ++-- .../pages_typed_ideographic_spaces.hwpx | Bin 0 -> 11063 bytes tests/test_layout_page_estimate.py | 24 +++++++++++ 5 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/hancom_saved/pages_typed_ideographic_spaces.hwpx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5973c0ef..67f3b022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -271,6 +271,12 @@ 낱말이 된다). 전에는 줄 끝에 걸리는 빈칸으로 보았다. 고정폭 빈칸은 장평·자간·최소 공백과 상관없이 글자 크기의 1/4(1/1800인치 단위로 버림)이고, 빈칸처럼 줄 끝에 걸리며 그 뒤에서 줄이 나뉜다. 전에는 온폭 글자로 보았다. 줄 캐시 없는 문단의 쪽 수 추정은 둘을 글에서 빼고 줄을 나누었는데, 이제 글자로 센다. +- 쪽 수 추정(실험, `estimate_pages`)이 `hp:t` 글 안에 글자로 든 전각 공백(U+3000)을 고정폭 빈칸(`hp:fwSpace`)으로 + 보던 것을 고친다(python-hwpx는 둘을 모두 U+3000으로 읽는다). 글자 전각 공백은 글꼴의 폭(대개 글자 크기만큼)을 + 차지하고, 그 뒤에서 줄이 나뉘며, 줄 끝에서는 몇 개가 이어져도 모두 매달린다. 전에는 1/4 폭의 고정폭 빈칸으로 + 재어 줄을 늦게 나누었다. + - FormFit은 고정폭 빈칸이 낱말 바로 뒤라도 여백에서(또는 넘어서) 시작하면 다음 줄로 넘긴다. 전에는 낱말이 + 여백에 딱 맞을 때 그 뒤 고정폭 빈칸을 줄 끝에 매달았다. - 쪽 수 추정(실험, `estimate_pages`)이 나누지 않게 한 흐르는 표(`hp:tbl@pageBreak="NONE"`)의 높이를 세지 않던 것을 고친다(다음 쪽으로 넘어갈 때 쪽 수만 하나 더했다). 한/글은 그런 표의 행이 앵커 줄 아래에 다 들어가면 여느 흐르는 표처럼 그 자리를 차지하고, 다음 문단은 표 끝 아래에 온다. 다 들어가지 않으면 표만 다음 쪽 맨 diff --git a/src/hwpx/form_fit/measure.py b/src/hwpx/form_fit/measure.py index 9ee2b63b..f4eaf7b0 100644 --- a/src/hwpx/form_fit/measure.py +++ b/src/hwpx/form_fit/measure.py @@ -102,12 +102,19 @@ # --- Hancom line layout rules (no font file needed) --------------------------- # #: Spaces that hang past the right margin at a line end; a line never starts -#: with one: the space and the fixed-width space (고정폭 빈칸, U+3000). A no-break -#: space (묶음 빈칸, U+00A0) does neither: it is a half-em space like the space -#: that keeps the words beside it on one line. -_HANGING_SPACES = " \u3000" +#: with one: the space, the fixed-width space (고정폭 빈칸, U+3000) and a typed +#: ideographic space (see ``_GLYPH_SPACE``). A no-break space (묶음 빈칸, U+00A0) +#: does neither: it is a half-em space like the space that keeps the words +#: beside it on one line. +_HANGING_SPACES = " \u3000\ufdd0" #: The fixed-width space: a quarter of the em, whatever the 장평, 자간 or 최소 공백. _FIXED_SPACE = "\u3000" +#: An ideographic space typed as a character (U+3000 in the text of ``hp:t``; +#: python-hwpx reads ``hp:fwSpace`` as U+3000 too), as the page estimate passes it: +#: the glyph U+3000 at the face's advance, which Hancom may start a line after +#: and which hangs at a line end however many follow each other (after a word, +#: only the first fixed-width space or space hangs). +_GLYPH_SPACE = "\ufdd0" #: The spaces Hancom makes half an em wide (unless the font's own space is used). _HALF_EM_SPACES = frozenset(" \u00a0") #: Closing punctuation that never starts a line; it moves down with the @@ -534,11 +541,13 @@ def char_advance(ch: str, font_pt: float, style: TextStyle | None = None) -> flo ``_LAYOUT_UNIT``); other glyphs scale their class average. """ + typed = ch == _GLYPH_SPACE + ch = "\u3000" if typed else ch if style is None: return _ADVANCE_EM[classify_char(ch)] * font_pt * 100.0 cls = classify_char(ch) height = round(font_pt * 100.0) - if ch == _FIXED_SPACE: + if ch == _FIXED_SPACE and not typed: return float(height // _LAYOUT_UNIT // 4 * _LAYOUT_UNIT) ratio, spacing = _scaling(ch, style) if ch in _HALF_EM_SPACES and not style.use_font_space: @@ -646,7 +655,9 @@ def hancom_line_starts( ``widths[k]`` is the width of line ``k`` in HWPUNIT (the last one repeats). A line takes characters while they fit (the last one without its 자간); the space right after a word hangs past the margin, and a further space - that starts at or past it begins the next line. With ``style.condense`` the + that starts at or past it begins the next line, as does a fixed-width + space that starts there right after a word; typed ideographic spaces all + hang (see ``_GLYPH_SPACE``). With ``style.condense`` the spaces after the line's first text may shrink by that share of a space without its 자간 to make room for a character; the spaces before it never do. Once the line's text reaches the margin (its 자간 included), its spaces @@ -679,13 +690,14 @@ def hancom_line_starts( fixed = None if advances is None else advances.get(end) advance = char_advance(ch, size, look) if fixed is None else fixed if ch in _HANGING_SPACES: - if used >= width and end > start and text[end - 1] in _HANGING_SPACES and not full: + if used >= width and end > start and not full and _spills(ch, text[end - 1]): spilled = True break if seen and used >= width and text[end - 1] not in _HANGING_SPACES: full = True # the text reached the margin: its spaces hang, the next word starts the next line used += advance - if seen and ch != _FIXED_SPACE: # the spaces before the line's first text never shrink + # the spaces before the line's first text never shrink, nor do fixed-width or typed ideographic ones + if seen and ch not in (_FIXED_SPACE, _GLYPH_SPACE): # a space of another size or style counts as its share of a space at *font_pt* pending += 1 if sizes is None and styles is None else ( char_advance(" ", size, _without_spacing(look)) / space) @@ -721,6 +733,16 @@ def hancom_line_starts( starts.append(start) +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 + word hangs), and a typed ideographic space never does: however many follow each other, they hang.""" + + if ch == _GLYPH_SPACE: + return False + return ch == _FIXED_SPACE or previous in _HANGING_SPACES + + @lru_cache(maxsize=256) def _without_spacing(style: TextStyle) -> TextStyle: return replace(style, spacing=0.0, scripts=tuple((name, ratio, 0.0) for name, ratio, _ in style.scripts)) diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index c62e8ecf..2c73b979 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -123,7 +123,7 @@ from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, Any -from ..form_fit.measure import char_advance, hancom_line_starts, indented_widths, text_style_from_refs +from ..form_fit.measure import _GLYPH_SPACE, char_advance, hancom_line_starts, indented_widths, text_style_from_refs from ..oxml._document_primitives import _remove_stale_paragraph_layout_cache from ..oxml.namespaces import HH, HP from ..oxml.paragraph_heading import paragraph_heading @@ -291,14 +291,16 @@ def _on(flags: dict[str, str], name: str) -> bool: def _t_text(text_element: Any) -> str: """The text of one ``hp:t``, with ``hp:lineBreak`` as a newline, ``hp:tab`` as a tab, ``hp:nbSpace`` as a - no-break space (U+00A0) and ``hp:fwSpace`` as a fixed-width one (U+3000), as python-hwpx reads them.""" + no-break space (U+00A0) and ``hp:fwSpace`` as a fixed-width one (U+3000), as python-hwpx reads them; an + ideographic space typed in the text is FormFit's ``_GLYPH_SPACE``, so it is not taken for a fixed-width one.""" parts = [text_element.text or ""] for child in text_element: name = _local(child) parts.append({"lineBreak": "\n", "tab": "\t", "nbSpace": "\u00a0", "fwSpace": "\u3000"}.get(name, "")) parts.append(child.tail or "") - return "".join(parts) + return "".join(part.replace("\u3000", _GLYPH_SPACE) if index % 2 == 0 else part + for index, part in enumerate(parts)) def _indented(line: float, shape: _Shape, style: Any) -> list[float]: diff --git a/tests/fixtures/hancom_saved/pages_typed_ideographic_spaces.hwpx b/tests/fixtures/hancom_saved/pages_typed_ideographic_spaces.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..e54a96da3afbd6d47ed6e48dc349ba7c12ffe246 GIT binary patch literal 11063 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 Non _assert_like_hancom(estimate_pages(_without_caches(data)), data, HANCOM_PAGES[name]) +def test_a_typed_ideographic_space_is_not_a_fixed_width_one() -> None: + # 맑은 고딕 10 pt cells of narrowing widths, laid out and saved by Hancom: rows of "가나다라마" each followed + # by one or two ideographic spaces typed in the text (U+3000), or by fixed-width spaces (hp:fwSpace, which + # python-hwpx reads as U+3000 too). The typed space is a full em, a line may start after it, and at a line + # end it hangs however many follow each other; a fixed-width space is a quarter em, and one starting at or + # past the margin begins the next line, even right after a word. + doc = HwpxDocument.open((FIXTURES / "pages_typed_ideographic_spaces.hwpx").read_bytes()) + cells = list(doc.oxml.sections[0].element.iter(f"{HP}tc")) + typed = 0 + for cell in cells: + paragraph = cell.find(f"{HP}subList/{HP}p") + runs = paragraph.findall(f"{HP}run") + ref = next(run.get("charPrIDRef") for run in runs if run.find(f"{HP}t") is not None) + style = page_layout.text_style_from_refs(doc.oxml, paragraph.get("paraPrIDRef"), [ref]) + segs = paragraph.findall(f"{HP}linesegarray/{HP}lineseg") + widths = [int(seg.get("horzsize")) for seg in segs] + starts = page_layout.hancom_line_starts(page_layout._run_text(runs), widths, 10, style) + assert starts == [int(seg.get("textpos")) for seg in segs], widths[0] + typed += "\u3000" in "".join(t.text or "" for t in paragraph.iter(f"{HP}t")) + assert (len(cells), typed) == (30, 22) + + @pytest.mark.parametrize( ("fixture", "pages"), [