diff --git a/CHANGELOG.md b/CHANGELOG.md index 6129f37a..28f93a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,18 @@ 것을 고친다. 전에는 칸 문단에 혼자 든 표만 그 높이로 셌고, 다른 개체는 없는 것처럼 줄을 세어 행이 한/글보다 낮았다. 이제 개체 하나만 든 문단은 개체 높이의 한 줄이고 줄 간격은 글자 크기로 센다. 글 사이에 있거나 여럿이면 본문처럼 개체의 폭과 높이로 줄 안에 놓는다. +- 쪽 수 추정(실험, `estimate_pages`)이 빈 문단에 문단 위 기준으로 둔 위아래 개체 여럿(표·그림)을 지원 밖 + (`an object with text or other objects in its paragraph`)으로 두던 것을 고친다. + - 한/글은 개체를 차례로, 문단의 쪽부터 그 쪽에서 가로로 겹치는 앞 개체 아래에 들어가는 첫 쪽에 둔다. + 문단의 쪽에서는 띄움과 앞 개체 아래 가운데 낮은 곳이다. 그래서 뒤 개체가 다음 쪽으로 간 앞 개체를 건너 + 문단의 쪽에 들어가기도 한다. + - 어느 쪽에도 들어가지 않는 개체는 마지막으로 쓴 쪽의 다음 쪽 맨 위에서 시작한다. 다만 첫 개체는 문단 자리에서 + 시작해 흐른다. 쪽보다 긴 표는 그 뒤 쪽들로 나뉜다(CELL은 줄 사이에서, TABLE은 행째). + - 이런 문단이 이어지면 뒤 문단의 개체도 앞 문단 개체를 비켜 들어가는 첫 쪽에 놓인다. 다만 쪽을 넘어 이어지는 표의 + 나머지는 다음 쪽 맨 위에서 이어지고, 그 쪽의 다른 개체와 겹쳐도 비키지 않는다. + - 문단의 줄과 뒤 줄은 개체를 비킨 첫 자리에 놓이고, 그 쪽에 자리가 없으면 다음 쪽 맨 위로 간다. 개체가 혼자 + 차지한 쪽으로 넘어간 줄(글자처럼 둔 표가 든 줄도)은 개체 아래 남은 자리에 들어가지 않으면 그 쪽을 건너뛴다. + - 여러 단, 쪽 바닥을 넘는 그림·도형은 전처럼 지원 밖이다. - 쪽 수 추정(실험, `estimate_pages`)이 `hp:t` 글 안에 글자로 든 전각 공백(U+3000)을 고정폭 빈칸(`hp:fwSpace`)으로 보던 것을 고친다(python-hwpx는 둘을 모두 U+3000으로 읽는다). 글자 전각 공백은 글꼴의 폭(대개 글자 크기만큼)을 차지하고, 그 뒤에서 줄이 나뉘며, 줄 끝에서는 몇 개가 이어져도 모두 매달린다. 전에는 1/4 폭의 고정폭 빈칸으로 diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index f811a95d..c9d7b461 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -68,7 +68,12 @@ page's top while the paragraph's lines go on under its line; a flowing table alone in its paragraph starts its offset below the paragraph's top, above the paragraph's spacing before (one offset up starts there), and the next paragraph goes below the table's end or below the paragraph's line and its - own spacing before, whichever is lower; + own spacing before, whichever is lower; several anchored so to an empty paragraph (none up from it) + go one by one to the first page, from the paragraph's on, where they fit below the earlier ones + they overlap across (on the paragraph's page at their offset or below them, whichever is lower); + the first, fitting there on none, flows from where it stands, and a later one starts at the top of + the page after the last one used (a table taller than a page split over the pages after); the + paragraph's line and the lines after take the first places clear of them; wrapped square anywhere across the column (from the column's, the paragraph's or the paper's left or right) before a paragraph's text (or alone in its paragraph), the text goes on each side of it at least 1440 wide that its text flow allows (both, the larger, the left or the right): a line @@ -132,7 +137,8 @@ settings in a cell or a text box are that list's own), section settings after a section's first paragraph (Hancom starts a new section there), a line or character grid, an object with text or other objects in its paragraph (but objects set as characters, with line spacing in percent or -fixed, one top-and-bottom object placed from the paragraph's top, and one object wrapped square +fixed, one top-and-bottom object placed from the paragraph's top, several so in an empty paragraph +(but in columns, or a picture or drawing past the page's foot), and one object wrapped square 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 @@ -937,6 +943,20 @@ class _Band: table: _FlowTable +@dataclass(frozen=True) +class _Stacked: + """An object placed top and bottom in a paragraph holding nothing else but more such objects: its + offset below the paragraph's top (above its spacing), its span across the column and its height, outer + margins included (a table as tall as its rows), and a table's rows, which split over the pages when + it is taller than a page.""" + + offset: int + left: int + right: int + height: int + table: _FlowTable | None = None + + @dataclass(frozen=True) class _Para: lines: int @@ -976,6 +996,8 @@ class _Para: hides: bool = False #: its lines are Hancom's own, from the paragraph's valid layout cache kept: bool = False + #: the top-and-bottom objects stacked in this paragraph, which holds nothing else (see :func:`_stack`) + stack: tuple[_Stacked, ...] = () def height(self, line: int) -> int: return self.cached[line][0] if self.cached else self.size @@ -1581,9 +1603,10 @@ def _anchored_notes(measure: _Measure, runs: list[Any], text: str, widths: list[ def _paragraph(measure: _Measure, page: _Page, paragraph: Any, wrap: _Wrap | None = None, - square: Any = None) -> _Para: + square: Any = None, stacked: tuple[Any, ...] = ()) -> _Para: runs = paragraph.findall(f"{HP}run") - objects = [obj for obj in _placed_objects(runs) if obj is not square] + objects = [obj for obj in _placed_objects(runs) + if obj is not square and all(obj is not other for other in stacked)] if page.unequal and any(obj.find(f"{HP}pos").get("treatAsChar") != "1" for obj in objects): raise _Unsupported("an object not set as a character in columns of unequal width") reflow = page.unequal and not _cached_metrics(paragraph) # its lines depend on the column's width @@ -1746,6 +1769,13 @@ def _wrapped_paragraph(measure: _Measure, page: _Page, paragraph: Any, runs = paragraph.findall(f"{HP}run") objects = _placed_objects(runs) + stacked = _stacked_objects(objects, runs, page.column_width) + if stacked: + if wrap is not None or page.unequal: + raise _Unsupported("objects placed top and bottom one below another beside other objects") + para = _paragraph(measure, page, paragraph, stacked=tuple(stacked)) + shape = measure.shape(paragraph.get("paraPrIDRef")) + return replace(para, stack=_stack(measure, stacked, page.column_width, shape)), None square = _square_object(objects, runs, page.column_width) pusher = _pushing_object(objects, _run_text(runs), page.column_width) shape = measure.shape(paragraph.get("paraPrIDRef")) @@ -1826,6 +1856,46 @@ def _pushing_object(objects: list[Any], text: str, column: int) -> Any: return obj +def _stacked_objects(objects: list[Any], runs: list[Any], column: int) -> list[Any]: + """The objects of a paragraph holding nothing else when there are several and each is placed top and + bottom from the paragraph's top (none up from it), from the column's or the paragraph's left or right; + none otherwise.""" + + if len(objects) < 2 or _run_text(runs).strip() or _marks(runs) or _note_anchors(runs): + return [] + for obj in objects: + pos = obj.find(f"{HP}pos") + if pos.get("treatAsChar") == "1" or not _wraps_top_and_bottom(obj, column) \ + or (pos.get("vertRelTo"), pos.get("vertAlign", "TOP")) != ("PARA", "TOP") \ + or int(pos.get("vertOffset", 0)) >= 1 << 31 or pos.get("horzRelTo") not in ("COLUMN", "PARA") \ + or pos.get("horzAlign", "LEFT") not in ("LEFT", "RIGHT"): + return [] + return objects + + +def _stack(measure: _Measure, objects: list[Any], column: int, shape: _Shape) -> tuple[_Stacked, ...]: + """*objects*, stacked in a paragraph holding nothing else, as :meth:`_Paginator._stacked` places them.""" + + stacked: list[_Stacked] = [] + for obj in objects: + pos = obj.find(f"{HP}pos") + width, height = _object_extent(obj, measure) + offset = int(pos.get("horzOffset", 0)) + if offset >= 1 << 31: # kept unsigned: one placed out past the edge + offset -= 1 << 32 + indent = (shape.left, shape.right) if pos.get("horzRelTo") == "PARA" else (0, 0) + left = indent[0] + offset if pos.get("horzAlign", "LEFT") == "LEFT" else column - indent[1] - width - offset + table = None + if _local(obj) == "tbl": + rows, cells = _table_rows(measure, obj) + margin = obj.find(f"{HP}outMargin") + ends = (0, 0) if margin is None else (_margin(margin, "top"), _margin(margin, "bottom")) + table = _FlowTable(rows, obj.get("pageBreak", "CELL"), obj.get("repeatHeader") == "1", ends, + tuple(cells), caption=_caption(measure, obj), cut=_spare_cut(obj)) + stacked.append(_Stacked(int(pos.get("vertOffset", 0)), left, left + width, height, table)) + return tuple(stacked) + + def _room_beside(wrap: _Wrap, width: int) -> int: """How wide a line of *width* is beside a square-wrapped object's band: its wider piece when the text goes on both sides.""" @@ -2204,7 +2274,9 @@ def __init__(self, body: int, columns: int, notes: _NoteShape, self.body = body self.widths = widths # each column's width when they differ #: frame -> (top, bottom) of the objects placed on the paper there: no line in them - self.bands = bands or {} + self.bands = {frame: list(found) for frame, found in (bands or {}).items()} # stacked objects add theirs + #: frame -> left, right, top, bottom of the objects stacked in empty paragraphs that went there + self.stacked: dict[int, list[tuple[int, int, int, int]]] = {} #: frame -> (top, bottom) of the ones leaving a line's room beside them, and whether a line without #: a layout cache, a table or an object reaches one of them (set beside it by Hancom, which is not #: followed: a line keeping its cache stays where it is) @@ -2252,6 +2324,9 @@ def _paragraph(self, index: int, paras: list[_Para], para: _Para) -> None: para = replace(para, column_break=False) if self.floor is not None: start, self.floor = max(start, self.floor), None + if para.stack: + self._stacked(index, paras, para, start) + return if para.band is not None or self.band is not None: self._banded(index, paras, para, start) return @@ -2322,6 +2397,84 @@ def _flow_below_band(self, para: _Para, table: _FlowTable, top: int) -> None: self._reach(self.frame, line, line + para.height(0)) self.last_vp, self.last_pitch, self.pending_next = line, para.advance(0), para.next + def _stacked(self, index: int, paras: list[_Para], para: _Para, start: int) -> None: + """Objects stacked in a paragraph holding nothing else go one by one to the first page, from the + paragraph's on, where they fit below the earlier ones there they overlap across (see + :meth:`_stack_spot`), those of earlier such paragraphs included. The first, not fitting on the + paragraph's page, flows from where it stands (a table split at the page's foot, going on at the next + page's top whatever is there); a later one, fitting on no page holding objects, starts at the top of + the page after, a table taller than a page split over the pages after. A page a table goes on from is + full. No line goes into them: the paragraph's line takes the first place clear of them, on a later + page when its own has none, and the lines after follow it.""" + + if self.columns > 1: + raise _Unsupported("objects placed top and bottom one below another in columns") + if self.band is not None or para.notes or self.page_notes[1]: + raise _Unsupported("objects placed top and bottom one below another beside notes or a table's band") + start, _ = self._breaks(para, start) + if self.last_vp is not None and start + para.height(0) > self.body: # its line goes on to the next + start = self._next_frame(para, 0, True) # page, the objects with it + home, top = self.frame, start - para.prev + placed = {frame: list(boxes) for frame, boxes in self.stacked.items() if frame >= home} + last = home + for number, obj in enumerate(para.stack): + end = last if number == 0 else max([last, *placed]) + spot = self._stack_spot(placed, obj, home, end, top) + if spot is None: # the first from where it stands, a later one from the top of the page after + spot = (end + 1, 0) if number else (home, max([top + obj.offset] + self._below(placed, obj, home))) + if obj.table is None and spot[1] + obj.height > self.body: + raise _Unsupported("an object placed top and bottom past the page's foot") + frame, y = spot + end_frame, bottom = frame, y + obj.height + if bottom > self.body: # a table split over the pages from there + assert obj.table is not None + end_frame, end = _flow_table(obj.table, frame, y + obj.table.above, self.body) + bottom = end + obj.table.below + for page in range(frame, end_frame + 1): + placed.setdefault(page, []).append( + (obj.left, obj.right, y if page == frame else 0, bottom if page == end_frame else self.body)) + last = max(last, end_frame) + for frame, boxes in placed.items(): + if self.bands.get(frame) and frame not in self.stacked or self.sides.get(frame) \ + or frame != home and self.reserved.get(frame): + raise _Unsupported("objects placed top and bottom one below another on a page with other objects") + self.bands[frame] = [(above, below) for _, _, above, below in boxes] + self.stacked[frame] = boxes + self.table_end = max(self.table_end, last) + frame, y, height = home, start, para.height(0) + while True: # the paragraph's line, at the first place clear of them + hits = [below for above, below in self.bands.get(frame, ()) if y < below and y + height > above] + if hits: + y = max(hits) + elif y + height <= self.body: + break + else: + frame, y = frame + 1, self.reserved.get(frame + 1, 0) + para.prev + if frame != home: + self.page_notes = [0, 0] + self.frame, self.laid = frame, para + self.out.append((frame, y)) + self._reach(frame, y, y + height) + self.last_vp, self.last_pitch, self.pending_next = y, para.advance(0), para.next + + def _stack_spot(self, placed: dict[int, list[tuple[int, int, int, int]]], obj: _Stacked, home: int, last: int, + top: int) -> tuple[int, int] | None: + """The first of the pages *home* to *last* where *obj* fits below the objects *placed* there it overlaps + across (on *home* at its offset below *top* or lower, on a later page from its top), and where; ``None`` + when it fits on none.""" + + for frame in range(home, last + 1): + y = max([top + obj.offset if frame == home else 0] + self._below(placed, obj, frame)) + if y + obj.height <= self.body: + return frame, y + return None + + @staticmethod + def _below(placed: dict[int, list[tuple[int, int, int, int]]], obj: _Stacked, frame: int) -> list[int]: + """The bottoms of the objects *placed* on *frame* that *obj* overlaps across.""" + + return [box[3] for box in placed.get(frame, ()) if box[0] < obj.right and obj.left < box[1]] + def _anchored(self, index: int, paras: list[_Para], para: _Para, start: int) -> None: """The lines before the anchor's line, the object at that line's top, then the rest of the paragraph below the object.""" @@ -2457,7 +2610,9 @@ def _clear_of_paper(self, first: int, last: int, top: int = 0, end: int | None = touching = below and frame == first if any(above < high and (low < bottom if touching else low <= bottom) for above, bottom in self.bands.get(frame, ())): - raise _Unsupported("a table on a page with an object placed on the paper") + stacked = frame in self.stacked + raise _Unsupported("a table on a page with objects stacked in an empty paragraph" if stacked + else "a table on a page with an object placed on the paper") def _reach(self, frame: int, top: int, bottom: int) -> None: """Note whether something from *top* to *bottom* in *frame* reaches the band of an object placed on @@ -2520,20 +2675,21 @@ def _lay(self, index: int, paras: list[_Para], para: _Para, start: int, broke: b para = self._at_width(para, 0) remaining, first_chunk = para.lines, True fresh = self.last_vp is None or broke + below = False # the next line was moved below an object's band while remaining: done = para.lines - remaining hit = self._band_hit(para, done, remaining, start) if hit is not None and hit[0] == 0: # the line reaches an object placed on the paper: below it - start = hit[1] + start, below = hit[1], True continue count = self._chunk(index, paras, para, start, remaining, done, first_chunk) - if count == 0 and (self.last_vp is None or fresh) and not self.reserved.get(self.frame): + if count == 0 and (self.last_vp is None or fresh) and not below and not self.reserved.get(self.frame): count = 1 # a line taller than the page still takes an empty page (and overflows it) if hit is not None and hit[0] < count: # the lines above it stay, the next goes below it count = hit[0] self.out.extend((self.frame, start + para.span(done, j)) for j in range(count)) self._place(para, done, count) - remaining, start, first_chunk = remaining - count, hit[1], False + remaining, start, first_chunk, below = remaining - count, hit[1], False, True continue self.out.extend((self.frame, start + para.span(done, j)) for j in range(count)) self._place(para, done, count) @@ -2545,7 +2701,7 @@ def _lay(self, index: int, paras: list[_Para], para: _Para, start: int, broke: b self.laid = para return False if remaining: - start = self._next_frame(para, count, first_chunk) + start, below = self._next_frame(para, count, first_chunk), False again = self._at_width(para, para.lines - remaining) if again is not para: para, remaining = again, again.lines diff --git a/tests/fixtures/hancom_saved/pages_stacked_first_table_2000_down.hwpx b/tests/fixtures/hancom_saved/pages_stacked_first_table_2000_down.hwpx new file mode 100644 index 00000000..ad7158d5 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_first_table_2000_down.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_four_tables_over_four_pages.hwpx b/tests/fixtures/hancom_saved/pages_stacked_four_tables_over_four_pages.hwpx new file mode 100644 index 00000000..df92136c Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_four_tables_over_four_pages.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_four_tables_with_margins.hwpx b/tests/fixtures/hancom_saved/pages_stacked_four_tables_with_margins.hwpx new file mode 100644 index 00000000..b340f808 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_four_tables_with_margins.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_in_two_paragraphs.hwpx b/tests/fixtures/hancom_saved/pages_stacked_in_two_paragraphs.hwpx new file mode 100644 index 00000000..dd1937d2 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_in_two_paragraphs.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_in_two_paragraphs_over_four_pages.hwpx b/tests/fixtures/hancom_saved/pages_stacked_in_two_paragraphs_over_four_pages.hwpx new file mode 100644 index 00000000..476473e6 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_in_two_paragraphs_over_four_pages.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_low_small_second_table.hwpx b/tests/fixtures/hancom_saved/pages_stacked_low_small_second_table.hwpx new file mode 100644 index 00000000..fff53b44 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_low_small_second_table.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_low_table_taller_than_a_page.hwpx b/tests/fixtures/hancom_saved/pages_stacked_low_table_taller_than_a_page.hwpx new file mode 100644 index 00000000..542c2b44 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_low_table_taller_than_a_page.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_low_two_tables_cell.hwpx b/tests/fixtures/hancom_saved/pages_stacked_low_two_tables_cell.hwpx new file mode 100644 index 00000000..e7940045 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_low_two_tables_cell.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_low_two_tables_table.hwpx b/tests/fixtures/hancom_saved/pages_stacked_low_two_tables_table.hwpx new file mode 100644 index 00000000..0edffb1c Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_low_two_tables_table.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_middle_table_past_the_foot_cell.hwpx b/tests/fixtures/hancom_saved/pages_stacked_middle_table_past_the_foot_cell.hwpx new file mode 100644 index 00000000..e44a43a2 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_middle_table_past_the_foot_cell.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_middle_table_past_the_foot_table.hwpx b/tests/fixtures/hancom_saved/pages_stacked_middle_table_past_the_foot_table.hwpx new file mode 100644 index 00000000..75656be2 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_middle_table_past_the_foot_table.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_picture_and_table.hwpx b/tests/fixtures/hancom_saved/pages_stacked_picture_and_table.hwpx new file mode 100644 index 00000000..478afaa2 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_picture_and_table.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_pictures_side_by_side.hwpx b/tests/fixtures/hancom_saved/pages_stacked_pictures_side_by_side.hwpx new file mode 100644 index 00000000..69999894 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_pictures_side_by_side.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_second_table_1230_down.hwpx b/tests/fixtures/hancom_saved/pages_stacked_second_table_1230_down.hwpx new file mode 100644 index 00000000..db00348a Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_second_table_1230_down.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_second_table_5000_down.hwpx b/tests/fixtures/hancom_saved/pages_stacked_second_table_5000_down.hwpx new file mode 100644 index 00000000..95c42401 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_second_table_5000_down.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_second_table_on_the_next_page.hwpx b/tests/fixtures/hancom_saved/pages_stacked_second_table_on_the_next_page.hwpx new file mode 100644 index 00000000..5d8dafd9 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_second_table_on_the_next_page.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_table_taller_than_a_page_cell.hwpx b/tests/fixtures/hancom_saved/pages_stacked_table_taller_than_a_page_cell.hwpx new file mode 100644 index 00000000..e82d944d Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_table_taller_than_a_page_cell.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_table_taller_than_a_page_table.hwpx b/tests/fixtures/hancom_saved/pages_stacked_table_taller_than_a_page_table.hwpx new file mode 100644 index 00000000..f5251dd6 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_table_taller_than_a_page_table.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_tables_filling_the_second_page.hwpx b/tests/fixtures/hancom_saved/pages_stacked_tables_filling_the_second_page.hwpx new file mode 100644 index 00000000..71fe86ab Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_tables_filling_the_second_page.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_then_a_flowing_table.hwpx b/tests/fixtures/hancom_saved/pages_stacked_then_a_flowing_table.hwpx new file mode 100644 index 00000000..908ad27b Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_then_a_flowing_table.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_then_a_table_as_character_below_a_short_one.hwpx b/tests/fixtures/hancom_saved/pages_stacked_then_a_table_as_character_below_a_short_one.hwpx new file mode 100644 index 00000000..8e21675d Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_then_a_table_as_character_below_a_short_one.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_then_a_table_as_character_past_a_filled_page.hwpx b/tests/fixtures/hancom_saved/pages_stacked_then_a_table_as_character_past_a_filled_page.hwpx new file mode 100644 index 00000000..cfa1f232 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_then_a_table_as_character_past_a_filled_page.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_then_text_below_a_nearly_filled_page.hwpx b/tests/fixtures/hancom_saved/pages_stacked_then_text_below_a_nearly_filled_page.hwpx new file mode 100644 index 00000000..baece2e6 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_then_text_below_a_nearly_filled_page.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_then_text_over_two_pages.hwpx b/tests/fixtures/hancom_saved/pages_stacked_then_text_over_two_pages.hwpx new file mode 100644 index 00000000..665db1f2 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_then_text_over_two_pages.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_then_text_past_a_filled_page.hwpx b/tests/fixtures/hancom_saved/pages_stacked_then_text_past_a_filled_page.hwpx new file mode 100644 index 00000000..a61ba806 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_then_text_past_a_filled_page.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_three_tables.hwpx b/tests/fixtures/hancom_saved/pages_stacked_three_tables.hwpx new file mode 100644 index 00000000..4adeb3eb Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_three_tables.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_two_pictures.hwpx b/tests/fixtures/hancom_saved/pages_stacked_two_pictures.hwpx new file mode 100644 index 00000000..f8893a0c Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_two_pictures.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_two_pictures_past_the_foot.hwpx b/tests/fixtures/hancom_saved/pages_stacked_two_pictures_past_the_foot.hwpx new file mode 100644 index 00000000..ac01b829 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_two_pictures_past_the_foot.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_two_tables.hwpx b/tests/fixtures/hancom_saved/pages_stacked_two_tables.hwpx new file mode 100644 index 00000000..69c75681 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_two_tables.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_stacked_two_tables_past_the_foot.hwpx b/tests/fixtures/hancom_saved/pages_stacked_two_tables_past_the_foot.hwpx new file mode 100644 index 00000000..160cf483 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_stacked_two_tables_past_the_foot.hwpx differ diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index 9ab2c265..7144ec4e 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -481,6 +481,85 @@ def test_without_line_caches_formfit_breaks_the_lines_the_same(name: str) -> Non _assert_like_hancom(estimate_pages(_without_caches(data)), data, HANCOM_PAGES[name]) +# Objects placed top and bottom from the top of an empty paragraph after four lines (10 pt, spaced 160%), the +# lines after it of text, laid out and saved by Hancom: its pages, and the page of the paragraph's line and +# the lines after when not the first: +STACKED_PAGES: dict[str, int | tuple[int, int]] = { + "pages_stacked_two_tables": 1, # both 0 down: one right below the other, the line below both + "pages_stacked_three_tables": 1, + "pages_stacked_second_table_1230_down": 1, # still right below the first + "pages_stacked_second_table_5000_down": 1, # below a gap, which takes the paragraph's line + "pages_stacked_first_table_2000_down": 1, # the line above it, the next line below both + "pages_stacked_four_tables_with_margins": 1, # outer margins 140 + "pages_stacked_two_pictures": 1, + "pages_stacked_pictures_side_by_side": 1, # each at its own offset, the lines above them staying there + "pages_stacked_picture_and_table": 1, + "pages_stacked_two_pictures_past_the_foot": 2, # the second alone on the next page, the text on the first + "pages_stacked_two_tables_past_the_foot": 2, + "pages_stacked_middle_table_past_the_foot_cell": 2, # the second on the next page, the third below the first + "pages_stacked_middle_table_past_the_foot_table": 2, # (split row by row, the same) + "pages_stacked_table_taller_than_a_page_cell": 3, # split between lines over the two pages after the first + "pages_stacked_table_taller_than_a_page_table": 3, # row by row + "pages_stacked_four_tables_over_four_pages": 4, # the third below the first, 1230 down buried; the fourth + # past the second page, row by row over the last two + "pages_stacked_tables_filling_the_second_page": 2, # the third below the second, on the second page + "pages_stacked_second_table_on_the_next_page": 2, # outer margins 141: the second from the next page's top + # The paragraph low on its page, the first table not fitting under it: it flows from there. + "pages_stacked_low_two_tables_cell": (2, 1), # split at the foot, the second below its end, then the line + "pages_stacked_low_two_tables_table": (2, 1), # its first row alone on the first page + "pages_stacked_low_small_second_table": (2, 1), # the small second one not back on the first page + "pages_stacked_low_table_taller_than_a_page": (3, 2), # no room left for the line: the next page's top + # After an empty paragraph whose second table of two went on to the next page: + "pages_stacked_in_two_paragraphs": 2, # another such paragraph: its tables below the first's line + "pages_stacked_in_two_paragraphs_over_four_pages": (4, 3), # four tables, then two more from where the + # first's line leaves off: the first of them split + # over the next page's top across the other's, the + # second below it, the line on the fourth page + "pages_stacked_then_a_flowing_table": 2, # a paragraph holding one flowing table: under its line + "pages_stacked_then_text_over_two_pages": (3, 2), # 60 lines, going on below the second table + # After an empty paragraph whose second table went alone to the top of the next page, filling it but for 1500: + "pages_stacked_then_text_below_a_nearly_filled_page": (3, 2), # 60 lines: one in those 1500, the rest after + "pages_stacked_then_text_past_a_filled_page": (3, 2), # 900 left, less than a line: the lines skip that page + "pages_stacked_then_a_table_as_character_past_a_filled_page": (3, 2), # one 55000 tall skips it too + "pages_stacked_then_a_table_as_character_below_a_short_one": (2, 1), # under a second table 5762 tall +} + + +@pytest.mark.parametrize("name", sorted(STACKED_PAGES)) +def test_objects_stacked_in_an_empty_paragraph_go_where_hancom_put_them(name: str) -> None: + # Each goes to the first page, from the paragraph's on, where it fits below the earlier ones it overlaps + # across; one fitting on none starts at the top of the page after the last one used. The lines take the + # first places clear of them. A last page holding only objects has no line, hence no HANCOM_PAGES entry. + data = (FIXTURES / f"{name}.hwpx").read_bytes() + hancom = _hancom_lines(data) + pages, lines_page = STACKED_PAGES[name] if isinstance(STACKED_PAGES[name], tuple) else (STACKED_PAGES[name], 0) + + 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.unsupported, estimate.pages) == ((), pages) + assert [lines[-1].page for lines in estimate.lines][-1] == lines_page + assert [mine for mine, theirs in zip(estimated, hancom) if theirs] == [theirs for theirs in hancom if theirs] + + +@pytest.mark.parametrize(("index", "height"), [(0, 60000), (1, 70000)]) # the first not fitting, a later on no page +def test_a_stacked_picture_past_the_page_foot_is_not_followed(index: int, height: int) -> None: + out = io.BytesIO() + data = (FIXTURES / "pages_stacked_two_pictures.hwpx").read_bytes() + with zipfile.ZipFile(io.BytesIO(data)) as source, zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as target: + for info in source.infolist(): + payload = source.read(info.filename) + if info.filename.startswith("Contents/section"): + root = etree.fromstring(payload) + list(root.iter(f"{HP}pic"))[index].find(f"{HP}sz").set("height", str(height)) + payload = etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True) + target.writestr(info, payload) + + assert estimate_pages(out.getvalue()).unsupported == ( + "section 0: an object placed top and bottom past the page's foot",) + + def test_objects_set_as_characters_among_text_on_both_sides_of_a_square_wrapped_object_are_not_followed() -> None: # The picture of pages_square_band_table_as_character_among_text moved 3000 from the column's left: text goes # on both sides of it, each line in two pieces, and an object among it is not followed there.