diff --git a/CHANGELOG.md b/CHANGELOG.md index c10d2d22..ff41431b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ ### 고침 +- 쪽 수 추정(실험, `estimate_pages`)이 칸 단위로 나누는 흐르는 표(`pageBreak="CELL"`)에서 병합 칸으로 이어진 + 행들이 한 쪽보다 길면 "rows merged together taller than a page"로 거부하던 것을 고친다. 한/글은 쪽 끝마다 + 다시 나눈다. 칸마다 그 쪽에 들어가는 줄까지 두고 나머지는 다음 쪽 머리 행 아래로 넘기며, 넘긴 부분이 다시 + 쪽보다 길면 그 쪽 끝에서 또 나눈다. 행들보다 긴 병합 칸은 행이 끝난 뒤에도 혼자 다음 쪽들로 이어진다. 글보다 + 높게 선언된 칸의 남는 높이도(글이 다 들어간 뒤에도) 쪽 끝마다 행처럼 잘려 이어진다. 어느 쪽에도 한 줄도 + 들어가지 않는 칸만 전처럼 거부한다. 줄 사이에서 나뉜 선언 행의 다음 쪽 부분은 선언 높이의 나머지와, 이어지는 + 줄에 칸 위아래 여백을 더한 높이 가운데 큰 쪽이다. 이어지는 줄이 있으면 남은 높이가 낮아도 버리지 않는다. - 쪽 수 추정(실험, `estimate_pages`)이 곁에 자리를 남기는 종이·쪽 기준 어울림(SQUARE) 개체의 띠에 닿는 줄을, 그 줄이 줄 캐시를 가질 때 따른다. 한/글은 그런 줄을 같은 높이·간격으로 개체 곁에 좁혀 둔다(양옆에 자리가 있으면 한 높이에 두 조각). 캐시 없는 줄·표·개체가 닿으면 전처럼 지원 밖이다. diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index 9b7ea7db..edcb6d2b 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -89,7 +89,8 @@ move to the next page as one, and in one split between cell lines each of their cells keeps the lines that fit and the rest go on, the rows from the one the page end falls in as tall as their cells' rest (a cell declared taller than its text, whose first line fits, is cut like such a row, - below). A table set as a character alone in a paragraph of a cell is one line as tall as it + below), a rest taller than a page split again at each page end the same way. A table set as a + character alone in a paragraph of a cell is one line as tall as it there, spaced like the text, and a row holding one splits between its cell's lines, each as tall as it is; so is another object set as a character alone in a cell paragraph without a layout cache, and such objects among its text, or several of them, take their place in its lines as in @@ -132,8 +133,9 @@ across the column before any text; an object offset down, but a flowing table, or wrapped square 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 +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 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 @@ -1853,13 +1855,7 @@ def _flow_table(table: _FlowTable, frame: int, y: int, body: int) -> tuple[int, continue if table.mode == "CELL" and end > index and table.cells \ and y + sum(row.height for row in rows[index:end + 1]) > foot: # split cell by cell - rest = _block_rest(table, index, end, y, body) - if not rest: # every cell done above the page end, what it declares below dropped: they end there - y, index = body, end + 1 - continue - frame, y = frame + 1, header + rest - if y > foot: - raise _Unsupported("rows merged together taller than a page") + frame, y = _split_block(table, index, end, frame, y, body, header) index = end + 1 continue before, start = frame, y @@ -1876,12 +1872,38 @@ def _flow_table(table: _FlowTable, frame: int, y: int, body: int) -> tuple[int, return frame, y -def _block_rest(table: _FlowTable, first: int, last: int, top: int, body: int) -> int: - """How tall rows *first*..*last*, joined by merged cells, are on the next page when the page end +def _split_block(table: _FlowTable, first: int, last: int, frame: int, y: int, body: int, + header: int) -> tuple[int, int]: + """Rows *first*..*last*, joined by merged cells and starting at *y*, in a table split between cell + lines when the page end falls among them: each page keeps the lines that fit, and what is left goes on + below the next page's *header*, split again at that page's end until it fits. The frame and position + where they end.""" + + foot, before = body - table.cut, None + while True: + cut, heights, cells = _block_rest(table, first, last, y, body) + rest = sum(heights.values()) + if not rest: # every cell done above the page end, what it declares below dropped: they end there + return frame, body + if header + rest <= foot: + return frame + 1, header + rest + if before is not None and rest >= before: # nothing of it fits a page + raise _Unsupported("rows merged together taller than a page") + rows = list(table.rows) + for index, height in heights.items(): + rows[index] = replace(rows[index], height=height) + table, first, frame, y, before = replace(table, rows=rows, cells=tuple(cells)), cut, frame + 1, header, rest + + +def _block_rest(table: _FlowTable, first: int, last: int, top: int, + body: int) -> tuple[int, dict[int, int], list[tuple[int, int, _Row]]]: + """What goes on to the next page of rows *first*..*last*, joined by merged cells, when the page end falls among them in a table split between cell lines: every cell keeps the lines that fit above the page end and the rest go on. From the row the page end falls in, each row is as tall as the rest of its cells of one row (the rows after it whole), then each merged cell's rest, the one - ending first first, adds what its rows lack to the last of them.""" + ending first first, adds what its rows lack to the last of them. The row the page end falls in, + each row's height from it on, and the cells that go on, each as tall as its rest with the lines + it has left.""" rows, tops, y, foot = table.rows, {}, top, body - table.cut for index in range(first, last + 1): @@ -1890,34 +1912,45 @@ def _block_rest(table: _FlowTable, first: int, last: int, top: int, body: int) - cut = next(index for index in range(first, last + 1) if tops[index] + rows[index].height > foot) heights = dict.fromkeys(range(cut, last + 1), 0) rests: list[tuple[int, int, int]] = [] + cells: list[tuple[int, int, _Row]] = [] for start, span, cell in table.cells: end = start + span - 1 if start < first or start > last or end < cut: continue # another row, or done above the page end - if start > cut: # wholly on the next page - rest = cell.height - else: - fitting = 0 - while fitting < cell.lines and tops[start] + cell.margins + fitting * cell.pitch + cell.size <= foot: - fitting += 1 - if cell.spare and fitting: # its text starts above the page end: the declared room is cut like a row's - rest = tops[start] + cell.height - foot - if rest <= _SPARE_DROPPED: - continue - elif cell.spare: # none of it fits: it goes on whole - rest = cell.height - elif fitting == cell.lines: - continue - else: - rest = cell.margins + (cell.lines - fitting - 1) * cell.pitch + cell.size + part = (cell.height, cell.lines) if start > cut else _cell_rest(cell, tops[start], foot) + if part is None: + continue + rest, begin = part[0], max(start, cut) + cells.append((begin, end - begin + 1, replace(cell, height=rest, lines=part[1]))) if span == 1: heights[start] = max(heights[start], rest) else: - rests.append((max(start, cut), end, rest)) + rests.append((begin, end, rest)) for start, end, rest in sorted(rests, key=lambda item: (item[1], item[0])): lacking = rest - sum(heights[index] for index in range(start, end + 1)) heights[end] += max(lacking, 0) - return sum(heights.values()) + return cut, heights, cells + + +def _cell_rest(cell: _Row, top: int, foot: int) -> tuple[int, int] | None: + """How tall the part of a cell starting at *top* that goes on past the page end *foot* is, and how many + lines it holds; ``None`` when nothing of it goes on.""" + + fitting = 0 + while fitting < cell.lines and top + cell.margins + fitting * cell.pitch + cell.size <= foot: + fitting += 1 + if cell.spare and (fitting or not cell.lines): # its text (or what is left: none) starts above the page end: + # the declared room is cut like a row's, and the lines that do not fit go on with it, which is then + # at least as tall as they are with the cell's margins + rest, left = top + cell.height - foot, cell.lines - fitting + if left: + return max(rest, cell.margins + (left - 1) * cell.pitch + cell.size), left + return (rest, 0) if rest > _SPARE_DROPPED else None + if cell.spare: # none of it fits: it goes on whole + return cell.height, cell.lines + if fitting == cell.lines: + return None + return cell.margins + (cell.lines - fitting - 1) * cell.pitch + cell.size, cell.lines - fitting def _flow_row(mode: str, row: _Row, frame: int, y: int, body: int, header: int, diff --git a/tests/fixtures/hancom_saved/pages_joined_rows_declared_cut_in_a_later_row.hwpx b/tests/fixtures/hancom_saved/pages_joined_rows_declared_cut_in_a_later_row.hwpx new file mode 100644 index 00000000..ecf16d2a Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_joined_rows_declared_cut_in_a_later_row.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_joined_rows_declared_first_line_not_fitting.hwpx b/tests/fixtures/hancom_saved/pages_joined_rows_declared_first_line_not_fitting.hwpx new file mode 100644 index 00000000..af38df47 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_joined_rows_declared_first_line_not_fitting.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_joined_rows_declared_rest_with_a_line.hwpx b/tests/fixtures/hancom_saved/pages_joined_rows_declared_rest_with_a_line.hwpx new file mode 100644 index 00000000..b8dd1c5b Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_joined_rows_declared_rest_with_a_line.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_declared_150000.hwpx b/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_declared_150000.hwpx new file mode 100644 index 00000000..9a1142e5 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_declared_150000.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_declared_cell.hwpx b/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_declared_cell.hwpx new file mode 100644 index 00000000..9767ddf9 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_declared_cell.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_empty_declared_cell.hwpx b/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_empty_declared_cell.hwpx new file mode 100644 index 00000000..cdf8379e Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_empty_declared_cell.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_label150.hwpx b/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_label150.hwpx new file mode 100644 index 00000000..31e246ff Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_label150.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_mixed.hwpx b/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_mixed.hwpx new file mode 100644 index 00000000..0fc22cdf Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_mixed.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_n100.hwpx b/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_n100.hwpx new file mode 100644 index 00000000..b4b88bd9 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_joined_rows_over_pages_n100.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_joined_rows_text_height_split.hwpx b/tests/fixtures/hancom_saved/pages_joined_rows_text_height_split.hwpx new file mode 100644 index 00000000..9646a50c Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_joined_rows_text_height_split.hwpx differ diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index e9883315..2e9d9d1d 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -84,6 +84,24 @@ # empty one spaced 562 before it "pages_hide_empty_lines_ending_the_document": 1, # two ending the document: no second page "pages_hide_empty_lines_off": 2, # two, the setting off: they start the next page + "pages_joined_rows_over_pages_n100": 3, # 100 one-line rows joined by a cell merged down all of them, + # split between cell lines: what is left is split again at + # each page end + "pages_joined_rows_over_pages_label150": 4, # 60 such rows and a merged cell of 150 lines: the cell + # goes on alone over the last two pages + "pages_joined_rows_over_pages_mixed": 3, # rows of one and three lines: a row split between its lines + "pages_joined_rows_over_pages_declared_cell": 3, # 8 rows joined by a cell declared 80000 tall, after 20 + # rows: its room cut at each page end, 10 rows after + "pages_joined_rows_over_pages_empty_declared_cell": 3, # an empty cell over 8 rows declared 75000 tall + # after 36 lines: cut over three pages + # Rows 1-5 joined by a cell merged down them (4 lines), declared taller than their text, the page end + # falling among them: + "pages_joined_rows_declared_rest_with_a_line": 2, # in row 1: its rest is the line going on with the + # cell margins, taller than the declared rest + "pages_joined_rows_declared_cut_in_a_later_row": 2, # in row 2: its declared rest, taller than the line + "pages_joined_rows_declared_first_line_not_fitting": 2, # in row 3, before its first line: whole on the + # next page + "pages_joined_rows_text_height_split": 2, # in row 1, the rows as tall as their text: lines and margins "pages_table_row_split_a_first_line_not_fitting": 2, # the first 8 pt line of one cell fits, the 16 pt # line of the other does not: it goes on whole "pages_table_row_split_every_first_line_fitting": 2, # room for both: split after two 8 pt lines @@ -782,20 +800,21 @@ def _flowing_table_after(paragraphs: int, rows: int) -> tuple[HwpxDocument, obje return document, table -def test_rows_merged_together_taller_than_a_page_are_unsupported() -> None: - # The cell merged over rows 1-4 is declared 150000 tall: what is left of it after the page end - # does not fit on the next page either. - document, table = _flowing_table_after(38, 6) - table.merge_cells(1, 0, 4, 0) - for cell in table.element.iter(f"{HP}tc"): - span = cell.find(f"{HP}cellSpan") - if span is not None and span.get("rowSpan", "1") != "1": - cell.find(f"{HP}cellSz").set("height", "150000") +def test_a_merged_cell_declared_taller_than_two_pages_is_cut_at_each_page_end() -> None: + # After 38 lines, a flowing table of 6 rows whose cell merged over rows 1-4 is declared 150000 tall, laid + # out and saved by Hancom: the cell's room is cut 101 above each page's foot and the rest goes on, page + # after page, the last row ending the table (and the document) on page 4. No line of text is on that + # page, so the pages are checked here rather than in HANCOM_PAGES. + data = (FIXTURES / "pages_joined_rows_over_pages_declared_150000.hwpx").read_bytes() + hancom = _hancom_lines(data) - estimate = estimate_pages(document) + for source in (data, _without_caches(data)): + estimate = estimate_pages(source) + estimated = [[line.vertpos for line in lines] for lines in estimate.lines] - assert estimate.pages is None - assert estimate.unsupported == ("section 0: rows merged together taller than a page",) + assert (estimate.unsupported, estimate.pages) == ((), 4) + assert len(estimated) == len(hancom) + assert [mine for mine, theirs in zip(estimated, hancom) if theirs] == [theirs for theirs in hancom if theirs] @pytest.mark.parametrize(("height", "rest"), [(20000, 13221), (8029, 0), (8061, 0), (8062, 1283), (8079, 1300)])