diff --git a/CHANGELOG.md b/CHANGELOG.md index 45347ef9..2d4a92cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,12 @@ - 쪽 수 추정에서 글이 없는 문단의 줄은 컨트롤이나 개체를 든 run과 마지막 run(문단 끝) 가운데 가장 큰 글자 크기로 잰다. 한/글은 그 앞의 빈 글 run은 세지 않는다. 전에는 빈 글 run의 가장 작은 크기로 재서, 구역 정의나 표를 든 큰 글자 run과 작은 빈 run이 섞인 문단(예: 문서 첫 문단)의 줄이 낮았다. +- 쪽 수 추정과 배치 검사(`hwpx.layout`)가 음수 쪽 여백을 음수로 읽는다. 머리말·꼬리말 여백이 음수인 문서(.hwp를 HWPX로 + 바꾼 문서에 있다)는 그 값을 부호 없는 32비트(-1400이면 4294965896)로 남기는데, 전에는 이를 큰 양수로 읽어 본문 높이가 + 음수가 되고 쪽 수 추정이 끝나지 않았다. 한/글은 그 값을 음수로 읽어 본문을 그만큼 늘린다. 반면 마이너스 기호로 쓴 + 여백(`header="-1400"`)은 한/글이 읽지 않고 새 쪽의 여백(위 5668, 아래 4252, 왼쪽·오른쪽 8504, 머리말·꼬리말 4252, + 제본 0)으로 놓은 뒤 그 값으로 저장하므로, 추정도 그 값을 쓴다. 여백 때문에 본문 높이나 글 너비가 0 이하이면 추정은 + 끝없이 돌지 않고 지원 밖(`page margins leaving no room for the body`)을 돌려준다. - FormFit과 쪽 수 추정이 탭을 한/글처럼 놓는다. - 탭은 단(셀이면 셀 글 영역)의 왼쪽 끝에서 4000 HWPUNIT마다 있는 기본 탭 위치 가운데 지금 위치 다음 것까지 간다. 줄은 문단 왼쪽 여백에서, 첫 줄은 들여쓰기만큼 더 들어가 시작한다(내어쓰기는 둘째 줄부터). diff --git a/src/hwpx/layout/lint.py b/src/hwpx/layout/lint.py index be29f3d5..54679ff0 100644 --- a/src/hwpx/layout/lint.py +++ b/src/hwpx/layout/lint.py @@ -45,7 +45,7 @@ from .report import LayoutFinding, LayoutLintReport from ..opc.security import guard_zip_file, read_member from ..oxml.header import parse_char_property, parse_paragraph_property -from ..oxml.section_format import _drawn_page_size +from ..oxml.section_format import _drawn_page_size, _page_margin from ..oxml.table_sizes import cell_margins_of if TYPE_CHECKING: @@ -526,7 +526,7 @@ def _page_heights(root: ET.Element) -> tuple[int, int] | None: int(page.get("width", "")), int(page.get("height", "")), page.get("landscape") ) top, bottom, header, footer = ( - int(margin.get(key, "0")) for key in ("top", "bottom", "header", "footer") + _page_margin(margin.get(key, "0"), key) for key in ("top", "bottom", "header", "footer") ) except ValueError: return None diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index e31c6cf0..29b8f1f8 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -191,7 +191,7 @@ from ..oxml.paragraph_heading import paragraph_heading from ..oxml.section import _remove_short_paragraph_layout_cache from ..oxml.header_part import HwpxOxmlHeader -from ..oxml.section_format import COLUMN_SHARES, _drawn_page_size +from ..oxml.section_format import COLUMN_SHARES, _drawn_page_size, _page_margin from ..oxml.table_sizes import cell_margins_of, grid_widths_of if TYPE_CHECKING: @@ -1404,12 +1404,17 @@ def _page(section: Any) -> _Page: if margin is None: raise _Unsupported("no page settings") width, height = _drawn_page_size(int(page.get("width", 0)), int(page.get("height", 0)), page.get("landscape")) - body = height - sum(int(margin.get(key, 0)) for key in ("top", "bottom", "header", "footer")) - text_width = width - sum(int(margin.get(key, 0)) for key in ("left", "right", "gutter")) + # A negative margin is kept as an unsigned 32-bit number: Hancom reads it signed, the body growing by it. + sides = {key: _page_margin(margin.get(key, 0), key) for key in ("top", "bottom", "header", "footer", "left", + "right", "gutter")} + body = height - sides["top"] - sides["bottom"] - sides["header"] - sides["footer"] + text_width = width - sides["left"] - sides["right"] - sides["gutter"] + if body <= 0 or text_width <= 0: + raise _Unsupported("page margins leaving no room for the body") columns, column_width, widths, gap = _columns(section, text_width) visibility = next(section.iter(f"{HP}visibility"), None) - return _Page(body, column_width, columns, int(margin.get("top", 0)) + int(margin.get("header", 0)), - int(margin.get("left", 0)) + int(margin.get("gutter", 0)), text_width, width, bool(widths), + return _Page(body, column_width, columns, sides["top"] + sides["header"], + sides["left"] + sides["gutter"], text_width, width, bool(widths), widths, height, visibility is not None and visibility.get("hideFirstEmptyLine") in ("1", "true"), gap) diff --git a/src/hwpx/oxml/section_format.py b/src/hwpx/oxml/section_format.py index 2391df7b..ed1c2304 100644 --- a/src/hwpx/oxml/section_format.py +++ b/src/hwpx/oxml/section_format.py @@ -64,6 +64,22 @@ def _drawn_page_size(width: int, height: int, landscape: str | None) -> tuple[in return (width, height) if landscape == _PAGE_PORTRAIT else (height, width) +#: What Hancom reads a page margin written with a minus sign as (it saves these back): the new-page margins. +_PAGE_MARGIN_FOR_MINUS = {"top": 5668, "bottom": 4252, "left": 8504, "right": 8504, "header": 4252, "footer": 4252, + "gutter": 0} + + +def _page_margin(value: str | int, side: str) -> int: + """The *side* page margin (``hp:margin``) as Hancom reads it. A negative one is kept as an unsigned 32-bit + number (a header margin of -1400 as 4294965896, as a converted .hwp has it) and counts as negative; one + written with a minus sign is not a margin Hancom reads, and it takes the new-page margin for that side.""" + + if isinstance(value, str) and value.strip().startswith("-"): + return _PAGE_MARGIN_FOR_MINUS[side] + margin = int(value) + return margin - (1 << 32) if margin >= 1 << 31 else margin + + def _hancom_page_orientation( page_pr: ET.Element, orientation: str, width: int | None, height: int | None ) -> tuple[str, int | None, int | None]: diff --git a/tests/fixtures/hancom_saved/pages_margin_minus_sign_header.hwpx b/tests/fixtures/hancom_saved/pages_margin_minus_sign_header.hwpx new file mode 100644 index 00000000..7a4463b2 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_margin_minus_sign_header.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_margin_negative_both_one_page.hwpx b/tests/fixtures/hancom_saved/pages_margin_negative_both_one_page.hwpx new file mode 100644 index 00000000..21197ebc Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_margin_negative_both_one_page.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_margin_negative_both_two_pages.hwpx b/tests/fixtures/hancom_saved/pages_margin_negative_both_two_pages.hwpx new file mode 100644 index 00000000..5d79c5c7 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_margin_negative_both_two_pages.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_margin_negative_footer_one_page.hwpx b/tests/fixtures/hancom_saved/pages_margin_negative_footer_one_page.hwpx new file mode 100644 index 00000000..8ad36dab Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_margin_negative_footer_one_page.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_margin_negative_footer_two_pages.hwpx b/tests/fixtures/hancom_saved/pages_margin_negative_footer_two_pages.hwpx new file mode 100644 index 00000000..d024d978 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_margin_negative_footer_two_pages.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_margin_negative_header_one_page.hwpx b/tests/fixtures/hancom_saved/pages_margin_negative_header_one_page.hwpx new file mode 100644 index 00000000..6811f3f8 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_margin_negative_header_one_page.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_margin_negative_header_two_pages.hwpx b/tests/fixtures/hancom_saved/pages_margin_negative_header_two_pages.hwpx new file mode 100644 index 00000000..047aa4eb Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_margin_negative_header_two_pages.hwpx differ diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index 7a5707d2..9e75fb7e 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -1100,6 +1100,72 @@ def test_a_shape_placed_up_from_its_paragraph_takes_no_more_than_its_foot() -> N assert estimate.pages == 1 +@pytest.mark.parametrize( + ("name", "pages"), + [ + ("pages_margin_negative_header_one_page", 1), + ("pages_margin_negative_header_two_pages", 2), + ("pages_margin_negative_footer_one_page", 1), + ("pages_margin_negative_footer_two_pages", 2), + ("pages_margin_negative_both_one_page", 1), + ("pages_margin_negative_both_two_pages", 2), + ], +) +def test_a_negative_page_margin_kept_unsigned_counts_as_negative(name: str, pages: int) -> None: + # Header margin -1400, footer margin -3600 and both (-4000 and -3600), kept as unsigned 32-bit numbers + # (4294965896 ...) as a converted .hwp has them, saved by Hancom: the body grows by them, so the one-page + # files hold just as many one-line paragraphs as fit it and the two-page ones one more. Read as unsigned, + # the body was negative and the estimate never ended. + data = (FIXTURES / f"{name}.hwpx").read_bytes() + + for estimate in (estimate_pages(data), estimate_pages(_without_caches(data))): + assert estimate.unsupported == () + assert estimate.pages == pages + + +@pytest.mark.parametrize(("side", "written"), [("header", "-1400"), ("header", "-4000"), ("top", "-1000")]) +def test_a_page_margin_written_with_a_minus_sign_takes_the_new_page_margin(side: str, written: str) -> None: + # Header margin written "-1400" (not the unsigned form), 46 one-line paragraphs: Hancom reads the minus sign as + # no margin it knows, lays the page with the new-page header margin 4252 and saves that -- two pages, where + # -1400 would have held them on one. The fixture is that save; written back with the minus sign (on the header + # or, with the new-page top 5668 it saves, the top), the estimate gives Hancom's two pages. + data = (FIXTURES / "pages_margin_minus_sign_header.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) + (margin,) = [m for m in root.iter(f"{HP}margin") if m.getparent().tag == f"{HP}pagePr"] + assert margin.get(side) == {"header": "4252", "top": "5668"}[side] + margin.set(side, written) + payload = etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True) + target.writestr(info, payload) + minus = out.getvalue() + + for estimate in (estimate_pages(data), estimate_pages(minus), estimate_pages(_without_caches(minus))): + assert estimate.unsupported == () + assert estimate.pages == 2 + + +def test_page_margins_leaving_no_body_are_unsupported() -> None: + data = (FIXTURES / "pages_margin_negative_header_one_page.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) + (margin,) = [m for m in root.iter(f"{HP}margin") if m.getparent().tag == f"{HP}pagePr"] + margin.set("top", "90000") + payload = etree.tostring(root, xml_declaration=True, encoding="UTF-8", standalone=True) + target.writestr(info, payload) + + estimate = estimate_pages(out.getvalue()) + assert estimate.pages is None + assert any("no room for the body" in reason for reason in estimate.unsupported) + + 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,