From 34a9a8d3e26f410c28f7ffe75240a2b58323c9ad Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:07:57 +0900 Subject: [PATCH 1/3] fix(hwp5,layout): give a negative cell height the cell's top and bottom margins A table cell may hold a negative height, kept as an unsigned 32-bit number (-633 as 4294966663). Opening such a .hwp wrote that number to hp:cellSz as is, and the page estimate and the layout lint read it as a cell four billion units tall, so a converted document came out tens of thousands of pages long. Hancom gives the cell the height of its top and bottom margins (its own when it has them, else the table's inner ones) and grows it to fit its text, both when it converts the .hwp and when it reads the HWPX. The converter now writes that height, and the estimate and the lint count a negative stored height as none. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++++ src/hwpx/hwp5/section_xml.py | 18 ++++++++++--- src/hwpx/layout/lint.py | 3 ++- src/hwpx/layout/pages.py | 12 +++++++-- .../pages_cell_height_negative.hwpx | Bin 0 -> 7890 bytes tests/fixtures/hwp5/cell_height_negative.hwp | Bin 0 -> 11264 bytes .../hwp5/cell_height_negative_own_margins.hwp | Bin 0 -> 11264 bytes tests/test_hwp5_open.py | 23 +++++++++++++++++ tests/test_layout_page_estimate.py | 24 ++++++++++++++++++ 9 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/hancom_saved/pages_cell_height_negative.hwpx create mode 100644 tests/fixtures/hwp5/cell_height_negative.hwp create mode 100644 tests/fixtures/hwp5/cell_height_negative_own_margins.hwp diff --git a/CHANGELOG.md b/CHANGELOG.md index 4717cd3f..5b46808f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,12 @@ ### 고침 +- .hwp를 열 때 높이가 음수인 표 칸(파일에는 부호 없는 32비트로 남는다, -633이면 4294966663)을 한/글처럼 그 칸의 + 위·아래 여백 합(칸 자체 여백이 있으면 그것, 없으면 표 안 여백) 높이로 바꾼다. 전에는 그 값을 그대로 써서 큰 양수 + 높이로 읽혔다. 한/글은 그 높이에서 글에 맞게 칸을 키운다. +- 쪽 수 추정과 배치 검사가 HWPX의 음수 칸 높이(부호 없는 32비트로 남은 값)를 없는 높이로 센다. 한/글은 그 칸을 위·아래 + 여백 높이로 놓고 글에 맞게 키우는데, 전에는 40억이 넘는 높이로 읽어 .hwp를 바꾼 문서의 쪽 수가 수만 쪽으로 나왔다. + - 쪽 수 추정이 문단 위로 올려 놓은 도형(문단 기준 음수 `vertOffset`, 파일에는 부호 없는 32비트로 남는다)을 그만큼 위에 놓인 것으로 센다. 전에는 그 값을 큰 양수로 읽어 문단 줄이 40억 넘게 높아지고, 한 쪽짜리 문서가 세 쪽으로 나왔다. 한/글은 그 도형을 문단 위가 있었을 자리에서 그만큼 위에 놓고, 그 띠에 걸린 앞 줄(아래끝이 띠 위끝보다 아래인 줄)을 diff --git a/src/hwpx/hwp5/section_xml.py b/src/hwpx/hwp5/section_xml.py index 25931982..24c14a80 100644 --- a/src/hwpx/hwp5/section_xml.py +++ b/src/hwpx/hwp5/section_xml.py @@ -1336,7 +1336,7 @@ def table(self, run: etree._Element, ctrl: rec.Record) -> etree._Element: for row_size in tp.row_sizes or [len(cells)]: tr = sub(table, "hp:tr") for header, paragraphs in cells[position : position + row_size]: - self.cell(tr, header, paragraphs) + self.cell(tr, header, paragraphs, tp.inner) position += row_size for data in (r for r in ctrl.children if r.tag == rec.CTRL_DATA): if data.payload.startswith(ct.NAME_SET_PREFIX): @@ -1371,9 +1371,21 @@ def caption(self, parent: etree._Element, header: rec.Record, paragraphs: list[r sub_list = sub(element, "hp:subList", list_attrs(cap.list_props)) self.paragraphs(sub_list, paragraphs) - def cell(self, tr: etree._Element, header: rec.Record, paragraphs: list[rec.Record]) -> None: + def cell( + self, + tr: etree._Element, + header: rec.Record, + paragraphs: list[rec.Record], + table_inner: tuple[int, int, int, int] = (510, 510, 141, 141), + ) -> None: cell = ct.CellHeader.decode(header.payload) flags = cell.flags + height = cell.height + if height >= 1 << 31: + # A negative height, kept unsigned: Hancom gives the cell its top and bottom margins (its own, or + # the table's inner ones), which it then grows to fit the text. + _left, _right, top, bottom = cell.margins if flags & 0x1 else table_inner + height = top + bottom tc = sub( tr, "hp:tc", @@ -1391,7 +1403,7 @@ def cell(self, tr: etree._Element, header: rec.Record, paragraphs: list[rec.Reco self.paragraphs(sub_list, paragraphs) sub(tc, "hp:cellAddr", (("colAddr", cell.col), ("rowAddr", cell.row))) sub(tc, "hp:cellSpan", (("colSpan", cell.col_span), ("rowSpan", cell.row_span))) - sub(tc, "hp:cellSz", (("width", cell.width), ("height", cell.height))) + sub(tc, "hp:cellSz", (("width", cell.width), ("height", height))) left, right, top, bottom = cell.margins sub( tc, diff --git a/src/hwpx/layout/lint.py b/src/hwpx/layout/lint.py index be29f3d5..b3adb559 100644 --- a/src/hwpx/layout/lint.py +++ b/src/hwpx/layout/lint.py @@ -559,7 +559,8 @@ def _row_min_heights(table: ET.Element, lines: "_LineHeights") -> list[int]: heights = [0] for cell in row: if _local_name(cell) == "tc" and _cell_int(cell, "cellSpan", "rowSpan", 1) == 1: - heights.append(max(_cell_int(cell, "cellSz", "height", 0), lines.cell_height(cell, table))) + stored = _cell_int(cell, "cellSz", "height", 0) # a negative one is kept unsigned: none + heights.append(max(0 if stored >= 1 << 31 else stored, lines.cell_height(cell, table))) heights_by_row.append(max(heights)) return heights_by_row diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index 3d87b2d6..ded4177d 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -928,6 +928,14 @@ def _table_alone(runs: list[Any]) -> Any: return children[0] if pos is not None and pos.get("treatAsChar") == "1" else None +def _cell_height(size: Any) -> int: + """A cell's stored height (hp:cellSz): a negative one, which the file keeps as an unsigned 32-bit number, + counts as none -- Hancom gives that cell its top and bottom margins and grows it to fit its text.""" + + height = int(size.get("height", 0)) if size is not None else 0 + return 0 if height >= 1 << 31 else height + + def _margin(margin: Any, side: str) -> int: """One side of an object's outer margins (hp:outMargin): a negative one, which the file keeps as an unsigned 32-bit number, counts as none, as Hancom lays it out (and saves it).""" @@ -1040,7 +1048,7 @@ def _cell_row(measure: _Measure, table: Any, cell: Any, width: int | None = None if _vertical(cell): # its lines go across it, the row as declared: one block, not split margins = cell_margins_of(cell, table) vertical = margins.top + margins.bottom - height = max(int(cell.find(f"{HP}cellSz").get("height", 0)), vertical) + height = max(_cell_height(cell.find(f"{HP}cellSz")), vertical) return _Row(height, 1, height - vertical, height - vertical, vertical, cell.get("header") == "1", first=height - vertical) paragraphs = cell.findall(f"{HP}subList/{HP}p") @@ -1075,7 +1083,7 @@ def _cell_row(measure: _Measure, table: Any, cell: Any, width: int | None = None if drawn: content, lines = sum(advance for _, advance in drawn[:-1]) + drawn[-1][0], len(drawn) vertical = margins.top + margins.bottom - height = max(int(size.get("height", 0)), vertical + content) + height = max(_cell_height(size), vertical + content) first = drawn[0][0] if drawn else \ before + (measure.stack_lines(paragraphs[:1], inner, caches=True, pieces=pieces) or ((0, 0),))[0][0] several = not nested and len(paragraphs) > 1 # its lines split at their own places, spacing included diff --git a/tests/fixtures/hancom_saved/pages_cell_height_negative.hwpx b/tests/fixtures/hancom_saved/pages_cell_height_negative.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..e7c882807565fe91cee696dd9c9053be75165ced GIT binary patch literal 7890 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
QWBwY{Y5oWO0OgJ) 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/hwp5/cell_height_negative_own_margins.hwp b/tests/fixtures/hwp5/cell_height_negative_own_margins.hwp new file mode 100644 index 0000000000000000000000000000000000000000..b8677c4922e8ce18302921274b60822837e76f07 GIT binary patch literal 11264 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 tV3 None: document.save_to_path(target) reopened = HwpxDocument.open(target) assert [p.text for p in reopened.paragraphs] == [p.text for p in document.paragraphs] + + +@pytest.mark.parametrize( + ("name", "margins"), + [("cell_height_negative", ("0", "141", "141")), ("cell_height_negative_own_margins", ("1", "300", "500"))], +) +def test_a_negative_cell_height_takes_the_cells_top_and_bottom_margins( + name: str, margins: tuple[str, str, str] +) -> None: + # Row 2, column 1 of a 3x2 table holds the height -633 (-1000 in the second, whose cell has margins of its + # own, top 300 and bottom 500), kept unsigned as the file has it. Hancom converts such a cell with the height + # of its top and bottom margins -- the table's inner ones (141 + 141) or its own (300 + 500) -- and grows it + # to fit its text; the other cells keep theirs. + data = (Path(__file__).parent / "fixtures" / "hwp5" / f"{name}.hwp").read_bytes() + root = HwpxDocument.open(data).sections[0].element + + cells = [ + (tc.get("hasMargin"), tc.find(f"{HP}cellSz").get("height"), tc.find(f"{HP}cellMargin")) for tc in root.iter(f"{HP}tc") + ] + has_margin, height, margin = cells[2] + assert (has_margin, margin.get("top"), margin.get("bottom")) == margins + assert int(height) == int(margins[1]) + int(margins[2]) + assert all(int(other) < 1 << 31 for _, other, _ in cells) diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index 3c4c6fbd..bbc3e7b3 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -1043,6 +1043,30 @@ def test_a_shape_placed_up_from_its_paragraph_takes_no_more_than_its_foot() -> N assert estimate.pages == 1 +@pytest.mark.parametrize("height", [str((1 << 32) - 633), str((1 << 32) - 1)]) +def test_a_negative_cell_height_kept_unsigned_counts_as_none(height: str) -> None: + # A 3x2 table whose row-2 cell was written with the height -633 kept unsigned (4294966663), saved by Hancom: + # it gives the cell its top and bottom margins (282) and grows it to fit the text, one page. Written back + # unsigned, the cell is not four billion tall: the estimate gives Hancom's page. + data = (FIXTURES / "pages_cell_height_negative.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) + cell = list(root.iter(f"{HP}tc"))[2] + assert cell.find(f"{HP}cellSz").get("height") == "282" + cell.find(f"{HP}cellSz").set("height", height) + payload = etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True) + target.writestr(info, payload) + negative = out.getvalue() + + for estimate in (estimate_pages(data), estimate_pages(negative), estimate_pages(_without_caches(negative))): + assert estimate.unsupported == () + 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, From cf0f5e80dbc06d29fcd3bc604702a639cab9400b Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:37:24 +0900 Subject: [PATCH 2/3] Keep independent review additions at distinct changelog and test locations --- CHANGELOG.md | 12 ++++---- tests/test_layout_page_estimate.py | 46 ++++++++++++++++-------------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d034ed6..33d3bd39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,12 +91,6 @@ ### 고침 -- .hwp를 열 때 높이가 음수인 표 칸(파일에는 부호 없는 32비트로 남는다, -633이면 4294966663)을 한/글처럼 그 칸의 - 위·아래 여백 합(칸 자체 여백이 있으면 그것, 없으면 표 안 여백) 높이로 바꾼다. 전에는 그 값을 그대로 써서 큰 양수 - 높이로 읽혔다. 한/글은 그 높이에서 글에 맞게 칸을 키운다. -- 쪽 수 추정과 배치 검사가 HWPX의 음수 칸 높이(부호 없는 32비트로 남은 값)를 없는 높이로 센다. 한/글은 그 칸을 위·아래 - 여백 높이로 놓고 글에 맞게 키우는데, 전에는 40억이 넘는 높이로 읽어 .hwp를 바꾼 문서의 쪽 수가 수만 쪽으로 나왔다. - - 쪽 수 추정에서 글이 없는 문단의 줄은 컨트롤이나 개체를 든 run과 마지막 run(문단 끝) 가운데 가장 큰 글자 크기로 잰다. 한/글은 그 앞의 빈 글 run은 세지 않는다. 전에는 빈 글 run의 가장 작은 크기로 재서, 구역 정의나 표를 든 큰 글자 run과 작은 빈 run이 섞인 문단(예: 문서 첫 문단)의 줄이 낮았다. @@ -111,6 +105,12 @@ 글을 줄 끝에서 끝낸다. 줄 끝에 닿은 탭은 다음 줄로 간다. 오른쪽 끝 자동 탭은 줄 끝의 오른쪽 위치로 함께 센다. 그다음은 기본 탭 위치다. 맞출 수 없어 지나친 위치가 있는데 기본 탭 위치에서 글이 줄에 들어가지 않으면 탭은 폭이 없다. 내어쓰기 자동 탭은 둘째 줄이 시작하는 곳에 왼쪽 위치를 둔다. + +- .hwp를 열 때 높이가 음수인 표 칸(파일에는 부호 없는 32비트로 남는다, -633이면 4294966663)을 한/글처럼 그 칸의 + 위·아래 여백 합(칸 자체 여백이 있으면 그것, 없으면 표 안 여백) 높이로 바꾼다. 전에는 그 값을 그대로 써서 큰 양수 + 높이로 읽혔다. 한/글은 그 높이에서 글에 맞게 칸을 키운다. +- 쪽 수 추정과 배치 검사가 HWPX의 음수 칸 높이(부호 없는 32비트로 남은 값)를 없는 높이로 센다. 한/글은 그 칸을 위·아래 + 여백 높이로 놓고 글에 맞게 키우는데, 전에는 40억이 넘는 높이로 읽어 .hwp를 바꾼 문서의 쪽 수가 수만 쪽으로 나왔다. - 쪽 수 추정에서 강제 줄 바꿈 다음 줄은 첫 줄이 아니다. 들여쓰기를 빼지 않고 다른 줄의 폭으로 잰다. 전에는 첫 줄 폭으로 재서, 들여쓴 문단에서 줄이 하나 더 생길 수 있었다. - FormFit과 쪽 수 추정이 텍스트 내보내기가 쓰지 않는 번호 형식(`KOREAN_DIGIT`, `HANGUL` 등)의 문단 번호를 만나도 diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index 4d6fb535..85eb7a70 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -1100,28 +1100,6 @@ def test_a_shape_placed_up_from_its_paragraph_takes_no_more_than_its_foot() -> N assert estimate.pages == 1 -@pytest.mark.parametrize("height", [str((1 << 32) - 633), str((1 << 32) - 1)]) -def test_a_negative_cell_height_kept_unsigned_counts_as_none(height: str) -> None: - # A 3x2 table whose row-2 cell was written with the height -633 kept unsigned (4294966663), saved by Hancom: - # it gives the cell its top and bottom margins (282) and grows it to fit the text, one page. Written back - # unsigned, the cell is not four billion tall: the estimate gives Hancom's page. - data = (FIXTURES / "pages_cell_height_negative.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) - cell = list(root.iter(f"{HP}tc"))[2] - assert cell.find(f"{HP}cellSz").get("height") == "282" - cell.find(f"{HP}cellSz").set("height", height) - payload = etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True) - target.writestr(info, payload) - negative = out.getvalue() - - for estimate in (estimate_pages(data), estimate_pages(negative), estimate_pages(_without_caches(negative))): - assert estimate.unsupported == () - assert estimate.pages == 1 def test_an_offset_written_with_a_minus_sign_is_read_as_zero() -> None: @@ -1493,6 +1471,30 @@ def test_the_evaluation_plan_form_lays_out_like_hancom_without_caches() -> None: @pytest.mark.parametrize("name", ["pages_table_with_cell_spacing", "pages_table_without_cell_spacing"]) +@pytest.mark.parametrize("height", [str((1 << 32) - 633), str((1 << 32) - 1)]) +def test_a_negative_cell_height_kept_unsigned_counts_as_none(height: str) -> None: + # A 3x2 table whose row-2 cell was written with the height -633 kept unsigned (4294966663), saved by Hancom: + # it gives the cell its top and bottom margins (282) and grows it to fit the text, one page. Written back + # unsigned, the cell is not four billion tall: the estimate gives Hancom's page. + data = (FIXTURES / "pages_cell_height_negative.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) + cell = list(root.iter(f"{HP}tc"))[2] + assert cell.find(f"{HP}cellSz").get("height") == "282" + cell.find(f"{HP}cellSz").set("height", height) + payload = etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True) + target.writestr(info, payload) + negative = out.getvalue() + + for estimate in (estimate_pages(data), estimate_pages(negative), estimate_pages(_without_caches(negative))): + assert estimate.unsupported == () + assert estimate.pages == 1 + + def test_a_table_with_cell_spacing_lays_its_cells_out_at_their_own_widths(name: str) -> None: # A table 46196 wide: one cell over its three columns, then cells of 15000, 21000 and 9800 with a cell # spacing of 198 (10196 without it), the last holding ten "가" (9720), every cell declared 282 tall. With From 93cf1d907cb87ac70e91f08b5dcfceb3b14796bd Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:38:11 +0900 Subject: [PATCH 3/3] Keep parametrized layout tests with their own decorators --- tests/test_layout_page_estimate.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index 85eb7a70..63fa42b4 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -1100,8 +1100,6 @@ 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, @@ -1470,7 +1468,6 @@ def test_the_evaluation_plan_form_lays_out_like_hancom_without_caches() -> None: _assert_like_hancom(estimate_pages(_without_caches(data)), data, 15) -@pytest.mark.parametrize("name", ["pages_table_with_cell_spacing", "pages_table_without_cell_spacing"]) @pytest.mark.parametrize("height", [str((1 << 32) - 633), str((1 << 32) - 1)]) def test_a_negative_cell_height_kept_unsigned_counts_as_none(height: str) -> None: # A 3x2 table whose row-2 cell was written with the height -633 kept unsigned (4294966663), saved by Hancom: @@ -1495,6 +1492,7 @@ def test_a_negative_cell_height_kept_unsigned_counts_as_none(height: str) -> Non assert estimate.pages == 1 +@pytest.mark.parametrize("name", ["pages_table_with_cell_spacing", "pages_table_without_cell_spacing"]) def test_a_table_with_cell_spacing_lays_its_cells_out_at_their_own_widths(name: str) -> None: # A table 46196 wide: one cell over its three columns, then cells of 15000, 21000 and 9800 with a cell # spacing of 198 (10196 without it), the last holding ten "가" (9720), every cell declared 282 tall. With