Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"로 거부하던 것을 고친다. 한/글은 쪽 끝마다
다시 나눈다. 칸마다 그 쪽에 들어가는 줄까지 두고 나머지는 다음 쪽 머리 행 아래로 넘기며, 넘긴 부분이 다시
Expand Down
96 changes: 89 additions & 7 deletions src/hwpx/layout/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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))
Expand All @@ -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:
Expand All @@ -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).
Expand Down Expand Up @@ -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 ---------------------------------------------------------------------


Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
38 changes: 38 additions & 0 deletions tests/test_layout_page_estimate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading