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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`가 글자 모양의 굵게를 담는다.
Expand Down
32 changes: 23 additions & 9 deletions src/hwpx/body_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hp:p> 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), <hp:t> 검색은 구조적으로
# 표 셀 내부까지 닿는다 — 편집된 문단이 셀(subList) 안이면 그 셀의
# lineWrap=SQUEEZE도 함께 BREAK로 바꾼다. 안 바꾸면 실측대로 압축 자간이
Expand All @@ -232,14 +224,36 @@ 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:
continue
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:]
Expand Down
28 changes: 28 additions & 0 deletions tests/test_body_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"</hp:tbl>.*?</hp:run>", section, re.S)
assert end is not None
holder_run = '<hp:run charPrIDRef="0"><hp:t>짧은값 옆 글</hp:t></hp: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"<hp:p\b(?:(?!<hp:p\b).)*?<hp:tbl\b.*</hp:tbl>.*?</hp:p>", after, re.S)
assert holder is not None
assert "linesegarray" not in holder.group(0)
assert HwpxDocument.open(io.BytesIO(result.data)).paragraphs
Loading