diff --git a/CHANGELOG.md b/CHANGELOG.md index ff41431b..3e92b3a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ ### 고침 +- 쪽 수 추정(실험, `estimate_pages`)이 줄 캐시 없는 표 칸에서 위아래 배치 표 하나가 든 문단을 지원 밖 + (`a nested table`)으로 두던 것을 고친다. 이제 그림·도형처럼 칸이 그 표의 아래 끝(행으로 잰 표 높이와 바깥 여백)까지 + 닿고, 칸의 다음 줄은 그 아래에서 이어진다. 위로 띄운 표는 문단 윗선에 선다. 그런 행이 쪽을 넘어 줄 사이에서 + 나뉠 때도 표는 그 높이의 한 줄로 센다. 글 앞에 둔 그런 표는 거기 닿는 첫 줄부터 표 아래로 보낸다. 한 문단에 표가 + 둘이면 한/글은 빈 줄을 아래 표 밑으로 보내므로 전처럼 캐시가 있어야 한다. - 쪽 수 추정(실험, `estimate_pages`)이 칸 단위로 나누는 흐르는 표(`pageBreak="CELL"`)에서 병합 칸으로 이어진 행들이 한 쪽보다 길면 "rows merged together taller than a page"로 거부하던 것을 고친다. 한/글은 쪽 끝마다 다시 나눈다. 칸마다 그 쪽에 들어가는 줄까지 두고 나머지는 다음 쪽 머리 행 아래로 넘기며, 넘긴 부분이 다시 diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index edcb6d2b..b12bf831 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -101,7 +101,10 @@ 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 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. 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; @@ -135,7 +138,8 @@ starting past their anchors on one page, rows merged together that do not fit under their table's anchor, or on a page in a table moved row by row (split between cell lines: a cell no line of which fits a page), -a nested table among text or not set as a character in a cell without such caches (in a table +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 flowing table on their page keeping clear of them), and in a paragraph without such a cache ruby text @@ -542,8 +546,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 @@ -551,8 +555,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) @@ -564,8 +568,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 @@ -596,14 +600,47 @@ 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 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 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, ruby text, or objects set as characters among its text or several of them, laid out at *width* as in the body; empty for @@ -826,7 +863,11 @@ 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(_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") size = cell.find(f"{HP}cellSz") margins = cell_margins_of(cell, table) @@ -1058,9 +1099,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")) @@ -1108,16 +1150,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": @@ -1129,6 +1175,40 @@ 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 _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 len(objects) == 1 and _local(objects[0]) == "tbl" \ + 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_alone.hwpx b/tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_1000_down_alone.hwpx new file mode 100644 index 00000000..a7b82e79 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_1000_down_alone.hwpx differ 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 00000000..3ea97434 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_1000_down_before_text.hwpx differ 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 00000000..fbb4d955 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_alone_then_a_line.hwpx differ 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 00000000..3bd470e0 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_table_nested_top_and_bottom_placed_up_before_text.hwpx differ 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 00000000..c5c5af77 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_table_nested_two_top_and_bottom.hwpx differ diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index 2e9d9d1d..d6494e3e 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -222,6 +222,15 @@ "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_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 @@ -617,16 +626,25 @@ 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_two_top_and_bottom", 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 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 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) - 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: @@ -681,19 +699,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", True), + ("pages_table_row_split_moves_nested_table", True), + ("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: 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"]) @@ -707,15 +731,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: