diff --git a/CHANGELOG.md b/CHANGELOG.md index a49e3740..d4ba2b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,20 @@ ### 고침 +- 쪽 수 추정(실험, `estimate_pages`)이 글과 함께 움직이고 나뉠 수 있는 어울림(SQUARE) 표가 본문 바닥을 넘을 때, + 줄 캐시가 없으면 지원 밖으로 두던 것을 고친다. + - 한/글은 그런 표를 흐르는 표처럼 쪽 끝에서 행 사이나 칸 줄 사이로 나눈다. 표가 걸친 쪽마다 곁 줄은 그 쪽 + 부분 곁으로 간다. 첫 쪽은 표 위부터 바닥까지, 표가 쪽을 다 덮으면 그 쪽 모든 줄, 마지막 쪽은 맨 위부터 표 + 끝까지다. + - 이제 그 표를 흐르는 표처럼 놓아 쪽마다 띠를 두고, 띠에 닿는 줄을 다시 나눈다. + - 한 쪽에 띠가 둘이면(앞 표의 끝과 다음 표의 시작) 둘 다 둔다. 한 문단이 두 띠에 걸치면 지원 밖이다. - 쪽 수 추정(실험, `estimate_pages`)이 글과 함께 움직이는 어울림(SQUARE) 그림·도형이 본문 바닥을 넘을 때, 줄 캐시가 없으면 지원 밖으로 두던 것을 고친다. - 한/글은 그 개체를 띄움과 상관없이 다음 쪽 본문 맨 위로 옮긴다. 개체가 있던 쪽의 줄은 모두 전체 폭이다. 다음 쪽에서 줄 위가 개체 아래 끝보다 위인 줄은 개체 곁으로 간다. - 이제 그 쪽들을 다시 놓는다. 개체 문단과 뒤 문단을 전체 폭으로 나누고, 다음 쪽 맨 위의 개체 곁에서 줄을 다시 나눈다. 그 자리에 개체·각주가 든 문단이나 표가 오면 지원 밖이다. - - 나누지 않게 둔 어울림 표도 그렇게 옮겨 간다. 나뉘는 어울림 표는 쪽 끝에서 나뉘므로 전처럼 캐시가 있을 때만 - 따른다. + - 나누지 않게 둔 어울림 표도 그렇게 옮겨 간다. 나뉘는 어울림 표는 쪽 끝에서 나뉜다(아래 항목). - 문단 줄 아래로 띄워 위아래로 둔 그림·도형도, 문단이 놓인 자리에서 개체 발이 바닥을 넘으면 다음 쪽 맨 위로 옮겨 간다. 문단 줄은 제자리에 남고, 다음 쪽에서 개체에 닿는 줄은 그 아래로 간다. 캐시가 있을 때도 전에는 문단째 다음 쪽으로 보냈다. diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index b478abe9..e92dcb9a 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -84,10 +84,12 @@ and its outer margins take (a drop cap is such an object), or, with text on both sides, is two pieces at one height, the left one first -- a table by the height of its rows; a picture or drawing, or a table set not to split, whose band passes the body's foot - goes on alone to the next page's body top, whatever its offset: the lines of its own page are the whole width, and the - lines on the next page whose top is above its foot go beside it (without layout caches, broken - again there; a paragraph holding objects or a table there is not followed), while such a table - is followed only through the layout caches; a line beside an object with text on both sides is + goes on alone to the next page's body top, whatever its offset: the lines of its own page are the + whole width, and the lines on the next page whose top is above its foot go beside it (without + layout caches, broken again there; a paragraph holding objects or a table there is not + followed), while a table that may split is split over the page end as a flowing table, the lines + on each page it reaches beside its part there (on a page it fills, all of them); a line beside an + object with text on both sides is two pieces even when its text ends in the first or it has none (the second empty) -- and wrapped square with no room beside it (less than 1440 on each side its text flow allows), it pushes the text below it like a top-and-bottom object; a table flowing with the text is laid out row by row -- split between cell lines, moved row @@ -1121,6 +1123,10 @@ class _Para: #: how far such an object pushed the paragraph's first line down (its band's foot is measured from where #: that line stood before) wrap_shift: int = 0 + #: a table wrapped square anchored here, flowing with the text and allowed to split, that goes on over the + #: page end: it as it flows, how far below the paragraph's first line it starts, and its band beside the + #: text (see :meth:`_Paginator._span_bands`) + spans: tuple[_FlowTable, int, _Wrap] | None = None #: such an object alone in this empty paragraph, its foot this far below the paragraph's top (the line is #: as tall as that): past the body's foot it goes on to the next page's top, the empty line staying moves: int = 0 @@ -1998,6 +2004,22 @@ def _square_sides(obj: Any, page: _Page) -> tuple[int, int]: return left, right +def _spans(measure: _Measure, page: _Page, table: Any) -> tuple[_FlowTable, int, _Wrap]: + """A table wrapped square that splits over the page end: it as a flowing table, its offset below its + paragraph's first line, and its band beside the text (top and bottom set page by page).""" + + offset = int(table.find(f"{HP}pos").get("vertOffset", 0)) + if offset < 0 or offset >= 1 << 31: + raise _Unsupported("a square-wrapped table placed up from its paragraph past the page foot") + rows, cells = _table_rows(measure, table) + margin = table.find(f"{HP}outMargin") + ends = (0, 0) if margin is None else (_margin(margin, "top"), _margin(margin, "bottom")) + flowing = _FlowTable(rows, table.get("pageBreak", "CELL"), table.get("repeatHeader") == "1", ends, + tuple(cells), caption=_caption(measure, table), cut=_spare_cut(table)) + left, right = _square_sides(table, page) + return flowing, offset, _Wrap(0, 0, page.column_width - left - right, split=left if left and right else 0) + + def _wrapped_paragraph(measure: _Measure, page: _Page, paragraph: Any, wrap: _Wrap | None, moved: bool = False) -> tuple[_Para, _Wrap | None]: """The paragraph with the lines beside a square-wrapped object's band narrower, and the band as the next paragraph sees it. With *moved*, the object went on to the next page's top: none here.""" @@ -2019,6 +2041,9 @@ def _wrapped_paragraph(measure: _Measure, page: _Page, paragraph: Any, wrap: _Wr # followed) alone = objects[0] if len(objects) == 1 and objects[0].find(f"{HP}pos").get("treatAsChar") != "1" else None obj = square if square is not None else pusher if pusher is not None else alone + if obj is not None and obj is square and wrap is None and _local(obj) == "tbl" \ + and obj.get("pageBreak", "CELL") != "NONE": # it splits over the page end instead + return replace(_paragraph(measure, page, paragraph, None, obj), spans=_spans(measure, page, obj)), None if obj is None or wrap is not None or _local(obj) == "tbl" and (obj is not square or obj.get("pageBreak", "CELL") != "NONE"): raise _Unsupported("a square-wrapped or offset top-and-bottom object past the page foot") @@ -2567,8 +2592,9 @@ def __init__(self, body: int, columns: int, notes: _NoteShape, self.reserved: dict[int, int] = {} # frames a table starting past its anchor takes: where text starts self.wrap_frame = -1 # the frame a square-wrapped object's band is on self.wrap_moved = False # that band passed the body's foot: the object went on to the next page - #: frame -> the band of a square-wrapped object that went on to its top (see :attr:`_Para.moved`) - self.squares: dict[int, _Wrap] = {} + #: frame -> the bands of square-wrapped objects there that the text there is broken beside: one that + #: went on to its top (see :attr:`_Para.moved`), the parts of tables split over its ends + self.squares: dict[int, list[_Wrap]] = {} #: a flowing table's band no line has reached yet: its frame, top, bottom there (None when it goes #: on over the page end), and the frame and position where the lines after it go on self.band: tuple[int, int, int | None, int, int] | None = None @@ -2955,6 +2981,9 @@ def _breaks(self, para: _Para, start: int) -> tuple[int, bool]: def _lay(self, index: int, paras: list[_Para], para: _Para, start: int, broke: bool) -> bool: """Place the lines of *para*; False when its notes ended the page after it.""" + if para.spans is not None: # a table wrapped square going on over the page end: its bands, page by page + self._span_bands(para.spans, start) + para = replace(para, spans=None) para = self._at_band(self._at_width(para, 0), 0, start) remaining, first_chunk = para.lines, True fresh = self.last_vp is None or broke @@ -3015,14 +3044,38 @@ def _at_band(self, para: _Para, line: int, start: int) -> _Para: """*para* with its lines from *line* on, laid from *start* down, broken again beside the band of a square-wrapped object set at the current frame's top when they reach it.""" - band = self.squares.get(self.frame) - if band is None or start >= band.bottom: + bands = sorted((band for band in self.squares.get(self.frame, ()) if band.bottom > start), + key=lambda band: band.top) + if not bands: return para - if para.blank: # an empty line: two empty pieces beside an object with text on both sides - return _in_two_pieces(para) if band.split and para.lines == 1 and not para.cached else para + band = bands[0] + if para.blank: # an empty line reaching it: two empty pieces beside an object with text on both sides + reaches = start + para.height(line) > band.top + return _in_two_pieces(para) if reaches and band.split and para.lines == 1 and not para.cached else para if para.reflow is None: raise _Unsupported("objects, notes or a layout cache beside a square-wrapped object moved to the next page") - return para.reflow.banded(para, line, replace(band, top=band.top - start, bottom=band.bottom - start)) + again = para.reflow.banded(para, line, replace(band, top=band.top - start, bottom=band.bottom - start)) + if len(bands) > 1 and start + again.span(0, again.lines) > bands[1].top: + raise _Unsupported("a paragraph beside two square-wrapped objects on a page") + return again + + def _span_bands(self, spans: tuple[_FlowTable, int, _Wrap], start: int) -> None: + """The bands of a table wrapped square, anchored in the paragraph being laid from *start*, that goes + on over the page end: it flows as a flowing table from its top, and on each page it reaches the lines + go beside its part there -- on its first page from its top to the foot, on a page it fills the whole + page, on its last page from the top down to its end.""" + + table, offset, band = spans + top = start + offset + last, end = _flow_table(table, self.frame, top + table.above, self.body) + for frame in range(self.frame, last + 1): + part = replace(band, top=top if frame == self.frame else 0, + bottom=end + table.below if frame == last else self.body) + if self.bands.get(frame) or self.sides.get(frame) or frame in self.reserved \ + or any(other.top < part.bottom and part.top < other.bottom for other in self.squares.get(frame, ())): + raise _Unsupported("a square-wrapped table going on over the page end beside other objects") + self.squares.setdefault(frame, []).append(part) + self.table_end = max(self.table_end, last) def _move_band(self, band: _Wrap) -> None: """A square-wrapped object anchored in the paragraph being laid, flowing with the text, went past the @@ -3031,12 +3084,12 @@ def _move_band(self, band: _Wrap) -> None: target = self.frame + 1 if band.bottom > self.body or self.bands.get(target) or self.sides.get(target) or target in self.reserved \ - or target in self.squares: + or any(other.top < band.bottom for other in self.squares.get(target, ())): raise _Unsupported("an object going on to the next page's top beside other objects") if band.push: # a top-and-bottom one: the lines reaching it go below it self.bands[target] = [(band.top, band.bottom)] else: - self.squares[target] = band + self.squares.setdefault(target, []).append(band) self.table_end = max(self.table_end, target) def _next_frame(self, para: _Para, count: int, first_chunk: bool) -> int: diff --git a/tests/fixtures/hancom_saved/pages_square_table_1548_down_split.hwpx b/tests/fixtures/hancom_saved/pages_square_table_1548_down_split.hwpx new file mode 100644 index 00000000..d7e70ebd Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_square_table_1548_down_split.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_square_table_split_between_cell_lines.hwpx b/tests/fixtures/hancom_saved/pages_square_table_split_between_cell_lines.hwpx new file mode 100644 index 00000000..5adec29d Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_square_table_split_between_cell_lines.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_square_table_split_in_a_merged_cell.hwpx b/tests/fixtures/hancom_saved/pages_square_table_split_in_a_merged_cell.hwpx new file mode 100644 index 00000000..76ac211d Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_square_table_split_in_a_merged_cell.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_square_table_split_over_three_pages.hwpx b/tests/fixtures/hancom_saved/pages_square_table_split_over_three_pages.hwpx new file mode 100644 index 00000000..d34a3b26 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_square_table_split_over_three_pages.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_square_table_split_text_on_both_sides.hwpx b/tests/fixtures/hancom_saved/pages_square_table_split_text_on_both_sides.hwpx new file mode 100644 index 00000000..e5039a90 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_square_table_split_text_on_both_sides.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_square_table_wide_split_text_beside_it.hwpx b/tests/fixtures/hancom_saved/pages_square_table_wide_split_text_beside_it.hwpx new file mode 100644 index 00000000..bd36e624 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_square_table_wide_split_text_beside_it.hwpx differ diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index e4790a4c..6745dcd4 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -52,6 +52,16 @@ "pages_square_picture_moved_from_8000_down": 3, # placed 8000 down from its paragraph: at the top too "pages_square_table_not_split_moved_to_the_next_page": 3, # a table of six rows at the column's left, # set not to split: the same + # A table wrapped square at the column's left (rows of two columns, 10 pt text spaced 160%), flowing with + # the text and allowed to split, its band past the body's foot: Hancom splits it over the page end as a + # flowing table, and the lines on each page it reaches go beside its part there: + "pages_square_table_split_between_cell_lines": 3, # 15000 wide, split between the lines of a row of four + "pages_square_table_split_in_a_merged_cell": 3, # the same through a cell merged over three rows + "pages_square_table_split_over_three_pages": 3, # 25 rows: all the lines of the page it fills beside it + "pages_square_table_1548_down_split": 3, # placed 1548 down: its paragraph's line above it the whole width + "pages_square_table_split_text_on_both_sides": 3, # in the middle: the lines in two pieces + "pages_square_table_wide_split_text_beside_it": 2, # 45626 wide, 2562 left beside it: two characters a + # line there "pages_picture_1319_down_moved_to_the_next_page": 3, # a picture 8000 tall placed top and bottom 1319 # below its empty paragraph's line: alone at the # next page's top, the line staying, the lines @@ -1197,27 +1207,23 @@ def test_a_page_break_in_a_row_holding_a_table_beside_a_taller_cell_is_unsupport @pytest.mark.parametrize( - ("name", "followed"), + "name", [ - ("pages_picture_square_past_the_page_foot", True), # its paragraph's three lines the page's last - ("pages_picture_square_past_the_page_foot_mid_paragraph", True), # its first line the page's last - ("pages_table_square_past_the_page_foot", False), # a table wrapped square alone in its paragraph + "pages_picture_square_past_the_page_foot", # its paragraph's three lines the page's last + "pages_picture_square_past_the_page_foot_mid_paragraph", # its first line the page's last + "pages_table_square_past_the_page_foot", # a table wrapped square alone in its paragraph ], ) -def test_a_square_object_past_the_page_foot_goes_on_at_the_next_page(name: str, followed: bool) -> None: +def test_a_square_object_past_the_page_foot_goes_on_at_the_next_page(name: str) -> None: # A picture wrapped square from its paragraph's top, 8000 tall, its band past the body's foot: Hancom # sets it alone at the next page's top, the lines beside it there narrower and the lines left above the - # foot as wide as the column. With the caches the lines stay where the caches put them; without them - # they are broken again beside it there. A table wrapped square is split over the page end instead, its - # band going on at the next page's top: not followed without the caches. + # foot as wide as the column. A table wrapped square is split over the page end instead, its band going + # on at the next page's top as tall as what is left of it. With the caches the lines stay where the + # caches put them; without them they are broken again beside it there. data = (FIXTURES / f"{name}.hwpx").read_bytes() _assert_like_hancom(estimate_pages(data), data, 2) - if followed: - _assert_like_hancom(estimate_pages(_without_caches(data)), data, 2) - else: - assert estimate_pages(_without_caches(data)).unsupported == ( - "section 0: a square-wrapped or offset top-and-bottom object past the page foot",) + _assert_like_hancom(estimate_pages(_without_caches(data)), data, 2) def test_a_top_and_bottom_table_not_split_offset_past_the_page_foot_is_unsupported() -> None: