diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e92b3a2..d01789a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ 닿고, 칸의 다음 줄은 그 아래에서 이어진다. 위로 띄운 표는 문단 윗선에 선다. 그런 행이 쪽을 넘어 줄 사이에서 나뉠 때도 표는 그 높이의 한 줄로 센다. 글 앞에 둔 그런 표는 거기 닿는 첫 줄부터 표 아래로 보낸다. 한 문단에 표가 둘이면 한/글은 빈 줄을 아래 표 밑으로 보내므로 전처럼 캐시가 있어야 한다. +- 쪽 수 추정(실험, `estimate_pages`)이 위아래 개체의 띠에 닿는 줄에 글자처럼 둔 개체가 있으면 지원 밖 + (`a page break or another object beside a square-wrapped object`)으로 두던 것을 고친다. 한/글은 그런 줄도 글 줄처럼 + 띠 아래로 보낸다. 글자처럼 둔 개체가 줄 높이를 키우지 않으면 줄 높이는 글 높이 그대로다. +- 쪽 수 추정(실험, `estimate_pages`)이 어울림(SQUARE) 개체 곁 띠에 글자처럼 둔 개체 하나만 든 빈 문단을 지원 밖 + (`an object beside a square-wrapped object`)으로 두던 것을 고친다. 한/글은 개체가 곁 자리보다 넓으면 그 줄을 띠 + 아래로 보내 전체 폭에 두고, 좁으면 곁에 둔다. +- 쪽 수 추정(실험, `estimate_pages`)이 어울림 개체 곁 띠에서 글 사이에 글자처럼 둔 개체가 든 문단을 띠 없이 세던 + 것을 고친다. 이제 띠 안에서 시작하는 줄은 곁 자리 폭으로 나눈다. 곁 자리보다 넓은 개체가 든 첫 줄은 한/글처럼 + 띠 아래로 보내 전체 폭에 두고, 곁의 남은 자리는 비운다. 글이 개체 양쪽에 놓이는 띠에서는 지원 밖 + (`objects set as characters among text beside a square-wrapped object`)이다. - 쪽 수 추정(실험, `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 b12bf831..aceeaf1b 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -1607,9 +1607,17 @@ def _paragraph(measure: _Measure, page: _Page, paragraph: Any, wrap: _Wrap | Non head = _head_size(measure, paragraph, sizes) mixed = len(set(sizes)) > 1 or bool(end) or bool(head) looks = _char_styles(measure, paragraph, runs) + lead = 0 # how far the paragraph's first line goes down below a square-wrapped object's band if among or beside: inline_text, inline_sizes, inline_looks, placed, marked = _inline_content(measure, paragraph, runs, anchored) - if not cached: + if wrap is not None: + if beside or wrap.split or marked: + raise _Unsupported("objects set as characters among text beside a square-wrapped object") + looks_or_none = inline_looks if len(set(inline_looks)) > 1 else None + lines, lead = _banded_object_lines(measure, inline_text, widths, inline_sizes, style, shape, wrap, placed, + looks_or_none, end, head) + cached = _cached_jumps(paragraph, cached) if cached else lines + elif not cached: looks_or_none = inline_looks if len(set(inline_looks)) > 1 else None cached = _line_metrics(measure, inline_text, widths, inline_sizes, style, shape, placed, looks_or_none, marked, end, head) @@ -1641,8 +1649,9 @@ def _paragraph(measure: _Measure, page: _Page, paragraph: Any, wrap: _Wrap | Non notes = _anchored_notes(measure, runs, text, widths, size, style, page.column_width, sizes if mixed else None, looks) flags = shape.flags - para = _Para(count, size, pitch, shape.prev, shape.next, _on(flags, "pageBreakBefore"), _on(flags, "keepLines"), - _on(flags, "keepWithNext"), _on(flags, "widowOrphan"), paragraph.get("pageBreak") == "1", + para = _Para(count, size, pitch, shape.prev + lead, shape.next, _on(flags, "pageBreakBefore"), + _on(flags, "keepLines"), _on(flags, "keepWithNext"), _on(flags, "widowOrphan"), + paragraph.get("pageBreak") == "1", paragraph.get("columnBreak") == "1", table, notes, cached, anchor, wrap_anchor=around, hides=page.hide_empty and _holds_nothing(runs)) if reflow and text: @@ -1738,9 +1747,10 @@ def _wrapped_paragraph(measure: _Measure, page: _Page, paragraph: Any, square = _square_object(objects, runs, page.column_width) pusher = _pushing_object(objects, _run_text(runs), page.column_width) shape = measure.shape(paragraph.get("paraPrIDRef")) + placed = [obj for obj in objects if obj.find(f"{HP}pos").get("treatAsChar") != "1"] if wrap is not None: wrap = wrap.lower(shape.prev) - if wrap is not None and (square is not None or pusher is not None or (wrap.push and objects) + if wrap is not None and (square is not None or pusher is not None or (wrap.push and placed) or _on(shape.flags, "pageBreakBefore") or paragraph.get("pageBreak") == "1" or paragraph.get("columnBreak") == "1"): raise _Unsupported("a page break or another object beside a square-wrapped object") @@ -1759,8 +1769,16 @@ def _wrapped_paragraph(measure: _Measure, page: _Page, paragraph: Any, if _local(pusher) == "tbl": # as tall as its rows tall += sum(row.height for row in _rows(measure, pusher)) - int(pusher.find(f"{HP}sz").get("height", 0)) return _push(para, _Wrap(top, top + tall, 0, push=True), starts=True) - if wrap is not None and wrap.push: + if wrap is not None and wrap.push: # objects set as characters on a line reaching it go below it with it return _push(_paragraph(measure, page, paragraph), wrap, starts=False) + if wrap is not None and objects and not placed and not _run_text(runs).strip(): + para = _paragraph(measure, page, paragraph) # one object set as a character, alone + if len(objects) > 1: + raise _Unsupported("objects set as characters beside a square-wrapped object") + if _object_extent(objects[0], measure)[0] > _room_beside(wrap, page.column_width - shape.left - shape.right): + return _push(para, replace(wrap, push=True), starts=False) # below the object, the whole width + beside = sum(1 for line in range(para.lines) if para.span(0, line) < wrap.bottom) + return replace(para, wrap_lines=beside), wrap.lower(para.span(0, para.lines) + para.next) starts = square is not None if square is not None: top = int(square.find(f"{HP}pos").get("vertOffset", 0)) @@ -1779,10 +1797,11 @@ def _wrapped_paragraph(measure: _Measure, page: _Page, paragraph: Any, raise _Unsupported("an object beside a square-wrapped object") if wrap is None: return para, None - beside = sum(1 for line in range(para.lines) if para.span(0, line) < wrap.bottom) + lead = para.prev - shape.prev # its first line moved below the band + beside = sum(1 for line in range(para.lines) if lead + para.span(0, line) < wrap.bottom) para = replace(para, wrap_lines=beside, wrap_bottom=wrap.bottom if starts else 0, wrap_fixed=bool(_cached_metrics(paragraph))) - return para, wrap.lower(para.span(0, para.lines) + para.next) + return para, wrap.lower(lead + para.span(0, para.lines) + para.next) def _pushing_object(objects: list[Any], text: str, column: int) -> Any: @@ -1805,6 +1824,13 @@ def _pushing_object(objects: list[Any], text: str, column: int) -> Any: return obj +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.""" + + return max(wrap.split, width - wrap.cut - wrap.split) if wrap.split else width - wrap.cut + + def _push(para: _Para, band: _Wrap, *, starts: bool) -> tuple[_Para, _Wrap | None]: """*para* with the first line reaching a top-and-bottom object's *band* moved below it (the lines after follow), and the band as the next paragraph sees it (``None`` once a line went below it). @@ -1876,6 +1902,62 @@ def _at_one_height(metrics: tuple[tuple[int, int], ...], firsts: set[int]) -> tu return tuple(lines) +def _banded_object_lines(measure: _Measure, text: str, widths: list[float], sizes: list[int], style: Any, + shape: _Shape, wrap: _Wrap, objects: dict[int, tuple[int, int]], looks: list[Any] | None, + end: int, head: int) -> tuple[tuple[tuple[int, int], ...], int]: + """(height, advance) of each line of text holding objects set as characters (*objects*: place -> width and + height) beside a square-wrapped object's band, and how far the first line goes down. A line whose top is in + the band is as wide as the room beside the object, but the first one holding an object wider than that room + goes below the band, the whole width, the rest of the room left empty (its line before spaced that much + more). Every line taken as narrow at first, the lines are broken again until the narrow ones settle.""" + + advances = {index: width for index, (width, _) in objects.items()} or None + mixed = sizes if len(set(sizes)) > 1 else None + narrow = set(range(len(text) + 1)) + for _ in range(8): + widths_by_line = [widths[min(line, 1)] - (wrap.cut if line in narrow else 0) + for line in range(min(max(narrow, default=0), len(text)) + 2)] + starts = measure.line_starts(text, widths_by_line, min(sizes), style, mixed, looks, advances) + metrics: list[tuple[int, int]] = [] + found: set[int] = set() + top = lead = 0 + for line, (start, stop) in enumerate(zip(starts, [*starts[1:], len(text)])): + span = range(start, stop) if stop > start else range(len(text) - 1, len(text)) + size = max([sizes[index] for index in span] + ([end] if stop == len(text) else []) + + ([head] if start == 0 else [])) + height = max([size] + [objects[index][1] for index in span if index in objects]) + pitch = _pitch(shape.kind, shape.value, size) + advance = pitch if shape.kind == "FIXED" else height + pitch - size + if top < wrap.bottom and top + height > wrap.top: + room = widths[min(line, 1)] - wrap.cut + if any(objects[index][0] > room for index in span if index in objects): # below the band + if metrics: + metrics[-1] = (metrics[-1][0], metrics[-1][1] + wrap.bottom - top) + else: + lead = wrap.bottom - top + top = wrap.bottom + else: + found.add(line) + metrics.append((height, advance)) + top += advance + if found == narrow: + return tuple(metrics), lead + narrow = found + raise _Unsupported("lines beside a square-wrapped object that do not settle") + + +def _cached_jumps(paragraph: Any, metrics: tuple[tuple[int, int], ...]) -> tuple[tuple[int, int], ...]: + """*metrics* from the paragraph's layout cache with each line spaced down to where the cache puts the next + one on its page when that is lower (a line Hancom moved below a square-wrapped object's band).""" + + tops = [int(segment.get("vertpos", 0)) for segment in paragraph.findall(f"{HP}linesegarray/{HP}lineseg")] + lines = list(metrics) + for line, (above, below) in enumerate(zip(tops, tops[1:])): + if line < len(lines) and below - above > lines[line][1]: + lines[line] = (lines[line][0], below - above) + return tuple(lines) + + # -- laying the paragraphs out --------------------------------------------------------------------- diff --git a/tests/fixtures/hancom_saved/pages_push_band_group_as_character.hwpx b/tests/fixtures/hancom_saved/pages_push_band_group_as_character.hwpx new file mode 100644 index 00000000..1592ad66 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_push_band_group_as_character.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_push_band_group_as_character_alone.hwpx b/tests/fixtures/hancom_saved/pages_push_band_group_as_character_alone.hwpx new file mode 100644 index 00000000..7d7b3407 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_push_band_group_as_character_alone.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_push_band_picture_as_character.hwpx b/tests/fixtures/hancom_saved/pages_push_band_picture_as_character.hwpx new file mode 100644 index 00000000..2d5aa499 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_push_band_picture_as_character.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_push_band_text.hwpx b/tests/fixtures/hancom_saved/pages_push_band_text.hwpx new file mode 100644 index 00000000..5008690a Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_push_band_text.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_square_band_narrow_table_as_character_alone.hwpx b/tests/fixtures/hancom_saved/pages_square_band_narrow_table_as_character_alone.hwpx new file mode 100644 index 00000000..8fa001fe Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_square_band_narrow_table_as_character_alone.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_square_band_narrow_table_as_character_among_text.hwpx b/tests/fixtures/hancom_saved/pages_square_band_narrow_table_as_character_among_text.hwpx new file mode 100644 index 00000000..d8731595 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_square_band_narrow_table_as_character_among_text.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_square_band_picture_as_character_among_text.hwpx b/tests/fixtures/hancom_saved/pages_square_band_picture_as_character_among_text.hwpx new file mode 100644 index 00000000..3ad0a4d3 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_square_band_picture_as_character_among_text.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_square_band_table_as_character_alone.hwpx b/tests/fixtures/hancom_saved/pages_square_band_table_as_character_alone.hwpx new file mode 100644 index 00000000..57f866a5 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_square_band_table_as_character_alone.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_square_band_table_as_character_among_text.hwpx b/tests/fixtures/hancom_saved/pages_square_band_table_as_character_among_text.hwpx new file mode 100644 index 00000000..d679fe53 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_square_band_table_as_character_among_text.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_square_band_table_as_character_below_a_short_band.hwpx b/tests/fixtures/hancom_saved/pages_square_band_table_as_character_below_a_short_band.hwpx new file mode 100644 index 00000000..6caab2a3 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_square_band_table_as_character_below_a_short_band.hwpx differ diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index d6494e3e..1c6dfa03 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -23,6 +23,25 @@ HH = "{http://www.hancom.co.kr/hwpml/2011/head}" FIXTURES = Path(__file__).parent / "fixtures" / "hancom_saved" HANCOM_PAGES = { + # A rectangle placed top and bottom 2000 down from an empty paragraph's top, 20000 x 8000 (one column + # 48188 wide): the next paragraph's line reaching it goes below it, the whole width, even holding objects + # set as characters (a group or a picture 44633 x 648, which do not make it taller than its text): + "pages_push_band_text": 1, # a character alone + "pages_push_band_group_as_character": 1, # a character, a group and a polygon in front of the text + "pages_push_band_picture_as_character": 1, + "pages_push_band_group_as_character_alone": 1, # the group and the polygon, no text + # A picture wrapped square from an empty paragraph's left, 41631 x 27112, leaving 6557 beside it; the + # next paragraph holds a table set as a character alone: + "pages_square_band_table_as_character_alone": 1, # 47058 wide: below the picture, the whole width + "pages_square_band_narrow_table_as_character_alone": 1, # 5000 wide: beside it, and the lines after + # ... or text whose lines in the room beside the picture are as wide as it, and an object set as a + # character among it: the first line holding one wider than the room goes below the picture, the whole + # width, the rest of the room left empty; one narrower stays beside it. + "pages_square_band_table_as_character_among_text": 2, # a table 47058 wide + "pages_square_band_narrow_table_as_character_among_text": 1, # 5000 wide + "pages_square_band_picture_as_character_among_text": 1, # a picture 47058 x 5000 + "pages_square_band_table_as_character_below_a_short_band": 1, # the picture 5000 tall: the table's line + # comes after the band anyway "pages_fixed_width_spaces": 2, # rows of a syllable and a fixed-width space: a quarter em that hangs "pages_no_break_spaces": 2, # rows of "가나" and a no-break space: each is half an em and keeps the row # one word @@ -448,6 +467,25 @@ 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]) +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. + out = io.BytesIO() + data = (FIXTURES / "pages_square_band_table_as_character_among_text.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) + square = next(pic for pic in root.iter(f"{HP}pic") if pic.get("textWrap") == "SQUARE") + square.find(f"{HP}pos").set("horzOffset", "3000") + payload = etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True) + target.writestr(info, payload) + + assert estimate_pages(_without_caches(out.getvalue())).unsupported == ( + "section 0: objects set as characters among text beside a square-wrapped object",) + + def test_a_typed_ideographic_space_is_not_a_fixed_width_one() -> None: # 맑은 고딕 10 pt cells of narrowing widths, laid out and saved by Hancom: rows of "가나다라마" each followed # by one or two ideographic spaces typed in the text (U+3000), or by fixed-width spaces (hp:fwSpace, which