claude-code-hwp-mcp 0.2.2 → 0.5.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Claude Code와 Claude Desktop에서 한글(HWP) 문서를 AI로 자동 편집하는 MCP 서버입니다.
4
4
 
5
- 85이상의 도구로 문서 열기, 표 채우기, 텍스트 편집, 서식 설정, PDF 변환까지 모두 자동화할 수 있습니다.
5
+ 94개 도구로 문서 열기, 표 채우기, 텍스트 편집, 서식 설정, 페이지 레이아웃, PDF 시각 검증까지 모두 자동화할 수 있습니다.
6
6
 
7
7
  > Windows 전용 | 한글 2014 이상 | Python 3.8+ | Claude Code + Claude Desktop 지원
8
8
 
@@ -299,7 +299,7 @@ Python 경로, pyhwpx 설치 여부, 한글 프로그램 설치 및 실행 상
299
299
 
300
300
  ---
301
301
 
302
- ## 기능 목록 (85개+)
302
+ ## 기능 목록 (94개)
303
303
 
304
304
  ### 환경/문서 관리 (6개)
305
305
 
@@ -379,16 +379,30 @@ Python 경로, pyhwpx 설치 여부, 한글 프로그램 설치 및 실행 상
379
379
  | hwp_set_cell_color | 셀 배경색 설정 |
380
380
  | hwp_set_table_border | 표 테두리 스타일 설정 |
381
381
 
382
- ### 이미지/레이아웃 (5개)
382
+ ### 페이지/레이아웃 (9개)
383
383
 
384
384
  | 도구 | 설명 |
385
385
  |------|------|
386
+ | hwp_set_page_setup | 여백, 용지 크기, 방향(가로/세로) 설정 |
387
+ | hwp_set_header_footer | 머리글/바닥글 삽입 |
388
+ | hwp_set_column | 다단 설정 (2단/3단, 구분선) |
389
+ | hwp_verify_layout | PDF→PNG 시각 검증 (PyMuPDF) |
386
390
  | hwp_insert_picture | 이미지 삽입 |
387
391
  | hwp_set_background_picture | 배경 이미지 설정 |
388
392
  | hwp_insert_line | 선(줄) 삽입 |
389
393
  | hwp_break_section | 섹션 나누기 |
390
394
  | hwp_break_column | 다단 나누기 |
391
395
 
396
+ ### 서식/그리기 (5개)
397
+
398
+ | 도구 | 설명 |
399
+ |------|------|
400
+ | hwp_apply_style | 문단 스타일 적용 ("제목1", "본문" 등) |
401
+ | hwp_set_cell_property | 셀 여백/수직정렬/텍스트방향/보호 |
402
+ | hwp_insert_textbox | 글상자 생성 (위치/크기 지정) |
403
+ | hwp_draw_line | 선 그리기 (두께/색상/스타일) |
404
+ | hwp_insert_caption | 표/그림 캡션 삽입 |
405
+
392
406
  ### 스마트/복합 도구 (16개)
393
407
 
394
408
  | 도구 | 설명 |
@@ -459,6 +473,130 @@ Python Bridge (hwp_service.py + pyhwpx)
459
473
 
460
474
  ---
461
475
 
