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 +18 -0
- hwpx/body_patch.py +573 -0
- hwpx/evalplan_fill.py +1590 -0
- hwpx/fill_residue.py +163 -0
- hwpx/formfill_quality.py +823 -0
- hwpx/guidance_scan.py +519 -0
- hwpx/ingest/__init__.py +26 -0
- hwpx/ingest/base.py +230 -0
- hwpx/ingest/hwpx_converter.py +127 -0
- hwpx/table_patch.py +633 -15
- {python_hwpx-2.23.0.dist-info → python_hwpx-2.24.0.dist-info}/METADATA +2 -1
- {python_hwpx-2.23.0.dist-info → python_hwpx-2.24.0.dist-info}/RECORD +17 -9
- {python_hwpx-2.23.0.dist-info → python_hwpx-2.24.0.dist-info}/WHEEL +1 -1
- {python_hwpx-2.23.0.dist-info → python_hwpx-2.24.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-2.23.0.dist-info → python_hwpx-2.24.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-2.23.0.dist-info → python_hwpx-2.24.0.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-2.23.0.dist-info → python_hwpx-2.24.0.dist-info}/top_level.txt +0 -0
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
|
+
)
|