From c68e0ca3c09a903eca1672c6c9d395e61d56ce46 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:02:40 +0900 Subject: [PATCH 1/4] feat(layout): page estimate for a table placed top and bottom alone in a cell paragraph without caches Without layout caches a cell paragraph holding nothing but tables placed top and bottom from its top makes the cell reach the tables' foot, as tall as their rows (one placed up stands at the paragraph's top), as a drawing there does; the line after such a paragraph goes on below that foot, also when the row splits between its lines over a page end. Such a cell was unsupported ("a nested table"). Co-Authored-By: Claude Opus 5.5 --- src/hwpx/layout/pages.py | 54 +++++++++++++++++++++++------- tests/test_layout_page_estimate.py | 48 +++++++++++++++++--------- 2 files changed, 74 insertions(+), 28 deletions(-) diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index 284ac425..3e9e05ce 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -96,7 +96,9 @@ where it would stand in the unsplit cell, a first line below such a table with the room above it, so the row splits again where it would). A picture or drawing placed top and bottom or wrapped square from a cell paragraph holding no text makes the cell reach its foot (outer margins - included). A flowing table's rows use + included), and so do tables placed top and bottom from a cell paragraph holding nothing else, + without such caches, as tall as their rows (one placed up from the paragraph's top stands at that + top); what follows in the cell goes on below that foot. A flowing table's rows use the body only down to just above the page's foot (101 above it, or 2 in a table set not to be adjusted), less the table's bottom outer margin: a row, or a cell line of a row split between its lines, ending lower goes on to the next page (where the table goes on below its top outer margin; @@ -129,7 +131,8 @@ stays on one page with the lines above or beside it), footnotes in such a paragraph, two tables starting past their anchors on one page, rows merged together that do not fit under their table's anchor or on a -page, a nested table among text or not set as a character in a cell without such caches (in a table +page, a nested table among text, or not set as a character but top and bottom alone in its paragraph, +in a cell without such caches (in a table Hancom has not laid out as it is), a page break in a flowing row holding a table beside a taller cell, other objects placed on the page or the paper (but top and bottom from its top or bottom, a flowing table on their page keeping clear of them), and in a paragraph without such a cache ruby text @@ -539,8 +542,8 @@ def stack(self, paragraphs: list[Any], width: int, caches: bool) -> tuple[int, i height += sum(advance for _, advance in cached[:-1]) + cached[-1][0] size, pitch = cached[-1] pending = pitch - size + shape.next - if top + _objects_reach(runs) > height: # an object placed from the paragraph reaches lower - height, pending = top + _objects_reach(runs), shape.next + if top + _objects_reach(runs, self) > height: # an object placed from it reaches lower + height, pending = top + _objects_reach(runs, self), shape.next lines += len(cached) continue style = self.style(paragraph.get("paraPrIDRef"), refs, paragraph) @@ -552,8 +555,8 @@ def stack(self, paragraphs: list[Any], width: int, caches: bool) -> tuple[int, i top = height height += (count - 1) * pitch + size pending = pitch - size + shape.next - if top + _objects_reach(runs) > height: # an object placed from the paragraph reaches lower - height, pending = top + _objects_reach(runs), shape.next + if top + _objects_reach(runs, self) > height: # an object placed from the paragraph reaches lower + height, pending = top + _objects_reach(runs, self), shape.next lines += count return height, lines, pitch, size @@ -589,7 +592,8 @@ def stack_lines(self, paragraphs: list[Any], width: int, caches: bool) -> tuple[ metrics += list(cached[:-1]) + [(cached[-1][0], cached[-1][1] + shape.next)] continue height, count, pitch, size = self.stack([paragraph], width, caches) - metrics += [(size, pitch)] * (count - 1) + [(height - (count - 1) * pitch, pitch + shape.next)] + last = height - (count - 1) * pitch # down to the foot of an object placed from it: the next below + metrics += [(size, pitch)] * (count - 1) + [(last, (last if last > size else pitch) + shape.next)] return tuple(metrics) def marked_lines(self, paragraph: Any, runs: list[Any], width: int) -> tuple[tuple[int, int], ...]: @@ -795,7 +799,9 @@ def _cell_row(measure: _Measure, table: Any, cell: Any) -> _Row: if nested and any(paragraph.find(f".//{HP}tbl") is not None and _table_alone(paragraph.findall(f"{HP}run")) is None for paragraph in paragraphs): drawn = _drawn_lines(measure, paragraphs) # among text, or not set as a character: as Hancom drew it - if not drawn: + if not drawn and not all(_tables_on_their_own(paragraph) for paragraph in paragraphs + if paragraph.find(f".//{HP}tbl") is not None + and _table_alone(paragraph.findall(f"{HP}run")) is None): raise _Unsupported("a nested table") size = cell.find(f"{HP}cellSz") margins = cell_margins_of(cell, table) @@ -1024,9 +1030,10 @@ def _caption(measure: _Measure, table: Any) -> tuple[int, int]: def _inline_table_height(measure: _Measure, table: Any) -> int: - """A table set as a character: its rows as the estimate measures them. When the row model does not - follow the table (merged rows, a nested table) but every paragraph in it keeps a valid layout cache, - Hancom laid it out as it is, and the height it saved (hp:sz) is the height it draws.""" + """A table set as a character (or placed top and bottom in a cell): its rows as the estimate measures + them. When the row model does not follow the table (merged rows, a nested table) but every paragraph in + it keeps a valid layout cache, Hancom laid it out as it is, and the height it saved (hp:sz) is the height + it draws.""" if table.find(f".//{HP}tbl") is not None or any(_row_span(tc) != 1 for tc in table.iter(f"{HP}tc")): paragraphs = list(table.iter(f"{HP}p")) @@ -1074,16 +1081,20 @@ def _check_section(section: Any) -> None: raise _Unsupported("section settings (hp:secPr) after the first paragraph") -def _objects_reach(runs: list[Any]) -> int: +def _objects_reach(runs: list[Any], measure: _Measure) -> int: """How far below the top of their paragraph in a cell, holding no text, the pictures and drawings placed from it reach: top and bottom or square, not set as a character, their outer margins included (the cell - holds them, and its row grows to). Tables in a cell are laid out on their own.""" + holds them, and its row grows to). So do the tables placed top and bottom from it, as tall as their rows + (one placed up stands at the paragraph's top).""" reach = 0 if _run_text(runs).strip(): # text goes on below such an object: not followed here return reach for obj in (child for run in runs for child in run): name, pos = _local(obj), obj.find(f"{HP}pos") + if name == "tbl" and _placed_top_and_bottom(obj): + reach = max(reach, _down(pos) + _inline_table_height(measure, obj) + _extent_margins(obj)) + continue if name not in _OBJECTS or name == "tbl" or pos is None or obj.find(f"{HP}sz") is None \ or pos.get("treatAsChar") == "1" or obj.get("textWrap") not in ("TOP_AND_BOTTOM", "SQUARE") \ or pos.get("vertRelTo") != "PARA" or pos.get("vertAlign", "TOP") != "TOP": @@ -1095,6 +1106,23 @@ def _objects_reach(runs: list[Any]) -> int: return reach +def _placed_top_and_bottom(obj: Any) -> bool: + """An object placed top and bottom from its paragraph's top, not set as a character.""" + + pos = obj.find(f"{HP}pos") + return pos is not None and pos.get("treatAsChar") != "1" and obj.get("textWrap") == "TOP_AND_BOTTOM" \ + and pos.get("vertRelTo") == "PARA" and pos.get("vertAlign", "TOP") == "TOP" + + +def _tables_on_their_own(paragraph: Any) -> bool: + """Whether a cell paragraph holds no text and nothing but tables placed top and bottom from its top.""" + + runs = paragraph.findall(f"{HP}run") + objects = [child for run in runs for child in run if _local(child) in _OBJECTS] + return not _run_text(runs).strip() and bool(objects) and all( + _local(obj) == "tbl" and _placed_top_and_bottom(obj) for obj in objects) + + def _placed_objects(runs: list[Any]) -> list[Any]: """The objects of the runs.""" diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index 42e456a5..7dc55d3e 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -529,16 +529,22 @@ def test_the_lines_around_a_nested_table_are_where_hancom_put_them() -> None: _assert_like_hancom(estimate_pages(data), data, 1) -@pytest.mark.parametrize("fixture", ["pages_table_nested_top_and_bottom_alone", - "pages_table_nested_top_and_bottom_before_text", "pages_table_nested_among_text"]) -def test_a_cell_holding_a_table_among_text_or_top_and_bottom_is_as_tall_as_hancom_drew_it(fixture: str) -> None: +@pytest.mark.parametrize(("fixture", "followed"), [("pages_table_nested_top_and_bottom_alone", True), + ("pages_table_nested_top_and_bottom_before_text", False), + ("pages_table_nested_among_text", False)]) +def test_a_cell_holding_a_table_among_text_or_top_and_bottom_is_as_tall_as_hancom_drew_it(fixture: str, + followed: bool) -> None: # A table in a cell of a flowing table, placed top and bottom alone in its paragraph (the cell # reaches down to its foot) or before text (the text goes below it), or set as a character among - # text: the lines of the cell's caches. Without the caches the estimate does not follow such a cell. + # text: the lines of the cell's caches. Without the caches the estimate follows the cell when the + # table is alone in its paragraph, as tall as its rows. data = (FIXTURES / f"{fixture}.hwpx").read_bytes() _assert_like_hancom(estimate_pages(data), data, 1) - assert estimate_pages(_without_caches(data)).unsupported == ("section 0: a nested table",) + if followed: + _assert_like_hancom(estimate_pages(_without_caches(data)), data, 1) + else: + assert estimate_pages(_without_caches(data)).unsupported == ("section 0: a nested table",) def test_a_negative_outer_margin_counts_as_none() -> None: @@ -593,19 +599,25 @@ def test_a_footnote_in_a_paragraph_without_a_cache_in_columns_of_unequal_width_i "in columns of unequal width",) -@pytest.mark.parametrize("fixture", ["pages_table_row_split_in_first_paragraph", - "pages_table_row_split_after_nested_table", - "pages_table_row_split_moves_nested_table", - "pages_table_row_split_moves_nested_table_above_text"]) -def test_a_row_holding_a_nested_table_split_over_a_page_splits_where_hancom_split_it(fixture: str) -> None: +@pytest.mark.parametrize(("fixture", "followed"), [("pages_table_row_split_in_first_paragraph", True), + ("pages_table_row_split_after_nested_table", False), + ("pages_table_row_split_moves_nested_table", True), + ("pages_table_row_split_moves_nested_table_above_text", False)]) +def test_a_row_holding_a_nested_table_split_over_a_page_splits_where_hancom_split_it(fixture: str, + followed: bool) -> None: # A flowing table (split by cell) whose row 1 holds, in one cell, six lines, a 2x2 table placed top and # bottom (alone in its paragraph, or above a line of text) and six lines more, going on over the page # end among the first six lines, after the nested table, or at it (the nested table goes on to the # next page, with the text below it). Hancom's caches of the cell start over at the next page's top. + # Without the caches the estimate splits the row there too when the table is alone in its paragraph: + # the table is one line, as tall as its rows, and the text after it goes on below its foot. data = (FIXTURES / f"{fixture}.hwpx").read_bytes() _assert_like_hancom(estimate_pages(data), data, 2) - assert estimate_pages(_without_caches(data)).unsupported == ("section 0: a nested table",) + if followed: + _assert_like_hancom(estimate_pages(_without_caches(data)), data, 2) + else: + assert estimate_pages(_without_caches(data)).unsupported == ("section 0: a nested table",) @pytest.mark.parametrize("fixture", ["pages_table_nested_square_alone", "pages_table_nested_square_beside_text"]) @@ -619,15 +631,21 @@ def test_a_cell_holding_a_table_wrapped_square_is_as_tall_as_hancom_drew_it(fixt assert estimate_pages(_without_caches(data)).unsupported == ("section 0: a nested table",) -@pytest.mark.parametrize("fixture", ["pages_table_nested_top_and_bottom_placed_up_alone", - "pages_table_nested_square_placed_up_alone"]) -def test_a_table_placed_up_from_its_paragraph_in_a_cell_stands_at_the_paragraph_top(fixture: str) -> None: +@pytest.mark.parametrize(("fixture", "followed"), [("pages_table_nested_top_and_bottom_placed_up_alone", True), + ("pages_table_nested_square_placed_up_alone", False)]) +def test_a_table_placed_up_from_its_paragraph_in_a_cell_stands_at_the_paragraph_top(fixture: str, + followed: bool) -> None: # A table alone in a cell's paragraph after two lines, placed top and bottom or wrapped square 1000 # up from the paragraph's top (a negative offset, kept as an unsigned number): Hancom puts it at the - # paragraph's top, and the cell reaches down to its foot from there. + # paragraph's top, and the cell reaches down to its foot from there; without the caches as well, for + # the table placed top and bottom. data = (FIXTURES / f"{fixture}.hwpx").read_bytes() _assert_like_hancom(estimate_pages(data), data, 1) + if followed: + _assert_like_hancom(estimate_pages(_without_caches(data)), data, 1) + else: + assert estimate_pages(_without_caches(data)).unsupported == ("section 0: a nested table",) def test_the_lines_a_cell_splits_between_add_up_to_its_height() -> None: From 953cf42358ae56fffebc897dcbf449e4bc078290 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:02:58 +0900 Subject: [PATCH 2/4] docs(changelog): page estimate for a table alone in a cell paragraph without caches Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e08c16d2..f1cae2ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ### 고침 +- 쪽 수 추정(실험, `estimate_pages`)이 줄 캐시 없는 표 칸에서 글 없이 위아래 배치 표만 든 문단을 지원 밖 + (`a nested table`)으로 두던 것을 고친다. 이제 그림·도형처럼 칸이 그 표의 아래 끝(행으로 잰 표 높이와 바깥 여백)까지 + 닿고, 칸의 다음 줄은 그 아래에서 이어진다. 위로 띄운 표는 문단 윗선에 선다. 그런 행이 쪽을 넘어 줄 사이에서 + 나뉠 때도 표는 그 높이의 한 줄로 센다. - 쪽 수 추정(실험, `estimate_pages`)이 글 문단의 첫 줄에서 아래로 띄운 흐르는 위아래 표를 쪽 끝에서도 따른다. - 그 줄이 쪽 바닥 위에 들어가지 않으면 줄과 표가 함께 다음 쪽으로 가고, 표는 거기서 띄운 만큼 아래에서 흐른다. - 줄은 들어가는데 표 윗선이 바닥을 넘거나, 행 단위로 옮기는(TABLE) 표의 첫 행이 들어가지 않으면, 표만 다음 쪽 맨 From 6cbdfa3b0f9693afac4840b972d17ca4b555428d Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:39:46 +0900 Subject: [PATCH 3/4] fix(layout): follow only one table placed top and bottom alone in a cell paragraph without caches Hancom sets the empty line of a paragraph holding two such tables below the second, not beside the first: without caches that cell stays unsupported. Fixtures: a line after such a table, a table 1000 down, two tables. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++-- src/hwpx/layout/pages.py | 18 ++++++++++-------- ...nested_top_and_bottom_1000_down_alone.hwpx | Bin 0 -> 8302 bytes ...sted_top_and_bottom_alone_then_a_line.hwpx | Bin 0 -> 8287 bytes ...pages_table_nested_two_top_and_bottom.hwpx | Bin 0 -> 8398 bytes tests/test_layout_page_estimate.py | 9 ++++++++- 6 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_1000_down_alone.hwpx create mode 100644 tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_alone_then_a_line.hwpx create mode 100644 tests/fixtures/hancom_saved/pages_table_nested_two_top_and_bottom.hwpx diff --git a/CHANGELOG.md b/CHANGELOG.md index f1cae2ae..ea8f4875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,11 @@ ### 고침 -- 쪽 수 추정(실험, `estimate_pages`)이 줄 캐시 없는 표 칸에서 글 없이 위아래 배치 표만 든 문단을 지원 밖 +- 쪽 수 추정(실험, `estimate_pages`)이 줄 캐시 없는 표 칸에서 글 없이 위아래 배치 표 하나만 든 문단을 지원 밖 (`a nested table`)으로 두던 것을 고친다. 이제 그림·도형처럼 칸이 그 표의 아래 끝(행으로 잰 표 높이와 바깥 여백)까지 닿고, 칸의 다음 줄은 그 아래에서 이어진다. 위로 띄운 표는 문단 윗선에 선다. 그런 행이 쪽을 넘어 줄 사이에서 - 나뉠 때도 표는 그 높이의 한 줄로 센다. + 나뉠 때도 표는 그 높이의 한 줄로 센다. 한 문단에 표가 둘이면 한/글은 빈 줄을 아래 표 밑으로 보내므로 전처럼 + 캐시가 있어야 한다. - 쪽 수 추정(실험, `estimate_pages`)이 글 문단의 첫 줄에서 아래로 띄운 흐르는 위아래 표를 쪽 끝에서도 따른다. - 그 줄이 쪽 바닥 위에 들어가지 않으면 줄과 표가 함께 다음 쪽으로 가고, 표는 거기서 띄운 만큼 아래에서 흐른다. - 줄은 들어가는데 표 윗선이 바닥을 넘거나, 행 단위로 옮기는(TABLE) 표의 첫 행이 들어가지 않으면, 표만 다음 쪽 맨 diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index 3e9e05ce..f8554dca 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -96,8 +96,8 @@ where it would stand in the unsplit cell, a first line below such a table with the room above it, so the row splits again where it would). A picture or drawing placed top and bottom or wrapped square from a cell paragraph holding no text makes the cell reach its foot (outer margins - included), and so do tables placed top and bottom from a cell paragraph holding nothing else, - without such caches, as tall as their rows (one placed up from the paragraph's top stands at that + included), and so does a table placed top and bottom from a cell paragraph holding nothing else, + without such caches, as tall as its rows (one placed up from the paragraph's top stands at that top); what follows in the cell goes on below that foot. A flowing table's rows use the body only down to just above the page's foot (101 above it, or 2 in a table set not to be adjusted), less the table's bottom outer margin: a row, or a cell line of a row split between its @@ -131,7 +131,8 @@ stays on one page with the lines above or beside it), footnotes in such a paragraph, two tables starting past their anchors on one page, rows merged together that do not fit under their table's anchor or on a -page, a nested table among text, or not set as a character but top and bottom alone in its paragraph, +page, a nested table among text, or not set as a character but top and bottom alone in its paragraph +(two there are laid out otherwise), in a cell without such caches (in a table Hancom has not laid out as it is), a page break in a flowing row holding a table beside a taller cell, other objects placed on the page or the paper (but top and bottom from its top or bottom, a @@ -799,7 +800,7 @@ def _cell_row(measure: _Measure, table: Any, cell: Any) -> _Row: if nested and any(paragraph.find(f".//{HP}tbl") is not None and _table_alone(paragraph.findall(f"{HP}run")) is None for paragraph in paragraphs): drawn = _drawn_lines(measure, paragraphs) # among text, or not set as a character: as Hancom drew it - if not drawn and not all(_tables_on_their_own(paragraph) for paragraph in paragraphs + if not drawn and not all(_table_on_its_own(paragraph) for paragraph in paragraphs if paragraph.find(f".//{HP}tbl") is not None and _table_alone(paragraph.findall(f"{HP}run")) is None): raise _Unsupported("a nested table") @@ -1114,13 +1115,14 @@ def _placed_top_and_bottom(obj: Any) -> bool: and pos.get("vertRelTo") == "PARA" and pos.get("vertAlign", "TOP") == "TOP" -def _tables_on_their_own(paragraph: Any) -> bool: - """Whether a cell paragraph holds no text and nothing but tables placed top and bottom from its top.""" +def _table_on_its_own(paragraph: Any) -> bool: + """Whether a cell paragraph holds no text and nothing but one table placed top and bottom from its top + (Hancom sets the empty line below two of them, not beside them).""" runs = paragraph.findall(f"{HP}run") objects = [child for run in runs for child in run if _local(child) in _OBJECTS] - return not _run_text(runs).strip() and bool(objects) and all( - _local(obj) == "tbl" and _placed_top_and_bottom(obj) for obj in objects) + return not _run_text(runs).strip() and len(objects) == 1 and _local(objects[0]) == "tbl" \ + and _placed_top_and_bottom(objects[0]) def _placed_objects(runs: list[Any]) -> list[Any]: diff --git a/tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_1000_down_alone.hwpx b/tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_1000_down_alone.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..a7b82e79f5c4a251e4d6469d8de4d0b44e194c49 GIT binary patch literal 8302 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 a|6J>uDi}yF1OVV7zrIMH^bJV}0Q^58<94h7 literal 0 HcmV?d00001 diff --git a/tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_alone_then_a_line.hwpx b/tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_alone_then_a_line.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..fbb4d955bb0f9b12cca48d7d563f38ca81fd69ff GIT binary patch literal 8287 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 KBS=C3;Qs(ZoR2C1 literal 0 HcmV?d00001 diff --git a/tests/fixtures/hancom_saved/pages_table_nested_two_top_and_bottom.hwpx b/tests/fixtures/hancom_saved/pages_table_nested_two_top_and_bottom.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..c5c5af77e04211d0a49adb417f998d3233b79fa3 GIT binary patch literal 8398 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 P!oNOnpCm!^aQFWJX};Q9 literal 0 HcmV?d00001 diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index 7dc55d3e..c7856a62 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -190,6 +190,11 @@ "pages_table_nested_in_table_as_character": 1, # a table in a cell of a table set as a character "pages_table_nested_row_split": 2, # a row holding a table splits after its first line of text "pages_table_nested_row_moved": 2, # none of a row holding a table fits: it goes on whole + "pages_table_nested_top_and_bottom_alone_then_a_line": 1, # a table placed top and bottom alone in + # a cell paragraph, then a line: right + # below the table + "pages_table_nested_top_and_bottom_1000_down_alone": 1, # the table 1000 down: the empty line stays + # above it, its foot at the table's top "pages_table_nested_row_declared_cut": 2, # the same row declared 16000: cut above the foot, the rest goes on "pages_table_nested_row_declared_cut_near_foot": 2, # declared 24000, cut 3579 below its top "pages_table_nested_row_declared_first_line_100_above_page_foot": 2, # its first line ends 100 above @@ -530,6 +535,7 @@ def test_the_lines_around_a_nested_table_are_where_hancom_put_them() -> None: @pytest.mark.parametrize(("fixture", "followed"), [("pages_table_nested_top_and_bottom_alone", True), + ("pages_table_nested_two_top_and_bottom", False), ("pages_table_nested_top_and_bottom_before_text", False), ("pages_table_nested_among_text", False)]) def test_a_cell_holding_a_table_among_text_or_top_and_bottom_is_as_tall_as_hancom_drew_it(fixture: str, @@ -537,7 +543,8 @@ def test_a_cell_holding_a_table_among_text_or_top_and_bottom_is_as_tall_as_hanco # A table in a cell of a flowing table, placed top and bottom alone in its paragraph (the cell # reaches down to its foot) or before text (the text goes below it), or set as a character among # text: the lines of the cell's caches. Without the caches the estimate follows the cell when the - # table is alone in its paragraph, as tall as its rows. + # table is alone in its paragraph, as tall as its rows. Two tables 0 and 3000 down in one paragraph + # put its empty line below the second (not beside the first): not followed without the caches. data = (FIXTURES / f"{fixture}.hwpx").read_bytes() _assert_like_hancom(estimate_pages(data), data, 1) From d0943a858a3e048cf1b236658e064524fd8b180d Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:44:27 +0900 Subject: [PATCH 4/4] feat(layout): page estimate for a table placed top and bottom before a cell paragraph's text without caches Without layout caches a cell paragraph whose text follows a table placed top and bottom from its top now lays the first line reaching the table, and the lines after it, below the table, as Hancom does (one placed up stands at the paragraph's top); the paragraph reaches down to the table's foot at least. Rows holding such a cell split between its lines over a page end as with the caches. Such a cell was unsupported. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- src/hwpx/layout/pages.py | 65 ++++++++++++++++-- ..._top_and_bottom_1000_down_before_text.hwpx | Bin 0 -> 8650 bytes ..._top_and_bottom_placed_up_before_text.hwpx | Bin 0 -> 8683 bytes tests/test_layout_page_estimate.py | 19 +++-- 5 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_1000_down_before_text.hwpx create mode 100644 tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_placed_up_before_text.hwpx diff --git a/CHANGELOG.md b/CHANGELOG.md index ea8f4875..ed21aab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,11 @@ ### 고침 -- 쪽 수 추정(실험, `estimate_pages`)이 줄 캐시 없는 표 칸에서 글 없이 위아래 배치 표 하나만 든 문단을 지원 밖 +- 쪽 수 추정(실험, `estimate_pages`)이 줄 캐시 없는 표 칸에서 위아래 배치 표 하나가 든 문단을 지원 밖 (`a nested table`)으로 두던 것을 고친다. 이제 그림·도형처럼 칸이 그 표의 아래 끝(행으로 잰 표 높이와 바깥 여백)까지 닿고, 칸의 다음 줄은 그 아래에서 이어진다. 위로 띄운 표는 문단 윗선에 선다. 그런 행이 쪽을 넘어 줄 사이에서 - 나뉠 때도 표는 그 높이의 한 줄로 센다. 한 문단에 표가 둘이면 한/글은 빈 줄을 아래 표 밑으로 보내므로 전처럼 - 캐시가 있어야 한다. + 나뉠 때도 표는 그 높이의 한 줄로 센다. 글 앞에 둔 그런 표는 거기 닿는 첫 줄부터 표 아래로 보낸다. 한 문단에 표가 + 둘이면 한/글은 빈 줄을 아래 표 밑으로 보내므로 전처럼 캐시가 있어야 한다. - 쪽 수 추정(실험, `estimate_pages`)이 글 문단의 첫 줄에서 아래로 띄운 흐르는 위아래 표를 쪽 끝에서도 따른다. - 그 줄이 쪽 바닥 위에 들어가지 않으면 줄과 표가 함께 다음 쪽으로 가고, 표는 거기서 띄운 만큼 아래에서 흐른다. - 줄은 들어가는데 표 윗선이 바닥을 넘거나, 행 단위로 옮기는(TABLE) 표의 첫 행이 들어가지 않으면, 표만 다음 쪽 맨 diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index f8554dca..d23d031d 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -98,7 +98,8 @@ square from a cell paragraph holding no text makes the cell reach its foot (outer margins included), and so does a table placed top and bottom from a cell paragraph holding nothing else, without such caches, as tall as its rows (one placed up from the paragraph's top stands at that - top); what follows in the cell goes on below that foot. A flowing table's rows use + top); what follows in the cell goes on below that foot. Before the paragraph's text, the first + line reaching the table and the lines after it go below it. A flowing table's rows use the body only down to just above the page's foot (101 above it, or 2 in a table set not to be adjusted), less the table's bottom outer margin: a row, or a cell line of a row split between its lines, ending lower goes on to the next page (where the table goes on below its top outer margin; @@ -131,8 +132,8 @@ stays on one page with the lines above or beside it), footnotes in such a paragraph, two tables starting past their anchors on one page, rows merged together that do not fit under their table's anchor or on a -page, a nested table among text, or not set as a character but top and bottom alone in its paragraph -(two there are laid out otherwise), +page, a nested table among text, or not set as a character but one top and bottom in its paragraph, +before any text (two there are laid out otherwise), in a cell without such caches (in a table Hancom has not laid out as it is), a page break in a flowing row holding a table beside a taller cell, other objects placed on the page or the paper (but top and bottom from its top or bottom, a @@ -534,8 +535,8 @@ def stack(self, paragraphs: list[Any], width: int, caches: bool) -> tuple[int, i count, size, pitch = 1, tall, tall + pitch - size else: cached = _cached_metrics(paragraph) if caches else () - cached = cached or self.marked_lines(paragraph, runs, width) \ - or self.mixed_lines(paragraph, runs, shape, width) + cached = cached or self.pushed_lines(paragraph, runs, width) \ + or self.marked_lines(paragraph, runs, width) or self.mixed_lines(paragraph, runs, shape, width) if cached: # the lines Hancom laid out, each as tall as it drew it if pending is not None: height += pending + shape.prev @@ -588,7 +589,8 @@ def stack_lines(self, paragraphs: list[Any], width: int, caches: bool) -> tuple[ runs = paragraph.findall(f"{HP}run") cached = () if _table_alone(runs) is not None or not caches else _cached_metrics(paragraph) if not cached and _table_alone(runs) is None: # each line as tall as stack makes it - cached = self.marked_lines(paragraph, runs, width) or self.mixed_lines(paragraph, runs, shape, width) + cached = self.pushed_lines(paragraph, runs, width) or self.marked_lines(paragraph, runs, width) \ + or self.mixed_lines(paragraph, runs, shape, width) if cached: metrics += list(cached[:-1]) + [(cached[-1][0], cached[-1][1] + shape.next)] continue @@ -597,6 +599,37 @@ def stack_lines(self, paragraphs: list[Any], width: int, caches: bool) -> tuple[ metrics += [(size, pitch)] * (count - 1) + [(last, (last if last > size else pitch) + shape.next)] return tuple(metrics) + def pushed_lines(self, paragraph: Any, runs: list[Any], width: int) -> tuple[tuple[int, int], ...]: + """(height, advance) of each line of a cell paragraph without a layout cache whose text follows a table + placed top and bottom from its top: the first line reaching the table and the lines after it go below + it, and the paragraph reaches down to the table's foot at least; empty for any other paragraph.""" + + table = _table_before_text(runs) + if table is None: + return () + top = _down(table.find(f"{HP}pos")) + foot = top + _inline_table_height(self, table) + _extent_margins(table) + shape = self.shape(paragraph.get("paraPrIDRef")) + lines = list(self.marked_lines(paragraph, runs, width) or self.mixed_lines(paragraph, runs, shape, width)) + if not lines: + size, refs, _ = _text_size(self, runs) + style = self.style(paragraph.get("paraPrIDRef"), refs, paragraph) + count = self.lines(_run_text(runs), _line_widths(shape, width, style), size, style) + lines = [(size, _pitch(shape.kind, shape.value, size))] * count + y = 0 + for index, (height, advance) in enumerate(lines): + if y < foot and y + height > top: # it reaches the table: below it, and the lines after it + if index: + lines[index - 1] = (lines[index - 1][0], lines[index - 1][1] + foot - y) + else: # the first line takes the room above it + lines[0] = (height + foot - y, advance + foot - y) + break + y += advance + above = sum(advance for _, advance in lines[:-1]) + if above + lines[-1][0] < foot: # every line above the table: the paragraph holds it + lines[-1] = (foot - above, foot - above) + return tuple(lines) + def marked_lines(self, paragraph: Any, runs: list[Any], width: int) -> tuple[tuple[int, int], ...]: """(height, advance) of each line of a paragraph holding composed characters or ruby text, laid out at *width* as in the body; empty for a paragraph holding neither.""" @@ -800,7 +833,9 @@ def _cell_row(measure: _Measure, table: Any, cell: Any) -> _Row: if nested and any(paragraph.find(f".//{HP}tbl") is not None and _table_alone(paragraph.findall(f"{HP}run")) is None for paragraph in paragraphs): drawn = _drawn_lines(measure, paragraphs) # among text, or not set as a character: as Hancom drew it - if not drawn and not all(_table_on_its_own(paragraph) for paragraph in paragraphs + if not drawn and not all(_table_on_its_own(paragraph) + or _table_before_text(paragraph.findall(f"{HP}run")) is not None + for paragraph in paragraphs if paragraph.find(f".//{HP}tbl") is not None and _table_alone(paragraph.findall(f"{HP}run")) is None): raise _Unsupported("a nested table") @@ -1125,6 +1160,22 @@ def _table_on_its_own(paragraph: Any) -> bool: and _placed_top_and_bottom(objects[0]) +def _table_before_text(runs: list[Any]) -> Any: + """The table placed top and bottom from its paragraph's top that is all the runs hold but text after it + (text), or ``None``.""" + + objects = [child for run in runs for child in run if _local(child) in _OBJECTS] + if len(objects) != 1 or _local(objects[0]) != "tbl" or not _placed_top_and_bottom(objects[0]) \ + or not _run_text(runs).strip(): + return None + for child in (child for run in runs for child in run): + if child is objects[0]: + return child + if _local(child) == "t" and _t_text(child).strip(): # text before it + return None + return None + + def _placed_objects(runs: list[Any]) -> list[Any]: """The objects of the runs.""" diff --git a/tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_1000_down_before_text.hwpx b/tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_1000_down_before_text.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..3ea97434e03ca67d2b4d2df18783bc60e58876c3 GIT binary patch literal 8650 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 LOyZ;Xb@%@O4c#^5 literal 0 HcmV?d00001 diff --git a/tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_placed_up_before_text.hwpx b/tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_placed_up_before_text.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..3bd470e0ba612f650e8ab0b5b9ef666d064ed1b4 GIT binary patch literal 8683 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 literal 0 HcmV?d00001 diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index c7856a62..3e36df01 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -195,6 +195,10 @@ # below the table "pages_table_nested_top_and_bottom_1000_down_alone": 1, # the table 1000 down: the empty line stays # above it, its foot at the table's top + "pages_table_nested_top_and_bottom_1000_down_before_text": 1, # 1000 down before three lines: the first + # stays above it, the others below it + "pages_table_nested_top_and_bottom_placed_up_before_text": 1, # 1000 up before them: at the paragraph's + # top, every line below it "pages_table_nested_row_declared_cut": 2, # the same row declared 16000: cut above the foot, the rest goes on "pages_table_nested_row_declared_cut_near_foot": 2, # declared 24000, cut 3579 below its top "pages_table_nested_row_declared_first_line_100_above_page_foot": 2, # its first line ends 100 above @@ -536,15 +540,16 @@ def test_the_lines_around_a_nested_table_are_where_hancom_put_them() -> None: @pytest.mark.parametrize(("fixture", "followed"), [("pages_table_nested_top_and_bottom_alone", True), ("pages_table_nested_two_top_and_bottom", False), - ("pages_table_nested_top_and_bottom_before_text", False), + ("pages_table_nested_top_and_bottom_before_text", True), ("pages_table_nested_among_text", False)]) def test_a_cell_holding_a_table_among_text_or_top_and_bottom_is_as_tall_as_hancom_drew_it(fixture: str, followed: bool) -> None: # A table in a cell of a flowing table, placed top and bottom alone in its paragraph (the cell # reaches down to its foot) or before text (the text goes below it), or set as a character among # text: the lines of the cell's caches. Without the caches the estimate follows the cell when the - # table is alone in its paragraph, as tall as its rows. Two tables 0 and 3000 down in one paragraph - # put its empty line below the second (not beside the first): not followed without the caches. + # table is alone in its paragraph or before its text, as tall as its rows. Two tables 0 and 3000 down + # in one paragraph put its empty line below the second (not beside the first): not followed without + # the caches. data = (FIXTURES / f"{fixture}.hwpx").read_bytes() _assert_like_hancom(estimate_pages(data), data, 1) @@ -607,17 +612,17 @@ def test_a_footnote_in_a_paragraph_without_a_cache_in_columns_of_unequal_width_i @pytest.mark.parametrize(("fixture", "followed"), [("pages_table_row_split_in_first_paragraph", True), - ("pages_table_row_split_after_nested_table", False), + ("pages_table_row_split_after_nested_table", True), ("pages_table_row_split_moves_nested_table", True), - ("pages_table_row_split_moves_nested_table_above_text", False)]) + ("pages_table_row_split_moves_nested_table_above_text", True)]) def test_a_row_holding_a_nested_table_split_over_a_page_splits_where_hancom_split_it(fixture: str, followed: bool) -> None: # A flowing table (split by cell) whose row 1 holds, in one cell, six lines, a 2x2 table placed top and # bottom (alone in its paragraph, or above a line of text) and six lines more, going on over the page # end among the first six lines, after the nested table, or at it (the nested table goes on to the # next page, with the text below it). Hancom's caches of the cell start over at the next page's top. - # Without the caches the estimate splits the row there too when the table is alone in its paragraph: - # the table is one line, as tall as its rows, and the text after it goes on below its foot. + # Without the caches the estimate splits the row there too: the table is one line, as tall as its + # rows, and the text after it goes on below its foot. data = (FIXTURES / f"{fixture}.hwpx").read_bytes() _assert_like_hancom(estimate_pages(data), data, 2)