476
+ ## HWP vs HWPX 파일 형식 차이
477
+
478
+ | 구분 | HWP (바이너리) | HWPX (XML 기반) |
479
+ |------|--------------|-----------------|
480
+ | 내부 구조 | OLE2 바이너리 | ZIP + XML |
481
+ | 텍스트 검색 | COM API (제한적) | XML 직접 검색 (안정적) |
482
+ | 찾기/바꾸기 | COM API (제한적) | XML 직접 치환 (안정적) |
483
+ | 표 생성/편집 | COM API | COM API |
484
+ | 문서 열기/저장 | COM API | COM API |
485
+
486
+ **HWPX 파일 사용을 권장합니다.** HWPX 파일의 텍스트 검색/치환은 XML을 직접 조작하므로 COM API보다 안정적입니다. 한글 프로그램에서 "다른 이름으로 저장" > "HWPX" 형식으로 변환할 수 있습니다.
487
+
488
+ ---
489
+
490
+ ## 알려진 제한사항
491
+
492
+ ### HWP 파일의 COM 텍스트 검색
493
+
494
+ HWP 바이너리 파일에서 `hwp_text_search`가 0건을 반환할 수 있습니다. 이는 한글 COM API(`HAction.Execute("FindReplace")`)의 반환값이 프로그래밍적으로 불안정한 설계 한계입니다.
495
+
496
+ 대안:
497
+ - `hwp_get_document_text`로 전체 텍스트를 가져와서 직접 검색
498
+ - 가능하면 HWPX 형식으로 변환하여 사용 (XML 검색은 안정적)
499
+
500
+ ### HWPX 파일 잠금
501
+
502
+ 한글에서 HWPX 파일을 열어둔 상태에서 XML 직접 편집을 시도하면 파일 잠금(EBUSY) 에러가 발생합니다. 이 경우 자동으로 COM 경로로 폴백합니다.
503
+
504
+ ---
505
+
506
+ ## 추천 워크플로우
507
+
508
+ ### 양식 채우기
509
+
510
+ ```
511
+ 1. hwp_open_document → 파일 열기
512
+ 2. hwp_smart_analyze → 문서 구조 파악
513
+ 3. hwp_smart_fill 또는 hwp_auto_fill_from_reference → 자동 채우기
514
+ 4. hwp_privacy_scan → 개인정보 확인
515
+ 5. hwp_save_document → 저장
516
+ ```
517
+
518
+ ### 텍스트 치환
519
+
520
+ ```
521
+ 1. hwp_open_document → 파일 열기
522
+ 2. hwp_find_replace 또는 hwp_find_replace_multi → 치환
523
+ 3. hwp_save_document → 저장
524
+ ```
525
+
526
+ ### 문서 분석
527
+
528
+ ```
529
+ 1. hwp_open_document → 파일 열기
530
+ 2. hwp_analyze_document → 전체 구조 분석
531
+ 3. hwp_get_tables → 표 데이터 확인
532
+ 4. hwp_word_count → 글자수 통계
533
+ ```
534
+
535
+ ---
536
+
537
+ ## 변경 이력
538
+
539
+ ### v0.5.0 (2026-03-25) — 94개 도구, 표 서식 대폭 강화
540
+
541
+ **신규 도구 9개:**
542
+ - `hwp_set_page_setup` — 여백, 용지 크기, 방향(가로/세로) 설정
543
+ - `hwp_set_header_footer` — 머리글/바닥글 삽입 (CreateAction 방식, 대화상자 없음)
544
+ - `hwp_set_column` — 다단 설정 (2단/3단, 구분선)
545
+ - `hwp_verify_layout` — PDF→PNG 시각 검증 (PyMuPDF 필요)
546
+ - `hwp_apply_style` — 문단 스타일 적용 ("제목1", "본문" 등)
547
+ - `hwp_set_cell_property` — 셀 여백/수직정렬/텍스트방향/보호
548
+ - `hwp_insert_textbox` — 글상자 생성 (위치/크기 지정)
549
+ - `hwp_draw_line` — 선 그리기 (두께/색상/스타일)
550
+ - `hwp_insert_caption` — 표/그림 캡션 삽입
551
+
552
+ **텍스트 서식 속성 25+ 추가 (insert_text):**
553
+ - 밑줄: underline_type(7종), underline_color
554
+ - 취소선: strikeout_type(4종), strikeout_color
555
+ - 효과: superscript, subscript, outline, shadow, emboss, engrave, small_caps
556
+ - 글꼴: font_name_latin(라틴 전용), shadow_color, use_kerning
557
+ - 배경: bg_color
558
+
559
+ **문단 서식 속성 11개 추가 (set_paragraph_style):**
560
+ - page_break_before, keep_with_next, widow_orphan
561
+ - line_wrap, snap_to_grid, auto_space_eAsian_eng/num
562
+ - break_latin_word, heading_type, keep_lines_together, condense
563
+
564
+ **표 기능 핵심 개선:**
565
+ - 셀 배경색: pyhwpx `cell_fill()` 내장 메서드 사용 (안정적)
566
+ - 셀 텍스트 정렬: `TableCellAlignCenterCenter` 액션 사용 (삽입 후 적용)
567
+ - 셀 병합: `TableCellBlockExtend` 방식으로 정확한 블록 선택
568
+ - 표 생성: col_widths(mm), row_heights(mm), alignment(left/center/right), header_style(Bold)
569
+ - 테두리: color(#RRGGBB), edges(방향별 적용) 지원
570
+
571
+ **버그 수정:**
572
+ - insert_text 자동 줄바꿈 — 각 호출이 독립 문단으로 생성
573
+ - set_header_footer: `CreateAction("HeaderFooter")` 방식 (대화상자 타임아웃 해결)
574
+ - apply_style: `Execute("Style")` + `SetItem("StyleName")` (대화상자 방지)
575
+ - draw_line: 대화형 InsertLine fallback 제거
576
+ - insert_footnote/endnote: try/except + CloseEx 추가
577
+ - get_cursor_context: page → total_pages + current_page(KeyIndicator)
578
+
579
+ ### v0.3.0 — HWPX XML 라우팅 + 10건 버그 수정
580
+
581
+ **버그 수정 (10건):**
582
+ - SelectAll 문서 파괴 수정 (table_create_from_data, gantt_chart 등)
583
+ - find_replace 전후 텍스트 비교 검증으로 개선
584
+ - text_search 선택 영역 기반 판단으로 개선
585
+ - find_and_append 커서 유실 수정 (Cancel → MoveRight)
586
+ - 버퍼 오버플로우 시 개별 요청만 거부
587
+ - XHwpMessageBoxMode 복원 (close_document)
588
+ - blank_template.hwpx 프로그래밍적 생성 (파일 의존성 제거)
589
+
590
+ **HWPX XML 라우팅:**
591
+ - HWPX 파일의 텍스트 검색/치환을 Node.js XML 엔진으로 직접 처리
592
+ - XML 실패 시(파일 잠금 등) COM 경로로 자동 폴백
593
+
594
+ **환경 진단 개선:**
595
+ - Microsoft Store Python 자동 감지 + 경고
596
+ - 한글 프로세스 실행 여부 체크 (tasklist)
597
+
598
+ ---
599
+
462
600
  ## 지원 한글 버전
463
601
 
464
602
  한글 2014, 2018, 2020, 2022, 2024 모두 지원합니다.
@@ -63,6 +63,7 @@ export declare class HwpBridge {
63
63
  getState(): BridgeState;
64
64
  setCurrentDocument(filePath: string | null): void;
65
65
  getCurrentDocument(): string | null;
66
+ getCurrentDocumentFormat(): string | null;
66
67
  getCachedAnalysis(): unknown | null;
67
68
  setCachedAnalysis(data: unknown): void;
68
69
  }
@@ -81,11 +81,22 @@ export class HwpBridge {
81
81
  });
