From 3764c9bbbf3c3bdb1319a4c37d93c68930c62ce7 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:58:28 +0900 Subject: [PATCH 1/4] fix(layout): read a negative page margin kept unsigned as negative A negative header or footer margin is kept as an unsigned 32-bit number (-1400 as 4294965896), as a .hwp converted to HWPX has it. The page estimate and the layout lint read it as a large positive one, so the body height went negative and the estimate never ended. Hancom reads it as negative and makes the body that much taller; both now do the same. Margins that leave no body height or text width make the estimate report the page as unsupported instead of looping. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ src/hwpx/layout/lint.py | 4 +- src/hwpx/layout/pages.py | 15 ++++--- src/hwpx/oxml/section_format.py | 8 ++++ .../pages_margin_negative_both_one_page.hwpx | Bin 0 -> 8089 bytes .../pages_margin_negative_both_two_pages.hwpx | Bin 0 -> 8106 bytes ...pages_margin_negative_footer_one_page.hwpx | Bin 0 -> 8043 bytes ...ages_margin_negative_footer_two_pages.hwpx | Bin 0 -> 8062 bytes ...pages_margin_negative_header_one_page.hwpx | Bin 0 -> 8018 bytes ...ages_margin_negative_header_two_pages.hwpx | Bin 0 -> 8038 bytes tests/test_layout_page_estimate.py | 41 ++++++++++++++++++ 11 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/hancom_saved/pages_margin_negative_both_one_page.hwpx create mode 100644 tests/fixtures/hancom_saved/pages_margin_negative_both_two_pages.hwpx create mode 100644 tests/fixtures/hancom_saved/pages_margin_negative_footer_one_page.hwpx create mode 100644 tests/fixtures/hancom_saved/pages_margin_negative_footer_two_pages.hwpx create mode 100644 tests/fixtures/hancom_saved/pages_margin_negative_header_one_page.hwpx create mode 100644 tests/fixtures/hancom_saved/pages_margin_negative_header_two_pages.hwpx diff --git a/CHANGELOG.md b/CHANGELOG.md index 4717cd3f..be10e371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,10 @@ ### 고침 +- 쪽 수 추정과 배치 검사(`hwpx.layout`)가 음수 쪽 여백을 음수로 읽는다. 머리말·꼬리말 여백이 음수인 문서(.hwp를 HWPX로 + 바꾼 문서에 있다)는 그 값을 부호 없는 32비트(-1400이면 4294965896)로 남기는데, 전에는 이를 큰 양수로 읽어 본문 높이가 + 음수가 되고 쪽 수 추정이 끝나지 않았다. 한/글은 그 값을 음수로 읽어 본문을 그만큼 늘린다. 여백 때문에 본문 높이나 글 + 너비가 0 이하이면 추정은 끝없이 돌지 않고 지원 밖(`page margins leaving no room for the body`)을 돌려준다. - 쪽 수 추정이 문단 위로 올려 놓은 도형(문단 기준 음수 `vertOffset`, 파일에는 부호 없는 32비트로 남는다)을 그만큼 위에 놓인 것으로 센다. 전에는 그 값을 큰 양수로 읽어 문단 줄이 40억 넘게 높아지고, 한 쪽짜리 문서가 세 쪽으로 나왔다. 한/글은 그 도형을 문단 위가 있었을 자리에서 그만큼 위에 놓고, 그 띠에 걸린 앞 줄(아래끝이 띠 위끝보다 아래인 줄)을 diff --git a/src/hwpx/layout/lint.py b/src/hwpx/layout/lint.py index be29f3d5..a18ced6c 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")) 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 3d87b2d6..2cabb816 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: @@ -1398,12 +1398,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)) 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..0068d69c 100644 --- a/src/hwpx/oxml/section_format.py +++ b/src/hwpx/oxml/section_format.py @@ -64,6 +64,14 @@ def _drawn_page_size(width: int, height: int, landscape: str | None) -> tuple[in return (width, height) if landscape == _PAGE_PORTRAIT else (height, width) +def _page_margin(value: str | int) -> int: + """A 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.""" + + 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_negative_both_one_page.hwpx b/tests/fixtures/hancom_saved/pages_margin_negative_both_one_page.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..21197ebc92f5b28ff5692ddf3e8b104f6aa99afa GIT binary patch literal 8089 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5d79c5c71a48017d38da6dbf5de71b610ce67677 GIT binary patch literal 8106 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 H{IT?Z23|B8 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8ad36dab01c870599048e162ed02abc3745ed4cc GIT binary patch literal 8043 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 C;~+Nx literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d024d978226a3287744ca0ec3452a62c31490dfd GIT binary patch literal 8062 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6811f3f855ac0d6427c5a7f135fa6cb8dadcf487 GIT binary patch literal 8018 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 W3M$+RAs{@0pZ;*0)D0&@K=?m+-1w^i literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..047aa4eba74a28d146e0867b67fca8316cd2c33c GIT binary patch literal 8038 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 literal 0 HcmV?d00001 diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index 3c4c6fbd..62cfaba5 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -1043,6 +1043,47 @@ 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 + + +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, From 44891e3dea7a12f10900585d06359b3d61fff0d5 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:46:54 +0900 Subject: [PATCH 2/4] fix(layout): give a page margin written with a minus sign the new-page margin A page margin written with a minus sign (header="-1400", not the unsigned form) is not one Hancom reads: it lays the page with the new-page margin for that side (top 5668, bottom 4252, left and right 8504, header and footer 4252, gutter 0) and saves that. The page estimate and the layout lint read it as negative; they now take the new-page margin too. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++-- src/hwpx/layout/lint.py | 2 +- src/hwpx/layout/pages.py | 4 +-- src/hwpx/oxml/section_format.py | 14 +++++++--- .../pages_margin_minus_sign_header.hwpx | Bin 0 -> 8010 bytes tests/test_layout_page_estimate.py | 25 ++++++++++++++++++ 6 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/hancom_saved/pages_margin_minus_sign_header.hwpx diff --git a/CHANGELOG.md b/CHANGELOG.md index be10e371..2efa13c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,8 +75,10 @@ - 쪽 수 추정과 배치 검사(`hwpx.layout`)가 음수 쪽 여백을 음수로 읽는다. 머리말·꼬리말 여백이 음수인 문서(.hwp를 HWPX로 바꾼 문서에 있다)는 그 값을 부호 없는 32비트(-1400이면 4294965896)로 남기는데, 전에는 이를 큰 양수로 읽어 본문 높이가 - 음수가 되고 쪽 수 추정이 끝나지 않았다. 한/글은 그 값을 음수로 읽어 본문을 그만큼 늘린다. 여백 때문에 본문 높이나 글 - 너비가 0 이하이면 추정은 끝없이 돌지 않고 지원 밖(`page margins leaving no room for the body`)을 돌려준다. + 음수가 되고 쪽 수 추정이 끝나지 않았다. 한/글은 그 값을 음수로 읽어 본문을 그만큼 늘린다. 반면 마이너스 기호로 쓴 + 여백(`header="-1400"`)은 한/글이 읽지 않고 새 쪽의 여백(위 5668, 아래 4252, 왼쪽·오른쪽 8504, 머리말·꼬리말 4252, + 제본 0)으로 놓은 뒤 그 값으로 저장하므로, 추정도 그 값을 쓴다. 여백 때문에 본문 높이나 글 너비가 0 이하이면 추정은 + 끝없이 돌지 않고 지원 밖(`page margins leaving no room for the body`)을 돌려준다. - 쪽 수 추정이 문단 위로 올려 놓은 도형(문단 기준 음수 `vertOffset`, 파일에는 부호 없는 32비트로 남는다)을 그만큼 위에 놓인 것으로 센다. 전에는 그 값을 큰 양수로 읽어 문단 줄이 40억 넘게 높아지고, 한 쪽짜리 문서가 세 쪽으로 나왔다. 한/글은 그 도형을 문단 위가 있었을 자리에서 그만큼 위에 놓고, 그 띠에 걸린 앞 줄(아래끝이 띠 위끝보다 아래인 줄)을 diff --git a/src/hwpx/layout/lint.py b/src/hwpx/layout/lint.py index a18ced6c..54679ff0 100644 --- a/src/hwpx/layout/lint.py +++ b/src/hwpx/layout/lint.py @@ -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 = ( - _page_margin(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 2cabb816..00256d3c 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -1399,8 +1399,8 @@ def _page(section: Any) -> _Page: raise _Unsupported("no page settings") width, height = _drawn_page_size(int(page.get("width", 0)), int(page.get("height", 0)), page.get("landscape")) # 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)) for key in ("top", "bottom", "header", "footer", "left", "right", - "gutter")} + 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: diff --git a/src/hwpx/oxml/section_format.py b/src/hwpx/oxml/section_format.py index 0068d69c..ed1c2304 100644 --- a/src/hwpx/oxml/section_format.py +++ b/src/hwpx/oxml/section_format.py @@ -64,10 +64,18 @@ def _drawn_page_size(width: int, height: int, landscape: str | None) -> tuple[in return (width, height) if landscape == _PAGE_PORTRAIT else (height, width) -def _page_margin(value: str | int) -> int: - """A 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.""" +#: 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 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 0000000000000000000000000000000000000000..7a4463b263cf33d71e70c9badf42c45739d0ec22 GIT binary patch literal 8010 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 UtPlWjAMp!7*rXQ3L;&Fb04d7~5C8xG literal 0 HcmV?d00001 diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index 62cfaba5..69b7f06c 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -1066,6 +1066,31 @@ def test_a_negative_page_margin_kept_unsigned_counts_as_negative(name: str, page 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() From 56eab9d81f77d48245524d87ce8e636538f73183 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:32:14 +0900 Subject: [PATCH 3/4] Restore full changelog while retaining both merge entries --- CHANGELOG.md | 3351 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 3351 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc9ab021..9d8b1279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,12 @@ ### 고침 +- 쪽 수 추정과 배치 검사(`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마다 있는 기본 탭 위치 가운데 지금 위치 다음 것까지 간다. 줄은 문단 왼쪽 여백에서, 첫 줄은 들여쓰기만큼 더 들어가 시작한다(내어쓰기는 둘째 줄부터). @@ -118,3 +124,3348 @@ - 표 뒤의 조판 부호는 전처럼 표 아래 빈 줄에 선다. - 어울림 개체 옆 좁은 띠에 든 이런 개체는 띠보다 넓어도 띠 안 그 자리에 놓인다. 전에는 개체 아래 전체 폭 줄로 내렸다. +- 쪽 수 추정이 문단 위로 올려 놓은 도형(문단 기준 음수 `vertOffset`, 파일에는 부호 없는 32비트로 남는다)을 그만큼 위에 + 놓인 것으로 센다. 전에는 그 값을 큰 양수로 읽어 문단 줄이 40억 넘게 높아지고, 한 쪽짜리 문서가 세 쪽으로 나왔다. + 한/글은 그 도형을 문단 위가 있었을 자리에서 그만큼 위에 놓고, 그 띠에 걸린 앞 줄(아래끝이 띠 위끝보다 아래인 줄)을 + 띠 아래끝으로 내린 뒤 그다음 줄을 이어서 놓는다. 도형의 문단 줄은 그 뒤에 온다. 추정도 이제 그렇게 놓는다(옮긴 줄이 + 쪽 끝을 넘거나 그 사이에 표·개체·각주가 있으면 지원 밖). +- 쪽 수 추정이 빼기 부호로 쓴 개체 오프셋(`vertOffset="-1500"`처럼, 한/글이 쓰는 부호 없는 32비트 꼴이 아닌 것)을 한/글처럼 + 0으로 읽는다. 한/글은 그런 값을 0으로 읽어 개체를 문단(또는 종이) 기준 자리에 그대로 그리고, 저장할 때 0으로 고쳐 쓴다. + 전에는 그만큼 위(왼쪽)로 놓인 것으로 읽어 뒤 줄 자리가 한/글과 달랐다. +- 쪽 수 추정이 글자처럼 취급한 그림을 그 효과까지 합친 크기로 놓는다. 빛남은 네 변에 반경만큼, 바깥 그림자는 + 그림(빛남 포함)을 방향으로 거리만큼 옮기고 반경만큼 넓힌 자리까지 차지해, 그 줄이 그만큼 높아진다. 전에는 효과를 + 세지 않아 줄과 쪽이 한/글보다 짧게 나왔다. 안쪽 그림자는 자리를 차지하지 않는다. 그림자의 흐림은 한/글처럼 화면 + 픽셀(96dpi, 75 HWPUNIT) 단위로 올려 세고(반경 400은 450), 뻗는 길이는 HWPUNIT 아래를 버린다. 반사는 대략 그 몫만큼의 + 그림(빛남 포함) 높이와 거리만큼 아래로 자리를 차지한다(한/글과 한 픽셀 안팎 차이). 부드러운 가장자리는 자리를 + 차지하지 않는다. +- 쪽 수 추정이 문단 맨 위에 둔 위아래 배치 표 아래로 문단 첫 줄이 그 쪽에 들어가지 않을 때 한/글처럼 놓는다. 그 + 쪽에서 끝나는 표는 첫 줄과 함께 다음 쪽 맨 위로 간다. 행 단위로 나누는 표는 그 쪽에 다 들어갈 때, 셀 단위로 + 나누는 표는 마지막 행의 남는 높이를 잘라 그 쪽에서 끝날 때도 그렇다. 나누지 않는 표가 그 자리에 들어가지 않으면 + 표만 다음 쪽 맨 위로 가고, 문단 줄과 그 뒤 문단은 제자리에 남는다. 전에는 앞의 두 경우 표를 그 자리에 두어 + 쪽 수가 한/글과 달랐고, 나누지 않는 표는 첫 줄과 함께 넘겨 쪽이 하나 더 나왔다. +- 쪽 수 추정이 문단 아래로 띄워 둔 위아래 배치 개체가 쪽 끝 가까이 있을 때 한/글처럼 놓는다. 개체 아래로 내려간 + 다음 줄이 쪽 끝을 넘으면 다음 쪽 맨 위에서 시작한다(개체 아래 간격은 그 쪽으로 가져가지 않는다). 개체의 앵커 줄이 + 다음 쪽으로 넘어가면 개체도 그 줄과 함께 넘어가, 그 줄 위에서 오프셋만큼 아래에 선다. 전에는 앞의 경우를 지원하지 + 않았고, 뒤의 경우 개체를 다음 쪽 맨 위에, 앵커 줄을 그 아래에 놓았다. +- 쪽 수 추정이 빈 문단에 홀로 든 위아래 배치 개체(그림·도형) 뒤의 다음 문단을 한/글처럼 놓는다. 개체가 + 글과 함께 움직이고 겹침을 허용하며 빈 줄의 글자 높이 안에서 시작할 때만 빈 줄이 개체 아래로 내려간다. + 그 밖에는 빈 줄이 문단 위에 그대로 있고, 다음 문단은 개체의 아래끝과 빈 줄에 두 문단 간격(앞 문단 아래·다음 + 문단 위)을 더한 자리 중 더 낮은 곳에서 시작한다. 전에는 개체 아래에 두 간격을 더했고, 빈 줄이 내려가는 경우에는 + 빈 줄의 높이를 빠뜨렸다. +- 쪽 수 추정이 줄보다 넓은 글자처럼 취급한 개체(표 등)와 위아래 배치 개체가 함께 든 문단을 한/글처럼 놓는다. + 넓은 개체는 한 줄을 혼자 차지한다. 위아래 배치 개체의 자리(조판 부호)가 그 앞, 곧 문단 맨 앞에 있으면 넓은 개체 + 위에, 그 뒤, 곧 문단 맨 끝에 있으면 아래에 글자 높이만 한 빈 줄이 하나 더 생긴다. 위아래 배치 개체는 그 빈 줄 + 위에 놓인다. 전에는 줄 배치 정보가 없으면 둘을 한 줄로 셌고, 줄 배치 정보가 있어도 자리가 문단 맨 앞이면 넓은 + 개체의 줄 위에 놓았다. 그래서 넓은 개체가 다음 쪽으로 밀리기도 했다. +- 쪽 수 추정이 글자처럼 취급한 개체가 든 문단에서 위아래 배치 개체가 놓일 줄을 한/글처럼 고른다. 자리(조판 부호) + 바로 뒤에서 다음 줄이 시작하면 자리는 앞 줄 끝에 남고, 개체는 그 앞 줄 위에 놓인다. 전에는 줄 배치 정보가 있으면 + 다음 줄 위에 놓았고, 줄 배치 정보가 없으면 글자처럼 취급한 개체를 빼고 글만으로 줄을 나눠 자리의 줄을 정했다. +- 문단에서 run을 빼거나(`HwpxOxmlRun.remove()`) 더하거나(`add_run`), 그림·표·도형 같은 개체를 넣거나, 메모를 달거나 + 지우거나(필드 컨트롤), 각주·미주를 넣으면 그 문단의 줄 배치 캐시(`hp:linesegarray`)를 지운다. 한/글은 저장된 캐시를 다시 쓰므로, + 내용이 바뀐 문단에 옛 캐시가 남으면 줄이 옛 자리에 그려지고, 줄 시작이 남은 내용을 넘으면 문서를 손상·변조 + 가능성으로 경고했다. 글만 든 문단은 저장할 때의 낡은 캐시 검사가 지워 줬지만, 개체가 든 문단은 그 검사에서 + 빠졌다. 다른 문단의 캐시는 그대로 둔다(#436). +- 문단 모양 조회와 글꼴·테두리 조회가 큰 머리말에서 느리던 것을 고친다. FormFit과 쪽 수 추정(실험)이 문단·글자 + 모양마다 이 조회를 불러, 문단 모양이 많은 문서에서는 거의 모든 시간을 여기에 썼다. + - 문서의 `paragraph_property()`가 부를 때마다 모든 머리말의 문단 모양을 모두 읽었다. 이제 머리말마다 찾는 + 모양만 읽는다(뒤 머리말이 이기는 것은 그대로). + - `font_face()`·`border_fill_info()`가 부를 때마다 머리말 전체를 훑었다. 이제 `hh:refList`의 글꼴·글자 모양· + 테두리 목록만 보고, 그런 목록이 없는 머리말만 전체를 훑는다. +- 표 구조 편집(`apply_table_ops`)이 `hp:cellAddr`·`hp:cellSz`가 없는 칸(다른 프로그램이 쓴 표)에서 + `AssertionError`로 멈추거나 엉뚱한 이유로 거부하던 것을 고친다. 행·열 삭제와 삽입, 열 폭 설정과 맞춤, 표 나누기, + 행 순서 바꾸기가 이제 그런 표를 표 안의 표처럼 거부한다. +- 표의 열을 모두 지우는 `delete_column`(1열 표의 열 포함)이 `ZeroDivisionError`로 멈추던 것을 고친다. 행을 모두 + 지우는 `delete_row`도 남는 격자 탓으로만 거부했다. 한/글도 마지막 행·열은 지우지 않는다. 이제 둘 다 거부하고 + `delete_table`을 쓰라고 알린다. +- 행 삽입(`insert_row_by_clone`)과 행·열 삭제(`delete_row`, `delete_column`)가 칸 영역(`hp:cellzone`)을 옮기지 + 않던 것을 고친다. 영역은 그려지므로, 남은 영역이 다른 칸에 테두리를 그렸다. 이제 영역이 칸과 함께 움직인다. + - 삽입: 새 행 뒤의 영역은 밀고, 새 행을 가로지르는 영역은 넓힌다. + - 삭제: 영역은 남는 줄만큼 줄고, 줄이 모두 지워진 영역은 없어진다(마지막 영역이면 목록째). +- 글머리표·번호 목록(`doc.styles.apply_list_format`)이 10보다 깊은 수준을 받던 것을 고친다. + - 한/글 목록은 1~10수준이다. 11수준 이상을 쓴 문서는 한/글이 다시 저장하지 못하고 멈춘다. + - 이제 수준이 1 이상 10 이하의 int가 아니면 무엇도 바꾸기 전에 `style-list-level-invalid` 오류로 거부한다(전에는 + 1 미만만 거부했다). +- 쪽 수 추정이 글 뒤에 둔 어울림(`textWrap="SQUARE"`) 개체의 자리가 문단 둘째 줄 이후에 오는 경우도 따른다. 한/글처럼 + 문단을 단 폭 그대로 나눴을 때 그 자리가 든 줄을 찾고, 개체를 그 줄 위에서부터 놓는다. 그 앞 줄은 단 폭 그대로이고, + 그 줄부터 개체 곁으로 좁아진다. 전에는 첫 줄에 드는 경우만 따르고 나머지는 지원하지 않았다. +- 위의 어울림 개체 자리가 앞 줄을 꼭 채운 바로 뒤(다음 줄의 시작)에 오면, 한/글처럼 그 글이 끝나는 앞 줄에 든 것으로 센다. + 예를 들어 첫 줄을 꼭 채운 글 뒤의 개체는 문단 위에서부터 놓인다. 전에는 줄 배치 정보가 없는 문서에서 다음 줄에 든 + 것으로 세어, 개체와 그 곁 줄이 한 줄 아래로 내려갔다. + +## [6.8.0] - 2026-10-06 + +`import hwpx`가 HWP 5.0 모듈과 `hwpx.tools`를 처음 쓸 때 읽도록 바꿔 가벼워진 릴리스입니다. FormFit은 글꼴 +38종을 글꼴 파일의 설계 폭으로 재고, 수식 상자 폭은 한/글 저장값과의 평균 오차가 10.8%에서 3.6%로 줄었습니다. +그래서 FormFit이 고르는 글자 크기와 줄 수, `add_equation`이 쓰는 수식 상자 크기가 6.7.0과 달라질 수 있습니다. +`save_report`는 `.hwp` 경로를 HWP 5.0으로 씁니다. 쪽 수 추정(실험)은 개체·표·칸의 배치를 한/글 저장본에 맞춰 +넓게 고쳤습니다. 코어는 6.7.0보다 약 6천 줄(7.7%) 늘었는데, 대부분 FormFit의 글꼴 폭 표(약 4,300줄)와 쪽 수 +추정(약 1,300줄)입니다. `import hwpx`가 읽는 hwpx 모듈은 117개에서 97개로 줄었습니다. 저장 성공만으로 시각 품질을 +보증하지 않습니다. + +### 바꿈 + +- `import hwpx`가 HWP 5.0 모듈(`hwpx.hwp5`)을 읽지 않는다. `.hwp`를 열거나 HWP 5.0으로 저장할 때 + 처음 읽는다. `Hwp5Error`·`Hwp5ConversionWarning`은 이제 `hwpx.errors`에 있고, `hwpx`와 + `hwpx.hwp5.errors`는 같은 클래스를 내보낸다. +- `import hwpx`가 `hwpx.tools`(텍스트 추출·문서 비교·메일 머지·패키지 검증·내보내기 등)를 읽지 + 않는다. `hwpx.TextExtractor`·`hwpx.doc_diff`·`hwpx.validate_package`처럼 `hwpx.tools`에 사는 + 이름은 처음 쓸 때 경고 없이 읽고, 같은 객체다. 위 `hwpx.hwp5`와 합쳐 `import hwpx`가 읽는 + hwpx 모듈은 117개에서 97개로 준다. 저장은 여전히 처음 저장할 때 패키지 검증 도구를 읽는다. +- FormFit이 글꼴 12종(바탕·바탕체·돋움·돋움체·굴림·굴림체·궁서·궁서체·한컴 고딕·함초롬바탕·함초롬돋움·맑은 고딕)에서 + KS X 1001 특수 기호와 전각 문자, 라틴-1·라틴 확장-A·일반 문장 부호·통화 기호·수학 연산자·도형·기타 기호·딩뱃· + 반각/전각 형태 블록의 글자를 글꼴의 설계 폭으로 잰다. 전에는 흔한 문장 부호와 기호 몇십 자 말고는 글자 종류의 평균 폭을 + 썼다. 글꼴에 없는 글자는 한/글이 대신 그리는 글꼴(바탕·궁서와 -체 글꼴은 함초롬바탕, 굴림·돋움·맑은 고딕·한컴 + 고딕은 함초롬돋움, 함초롬 글꼴은 Segoe UI Symbol)의 폭을 그 글꼴 단위로 옮겨 쓴다. 이런 글자가 든 칸은 채우기 + 결과(글자 크기, 줄 수)가 달라질 수 있다. + - 기호 글꼴 Wingdings·Symbol도 문서가 글자를 두는 사용자 영역(U+F020–F0FF)의 설계 폭으로 잰다. 실제 너비를 쓰는 + Wingdings 글머리표(U+F09F)는 10 pt에서 456을 차지한다. + - HY헤드라인M도 글꼴의 설계 폭으로 잰다. 글꼴에 없는 글자와 `` ` ``는 함초롬바탕의 폭을 쓴다. + - 휴먼명조도 글꼴의 설계 폭으로 잰다. 글꼴에 없는 글자는 함초롬바탕의 폭을 쓴다. +- FormFit이 한/글이나 Windows와 함께 오는 글꼴 26종도 글꼴의 설계 폭으로 잰다. 전에는 글자 종류의 평균 폭을 썼다. + 이 글꼴로 쓴 칸은 채우기 결과(글자 크기, 줄 수)가 달라질 수 있다. + - 글꼴: 한컴산뜻돋움, 휴먼고딕, 한컴돋움, HY울릉도M·B, HY중고딕, HY견고딕, HY신명조, HY태백B, HY그래픽, 한컴바탕, + 한컴 윤고딕 230·240·720·760, 한컴 윤체 L, 한컴 소망 M·B, 한컴 백제 B·M, 한컴 바겐세일 B·M, 새굴림, + 한컴 말랑말랑 Regular·Bold, MD개성체 + - 글꼴에 없는 글자는 한컴산뜻돋움·새굴림이 함초롬돋움의 폭을, HY중고딕·HY견고딕·HY신명조·한컴 말랑말랑 Regular가 + 함초롬바탕의 폭을 그 글꼴 단위로 옮겨 쓴다. 나머지 글꼴에 없는 글자는 전처럼 평균 폭이다. + - 한글 음절마다 폭이 다른 글꼴은 음절마다 그 폭을 쓴다(`_glyph_table.HANGUL_ADVANCES`). 예를 들어 한컴 + 말랑말랑은 가로 모음(ㅗ·ㅛ·ㅜ·ㅠ·ㅡ)이 든 음절이 820/1000이고, 나머지는 880이다. + +### 고침 + +- 쪽 수 추정(실험, `estimate_pages`)이 글자처럼 둔 도형의 그림자를 줄 높이에 넣지 않던 것을 고친다. + - 한/글은 그런 도형의 줄을 도형과 평행 그림자의 세로 이동만큼 높게 놓는다. 이동은 아래쪽 그림자면 + `offsetY + 600`, 위쪽 그림자면 `offsetY - 600`이다(아래가 양수). 그림자가 위로 가면 도형이 그만큼 아래에 + 그려진다. 선 굵기는 줄 높이에 더하지 않는다. + - 전에는 도형 크기만 세어, 그런 도형 뒤 줄들이 그만큼 위에 놓였다. +- 쪽 수 추정(실험, `estimate_pages`)이 글꼴에서 줄 높이를 정하는 문단 모양(`fontLineHeight`)을 무시하던 것을 + 고친다. + - 한/글은 그런 문단의 줄을 글자 크기가 아니라 글꼴의 오름과 내림(OS/2 usWinAscent·usWinDescent)만큼 높게 + 놓고, 줄 간격도 그 높이에서 센다. 함초롬 글꼴과 한컴산뜻돋움은 1.3배, 맑은 고딕은 약 1.33배다. 바탕·굴림·HY + 글꼴처럼 오름과 내림을 더하면 em인 글꼴은 크기 그대로다. + - 이제 줄 캐시가 없는 문단은 FormFit 글꼴 표에 더한 오름·내림으로 줄 높이를 잡는다. 오름과 내림을 각각 올림해 + 더하며, 한/글은 크기에 따라 1~2 더 높게 놓기도 한다. 표에 없는 글꼴은 전처럼 글자 크기다. + - FormFit도 칸의 첫 문단이 그런 모양이면 칸에 들어가는 줄 수를 그 줄 높이로 센다(`SlotMetrics.font_line`, `line_size`). +- `hwpx.tools`도 공개 이름과 하위 모듈을 처음 쓸 때 읽는다. 첫 저장은 패키지·문서 검증기만 + 읽고, 내보내기·뷰어·문서 비교·메일 머지 등은 읽지 않는다. 기존 import·객체·타입 정보와 + `doc_diff` 함수 이름을 유지한다. HWPX Markdown 변환기의 배포판 버전 조회도 변환 시점으로 + 미뤄, `import hwpx`가 `importlib.metadata`를 읽지 않는다(#386). +- 수식 상자 측정(`measure_equation`, `estimate_equation_size`)의 폭을 한/글이 저장한 상자에 더 가깝게 고친다. + 합성 수식 말뭉치(132식 × 기준 크기 1000·1100·1300, macOS 한/글 저장값)에 대한 폭의 평균 절대 오차가 + 10.8%에서 3.6%로 준다(1000: 8.4→2.8%, 1100: 15.0→6.1%, 1300: 8.9→1.9%). 높이(2.5%)와 기준선(평균 0.9)은 + 그대로다. + - 글자 폭을 글리프별로 잰다. 숫자·기울인 글자는 더 좁고(`x` 0.52em, 숫자 0.47em), `i`·`j`·`l`·`t`는 좁고 + `m`·`w`는 넓다. 연산자·관계 기호는 기호마다 폭이 있고 양옆에 같은 간격을 둔다. 앞에 받을 것이 없는 `+`·`-`는 + 부호라 뒤에만 간격을 둔다. 상자 끝의 여백은 없앤다. + - `rm` 뒤의 글자는 `it`이나 묶음 끝까지 바로 선 글자 폭으로, 함수 이름(`sin`·`log` 등)도 바로 선 글자 폭의 합으로 + 잰다. 따옴표 안 글자는 그때의 글꼴 모양을 따른다. + - 첨자의 폭은 본 크기의 0.55배가 아니라 0.66배로 잰다(높이는 그대로). `LEFT`/`RIGHT`와 행렬이 늘이는 + 괄호는 괄호 모양마다(`(`·`[`·`|`·`{`) 폭이 다르다. + - `choose`·`binom`은 한/글이 `pmatrix {n # r}`과 같은 상자로 저장하므로, 늘인 괄호 안의 두 줄로 잰다. `atop`은 + 전처럼 가로줄 없는 분수다. + - 상자는 여전히 `baseUnit`에 비례해 커진다. 한/글(macOS)은 글자 폭을 크기에 꼭 비례해 두지 않아(1100에서 글자가 + 1000과 같은 폭) 1100의 오차가 가장 크다. Windows 한/글에서의 비례는 아직 확인하지 않았다. +- 도형·그림·수식을 넣을 때 음수 크기와 사각형의 음수 모서리 곡률을 그대로 쓰던 것을 고친다. + - 한/글은 음수 크기를 0으로 읽고 0으로 저장한다. 그 크기에서 쓴 원래 크기(`orgSz`)·회전 중심·그림의 이미지 + 크기(`imgDim`)도 0이 된다. 음수 곡률(`ratio`)도 0으로 읽는다. + - 사각형·타원·호(`doc.shapes.add_rectangle`·`add_ellipse`·`add_arc`), 그림(`doc.add_picture`), 수식 상자 + (`doc.shapes.add_equation`의 `size`), 도형 크기 바꾸기(`HwpxOxmlShape.resize`)는 이제 0 이상 2**31 미만의 + 크기만 받는다. 그 밖의 값은 문단·런·그림을 더하기 전에 `shape-size-value` 오류로 거부한다. 곡률은 + `shape-rect-ratio-value`로 거부한다. + - 수식의 `base_unit`이 0 이하일 때 내던 `ValueError`를 `shape-equation-base-unit-value`(`HwpxValueError`)로 + 바꾸고 문단을 더하기 전에 낸다. 2**31 이상도 거부한다. 아주 큰 `base_unit`으로 잰 상자가 2**31 이상이면 + `shape-size-value`로 거부한다. 한/글은 그런 상자를 2**32 - 1로 줄여 저장한다. + - `HwpxOxmlParagraph.add_picture`는 그림 요소를 만든 뒤에 런을 더한다. 값이 거부되면 빈 런이 남지 않는다. + - 그림의 HWPUNIT 크기는 정수 변환 전에 검사한다. bool·소수·문자열을 정수로 바꾸어 받아들이지 않는다. +- 캡션 간격, 같은 폭 단의 간격, 새 번호를 한/글이 그대로 두지 않는 값도 그대로 쓰던 것을 고친다. + - 한/글은 캡션 간격(`hp:caption@gap`)과 단 간격(`hp:colPr@sameGap`)을 부호 있는 16비트 수로, 새 번호 + (`hp:newNum@num`)를 부호 없는 16비트 수로 읽는다. 범위 밖 값은 감싸 읽는다. 캡션 간격 32768은 −32768, + 새 번호 −1은 65535, 65536은 0이 된다. 단 간격 32768은 −32768로 읽어 둘째 단이 첫째 단과 겹친다. 음수 글로 + 쓴 단 간격은 0으로 읽는다. + - 이제 캡션 간격(`set_caption`의 `gap`)은 −32768 이상 32767 이하, 단 간격(`doc.page.set_columns`의 + `same_gap`, `doc.page.setup`의 `column_gap_mm`)은 0 이상 32767 이하, 새 번호(`doc.page.restart_page_number`의 + `number`)는 0 이상 65535 이하만 받는다. 그 밖의 값은 무엇도 바꾸기 전에 `shape-caption-gap-value`, + `page-column-gap-value`, `page-new-num-value` 오류로 거부한다. `doc.page.setup`은 쪽 크기와 여백을 바꾸기 + 전에 단 간격을 검사한다. +- 너비가 다른 단(`doc.page.set_columns(same_size=False, column_widths=...)`)의 너비와 간격을 한/글이 읽는 꼴로 + 쓴다. 한/글은 `hp:colSz`의 너비와 간격을 HWPUNIT이 아니라 본문 폭을 32768로 나눈 몫으로 읽고, 단을 + 너비 × 본문 폭 / 32768로 놓는다. 전에는 받은 값을 그대로 써서, 본문 폭에 맞춰 HWPUNIT으로 준 단(A4에서 + `[(20000, 1000), (21520, 0)]`)이 1.3배 넓게 놓여 둘째 단이 종이 밖으로 나갔다. + - 이제 `column_widths`를 비율로 받아 합이 32768이 되게 맞춰 쓴다. 본문 폭에 맞춘 HWPUNIT 값은 그 너비 그대로 + 나오고, 합이 32768인 값은 그대로 쓴다. + - 음수, 정수가 아닌 값, 모두 0인 값, 두 값이 아닌 쌍은 무엇도 바꾸기 전에 `page-column-widths-value` 오류로 + 거부한다. + - 쪽 수 추정(실험, `estimate_pages`)도 단 너비를 너비 × 본문 폭 / 32768로 센다. 전에는 너비들의 합으로 나눴다. + 한/글이 만든 문서는 합이 32768이라 결과가 같다. +- 쪽·구역·목록 설정에서 한/글이 그대로 두지 않는 값과, 음수를 조용히 0으로 쓰던 값을 거부한다. + - 한/글은 줄 번호의 시작·간격(`doc.page.set_line_numbers`의 `start_number`·`count_by`), 구역 시작 번호 + (`set_start_numbering`), 목록 시작 번호(`doc.styles.apply_list_format`의 `start`)를 부호 없는 16비트 수로, + 쪽 테두리 오프셋을 부호 있는 16비트 수로 둔다. 그 밖의 값은 감싸 읽는다(예: 65536은 0). 모르는 제본 방식 + (`gutter_type`)과 쪽 테두리 채울 영역(`fill_area`)은 기본값(`LEFT_ONLY`, `PAPER`)으로 읽는다. + - 이제 번호는 0 이상 65535 이하, 줄 번호 거리와 재시작은 0 이상 2**31 미만, 쪽 테두리 오프셋은 0 이상 32767 + 이하, 쪽 크기와 여백은 0 이상 2**31 미만만 받는다. 쪽 테두리 종류(`page_type`)는 `BOTH`·`EVEN`·`ODD`, + 채울 영역은 `PAPER`·`PAGE`·`BORDER`, 제본 방식은 `LEFT_ONLY`·`LEFT_RIGHT`·`TOP_BOTTOM`만 받는다. 음수 + 글자 크기(`ensure_run`의 `size`)도 거부한다. + - 그 밖의 값은 무엇도 바꾸기 전에 형식 있는 오류(`page-number-value`, `page-border-fill-invalid`, + `page-gutter-type-invalid`, `page-size-value`, `style-list-start-value`, `style-run-size-value`)로 거부한다. + 전에는 음수를 0으로 쓰고, 범위 밖 값은 그대로 썼다. `doc.page.setup`은 쪽을 바꾸기 전에 크기와 여백을 검사한다. + - 목록 시작 번호 0은 그대로 쓴다(한/글이 그대로 두고 1처럼 그린다). 전에는 1로 썼다. + - 구역에 없던 설정(쪽 테두리, 시작 번호, 줄 번호, 격자, 보이기 등)을 더할 때 `TypeError`가 나던 것을 고친다. + 예를 들어 새 종류의 쪽 테두리를 만들면 그랬다. +- 새 표(`doc.add_table`, 칸 안의 `add_table`)와 열 너비 바꾸기(`HwpxOxmlTable.set_column_widths`)가, 한/글이 그리는 + 것보다 좁은 열을 그대로 쓰던 것을 고친다. + - 한/글은 표의 열을 적어도 그 칸들의 왼쪽·오른쪽 여백과 283을 더한 폭(새 표는 510 + 510 + 283 = 1303)으로 + 그린다. 칸에 적힌 좁은 폭은 그대로 두고, 표는 그만큼 넓게 저장한다. 예를 들어 폭 0으로 쓴 2×2 표는 2606이 되고, + 42520 폭 표에 열 비율 0과 1을 주면 43823이 된다. + - 이제 새 표는 적어도 열마다 그 폭으로, `set_column_widths`는 그보다 좁은 열을 그 폭으로 쓰고 표 폭을 그만큼 + 늘린다. 그래서 쓴 표가 한/글이 그리고 저장하는 표와 같다. 폭을 주지 않은 좁은 칸 안의 표도 그렇다. + - 새 표의 폭이 0 초과 2**31 미만의 int가 아니면, 문단이나 테두리 채우기를 더하기 전에 `table-width-value` + 오류로 거부한다. 전에는 0이나 음수 폭을 0으로 썼다. +- 문단에서 새 단을 시작하는 단 정의(`doc.page.set_columns(..., paragraph=...)`, `add_column_definition`)를 문단의 + 글 뒤 런에 쓰던 것을 고친다. 한/글은 문단의 글보다 앞에 있는 단 정의로만 새 단을 시작하고(한/글 문서는 그것을 + 문단의 첫 런에, 구역 설정 다음에 둔다), 글 뒤에 있으면 새 영역만 열고 단은 적용하지 않아 뒤 글이 본문 폭으로 + 놓였다. 이제 단 정의의 런을 문단의 글보다 앞에 쓴다. + - 구역의 첫 문단에서는 구역 설정 옆 단 정의를 그 자리에서 고쳐 쓴다(문단을 주지 않은 `set_columns`와 같다). + 첫 문단에 단 정의가 하나 더 있으면 한/글이 문서를 열지 못한다. +- 쪽 수 추정(실험, `estimate_pages`)이 구역 안에서 단이 바뀌는 문서를 지원 밖(`the columns change inside the + section`)으로 두던 것을 고친다. 이제 한/글처럼 단 영역별로 놓는다. + - 구역 첫 문단 뒤에서 단 정의를 가진 문단은 새 단 영역을 시작한다. + - 영역은 앞 영역이 끝난 쪽에서, 앞 영역의 가장 낮은 줄 아래끝보다 1134(4mm) 아래에 시작한다. 줄 간격이나 + 문단 간격과 상관없다. 자리가 없으면 다음 쪽 위에서 시작한다. + - 영역의 줄 위치는 그 쪽에서 영역 위쪽부터 센다(한/글의 줄 캐시와 같다). + - 다음 영역이 오는 영역은 마지막 쪽의 단을 높이로 균형 맞춘다. 모든 줄이 그 쪽에 남는 가장 짧은 단이다. + 균형 다단(`BALANCED_NEWSPAPER`)의 단 나누기도 새 균형 영역을 연다. + - 문단의 글 뒤에 있는 단 정의는 영역만 새로 열고 단은 바꾸지 않는다. + - 단이 바뀌는 구역의 각주와 쪽·종이 기준 개체는 여전히 지원 밖이다. +- 쪽 수 추정(실험, `estimate_pages`)이 쪽 번호 조판 부호(`hp:ctrl/hp:pageNum`)와 줄보다 넓은 글자처럼 둔 표만 + 있는 문단을 표 높이의 한 줄로 세던 것을 고친다. + - 한/글은 둘을 한 줄에 두지 않는다. 부호의 줄은 문단 글의 빈 줄이다. + - 부호가 앞이면 빈 줄 다음에 표의 줄이 오고, 표 뒤면 표의 줄 다음에 빈 줄이 온다. + - 표가 줄에 들어가거나, 앞에 구역·단 정의 부호만 있으면 전처럼 한 줄이다. +- 쪽 수 추정(실험, `estimate_pages`)이 줄보다 넓은 글자처럼 둔 표 곁의 다른 조판 부호(감출 쪽 번호, + 새 번호, 자동 번호, 머리말·꼬리말, 쪽 번호 위치, 책갈피, 누름틀, 찾아보기 표시 등)를 표의 줄에 함께 세던 것을 + 고친다. + - 한/글은 단 정의(`hp:colPr`)를 뺀 모든 조판 부호를 쪽 번호처럼 표와 다른 줄에 둔다. 표 앞의 부호는 + 표 위의 빈 줄, 표 뒤의 부호는 표 아래의 빈 줄이다. + - 그때 표의 줄 간격은 표가 든 런의 글자 크기로, 부호 줄은 그 부호들이 든 런의 글자 크기로 센다. 전에는 문단 + 모든 런 가운데 가장 큰 글자로 셌다. +- 쪽 수 추정(실험, `estimate_pages`)이 문단 글 뒤에 놓인 어울림(SQUARE) 개체를 지원 밖 + (`... wrapped square after text`)으로 두던 것을, 그 개체의 조판 부호가 문단 첫 줄에 있을 때 고친다. + - 한/글은 개체의 문단 기준 세로 자리를 부호가 놓인 줄(문단을 단 전체 폭으로 놓았을 때)의 윗선에서 잰다. + 부호가 첫 줄에 있으면 문단 윗선이라, 글 앞에 놓인 개체와 같다. 띠에 닿는 줄은 그 문단의 줄도 곁으로 간다. + - 첫 줄은 FormFit으로 문단을 단 전체 폭에서 나누어 가린다. 부호가 뒤 줄에 있으면 전처럼 지원 밖이다. +- 쪽 수 추정(실험, `estimate_pages`)이 문단 맨 위(세로 위치 0)에 둔 위아래 배치 개체가 본문 바닥을 넘을 때 문단째 + 다음 쪽으로 넘기거나(빈 문단) 지원 밖(`... anchored in text at a page end`)으로 두던 것을 고친다. + - 한/글은 글과 함께 움직이는 개체(`flowWithText`)면 개체만 다음 쪽 본문 맨 위로 보낸다. 문단의 줄은 빈 줄이든 + 글 줄이든 제자리에 전체 폭으로 남고, 다음 쪽 내용은 개체 아래에서 시작한다. 문단 위치에서 아래로 내려 둔 + 개체(세로 위치 > 0)에 쓰던 규칙과 같다. + - 글과 함께 움직이지 않는 개체는 그 쪽에 남아 바닥을 넘는다. 이때 들어가야 하는 것은 문단의 줄뿐이고, 다음 + 문단은 개체 아래(대개 다음 쪽 맨 위)에서 시작한다. + - 개체만 넘기는 것은 문단 자기 줄이 제자리에 들어갈 때뿐이다. 줄도 안 들어가면 문단째 넘어간다(문단 위치에서 + 내려 둔 개체도 같다). 개체가 혼자 넘어간 쪽을 따르지 못하는 구역(그 띠를 흐르는 표가 지나가는 등)은 예전처럼 + 개체를 문단과 함께 넘겨 다시 놓는다. + - 개체가 넘어간 쪽에서, 통째로 넘어간 위아래 배치 표는 개체 아래에서 시작하고 그 문단 줄은 앞 쪽에 남는다. 앞 + 쪽에서 나뉘어 이어지는 표는 그 쪽 맨 위에서 개체와 겹쳐 이어지고, 뒤 글은 둘 아래에서 시작한다. 그 쪽으로 + 넘어간 표 문단은 개체 아래에서 쪽 맨 위처럼 놓인다. +- 쪽 수 추정(실험, `estimate_pages`)이 줄을 나누지 않는 표 칸(`hp:subList@lineWrap`이 SQUEEZE나 KEEP)의 글을 + 칸 폭에서 나누던 것을 고친다. + - 한/글은 그런 칸의 문단을 한 줄로 두고, 그릴 때 칸 폭에 눌러 넣는다. 줄 바꿈과 문단 나눔만 새 줄을 연다. + - 이제 캐시 없는 그런 칸의 줄을 줄 바꿈마다 한 줄로 센다. +- 쪽 수 추정(실험, `estimate_pages`)이 표 칸 문단에 홀로 둔 위아래 배치 표(중첩 표)를 바깥 칸의 한 줄로 세어, + 바깥 행이 쪽 끝에 걸리면 그 행을 쪽 바닥에서 그대로 자르던 것을 고친다. + - 한/글은 그런 표를 행 사이에서 나눈다(쪽 나눔 CELL·TABLE). CELL이면 여러 줄인 행도 줄 사이에서 나누고, 나뉜 + 행의 각 부분에 칸 여백을 둔다. 머리행 반복이면 다음 쪽 부분 위에 머리행을 다시 둔다. + - 이제 그 표를 행(또는 줄) 조각으로 펼쳐 바깥 칸의 줄처럼 나눈다. 글자처럼 둔 중첩 표는 전처럼 나뉘지 않고 + 통째로 넘어간다. 나누지 않는 표(NONE), 아래로 내려 둔 표, 캡션이 있거나 행이 병합된 표는 전처럼 한 + 덩어리다. +- 쪽 수 추정(실험, `estimate_pages`)이 글과 함께 움직이고 나뉠 수 있는 어울림(SQUARE) 표가 본문 바닥을 넘을 때, + 줄 캐시가 없으면 지원 밖으로 두던 것을 고친다. + - 한/글은 그런 표를 흐르는 표처럼 쪽 끝에서 행 사이나 칸 줄 사이로 나눈다. 표가 걸친 쪽마다 곁 줄은 그 쪽 + 부분 곁으로 간다. 첫 쪽은 표 위부터 바닥까지, 표가 쪽을 다 덮으면 그 쪽 모든 줄, 마지막 쪽은 맨 위부터 표 + 끝까지다. + - 이제 그 표를 흐르는 표처럼 놓아 쪽마다 띠를 두고, 띠에 닿는 줄을 다시 나눈다. + - 한 쪽에 띠가 둘이면(앞 표의 끝과 다음 표의 시작) 둘 다 둔다. 한 문단이 두 띠에 걸치면 지원 밖이다. +- 쪽 수 추정(실험, `estimate_pages`)이 문단 글 앞에서 조판 부호만 든 런(구역·단 정의, 쪽 번호, 자동 번호, + 꼬리말 등)의 글자 크기를 줄 높이에서 빼던 것을 고친다. + - 한/글은 그런 런의 글자를 첫 줄의 글자로 센다. 그 런이 글보다 크면 첫 줄이 그만큼 높다. 머리표·번호와 같다. + - 부호도 글도 없는 빈 런은 전처럼 자리를 차지하지 않는다. +- 쪽 수 추정(실험, `estimate_pages`)이 앞 문단의 어울림(SQUARE) 개체 띠가 남은 채 위아래 배치 개체(표 등)를 글 앞에 + 둔 문단을 지원 밖(`... beside a square-wrapped object`)으로 두던 것을, 그 문단의 줄이 띠에 닿지 않을 때 고친다. + - 한/글은 그 개체를 띠와 상관없이 자기 위치(문단 윗선 + 세로 위치)에 놓고, 겹치면 띠의 개체 위에 그린다. 문단의 + 글 줄은 개체 아래에서 시작한다. + - 이제 개체가 첫 줄에 닿아 줄을 자기 아래로 밀고 그 발이 띠 끝보다 낮으면, 그 문단을 띠 없이 놓는다. 개체 아래 + 줄이 아직 띠에 닿는 경우와 어울림 개체가 둘인 경우는 전처럼 지원 밖이다. +- 쪽 수 추정(실험, `estimate_pages`)이 글과 함께 움직이는 어울림(SQUARE) 그림·도형이 본문 바닥을 넘을 때, 줄 캐시가 + 없으면 지원 밖으로 두던 것을 고친다. + - 한/글은 그 개체를 띄움과 상관없이 다음 쪽 본문 맨 위로 옮긴다. 개체가 있던 쪽의 줄은 모두 전체 폭이다. 다음 + 쪽에서 줄 위가 개체 아래 끝보다 위인 줄은 개체 곁으로 간다. + - 이제 그 쪽들을 다시 놓는다. 개체 문단과 뒤 문단을 전체 폭으로 나누고, 다음 쪽 맨 위의 개체 곁에서 줄을 다시 + 나눈다. 그 자리에 개체·각주가 든 문단이나 표가 오면 지원 밖이다. + - 나누지 않게 둔 어울림 표도 그렇게 옮겨 간다. 나뉘는 어울림 표는 쪽 끝에서 나뉜다(아래 항목). + - 문단 줄 아래로 띄워 위아래로 둔 그림·도형도, 문단이 놓인 자리에서 개체 발이 바닥을 넘으면 다음 쪽 맨 위로 + 옮겨 간다. 문단 줄은 제자리에 남고, 다음 쪽에서 개체에 닿는 줄은 그 아래로 간다. 캐시가 있을 때도 전에는 + 문단째 다음 쪽으로 보냈다. + - 양옆에 글이 가는 개체 곁의 줄은, 글이 첫 조각에서 끝나거나 글이 없어도 두 조각이다(둘째는 빈 조각). 캐시 + 없는 추정도 이제 그렇게 센다. + - 문단 첫 줄을 그림 아래로 밀어낸 위아래 그림은, 밀린 줄 자리에 그림 높이를 한 번 더 더해 바닥을 넘는다고 보아 + 지원 밖으로 두었다. 이제 밀리기 전 줄 자리에서 잰다. +- 쪽 수 추정(실험, `estimate_pages`)이 세로쓰기 칸(`hp:subList@textDirection="VERTICAL"`)의 글을 가로 글처럼 + 칸 폭에서 나누어 행을 키우던 것을 고친다. + - 한/글은 세로쓰기 칸의 줄을 칸 높이를 따라 세로로 놓고 옆으로 늘어놓는다. 넘치는 글은 잘리고, 행은 선언 + 높이 그대로다. + - 세로쓰기 칸이 있는 흐르는 표는 쪽 끝에서 나뉘지 않고, 나누지 않게 둔 표처럼 통째로 다음 쪽으로 간다. + 기준 문단 줄과 그 뒤 글은 앞 쪽에 남는다. +- 쪽 수 추정(실험, `estimate_pages`)이 표 칸의 글을 칸 자신의 폭(`hp:cellSz`)에서 나누던 것을 고친다. 같은 열의 + 칸들이 폭이 다르면 한/글은 칸을 표 격자의 폭으로 놓는다. + - 열은 왼쪽부터 쌓는다. 열마다 그 열에서 끝나는 칸들의 시작 + 폭(+ 칸 간격 `cellSpacing`) 가운데 가장 먼 곳에서 + 끝난다. 그래서 열은 그 열 낱칸 가운데 가장 넓은 것만큼 넓고, 여러 열을 합친 칸이 그 열들보다 넓으면 넘침이 + 마지막 열에 붙는다. + - 칸은 첫 열의 시작부터 마지막 열의 끝까지다. 다른 행 때문에 자기 폭보다 넓어질 수 있다. + - 줄 캐시가 없는 칸의 줄 나눔과 행 높이에 쓴다(`hwpx.oxml.table_sizes.grid_widths_of`). + - FormFit(`resolve_slot_metrics`)도 칸의 자리를 그 폭에서 잰다(`SlotMetrics.raw_width`). 전에는 칸 자신의 + `hp:cellSz` 폭이었다. +- 쪽 수 추정(실험, `estimate_pages`)이 표의 칸 간격(`hp:tbl@cellSpacing`)을 표 높이에서 + 빠뜨리던 것을 고친다. + - 한/글은 행마다 위에 칸 간격을 두고, 마지막 행 아래에도 하나 둔다. 쪽을 넘는 표는 바깥 여백처럼 쪽마다 + 그 쪽 조각의 마지막 행 아래에 간격을 다시 두고, 다음 쪽 첫 행 위에도 간격이 있다. + - 글자처럼 둔 표의 줄은 행들과 그 위아래 간격만큼 높다. +- 쪽 수 추정(실험, `estimate_pages`)이 줄 캐시 없는 표 칸에서 글자처럼 둔 표 옆에 글 앞·뒤로 둔 개체가 있는 + 문단을 지원 밖(`a nested table`)으로 두던 것을 고친다. + - 그런 개체는 자리를 차지하지 않으므로, 그 문단은 표 혼자인 문단처럼 표 높이의 한 줄이다. + - 위아래로 둔 표 옆에 그런 개체가 있으면 한/글은 빈 줄을 표 아래에 둔다(표 둘일 때처럼). 이제 캐시 없이도 + 따른다(아래 항목). +- 쪽 수 추정(실험, `estimate_pages`)이 글 없는 문단에 문단 위에서 위아래로 둔 개체(표·그림)와 글 앞·뒤로 둔 + 개체가 함께 있을 때, 빈 줄을 위아래 개체의 윗선에 두던 것을 고친다. + - 한/글은 공백이나 머리표가 있을 때처럼 빈 줄을 그 개체 아래에 전체 폭으로 둔다. 뜬 개체가 종이 기준이든 문단 + 기준이든, 글 앞이든 뒤든 같다. + - 위아래 개체만 있으면 전처럼 윗선에 폭 0이다. + - 본문과 표 칸 모두 그렇다. 표 칸에서는 전에 캐시 없이 지원 밖(`a nested table`)이었다. +- 쪽 수 추정(실험, `estimate_pages`)이 위아래 배치 개체와 글자처럼 둔 개체가 함께 있는 문단 일부를 지원 밖 + (`an object with text or other objects in its paragraph`)으로 두던 것을 고친다. + - 글 없는 문단에 문단 위에서 위아래로 둔 개체가 여럿이면, 한/글은 그 개체들을 쌓고 글자처럼 둔 개체의 줄을 + 그 아래에 둔다. 런 순서와 상관없다. 전에는 위아래 개체가 하나일 때만 셌다. + - 글 문단의 줄 아래로 띄워 둔 위아래 개체는 글 속에 글자처럼 둔 개체가 있어도 같다. 문단 줄은 제자리이고, + 개체 띠에 닿는 줄은 개체 발 아래로 간다. 그 줄이 다음 쪽으로 가도 개체는 첫 줄의 쪽에 남는다. + - 글자처럼 둔 개체가 있는 문단에서 문단 위보다 위로 둔(세로 위치가 음수인) 위아래 표는 문단 윗선에 놓는다. + 한/글은 표의 음수 세로 위치(부호 없는 32비트로 적힌다)를 0으로 보고 그 자리에 그린다. + - 공백만 든 문단도 글 문단이다. 공백 하나라도 있으면, 문단 줄 아래로 띄운 위아래 개체에 닿는 그 줄은 개체 발 + 아래로 간다. 빈 문단의 줄만 전처럼 문단 윗선에 남는다. +- 쪽 수 추정(실험, `estimate_pages`)이 글자처럼 둔 개체 하나와 공백·줄 바꿈만 있는 문단을 언제나 한 줄로 세던 + 것을 고친다. + - 한/글은 개체 바로 뒤 공백 둘을 여백을 넘어도 그 줄에 두고, 그다음 공백이 여백이나 그 너머에서 시작하면 + 다음 줄로 넘긴다. 개체 앞에 공백이 있고 그 뒤에 개체가 들어가지 않으면 개체가 다음 줄로 간다. + - 개체 앞뒤의 줄 바꿈도 새 줄을 연다. 개체 앞의 빈 줄은 글 한 줄만큼 내려간다. + - 이제 그런 문단을 FormFit 줄 나눔으로 나누고, 줄마다 높이를 센다(본문과 표 칸). 넘치지 않으면 전처럼 개체 + 높이의 한 줄이다. + - 유효한 줄 캐시가 있으면 다른 문단처럼 캐시의 줄(한/글이 파일을 열 때 쓰는 줄)을 그대로 쓰고, 캐시가 없을 + 때만 FormFit으로 나눈다. + - FormFit `hancom_line_starts`가 개체 자리(`objects`)를 받아, 개체 바로 뒤 둘째 공백도 여백 밖에 매단다. +- 쪽 수 추정(실험, `estimate_pages`)이 글자처럼 둔 개체만 있는 문단의 줄 간격을, 문단 줄 간격의 종류와 + 상관없이 글자 크기에서 세던 것을 고친다. + - 고정 줄 간격이면 다음 줄은 개체가 더 높아도 그 값만큼만 내려간다(개체는 겹쳐 그려진다). + - 최소 줄 간격이면 줄 사이는 개체 높이와 그 값 가운데 큰 쪽이다. + - 비율 줄 간격은 전처럼 글자 크기에서 센다. 개체를 줄 간격에 영향을 주게 둔 것(`hp:pos@affectLSpacing`) + 이면 줄 높이(개체 높이)에서 센다. + - 본문과 표 칸 모두 그렇다. +- 쪽 수 추정(실험, `estimate_pages`)이 줄 캐시 없는 표 칸에서 글자처럼 둔 표가 글 사이에 있거나 여럿인 문단을 + 지원 밖(`a nested table`)으로 두던 것을 고친다. + - 이제 본문처럼 그 문단의 줄에 놓는다. 줄은 그 줄의 표와 글 가운데 큰 쪽만큼 높고, 들어가지 않는 표는 다음 + 줄로 간다. + - 칸 문단에 둔 머리말·꼬리말·각주 같은 컨트롤 안의 표는 그 칸이 아니라 컨트롤 자리에 놓이므로, 칸 안 표로 세지 + 않는다. +- 저장할 때, 줄이 개체 양옆 두 조각으로 나뉘고 오른쪽 조각이 비어 있는 한/글 줄 캐시를 낡은 것으로 보고 지우던 것을 + 고친다. + - 한/글은 그런 빈 조각(`hp:lineseg@flags`의 16번 비트)을 글 끝 바로 다음 자리(`textpos` = 글 길이 + 1)에서 시작한다. + - 전에는 `textpos`가 글 길이를 넘으면 문단의 줄 캐시를 통째로 지워, 무편집 저장에서도 캐시가 사라졌다. + - 패키지 검사(`validate_package`)와 레이아웃 검사도 이를 낡은 캐시로 보고했다. + - 이제 글 끝 바로 다음의 빈 조각만 받아들인다. 그보다 뒤이거나 비지 않은 조각은 전처럼 낡은 캐시다. +- 쪽 수 추정(실험, `estimate_pages`)이 글과 함께 움직이지 않게 둔(`flowWithText="0"`) 어울림(SQUARE) 개체가 본문 + 바닥을 넘으면 지원 밖으로 두던 것을 고친다. + - 한/글은 그런 개체를 다음 쪽으로 옮기지 않고 제 쪽에 둔다(바닥을 넘어 그린다). + - 곁 줄은 그 쪽 끝까지 좁게 이어지고, 다음 쪽 줄은 개체 없이 전체 폭이다. + - 이제 그 개체를 옮기지 않는다. 곁 문단이 다음 쪽으로 넘어가도, 그 줄이 캐시를 지키거나 글이 없으면 + 따른다. 글과 함께 움직이는 개체는 전과 같다. +- 쪽 수 추정(실험, `estimate_pages`)이 문단 줄 아래로 띄워 둔 흐르는 위아래 표 바로 다음 문단에 또 그런 표가 + 있으면 지원 밖(`a page break or another object beside a top-and-bottom table's band`)으로 두던 것을 고친다. + - 한/글은 뒤 문단을 앞 표의 발(쪽을 넘었으면 다음 쪽 조각의 발)에서 시작한다. 뒤 문단의 표는 그 문단 윗선 + + 세로 위치에 놓이고, 그 뒤 글은 그 표의 발에서 시작한다. + - 뒤 문단의 첫 줄이 앞 표에 닿지 않으면 전처럼 지원 밖이다. +- 쪽 수 추정(실험, `estimate_pages`)이 개체 하나만 든 빈 문단에 위아래로 띄워 둔 표나 그림을, 문단을 '띄운 거리 + + 개체 높이'만큼 높은 한 줄로 세어 뒤의 모든 줄을 개체 아래로 보내던 것을 고친다. + - 한/글은 빈 문단의 줄을 제자리에 둔다(개체의 띠가 그 줄 아래끝보다 위에서 시작하면 폭 0인 빈 조각이 된다). + - 뒤 줄은 보통 간격으로 놓이다가, 띠와 겹치는 첫 줄부터 띠 아래끝으로 간다(줄 간격은 겹침에 넣지 않는다). + - 흐르는 표는 홀로 둔 표처럼 앞 간격 위 문단 위쪽부터 띄운 거리를 잰다. +- 쪽 수 추정(실험, `estimate_pages`)이 표 칸 첫 문단의 앞 간격을 칸 높이에서 빠뜨리던 것을 고친다. + - 한/글은 칸의 첫 줄을 그 간격만큼 아래에서 시작한다. 글이 선언 높이보다 길면 행이 그만큼 더 늘어난다. + - 행이 쪽을 넘어 줄 사이에서 나뉘면 다음 쪽 부분에는 붙지 않는다(문단 중간 줄이므로). 행째 넘어가는 표는 + 행마다 붙는다. + - 글자처럼 둔 표도 그렇다. +- 쪽 수 추정(실험, `estimate_pages`)과 FormFit이, 들여쓰기나 내어쓰기가 줄에 1440보다 좁은 폭을 남기는 + 문단의 그 줄을 1440 폭으로 놓고 나누던 것을 고친다. + - 한/글은 그런 들여쓰기를 통째로 무시하고 문단의 모든 줄을 전체 폭으로 놓는다. 글자 크기와 상관없다. + - 1440 이상을 남기면 전처럼 들여쓰기를 둔다. 글자 하나가 들어가지 않을 만큼 좁아도 줄마다 한 글자씩 둔다. + - 글머리표·번호가 있는 문단은 전과 같다. +- 쪽 수 추정(실험, `estimate_pages`)이 글상자(`hp:drawText`)가 든 도형의 높이를 `hp:sz`로만 세던 것을 고친다. + - 한/글은 글상자의 글이 넘치면 도형을 글과 위아래 글 여백만큼 늘려 그린다. 늘어난 크기는 `hp:curSz`에만 적고 + (`hp:sz`는 그대로 둔다), 가끔 1 작게 적는다. + - 이제 글상자의 글(캐시가 있으면 그 줄, 없으면 배치한 줄)과 위아래 글 여백을 더한 높이, 그리고 `hp:curSz` + 가운데 큰 쪽이 `hp:sz`보다 높으면 그 높이로 센다. + - 글자처럼 둔 것과 그렇지 않은 것, 본문과 표 칸 모두 그렇다. 글이 넘치지 않으면 전처럼 `hp:sz`다. + - 위아래로 뒤집은 도형은 `hp:curSz`가 음수로 적힌다. 그 크기를 쓴다. +- `HwpxDocument.save_report`가 `.hwp` 경로에도 HWPX 패키지(ZIP)를 써서, 확장자와 다른 형식의 파일이 경고 없이 + 생기던 것을 고친다(#375). 이제 `save_to_path`처럼 HWP 5.0으로 쓴다. HWP 5.0으로 담을 수 없는 내용이면 아무것도 + 쓰기 전에 `Hwp5Error`(`hwp5-write-unsupported`)를 낸다. 저장 관문은 전처럼 HWPX 부분을 검사하고, 통과했을 때만 + 쓴다. +- 도형 위치(`HwpxOxmlShape.set_position`)와 새 연결선(`doc.shapes.add_connector`)이 기준 위나 왼쪽으로 둔 + 음수 오프셋을 `"-2000"`처럼 부호 있는 수로 쓰던 것을 고친다. + - 한/글은 음수 오프셋을 부호 없는 32비트(`4294965296`)로 적고 읽는다. 부호 있는 수는 0으로 읽어, 도형이 기준 + 가장자리에 그려지고 다시 저장하면 0이 된다. 스키마도 음 아닌 정수다. + - 이제 둘 다 부호 없는 32비트로 쓴다. 연결선은 잇는 도형의 오프셋을 부호 있는 수로 다시 읽는다. +- 쪽 수 추정(실험, `estimate_pages`)이 흐르는 표가 그 문단 줄보다 뒤 쪽에서 끝날 때, 다음 문단을 한/글처럼 둔다. + - 한/글은 다음 문단의 줄을 개체 띠 아래로 밀린 줄처럼 표 끝 바로 아래에 둔다. 그 문단의 앞 간격은 자리를 + 차지하지 않는다. 전에는 앞 간격을 더했다. + - 그 문단이 단 나누기로 시작하면 앞 문단 줄의 다음 단에 놓인다. 1단이면 표가 끝난 쪽의 표 아래다. 전에는 그 + 다음 쪽으로 보냈다. + - 쪽 나누기는 전처럼 표가 끝난 쪽의 다음 쪽 맨 위다. +- 쪽 수 추정(실험, `estimate_pages`)이 쪽이나 용지 기준으로 세로 가운데에 둔 위아래 개체를 지원 밖 + (`... placed TOP_AND_BOTTOM relative to PAGE`·`PAPER`)으로 두던 것을 고친다. + - 한/글은 그 개체를 본문(쪽 기준)이나 용지(용지 기준)의 세로 가운데에 놓고, 세로 위치만큼 내린다. 음수면 + 올린다(부호 없는 32비트로 적힌다). 그 쪽에서 개체 띠에 닿는 줄은 띠 아래로 간다. + - 위·아래 기준은 전과 같다. +- 쪽 수 추정(실험, `estimate_pages`)이 표 칸 문단에서 위아래로 둔 그림·도형을 한/글처럼 센다. + - 캐시가 있으면 표가 든 칸처럼 칸 줄을 한/글이 둔 자리대로 센다. 개체 아래로 밀린 줄과, 개체 끝까지 닿는 칸이 + 그대로 들어간다. + - 위로 띄운 개체(음수 띄움)는 그 문단 윗선에 선다. 칸 위나 앞 문단으로 올라가지 않는다. 전에는 띄움만큼 위로 + 셈해 행 높이에서 빠졌다. + - 개체 뒤에 글이 있으면(공백 하나나 글머리표·번호도) 그 줄은 개체 아래로 간다. 캐시가 없을 때 표 뒤 글만 + 그렇게 셌던 것을 그림·도형으로 넓힌다. +- 쪽 수 추정(실험, `estimate_pages`)이 표 위·아래 캡션의 높이에서 캡션 첫 문단의 앞 간격을 빠뜨리던 것을 고친다. + 한/글은 캡션의 첫 줄을 그 간격만큼 아래에서 시작한다. 캡션이 차지하는 자리는 그 앞 간격과 줄들과 gap을 더한 + 높이다. 마지막 문단의 뒤 간격은 들지 않는다. 글자처럼 둔 표와 흐르는 표 모두 그렇다. +- 쪽 수 추정(실험, `estimate_pages`)이 위아래 배치 개체(표·그림) 뒤에 공백만 있는 문단, 그리고 글머리표·번호가 + 붙은 문단을 글 없는 문단으로 보던 것을 고친다. + - 한/글은 공백과 머리표·번호도 글로 본다. 그 줄을 개체 아래에 전체 폭으로 두고, 다음 문단은 그 줄 아래에 둔다. + - 공백도 머리도 없을 때만 줄을 개체 윗선에 폭 0으로 둔다. + - 공백은 본문과 표 칸 모두, 머리표·번호는 본문에서 따른다. + - 공백만 있는 한 줄 문단의 표가 그 자리에서 시작하지 못하면 표 혼자인 문단처럼 놓는다. 표는 다음 쪽 맨 + 위에서 시작하고, 문단 줄은 제자리에 남으며, 뒤 글은 그 줄 아래에 이어진다. 나누지 않게 둔 표도 같다. +- 쪽 수 추정(실험, `estimate_pages`)이 줄 캐시 없는 표 칸에서 위아래 배치 표 하나가 든 문단을 지원 밖 + (`a nested table`)으로 두던 것을 고친다. 이제 그림·도형처럼 칸이 그 표의 아래 끝(행으로 잰 표 높이와 바깥 여백)까지 + 닿고, 칸의 다음 줄은 그 아래에서 이어진다. 위로 띄운 표는 문단 윗선에 선다. 그런 행이 쪽을 넘어 줄 사이에서 + 나뉠 때도 표는 그 높이의 한 줄로 센다. 글 앞에 둔 그런 표는 거기 닿는 첫 줄부터 표 아래로 보낸다. 한 문단에 표가 + 둘이면 한/글은 빈 줄을 아래 표 밑으로 보내므로 전처럼 캐시가 있어야 한다. +- 쪽 수 추정(실험, `estimate_pages`)이 위아래 개체의 띠에 닿는 줄에 글자처럼 둔 개체가 있으면 지원 밖 + (`a page break or another object beside a square-wrapped object`)으로 두던 것을 고친다. 한/글은 그런 줄도 글 줄처럼 + 띠 아래로 보낸다. 글자처럼 둔 개체가 줄 높이를 키우지 않으면 줄 높이는 글 높이 그대로다. +- 쪽 수 추정(실험, `estimate_pages`)이 어울림(SQUARE) 개체 곁 띠에 글자처럼 둔 개체 하나만 든 빈 문단을 지원 밖 + (`an object beside a square-wrapped object`)으로 두던 것을 고친다. 한/글은 개체가 곁 자리보다 넓으면 그 줄을 띠 + 아래로 보내 전체 폭에 두고, 좁으면 곁에 둔다. +- 쪽 수 추정(실험, `estimate_pages`)이 어울림 개체 곁 띠에서 글 사이에 글자처럼 둔 개체가 든 문단을 띠 없이 세던 + 것을 고친다. 이제 띠 안에서 시작하는 줄은 곁 자리 폭으로 나눈다. 곁 자리보다 넓은 개체가 든 첫 줄은 한/글처럼 + 띠 아래로 보내 전체 폭에 두고, 곁의 남은 자리는 비운다. 글이 개체 양쪽에 놓이는 띠에서는 지원 밖 + (`objects set as characters among text beside a square-wrapped object`)이다. +- 쪽 수 추정(실험, `estimate_pages`)이 칸 단위로 나누는 흐르는 표(`pageBreak="CELL"`)에서 병합 칸으로 이어진 + 행들이 한 쪽보다 길면 "rows merged together taller than a page"로 거부하던 것을 고친다. 한/글은 쪽 끝마다 + 다시 나눈다. 칸마다 그 쪽에 들어가는 줄까지 두고 나머지는 다음 쪽 머리 행 아래로 넘기며, 넘긴 부분이 다시 + 쪽보다 길면 그 쪽 끝에서 또 나눈다. 행들보다 긴 병합 칸은 행이 끝난 뒤에도 혼자 다음 쪽들로 이어진다. 글보다 + 높게 선언된 칸의 남는 높이도(글이 다 들어간 뒤에도) 쪽 끝마다 행처럼 잘려 이어진다. 어느 쪽에도 한 줄도 + 들어가지 않는 칸만 전처럼 거부한다. 줄 사이에서 나뉜 선언 행의 다음 쪽 부분은 선언 높이의 나머지와, 이어지는 + 줄에 칸 위아래 여백을 더한 높이 가운데 큰 쪽이다. 이어지는 줄이 있으면 남은 높이가 낮아도 버리지 않는다. +- 쪽 수 추정(실험, `estimate_pages`)이 곁에 자리를 남기는 종이·쪽 기준 어울림(SQUARE) 개체의 띠에 닿는 줄을, 그 줄이 + 줄 캐시를 가질 때 따른다. 한/글은 그런 줄을 같은 높이·간격으로 개체 곁에 좁혀 둔다(양옆에 자리가 있으면 한 높이에 + 두 조각). 캐시 없는 줄·표·개체가 닿으면 전처럼 지원 밖이다. +- 쪽 수 추정(실험, `estimate_pages`)이 같은 폭의 여러 단 쪽에서 종이 기준으로 둔 위아래 배치 개체가 본문 너비를 + 다 덮지 않으면 지원 밖(`objects placed on the paper`)으로 두던 것을 고친다. 한/글은 개체가 가로로 걸치는 + 단에서만 줄을 개체 아래로 보내고, 걸치지 않는 단의 줄은 개체가 없을 때 자리에 둔다. 이제 종이의 왼쪽·가운데· + 오른쪽을 기준으로 둔 개체는 바깥 여백까지 쳐서 걸치는 단마다 그렇게 놓는다. +- `hwpx.experimental.latex_to_eqedit`(실험)가 `e^{it}`, `x_{rm}`처럼 글자가 `rm`·`it`·`bold`가 되는 식별자를 그대로 + 쓰던 것을 고친다. 한/글은 이 낱말을 글꼴 전환으로 읽어서, 첨자에 홀로 오면 아무것도 그리지 않는다. 이제 다른 + 예약어처럼 따옴표로 감싸 쓰고(`e ^{"it"}`), `eqedit_to_latex`는 그것을 다시 글자로 읽는다. +- `latex_to_eqedit`가 `\lbrace`·`\rbrace`를 `\{`·`\}`처럼 `LBRACE`·`RBRACE`로 옮긴다(`\left`·`\right` 뒤에서도). 전에는 + 지원하지 않는 명령이었다. `eqedit_to_latex`는 `LEFT`·`RIGHT` 밖의 맨 `LBRACE`·`RBRACE`를 `\{`·`\}`로 읽는다. 전에는 + 낱말 그대로 남았다. +- 수식 상자 설명에, Windows의 한/글은 저장할 때 상자(`hp:sz`)와 `baseLine`을 그대로 두고 그 상자로 쪽을 배치한다는 + 점을 더한다. +- 쪽 수 추정(실험, `estimate_pages`)이 줄 캐시 없는 표 칸 문단에서 글자처럼 둔 개체(그림·도형·수식 등)를 세지 않던 + 것을 고친다. 전에는 칸 문단에 혼자 든 표만 그 높이로 셌고, 다른 개체는 없는 것처럼 줄을 세어 행이 한/글보다 + 낮았다. 이제 개체 하나만 든 문단은 개체 높이의 한 줄이고 줄 간격은 글자 크기로 센다. 글 사이에 있거나 여럿이면 + 본문처럼 개체의 폭과 높이로 줄 안에 놓는다. +- 쪽 수 추정(실험, `estimate_pages`)이 빈 문단에 문단 위 기준으로 둔 위아래 개체 여럿(표·그림)을 지원 밖 + (`an object with text or other objects in its paragraph`)으로 두던 것을 고친다. + - 한/글은 개체를 차례로, 문단의 쪽부터 그 쪽에서 가로로 겹치는 앞 개체 아래에 들어가는 첫 쪽에 둔다. + 문단의 쪽에서는 띄움과 앞 개체 아래 가운데 낮은 곳이다. 그래서 뒤 개체가 다음 쪽으로 간 앞 개체를 건너 + 문단의 쪽에 들어가기도 한다. + - 어느 쪽에도 들어가지 않는 개체는 마지막으로 쓴 쪽의 다음 쪽 맨 위에서 시작한다. 다만 첫 개체는 문단 자리에서 + 시작해 흐른다. 쪽보다 긴 표는 그 뒤 쪽들로 나뉜다(CELL은 줄 사이에서, TABLE은 행째). + - 이런 문단이 이어지면 뒤 문단의 개체도 앞 문단 개체를 비켜 들어가는 첫 쪽에 놓인다. 다만 쪽을 넘어 이어지는 표의 + 나머지는 다음 쪽 맨 위에서 이어지고, 그 쪽의 다른 개체와 겹쳐도 비키지 않는다. + - 문단의 줄과 뒤 줄은 개체를 비킨 첫 자리에 놓이고, 그 쪽에 자리가 없으면 다음 쪽 맨 위로 간다. 개체가 혼자 + 차지한 쪽으로 넘어간 줄(글자처럼 둔 표가 든 줄도)은 개체 아래 남은 자리에 들어가지 않으면 그 쪽을 건너뛴다. + - 여러 단, 쪽 바닥을 넘는 그림·도형은 전처럼 지원 밖이다. +- 쪽 수 추정(실험, `estimate_pages`)이 `hp:t` 글 안에 글자로 든 전각 공백(U+3000)을 고정폭 빈칸(`hp:fwSpace`)으로 + 보던 것을 고친다(python-hwpx는 둘을 모두 U+3000으로 읽는다). 글자 전각 공백은 글꼴의 폭(대개 글자 크기만큼)을 + 차지하고, 그 뒤에서 줄이 나뉘며, 줄 끝에서는 몇 개가 이어져도 모두 매달린다. 전에는 1/4 폭의 고정폭 빈칸으로 + 재어 줄을 늦게 나누었다. + - FormFit은 고정폭 빈칸이 낱말 바로 뒤라도 여백에서(또는 넘어서) 시작하면 다음 줄로 넘긴다. 전에는 낱말이 + 여백에 딱 맞을 때 그 뒤 고정폭 빈칸을 줄 끝에 매달았다. +- 쪽 수 추정(실험, `estimate_pages`)이 글 문단의 첫 줄에서 아래로 띄운 흐르는 위아래 표를 쪽 끝에서도 따른다. + - 그 줄이 쪽 바닥 위에 들어가지 않으면 줄과 표가 함께 다음 쪽으로 가고, 표는 거기서 띄운 만큼 아래에서 흐른다. + - 줄은 들어가는데 표 윗선이 바닥을 넘거나, 행 단위로 옮기는(TABLE) 표의 첫 행이 들어가지 않으면, 표만 다음 쪽 맨 + 위에서 시작한다. 문단의 글은 그 줄 아래로 이어지고, 표가 차지하는 쪽에서는 표 아래로 간다. + - 전에는 모두 지원 밖(`a top-and-bottom table offset down from a line at a page end`)이었다. 첫 줄이 아닌 줄에서 + 띄운 표는 전처럼 지원 밖이다. +- 쪽 수 추정(실험, `estimate_pages`)이 표 칸 안에 위아래나 어울림으로 둔 그림·도형의 높이를 행 높이에 넣는다. 글 없는 칸 + 문단에서 문단 위를 기준으로 둔 것(글자처럼 두지 않은 것)이면, 칸이 그 개체의 아래 끝(바깥 여백 포함)까지 온다. 전에는 그 + 개체를 세지 않아 행을 한 줄 높이로 잡았다. +- 쪽 수 추정(실험, `estimate_pages`)이 병합 칸으로 묶인 행들이 쪽 바닥을 빈 높이 안에서만 넘을 때, 그 행들을 그 쪽 바닥에서 + 끝낸다. 모든 칸의 글이 바닥 위에 들어가고 남는 높이가 1282 이하면 다음 쪽으로 넘길 것이 없다. 전에는 표 윗 여백만 있는 + 빈 쪽이 하나 더 생겼다. +- 쪽 수 추정(실험, `estimate_pages`)이 띠가 본문 아래 끝을 넘는 어울림(SQUARE) 개체를, 그 문단과 곁 문단이 줄 캐시를 + 가질 때 받는다. + - 한/글은 이런 개체만 다음 쪽 맨 위로 보낸다. 개체는 줄 높이를 차지하지 않으므로 캐시를 가진 줄은 캐시 자리에 + 그대로 있다. + - 캐시 없는 문단이 그 곁에 있으면 전처럼 지원 밖이다. 줄을 어디서 나눌지가 개체의 쪽에 달려 있기 때문이다. +- 쪽 수 추정(실험, `estimate_pages`)이 곁에 줄 하나 들어갈 자리를 남기는 종이·쪽 기준 어울림(SQUARE) 개체를 + 받는다(한 단일 때). + - 그 쪽의 줄·표·개체가 개체의 띠에 닿지 않으면 개체가 없을 때와 같다. 띠 위 끝에 바닥이 맞닿은 줄도 그렇다. + - 닿는 줄은 한/글이 개체 곁에 좁혀 둔다. 이것은 따르지 않고 지원 밖으로 둔다(`text beside an object placed on + the paper`). +- 쪽 수 추정(실험, `estimate_pages`)이 시험지 첫 쪽 모양을 따른다. 종이 위에서 본문 머리에 걸친 머리 표 아래에 + 문단 위에서 위아래로 둔 문항 표가 서는 모양이다. + - 종이·쪽 기준 어울림(SQUARE) 개체가 본문 양옆에 줄 하나 들어갈 자리(1440)를 남기지 않으면, 위아래 + 개체처럼 그 쪽 줄을 띠 아래로 민다. 곁에 자리가 남으면 전처럼 지원 밖이다. + - 문단 첫 줄 위에 앵커한 위아래 개체는 그 줄이 띠에 밀리면 띠 바로 아래에 서고, 그 줄은 개체 아래로 간다. + 흐르는 표만 든 문단도 줄이 띠에 밀리면 표가 띠 바로 아래에 서고 빈 줄은 표 아래로 간다. 밀리지 않으면 + 줄은 전처럼 표 윗선에 있다. + - 띠 끝에 맞닿아 시작하는 이런 표는 받는다. 띠에 닿는 흐르는 표는 전처럼 지원 밖이다. +- 쪽 수 추정(실험, `estimate_pages`)이 구역의 '빈 줄 감추기'(`hp:visibility@hideFirstEmptyLine`)를 따르지 않던 것을 + 고친다. 이 설정이 켜진 구역에서 한/글은 다음 쪽이나 단을 시작할 빈 문단을 둘까지 앞 쪽 바닥 밑, 그 문단이 설 + 자리에 겹쳐 두어 감춘다. 감춘 문단은 자리를 차지하지 않고 쪽을 만들지 않으며, 다음 글은 다음 쪽 맨 위에서 + 시작한다. 셋째 빈 문단부터, 쪽을 나누는 문단, 빈칸이 든 문단은 감추지 않는다. 전에는 그런 빈 문단을 다음 쪽으로 + 넘겨, 문서가 빈 문단으로 끝나면 빈 쪽을 하나 더 세기도 했다. +- 그림 넣기(`doc.add_picture`)가 `align`에 받은 값을 검사 없이 `hp:pos@horzAlign`에 쓰던 것을 고친다. + - 한/글은 모르는 정렬 값을 왼쪽(LEFT)으로 읽는다. 정수 같은 값은 그림과 문단을 더한 뒤에야 오류가 났다. + - 이제 대소문자와 상관없이 `LEFT`·`CENTER`·`RIGHT`·`INSIDE`·`OUTSIDE`만 받는다. 그 밖의 값은 아무것도 더하기 + 전에 `shape-position-frame` 오류로 거부한다. +- 쪽 수 추정(실험, `estimate_pages`)이 칸 단위로 나누는 흐르는 표에서 문단이 여럿인 칸을 줄 간격 하나로만 세어 + 나누던 것을 고친다. 한/글은 문단 사이 간격(앞 문단의 아래 간격과 다음 문단의 위 간격)도 세고, 다음 쪽 부분이 + 문단의 첫 줄로 시작하면 그 문단의 위 간격을 그대로 둔다. 이제 그런 칸은 줄마다 제 자리로 나눈다. 전에는 문단 + 간격이 있으면 한 쪽에 줄을 더 넣어, 표가 한/글보다 짧게 끝날 수 있었다. +- 문단 모양 적용(`doc.styles.apply_paragraph_format`)이 퍼센트 줄 간격의 `hh:lineSpacing@unit`을 `PERCENT`로 + 쓰던 것을 고친다. + - 스키마의 단위는 `CHAR`·`HWPUNIT`이고, 한/글은 퍼센트 줄 간격도 `HWPUNIT`으로 적는다. `PERCENT`로 쓴 것도 같게 + 배치하지만, 다시 저장하면 바꾼다. 이제 처음부터 그렇게 쓴다. + - 그래서 같은 줄 간격의 문단 모양이 이미 있으면, 단위만 다른 모양을 새로 만들지 않고 그것을 쓴다. + +## [6.7.0] - 2026-10-01 + +본문 쪽 수 추정(실험, `hwpx.experimental.estimate_pages`)을 더하고, 곡선·연결선, 바탕쪽 정리, 글상자 채우기, +쓰지 않는 그림·차트 정리 같은 저작·정리 API를 넓힌 릴리스입니다. FormFit은 글꼴마다 한/글이 배치하는 폭과 +칸을 늘리는 식을 따르도록 다시 맞춰서, 고르는 글자 크기와 줄 수가 6.6.0과 달라질 수 있습니다. 누름틀 채우기도 +한/글에 맞췄습니다. 여러 문단에 걸친 누름틀은 걸친 문단을 지우고, 끝(`hp:fieldEnd`)이 없는 누름틀은 +`field-end-missing` 오류를 냅니다. 새 도형의 기본 선 굵기는 33(0.12 mm)이고, 각주·미주의 인스턴스 ID는 +`instId`로 씁니다. 저장 성공만으로 시각 품질을 보증하지 않습니다. + +### 추가 + +- `hwpx.experimental.latex_to_eqedit`(실험)가 고등학교 수학에서 흔한 표기 몇 가지를 더 옮긴다(#347). 전에는 + `UnsupportedLatexError`로 거부했다. + - `\,` → `` ` ``(작은 공백), `\overrightarrow{AB}` → `vec {AB}`(두 글자 위로 늘어난 화살표), + `\overleftrightarrow{AB}` → `dyad {AB}`(↔), `\therefore` → `therefore`(∴), `\because` → `because`(∵). + - `\mathbf{X}` → `{rm {bold {X}} it}`. `bold`만으로는 영문자가 기울임으로 남고, `rm`은 중괄호 밖으로 + 번지므로 곧은 굵은 글꼴로 그린 뒤 `it`으로 기울임을 되돌린다. + - `eqedit_to_latex`는 이 철자들을 되읽는다(`` ` `` → `\,`, `dyad` → `\overleftrightarrow`, `therefore`·`THEREFORE` + → `\therefore`, `because` → `\because`, `{rm {bold X} it}` → `\mathbf{X}`). 수식 크기 추정은 `dyad`를 `vec`처럼 + 꾸밈으로 잰다. +- `hwpx.experimental.latex_to_eqedit`(실험)가 `\mid`와 `\binom`도 옮긴다(#347). 전에는 `UnsupportedLatexError`로 + 거부했다. 철자는 한/글에서 그려 본 결과(#347 댓글)를 따른다. + - `\mid` → `~|~`. 맨 `|`는 앞뒤 간격 없이 붙어 그려지고, `~|~`가 `\mid`의 간격에 가장 가깝다. + - `\binom{n}{r}` → `{n} choose {r}`. `binom {n} {r}`·`pmatrix {n # r}`와 같은 모양이고, `( {n} atop {r} )`는 + 괄호가 늘어나지 않는다. + - `eqedit_to_latex`는 `~|~`를 `\mid`로, `{n} choose {r}`와 `binom {n} {r}`를 `\binom{n}{r}`로 되읽는다. 수식 크기 + 추정은 둘을 `atop`처럼 두 줄로 잰다. `choose`·`binom`은 예약어가 되어, LaTeX의 맨 글자 `choose`는 따옴표로 + 감싼다. + - LaTeX `~`(띄움)를 EqEdit의 보통 띄움 `~`로 옮긴다. 전에는 거부해서, `eqedit_to_latex`가 내보낸 `~`가 든 LaTeX를 + 다시 EqEdit로 옮기지 못했다. 이제 EqEdit의 작은 띄움 `` ` ``(`\,`)과 보통 띄움 `~`가 모두 왕복한다. +- `doc.shapes.add_curve()`·`add_connector()`를 더한다(곡선과 붙은 연결선 저작). + - 곡선은 앵커를 지나는 `CURVE` 구간으로 쓴다(닫으면 끝에서 첫 앵커로). 한/글은 곡선의 크기 상자를 다시 계산하지 + 않으므로, 한/글 곡선이 갖는 상자(앵커를 지나는 Catmull-Rom 곡선을 구간마다 16단계로 근사한 상자)를 쓰고 앵커는 + 그 상자 기준으로 둔다. 직선 구간이 섞인 곡선과 묶음 안 곡선은 쓰지 않는다. + - 연결선은 글자처럼 두지 않고 같은 기준에 놓인 두 도형의 변 가운데(위·오른쪽·아래·왼쪽)에 붙인 직선(`STRAIGHT`)이나 + 꺾인 선(`STROKE`)이다. 한/글은 붙은 연결선을 도형 상자로 다시 그리므로 도형을 옮기거나 키워도 선이 따라간다. + 호(`ARC`) 연결선은 제어점이 필요해 거부한다(`shape-connector-kind-unsupported`). + - 오류 코드 `shape-curve-too-few-points`, `shape-connector-target-inline`, `shape-connector-frame`, + `shape-connector-side-invalid`, `shape-connector-kind-unsupported`를 더한다. +- `doc.shapes.add_drop_cap(style="DoubleLine")`을 지원한다(전에는 `TripleLine`만 받고 나머지는 + `shape-drop-cap-style-unsupported`로 거부했다). 한/글은 같은 `hp:rect` + `hp:drawText` 구조에 `hp:parameterset` + 값만 1(`TripleLine`은 2)인 첫 글자 장식을 읽고, 그리고, 그대로 저장한다. 상자 크기가 같으면 두 스타일의 + 화면이 같다. 상자 옆으로 가는 줄 수는 상자 높이가 정한다. `Margin`은 상자를 여백 쪽 어디에 두는지 근거가 + 없어 계속 거부한다. +- 쪽 수 추정(실험, `estimate_pages`)이 문단 위에서 위아래로 둔 개체와 글자처럼 둔 개체가 함께 있는 문단을 + 따른다(글이 없어도). 한/글은 글자처럼 둔 개체의 줄을 위아래 개체 아래(바깥 여백 포함)에 두고, 흐르는 표가 + 쪽 끝에서 나뉘면 표의 끝 아래에 둔다. 전에는 글이 있는 문단만 따랐고, 글자처럼 둔 개체가 함께 있으면 지원 + 밖이었다. + - 문단 위에서 아래로 띄운 위아래 개체도 글 없이 글자처럼 둔 개체만 있는 문단에서 따른다. 글자처럼 둔 개체들이 + 한 줄이고, 글 문단의 줄처럼 개체에 닿으면 개체 아래로 가고 닿지 않으면 제자리이며, 다음 문단은 보통 자리와 + 개체 아래 가운데 낮은 쪽에 온다. 그런 줄의 높이는 글자처럼 둔 개체와 글자 크기 가운데 큰 쪽이다. +- 쪽 수 추정(실험, `estimate_pages`)이 중첩 표를 품은 행이 쪽을 넘어 나뉜 흐르는 표를 따른다. 한/글은 그런 칸의 + 줄 캐시를 다음 쪽 부분에서 맨 위부터 다시 적는데, 전에는 그 칸을 읽지 못해 지원 밖이었다. 이제 위로 되돌아간 + 줄을 나뉘지 않은 칸에서 설 자리로 이어 읽고(앞 줄의 높이와 간격 아래, 문단의 첫 줄이면 문단 위), 행을 다시 + 나눈다. 문단 위에서 놓은 개체 아래에 있는 첫 줄은 그 위 공간을 함께 차지해, 개체와 함께 다음 쪽으로 간다. +- 쪽 수 추정(실험, `estimate_pages`)이 폭이 다른 다단 구역을 따른다. 모든 문단이 저장 때 남는 줄 캐시를 가지고 + 개체를 글자처럼만 두면, 줄은 캐시대로 두고 단마다 같은 본문 높이로 흐른다(전에는 지원 밖이었다). 글자처럼 두지 + 않은 개체가 있으면 지원 밖이다. +- 쪽 수 추정(실험)이 폭이 다른 다단에서 줄 캐시가 없는 문단도 따른다. 단의 폭은 본문 폭을 단 너비·간격 + (`hp:colSz`)의 비율로 나눠 반올림한 값이다. 단에 든 줄은 그 단의 폭으로 나누고, 문단이 폭이 다른 단으로 넘어가면 + 남은 글을 그 단의 첫 글자부터 그 단의 폭으로 다시 나눈다. 전에는 줄 캐시가 없는 문단이 있으면 지원 밖이었다. + 그런 문단에 개체·글자 겹치기·덧말·각주가 있으면 여전히 지원 밖이다. +- 쪽 수 추정(실험) `hwpx.experimental.estimate_pages(document)`를 더한다. 한/글 없이 본문 문단의 줄마다 쪽·단· + 세로 위치(`EstimatedLine`)와 쪽 수(`PageEstimate.pages`)를 추정한다. + - 줄 나눔: 문단의 줄 캐시가 저장 때 남는 것이면 그 줄(한/글이 그린 줄)을 쓰고, 아니면 FormFit처럼 줄을 나눈다. + 캐시의 줄은 한/글이 준 높이와 간격(`vertsize`, `spacing`)대로 놓는다. 캐시 없이 글자 크기가 여럿인 문단은 + 글자마다 제 크기로 줄을 나누고, 줄마다 그 줄에서 가장 큰 글자만큼 높으며 줄 간격도 그 크기로 센다. 빈 + run은 자리를 차지하지 않는다. 글이 든 run마다 글꼴·장평·자간이 다르면 글자마다 제 run의 것으로 줄을 + 나눈다(전에는 첫 run의 것을 모든 글자에 썼다). FormFit의 `hancom_line_starts()`에 글자마다 크기와 + 글자 모양을 주는 `sizes`·`styles`를 더한다. + - 세로: 줄 간격 네 종류, 문단 앞뒤 간격, 쪽·단 나눔, 문단 보호·다음 문단과 함께·외톨이줄, 같은 폭 다단, + 글자처럼 취급한 표·그림, 문단에 붙은 위아래 배치 개체, 본문과 함께 흐르는 표(칸·행 단위로 나눔, + 나누지 않음, 머리행 반복), 각주(다음 쪽으로 이어짐 포함)를 따른다. 줄은 끝이 본문 끝보다 위일 때만 그 + 쪽에 남는다. 끝이 본문 끝과 같은 줄(빈 줄도)은 다음 쪽으로 가고(전에는 남았다), 1 위에서 끝나는 줄은 남는다. + - 글 사이에 글자처럼 둔 개체(그림·표·도형·수식, 글 앞·가운데·뒤)는 그 자리에서 글자처럼 제 폭을 + 차지하고, 그 줄을 개체 높이까지 높인다. 줄 간격은 글의 것이고, 고정 줄 간격이면 다음 줄은 그만큼 + 아래에 온다. 줄 간격이 퍼센트·고정이 아니면 따르지 않는다. + - 글 앞으로·글 뒤로 둔 개체(글자처럼 두지 않은 것)는 자리를 차지하지 않는다. 문단·종이 어느 기준에 놓이든, + 쪽 끝을 넘어도 줄은 그 개체가 없을 때 자리에 놓인다. 전에는 그런 개체가 글이나 다른 개체와 한 문단에 + 있거나 문단에 혼자 있으면 따르지 않았다. + - 종이나 쪽(본문)의 위 끝이나 아래 끝을 기준으로 놓은 위아래 배치 개체(글자처럼 두지 않은 것)는 앵커 문단이 + 놓인 쪽 전체에 작용한다. 그 쪽에서 개체 자리(기준 끝에서 오프셋만큼 안쪽, 개체 높이만큼)에 닿는 줄은 앵커 + 문단의 줄이든 그 앞뒤 문단의 줄이든 개체 아래로 내려가고(자리가 본문 끝까지 닿으면 다음 쪽으로), 뒤 줄이 + 따라간다. 표도 같다. 다단이면 본문 너비를 다 덮는 개체가 그 쪽의 모든 단에 + 작용한다. 개체는 문단 안에서 자리를 차지하지 않는다. 같은 쪽의 흐르는 표가 앵커 줄부터 끝까지 개체 자리에 + 닿거나 그 바로 아래에서 시작하면 따르지 않는다(전에는 이런 개체를 따르지 않았다). + - 글이 든 문단에 앵커한 위아래 배치 개체(문단 위 기준, 오프셋 0)는 그 자리가 든 줄의 위에 놓이고, 그 줄과 + 문단의 나머지는 개체 아래에 온다. 흐르는 표면 행 단위로 나뉜다. 오프셋이 있으면 개체는 그 줄의 위에서 + 오프셋만큼 아래에 놓이고, 개체에 닿는 첫 줄(그 문단이나 다음 문단의)과 그 뒤 줄들이 개체 아래로 내려간다. + 흐르는 표(칸·행 단위로 나눔)는 그 자리에서 쪽 끝을 넘어 이어지고, 표에 닿는 첫 줄은 표가 끝나는 쪽의 + 표 아래로 간다(전에는 따르지 않았다). 그 밖의 개체나 그 위의 줄이 쪽을 넘으면 따르지 않는다. + - 글 앞에(또는 문단에 혼자) 둔 어울림(SQUARE) 개체(문단 위 기준, 단·문단·종이의 왼쪽이나 오른쪽에서 + 오프셋만큼 — 첫 글자 장식도 그렇다)는 곁의 두 쪽 가운데 폭이 1440 이상이고 글 흐름(양쪽·넓은 쪽·왼쪽만· + 오른쪽만)이 허락하는 쪽에 글을 둔다. 개체 높이 안에 줄의 위가 드는 줄은 그 문단과 다음 문단들에서 그만큼 + 좁아지고, 양쪽에 글이 가면 한 줄이 같은 높이의 두 조각(왼쪽 먼저)이 된다. 전에는 단 끝에 붙고 양쪽으로 + 흐르는 개체만 따랐다. 표는 행 높이의 합을 높이로 쓴다. 단보다 넓어 곁에 자리가 없는 어울림 개체는 + 위아래 배치처럼 글을 아래로 민다. 개체나 그 곁의 줄이 쪽을 넘으면 따르지 않는다. 줄 캐시에서도 같은 + 높이에 나란한 두 조각은 한 줄로 센다. + - 글자 겹치기·덧말이 든 문단은 저장 때 남는 줄 캐시가 있으면 그 줄(한/글이 그린 줄)을 따른다. 캐시가 없으면 + (표 칸 안 문단도) 글자 겹치기·덧말을 그 run의 글자처럼 제자리에 두고 줄을 나눈다. 글자 겹치기는 틀(원·사각형 + 등)이 있거나 펼침(`SPREAD`)이면 한글 한 글자의 폭, 틀 없이 겹치면 가장 넓은 글자의 폭이고, 줄 높이를 바꾸지 + 않는다. 덧말은 본말의 폭이다(덧말이 더 길어도). 덧말이 든 줄은 본말 위에 덧말(크기 비율, 0이면 절반)을 얹은 + 높이, 아래 덧말이면 본말 높이의 91/100에 덧말을 더한 높이이고(둘 다 1/1800인치 단위로 셈), 줄 간격도 그 + 높이로 센다. 전에는 캐시가 없으면 본문 문단은 따르지 않았고, 칸 안 문단은 글자 겹치기·덧말을 빼고 셌다. + - 줄 캐시를 쓸 때 줄의 높이와 다음 줄까지의 거리는 `textheight`와 `spacing`으로 센다. 글자처럼 둔 개체가 + 뒤따르는 줄은 `vertsize`가 개체 높이일 때가 있지만, 한/글은 그 줄의 글 높이만큼만 내려간다. 표 칸 안 + 문단도 저장 때 남는 줄 캐시가 있으면 그 줄 높이와 간격을 쓴다(전에는 줄 수만 쓰고 높이는 첫 글자 크기로 + 셌다). 높이가 0인 줄로 된 캐시는 없는 것으로 본다. + - 흐르는 표의 앵커 줄이 쪽 끝에 들어가지 않으면, 앵커 줄과 표가 다음 쪽 위에서 시작한다. + - 행 단위로 나누는 흐르는 표의 첫 행이 앵커 문단 아래에 들어가지 않으면, 표는 다음 쪽에서 시작한다. 표 + 뒤의 글은 앵커 아래에 이어지다가, 표가 차지한 쪽에서는 표 아래에 놓인다. + - 칸 안에 글자처럼 둔 표가 문단에 혼자 있으면, 그 줄은 표 높이(행 높이의 합과 바깥 여백)만큼 높고 줄 간격은 + 글의 것이다. 칸 단위로 나누는 표에서 그런 칸이 든 행은 칸 줄 사이에서 나뉜다. 줄마다 제 높이이고(안쪽 표는 + 한 줄), 쪽 끝에 들어가는 줄만 남고 나머지는 다음 쪽에서 이어진다. 행 단위로 나누는 표에서는 행째 다음 쪽으로 + 간다. 칸 안 표가 글과 한 문단에 있거나 글자처럼 두지 않은 것(위아래 배치나 어울림)이어도, 칸의 모든 문단이 + 저장 때 남는 줄 캐시를 가지면 따른다. 칸은 한/글이 그린 줄 위치대로 높고, 위아래 배치나 어울림 표가 마지막 + 줄보다 아래로 내려가면 그 표의 아래 끝까지 높다. 문단 위에서 위로 둔(음수 오프셋) 표는 문단 위에 선다 + (전에는 따르지 않았다). + 간다. 선언 높이가 글보다 큰 그런 행은 여느 선언 높이 행처럼 첫 줄이 들어가면 쪽 끝 바로 위에서 잘린다(아래). + 간다. + - 흐르는 표의 행은 쪽 본문 끝에서 101 HWPUNIT 위까지만 쓴다. 그보다 아래에서 끝나는 행은 다음 쪽으로 + 가고, 칸 단위로 나누는 표에서는 그 아래로 내려가는 칸 줄부터 다음 쪽에서 이어진다(전에는 쪽 끝까지 썼다). + 행 단위로 나누는 표의 첫 행이 앵커 아래에 들어가는지도 이 자리로 따진다. + - 칸 단위로 나누는 흐르는 표에서 선언 높이가 글보다 큰 행은 쪽 끝 바로 위(101 HWPUNIT)에서 잘리고, + 남은 높이는 다음 쪽으로 넘어간다. 남은 높이가 1282(기본 칸 여백의 10pt 한 줄) 이하이면 넘기지 않는다. + 칸 여백, 세로 정렬, 글자 크기는 자르는 자리와 이 문턱을 바꾸지 않는다. + - 글자처럼 취급한 표에 병합 행이나 중첩 표가 있어도, 표 안 모든 문단이 저장 때 남는 줄 캐시를 가지면 + (한/글이 배치한 그대로면) 한/글이 저장한 표 높이(`hp:sz`)를 쓴다. 그 밖의 병합 행 표(본문과 함께 흐르는 + 표 포함)는 행마다 한 행짜리 칸 가운데 가장 높은 칸의 높이이고, 병합된 칸이 걸친 행들보다 높으면 모자란 + 만큼 그 마지막 행이 높아진다. 병합 칸이 여럿이면 끝 행이 먼저인 것부터 차례로 따진다(걸친 행이 같거나, + 엇갈리거나, 한쪽이 다른 쪽을 품어도). 한 행짜리 칸이 없는 행은 높이 0에서 시작한다. 행 단위로 나누는 + 흐르는 표에서는 병합 칸으로 이어진 행들이 한 덩어리로 다음 쪽으로 간다. 칸 단위로 나누는 표에서는 그 행들의 + 칸마다 쪽 끝까지 들어가는 줄만 남고 나머지 줄이 다음 쪽에서 이어진다. 쪽 끝이 걸린 행부터는 칸의 남은 줄만큼 + 높고, 병합 칸의 남은 높이는 모자란 만큼 그 마지막 행에 더한다. 그 행들에서 선언 높이가 글보다 큰 칸은 글의 + 첫 줄이 쪽 끝에 들어가면(글이 다 들어가지 않아도) 선언 높이를 행처럼 쪽 끝 바로 위에서 자르고, 남은 높이가 + 1282 이하이면 버린다. 첫 줄도 들어가지 않으면 칸째 다음 쪽으로 간다. 글이 든 문단에 앵커한 위아래 배치 표도 + 같다. 칸·글상자 + 안 문단의 단 설정은 그 목록의 것이라 구역의 단 변경으로 보지 않는다. + - 그 밖의 것(미주, 구역 안 단 변경, 첫 문단 뒤의 구역 설정, 격자, 글과 개체가 한 문단(위의 글자처럼 둔 + 개체는 빼고)과 그런 문단의 각주, 앵커 아래나 한 쪽에 들어가지 않는 병합 행 덩어리, + 한/글이 배치한 그대로가 아닌 표에서, 줄 캐시가 없는 칸의 글과 한 문단에 있거나 글자처럼 두지 않은 중첩 표, + 표를 품은 칸 곁에 더 높은 칸이 있는 흐르는 표 행의 쪽 나눔, 다음 쪽에서 시작하는 흐르는 표 둘이 한 쪽에 + 놓임, 쪽·종이 기준 개체(글 앞·뒤 개체와, 흐르는 표가 없는 쪽의 종이 위 기준 위아래 개체는 빼고), 줄 캐시 + 없는 문단에서 위·아래가 아닌 자리나 퍼센트가 아닌 줄 간격의 덧말, 글자처럼 두지 않은 개체 곁의 글자 + 겹치기·덧말)이 있으면 `pages`는 `None`이고 + `unsupported`가 구역마다 까닭을 적는다. +- `doc.shapes.remove_unused_charts()`를 더한다. 어떤 차트도 가리키지 않는 차트 파트(`Chart/chartN.xml`)를 + 모두 지우고 지운 파트 이름을 돌려준다. 본문에서 차트를 지우면(`section.clear_body()`, 문단 삭제) 그 파트가 차트의 + 항목 이름·값과 함께 파일에 남았다. 한/글은 문서를 저장할 때 이런 파트를 지운다. `docs/known-traps.md`와 + `clear_body()` 설명에도 적는다. +- `export_text()`·`export_html()`·`export_markdown()`에 `notes`를 더한다. `True`면 각주·미주를 본문·칸·글상자 + 안의 제자리에 한/글 텍스트 저장처럼 쓴다. 참조 번호(`1)`) 뒤에 각주의 문단마다 그 번호로 시작하는 글과 + 줄바꿈이 온다. 번호는 각주·미주를 따로 문서 순서로 매기고, 구역의 번호 모양(`autoNumFormat`)과 번호 + 매기기(`ON_SECTION`은 구역마다 `newNum`부터, 새 번호 지정 `hp:newNum`)를 따른다. 쪽마다 새로 매기는 + 번호는 쪽을 나누지 않으므로 이어 매긴다. 기본값은 전처럼 각주·미주를 쓰지 않는다. +- `doc.styles.apply_list_format()`에 `continue_list`를 더한다. `True`면 대상 문단 앞의 마지막 번호 + 목록(개요는 개요 목록)과 같은 번호 정의를 써서 한/글에서 번호가 이어진다. 전에는 부를 때마다 번호 + 정의를 새로 만들어, 문단마다 따로 부르면 목록이 매번 1부터 다시 시작했다(기본 동작은 그대로다). 앞에 + 그런 목록이 없으면 새 목록을 만든다. 앞 목록의 번호 정의에 없는 더 깊은 수준으로 이어 붙이면 그 수준의 + 모양을 새 목록과 같은 꼴(`^1.^2.`)로 정의에 더한다. 한/글은 번호 정의에 없는 수준에는 번호를 그리지 + 않는다. `number_format`·`start`와 함께 주면 `style-list-continue-conflict`로 거부한다. +- `doc.media.remove_unused_images()`를 더한다. 문서가 어디서도 가리키지 않는 이진 항목(매니페스트 항목·파트· + header의 `binItem`)을 모두 지우고 지운 항목을 돌려준다. 한/글은 문서를 저장할 때 이런 항목을 지운다. 본문에서 + 그림을 지우면(`section.clear_body()`, 문단 삭제) 그 그림의 이진 항목이 남으므로 이 호출로 정리한다. `remove_image`가 + 확인하는 참조(그림, 채우기 그림·그림 글머리표, 바탕쪽, 동영상, OLE, 내장 글꼴)가 있는 항목은 남긴다. +- `doc.fields.text_boxes`와 `doc.fields.fill_text_box()`를 더한다. 한/글은 이름이 있는 글상자 + (`hp:drawText@name`)를 필드로 보고, 필드 목록에 넣고 이름으로 채운다. `text_boxes`는 이름 붙은 글상자를 문서 + 순서로 돌려준다(이름 없는 글상자는 뺀다). `fill_text_box(value, name=, index=)`는 한/글처럼 글상자의 글을 값 한 + 문단으로 바꾼다. 첫 문단과 첫 run의 모양은 그대로이고, 줄바꿈은 `hp:lineBreak`로 쓴다. 같은 이름의 글상자는 + 모두 채우고, 없는 이름은 `field-text-box-not-found`로 거부한다. `set_draw_text(name=)`의 설명도 고쳤다(이름이 + 글상자를 필드로 만든다). +- `doc.parts.remove_master_page(id)`를 더한다. 어느 구역도 참조하지 않는 바탕쪽의 매니페스트 항목과 + 파트를 지우고, 저장된 적이 있어 패키지에 있는 파트 파일도 지운다. 구역이 참조하는 바탕쪽은 + `master-page-in-use`, 없는 id는 `master-page-not-found`로 거부하고 아무것도 바꾸지 않는다. +- `doc.parts.add_master_page()`에 `section`을 더한다. 주면 만든 바탕쪽을 그 구역에 바로 연결한다. + 그 구역에 같은 쪽의 바탕쪽이 이미 있으면 파트를 만들기 전에 `master-page-pages-taken`으로 + 거부하므로, 거부된 호출은 문서에 아무것도 남기지 않는다. +- `add_rectangle()`·`add_ellipse()`(`doc.shapes`와 문단)에 `original_size=(w, h)`를 더한다. + `hp:orgSz`를 그리는 크기와 따로 쓴다. 도형과 기하는 원래 크기로 만들고, `curSz`·`sz`는 + `width`×`height`, `scaMatrix`는 두 크기의 비율, 회전 중심은 `curSz`의 절반이다. 한/글이 저장한 + 코퍼스 파일의 크기 바뀐 도형과 같은 배치다. 주지 않으면 출력은 전과 같다. 양의 정수 두 개가 + 아니면 `shape-original-size-invalid`로 거부한다. 한/글에서 렌더는 확인하지 않았다. +- `doc.styles.replace_font()`에 `font_type`(`REP`/`TTF`/`HFT`)을 더한다. 이 호출이 새로 선언하는 + 글꼴의 `type`을 정한다. 이미 있던 글꼴은 바꾸지 않는다. + +- `hwpx.opc.relationships.ManifestItemRef`에 매니페스트 항목의 `isEmbeded` 값을 담는 `is_embeded` 필드를 + 더한다. +### 바꿈 + +- `hwpx.experimental.latex_to_eqedit`(실험)가 `\text{…}`·`\mathrm{…}`·`\textrm{…}`·`\mbox{…}`를 `{rm "…" it}`로 + 옮긴다(#347). 전에는 따옴표 글 `"…"`로 옮겼는데, 한/글은 따옴표 글을 기울여 그린다. `rm`은 곧게 그리되 중괄호 + 밖으로 번지므로 `it`으로 뒤 글자를 기울임으로 되돌린다(예: `\mathrm{d}x` → `{rm "d" it} x`). + - `eqedit_to_latex`는 이 묶음을 `\text{…}`로 되읽는다. 따옴표 글은 전처럼 `\text{…}`로 읽되, 낱말 하나가 + EqEdit 예약어인 것(쓰는 쪽이 기호로 바뀌지 않게 감싼 `T _{"int"}` 같은 것)은 맨 글자로 읽는다. 그래서 쓰기 → + 읽기 → 쓰기가 전처럼 고정점이다. +- FormFit이 칸의 높이 예산으로 저장된 안쪽 높이를 그대로 쓴다. 한/글은 그 높이까지 줄을 넣어도 행을 키우지 않고, + 줄 간격은 한/글과 같게 센다. 폭의 안전 여유(0.93)는 그대로다. 전에는 높이에도 0.93을 곱해, 칸에 들어가는 값을 + 줄이거나 거부했다(예: 안쪽 2047에 1939를 쓰는 두 줄 값). +- FormFit이 화살표·도형 기호·그 밖의 기호(U+2190–21FF, U+25A0–25FF, U+2600–26FF: → ○ □ △ ☆ ☎ 등)를 글꼴 폭 + 표에 없는 글꼴에서 전각(1 em)으로 센다. 표에 있는 한국어 글꼴에서 이 기호들은 0.89~1 em이다. 전에는 문장 + 부호 폭으로 세어, 표에 없는 글꼴의 ○ 다섯 개를 한 줄로 예측했는데 한/글은 두 줄로 나눴다. +- 탭 정의를 한/글처럼 읽고 쓴다. 한/글이 저장한 `hh:tabPr`은 탭마다 `hp:switch`로 감싸 `hp:case`에 HWPUNIT 위치 + (`unit="HWPUNIT"`)를, `hp:default`에 그 두 배를 쓴다. `doc.styles.tab_properties`·`tab_property()`의 `tab_stops`가 + 이제 모든 스위치의 `hp:case` 위치를 읽는다. 전에는 첫 스위치의 `hp:default`만 읽어서, 탭이 여럿인 정의를 탭 + 하나로, 위치는 두 배로 보고했다. 스위치 없는 `hh:tabItem`은 전처럼 그 값 그대로다(한/글도 HWPUNIT으로 읽는다). + `ensure_tab_definition()`(`apply_paragraph_format(tab_stops=…)`, 차례 만들기)도 탭마다 같은 스위치로 쓰고, 같은 + 정의를 찾을 때 `hp:case` 위치로 비교한다. +- FormFit이 저장 높이가 한 줄보다 낮은 칸(글에 따라 늘어나는 칸)에도 높이 예산을 쓴다. 한/글은 이런 + 행을 그 행에서 가장 높은 칸만큼 그린다. 그 높이에서 이 칸의 위아래 여백을 뺀 것이 예산이다. 칸의 + 높이는 한/글이 배치한 줄(줄 캐시)의 끝에 여백을 더한 것이고, 줄 캐시가 없으면 그 칸의 글을 잰 줄 + 수로 계산한다. 저장 높이가 더 크면 저장 높이다. 전에는 이런 칸을 너비로만 맞춰서, 행이 담는 것보다 + 줄이 많은 값을 쓰면 행이 커졌다. 이제 다른 행과 같이 정책을 따른다. 기본 정책은 그런 값을 거부하고, + `allow_row_expand`를 주면 전처럼 행이 커진다. +- FormFit이 줄이 이미 저장 높이를 넘친 행에서는 한/글이 그린 높이를 예산으로 쓴다. 한/글은 칸의 줄이 저장 + 높이를 넘으면 행을 그 줄만큼 그린다. 전에는 저장 높이로만 예산을 잡아, 원래 두 줄이던 칸에 두 줄 값을 + 넣어도 한 줄로 줄이거나 거부했다. +- `doc.fields.fill()`이 값을 누름틀 시작이 든 런의 글자 모양으로 쓴다. 한/글은 값이 이미 든 누름틀을 채울 + 때도 옛 값의 글자 모양(파란 글자, 굵게 등)을 버리고 시작 런의 모양으로 쓴다. 전에는 안내문(자리표시)을 + 바꿀 때만 시작 런의 모양으로 바꾸고, 값이 든 누름틀에서는 옛 값 런의 모양을 지켰다. 그런 누름틀을 채운 + 결과의 `style_preserved`는 이제 `False`다. +- 셀 글 쓰기(`table.set_cell_text()`, `cell.text = …`, `doc.fields.fill_cell()`)가 값을 받은 문단 밖의 빈 문단을 + 모두 지운다. 한/글은 칸을 채우면 값을 칸의 한 문단으로 만든다. 전에는 이번 쓰기로 비운 문단만 지우고 원래 + 비어 있던 문단(빈 줄)은 남겼다. 그래서 칸이 빈 줄만큼 높았고, `cell.text`가 쓴 값과 다르게 읽히기도 했다. + 안쪽 표·개체를 담은 문단은 전처럼 남는다. +- `add_paragraph(style=…)`가 문단에 그 스타일의 문단 모양과 글자 모양을 쓴다. 한/글이 스타일을 + 적용할 때처럼 문단의 `paraPrIDRef`와 첫 run의 `charPrIDRef`가 스타일의 것이 된다. 전에는 + `styleIDRef`만 바꾸고 모양은 앞 문단에서 물려받아, 한/글에서 개요 스타일 문단이 본문처럼 보이거나 + 앞 제목의 번호가 붙었다. `para_pr_id_ref`·`char_pr_id_ref`나 `run_attributes`의 `charPrIDRef`를 + 함께 주면 그 값을 쓴다. 숫자 id를 쓰는 `style_id_ref`는 전처럼 스타일 이름만 붙인다. + 개요 스타일 문단에는 한/글이 개요 번호를 붙이므로 README 예시에서 글에 쓴 번호를 뺐다. +- FormFit이 로마 숫자(Ⅰ–Ⅻ, ⅰ–ⅻ)와 원 숫자·괄호 숫자(①–⑳, ⑴–⒇)를 글꼴에서 한/글이 배치하는 폭으로 센다. + 함초롬바탕·함초롬돋움은 0.972 em, 맑은 고딕은 로마 숫자 0.952 em·원 숫자 1 em이다. 글꼴 폭 표에 없는 + 글꼴이나 글자도 전각(1 em)으로 본다. 전에는 평균 폭(0.62 em)으로 세어, 이런 글자가 든 칸을 한 줄로 + 예측했는데 한/글은 두 줄로 나눴다. +- FormFit이 한글 한 자를 글자 모양의 한글 글꼴에서 한/글이 배치하는 폭으로 센다. + 함초롬바탕·함초롬돋움은 0.972 em, 한컴 고딕은 0.932 em이고, 다른 글꼴(맑은 고딕·바탕· + 돋움·굴림·궁서 등)은 전처럼 1 em이다. 이 세 글꼴의 셀과 누름틀에는 전보다 한 줄에 글자가 + 조금 더 들어간다고 본다. + - `TextStyle.hangul_advance`(em, 기본 1.0)를 더했다. 글자별 폭을 모르는 글꼴의 한글 폭이다. +- FormFit이 한글이 아닌 글자(숫자, 영문, 문장 부호, 자주 쓰는 기호)도 함초롬바탕·함초롬돋움·맑은 + 고딕에서는 한/글이 배치하는 글자별 폭으로 센다. 전에는 글자 종류별 평균(숫자 0.55 em, 문장 부호 + 0.42 em 등)을 썼다. 한글·영문·기타·기호 글꼴이 모두 같은 글자 모양일 때 쓰고, 다른 글꼴이나 표에 + 없는 글자는 전처럼 평균을 쓴다(`TextStyle.glyph_face`, `hwpx.form_fit.measure.glyph_advance_em`). +- FormFit의 세로 예산(`SlotMetrics.height_lines`)이 한/글이 칸을 늘리는 식을 따른다. n줄은 + (n − 1) × 줄 간격 + 글자 크기를 차지하고 마지막 줄의 줄 간격은 들어가지 않는다. 전에는 n × 줄 + 간격으로 셌다. 줄 간격은 비율(`PERCENT`)뿐 아니라 고정(`FIXED` = 값), 여백만(`BETWEEN_LINES` = 크기 + + 값), 최소(`AT_LEAST` = 크기와 값 중 큰 것)도 읽는다(`SlotMetrics.line_spacing`). 전에는 비율이 + 아니면 160%로 봤다. 비율 줄 간격에서 크기를 넘는 부분은 한/글처럼 1/1800인치(4 HWPUNIT) 단위로 + 센다(크기를 그 단위로 내린 em에 비율을 곱해 반올림, 10.5pt 160%는 630이 아니라 628). +- FormFit이 글자 폭을 한/글처럼 1/1800인치(4 HWPUNIT) 단위로 센다. 글꼴의 설계 폭을 글자 크기에 + 맞춘 뒤 장평이 100%면 반올림하고 다른 장평이면 내린다. 자간은 그 폭에 비율을 곱해 0에서 먼 쪽으로 + 반올림해 더한다. 반각 공백은 em의 절반(내림)에 장평을 곱해 반올림한다. 전에는 10pt에서 본 비율을 + 크기에 곱하기만 해서, 10pt가 아닌 크기나 장평·자간이 있는 칸에서 줄 끝이 한/글과 어긋날 수 있었다. + - 글자별 폭을 아는 글꼴에 바탕·바탕체·돋움·돋움체·굴림·굴림체·궁서·궁서체·한컴 고딕을 더했다(모두 + 12개). 굵은 글자도 같은 폭을 쓴다. + - `TextStyle.hangul_face`를 더했다. 셀·누름틀 슬롯은 첫 run 글자 모양의 한글 글꼴 이름으로 채우고, + 표에 있는 글꼴이면 `hangul_advance` 대신 그 글꼴의 한글 폭을 쓴다. + - `glyph_advance_em()`은 글꼴의 설계 폭(em)을 돌려준다. +- FormFit이 칸 줄 폭을 한/글처럼 센다. 1440 HWPUNIT 하한을 들여쓰기를 뺀 뒤 줄마다 적용하고(좁은 칸의 + 내어쓰기 줄도 1440), 칸보다 넓은 인라인 개체 다음 줄들은 칸 폭으로 센다(`SlotMetrics.line_width`, + `SlotMetrics.min_line_width`). +- FormFit이 칸 첫 줄의 인라인 개체(체크 상자·그림 등) 뒤에 글의 첫 글자도 들어가지 않으면, + 한/글처럼 글이 다음 줄에서 시작한다고 센다. 전에는 첫 줄에 한 글자를 억지로 넣어 한 줄 적게 + 세거나, 개체 뒤 폭이 0이면 들어갈 수 없다고 보았다. +- 한 줄에 좁혀 넣는 칸(`lineWrap="SQUEEZE"`)에 글을 넣으면(`cell.text = …`, `set_cell_text()`), 값이 칸 줄 폭의 + 1.1배 이하일 때는 SQUEEZE를 그대로 둔다. 전에는 글이 바뀌면 늘 BREAK로 바꿔서, 줄 폭을 조금만 넘어도 줄이 + 바뀌고 행이 커져 쪽이 밀렸다. 한/글은 SQUEEZE 칸의 글을 한 줄에 두고 글자 사이만 좁히며, 1.1배까지는 읽히고 + 그보다 길면 글자가 닿거나 겹친다. 그래서 더 긴 값은 전처럼 BREAK로 바꾼다. 폭은 칸의 글자 모양으로 잰다. +- 도형의 기본 선 굵기를 283(1 mm)에서 33(0.12 mm)으로 바꾼다. 한/글이 새 도형에 쓰는 굵기다. + `doc.shapes.add_line()`·`add_rectangle()`·`add_ellipse()`·`add_arc()`·`add_polygon()`, 문단의 같은 이름 + 메서드, 묶음 도형의 멤버가 이 기본값을 쓴다. `line_width`를 주지 않은 도형은 전보다 가늘게 그려진다. + 옛 5.x 이름(`doc.add_rectangle()` 등)은 시그니처를 그대로 두기로 한 호환 계층이라 전처럼 283이다. +- `set_footnote_numbering()`·`set_endnote_numbering()`의 설명과 `docs/known-traps.md`에, 시작 번호(`new_num`)는 + 번호 방식이 `ON_SECTION`일 때만 한/글에 보인다고 적는다. `CONTINUOUS`·`ON_PAGE`는 1부터 매긴다. +- FormFit이 칸을 채울 때 기본으로 행 높이를 지킨다. 값이 칸 높이가 담는 줄 수보다 조금 많으면 전에는 줄여 넣지 + 못한 경우 경고(`overflow_detected`)만 남기고 값을 넣어 행이 늘었고, 그 뒤 쪽이 밀렸다. 이제 넘는 정도와 + 관계없이 글자를 줄여(`min_font_pt`까지) 칸 안에 넣고, 그래도 안 되면 `FitPolicy.overflow`를 따른다. 기본 + `fail`은 채우지 않는다(`doc.fields.fill()`은 `field-fit-failed`, `set_cell_text(fit=...)`는 칸을 그대로 두고 `ok=False`인 결과). 행을 늘려도 되면 + `FitPolicy(allow_row_expand=True)`, 경고만 받고 넣으려면 `FitPolicy(overflow="warn")`을 준다. 칸 높이를 잴 수 없는 + 칸(병합·자동 늘어남)은 전처럼 폭만 본다. +- `docs/known-traps.md`와 `section.clear_body()` 설명에, 본문에서 지운 그림(`clear_body()`, 문단 삭제)의 이미지가 + 파일에 남는다고 적는다. 파일에서도 없애려면 저장 전에 `doc.media.remove_unused_images()`를 부른다. 한/글은 + 저장할 때 아무것도 가리키지 않는 이미지를 지운다. + +### 고침 + +- 쪽 수 추정(실험, `estimate_pages`)이 줄 사이에서 나뉘는(CELL) 흐르는 표의 아래 캡션을 한/글처럼 둔다. 마지막 행이 글보다 + 높게 선언돼 있으면, 그 행과 캡션을 함께 다음 쪽으로 보내지 않고 행의 빈 높이를 캡션 위에서 자른다. 쪽 끝에서 빈 높이를 + 자르는 규칙과 같다. 남은 높이가 1282 이하면 버리고 캡션은 그 쪽에 남는다. 넘으면 남은 부분과 캡션이 다음 쪽으로 간다. + 빈 높이로 모자라면 전처럼 행이 캡션과 함께 넘어간다. +- 쪽 수 추정(실험, `estimate_pages`)이 문단 위에서 단이나 문단의 왼쪽·오른쪽 끝을 기준으로 둔 어울림(SQUARE) + 개체 가운데, 글이 갈 수 있는 양옆에 1440보다 좁은 자리만 남기는 것을 위아래 개체처럼 받는다. 전에는 단보다 좁으면 + 지원 밖(`wrapped square leaving little room for text`)이었다. + - 글은 개체 아래(문단 윗선 + 개체 높이)로 간다. 표만 든 문단의 빈 줄은 표 윗선에 있고, 다음 문단은 표 아래에서 + 시작한다. +- 쪽 수 추정(실험, `estimate_pages`)이 줄 캐시 없는 문단의 글머리표·번호 표지가 자기 글자 모양을 가질 때 그 크기를 + 세지 않던 것을 고친다. 한/글은 그런 표지를 문단 첫 줄에 놓인 그 크기의 글자로 두어, 표지가 글보다 크면 첫 + 줄이 표지만큼 높아지고 줄 간격도 그 크기로 센다(고정 간격이면 다음 줄은 그 간격만큼 아래). 표지가 글보다 + 작으면 그대로다. 본문 문단과 표 칸 문단 모두 그렇다(`_head_size`, `_line_metrics`의 `head`). +- 쪽 수 추정(실험, `estimate_pages`)이 줄 캐시 없는 표 칸 문단의 글자 크기를 문단의 첫 run에서 읽던 것을 + 고친다. 칸 문단이 다른 크기의 빈 run으로 시작하면(빈 문단에 run을 더해 쓴 문단처럼) 그 빈 run의 크기로 줄 + 높이와 간격을 셌다. 이제 본문 문단처럼 글이 든 run의 크기를 쓰고, 빈 run은 자리를 차지하지 않는다. +- 수식 상자 측정(`measure_equation`, `estimate_equation_size`)이 기호를 뜻하는 수식 명령어를 그 철자 글자들로 + 재던 것을 고친다. `lbrace`·`rbrace`(대소문자 무관)는 중괄호 한 글자로, `LINE`·`vert`는 `|`, `VERT`는 겹세로줄로 + 잰다. `CHOOSE`·`BINOM`처럼 대문자로 쓴 이항계수도 `choose`·`binom`처럼 두 줄로 잰다. 전에는 + `lbrace x | x > 0 rbrace`가 한/글이 저장한 상자보다 2.5배 넓었다. 다음 폭도 맞춘다: `prime`과 `'`는 좁은 표시, + `DEG`는 도 기호 하나, `theta` 등 그리스 글자는 글자별 폭, `root`의 짧은 지수는 근호 왼쪽 홈에 들어가 폭을 더하지 + 않고, 근호와 행렬 괄호는 더 좁다. `atop`(과 같은 모양의 `choose`·`binom`)은 가로줄이 없어 `over`보다 낮고, + `LEFT`/`RIGHT` 괄호는 높이를 더하지 않으며, 그 묶음(과 분수)에 붙은 첨자는 묶음의 위아래를 따라 밖으로 + 나간다. 따옴표 안의 대문자·숫자는 글자 종류대로 잰다. +- 수식 측정 모듈 설명에서 "한컴은 문서를 열 때 수식을 다시 재지 않는다"는 문장을 관찰한 대로 고친다. macOS + 한/글은 저장할 때 저장된 크기와 상관없이 수식을 다시 배치해 ``를 다시 쓰는 것이 관찰되었다. 파일을 읽어 + 쪽을 배치하는 다른 프로그램에는 저장된 상자가 그대로 쓰인다. +- FormFit(과 줄 캐시 없는 문단의 쪽 수 추정)이 글머리표·문단 번호·개요 번호가 붙은 문단의 줄을 표지 없이 나누던 + 것을 고친다. 이제 표지 폭과 너비 조정(`widthAdjust`), 본문과의 간격(`textOffset`: 표지 글자 크기의 %나 + HWPUNIT)만큼 첫 줄이 좁아진다. 자동 내어 쓰기(`autoIndent`)면 둘째 줄부터도 좁아지고, 문단의 들여쓰기는 + 둘째 줄부터만 든다(내어쓰기면 더 좁고, 첫 줄 들여쓰기면 그만큼 넓다). 표지 폭은 실제 너비 사용 + (`useInstWidth`)이면 표지 글자들의 폭이고, 아니면 ASCII 글자는 반 em, 다른 글자는 1 em이다(번호는 그 형식의 + 글자 하나로 센다). 쪽 수 추정은 번호 문단마다 한/글이 그리는 번호를 읽는 순서대로 세어 쓰고, FormFit은 그 + 수준의 첫 번호를 쓴다(`TextStyle.head`·`head_hangs`, `indented_widths()`, `label_room()`, + `paragraph_label()`). +- 쪽 수 추정(실험, `estimate_pages`)이 줄 캐시 없는 문단 끝의 빈 run을 세지 않던 것을 고친다. 한/글은 문단 끝을 + 마지막 run의 글자 모양으로 두어, 빈 run이 문단을 끝내면 마지막 줄이 그 글자 크기만큼 높아진다(빈칸만 든 + 줄도, 글이 든 줄도). 견주는 것은 마지막 줄의 글자다. 앞 줄에 더 큰 글자가 있어도 그렇다. 글 앞의 빈 run은 + 전처럼 자리를 차지하지 않는다. +- FormFit이 묶음 빈칸(U+00A0, `hp:nbSpace`)과 고정폭 빈칸(U+3000, `hp:fwSpace`)을 한/글과 다르게 세던 것을 + 고친다. 묶음 빈칸은 반각 빈칸만큼 넓고, 줄 끝에 걸리지 않으며 그 자리에서 줄이 나뉘지 않는다(앞뒤 낱말이 한 + 낱말이 된다). 전에는 줄 끝에 걸리는 빈칸으로 보았다. 고정폭 빈칸은 장평·자간·최소 공백과 상관없이 글자 크기의 + 1/4(1/1800인치 단위로 버림)이고, 빈칸처럼 줄 끝에 걸리며 그 뒤에서 줄이 나뉜다. 전에는 온폭 글자로 보았다. + 줄 캐시 없는 문단의 쪽 수 추정은 둘을 글에서 빼고 줄을 나누었는데, 이제 글자로 센다. +- 쪽 수 추정(실험, `estimate_pages`)이 나누지 않게 한 흐르는 표(`hp:tbl@pageBreak="NONE"`)의 높이를 세지 않던 + 것을 고친다(다음 쪽으로 넘어갈 때 쪽 수만 하나 더했다). 한/글은 그런 표의 행이 앵커 줄 아래에 다 들어가면 + 여느 흐르는 표처럼 그 자리를 차지하고, 다음 문단은 표 끝 아래에 온다. 다 들어가지 않으면 표만 다음 쪽 맨 + 위로 옮기고, 뒤 글은 앵커 줄 아래에 이어지며, 다음 쪽의 줄은 표 아래에서 시작한다(쪽보다 큰 표도 다음 쪽으로 + 간다). 글이 든 문단 맨 앞에 둔 표는 그 아래 줄까지 들어가지 않으면 문단째 다음 쪽으로 간다. +- FormFit(과 줄 캐시 없는 문단의 쪽 수 추정)이 최소 공백(`condense`)을 한/글과 다르게 세던 것을 고친다. 빈칸은 + 자간을 뺀 빈칸 폭의 그 비율만큼 줄어든다(전에는 자간을 넣은 폭의 비율). 빈칸이 줄어드는 것은 여백을 넘는 + 낱말을 넣을 때뿐이다. 줄의 글이 자간을 넣어 여백에 닿으면 그 뒤 빈칸은 모두 줄 끝에 걸리고 다음 낱말은 다음 + 줄에서 시작한다. 전에는 빈칸을 줄여 다음 낱말도 넣었다. +- FormFit이 모든 글자에 한글의 장평·자간을 쓰던 것을 고친다. 글자 모양이 영문·기호·한자에 따로 준 값이 있으면 + 그 글자에 쓴다(`glyph_script()`, `TextStyle.scripts`). 영문은 U+024F까지(숫자, 문장 부호, 가운뎃점, °, ×)와 + ‘’“”다. 기호는 일반 문장 부호(줄표, 줄임표, ‰, ※), 글자꼴 기호(℃), 숫자 꼴(Ⅰ, ①), 화살표, 도형, CJK 기호와 + 괄호, 전각 문자다. 한자는 CJK 한자다. 반각 빈칸은 한글 장평에 영문 자간을 쓴다. +- 쪽 수 추정(실험, `estimate_pages`)이 줄 캐시 없는 표 칸 문단의 줄을 칸 안쪽 폭 그대로, 첫 run의 글자 크기와 + 모양 하나로 나누던 것을 고친다. 이제 본문 문단처럼 문단 여백과 첫 줄 들여쓰기(내어쓰기면 둘째 줄부터)를 빼고, + 글자 크기나 모양이 섞이면 글자마다 제 크기와 모양으로 나누며, 줄마다 그 줄의 가장 큰 글자만큼 높다. +- 묶음(`hp:container`)의 `resize()`가 원래 크기(`orgSz`)를 새 크기로 덮고 배율 행렬과 구성원은 그대로 두던 것을 + 고친다. 한/글은 묶음을 `sz` 크기로 그리고, 크기를 바꾼 묶음을 저장할 때 `orgSz`를 두고 `sz`·`curSz`를 새 + 크기로(바뀌지 않은 축의 `curSz`는 0), 묶음의 `scaMatrix`를 새 크기/원래 크기로 쓴다. 구성원마다(묶음 안 묶음의 + 구성원도) 첫 `scaMatrix`에 같은 배율과 오프셋 × (배율 − 1)만큼의 이동을, `curSz`에 `orgSz` × 들어 있는 묶음들의 + 배율(버림)을 쓴다. 이제 `resize()`가 그 모양을 쓴다. 전에도 그림은 새 크기였지만, 한/글이 다시 저장하면 + `curSz`가 그린 크기의 두 배로 기록됐다. +- 메모 API(`doc.notes.memos`, `doc.notes.remove_memo()`)가 MEMO 필드에만 든 메모를 보지 못하던 것을 고친다. 한/글은 + 메모를 `hp:memogroup` 없이 MEMO 필드(`hp:fieldBegin type="MEMO"`)의 `hp:subList`에 두며, HWP를 HWPX로 읽은 + 문서도 그렇다. 이제 `notes.memos`가 그런 메모도 문서 순서대로 돌려준다(`HwpxOxmlFieldMemo`: id·번호·작성자는 + 필드의 매개변수, 글은 `hp:subList`). 메모 그룹과 필드에 함께 있는 메모(python-hwpx가 앵커를 건 메모)는 한 번만 + 센다. `remove_memo()`는 필드의 두 제어와 메모 글을 지우고 필드가 걸친 글은 남긴다. python-hwpx가 앵커를 건 + 메모를 지우면 그 MEMO 필드도 함께 지운다(전에는 필드가 남아 한/글에 메모가 그대로 보였다). +- 쪽 수 추정(실험, `estimate_pages`)이 칸 줄 사이에서 나누는 흐르는 표 행을 한 칸(가장 높은 칸)의 줄로만 나누던 + 것을 고친다. 한/글은 모든 칸의 첫 줄이 쪽 끝 바로 위까지 들어갈 때만 행을 나누고, 그렇지 않으면 행째 다음 + 쪽으로 보낸다. +- 쪽 수 추정(실험, `estimate_pages`)이 글자처럼 둔 개체가 든 줄의 줄 간격을 개체가 든 런의 글자 크기 없이 세던 + 것을 고친다. 한/글은 그 줄의 가장 큰 글자로 줄 간격을 세며, 개체는 그 런의 글자 크기로 센다. 개체 곁에 공백만 + 있으면(또는 아무것도 없으면) 그 문단의 어느 런이든 가장 큰 글자 크기로 센다(전에는 가장 작은 글자). +- 쪽 수 추정(실험, `estimate_pages`)이 표의 캡션을 세지 않던 것을 고친다. 한/글은 표 위나 아래의 캡션에 그 줄과 + 간격만큼 자리를 둔다(글자처럼 둔 표는 그 줄이 그만큼 높다). 아래 캡션이 표의 마지막 행과 함께 쪽 끝 바로 + 위까지 들어가지 않으면 그 행과 함께 다음 쪽으로 간다. 표 곁의 캡션은 자리를 두지 않는다. +- 쪽 수 추정(실험, `estimate_pages`)이 문단에 혼자 있는 흐르는 표의 오프셋(문단 위에서 아래로 둔 거리)을 무시하던 + 것을 고친다. 한/글은 그 표를 문단 위(문단 위 간격보다 위)에서 오프셋만큼 아래에서 시작한다(위로 둔 표는 문단 + 위에서). 다음 문단은 표 끝과, 앵커 문단의 줄 뒤에 제 문단 위 간격을 더한 자리 가운데 더 아래에 놓인다. 오프셋을 + 두고 흐르는 표 뒤에 그런 문단이 오면, 그 줄은 앞 표가 끝난 자리로 내려가고 표는 거기서 흐른다(전에는 지원 + 밖이었다). +- 쪽 수 추정(실험, `estimate_pages`)이 선언 높이가 글보다 큰 흐르는 표 행을 쪽 끝에서 자를 때, 들어가지 않은 글 + 줄을 잃던 것을 고친다. 남은 선언 높이가 1282 이하이면 버렸지만, 한/글은 들어가지 않은 줄을 다음 쪽으로 보내고 + 남은 높이를 그 줄과 칸 여백만큼은 둔다. 같은 높이의 칸이 여럿이면 줄이 가장 많은 칸으로 행을 나눈다(전에는 + 첫 칸). +- 쪽 수 추정(실험, `estimate_pages`)이 제목 줄을 반복하는 흐르는 표에서 행의 제목 여부를 가장 높은 칸 하나로만 + 보던 것을 고친다. 그 칸이 제목 칸이 아니면 행을 다음 쪽에 되풀이하지 않았지만, 한/글은 제목 칸이 하나라도 + 있는 행을 되풀이한다. +- 쪽 수 추정(실험, `estimate_pages`)이 크기를 자동으로 조절하지 않게 한 흐르는 표(`hp:tbl@noAdjust="1"`)의 쪽 + 끝을 여느 표처럼 보던 것을 고친다. 한/글은 여느 표의 행과 칸 줄을 본문 끝 101 위까지만 두지만, 그런 표는 + 2 위까지 둔다(끝이 1 위면 다음 쪽). 선언 높이가 글보다 큰 행도 거기서 자른다. +- 쪽 수 추정(실험, `estimate_pages`)이 음수인 바깥 여백(파일에는 부호 없는 32비트 수로 남는다)을 그 큰 수 그대로 + 세던 것을 고친다. 한/글은 그런 여백을 없는 것으로 배치하고, 저장하면 0으로 쓴다. +- 쪽 수 추정(실험, `estimate_pages`)이 여러 쪽에 걸친 흐르는 표의 바깥 여백을 쪽 끝과 다음 쪽에서 따르지 않던 + 것을 고친다. 한/글은 표의 행과 칸 줄을 본문 끝에서 바깥 아래 여백을 뺀 자리의 바로 위까지만 두고, 다음 쪽에서는 + 표를 바깥 위 여백만큼 내려 다시 시작한다. 전에는 두 여백을 표의 처음과 끝에서만 셌다(여백이 0인 표는 그대로). +- 쪽 수 추정(실험, `estimate_pages`)이 흐르는 표(글자처럼 두지 않은 표)를 품은 문단의 쪽 나누기를 무시하던 것을 + 고친다. 그 표는 앞 글이나 앞 표 바로 뒤에 놓였지만, 한/글은 새 쪽에서 시작한다. 앞 표가 다음 쪽까지 이어지면 + 그 표가 끝난 쪽의 다음 쪽이다. +- 편집기 안전 검사가 한/글이 여는 중에 멈추는 바탕쪽을 통과시키던 것을 고친다. 이제 `hp:subList`가 없는 + 바탕쪽(`masterPage`)과 바탕쪽 파트의 문단 없는 `hp:subList`는 오류다(문단 없는 `hp:subList` 검사는 전에는 + 구역 파트만 보았다). `doc.parts.add_master_page(paragraphs=[])`는 문단 없는 바탕쪽을 쓰던 것을 고쳐, 이제 + 빈 문단 하나를 쓴다. +- 문단 모양 조회(`paragraph_property()`)가 부를 때마다 헤더의 문단 모양을 모두 다시 읽던 것을 고친다. 이제 찾는 + 문단 모양 하나만 읽고, 찾는 규칙(id, 숫자로 고친 id)은 `paragraph_properties`와 같다. FormFit 칸 채우기처럼 칸이나 + 문단마다 문단 모양을 찾는 일이, 문단 모양이 많은 문서에서 크게 빨라진다. +- `remove_section()`이 지운 구역의 파트(`Contents/sectionN.xml`)를 패키지에 남기던 것을 고친다. 매니페스트는 + 그 파트를 더 가리키지 않지만 저장한 파일 안에 지운 구역의 글이 그대로 남았다(한/글은 읽지 않고, 다시 저장하면 + 없어진다). 이제 구역을 지우면 그 파트도 지운다. +- 편집기 안전 검사가 한컴이 열지 못하거나 여는 중에 멈추는 찾아보기 표시·도형 매개변수 묶음을 통과시키던 것을 고친다. + 이제 다음은 오류다: `hp:firstKey`가 없는 `hp:indexmark`, `hp:parameterset` 안의 `name` 없는 `hp:listParam`· + `hp:unsignedintegerParam`, `cnt`로 매개변수가 있다고 하면서 하나도 없는 `hp:parameterset`·`hp:listParam`. + `cnt="0"`인 빈 묶음과, 매개변수가 있는데 `cnt`만 다른 묶음은 한컴이 열므로 그대로 둔다. +- `doc.page.set_header()`·`set_footer()`·`set_page_number()`가 구역 첫 문단의 첫 run에서 시작하는 필드(누름틀 등) + 뒤에 머리말·꼬리말 컨트롤을 넣던 것을 고친다. 그 자리는 필드 범위 안이라 한/글이 저장하면서 머리말·꼬리말을 + 버렸다. 이제 필드가 시작하는 컨트롤 앞에 넣는다. 머리말·꼬리말 글을 고치며 `hp:ctrl` 사본을 새로 만들 때도 같다. +- 편집기 안전 검사(`validate_package`·`validate_editor_open_safety`)가 한컴이 열지 + 못하거나(거부) 멈추거나 배치를 끝내지 못하는 문서를 통과시키던 것을 고친다. 이제 + 다음은 오류이고, 공개 저장 경로는 이런 문서를 쓰지 않는다. + - 패키지: `rootfile@media-type`, `opf:item`의 `id`·`href`·`media-type`, + `opf:itemref@idref`, `opf:meta@name`이 없거나 `opf:manifest`에 항목이 없음 + - `hh:head@secCnt` 없음(예전에는 경고), `HCFVersion@tagetApplication` 없음 + - 표: `hp:tbl`의 `rowCnt`·`colCnt`, `hp:tr`/`hp:tc`, `hp:cellAddr`의 + `colAddr`·`rowAddr`, `hp:cellSpan`의 `colSpan`·`rowSpan`이 없음 + - `hp:secPr`에 `hp:startNum`·`hp:visibility` 없음, `hp:lineseg@textpos` 없음 + - 필드: `hp:fieldBegin@id`, `hp:fieldEnd@beginIDRef`가 없거나, 없는 `fieldBegin`을 + 가리키는 `fieldEnd`가 자기 `hp:run`의 맨 앞에 있거나, 필드 종류와 `fieldid`가 둘 다 + 한컴이 모르는 값임. run 안에서 글·빈 `hp:t`·다른 컨트롤 뒤에 오는 그런 `fieldEnd`는 + 한컴이 버리고 문서를 연다 + - 도형: 필수 하위 요소가 없는 도형과 빈 `hp:ctrl`(예전에는 경고), + `hp:renderingInfo` 행렬이 없는 도형, 모서리 점(`hc:pt0`~`pt3`)이 없는 사각형, + `hc:img`가 없는 그림 + - 구역 설정의 `hp:parameterset` 안 `hp:booleanParam`, 패키지에 없는 바탕쪽을 가리키는 + `hp:masterPage`, `hp:subList`가 없는 머리말·꼬리말 컨트롤, 문단 없는 `hp:subList` + - 머리말·구역 항목의 `opf:item@href`가 패키지 루트에서 본 파트 이름이 아님 + (`content.hpf` 폴더 기준 상대 경로, `/`로 시작하는 경로). 한컴은 href를 패키지 + 루트에서 그대로 찾는다. 그림 항목이 그러면 경고다(한컴은 그 그림을 빼고 연다) + - 구역 항목의 `opf:item@id`가 `section0`부터 번호가 이어지지 않음. 한컴은 구역을 이 + id의 번호 순서로 찾고(spine이나 파일 이름이 아니라) 하나라도 없으면 열지 않는다. + 번호는 이어지지만 spine 순서와 다르면 경고다(한컴은 id 순서로 읽는다). id는 spine이 + 가리키는 것을 보고, 같은 구역 파트를 가리키는 다른 항목은 세지 않는다. `repair_repack`은 + 구역 id를 spine 순서대로 다시 매긴다 +- `add_heading(level=8~10)`과 `apply_paragraph_format(outline_level=8~10)`이 문단 모양의 개요를 한/글과 다른 + 모양으로 쓰던 것을 고친다. 개요 8~10 수준은 2016 문단 이름공간에만 있어서, 한/글은 `hp:switch`의 `hp:case`에 + 개요를 두고 `hp:default`에 `NONE`을 둔다(새 문서의 개요 8~10 스타일도 이렇다). 전에는 이 `hp:switch`를 + 비우고 개요를 문단 모양에 바로 썼다. 이제 한/글과 같은 모양으로 쓰고, 새 문서에서는 개요 8~10 스타일의 문단 + 모양을 그대로 쓴다. 문단 모양을 읽을 때도 `hp:switch` 안의 개요를 읽는다(`paragraph_property().heading`, + 목록 글자 내보내기). +- 글자 겹치기(`add_composed_character()`)와 덧말(`add_dutmal()`)을 한/글이 쓰는 모양으로 쓴다. 글자 겹치기는 늘 + `hp:charPr` 열 칸을 쓰고(쓰지 않는 칸은 `4294967295`), 주지 않은 속성은 한/글이 새 글자 겹치기에 주는 값 + (`circleType="SHAPE_CIRCLE"`, `charSz="-4"`, `composeType="SPREAD"`)으로 쓴다. 덧말은 스타일을 주지 않으면 + `styleIDRef="0"`을 쓴다. 전에는 준 칸만 쓰고 속성을 뺐으며, 한/글은 저장할 때 이 값들을 채웠다. +- 새 도형(`add_rectangle()`·`add_ellipse()`·`add_line()`·`add_arc()`·`add_polygon()`·`add_container()`)에 + `textWrap`·`textFlow`를 쓴다. 한/글은 모든 도형에 이 둘을 쓰고, 없으면 `SQUARE`·`BOTH_SIDES`로 읽는다. + 그래서 새 도형은 `textWrap="SQUARE" textFlow="BOTH_SIDES"`이고, 묶음(`hp:container`) 안 도형은 한/글처럼 + `TOP_AND_BOTTOM`이다. 채운 도형의 `hc:winBrush`에도 한/글처럼 `alpha="0"`을 쓴다. 한/글에서 보이는 모양은 + 그대로다. +- FormFit이 공백이 이어진 글을 한/글처럼 줄 나눈다. 낱말 바로 뒤의 공백은 전처럼 줄 끝에 매달리지만, 그 뒤로 + 이어지는 공백이 줄 끝이나 그 너머에서 시작하면 다음 줄로 간다. 최소 공백(`condense`)은 줄의 첫 글자 뒤에 있는 + 공백만 줄이고, 줄 앞의 공백은 줄이지 않는다. 전에는 이어진 공백을 모두 줄 끝에 매달고 줄 앞 공백까지 줄여서, + 공백으로 칸을 채운 값을 한 줄로 예측했는데 한/글은 두 줄로 나눴다. +- 누름틀 안에 다른 누름틀이 있을 때 바깥 누름틀을 `doc.fields.fill()`로 채우면 안쪽 누름틀의 시작과 끝을 + 지운다. 한/글은 바깥 누름틀의 내용 전체를 값으로 바꾸므로 안쪽 누름틀도 함께 없어진다. 전에는 안쪽 + 누름틀의 시작과 끝이 빈 채로 남아 필드 목록에 그대로 있었다. 안쪽 누름틀만 채우면 바깥 누름틀은 그대로다. +- 필드를 쓸 때 `fieldid`에 그 필드 종류의 제어 id를 쓴다. 한/글은 종류마다 모든 필드에 같은 `fieldid`를 쓴다. + 제어 id 네 글자를 수로 읽은 값이다(누름틀 `%clk` 627272811, 날짜 `%dte`, 경로 `%pat`, 교정 부호 `%sig`, 메일 + 머지 `%mmg`, 차례 `%toc`, 하이퍼링크 `%hlk`, 상호 참조 `%xrf`). 전에는 누름틀·날짜·경로·교정 부호·메일 머지· + 차례·상호 참조 필드에 임의 수를 썼고, 차례 도구가 쓴 필드 끝에는 `fieldid`가 없었다. 문서 합치기 + (`insert_document()`·`append_document()`)도 가져온 필드의 `fieldid`를 임의 수 대신 종류의 제어 id로 쓴다. +- 누름틀의 끝을 찾을 때 `beginIDRef`가 있는 끝은 그 id의 누름틀 끝으로만 본다. 같은 종류의 필드는 `fieldid`를 + 함께 쓰기 때문에, 한/글이 저장한 문서에서 끝이 없는 누름틀이 다음 누름틀의 끝을 제 끝으로 잡았다. 그러면 + 채울 때 그 사이 내용을 지웠다. 이제 이런 누름틀은 `field-end-missing`으로 거부한다. +- FormFit이 문단의 왼쪽·오른쪽 여백을 줄 폭에서 빼고, 문단 위 간격을 칸의 높이 예산에서 뺀다. 한/글은 줄을 문단 + 왼쪽 여백에서 시작하고, 줄 폭은 칸 안쪽에서 두 여백을 뺀 값이다. 첫 줄은 위 간격 아래에서 시작하고, 칸 + 마지막 문단의 아래 간격은 자리를 차지하지 않는다. 전에는 두 여백과 위 간격을 무시해서, 여백이 있는 칸에서 + 줄 수를 적게 세고 칸에 들어가는 줄 수를 많게 셌다. +- 메모를 여럿 달면 모두 `Number` 1로 쓰던 것을 고친다. 한/글은 번호가 같은 메모를 한 메모로 보아, 문서를 + 다시 저장할 때 둘째 메모부터 메모 범위의 끝(`hp:fieldEnd`)을 지웠다. 이제 `doc.notes.add_memo()`· + `doc.notes.attach()`에 `number`를 주지 않으면 메모는 문서의 다음 번호(`zorder`와 같은 값)를 쓴다. 메모 + 필드도 한/글이 쓰는 모양으로 쓴다. 필드 id는 숫자이고, `fieldid`는 메모 필드의 종류 코드(`%%me`)다. + `name=""`, `editable="1"`, `dirty="0"`을 쓰고, 메모 글 `subList`에는 한/글과 같은 속성을 쓴다. +- `doc.fields.fill()`이 내용이 여러 문단에 걸친 누름틀을 채우면 값만 시작 뒤에 넣고 걸친 문단의 원래 + 글을 그대로 두던 것을 고친다. 누름틀 끝을 시작과 같은 문단에서만 찾았기 때문이다. 이제 끝을 뒤 + 문단에서도 찾고, 한/글처럼 걸친 문단(그 안의 표 포함)을 지운 뒤 값과 끝을 시작 문단에 둔다. 끝 + 문단에서 끝 뒤에 있던 글은 시작 문단으로 합치고, 시작 문단의 문단 모양은 그대로다. 이런 누름틀의 + `FormField.value`는 걸친 문단의 글을 한 줄에 한 문단씩 읽는다. +- 끝(`hp:fieldEnd`)이 없는 누름틀을 `doc.fields.fill()`로 채우면 `HwpxValueError(code="field-end-missing")`를 + 낸다. 바꿀 내용이 없기 때문이다(한/글도 이런 누름틀은 채우지 않는다). 전에는 값을 시작 뒤에 넣었다. +- 안내문을 `Command` 문자열(`Clickhere:set:…:Direction:wstring:<길이>:<안내문> …`) 안에만 둔 누름틀을 + 자리표시로 알아보지 못하던 것을 고친다. 그래서 `doc.fields.fill()`이 값을 안내문의 글자 모양(보통 빨강)으로 + 남겼다. 이제 안내문을 그 문자열에서도 읽고(`FormField.prompt`, `is_placeholder`), 값은 누름틀의 글자 + 모양으로 쓴다. 누름틀의 매개변수는 바꾸지 않는다. +- `doc.parts.add_master_page(section=...)`가 내는 `master-page-pages-taken`을 오류 코드 목록 + (`hwpx.errors.ERROR_CODES`, `docs/error-codes.md`)에 올린다. 전에는 목록에 없었다. +- `doc.styles.replace_font()`가 *dst_face*를 새로 선언하는 블록에 늘 `type="TTF"`를 쓰던 것을 + 고친다. 다른 블록이 같은 글꼴을 `HFT`로 선언해도 그랬다. 이제 다른 블록에 *dst_face* 선언이 + 있으면 그 `hh:font`를 id만 바꿔 복사한다. 그래서 `type`·`isEmbedded`·`hh:typeInfo`가 그 선언과 + 같다. 어느 블록에도 없을 때만 전처럼 `TTF`로 선언한다. +- `check_id_integrity()`가 동영상이 가리키는 항목(포스터 `imageIDRef`, 파일 `fileIDRef`)을 고아 + BinData로 보고해 `ok=False`를 내던 것을 고친다. `binaryItemIDRef`만 참조로 셌기 때문이다. 이제 + `imageIDRef`·`fileIDRef`·`soundIDRef`도 참조로 세고, 패키지 밖 파일을 잇는 항목(`isEmbeded="0"`이고 + href가 `BinData/` 밖)은 고아 목록에 넣지 않는다. 이진 항목 판정·이름 비교·참조 속성은 + `doc.media.remove_image()`·`doc.media.images`·`doc.validate()`와 같은 규칙(`hwpx.opc.relationships`)을 + 쓴다. 참조 무결성 게이트(`require_reference_integrity`)가 고아 항목 때문에 실패하면 메시지에 그 + 항목을 적는다. 전에는 메시지가 `ID/reference integrity failed: `로 끝났다. +- `doc.media.images`가 한/글이 `BinData/`에 두는 OLE 항목을 빼던 것을 고친다. `isEmbeded="0"`이면 + 모두 바깥 파일로 봤기 때문이다. 이제 href가 `BinData/` 밖인 `isEmbeded="0"` 항목(패키지 밖 파일)만 + 뺀다. +- 표 칸의 안쪽 여백을 읽는 곳마다 규칙이 달라 같은 칸에 서로 다른 값을 쓰던 것을 고친다. 이제 + 양식 맞춤(`resolve_slot_metrics`), 칸 안 중첩 표의 기본 너비, 새 표의 쪽 넘김 판단, 레이아웃 + 미리보기의 칸 안쪽 여백, 템플릿 분석(`analyze_template`)의 `CellSummary.margin`, 바이트 보존 칸 + 채우기(`fill_cells`)의 글자 줄이기가 모두 `cell.margins`와 같은 규칙을 따른다. 칸의 `hasMargin`이 + 켜져 있을 때만 칸의 `hp:cellMargin`을 쓰고, 꺼져 있거나 없으면 표의 `hp:inMargin`을 쓴다. 전에는 + 양식 맞춤이 `hasMargin`이 없는 칸에 칸 여백을 썼고, 나머지는 `hasMargin`을 보지 않고 칸 여백만 + 읽었다. 한/글이 저장한 실문서의 줄 너비(`lineseg@horzsize`)가 이 규칙과 맞는다. `hasMargin="0"`이고 + 두 여백이 다른 칸이 있는 문서는 중첩 표 기본 너비, 미리보기 안쪽 여백, 템플릿 분석 값, + `fill_cells`의 글자 줄이기 판단이 바뀐다. 양식 맞춤 결과는 테스트 실문서에서 바뀌지 않는다. +- `tests/data/model_surface.json`의 `internalCount`(stable 목록 밖 공개 멤버 수)를 확인하는 + 테스트가 없어 19개 클래스 중 12개 값이 실제와 어긋나 있던 것을 고친다. 값을 실제에 맞추고(합 82 → + 154), 표면 테스트가 모든 클래스의 값과 `docs/stable-api.md`의 수를 확인한다. 공개 API는 바뀌지 + 않는다. +- 5.0에서 `python-hwpx-automation`으로 옮긴 모듈(`hwpx.builder`, `hwpx.authoring`, `hwpx.exam`, + `hwpx.tools.pii` 등 21개)을 import하면 `No module named ...`만 나오던 것을 고친다. 이제 + `ModuleNotFoundError` 메시지가 새 모듈 경로(예: `hwpx_automation.office.authoring.builder`), + `pip install python-hwpx-automation`, 이전 가이드 주소를 알려 준다. 배포본에서 뺀 저장소 전용 + 패키지(`hwpx.benchmark`, `hwpx.conformance`, `hwpx.tools.fuzz`)는 소스 체크아웃에서 쓰라고 + 알려 준다. core는 여전히 companion 패키지를 import하지 않고, 옮긴 경로를 파일로 되살리지 않는다. + 모듈별 대체표는 `docs/migration-5.0.md`의 "Module paths"에 있고 메시지와 같은 표에서 나온다. +- `validate_package()`(`hwpx-validate-package`)가 패키지 밖 파일을 잇는 매니페스트 항목(`isEmbeded="0"`이고 + href가 `BinData/` 밖, 예: 연결한 동영상)을 파트가 없다는 오류로 보고해 `ok=False`를 내던 것을 고친다. + 이 때문에 참조 무결성 게이트(`require_reference_integrity`)로 저장하면 `OPC/package validation failed`로 + 실패했다. 이제 `doc.validate()`·`check_id_integrity()`·`doc.media.images`와 같은 규칙(`is_linked_file`)으로 + 이런 항목을 건너뛴다. `BinData/`에 파일을 두는 OLE 항목은 `isEmbeded="0"`이어도 계속 검사한다. +- `doc.validate()`와 `validate_document()`(`hwpx-validate`)가 같은 패키지에 다른 답을 하던 것을 고친다. + 매니페스트 불일치 경고(파트가 없는 매니페스트 항목, 매니페스트 항목이 없는 `BinData/` 파트)를 + `doc.validate()`만 냈기 때문이다. 이제 이 검사를 `validate_document()`가 하고 `doc.validate()`는 그 결과를 + 그대로 돌려준다. 파트가 없는 매니페스트 항목을 오류로 보는 것은 여전히 패키지 검사(`validate_package()`)다. + `doc.validate()`의 docstring에 무엇을 검사하는지 적는다. +- `lint_layout()`이 글을 채워 쪽 본문보다 높아진 글자처럼 취급한 표를 알리지 못하던 것을 고친다. + 행 높이를 칸에 저장된 높이(`hp:cellSz`)로만 셌기 때문이다. 글을 채워도 저장된 높이는 그대로이고, + 한/글은 칸의 줄에 맞춰 행을 늘려 그린다. 이제 칸마다 문단과 줄바꿈(`hp:lineBreak`, 글 안의 줄바꿈 + 문자) 수만큼 줄을 센다. n줄은 (n − 1) × 줄 간격 + 글자 크기를 차지하고, 여기에 칸 위아래 여백을 + 더한 값을 행 높이의 하한으로 쓴다. 글자 모양이 하나인 문단은 긴 글이 칸 폭에서 접혀 생긴 줄도 센다. + 줄 나눔은 FormFit과 같고, 글자마다 측정 오차만큼 좁게 재므로 한/글이 그리는 줄보다 많이 세지 않는다. + 탭·개체가 있거나 글자 모양이 여럿인 문단은 문단과 줄바꿈 수만 센다. +- 문단이나 표 칸에 이미 가진 글을 다시 넣으면(`paragraph.text = paragraph.text`, `cell.text = cell.text`) + 줄 배치 캐시(`hp:linesegarray`)를 지우던 것을 고친다. 한/글은 캐시가 없는 문단을 다시 배치하는데, 다른 + 판의 한/글이 만든 캐시와 결과가 달라 글이 바뀌지 않았는데도 쪽 수가 달라질 수 있었다. 여러 런 문단은 + 런까지 첫 글자 모양으로 합쳐졌고, 여러 문단 칸은 한 문단이 됐다. 이제 같은 글이면 아무것도 바꾸지 + 않는다(`set_text_preserving_runs`와 같다). 칸의 `set_text(..., preserve_format=False)`는 같은 글이어도 + 전처럼 글자 모양을 되돌린다. +- `lint_layout()`이 `pageBreak="TABLE"` 표를 검사하지 않던 것을 고친다. 한/글은 이 표를 행 + 사이에서만 나눈다. 그래서 쪽 본문보다 높은 행은 한 쪽에 그리고, 종이 아래 끝을 넘는 줄은 그리지 + 않는다. 이제 이런 행을 `TABLE_TALLER_THAN_PAGE`로 알린다(`detail["row"]`는 행 번호). 행 안에서도 + 나누는 `pageBreak="CELL"` 표는 전처럼 검사하지 않는다. +- 각주·미주의 인스턴스 ID를 `instid` 속성으로 쓰던 것을 고친다. 스키마(`NoteType`)와 한/글이 쓰는 이름은 + `instId`이고, 소문자 `instid`는 그림·도형 같은 개체의 철자다. 한/글은 이런 파일을 저장하며 `instid`를 + 버리고 `instId`를 새로 매겼다. HWP로 저장하면 각주·미주의 인스턴스 ID가 0이 됐다. 이제 `add_footnote()`· + `add_endnote()`는 `instId`를 쓰고, 읽기는 `instId`를 먼저 보고 전에 쓴 `instid`도 읽는다. 문단 복제는 + 어느 철자든 값만 새로 매긴다. +- `doc.fields.fill()`이 값의 줄바꿈 문자(CRLF·LF·CR)를 글 안에 그대로 쓰던 것을 고친다. 한/글은 이 문자를 + 열 때 같은 문단 안 줄바꿈(`hp:lineBreak`)으로 바꿔 보여 줬다. 이제 줄바꿈마다 `hp:lineBreak`를 쓴다. + 누름틀 값을 읽을 때도 `hp:lineBreak`를 줄바꿈 문자로, `hp:tab`을 탭으로 읽는다(전에는 빠졌다). + 채울 때 옛 값에 있던 줄바꿈·탭 요소가 새 값 뒤에 남던 것도 함께 고친다. +- 이름이 빈 누름틀(`name=""`)을 `doc.fields.all`이 `id`를 이름으로 삼아 보여 주던 것을 고친다. 한/글은 이런 + 누름틀을 이름 없는 필드로 보고 필드 목록과 이름으로 채우기에서 뺀다. 이제 `name` 속성이 있으면 빈 값이라도 + 그 값을 이름으로 쓴다. 이름 없는 누름틀은 `doc.fields.all`·`list_form_fields()`에 빈 이름으로 나오고(`field_id`로 + 구별한다), `fill(name=)`으로는 찾지 않는다(안내문으로도 찾지 않는다). `fill(field_index=)`·`fill(field_id=)`로는 + 전처럼 채울 수 있다. +- 안에 표가 든 칸에 글을 넣으면(`cell.text = …`, `set_cell_text()`) 안쪽 표의 칸 글까지 모두 지워 빈 표가 + 남던 것을 고친다. 이제 칸 자신의 문단 글만 바꾸고, 칸 안의 표·개체는 글을 그대로 둔다. 누름틀 칸 채우기 + (`doc.fields.fill_cell()`)도 같은 방식으로 쓴다. 그래서 `fill_cell()`도 이번 쓰기로 비워진 칸 문단을 + 지운다(전에는 빈 줄로 남아 칸이 높아졌다). + +## [6.6.0] - 2026-09-28 + +HWP 5.0(`.hwp`) 읽기·쓰기를 더하고, 템플릿·양식을 공개 API만으로 다루는 기능(칸 안쪽 여백, +글꼴·테두리 읽기와 글꼴 교체, 떠 있는 도형의 기준 틀, 본문 비우기, 메타데이터·미리보기·그림 +정리)을 공개한 릴리스입니다. 한/글과 결과를 맞추느라 출력이 바뀌는 고침이 여럿 있습니다. +특히 `equalize_column_widths()`는 한/글처럼 표 폭을 행별 칸 수의 공배수로 올립니다. 전의 분배가 +필요하면 `set_column_widths([1] * column_count)`를 씁니다. 저장 성공만으로 시각 품질을 +보증하지 않습니다. + +### 바꿈 + +- FormFit(`hwpx.form_fit`)이 셀과 누름틀 값의 줄을 한컴의 줄 배치 규칙으로 센다. + 글꼴 파일은 쓰지 않는다. 공백은 0.5 em이고, 폭에 장평과 (1 + 자간)을 곱한다(줄 끝 + 글자에는 자간을 붙이지 않는다). 칸 안 폭이 1440 HWPUNIT보다 좁아도 줄 폭은 1440이다. + 한글·영어 나눔 설정을 따른다(한글 `BREAK_WORD`는 어절 단위, `KEEP_WORD`는 글자 + 단위). 줄 끝 공백은 여백 밖으로 걸치고, 첫 줄 들여쓰기와 내어쓰기, 최소 공백, + 닫는 부호의 줄 머리 금칙을 반영한다. 영어 단어 안의 `/ - . @` 뒤에서는 나누지 + 않는다. 같은 줄의 체크박스 같은 인라인 개체는 첫 줄 폭만 줄인다. + - 셀 슬롯(`resolve_slot_metrics`)은 셀 첫 문단의 문단 모양과 글자 모양에서, + 누름틀 슬롯은 필드 문단과 글자 모양에서 이 설정(`TextStyle`)을 읽는다. + - 새 `TextStyle`과 `hancom_line_starts()`를 공개한다. `estimate_text_width`와 + `estimate_lines`는 `style`을 선택 인자로 받고, `style` 없이 부르면 전과 같다. + - 문단 모양 읽기(`ParagraphProperty.margin`)가 한컴 표기의 여백 값 + (``)을 읽는다. 전에는 요소 글자에서만 값을 찾아 + 한컴 문서의 문단 여백이 모두 `None`이었고, FormFit도 들여쓰기를 0으로 봤다. + +### 추가 + +- `Section.clear_body()`로 문서를 양식 틀로 비운다. 첫 문단 첫 run의 `hp:secPr`(쪽 + 설정)와 첫 문단 모든 run의 `hp:ctrl`(단·머리말·꼬리말·쪽 번호 — 한/글은 이것들을 흔히 + 둘째 run에 쓴다)을 남기고, 나머지 문단, 첫 문단 run의 다른 자식, 컨트롤이 없는 뒤쪽 run, + 첫 문단의 줄 배치 캐시를 지운다. 머리말·꼬리말처럼 남길 + 컨트롤 안에 글·표·개체·양식 개체·덧말·글자 겹침이 있으면 기본으로 `section-clear-control-content`로 거부하고, + `on_control_content="keep"`은 남기며, `"strip"`은 그 컨트롤과 `hp:secPr` 안의 + 머리말·꼬리말 사본을 지운다. `"strip"`으로도 지울 수 없는 내용이 있으면 거부한다. + `hp:t` 안 자식 뒤의 글(`이름`)도 내용으로 본다. + 어느 쪽이든 찾은 태그를 `ClearBodyReport.control_content`에 담는다. 거부는 바꾸기 + 전에 일어난다. +- `Run.content_kinds()`가 run이 담은 것을 닫힌 어휘(`RUN_CONTENT_KINDS`: `text`· + `table`·`picture`·`shape`·`equation`·`ole`·`chart`·`video`·`form`·`control`· + `section_properties`·`other`)로 알려 준다. 빈 `hp:t`는 글로 세지 않는다. +- `doc.text.plain()`·`markdown()`·`html()`(`hwpx.tools.exporter`)에 `list_labels` 인자(기본 + `False`): 번호·개요·글머리표 문단 앞에 한/글이 그리는 글자(`1.`·`가.`·`①`·`●` 등)와 공백 + 하나를 붙인다. 번호는 번호 정의와 수준마다 문서 순서로 세고(윗수준이 오면 아랫수준은 새로 + 시작, 건너뛴 윗수준은 시작값), 개요 문단은 구역의 개요 번호를 쓴다. 표 칸 문단도 읽는 순서로 + 센다(`include_tables=False`로 표를 빼도 세기는 한다). 쓸 수 있는 번호 모양은 숫자·한글·자모· + 원 숫자·원 한글·로마 숫자·라틴 문자이고, 다른 모양(갑·일·一 등)의 문단에는 라벨을 붙이지 않는다. + 센다(`include_tables=False`로 표를 빼도 세기는 한다). `^N`은 1수준부터 그 수준까지의 번호를 + 숫자로 잇고(`1.2.`), 정의가 없는 번호는 모든 수준을 그렇게 쓰며, 정의가 없는 글머리표는 `●`로 + 쓴다. 글자가 기호 글꼴 문자(사용자 정의 영역의 U+F09F·U+F0A7 등) 하나뿐이면 한/글의 텍스트 + 저장처럼 `●`·`■` 등 유니코드 기호로 쓴다. 쓸 수 있는 번호 모양은 숫자·한글·자모·원 숫자· + 원 한글·로마 숫자·라틴 문자이고, 다른 모양(갑·일·一 등)의 문단에는 라벨을 붙이지 않는다. +- 셀 필드(이름 붙은 표 칸, `hp:tc@name`): `doc.fields.cells`가 문서 순서로 돌려주고(본문 표와 + 칸 안의 표), `doc.fields.fill_cell(value, name=..., index=None)`이 한/글 `PutFieldText`처럼 + 같은 이름의 칸을 모두 채운다. `index`(0부터)를 주면 그 하나만 채운다. 칸의 이름은 + `cell.field_name`으로 읽고 쓴다. 채우면 칸 자신의 문단 글만 바뀌고, 칸 안에 든 표나 개체의 + 글(그 안의 셀 필드 값 포함)은 그대로다. 새 오류 코드 `field-cell-not-found`. +- `doc.text.replace(search, replacement, everywhere=True)`: 한/글 "모두 바꾸기"처럼 표 + 칸(칸 안 표 포함)·글상자·캡션·머리말·꼬리말·각주·미주·바탕쪽의 문단도 바꾸고, 서식이 다른 런에 + 걸친 말도 바꾼다. 바꿀 글의 글자는 같은 자리의 찾은 글자가 있던 런의 서식을 따르고, 남는 + 글자는 찾은 글의 마지막 글자 뒤에 붙는다. 탭·줄 바꿈이나 컨트롤을 + 사이에 둔 글은 한 말로 보지 않고, 메모 본문은 바꾸지 않는다. python-hwpx가 두 벌로 쓰는 + 머리말·꼬리말은 두 곳을 같이 바꾸고 한 번으로 센다. 기본값(`False`)은 전처럼 본문 문단의 + 런 안만 바꾼다. `docs/ai-assistants.md`의 양식 채우기 예제는 이 인자를 쓴다. +- `doc.styles.apply_paragraph_format(paragraphs=[...])`: 본문 인덱스 대신 문단 객체를 + 받아 표 셀(중첩 표 포함)·머리말·꼬리말 문단에도 사람 단위(mm·pt·%) 문단 서식을 + 적용한다(예: `paragraphs=[table.cell(0, 0).paragraphs[0]]`). 같은 문단 모양을 쓰던 + 다른 문단은 그대로다. 이 문서에 속하지 않은 문단(다른 문서의 문단, 지운 문단)이 + 섞이면 아무것도 바꾸기 전에 `paragraph-not-in-document`로 거부하고, 인덱스 인자와 + 함께 주면 `paragraph-argument-conflict`로 거부한다. 결과의 `paragraphs`에는 본문 + 문단 인덱스만 담기고 `formatted`는 대상 수다. 기존 `paragraph_index`· + `paragraph_indexes`의 뜻은 그대로다. 머리말·꼬리말 객체에 `paragraphs` 속성 + (`cell.paragraphs`처럼 문단 객체 목록)을 더한다. +- `apply_list_format()`의 기본 글머리표·번호 모양이 한/글 새 목록과 다른 점(글머리표 + `-`·`○` 대 `●`, 번호 2수준 `1.1.` 대 `가.`, 3수준 `1.1.1.` 대 `1)`)과 글머리표를 맞추는 법 + (`bullet_char="●"`)을 설명과 known-traps에 적는다. 한/글이 새 목록에 쓰는 모양은 한/글이 + 저장한 목록 fixture로 테스트에 고정한다. +- HWP 5.0(`.hwp`)을 읽고 쓴다. `HwpxDocument.open`이 `.hwp`를 같은 문서 모델로 열고, + `save_to_path("x.hwp")`·`save_to_stream(..., format="hwp")`·`to_bytes(format="hwp")`가 + HWP 5.0으로 쓴다. 본문·글자와 문단 모양·스타일·표·그림·그리기 개체·글맵시·수식·차트·OLE· + 동영상·필드·누름틀과 양식 개체·머리말과 꼬리말·각주와 미주·메모·바탕쪽·변경 추적· + 문서 정보·인쇄 설정·스크립트를 옮긴다. 옮기지 못한 내용은 `document.conversion_report` + (`unconverted`·`dropped`)에 세고 `Hwp5ConversionWarning`으로 알린다. HWP 5.0으로 쓸 수 + 없는 내용은 쓰기 전에 `Hwp5Error`(`hwp5-write-unsupported`)로 거부하고, 암호·배포용·DRM + 문서도 `Hwp5Error`로 거부한다(`hwp5-*` 코드). `HwpxPackage.open`은 그대로 HWPX 전용이며 + `.hwp`에는 `HwpxDocument.open`을 안내한다. 새 의존성은 없다. +- 쪽 번호·줄 번호 설명을 보강한다(`page.set_page_number`, `set_visibility`, + `hide_page_elements`, `set_line_numbers`와 known-traps). `set_page_number()`의 + 번호는 머리말/꼬리말 글이라 한컴의 쪽 번호 감추기(`hide_first_page_num`, + `hide_page_elements(page_num=True)`)로 숨지 않으니 머리말/꼬리말째 감춘다. + `hide_page_elements()`는 문단이 있는 쪽에서만 감춘다(다음 쪽부터 다시 보임, 설명을 + 바로잡음). 줄 번호는 `set_visibility(show_line_number=True)`일 때만 그려진다. +- 새 문서가 빈 줄로 시작하는 까닭(`HwpxDocument.new()`의 첫 문단은 구역 설정을 담은 + 빈 문단이고 `add_paragraph()`는 그 뒤에 붙는다)과 첫 줄부터 쓰는 법 + (`doc.paragraphs[0].text = ...`)을 `HwpxDocument.new()` 설명과 known-traps에 적는다. +- `hwpx.layout.lint_layout()`이 쪽에서 나뉘지 않는 본문 표(글자처럼 취급한 표, + `pageBreak="NONE"` 표)가 쪽 본문보다 높으면 `TABLE_TALLER_THAN_PAGE`로 알린다. + 한컴은 이런 표를 한 쪽에 그린다. 쪽 첫머리가 아니면 다음 쪽으로 옮기고, 아래 + 여백까지 내려 그리며, 종이 아래 끝을 넘는 행은 그리지 않는다. 높이는 행마다 가장 높은 셀 높이의 합(아래 한계)으로 + 잰다. 행이 잘리면 `overflow_policy="fail"`에서 오류, 아니면 경고다. 여러 쪽에 + 걸칠 표는 `Table.set_treat_as_char(False)`로 흐르게 한다. +- `styles.apply_paragraph_format(border={...})`로 문단 테두리를 만든다. 키는 + `sides`(기본 네 면), `color`(`#000000`), `width`(`0.12 mm`), `type`(`SOLID`), + `offset_mm`(글과의 간격 mm, 수 하나 또는 왼쪽·오른쪽·위·아래), `connect`, + `ignore_margin`이다. `connect=True`면 같은 문단 모양을 쓰는 연속 문단이 단과 + 쪽을 넘는 상자 하나로 그려진다(한컴의 "문단 테두리 연결"). 상자 안의 빈 문단에도 + 같은 서식을 주면 상자가 끊기지 않는다. 기존 `bottom_border=True`는 그대로 + 아래 한 면만 켠다. 잘못된 지정은 `paragraph-border-invalid`로 거부한다. +- 템플릿을 넘기기 전에 패키지를 정리하는 기본 동작을 더한다. 무엇을 지울지는 호출자가 + 정한다. + - `doc.parts.clear_document_metadata(keep=("title", "language"), + timestamp="1970-01-01T00:00:00Z")`(`HwpxPackage`에도 같은 이름)는 `opf:metadata`에서 + `keep` 밖의 필드를 모두 비우고 비운 키를 문서 순서로 돌려준다. 이 라이브러리가 모르는 + `opf:meta` 이름도 비운다. `CreatedDate`·`ModifiedDate`처럼 이름에 `Date`가 든 필드는 + `timestamp`로 바꾸고, 자유형식 `date`를 비롯한 나머지는 요소와 속성을 두고 내용만 비운다. + - `doc.parts.clear_preview()`는 `Preview/PrvText.txt`를 비우고 `Preview/PrvImage.png`를 + 1×1 흰 PNG로 바꾼다. 두 파트를 지우지는 않는다. + - `doc.validate()`가 파트가 없는 매니페스트 항목과, 매니페스트 항목이 없는 `BinData/` + 파트를 경고로 알린다. 경고라서 `ok`는 그대로다. `BinData/` 밖의 파일을 잇는 + `isEmbeded="0"` 항목(동영상 링크 등)은 알리지 않는다. 한/글은 OLE 개체에도 + `isEmbeded="0"`을 쓰지만 그 파일은 `BinData/` 안에 두므로, 빠지면 알린다. +- `Shape.set_position()`이 기준 프레임과 정렬도 정한다. `horz_rel_to`·`vert_rel_to`· + `horz_align`·`vert_align`이 `hp:pos`의 `horzRelTo`·`vertRelTo`·`horzAlign`· + `vertAlign`을 쓴다. 값은 OWPML 스키마 철자 그대로다(`vert_rel_to`에는 `COLUMN`이 + 없다). `PAPER`로 두면 확인 도장 같은 떠 있는 도형을 쪽 여백에 고정할 수 있다. + 주지 않은 값은 그대로 두므로 오프셋만 주는 기존 호출은 결과가 같다. 스키마 밖의 + 값은 아무것도 바꾸기 전에 `shape-position-frame`으로 거부한다. +- `Shape.set_draw_text()`에 `para_pr_id_ref`(도형 안 문단의 `paraPrIDRef`)와 + `vert_align`(`hp:subList/@vertAlign`, `TOP`/`CENTER`/`BOTTOM`)을 더한다. 둘 다 + 생략하면 XML이 전과 같다. 잘못된 정렬 값은 `shape-draw-text-vert-align`으로 거부한다. +- `Shape.draw_text`·`set_draw_text`·`remove_draw_text`를 stable 표면에 올리고, 이들이 + 돌려주는 글상자를 `hwpx.model.DrawText`라는 계약 이름으로 낸다(`name`·`editable`· + `text_margin`·`paragraphs`·`text`·`add_paragraph`가 stable). 반환 객체 계약은 + 19개 클래스 / 189개 멤버가 된다. +- 글꼴 표와 테두리를 값으로 읽는다. `doc.styles.fonts(lang="HANGUL")`는 그 lang 블록의 + 글꼴을 id 문자열 → `Font`로, `doc.styles.font_face(char_pr_id_ref, lang="HANGUL")`는 글자 + 모양이 그 lang에서 가리키는 글꼴 이름을 돌려준다. `doc.styles.border_fill_info(id)`는 + `hh:borderFill` 하나를 `BorderFillInfo`(네 변과 대각선의 `BorderLine(type, width_mm, color)`, + 단색 채우기 `fill`)로 돌려준다. 값은 저장된 그대로이고, 그라데이션·그림 채우기는 설명하지 + 않는다(`fill`은 `None`). 너비를 읽을 수 없으면 `style-border-fill-width-invalid`다. 기존 + `border_fill()`은 그대로 `GenericElement`를 돌려준다. 모두 메모리의 헤더를 읽어 + `ensure_font()`·`ensure_border_fill()` 직후에도 맞는다. +- `doc.styles.replace_font(src_face, dst_face, *, langs=None)`로 글꼴 하나를 문서 전체에서 + 다른 글꼴로 바꾼다(읽는 쪽에 없는 장식 글꼴을 흔한 글꼴로 바꿀 때). lang 블록마다 바꿀 + 글꼴을 지우고 남은 글꼴 id를 0부터 다시 매기며, 모든 글자 모양의 `fontRef`를 옮겨 다른 + 참조가 가리키는 글꼴은 바뀌지 않는다. 결과는 `FontReplaceReport`(바뀐 블록 `langs`, 새로 + 선언한 블록 `declared`, 옮긴 참조 수 `repointed`)다. 같은 글꼴 둘은 + `style-font-replace-same-face`로 거부한다. +- 표 셀 안쪽 여백을 읽고 쓴다. `cell.margins`는 한/글이 셀을 배치하는 여백을 + `hwpx.objects.CellMargins`(HWPUNIT, `left`·`right`·`top`·`bottom`)로 돌려준다. 셀의 + `hasMargin`이 꺼져 있으면 표의 `hp:inMargin`, 켜져 있으면 셀의 `hp:cellMargin`이다. + `add_table()`이 만든 셀은 `CellMargins(510, 510, 141, 141)`이다. + `cell.set_margins(left=, right=, top=, bottom=)`는 주지 않은 면을 지금 여백으로 채워 + 네 면을 셀 여백에 쓰고 `hasMargin="1"`로 켠 뒤 새 여백을 돌려준다. 여백이 실제로 바뀌면 + 그 셀에 바로 든 문단의 줄 배치 캐시를 지운다(안쪽 표 문단은 그대로). 인자가 없으면 + 아무것도 바꾸지 않는다. `0 <= v < 2**31`인 `int`가 아닌 값(`bool` 포함)은 바꾸기 전에 + `HwpxValueError`(`cell-margin-value`)로 거부한다. +- 편집 의미론 문서에 `table.set_cell_text()`가 남기는 것과 다시 만드는 것(기본은 첫 + `hp:t`에 쓰고 나머지 `hp:t`를 비우며 그렇게 비워진 문단만 지운다, `split_paragraphs=True`는 + 문단을 다시 만든다), + `table.set_column_widths([1] * column_count)`가 나누는 규칙(전의 `equalize_column_widths()` + 나눔: 표 너비 유지, 앞 `n-1`열 `round(W / n)`, 마지막 열 나머지)과 `Paragraph.char_pr_id_ref`가 그 문단의 run 전부에 들어간다는 것을 적는다. + +### 바꿈 + +- 마지막 문단을 지우려는 `paragraph.remove()`가 맨 `ValueError` 대신 + `HwpxValueError`(`ValueError` 하위라 `except ValueError`는 그대로 잡는다)를 낸다. + `code`는 `paragraph-remove-last`, `context["container"]`는 문단을 담은 곳 + (`section`·`cell`·`header`·`footer`)이다. 메시지는 같다. +- `HwpxOxmlTable.equalize_column_widths()`가 한/글 "셀 너비를 같게"처럼 행마다 칸을 같은 + 너비로 나눈다. 합친 칸은 그 행의 한 칸으로 세고, 모든 행이 같은 자리에서 끝나도록 표 + 너비를 행마다의 칸 수로 모두 나누어떨어지는 값까지 올린 뒤(예: 3칸 행과 4칸 행이면 12의 + 배수) 열 격자(`colCnt`·`cellAddr`·`cellSpan`)를 새 칸 경계로 다시 짠다. 한/글과 같아서 칸 수가 + 다른 행이 많으면 표가 크게 넓어질 수 있다. 칸 영역(`hp:cellzone`)의 열 주소도 새 격자로 + 옮기고, 같은 칸을 덮을 수 없으면 표를 그대로 두고 `HwpxValueError`를 낸다. 합친 칸 아래에 + 크기 0인 자리 표시 칸이 남아 있는 표에서는 자리 표시 칸마다 덮는 칸의 새 열을 하나씩 주고, + 열이 모자라면 남는 빈 자리 표시 칸은 없앤다(한/글은 합친 칸 아래에 칸을 쓰지 않는다). 전에는 격자 열을 + 같게 해서, 칸 경계가 행마다 다른 표에서는 칸 너비가 고르지 않았다. 세로로 합친 칸이 + 행마다 다른 너비를 받아야 하는 표는 한/글처럼 바꾸지 않고 `HwpxValueError`를 낸다. + 격자 열마다 같은 너비가 필요하면 `set_column_widths([1] * column_count)`를 쓴다. +- `validate_document()`가 구역·머리 XML을 번들된 전체 OWPML 스키마(`DevDoc/OWPML SCHEMA`의 + Body·ParaList·Core·Header)로도 검사한다(#118). 한/글 2011 네임스페이스를 2024로 바꿔 끼운 + 사본을 보고, 한/글이 여는 문서에도 있는 편차(2011 관행)는 빼고 나머지를 경고(`OWPML + schema: …`)로 낸다. `ok`는 전처럼 하드 오류만 본다. 끄려면 `full_schema=False`. 이 검사가 + 돌지 못하면 멈추지 않고 그 까닭을 경고로 남긴다. `hp:secPr` 안의 주석·처리 지시는 건너뛴다. +- 문단 객체의 `add_hyperlink(url, text)`도 `doc.add_hyperlink`·`doc.refs.add_hyperlink`처럼 + 링크 글을 한/글 관례(파랑 `#0000FF` + 파랑 밑줄)로 쓴다. 전에는 문단의 글자 모양을 그대로 + 써서 링크가 보통 글처럼 보였다. 글꼴·크기·굵기 같은 나머지 모양은 링크가 들어가는 문단의 + 글자 모양을 따른다. 두 경로 모두 같고, 모양이 같은 문단의 링크는 글자 모양 하나를 함께 쓴다. + `char_pr_id_ref`를 주면 그 모양을 쓴다. 각주·미주 본문의 `add_hyperlink`도 같다. Markdown 내보내기는 링크 글에 밑줄·글자색 장식을 붙이지 않는다 + (링크 표기가 이미 그 모양이다). +- `doc.text.plain()`·`markdown()`·`html()`(`hwpx.tools.exporter`)이 본문 글상자(도형 안 글)의 + 글을 내보낸다. 글상자의 문단은 그 글상자가 놓인 자리에, 그 문단의 표와 함께 문서 순서로 + 온다. 쪽 미리보기(`render_layout_preview`, 문서 뷰어)는 `⟦도형⟧` 표시 옆에 글상자 + 글을 쓴다. 전에는 둘 다 글상자 글을 버렸다(칸 안 글상자는 전부터 칸 글에 들어갔다). + 머리말·꼬리말·각주·미주·메모 안의 글상자는 전처럼 본문으로 치지 않는다. 칸 안 글상자의 글은 + 미리보기에 한 번만 나온다. 메일 머지(`merge_template_rows`)는 글상자 속 자리표시자도 바꾼다 + (본문·칸·다른 글상자 안의 글상자 모두). 전에는 찾기만 하고 바꾸지 않았다. +- `doc.text.plain()`·`markdown()`·`html()`(`hwpx.tools.exporter`)이 문단 안의 표·글상자· + 캡션을 그 문단의 글 뒤에 모아 쓰지 않고, 한/글의 텍스트 저장처럼 놓인 자리에서 문단 글을 + 나눠 쓴다(개체 앞 글, 개체, 개체 뒤 글). `list_labels`의 번호와 글머리표는 첫 조각에 + 붙는다. 표 칸 안에서도 같아서, 칸 안의 글상자와 표는 칸 글 속 제자리에 오고 칸 안 표의 + 캡션은 그 표 뒤에 온다(전에는 칸 문단의 글 뒤에 왔고 캡션은 표 앞에 왔다). +- `apply_table_ops`의 `insert_row_by_clone`이 합친 칸이 걸린 행도 한/글이 줄을 넣듯 처리한다. + 기준 행 아래로 이어지는 합친 칸은 복제하지 않고 새 행 수만큼 `rowSpan`을 늘리며, 윗행에서 + 내려와 기준 행에서 끝나는 합친 칸 밑에는 새 행마다 그 칸의 서식·너비로 빈 칸(기준 행 높이)을 + 넣는다. 전에는 기준 행에 여러 행에 걸친 칸이 있으면 거부했고("clone source row must have + rowSpan==1 cells"), 합친 칸이 기준 행에서 끝나면 새 행에 칸이 비어 거부했다. 모든 칸이 아래로 + 이어지는 행은 복제할 칸이 없어 거부한다. +- `doc.parts.add_master_page()`(`doc.oxml.add_master_page()`)가 `page_number`를 받지 않으면 + `OPTIONAL_PAGE`는 1, 다른 종류(`BOTH`·`ODD`·`EVEN`·`LAST_PAGE`)는 한/글처럼 0을 쓴다. 전에는 + 종류와 상관없이 1을 썼다. +- 한 구역에 같은 쪽의 바탕쪽을 둘 연결하면(`add_master_page_reference`·`doc.page.set_master_page`) + `HwpxValueError`(`master-page-pages-taken`)를 낸다. 양쪽·홀수·짝수·마지막 쪽은 하나씩, + 한 쪽 바탕쪽은 쪽 번호마다 하나다. 전에는 그대로 연결되어 `.hwp` 저장이 실패했다. +- `add_table()`이 만드는 표의 행 높이 기본값을 12.7 mm(3600)에서 한컴이 새 표에 쓰는 + 282로 바꾼다. 행은 셀 글 높이에 맞춰 자라서, 한 줄이면 약 4.6 mm다. 전처럼 높은 행이 + 필요하면 `height=`를 준다. +- `add_table()`은 행들만으로 쪽 본문보다 높은 표를 흐르는 표로 만든다(글자처럼 취급을 + 끔). 행 높이는 셀 높이와 셀 글 한 줄 + 셀 위아래 여백 중 큰 값으로 잰다. 한컴은 + 글자처럼 취급한 표를 쪽에서 나누지 않아, 이런 표는 종이 끝에서 잘렸다. 흐르는 표는 + 행 사이에서 쪽을 넘는다. 쪽 크기는 그려지는 쪽(가로면 돌린 높이)으로 잰다. +- `doc.media.remove_image()`가 문서가 아직 가리키는 이진 항목을 지우지 않고 + `HwpxValueError`(`media-item-in-use`)를 낸다. 가리키는 곳은 그림, header의 채우기 그림·그림 + 글머리표, 바탕쪽, 동영상 파일·포스터, OLE, 내장 글꼴이고, `context["references"]`에 + `"파트: 요소@속성"`으로 담긴다. 거부는 바꾸기 전에 일어난다. 전에는 그대로 지워서 쪽 테두리 + 채우기 그림 같은 참조가 끊긴 채 저장되었다. 그래도 지우려면 `force=True`를 준다. 구역·header처럼 + 이진 항목이 아닌 매니페스트 항목은 지우지 않고 `False`를 돌려준다(전에는 + `remove_image("section0")`이 구역 파트를 지웠다). + +### 고침 + +- `doc.styles.replace_font()`가 한 언어 블록에 같은 이름으로 두 번 선언된 글꼴을 하나만 바꾸던 것을 + 고친다. 한/글이 저장한 문서에 흔한 모양으로, 둘째 선언을 가리키는 글자 모양은 옛 글꼴로 남는데 + 보고서는 그 블록이 바뀌었다고 알렸다. 이제 블록 안의 같은 이름 선언을 모두 지우고 그 참조를 모두 새 + 글꼴로 옮긴다. +- MS Word처럼 배치하는 문서(`hh:compatibleDocument`의 `targetProgram="MS_WORD"`)를 `.hwp`로 열고 저장할 + 때 배치 호환 설정이 사라지던 것을 고친다. 열 때 `hh:layoutCompatibility`를 비워 두고 저장할 때 0으로 + 써서, Word 호환 배치가 모두 꺼졌다. 이제 켜진 설정마다 `hh:layoutCompatibility`의 자식 하나로 + 한/글이 쓰는 순서대로 열고, 저장할 때 다시 쓴다. 한/글처럼 `HWP201X` 문서는 설정 없이 저장하고, + 한/글이 모르는 자식이 있으면 한/글의 기본 설정으로 저장한다. 한/글의 일부 판이 쓰는 묶음 요소 + (`hh:char`·`hh:paragraph`·`hh:section`·`hh:object`·`hh:field`)와 그 안의 것은 한/글처럼 건너뛴다. + `applyFontspaceToLatin`은 한/글이 HWPX로 쓰지는 않지만 읽는 설정이라, 문서의 배치가 그대로 남도록 + 열어 둔다. +- `.hwp`로 저장할 때 줄 간격 `AT_LEAST`(최소)를 옛 줄 간격 필드에 한/글처럼 100%로 쓴다. 전에는 + 옛 필드에 `BETWEEN_LINES`와 새 값을 썼다. +- 표 `merge_cells()`(`doc.tables.merge_cells`)가 가려지는 칸의 글과 개체를 버리던 것을 + 고친다. 이제 병합 칸이 글이나 개체가 있는 칸의 문단을 읽기 순서(행마다 왼쪽에서 + 오른쪽, 위 행부터)로 모두 받고, 빈 칸(빈 문단 하나)은 아무것도 보태지 않는다. 병합 + 칸이 비어 있었으면 그 빈 줄 대신 옮겨 온 문단만 남는다. 칸 크기가 바뀌므로 병합 칸의 + 줄 배치 캐시는 비운다. 병합한 뒤 `split_merged_cell()`로 나누면 옮겨 온 글은 첫 칸에 + 남는다. 병합할 영역의 칸 격자는 한 번만 만들어서 큰 표의 병합이 빨라졌다. 병합으로 칸이 + 하나도 남지 않은 행과, 어느 칸도 시작하지 않는 열은 한/글처럼 없앤다(행 전체를 합치면 그 + 높이의 한 행이 되고, 표 전체를 합치면 한 칸짜리 표가 된다). 빈 `hp:tr`이 남은 표는 한/글이 + 열지 못했다. +- 표 칸의 `.text =`(`set_text`)가 글을 지운 문단을 빈 줄로 남기던 것을 고친다. 글이 지워지고 + 남은 것이 없는 칸 문단은 없애서, 넣은 글이 그대로 읽힌다(병합한 칸에 글을 넣을 때도 같다). + 원래 빈 줄이던 문단과 개체가 든 문단은 그대로 둔다. +- 번호 형식을 받은 그대로(대문자로만 바꿔) 쓰던 것을 고친다. 목록(`styles.apply_list_format`의 + `number_format`, `ensure_numbering` 레벨의 `format`), 각주·미주(`set_footnote_auto_num_format`· + `set_endnote_auto_num_format`의 `type`), 쪽 번호(`page.set_page_number`의 `format_type`)가 + 그렇다. 한컴은 모르는 형식을 거부하지 않고 아라비아 숫자로 매겨서, `number_format="decimal"` + 같은 값이 조용히 `1. 2. 3.`이 되었다. 이제 목록·개요 머리와 쪽 번호는 한컴 번호 모양 + 15개(`hc:NumberType1`: `DIGIT`·`ROMAN_SMALL`·`ROMAN_CAPITAL`·`LATIN_SMALL`·`CIRCLED_DIGIT`· + `HANGUL_SYLLABLE` 등, `hwpx.oxml.numbering_kinds.NUMBER_FORMATS`)를, 각주·미주 번호는 여기에 + `DECAGON_CIRCLE`·`DECAGON_CIRCLE_HANJA`·`SYMBOL`·`USER_CHAR`를 더한 19개(`hc:NumberType2`, + `NOTE_NUMBER_FORMATS`)를 받는다. 쪽 번호가 먼저 받던 짧은 이름(`roman`→`ROMAN_CAPITAL`, + `roman_lower`→`ROMAN_SMALL`, `alpha`→`LATIN_CAPITAL` 등)도 받고, 그 밖의 값은 + `HwpxValueError`(`style-number-format-invalid`)로 거부한다. +- 색 인자가 `#RRGGBB`가 아니어도 그대로 쓰던 것을 고친다. 한컴은 색 값을 16진 수 하나로 + 읽고 나머지를 거부하지 않아서, `#ABC`는 CSS의 `#AABBCC`가 아니라 `#000ABC`로, `red`는 + 검정으로, `#12345`는 `#012345`로 보였다. 이제 `styles.ensure_run`(`color`·`highlight`· + `underline_color`·`shadow`), 칸·테두리 채우기와 그러데이션 색, 메모 모양, 도형 선·채우기, + 단 구분선, `body_patch`의 `restyle_text` 글자색은 `#RRGGBB`와 `#AARRGGBB`(`#` 생략·소문자 + 허용)만 받고(`none`을 받던 자리는 `none`도), 그 밖의 값은 `HwpxValueError` + (`style-color-invalid`)로 거부한다. `#AARRGGBB`는 한컴이 무늬·테두리·선·그림자 색에 쓰는 + 꼴(알파 먼저)이라, 문서에서 읽은 색을 넘겨도 그대로 쓴다. 거부하면 아무것도 바꾸지 않는다: 머리말·꼬리말 `set_content`는 내용을 비우기 전에 + 색을 검사하고, 그 뒤에 값이 거부되어도 원래 내용으로 되돌린다. `set_columns`도 구분선 값을 + 먼저 검사한다. +- 테두리 굵기(`styles.ensure_border_fill`의 `border_width`, `apply_paragraph_format`의 + 문단 테두리 굵기)와 단 구분선 굵기(`page.set_columns`의 `separator_width`)를 받은 그대로 + 쓰던 것을 고친다. 한컴은 선 굵기 목록(`0.1 mm`~`5.0 mm`)에 글자 그대로 있는 값만 읽고, + `1 mm`·`2 mm`·`0.12mm`처럼 목록 밖의 값은 가장 가는 `0.1 mm`로 그렸다. 이제 `"1 mm"`· + `"1mm"`·`1`은 `"1.0 mm"`로 쓰고, 목록에 없는 굵기는 가장 가까운 목록 값으로 쓴다(한가운데면 + 굵은 쪽: `"0.45 mm"`는 `"0.5 mm"`, `"6 mm"`는 `"5.0 mm"`). 0보다 큰 mm 값이 아니면 + (`"thick"`·`"1 cm"`·`0`) `HwpxValueError`(`style-line-width-invalid`)로 거부한다. + `hwpx.oxml.utils.LINE_WIDTHS`가 그 목록이다. +- `styles.ensure_run(ratio=...)`(장평)가 256~400을 받던 것을 고친다. 한컴은 장평을 한 + 바이트로 두어 256 이상은 256을 뺀 값이 된다(300은 44%로 그려짐). 이제 범위는 10~255다. +- 테두리 종류(`styles.ensure_border_fill`의 `border_type`, 문단 테두리, 표 칸 테두리)의 + `THICK_3D`·`THICK_3D_REVERSE_LIGHTING`·`SLIM_3D`·`SLIM_3D_REVERSE_LIGHTING`을 그대로 써서, + 한컴이 모르는 표기라 테두리가 없는 것(`NONE`)으로 그려지던 것을 고친다. 이제 한컴 표기 + `THICK3D`·`THICKREV3D`·`3D`·`REV3D`로 바꿔 쓰고, 한컴 표기도 그대로 받는다. +- `.hwp`로 저장할 때 그림 효과 `PATTERN8x8`을 한/글이 읽지 않는 값으로 쓰던 것을 고친다. 한/글처럼 + 원래 그림(`REAL_PIC`)으로 쓰고, `.hwp`에서 이름이 없는 효과 값은 `effect` 없이 연다. +- `.hwp`의 선 종류 13~17을 한/글이 쓰는 이름(`DOUBLEWAVE`·`THICK3D`·`THICKREV3D`·`3D`·`REV3D`)으로 + 열고, 저장할 때는 이 이름과 `THICK_3D` 같은 다른 철자를 모두 받는다. 문단 번호의 번호 형식을 + 4비트로만 써서 `DECAGON_CIRCLE_HANJA`가 다른 형식으로 바뀌던 것도 고친다. +- 그림자가 있는 도형을 `.hwp`로 저장하고 열 때 바깥 여백을 한/글처럼 셈한다. 기울임·원근·확대·축소 + 그림자가 도형 밖으로 나간 만큼을 더하고 빼며, 왼쪽이나 위로 민 그림자도 그쪽 여백에 더한다. 큰 + 도형에서 여백이 16비트를 넘으면 한/글처럼 아래 16비트로 저장하고, 열 때 원래 여백으로 되돌린다 + (전에는 32비트를 넘는 값으로 열렸다). +- HWPX에 없는 배치 속성을 `.hwp`에 쓸 때 한/글이 주는 값을 쓴다. `textWrap`은 도형이 `SQUARE`, + 양식 개체가 `TOP_AND_BOTTOM`이고, `vertRelTo`·`horzRelTo`는 `PAPER`, `widthRelTo`·`heightRelTo`는 + `PAGE`, 캡션 `side`는 `LEFT`다. 채운 화살표(`FILLED_*`)와 덧말 위치 `CENTER`처럼 `.hwp`에 없는 + 값도 한/글처럼 기본값으로 쓴다. +- `.hwp`의 틀 없는 글자 겹치기를 한/글처럼 `OVERLAP`으로 연다. 전에는 `SPREAD`로 열어 저장할 때 + 빈칸을 붙였다. 수식 버전이 빈 `.hwp` 수식은 빈 채로 열고 저장한다. +- `export_text()`·`export_markdown()`·`export_html()`이 표 칸 글의 앞뒤 공백을 지우던 것을 고친다. + 들여 맞춘 ` 용 역 명`이 `용 역 명`으로, `협조자 `가 `협조자`로 나왔다. 한/글이 저장한 + 문서의 미리보기 글(`Preview/PrvText.txt`)도 칸 글의 공백을 그대로 둔다. 칸 끝의 빈 문단은 + 전처럼 빼고, 글에 든 공백만 지킨다. +- `apply_paragraph_format()`가 여백·들여쓰기·문단 간격을 `hp:switch` 안에 쓸 때 `hp:default`에는 + `hp:case` 값의 두 배를 쓴다. 한컴 문서와 같은 표기다. 전에는 두 곳에 같은 값을 써서, `hp:case`를 + 모르고 `hp:default`를 읽는 프로그램에서는 여백이 절반으로 보였다. `hp:switch` 밖의 여백은 준 값 그대로다. +- 표 칸 하나의 테두리를 바꾸면(`table.set_cell_borders`) 이웃 칸의 맞닿은 변이 옛 선으로 + 남아, 한컴에서 두 선이 겹쳐 그려지던 것을 고친다. 칸마다 네 변을 따로 가지고 맞닿은 변은 + 두 칸의 선이 다 그려지므로, 한컴의 칸 테두리 명령은 이웃 칸의 맞닿은 변도 같은 선으로 + 바꾼다. 이제 `set_cell_borders()`도 이웃 칸의 맞닿은 변만 같은 선으로 바꾼 테두리/배경 + 정의를 이웃에 준다. 이웃의 다른 변과 배경은 그대로다. 한컴처럼, 맞닿은 변이 바꾼 칸의 + 변보다 긴 이웃(한 칸 옆의 병합 칸)은 바꾸지 않는다. 칸 하나가 가리킬 정의를 정하는 + `set_cell_border_fill()`은 전처럼 그 칸만 바꾼다. +- `apply_paragraph_format(alignment=...)`가 한컴의 나눔 정렬 `DISTRIBUTE_SPACE`를 받는다. 전에는 + 배분 정렬 `DISTRIBUTE`만 받고 `DISTRIBUTE_SPACE`는 알 수 없는 정렬이라며 거부했다(문서를 읽을 + 때는 이 값을 그대로 둔다). +- `add_heading()`이 제목 문단을 그 개요 스타일의 문단 모양·글자 모양에서 만든다. 전에는 문단 + 모양 0(바탕글)에 개요 수준만 더하고 글자 모양도 0을 써서, 한컴에서 개요 번호는 붙지만 스타일의 + 들여쓰기·간격·글자 모양이 빠진 제목이 됐다. 한컴은 문단을 스타일이 아니라 문단에 달린 모양으로 + 그린다. `char_pr_id_ref`를 주면 그 글자 모양을 쓴다. +- 수식 상자 크기(`hp:sz`)와 기준선(`baseLine`)을 수식 구조로 잰다(`add_equation`, + `estimate_equation_size`). 한컴은 문서를 열 때 수식을 다시 재지 않고 저장된 상자로 줄을 + 배치한다. 이전 추정(보이는 글자 수 × 0.45, 높이는 기준 크기의 1.6~2.5배)은 한 줄짜리 수식도 + 상자를 크게 잡아 수식 뒤에 빈틈이 생기고 줄이 높아졌으며, 좁으면 뒤 글자와 겹쳤다. 기준선도 + 기준 크기에서 계산한 값이라 수식이 위아래로 어긋났다. 이제 글자 종류별 너비, 분수(`over`), + 위·아래 첨자, 근호, 큰 연산자와 그 범위, `lim` 아래 범위, 행렬과 `pile`, 줄 나눔(`#`)을 + 상자로 쌓아 크기를 재고, 기준선은 상자 높이에서 기준선 위 부분의 비율(한 줄 수식 약 86)로 + 쓴다. 설명의 '한컴이 열 때 다시 잰다'도 바로잡았다. +- 구역에 XML 주석이나 처리 명령이 있는 문서를 편집한 뒤 저장하면 + `TypeError: cannot serialize `로 멈추던 것을 고친다(6.5.0부터 있던 + 결함). 실문서 가운데 표 행 안에 주석을 둔 문서가 있었고, 편집하지 않고 저장하면 원본 + 바이트를 그대로 써서 드러나지 않았다. 이제 편집한 파트를 쓸 때 주석과 처리 명령은 + 내용이 아니므로 빼고, 그 뒤의 글은 남긴다. 주석이 없는 문서의 출력은 그대로다. +- `doc.text.replace(..., everywhere=True)`가 `hp:t` 안 XML 주석의 글까지 찾아 바꾸던 것을 + 고친다. 주석은 건너뛰고, 주석 양쪽의 글은 이어진 글로 찾는다. +- `doc.text.plain()`·`markdown()`·`html()`(`hwpx.tools.exporter`)이 표·그림·도형의 캡션과 덧말을 + 내보내지 않던 것을 고친다. 한/글의 텍스트 저장처럼 표 캡션은 표 뒤에, 그림·도형 캡션은 그 자리에 + 오고, 덧말은 본말 뒤에 `(덧말:<덧말 글>)`로 붙는다. 칸 안 표의 캡션은 전처럼 칸 글에 한 번 + 들어간다. 캡션의 자동 번호는 담긴 번호로 쓴다(`그림 1`, `표 1`). `hp:switch`에 두 번 쓰인 개체 + (차트와 그 OLE 대체본)는 한 번만 읽고, `include_tables=False`면 표와 함께 그 캡션도 뺀다. + 메일 머지(`merge_template_rows`)는 자리표시자를 한/글 "모두 바꾸기"가 닿는 곳(표 칸·글상자· + 캡션·머리말·꼬리말·각주·미주)과 덧말에서도 바꾼다. 전에는 내보내기로 찾기만 하고 바꾸지 못해 + 그 행을 실패로 보고했다. +- 글자 모양을 바꾸는 API가 기준 글자 모양의 나머지 서식을 지키게 고친다. 런의 + `bold`·`italic`·`underline`을 바꾸면 글자 크기·색·글꼴이 첫 글자 모양의 것으로 + 돌아갔다. `styles.ensure_run()`과 `add_run(size=...)` 같은 글자 서식 인자는 요청한 값만 + 같은 아무 글자 모양이나 돌려줘, 새 문서에서 `add_run("글", size=16)`이 파란 제목 글자가 + 되고 `base_char_pr_id`를 주어도 다른 글자 모양이 나왔다. 양식 칸에 맞춰 글자를 줄이면 + 굵게·기울임·밑줄이 풀렸고, 새로 만든 글자 모양에서는 기준의 취소선이 빠졌다. 이제 + 요청한 글자 모양은 기준(`base_char_pr_id`, 없으면 첫 글자 모양)에서 요청한 값만 바꾼 + 것이고, 내용이 같은 글자 모양이 있을 때만 그것을 다시 쓴다. `ensure_run()`에서 주지 + 않은 `bold`·`italic`·`underline`은 기준의 것을 따른다(기준을 주지 않으면 끔). +- 끝이 아닌 구역을 `remove_section()`으로 지운 문서를 한/글이 열지 못하고, `add_section(after=...)`로 + 가운데 넣은 구역이 한/글에서 맨 뒤에 나오던 것을 고친다. 한/글은 구역을 `content.hpf` 항목 id + `section0`, `section1`, …의 번호 순서로 찾고(spine 순서나 파일 이름이 아니라), 번호가 하나라도 + 빠지면 문서를 열지 않는다. 이제 구역을 넣거나 지우거나 문서를 저장할 때 구역 항목의 id를 문서 + 순서대로 다시 매긴다(파일 이름은 그대로). 그래서 id가 어긋난 채 저장된 파일도 다시 저장하면 + 바로잡힌다. 새 구역은 다른 구역 파일이나 항목이 쓰는 번호를 받지 않는다. +- `doc.text.replace()`(과 `run.replace_text()`를 쓰는 메일 병합)가 `hp:t` 안의 탭·줄 바꿈을 사이에 + 둔 글을 이어 읽어, `사⇥과`를 `사과`로 찾아 바꾸던 것을 고친다(탭은 남고 글자만 합쳐졌다). + 한/글처럼 탭·줄 바꿈 같은 글자 요소와 컨트롤은 말을 끊는다. 형광펜 표시는 전처럼 끊지 않는다. +- 표 셀·머리말·꼬리말 문단의 `remove()`가 아무 일도 하지 않던 것을 고친다. 문단을 구역 + 바로 아래에서만 찾아, 셀 문단(`cell.paragraphs[i].remove()`)은 오류 없이 그대로 남았다. + 이제 문단을 담은 구역이나 `hp:subList`에서 지운다. 담은 곳의 마지막 문단은 지우지 않고 + `ValueError`를 낸다(문단이 없는 셀·머리말·꼬리말은 한/글이 열지 못한다). 이미 지운 문단의 + `remove()`는 조용히 넘어간다. 같은 까닭으로 셀 문단의 `apply_model()`이 `ValueError`를 + 내던 것도 고친다. +- 그림 같은 이진 항목이 많은 문서를 여는 데 오래 걸리던 것을 고친다. `content.hpf`의 + 항목마다 패키지의 모든 파트 이름을 다시 정규화해서, 항목·파트 수의 제곱에 비례했다 + (파트 1,400개 문서에서 열기 1분 넘게). 이제 목록을 읽을 때 파트 이름을 한 번만 + 정규화한다. `resolve_part_name()`의 동작은 그대로다. +- 저장이 한컴이 쓴 문단 줄 배치 캐시(`hp:linesegarray`)를 멀쩡한데도 지우던 것을 고친다. + 저장 때 캐시가 글보다 뒤를 가리키는지 보는 검사가 `hp:t` 안의 줄 바꿈·고정 폭 빈칸·탭을 + 글자 수에서 빼고 셌다. 한컴의 줄 시작 위치(`textpos`)는 이것들을 센다(탭 8, 나머지 1). + 그래서 구역을 다시 쓸 때(그 구역의 다른 문단만 고쳐도) 이런 문단의 캐시가 사라져, 한컴이 + 그 문단을 새로 배치하면서 쪽 수가 바뀔 수 있었다. 이제 한컴의 글자 위치로 센다. +- `doc.text.markdown(rich=True)`(`hwpx.tools.markdown_export`)가 도형(사각형·타원· + 다각형)을 품은 문단의 글을 잃던 것을 고친다. + - 도형을 품은 문단은 제 글을 통째로 버렸다(도형 글이 흘러나온 것으로 보았다). 이제 + 문단 자신의 글이 도형 안의 글과 같을 때만 뺀다. + - 도형 안 문단을 이미 쓴 것인지 `id()`로 가렸다. 풀린 lxml 요소 프록시의 번호를 + 다른 문단이 다시 받으면, 처음 보는 도형 문단을 건너뛰었다(칸 안 도형도 같음). + 이제 요소 자체로 가린다. +- `hwpx.table_patch.apply_table_ops`의 `delete_table`·`clone_table`·`split_table`· + `merge_table`이 표를 담은 문단을 잘못 찾던 것을 고친다. 문단 안에서 표 앞에 + `hp:pagePr`·`hp:pageBorderFill`·`hp:pic`처럼 이름이 `p`로 시작하는 요소가 있으면 + (구역 설정이 든 구역 첫 문단의 표 등) 그 요소를 문단 시작으로 알아, 잘린 XML이 + 저장 전 검사에 걸려 `ValueError`로 멈추거나 `merge_table`이 두 표 사이에 글이 + 있다고 잘못 거부했다. 이제 표를 감싸는 가장 안쪽 문단을 찾는다. 그 문단에 구역 + 설정·글·다른 표가 함께 있으면 문단 대신 표만 다룬다. `delete_table`은 표만 지우고, + `clone_table`·`split_table`의 새 표는 그 표만 담은 새 문단(같은 문단 모양, 쪽·단 + 나눔 없음)에 들어간다. 한 문단에 든 두 표를 `merge_table`하면 표만 합치고, 두 표 + 사이에 글이 있으면 거부한다. +- `autofit_columns`가 표 안에 표가 있는 표에서 `KeyError`를 내던 것을, 다른 구조 + 편집처럼 `TableStructureError`로 거부하게 한다. +- `delete_column`이 한 칸짜리 칸만 있는 행이 표의 모든 열을 덮지 않을 때(오른쪽 열이 + 윗행에서 내려온 합친 칸에 덮인 행) 그 행을 열 폭의 근거로 써서 `KeyError`를 내던 + 것을 고친다. 이런 행은 건너뛰고, 없으면 합친 칸 격자에서 폭을 구한다. +- `delete_row`가 세로로 합친 칸의 첫 행을 지우면 합친 칸까지 지워 표에 빈 자리가 + 생겨 거부되던 것을 고친다. 이제 합친 칸은 다음 행으로 내려가 한 행 줄어들고, 글과 + 서식은 그대로다(한컴 편집기에서 그 행을 지운 결과와 같다). +- `doc.media.images`가 `content.hpf` 매니페스트에만 있는 이진 항목을 보지 못하던 것을 + 고친다. 한컴이 저장한 파일은 보통 header에 `binDataList`가 없어 그림이 있어도 빈 목록이 + 나왔다. 이제 header 항목 뒤에 매니페스트에만 있는 항목(href가 `BinData/` 아래이거나 + media-type이 `image/*`)을 붙인다. `isEmbeded="0"`으로 바깥 파일을 잇는 항목은 넣지 않고, + 파트가 없는 내장 항목은 `size=0`으로 넣는다. `doc.media.remove_image()`는 매니페스트 id·파트 경로 + (`"BinData/image1.png"`)·`BinaryItem`을 모두 받아 이런 항목도 지운다. +- `doc.media.remove_image("image1")`이 header에 `image10.png`가 먼저 있으면 그 `binItem`과 + 파트를 대신 지우던 것을 고친다. header 항목을 파일 이름 앞부분으로 맞췄기 때문이다. 이제 + 확장자를 뺀 파일 이름(또는 파일 이름 전체)이 같아야 맞는다. +- `doc.media.replace_picture()`가 바꾼 그림의 옛 이진 항목을 header의 채우기 그림이나 바탕쪽 + 같은 다른 곳이 아직 쓰는데도 지우던 것을 고친다. 본문 그림만 보고 고아로 판단했기 + 때문이다. 이제 문서 어디서도 가리키지 않을 때만 지우고, 아니면 남긴다(`removed_orphans`가 빈다). +- `HwpxPackage.remove_manifest_item()`에 파트 경로를 넘기면 `False`만 돌려주고 항목을 + 남기던 것을 고친다. 이 인자는 매니페스트 `id`다. 그 id의 항목이 없고 값에 `/`가 있으면 + href가 같은 파트를 가리키는 항목을 지운다. +- HWP 5.0으로 저장(`to_bytes(format="hwp")`·`save_to_path("*.hwp")`)할 때 원본 구역에 + XML 주석이 있으면 `ValueError: Invalid input tag`로 멈추던 것을 고친다. 실문서 가운데 + 표 행 안에 주석을 둔 문서가 있었다. 이제 주석과 처리 명령은 내용이 아니므로 건너뛴다. +- 머리말·꼬리말 객체로 고친 내용이 한/글에 나타나지 않던 것을 고친다. python-hwpx가 만든 + 머리말·꼬리말은 구역 설정(`hp:secPr`) 안과 본문 `hp:ctrl` 안에 두 벌로 쓰이고, 한/글은 + `hp:ctrl` 쪽만 읽는다. `set_header()`·`set_footer()`가 돌려준 객체의 `text` 설정, + `add_run()`, `add_paragraph()`, `add_page_number_field()`, `set_content()`, + `clear_content()`와 그 문단을 고친 내용은 구역 설정 쪽에만 들어가, 한/글에서는 처음 글이 + 그대로 보였다. 이제 고친 구역을 저장할 때 두 벌을 맞춘다. 두 벌이 마지막으로 같았던 + 때(문서를 열 때, 머리말·꼬리말을 설정할 때, 저장할 때) 뒤에 바뀐 쪽을 다른 쪽에 옮기고, + 알 수 없거나 둘 다 바뀌었으면 구역 설정 쪽을 따른다. 두 요소는 제자리에 남아서 저장한 뒤에도 + 머리말 객체를 계속 쓸 수 있다. 두 벌로 쓰인 머리말·꼬리말의 누름틀은 `doc.fields`에 한 번만 + 나온다. `id`·`apply_page_type`을 바꾸면 `hp:ctrl` 쪽도 함께 바꾸고(홀수 쪽으로 바꾼 + 머리말이 모든 쪽에 보이던 것), `BOTH`가 되면 홀수·짝수 쪽 것보다 앞에 둔다(`hp:ctrl` 쪽만 + 있는 머리말·꼬리말도 같다). 한/글이 저장한 문서처럼 `hp:ctrl` 쪽만 있는 머리말·꼬리말의 + 내용은 저장할 때 건드리지 않는다. `clear_content()`와 빈 + `set_content([])`는 빈 문단 하나를 남긴다. 전에는 `hp:subList`까지 지웠는데, 실제 문서의 + 머리말·꼬리말은 모두 `hp:subList`와 문단을 하나 이상 가진다. +- 칸·테두리 그림 채우기(`set_cell_fill_image(mode=)`, `ensure_border_fill(fill_image={"mode": ...})`)가 + `LEFT_TOP`·`LEFT_BOTTOM`·`RIGHT_CENTER`·`RIGHT_TOP`도 받는다. 한/글이 쓰는 그림 위치인데 OWPML + 스키마의 목록(12개)에 없어 `ValueError`를 냈다. +- `TextExtractor.extract_text()`가 안쪽 문단을 두 번 쓰던 것을 고친다. `include_nested=True` + (기본)에서 각주를 `footnote="inline"`으로, 컨트롤을 `control="nested"`로, 개체를 + `object_behavior="nested"`로 글에 넣으면 그 안의 문단이 따로 한 번 더 나왔다. + `object_behavior="nested"`는 칸 안의 표처럼 개체 안의 개체 글을 문단 하나 안에서도 + 거듭 썼다. 이제 앞 문단의 글에 이미 들어간 문단은 따로 쓰지 않고, 개체는 자기 + 칸·글상자의 문단만 돌며 그 안의 개체는 그 문단이 쓴다. +- 평문·HTML·Markdown 내보내기(`doc.text.plain()`·`html()`·`markdown()`, + `hwpx.tools.exporter`)의 표 글을 고친다. 칸 안의 표는 바깥 칸의 글에 들어 있는데도 + 따로 한 번 더 나왔다. 칸에 단 각주·미주·메모의 본문이 칸 글에 섞였다(본문 문단에 + 단 것은 빠졌다). 머리말·꼬리말 안의 표가 본문 첫 문단 자리에 나왔다. 이제 표는 + 바깥 표만 한 번 나오고, 각주·미주·메모·숨은 설명·머리말·꼬리말의 글은 본문 + 내보내기에 넣지 않는다. Markdown 표 칸은 문단 나눔을 `
`로, `|`를 `\|`로 써서 + 한 줄에 둔다(전에는 칸 안의 줄 나눔이 표 행을 깨뜨렸다). +- `doc.text.markdown()`이 첫 행보다 칸이 많은 행의 뒤쪽 칸을 버리던 것을 고친다. + 첫 행이 합친 칸(제목 행 등)이면 그 아래 행은 첫 칸만 남았다. 이제 칸을 + `hp:cellAddr` 자리에 놓고 가장 넓은 행만큼 열을 두며, 합친 칸이 덮는 자리는 비운다. +- `doc.tracking.delete(문단)`로 글만 있는 문단을 통째로 지우면, 한컴에서 그 자리에 빈 줄이 + 남던 것을 고친다. 문단 나눔은 삭제로 표시되지 않았다. 이제 뒤에 문단이 이어지면 마지막 + 삭제 표시에 `paraend="1"`을 써서 문단 나눔도 지운다. 구역 설정·컨트롤·개체가 있는 + 문단과 마지막 문단은 전처럼 나눔을 남긴다. +- `append_document`/`insert_document`가 원본 첫 문단의 단 설정(`hp:colPr`)을 뺄 때, 같은 + `hp:ctrl` 안에 함께 있던 다른 컨트롤(누름틀 시작, 쪽 번호 새로 시작 등)까지 지우던 것을 + 고친다. 누름틀 끝만 남아 짝이 끊긴 문서는 한컴에서 열리지 않았다. 이제 `hp:colPr`만 빼고, + `hp:ctrl`은 비었을 때만 지운다. `insert_document(after_paragraph_index=-1)`가 대상의 구역 + 설정을 새 첫 문단으로 옮길 때도 `hp:colPr`만 옮긴다. +- 문서를 저장할 때 같은 패키지 바이트에 열기 안전 검사(`validate_editor_open_safety`)를 + 두 번 돌리던 것을 한 번으로 줄인다. 패키지 저장이 검사를 통과시킨 바이트의 SHA-256을 + 기억하고, 문서 쪽 검사는 같은 바이트면 건너뛴다. 패키지가 검사하지 않은 바이트는 전처럼 + 검사한다. 긴 문서의 저장이 빨라진다. +- `styles.ensure_run(font=...)`가 머리말에 없는 글꼴을 조용히 무시하던 것을 고친다(글자는 기본 + 글꼴로 그려졌다). 이제 한컴처럼 그 글꼴을 일곱 언어 모두에 선언하고 적용한다(`ensure_font`의 + 기본값과 같다). +- 일부 언어에만 선언된 글꼴을 `ensure_run(font=...)`로 쓰면, 선언되지 않은 언어의 `fontRef`에 + 다른 언어의 글꼴 번호가 들어가던 것을 고친다. `fontRef`는 언어마다 번호를 따로 매겨서, 그 + 언어에 글꼴이 더 있으면 엉뚱한 글꼴이 됐다. 이제 글꼴이 선언된 언어만 바꾸고, 나머지 언어는 + 바탕 글자 모양의 글꼴을 그대로 둔다(`font_ref_for_face`는 선언된 언어만 돌려준다). +- 긴 문서를 열고 저장하는 시간을 줄인다. 네임스페이스 정규화(`normalize_hwpml_namespaces`)가 + XML 파트마다 모든 태그와 속성을 파이썬 콜백으로 다시 훑었는데, 이제 2016 네임스페이스 URI가 + 든 태그만 찾아 고친다. 결과 바이트는 같다. 저장은 열기 안전 검사에서 패키지를 다시 읽으므로 + 함께 빨라진다. +- `page.set_page_number(format_type=...)`로 고른 쪽 번호 모양(로마 숫자, 원 숫자 등)이 + 한컴에서 늘 아라비아 숫자로 그려지던 것을 고친다. 한컴은 머리말·꼬리말의 쪽 번호를 + `hp:autoNum`으로 그리고 그 모양을 자식 `hp:autoNumFormat`에서 읽는데, 모양은 옆의 + `hp:pageNum@formatType`에만 적혀 있었다. 이제 `hp:autoNum`이 한컴처럼 모양을 가진다. + "쪽/전체 쪽"(`format="page/total"`)의 전체 쪽 번호도 같은 모양을 쓴다. +- 표 셀에 넣은 차트(`shapes.add_chart(..., paragraph=셀 문단)`)가 셀보다 넓게 만들어져 + 한컴에서 셀 경계에서 잘리던 것을 고친다. `size`를 주지 않으면 기본 크기(32250×18750)를 + 넣을 곳(표 셀이나 본문)의 쓸 수 있는 폭에 맞게 같은 비율로 줄인다. 중첩 표의 기본 폭과 + 같은 기준이다. 본문의 기본 크기와 `size`로 준 크기는 그대로다. +- `tools.toc_author.add_native_toc()`가 만든 차례 항목에서 쪽 번호가 제목 바로 뒤에 + 붙던 것을 고친다. 한컴은 탭을 문단의 탭 정지로 배치하고 `hp:tab` 속성은 그 결과의 + 캐시로만 쓰는데, 항목 문단(기본 문단 모양)에는 탭 정지가 없었다. 이제 항목 문단은 + 첫 구역 본문 오른쪽 끝(쪽 폭에서 왼쪽·오른쪽·제본 여백을 뺀 곳)에 `leader` 채움의 + 오른쪽 탭 정지를 가진다. 기본값 3은 한컴 차례와 같은 점선이다. `leader`는 탭 채움 + 코드 0–11만 받고, 그 밖의 값은 `paragraph-tab-leader-invalid`로 거부한다. +- 탭이 든 문단의 멀쩡한 줄 배치 캐시를 오래됐다고 보고 지우던 것을 고친다. 그래서 한컴 문서를 + 편집 없이 저장하기만 해도 쪽 경계에서 줄이 옮겨 갈 수 있었다. 캐시의 줄 시작 위치 + (`hp:lineseg@textpos`)는 `hp:t` 안의 인라인 요소도 센다. 탭은 8칸, 줄바꿈·하이픈·고정 빈칸은 + 1칸이다. 저장 때 캐시 정리와 편집 안전 검사의 "줄 시작이 글 길이를 넘는 캐시" 규칙이 이 길이 + (`oxml.utils.hancom_text_length`)를 쓴다. 글을 넘어서 시작하는 캐시는 전처럼 지운다. +- 문서에 이미 들어 있던 줄 배치 캐시가 문단 글을 다 담지 못하면(여러 줄 글에 한 줄 + 캐시) python-hwpx가 편집하지 않은 문서도 저장하지 못하던 것을 고친다. 편집 안전 + 검사는 이런 캐시를 거부하는데(한컴이 꼬리 글을 겹쳐 그린다), 저장 때 모든 구역에 + 도는 캐시 정리(`HwpxOxmlSection.remove_stale_layout_caches()`)는 글 길이를 넘는 줄 + 시작만 지웠다. 이제 그 검사가 거부할 캐시도 지운다. 한컴은 그 문단을 다시 배치한다. +- 머리말·꼬리말 글(`page.set_header(text=...)`·`set_footer(text=...)`, 머리말·꼬리말 + 객체의 `text`와 `add_run()`)과 각주·미주 글(`notes.add_footnote()` 등, 주석 객체의 + `text`)이 탭을 지우던 것을 고친다. 이제 탭을 `hp:t` 안의 `hp:tab`으로 쓴다 + (`page.set_header(text="학교명\t날짜")`). 두 `text`는 `hp:t` 안 요소 뒤의 글과 탭까지 + 읽는다. 머리말·꼬리말의 `set_simple_text_preserving()`은 전처럼 탭을 받지 않는다. +- 셀의 `set_text()`(`Table.set_cell_text()`)와 `run.text = ...`가 옛 글 일부를 남기던 것을 + 고친다. 한컴이 쓴 셀과 run에는 `hp:t` 안에 줄바꿈·탭·공백 요소와 형광펜 표시가 있고, + 그 뒤에도 글이 있다. 두 설정자는 `hp:t`의 앞 글만 바꿔서 요소와 그 뒤 옛 글이 새 값 + 뒤에 남았다(`run.text`는 새 값을 옛 글 뒤에 붙였다). 이제 `paragraph.text`처럼 옛 글을 + 요소까지 모두 지우고 새 값을 쓴다. `run.text`는 첫 `hp:t` 자리에 쓴다. +- 같은 두 설정자가 값의 탭을 지우던 것을 고친다. 이제 탭을 `hp:t` 안의 `hp:tab`으로 + 쓴다(`split_paragraphs=True`도 같다). +- 탭 문자가 든 글을 쓴 문서를 한컴이 끝내 열지 못하던 것을 고친다. + `paragraph.add_run("이름\t홍길동")`, `doc.text.replace()`, `fill_cells()`, + `paragraph_patch()`, `apply_body_ops()`의 `replace_text`와 삽입 op는 탭을 문자 그대로 + `hp:t`에 넣었다. 한컴은 탭을 `hp:t` 안의 `hp:tab` 요소로만 읽고, 탭 문자가 있으면 + 문단 배치를 끝내지 못한다. 이제 모든 쓰기 경로가 탭을 `hp:tab` 요소로 쓴다. + 메모리의 문서 트리는 그대로이고, 저장하는 XML만 바뀐다. +- 라이브러리 안의 코드가 6.0에서 옮긴 `HwpxDocument` 이름을 불러, 지금 API만 쓰는 + 사용자에게도 `DeprecationWarning`이 나던 것을 고친다. 셀 맞춤(`set_cell_text(fit=...)`, + 메일 머지)의 글자·문단 모양 읽기와 글자 줄이기, `lint_layout(required_fields=...)`, + 변경 추적 점검(`inspect_redline_structure`), HWPX→Markdown 수집기가 이제 새 이름 + (`doc.fields.all`, `doc.tracking.*`, `doc.parts.headers`, `doc.text.markdown(rich=True)`)이나 + 문서 루트를 부른다. +- `shapes.add_chart()`가 계열 축이 없는 3D 차트(세 번째 `c:axId`가 `0`)를 축이 + 없다고 거부하던 것을 고친다. `0`은 축이 없다는 표시라, 이제 나머지 두 축 id만 + 정의돼 있으면 받아들인다. 한컴은 이런 차트를 축 id 두 개짜리와 같게 그린다. +- `shapes.add_drop_cap(paragraph=...)`과 문단의 `add_drop_cap()`이 문단 첫 글자 장식을 + 문단 텍스트 뒤에 붙여, 한컴이 장식을 문단 마지막 줄에 그리던 것을 고친다. 이제 장식은 + 한컴처럼 문단 첫 글이 든 run 안, 그 글(`hp:t`) 바로 앞(앞선 컨트롤 뒤)에 들어가 첫 + 줄부터 그려진다. 구역 첫 문단의 구역 설정은 그대로 맨 앞에 남는다. 장식을 담은 run은 + 첫 글자의 글자 모양(`char_pr_id_ref`) 대신 문단 글의 글자 모양을 쓴다. 큰 글자 모양이 + 그 run에 있으면 한컴이 첫 줄을 그 크기로 잡아 글이 장식을 감싸지 못했다. +- `shapes.add_polygon()`과 그룹 멤버 `ContainerMember.polygon()`이 만든 다각형이 한컴에서 + 마지막 변 없이 열린 선으로 그려지던 것을 고친다. 한컴은 첫 꼭짓점을 끝에 한 번 더 + 써서 다각형을 닫는다. 이제 두 API도 그렇게 쓴다. 열린 선이 필요하면 `closed=False`를 + 넘긴다. +- 문단 글에 넣은 탭이 한컴에서 사라지던 것을 고친다. `add_paragraph("이름\t홍길동")` + 같은 탭을 `hp:t`와 나란한 run 자식 `hp:tab`으로 썼는데, 한컴은 이런 탭을 열 때 + 버려서 글이 붙고 탭 정지도 효과가 없었다. 이제 한컴처럼 탭을 `hp:t` 안에 + 쓴다(`이름홍길동`). +- `shapes.add_line()`로 그린 가로선과 세로선이 한컴에서 보이지 않던 것을 고친다. + 선의 외접 상자(`orgSz`·`curSz`)에 0인 변이 있으면 한컴이 선을 그리지 않는다. + 이제 한컴처럼 두 크기는 각 변을 최소 1로 쓰고(가로선은 폭 × 1), `sz`와 시작·끝점은 + 그대로 둔다. `Shape.resize()`도 같게 쓴다. +- `add_picture(align="CENTER")`(와 `"LEFT"`·`"RIGHT"`)로 넣은 그림이 한컴에서 늘 왼쪽에 + 놓이던 것을 고친다. 글자처럼 취급하는 그림은 문단 정렬을 따르는데, `hp:pos@horzAlign`만 + 바꾸고 그림 문단은 양쪽 정렬로 두었다. 이제 그림 문단의 정렬도 같은 값으로 맞춘다. +- `hwpx.tools.read_fidelity.resolve_run_spans()`(와 이를 쓰는 판독 표면)가 위·아래 + 첨자를 `offset` 부호만 보고 판정하던 것을 고친다. 부호가 거꾸로여서(음수가 + 위로 올린다, DEV-028) 예전 python-hwpx 위첨자를 아래첨자로 보고했고, + `hh:supscript`/`hh:subscript` 요소를 보지 않아 한컴 자신의 첨자(`offset 0`)와 + 지금 `ensure_run(script=...)`이 만드는 첨자는 아예 첨자로 보지 않았다. 이제 + `CharProperty.is_superscript()`/`is_subscript()`처럼 요소로 판정한다. 요소 없이 + `offset`만 있는 글자는 한컴의 글자 위치(올림·내림) 설정이라 첨자로 보지 않는다. +- `hwpx.tools.toc_author.add_native_toc(headings=...)`로 제목을 직접 준 목차가 + 한컴에서 열 때 항목과 링크를 잃던 것을 고친다. `dirty` 기본값이 늘 `True`라, + 한컴이 목차를 다시 만들면서 개요 문단만 모았다. 이제 `dirty`의 기본값은 제목을 + 자동으로 찾을 때 `True`, `headings`를 직접 줄 때 `False`다. 직접 넘기면 그 값을 쓴다. +- `page.setup(columns=N)`과 `page.set_columns()`가 섹션의 단 정의는 그대로 두고 섹션 + 끝에 빈 문단과 단 컨트롤을 덧붙여, 이미 있는 본문이 1단으로 남던 것을 고친다. 이제 + `hp:secPr` 옆의 섹션 단 정의(`hp:colPr`)를 제자리에서 고쳐 섹션 전체가 N단이 된다. + 구분선(`separator_type` 등)도 이 정의에 들어가고, 빠진 값은 한컴 기본값(`SOLID`, + `0.12 mm`, `#000000`)으로 채운다. 특정 문단부터 단을 바꾸려면 + `page.set_columns(paragraph=...)`를 쓴다. + - `page.setup(columns=...)`은 같은 폭의 단 N개와 간격만 쓴다. 있던 구분선은 지운다. + - `page.set_columns`는 1~255 밖의 단 수를 `page-columns-invalid`로 거부한다. + - 문단에 넣는 단 컨트롤도 한컴 표기대로 `id=""`와 `sameSz="1"`/`"0"`을 쓴다. +- 용지 방향을 한컴 표기로 쓴다. `page.setup(orientation="PORTRAIT")`는 스키마 밖 값 + `landscape="PORTRAIT"`를 써서 한컴이 가로로 그렸고, `set_page_size(orientation= + "LANDSCAPE")`에 가로 치수를 주면 세로로 그려졌다. 이제 세로는 `WIDELY`, 가로는 + `NARROWLY`이고, 치수는 두 방향 모두 용지의 세로 치수(너비 ≤ 높이)로 저장한다 + (`NARROWLY`는 한컴이 돌려서 그린다). `page.setup`, `page.set_size`, + `section.properties.set_page_size`가 모두 같다. + - `WIDELY`/`NARROWLY`를 직접 넘기면 한컴 뜻 그대로(`WIDELY`가 세로) 읽는다. + 예전에는 `WIDELY`를 가로로 읽었다. + - `page.set_size`는 모르는 방향 값을 `page-orientation-unsupported`로 거부한다. + - `landscape`가 없으면 `page_size.orientation`은 스키마 기본값 `NARROWLY`를 + 돌려준다. 한컴도 그렇게 읽는다. + - 새 `PageSize.drawn_width`/`drawn_height`는 한컴이 그리는 쪽 크기다. 표 기본 폭, + 머리말·꼬리말 폭, `template_analyzer`의 쪽·본문 폭, `layout_preview`의 쪽 상자가 + 이 크기를 쓴다. 그래서 `NARROWLY`와 세로 치수로 된 가로 문서도 가로 폭으로 계산한다. +- `styles.ensure_run(script="sup"/"sub")`가 만든 위·아래 첨자가 한컴에서 두 번 + 줄어들던 것을 고친다. `hh:supscript`/`hh:subscript` 요소와 함께 `relSz 67`· + `offset -30/+30`도 썼는데, 한컴은 요소만으로 글자를 줄이고 올리거나 내린다. + 이제 한컴 자신의 첨자처럼 요소만 쓴다(`relSz 100`·`offset 0`). 예전 모양의 + 글자 모양은 다시 쓰지 않고, 기준 글자 모양으로 받으면 기본값으로 되돌린다. +- 한컴이 `hp:t` 안에 넣는 탭·줄 바꿈·특수 공백(`성명홍길동`) + 뒤의 글자를 `paragraph.text`·`run.text`·`doc.text.plain()`·Markdown 내보내기· + 표 이름표 찾기가 버리던 것을 고친다. 이들은 `hp:t`의 첫 글자 묶음만 읽어 위 + 예에서 "성"만 돌려줬다. 이제 모두 `hwpx.tools.text_extractor`와 같은 + 규칙으로 읽는다: 탭 `\t`, 줄 바꿈 `\n`, 줄 안 공백 U+00A0, 전각 공백 U+3000, + 하이픈 U+00AD, 그 밖의 자식 요소는 제 글자와 뒤 글자(형광펜·변경 추적 표시는 + 빈 요소라 뒤 글자만). 텍스트 추출기도 `hp:t` 안의 + 탭을 버리던 것을 고친다(목차 항목의 점선 탭 뒤 쪽 번호가 붙어 나왔다). 변경 추적으로 + 삽입한 글(`hp:insertBegin` 뒤 글자)도 이제 `paragraph.text`와 평문에 나온다. +- `doc.styles.ensure_run()`이 만든 글자 모양(`hh:charPr`)의 자식 순서가 OWPML + 스키마(`CharShapeType`)를 어기던 것을 고친다. `bold`·`italic`·`underline`· + `strikeout`을 `outline`·`shadow` 뒤에 덧붙였고, `bold`를 `italic`보다 먼저 썼다. + 이제 서식을 적용한 뒤 자식을 스키마 순서(`fontRef, ratio, spacing, relSz, offset, + italic, bold, underline, strikeout, outline, shadow, emboss, engrave, supscript, + subscript`)로 맞춘다. 한컴이 쓰는 순서와 같다. +- `doc.refs.add_hyperlink()`로 만든 링크가 한컴에서 **아무 데도 가지 않던** 것을 + 고친다. 대상 주소를 `fieldBegin@name`에만 썼는데, 한컴은 링크 대상을 필드 + 매개변수에서 읽는다. 한컴에서 다시 저장하면 링크는 남지만 + `Category=HWPHYPERLINK_TYPE_HWP`만 붙고 `Command`가 없는(대상 없는) 문서 안 + 링크가 됐다. 이제 한컴이 쓰는 모양 그대로 `Command`(`: ? ; #` 백슬래시 이스케이프 + `;1;0;0;`, 메일 `;2;0;0`, + 책갈피 `?이름;0;0;0;`)·`Path`·`Category`·`TargetType`·`DocOpenType`을 쓰고, + `fieldid`는 한컴 하이퍼링크 컨트롤 id(627600491)로 둔다. `#이름`을 주면 그 + 책갈피로 가는 링크가 된다. 옛 판독기를 위해 `@name`에도 주소를 그대로 둔다. +- 링크 대상을 읽는 곳이 서로 달랐던 것을 맞춘다. `paragraph.hyperlinks`와 + Markdown 내보내기는 `@name`만 읽어 한컴이 만든 링크의 주소가 빈 값이었고, + 주석 달린 텍스트 추출은 `Command`를 이스케이프와 `;1;0;0;` 꼬리가 붙은 채로 + 돌려줬다. 이제 셋 다 `Path` → `Command`(꼬리·툴팁 제거, 이스케이프 풀기) → + `@name` 순으로 같은 값을 돌려준다. +- `doc.page.set_page_number(format="page/total")`이 "쪽/전체 쪽수" 대신 + "쪽/쪽"을 그리던 것을 고친다. 두 번째 번호도 현재 쪽(`numType="PAGE"`)으로 + 써서, 3쪽 문서가 1/1, 2/2, 3/3으로 나왔다. 이제 한컴 문서의 쪽/전체 + 머리말·꼬리말처럼 `/` 뒤에 `hp:autoNum numType="TOTAL_PAGE"`를 쓰고(1/3, 2/3, + 3/3), 쪽 번호 위치 컨트롤(`hp:pageNum`)은 한 번만 넣는다. +- `hwpx.tools.toc_author.add_native_toc()`를 기본값(`at_index=0`)으로 부르면 목차가 + 구역 설정(`hp:secPr`)을 가진 첫 문단 앞에 들어가, 한컴이 목차를 별도 구역으로 + 읽던 것을 고친다(목차 뒤에 쪽 나눔이 생겼다). 이제 + `hp:secPr`와 구역 처음부터 적용되는 조판 부호(단·머리말·꼬리말·쪽 번호)는 새 + 첫 문단의 맨 앞 run으로 옮겨 가고, 목차는 그대로 문서 맨 앞에 선다. +- `doc.styles.apply_paragraph_format()`·`apply_list_format()`처럼 문단 모양 + (`paraPrIDRef`)을 바꾸는 편집이 그 문단의 줄배치 캐시(`hp:linesegarray`)를 남기던 + 것을 고친다. 한컴은 줄 높이를 이 캐시에서 가져오므로, 행간을 130%로 바꿔도 + 예전 160% 줄 높이로 그려졌다. 이제 문단 모양이 바뀐 문단의 캐시만 지우고, 글자 + 모양을 바꿀 때처럼 다른 문단의 캐시는 그대로 둔다. +- `add_chart`가 축(`c:axId`·`c:catAx`·`c:valAx`)이 필요한 차트 종류(막대·꺾은선· + 영역·분산형·방사형·거품형, 3D·주식형·표면형 포함)에 축이 없으면 + `HwpxValueError`(`shape-chart-axes-missing`)로 거부하고 축을 넣는 방법을 + 안내한다. 한컴은 축 없는 막대 차트를 막대 없이 빈 차트로 그리고, 나머지 종류는 + 렌더하거나 저장하다 멈췄다. 같은 차트에 축만 넣으면 정상이었다. 축이 없는 + 원형·도넛 차트는 전처럼 받는다. +- 암호가 걸린 HWPX를 열면 `Start tag expected, '<' not found` 같은 파일 손상처럼 + 보이는 lxml 오류만 나오던 것을 고친다. `META-INF/manifest.xml`이 파트를 + `encryption-data`로 암호화 선언한 경우 예외 타입(`XMLSyntaxError`)은 그대로 + 두고 메시지가 문서 암호 해제 후 재저장을 안내하며, 원래 lxml 오류는 + `__cause__`로 남는다(HWP v5 안내와 같은 방식). 계기: HWPX 1,395건(실업무 문서· + 공개 픽스처·저작 산출) 열기 기준선에서 유일하게 열리지 않던 파일이 한컴오피스 + 한글 13.0.0.711이 저장한 암호 문서였다. +- 쪽 종류가 다른 머리말·꼬리말을 함께 쓰면 한컴에서 일부가 사라지던 문제를 + 고친다. 머리말·꼬리말을 설정하면 한컴이 읽는 `hp:ctrl` 사본을 같은 쪽 종류만 + 바꾸도록 했다. 예전에는 그 태그의 사본을 모두 지웠다. 그래서 홀수 쪽 꼬리말 + 뒤에 `set_page_number`(양쪽 꼬리말)를 부르면 홀수 쪽 글자가 한컴에서 + 사라졌다. python-hwpx만 읽는 `hp:secPr` 사본에는 글자가 남아 있었다. 또 + `BOTH` 사본을 `ODD`·`EVEN`보다 앞에 둔다. 한컴은 + 쪽마다 문서 순서상 마지막으로 해당하는 컨트롤을 그려서, 양쪽 꼬리말이 뒤에 + 있으면 홀수 쪽 꼬리말은 한 번도 그려지지 않았다. `remove_header`·`remove_footer`도 + 이제 지정한 쪽 종류의 사본만 지운다. +- 메모 필드가 한컴에서 저장된 뒤 범위 끝과 작성 시각을 잃던 문제를 고친다. + `attach_memo_field`는 MEMO `fieldBegin`에 메모마다 양의 `zorder`를 주고, + `CreateDateTime`을 한컴과 같은 UTC ISO 8601(`2024-12-02T09:00:00Z`)로 쓴다. + 시간대 없는 값은 로컬 시각으로 보고 UTC로 바꾼다. 한컴에서는 + `zorder`가 없거나 `-1`이면 저장 때 `fieldEnd`가 빠졌고, + `2024-12-02 09:00:00` 형식은 열 때 1601년 값으로 바뀌었다. 매개변수 개수 + 속성도 다른 필드처럼 한컴 표기 `cnt`로 쓴다(한컴은 저장하며 `count`를 + `cnt`로 바꿔 썼다). +- `insert_document(..., after_paragraph_index=-1)`는 대상 절의 `hp:secPr`을 + 둘째 이후 문단에 남겼다. 한컴은 이를 새 절의 시작으로 읽어, 끼운 문단을 + 기본 쪽 설정의 별도 절로 열고 쪽을 넘겼다. 이제 절 설정(`hp:secPr`, + `hp:ctrl/hp:colPr`)을 새 첫 문단으로 옮기고, 병합 보고서에 + `sectionPropertiesRelocated`를 남긴다. +- 문서 병합은 여러 문단에 걸친 필드(한컴 양식의 누름틀 등)의 `fieldEnd`를 옛 + id에 남겨 짝을 끊었다. 이런 병합 결과는 한컴이 열지 못하거나 + 저장할 때 `fieldEnd`를 버렸다. 이제 id 대응을 복사한 문단 전체에서 맞추고, + `fieldEnd`의 `fieldid`는 자기 `fieldBegin`을 따른다. + +## [6.5.0] - 2026-09-21 + +기존 문서 편집과 저작 표의 보존 범위를 넓힌 릴리스입니다. 저장 성공만으로 +시각 품질을 보증하지 않으며, 과거 셀 채움 페이지 흐름 사례 두 건의 글자 가림은 +이번 버전에도 남아 있습니다. + +### 추가 + +- 표 높이와 텍스트 흐름 설정을 공개해 긴 저작 표가 다음 페이지로 이어지도록 한다. +- 혼합 run·필드·여러 story의 요청 범위를 보존하면서 편집한다. + +### 고침 + +- EqEdit의 공식 `TRIANGLE` 토큰과 실문서에서 관측된 소문자 `triangle` 토큰을 + `\triangle`로 변환한다. 미리보기 MathML에서도 삼각형을 식별자 문자열이 아닌 + 수학 연산자 기호로 보존하며, 혼합 대소문자나 더 긴 식별자는 해석하지 않는다. + 따옴표 리터럴(`\text{...}`) 안의 `\triangle`은 그대로 문자로 남기고, 저작 + 방향(`latex_to_eqedit`)은 실한컴 검증 전까지 문서화된 대문자 `TRIANGLE`만 + 낸다. (@kilomanyo, #93) + +## [6.4.0] - 2026-09-09 + +기존 문서 편집 보존 트레인. 구조 검증은 한컴 화면의 잘림·겹침·페이지 수 +보존을 뜻하지 않는다. + +### 고침 + +- 네이티브 머리글/바닥글과 빈 필드, 복제 행 문단 ID, XML 네임스페이스 + 정규화, 상속 셀 여백을 보존하도록 수정했다. + +- 표 라벨 탐색에서 병합 셀을 물리 셀 하나로 계산하고, 방향 이동이 병합 + 영역을 건너뛰도록 수정했다. 실제로 다른 셀에 반복된 라벨은 계속 모호한 + 대상으로 처리한다. +- 주요 사용 문서와 AI 참조 문서를 현재 네임스페이스 API에 맞췄다. + 저장 성공, 요청한 내용의 반영, 화면 검증의 의미를 구분한다. + +### 바꿈 + +- **ChatGPT 안내를 wheel 업로드 경로로 교체** — ChatGPT 채팅의 파이썬 환경은 + PyPI 접근이 막혀 `pip install python-hwpx` 프롬프트가 더 이상 동작하지 + 않는다. README(한/영)의 따라 하기 프롬프트와 "어디서 쓰나" 표를 GitHub + Release에 첨부된 `py3-none-any` wheel을 문서와 함께 올려 오프라인 설치하는 + 경로로 바꾸고, `docs/ai-assistants.md`(사람용 3단계·에이전트용 지시문·lxml + 의존성·최소 예제)를 신설했다. `docs/installation.md`에 wheel 설치 절을, + `llms.txt`에 PyPI 없는 샌드박스의 설치 문장을 추가했다. Release 자산 첨부는 + 기존 `release.yml`이 이미 수행하므로 CI 변경은 없다. +- **에이전트 학습 표면을 6.0 API로 현행화** — `llms.txt`와 llms-full 원천 + (`quickstart`·`recipes-traversal`·`mutation-semantics`)이 6.0에서 이동돼 + `DeprecationWarning`을 내고 7.0에서 제거되는 5.x 평면 이름 + (`replace_text_in_runs`·`iter_runs`·`add_memo_with_anchor`·`add_footnote`· + `remove_paragraph`·`list_form_fields`·`add_form_field`·`fill_form_field`· + `add_equation`·`find_runs_by_style`·`memos`·`memo_shapes`)을 안내하고 있었다. + `doc.text`/`doc.notes`/`doc.fields`/`doc.shapes` 네임스페이스와 + `paragraph.remove()`로 바꾸고, `add_heading`을 python-docx 습관으로 오기한 + 문장을 정정했다. 문서 예제 원장과 게이트 해시를 갱신했다(블록 수 117 불변). + 동봉 계약 문서(`src/hwpx/data/contract_docs/`)도 같이 재동기화했다. + +### 더함 + +- `Shape.set_position`과 `Table.set_cell_borders`를 안정 API에 추가했다. + 기존 개체 속성·셀 배경·테두리 굵기를 보존하며 모호한 구조를 거부한다. +- 기존 양식을 임시 파일에 편집한 뒤 재개봉·내용 확인 후 출력하는 + `examples/edit_existing_form.py`와 외부 문서 편집 회귀 검증. 기존 값 + 사전조건으로 잘못된 대상이나 변경된 양식을 거부할 수 있다. + +- **wheel을 GitHub Actions artifact로도 유지** — ChatGPT의 GitHub 커넥터가 + Release 자산 바이너리는 못 받고 Actions artifact는 받을 수 있는 것으로 + 확인되어, 릴리스 워크플로가 wheel과 `SHA256SUMS`를 artifact + `python-hwpx-wheel`로 올리고, 신설 `wheel-artifact.yml`이 매달·릴리스마다 + 최신 Release의 wheel을 해시 검증 후 같은 이름으로 다시 올린다(artifact 90일 + 만료 대응). 빌드하지 않고 발행된 bytes만 재게시하며, 채팅 런타임까지의 + 전 과정 실측은 아직이라 안내에 실험 경로로 표시했다. + +## [6.3.0] - 2026-08-19 + +### 더함 + +- **왕복 충실도 공개 지표** (`docs/corpus-metrics.md` 신규 절 + `docs/roundtrip/` + 영수증) — 실한컴 저장본 76건(provenance 심사, 정직 제외 3건) 무편집 + 재직렬화에서 **실질 구조 변화 0**(byte-identical 22 · zip-container-only 54), + 실한컴(Mac 12.30.0 build 6446) 재개봉 판정 가능 73/73 무손상, 음성 대조 2/2 + 정상 거부. 분모 루트를 `tests/data` gold pair·패키지 동봉 Skeleton까지 확장 + (`scripts/roundtrip_fidelity.py`). 상주 회귀 + `tests/test_roundtrip_fidelity_corpus.py`가 "substantive 0"을 매 스위트 + 게이트로 강제한다. +- **stale lineseg 꼬리 커버리지 판정** — 기존 범위 검사(textpos>len)가 못 보던 + 방향(편집으로 문단이 캐시 줄 수 너머로 자란 경우 = 변경추적 겹침 렌더의 실물 + 방향)을 layout lint/패키지 검증기가 검출한다. 꼬리 폭을 overflow lint와 같은 + 보수 추정으로 재고 줄 폭 2배 초과만 error(실한컴 저장본 76건 캘리브레이션 + 오검출 0). 실한컴 렌더 겹침 재검은 opt-in 스모크 + `tests/test_tracked_overlap_render_smoke.py`(`HWPX_MAC_ORACLE_SMOKE=1`). +- **`reorder_rows` 표 구조 op** (`apply_table_ops`) — 물리 행 재배열(완전 순열 + + rowAddr 재기입). 편집기 [표>정렬…] 역매핑 갭의 엔진 프리미티브. 수직 + 병합(rowSpan>1) 표는 typed refuse(바이트 무변경). 정렬 키 비교는 호출자 몫. + +### 고침 + +- **instid 속성명 카멜케이스 불일치** (#88) — 스키마·실한컴 산출물의 정본은 + 소문자 `instid`(픽스처 전수 744 vs 0)인데 코드 6곳이 `instId`를 읽거나 썼다. + 문단 복제 시 인스턴스 ID 재발급 분기가 영원히 안 타던 사문 수리(중복 instid + 복제 해소), 각주/미주 저작이 스키마에 없는 속성명을 방출하던 것을 정본으로 + 교정, 리더 4곳은 `instid` 우선 + 과거 자사 산출물(`instId`) 폴백. JSON 투영 + 키(`instId`)는 출력 계약이라 불변. +- **`hp:audio` 인라인 분류 정리** (#89) — 실한컴(Windows 한글 2024)의 소리 + 삽입 실물은 `hp:ole`(EMBEDDED/ICON)이고 `hp:audio`는 생성되지 않으며 실코퍼스 + 표본 0건. 빈도 0 요소는 읽기측 불투명 보존만 한다는 원칙에 따라 + `INLINE_OBJECT_NAMES`에서 제외(GenericElement 보존, 무손실 불변). + +## [6.2.1] - 2026-08-19 + +### 고침 + +- **v6.2.0은 보존된 실패 태그입니다** — 워크스페이스 편의용 `work` 심볼릭 + 링크(정본 체크아웃의 `work/`를 가리키는 상대 링크)가 릴리스 트레인에 + 추적돼 들어갔고, CI 러너의 경로 구조에서는 그 상대 경로가 자기 자신을 + 가리키는 순환 링크가 되어 `test_emit_loss_inventory`가 + `OSError: Too many levels of symbolic links`로 실패했다(1 failed/3107 — + prepublish 게이트가 발행을 정상 차단). `.gitignore`의 `work/` 패턴은 + 디렉터리만 매칭해 링크 **파일**을 못 막던 것이 근본 원인 — 링크를 + 언트랙하고 패턴에 `work`(슬래시 없음)를 보강했다. 태그는 불변 원칙대로 + 보존한다(v2.29.0 전례). 6.2.1은 그 외 6.2.0과 동일하다. + +## [6.2.0] - 2026-08-19 (보존된 실패 태그 — PyPI 미발행) + +### 더함 + +- **`HwpxOxmlParagraph.add_index_mark(first, second=)`** — 색인 표시 + (`hp:indexmark`) 저작. 실한컴 GUI 프로브 gold 2건 역설계. `add_title_mark`와 + 타겟팅 계약은 같으나(호출자가 대상 문단을 직접 지정 = 편집기의 캐럿 문단) + 배치가 다르다 — titleMark는 `hp:t` **안**, indexmark는 같은 run 안의 + `hp:ctrl` 형제로 텍스트 **앞**. 스키마는 `firstKey`/`secondKey`를 둘 다 + 필수 시퀀스로 선언하지만 실물은 1단계 색인에서 `secondKey`를 생략하므로 + 실측을 따랐다(`add_new_num`의 `autoNumFormat` 생략과 같은 부류). +- **`HwpxOxmlParagraph.add_mail_merge_field(name, cached_text=)`** — 메일머지 + 표시 필드(`hp:fieldBegin type="MAILMERGE"`) 저작. 기존 + `hwpx.tools.mail_merge.merge_template_rows`가 **기존 텍스트를 치환**하는 + Edit인 것과 달리 필드 컨트롤 자체를 만드는 Create다. 파라미터 이름 + `Fiexde`는 실물·한컴 공식 문서 공통의 오타 철자라 그대로 쓴다. 캐시 텍스트 + 기본값 `{{이름}}`은 그 배치 생성기가 인식하는 플레이스홀더 문법이라 이 + 필드로 저작한 템플릿이 곧바로 물린다. +- **`doc.parts.set_license_mark(...)`/`.remove_license_mark()`** — 문서 수준 + 라이선스 레코드(`hh:licensemark`) 저작. "입력 > CCL 넣기…"가 남기는 그 + 레코드이며, 눈에 보이는 배지는 별개(라이선스 증서 URL을 `href`로 단 그림). +- **openrate v22** (`docs/openrate/report-v22.json`) — 위 세 표면의 실한컴 + 렌더 배치: 3 스트라텀 × 5건, macOS Hancom GUI 오라클 **15/15 + render_checked·0 failed**, 음성 대조 3/3 정상 거부. 각 스트라텀에 관측 밖 + 경계 프로브 1건씩(한 문단 색인 표식 2개·한글 필드명·미관측 라이선스 type + 문자열 `"KOGL"`)을 일부러 실었고 전부 수용됐다 — 실한컴의 수용 경계가 + 관측 분포보다 넓다는 기존 v10/v11 관찰의 재확인. 원장은 `hp:indexmark`/ + `hp:firstKey`/`hp:secondKey`/`hh:licensemark`를 `by-openrate-corpus`로 + 승격했고, `hp:fieldBegin`은 공유 저수준 요소 원칙(누름틀 v15 전례)대로 + 매트릭스 산문 근거만 갱신했다. + +### 고침 + +- **`LicenseMark.type`이 `int` → `str`** — 스키마(`Header XML schema.xml`의 + `DocOptionType`)가 `xs:unsignedInt use="required"`로 선언해 그대로 믿었으나 + 실한컴이 실제로 쓰는 값은 문자열 `"CCL"`이다. 그래서 실한컴이 만든 CCL + 문서를 `to_model()`로 읽으면 `ValueError: Invalid integer value: 'CCL'`로 + 터졌다 — 위 저작 표면을 열면서 실측으로 드러났다. `flag`/`lang`은 관측값이 + 정수라 그대로다. + +## [6.1.0] - 2026-08-15 + +15개 작업 사이클(6.1~6.15)을 하나의 트레인으로 묶어 발행한다. 완전성 감사 +(2026-08-04)가 지목한 갭을 순서대로 닫아나가면서, 그 과정에서 두 개의 새 +계측기(편집기 표면 인벤토리·편집기 메뉴 역방향 지도)를 만들어 남은 갭을 +스스로 찾아내는 체제로 전환했다. 저작 표면 하나하나는 실코퍼스 리버스가 +먼저이고, 실한컴 오라클(Windows COM 또는 macOS GUI) 렌더 검증이 뒤따르는 +원칙을 전 트레인에서 유지했다 — 아래 각 항목은 그 순서를 따라 서술한다. +이 트레인은 이 브랜치가 갈라져 나온 뒤 별도로 발행된 **6.0.3의 보안 수정 +(GHSA-8g8m-xpm4-wxvx, 아래 [6.0.3] 항목)을 그대로 포함한다** — 병합으로 +합류했을 뿐 내용은 손대지 않았다. + +### 더함 + +- **`doc.styles.ensure_font`** — 폰트 선언·치환 저작. 완전성 감사가 지목한 + 최대 갭(#1): 모든 실문서가 `hh:fontfaces`를 갖는데도 이제까지 그 블록은 + frozen 스켈레톤 출력이었다. 언어 블록 7종을 기본 등록하고 `ensure_style`과 + 같은 dedupe 관용구를 따르며, `substFont`는 실코퍼스 그대로 — 임베딩이 + 아니면 `binaryItemIDRef` 속성 자체를 쓰지 않고(1682/1682 관측) + `substFont` 값은 항상 빈 문자열(284/284), `isEmbedded`는 "0"/"1" 스펠링 + (6741 관측). `fontface` id 번호가 언어 블록마다 독립적으로 매겨진다는 + 실측도 고정했다. +- **탭 정지 저작·읽기** — `apply_paragraph_format(tab_stops=...)`. 감사 갭 + #2. 스키마는 `tabPr`당 `tabItem` 최대 1개를 암시하지만 실 정부 문서는 + 위치정렬 최대 4개를 한 `tabPr`에 담는다 — 코퍼스가 스키마를 이겼고 + 저작은 문서를 따른다. auto-tab 불리언도 같은 "0"/"1" 스펠링. +- **`layoutCompatibility`·`compatibleDocument`·`settings.xml` 읽기 표면** — + 감사의 R1 반박(모든 실문서에 있는데도 코드가 전혀 못 읽던 `hh: + layoutCompatibility`)을 닫는다. `settings.xml`은 벤더드 스키마 어디에도 + 선언이 없어 177개 실문서로 구조를 역설계했고(`config:config-item` 어휘는 + OASIS ODF 1.0을 그대로 재사용), `layoutCompatibility`는 스키마가 48개 + 플래그를 선언하지만 실문서 전부 비어 있어 관찰된 자식 이름 집합만 + 보존한다. 의도적으로 읽기 전용 — 쓰기 API가 없으므로 OPC 계층의 무손실 + 바이트 보존이 그대로 적용된다(3개 실픽스처로 확인). +- **도형 텍스트(draw-text)·개체 캡션** — 감사 갭 #3·#4. `.draw_text`/ + `.set_draw_text`(rect·ellipse 호스트, 스키마가 도형 개체로만 제한)와 + 표·그림·도형이 공유하는 `.caption`/`.set_caption`/`.remove_caption`. + 실코퍼스는 스키마와 반대로 `shadow`가 `drawText`보다 먼저 오고, 캡션 + 배치는 호스트에 관계없이 "outMargin 다음"으로 일관됐다. +- **하이라이트(형광펜)** — `doc.text.highlight`/`.highlights`. + `markpenBegin`/`End`를 LIFO로 페어링해 `hp:t` 내부에 삽입한다. 벤더드 + 코퍼스의 유일한 실 예시가 스키마상 잘못된 위치(hwpxlib 오류-회귀 + 사례)이므로 그 기형 원본은 "읽기-비어있음"으로 그대로 두고 신규 저작 + 경로만 정상 계약으로 검증했다. +- **이미지·그라디언트 채우기** — `ensure_border_fill`이 스키마의 `fillBrush` + 선택지(`winBrush`/`gradation`/`imgBrush`) 전부를 커버하게 됐다. + `fill_image=`(`doc.media` 경유, 실측 전량 `TOTAL` 모드)·`fill_gradient=` + (`LINEAR` 기본, alpha "0" 관측)가 `fill_color=`와 상호배타 옵션으로 + 합류했고, 표 셀 배경 `set_cell_fill_image`/`set_cell_fill_gradient`까지 + 실제 소비 경로가 이어진다. +- **메모 도형 저작** — `ensure_memo_shape`. `hh:memoPr`을 find-or-create + dedupe로 등록한다. 실코퍼스 다수 기본값(폭 15591·SOLID·공유 3색 + 프로필)을 따르고, id는 1부터 시작(`borderFill`의 0과 다름), `memoType`은 + "NOMAL" 스펠링을 스키마·실문서 그대로 유지한다. +- **페이지 번호 재시작·페이지별 요소 숨기기** — `restart_page_number` + (`hp:newNum`)·`hide_page_elements`(`hp:pageHiding`). `newNum`은 스키마가 + 요구하는 `autoNumFormat` 자식 없이 자기닫힘으로 실코퍼스 그대로 + 방출한다(편차 등재). +- **위·아래 첨자 실요소, 양각·음각, 외곽선** — `ensure_run` 확장. hwpxlib + 실코퍼스(오류 문서 charPr id=513)에서 실한컴이 위첨자를 `relSz`/`offset` + 수치가 아니라 `hh:supscript` 플래그 요소만으로 판정함을 확인해, 기존 + offset 부호 계약(실한컴 렌더 검증으로 이미 확정됨)은 그대로 두고 실요소를 + 병행 방출한다. emboss·engrave·outline은 스키마 근거로 추가. +- **필드 매개변수 일반 모델(`ParameterList`)·타입 있는 콤보박스 + `listItem`** — `hp:parameters`(필드 클릭 액션)와 `hp:parameterset`(도형 + 확장 속성)가 스키마상 같은 재사용 타입임을 실코퍼스로 확인하고 범용 + 파서·직렬화기로 승격했다(불리언·정수·실수·문자열·리스트 매개변수 재귀 + 포함). 처음엔 읽기 모델만 있고 실제 문서 dispatch에 연결이 안 돼 있던 + 진짜 갭이었음이 이후 사이클에서 드러나 바로 이어붙였다(아래 고침 참조). +- **`hp:compose`(글자 겹치기·원문자)·재귀 `InlineObject` 승격** — + `ComposedCharacter`가 `GenericElement` 대신 타입 있는 필드로 노출된다. + 실코퍼스로 위치가 스키마와 다름을 확정했고(`hp:t` 자식이 아니라 `hp:run` + 직속), 컨테이너 등 내부에 중첩된 도형도 재귀 깊이와 무관하게 + `InlineObject`로 인식되도록 확장했다. +- **`doc.shapes.add_polygon`** — 다각형 저작. 실코퍼스(`hwpxlib_corpus/ + reader_writer__SimplePolygon.hwpx`) 리버스: 꼭짓점은 `hc:pt`(core + 네임스페이스, rect·ellipse와 같은 기하 네임스페이스 계약)이고 자기 bbox + 좌상단 원점 로컬 좌표계에 산다(정점 목록이 그 자기 `orgSz`와 정확히 + 일치, 실측) — `points_mm`로 받은 페이지-스페이스 좌표를 그 로컬 좌표계로 + 평행이동한다. 기존 `resize()`는 이미 범용(`pt` 로컬명 스캔)이라 변경 + 없이 다각형에도 적용된다. curve(`hp:curve`/`hp:seg`)는 정직 보류: 유일한 + 실 예시에서 곡선의 `orgSz`가 앵커점 bbox보다 뚜렷이 크다(스플라인이 + 앵커점 밖으로 부풀어 오르는 실측) — 한컴의 정확한 곡선 적합 알고리즘 + 근거 없이 bbox를 추정하면 침묵 오류가 되므로 미룬다. +- **`doc.shapes.add_arc`** — 사분원(호) 저작. 실코퍼스(`hwpxlib_corpus/ + reader_writer__SimpleArc.hwpx`) 리버스: 스키마상 `hp:arc`는 좌표 3점 + (`center`/`ax1`/`ax2`)뿐 각도 필드가 없다. 유일한 실 예시는 `center`가 + 자기 bbox 모서리에 앉고 `ax1`이 바로 아래, `ax2`가 바로 오른쪽인 사분원 + 하나뿐이라 그 배치만 점 단위로 검증됐고, 나머지 세 모서리는 다른 도형이 + 이미 쓰는 `hp:flip` 미러링으로 얻는다(`corner` 인자 — `TOP_LEFT`만 실측, + 나머지 3개는 유도). `arc_type`(NORMAL/PIE/CHORD)은 스키마 열거값 그대로 + 통과한다. connectLine은 정직 보류: 유일한 정본 예시(`SimpleConnectLine. + hwpx`)는 자유선이 아니라 `subjectIDRef`로 다른 두 도형을 잇는 "스마트 + 연결선"이고, `offset`이 음수·`curSz`≠`orgSz`·`scaMatrix`가 평행이동까지 + 얹은 비항등 행렬이라 관계식을 재현할 근거가 없다. +- **파트 계층 읽기 모델** — `version.xml`(`HcfVersion`)·`masterpage.xml` + (`MasterPage`)·`history.xml`(`History`) 읽기 승격(감사 갭 #15). + `settings.xml` 관용구 그대로: `doc.parts.*`가 돌려주는 `HwpxOxml + {Version,MasterPage,History}`에 `.to_model()` 추가. 실 산출물 + 47/47(version)·다수(masterpage, 서로 다른 두 문서 계열 110건) 전수가 + 벤더드 2024 초안 스키마와 루트 이름·네임스페이스가 다르다는 걸 + 재확인했다(스키마가 아니라 실코퍼스가 진실 원천 — `version.xml` 루트는 + `hv:HCFVersion`이고 오탈자 `tagetApplication`을 그대로 쓴다, + `masterpage.xml` 루트 `masterPage`는 네임스페이스가 없다). `history.xml`은 + 실 예시가 하나도 없어(코퍼스+개인 실문서 6,262건 전수) 스키마 전용으로 + 표기 — 평평한 리비전 메타데이터만 타입 있는 필드로 옮기고, 재귀 + 중첩되는 diff 본문은 `DiffNode`로 원문 구조 그대로 보존한다. +- **그룹 개체(컨테이너) 저작** — `doc.shapes.add_container`. 실코퍼스 74개 + 컨테이너(벤더드 3개 + 오류-회귀 픽스처 71개) 리버스: 멤버는 독립 도형과 + 동일한 offset/orgSz/curSz/flip/renderingInfo 계약을 갖되 + `AbstractShapeObjectType` 꼬리(sz/pos/outMargin/shapeComment)를 그룹이 + 대신 갖고 `groupLevel="1"`이 된다. 컨테이너 자신의 `orgSz`는 멤버들의 + 합집합 bbox. 알려진 한계도 그대로 문서화했다: `resize()`가 컨테이너 + 자신의 크기만 갱신하고 멤버 좌표는 안 건드린다. +- **특수 인라인 텍스트 원자(줄바꿈·전각공백·고정폭공백)** — + `add_run(expand_special_characters=True)`. 세 마커 모두 `hp:t` 내부 + 혼합 콘텐츠로 중첩되는 실코퍼스 관용구를 그대로 재현한다(스키마가 + 허용하는 `hp:run` 형제 배치는 실사용 0건). 저작과 별개로 읽기 쪽 실버그도 + 같은 트레인에서 발견·수리했다(아래 고침 참조). +- **`hp:switch`/`case`/`default` 읽기 모델** — 스키마 선언이 전혀 없는데도 + 실코퍼스 236/237이 쓰는 버전-호환 래퍼. `paraPr`의 `margin`/`lineSpacing`이 + 이 래퍼 안에 있으면 `None`으로 읽히던 실버그를 `hp:case`-우선/`hp:default`- + 폴백 읽기로 수리했다(아래 고침 참조). `tabPr` 쪽은 두 분기가 서로 다른 + 값(정확히 2배 관계)을 가져 정반대로 `hp:default`를 우선해야 한다는 것도 + 별도로 확정했다. +- **저수준 도형·컨트롤 이스케이프 해치의 정직한 검증 신호** — + `add_shape`/`add_control`로 필수 자식이 빠진 도형이나 빈 `hp:ctrl`을 + 만들면 실한컴이 열지 못하는데도 `validate_package`/ + `validate_editor_open_safety` 둘 다 통과시키던 갭. 경고 레벨 신호를 + 추가했다(오류로 만들지 않음 — 여러 호출에 걸친 점진적 조립이라는 이 + 이스케이프 해치의 문서화된 사용법을 깨지 않기 위해서다). 컨테이너 + 멤버는 이 5개 자식이 없는 게 정상이라 스캔에서 제외한다. +- **문서 옵션·호환성 설정 저작** — `hh:compatibleDocument/@targetProgram` + (실측 47/47 "HWP201X" 고정)·`layoutCompatibility` 플래그·`hh:docOption/ + linkinfo`·`hh:paraPr/autoSpacing`까지 쓰기 경로를 열었다. 모든 불리언은 + "0"/"1" 관용구(다른 OWPML 불리언 계열과 동일). +- **`hp:label`(양식 라벨·명패) 읽기+쓰기** — 개인 실문서(학교 행정서식) 75건 + 리버스: 항상 `hp:tbl`의 마지막 자식, 스키마 11개 속성 전부 사용, 실측은 + 정확히 2가지 조합(2×9 소형 라벨시트 325건/1×2 대형 명패 111건)으로 + 수렴하지만 저작은 그 두 조합에 값을 제한하지 않는다(실증 없는 값을 + 기계적으로 금지하지 않는다는 원칙). +- **문서 삽입·병합(`append_document`/`insert_document`)** — 다른 HWPX + 문서의 본문을 열린 문서에 복사하면서 헤더 소유 공유 자원(charPr/paraPr/ + style/borderFill/tabPr/numbering/bullet/memoPr/fontfaces)마다 새 대상 + id로 재매핑해, 대상이 이미 그 번호에 갖고 있던 값에 조용히 앨리어싱되지 + 않도록 한다. v2에서 실측된 한컴 자신의 "문서 끼워 넣기" 4축 정책(글자 + 모양·스타일·문단 모양·쪽 모양 유지)을 named parameter로 노출한다 + (기본값=v1의 이미 실한컴 검증된 동작, 비기본값은 타입 오류로 거부) — + KeepStyle 축이 동일-이름 스타일 충돌 해소 규칙임을, KeepSection 축이 + 별도 섹션 파트 생성 여부임을 실 골드 2라운드로 확정했다. MEMO 병합 + 지원도 추가됐다(`hp:memogroup`이 문단이 아니라 섹션의 형제임을 확인 + 후). 이 저작 표면을 만드는 과정에서 여러 실결함을 발견해 고쳤다(아래 + 고침 참조). +- **덧말·글자 겹치기(`add_dutmal`/`add_composed_character`) 정식 완결** — + 구현 자체는 앞서 들어와 있었고, 이번에 capability 영역 등록·편차 등재· + 정식 테스트로 마무리했다. +- **문서정보(document metadata)** — `doc.parts.set_document_metadata`/ + `document_metadata()`. 실문서 67픽스처 전수 조사: title/creator/subject/ + keyword/lastsaveby는 요소는 있지만 대개 비어 있고, `CreatedDate`/ + `ModifiedDate`는 65/65 단일 ISO-8601 포맷(저작 대상)인 반면 자유형식 + `date` 필드는 한컴 버전·로캘에 따라 5가지 이상 서로 다른 포맷이 관측돼 + 정직하게 저작을 보류한다(불투명 문자열 보존만, curve·connectLine과 같은 + 원칙). +- **드롭캡** — `doc.shapes.add_drop_cap`. `dropcapstyle`이 `hh:paraPr`이 + 아니라 `AbstractShapeObjectType`(모든 임베드 가능 도형의 공유 속성)에 + 있다는 걸 스키마 대조로 발견했다. 67개 픽스처 중 유일한 실사용 예시 + (TripleLine)를 구조까지 리버스했고(투명 `hp:rect`가 확대 글자를 감싼 + `drawText`/`subList`, `horzRelTo=PARA`/`flowWithText=1`, `curSz`가 항상 + 0/0인 센티널), DoubleLine·Margin은 표본이 없어 타입 오류로 거부한다. +- **글자 방향(세로쓰기)** — `doc.page.text_direction`/`set_text_direction`. + `hp:secPr`의 `textDirection`(HORIZONTAL/VERTICAL/VERTICALALL)· + `textVerticalWidthHead`를 노출한다. 67픽스처 74개 `secPr` 전부 + HORIZONTAL이라 VERTICAL 계열은 실사용 전례가 없지만, 이 프로젝트 + 최초로 VERTICAL/VERTICALALL을 실제로 실한컴 렌더 검증까지 통과시켰다. +- **단 나누기** — `apply_paragraph_format(column_break=...)`. `hp:p` 자신의 + `columnBreak` 속성으로, 기존 `page_break_before`(`hh:breakSetting`이라는 + 공유 스타일 속성)와는 메커니즘이 다른 별개의 문단-인스턴스 속성임을 + 확인한 뒤 저작했다(실 73건 `pageBreak="1"` 계열로 동일 어휘 계약 확인). +- **표 나누기·붙이기** — `apply_table_ops`의 `split_table`·`merge_table`. + 전용 OWPML 어휘가 없어 `hp:tr`/`hp:tc`/`cellAddr`/`cellSpan` 순수 구조 + 편집으로 구현했고, 병합 셀이 분할 경계를 가로지르면 실패 폐쇄로 + 거부한다. 표 뒤집기는 정의가 불가능하다는 판단으로(병합 셀이 있을 때 + 어느 쪽이 내용을 갖는지 정할 근거가 스키마·코퍼스 어디에도 없음) 정직 + 보류한다. +- **셀 균등화(행 높이·열 너비)** — `equalize_column_widths()`/ + `equalize_row_heights()`. 이미 있던 `set_column_widths`/셀별 `set_size`를 + "균등화"라는 이름 있는 연산으로 노출했다. 병합 셀은 자신이 걸친 + 행·열의 합을 받는다. +- **개요 번호매기기** — `ensure_numbering`/`apply_list_format`의 세 번째 + kind, `"outline"`. `hh:heading type="OUTLINE"`이 리스트 서식과 같은 + `hh:numbering`/`hh:paraHead` id-공간을 공유한다는 사실을 활용해 커스텀 + 번호 형식·시작값을 지정할 수 있게 했다. +- **바탕쪽(master page) 쓰기 경로** — `doc.parts.add_master_page` + + `doc.page.set_master_page`/`.master_page_refs`. 유일한 실 샘플에서 파트 + 파일명·매니페스트 `opf:item` id·`masterPage` 루트 자신의 id·섹션의 + `hp:masterPage/@idRef`가 모두 같은 문자열("masterpageN")을 공유함을 + 직접 확인해 재현했다. 마스터 페이지는 `opf:item`은 받지만 spine + `itemref`는 받지 않는다(읽기-순서 파트가 아니므로). +- **날짜·교정 부호 필드** — `add_date_field(type="DATE")`· + `add_proofreading_mark(type="PROOFREADING_MARKS_SIGN")`(DEV-043). 실한컴 + macOS GUI 프로브 골드(`date_and_proofreading_mark.hwpx`) 리버스. 스키마는 + `PROOFREADING_MARKS`를 선언하지만 실측은 열거형에 아예 없는 값 + `PROOFREADING_MARKS_SIGN`이다. +- **PATH 필드** — `add_path_field(type="PATH")`. GUI 프로브 없이 기존 + 실코퍼스 샘플(파일 이름 필드, `Command=Format="$F"`)만으로 계약을 + 확보했다. 이 저작을 붙이며 `fieldEnd`의 `@fieldid` 갱신 누락이라는 + 실결함을 함께 발견해 DATE·교정 부호·PATH 세 필드 전부 소급 수리했다 + (아래 고침 참조). +- **titleMark(차례 숨기기·제목 차례 표시)** — `add_title_mark(*, in_toc: + bool)`(DEV-044). macOS GUI 자동화로는 캐럿을 임의 문단에 놓을 수 없어 + 한 사이클 넘게 저작이 보류돼 있었는데, Windows 박스 COM `SetPos` 3- + variant 프로브로 캐럿-문단 타겟팅 규칙이 확정되며 열렸다. `in_toc=True` + → `ignore="1"`, `False` → `ignore="0"`(이름의 직관과 반대인 극성을 + macOS GUI·Windows COM 양쪽에서 독립적으로 확인). 필드 래퍼가 전혀 없는 + 유일한 마커 계열이라(DATE/교정 부호/PATH의 `ctrl`/`fieldBegin` 메커니즘과 + 다름) 새 run을 만들지 않고 기존 run의 첫 `hp:t` 안에 바로 삽입한다. + +### 고침 + +- **문서 병합의 `linkListIDRef` 과잉 거부 + 저장 경로 무결성 훼손** — + `hp:subList`가 관용적으로 갖는 센티널 값("0", 벤더드 코퍼스 5,891/5,891) + 을 실제 연결된 텍스트박스 체인으로 오판해, 표를 가진 모든 문서(이 + 라이브러리 자신이 생성한 문서 포함)를 병합 불가로 만들던 결함. 더 + 심각한 두 번째 결함이 같은 트레인에서 함께 드러났다: 병합이 대상 + 헤더 트리를 직접 변형하면서도 `target_header.mark_dirty()`를 호출하지 + 않아, 그림이 껴 있지 않은 모든 병합이 메모리상 참조 무결성 검사는 + 통과하면서 실제로는 손상된 파일을 저장하고 있었다(재오픈 전까지 안 + 보임). 세 번째: `hp:secPr` 제거 로직이 같은 run에 얹혀 있던 표·텍스트 + 까지 통째로 삭제하던 결함(빈 문서 스켈레톤으로만 테스트돼 있었다). +- **문서 병합의 `fieldEnd`/`@fieldid` 미갱신** — `hp:fieldBegin`· + `hp:fieldEnd` 둘 다 생성 시 같은 `fieldid`를 갖지만, 병합 리프레시가 + `fieldBegin` 쪽 id/fieldid와 `fieldEnd`의 `beginIDRef`만 재발급하고 + `fieldEnd` 자신의 `fieldid`는 건드리지 않아 원본 `uuid4` 값이 복사된 + 콘텐츠에 그대로 남아있던 결함. DATE·교정 부호·PATH 필드 저작에도 같은 + 결함이 있어 함께 소급 수리했다. +- **문서 병합의 속성-클론 중첩 참조 앨리어싱** — `hh:paraPr` 내부 테두리의 + `borderFillIDRef`/`tabPrIDRef`, `hh:charPr`의 글자-테두리 + `borderFillIDRef`가 본문이 아니라 방금 복사된 헤더 항목 안에 살아있어 + 임포트 스캔에 안 잡히던 결함(참조가 매달린 상태가 아니라서 참조 + 무결성 검사도 통과했다). 무테두리 문서를 표 스타일(SOLID) 대상에 + 병합하면 표지 전체가 박스로 렌더되는 것으로 실증(실한컴 A/B 대조)한 + 뒤 수리했다. +- **변경추적(추적된 삽입·삭제·교체) 줄 겹침 렌더** — run 교체 경로 + (`apply_model`)가 문단의 lineseg 캐시를 지우지 않아 한컴이 옛 + 줄배치를 재사용, 추가된 텍스트가 기존 줄 위에 겹쳐 렌더되던 실사용 + 제보를 재현·수리했다. 실한컴 오라클로 수리 전후 렌더를 직접 + 대조했다. 후속 감사로 문단 복제 경로 7종을 전수 지도화해 무방비 + 2종(document-merge·공개 `insert_paragraphs`/`copy_paragraph_range`)의 + lineseg 캐시도 클론 시점에 제거하도록 정리했다(항상 새 위치의 새 + 내용이므로 원본 절대좌표를 들고 있을 이유가 없다). +- **`hh:tabPr` 탭 정지가 `hp:switch`로 감싸이면 통째로 안 읽히던 결함 + (DEV-022)** — `parse_tab_definition`이 직계 자식만 스캔해 `hp:switch` + 안의 `hh:tabItem`(실코퍼스 449건)을 전부 놓치던 결함. `hp:case`/ + `hp:default` 두 분기가 실제로는 다른 값(정확히 2배 관계)을 갖는다는 걸 + 확인해 `hp:default`를 우선하는 읽기 모델을 세웠다(같은 값을 공유하는 + `paraPr`의 case-우선 규칙과는 정반대). 저작 쪽의 자매 결함(`ensure_ + tab_definition`의 dedupe 비교가 같은 맹점으로 중복 `tabPr`을 만들던 + 문제)도 같은 커밋에서 함께 수리했다. +- **`add_section()`이 메모가 앵커된 첫 문단에서 깨지던 결함** — `hp:secPr` + 이 항상 첫 문단의 물리적으로 첫 run에 있다고 가정하던 코드가, 메모(나 + 다른 필드)가 앞에 붙으면 실제로 존재하는 `secPr`을 못 찾고 "양수 페이지 + 크기를 가진 섹션이 없다"는 오류를 내던 결함. +- **`paraPr` 비중복화** — `ensure_paragraph_format`이 호출마다 동일 내용의 + `paraPr`을 새 id로 계속 발급하던 결함(실한컴은 저장 시점에 중복을 자체 + 병합하지만 이 라이브러리는 하지 않고 있었다). 구조 비교 기반 + find-or-existing으로 수리했다. +- **특수 인라인 텍스트 원자 읽기 버그** — `TextExtractor`가 `lineBreak`/ + `nbSpace`/`fwSpace`를 재귀 순회에서 건너뛰기만 하고 대체 문자를 넣지 + 않아, 줄바꿈이 조용히 사라지고 앞뒤 텍스트가 그대로 붙어버리던 결함. + 각 마커를 유니코드 대응(줄바꿈은 개행, 전각·고정폭 공백은 각각의 + 전용 공백 문자, 하이픈은 소프트 하이픈)으로 치환하도록 고쳤다. +- **커버리지 원장(coverage ledger) 계측기 자체의 오분류 4종** — 독립 + 완전성 감사(2026-08-04)가 판정한 오분류(주석·문서화 문자열을 코드로 + 오인, 함수 인자로 전달된 태그를 못 따라감, 런타임 조립 마크에 근거 + 없는 화이트리스트 등)를 tokenize+ast 기반 스캐너·고정점 인자 추적기· + 근거-필수 화이트리스트로 수리했다. 감사가 제시한 4개 오분류 사례 전부 + 수리 전/후 재현으로 확인했다. + +### 정리 (측정 인프라) + +- **census 모집단 재구성** — 커버리지 원장의 census를 재현 불가능한 + 옛 166파일 모집단에서 물러나, 벤더드 hwpxlib 코퍼스(47, 누구나 재현 + 가능) + 관리자 개인 실문서 집계(190, 경로·이름·내용 없이 개수만) = + 237건으로 재구성했다. 두 모집단의 비대칭성을 census 문서 자체에 + 명시한다. +- **편집기 표면 인벤토리(신규 계측기)** — coverage ledger가 재는 "요소 + 축"과 별개로 "기능 축"(사용자가 실제로 에디터에서 클릭하는 모든 + 기능)을 재는 새 문서(`docs/editor-surface-inventory.md`). capabilities + 등록 영역과 support-matrix 근거를 자동 교차해 `[엔진 상태]×[증거]× + [실한컴 검증]`을 도출한다. 이 인벤토리 자체가 자기 스캔으로 8개 + 실동작·미등록 기능(`doc.page`의 19개 메서드 전체 등)을 찾아냈고, 사이클 + 진행에 따라 미실측 행을 하나씩 닫아 이번 릴리스 시점에는 구조적으로 + 검증 불가능한 `shape-escape-hatch` 한 줄만 남았다(실한컴이 설계상 그 + 입력을 거부하므로 "렌더 검증"이라는 개념 자체가 성립하지 않는다). +- **편집기 메뉴 역방향 지도(신규 계측기)** — "한컴 메뉴 항목 → 우리 엔진 + 대응"이라는 반대 방향 질문에 답하는 새 문서(`docs/editor-menu-reverse- + map.md`). macOS 9개 메뉴 128개 항목을 전수 판정했다([대응 영역]/ + [부분 대응]/[대응 없음=신규 갭]/[스코프 밖]/[미확인]). 여기서 나온 + "대응 없음" 목록이 이번 릴리스의 신규 저작 표면(문서정보·드롭캡· + 글자방향·단나누기·표나누기/붙이기·개요·바탕쪽·날짜/교정/PATH 필드· + titleMark 등) 대다수의 최초 입력이 됐다. +- **openrate 코퍼스 v5~v21** — 사이클마다 새로 연 저작 표면을 실한컴 + 오라클로(초기엔 Windows COM, v6부터는 macOS GUI) 배치 측정하는 시리즈를 + 17회 더 이어갔다. 매 배치마다 결정론(독립 재생성 2회 바이트 동일)· + 정적 오픈-세이프티 사전필터·부정 대조군 거부를 확인했고, 6.11 사이클 + 종료 시점에 인벤토리의 미실측 행을 한 차례 0으로 닫았다(이후 신규 + 저작마다 다시 쌓이고 다시 닫히는 순환이 이어졌다). +- **편차 등재 DEV-002~044** — 스키마-대-실측 괴리를 재실행 가능한 + 프로브와 함께 43건 추가 기록했다(예: `tabItem` 카디널리티, connectLine의 + `subjectIDRef`가 `id`가 아닌 `instid`로 해석되는 점, `hp:label`의 실측 + 2-클러스터 수렴, `hp:switch`/`case`/`default`의 스키마 완전 부재, + titleMark가 스키마에는 선언돼 있으나 문서화가 전무한 점). 별도로, + 이전 조사 회차에서 미병합 상태로 남아 있던 편차 16건도 재검증을 거쳐 + 정식 등재했다. + +## [6.0.3] - 2026-08-11 + +### 보안 + +- **신뢰할 수 없는 HWPX를 읽을 때의 자원 고갈을 수정합니다(GHSA-8g8m-xpm4-wxvx, HIGH).** + 405 KB짜리 조작 파일 하나로 `HwpxDocument.open()`이 830 MB를 할당할 수 있었고, + 비율은 공격자가 정합니다. 원인은 두 가지였습니다 — 멤버를 읽는 대부분의 경로가 + `guard_zip_file()`을 아예 호출하지 않았고, 가드가 도는 자리에서도 중앙 디렉터리에 + 적힌 크기만 검사해 실제 할당량을 제한하지 못했습니다. + - `read_member()`/`read_zip_members()`를 도입해 적힌 크기가 아니라 **실제로 읽힌 + 바이트**를 64 KB 단위로 세며 한도를 넘으면 중단합니다. 이제 할당량이 저장량이 + 아니라 선언 크기에 비례합니다. + - 압축 데이터가 적힌 크기보다 큰 멤버(위조 크기), 데이터를 실은 디렉터리 엔트리, + 드라이브 경로(`C:/`·`D:\`·`//server/share`), STORED·DEFLATED 외 압축 방식 + (bzip2·lzma는 CPython이 청크 읽기로 bound하지 못함)을 거부합니다. + - 패치 계열·아카이브 CLI·진단 도구 등 신뢰할 수 없는 아카이브를 읽는 모든 경로가 + 첫 멤버를 읽기 전에 `guard_zip_file()`을 통과하도록 배선했습니다. +- **동작 변경**: 위 경로들에는 지금까지 크기 제한이 없었습니다. 그래서 + `HwpxDocument.open()`이 이미 거부하던 문서(멤버 128 MB 초과, 멤버 4096개 초과, + 압축비 1000:1 초과)가 이제 `paragraph_patch()`·`hwpx-unpack` 등에서도 거부됩니다. + 128 MB가 넘는 동영상·이미지를 담은 문서를 다루던 사용자는 영향을 받을 수 있으며, + `guard_zip_file(limits=...)`로 조정할 수 있습니다. +- 신고 및 수정: [@Nekonic](https://github.com/Nekonic). + +## [6.0.2] - 2026-08-04 + +`v6.0.1`도 보존된 실패 태그입니다 — 아무것도 게시되지 않았습니다. prepublish의 +mypy 게이트가 Q3b 구역 서식 setter의 루프 변수 재사용 타입 충돌 4건을 적발해 +업로드 전에 중단했습니다(로컬 태그-전 점검이 core에서 타입체커를 생략한 것이 +원인 — 체크리스트에 core mypy+pyright를 명문화). 변수 분리로 고친 동일 내용이 +6.0.2입니다. 삭제·이동·재사용하지 않습니다. + +## [6.0.1] - 2026-08-04 (preserved failed tag — nothing published) + +`v6.0.0`은 보존된 실패 태그입니다 — 아무것도 게시되지 않았습니다. prepublish의 +공개 위생 게이트가 이번 트레인 신규 파일 3개(코퍼스 v4 생성기·왕복 하니스· +감사 판정문)에 유입된 내부 작업 코드네임을 적발해 업로드 전에 트레인을 +중단했습니다. 코드네임을 공개 서술로 치환하고 동결 인벤토리를 75→74로 전진 +(6.0 표면 재편으로 삭제된 파일의 역사 항목 1건 소멸)한 동일 내용이 +6.0.1입니다. 삭제·이동·재사용하지 않습니다. + +## [6.0.0] - 2026-08-04 (preserved failed tag — nothing published) + +엔진 완전성 트레인의 major 경계입니다 — 표면을 도메인 네임스페이스로 재편하고, +완전성을 측정할 구조물(커버리지 원장·편차 레지스트리·왕복 하니스·코퍼스 v4)을 +세웁니다. 독립 감사 판정: "완전성에 도달한 버전"이 아니라 "완전성을 측정하고 +채워 나갈 구조물"이 정확한 서사입니다(`docs/2026-08-04-completeness-audit-verdict.md`). + +### 바뀜 (파괴적 — major 근거) + +- **루트 표면 102 → 34.** 이동한 79개 이름은 도메인 네임스페이스로: + `doc.notes`(각주·미주·메모) · `doc.fields`(누름틀·체크박스·하이퍼링크) · + `doc.shapes`(도형·그림·수식·차트) · `doc.styles`(문자·문단 서식, 스타일) · + `doc.page`(용지·구역) · `doc.tracking`(변경추적) · `doc.text`(검색·치환) · + `doc.toc` · `doc.plan` · `doc.media` · `doc.parts`. 5.x 루트 이름 전부가 + 행선지를 안내하는 `DeprecationWarning` shim으로 7.0까지 동작합니다. +- **반환 규약 단일화**: 저작 메서드는 라이브 도메인 객체를, 결과 페이로드는 + `frozen dataclass`(+`to_dict()`)를 반환합니다 — dict/tuple/스칼라 반환 0. +- **typed error**: 공개 경로 100%가 kebab `code`를 가진 + `HwpxValueError/TypeError/LookupError/StateError`로 던집니다(기존 builtin + except 절과 이중상속 호환). 스타일 이름 오타는 호출 시점에 + `style-not-found`(+근접 제안)로, 없는 숫자 id도 저장 전에 거부됩니다. + +### 추가됨 + +- `add_paragraph(style="개요 1")` 스타일 **이름** 해석과 `add_heading(level=)` + (styleIDRef + `hh:heading` OUTLINE 이중 방출). +- Q3b 요소 개방: `pageBorderFill`(BOTH/EVEN/ODD 질의·저작), + footnote/endnote 모양 5블록 부분 갱신, `ensure_style`(이름 기반 신규 스타일), + `set_visibility`/`set_line_numbers`/`set_grid`. +- **커버리지 원장**(`docs/coverage-ledger.md`, 345 요소 기계 재산출 — + ⚠알려진 측정 오차 절 필독) · **OWPML 편차 레지스트리** 17항목 + (`specs/063-owpml-deviations/`) · 왕복 충실도 하니스 · 코퍼스 v4 + (실한컴 12.0.0.3288에서 170/170 개봉·170/170 렌더 검증, + `docs/openrate/report-v4.json`). +- 실측 비교표 `docs/comparison-python-docx.md`(대조군 리플렉션 실측, + 지는 칸 5행 포함) · 이주 가이드 `docs/migration-6.0.md` · + 문서 예제 자립 실행 71/117. + +### 제거 예고 + +- legacy shim 79종과 `section_index=` 파라미터는 **7.0.0에서 제거**됩니다. + `python -m hwpx.capabilities`의 `surfaceShape`가 shim 수와 제거 시점을 + 기계 판독 가능하게 노출합니다. + +## [5.8.0] - 2026-08-03 + +정직성 트레인입니다 — 새 저작 표면 없이, 라이브러리가 하는 말을 전부 참으로 +되돌립니다. + +### 고쳐짐 (영수증 무결성) + +- `SavePipeline`의 레이아웃 게이트가 꺼져 있거나 내부 예외로 못 돌았을 때 + `passed`를 돌려주던 것을 중단합니다. `LayoutReport`에 명시적 + `status`(`passed`/`warned`/`failed`/`unverified`)와 `unverified_reason`이 + 추가되어, 끈 게이트·터진 게이트·진짜 통과가 영수증에서 구분됩니다. + `ok`의 의미(저장 차단 여부)는 그대로라 동작은 바뀌지 않습니다. +- ID/참조 무결성 검사가 내부 예외를 삼킨 채 통과로 남던 것을 수리했습니다. + `require_reference_integrity=True`인데 검사가 실행되지 못하면 이제 + 실패입니다 — "확인 못 했음"을 "확인했고 정상"으로 세탁하지 않습니다. +- `paragraph_patch`가 전량 폐기된 편집을 `applied`에 싣던 것을 수리했습니다. + 이 계열은 all-or-nothing이므로, 매칭됐지만 버려진 편집은 신설된 + `discarded`로 보고되고 `applied`는 비웁니다. `output_path`가 원본 바이트 + 사본을 받는 문서화된 경우는 신설 `outputIsSourceCopy`가 명시합니다(두 키 + 모두 additive, 기본값 빈/false). + +### 고쳐짐 (자기서술) + +- 동봉 지원 매트릭스가 이미 출하된 기능을 부정하던 3곳을 정정했습니다: + 차트("생성 API 없음" → `add_chart`는 5.3.0부터 실재), 표 기본값("수리 + 예정" → 5.4.0에서 수리 완료), 체크박스(행 부재 → 실한컴 계약과 함께 신설). + 이 문서는 휠에 동봉되고 MCP 리소스로 서빙되므로, 이를 읽는 에이전트가 + 실재하는 기능을 회피하고 있었습니다. +- 능력 레지스트리 드리프트 가드를 문서↔문서 대조에서 **레지스트리↔실코드** + 대조로 뒤집었습니다. `HwpxDocument`의 모든 `add_*` 메서드는 정확히 한 능력 + 영역에 귀속되어야 하며, 등재 없이는 새 저작 메서드를 출하할 수 없습니다 + (직전 릴리스에서 체크박스가 양쪽 문서에 다 없어 옛 가드를 통과한 실패의 + 구조적 차단). + +### 추가됨 + +- `hwpx.capabilities.verify_self_description()`과 + `python -m hwpx.capabilities --verify` — 설치된 휠에서 자기서술이 실표면과 + 일치하는지 누구나 직접 검사할 수 있습니다. 선언 대신 검증기를 동봉합니다. +- 실측 코퍼스 v3 발행: 현행 스택이 만든 120문서(기준 스트라텀 + 각주·누름틀· + 수식·차트·체크박스·편집계획 6표면)를 실한컴 12.0.0.3288이 전건 개봉·렌더 + (120/120, rule-of-three 하한 97.5%). 영수증(`docs/openrate/verdicts_v3.jsonl`) + 은 행마다 `bucket`을 가져 분모 분해가 `jq` 한 줄로 재현됩니다. 축소 범위 + (497 전수 아님)는 매트릭스·README에 명시합니다. + +### 문서 + +- README 헤드라인 수치에 측정 스택·일자를 병기합니다(측정 없는 "continuously + verified" 문구 폐기). +- NOTICE에 한컴 파일 형식 공개 문서가 요구하는 고지 문구를 원문 그대로 + 추가했습니다. 낡은 사내 라이선스 메타데이터 정책 문서(재라이선스 이전 + 잔재)를 제거했습니다. + +## [5.7.0] - 2026-08-03 + +### 추가 +- **체크박스 양식개체 저작**(experimental): `add_check_box(caption, checked=…)` + ·`list_check_boxes()`·`set_check_box(checked, index=|name=)`. 방출 형상은 + 실한컴 실측 계약을 따릅니다 — `value="CHECKED"`가 ☑, `UNCHECKED`가 □로 + 그려지고 caption은 렌더 텍스트 레이어에 나옵니다. **``는 + 필수 자식**이며(없으면 한컴이 문서를 거부합니다 — 단일 변인 프로브로 확정) + ``은 누름틀과 달리 `` 래핑 없이 run 직속입니다. + 표 셀 안 배치도 지원하며, 선택자 없는/모호한 `set_check_box`와 빈 caption은 + typed 거부입니다. + + 참고: 한국 정부 공문서 서식은 이 양식개체가 아니라 텍스트 `[ ]`+√ 관례를 + 규정합니다(시행규칙 별표 4 제10호). 이 프리미티브는 실제로 한컴 체크박스를 + 쓰는 서식용입니다. + +## [5.6.0] - 2026-08-03 + +### 추가 +- **`hwpx.plan` 편집 계획 실행기** (experimental): 바이트-스플라이스 편집 op + 7종(`paragraph_patch`·`fill_cells`·`apply_table_ops`·`apply_body_ops`· + `recolor_runs_by_color`·`strip_runs_by_color`·`strip_trailing_table_captions`) + 을 선언적 계획 1파일(`hwpx.edit-plan/v1`)로 합성해 *정적 선검증 → 전 체인 + 인메모리 실행 → 최종 open-safety 검증 → 단 1회 원자 쓰기*로 실행합니다. + 중간 step이 실패하면 output·source가 바이트 불변임이 테스트로 증명되는 + all-or-nothing 계약이며, 결과는 step별 + 원본→최종 실측 + `hwpx.mutation-report/v1` 사영을 실은 `hwpx.plan-report/v1`입니다. + dryRun은 동일 체인을 전부 실행하되 쓰기만 생략하고, `journalPath` 지정 시 + JSONL 저널(진단용, resume 계약 아님)을 남깁니다. +- **`hwpx.capabilities` 기계가독 자기서술** (experimental): + `describe_capabilities()`가 설치 버전·extras 실측 프로브·라이브 표면 + census(stable/experimental)·편집 계획 op 어휘·능력 영역 레지스트리를 + 보고합니다. `contract_document()`는 패키지 동봉 계약 문서 4종 + (support-matrix·recipes-traversal·mutation-semantics·known-traps 신규 저작) + 을, `contract_json_schema()`는 계약 JSON Schema 4종(edit-plan·plan-report· + mutation-report·capabilities)을 서빙합니다. 드리프트 가드 테스트가 + 레지스트리 진입점 해석·op 어휘 3자 일치·support-matrix 행 대조를 + 강제합니다(core는 렌더 오라클 가용성을 주장하지 않습니다 — 그 보고는 + automation 계층 소유). + +### 수정 +- **support-matrix 각주/미주 행의 낡은 서술 정정**: 5.5.0이 각주 방출 계약을 + 수리하고도 매트릭스 행은 `Render-unverified·honest-defer`(2026-08-01 문구) + 로 남아 출하됐습니다. 행을 실상태(Render-verified, 5.5.0 수리 근거)로 + 갱신했고, 같은 부류의 드리프트를 `hwpx.capabilities` 가드 테스트가 이제 + 구조적으로 막습니다. `docs/stable-api.md`의 experimental 목록도 5.2.0 수식 + 3종 누락을 함께 정정(12→23). + +## [5.5.0] - 2026-08-02 + +### 수정 +- **각주/미주가 실한컴에서 렌더되도록 방출 계약 수리** (실한컴 gold + 리버스): 각주는 본문 run 안 `` 래핑으로 삽입되고 `number`(유형별 + 연속 번호)·`suffixChar`를 실으며, 각주 본문 문단은 각주/미주 스타일 + (헤더 이름 조회, 기본 15/16·paraPr 10·charPr 3)과 선행 + ``(FOOTNOTE/ENDNOTE)을 갖습니다 — 기존 방출(run 직속·번호 + 없음)은 실한컴이 각주를 그리지 않던 실결함(감사 실결함 3번). 리더 + (markdown [^fn] 부록·텍스트 추출 주석)는 실한컴 ctrl-래핑 형상과 구식 + 자사 형상을 모두 읽습니다 — 실한컴산 문서의 진짜 각주를 그동안 리더가 + 놓치고 있던 갭도 함께 수리. `note.text` setter는 gold 본문(autoNum· + 스타일)을 보존합니다. + +## [5.4.0] - 2026-08-01 + +### 추가 +- **`ensure_border_fill(border_type=…)`**: OWPML 선 종류 어휘(SOLID·DASH· + DOT·DASH_DOT 등)를 검증해 테두리 모양을 지정합니다. 어휘 밖 값은 typed + 거부(ValueError). +- **`HwpxOxmlTable.set_cell_border_fill(row, col, border_fill_id_ref)`**: + 개별 셀의 테두리/배경 참조를 교체하는 표면(저작 충실도 감사에서 확인된 + B 표면갭 수리). +- **`ensure_run_style` 확장 7종**: `underline_shape`/`underline_color`(물결 + 등 밑줄 모양·색), `strike_shape`(취소선 모양), `ratio`(장평 10–400), + `letter_spacing`(자간 −50–100), `shadow`(그림자 색), `script`(`sup`/`sub` + 위·아래 첨자 — 실한컴 계약 relSz 67·offset ±30). 전부 감사에서 실한컴 + 렌더로 검증된 charPr 자식 어휘이며, 범위·어휘 밖 값은 typed 거부. + +### 수정 +- **`add_hyperlink` 표시 텍스트가 실한컴 관례를 따름**: 파랑(#0000FF) + 텍스트 + 파랑 BOTTOM 밑줄(실코퍼스 하이퍼링크 최빈 서식). 서식은 표시 + 런에만 실리며(fieldBegin/fieldEnd 런은 주변 서식 유지 — gold 계약) + 다음 문단으로 전염되지 않습니다. `char_pr_id_ref=`로 override 가능. +- **첨자 offset 부호 정정**: 실한컴 렌더 실측 결과 offset 양수=아래· + 음수=위 — `script="sup"`가 위로, `"sub"`가 아래로 정확히 그려지도록 + 부호를 맞췄습니다. +- **밑줄 type 어휘 gold 일치**: `underline=True`가 방출하던 + `type="SOLID"`(비표준)를 실한컴 관례 위치 어휘 `BOTTOM`으로 수정. + +### 변경 +- **`add_table` 기본값이 실한컴 계약과 일치**: 셀 안여백 기본 + 510/510/141/141(기존 0 — 한컴에서 만든 표와 여백이 달랐음), 기본 표 + 폭은 본문 폭(용지 − 여백)으로, 셀 안에 만드는 중첩 표는 부모 셀 사용 + 폭에 맞춤(기존 고정 7200/셀 기본값이 부모 셀보다 넓어 내용이 소리 없이 + 잘려 보이던 감사 실결함 수리). 명시 `width=` 지정 시 동작 불변. +- **목록 문단 비상속**: 글머리표/번호 문단 뒤에 `add_paragraph`로 추가하는 + 일반 문단이 목록 paraPr를 상속하지 않습니다(실한컴에서 프로그램적 추가 + 문단은 본문 — 감사에서 모든 후속 문단이 목록화되던 실결함 수리). 명시 + `para_pr_id_ref` 지정 시 목록 계속 사용 가능. + +## [5.3.0] - 2026-07-31 + +### 추가 +- **차트 저작 `HwpxDocument.add_chart`** (experimental 계약): ECMA-376 + chartML(`c:chartSpace`)을 `Chart/chartN.xml` 파트로 저장하고 실한컴 + `` 앵커를 방출합니다 — 실한컴 계약 그대로 파트는 + 어느 manifest에도 등록하지 않으며(실측), 한컴은 chartML만으로 차트를 + 그립니다(OLE 폴백·사전렌더 이미지 불요, 실한컴 렌더 픽셀 실측: 막대· + 꺾은선·원). chartML은 파싱·루트 검증 후에만 기록(typed 거부), 표 셀 + 배치·인라인 배치 지원, 생성 직후 표준 섹션 스캔 재인식 실패 시 즉시 + 실패합니다. 기존 차트 파트와의 경로 충돌 없이 순차 할당하며 무관 파트는 + 바이트 보존됩니다. + +## [5.2.0] - 2026-07-31 + +### 추가 +- **수식 저작 `HwpxDocument.add_equation`** (experimental 계약): 실한컴이 + 만드는 `` 형상 그대로(EqEdit script는 `` 자식, + `treatAsChar="1"` 인라인, lineseg 캐시 없이 — 한컴이 열 때 재조판) 수식을 + 삽입합니다. 표 셀 내부 삽입을 지원하고, 생성 직후 표준 섹션 스캔으로 + 재인식되지 않으면 즉시 실패합니다(특수분기 0). 계약은 실한컴 gold + 리버스(수식 계약 문서) 기준입니다. +- **LaTeX → EqEdit 변환기 `hwpx.equation.latex_to_eqedit`** (experimental): + 리더 방향(EqEdit → LaTeX)과 같은 토큰맵의 역방향입니다. 분수·근호/n제곱근· + 첨자·그리스·관계/이항연산·합/적분/극한 상하한·행렬(`pmatrix` 계열·`cases`)· + `\left`/`\right`·accent·`\text` 리터럴·예약어 따옴표 보호(`T_{int}` → + `T _{"int"}`)를 지원하며, **검증된 토큰셋 밖의 LaTeX는 + `UnsupportedLatexError`로 typed 거부**합니다(무음 근사 없음). 왕복 + property 테스트가 저작 EqEdit를 기존 리더로 복원해 고정점을 증명합니다. + `estimate_equation_size` 휴리스틱이 `` 자리표시 크기를 계산합니다 + (한컴이 열 때 실측 재계산). + +## [5.1.1] + +`v5.1.0`은 보존된 실패 태그입니다 — 아무것도 게시되지 않았습니다. 태그 +워크플로의 공개 위생 게이트가 실한컴 gold 픽스처 2종의 OPF 메타데이터에 +남은 사적 출처 마커(creator/lastsaveby)를 적발해 중단했고, 마커를 제거한 +동일 내용이 5.1.1로 복구되었습니다. `v5.1.0`은 삭제·이동·재사용하지 않습니다. + +## [5.1.0] + +### 추가 +- **누름틀 필드 저작 `HwpxDocument.add_form_field`** (experimental 계약): + 실한컴이 만드는 CLICKHERE 형상 그대로(필드 속성·`Prop`/`Command`/ + `Direction`/`HelpState` 파라미터·화면 전용 안내문 런·`fieldEnd` 쌍) 필드를 + 생성합니다. 안내문 길이 직렬화는 문자 수 기준이며, 만든 필드는 기존 + `list_form_fields`/`fill_form_field`가 특수분기 없이 그대로 소비하고 실제 + 한컴오피스가 열거·채움을 수행합니다(실한컴 gold pair 리버스 계약). 표 셀 + 내부(중첩 포함) 삽입을 지원합니다. +- **필드 페이로드 확장**: `list_form_fields` 결과에 `memo`(HelpState)· + `dirty`·`is_placeholder`가 추가되고, 실한컴 파라미터 이름 + `Direction`/`HelpState`가 `prompt`/`memo`로 매핑됩니다. `dirty != "1"`인 + 동안 보이는 값은 사용자 값이 아니라 안내문입니다. + +### 수정 +- **채움이 한컴과 같은 흔적을 남깁니다**: `fill_form_field`가 값 기록 시 + `dirty="1"`을 설정하고, 안내문(placeholder) 상태의 필드를 채울 때 안내문 + 전용 스타일(빨강·이탤릭)을 주변 스타일로 교체합니다. 종전에는 값이 안내문 + 스타일을 그대로 물려받아 빨간 이탤릭으로 남는 무음 시각 결함이 있었습니다. + +## [5.0.2] + +### 수정 +- **첫 실행이 조용해졌습니다**: 기본 템플릿으로 `new()`→`save_to_path()`만 + 해도 `manifest에서 masterPage/history/version …` 경고가 15줄 출력되던 문제를 + 고쳤습니다. 빈 문서에 masterPage/history가 없는 것과 version.xml이 + manifest 선언 없이 고정 경로에 있는 것은 실제 한컴 산출물과 동일한 정상 + 상태입니다(실한컴 gold fixture 6종 대조로 확인). 이제 manifest가 실재 + 파일을 놓친 경우에만 경고하며, 반환값은 변하지 않습니다. +- **릴리스 해시 검증기가 태그에서 버전을 유도합니다**: 종전에는 PyPI 조회 + URL의 버전이 스크립트 상수로 고정되어, v5.0.1 릴리스에서 존재할 수 없는 + 5.0.0 JSON을 조회하다 404로 빨간 표시를 냈습니다(아티팩트는 건전, 독립 + readback으로 종결했던 그 건입니다). 이제 `--tag`에서 버전을 유도하고, + 매니페스트 파일명이 태그 버전을 담는지 교차 검증하며, 인덱스 전파 지연 + 대비 재시도 예산을 60초에서 300초로 늘렸습니다. + +### 추가 +- **5분 퀵스타트 개편**: 읽기(텍스트 추출)와 저장 영수증(`MutationReport`) + 단계를 추가하고, 편집 예제를 복붙만으로 이어지는 완결 스크립트로 + 재구성했습니다. 문서 예제 ledger 재생성 스크립트 + (`scripts/regenerate_example_ledger.py`)도 함께 추가했습니다. +- **과업 축 매뉴얼 2종**: [순회 레시피](docs/recipes-traversal.md)(문단·중첩 + 표·각주·런·메모/누름틀)와 [편집 의미론](docs/mutation-semantics.md)(반환형· + 실패 모드·재실행 성질·저장 보증 — 전부 실측 확인) — 실행 ledger 게이트에 + 편입. +- **llms.txt 발행**: 훈련 데이터에 이 라이브러리가 없는 AI 도구가 정확한 + API를 배우도록 `https://airmang.github.io/python-hwpx/llms.txt`(+ 매뉴얼 + 병합본 `llms-full.txt`, 빌드 시 자동 생성)를 사이트 루트에 제공합니다. + 내용 계약은 테스트로 고정(버전 문자열 금지 포함). +- **채택·응답 지표 공개**: [채택 지표 페이지](docs/adoption-metrics.md)와 + 기계 판독 이력(`adoption-metrics-history.json`, 사이트 루트)을 추가했습니다. + 수집은 `scripts/snapshot_adoption_metrics.py`(멱등 — 같은 날 재실행은 + 교체)이며, 각 지표에 "이 숫자가 말해주지 않는 것" 캐비앗을 함께 둡니다. +- **Python 3.13·3.14 공식 지원**: CI 매트릭스와 trove classifier에 3.13/3.14를 + 추가했습니다(전체 스위트 1376 passed를 양 버전에서 확인한 뒤 반영). + 지원 정책은 [설치 가이드](docs/installation.md)에 명문화했습니다. + +## [5.0.1] + +`v5.0.0` is a preserved failed tag: its release run stopped in prepublish +(the installed-wheel docs gate requires `uv`, which the workflow never +installed) and nothing was published to PyPI or GitHub. 5.0.1 is the same +train content plus the workflow fixes — `uv` is now bootstrapped in both +the test and release jobs, and the test job's `tee` pipe no longer masks +pytest's exit code. Never delete, move, or reuse `v5.0.0`. + +## [5.0.0] + +### Removed — BREAKING + +`python-hwpx` is now the HWPX object model, OPC/OXML, and the format-native +primitives built on them. The application workflows that had grown inside the +library moved to `python-hwpx-automation`, which has owned their canonical +implementation since the 4.x line. **Nothing is discontinued** — every removed +import has a named replacement in +[the migration guide](docs/migration-5.0.md), and 4.x keeps all of it. + +- `hwpx.agent` and the `hwpx` console command → `hwpx_automation.office.agent`. + The automation package declares the `hwpx` name in the train that raises its core floor to 5.0, + so no valid install ever has two declarers of it. +- `hwpx.authoring`, `hwpx.builder`, `hwpx.design`, `hwpx.presets` → + `hwpx_automation.office.authoring` +- `hwpx.exam` → `hwpx_automation.office.exam` +- `hwpx.evalplan_fill` → `hwpx_automation.office.evalplan` +- `hwpx.form_fill`, `hwpx.formfill_quality`, `hwpx.fill_residue`, + `hwpx.guidance_scan`, `hwpx.template_formfit` → + `hwpx_automation.office.form_fill` +- `hwpx.tools.official_lint`, `hwpx.tools.pii`, `hwpx.tools.table_compute`, + `hwpx.tools.style_profile`, `hwpx.tools.advanced_generators`, + `hwpx.tools.report_parser` → `hwpx_automation.office.compliance` / + `office.quality` / `office.utilities` / `office.authoring` +- `hwpx.tools.mail_merge.mail_merge` → `merge_template_rows`, which is now + public. The removed wrapper masked by default and core no longer carries the + detection rules; the generic function takes a `value_sanitizer` so the caller + decides, and the decision is visible at the call site instead of implied by an + omitted argument. +- `hwpx.tools.doc_diff.build_comparison_table_plan` → + `hwpx_automation.office.document_ops`. Generic diff and reference-consistency + stay here. +- The four `template_formfit` names that 4.x warned would go "in the next major" + are gone. That was this major. +- `hwpx-conformance` console script: its package is repository QA and no longer + ships, and a command that cannot start is worse than no command. + +### Changed + +- `HwpxDocument.add_table()` now creates a neutral table-anchor paragraph by + default, so a preceding numbered heading/list no longer renders a stray + number beside the table in Hancom. Pass `inherit_style=True` or explicit + paragraph/style references when anchor-style inheritance is intentional. +- `HwpxDocument.add_tracked_replace()` now writes the inserted replacement at + the deleted text's source position, preserves that run's character style, + and leaves later tracked inserts in their original order. +- The product-boundary lazy-loader fingerprint now canonicalizes parser-only + empty AST fields, so the same guarded source passes consistently on Python + 3.10–3.13 while real syntax changes still fail closed. +- `verify_redline` and `verify_fill` take an injected + `hwpx.quality.rendering.RenderBackend`. Core does not look for a Hancom + installation any more; without a backend the report is + `render_checked=False` rather than a visual verdict it never earned. +- `toc_fidelity.heading_rendered_pages` takes a word-box extractor, and + `heading_pages_from_word_boxes` is public for callers that already have boxes. + Reading a PDF needs an imaging stack; matching headings does not. +- `hwpx.oxml.color.color_family` is public. Both core's run rewriting and the + application-side guidance scanner classify colour the same way, so there is + one implementation of what counts as red. + +### Packaging + +- `hwpx.benchmark` and `hwpx.conformance` are excluded from the wheel. They are + things this project runs on itself, not things a library user installs. +- The fuzz harness moved to `scripts/fuzz/`, outside the package, because it + builds documents through the MCP owner's builder and core must never import a + companion layer. + +### Migration + +`pip install python-hwpx-automation` and follow [docs/migration-5.0.md](docs/migration-5.0.md). +`pip install "python-hwpx<5"` rolls back; the 4.x line is not a dead end. + + +### Documentation +- 4.x compatibility/deprecation 관찰 정책을 공개했습니다. 8개 runtime family, + 3개 CLI, 공개 스키마/리포트 projection은 2026-10-31까지 모두 `extend`하며 + 제거는 0건입니다. 신규 코드를 위한 MCP canonical 경로, side-by-side 이행, + rollback, 별도 next-major 승인 조건을 + [4.x 호환 표면 관찰 정책](docs/compatibility-observation-4.x.md)에 기록했습니다. + +## [4.2.0] - 2026-07-22 + +### Added — 평가계획(교수학습운영·평가계획) 실채움 엔진 확장 +- `fill_evalplan`에 `phase="clean"`을 추가했습니다 — 기존 `"all"`(채움) 뒤에 core의 + 결정론적 양식 정리(제목·교사·정의적 표 채움, 양식 지시문·외래 샘플 소제목·고아 + 헤딩 prune, 빨강 지시 run 제거, 슬롯-파랑→본문 검정 재색, 표 꼬리 캡션 strip)를 + 한 경로로 수행해, 별도 정리 스크립트 없이 실제 도교육청 양식을 잔존물 없이 + 채웁니다. 기본값 `"all"`은 기존 동작 그대로입니다(비파괴). +- `finalize_evalplan`(위 정리 오케스트레이션)과 프리미티브 2종 + (`table_patch.strip_trailing_table_captions`, `guidance_scan.is_form_instruction`)을 + 추가했습니다. 전부 패턴·구조 기반이며 과목·학년 문자열 하드코딩이 없습니다. + +### Fixed — 평가계획 표 파싱·채움 +- 성취기준별 §4가 성취수준 표(A~E 또는 상/중/하)를 파싱·채우지 못하던 문제를 + 고쳤습니다. 콘텐츠의 성취수준 단계 수를 읽어 표 구조에 맞춰 채웁니다. +- 도너 스케줄 표의 병합이 일부 주차 행을 흡수하던 문제를 고쳤습니다(병합 분리). +- §7 수행평가 세부기준 상세 배점 루브릭(평가요소·수행수준·배점, 세부 영역 가/나/다)을 + 파싱·채우도록 확장했습니다. + +## [4.1.1] - 2026-07-21 + +### Fixed +- 4.1.0 태그의 발행이 공개 위생 게이트(워크스테이션 경로 검사)에 걸려 중단됐습니다 + — 내부 개발용 rhwp 트리아지 스크립트가 공개 레포에 포함돼 있었고, 해당 도구는 + 공개 표면이 아니므로 저장소에서 제거했습니다. 라이브러리 코드는 4.1.0과 + 동일합니다. (`v4.1.0`은 보존된 실패 태그이며 PyPI 산출물이 없습니다.) + +## [4.1.0] - 2026-07-21 + +### Fixed — form-fill 차등 판정기 교정 (verify 신호 신뢰성) +- `hwpx.form_fit.wordbox`의 표 구조 지문(`extract_layout_signature`)과 overflow + 클립(`extract_cell_clips`)이 fitz `find_tables()` 기본 전략(글리프 스냅) 대신 + **그려진 테두리 전용**(`lines_strict`)을 쓰도록 교정했습니다. 종전에는 빈 셀을 + 채우면 그려진 격자가 그대로인데도 유령 표·허위 overflow가 검출되어 + `verify_form_fill` 계열 게이트가 허위 실패를 냈습니다. wild 코퍼스 실측: + 허위 shape 실패 6건·허위 overflow 3건 해소, 회귀 0, 산출분 pass 17/28→23/28. + **제품 산출물(채움 바이트)은 변경되지 않았습니다** — 판정만 정확해졌습니다. + +### Docs — 정직 실측 갱신 +- [실측 코퍼스 메트릭](docs/corpus-metrics.md)에 「구조결함 2차 실측」 추가: + 잔존은 페이지 리플 5건(4건 typed 경고 동반·완전 무음 1건)이며, typed 거부 + 35건 전수 감별 결과 과잉거부 0. 지원 매트릭스 양식 채움 행 동기화. + +## [4.0.0] - 2026-07-21 + +### Removed — deprecation window 준수 정리 (major 경계) +- **`HwpxDocument.save()` 제거**: v2.6(2026-02-19)부터 5개월간 `DeprecationWarning`을 + 달고 있던 호환 래퍼를 제거했습니다. 목적지별 명시 메서드를 쓰세요 — + 경로 `save_to_path(path)` · 스트림 `save_to_stream(stream)` · 바이트 `to_bytes()`. + 이 셋은 [안전한 쓰기 계약](docs/safe-write-contract.md)(`mode`/`fallback` 등급 + + `return_report`)을 지원하며, 제거된 `save()`에는 없던 기능입니다. +- `hwpx.package` 모듈(→`hwpx.opc.package`)은 경고 이력이 있지만 stable + `HwpxPackage`의 역사적 import 경로라 **유지**합니다(shim, 비용 0). 전체 표·이유는 + [4.0.0 마이그레이션](docs/migration-4.0.md). + +### Added — 구조화 예외 베이스 (오류 계약 통일) +- `hwpx.errors.HwpxError`(최상위 `hwpx.HwpxError`로도 import) 도입 — fail-closed 공개 + 경로 예외의 베이스. 사람용 `str(exc)`는 그대로 두고 기계가 읽는 `code`(kebab + 안정 식별자)·`context`(실측 값 dict)·`suggestion`(다음 행동)을 얹으며 `to_dict()`로 + 봉투를 냅니다. **공개 계약 경로부터** 이행: `PreservationDowngradeError` + (`preservation-downgrade`), 신규 `SaveError`(대표 저장 경로의 사전검증·open-safety· + 품질 게이트 실패), `TableStructureError`(`table-structure`), + `RenderCheckRequired`(`render-check-required`). 기존 `except` + 타입(`ValueError`/`RuntimeError`/`Exception`)은 상속으로 그대로 동작합니다. + 전체 표는 [stable-api.md 오류 계약](docs/stable-api.md). +- `HwpxError`가 stable 표면에 추가되어 `hwpx.__all__`은 **stable 67개**가 됩니다. + +### Changed — 스키마 동결 + mutation 반환형 통일 +- **스키마 동결**: `hwpx.mutation-report/v1`·`hwpx.document_plan.v1`/`v2`· + `hwpx.agent-batch/v1`·`hwpx.mixed-form-plan/v1`의 required 필드 집합을 동결하고 + 계약 테스트(`tests/test_schema_freeze.py`)로 고정했습니다. 정책은 additive-only — + 신규 필드는 Optional, 파괴 변경은 새 major + 새 스키마 버전 문자열 + ([schema-freeze.md](docs/schema-freeze.md)). +- **mutation 반환형 통일**: 공개 mutation-write 경로가 전부 `hwpx.mutation-report/v1`로 + 사영 가능함을 계약 테스트(`tests/test_mutation_contract_unification.py`)로 고정 + 했습니다(byte-write 결과모델 4종 + 네이티브 `save_to_path` 영수증). 사영 없는 신규 + byte-write 모델이 추가되면 구조 가드가 실패합니다. +- `save_to_path`/`save_to_stream`/`to_bytes` 이름을 **장기 확정**으로 명문화(개명 재론 + 종결, [safe-write-contract.md](docs/safe-write-contract.md)). + +### Changed — 최상위 API 표면 3계층화 (major 경계 준비) +- `from hwpx import ...` 표면을 **stable / experimental / deprecated** 3계층으로 + 분류했습니다. `hwpx.__all__`에는 **stable 67개만** 남깁니다(P1의 66 + P2 `HwpxError`). + 계층·정책 전수 목록은 `docs/stable-api.md`. +- **experimental(12)** — ingestion 프레임워크·레이아웃 프리뷰·문서 프리뷰 뷰어. + `from hwpx.experimental import ...`로 사용하세요. 최상위 재내보내기는 하위 호환을 + 위해 유지하되 접근 시 `DeprecationWarning`이 나며 다음 major에서 최상위 경로가 + 제거될 예정입니다(구현 모듈·`hwpx.experimental` 경로는 유지). +- **deprecated(4)** — `analyze_template_formfit`/`apply_template_formfit`와 + `TEMPLATE_FORMFIT_*_SCHEMA_VERSION` 상수 2개. 대체 = 구조적 form-fill 경로 + (`hwpx.table_patch.fill_cells` 계열 + MCP `analyze_form_fill`/`apply_form_fill`/ + `verify_form_fill`). +- **최상위에서 제거된 이름 0개**: 기존 최상위 이름 82개는 전부 계속 import 가능합니다 + (최소 deprecation window 준수 — 경고 없는 즉시 제거 금지). `HwpxDocument.save()`는 + 최상위 표면 이름이 아니라 메서드로, 위 Removed 절에 따라 제거됐습니다. + +## [3.8.0] - 2026-07-21 + +### Added — 문서 프리뷰 뷰어 +- `render_document_viewer`: 한컴 없이 문서를 **스크롤 통독**하는 self-contained + HTML 뷰어(상단바·현재 페이지 표시·키보드 탐색·외부 리소스 0). 충실도 배지가 + 한계를 정직하게 표시합니다(텍스트 근사·페이지네이션은 한컴과 다를 수 있음). +- **수식 실제 렌더**: `hwpx.equation` — ``의 EqEdit 스크립트를 + clean-room 토큰맵으로 LaTeX로 변환해 MathML로 렌더합니다(선택 extra + `python-hwpx[preview]` = latex2mathml). 실한컴 ground-truth와 시각 대조로 + 검증(specs 증거). 변환 불가/라이브러리 부재 시 **빈칸 대신** 원문·LaTeX + 코드블록으로 정직 표시하고, 그림·도형은 자리표시 마커로 보입니다 — 기존 + 프리뷰가 수식을 조용히 빈 문단으로 떨구던 동작은 제거됐습니다. + +### Internal +- MCP 경계 타이핑 정밀화를 위한 evalplan 경로 Path/str 유니온(순수 어노테이션). +- 복잡도 3물결: 최상위 15개 함수 분해(C901 115→100), characterization 39건 추가. + + +## [3.7.0] - 2026-07-21 + +### Added — Safe Write Contract +- 일반 저장 경로가 **명시적 쓰기 모드**를 받습니다: + `save_to_path(path, mode="patch"|"rebuild"|"auto", fallback="error"|"rebuild", + return_report=True)`. 반환 기본값은 기존과 동일(경로)이며 `return_report=True` + 일 때만 스키마버전드 **`hwpx.mutation-report/v1`** 영수증을 반환합니다 — + 요청/실제 모드, 변경 part 목록(+가능한 경우 실제 범위, coordinate space 명시), + 보존 보증 3계층(미수정 part payload / zip local record / whole package), + 수행한 검증의 3값 기록(passed/failed/not_performed). +- **무음 강등 없음**: `mode="patch"`에서 요청 보존 등급을 만족하지 못하면 + 출력 파일을 쓰지 않고 typed `PreservationDowngradeError`를 던집니다. + `fallback="rebuild"`로 명시 동의한 경우에만 진행하며 영수증에 + `fallbackUsed: true`가 남습니다. 보존은 단언이 아니라 **open-시점 기준선 + 대비 실측**입니다(발행 성공 시 기준선 전진). +- 기존 쓰기 결과 4종(BytePreservingPatchResult·CellFillResult·BodyOpsResult· + AgentBatchResult)에 `as_mutation_report()` 사영 추가 — byte-splice 계열은 + `source=`(bytes/str/Path) 제공 시 보존 3계층과 실제 스플라이스 범위를 + 실측 보고, 미제공 시 정직 강등. 기존 필드·to_dict 출력은 불변(additive). + +### Docs +- README 포지셔닝 개정(§안전 자동화 계층+검증된 저작), **지원 매트릭스** + 공개(docs/support-matrix.md — Parse/Preserve/Edit/Create/Render-verified/ + Unsupported-but-preserved/Unsupported-and-rejected, 셀별 증거 링크), + docs/safe-write-contract.md 신설, 헤드라인 지표에 corpus·분모·측정일· + assurance 병기(오픈 수용률 하한 표기를 rule-of-three 99.37%로 정밀화). + + +## [3.6.0] - 2026-07-20 + +### Fixed +- **양식 채움 무음 서식파괴의 지배 원인 픽스**: fit 슬롯이 셀 안 인라인 + treat-as-char 객체(체크박스·폼컨트롤·그림)의 선언 폭(`hp:sz/@width`)을 + 가용폭에서 차감합니다. 이전에는 이를 비모델링해 "맞음"으로 오판한 채움이 + 실한컴에서 줄바꿈·행 성장을 일으켜 다중 페이지 양식 전체를 밀었습니다 + (실측: 3글자 채움이 10페이지 양식을 11페이지로). 이제 그런 채움은 **typed + FIELD_OVERFLOW 거부**(컨트롤 동거 경고 포함)가 됩니다. 실한컴 재측정에서 + 판정 조합 전체의 무음 서식파괴가 47%→16.7%로 감소했습니다. +- 레이아웃 캐시 무효화를 편집 스코프로 축소: 편집한 문단의 캐시만 제거하고 + 저장 시 전량 제거(2개 레이어) 대신 증명 가능한 stale만 걷어냅니다. 채움 + 바이트 델타가 −133,651자에서 −170자로 줄어 바이트 보존 계열과 정합합니다. + +### Added +- `SlotMetrics.inline_object_width`/`inline_object_count` — 슬롯 진단 표면. + + +## [3.5.0] - 2026-07-20 + +### Added +- 양식 채움 fit 판정에 **수직(행 높이) 예산**을 도입합니다. 이전에는 셀의 가로 + 폭만 측정해 세로로 100줄까지 "맞음"으로 판정, 축소도 거부도 발동하지 않고 행이 + 조용히 자라 페이지를 밀었습니다. 이제 `available_height`(셀 높이 − 상하 마진)와 + 글꼴별 라인하이트로 세로 예산을 재서, `wrap_then_shrink`는 값을 예산 안으로 + 축소하고, 최소 글꼴에서도 안 들어가는 과대 입력은 `overflow="fail"`에서 **typed + 거부**(FIELD_OVERFLOW)로 돌려줍니다. `allow_row_expand`/`expand_row`는 명시적 + opt-out입니다. `FitPolicy.keep()`과 폭-only 경로는 바이트 불변입니다. + +### Measured (정직 발견) +- wild 공개 양식 전수 재측정(실한컴): 양식 채움 차등이 fit-on 32/63 = 50.8% + (fit-off 31/63 = 49.2%). 기대-통과 층(short+medium)은 61.4%로 **이동 없음** — + fit은 wild 실패의 지배 레버가 아니며, 지배 원인은 구조적 3부류(채움-겹침·다중 + 페이지 경계 밀림·표 구조 민감도)임이 측정됐습니다. 상세는 `docs/corpus-metrics.md` + 및 leap 데모 `demo/S-085-wild-formfill/`. + + +## [3.4.1] - 2026-07-19 + +### Note +- 복구 릴리스. `v3.4.0` 태그는 prepublish 공개 위생 게이트에서 실패했고 + (테스트 픽스처의 워크스테이션형 경로 문자열 — `/home/...` 패턴), PyPI + 아티팩트나 GitHub Release는 만들어지지 않았습니다. 실패 태그는 이력으로 + 보존하며 3.4.1이 실제 공개 릴리스입니다. 기능 내용은 아래 3.4.0 항목과 + 동일합니다. + +## [3.4.0] - 2026-07-19 (tag preserved; not published) + +### Added +- 선언형 문서 계획(plan-v2)에 네이티브 자동 목차 옵션(`native: true`)을 추가합니다. + 정적 목차를 내려쓴 뒤 한컴이 열기 시점에 재계산하는 TABLEOFCONTENTS 필드로 + 승격하며, 본문 스타일 수집 가드(바탕글-수집 함정)를 포함합니다. +- M9 출판 코퍼스 도구를 추가합니다: 코퍼스 v2 additive 생성기, 무인 실한컴 박스 + 판정(오픈/파싱·스케일 렌더 배치·redline InitScan 프로브), 축별 검증 드라이버 + (byte-identity·PII 0-leak·저작 품질·양식 채움 차등·TOC 검증)와 결합 집계기 + (provenance 이중 발행·redline-aware parsed·렌더 티어). +- 실측 결과를 문서로 발행합니다: `docs/corpus-metrics.md`(축 주석 포함)와 + 릴리스별 기계 판독 히스토리 `docs/corpus-metrics-history.json`. + +### Fixed +- 오픈레이트 박스 러너의 재개 키를 basename에서 전체 경로로 바꿔, 중복 파일명을 + 가진 코퍼스에서 파일이 조용히 건너뛰어지던 문제를 고칩니다. +- Windows PowerShell 5.1에서 렌더 잡 배열이 1개 객체로 접히던 열거 함정을 고칩니다. + +### Internal +- `HwpxDocument` facade를 도메인 owner 8모듈로 행동 보존 분해합니다 + (2,961→1,777줄; 공개 표면 97멤버 characterization 스냅샷으로 동결). +- 복잡도 상위 함수 5종+1을 분해해 C901 초과를 120→114로 낮추고, owner 모듈을 + mypy/pyright 점진 게이트에 편입합니다(40파일 clean). + + +## [3.3.1] - 2026-07-18 + +### Internal +- 최악 복잡도 검증기 2종을 행동 보존 분해합니다: + `validate_blueprint_manifest`(C901 60→1, 헬퍼 14개)와 + `validate_package`(53→10, 헬퍼 12개). 에러 문자열·검사 순서·manifest 불변성은 + 기준선과 verbatim 동일하며 테스트 델타 0으로 실증했습니다. +- 공개 표면·계약 변화는 없습니다. S-083 릴리스 트레인(hwpx-mcp-server 4.3.0 · + hwpx-plugin 0.6.0)과 좌표를 맞추는 릴리스입니다. + +## [3.3.0] - 2026-07-18 + +### 추가 +- 선택적 Mac GUI 오라클(`MacHancomOracle`)에 도달성 프로브를 추가했습니다. 한컴 앱이 + 설치돼 있어도 GUI 세션·Automation(TCC) 권한이 없으면 5초 이내에 `available() == False`로 + 정직하게 강등되며(프로세스 수명 캐시), 기존 구조 검증 경로가 그대로 동작합니다. +- 렌더 오라클에 단일 외부 예산 전파를 추가했습니다. `budget_seconds`(생성자· + `resolve_oracle`)가 지정되면 내부의 모든 subprocess 타임아웃이 남은 예산으로 + clamp되고, 예산 소진 시 subprocess를 생성하지 않고 즉시 강등합니다. +- `HWPX_ORACLE_STRUCTURAL_ONLY` 환경변수를 추가했습니다. 설정 시 `resolve_oracle()`은 + `NullOracle`을 반환하고 Mac GUI 백엔드는 어떤 경로로도 GUI 자동화에 진입하지 + 않습니다(구조 판정 전용 모드). +- `HWPX_ORACLE_BUDGET_SECONDS` 환경변수를 추가했습니다. 호스팅 프로세스가 한 번 + 선언한 외부 deadline이 `resolve_oracle()`의 모든 호출 지점(코어 verify·MCP 핸들러)에 + 자동 전파됩니다. 명시적 `budget_seconds` 파라미터가 env보다 우선합니다. + +### 변경 +- `src/hwpx/visual/oracle.py`를 mypy·pyright 점진적 게이트에 편입하고 기존 타입 오류 + 2건(textLength 협소화, 검증 항목 변수 재사용)을 수정했습니다. + +## [3.2.0] - 2026-07-17 + +### 추가 +- 기존 섹션 머리글을 네이티브 ID 또는 페이지 유형으로 지정해 본문·표 셀과 같은 + `hwpx.agent-batch/v1` 트랜잭션에서 편집하는 타입드 story 경로를 추가했습니다. dry-run, + revision 및 idempotency 검사, 오류 시 rollback, 저장 후 story identity·구조 보존 확인을 + 동일한 단일 직렬화 경로에서 수행합니다. + +### 변경 +- 비대해진 OXML 문서 구현을 문서 프리미티브, 패키지/구역 관리, 구역 서식과 story 등 + 책임별 런타임 모듈로 분리했습니다. 기존 공개 facade와 import 경로는 유지하면서 수정된 + 소유 모듈 전부를 Ruff·mypy·pyright 점진적 게이트에 포함했습니다. + +### 수정 +- `add_section()`이 인접 구역의 렌더 가능한 페이지·단 설정을 복제하되 머리글·바닥글 + story는 복제하지 않고, manifest/spine 순서와 `hh:head/@secCnt`를 함께 갱신하도록 + 수정했습니다. 음수 `after` 인덱스, 범위 오류, 불완전 패키지, 상대 manifest href를 + 원자적으로 처리하며 `remove_section()`과 패키지 열기 안전성 검증도 같은 구역 수 + 불변식을 확인합니다. + +### 검증 +- 2개 구역 합성 fixture의 결정적 재생성, 저장·재열기, 패키지 바이트 보존, 오류 주입 + rollback 및 한컴오피스 2/2쪽·2/2구역 경고 없는 열기 회귀를 고정했습니다. + +## [3.1.0] - 2026-07-16 + +### 추가 +- **타입드 혼합 양식 계획기**: `hwpx.mixed-form-plan/v1`이 네이티브 필드, 고유 라벨 셀, + revision-bound canonical path, 본문 직접 단일-run anchor를 하나의 엄격한 계획으로 받습니다. + 모든 대상을 먼저 canonical path로 해석한 뒤 기존 `hwpx.agent-batch/v1` 한 건으로 컴파일하며, + `apply_document_commands`만 실행기로 사용해 dry-run·rollback·idempotency·단일 저장 게이트를 + 그대로 보장합니다. +- 병합 셀의 논리 좌표를 실제 `cellAddr` 앵커로 정규화하는 비변형 `resolve_cell_target` 계획 + 프리미티브와 공개 plan/compiled-plan JSON Schema를 추가했습니다. + +### 변경 +- 에이전트 batch JSON Schema를 정본 카탈로그에 추가하고 quality 객체의 enum·boolean 필드를 + 엄격하게 검증해 알 수 없는 값이 실행 단계까지 전달되지 않도록 했습니다. +- 혼합 양식 출력이 입력과 동일한 resolved path·symlink·hardlink인 경우 계획과 적용 직전에 + 거부하고, 셀 라벨은 정규화된 정확 일치만 허용합니다. 컴파일 계획은 locator별 node/path/ + section/좌표 불변식을 검증하며, 멱등성 식별자에 공개 locator 요청 hash를 포함합니다. + +### 검증 +- 합성 1쪽 한국어 혼합 양식으로 네 locator 동시 적용, 드라이런 무출력, 주입 실패 rollback, + idempotent replay/conflict, 미변경 OPC member 바이트 보존, 재열기와 `openSafety.ok`를 고정했습니다. + `evalplan` 회귀는 유지하며 `exam` 모듈은 혼합 양식 경로에 가져오지 않습니다. + +## [3.0.0] - 2026-07-16 + +### 변경 +- 공개 배포에서 내부 품질 검증용 `hwpx.practice` 런타임과 관련 Python API를 제거했습니다. + 문서 작성·편집·검증, 시험지, 평가계획, 범용 양식 채움 기능과 공개 conformance 흐름은 + 그대로 유지됩니다. + +### 수정 +- 높이가 서로 다른 물리 행을 삭제할 때 병합 셀 높이를 평균값으로 줄이던 문제를 수정했습니다. + 이제 삭제 행의 실제 최대 높이를 사용해 평가계획 표를 다시 배치해도 병합 높이 합과 한컴 + 편집기 열기 안전성이 유지됩니다. + +### 보안 +- 소스·wheel·sdist·import 표면에 내부 품질 검증 런타임이 다시 포함되지 않도록 공개 위생과 + 패키징 회귀 게이트를 추가했습니다. + +## [2.29.2] - 2026-07-15 + +### 수정 +- 공개 저장소의 운영 기록·생성 산출물·workstation 경로를 제거하고, 실제 회귀 입력은 명시적인 + 합성 fixture 디렉터리로 이동했습니다. 패키지에 포함되는 HWPX 예제와 conformance corpus의 + 작성자 메타데이터도 합성 값으로 정규화했습니다. +- 치명적 정적 오류를 차단하는 Ruff `E9,F` 및 텍스트/HWPX/wheel 공개 위생 게이트를 추가하고, + 기존에 이름이 겹쳐 실행되지 않던 builder 회귀 테스트를 고유 이름으로 복구했습니다. + +### 보안 +- GitHub Actions를 불변 커밋으로 고정하고 CodeQL, dependency review, Dependabot, CycloneDX SBOM, + SECURITY/CODEOWNERS 정책을 릴리스 표면에 추가했습니다. + +## [2.29.1] - 2026-07-15 + +### 수정 +- 릴리스 prepublish가 시각 fixture 테스트를 실행하면서도 Pillow와 NumPy를 설치하지 않아 + `v2.29.0` 태그의 게시 작업이 중단된 문제를 수정했습니다. `test` extra에 두 의존성을 포함해 + clean CI에서도 동일한 테스트 계약을 결정적으로 재현합니다. + +### 비고 +- `v2.29.0` 태그는 prepublish 실패 이력으로 보존되며 PyPI 패키지와 GitHub Release는 생성되지 + 않았습니다. 기능 내용은 아래 2.29.0 항목과 동일하고, 실제 공개 패키지는 2.29.1입니다. + +## [2.29.0] - 2026-07-15 + +### 추가 +- **타입드 에이전트 문서 인터페이스와 블루프린트 재생**: 안정적인 semantic path/query, revision-bound + atomic command, 통합 `hwpx` CLI, deterministic `.hwpxbp` dump와 strict portable/source-bound replay를 + 추가했습니다. 스타일·번호·리소스·참조는 typed semantic signature로 매핑하고, raw XML 없이 + fidelity/identity/dependency/lossless/openSafety 영수증을 반환합니다. +- **내구성 렌더·시각 QA**: supervised Hancom render worker, resumable queue/poison-session 처리와 + fixture 기반 page QA·blind evaluation 계약을 추가했습니다. + +### 수정 +- 블루프린트 replay가 병합 표 grid와 한컴 네이티브 control을 보존하도록 보강했습니다. +- 공개 2.24.1의 안전 동작을 유지해, 실제로 변경된 비어 있지 않은 `lineWrap="SQUEEZE"` 셀만 + `BREAK`로 전환하고 no-op·지우기·미편집 셀은 원래 모드를 보존합니다. + +### 비고 +- 2.25.0–2.28.0은 공개 배포가 아니라 단계별 로컬 후보였으며, 그 누적 변경을 이 2.29.0 공개 + 항목으로 통합합니다. + +## [2.24.1] - 2026-07-14 +### 수정 +- 표 셀의 `lineWrap="SQUEEZE"`가 긴 신규 값을 한 줄 폭에 강제 압축해 글자가 포개지던 문제를 수정했습니다. 바이트보존 `fill_cells`/`apply_table_ops(fill_cell)`와 일반 `set_cell_text` 모두 실제로 변경된 비어 있지 않은 셀만 `BREAK`로 전환하고, 미편집 셀·no-op·지우기 작업의 원래 모드는 보존합니다. + +## [2.24.0] - 2026-07-08 +### 추가 +- **Stage 3 범용 form-fill 프리미티브 (universal form-fill goal)**: 임의 양식을 제출본급으로 채우는 동적 파이프라인의 손. `hwpx.guidance_scan.scan_form_guidance`(비변형 정찰: 셀·캡션 포함 색 신호/범례/placeholder/빈 셀 후보). `hwpx.body_patch`(표 밖 문단 바이트보존 op: replace_text·delete_paragraph·insert_paragraph_by_clone·reorder_paragraphs·restyle_text·set_paragraph_text + 문서전체 `strip_runs_by_color`(범례 "이 색=삭제" 구동)·`recolor_runs_by_color`(슬롯색→본문색)). `hwpx.table_patch` 신규 op: `split_cell_vertical`(병합 셀 N그룹 분할)·`clone_table`(표 복제)·`set_row_heights`·`set_cell_line_spacing` + `apply_table_ops(dry_run=)` transcript. `hwpx.fill_residue.inspect_fill_residue`(채움본 잔존물 zero-체크 게이트). +### 비고 +- 평가계획 3학년 실양식을 처음부터 끝까지(삭제·재구성·채움·청소·recolor) 범용 프리미티브만으로 완성, 실한컴 렌더 + 오너 실물 검수 PASS로 검증(도메인 전용 코드 0줄). + + +## [2.23.0] - 2026-07-03 +### 추가 +- **폰트 shrink-to-fit (M10 후속, S-064)**: `hwpx.table_patch.fill_cells`에 `fit_max_lines`(+ 셀별 `max_lines`) — 셀 텍스트가 템플릿 폰트로 목표 줄수를 넘겨 wrap되면 `form_fit` FitEngine이 확신을 갖고 들어가는 가장 큰 폰트(≥ `min_font_pt`)를 골라 **실제 ``로 재료화**(base charPr 복제·height 변경)하고 셀 run을 그 charPr로 재지정. byte-preserving(header.xml의 새 charPr + 해당 섹션만 변경, opt-in이라 목표 없는 채움은 바이트 동일). FitEngine 정직 게이트가 borderline shrink는 거부(확실히 들어갈 때만 축소). +### 비고 +- 오라클 실증: 실제 3학년 양식 성취기준 셀을 긴 텍스트 + `max_lines=4`로 채우니 9pt→6.5pt 축소, 실한컴 clean 렌더(나머지 표는 9pt 유지). 정직: 도교육청 폼은 base 9pt라 축소 여지(→8pt)가 작아 `autofit_columns`(가로)가 주력이고 폰트 축소는 보조; base 폰트 큰 폼엔 효과적. README 3스택 정비(python-hwpx 425→171·mcp 599→184·skill 471→178줄) 동반. + +## [2.22.0] - 2026-07-03 +### 추가 +- **열 너비 조정 (M10 후속, S-064)**: `hwpx.table_patch.apply_table_ops` 새 op 2종 — `set_column_widths(table_index, widths)`(명시적 논리 열너비; 각 셀 cellSz.width = 걸친 열들의 합, 병합 인식)·`autofit_columns(table_index)`(내용에 맞춰 열너비 재균형: demand = 최장 단일-span 셀 텍스트폭[`form_fit` 어드밴스 모델], sqrt-damped로 문단 열 폭주 방지, 열별 최소폭 floor, 표 총폭 보존). 둘 다 **byte-preserving**(cellSz만 편집, charPr/header 불변)이며 grid 검증. 배경: 텍스트가 길어지면 한컴이 행 높이를 자동으로 늘려 넘침은 없으나 좁은 열은 촘촘히 wrap됨 — autofit이 내용 많은 열을 넓혀 완화한다(오라클 실증: 운영계획 성취기준 열 14186→16441, wrap 약 16→9줄, 총폭 보존). + +## [2.21.0] - 2026-07-03 +### 추가 +- **M10 바이트보존 구조적 양식채움 (S-064)**: `hwpx.table_patch` — 2026-07-03 실전 실패(도교육청 평가계획 양식을 재생성으로 채워 서식 파괴)를 드라이버로, S-052 바이트 코어 위에 "양식 채움 층"을 완성. `fill_cells(source, cells)` — `(table_index, row, col)` 주소로 셀 텍스트를 바이트보존 splice(빈/self-closing 셀 삽입, 다중 문단 셀 전체 교체, 병합 앵커 해석). 미변경 셀·표·섹션은 **바이트 동일**(원칙 VII), no-op=바이트동일, 미해결 주소는 mutate 없이 `skipped`. +- **표 구조 프리미티브** `apply_table_ops(source, ops)`: `delete_column`(자유폭 재분배 + 열삭제로 빈 행 생기면 캐스케이드 삭제·rowSpan 붕괴)·`delete_row`·`delete_table`·`insert_row_by_clone`(rowSpan==1 참조행 복제, 서식보존·문단 id 리프레시 — 균등 재생성 금지). 각 편집 후 `build_grid` 검증(overlap/hole/oob)으로 무효면 거부(fail-closed, 원칙 VI). 중첩표 거부. +- **실한컴 오라클 게이트** `verify_fill(before, after, require=)`: `resolve_oracle`+`visual_check`로 before/after를 실제 한컴 렌더 대조 → `render_checked`·overflow·overlap(글자겹침)·page_count. 오라클 없으면 정직 degrade(`render_checked=False`), `require=True`면 fail-closed. open-safety/HTML 프리뷰를 한컴 수용으로 오인 금지. +### 비고 +- 오라클 실증: 실제 3학년 양식에서 `delete_column`(반영비율 7→5열 캐스케이드)·`insert_row_by_clone`(세부기준 +행, 85병합 표)·content-complete 운영계획 채움이 실한컴에서 서식보존·clean 렌더. MCP 표면(`apply_table_ops`·`verify_form_fill`)은 hwpx-mcp-server 2.13.0에서 합류. + +## [2.20.0] - 2026-07-02 +### 추가 +- **M7 네이티브 자동 차례·상호참조 (S-062)**: `hwpx.tools.toc_author` — `add_native_toc`(한컴 네이티브 `TABLEOFCONTENTS` 필드영역 + Command DSL, `dirty=1` 기본 = 한컴이 처음 여는 순간 항목·차례 스타일·쪽번호를 재계산), `add_page_crossref`(쪽 번호 `CROSSREF` 필드 + 캐시 결과런 — 한컴이 편집/저장 시 자동 재계산), `mark_toc_dirty`(편집 후 재번호 재트리거), `ensure_paragraph_anchor_id`/`outline_heading_paragraphs`. 계약은 실제 한컴 저작 gold pair에서 리버스엔지니어링(`tests/fixtures/m7_toc_gold/`). +- **차례 충실도 하니스**: `hwpx.tools.toc_fidelity` — `parse_toc_model`(하이퍼링크·평문 재생성 항목 모두), `structural_report`(오라클 없이도 CROSSREF↔차례 캐시 모순으로 stale 탐지), `toc_verify`(한컴 렌더 대조 `toc_correctness_ratio`, 무오라클 시 정직 `unverified`), `grow_paragraph`. +- **Mac 오라클 새로고침 레그**: `MacHancomOracle.refresh_document`(+`_refresh_hwpx_mac.applescript`) — 열기→dirty 필드 재생성→제자리 저장→닫기. dirty-재생성 직후 같은 세션 PDF export가 이 한컴 빌드를 크래시시키는 실측 때문에 refresh와 render는 의도적으로 별도 세션. +### 수정 +- Mac 렌더 스크립트 `waitForFile`이 `%%EOF` 트레일러를 요구 — size>0만으로는 비동기 export 도중의 잘린 PDF를 캡처했다(실측). +### 비고 +- E2E 오라클 증명: 저작→새로고침→ratio 1.0(2/5/8쪽) → 재페이지네이션+`mark_toc_dirty`→새로고침→ratio 1.0 + 페이지 SHIFT(2/7/10). 실측 수집 규칙: `ContentsStyles:0:`이 바탕글(스타일 0) 문단도 차례 항목으로 수집 — 본문은 본문(스타일 1) 등 비수집 스타일 권장. MCP 표면(`add_toc`·`add_cross_reference`·`verify_toc`)은 hwpx-mcp-server 2.12.0에서 합류. + +## [2.19.0] - 2026-07-02 +### 추가 +- **M6 런서식 충실 읽기 하니스 (S-060)**: `hwpx.tools.read_fidelity` — `resolve_run_spans`(런별 bold/italic/underline/strikeout/color/size_pt/font/super-subscript를 charPr+fontface 해석), `collect_notes`(각주/미주 본문 + 본문 서식), `roundtrip_fidelity`/`corpus_fidelity`(콘텐츠-레벨 라운드트립 충실도), `spans_fidelity`/`notes_fidelity` 비교기, 공개 `fontface_maps`/`run_span`. 요소-카운트만 재던 `roundtrip_diff`와 달리 charPr-해석 런-스팬 및 각주 본문의 무손실을 측정한다. +- `strikeout`은 shape 속성으로 정규화(항상 존재하는 ``가 상시-on으로 오독되던 문제 회피), `underline` type `NONE`→`None` 정규화. +### 비고 +- 코퍼스 런서식 라운드트립 충실도 1.0(4075 런 / hwpxlib 47편). reading 차원 4→5(구조적 corpus-scale, 오라클 불요). 설치 MCP 표면 노출은 hwpx-mcp-server 2.11.0에서 합류. + +## [2.18.0] - 2026-07-01 +### 추가 +- **M5 개인정보(PII) 마스킹 엔진 (S-059)**: `hwpx.tools.pii` — `detect_pii` / `mask_pii` / `mask_value` / `PIIPolicy`. 기계검증 세트(주민등록번호·휴대폰·이메일·카드+Luhn)는 항상-on high-confidence, 맥락형(계좌·주소·이름)은 라벨 게이트 low-confidence(과마스킹 방지). 필드 최소화 `minimize_fields`, 가명 `Pseudonymizer`(결정적 토큰맵), 비식별 `deidentify`(불가역 salted-SHA256), 로그 위생 `PiiLogFilter` / `scrub_exception_message`. +- **메일머지·추출 경로 마스킹**: `mail_merge(masking_policy=DEFAULT_POLICY)` 기본 ON — 명부 산출물의 기계검증 PII 자동 마스킹(마스킹 길이로 FitPolicy 재측정). `export_text` / `export_html` / `export_markdown(masking_policy=...)` opt-in 추출 마스킹(기본 `None` = 내부 placeholder 탐지 보존). +### 비고 +- 폼필(form-fill) 경로 마스킹·`scan_personal_info`·전 경로 0-누출 게이트는 MCP 표면(hwpx-mcp-server) 단계에서 합류합니다. + +## [2.17.0] - 2026-06-30 +### 추가 +- **M4 변경추적(redline) 저작 (S-058)**: `HwpxDocument.add_tracked_insert` / `add_tracked_delete` / `add_tracked_replace` — 에이전트가 변경추적(삽입/삭제/치환)을 작성자·일자와 함께 저작하고, 사람이 한컴 검토 리본에서 개별 수락/거부할 수 있습니다. 헤더 `trackChanges`/`trackChangeAuthors` surgical splice(작성자 dedup·표시 플래그) + 본문 `insertBegin/End`·`deleteBegin/End` 마크(charPrIDRef 상속, paraend=0). 한컴 수용성은 measure-first 스파이크로 입증(실 Windows 한컴 COM `IsTrackChange=1`·opens-clean·roundtrip + 검토 리본 수락→반영/거부→취소 확인). +- `hwpx.tools.redline.verify_redline(before, after, *, oracle=None)` — 구조 검증(변경 수·TcId 마크 연결·표시 플래그·opens-clean) + `visual_check` `render_checked` 를 정직하게 fold(오라클 없으면 `unverified`, 거짓 통과 없음). +### 수정 +- **메모(코멘트) 본문이 숫자로 표시되던 버그**: `attach_memo_field` 가 MEMO 필드 subList에 코멘트 내용 대신 메모 ID(숫자)를 넣어, 한컴이 메모 박스에 숫자를 렌더했습니다. 한컴 오라클 구조에 맞춰 subList에 코멘트 텍스트를 넣고 `MemoShapeIDRef`(기본 65535)로 박스를 연결하도록 수정했습니다(실 Windows 한컴 검증). +### 비고 +- 수락/거부는 사람이 한컴 검토 리본에서 수행합니다(COM accept 액션 미노출 — 정석 워크플로). +- byte-identity: 미수정 part(ZIP 엔트리)는 byte-identical. 수정 섹션 내부의 문단단위 완전 byte-identical(surgical splice)은 stretch로 연기(한컴 렌더·수용엔 무영향). + +## [2.16.0] - 2026-06-29 +### 추가 +- **M3 문서 작성 (S-057)**: `create_document_from_plan` 이 `document_type`(공문/보고서/가정통신문)을 보고 실제 한컴-harvest 프로파일(`hwpx.design.compose`)로 라우팅합니다. 미매칭 유형은 기존 제로베이스 경로를 유지하고 `-> HwpxDocument` 반환 계약을 보존합니다. 공문은 결문 메타 `document_plan.gyeolmun = {issuer, productionNumber, enforcementDate, disclosure}` 를 지원합니다. +- `hwpx.design.profiles.home_notice` — 실제 가정통신문에서 harvest한 디자인 프로파일. +- **공문 구조 hard-gate**: `inspect_official_document_style(source, *, document_type="공문")` 이 시행문 척추(수신·발신명의·시행·공개구분·끝.)를 ERROR 심각도로 검사하고 `structure_pass` 를 반환합니다. 표 셀까지 읽는 table-aware 텍스트 추출(실 시행문의 두문/결문은 표 안에 있음)을 추가했고, 진짜 시행문(`tests/fixtures/m3_gongmun_gold/seoul_sihaengmun.hwpx`)을 앵커로 삼습니다. +- `inspect_document_authoring_quality` 에 `korean_proofing_status`(정직 `unverified` / `llm_proofed_not_oracle_verified`, 거짓 통과 없음)와 `verify_render=True` 시 실제 한컴 렌더 영수증 `render_checked`/`visual_complete` 를 추가했습니다. +### 비고 +- 각주(footnote) 작성은 한컴 렌더가 확인되지 않아 honest-deferred(`unverified`) 상태입니다. + +## [2.15.0] - 2026-06-27 +### 추가 +- `HwpxDocument.set_paragraph_format(keep_with_next=, keep_lines=, page_break_before=)` — 문단 keep-together 플래그를 엔진 `ensure_paragraph_format(break_setting=)`로 전달한다(새 paraPr 발행, 기존 paraPr 미수정 = 무손실). 시험지 조판 등에서 한 문항이 단/쪽 경계에서 잘리지 않게 묶을 때 쓴다. +- `hwpx.exam`: re-typeset an authored exam (Markdown) into a school form `.hwpx` + — Exam IR + strict md parser, form profiler (role→existing form style), + keep-together body composition (insert into the form's body region, never + append; 관리박스 + footer preserved byte-identical), and an oracle convergence + driver `compose_exam_into_form`. The driver renders via Hancom and, when the + composed 문항 are in the extractable text layer, verifies 문항-split / overflow + / placeholder integrity (inserting column/page breaks to converge); when they + are not (forms whose body Hancom exports as vector curves) or no oracle is + available, it returns `render_checked`/`splits=None` + `needs_review` rather + than a silent pass (Constitution V — honest unverified). +- `find_seal_anchor` — 발신명의가 좁은 표 셀에서 **여러 줄로 wrap**된 경우도 앵커를 찾는 fallback(연속 줄 윈도우, 최대 3줄). 단일 줄 매칭이 우선이라 기존 동작 불변; spurious 다중줄 매칭은 윈도우·동일페이지로 차단. +### 수정 +- `paragraph.add_picture` — `treat_as_char=True`(inline)인데 `pos_overrides`(PAPER relTo/offset)를 주면 모순된 inline/floating ``를 방출하던 것을 `ValueError`로 fail-fast. floating 배치는 `treat_as_char=False`에서만. + +## [2.14.0] - 2026-06-25 +### 추가 +- `hwpx.form_fit.seal` — 직인/관인 배치 + 규정 검사(M2 P3). `find_seal_anchor`(발신명의 줄의 끝글자=도장 중심), `check_seal_placement`(중심 tol·가림 글자 차별 pass/fail), `seal_pos_offsets`(PDF pt 앵커→PAPER HWPUNIT offset), `place_seal`(발신명의 소스 문단—표 셀까지 탐색—에 직인을 floating 스탬프; 오라클 검증 0.12pt, fail-closed, page/clamp 정직신호). +- `hwpx.form_fit.wordbox.extract_image_boxes` — 렌더된 PDF에서 임베디드 이미지(직인) rect 추출. 직인은 글자가 아니라 그림이라 `get_text`로 안 잡힘. +- `add_picture(treat_as_char=False, pos_overrides=, text_wrap=)` — floating 그림 경로. PAPER 상대 ``(offset은 xs:nonNegativeInteger로 coerce) + `textWrap`(직인은 `IN_FRONT_OF_TEXT`로 텍스트 안 밀고 위에 스탬프). +- `mail_merge(fit_policy=, max_lines=)` — fit-aware 배치(M2 P4 / FR-004). 각 placeholder 슬롯을 템플릿에서 한 번 측정(template-once-measure, advance-model·오라클 불필요)하고 레코드별로 fit. 넘침/결측 행을 `needsReview[]`/`skipped[]`(reason 코드 + retry advice)로 격리—자동 truncate 없음. `[xlsx]` extra(openpyxl)로 Excel/명부(.xlsx/.xlsm) 수용. +### 수정 +- 임베디드 이미지 manifest ``에 `isEmbeded="1"`(OWPML 단일-d 철자) 방출 — 없으면 한컴이 `add_picture`로 넣은 **모든 그림을 렌더 드롭**하던 잠복 버그(한컴 GUI 렌더로 확정). +- `mail_merge`가 **표 셀** 안 placeholder도 치환 — `replace_text_in_runs`(본문 전용)가 셀 런에 안 닿아 발신·결재/안내 표 안 `{{토큰}}`이 미치환으로 남던 버그. + +## [2.13.0] - 2026-06-24 +### 추가 +- `hwpx.conformance` — VisualComplete 적합성 코퍼스 + 배지 등급(plan §2 Phase G). `hwpx-conformance run`이 코퍼스를 4개 배지 등급(Open-Safe/Semantic-Safe/Form-Safe/VisualComplete)으로 채점하고 등급별 통과율을 산출합니다. 임계값은 엄격 기본값(구조 등급 100%, 폼셋 overflow 0%, VisualComplete ≥95%). golden 베이스라인(`tests/conformance/golden/structural.json`) 대비 회귀를 숫자로 감지하며(`--check`), CI가 구조 등급을 추적합니다. 어슈어런스 등급은 절대 섞지 않습니다(§0.0): 한컴이 없는 구조 실행은 VisualComplete를 `unverified`로 보고하고, 오라클 실행(도달 가능한 한컴 백엔드)만 VisualComplete를 검증합니다. 케이스에 `before`(+선택적 `editMask`)를 선언하면 VisualComplete가 오라클의 **before/after diff 경로**로 게이트됩니다(마스크 밖 변경·글자 겹침을 잡음). `expectVisualDefect`는 일부러 깨뜨린 쌍을 positive control로 삼아 게이트가 결함을 실제로 잡는지 검증합니다. (실측: 실제 한컴-저장 코퍼스에서 clean 쌍은 통과, out-of-slot 변경은 catch.) + +## [2.12.0] - 2026-06-24 +### 추가 +- `hwpx.quality` — 단일 저장 게이트 `SavePipeline`, `QualityPolicy`, `VisualCompleteReport`. 모든 직렬화 출력이 이 게이트를 통과합니다(무결성·XML·OPC/ID·열림안전·시각 오라클 → 단일 리포트). `HwpxDocument.save_report(...)`로 노출. +- `hwpx.form_fit` — FormFit 엔진(`FitPolicy`/`FitResult`). 폼 값이 셀/필드 박스에 맞는지 측정(한글=1.0em, 한컴 실측 보정)해 wrap/shrink/truncate/fail 처리. `set_cell_text(fit=...)` / `fill_form_field(fit_policy=...)`로 연결. +- `hwpx.layout` — 렌더러 없는 구조적 시각 스모크 `lint_layout`(stale lineseg·dirty/lineseg·overflow risk·표 구조). `QualityPolicy.layout_lint`로 SavePipeline 하드 게이트로 연결. +- `hwpx.design` — 검증된 한컴 저장 템플릿 + harvest 프래그먼트로 새 문서를 생성하는 `compose`/`DocumentPlan`/profile 빌더. `official_notice`/`report`/`application_form` 프로파일 동봉. +- 시각 오라클 `hwpx.visual`에 Mac 한컴 백엔드(`MacHancomOracle`) 추가. + +## [2.11.1] - 2026-06-12 +### 수정 +- `create_document_from_plan()`의 `heading` block과 builder `Heading`이 기본 템플릿의 `개요 N`/`Outline N` 문단 스타일을 실제로 적용하도록 수정했습니다. 생성 문서가 한컴 개요/문서 탐색과 MCP outline readback에서 구조화된 제목으로 인식됩니다. +- document-plan 기본 스타일 preset에 제목 18pt, 부제 12pt, 장 제목 14pt 글자 크기와 함초롬바탕 폰트를 적용해 보고서 생성 시 제목/본문 시각 위계가 명확하게 보이도록 했습니다. + +## [2.11.0] - 2026-06-12 +### 추가 +- 시드 결정적 퍼징 수렴 루프 `hwpx.tools.fuzz`(시나리오 카탈로그·생성기·3중 오라클 러너·최소화)와 `tests/fixtures/fuzz_regressions` 회귀 박제 수트를 추가했습니다. +- 레이아웃 근사 프리뷰 렌더러 `hwpx.tools.layout_preview`를 추가했습니다(페이지 박스·표·여백 근사 HTML/PNG — 에이전트 자기검증용). +- section XML 바이트 splice 기반 문단 패치 경로 `hwpx.patch`를 추가했습니다(미수정 영역 바이트 보존). +- 그림 자산 안전 삽입·치환 API(`add_picture` 및 치환 워크플로)와 manifest 검증을 추가했습니다. +- 기존 문서 서식 편집 API를 추가했습니다: 문단 정렬·줄간격·들여쓰기·문단 간격, 용지·여백·방향, 머리말/꼬리말·쪽번호, 불릿/번호 형식. +- 누름틀(클릭히어 필드) 1급 조회·채움 API를 추가했습니다. +- 공문서 작성규정 lint `hwpx.tools.official_lint`(항목기호 위계·"끝." 표시·붙임·날짜 표기)와 결재란 프리셋을 추가했습니다. +- 고급 생성기 `hwpx.tools.advanced_generators`를 추가했습니다: 사진대지(`build_image_grid`)·회의 명패(`build_meeting_nameplates`)·표 기반 조직도. +- 신구대조 문단 diff와 참조 정합 lint `hwpx.tools.doc_diff`를 추가했습니다. +- 메일머지 대량 생성과 표 합계·평균 계산 유틸 `hwpx.tools.mail_merge`를 추가했습니다. +- 참조 문서 서식 프로파일 추출·적용과 템플릿 레지스트리 `hwpx.tools.style_profile`을 추가했습니다. +- template analyzer 리포트를 강화했습니다(열너비 재구성·cell margin·vertAlign). + +### 변경 +- `hwpx.oxml.document` 모놀리스(5,700여 줄)를 요소별 모듈(`_document_impl` 외 18개)로 분할했습니다. 공개 API는 변하지 않습니다. + +### 수정 +- 신뢰할 수 없는 입력 파싱을 강건화했습니다(`hwpx.opc.security`): XML entity 선언 거부와 깊이/크기 한도, ZIP 압축비·멤버 수 한도를 적용해 entity 폭탄·압축 폭탄 입력을 안전하게 거부합니다. + +## [2.10.3] - 2026-06-09 +### 추가 +- `hwpx.tools.validate_editor_open_safety()`와 `EditorOpenSafetyReport`를 추가해 package validation, document validation, 재오픈 검증을 한 곳에서 확인할 수 있게 했습니다. + +### 수정 +- 텍스트를 줄이는 저수준 편집 뒤 stale `hp:linesegarray`가 남아 한컴 편집기에서 열리지 않을 수 있는 문제를 막기 위해, 저장 직전 plain-text 문단의 무효한 layout cache를 제거합니다. +- 편집된 section과 public 저수준 section write 경로는 모든 `hp:lineSegArray` layout cache를 제거해, 복합 문단처럼 stale 여부를 안전하게 계산하기 어려운 경우도 편집기가 다시 계산하도록 했습니다. +- public 저수준 section/header XML write 경로도 Hancom-compatible root namespace 선언과 `standalone="yes"` XML declaration을 보정해 generic XML serializer 출력이 그대로 저장되지 않도록 했습니다. +- `HwpxDocument.to_bytes()`, `save_to_path()`, `save_to_stream()`이 생성된 패키지의 editor-open safety를 확인한 뒤에만 결과를 반환하거나 쓰도록 보강했습니다. `save_to_path()`는 safety 실패 시 기존 대상 파일을 교체하지 않습니다. +- `HwpxPackage.save()`도 editor-open safety를 기본 검증해 저수준 package 직접 편집이 unsafe HWPX를 bytes/path/stream으로 내보내지 않도록 막습니다. +- public `HwpxPackage.save()`에서 editor-open safety 검증을 우회하는 파라미터를 제공하지 않도록 정리했습니다. 검증 실패 상태의 bytes snapshot은 package 내부 진단 토큰이 있는 경로에서만 생성하며, unchecked 경로는 caller-provided file path/stream에 직접 쓸 수 없습니다. +- `HwpxDocument._to_bytes_raw()`의 open-safety bypass 인자를 제거해 document 객체에서 unchecked bytes를 얻는 실수성 우회 경로를 더 좁혔습니다. +- private archive writer도 save 내부 컨텍스트에서만 동작하게 해, `_write_archive()`/`_write_zip_entry()` 직접 호출로 editor-open safety 검증을 건너뛴 ZIP을 만드는 실수성 우회 경로를 막았습니다. +- 문서/package 저장 중 open-safety, 실제 파일 쓰기, stream short write가 실패하면 dirty 상태를 성공처럼 정리하지 않도록 보강했습니다. seek 가능하고 안전하게 복원 가능한 stream은 쓰기 실패 시 원래 내용으로 rollback합니다. +- archive pack CLI가 재패킹 결과를 editor-open safety 리포트로 재검증하고, 실패 시 기존 output을 보존합니다. 성공 시 `PackResult.open_safety`와 CLI `open_safety_ok=true` 출력으로 handoff evidence를 제공합니다. +- repair/recover 출력도 CRC와 package validation 뒤에 editor-open safety를 재검증하고, section의 `hp:lineSegArray` layout cache 제거와 section/header root namespace 및 `standalone="yes"` 보정을 적용합니다. 실패 시 기존 output은 보존하고, 성공 시 `RepairResult.open_safety`로 handoff evidence를 제공합니다. +- template form-fit apply가 최종 목적지에 먼저 복사하지 않고 temp 파일에서 저장 및 editor-open safety 검증을 끝낸 뒤에만 교체하도록 보강했습니다. +- builder `Document.save_to_path()` 리포트에 `editor_open_safety` hard gate와 세부 리포트를 포함합니다. +- template form-fit paragraph clone 경로가 텍스트를 직접 바꿀 때도 layout cache를 제거합니다. +- package validator가 실제 텍스트 길이를 넘어서는 `lineseg/@textpos`를 hard error로 보고하도록 보강했습니다. +- `EditorOpenSafetyReport.ok`가 package/reopen뿐 아니라 document validation 실행 실패와 hard error도 반영하도록 보강했습니다. +- 저장 직전 paragraph의 `styleIDRef`가 `Normal`/`본문`처럼 header style 이름으로 잘못 들어간 경우, 일치하는 numeric style id로 정규화해 저수준 편집 산출물이 document validation에서 차단되거나 편집기 오픈 리스크를 만들지 않도록 했습니다. +- `HwpxPackage.save(updates=...)` 같은 순수 package 저수준 저장 경로도 header style 이름으로 된 paragraph `styleIDRef`를 numeric id로 정규화해, MCP를 거치지 않는 직접 ZIP/XML 편집 산출물도 같은 safety 보정을 받도록 했습니다. + +## [2.10.2] - 2026-06-06 +### 추가 +- `hwpx.tools.markdown_export.export_markdown()`와 `HwpxDocument.export_rich_markdown()`을 추가해 풍부한 Markdown 변환을 지원합니다. 인라인 서식(굵게/기울임/취소선/색상/하이라이트), 표 병합 셀(colspan/rowspan HTML), 중첩 표 재귀, `rect`/`ellipse`/`polygon` 도형 내부 paragraph, BinData 이미지 추출, `Ⅰ.`/`1.` 패턴 기반 헤딩 감지(`# `/`## `), 각주·미주(정확 위치 마커 + `fn1`/`en1` 일련번호 + 본문 인라인 서식), 하이퍼링크(`[text](url)`) 보존을 한 번에 처리합니다. 기존 `HwpxDocument.export_markdown()`은 그대로 유지됩니다. +- `HwpxOxmlNote`에 본문 paragraph 접근/편집 helper를 추가했습니다: `body_paragraph` property, `add_run(text, *, char_pr_id_ref=..., bold=..., italic=..., underline=..., color=..., font=..., size=..., highlight=..., strike=..., attributes=...)`, `add_hyperlink(url, display_text, *, char_pr_id_ref=...)`. XML 직접 조작 없이 각주 본문에 혼합 서식 run과 하이퍼링크를 추가할 수 있습니다. +- `get_table_map()` 결과에 본문 표 anchor `location`, 셀 문단별 `table_cell_paragraph` location, `caption_text`, `preceding_paragraph_text`를 추가했습니다. +- 새 컨버터와 helper에 대한 회귀 테스트를 `tests/test_markdown_export.py`에 추가했습니다. + +### 변경 +- `HwpxOxmlTableCell.text`가 셀 내부 여러 문단을 줄바꿈으로 보존하고, `set_text(..., preserve_format=True, split_paragraphs=True)` 경로에서 기존 run `charPrIDRef`를 유지하도록 개선했습니다. + +### 수정 +- `HwpxOxmlParagraph.add_footnote()`/`add_endnote()`의 `char_pr_id_ref` 인자가 외부 호스팅 run에만 적용되고 각주 본문 run은 항상 `charPrIDRef="0"`으로 하드코딩되던 문제를 수정했습니다. 인자가 사용자 의도대로 본문 run에도 적용됩니다. + +## [2.10.1] - 2026-06-04 +### 추가 +- `document_plan` authoring을 builder lowering 중심으로 확장하고 v2 builder node, TOC, government_report preset을 지원합니다. +- 정부보고서 계산/파싱 유틸리티(`hwpx.tools.report_utils`, `hwpx.tools.report_parser`)와 computed field 치환을 추가했습니다. +- generic element coverage inventory, table cleanup, table profile/caption/unit preservation, id reference integrity checker를 추가했습니다. +- `linesegarray`, `transMatrix`, `scaMatrix`, `rotMatrix`, edit/combo box control을 first-class OXML 모델로 승격했습니다. + +### 변경 +- builder save report의 hard gate가 id integrity를 실제 검사 결과로 반영하도록 강화했습니다. +- 패키지 rewrite 시 `mimetype` 엔트리를 보존하도록 OPC 저장 경로를 정리했습니다. + +## [2.10.0] - 2026-06-02 +### 추가 +- `hwpx.builder` 공개 패키지를 추가했습니다. `Document`, `Section`, `Paragraph`, `Run`, `Heading`, `Bullet`, `NumberedList`, `Table`, `Image`, `Header`, `Footer`, `PageNumber`, `PageBreak`, `Metadata`, `PageSize`, `Margins` 노드로 조립형 HWPX 생성을 지원합니다. +- `BuilderSaveReport`와 `ReopenReport`를 추가해 builder 저장 후 package validation, document error/lint, reopen, feature flags, visual review 필요 여부를 확인할 수 있게 했습니다. +- 머리글/바닥글 리치 content, 자동 쪽번호, 리치 런 서식(color/font/size/highlight/strike), 다단계 목록, 표 병합/음영/열너비, 이미지 배치를 위한 `HwpxDocument` facade 및 OXML wrapper 메서드를 추가했습니다. +- `hwpx.document_plan.v1`, 운영 계획서 품질 프로필, template form-fit authoring, proposal/form-fill 품질 검증 흐름을 강화했습니다. +- hwpxlib sample corpus 기반 oracle fixture와 builder vertical slice 통합 테스트를 추가했습니다. +- `src/hwpx/tools/_schemas/owpml/`에 2011 Hancom 네임스페이스용 subset XSD 번들을 추가했습니다 (`header.xsd`, `body.xsd`, `paralist.xsd`, `core.xsd`, `xml.xsd`, `NOTICE`). +- `hwpx.oxml.load_compound_schema()`와 `SchemaImportError`를 추가해 offline compound XSD 로딩을 지원합니다. +- fixture matrix 기반 Phase 1 validation 리포트(`shared/hwpx/HWPX_STACK_VALIDATION_2026-04-20_pre-phase1.md`, `..._post-phase1.md`)와 회귀 테스트를 추가했습니다. + +### 변경 +- `validate_document().ok`는 error 기준으로 유지하고 schema warning은 lint/warning으로 분리해 가시화합니다. +- `HwpxDocument.save_to_path()` 기반 저장/재오픈 검증 경로를 builder와 authoring workflow에서 일관되게 사용하도록 정리했습니다. +- `hwpx-validate`는 이제 기본 strict 모드로 Phase 1 subset schema bundle을 사용합니다. `--no-strict`로 warning-only 분류를 지원합니다. +- `HwpxDocument.validate()`는 기본 `strict=False`로 동작하며, `validate_on_save_strict` 옵션으로 저장 시 strict 검증을 제어할 수 있습니다. +- 패키지 배포물(sdist/wheel)에 OWPML subset schema bundle이 포함되도록 package-data를 확장했습니다. + +### 수정 +- split-run placeholder, template form-fit, proposal/document-plan 생성 경로의 회귀를 보강했습니다. +- builder vertical slice에서 Hancom Office HWP 재오픈과 구조 hard gate가 통과하도록 머리글/바닥글 lowering과 page number control 배치를 정렬했습니다. + +## [2.9.1] - 2026-04-27 + +상호운용성(interop) 버그 묶음 릴리즈입니다. 외부 기여자들이 보고하고 수정한 세 가지 문제를 정리합니다. + +### 수정 +- `HwpxOxmlTableCell._ensure_text_element`와 `ensure_run_style` 내 modifier가 lxml 엘리먼트 상에서 또한 `ET.SubElement`를 호출해 `TypeError`를 발생시키던 경로를 기본 헬퍼 `_append_child`로 정리했습니다. 이제 `cell.text = ...`와 `paragraph.add_run(..., bold=True)`가 monkey-patch 없이 정상 동작합니다 (#30, [@hhy827](https://github.com/hhy827)). +- `_paragraph_id` / `_object_id` / `_memo_id`가 `uuid4().int & 0xFFFFFFFF`로부터 signed int32 범위를 벗어나는 값을 약 50% 확률로 생성하던 문제를 수정했습니다. id 값을 signed 32-bit 양수 범위(`0 <= x < 2^31`)로 클램프해 downstream 소비자와의 상호운용성을 확보했습니다 (#34, [@seonghoony](https://github.com/seonghoony)). +- `HwpxDocument.new()`의 seed로 쓰이는 번들 `Skeleton.hwpx`에 signed int32 범위를 벗어나는 ``가 포함돼 있던 문제를 수정했습니다 (#35, [@seonghoony](https://github.com/seonghoony)). +- `pyproject.toml`에 PEP 639 `license` expression과 같이 남아 있던 legacy `License :: OSI Approved :: Apache Software License` classifier를 제거해 `setuptools>=77`에서의 소스 설치/바이너리 빌드 실패를 해소했습니다. + +### 추가 +- 위 세 버그에 대한 회귀 테스트를 추가했습니다 (`tests/test_document_formatting.py`, `tests/test_id_generator_range.py`, `tests/test_skeleton_template_ids.py`). +- 머지된 기여를 인정하는 `CONTRIBUTORS.md`를 추가하고 `README.md` / `CONTRIBUTING.md`에서 연결했습니다. + +### 변경 +- License relicensed to Apache-2.0 (sole author, full consent). Previous license terms no longer apply to future releases. + +## [2.9.0] - 2026-04-02 +### 추가 +- `HwpxDocument.get_table_map()`, `find_cell_by_label()`, `fill_by_path()`를 추가해 HWPX 양식/템플릿 표를 문서 순서 기반으로 탐색하고 채울 수 있게 했습니다. +- `hwpx.tools.table_navigation` 모듈을 추가해 엔진 레벨에서 재사용 가능한 표 탐색, 라벨 정규화, 방향 이동, 배치 채우기 helper를 공개했습니다. + +### 변경 +- 라벨 매칭이 공백 축약, 대소문자 무시, 후행 콜론 허용 규칙을 따르도록 정규화 로직을 추가했습니다. +- 표 자동화 API에 대한 회귀 테스트와 README/API 레퍼런스 문서를 추가했습니다. + +## [2.8.3] - 2026-03-10 +### 변경 +- 저장소와 배포 메타데이터의 라이선스 표기를 실제 `LICENSE` 파일과 일치하도록 정렬했습니다. +- `pyproject.toml`을 PEP 639 방식의 `LicenseRef-python-hwpx-NonCommercial` + `license-files` 구성으로 갱신하고, 잘못된 MIT 분류자를 제거했습니다. +- README 라이선스 배지/섹션을 커스텀 비상업적 라이선스 기준으로 수정하고, wheel/sdist 산출물의 라이선스 메타데이터를 검증하는 회귀 테스트를 추가했습니다. + +## [2.8.2] - 2026-03-08 +### 변경 +- README를 현재 공개 API와 CLI 범위에 맞춰 정리했습니다. Quick start, 텍스트 추출, 객체 검색 예시를 실제 호출 방식 기준으로 수정했습니다. +- `add_memo()`/`add_memo_with_anchor()`가 `HwpxDocument.new()`로 만든 실제 `lxml` 기반 문서에서도 동작하도록 memo XML 생성 경로를 엔진 호환 방식으로 정리했습니다. +- 실제 빈 문서 템플릿에서 메모 추가 후 roundtrip 되는 회귀 테스트를 추가했습니다. + +## [2.8.1] - 2026-03-08 +### 추가 +- 템플릿 자동화 회귀 스위트를 추가했습니다 (`tests/template_automation/`). 단순 토큰, 반복 토큰, split-run, 공백 정규화, 표/머리글/바닥글/다중 섹션, 체크박스 토글, extract-repack, 비표준 rootfile 패턴을 대표 fixture + 시나리오 계약으로 점검합니다. +- `DevDoc/template-automation-regression-suite.md`를 추가해 스위트의 보장 범위, 한계, fixture 추가 절차를 문서화했습니다. + +### 변경 +- 실제 `lxml` 기반 문서에서 `set_header_text()`/`set_footer_text()`가 동작하도록 header/footer 생성 경로를 XML 엔진 호환 방식으로 정리했습니다. +- 섹션 속성(`secPr`)이 비어 있을 때 보강 생성하는 경로를 XML 엔진 호환 방식으로 정리했습니다. +- `add_section()`이 새 섹션을 잘못된 네임스페이스로 만들던 문제를 수정했습니다. +- mypy/pyright gradual scope에 이번에 추가한 template automation helper/generator 모듈을 포함했습니다. +## [2.8] - 2026-03-08 +### 변경 +- `HwpxPackage`와 OXML 로딩/저장이 rootfile/manifest-relative 경로를 실제로 따르도록 정렬했습니다. +- `hwpx-analyze-template --extract-dir`가 재구성에 바로 쓸 수 있는 작업 디렉터리와 `.hwpx-pack-metadata.json`을 생성하도록 확장했습니다. +- `hwpx-validate-package`를 엔진 정합 기준으로 재작성해 dynamic rootfile/manifest 관계, CRC, fallback warning을 구분하도록 했습니다. +- `hwpx-unpack` 기본값을 raw-byte preserving으로 바꾸고 `--pretty-xml` opt-in을 추가했습니다. +- tooling/OPC 회귀 테스트를 확대하고, coverage threshold를 60으로 올렸으며, pyright는 touched OPC/tooling 범위에서 `basic`으로 상향했습니다. + +## [2.7.1] - 2026-03-08 +### 변경 +- 공개 저장소와 배포 산출물에서 내부 감사 문서를 제거했습니다. + +## [2.7] - 2026-03-08 +### 추가 +- `hwpx-unpack`, `hwpx-pack`, `hwpx-analyze-template` CLI를 추가했습니다. +- `src/hwpx/tools/archive_cli.py`를 추가해 unpack/pack 워크플로를 패키지 레벨 도구로 승격했습니다. +- unpack 시 `.hwpx-pack-metadata.json`을 기록하고, pack 시 이를 사용해 원본 ZIP 엔트리 순서/압축 방식을 가능한 범위에서 보존하도록 했습니다. +- `src/hwpx/tools/template_analyzer.py`를 추가했습니다. + +### 변경 +- `scripts/office/unpack.py`, `scripts/office/pack.py`, `scripts/analyze_template.py`를 패키지 도구 래퍼로 정리했습니다. +- `page_guard`에 shape/control count 및 히스토그램 비교를 추가하고, 실제 페이지 수 계산기가 아니라 구조 변화 징후 점검 도구임을 문서와 CLI 설명에 명시했습니다. +- README와 `docs/usage.md`에 새 CLI 사용 예시를 추가했습니다. +- 새 tooling에 대한 CLI/추출/overwrite/page-guard 회귀 테스트를 강화했습니다. + +## [2.6] - 2026-03-08 +### 추가 +- `hwpx-validate-package` CLI와 `hwpx.tools.package_validator`를 추가해 ZIP/OPC/HWPX 패키지 구조, `mimetype`, `container.xml`, manifest/spine 참조, XML well-formedness를 점검할 수 있게 했습니다. +- `hwpx-page-guard` CLI와 `hwpx.tools.page_guard`를 추가해 섹션 수, 단락 수, page/column break, 표 구조, 텍스트 길이 변화량을 기준으로 문서 드리프트를 비교할 수 있게 했습니다. +- `hwpx-text-extract` CLI를 추가해 기존 `TextExtractor` 기능을 plain/markdown 형태로 바로 사용할 수 있게 했습니다. +- `scripts/office/unpack.py`, `scripts/office/pack.py`, `scripts/analyze_template.py`를 추가해 XML-first HWPX 작업 흐름을 지원합니다. +- gap-closure 반영분에 대한 테스트를 추가했습니다 (`tests/test_gap_closure_tools.py`). + +### 수정 +- `HwpxDocument.validate()`가 내부 직렬화 과정에서 dirty 상태를 지워 버리던 부작용을 제거해, 검증 이후에도 저장 필요 상태가 유지되도록 수정했습니다. + +## [2.3.1] - 2026-02-28 +### 추가 +- **단락 삭제 API**: `paragraph.remove()`, `section.remove_paragraph()`, `document.remove_paragraph()` 메서드를 추가했습니다. 마지막 단락 삭제 시 `ValueError`가 발생합니다. +- **섹션 추가/삭제 API**: `document.add_section(after=)`, `document.remove_section()` 메서드를 추가했습니다. 새 섹션은 manifest/spine에 자동 등록되며, 마지막 섹션 삭제 시 `ValueError`가 발생합니다. +- **네임스페이스 상수 모듈**: `hwpx.oxml.namespaces` 모듈을 추가하여 HP, HH, HC 등 공유 네임스페이스 상수를 제공합니다. +- 새 API에 대한 16개 테스트 케이스를 추가했습니다 (`test_paragraph_section_management.py`). + +### 수정 +- `import hwpx`만으로 `DeprecationWarning`이 발생하던 문제를 수정했습니다. `hwpx.package` 경고는 이제 사용자가 직접 해당 모듈을 import할 때만 표시됩니다. +- `HwpxOxmlTableCell.text`가 셀에 여러 단락이 있을 때 첫 번째 텍스트만 반환하던 버그를 수정했습니다. 모든 `` 요소의 텍스트를 결합하여 반환합니다. +- `add_hyperlink()` 메서드에서 사용되지 않는 `field_inst_id` 변수를 제거했습니다. +- deprecated `save()` 호출을 사용하던 테스트 코드를 `save_to_path()`/`save_to_stream()`으로 업데이트했습니다. + +## [1.9] - 2026-02-18 +### 변경 +- `hwpx.__version__` 하드코딩 값을 제거하고 `importlib.metadata.version("python-hwpx")` 기반으로 노출하도록 정리했습니다. +- editable/로컬 소스 실행처럼 배포 메타데이터가 없는 환경에서도 동작하도록 `PackageNotFoundError` fallback(`0+unknown`)을 추가했습니다. + +## [0.1.0] - 2025-09-17 +### 추가 +- `hwpx.opc.package.HwpxPackage`와 `hwpx.document.HwpxDocument`를 포함한 핵심 API를 공개했습니다. +- 텍스트 추출, 객체 탐색, 문서 유효성 검사 등 도구 모듈과 `hwpx-validate` CLI를 제공합니다. +- HWPX 스키마 리소스와 예제 스크립트를 번들링해 바로 사용할 수 있도록 했습니다. +- 설치 가이드, 사용 예제, 스키마 개요 등 배포 문서를 정리했습니다. From 1340cda6cbbcabfff08201ace2cca9a8ad27e363 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 4/4] Keep independent review additions at distinct changelog and test locations --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf5e5c49..2d4a92cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,15 +91,15 @@ ### 고침 +- 쪽 수 추정에서 글이 없는 문단의 줄은 컨트롤이나 개체를 든 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`)을 돌려준다. -- 쪽 수 추정에서 글이 없는 문단의 줄은 컨트롤이나 개체를 든 run과 마지막 run(문단 끝) 가운데 가장 큰 글자 크기로 - 잰다. 한/글은 그 앞의 빈 글 run은 세지 않는다. 전에는 빈 글 run의 가장 작은 크기로 재서, 구역 정의나 표를 든 큰 글자 - run과 작은 빈 run이 섞인 문단(예: 문서 첫 문단)의 줄이 낮았다. - FormFit과 쪽 수 추정이 탭을 한/글처럼 놓는다. - 탭은 단(셀이면 셀 글 영역)의 왼쪽 끝에서 4000 HWPUNIT마다 있는 기본 탭 위치 가운데 지금 위치 다음 것까지 간다. 줄은 문단 왼쪽 여백에서, 첫 줄은 들여쓰기만큼 더 들어가 시작한다(내어쓰기는 둘째 줄부터).