From 702b4b8bde9072d8fe129e7948144ba36b8dcc0e Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:29:48 +0900 Subject: [PATCH 1/3] fix(pages): make a line taller by the borders its characters' shapes draw above and below A character shape with a border above or below (hh:borderFill of its charPr) makes the line its characters stand on that much taller on each side, its line spacing reckoned from that height, as Hancom lays it out; an empty run ending the paragraph counts as well. Left and right borders take no room. The line cache leaves such paragraphs to Hancom, which writes their heights and baselines otherwise. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ src/hwpx/layout/line_cache.py | 2 ++ src/hwpx/layout/pages.py | 25 ++++++++++++++++-- .../pages_character_border_line_height.hwpx | Bin 0 -> 14982 bytes tests/test_layout_page_estimate.py | 4 +++ 5 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/hancom_saved/pages_character_border_line_height.hwpx diff --git a/CHANGELOG.md b/CHANGELOG.md index 8852b181..1aec477d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,8 @@ - 쪽 수 추정과 FormFit이 줄 바꿈 뒤 빈 run으로 끝나는 문단의 빈 마지막 줄을 한/글처럼 그 run의 크기로 잡는다(글자보다 작아도). 전에는 작은 run은 무시하고 큰 run은 줄 바꿈 앞 줄까지 키웠다. 끝 run이 더 크면 그 문단의 줄 캐시는 쓰지 않는다. +- 쪽 수 추정이 글자 모양의 위·아래 테두리를 줄 높이에 넣는다. 한/글은 테두리가 있는 글자(문단 끝 빈 run 포함)가 선 줄을 그 두께만큼 + 위아래로 키우고 줄 간격도 그 높이로 잰다. 그런 문단의 줄 캐시는 쓰지 않는다. - 쪽 수 추정이 아래로 놓인 위아래 표의 띠가 오기 전에 쪽 나눔이 있어도 문서를 추정한다. 한/글처럼 표는 제자리에 두고, 쪽 나눔 문단은 표가 끝나는 쪽의 다음 쪽에서 시작한다. 전에는 그런 문서를 추정하지 않았다. - 쪽 수 추정이 한 문단에 종이 기준으로 놓인 개체가 여럿이어도 문서를 추정한다. 한/글처럼 개체마다 따로 띠로 보고 본문 줄을 diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index 7fcd6340..cd547d39 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -468,6 +468,8 @@ def _lines(measure: Any, paragraph: Any, width: int, *, first: bool, size, refs, sizes = _pages._text_size(measure, runs) if headed and not sizes and _pages._text_size(measure, runs, paragraph)[0] > size: return None # an empty paragraph under a taller number label (its line as tall as that): left for Hancom + if any(measure.char_box(run.get("charPrIDRef")) > measure.char_height(run.get("charPrIDRef")) for run in runs): + return None # a character border above or below: Hancom writes its lines' heights and baselines otherwise end = _pages._end_size(measure, runs, sizes) if sizes and end > sizes[-1] and _pages._run_text(runs).endswith("\n"): return None # a larger empty end after a line break: Hancom writes the line before it as tall as the end diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index e1246cc2..421b06b0 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -625,6 +625,7 @@ def __init__(self, root: Any) -> None: self._numbered: dict[str, bool] = {} self._label_sizes: dict[str, int] = {} self._pictured: dict[str, bool] = {} + self._boxes: dict[str, int] = {} #: how far apart the default tab stops of the section at hand are (see :func:`_tab_stop`) self.tab_stop = _DEFAULT_TAB_STOP @@ -638,6 +639,21 @@ def char_height(self, char_pr_id: Any) -> int: style = self._root.char_property(char_pr_id) return int(style.attributes.get("height", 1000)) if style is not None else 1000 + def char_box(self, char_pr_id: Any) -> int: + """How tall a character of *char_pr_id* makes its line: its height, and the width of a border its shape + draws above and below it (Hancom: the line that much taller, spaced from that height).""" + + key = str(char_pr_id) + if key not in self._boxes: + style = self._root.char_property(char_pr_id) + fill = self._root.border_fill(style.attributes.get("borderFillIDRef")) if style is not None else None + edges = 0 + for child in getattr(fill, "children", None) or (): + if child.name in ("topBorder", "bottomBorder") and child.attributes.get("type", "NONE") != "NONE": + edges += round(float(child.attributes.get("width", "0 mm").split()[0]) * 7200 / 25.4) + self._boxes[key] = self.char_height(char_pr_id) + edges + return self._boxes[key] + def font_line(self, size: int, style: Any) -> int: """How tall a line of characters *size* tall in *style* is in a paragraph taking its line height from the font (see :func:`~hwpx.form_fit.measure.font_line_height`); *size* for a face the glyph table does @@ -2745,7 +2761,7 @@ def _end_size(measure: _Measure, runs: list[Any], sizes: list[int]) -> int: if not runs or not sizes or any(len(child) or child.tag != f"{HP}t" or child.text for child in runs[-1]): return 0 - size = measure.char_height(runs[-1].get("charPrIDRef")) + size = measure.char_box(runs[-1].get("charPrIDRef")) if _run_text(runs).endswith("\n"): # after a line break the empty last line is the run alone: as tall as return size if size != sizes[-1] else 0 # itself, smaller or larger (see _line_metrics) return size if size > min(sizes) else 0 @@ -2783,15 +2799,20 @@ def _held_sizes(measure: _Measure, runs: list[Any], sizes: list[int]) -> dict[in """Where in the text a run holding a control shown on a line (see :func:`_shown_control`; not a note) or an object not set as a character stands after the first character -> that run's size, when it is larger than the smallest character: Hancom sets it as a character of no width, the line it falls on as tall as its run's - characters (those before the text, see :func:`_head_size`).""" + characters (those before the text, see :func:`_head_size`). Each character whose shape draws a border above + or below it is held too, its line as tall as the character and the border (see :meth:`_Measure.char_box`).""" if not sizes: return {} place, held = 0, {} for run in runs: + box = measure.char_box(run.get("charPrIDRef")) + boxed = box > measure.char_height(run.get("charPrIDRef")) # a border above or below its characters for child in run: name = _local(child) if name == "t": + for offset in range(len(_t_text(child))) if boxed else (): + held[place + offset + 1] = max(held.get(place + offset + 1, 0), box) place += len(_t_text(child)) elif place and (_shown_control(child) and not any(_local(kind) in ("footNote", "endNote") for kind in child) or name in _OBJECTS and (child.find(f"{HP}pos") is None diff --git a/tests/fixtures/hancom_saved/pages_character_border_line_height.hwpx b/tests/fixtures/hancom_saved/pages_character_border_line_height.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..98b96785249206d1c0da4283fc810d5c8d6cf1bb GIT binary patch literal 14982 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 XMO6Xm!4Ecc*tZ!rlYW3P0s#L9azcTR literal 0 HcmV?d00001 diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index 56b2393d..8bc0b3db 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -246,6 +246,10 @@ # empty one at 10 or 20 pt: after the break the empty last line is that run alone, as tall as itself smaller # or larger, the line before it spaced from its own characters "pages_empty_end_run_after_a_line_break": 12, + # Twenty-six times on a page of its own: ten characters at 10 or 14 pt, an empty run ending them, all of them + # or two amid them in a character shape with a border 0.1 or 0.4 mm on every side, above and below or left + # and right: a border above or below makes the line that much taller on each side, spaced from that height + "pages_character_border_line_height": 26, # A run of 14 pt before a line of 10 pt text (함초롬바탕, spaced 160%): one holding nothing but controls # makes the line as tall as itself (1400, spaced 840), an empty one takes no room: "pages_section_controls_run_taller_than_the_text": 1, # the section's and the columns' definitions From 81573dfaa3da393373da0d065eccd69a0d5cd9b6 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:27:42 +0900 Subject: [PATCH 2/3] perf(pages): read character border widths from the header once per document Looking up each character shape's border fill through the document's border_fills property parsed the whole header again on every call, making line-cache writing half as slow again. The widths above and below each border fill are now read once from the header element the measure holds. Co-Authored-By: Claude Opus 5.5 --- src/hwpx/layout/pages.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index 421b06b0..adb8ad62 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -626,6 +626,7 @@ def __init__(self, root: Any) -> None: self._label_sizes: dict[str, int] = {} self._pictured: dict[str, bool] = {} self._boxes: dict[str, int] = {} + self._edges: dict[str, int] | None = None #: how far apart the default tab stops of the section at hand are (see :func:`_tab_stop`) self.tab_stop = _DEFAULT_TAB_STOP @@ -643,15 +644,18 @@ def char_box(self, char_pr_id: Any) -> int: """How tall a character of *char_pr_id* makes its line: its height, and the width of a border its shape draws above and below it (Hancom: the line that much taller, spaced from that height).""" + if self._edges is None: # each border fill's width above and below, read once from the header + self._edges = {} + for fill in self._header.iter(f"{HH}borderFill"): + self._edges[fill.get("id")] = sum( + round(float(rule.get("width", "0 mm").split()[0]) * 7200 / 25.4) + for rule in (fill.find(f"{HH}topBorder"), fill.find(f"{HH}bottomBorder")) + if rule is not None and rule.get("type", "NONE") != "NONE") key = str(char_pr_id) if key not in self._boxes: style = self._root.char_property(char_pr_id) - fill = self._root.border_fill(style.attributes.get("borderFillIDRef")) if style is not None else None - edges = 0 - for child in getattr(fill, "children", None) or (): - if child.name in ("topBorder", "bottomBorder") and child.attributes.get("type", "NONE") != "NONE": - edges += round(float(child.attributes.get("width", "0 mm").split()[0]) * 7200 / 25.4) - self._boxes[key] = self.char_height(char_pr_id) + edges + fill = style.attributes.get("borderFillIDRef") if style is not None else None + self._boxes[key] = self.char_height(char_pr_id) + self._edges.get(fill, 0) return self._boxes[key] def font_line(self, size: int, style: Any) -> int: From 6a80127e1a50050e3081900a4e3c365a8b40a6c3 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:20:39 +0900 Subject: [PATCH 3/3] docs(changelog): list the change under Unreleased, 6.11.0 having been released Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aec477d..778fc932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ 끝은 줄에 자리를 차지하지 않는다. 전에는 필드 끝을 다음 줄로 보내 빈 줄을 하나 더 셌다. - 쪽 수 추정이 글 문단 첫 줄 아래로 놓인 나누지 않는 위아래 표가 쪽에 들어가지 않을 때, 한/글처럼 문단 줄은 들어가는 쪽에 두고 표만 다음 쪽 맨 위로 보낸다. 전에는 문단 줄까지 다음 쪽 표 아래로 보내 쪽 수가 하나 늘었다. +- 쪽 수 추정이 글자 모양의 위·아래 테두리를 줄 높이에 넣는다. 한/글은 테두리가 있는 글자(문단 끝 빈 run 포함)가 선 줄을 그 두께만큼 + 위아래로 키우고 줄 간격도 그 높이로 잰다. 그런 문단의 줄 캐시는 쓰지 않는다. ## [6.11.0] - 2026-11-02 @@ -52,8 +54,6 @@ - 쪽 수 추정과 FormFit이 줄 바꿈 뒤 빈 run으로 끝나는 문단의 빈 마지막 줄을 한/글처럼 그 run의 크기로 잡는다(글자보다 작아도). 전에는 작은 run은 무시하고 큰 run은 줄 바꿈 앞 줄까지 키웠다. 끝 run이 더 크면 그 문단의 줄 캐시는 쓰지 않는다. -- 쪽 수 추정이 글자 모양의 위·아래 테두리를 줄 높이에 넣는다. 한/글은 테두리가 있는 글자(문단 끝 빈 run 포함)가 선 줄을 그 두께만큼 - 위아래로 키우고 줄 간격도 그 높이로 잰다. 그런 문단의 줄 캐시는 쓰지 않는다. - 쪽 수 추정이 아래로 놓인 위아래 표의 띠가 오기 전에 쪽 나눔이 있어도 문서를 추정한다. 한/글처럼 표는 제자리에 두고, 쪽 나눔 문단은 표가 끝나는 쪽의 다음 쪽에서 시작한다. 전에는 그런 문서를 추정하지 않았다. - 쪽 수 추정이 한 문단에 종이 기준으로 놓인 개체가 여럿이어도 문서를 추정한다. 한/글처럼 개체마다 따로 띠로 보고 본문 줄을