diff --git a/CHANGELOG.md b/CHANGELOG.md index 45347ef9..33d3bd39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,12 @@ 글을 줄 끝에서 끝낸다. 줄 끝에 닿은 탭은 다음 줄로 간다. 오른쪽 끝 자동 탭은 줄 끝의 오른쪽 위치로 함께 센다. 그다음은 기본 탭 위치다. 맞출 수 없어 지나친 위치가 있는데 기본 탭 위치에서 글이 줄에 들어가지 않으면 탭은 폭이 없다. 내어쓰기 자동 탭은 둘째 줄이 시작하는 곳에 왼쪽 위치를 둔다. + +- .hwp를 열 때 높이가 음수인 표 칸(파일에는 부호 없는 32비트로 남는다, -633이면 4294966663)을 한/글처럼 그 칸의 + 위·아래 여백 합(칸 자체 여백이 있으면 그것, 없으면 표 안 여백) 높이로 바꾼다. 전에는 그 값을 그대로 써서 큰 양수 + 높이로 읽혔다. 한/글은 그 높이에서 글에 맞게 칸을 키운다. +- 쪽 수 추정과 배치 검사가 HWPX의 음수 칸 높이(부호 없는 32비트로 남은 값)를 없는 높이로 센다. 한/글은 그 칸을 위·아래 + 여백 높이로 놓고 글에 맞게 키우는데, 전에는 40억이 넘는 높이로 읽어 .hwp를 바꾼 문서의 쪽 수가 수만 쪽으로 나왔다. - 쪽 수 추정에서 강제 줄 바꿈 다음 줄은 첫 줄이 아니다. 들여쓰기를 빼지 않고 다른 줄의 폭으로 잰다. 전에는 첫 줄 폭으로 재서, 들여쓴 문단에서 줄이 하나 더 생길 수 있었다. - FormFit과 쪽 수 추정이 텍스트 내보내기가 쓰지 않는 번호 형식(`KOREAN_DIGIT`, `HANGUL` 등)의 문단 번호를 만나도 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 e31c6cf0..7a59d5aa 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -934,6 +934,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).""" @@ -1046,7 +1054,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") @@ -1081,7 +1089,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 00000000..e7c88280 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_cell_height_negative.hwpx differ diff --git a/tests/fixtures/hwp5/cell_height_negative.hwp b/tests/fixtures/hwp5/cell_height_negative.hwp new file mode 100644 index 00000000..bef8259d Binary files /dev/null and b/tests/fixtures/hwp5/cell_height_negative.hwp differ 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 00000000..b8677c49 Binary files /dev/null and b/tests/fixtures/hwp5/cell_height_negative_own_margins.hwp differ diff --git a/tests/test_hwp5_open.py b/tests/test_hwp5_open.py index 8d9810c3..f16cc8cc 100644 --- a/tests/test_hwp5_open.py +++ b/tests/test_hwp5_open.py @@ -1214,3 +1214,26 @@ def test_the_converted_package_saves_as_hwpx(tmp_path: Path) -> 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 7a5707d2..63fa42b4 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -1468,6 +1468,30 @@ 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("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 + + @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