diff --git a/pyproject.toml b/pyproject.toml index 98fce19..3f83523 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "xgen-edit2docs" -version = "0.26.0" +version = "0.26.1" description = "AI-agent-native document engine: generate and chat-edit DOCX, XLSX and PPTX as a Python library, agent tool set, MCP server or hosted service. English-first with first-class Korean support. Sister project of edit2ppt." readme = "README.md" requires-python = ">=3.12" diff --git a/src/xgen_edit2docs/__init__.py b/src/xgen_edit2docs/__init__.py index 028bec9..6059558 100644 --- a/src/xgen_edit2docs/__init__.py +++ b/src/xgen_edit2docs/__init__.py @@ -19,7 +19,7 @@ import importlib from typing import Any -__version__ = "0.26.0" +__version__ = "0.26.1" _LAZY: dict[str, str] = { # Unified, extension-dispatched verbs (docx / xlsx / pptx) diff --git a/src/xgen_edit2docs/documents/legacy/hwp_convert.py b/src/xgen_edit2docs/documents/legacy/hwp_convert.py index 107b607..74b7f6b 100644 --- a/src/xgen_edit2docs/documents/legacy/hwp_convert.py +++ b/src/xgen_edit2docs/documents/legacy/hwp_convert.py @@ -437,10 +437,19 @@ class _Node: # ── 저수준 파서 ──────────────────────────────────────────────── +#: 스트림 하나의 레코드 수 상한. 실문서 최대는 수만 개(한컴 스펙 문서 2.6만) — 작은 +#: 레코드를 수천만 개 늘어놓은 파일이 레코드 객체만으로 서버 메모리를 먹지 않게 한다. +_MAX_RECORDS = 2_000_000 + + def _iter_records(data: bytes): """(tagid, level, payload) 를 순서대로 — 손상 시 그 지점에서 멈춘다.""" pos, n = 0, len(data) + count, limit = 0, _MAX_RECORDS while pos + 4 <= n: + count += 1 + if count > limit: + raise LegacyConvertError("hwp 레코드가 너무 많습니다 (상한 초과)") (hdr,) = struct.unpack_from(" Tuple[bytes, int]: return merged[offset:offset + 16], options -def _decrypt_view_text(raw: bytes) -> Tuple[bytes, int]: - """ViewText 스트림 → (복호화된 본문 바이트, 옵션 플래그).""" +def _distribution_record(raw: bytes) -> Tuple[bytes, int]: + """ViewText 스트림 앞의 배포용 문서 데이터 레코드 → (256B, 암호문 시작 위치).""" if len(raw) < 4: raise LegacyConvertError("배포용 hwp 본문이 비어 있습니다") (hdr,) = struct.unpack_from(" Tuple[bytes, int]: pos = 8 if tagid != _TAG_DISTRIBUTE_DOC_DATA or size < 256 or pos + size > len(raw): raise LegacyConvertError("배포용 hwp 의 배포용 문서 데이터가 없습니다") - key, options = _distribution_key(raw[pos:pos + 256]) - body = raw[pos + size:] + return raw[pos:pos + 256], pos + size + + +def _decrypt_view_text(raw: bytes) -> Tuple[bytes, int]: + """ViewText 스트림 → (복호화된 본문 바이트, 옵션 플래그).""" + data, start = _distribution_record(raw) + key, options = _distribution_key(data) + body = raw[start:] body = body[:len(body) - len(body) % 16] try: from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -1126,6 +1141,15 @@ def _open(self) -> None: if not self.section_names: raise LegacyConvertError(f"hwp 본문({body}/Section*)이 없습니다") + if self.distribution: + # 복사·인쇄 방지는 여는 순간 안다 — 본문을 풀기 전에 쓰임새를 정할 수 있게. + # (옵션은 배포용 문서 데이터 256B 에 있어 복호화가 필요 없다.) + for name in self.section_names: + head = ole.openstream(name).read(8 + 256) + _, options = _distribution_key(_distribution_record(head)[0]) + self.copy_protected = self.copy_protected or bool(options & 0x1) + self.print_protected = self.print_protected or bool(options & 0x2) + def read_stream(self, name: str) -> bytes: raw = self._ole.openstream(name).read() return _decompress(raw) if self.compressed else raw @@ -1203,6 +1227,10 @@ def hwp_to_docx(content: bytes) -> bytes: from docx.shared import Emu, Pt, RGBColor with HwpFile(content) as hf: + if hf.copy_protected: + # 배포용 문서의 복사 방지 — DOCX 는 글자를 꺼내 쓰는 길(편집·에이전트 읽기)이다. + # 보기는 hwp_html 이 한다(선택·인쇄를 막은 채로). + raise LegacyConvertError("복사가 금지된 배포용 hwp 는 변환하지 않습니다") info = hf.info bin_blob = hf.bin_blob diff --git a/src/xgen_edit2docs/documents/legacy/hwp_html.py b/src/xgen_edit2docs/documents/legacy/hwp_html.py index 879b0c5..b7b40da 100644 --- a/src/xgen_edit2docs/documents/legacy/hwp_html.py +++ b/src/xgen_edit2docs/documents/legacy/hwp_html.py @@ -138,14 +138,11 @@ def __init__(self, hf: HwpFile): def render(self) -> str: out: list[str] = [] page = _PageDef() - header: list[_Para] = [] - footer: list[_Para] = [] for roots in self.hf.sections(): page = first_page_def(roots) or page - # 머리말·꼬리말은 구역마다 — 새로 정하지 않은 구역은 앞 구역 것을 잇는다. - h, f = _header_footer_paras(roots) - header = h or header - footer = f or footer + # 머리말·꼬리말은 그 구역이 새로 정했을 때만 그린다 — 앞 구역 것을 잇는 + # 구역마다 되풀이하면 같은 머리말(과 그림)이 구역 수만큼 쌓인다. + header, footer = _header_footer_paras(roots) body = "".join(self._blocks(_interpret_paras(roots))) master = _master_page_paras(roots) if master: # 바탕쪽(쪽 머리 장식·틀)은 구역 맨 위에 흐리게 diff --git a/tests/unit/test_hwp_html.py b/tests/unit/test_hwp_html.py index d1e8d34..67614a5 100644 --- a/tests/unit/test_hwp_html.py +++ b/tests/unit/test_hwp_html.py @@ -213,6 +213,13 @@ def test_a_decompression_bomb_is_refused(self, monkeypatch): with pytest.raises(LegacyConvertError, match="너무 큽니다"): hwp_to_html(_hwp(_para(0, _utf16("가" * 200)))) + def test_too_many_records_are_refused(self, monkeypatch): + """레코드 객체만으로 메모리를 먹는 파일 — 상한에서 멈춘다(실문서 최대 2.6만).""" + monkeypatch.setattr(H, "_MAX_RECORDS", 5) + section = b"".join(_para(0, _utf16(f"문단{i}")) for i in range(5)) + with pytest.raises(LegacyConvertError, match="레코드가 너무 많습니다"): + hwp_to_html(_hwp(section)) + def test_drm_documents_are_refused_by_name(self): with pytest.raises(LegacyConvertError, match="DRM"): hwp_to_html(_hwp(_para(0, _utf16("x")), flags=0x1 | 0x10)) @@ -267,6 +274,22 @@ def test_copy_and_print_protection_carry_over(self): assert "user-select:none" in html assert f"@media print{{.{ROOT_CLASS}{{display:none;}}}}" in html + def test_protection_is_known_when_the_file_opens(self): + with H.HwpFile(self._doc(options=0x1)) as hf: + assert hf.copy_protected and not hf.print_protected + + def test_copy_protected_text_is_not_handed_out_as_docx(self): + """DOCX 는 글자를 꺼내 쓰는 길(편집·에이전트 읽기) — 복사 방지 문서는 보기만.""" + with pytest.raises(LegacyConvertError, match="복사가 금지된"): + convert_to_ooxml(self._doc(options=0x1), "hwp") + assert "배포용 본문" in _text_of(hwp_to_html(self._doc(options=0x1))) + + def test_an_unprotected_distribution_document_converts(self): + from docx import Document + + d = Document(io.BytesIO(convert_to_ooxml(self._doc(options=0), "hwp")[0])) + assert any("배포용 본문" in p.text for p in d.paragraphs) + # ───────────────────────────────────────────────────────────── # HTML — 충실도와 안전 @@ -300,6 +323,16 @@ def test_hanging_indent_and_korean_word_breaking(self): assert "margin-left:15pt" in html and "text-indent:-5pt" in html assert "word-break:keep-all" in html + def test_a_header_is_drawn_where_it_is_defined_not_per_section(self): + head = _ctrl(1, "head", b"\x00" * 8) + _rec(0x48, 2, struct.pack("