From 39d28c81a2ebb3b38569a7035d9c36492933955e Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:30:47 +0900 Subject: [PATCH] fix(layout): read an object offset written with a minus sign as 0, as Hancom does Hancom keeps an offset up or left as an unsigned 32-bit number. One written with a minus sign instead (vertOffset="-1500") it reads as 0: it draws the object at its paragraph's (or the paper's) place and saves 0. The page estimate read such a value as that far up, so the lines after the object went elsewhere than Hancom put them. It now reads every vertOffset and horzOffset through one helper that does as Hancom does. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++ src/hwpx/layout/pages.py | 48 +++++++++++++++++------------- tests/test_layout_page_estimate.py | 23 ++++++++++++++ 3 files changed, 54 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a4a98a..3dfda659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,9 @@ 한/글은 그 도형을 문단 위가 있었을 자리에서 그만큼 위에 놓고, 그 띠에 걸린 앞 줄(아래끝이 띠 위끝보다 아래인 줄)을 띠 아래끝으로 내린 뒤 그다음 줄을 이어서 놓는다. 도형의 문단 줄은 그 뒤에 온다. 추정도 이제 그렇게 놓는다(옮긴 줄이 쪽 끝을 넘거나 그 사이에 표·개체·각주가 있으면 지원 밖). +- 쪽 수 추정이 빼기 부호로 쓴 개체 오프셋(`vertOffset="-1500"`처럼, 한/글이 쓰는 부호 없는 32비트 꼴이 아닌 것)을 한/글처럼 + 0으로 읽는다. 한/글은 그런 값을 0으로 읽어 개체를 문단(또는 종이) 기준 자리에 그대로 그리고, 저장할 때 0으로 고쳐 쓴다. + 전에는 그만큼 위(왼쪽)로 놓인 것으로 읽어 뒤 줄 자리가 한/글과 달랐다. - 쪽 수 추정이 글자처럼 취급한 그림을 그 효과까지 합친 크기로 놓는다. 빛남은 네 변에 반경만큼, 바깥 그림자는 그림(빛남 포함)을 방향으로 거리만큼 옮기고 반경만큼 넓힌 자리까지 차지해, 그 줄이 그만큼 높아진다. 전에는 효과를 세지 않아 줄과 쪽이 한/글보다 짧게 나왔다. 안쪽 그림자는 자리를 차지하지 않는다. 그림자의 흐림은 한/글처럼 화면 diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index b858da08..8262becf 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -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: @@ -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) @@ -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")) @@ -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 @@ -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 @@ -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 @@ -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: @@ -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)) @@ -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 @@ -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": @@ -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) @@ -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) @@ -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) @@ -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) @@ -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 @@ -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 @@ -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) @@ -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) diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index ba3bcacc..8dc00675 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -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: