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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@
한/글은 그 도형을 문단 위가 있었을 자리에서 그만큼 위에 놓고, 그 띠에 걸린 앞 줄(아래끝이 띠 위끝보다 아래인 줄)을
띠 아래끝으로 내린 뒤 그다음 줄을 이어서 놓는다. 도형의 문단 줄은 그 뒤에 온다. 추정도 이제 그렇게 놓는다(옮긴 줄이
쪽 끝을 넘거나 그 사이에 표·개체·각주가 있으면 지원 밖).
- 쪽 수 추정이 빼기 부호로 쓴 개체 오프셋(`vertOffset="-1500"`처럼, 한/글이 쓰는 부호 없는 32비트 꼴이 아닌 것)을 한/글처럼
0으로 읽는다. 한/글은 그런 값을 0으로 읽어 개체를 문단(또는 종이) 기준 자리에 그대로 그리고, 저장할 때 0으로 고쳐 쓴다.
전에는 그만큼 위(왼쪽)로 놓인 것으로 읽어 뒤 줄 자리가 한/글과 달랐다.
- 쪽 수 추정이 글자처럼 취급한 그림을 그 효과까지 합친 크기로 놓는다. 빛남은 네 변에 반경만큼, 바깥 그림자는
그림(빛남 포함)을 방향으로 거리만큼 옮기고 반경만큼 넓힌 자리까지 차지해, 그 줄이 그만큼 높아진다. 전에는 효과를
세지 않아 줄과 쪽이 한/글보다 짧게 나왔다. 안쪽 그림자는 자리를 차지하지 않는다. 그림자의 흐림은 한/글처럼 화면
Expand Down
48 changes: 28 additions & 20 deletions src/hwpx/layout/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -1003,12 +1003,20 @@ def _holds_table(element: Any) -> bool:
return False


def _pos_offset(pos: Any, name: str) -> int:
"""An object's ``vertOffset`` or ``horzOffset`` as Hancom reads it: the file keeps an offset up or left as
an unsigned 32-bit number; one written with a minus sign Hancom reads as 0 (and saves so)."""

value = pos.get(name, "0").strip()
return 0 if value.startswith("-") else int(value)


def _down(pos: Any) -> int:
"""How far below its paragraph's top an object placed from there stands: one offset up (a negative
offset, which the file keeps as an unsigned 32-bit number) stands at the paragraph's top."""

offset = int(pos.get("vertOffset", 0))
return 0 if offset < 0 or offset >= 1 << 31 else offset
offset = _pos_offset(pos, "vertOffset")
return 0 if offset >= 1 << 31 else offset


