@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@p.e.c/boaz-skills",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "AI 코딩 도구용 스킬 모음 (Claude Code, Codex, Gemini)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,38 +5,31 @@ description: 'HWPX(한글) 파일 수정 스킬. MCP 서버를 통해 HWPX 문
5
5
 
6
6
  # HWPX (한글) 파일 수정
7
7
 
8
- > MCP 서버 기반으로 HWPX 문서를 프로그래밍 방식으로 편집하는 스킬
8
+ > HWPX 문서를 프로그래밍 방식으로 편집하는 스킬. 두 가지 방식을 지원한다.
9
9
 
10
10
  ## 핵심 요약
11
11
 
12
- **MCP 서버**: `@p.e.c/hwpx-mcp` HWPX 문서의 텍스트/표/이미지를 수정하는 77개 도구 제공
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
- ## 사전 준비 (Setup)
22
+ ## 방식 A: MCP 서버 (Claude Code 전용)
21
23
 
22
- ### 1. MCP 서버 설치
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
- ### 2. Claude Code 재시작
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. **검증**: 한글에서 열어 확인하도록 안내