diff --git a/CHANGELOG.md b/CHANGELOG.md index 374133d6..d4fc900e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,6 +135,9 @@ - `doc.styles.replace_font`가 바뀐 글꼴을 쓰는 문단의 줄 배치 정보(`hp:linesegarray`)를 지운다. 한/글은 남은 정보의 줄 나눔을 그대로 따르므로, 전에는 새 글꼴이 옛 글꼴의 줄 폭에 맞춰 눌리거나 겹쳐 보일 수 있었다. `doc.parts.set_paragraph_auto_spacing`도 값이 바뀌면 그 문단 모양을 쓰는 문단의 줄 배치 정보를 지운다. +- `apply_body_ops`의 `replace_text`가 표를 담은 문단과 그 표의 칸 문단에서 함께 찾은 글을 바꿀 때 XML을 깨뜨려 + 저장 검사에서 `ValueError: malformed XML`로 멈추던 것을 고친다. 두 문단의 편집 구간이 겹쳐 있었다. 이제 바깥 문단 + 하나로 묶어 그 안의 치환과 칸의 줄 나눔(SQUEEZE→BREAK) 편집을 한 번에 적용한다. - FormFit과 쪽 수 추정이 한양 글꼴(한양신명조·한양중고딕·한양견고딕)의 굵은 글자를 em의 1/20만큼 넓게 잡는다. 한/글은 그 폭을 더한 뒤 배치 단위로 반올림한다(14pt "가"가 1,400이 아니라 1,472). 전에는 굵은 글자도 보통 폭으로 셌다. 다른 글꼴의 굵은 글자는 전처럼 보통 폭이다. `TextStyle.bold`가 글자 모양의 굵게를 담는다. diff --git a/src/hwpx/body_patch.py b/src/hwpx/body_patch.py index 03bc7b91..1dd28fa9 100644 --- a/src/hwpx/body_patch.py +++ b/src/hwpx/body_patch.py @@ -215,14 +215,6 @@ def _op_replace_text(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, An raise ValueError("replace_text: match outside any paragraph") by_para.setdefault(span, []).append(hit) - edits: list[tuple[int, int, str]] = [] - for (pa, pb), phits in by_para.items(): - block = xml[pa:pb] - for a, b in sorted(phits, reverse=True): - block = block[: a - pa] + esc_replace + block[b - pa:] - block = _strip_paragraph_layout_cache(block.encode("utf-8")).decode("utf-8") - edits.append((pa, pb, block)) - # 모듈 취지는 표-밖 본문이지만(위 docstring), 검색은 구조적으로 # 표 셀 내부까지 닿는다 — 편집된 문단이 셀(subList) 안이면 그 셀의 # lineWrap=SQUEEZE도 함께 BREAK로 바꾼다. 안 바꾸면 실측대로 압축 자간이 @@ -232,6 +224,7 @@ def _op_replace_text(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, An # 문단이 여럿이어도 셀 편집은 한 번만. sublist_spans = _all_sublist_spans(xml) fixed_sublists: set[tuple[int, int]] = set() + wrap_edits: list[tuple[int, int, str]] = [] for pa, _pb in by_para: sublist_span = _innermost_span(pa, sublist_spans) if sublist_span is None or sublist_span in fixed_sublists: @@ -239,7 +232,28 @@ def _op_replace_text(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, An fixed_sublists.add(sublist_span) wrap_edit = _squeeze_wrap_edit_for_sublist(xml, sublist_span) if wrap_edit is not None: - edits.append(wrap_edit) + wrap_edits.append(wrap_edit) + + # 표를 담은 문단과 그 표의 셀 문단이 둘 다 편집되면 두 문단 구간이 겹친다. + # 바깥 문단 하나로 묶어 그 안의 치환·셀 SQUEEZE 편집을 한 번에 적용한 뒤 + # 캐시를 지운다(바깥 문단의 캐시 제거가 안쪽 문단의 캐시도 지운다). + outer = [ + span for span in by_para + if not any(other != span and other[0] <= span[0] and span[1] <= other[1] for other in by_para) + ] + edits: list[tuple[int, int, str]] = [] + for pa, pb in outer: + local = [(a, b, esc_replace) for a, b in hits if pa <= a < pb] + local += [edit for edit in wrap_edits if pa <= edit[0] and edit[1] <= pb] + block = xml[pa:pb] + for a, b, replacement in sorted(local, key=lambda e: e[0], reverse=True): + block = block[: a - pa] + replacement + block[b - pa:] + block = _strip_paragraph_layout_cache(block.encode("utf-8")).decode("utf-8") + edits.append((pa, pb, block)) + edits += [ + edit for edit in wrap_edits + if not any(pa <= edit[0] and edit[1] <= pb for pa, pb in outer) + ] for a, b, replacement in sorted(edits, key=lambda e: e[0], reverse=True): xml = xml[:a] + replacement + xml[b:] diff --git a/tests/test_body_patch.py b/tests/test_body_patch.py index 56528c4d..b2fc957a 100644 --- a/tests/test_body_patch.py +++ b/tests/test_body_patch.py @@ -160,3 +160,31 @@ def test_replace_text_fixes_only_the_touched_cell_when_two_cells_are_squeezed( subs = list(_SUBLIST_OPEN_RE.finditer(changed_section)) assert 'lineWrap="BREAK"' in subs[0].group(0), "편집된 셀만 BREAK로 바뀌어야 함" assert 'lineWrap="SQUEEZE"' in subs[1].group(0), "안 건드린 셀은 SQUEEZE 그대로" + + +def test_replace_text_in_a_table_holder_and_its_own_cell_at_once(squeeze_doc_bytes: bytes) -> None: + """A match in the paragraph that holds a table and another in a cell of that + table nest one edited paragraph inside the other: both are replaced, the + squeezed cell turns to BREAK, and no layout cache is left in the holder.""" + + section = _section_xml(squeeze_doc_bytes) + end = re.search(r".*?", section, re.S) + assert end is not None + holder_run = '짧은값 옆 글' + section = section[: end.end()] + holder_run + section[end.end() :] + data = _with_section(squeeze_doc_bytes, section) + + result = apply_body_ops( + data, + [{"op": "replace_text", "find": "짧은값", "replace": "훨씬 더 길게 채운 실제 값입니다", "count": 2}], + ) + assert result.ok, result.skipped + + after = _section_xml(result.data) + assert after.count("훨씬 더 길게 채운 실제 값입니다") == 2 + assert "짧은값" not in after + assert 'lineWrap="SQUEEZE"' not in after + holder = re.search(r".*?", after, re.S) + assert holder is not None + assert "linesegarray" not in holder.group(0) + assert HwpxDocument.open(io.BytesIO(result.data)).paragraphs