def _vertical(cell: Any) -> bool:
Expand Down Expand Up @@ -1700,7 +1708,7 @@ def _paper_band(measure: _Measure, page: _Page, paragraph: Any) -> tuple[int, in
if _local(obj) == "tbl": # as tall as its rows
tall += _rows_height(measure, obj) - int(obj.find(f"{HP}sz").get("height", 0))
pos = obj.find(f"{HP}pos")
offset = int(pos.get("vertOffset", 0))
offset = _pos_offset(pos, "vertOffset")
align = pos.get("vertAlign", "TOP")
height, base = (page.body, 0) if pos.get("vertRelTo") == "PAGE" else (page.paper_height, -page.top)
if align == "CENTER": # centred, then moved down by the offset (up when negative, kept unsigned)
Expand Down Expand Up @@ -1738,7 +1746,7 @@ def _paper_left(obj: Any, page: _Page) -> int | None:
pos = obj.find(f"{HP}pos")
if pos.get("horzRelTo") != "PAPER":
return None
width, offset = _extent(obj, "width"), int(pos.get("horzOffset", 0))
width, offset = _extent(obj, "width"), _pos_offset(pos, "horzOffset")
return {"LEFT": offset, "CENTER": (page.paper_width - width) // 2 + offset,
"RIGHT": page.paper_width - width - offset}.get(pos.get("horzAlign", "LEFT"))

Expand Down Expand Up @@ -2016,7 +2024,7 @@ def _anchored_object(objects: list[Any], text: str, column: int, headed: bool =
pos = obj.find(f"{HP}pos")
if not _wraps_top_and_bottom(obj, column):
return None
offset = int(pos.get("vertOffset", 0))
offset = _pos_offset(pos, "vertOffset")
if pos.get("vertRelTo") != "PARA" or pos.get("vertAlign", "TOP") != "TOP" \
or (_down(pos) if _local(obj) == "tbl" else offset): # a table placed up stands at the top
return None
Expand Down Expand Up @@ -2135,12 +2143,12 @@ def _object_line(
if _wraps_top_and_bottom(obj, column) and on_paragraph:
if name == "tbl":
rows, cells = _table_rows(measure, obj)
offset = int(pos.get("vertOffset", 0)) # one up (a negative offset, kept unsigned) starts at the line
offset = _pos_offset(pos, "vertOffset") # one up (a negative offset, kept unsigned) starts at the line
table = _FlowTable(rows, _page_break(obj), obj.get("repeatHeader") == "1", _table_ends(obj),
tuple(cells), 0 if offset < 0 or offset >= 1 << 31 else offset,
_caption(measure, obj), _spare_cut(obj))
return count, size, pitch, table
offset = int(pos.get("vertOffset", 0))
offset = _pos_offset(pos, "vertOffset")
below = offset + tall
if offset >= 1 << 31: # one up, kept as an unsigned 32-bit number: Hancom draws it that far above, the
below = max(offset - (1 << 32) + tall, size) # paragraph's line no shorter than its characters
Expand Down Expand Up @@ -2272,7 +2280,7 @@ def _paragraph(measure: _Measure, page: _Page, paragraph: Any, wrap: _Wrap | Non
cached = tuple(line for line in (empty[0], (size, pitch), empty[1]) if line is not None)
count = len(cached)
pos = objects[0].find(f"{HP}pos")
offset = int(pos.get("vertOffset", 0))
offset = _pos_offset(pos, "vertOffset")
if table is None and pos.get("treatAsChar") != "1" and 0 <= offset < 1 << 31:
if pos.get("flowWithText") != "0": # from the paragraph's top down: past the body's foot it goes on
moves = size if offset or page.alone_at_top else 0 # to the next page's top alone, the line staying
Expand Down Expand Up @@ -2309,7 +2317,7 @@ def _paragraph(measure: _Measure, page: _Page, paragraph: Any, wrap: _Wrap | Non
if floor:
para = replace(para, floor=floor)
if alone and table is None and controls is None and not text and _rises(objects[0], page.column_width):
up = int(objects[0].find(f"{HP}pos").get("vertOffset", 0)) - (1 << 32)
up = _pos_offset(objects[0].find(f"{HP}pos"), "vertOffset") - (1 << 32)
para = replace(para, rises=(up, _extent(objects[0], "height", measure), own, own_pitch))
if page.rebreak and not _cached_metrics(paragraph) and not objects and not marks and not notes:
if not text:
Expand All @@ -2325,7 +2333,7 @@ def _rises(obj: Any, column: int) -> bool:

pos = obj.find(f"{HP}pos")
return (pos is not None and pos.get("treatAsChar") != "1" and pos.get("vertRelTo") == "PARA"
and pos.get("vertAlign", "TOP") == "TOP" and int(pos.get("vertOffset", 0)) >= 1 << 31
and pos.get("vertAlign", "TOP") == "TOP" and _pos_offset(pos, "vertOffset") >= 1 << 31
and _wraps_top_and_bottom(obj, column))


Expand Down Expand Up @@ -2377,7 +2385,7 @@ def _wraps_top_and_bottom(obj: Any, column: int) -> bool:
if pos is None or pos.get("horzRelTo") not in ("COLUMN", "PARA") or pos.get("horzAlign") not in ("LEFT", "RIGHT") \
or obj.get("textFlow", "BOTH_SIDES") not in ("BOTH_SIDES", "LARGEST_ONLY"):
return False
offset = int(pos.get("horzOffset", 0))
offset = _pos_offset(pos, "horzOffset")
if offset >= 1 << 31: # kept unsigned: one placed out past the edge
offset -= 1 << 32
start = offset if pos.get("horzAlign") == "LEFT" else column - width - offset
Expand Down Expand Up @@ -2425,7 +2433,7 @@ def _square_sides(obj: Any, page: _Page) -> tuple[int, int]:
the paragraph or the paper's left or right (from the paper only in one column)."""

pos = obj.find(f"{HP}pos")
width, offset = _extent(obj, "width"), int(pos.get("horzOffset", 0))
width, offset = _extent(obj, "width"), _pos_offset(pos, "horzOffset")
if offset >= 1 << 31: # kept unsigned: one placed out past the edge
offset -= 1 << 32
if pos.get("horzRelTo") == "PAPER":
Expand All @@ -2447,7 +2455,7 @@ def _spans(measure: _Measure, page: _Page, table: Any) -> tuple[_FlowTable, int,
"""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))
offset = _pos_offset(table.find(f"{HP}pos"), "vertOffset")
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)
Expand Down Expand Up @@ -2511,7 +2519,7 @@ def _wrapped_paragraph(measure: _Measure, page: _Page, paragraph: Any, wrap: _Wr
raise _Unsupported("a page break or another object beside a square-wrapped object")
if pusher is not None:
para = replace(_paragraph(measure, page, paragraph, None, pusher), bare=bare)
offset = int(pusher.find(f"{HP}pos").get("vertOffset", 0))
offset = _pos_offset(pusher.find(f"{HP}pos"), "vertOffset")
if _local(pusher) == "tbl" and pusher.get("pageBreak", "CELL") in ("CELL", "TABLE"): # it flows
rows, cells = _table_rows(measure, pusher)
ends = _table_ends(pusher)
Expand Down Expand Up @@ -2539,7 +2547,7 @@ def _wrapped_paragraph(measure: _Measure, page: _Page, paragraph: Any, wrap: _Wr
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))
top = _pos_offset(square.find(f"{HP}pos"), "vertOffset")
line = _square_anchor_line(measure, page, paragraph, runs, square)
if line: # after text: from the top of its line, the lines above it the column's whole width
top += _paragraph(measure, page, paragraph, None, square).span(0, line)
Expand Down Expand Up @@ -2577,7 +2585,7 @@ def _clear_of_its_band(measure: _Measure, page: _Page, paragraph: Any, obj: Any)
differ from the paragraph laid out as the object alone."""

para = _paragraph(measure, page, paragraph, None, obj)
offset = int(obj.find(f"{HP}pos").get("vertOffset", 0)) - (para.prev if _local(obj) == "tbl" else 0)
offset = _pos_offset(obj.find(f"{HP}pos"), "vertOffset") - (para.prev if _local(obj) == "tbl" else 0)
return offset >= para.advance(0)


Expand All @@ -2596,7 +2604,7 @@ def _pushing_object(objects: list[Any], text: str, column: int, bare: bool = Fal
if not _wraps_top_and_bottom(obj, column) or pos.get("vertRelTo") != "PARA" \
or pos.get("vertAlign", "TOP") != "TOP":
return None
offset = int(pos.get("vertOffset", 0))
offset = _pos_offset(pos, "vertOffset")
if offset <= 0 or offset >= 1 << 31: # at the paragraph's top (see _anchored_object), or placed up (unsigned)
return None
return obj
Expand All @@ -2614,7 +2622,7 @@ def _stacked_objects(objects: list[Any], runs: list[Any], column: int) -> list[A
pos = obj.find(f"{HP}pos")
if 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_offset(pos, "vertOffset") >= 1 << 31 or pos.get("horzRelTo") not in ("COLUMN", "PARA") \
or pos.get("horzAlign", "LEFT") not in ("LEFT", "RIGHT"):
return []
return placed
Expand All @@ -2627,7 +2635,7 @@ def _stack(measure: _Measure, objects: list[Any], column: int, shape: _Shape) ->
for obj in objects:
pos = obj.find(f"{HP}pos")
width, height = _object_extent(obj, measure)
offset = int(pos.get("horzOffset", 0))
offset = _pos_offset(pos, "horzOffset")
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)
Expand All @@ -2638,7 +2646,7 @@ def _stack(measure: _Measure, objects: list[Any], column: int, shape: _Shape) ->
ends = _table_ends(obj)
table = _FlowTable(rows, _page_break(obj), 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))
stacked.append(_Stacked(_pos_offset(pos, "vertOffset"), left, left + width, height, table))
return tuple(stacked)


Expand Down
23 changes: 23 additions & 0 deletions tests/test_layout_page_estimate.py
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,29 @@ def test_a_shape_placed_up_from_its_paragraph_takes_no_more_than_its_foot() -> N
assert estimate.pages == 1


def test_an_offset_written_with_a_minus_sign_is_read_as_zero() -> None:
# The same rectangle written vertOffset="-1500" (not the unsigned form): Hancom reads it as 0, draws the
# rectangle at the paragraph's top and saves 0. The fixture is that save; written back with the minus sign,
# the lines go where Hancom put them.
data = (FIXTURES / "pages_para_shape_signed_offset.hwpx").read_bytes()
out = io.BytesIO()
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 == "Contents/section0.xml":
root = etree.fromstring(payload)
(pos,) = [rect.find(f"{HP}pos") for rect in root.iter(f"{HP}rect")]
pos.set("vertOffset", "-1500")
payload = etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True)
target.writestr(info, payload)
signed = out.getvalue()

for estimate in (estimate_pages(signed), estimate_pages(_without_caches(signed))):
assert estimate.unsupported == ()
assert estimate.pages == 1
assert [[line.vertpos for line in lines] for lines in estimate.lines] == _hancom_lines(data)


# 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:
Expand Down
Loading