python-hwpx 2.23.0__py3-none-any.whl → 2.24.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hwpx/__init__.py CHANGED
@@ -75,6 +75,16 @@ from .tools.layout_preview import (
75
75
  PreviewPage,
76
76
  render_layout_preview,
77
77
  )
78
+ from .ingest import (
79
+ ConversionAttempt,
80
+ DocumentConverter,
81
+ DocumentIngestError,
82
+ DocumentIngestResult,
83
+ DocumentIngestor,
84
+ DocumentSourceInfo,
85
+ HwpxMarkdownConverter,
86
+ UnsupportedDocumentFormat,
87
+ )
78
88
  from .patch import (
79
89
  BytePreservingPatchResult,
80
90
  ParagraphTextPatch,
@@ -123,8 +133,14 @@ __all__ = [
123
133
  "DEFAULT_STYLE_PRESET",
124
134
  "DOCUMENT_PLAN_SCHEMA_VERSION",
125
135
  "get_document_plan_schema",
136
+ "ConversionAttempt",
126
137
  "DocumentBlock",
138
+ "DocumentConverter",
139
+ "DocumentIngestError",
140
+ "DocumentIngestResult",
141
+ "DocumentIngestor",
127
142
  "DocumentPlan",
143
+ "DocumentSourceInfo",
128
144
  "DocumentStylePreset",
129
145
  "EditorOpenSafetyReport",
130
146
  "ParagraphInfo",
@@ -141,6 +157,7 @@ __all__ = [
141
157
  "TEMPLATE_FORMFIT_PLAN_SCHEMA_VERSION",
142
158
  "TextExtractor",
143
159
  "FoundElement",
160
+ "HwpxMarkdownConverter",
144
161
  "ObjectFinder",
145
162
  "OFFICIAL_DOCUMENT_STYLE_REPORT_VERSION",
146
163
  "DOC_DIFF_REPORT_VERSION",
@@ -150,6 +167,7 @@ __all__ = [
150
167
  "STYLE_PROFILE_SCHEMA_VERSION",
151
168
  "TEMPLATE_REGISTRY_SCHEMA_VERSION",
152
169
  "TABLE_COMPUTE_REPORT_VERSION",
170
+ "UnsupportedDocumentFormat",
153
171
  "apply_style_profile_to_plan",
154
172
  "build_comparison_table_plan",
155
173
  "build_image_grid",
hwpx/body_patch.py ADDED
@@ -0,0 +1,573 @@
1
+ """본문(섹션 직속 문단) 바이트보존 op — Stage 2 결정표의 표-밖 실행 어휘.
2
+
3
+ KACE 투고본 변환 스파이크(본문 인용 [n] 치환 · 참고문헌 문단 재배열)의 스택 승격.
4
+ :func:`hwpx.table_patch.apply_table_ops` 와 동형 계약: 순차 적용, fail-closed
5
+ (정합 불가 시 이유와 함께 skip), op별 transcript, ``dry_run`` (해석·검증은 전부
6
+ 실제로 수행하되 파일은 쓰지 않음 — 상의 루프의 승인 근거).
7
+
8
+ Ops (index = 섹션 직속 ``<hp:p>`` 문서순, **op 실행 시점의 현재 상태** 기준):
9
+
10
+ - ``replace_text`` {find, replace, count=1}
11
+ ``<hp:t>`` 텍스트 콘텐츠 안에서만 매치(태그/속성 불가침). 발견 수가 count와
12
+ 다르면 refuse — 런 경계를 걸친 문자열은 매치되지 않아 자연히 refuse된다.
13
+ - ``delete_paragraph`` {index}
14
+ 표를 품은 문단은 refuse(표까지 무음 소실 방지, ``allow_tables=True``로 해제).
15
+ - ``insert_paragraph_by_clone`` {ref_index, count=1, texts=[...]?}
16
+ 참조 문단을 서식 verbatim 복제(id 재작성·linesegarray 제거) 후 ref 뒤에 삽입.
17
+ texts[i]가 있으면 해당 클론의 텍스트를 교체 — **이웃 문단 서식 상속** 경로.
18
+ - ``set_paragraph_text`` {index, text}
19
+ index번째 섹션 직속 문단의 텍스트를 통째 설정(fill_cell의 본문판) — 빈/마커
20
+ 문단("가." 등)에 내용을 넣을 때. 첫 런 서식 상속, stale linesegarray 제거.
21
+ - ``reorder_paragraphs`` {start, end, order}
22
+ 연속 구간 [start..end]를 order(구간 내 상대 인덱스 순열)로 재배열.
23
+ - ``restyle_text`` {find, count=1, text_color?, drop_italic=True}
24
+ find를 품은 런의 charPr을 정규화 변형(색 교체·이탤릭 제거)으로 재매핑 —
25
+ 안내용 슬롯 서식(파란 이탤릭 등)을 상속한 채움 텍스트를 "사람이 작성한
26
+ 본문"으로 만든다(2026-07-07 AI중점학교 신청서 실측). 원본 charPr은 불변,
27
+ 변형은 header에 새 id로 추가(dedupe).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import html
33
+ import re
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import Any, Mapping, Sequence
37
+
38
+ from .patch import (
39
+ _finalize,
40
+ _patch_zip_entries,
41
+ _read_source_bytes,
42
+ _rewrite_zip_entries,
43
+ _strip_paragraph_layout_cache,
44
+ _text_edit_for_paragraph,
45
+ )
46
+
47
+ __all__ = [
48
+ "BodyOpsResult", "apply_body_ops", "direct_paragraph_spans",
49
+ "strip_runs_by_color", "recolor_runs_by_color",
50
+ ]
51
+
52
+ _P_EDGE_RE = re.compile(r"<hp:p[ >]|</hp:p>")
53
+ _T_CONTENT_RE = re.compile(r"<hp:t(?:\s[^>]*)?>(.*?)</hp:t>", re.S)
54
+ _PARA_ID_RE = re.compile(r'(<hp:p\b[^>]*\bid=")(\d+)(")')
55
+ _TBL_RE = re.compile(r"<hp:tbl\b")
56
+
57
+
58
+ def direct_paragraph_spans(section_xml: str) -> list[tuple[int, int]]:
59
+ """섹션 직속 ``<hp:p>`` 블록들의 (start, end) — 중첩(셀 내부) 문단 제외."""
60
+ spans: list[tuple[int, int]] = []
61
+ depth, start = 0, -1
62
+ for m in _P_EDGE_RE.finditer(section_xml):
63
+ if m.group().startswith("</"):
64
+ depth -= 1
65
+ if depth == 0 and start >= 0:
66
+ spans.append((start, m.end()))
67
+ start = -1
68
+ else:
69
+ if depth == 0:
70
+ start = m.start()
71
+ depth += 1
72
+ return spans
73
+
74
+
75
+ def _all_paragraph_spans(section_xml: str) -> list[tuple[int, int]]:
76
+ """모든 깊이의 ``<hp:p>`` 블록 span (셀 내부 포함) — lineseg 무효화 대상 탐색용."""
77
+ spans: list[tuple[int, int]] = []
78
+ stack: list[int] = []
79
+ for m in _P_EDGE_RE.finditer(section_xml):
80
+ if m.group().startswith("</"):
81
+ if stack:
82
+ spans.append((stack.pop(), m.end()))
83
+ else:
84
+ stack.append(m.start())
85
+ return spans
86
+
87
+
88
+ def _preview(text: str, limit: int = 60) -> str:
89
+ flat = re.sub(r"\s+", " ", text).strip()
90
+ return flat[: limit - 1] + "…" if len(flat) > limit else flat
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class BodyOpsResult:
95
+ data: bytes
96
+ skipped: tuple[dict[str, Any], ...]
97
+ transcript: tuple[dict[str, Any], ...]
98
+ changed_parts: tuple[str, ...]
99
+ byte_identical: bool
100
+ open_safety: dict[str, Any]
101
+
102
+ @property
103
+ def ok(self) -> bool:
104
+ return bool(self.open_safety.get("ok")) and not self.skipped
105
+
106
+ def to_dict(self) -> dict[str, Any]:
107
+ return {
108
+ "ok": self.ok,
109
+ "skipped": list(self.skipped),
110
+ "transcript": list(self.transcript),
111
+ "changedParts": list(self.changed_parts),
112
+ "byteIdentical": self.byte_identical,
113
+ "openSafety": self.open_safety,
114
+ }
115
+
116
+
117
+ def _op_replace_text(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
118
+ find = str(op["find"])
119
+ replace = str(op.get("replace", ""))
120
+ expected = int(op.get("count", 1))
121
+ esc_find = html.escape(find, quote=False)
122
+ esc_replace = html.escape(replace, quote=False)
123
+ hits: list[tuple[int, int]] = []
124
+ for t in _T_CONTENT_RE.finditer(xml):
125
+ a, b = t.start(1), t.end(1)
126
+ pos = xml.find(esc_find, a)
127
+ while pos != -1 and pos + len(esc_find) <= b:
128
+ hits.append((pos, pos + len(esc_find)))
129
+ pos = xml.find(esc_find, pos + 1)
130
+ if pos >= b:
131
+ break
132
+ if len(hits) != expected:
133
+ raise ValueError(
134
+ f"replace_text: {find!r} matched {len(hits)} time(s) inside <hp:t> "
135
+ f"(expected {expected}) — run-spanning or ambiguous text is refused"
136
+ )
137
+ # 편집된 문단의 linesegarray(한컴 줄배치 캐시)를 함께 제거해야 한다 — 안 지우면
138
+ # 한컴이 옛 텍스트 기준 줄배치를 재사용해 긴 새 텍스트가 겹쳐 렌더된다
139
+ # (2026-07-07 AI중점학교 신청서 실측: 교체 문단만 줄겹침, lineseg 제거된
140
+ # 클론 문단은 정상). 문단 단위로 묶어 뒤에서부터: 치환 → 캐시 제거 → 재조립.
141
+ para_spans = _all_paragraph_spans(xml)
142
+
143
+ def _innermost(pos: int) -> tuple[int, int]:
144
+ best = None
145
+ for a, b in para_spans:
146
+ if a <= pos < b and (best is None or (a >= best[0] and b <= best[1])):
147
+ best = (a, b)
148
+ if best is None:
149
+ raise ValueError("replace_text: match outside any <hp:p> paragraph")
150
+ return best
151
+
152
+ by_para: dict[tuple[int, int], list[tuple[int, int]]] = {}
153
+ for hit in hits:
154
+ by_para.setdefault(_innermost(hit[0]), []).append(hit)
155
+ for (pa, pb), phits in sorted(by_para.items(), reverse=True):
156
+ block = xml[pa:pb]
157
+ for a, b in sorted(phits, reverse=True):
158
+ block = block[: a - pa] + esc_replace + block[b - pa:]
159
+ block = _strip_paragraph_layout_cache(block.encode("utf-8")).decode("utf-8")
160
+ xml = xml[:pa] + block + xml[pb:]
161
+ return xml, {"find": _preview(find), "replace": _preview(replace), "hits": expected}
162
+
163
+
164
+ def _op_delete_paragraph(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
165
+ spans = direct_paragraph_spans(xml)
166
+ index = int(op["index"])
167
+ if not 0 <= index < len(spans):
168
+ raise ValueError(f"delete_paragraph: index {index} out of range (0..{len(spans) - 1})")
169
+ a, b = spans[index]
170
+ block = xml[a:b]
171
+ if _TBL_RE.search(block) and not op.get("allow_tables"):
172
+ raise ValueError(
173
+ "delete_paragraph: paragraph wraps a table — refuse (allow_tables=True to override)"
174
+ )
175
+ old = "".join(m.group(1) for m in _T_CONTENT_RE.finditer(block))
176
+ return xml[:a] + xml[b:], {"index": index, "old": _preview(html.unescape(old))}
177
+
178
+
179
+ def _op_insert_paragraph_by_clone(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
180
+ spans = direct_paragraph_spans(xml)
181
+ ref = int(op["ref_index"])
182
+ count = int(op.get("count", 1))
183
+ texts = list(op.get("texts") or [])
184
+ if not 0 <= ref < len(spans):
185
+ raise ValueError(f"insert_paragraph_by_clone: ref_index {ref} out of range")
186
+ if count < 1:
187
+ raise ValueError("insert_paragraph_by_clone: count must be >= 1")
188
+ if texts and len(texts) != count:
189
+ raise ValueError("insert_paragraph_by_clone: len(texts) must equal count")
190
+ a, b = spans[ref]
191
+ block = xml[a:b]
192
+ if _TBL_RE.search(block):
193
+ raise ValueError("insert_paragraph_by_clone: ref paragraph wraps a table — refuse")
194
+ stripped = _strip_paragraph_layout_cache(block.encode("utf-8")).decode("utf-8")
195
+ clones: list[str] = []
196
+ for i in range(count):
197
+ clone = _PARA_ID_RE.sub(
198
+ lambda m: m.group(1) + str((int(m.group(2)) + (i + 1) * 100003) & 0x7FFFFFFF) + m.group(3),
199
+ stripped,
200
+ )
201
+ if texts:
202
+ edited = _text_edit_for_paragraph(clone.encode("utf-8"), str(texts[i]))
203
+ if edited is None:
204
+ raise ValueError("insert_paragraph_by_clone: ref paragraph has no text run to fill")
205
+ clone = edited[2].decode("utf-8")
206
+ clones.append(clone)
207
+ return xml[:b] + "".join(clones) + xml[b:], {
208
+ "refIndex": ref,
209
+ "count": count,
210
+ "texts": [_preview(str(t)) for t in texts],
211
+ }
212
+
213
+
214
+ def _op_reorder_paragraphs(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
215
+ spans = direct_paragraph_spans(xml)
216
+ start, end = int(op["start"]), int(op["end"])
217
+ order = [int(i) for i in op["order"]]
218
+ if not (0 <= start <= end < len(spans)):
219
+ raise ValueError(f"reorder_paragraphs: range {start}..{end} out of bounds")
220
+ size = end - start + 1
221
+ if sorted(order) != list(range(size)):
222
+ raise ValueError("reorder_paragraphs: order must be a permutation of 0..end-start")
223
+ region = spans[start:end + 1]
224
+ for (a0, b0), (a1, _b1) in zip(region, region[1:]):
225
+ if xml[b0:a1].strip():
226
+ raise ValueError("reorder_paragraphs: non-whitespace content between paragraphs — refuse")
227
+ blocks = [xml[a:b] for a, b in region]
228
+ new_region = "".join(blocks[i] for i in order)
229
+ return xml[: region[0][0]] + new_region + xml[region[-1][1]:], {
230
+ "start": start, "end": end, "order": order,
231
+ }
232
+
233
+
234
+ def _materialize_restyled_charpr(
235
+ header: str, base_id: str, text_color: str | None, drop_italic: bool,
236
+ cache: dict[tuple, str],
237
+ ) -> tuple[str, str]:
238
+ """charPr *base_id*를 색/이탤릭만 바꾼 변형으로 복제해 header에 추가."""
239
+ key = (base_id, text_color, drop_italic)
240
+ if key in cache:
241
+ return header, cache[key]
242
+ bid = re.escape(base_id)
243
+ m = re.search(r'<(?:[A-Za-z_][\w.-]*:)?charPr\b[^>]*?\bid="' + bid + r'".*?</(?:[A-Za-z_][\w.-]*:)?charPr>', header, re.DOTALL)
244
+ if m is None:
245
+ m = re.search(r'<(?:[A-Za-z_][\w.-]*:)?charPr\b[^>]*?\bid="' + bid + r'"[^>]*/>', header)
246
+ if m is None:
247
+ raise ValueError(f"restyle_text: base charPr {base_id} not found in header")
248
+ ids = [int(x) for x in re.findall(r'<(?:[A-Za-z_][\w.-]*:)?charPr\b[^>]*?\bid="(\d+)"', header)]
249
+ new_id = str(max(ids) + 1 if ids else 0)
250
+ clone = re.sub(r'(\bid=")\d+(")', r"\g<1>" + new_id + r"\g<2>", m.group(0), count=1)
251
+ if text_color is not None:
252
+ if re.search(r'\btextColor="[^"]*"', clone):
253
+ clone = re.sub(r'(\btextColor=")[^"]*(")', r"\g<1>" + text_color + r"\g<2>", clone, count=1)
254
+ else:
255
+ clone = re.sub(r'(<(?:[A-Za-z_][\w.-]*:)?charPr\b)', r"\g<1>" + f' textColor="{text_color}"', clone, count=1)
256
+ if drop_italic:
257
+ clone = re.sub(r'<(?:[A-Za-z_][\w.-]*:)?italic\b[^>]*/>', "", clone)
258
+ clone = re.sub(r'<((?:[A-Za-z_][\w.-]*:)?)italic\b[^>]*>.*?</\1italic>', "", clone, flags=re.DOTALL)
259
+ close = re.search(r'</(?:[A-Za-z_][\w.-]*:)?charProperties>', header)
260
+ if close is None:
261
+ raise ValueError("restyle_text: charProperties close tag not found")
262
+ header = header[: close.start()] + clone + header[close.start():]
263
+ header = re.sub(
264
+ r'(<(?:[A-Za-z_][\w.-]*:)?charProperties\b[^>]*?\bitemCnt=")(\d+)(")',
265
+ lambda mm: mm.group(1) + str(int(mm.group(2)) + 1) + mm.group(3),
266
+ header, count=1,
267
+ )
268
+ cache[key] = new_id
269
+ return header, new_id
270
+
271
+
272
+ def _op_restyle_text(xml: str, op: Mapping[str, Any], ctx: dict[str, Any]) -> tuple[str, dict[str, Any]]:
273
+ find = str(op["find"])
274
+ expected = int(op.get("count", 1))
275
+ text_color = op.get("text_color", op.get("textColor"))
276
+ drop_italic = bool(op.get("drop_italic", op.get("dropItalic", True)))
277
+ esc_find = html.escape(find, quote=False)
278
+ hits: list[int] = []
279
+ for t in _T_CONTENT_RE.finditer(xml):
280
+ a, b = t.start(1), t.end(1)
281
+ pos = xml.find(esc_find, a)
282
+ while pos != -1 and pos + len(esc_find) <= b:
283
+ hits.append(pos)
284
+ pos = xml.find(esc_find, pos + 1)
285
+ if pos >= b:
286
+ break
287
+ if len(hits) != expected:
288
+ raise ValueError(
289
+ f"restyle_text: {find!r} matched {len(hits)} time(s) inside <hp:t> (expected {expected})"
290
+ )
291
+ remapped = []
292
+ for pos in sorted(hits, reverse=True):
293
+ run_open = xml.rfind("<hp:run", 0, pos)
294
+ if run_open < 0:
295
+ raise ValueError("restyle_text: enclosing <hp:run> not found")
296
+ tag_end = xml.find(">", run_open)
297
+ open_tag = xml[run_open: tag_end + 1]
298
+ m = re.search(r'charPrIDRef="(\d+)"', open_tag)
299
+ if m is None:
300
+ raise ValueError("restyle_text: run has no charPrIDRef")
301
+ base = m.group(1)
302
+ ctx["header"], new_id = _materialize_restyled_charpr(
303
+ ctx["header"], base, text_color, drop_italic, ctx["charpr_cache"]
304
+ )
305
+ ctx["header_changed"] = True
306
+ new_tag = open_tag.replace(f'charPrIDRef="{base}"', f'charPrIDRef="{new_id}"', 1)
307
+ xml = xml[:run_open] + new_tag + xml[tag_end + 1:]
308
+ remapped.append(f"{base}→{new_id}")
309
+ return xml, {"find": _preview(find), "hits": expected, "charPr": remapped,
310
+ "textColor": text_color, "dropItalic": drop_italic}
311
+
312
+
313
+ def _op_set_paragraph_text(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
314
+ spans = direct_paragraph_spans(xml)
315
+ index = int(op["index"])
316
+ if not 0 <= index < len(spans):
317
+ raise ValueError(f"set_paragraph_text: index {index} out of range (0..{len(spans) - 1})")
318
+ a, b = spans[index]
319
+ block = xml[a:b]
320
+ if _TBL_RE.search(block):
321
+ raise ValueError("set_paragraph_text: paragraph wraps a table — refuse")
322
+ edited = _text_edit_for_paragraph(block.encode("utf-8"), str(op["text"]))
323
+ if edited is None:
324
+ raise ValueError("set_paragraph_text: paragraph has no text run to set")
325
+ new_block = _strip_paragraph_layout_cache(edited[2]).decode("utf-8")
326
+ old = "".join(m.group(1) for m in _T_CONTENT_RE.finditer(block))
327
+ return xml[:a] + new_block + xml[b:], {"index": index, "old": _preview(html.unescape(old)),
328
+ "new": _preview(str(op["text"]))}
329
+
330
+
331
+ _OPS = {
332
+ "replace_text": _op_replace_text,
333
+ "delete_paragraph": _op_delete_paragraph,
334
+ "insert_paragraph_by_clone": _op_insert_paragraph_by_clone,
335
+ "reorder_paragraphs": _op_reorder_paragraphs,
336
+ "set_paragraph_text": _op_set_paragraph_text,
337
+ "restyle_text": _op_restyle_text, # dispatch에서 ctx 전달로 특수 처리
338
+ }
339
+
340
+
341
+ def recolor_runs_by_color(
342
+ source: str | Path | bytes,
343
+ from_hexes: Sequence[str],
344
+ to_color: str,
345
+ *,
346
+ output_path: str | Path | None = None,
347
+ dry_run: bool = False,
348
+ ) -> BodyOpsResult:
349
+ """문서 전체(셀 포함)에서 지정 색(정확 hex)의 run을 to_color로 재색.
350
+
351
+ 양식이 "이 색 글씨는 과목별로 수정"(평가계획 파랑)이라 선언한 슬롯을 채우면
352
+ 슬롯 색을 상속하는데, 채운 내용은 사람 본문이므로 검정이어야 한다. from_hexes와
353
+ 정확히 일치하는 charPr을 to_color 변형으로 복제(header)하고 해당 run의
354
+ charPrIDRef만 재매핑한다(글꼴·크기 등 나머지 서식 보존). 디자인색 오염 방지를
355
+ 위해 계열이 아닌 **정확 hex** 매칭."""
356
+ source_bytes = _read_source_bytes(source)
357
+ from_set = {h.upper() for h in from_hexes}
358
+ import io, zipfile
359
+
360
+ with zipfile.ZipFile(io.BytesIO(source_bytes)) as z:
361
+ names = z.namelist()
362
+ header_name = next((n for n in names if n.endswith("header.xml")), None)
363
+ header_xml = z.read(header_name).decode("utf-8") if header_name else ""
364
+ sections = {n: z.read(n).decode("utf-8") for n in names if re.search(r"section\d+\.xml$", n)}
365
+
366
+ ids = set()
367
+ for cm in re.finditer(r"<(?:[A-Za-z_][\w.-]*:)?charPr\b[^>]*?>", header_xml):
368
+ idm = re.search(r'\bid="(\d+)"', cm.group(0))
369
+ colm = re.search(r'\btextColor="([^"]+)"', cm.group(0))
370
+ if idm and colm and colm.group(1).upper() in from_set:
371
+ ids.add(idm.group(1))
372
+
373
+ cache: dict[tuple, str] = {}
374
+ idmap: dict[str, str] = {}
375
+ for rid in sorted(ids, key=int):
376
+ header_xml, new_id = _materialize_restyled_charpr(header_xml, rid, to_color, False, cache)
377
+ idmap[rid] = new_id
378
+
379
+ transcript: list[dict[str, Any]] = []
380
+ changed: set[str] = set()
381
+ for sp, xml in sections.items():
382
+ n = [0]
383
+
384
+ def _remap(m: "re.Match") -> str:
385
+ old = m.group(1)
386
+ if old not in idmap:
387
+ return m.group(0)
388
+ n[0] += 1
389
+ return m.group(0).replace(f'charPrIDRef="{old}"', f'charPrIDRef="{idmap[old]}"', 1)
390
+
391
+ new_xml = re.sub(r'<hp:run\b[^>]*\bcharPrIDRef="(\d+)"[^>]*>', _remap, xml)
392
+ if n[0] == 0:
393
+ continue
394
+ sections[sp] = new_xml
395
+ changed.add(sp)
396
+ transcript.append({"op": "recolor_runs_by_color", "sectionPath": sp,
397
+ "runsRecolored": n[0], "toColor": to_color,
398
+ "status": "would_apply" if dry_run else "applied"})
399
+
400
+ intermediate = source_bytes
401
+ if changed and header_name:
402
+ payload = {sp: sections[sp].encode("utf-8") for sp in changed}
403
+ payload[header_name] = header_xml.encode("utf-8")
404
+ try:
405
+ intermediate = _patch_zip_entries(source_bytes, payload)
406
+ except ValueError:
407
+ intermediate = _rewrite_zip_entries(source_bytes, payload)
408
+ open_safety, _ = _finalize(intermediate, None if dry_run else output_path, source=source)
409
+ return BodyOpsResult(intermediate, (), tuple(transcript), tuple(sorted(changed)),
410
+ intermediate == source_bytes, open_safety)
411
+
412
+
413
+ def strip_runs_by_color(
414
+ source: str | Path | bytes,
415
+ hex_colors: Sequence[str],
416
+ *,
417
+ output_path: str | Path | None = None,
418
+ dry_run: bool = False,
419
+ ) -> BodyOpsResult:
420
+ """문서 전체(셀 내부 포함)에서 지정 색의 run 텍스트를 비운다(런 구조 유지).
421
+
422
+ 양식이 색 범례로 "이 색 글씨는 모두 삭제"를 선언한 경우(평가계획 빨강)의
423
+ 일괄 청소 — 안내문·범례가 셀·헤딩 표에 흩어진 red run이라 문단 op가 못 닿는
424
+ 문제를 해결한다. charPrIDRef가 header에서 target textColor를 가리키는 run의
425
+ ``<hp:t>`` 내용을 비우고, 그 run이 든 문단의 stale linesegarray를 제거한다.
426
+ 미변경 부분은 byte-identical."""
427
+ source_bytes = _read_source_bytes(source)
428
+ import io, zipfile
429
+
430
+ targets = {h.upper() for h in hex_colors}
431
+ with zipfile.ZipFile(io.BytesIO(source_bytes)) as z:
432
+ names = z.namelist()
433
+ header_name = next((n for n in names if n.endswith("header.xml")), None)
434
+ header_xml = z.read(header_name).decode("utf-8") if header_name else ""
435
+ sections = {n: z.read(n).decode("utf-8") for n in names if re.search(r"section\d+\.xml$", n)}
436
+
437
+ # 계열 매칭(잔존 게이트와 정렬): 대상 색의 _color_family에 드는 모든 charPr.
438
+ from .guidance_scan import _color_family
439
+ target_families = {_color_family(h) for h in targets}
440
+ ids = set()
441
+ for cm in re.finditer(r"<(?:[A-Za-z_][\w.-]*:)?charPr\b[^>]*?>", header_xml):
442
+ tag = cm.group(0)
443
+ idm = re.search(r'\bid="(\d+)"', tag)
444
+ colm = re.search(r'\btextColor="([^"]+)"', tag)
445
+ if not (idm and colm):
446
+ continue
447
+ col = colm.group(1).upper()
448
+ if col in targets or _color_family(col) in target_families:
449
+ ids.add(idm.group(1))
450
+ transcript: list[dict[str, Any]] = []
451
+ changed: set[str] = set()
452
+ total = 0
453
+ counter = {"n": 0}
454
+ run_re = re.compile(
455
+ r"(?P<open><hp:run\b[^>]*\bcharPrIDRef=\"(\d+)\"[^>]*>)(?P<body>.*?)(?P<close></hp:run>)",
456
+ re.S,
457
+ )
458
+
459
+ def _sub(m: "re.Match") -> str:
460
+ if m.group(2) not in ids:
461
+ return m.group(0)
462
+ body = re.sub(r"(<hp:t\b[^>]*>).*?(</hp:t>)", r"\1\2", m.group("body"), flags=re.S)
463
+ if body != m.group("body"):
464
+ counter["n"] += 1
465
+ return m.group("open") + body + m.group("close")
466
+
467
+ for sp, xml in sections.items():
468
+ counter["n"] = 0
469
+ new_xml = run_re.sub(_sub, xml)
470
+ n = counter["n"]
471
+ if n == 0:
472
+ continue
473
+ # 편집된 문단들의 linesegarray 제거(재계산)
474
+ new_xml = re.sub(
475
+ r"(<hp:p\b(?:(?!</hp:p>).)*?<hp:t\b[^>]*></hp:t>(?:(?!</hp:p>).)*?)"
476
+ r"<(?P<ns>(?:[A-Za-z_][\w.-]*:)?)linesegarray\b(?:[^>]*?/>|[^>]*>.*?</(?P=ns)linesegarray>)",
477
+ r"\1", new_xml, flags=re.S,
478
+ )
479
+ sections[sp] = new_xml
480
+ changed.add(sp)
481
+ total += n
482
+ transcript.append({"op": "strip_runs_by_color", "sectionPath": sp,
483
+ "runsBlanked": n, "status": "would_apply" if dry_run else "applied"})
484
+
485
+ intermediate = source_bytes
486
+ if changed:
487
+ payload = {sp: sections[sp].encode("utf-8") for sp in changed}
488
+ try:
489
+ intermediate = _patch_zip_entries(source_bytes, payload)
490
+ except ValueError:
491
+ intermediate = _rewrite_zip_entries(source_bytes, payload)
492
+ open_safety, _ = _finalize(intermediate, None if dry_run else output_path, source=source)
493
+ return BodyOpsResult(intermediate, (), tuple(transcript), tuple(sorted(changed)),
494
+ intermediate == source_bytes, open_safety)
495
+
496
+
497
+ def apply_body_ops(
498
+ source: str | Path | bytes,
499
+ ops: Sequence[Mapping[str, Any]],
500
+ *,
501
+ output_path: str | Path | None = None,
502
+ dry_run: bool = False,
503
+ ) -> BodyOpsResult:
504
+ """섹션 직속 문단 op들을 바이트보존으로 적용한다 (모듈 docstring 참조)."""
505
+ source_bytes = _read_source_bytes(source)
506
+ import io, zipfile
507
+
508
+ header_part: str | None = None
509
+ with zipfile.ZipFile(io.BytesIO(source_bytes)) as z:
510
+ sections = {
511
+ n: z.read(n).decode("utf-8")
512
+ for n in z.namelist()
513
+ if re.search(r"section\d+\.xml$", n)
514
+ }
515
+ header_part = next((n for n in z.namelist() if n.endswith("header.xml")), None)
516
+ header_xml = z.read(header_part).decode("utf-8") if header_part else ""
517
+
518
+ ctx: dict[str, Any] = {"header": header_xml, "header_changed": False, "charpr_cache": {}}
519
+ skipped: list[dict[str, Any]] = []
520
+ transcript: list[dict[str, Any]] = []
521
+ changed: set[str] = set()
522
+
523
+ for op in ops:
524
+ name = str(op.get("op"))
525
+ sp = str(op.get("section_path") or op.get("sectionPath") or "Contents/section0.xml")
526
+ entry: dict[str, Any] = {"op": name, "sectionPath": sp}
527
+ handler = _OPS.get(name)
528
+ if handler is None:
529
+ entry["status"] = f"refused: unknown op {name!r}"
530
+ skipped.append(dict(entry))
531
+ transcript.append(entry)
532
+ continue
533
+ xml = sections.get(sp)
534
+ if xml is None:
535
+ entry["status"] = "refused: section part not found"
536
+ skipped.append(dict(entry))
537
+ transcript.append(entry)
538
+ continue
539
+ try:
540
+ if name == "restyle_text":
541
+ new_xml, detail = _op_restyle_text(xml, op, ctx)
542
+ else:
543
+ new_xml, detail = handler(xml, op)
544
+ except (ValueError, KeyError, TypeError) as exc:
545
+ entry["status"] = f"refused: {exc}"
546
+ skipped.append(dict(entry))
547
+ transcript.append(entry)
548
+ continue
549
+ sections[sp] = new_xml
550
+ changed.add(sp)
551
+ entry.update(detail)
552
+ entry["status"] = "would_apply" if dry_run else "applied"
553
+ transcript.append(entry)
554
+
555
+ intermediate = source_bytes
556
+ if changed or ctx["header_changed"]:
557
+ payload = {sp: sections[sp].encode("utf-8") for sp in changed}
558
+ if ctx["header_changed"] and header_part:
559
+ payload[header_part] = ctx["header"].encode("utf-8")
560
+ try:
561
+ intermediate = _patch_zip_entries(source_bytes, payload)
562
+ except ValueError:
563
+ intermediate = _rewrite_zip_entries(source_bytes, payload)
564
+
565
+ open_safety, _ = _finalize(intermediate, None if dry_run else output_path, source=source)
566
+ return BodyOpsResult(
567
+ intermediate,
568
+ tuple(skipped),
569
+ tuple(transcript),
570
+ tuple(sorted(changed)),
571
+ intermediate == source_bytes,
572
+ open_safety,
573
+ )