diff --git a/CHANGELOG.md b/CHANGELOG.md index 22b755a6..ef25db74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,40 @@ ### 고침 +- `apply_body_ops`가 문서를 망가뜨리던 네 경우를 거부하거나 바로잡는다(전에는 모두 `applied`에 열림 검사 통과였다). + - 구역 정의(`hp:secPr`)를 품은 문단: `delete_paragraph`는 구역 정의째 지웠고, `insert_paragraph_by_clone`은 구역 + 정의를 한 벌 더 만들었고, `reorder_paragraphs`는 그 문단을 첫 자리에서 옮겼다. 이제 셋 다 거부한다. + - 글상자·각주·머리말을 품은 문단: `set_paragraph_text`와 `texts`를 준 `insert_paragraph_by_clone`이 새 글을 안쪽 + 문단의 첫 글 자리에 넣고 나머지 글을 비울 수 있었다. 이제 거부한다(표를 품은 문단은 전부터 거부). + - `restyle_text`: 찾은 글이 표 뒤에 있으면 그 글의 run이 아니라 표 마지막 칸 안 run의 글자 모양을 바꿨다. 이제 글을 + 직접 가진 run을 바꾼다. +- XML 주석이나 처리 지시문이 `header.xml`이나 줄 배치 캐시가 있는 문단에 든 문서도 저장된다. 전에는 문서가 열리고 + 열림 검사도 통과했지만, 고치지 않은 저장까지 `TypeError: argument of type 'cython_function_or_method' is not + iterable`로 끝났다(저장 때 도는 검사가 주석 노드의 `tag`를 요소 이름으로 읽었다). 문서 병합(`append_document`)도 + 같은 오류였다. +- 저장 관문이 필수 자식(`offset`·`orgSz`·`curSz`·`sz`·`pos`, `renderingInfo`)이 없는 글맵시(`hp:textart`)·OLE(`hp:ole`)· + 동영상(`hp:video`)도 `hp:rect`처럼 거부한다. `doc.shapes.add_raw("textart"|"ole"|"video")`로 만든 빈 요소는 전에 + 저장됐고, 그 파일을 한/글 2024가 열다가 경고 없이 종료했다(종료 코드 0xC0000005). `validate_package`· + `validate_editor_open_safety`도 오류로 보고한다. 한/글이 저장한 글맵시·OLE·동영상은 전처럼 통과하고, 빈 + 수식(`hp:equation`)은 한/글이 정상으로 열므로 막지 않는다. +- `paragraph.apply_model`(글·런을 바꾼 경우), `add_index_mark`, `add_title_mark`, `add_drop_cap`이 고친 문단의 줄 배치 + 캐시를 지운다. 전에는 문단 내용이 바뀌어도 옛 캐시가 남았다. `apply_model`은 모델이 문단 내용을 바꾸고 캐시는 그대로 + 돌려준 때만 지우므로, 모델로 캐시 자체를 고치는 쓰임은 전과 같다. +- 저장 때의 낡은 캐시 훑기가, 고친 구역에서 **내용이 바뀐 문단에 남은 옛 캐시**를 지운다. 구역은 읽을 때(그리고 저장할 + 때마다)의 "문단 내용 + 캐시" 짝을 기억하고, 파일에 있던 캐시가 다른 내용 위에 놓여 있으면 그 캐시를 지운다. 편집 + API가 캐시 지우기를 빠뜨려도 글이 한 글자만 늘어난 경우까지 잡힌다(전에는 마지막 줄 뒤 글이 그 줄 폭의 2배를 넘어야 + 지워졌다). 내용이 그대로인 문단의 캐시와 읽은 뒤 새로 쓴 캐시는 건드리지 않는다. 고치지 않은 구역은 전처럼 바이트 + 그대로 저장된다. 폭 추정으로 지우는 기존 기준(2배)은 그대로다. +- 찾아 바꾸기와 부분 편집도 줄 바꿈을 `hp:lineBreak`로, 탭을 `hp:tab`으로 쓴다. `run.replace_text`, + `doc.text.replace`(기본·`everywhere=True`), `doc.replace_text_in_runs`, `paragraph.apply_model`·`run.apply_model`은 + 바꿀 글의 `"\n"`을 `hp:t` 안에 글자로 남겼고, `paragraph.set_text_preserving_runs`는 `"\n"`을 글자로 남기고 탭은 + 지웠다. 이제 `paragraph.text =`·`add_run`과 같은 요소로 쓴다(`"\r\n"`·`"\r"`도 줄 바꿈 하나). +- `strip_runs_by_color`가 표·글상자·각주를 품은 대상 색 run에서 안쪽 run의 글을 지우지 않는다. 전에는 run의 여는 + 태그를 그 뒤 첫 ``과 짝지어, 빨강 run이 표를 품고 있으면 첫 칸의 검은 글이 지워지고도 `applied`로 + 보고됐다. 빈 run(``) 뒤의 다른 색 글도 같은 까닭으로 지워졌다. 이제 대상 run이 직접 가진 글만 비우고, + 표 등을 품은 run을 비운 수를 `runsHoldingNestedRuns`로 알린다. 줄 배치 캐시는 글이 바뀐 문단의 것만 지운다(전에는 + 표 뒤 글이 지워진 바깥 문단의 캐시가 남았고, 원래 비어 있던 문단의 캐시는 지워졌다). + - 표 행·열·칸 구조 편집(객체 경로와 `apply_table_ops`)이 쪽을 넘어 나뉜 표의 저장 높이(`hp:sz`)를 그대로 둔다. 한/글은 그런 표에 첫 쪽 조각의 높이만 저장하므로(같은 행의 표가 놓인 자리에 따라 30463·17551·5865로 저장됨) 행 높이 증감을 더할 근거가 없다. 전에는 증감을 그대로 더해, 평가계획 채우기가 13행 표(행 28762, 저장 13222)에서 9행을 지우면 diff --git a/README.md b/README.md index f025df0f..a52cf01d 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,9 @@ doc.save_to_path("계획.hwpx") - 배치 추정(실험): `estimate_pages`는 한/글 없이 쪽 수와 줄마다의 쪽·세로 위치를 추정합니다. `write_line_caches`는 한/글이 저장할 때 쓰는 줄 배치 정보를, 한/글과 같은 값을 확실히 아는 문단에만 씁니다. rhwp처럼 이 정보로 그림 자리를 찾는 뷰어에서도 그림이 보이게 됩니다. 기본으로는 쓰지 않습니다. -- 검증·안전: 패키지 구조 검사 CLI, 저장 전 열림 검사, 저장 결과 보고(`return_report=True`). +- 검증·안전: 패키지 구조 검사 CLI, 저장 전 열림 검사, 저장 결과 보고(`return_report=True`). 기본 저장은 없는 글자 모양· + 문단 모양을 가리키는 참조까지는 확인하지 않습니다. 그 검사는 `save_report(quality=QualityPolicy.strict())`가 합니다 + ([검사 범위](docs/usage.md#변경-사항-저장과-결과물-확인)). 자세한 내용: [5분 퀵스타트](docs/quickstart.md) · [사용 가이드](docs/usage.md) · [API 레퍼런스](https://airmang.github.io/python-hwpx/) · [예제](docs/examples.md) · [지원 매트릭스](docs/support-matrix.md) diff --git a/README_EN.md b/README_EN.md index aefb8fbc..3e0de70a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -67,7 +67,8 @@ For higher-level workflows (document authoring, form filling, exam typesetting), paragraphs where it is sure to match Hangul. Viewers such as rhwp, which place pictures from that data, then show them too. By default nothing is written. - Verify and stay safe: a package-structure validation CLI, an open-safety check before saving, and a save - report (`return_report=True`). + report (`return_report=True`). A default save does not check that the character and paragraph shapes the + text points at exist; `save_report(quality=QualityPolicy.strict())` does. More: [five-minute quickstart](docs/quickstart.md) · [usage guide](docs/usage.md) · [API reference](https://airmang.github.io/python-hwpx/) · [examples](docs/examples.md) · [support matrix](docs/support-matrix.md) diff --git a/docs/python-example-ledger.json b/docs/python-example-ledger.json index e3130994..7e957c13 100644 --- a/docs/python-example-ledger.json +++ b/docs/python-example-ledger.json @@ -16,12 +16,12 @@ "fenceCount": 117, "sourceSha256": "a164785f35a43a2f224d2b1b906b71f113de67c0c7e05dea6e3c3a4fa3cb989d", "manualSha256": { - "README.md": "d63726c59b063948126eaaa05022b13c3264c88019466ee4bb67db6d8ba27fc3", - "README_EN.md": "185f69b6cde4093acdde7bfb2505aa2e9e31b2bb8f46763dc93a854702742082", + "README.md": "645e47f52ac3cbdc32b75a600e37dcfb6e64af089ec75b2acf350582e4ddd701", + "README_EN.md": "d6afeebcd4281108b49ac8763443943cece4932dd9e6b49b6a8c572d178e2f05", "docs/quickstart.md": "44d8ca82ee70c0e3dfdf087713a4e4ce935e64cd3f6a546311f95b054a285a18", "docs/recipes-traversal.md": "b63f42b1a6ed23812688599474dcf83300509b86a94b5daceae679bfdbde9343", "docs/mutation-semantics.md": "6de1471f64c1cd8db2e661c413d92bfd27221f5d930708942ac47c6aff2d608b", - "docs/usage.md": "ec70af303f2efaed9fe31f633af01817a4f2f1c6f31174dce4c21fadfd3b6948", + "docs/usage.md": "885e37139630b996f817a42ad8fc1c05daabe75acf54429a2cad49fa6059fb81", "docs/examples.md": "f03f0ecee4faef94dda05c92dd25672dfbde3a7447f823603b611bea59f6432e", "docs/faq.md": "cd82e2f1e740cff3f50e620c060ed8ae0aa130271fa373eca3a07a70fb9ca604", "docs/index.md": "111754d7637414e0078ba0c7bbcf19d1a4550a68202acf3dd2c89c0748ea0050", diff --git a/docs/usage.md b/docs/usage.md index e938f282..b61a7188 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1158,6 +1158,8 @@ for mark in span.marks: paragraph.apply_model(model) ``` +모델로 문단의 글이나 런을 바꾸면 `apply_model()`이 그 문단의 줄 배치 캐시(`hp:linesegarray`)를 지웁니다. 한/글은 캐시의 줄 수를 믿고 글을 그 안에 눌러 담으므로, 바뀐 내용 위에 옛 캐시를 남기지 않습니다. 런 텍스트에 넣은 `"\n"`과 탭은 `hp:lineBreak`·`hp:tab`으로 기록됩니다. + ## 런 서식 지정 헤더의 `` 정의는 여러 런이 공유하는 문자 서식을 담고 있습니다. `HwpxDocument.ensure_run_style()`은 굵게/기울임/밑줄 조합에 맞는 `charPr` 항목을 찾아 ID를 반환하고, 필요한 경우 새 항목을 생성합니다. 문단 객체는 `add_run()` 메서드를 통해 해당 서식을 즉시 사용하는 런을 만들 수 있습니다. @@ -1255,6 +1257,13 @@ for element in paragraphs_with_bookmark: | `save_to_stream(stream)` | 검증된 HWPX만 `BinaryIO` 스트림에 기록 | | `to_bytes()` | 검증된 HWPX를 `bytes`로 직렬화 | +세 방법의 기본 검사가 보는 범위는 다음과 같습니다. + +- 보는 것: ZIP과 각 XML 파트가 읽히는지, 한/글이 열지 못하는 구조(필수 자식이 없는 표·도형·글맵시·OLE·동영상, 빈 컨트롤, 필수 파트·속성 누락), 글보다 뒤를 가리키거나 줄 수가 크게 모자란 줄 배치 캐시. +- 보지 않는 것: **참조 무결성**. 본문이 가리키는 글자 모양(`charPrIDRef`)·문단 모양(`paraPrIDRef`)·스타일·테두리·그림 자료가 `header.xml`과 패키지에 실제로 있는지는 기본 저장에서 확인하지 않습니다. 없는 id를 가리키는 문서도 기본 검사를 통과해 저장됩니다. + +참조까지 확인하려면 `save_report(path, quality=QualityPolicy.strict())`를 쓰거나(`REFERENCE_INTEGRITY_FAILED`로 보고), `hwpx.tools.id_integrity.check_id_integrity(document)`를 직접 부릅니다. 이 검사는 문서를 여는 시간의 0.7배쯤 더 걸리고, 한/글이 저장한 문서 가운데에도 걸리는 것이 있어(정의되지 않은 글머리표 `idRef="0"`) 기본 저장에는 넣지 않았습니다. + ```python # 파일로 저장 document.save_to_path("result.hwpx") diff --git a/src/hwpx/body_patch.py b/src/hwpx/body_patch.py index 17055a3d..554bef7c 100644 --- a/src/hwpx/body_patch.py +++ b/src/hwpx/body_patch.py @@ -25,6 +25,11 @@ 안내용 슬롯 서식(파란 이탤릭 등)을 상속한 채움 텍스트를 "사람이 작성한 본문"으로 만든다(2026-07-07 AI중점학교 신청서 실측). 원본 charPr은 불변, 변형은 header에 새 id로 추가(dedupe). + +거부(공통): 구역 정의(``hp:secPr``)를 품은 문단은 지우거나 복제하거나 자리를 +옮기지 않는다(구역마다 하나, 첫 문단에 있어야 한다). 글상자·각주·머리말처럼 +다른 문단을 품은 문단에는 ``set_paragraph_text``와 texts를 준 복제를 하지 +않는다(안쪽 글이 비워진다). """ from __future__ import annotations @@ -35,6 +40,7 @@ from pathlib import Path from typing import Any, Mapping, Sequence +from .errors import HwpxValueError from .opc.security import guard_zip_file, read_member from .mutation_report import MutationReport, project_byte_splice from .patch import ( @@ -56,6 +62,20 @@ _T_CONTENT_RE = re.compile(r"]*)?>(.*?)", re.S) _PARA_ID_RE = re.compile(r'(]*\bid=")(\d+)(")') _TBL_RE = re.compile(r" bool: + """구역 정의(``hp:secPr``: 용지·여백·단)를 품은 문단인가. 구역의 첫 문단이 + 이것을 갖고, 한/글은 구역마다 하나를 그 자리에서 읽는다.""" + + return _SEC_PR_RE.search(block) is not None + + +def _holds_another_paragraph(block: str) -> bool: + """글상자·각주·머리말처럼 안에 다른 문단을 품었는가(표가 아니어도).""" + + return sum(1 for m in _P_EDGE_RE.finditer(block) if not m.group().startswith(" 1 _SUBLIST_EDGE_RE = re.compile(r"]*>|") _SQUEEZE_VALUE_RE = re.compile(r'\blineWrap="(?PSQUEEZE)"') @@ -137,6 +157,86 @@ def _squeeze_wrap_edit_for_sublist(section_xml: str, sublist_span: tuple[int, in return start + match.start("value"), start + match.end("value"), "BREAK" +@dataclass(frozen=True) +class _RunScan: + """한 ````과 그 run이 **직접** 가진 ````들.""" + + open_start: int + open_end: int + char_pr: str | None + texts: tuple[tuple[int, int], ...] # 각 hp:t 내용의 (start, end) + holds_runs: bool # 표·글상자·각주처럼 안에 다른 run을 품었는가 + + +_RUN_SCAN_RE = re.compile( + r"||<\?.*?\?>" + r"|]*>||]*>", + re.S, +) +_CHAR_PR_REF_RE = re.compile(r'\bcharPrIDRef="(\d+)"') +_LINESEG_ARRAY_STR_RE = re.compile( + r"<(?P(?:[A-Za-z_][\w.-]*:)?)linesegarray\b(?:[^>]*?/>|[^>]*>.*?)", + re.S | re.I, +) + + +def _scan_runs(xml: str) -> list[_RunScan]: + """모든 깊이의 run을 중첩을 따라가며 읽는다. + + run은 표·글상자·각주를 품을 수 있고 그 안에 다시 run이 있다. 여는 태그를 + 그 뒤 첫 ````과 짝지으면 안쪽 run의 닫는 태그에 걸려 다른 run의 + 글을 건드리게 되므로, 깊이를 세어 각 ````를 그것을 직접 가진 run에 + 붙인다. 주석·CDATA·처리 지시문 안의 닮은 글은 건너뛴다. + """ + + done: list[_RunScan] = [] + stack: list[dict[str, Any]] = [] + pos = 0 + while True: + m = _RUN_SCAN_RE.search(xml, pos) + if m is None: + break + token = m.group() + pos = m.end() + if token.startswith(""): + done.append(_RunScan(m.start(), m.end(), char_pr, (), False)) + else: + stack.append({"a": m.start(), "b": m.end(), "id": char_pr, "texts": [], "holds": False}) + elif token == "": + if stack: + top = stack.pop() + done.append(_RunScan(top["a"], top["b"], top["id"], tuple(top["texts"]), top["holds"])) + elif token.startswith(""): + continue + close = xml.find("", m.end()) + if close == -1: + break + if stack: + stack[-1]["texts"].append((m.end(), close)) + pos = close + len("") + done.sort(key=lambda run: run.open_start) + return done + + +def _own_layout_cache_spans( + xml: str, para_span: tuple[int, int], para_spans: Sequence[tuple[int, int]] +) -> list[tuple[int, int]]: + """*para_span* 문단 자신의 ``linesegarray`` 자리(안쪽 문단의 것은 뺀다).""" + + a, b = para_span + return [ + (m.start(), m.end()) + for m in _LINESEG_ARRAY_STR_RE.finditer(xml, a, b) + if _innermost_span(m.start(), para_spans) == para_span + ] + + def _preview(text: str, limit: int = 60) -> str: flat = re.sub(r"\s+", " ", text).strip() return flat[: limit - 1] + "…" if len(flat) > limit else flat @@ -268,6 +368,12 @@ def _op_delete_paragraph(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str raise ValueError(f"delete_paragraph: index {index} out of range (0..{len(spans) - 1})") a, b = spans[index] block = xml[a:b] + if _holds_section_settings(block): + raise HwpxValueError( + "delete_paragraph: paragraph holds the section settings (hp:secPr) — refuse; " + "clear its text with set_paragraph_text instead", + code="body-op-section-settings", + ) if _TBL_RE.search(block) and not op.get("allow_tables"): raise ValueError( "delete_paragraph: paragraph wraps a table — refuse (allow_tables=True to override)" @@ -291,6 +397,18 @@ def _op_insert_paragraph_by_clone(xml: str, op: Mapping[str, Any]) -> tuple[str, block = xml[a:b] if _TBL_RE.search(block): raise ValueError("insert_paragraph_by_clone: ref paragraph wraps a table — refuse") + if _holds_section_settings(block): + raise HwpxValueError( + "insert_paragraph_by_clone: ref paragraph holds the section settings (hp:secPr) — refuse; " + "a copy would give the section a second set", + code="body-op-section-settings", + ) + if texts and _holds_another_paragraph(block): + raise HwpxValueError( + "insert_paragraph_by_clone: ref paragraph holds another paragraph (text box, note) — " + "refuse to set texts; its inner text would be emptied", + code="body-op-nested-paragraph", + ) stripped = _strip_paragraph_layout_cache(block.encode("utf-8")).decode("utf-8") clones: list[str] = [] for i in range(count): @@ -325,6 +443,11 @@ def _op_reorder_paragraphs(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[s if xml[b0:a1].strip(): raise ValueError("reorder_paragraphs: non-whitespace content between paragraphs — refuse") blocks = [xml[a:b] for a, b in region] + if any(source != place and _holds_section_settings(blocks[source]) for place, source in enumerate(order)): + raise HwpxValueError( + "reorder_paragraphs: the paragraph holding the section settings (hp:secPr) would move — refuse", + code="body-op-section-settings", + ) new_region = "".join(blocks[i] for i in order) return xml[: region[0][0]] + new_region + xml[region[-1][1]:], { "start": start, "end": end, "order": order, @@ -391,11 +514,14 @@ def _op_restyle_text(xml: str, op: Mapping[str, Any], ctx: dict[str, Any]) -> tu f"restyle_text: {find!r} matched {len(hits)} time(s) inside (expected {expected})" ) remapped = [] + # 찾은 글을 **직접** 가진 run. 바로 앞의 여는 태그는 그 run이 품은 표·글상자 안 + # run의 것일 수 있다(글이 표 뒤에 올 때). + owners = [(a, b, run) for run in _scan_runs(xml) for a, b in run.texts] for pos in sorted(hits, reverse=True): - run_open = xml.rfind(" not found") - tag_end = xml.find(">", run_open) + run_open, tag_end = owner.open_start, owner.open_end - 1 open_tag = xml[run_open: tag_end + 1] m = re.search(r'charPrIDRef="(\d+)"', open_tag) if m is None: @@ -421,6 +547,12 @@ def _op_set_paragraph_text(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[s block = xml[a:b] if _TBL_RE.search(block): raise ValueError("set_paragraph_text: paragraph wraps a table — refuse") + if _holds_another_paragraph(block): + raise HwpxValueError( + "set_paragraph_text: paragraph holds another paragraph (text box, note) — refuse; " + "its inner text would be emptied", + code="body-op-nested-paragraph", + ) edited = _text_edit_for_paragraph(block.encode("utf-8"), str(op["text"])) if edited is None: raise ValueError("set_paragraph_text: paragraph has no text run to set") @@ -526,7 +658,11 @@ def strip_runs_by_color( 일괄 청소 — 안내문·범례가 셀·헤딩 표에 흩어진 red run이라 문단 op가 못 닿는 문제를 해결한다. charPrIDRef가 header에서 target textColor를 가리키는 run의 ```` 내용을 비우고, 그 run이 든 문단의 stale linesegarray를 제거한다. - 미변경 부분은 byte-identical.""" + 미변경 부분은 byte-identical. + + 대상 run이 표·글상자·각주를 품고 있으면 그 run이 **직접** 가진 글만 비운다. + 안쪽 run의 글은 안쪽 run의 색으로 따로 판정하고, 그런 run을 비운 수는 + transcript의 ``runsHoldingNestedRuns``에 싣는다.""" source_bytes = _read_source_bytes(source) import io, zipfile @@ -553,38 +689,42 @@ def strip_runs_by_color( ids.add(idm.group(1)) transcript: list[dict[str, Any]] = [] changed: set[str] = set() - total = 0 - counter = {"n": 0} - run_re = re.compile( - r"(?P]*\bcharPrIDRef=\"(\d+)\"[^>]*>)(?P.*?)(?P)", - re.S, - ) - - def _sub(m: "re.Match") -> str: - if m.group(2) not in ids: - return m.group(0) - body = re.sub(r"(]*>).*?()", r"\1\2", m.group("body"), flags=re.S) - if body != m.group("body"): - counter["n"] += 1 - return m.group("open") + body + m.group("close") for sp, xml in sections.items(): - counter["n"] = 0 - new_xml = run_re.sub(_sub, xml) - n = counter["n"] - if n == 0: + # 대상 색 run이 **직접** 가진 hp:t만 비운다. 표·글상자·각주를 품은 run은 + # 그 안에 다른 run을 담고 있고, 안쪽 run의 글은 안쪽 run의 색을 따른다. + blanked: list[tuple[int, int]] = [] + runs_blanked = 0 + runs_holding_runs = 0 + for run in _scan_runs(xml): + if run.char_pr not in ids: + continue + filled = [(a, b) for a, b in run.texts if b > a] + if not filled: + continue + blanked.extend(filled) + runs_blanked += 1 + if run.holds_runs: + runs_holding_runs += 1 + if not blanked: continue - # 편집된 문단들의 linesegarray 제거(재계산) - new_xml = re.sub( - r"().)*?]*>(?:(?!).)*?)" - r"<(?P(?:[A-Za-z_][\w.-]*:)?)linesegarray\b(?:[^>]*?/>|[^>]*>.*?)", - r"\1", new_xml, flags=re.S, - ) - sections[sp] = new_xml + # 글이 바뀐 문단 자신의 줄 배치 캐시만 지운다(안쪽·바깥 문단의 캐시는 그대로). + para_spans = _all_paragraph_spans(xml) + touched = {span for span in (_innermost_span(a, para_spans) for a, _b in blanked) if span} + edits: list[tuple[int, int]] = list(blanked) + for span in touched: + edits.extend(_own_layout_cache_spans(xml, span, para_spans)) + for a, b in sorted(set(edits), reverse=True): + xml = xml[:a] + xml[b:] + sections[sp] = xml changed.add(sp) - total += n - transcript.append({"op": "strip_runs_by_color", "sectionPath": sp, - "runsBlanked": n, "status": "would_apply" if dry_run else "applied"}) + entry: dict[str, Any] = {"op": "strip_runs_by_color", "sectionPath": sp, + "runsBlanked": runs_blanked, + "status": "would_apply" if dry_run else "applied"} + if runs_holding_runs: + # 표·글상자·각주를 품은 run: 그 run의 글만 비웠고 안쪽 글은 남겼다. + entry["runsHoldingNestedRuns"] = runs_holding_runs + transcript.append(entry) intermediate = source_bytes if changed: diff --git a/src/hwpx/oxml/_paragraph_text_edit.py b/src/hwpx/oxml/_paragraph_text_edit.py index 9d9c29b4..c9ff37bf 100644 --- a/src/hwpx/oxml/_paragraph_text_edit.py +++ b/src/hwpx/oxml/_paragraph_text_edit.py @@ -6,7 +6,14 @@ import xml.etree.ElementTree as ET from ..errors import HwpxValueError -from ._document_primitives import _HP_NS, _child_tag_like, _fill_text_element, _is_tab_control_element, _sanitize_text +from ._document_primitives import ( + _HP_NS, + _TEXT_MARK_SPLIT_RE, + _child_tag_like, + _fill_text_element, + _is_tab_control_element, + _sanitize_text, +) from .namespaces import tag_local_name @@ -61,6 +68,49 @@ def set_text_with_tabs(text_element: ET.Element, value: str) -> None: _fill_text_element(text_element, value) +def text_marks_as_elements(text_element: ET.Element) -> bool: + """Write each tab or line-break character left in the ``hp:t`` *text_element* as the element + Hancom writes for it (``hp:tab``, ``hp:lineBreak``), the way :func:`set_text_with_tabs` does. + + For edits that put characters into the text already there (find and replace, a model applied + back) instead of filling the element: the rest of the element stays as it is. Returns whether + anything changed. + """ + + tab_tag = _child_tag_like(text_element, "tab", _HP_NS) + break_tag = _child_tag_like(text_element, "lineBreak", _HP_NS) + changed = False + position = 0 # where the elements for the text in front of the next child go + for holder in (None, *list(text_element)): + if holder is not None: + position += 1 + value = (text_element.text if holder is None else holder.tail) or "" + parts = _TEXT_MARK_SPLIT_RE.split(value) + if len(parts) == 1: + continue + changed = True + if holder is None: + text_element.text = parts[0] + else: + holder.tail = parts[0] + for mark, segment in zip(parts[1::2], parts[2::2]): + element = text_element.makeelement(tab_tag if mark == "\t" else break_tag, {}) + element.tail = segment + text_element.insert(position, element) + position += 1 + return changed + + +def text_marks_as_elements_in(root: ET.Element) -> bool: + """:func:`text_marks_as_elements` for every ``hp:t`` at or under *root*.""" + + changed = False + for node in list(root.iter()): + if isinstance(node.tag, str) and tag_local_name(node.tag) == "t": + changed = text_marks_as_elements(node) or changed + return changed + + def clear_text_element(text_element: ET.Element) -> None: """Empty an ``hp:t``: its text, and the elements inside it with the text after them.""" diff --git a/src/hwpx/oxml/document_parts.py b/src/hwpx/oxml/document_parts.py index 15ecc330..8a937405 100644 --- a/src/hwpx/oxml/document_parts.py +++ b/src/hwpx/oxml/document_parts.py @@ -488,7 +488,9 @@ def from_package(cls, package: "HwpxPackage") -> "HwpxOxmlDocument": sections: list[HwpxOxmlSection] = [] for section_index, path in enumerate(section_paths): try: - sections.append(HwpxOxmlSection(path, package.get_xml(path))) + section = HwpxOxmlSection(path, package.get_xml(path)) + section._remember_source(package.read(path)) + sections.append(section) except Exception: logger.exception( "section 파싱 실패: section_index=%d, part_path=%s", diff --git a/src/hwpx/oxml/drop_cap.py b/src/hwpx/oxml/drop_cap.py index 3520f868..94e73913 100644 --- a/src/hwpx/oxml/drop_cap.py +++ b/src/hwpx/oxml/drop_cap.py @@ -57,7 +57,14 @@ from lxml import etree as LET # type: ignore[reportAttributeAccessIssue] # lxml has no complete bundled typing -from ._document_primitives import _HC, _HP, _append_child, _object_id, _paragraph_id +from ._document_primitives import ( + _HC, + _HP, + _append_child, + _clear_paragraph_layout_cache, + _object_id, + _paragraph_id, +) from .objects import HwpxOxmlInlineObject if TYPE_CHECKING: @@ -264,5 +271,8 @@ def _paragraph_add_drop_cap( run.append(drop_cap) else: run.insert(list(run).index(text), drop_cap) + # The text now wraps around the drop cap, so the paragraph's cached lines are those of the old + # layout. (The empty hp:linesegarray inside the drop cap itself is not a cache and stays.) + _clear_paragraph_layout_cache(self.element) self.section.mark_dirty() return HwpxOxmlInlineObject(drop_cap, self) diff --git a/src/hwpx/oxml/paragraph.py b/src/hwpx/oxml/paragraph.py index 54c93105..9c9e83d4 100644 --- a/src/hwpx/oxml/paragraph.py +++ b/src/hwpx/oxml/paragraph.py @@ -49,6 +49,8 @@ paragraph_container, plain_text_nodes_for_edit, remove_paragraph_element, + set_text_with_tabs, + text_marks_as_elements_in, ) from .objects import ( HwpxOxmlInlineObject, @@ -74,6 +76,18 @@ from .section import HwpxOxmlSection +def _content_and_cache(node: "LET._Element") -> tuple[bytes, bytes]: + """A serialized paragraph split into its content and its own line cache.""" + + copy = LET.fromstring(LET.tostring(node)) + cache = b"" + for child in list(copy): + if isinstance(child.tag, str) and tag_local_name(child.tag).lower() == "linesegarray": + cache += LET.tostring(child, with_tail=False) + copy.remove(child) + return LET.tostring(copy), cache + + @dataclass class HwpxOxmlParagraph: """Lightweight wrapper around an ```` element.""" @@ -103,8 +117,20 @@ def model(self) -> "body.Paragraph": return self.to_model() def apply_model(self, model: "body.Paragraph") -> None: + """Write *model* back over this paragraph. + + When the model changes the paragraph (its text, its runs, what sits in + them) and leaves the line cache as it was, the cache is dropped: Hancom + would lay the new content into the old lines. A model that changes + only the cache, or nothing, is written as given. + """ + new_node = body.serialize_paragraph(model) xml_bytes = LET.tostring(new_node) + # Compare through the same serializer, so only what the caller changed differs. + before = _content_and_cache(body.serialize_paragraph(self.to_model())) + after = _content_and_cache(new_node) + content_changed, cache_changed = before[0] != after[0], before[1] != after[1] parent = paragraph_container(self.element, self.section.element) if parent is None: parent = self.section.element @@ -112,11 +138,16 @@ def apply_model(self, model: "body.Paragraph") -> None: replacement = LET.fromstring(xml_bytes) else: replacement = ET.fromstring(xml_bytes) + text_marks_as_elements_in(replacement) # "\n" set on a model span, as hp:lineBreak paragraph_children = list(parent) index = paragraph_children.index(self.element) parent.remove(self.element) parent.insert(index, replacement) self.element = replacement + if content_changed and not cache_changed: + # The model changed the paragraph and brought its old line cache back + # unchanged: those lines are the old content's. + _clear_paragraph_layout_cache(replacement) self.section.mark_dirty() def _run_elements(self) -> list[ET.Element]: @@ -387,7 +418,8 @@ def set_text_preserving_runs(self, value: str) -> None: ) node, offset = candidates[0] current = node.text or "" - node.text = _sanitize_text(current[:prefix - offset] + replacement + current[end - offset:]) + # A tab goes in as hp:tab and a line break as hp:lineBreak, as the other text setters write them. + set_text_with_tabs(node, current[:prefix - offset] + replacement + current[end - offset:]) _clear_paragraph_layout_cache(self.element) self.section.mark_dirty() @@ -928,6 +960,7 @@ def add_title_mark(self, *, in_toc: bool) -> HwpxOxmlInlineObject: mark.tail = text_element.text text_element.text = None text_element.insert(0, mark) + _clear_paragraph_layout_cache(self.element) # the mark takes text positions the cached lines do not count self.section.mark_dirty() return HwpxOxmlInlineObject(mark, self) @@ -986,6 +1019,7 @@ def add_index_mark(self, first: str, *, second: str | None = None) -> HwpxOxmlIn second_key = _append_child(mark, _child_tag_like(target_run, "secondKey", _HP_NS), {}) second_key.text = _sanitize_text(second) target_run.insert(list(target_run).index(text_element), ctrl) + _clear_paragraph_layout_cache(self.element) # a control in front of the text moves every cached line start self.section.mark_dirty() return HwpxOxmlInlineObject(ctrl, self) diff --git a/src/hwpx/oxml/run.py b/src/hwpx/oxml/run.py index a2316e20..618d9812 100644 --- a/src/hwpx/oxml/run.py +++ b/src/hwpx/oxml/run.py @@ -17,7 +17,12 @@ _element_local_name, _text_element_content, ) -from ._paragraph_text_edit import clear_text_element, set_text_with_tabs +from ._paragraph_text_edit import ( + clear_text_element, + set_text_with_tabs, + text_marks_as_elements, + text_marks_as_elements_in, +) if TYPE_CHECKING: from .paragraph import HwpxOxmlParagraph @@ -393,6 +398,8 @@ def replace_across_runs( combined = "".join(segment.text for segment in segments) position = combined.find(search, position + len(replacement)) if total: + for run in runs: + text_marks_as_elements_in(run) # a tab or line break in the replacement, as Hancom writes it _clear_paragraph_layout_cache(paragraph.element) paragraph.section.mark_dirty() return total @@ -422,6 +429,7 @@ def apply_model(self, model: "body.Run") -> None: replacement = LET.fromstring(xml_bytes) else: replacement = ET.fromstring(xml_bytes) + text_marks_as_elements_in(replacement) # "\n" set on a model span, as hp:lineBreak run_children = list(parent) index = run_children.index(self.element) parent.remove(self.element) @@ -579,6 +587,10 @@ def replace_text( total_replacements += _replace_all_occurrences(segments, search, replacement, remaining) if total_replacements: + # A tab or line break in the replacement goes in as hp:tab / hp:lineBreak, + # as the text setters write it, not as a character inside hp:t. + for node in self.element.findall(f"{_HP}t"): + text_marks_as_elements(node) if _clear_layout: _clear_paragraph_layout_cache(self.paragraph.element) self.paragraph.section.mark_dirty() diff --git a/src/hwpx/oxml/section.py b/src/hwpx/oxml/section.py index f9af005d..fff05036 100644 --- a/src/hwpx/oxml/section.py +++ b/src/hwpx/oxml/section.py @@ -4,8 +4,12 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Iterable, Mapping, Optional, Sequence +import hashlib as _hashlib +import re as _re import xml.etree.ElementTree as ET +from lxml import etree as LET # type: ignore[reportAttributeAccessIssue] # lxml has no complete bundled typing + from ._document_primitives import ( _DEFAULT_PARAGRAPH_ATTRS, _HP, @@ -73,6 +77,10 @@ def __init__( self._document = document # the header/footer copies that agree when opened, for the save to tell which copy an edit changed self._story_marks = story_marks(element) + # the part as it was read, for the save to tell a line cache that outlived its paragraph's content + self._source_xml: bytes | None = None + self._source_caches: list[Any] | None = None + self._tracked_caches: list[tuple[Any, bytes, bytes]] | None = None def __repr__(self) -> str: """Return a compact and safe summary of section structure.""" @@ -563,9 +571,16 @@ def remove_stale_layout_caches(self) -> int: (the cache lacks lines, so Hancom would draw the tail over itself). The save path runs this on every section, so a document whose own caches look like that still saves; Hancom lays those paragraphs out again. + + In a section that was edited, a cache the part was read with goes as + well when its paragraph no longer has the content it came with, + whatever its lines look like: the edit that changed the paragraph left + it behind, and Hancom would press the new text into the old line + count. A cache on unchanged content, and one written since the part + was read, stays. """ - removed = 0 + removed = self._remove_outlived_layout_caches() squeezed = self._squeezed_paragraph_shapes() narrowed = self._narrowed_char_shapes() for paragraph in self._element.iter(): @@ -580,6 +595,40 @@ def remove_stale_layout_caches(self) -> int: self.mark_dirty() return removed + def _remove_outlived_layout_caches(self) -> int: + """Drop the caches an edit left on a paragraph whose content it changed.""" + + if not self._dirty: + return 0 + tracked = self._tracked_caches + if tracked is None: + if self._source_xml is None or self._source_caches is None: + return 0 + from ..opc.xml_utils import parse_xml + + try: + tracked = _track_caches(self._source_caches, parse_xml(self._source_xml)) + except Exception: # the part was read once already; without it nothing is judged + tracked = None + self._source_xml = self._source_caches = None + if tracked is None: + return 0 + removed, self._tracked_caches = _remove_outlived_caches(self._element, tracked) + return removed + + def _remember_source(self, source_xml: bytes) -> None: + """Note the part's bytes and its line caches as read, before anything edits the tree. + + The save uses them to tell a cache that an edit left on a paragraph whose + content it changed (see ``_remove_outlived_caches`` below). + """ + + if not hasattr(self._element, "getparent"): # lxml trees only + return + self._source_xml = source_xml + self._source_caches = _cache_elements(self._element) + self._tracked_caches = None + def remove_layout_caches(self) -> int: """Drop all paragraph layout caches from this section. @@ -728,4 +777,90 @@ def _remove_short_paragraph_layout_cache(paragraph: ET.Element, narrowed: Mappin return False +# -- line caches that outlived their paragraph's content ------------------------ +# +# Tell a line cache that outlived the paragraph content it was computed for. +# +# Hancom trusts the line count of a cached ``hp:linesegarray`` and presses the +# text into it, so a cache left on a paragraph whose content changed is worse +# than no cache. The editing APIs clear the cache of the paragraph they change; +# this is the net under them, for an API that forgets to. It needs no estimate +# of text widths: a cache is kept only on the content it came with. +# +# A section keeps hold of the cache elements its part was read with. At the +# save of an edited section each of them that is still in the tree is compared +# with the part as it was read: when the content of its paragraph differs, the +# cache goes. A cache made or rewritten since (a cleared one written again on +# purpose, a copied paragraph's) is not what the part was read with and is left +# alone. + +_CACHE_BYTES_RE = _re.compile( + rb"<(?P(?:[A-Za-z_][\w.-]*:)?)linesegarray\b(?:[^>]*?/>|[^>]*>.*?)", + _re.DOTALL | _re.IGNORECASE, +) + +#: A cache element, the digest of the content its paragraph had with it, and the cache as it was. +_TrackedCaches = list[tuple[Any, bytes, bytes]] + + +def _cache_guard_local(node: Any) -> str: + tag = node.tag + return tag.rpartition("}")[2] if isinstance(tag, str) else "" + + +def _cache_elements(root: Any) -> list[Any]: + """The line caches under *root* that sit directly in a paragraph, in document order.""" + + found = [] + for node in root.iter(): + if _cache_guard_local(node).lower() == "linesegarray": + parent = node.getparent() + if parent is not None and _cache_guard_local(parent) == "p": + found.append(node) + return found + + +def _content_digest(paragraph: Any) -> bytes: + """A digest of everything in *paragraph* but line caches (its own and those of paragraphs inside it).""" + + content = _CACHE_BYTES_RE.sub(b"", LET.tostring(paragraph, with_tail=False)) + return _hashlib.blake2b(content, digest_size=16).digest() + + +def _track_caches(caches: list[Any], source_root: Any) -> _TrackedCaches | None: + """Pair the cache elements a part was read with (*caches*) with the content each came with, + read from a fresh parse of the same part (*source_root*). ``None`` when the two do not line up.""" + + originals = _cache_elements(source_root) + if len(originals) != len(caches): + return None + return [ + (cache, _content_digest(original.getparent()), LET.tostring(original, with_tail=False)) + for cache, original in zip(caches, originals) + ] + + +def _remove_outlived_caches(root: Any, tracked: _TrackedCaches) -> tuple[int, _TrackedCaches]: + """Drop from *root* each tracked cache whose paragraph no longer has the content it came with. + + Returns how many were dropped, and what to track until the next save: every + cache now in the tree, with its paragraph's content as it is now. + """ + + known = {id(cache): (digest, was) for cache, digest, was in tracked} + removed = 0 + following: _TrackedCaches = [] + for cache in _cache_elements(root): + paragraph = cache.getparent() + digest = _content_digest(paragraph) + now = LET.tostring(cache, with_tail=False) + expected = known.get(id(cache)) + if expected is not None and expected[0] != digest and expected[1] == now: + paragraph.remove(cache) # the content changed under the cache it was read with + removed += 1 + else: + following.append((cache, digest, now)) + return removed, following + + __all__ = ["HwpxOxmlSection", "HwpxOxmlSectionHeaderFooter", "HwpxOxmlSectionProperties"] diff --git a/src/hwpx/tools/package_validator.py b/src/hwpx/tools/package_validator.py index d79077ad..b401f856 100644 --- a/src/hwpx/tools/package_validator.py +++ b/src/hwpx/tools/package_validator.py @@ -317,6 +317,10 @@ def _check_hwpml_compat_root( def _local_name(element: ET.Element) -> str: tag = element.tag + if not isinstance(tag, str): + # A comment or processing instruction: its ``tag`` is a function, not + # a name. ``""`` matches no element, so every check passes over it. + return "" if "}" in tag: return tag.rsplit("}", 1)[1] return tag @@ -574,11 +578,17 @@ def _check_table_editor_acceptance( #: 의도적으로 이 5종이 없다(DEV-016 — AbstractShapeObjectType 꼬리는 #: 그룹 자신만 갖는다) -- 422개 최상위 도형 전량 0건 결여, 179개 #: 컨테이너-내부 부재 179건 "결여"(정상, 결여가 계약)로 실측 확인했다. -#: pic/equation/ole/chart/video/audio/textart는 이 5종 계약을 공유하지 +#: pic/equation/chart/audio는 이 5종 계약을 공유하지 #: 않거나(별도 필수요소 계약) 전용 저작 경로가 이미 완전한 구조를 쓰므로 #: 범위 밖이다. +#: +#: 글맵시·OLE·동영상(2026-11 점검 ENG-23): 탈출구(``add_raw``)로 만든 빈 +#: ``hp:textart``·``hp:ole``·``hp:video``를 한/글 2024가 열다가 경고 없이 +#: 죽는다(종료 코드 0xC0000005). 한/글이 저장한 것은 모두 위 5종과 +#: ``renderingInfo``를 갖추고 있다(tests 자료의 글맵시 1·OLE 5·동영상 1개). +#: 빈 ``hp:equation``은 한/글이 정상으로 열므로 넣지 않는다. _SHAPE_OPEN_SAFETY_TAGS = frozenset( - {"line", "rect", "ellipse", "arc", "polygon", "curve", "connectLine", "container"} + {"line", "rect", "ellipse", "arc", "polygon", "curve", "connectLine", "container", "textart", "ole", "video"} ) diff --git a/tests/test_body_patch_color_runs.py b/tests/test_body_patch_color_runs.py new file mode 100644 index 00000000..65608822 --- /dev/null +++ b/tests/test_body_patch_color_runs.py @@ -0,0 +1,197 @@ +# SPDX-License-Identifier: Apache-2.0 +"""``hwpx.body_patch``의 색 기준 일괄 연산(`strip_runs_by_color`, `recolor_runs_by_color`). + +`strip_runs_by_color`는 대상 색 run이 **직접** 가진 글만 비워야 한다. 표·글상자· +각주를 품은 run은 그 안에 다른 run을 담고 있고, 안쪽 run의 글은 안쪽 run의 +색을 따른다(2026-11 점검 ENG-03: 여는 태그를 첫 ``과 짝지어 첫 칸의 +검은 글이 지워졌다). +""" + +from __future__ import annotations + +import io +import re +import zipfile +from pathlib import Path + +from hwpx.body_patch import strip_runs_by_color +from hwpx.document import HwpxDocument + +SECTION = "Contents/section0.xml" +_SEG = ( + '' +) + + +def _base() -> tuple[bytes, str]: + """빈 문서와 빨강 글자 모양 id.""" + + doc = HwpxDocument.new() + red = str(doc.styles.ensure_run(color="#FF0000")) + data = doc.to_bytes() + doc.close() + return data, red + + +def _with_body(data: bytes, body: str) -> bytes: + """구역 정의 문단은 그대로 두고 그 뒤에 *body* 문단들을 붙인다.""" + + buf = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(data)) as src, zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as dst: + for item in src.infolist(): + payload = src.read(item.filename) + if item.filename == SECTION: + xml = payload.decode("utf-8") + end = xml.rindex("") + payload = (xml[:end] + body + xml[end:]).encode("utf-8") + dst.writestr(item, payload) + return buf.getvalue() + + +def _section(data: bytes) -> str: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + return archive.read(SECTION).decode("utf-8") + + +def _p(pid: int, inner: str, *, cache: bool = True) -> str: + return ( + f'' + f"{inner}{_SEG if cache else ''}" + ) + + +def _run(char_pr: str, text: str | None) -> str: + if text is None: + return f'' + return f'{text}' + + +def _table(cell_inner: str) -> str: + return ( + '' + '' + '' + '' + '' + '' + f"{cell_inner}" + '' + '' + "" + ) + + +def _paragraph_block(xml: str, pid: int) -> str: + """id가 *pid*인 문단의 XML(안쪽 문단 포함).""" + + start = xml.index(f']|", xml[start:]): + depth += -1 if match.group().startswith(" None: + data, red = _base() + cell = _p(11, _run("0", "칸 검은 글 KEEP")) + _p(12, _run(red, "칸 빨강 글 DROP")) + holder = ( + f'표 앞 빨강 DROP{_table(cell)}' + "표 뒤 빨강 DROP" + ) + source = _with_body(data, _p(10, holder) + _p(13, _run("0", "꼬리 검은 글 KEEP"))) + + result = strip_runs_by_color(source, ["#FF0000"]) + + xml = _section(result.data) + assert result.open_safety.get("ok") is True + assert "칸 검은 글 KEEP" in xml and "꼬리 검은 글 KEEP" in xml + assert "DROP" not in xml + entry = result.transcript[0] + assert entry["status"] == "applied" + assert entry["runsBlanked"] == 2 + # 표를 품은 run은 그 run의 글만 비웠다는 사실을 결과에 싣는다. + assert entry["runsHoldingNestedRuns"] == 1 + + +def test_strip_does_not_pair_a_self_closing_target_run_with_the_next_run() -> None: + data, red = _base() + source = _with_body(data, _p(20, _run(red, None) + _run("0", "빈 빨강 run 뒤 검은 글 KEEP"))) + + result = strip_runs_by_color(source, ["#FF0000"]) + + assert result.byte_identical is True + assert result.transcript == () + assert "빈 빨강 run 뒤 검은 글 KEEP" in _section(result.data) + + +def test_strip_clears_only_the_layout_cache_of_paragraphs_whose_text_changed() -> None: + data, red = _base() + cell = _p(31, _run("0", "칸 글 KEEP")) + holder = f'{_table(cell)}표 뒤 빨강 DROP' + body = ( + _p(30, holder) + + _p(32, _run(red, "빨강 문단 DROP")) + + _p(33, _run("0", "검은 문단 KEEP")) + + _p(34, '') # 원래 비어 있던 문단 + ) + source = _with_body(data, body) + + xml = _section(strip_runs_by_color(source, ["#FF0000"]).data) + + # 글이 바뀐 문단: 캐시가 없어야 한다(표를 품은 문단은 안쪽 문단 뒤에 캐시가 온다). + holder_block = _paragraph_block(xml, 30) + cell_block = _paragraph_block(xml, 31) + assert holder_block.count(" None: + data, red = _base() + comment = f'' + source = _with_body(data, _p(40, comment + _run("0", "주석 뒤 검은 글 KEEP"))) + + result = strip_runs_by_color(source, ["#FF0000"]) + + assert result.byte_identical is True + assert "주석 뒤 검은 글 KEEP" in _section(result.data) + + +def test_strip_dry_run_reports_without_writing(tmp_path: Path) -> None: + data, red = _base() + source = _with_body(data, _p(50, _run(red, "빨강 DROP"))) + target = tmp_path / "out.hwpx" + + result = strip_runs_by_color(source, ["#FF0000"], output_path=target, dry_run=True) + + assert result.transcript[0]["status"] == "would_apply" + assert not target.exists() + assert "DROP" not in _section(result.data) + + +def test_strip_matches_the_colour_family_and_leaves_the_rest_byte_identical() -> None: + doc = HwpxDocument.new() + red = str(doc.styles.ensure_run(color="#FF0000")) + blue = str(doc.styles.ensure_run(color="#0000FF")) + data = doc.to_bytes() + doc.close() + source = _with_body(data, _p(60, _run(red, "빨강 DROP") + _run(blue, "파랑 KEEP"))) + + result = strip_runs_by_color(source, ["#FF0000"]) + + assert result.changed_parts == (SECTION,) + assert "파랑 KEEP" in _section(result.data) and "DROP" not in _section(result.data) + with zipfile.ZipFile(io.BytesIO(source)) as before, zipfile.ZipFile(io.BytesIO(result.data)) as after: + for name in before.namelist(): + if name != SECTION: + assert before.read(name) == after.read(name), name diff --git a/tests/test_body_patch_ops.py b/tests/test_body_patch_ops.py new file mode 100644 index 00000000..d15771c9 --- /dev/null +++ b/tests/test_body_patch_ops.py @@ -0,0 +1,461 @@ +# SPDX-License-Identifier: Apache-2.0 +"""``hwpx.body_patch.apply_body_ops``의 연산별 계약. + +2026-11 점검 ARC-16: `insert_paragraph_by_clone`·`reorder_paragraphs`· +`set_paragraph_text`는 어느 저장소의 테스트도 실행하지 않았고, +`delete_paragraph`·`restyle_text`·`recolor_runs_by_color`는 core 테스트에서 +0줄이었다. 사용자 문서를 바이트 수준으로 고치는 연산이므로 core에서 지킨다. +""" + +from __future__ import annotations + +import io +import re +import zipfile +from pathlib import Path + +import pytest + +from hwpx.body_patch import apply_body_ops, direct_paragraph_spans, recolor_runs_by_color +from hwpx.document import HwpxDocument +from tests.test_body_patch_color_runs import SECTION, _p, _paragraph_block, _run, _section, _table, _with_body + +HEADER = "Contents/header.xml" + + +def _doc(*texts: str) -> bytes: + """구역 정의 문단 뒤에 캐시가 있는 한 줄 문단들(id 100, 101, …).""" + + doc = HwpxDocument.new() + data = doc.to_bytes() + doc.close() + return _with_body(data, "".join(_p(100 + i, _run("0", text)) for i, text in enumerate(texts))) + + +def _texts(data: bytes) -> list[str]: + """구역 직속 문단의 글(안쪽 문단의 글 포함), 구역 정의 문단은 뺀다.""" + + xml = _section(data) + out = [] + for a, b in direct_paragraph_spans(xml)[1:]: + out.append("".join(re.findall(r"]*)?>(.*?)", xml[a:b], re.S))) + return out + + +def _header(data: bytes) -> str: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + return archive.read(HEADER).decode("utf-8") + + +def _only_section_changed(source: bytes, result_data: bytes, *also: str) -> None: + with zipfile.ZipFile(io.BytesIO(source)) as before, zipfile.ZipFile(io.BytesIO(result_data)) as after: + assert before.namelist() == after.namelist() + for name in before.namelist(): + if name not in (SECTION, *also): + assert before.read(name) == after.read(name), name + + +# -- insert_paragraph_by_clone ------------------------------------------------ + + +def test_insert_by_clone_copies_the_reference_paragraph_after_it() -> None: + source = _doc("가", "나", "다") + + result = apply_body_ops(source, [{"op": "insert_paragraph_by_clone", "ref_index": 2, "count": 2}]) + + assert result.ok + assert _texts(result.data) == ["가", "나", "나", "나", "다"] + entry = result.transcript[0] + assert (entry["status"], entry["refIndex"], entry["count"]) == ("applied", 2, 2) + _only_section_changed(source, result.data) + + +def test_insert_by_clone_sets_the_texts_and_keeps_the_reference_formatting() -> None: + doc = HwpxDocument.new() + bold = str(doc.styles.ensure_run(bold=True)) + data = doc.to_bytes() + doc.close() + source = _with_body(data, _p(100, _run(bold, "본보기")) + _p(101, _run("0", "끝"))) + + result = apply_body_ops( + source, + [{"op": "insert_paragraph_by_clone", "ref_index": 1, "count": 2, "texts": ["첫째\n줄", "둘째 & <셋째>"]}], + ) + + assert result.ok + xml = _section(result.data) + blocks = [xml[a:b] for a, b in direct_paragraph_spans(xml)[1:]] + assert len(blocks) == 4 + assert "본보기" in blocks[0] + assert "첫째줄" in blocks[1] + assert "둘째 & <셋째>" in blocks[2] + for clone in blocks[1:3]: + assert f'charPrIDRef="{bold}"' in clone + assert 'paraPrIDRef="0"' in clone + + +def test_insert_by_clone_gives_each_copy_its_own_id_and_no_line_cache() -> None: + source = _doc("가", "나") + + result = apply_body_ops(source, [{"op": "insert_paragraph_by_clone", "ref_index": 1, "count": 3}]) + + xml = _section(result.data) + blocks = [xml[a:b] for a, b in direct_paragraph_spans(xml)[1:]] + ids = [re.match(r'= 1"), + ({"ref_index": 1, "count": 2, "texts": ["하나"]}, "len(texts) must equal count"), + ({"ref_index": 0}, "section settings"), + ], +) +def test_insert_by_clone_refuses_what_it_cannot_do_safely(op: dict, reason: str) -> None: + source = _doc("가", "나") + + result = apply_body_ops(source, [{"op": "insert_paragraph_by_clone", **op}]) + + assert not result.ok + assert result.byte_identical + assert reason in result.skipped[0]["status"] + + +def test_insert_by_clone_refuses_a_reference_paragraph_that_holds_a_table() -> None: + doc = HwpxDocument.new() + data = doc.to_bytes() + doc.close() + source = _with_body(data, _p(100, f'{_table(_p(101, _run("0", "칸")))}')) + + result = apply_body_ops(source, [{"op": "insert_paragraph_by_clone", "ref_index": 1}]) + + assert not result.ok and result.byte_identical + assert "wraps a table" in result.skipped[0]["status"] + + +# -- reorder_paragraphs ------------------------------------------------------- + + +def test_reorder_moves_the_paragraph_blocks_without_changing_them() -> None: + source = _doc("가", "나", "다", "라") + before = _section(source) + blocks = [_paragraph_block(before, pid) for pid in (100, 101, 102, 103)] + + result = apply_body_ops(source, [{"op": "reorder_paragraphs", "start": 1, "end": 3, "order": [2, 0, 1]}]) + + assert result.ok + assert _texts(result.data) == ["다", "가", "나", "라"] + after = _section(result.data) + assert after == before.replace("".join(blocks[:3]), blocks[2] + blocks[0] + blocks[1]) + _only_section_changed(source, result.data) + + +@pytest.mark.parametrize( + ("op", "reason"), + [ + ({"start": 1, "end": 9, "order": [0]}, "out of bounds"), + ({"start": 3, "end": 1, "order": [0]}, "out of bounds"), + ({"start": 1, "end": 2, "order": [0, 0]}, "permutation"), + ({"start": 1, "end": 2, "order": [0, 1, 2]}, "permutation"), + ({"start": 0, "end": 1, "order": [1, 0]}, "section settings"), + ], +) +def test_reorder_refuses_a_bad_range_or_order(op: dict, reason: str) -> None: + source = _doc("가", "나", "다") + + result = apply_body_ops(source, [{"op": "reorder_paragraphs", **op}]) + + assert not result.ok and result.byte_identical + assert reason in result.skipped[0]["status"] + + +def test_reorder_keeps_the_section_settings_paragraph_in_place_when_it_does_not_move() -> None: + source = _doc("가", "나") + + result = apply_body_ops(source, [{"op": "reorder_paragraphs", "start": 0, "end": 2, "order": [0, 2, 1]}]) + + assert result.ok + assert _texts(result.data) == ["나", "가"] + + +# -- set_paragraph_text ------------------------------------------------------- + + +def test_set_paragraph_text_replaces_the_text_and_drops_the_line_cache() -> None: + source = _doc("가", "나", "다") + + result = apply_body_ops(source, [{"op": "set_paragraph_text", "index": 2, "text": "새 글\t탭 & 줄\n바꿈"}]) + + assert result.ok + xml = _section(result.data) + block = _paragraph_block(xml, 101) + assert "새 글탭 & 줄바꿈" in block + assert " None: + doc = HwpxDocument.new() + bold = str(doc.styles.ensure_run(bold=True)) + data = doc.to_bytes() + doc.close() + source = _with_body(data, _p(100, _run(bold, "굵은") + _run("0", " 보통"))) + + result = apply_body_ops(source, [{"op": "set_paragraph_text", "index": 1, "text": "새 글"}]) + + block = _paragraph_block(_section(result.data), 100) + assert f'새 글' in block + assert '' in block + + +def test_set_paragraph_text_fills_a_paragraph_whose_run_is_empty() -> None: + doc = HwpxDocument.new() + data = doc.to_bytes() + doc.close() + source = _with_body(data, _p(100, _run("0", None))) + + result = apply_body_ops(source, [{"op": "set_paragraph_text", "index": 1, "text": "채움"}]) + + assert result.ok + assert '채움' in _section(result.data) + + +def test_set_paragraph_text_refuses_a_paragraph_holding_a_table_or_a_text_box() -> None: + doc = HwpxDocument.new() + doc.add_paragraph("각주가 있는 문단").add_footnote("각주 글 KEEP") + data = doc.to_bytes() + doc.close() + source = _with_body(data, _p(100, f'{_table(_p(101, _run("0", "칸 KEEP")))}')) + + result = apply_body_ops( + source, + [ + {"op": "set_paragraph_text", "index": 1, "text": "덮어씀"}, # 각주를 품은 문단 + {"op": "set_paragraph_text", "index": 2, "text": "덮어씀"}, # 표를 품은 문단 + {"op": "set_paragraph_text", "index": 9, "text": "없음"}, + ], + ) + + assert result.byte_identical + reasons = [entry["status"] for entry in result.skipped] + assert "holds another paragraph" in reasons[0] + assert "wraps a table" in reasons[1] + assert "out of range" in reasons[2] + assert "각주 글 KEEP" in _section(result.data) and "칸 KEEP" in _section(result.data) + + +# -- delete_paragraph --------------------------------------------------------- + + +def test_delete_paragraph_removes_that_block_only() -> None: + source = _doc("가", "나", "다") + before = _section(source) + + result = apply_body_ops(source, [{"op": "delete_paragraph", "index": 2}]) + + assert result.ok + assert _section(result.data) == before.replace(_paragraph_block(before, 101), "") + assert result.transcript[0]["old"] == "나" + _only_section_changed(source, result.data) + + +def test_delete_paragraph_refuses_a_table_unless_allowed_and_never_the_section_settings() -> None: + doc = HwpxDocument.new() + data = doc.to_bytes() + doc.close() + source = _with_body( + data, _p(100, f'{_table(_p(101, _run("0", "칸")))}') + _p(102, _run("0", "끝")) + ) + + refused = apply_body_ops( + source, + [ + {"op": "delete_paragraph", "index": 1}, + {"op": "delete_paragraph", "index": 0}, + {"op": "delete_paragraph", "index": 0, "allow_tables": True}, + {"op": "delete_paragraph", "index": 7}, + ], + ) + assert refused.byte_identical + reasons = [entry["status"] for entry in refused.skipped] + assert "wraps a table" in reasons[0] + assert "section settings" in reasons[1] and "section settings" in reasons[2] + assert "out of range" in reasons[3] + + allowed = apply_body_ops(source, [{"op": "delete_paragraph", "index": 1, "allow_tables": True}]) + assert allowed.ok + assert _texts(allowed.data) == ["끝"] + + +# -- restyle_text ------------------------------------------------------------- + + +def _blue_italic_doc() -> tuple[bytes, str]: + doc = HwpxDocument.new() + blue = str(doc.styles.ensure_run(color="#0000FF", italic=True)) + data = doc.to_bytes() + doc.close() + return data, blue + + +def test_restyle_text_points_the_run_at_a_new_shape_and_leaves_the_old_one() -> None: + data, blue = _blue_italic_doc() + source = _with_body(data, _p(100, _run(blue, "채운 글")) + _p(101, _run(blue, "안내 글"))) + header_before = _header(source) + + result = apply_body_ops(source, [{"op": "restyle_text", "find": "채운", "text_color": "#000000"}]) + + assert result.ok + assert result.changed_parts == (SECTION,) + xml = _section(result.data) + new_id = re.search(r'채운 글', xml).group(1) + assert new_id != blue + assert f'안내 글' in xml + header = _header(result.data) + assert header.count("]*\bitemCnt="(\d+)"', text).group(1)) # noqa: E731 + assert count(header) == count(header_before) + 1 + shape = re.search(rf']*\bid="{new_id}".*?', header, re.S).group(0) + assert 'textColor="#000000"' in shape and "]*\bid="{blue}".*?', header, re.S).group(0) + assert 'textColor="#0000FF"' in original and " None: + data, blue = _blue_italic_doc() + source = _with_body(data, _p(100, _run(blue, "하나")) + _p(101, _run(blue, "둘"))) + + result = apply_body_ops( + source, + [ + {"op": "restyle_text", "find": "하나", "text_color": "#000000"}, + {"op": "restyle_text", "find": "둘", "text_color": "#000000"}, + ], + ) + + assert result.ok + ids = re.findall(r'(?:하나|둘)', _section(result.data)) + assert len(ids) == 2 and ids[0] == ids[1] != blue + assert _header(result.data).count(" None: + """찾은 글이 표 뒤에 있으면 바로 앞의 여는 태그는 칸 안 run의 것이다.""" + + data, blue = _blue_italic_doc() + holder = f'{_table(_p(101, _run("0", "칸 글")))}표 뒤 채운 글' + source = _with_body(data, _p(100, holder)) + + result = apply_body_ops(source, [{"op": "restyle_text", "find": "표 뒤 채운", "text_color": "#000000"}]) + + assert result.ok + xml = _section(result.data) + assert '칸 글' in xml # 칸 안 run은 그대로 + assert f'' not in xml + + +def test_restyle_text_refuses_a_count_mismatch() -> None: + data, blue = _blue_italic_doc() + source = _with_body(data, _p(100, _run(blue, "같은 글")) + _p(101, _run(blue, "같은 글"))) + + result = apply_body_ops(source, [{"op": "restyle_text", "find": "같은 글"}]) + + assert not result.ok and result.byte_identical + assert "matched 2 time(s)" in result.skipped[0]["status"] + + +# -- recolor_runs_by_color ---------------------------------------------------- + + +def test_recolor_remaps_runs_of_the_exact_colour_only() -> None: + doc = HwpxDocument.new() + blue = str(doc.styles.ensure_run(color="#0000FF", bold=True)) + navy = str(doc.styles.ensure_run(color="#0000CC")) + data = doc.to_bytes() + doc.close() + cell = _p(101, _run(blue, "칸 파랑")) + source = _with_body( + data, + _p(100, f'{_table(cell)}표 뒤 파랑') + + _p(102, _run(navy, "남색") + _run("0", "검정") + f''), + ) + + result = recolor_runs_by_color(source, ["#0000ff"], "#000000") + + assert result.ok + xml = _section(result.data) + assert f'charPrIDRef="{blue}"' not in xml + assert f'남색' in xml and '검정' in xml + new_id = re.search(r'칸 파랑', xml).group(1) + shape = re.search(rf']*\bid="{new_id}".*?', _header(result.data), re.S).group(0) + assert 'textColor="#000000"' in shape and " None: + doc = HwpxDocument.new() + blue = str(doc.styles.ensure_run(color="#0000FF")) + data = doc.to_bytes() + doc.close() + source = _with_body(data, _p(100, _run(blue, "파랑"))) + + nothing = recolor_runs_by_color(source, ["#00FF00"], "#000000") + assert nothing.byte_identical and nothing.transcript == () + + target = tmp_path / "out.hwpx" + dry = recolor_runs_by_color(source, ["#0000FF"], "#000000", output_path=target, dry_run=True) + assert dry.transcript[0]["status"] == "would_apply" + assert not target.exists() + written = recolor_runs_by_color(source, ["#0000FF"], "#000000", output_path=target) + assert target.read_bytes() == written.data + + +# -- 공통 계약 ---------------------------------------------------------------- + + +def test_ops_run_in_order_against_the_current_state_and_a_refusal_skips_only_that_op() -> None: + source = _doc("가", "나", "다") + + result = apply_body_ops( + source, + [ + {"op": "delete_paragraph", "index": 1}, # 가 삭제 → 나, 다 + {"op": "no_such_op"}, + {"op": "set_paragraph_text", "index": 1, "text": "나2"}, # 지금의 1번은 '나' + {"op": "replace_text", "find": "다", "replace": "다2", "section_path": "Contents/section9.xml"}, + ], + ) + + assert not result.ok # 거부된 op가 있으면 ok가 아니다 + assert _texts(result.data) == ["나2", "다"] + assert [entry["status"].split(":")[0] for entry in result.transcript] == ["applied", "refused", "applied", "refused"] + assert [entry["op"] for entry in result.skipped] == ["no_such_op", "replace_text"] + assert result.to_dict()["changedParts"] == [SECTION] + + +def test_dry_run_resolves_everything_and_writes_nothing(tmp_path: Path) -> None: + source = _doc("가", "나") + target = tmp_path / "out.hwpx" + + dry = apply_body_ops(source, [{"op": "set_paragraph_text", "index": 1, "text": "새"}], output_path=target, dry_run=True) + assert dry.transcript[0]["status"] == "would_apply" + assert not target.exists() + + real = apply_body_ops(source, [{"op": "set_paragraph_text", "index": 1, "text": "새"}], output_path=target) + assert target.read_bytes() == real.data == dry.data + assert real.as_mutation_report(source=source).to_dict()["schemaVersion"].startswith("hwpx.mutation-report") diff --git a/tests/test_edit_clears_line_cache.py b/tests/test_edit_clears_line_cache.py new file mode 100644 index 00000000..e4d4d990 --- /dev/null +++ b/tests/test_edit_clears_line_cache.py @@ -0,0 +1,199 @@ +"""An edit drops the line cache of the paragraph it changed, and the save drops +a cache an edit left on content it changed. + +Hancom trusts the line count of a cached ``hp:linesegarray`` and presses the +text into it, so a cache must not outlive the content it was computed for +(2026-11 audit ENG-06: ``paragraph.apply_model``, ``add_index_mark``, +``add_title_mark`` and ``add_drop_cap`` kept it, and the save-time sweep only +caught a cache short by more than a line's worth of text). +""" + +from __future__ import annotations + +import io +import zipfile + +import pytest + +from hwpx import HwpxDocument +from hwpx.tools.package_validator import validate_editor_open_safety + +HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" +SECTION = "Contents/section0.xml" +# 10 pt text on a 42520-wide line: 42 Hangul letters fill it. +FITS = "가" * 30 # 0.7 of the line +OVER = "나" * 50 # about 1.2 lines: more than the cached line, less than the sweep's 2x proof +OTHER = "다른 문단" +ONE_LINE = ( + '' +) + + +def _with_one_line_caches(data: bytes, texts: tuple[str, ...]) -> bytes: + """Give the paragraph holding each of *texts* a one-line cache, as a file could carry.""" + + out = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(data)) as source, zipfile.ZipFile(out, "w") as target: + for info in source.infolist(): + payload = source.read(info.filename) + if info.filename == SECTION: + xml = payload.decode("utf-8") + for text in texts: + marker = f"{text}" + assert xml.count(marker) == 1, text + xml = xml.replace(marker, marker + ONE_LINE) + payload = xml.encode("utf-8") + method = zipfile.ZIP_STORED if info.filename == "mimetype" else zipfile.ZIP_DEFLATED + target.writestr(zipfile.ZipInfo(info.filename, info.date_time), payload, compress_type=method) + return out.getvalue() + + +def _source(*texts: str) -> bytes: + """A document whose paragraphs *texts* each carry a one-line cache.""" + + doc = HwpxDocument.new() + for text in texts: + doc.add_paragraph(text) + return _with_one_line_caches(doc.to_bytes(), texts) + + +def _section(data: bytes) -> bytes: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + return archive.read(SECTION) + + +def _cached(data: bytes) -> dict[str, bool]: + """Whether each top-level paragraph, by its first letters, still has a cache.""" + + from lxml import etree + + root = etree.fromstring(_section(data)) + return { + "".join("".join(text.itertext()) for text in paragraph.findall(f"{HP}run/{HP}t"))[:5]: + paragraph.find(f"{HP}linesegarray") is not None + for paragraph in root.findall(f"{HP}p") + } + + +def _model_text(paragraph) -> None: + model = paragraph.model + model.runs[0].text_spans[0].text = FITS + "라" + paragraph.apply_model(model) + + +@pytest.mark.parametrize( + "edit", + [ + _model_text, + lambda paragraph: paragraph.add_index_mark("색인"), + lambda paragraph: paragraph.add_title_mark(in_toc=True), + lambda paragraph: paragraph.add_drop_cap("가", width=2000, height=2000), + ], + ids=["apply_model", "add_index_mark", "add_title_mark", "add_drop_cap"], +) +def test_the_edit_drops_the_cache_of_its_paragraph_only(edit) -> None: + doc = HwpxDocument.open(_source(FITS, OTHER)) + paragraph = doc.paragraphs[1] + edit(paragraph) + + # at the edit, not only at the save + assert doc.paragraphs[1].element.find(f"{HP}linesegarray") is None + saved = doc.to_bytes() + cached = _cached(saved) + assert cached[FITS[:5]] is False + assert cached[OTHER[:5]] is True + assert validate_editor_open_safety(saved).ok + + +def test_apply_model_keeps_the_cache_when_the_model_changed_nothing() -> None: + doc = HwpxDocument.open(_source(FITS, OTHER)) + paragraph = doc.paragraphs[1] + paragraph.apply_model(paragraph.model) + + # A model may be used to edit the cache itself (tests/test_coverage_promotion.py), so + # apply_model drops it only when the content changed under an unchanged cache. + assert paragraph.element.find(f"{HP}linesegarray") is not None + + +def test_a_drop_cap_keeps_its_own_empty_line_array() -> None: + doc = HwpxDocument.open(_source(FITS)) + doc.paragraphs[1].add_drop_cap("가", width=2000, height=2000) + + # Hancom writes an empty hp:linesegarray inside a drop cap; it is not a cache. + assert _section(doc.to_bytes()).count(b"") == 1 + + +def _grow_behind_the_api(doc: HwpxDocument, index: int, extra: str) -> None: + """Change a paragraph's text without clearing its cache, as an API that forgot to would.""" + + paragraph = doc.paragraphs[index] + text = paragraph.element.find(f"{HP}run/{HP}t") + text.text = (text.text or "") + extra + paragraph.section.mark_dirty() + + +def test_an_unedited_section_is_saved_as_it_was() -> None: + source = _source(OVER, FITS) + saved = HwpxDocument.open(source).to_bytes() + + assert _section(saved) == _section(source) + + +def test_an_edited_section_keeps_the_caches_of_the_paragraphs_left_alone() -> None: + doc = HwpxDocument.open(_source(OVER, FITS, OTHER)) + doc.paragraphs[3].text = "다른 문단을 고쳤다" + + cached = _cached(doc.to_bytes()) + assert cached[OVER[:5]] is True # as the file had it: not judged by an estimate of its width + assert cached[FITS[:5]] is True + assert cached["다른 문단"] is False + + +@pytest.mark.parametrize("extra", ["마", "마" * 20], ids=["one-letter", "a-line-more"]) +def test_text_changed_behind_the_api_does_not_keep_its_cache(extra: str) -> None: + doc = HwpxDocument.open(_source(FITS, OTHER)) + _grow_behind_the_api(doc, 1, extra) + + saved = doc.to_bytes() + cached = _cached(saved) + assert cached[FITS[:5]] is False + assert cached[OTHER[:5]] is True + assert validate_editor_open_safety(saved).ok + + +def test_the_next_save_judges_against_the_document_as_last_saved() -> None: + doc = HwpxDocument.open(_source(FITS, OTHER)) + doc.add_paragraph("새 문단") + first = _cached(doc.to_bytes()) + assert first[FITS[:5]] is True and first[OTHER[:5]] is True + + _grow_behind_the_api(doc, 2, " 더 길게") + second = _cached(doc.to_bytes()) + assert second[FITS[:5]] is True + assert second[OTHER[:5]] is False + + +def test_a_cache_written_after_the_edit_stays() -> None: + doc = HwpxDocument.open(_source(FITS, OTHER)) + _grow_behind_the_api(doc, 1, "마") + seg = doc.paragraphs[1].element.find(f"{HP}linesegarray/{HP}lineseg") + seg.set("horzsize", "42000") # a cache computed for the new text, not the one the file carried + + assert _cached(doc.to_bytes())[FITS[:5]] is True + + +def test_a_cell_edit_drops_the_cache_left_on_the_cell_paragraph() -> None: + doc = HwpxDocument.new() + doc.add_table(1, 1).cell(0, 0).text = FITS + doc.add_paragraph(OTHER) + doc = HwpxDocument.open(_with_one_line_caches(doc.to_bytes(), (FITS, OTHER))) + cell = doc.tables.all[0].cell(0, 0) + text = next(cell.element.iter(f"{HP}t")) + text.text = FITS + "마" + doc.sections[0].mark_dirty() + + xml = _section(doc.to_bytes()).decode("utf-8") + cell_paragraph = xml[xml.index("")] + assert "linesegarray" not in cell_paragraph + assert _cached(doc.to_bytes())[OTHER[:5]] is True diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index cec12888..65a8bc6e 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -1665,6 +1665,13 @@ def with_switch(branches: tuple[tuple[str, str, str], ...]) -> bytes: holders.append(etree.SubElement(switch, f"{HP}{branch}", attributes)) obj = etree.Element(f"{HP}{name}", {"textWrap": "SQUARE"}) holders[-1].insert(0, obj) + if name == "ole": + # the children Hancom writes on every OLE object; the save refuses one without them + for child in ("offset", "orgSz", "curSz"): + etree.SubElement(obj, f"{HP}{child}") + rendering = etree.SubElement(obj, f"{HP}renderingInfo") + for matrix in ("transMatrix", "scaMatrix", "rotMatrix"): + etree.SubElement(rendering, f"{{http://www.hancom.co.kr/hwpml/2011/core}}{matrix}") etree.SubElement(obj, f"{HP}sz", {"width": width, "height": "9000"}) etree.SubElement(obj, f"{HP}pos", {"treatAsChar": "0", "vertRelTo": "PARA", "horzRelTo": "COLUMN", "vertAlign": "TOP", "horzAlign": "LEFT"}) diff --git a/tests/test_line_break_writing.py b/tests/test_line_break_writing.py index a5c980bf..4a830014 100644 --- a/tests/test_line_break_writing.py +++ b/tests/test_line_break_writing.py @@ -129,3 +129,86 @@ def test_add_run_special_spaces_beside_tabs_and_line_breaks(expand: bool, xml: s assert xml in _text_nodes(data) assert HwpxDocument.open(data).paragraphs[1].text == "가 나\t다 라\n마" + + +# Find-and-replace and partial edits write the same elements as the setters +# (2026-11 audit ENG-05: they left "\n" as a character inside hp:t, and +# set_text_preserving_runs dropped the tab). +_VALUE = "가\n나\t다" +_WRITTEN = "가나다" + + +def _replaced(edit) -> bytes: + doc = HwpxDocument.new() + doc.add_paragraph("앞 원래 뒤") + edit(doc) + return doc.to_bytes() + + +def _model_text(doc: HwpxDocument) -> None: + paragraph = doc.paragraphs[1] + model = paragraph.to_model() + model.runs[0].text_spans[0].text = f"앞 {_VALUE} 뒤" + paragraph.apply_model(model) + + +def _run_model_text(doc: HwpxDocument) -> None: + run = doc.paragraphs[1].runs[0] + model = run.to_model() + model.text_spans[0].text = f"앞 {_VALUE} 뒤" + run.apply_model(model) + + +@pytest.mark.parametrize( + "edit", + [ + lambda doc: doc.paragraphs[1].runs[0].replace_text("원래", _VALUE), + lambda doc: doc.text.replace("원래", _VALUE), + lambda doc: doc.text.replace("원래", _VALUE, everywhere=True), + lambda doc: doc.replace_text_in_runs("원래", _VALUE), + lambda doc: doc.paragraphs[1].set_text_preserving_runs(f"앞 {_VALUE} 뒤"), + _model_text, + _run_model_text, + ], + ids=[ + "run.replace_text", + "text.replace", + "text.replace-everywhere", + "replace_text_in_runs", + "set_text_preserving_runs", + "paragraph.apply_model", + "run.apply_model", + ], +) +def test_replace_and_partial_edits_write_breaks_and_tabs_as_elements(edit, recwarn) -> None: + data = _replaced(edit) + + _assert_no_raw_break(data) + assert not [node for node in _text_nodes(data) if "\t" in node] + assert f"앞 {_WRITTEN} 뒤" in _text_nodes(data) + assert HwpxDocument.open(data).paragraphs[1].text == f"앞 {_VALUE} 뒤" + + +def test_replace_keeps_the_marks_around_the_match_in_place() -> None: + doc = HwpxDocument.new() + paragraph = doc.add_paragraph("") + paragraph.add_run("하나\t둘 원래 셋\n넷") + + assert paragraph.runs[-1].replace_text("원래", "A\r\nB") == 1 + data = doc.to_bytes() + + assert "하나둘 AB 셋넷" in _text_nodes(data) + + +def test_replace_across_runs_writes_a_line_break_where_the_match_ended() -> None: + doc = HwpxDocument.new() + paragraph = doc.add_paragraph("") + paragraph.add_run("앞 원") + bold = doc.styles.ensure_run(bold=True) + paragraph.add_run("래 뒤", char_pr_id_ref=bold) + + assert doc.text.replace("원래", "가\n나다", everywhere=True) == 1 + data = doc.to_bytes() + + _assert_no_raw_break(data) + assert HwpxDocument.open(data).paragraphs[1].text == "앞 가\n나다 뒤" diff --git a/tests/test_save_with_comment_nodes.py b/tests/test_save_with_comment_nodes.py new file mode 100644 index 00000000..23537d7c --- /dev/null +++ b/tests/test_save_with_comment_nodes.py @@ -0,0 +1,122 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A document carrying an XML comment or processing instruction still saves. + +2026-11 audit ENG-04: such a document opened and passed the open-safety check, +but every save -- one with no edit too -- ended in ``TypeError: argument of +type 'cython_function_or_method' is not iterable`` when the node sat in +``header.xml`` or in a paragraph that has a line cache. The save-time checks +read a comment's ``tag`` (a function, not a string) as an element name. +""" + +from __future__ import annotations + +import io +import zipfile +from typing import Callable + +import pytest +from lxml import etree + +from hwpx import HwpxDocument +from hwpx.tools.document_merge import append_document +from hwpx.tools.package_validator import validate_editor_open_safety, validate_package + +HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" +HH = "{http://www.hancom.co.kr/hwpml/2011/head}" +SECTION = "Contents/section0.xml" +HEADER = "Contents/header.xml" +TEXT = "줄 배치 캐시가 있는 문단" +ONE_LINE = ( + '' +) + + +def _comment() -> etree._Comment: + return etree.Comment("c") + + +def _pi() -> etree._ProcessingInstruction: + return etree.ProcessingInstruction("x", "y") + + +def _cached(tree: etree._Element) -> etree._Element: + return next(p for p in tree.iter(f"{HP}p") if p.find(f"{HP}linesegarray") is not None) + + +CASES: dict[str, tuple[str, Callable[[etree._Element], None]]] = { + "comment in a run of a cached paragraph": (SECTION, lambda t: _cached(t).find(f"{HP}run").append(_comment())), + "comment under a cached paragraph": (SECTION, lambda t: _cached(t).insert(0, _comment())), + "PI in a run of a cached paragraph": (SECTION, lambda t: _cached(t).find(f"{HP}run").append(_pi())), + "comment between paragraphs": (SECTION, lambda t: _cached(t).addprevious(_comment())), + "comment between cells": (SECTION, lambda t: t.find(f".//{HP}tr").insert(0, _comment())), + "comment between rows": (SECTION, lambda t: t.find(f".//{HP}tr").addprevious(_comment())), + "comment in a cell": (SECTION, lambda t: t.find(f".//{HP}tc/{HP}subList").insert(0, _comment())), + "comment in hp:t": (SECTION, lambda t: _cached(t).find(f".//{HP}t").append(_comment())), + "comment under hh:refList": (HEADER, lambda t: t.find(f"{HH}refList").append(_comment())), + "comment in hh:paraPr": (HEADER, lambda t: t.find(f".//{HH}paraPr").append(_comment())), + "comment in hh:charPr": (HEADER, lambda t: t.find(f".//{HH}charPr").append(_comment())), + "PI under hh:head": (HEADER, lambda t: t.append(_pi())), +} + + +def _base() -> bytes: + doc = HwpxDocument.new() + doc.add_paragraph(TEXT) + doc.add_table(2, 2).cell(0, 0).text = "칸" + return doc.to_bytes() + + +def _source(part: str, mutate: Callable[[etree._Element], None]) -> bytes: + out = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(_base())) as source, zipfile.ZipFile(out, "w") as target: + for info in source.infolist(): + payload = source.read(info.filename) + if info.filename == SECTION: + marker = f"{TEXT}".encode() + assert payload.count(marker) == 1 + payload = payload.replace(marker, marker + ONE_LINE.encode()) + if info.filename == part: + tree = etree.fromstring(payload) + mutate(tree) + payload = etree.tostring(tree, xml_declaration=True, encoding="UTF-8", standalone=True) + assert b"" in payload or b"" in payload + method = zipfile.ZIP_STORED if info.filename == "mimetype" else zipfile.ZIP_DEFLATED + target.writestr(zipfile.ZipInfo(info.filename, info.date_time), payload, compress_type=method) + return out.getvalue() + + +@pytest.mark.parametrize("case", list(CASES)) +def test_a_document_with_a_comment_or_pi_saves_unchanged_and_after_an_edit(case: str) -> None: + source = _source(*CASES[case]) + assert validate_editor_open_safety(source).ok + + unchanged = HwpxDocument.open(source).to_bytes() + # nothing was edited, so the part and its comment are as they were + part = CASES[case][0] + with zipfile.ZipFile(io.BytesIO(source)) as before, zipfile.ZipFile(io.BytesIO(unchanged)) as after: + assert before.read(part) == after.read(part) + + doc = HwpxDocument.open(source) + doc.add_paragraph("더한 문단") + edited = doc.to_bytes() + assert validate_editor_open_safety(edited).ok + assert "더한 문단" in HwpxDocument.open(edited).paragraphs[-1].text + + +@pytest.mark.parametrize("case", list(CASES)) +def test_the_validators_read_a_document_with_a_comment_or_pi(case: str) -> None: + source = _source(*CASES[case]) + + assert validate_package(source).ok + assert validate_editor_open_safety(source).ok + + +def test_appending_a_document_with_comments_to_another() -> None: + target = HwpxDocument.new() + target.add_paragraph("받는 문서") + source = HwpxDocument.open(_source(*CASES["comment under hh:refList"])) + + append_document(target, source) + + assert TEXT in "\n".join(paragraph.text for paragraph in HwpxDocument.open(target.to_bytes()).paragraphs) diff --git a/tests/test_shape_open_safety_risk_check.py b/tests/test_shape_open_safety_risk_check.py index 7ff5481c..4808e321 100644 --- a/tests/test_shape_open_safety_risk_check.py +++ b/tests/test_shape_open_safety_risk_check.py @@ -162,3 +162,52 @@ def test_real_corpus_produces_zero_shape_open_safety_risk_warnings() -> None: offenders.append(f"{sample.name}: {[str(i) for i in risky]}") assert offenders == [], offenders + + +# 2026-11 audit ENG-23: an empty 글맵시, OLE object or video made through the +# escape hatch passed the save gate, and Hancom (desktop 2024) dies opening +# the file -- exit code 0xC0000005, no warning. An empty equation opens fine. +@pytest.mark.parametrize("kind", ["textart", "ole", "video"]) +def test_an_empty_text_art_ole_or_video_is_refused_on_save(kind: str) -> None: + document = HwpxDocument.new() + with pytest.warns(UserWarning): + document.shapes.add_raw(kind) + with pytest.raises(HwpxPackageError, match=f"hp:{kind} missing"): + document.to_bytes() + + safety = validate_editor_open_safety(_bytes_without_open_safety_gate(document)) + + assert not safety.ok + assert any(f"hp:{kind} missing offset" in str(issue) for issue in safety.blocking_package_errors) + + +def test_an_empty_equation_still_saves() -> None: + document = HwpxDocument.new() + document.shapes.add_raw("equation") + + assert validate_editor_open_safety(document.to_bytes()).ok + + +@pytest.mark.parametrize( + ("fixture", "tag"), + [ + ("reader_writer__SimpleTextArt.hwpx", b" None: + import io + import zipfile + + path = next(Path(__file__).parent.glob(f"fixtures/**/{fixture}")) + assert validate_editor_open_safety(path.read_bytes()).ok + + document = HwpxDocument.open(path) + document.add_paragraph("고친 문단") # the section is written again, through the gate + saved = document.to_bytes() + + assert validate_editor_open_safety(saved).ok + with zipfile.ZipFile(io.BytesIO(saved)) as archive: + assert any(tag in archive.read(name) for name in archive.namelist() if "section" in name)