Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/xgen_edit2docs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 32 additions & 4 deletions src/xgen_edit2docs/documents/legacy/hwp_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<I", data, pos)
pos += 4
tagid = hdr & 0x3FF
Expand Down Expand Up @@ -1041,8 +1050,8 @@ def _distribution_key(data: bytes) -> 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("<I", raw, 0)
Expand All @@ -1052,8 +1061,14 @@ def _decrypt_view_text(raw: bytes) -> 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
9 changes: 3 additions & 6 deletions src/xgen_edit2docs/documents/legacy/hwp_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: # 바탕쪽(쪽 머리 장식·틀)은 구역 맨 위에 흐리게
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/test_hwp_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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 — 충실도와 안전
Expand Down Expand Up @@ -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("<H", 1) + b"\x00" * 6) \
+ _para(2, _utf16("머리말 한 번"))
first = _para(0, _ext(0x10, "head") + _utf16("첫 구역"), extra=head)
second = _para(0, _utf16("둘째 구역"))
doc = _hwp(first, extra={"BodyText/Section1": _comp(second)})
html = hwp_to_html(doc)
assert "둘째 구역" in html
assert html.count("머리말 한 번") == 1

def test_table_border_fill_applies_to_the_table(self):
bf = bytearray(44)
for k in range(4):
Expand Down
Loading