@p.e.c/boaz-skills 1.3.0 → 1.4.0
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.
- package/package.json +1 -1
- package/skills/modify-hwpx/SKILL.md +190 -16
package/package.json
CHANGED
|
@@ -5,38 +5,31 @@ description: 'HWPX(한글) 파일 수정 스킬. MCP 서버를 통해 HWPX 문
|
|
|
5
5
|
|
|
6
6
|
# HWPX (한글) 파일 수정
|
|
7
7
|
|
|
8
|
-
>
|
|
8
|
+
> HWPX 문서를 프로그래밍 방식으로 편집하는 스킬. 두 가지 방식을 지원한다.
|
|
9
9
|
|
|
10
10
|
## 핵심 요약
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
**방식 A (MCP)**: Claude Code + `@p.e.c/hwpx-mcp` MCP 서버 — 77개 도구로 편집
|
|
13
|
+
**방식 B (Fallback)**: Python lxml로 직접 XML 수정 — MCP 없는 환경(Codex, Gemini 등)에서 사용
|
|
13
14
|
|
|
14
15
|
```
|
|
15
|
-
HWPX 파일 → MCP 서버로 열기 → 도구로 편집 → 저장
|
|
16
|
+
방식 A: HWPX 파일 → MCP 서버로 열기 → 도구로 편집 → 저장
|
|
17
|
+
방식 B: HWPX(ZIP) → unzip → lxml로 section XML 수정 → zip CLI로 교체 → 저장
|
|
16
18
|
```
|
|
17
19
|
|
|
18
20
|
---
|
|
19
21
|
|
|
20
|
-
##
|
|
22
|
+
## 방식 A: MCP 서버 (Claude Code 전용)
|
|
21
23
|
|
|
22
|
-
###
|
|
24
|
+
### 사전 준비
|
|
23
25
|
|
|
24
26
|
```bash
|
|
25
27
|
# Claude Code에 MCP 서버 등록
|
|
26
28
|
claude mcp add --transport stdio --scope user hwpx-mcp -- npx -y @p.e.c/hwpx-mcp
|
|
29
|
+
# Claude Code 재시작 후 /mcp 로 확인
|
|
27
30
|
```
|
|
28
31
|
|
|
29
|
-
###
|
|
30
|
-
|
|
31
|
-
MCP 서버가 로드되려면 Claude Code를 재시작해야 합니다.
|
|
32
|
-
|
|
33
|
-
### 3. 설치 확인
|
|
34
|
-
|
|
35
|
-
Claude Code 내에서 `/mcp` 명령으로 `hwpx-mcp` 서버 상태를 확인합니다.
|
|
36
|
-
|
|
37
|
-
---
|
|
38
|
-
|
|
39
|
-
## 작업 워크플로우
|
|
32
|
+
### 작업 워크플로우
|
|
40
33
|
|
|
41
34
|
### Step 1: 문서 열기
|
|
42
35
|
|
|
@@ -169,3 +162,184 @@ claude mcp add --transport stdio --scope user hwpx-mcp -- npx -y @p.e.c/hwpx-mcp
|
|
|
169
162
|
1. `save_document`로 저장한다 (원본 보존을 위해 다른 경로로 저장 권장)
|
|
170
163
|
2. `verify_integrity: true` 옵션으로 무결성 검증을 수행한다
|
|
171
164
|
3. 사용자에게 한글에서 열어 확인하도록 안내한다
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## 방식 B: Python lxml Fallback (MCP 없는 환경)
|
|
169
|
+
|
|
170
|
+
MCP를 지원하지 않는 도구(Codex CLI, Gemini CLI 등)에서는 Python으로 직접 HWPX XML을 수정한다.
|
|
171
|
+
|
|
172
|
+
### 사전 준비
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
pip3 install lxml --break-system-packages
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### HWPX 구조 이해
|
|
179
|
+
|
|
180
|
+
HWPX는 ZIP 파일이다. 내부 구조:
|
|
181
|
+
```
|
|
182
|
+
mimetype ← 시그니처 ("application/hwp+zip")
|
|
183
|
+
Contents/header.xml ← 문서 스타일/폰트 정의
|
|
184
|
+
Contents/section0.xml ← 본문 첫번째 섹션
|
|
185
|
+
Contents/section1.xml ← 두번째 섹션 ...
|
|
186
|
+
Contents/content.hpf ← 섹션 목록 (spine)
|
|
187
|
+
BinData/ ← 이미지, OLE 객체
|
|
188
|
+
META-INF/ ← 매니페스트
|
|
189
|
+
Preview/PrvText.txt ← 텍스트 미리보기
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### 파이프라인: 읽기 → 수정 → 저장
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from lxml import etree
|
|
196
|
+
import zipfile, shutil, subprocess, os
|
|
197
|
+
|
|
198
|
+
SRC = "원본.hwpx"
|
|
199
|
+
DST = "수정본.hwpx"
|
|
200
|
+
SECTION = "Contents/section0.xml"
|
|
201
|
+
|
|
202
|
+
# 1. 원본에서 XML 읽기
|
|
203
|
+
with zipfile.ZipFile(SRC, 'r') as z:
|
|
204
|
+
data = z.read(SECTION)
|
|
205
|
+
|
|
206
|
+
root = etree.fromstring(data)
|
|
207
|
+
ns_p = 'http://www.hancom.co.kr/hwpml/2011/paragraph'
|
|
208
|
+
|
|
209
|
+
# 2. 수정 (예시: 텍스트 치환)
|
|
210
|
+
for t_elem in root.iter(f'{{{ns_p}}}t'):
|
|
211
|
+
if t_elem.text and '치환대상' in t_elem.text:
|
|
212
|
+
t_elem.text = t_elem.text.replace('치환대상', '새텍스트')
|
|
213
|
+
|
|
214
|
+
# 3. XML 직렬화 (원본 선언부 보존)
|
|
215
|
+
xml_out = etree.tostring(root, xml_declaration=False, encoding='unicode')
|
|
216
|
+
final = '<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>' + xml_out
|
|
217
|
+
final_bytes = final.encode('utf-8')
|
|
218
|
+
|
|
219
|
+
# 4. 임시 파일에 저장
|
|
220
|
+
import tempfile
|
|
221
|
+
tmpdir = tempfile.mkdtemp()
|
|
222
|
+
os.makedirs(os.path.join(tmpdir, 'Contents'), exist_ok=True)
|
|
223
|
+
with open(os.path.join(tmpdir, SECTION), 'wb') as f:
|
|
224
|
+
f.write(final_bytes)
|
|
225
|
+
|
|
226
|
+
# 5. 원본 복사 후 zip CLI로 해당 파일만 교체
|
|
227
|
+
shutil.copy2(SRC, DST)
|
|
228
|
+
subprocess.run(['zip', DST, SECTION], cwd=tmpdir)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**중요**: Python `zipfile`로 전체 재패키징하면 파일이 손상될 수 있다. 반드시 **원본을 복사**하고 **`zip` CLI로 수정된 XML만 교체**한다.
|
|
232
|
+
|
|
233
|
+
### 표(테이블) 행 삭제 시 필수 처리
|
|
234
|
+
|
|
235
|
+
병합 셀(rowSpan > 1)이 있는 표에서 행을 삭제할 때 **3가지를 반드시 함께 처리**해야 한다. 누락하면 한글이 크래시한다.
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
ns_p = 'http://www.hancom.co.kr/hwpml/2011/paragraph'
|
|
239
|
+
|
|
240
|
+
def get_all_text(elem):
|
|
241
|
+
return ''.join(elem.itertext())
|
|
242
|
+
|
|
243
|
+
def delete_table_rows(root, table_index, rows_to_remove):
|
|
244
|
+
"""
|
|
245
|
+
표에서 행을 삭제한다. rowSpan/rowAddr/rowCnt를 자동 조정한다.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
root: section XML의 lxml root element
|
|
249
|
+
table_index: 삭제 대상 테이블 인덱스 (0-based)
|
|
250
|
+
rows_to_remove: 삭제할 행 인덱스 set (예: {9, 10, 11})
|
|
251
|
+
"""
|
|
252
|
+
tables = list(root.iter(f'{{{ns_p}}}tbl'))
|
|
253
|
+
if table_index >= len(tables):
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
tbl = tables[table_index]
|
|
257
|
+
all_rows = tbl.findall(f'{{{ns_p}}}tr')
|
|
258
|
+
|
|
259
|
+
# 1. rowSpan 조정: 삭제되지 않는 행의 셀이 삭제 행에 걸쳐있으면 rowSpan 줄이기
|
|
260
|
+
for i, tr in enumerate(all_rows):
|
|
261
|
+
if i in rows_to_remove:
|
|
262
|
+
continue
|
|
263
|
+
for tc in tr.findall(f'{{{ns_p}}}tc'):
|
|
264
|
+
addr = tc.find(f'{{{ns_p}}}cellAddr')
|
|
265
|
+
span = tc.find(f'{{{ns_p}}}cellSpan')
|
|
266
|
+
if addr is None or span is None:
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
row_addr = int(addr.get('rowAddr'))
|
|
270
|
+
row_span = int(span.get('rowSpan'))
|
|
271
|
+
if row_span <= 1:
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
# 이 셀의 span 범위 내에 삭제 대상 행이 몇 개 있는지 계산
|
|
275
|
+
span_range = set(range(row_addr, row_addr + row_span))
|
|
276
|
+
overlap = span_range & rows_to_remove
|
|
277
|
+
if overlap:
|
|
278
|
+
span.set('rowSpan', str(row_span - len(overlap)))
|
|
279
|
+
|
|
280
|
+
# 2. 행 삭제 (역순)
|
|
281
|
+
for i in sorted(rows_to_remove, reverse=True):
|
|
282
|
+
if i < len(all_rows):
|
|
283
|
+
tbl.remove(all_rows[i])
|
|
284
|
+
|
|
285
|
+
# 3. rowAddr 재번호
|
|
286
|
+
remaining_rows = tbl.findall(f'{{{ns_p}}}tr')
|
|
287
|
+
for new_idx, tr in enumerate(remaining_rows):
|
|
288
|
+
for tc in tr.findall(f'{{{ns_p}}}tc'):
|
|
289
|
+
addr = tc.find(f'{{{ns_p}}}cellAddr')
|
|
290
|
+
if addr is not None:
|
|
291
|
+
addr.set('rowAddr', str(new_idx))
|
|
292
|
+
|
|
293
|
+
# 4. rowCnt 업데이트
|
|
294
|
+
tbl.set('rowCnt', str(len(remaining_rows)))
|
|
295
|
+
return True
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
사용 예시:
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
# 표 0번에서 행 9, 10, 11 삭제
|
|
302
|
+
delete_table_rows(root, table_index=0, rows_to_remove={9, 10, 11})
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### 텍스트 검색/치환
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
# 문서 전체에서 텍스트 검색
|
|
309
|
+
for t_elem in root.iter(f'{{{ns_p}}}t'):
|
|
310
|
+
if t_elem.text and '검색어' in t_elem.text:
|
|
311
|
+
print(f"Found: {t_elem.text}")
|
|
312
|
+
|
|
313
|
+
# 치환
|
|
314
|
+
for t_elem in root.iter(f'{{{ns_p}}}t'):
|
|
315
|
+
if t_elem.text and '이전값' in t_elem.text:
|
|
316
|
+
t_elem.text = t_elem.text.replace('이전값', '새값')
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### 표 구조 확인
|
|
320
|
+
|
|
321
|
+
```python
|
|
322
|
+
for i, tbl in enumerate(root.iter(f'{{{ns_p}}}tbl')):
|
|
323
|
+
print(f"Table {i}: rowCnt={tbl.get('rowCnt')}, colCnt={tbl.get('colCnt')}")
|
|
324
|
+
for j, tr in enumerate(tbl.findall(f'{{{ns_p}}}tr')):
|
|
325
|
+
for tc in tr.findall(f'{{{ns_p}}}tc'):
|
|
326
|
+
addr = tc.find(f'{{{ns_p}}}cellAddr')
|
|
327
|
+
span = tc.find(f'{{{ns_p}}}cellSpan')
|
|
328
|
+
text = get_all_text(tc).strip()[:30]
|
|
329
|
+
ra = addr.get('rowAddr') if addr is not None else '?'
|
|
330
|
+
rs = span.get('rowSpan') if span is not None else '1'
|
|
331
|
+
if text:
|
|
332
|
+
print(f" Row {j} Cell: rowAddr={ra} rowSpan={rs} '{text}'")
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Fallback 작업 프로토콜
|
|
336
|
+
|
|
337
|
+
MCP가 없는 환경에서 HWPX 수정 요청 시:
|
|
338
|
+
|
|
339
|
+
1. **lxml 확인**: `python3 -c "from lxml import etree"` 실행. 없으면 설치
|
|
340
|
+
2. **구조 파악**: `unzip -l 파일.hwpx`로 내부 구조 확인
|
|
341
|
+
3. **텍스트 미리보기**: `Preview/PrvText.txt` 추출하여 내용 파악
|
|
342
|
+
4. **XML 읽기**: 대상 section XML을 zipfile로 읽기
|
|
343
|
+
5. **lxml로 수정**: 텍스트 치환, 행 삭제 등 실행 (위 함수 사용)
|
|
344
|
+
6. **원본 복사 + zip CLI 교체**: Python zipfile로 재패키징하지 않는다
|
|
345
|
+
7. **검증**: 한글에서 열어 확인하도록 안내
|