82
82
  this.process.stdout?.on('data', (chunk) => {
83
83
  this.buffer += chunk.toString('utf-8');
84
- // Buffer 크기 제한 (10MB) 메모리 폭증 방지
84
+ // BUG-6 fix: 버퍼 초과 가장 오래된 대기 요청만 거부 (전체가 아닌 개별)
85
85
  if (this.buffer.length > this.MAX_BUFFER_SIZE) {
86
- console.error(`[HWP MCP Bridge] Buffer exceeded ${this.MAX_BUFFER_SIZE / 1024 / 1024}MB, clearing`);
87
- this.buffer = '';
88
- this.rejectAllPending(new Error('응답 크기가 너무 큽니다. 문서를 닫고 다시 열어주세요.'));
86
+ console.error(`[HWP MCP Bridge] Buffer exceeded ${this.MAX_BUFFER_SIZE / 1024 / 1024}MB — truncating oldest pending`);
87
+ // 버퍼를 비우되, 마지막 줄바꿈 이후 부분은 보존 (진행 중인 응답)
88
+ const lastNewline = this.buffer.lastIndexOf('\n');
89
+ this.buffer = lastNewline >= 0 ? this.buffer.slice(lastNewline + 1) : '';
90
+ // 가장 오래된 요청 하나만 거부
91
+ const oldestId = this.pending.keys().next().value;
92
+ if (oldestId) {
93
+ const oldest = this.pending.get(oldestId);
94
+ if (oldest) {
95
+ clearTimeout(oldest.timer);
96
+ oldest.reject(new Error('응답 크기가 너무 큽니다.'));
97
+ this.pending.delete(oldestId);
98
+ }
99
+ }
89
100
  return;
90
101
  }
91
102
  this.processBuffer();
@@ -278,23 +289,28 @@ export class HwpBridge {
278
289
  };
279
290
  return result;
280
291
  }
281
- // 3) 한글(HWP) COM 등록 체크
292
+ // 3) 한글(HWP) 설치 체크 — COM Dispatch 대신 파일 존재 + pyhwpx import 확인
293
+ // (COM Dispatch는 빈 한글 문서를 열어버리는 부작용이 있으므로 제거)
282
294
  try {
283
- await execFileAsync(pythonExe, ['-c', 'import win32com.client; o = win32com.client.gencache.EnsureDispatch("HWPFrame.HwpObject"); o.XHwpDocuments.Close(False); del o'], { timeout: 15000 });
284
- result.hwp = { found: true };
285
- }
286
- catch (err) {
287
- const msg = err.message || '';
288
- if (msg.includes('COM class not registered') || msg.includes('gencache')) {
289
- result.hwp = {
290
- found: false,
291
- guide: '한글(HWP) 프로그램이 설치되지 않았습니다.\n→ 한컴오피스 한글 설치 필요 (한글 2014 이상)\n→ 설치 후 한글을 한번 실행하여 초기 설정 완료',
292
- };
295
+ const { stdout } = await execFileAsync(pythonExe, ['-c',
296
+ 'import os; paths = [r"C:\\Program Files\\Hancom", r"C:\\Program Files (x86)\\Hancom"]; ' +
297
+ 'found = any(os.path.isdir(p) for p in paths); ' +
298
+ 'print("installed" if found else "not_found")'
299
+ ], { timeout: 5000 });
300
+ if (stdout.trim() === 'installed') {
301
+ result.hwp = { found: true };
293
302
  }
294
303
  else {
295
- result.hwp = { found: true };
304
+ // 폴더가 없어도 pyhwpx가 COM을 찾을 수 있으므로 pyhwpx import 성공이면 OK
305
+ result.hwp = { found: true, guide: '한컴 설치 폴더를 찾을 수 없지만, pyhwpx가 설치되어 있으므로 동작할 수 있습니다.' };
296
306
  }
297
307
  }
308
+ catch {
309
+ result.hwp = {
310
+ found: false,
311
+ guide: '한글(HWP) 프로그램이 설치되지 않았습니다.\n→ 한컴오피스 한글 설치 필요 (한글 2014 이상)\n→ 설치 후 한글을 한번 실행하여 초기 설정 완료',
312
+ };
313
+ }
298
314
  // 4) 한글 프로세스 실행 여부 체크
299
315
  try {
300
316
  const { stdout } = await execFileAsync('tasklist', ['/FI', 'IMAGENAME eq Hwp.exe', '/NH'], { timeout: 5000 });
@@ -329,6 +345,9 @@ export class HwpBridge {
329
345
  getCurrentDocument() {
330
346
  return this.currentDocumentPath;
331
347
  }
348
+ getCurrentDocumentFormat() {
349
+ return this.currentDocumentFormat;
350
+ }
332
351
  getCachedAnalysis() {
333
352
  return this.lastAnalysis;
334
353
  }
@@ -24,9 +24,28 @@ export declare function extractTextFromSection(doc: Document): string[];
24
24
  * CLAUDE.md 규칙: 수정 후 linesegarray 삭제 필수.
25
25
  */
26
26
  export declare function replaceTextInSection(doc: Document, find: string, replace: string): number;
27
+ /**
28
+ * HWPX section XML에서 텍스트 검색. COM FindReplace 우회용.
29
+ */
30
+ export declare function searchTextInSection(doc: Document, searchText: string): {
31
+ total: number;
32
+ results: Array<{
33
+ index: number;
34
+ paragraph: number;
35
+ context: string;
36
+ }>;
37
+ };
38
+ /**
39
+ * HWPX section XML에서 N번째 텍스트만 치환.
40
+ */
41
+ export declare function replaceTextNthInSection(doc: Document, find: string, replace: string, nth: number): boolean;
42
+ /**
43
+ * HWPX section XML에서 텍스트를 찾아 그 뒤에 추가.
44
+ */
45
+ export declare function findAndAppendInSection(doc: Document, find: string, appendText: string): boolean;
27
46
  /**
28
47
  * 빈 HWPX 파일 생성.
29
- * blank_template.hwpx 복사하고, 필요시 제목 삽입.
48
+ * BUG-9 fix: blank_template.hwpx 없으면 프로그래밍적으로 생성.
30
49
  */
31
50
  export declare function createBlankHwpx(outputPath: string, title?: string): Promise<void>;
32
51
  /**
@@ -114,6 +114,77 @@ export function replaceTextInSection(doc, find, replace) {
114
114
  }
115
115
  return count;
116
116
  }
117
+ // ── HWPX XML 검색 (COM 우회) ──
118
+ /**
119
+ * HWPX section XML에서 텍스트 검색. COM FindReplace 우회용.
120
+ */
121
+ export function searchTextInSection(doc, searchText) {
122
+ const results = [];
123
+ const paragraphs = doc.getElementsByTagNameNS(NS_HP, 'p');
124
+ let matchCount = 0;
125
+ for (let i = 0; i < paragraphs.length; i++) {
126
+ const runs = paragraphs[i].getElementsByTagNameNS(NS_HP, 'run');
127
+ let paraText = '';
128
+ for (let j = 0; j < runs.length; j++) {
129
+ const tNodes = runs[j].getElementsByTagNameNS(NS_HP, 't');
130
+ for (let k = 0; k < tNodes.length; k++) {
131
+ paraText += tNodes[k].textContent || '';
132
+ }
133
+ }
134
+ let pos = 0;
135
+ while ((pos = paraText.indexOf(searchText, pos)) !== -1) {
136
+ matchCount++;
137
+ const start = Math.max(0, pos - 20);
138
+ const end = Math.min(paraText.length, pos + searchText.length + 20);
139
+ results.push({
140
+ index: matchCount,
141
+ paragraph: i,
142
+ context: paraText.slice(start, end),
143
+ });
144
+ pos += searchText.length;
145
+ }
146
+ }
147
+ return { total: matchCount, results };
148
+ }
149
+ /**
150
+ * HWPX section XML에서 N번째 텍스트만 치환.
151
+ */
152
+ export function replaceTextNthInSection(doc, find, replace, nth) {
153
+ const tNodes = doc.getElementsByTagNameNS(NS_HP, 't');
154
+ let matchCount = 0;
155
+ for (let i = 0; i < tNodes.length; i++) {
156
+ const t = tNodes[i];
157
+ const text = t.textContent || '';
158
+ let pos = 0;
159
+ while ((pos = text.indexOf(find, pos)) !== -1) {
160
+ matchCount++;
161
+ if (matchCount === nth) {
162
+ t.textContent = text.slice(0, pos) + replace + text.slice(pos + find.length);
163
+ removeLinesegarray(doc);
164
+ return true;
165
+ }
166
+ pos += find.length;
167
+ }
168
+ }
169
+ return false;
170
+ }
171
+ /**
172
+ * HWPX section XML에서 텍스트를 찾아 그 뒤에 추가.
173
+ */
174
+ export function findAndAppendInSection(doc, find, appendText) {
175
+ const tNodes = doc.getElementsByTagNameNS(NS_HP, 't');
176
+ for (let i = 0; i < tNodes.length; i++) {
177
+ const t = tNodes[i];
178
+ const text = t.textContent || '';
179
+ const pos = text.indexOf(find);
180
+ if (pos !== -1) {
181
+ t.textContent = text.slice(0, pos + find.length) + appendText + text.slice(pos + find.length);
182
+ removeLinesegarray(doc);
183
+ return true;
184
+ }
185
+ }
186
+ return false;
187
+ }
117
188
  /**
118
189
  * linesegarray 요소 삭제 (CLAUDE.md 규칙 8).
119
190
  */
@@ -128,19 +199,55 @@ function removeLinesegarray(doc) {
128
199
  }
129
200
  }
130
201
  // ── 빈 HWPX 생성 ──
202
+ /**
203
+ * BUG-9 fix: blank_template.hwpx 파일 의존 제거.
204
+ * 최소 유효 HWPX를 프로그래밍적으로 생성.
205
+ */
206
+ async function createMinimalHwpx(outputPath, title) {
207
+ const zip = new JSZip();
208
+ // mimetype
209
+ zip.file('mimetype', 'application/hwp+zip');
210
+ // META-INF/manifest.xml
211
+ zip.file('META-INF/manifest.xml', `<?xml version="1.0" encoding="UTF-8"?>
212
+ <manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
213
+ <manifest:file-entry manifest:full-path="/" manifest:media-type="application/hwp+zip"/>
214
+ <manifest:file-entry manifest:full-path="Contents/section0.xml" manifest:media-type="text/xml"/>
215
+ <manifest:file-entry manifest:full-path="Contents/content.hpf" manifest:media-type="text/xml"/>
216
+ </manifest:manifest>`);
217
+ // Contents/content.hpf
218
+ zip.file('Contents/content.hpf', `<?xml version="1.0" encoding="UTF-8"?>
219
+ <hp:HWPMLPackageFormat xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
220
+ <hp:BodyText>
221
+ <hp:SectionRef hp:IDRef="0"/>
222
+ </hp:BodyText>
223
+ </hp:HWPMLPackageFormat>`);
224
+ // Contents/section0.xml
225
+ const titleText = title || '';
226
+ zip.file('Contents/section0.xml', `<?xml version="1.0" encoding="UTF-8"?>
227
+ <hp:sec xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
228
+ <hp:p>
229
+ <hp:run>
230
+ <hp:t>${titleText}</hp:t>
231
+ </hp:run>
232
+ </hp:p>
233
+ </hp:sec>`);
234
+ const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
235
+ fs.writeFileSync(outputPath, buffer);
236
+ }
131
237
  function getTemplatePath() {
132
- // ESM에서 __dirname 대안
133
238
  const thisFile = new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1');
134
239
  return path.join(path.dirname(thisFile), '../../blank_template.hwpx');
135
240
  }
136
241
  /**
137
242
  * 빈 HWPX 파일 생성.
138
- * blank_template.hwpx 복사하고, 필요시 제목 삽입.
243
+ * BUG-9 fix: blank_template.hwpx 없으면 프로그래밍적으로 생성.
139
244
  */
140
245
  export async function createBlankHwpx(outputPath, title) {
141
246
  const templatePath = getTemplatePath();
142
247
  if (!fs.existsSync(templatePath)) {
143
- throw new Error(`빈 HWPX 템플릿을 찾을 수 없습니다: ${templatePath}. 한글에서 빈 문서를 HWPX로 저장하세요.`);
248
+ // 템플릿 파일 없음 프로그래밍적 생성
249
+ await createMinimalHwpx(outputPath, title);
250
+ return;
144
251
  }
145
252
  fs.copyFileSync(templatePath, outputPath);
146
253
  if (title) {
package/dist/index.js CHANGED
@@ -19,8 +19,8 @@ const resolvedToolset = validToolsets.includes(toolset)
19
19
  : 'standard';
20
20
  const bridge = new HwpBridge();
21
21
  const server = new McpServer({
22
- name: 'claude-code-hwp-mcp',
23
- version: '0.2.2',
22
+ name: 'hwp-studio',
23
+ version: '0.4.1',
24
24
  });
25
25
  setupServer(server, bridge, resolvedToolset);
26
26
  const transport = new StdioServerTransport();