claude-code-hwp-mcp 0.2.2 → 0.3.1

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
@@ -459,6 +459,102 @@ Python Bridge (hwp_service.py + pyhwpx)
459
459
 
460
460
  ---
461
461
 
462
+ ## HWP vs HWPX 파일 형식 차이
463
+
464
+ | 구분 | HWP (바이너리) | HWPX (XML 기반) |
465
+ |------|--------------|-----------------|
466
+ | 내부 구조 | OLE2 바이너리 | ZIP + XML |
467
+ | 텍스트 검색 | COM API (제한적) | XML 직접 검색 (안정적) |
468
+ | 찾기/바꾸기 | COM API (제한적) | XML 직접 치환 (안정적) |
469
+ | 표 생성/편집 | COM API | COM API |
470
+ | 문서 열기/저장 | COM API | COM API |
471
+
472
+ **HWPX 파일 사용을 권장합니다.** HWPX 파일의 텍스트 검색/치환은 XML을 직접 조작하므로 COM API보다 안정적입니다. 한글 프로그램에서 "다른 이름으로 저장" > "HWPX" 형식으로 변환할 수 있습니다.
473
+
474
+ ---
475
+
476
+ ## 알려진 제한사항
477
+
478
+ ### HWP 파일의 COM 텍스트 검색
479
+
480
+ HWP 바이너리 파일에서 `hwp_text_search`가 0건을 반환할 수 있습니다. 이는 한글 COM API(`HAction.Execute("FindReplace")`)의 반환값이 프로그래밍적으로 불안정한 설계 한계입니다.
481
+
482
+ 대안:
483
+ - `hwp_get_document_text`로 전체 텍스트를 가져와서 직접 검색
484
+ - 가능하면 HWPX 형식으로 변환하여 사용 (XML 검색은 안정적)
485
+
486
+ ### HWPX 파일 잠금
487
+
488
+ 한글에서 HWPX 파일을 열어둔 상태에서 XML 직접 편집을 시도하면 파일 잠금(EBUSY) 에러가 발생합니다. 이 경우 자동으로 COM 경로로 폴백합니다.
489
+
490
+ ---
491
+
492
+ ## 추천 워크플로우
493
+
494
+ ### 양식 채우기
495
+
496
+ ```
497
+ 1. hwp_open_document → 파일 열기
498
+ 2. hwp_smart_analyze → 문서 구조 파악
499
+ 3. hwp_smart_fill 또는 hwp_auto_fill_from_reference → 자동 채우기
500
+ 4. hwp_privacy_scan → 개인정보 확인
501
+ 5. hwp_save_document → 저장
502
+ ```
503
+
504
+ ### 텍스트 치환
505
+
506
+ ```
507
+ 1. hwp_open_document → 파일 열기
508
+ 2. hwp_find_replace 또는 hwp_find_replace_multi → 치환
509
+ 3. hwp_save_document → 저장
510
+ ```
511
+
512
+ ### 문서 분석
513
+
514
+ ```
515
+ 1. hwp_open_document → 파일 열기
516
+ 2. hwp_analyze_document → 전체 구조 분석
517
+ 3. hwp_get_tables → 표 데이터 확인
518
+ 4. hwp_word_count → 글자수 통계
519
+ ```
520
+
521
+ ---
522
+
523
+ ## v0.3.0 변경사항
524
+
525
+ ### 버그 수정 (10건)
526
+
527
+ - SelectAll 문서 파괴 수정 (table_create_from_data, gantt_chart 등)
528
+ - find_replace 전후 텍스트 비교 검증으로 개선
529
+ - text_search 선택 영역 기반 판단으로 개선
530
+ - find_and_append 커서 유실 수정 (Cancel → MoveRight)
531
+ - 버퍼 오버플로우 시 개별 요청만 거부
532
+ - XHwpMessageBoxMode 복원 (close_document)
533
+ - insert_markdown 표 파싱 추가
534
+ - blank_template.hwpx 프로그래밍적 생성 (파일 의존성 제거)
535
+ - except pass → stderr 로깅 변환
536
+ - MAX_TABLES scanned_tables 필드 분리
537
+
538
+ ### HWPX XML 라우팅
539
+
540
+ HWPX 파일의 텍스트 검색/치환을 Node.js XML 엔진으로 직접 처리:
541
+ - hwp_find_replace → XML replaceTextInSection
542
+ - hwp_find_replace_multi → XML 루프 치환
543
+ - hwp_find_replace_nth → XML N번째 치환
544
+ - hwp_find_and_append → XML findAndAppend
545
+ - hwp_text_search → XML searchTextInSection
546
+
547
+ XML 실패 시(파일 잠금 등) COM 경로로 자동 폴백.
548
+
549
+ ### 환경 진단 개선
550
+
551
+ - Microsoft Store Python 자동 감지 + 경고
552
+ - 한글 프로세스 실행 여부 체크 (tasklist)
553
+ - Python 실행 경로 표시
554
+ - postinstall 스크립트 ESM 호환
555
+
556
+ ---
557
+
462
558
  ## 지원 한글 버전
463
559
 
464
560
  한글 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();
@@ -329,6 +340,9 @@ export class HwpBridge {
329
340
  getCurrentDocument() {
330
341
  return this.currentDocumentPath;
331
342
  }
343
+ getCurrentDocumentFormat() {
344
+ return this.currentDocumentFormat;
345
+ }
332
346
  getCachedAnalysis() {
333
347
  return this.lastAnalysis;
334
348
  }
@@ -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
@@ -20,7 +20,7 @@ const resolvedToolset = validToolsets.includes(toolset)
20
20
  const bridge = new HwpBridge();
21
21
  const server = new McpServer({
22
22
  name: 'claude-code-hwp-mcp',
23
- version: '0.2.2',
23
+ version: '0.3.1',
24
24
  });
25
25
  setupServer(server, bridge, resolvedToolset);
26
26
  const transport = new StdioServerTransport();
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Analysis tools: analyze, get text, get tables, get fields
3
+ * HWPX 파일은 XML 직접 검색으로 라우팅 (COM 우회)
3
4
  */
4
5
  import { z } from 'zod';
5
6
  import path from 'node:path';
6
7
  import fs from 'node:fs';
8
+ import { readHwpxXml, searchTextInSection } from '../hwpx-engine.js';
7
9
  const HWP_EXTENSIONS = new Set(['.hwp', '.hwpx']);
8
10
  const ANALYSIS_TIMEOUT = 60000;
9
11
  async function ensureAnalysis(bridge, filePath) {
@@ -141,10 +143,26 @@ export function registerAnalysisTools(server, bridge, toolset = 'standard') {
141
143
  search: z.string().describe('검색할 텍스트'),
142
144
  max_results: z.number().int().min(1).optional().describe('최대 검색 결과 수 (기본 50)'),
143
145
  }, async ({ search, max_results }) => {
144
- if (!bridge.getCurrentDocument()) {
146
+ const filePath = bridge.getCurrentDocument();
147
+ if (!filePath) {
145
148
  return { content: [{ type: 'text', text: JSON.stringify({ error: '열린 문서가 없습니다. hwp_open_document로 문서를 열어주세요.' }) }], isError: true };
146
149
  }
147
150
  try {
151
+ // HWPX → XML 직접 검색 시도. EBUSY 시 COM 폴백.
152
+ if (bridge.getCurrentDocumentFormat() === 'HWPX') {
153
+ try {
154
+ const doc = await readHwpxXml(filePath, 'Contents/section0.xml');
155
+ const result = searchTextInSection(doc, search);
156
+ const limited = max_results ? result.results.slice(0, max_results) : result.results.slice(0, 50);
157
+ return { content: [{ type: 'text', text: JSON.stringify({
158
+ search, total_found: result.total, results: limited, engine: 'xml',
159
+ }) }] };
160
+ }
161
+ catch (xmlErr) {
162
+ console.error('[text_search] XML failed, falling back to COM:', xmlErr.message);
163
+ }
164
+ }
165
+ // COM 경로 (HWP 또는 HWPX XML 실패 시 폴백)
148
166
  await bridge.ensureRunning();
149
167
  const params = { search };
150
168
  if (max_results)
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Editing tools: fill fields, fill table cells, find/replace, insert text
3
+ * HWPX 파일은 XML 직접 조작으로 라우팅 (COM 우회)
3
4
  */
4
5
  import { z } from 'zod';
5
6
  import path from 'node:path';
7
+ import { readHwpxXml, writeHwpxXml, replaceTextInSection, replaceTextNthInSection, findAndAppendInSection } from '../hwpx-engine.js';
6
8
  const FILL_TIMEOUT = 60000;
7
9
  export function registerEditingTools(server, bridge, toolset = 'standard') {
8
10
  // --- standard 이상에서만: fill_fields ---
@@ -121,13 +123,30 @@ export function registerEditingTools(server, bridge, toolset = 'standard') {
121
123
  replace: z.string().describe('바꿀 텍스트'),
122
124
  use_regex: z.boolean().optional().describe('정규식 사용 여부 (기본: false)'),
123
125
  }, async ({ find, replace, use_regex }) => {
124
- if (!bridge.getCurrentDocument()) {
126
+ const filePath = bridge.getCurrentDocument();
127
+ if (!filePath) {
125
128
  return { content: [{ type: 'text', text: JSON.stringify({
126
129
  error: '열린 문서가 없습니다. hwp_open_document로 문서를 열어주세요.',
127
- hint: 'Python 프로세스가 재시작되면 열린 문서 상태가 초기화됩니다.',
128
130
  }) }], isError: true };
129
131
  }
130
132
  try {
133
+ // HWPX → XML 직접 치환 시도 (COM 우회). EBUSY 시 COM 폴백.
134
+ if (bridge.getCurrentDocumentFormat() === 'HWPX' && !use_regex) {
135
+ try {
136
+ const doc = await readHwpxXml(filePath, 'Contents/section0.xml');
137
+ const count = replaceTextInSection(doc, find, replace);
138
+ await writeHwpxXml(filePath, filePath, 'Contents/section0.xml', doc);
139
+ bridge.setCachedAnalysis(null);
140
+ return { content: [{ type: 'text', text: JSON.stringify({
141
+ status: 'ok', find, replace, replaced: count > 0, count, engine: 'xml',
142
+ }) }] };
143
+ }
144
+ catch (xmlErr) {
145
+ // 파일 잠금(EBUSY) 등 XML 실패 시 COM 폴백
146
+ console.error('[find_replace] XML failed, falling back to COM:', xmlErr.message);
147
+ }
148
+ }
149
+ // COM 경로 (HWP 또는 HWPX XML 실패 시 폴백)
131
150
  await bridge.ensureRunning();
132
151
  const params = { find, replace };
133
152
  if (use_regex)
@@ -152,12 +171,38 @@ export function registerEditingTools(server, bridge, toolset = 'standard') {
152
171
  })).describe('치환 목록'),
153
172
  use_regex: z.boolean().optional().describe('정규식 사용 여부 (기본: false)'),
154
173
  }, async ({ replacements, use_regex }) => {
155
- if (!bridge.getCurrentDocument()) {
174
+ const filePath = bridge.getCurrentDocument();
175
+ if (!filePath) {
156
176
  return { content: [{ type: 'text', text: JSON.stringify({
157
177
  error: '열린 문서가 없습니다. hwp_open_document로 문서를 열어주세요.',
158
178
  }) }], isError: true };
159
179
  }
160
180
  try {
181
+ // HWPX → XML 직접 다건 치환 시도. EBUSY 시 COM 폴백.
182
+ if (bridge.getCurrentDocumentFormat() === 'HWPX' && !use_regex) {
183
+ try {
184
+ const doc = await readHwpxXml(filePath, 'Contents/section0.xml');
185
+ const results = [];
186
+ let totalCount = 0;
187
+ for (const item of replacements) {
188
+ const count = replaceTextInSection(doc, item.find, item.replace);
189
+ results.push({ find: item.find, replaced: count > 0, count });
190
+ totalCount += count;
191
+ }
192
+ if (totalCount > 0) {
193
+ await writeHwpxXml(filePath, filePath, 'Contents/section0.xml', doc);
194
+ }
195
+ bridge.setCachedAnalysis(null);
196
+ return { content: [{ type: 'text', text: JSON.stringify({
197
+ status: 'ok', results, total: results.length,
198
+ success: results.filter(r => r.replaced).length, engine: 'xml',
199
+ }) }] };
200
+ }
201
+ catch (xmlErr) {
202
+ console.error('[find_replace_multi] XML failed, falling back to COM:', xmlErr.message);
203
+ }
204
+ }
205
+ // COM 경로 (HWP 또는 HWPX XML 실패 시 폴백)
161
206
  await bridge.ensureRunning();
162
207
  const params = { replacements };
163
208
  if (use_regex)
@@ -178,12 +223,30 @@ export function registerEditingTools(server, bridge, toolset = 'standard') {
178
223
  append_text: z.string().describe('찾은 텍스트 뒤에 추가할 텍스트'),
179
224
  color: z.array(z.number().int().min(0).max(255)).length(3).optional().describe('텍스트 색상 [R, G, B] (0-255)'),
180
225
  }, async ({ find, append_text, color }) => {
181
- if (!bridge.getCurrentDocument()) {
226
+ const filePath = bridge.getCurrentDocument();
227
+ if (!filePath) {
182
228
  return { content: [{ type: 'text', text: JSON.stringify({
183
229
  error: '열린 문서가 없습니다. hwp_open_document로 문서를 열어주세요.',
184
230
  }) }], isError: true };
185
231
  }
186
232
  try {
233
+ // HWPX → XML 직접 조작 시도. EBUSY 시 COM 폴백.
234
+ if (bridge.getCurrentDocumentFormat() === 'HWPX' && !color) {
235
+ try {
236
+ const doc = await readHwpxXml(filePath, 'Contents/section0.xml');
237
+ const found = findAndAppendInSection(doc, find, append_text);
238
+ if (!found) {
239
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'not_found', find, engine: 'xml' }) }] };
240
+ }
241
+ await writeHwpxXml(filePath, filePath, 'Contents/section0.xml', doc);
242
+ bridge.setCachedAnalysis(null);
243
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'ok', find, appended: true, engine: 'xml' }) }] };
244
+ }
245
+ catch (xmlErr) {
246
+ console.error('[find_and_append] XML failed, falling back to COM:', xmlErr.message);
247
+ }
248
+ }
249
+ // COM 경로 (HWP 또는 HWPX XML 실패 시 폴백)
187
250
  await bridge.ensureRunning();
188
251
  const params = { find, append_text };
189
252
  if (color)
@@ -292,12 +355,31 @@ export function registerEditingTools(server, bridge, toolset = 'standard') {
292
355
  replace: z.string().describe('바꿀 텍스트'),
293
356
  nth: z.number().int().min(1).describe('몇 번째 매칭을 치환할지 (1부터 시작)'),
294
357
  }, async ({ find, replace, nth }) => {
295
- if (!bridge.getCurrentDocument()) {
358
+ const filePath = bridge.getCurrentDocument();
359
+ if (!filePath) {
296
360
  return { content: [{ type: 'text', text: JSON.stringify({
297
361
  error: '열린 문서가 없습니다. hwp_open_document로 문서를 열어주세요.',
298
362
  }) }], isError: true };
299
363
  }
300
364
  try {
365
+ // HWPX → XML 직접 N번째 치환 시도. EBUSY 시 COM 폴백.
366
+ if (bridge.getCurrentDocumentFormat() === 'HWPX') {
367
+ try {
368
+ const doc = await readHwpxXml(filePath, 'Contents/section0.xml');
369
+ const replaced = replaceTextNthInSection(doc, find, replace, nth);
370
+ if (replaced) {
371
+ await writeHwpxXml(filePath, filePath, 'Contents/section0.xml', doc);
372
+ }
373
+ bridge.setCachedAnalysis(null);
374
+ return { content: [{ type: 'text', text: JSON.stringify({
375
+ status: 'ok', find, replace, nth, replaced, engine: 'xml',
376
+ }) }] };
377
+ }
378
+ catch (xmlErr) {
379
+ console.error('[find_replace_nth] XML failed, falling back to COM:', xmlErr.message);
380
+ }
381
+ }
382
+ // HWP → Python COM
301
383
  await bridge.ensureRunning();
302
384
  const response = await bridge.send('find_replace_nth', { find, replace, nth });
303
385
  if (!response.success) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hwp-mcp",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "MCP server for HWP (한글) document automation via pyhwpx COM API. 85+ tools for document editing, analysis, and AI-powered filling.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,7 +14,7 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "build": "tsc",
17
- "postinstall": "node -e \"try{require('child_process').execSync('python -c \\\"import pyhwpx\\\"',{stdio:'ignore'})}catch{console.log('\\n[claude-code-hwp-mcp] pyhwpx 미설치. 실행하세요: pip install pyhwpx pywin32\\n')}\"",
17
+ "postinstall": "node --input-type=commonjs -e \"try{require('child_process').execSync('python -c \\\"import pyhwpx\\\"',{stdio:'ignore'})}catch(e){console.log('\\n[claude-code-hwp-mcp] pyhwpx 미설치. 실행하세요: pip install pyhwpx pywin32\\n')}\"",
18
18
  "prepublishOnly": "npm run build",
19
19
  "start": "node dist/index.js",
20
20
  "dev": "tsx src/index.ts"
@@ -91,8 +91,8 @@ def analyze_document(hwp, file_path, already_open=False):
91
91
  # 커서를 문서 처음으로 이동
92
92
  try:
93
93
  hwp.MovePos(2) # movePOS_START: 문서 처음으로
94
- except Exception:
95
- pass
94
+ except Exception as e:
95
+ print(f"[WARN] MovePos failed: {e}", file=sys.stderr)
96
96
 
97
97
  result = {
98
98
  "file_path": file_path,
@@ -131,13 +131,16 @@ def analyze_document(hwp, file_path, already_open=False):
131
131
  result["tables"].append(table_info)
132
132
  try:
133
133
  hwp.Cancel()
134
- except Exception:
135
- pass
134
+ except Exception as e:
135
+ print(f"[WARN] Cancel failed: {e}", file=sys.stderr)
136
136
  table_idx += 1
137
137
  except Exception:
138
138
  break
139
+ # BUG-7 fix: 실제 발견된 표 수와 스캔 상한 분리
140
+ result["scanned_tables"] = table_idx
139
141
  if table_idx >= MAX_TABLES:
140
142
  result["tables_truncated"] = True
143
+ result["tables_truncated_message"] = f"표가 {MAX_TABLES}개 이상일 수 있습니다. 처음 {MAX_TABLES}개만 분석했습니다."
141
144
  print(f"[WARN] Table scan capped at {MAX_TABLES}", file=sys.stderr)
142
145
  except Exception as e:
143
146
  print(f"[WARN] Table extraction failed: {e}", file=sys.stderr)
@@ -152,8 +155,8 @@ def analyze_document(hwp, file_path, already_open=False):
152
155
  value = ""
153
156
  try:
154
157
  value = hwp.GetFieldText(field.strip()) or ""
155
- except Exception:
156
- pass
158
+ except Exception as e:
159
+ print(f"[WARN] GetFieldText failed: {e}", file=sys.stderr)
157
160
  result["fields"].append({
158
161
  "name": field.strip(),
159
162
  "value": value,
@@ -194,8 +197,8 @@ def analyze_document(hwp, file_path, already_open=False):
194
197
  if scan_started:
195
198
  try:
196
199
  hwp.ReleaseScan()
197
- except Exception:
198
- pass
200
+ except Exception as e:
201
+ print(f"[WARN] ReleaseScan failed: {e}", file=sys.stderr)
199
202
 
200
203
  return result
201
204
 
@@ -235,8 +238,8 @@ def map_table_cells(hwp, table_idx, max_cells=200):
235
238
  finally:
236
239
  try:
237
240
  hwp.HAction.Run("Cancel")
238
- except Exception:
239
- pass
241
+ except Exception as e:
242
+ print(f"[WARN] Cancel in map_table_cells: {e}", file=sys.stderr)
240
243
 
241
244
  cell_map.append({
242
245
  "tab": i,
@@ -251,8 +254,8 @@ def map_table_cells(hwp, table_idx, max_cells=200):
251
254
 
252
255
  try:
253
256
  hwp.Cancel()
254
- except Exception:
255
- pass
257
+ except Exception as e:
258
+ print(f"[WARN] Final cancel in map_table_cells: {e}", file=sys.stderr)
256
259
 
257
260
  return {
258
261
  "table_index": table_idx,
@@ -28,8 +28,8 @@ def insert_text_with_color(hwp, text, rgb=None):
28
28
  act.GetDefault("CharShape", pset.HSet)
29
29
  pset.TextColor = hwp.RGBColor(0, 0, 0)
30
30
  act.Execute("CharShape", pset.HSet)
31
- except Exception:
32
- pass
31
+ except Exception as e:
32
+ print(f"[WARN] {e}", file=sys.stderr)
33
33
 
34
34
 
35
35
  def insert_text_with_style(hwp, text, style=None):
@@ -102,24 +102,24 @@ def insert_text_with_style(hwp, text, style=None):
102
102
  try:
103
103
  pset.SpacingHangul = int(style["char_spacing"])
104
104
  pset.SpacingLatin = int(style["char_spacing"])
105
- except Exception:
106
- pass
105
+ except Exception as e:
106
+ print(f"[WARN] {e}", file=sys.stderr)
107
107
  if "width_ratio" in style:
108
108
  try:
109
109
  pset.RatioHangul = int(style["width_ratio"])
110
110
  pset.RatioLatin = int(style["width_ratio"])
111
- except Exception:
112
- pass
111
+ except Exception as e:
112
+ print(f"[WARN] {e}", file=sys.stderr)
113
113
  if "font_name_hanja" in style:
114
114
  try:
115
115
  pset.FaceNameHanja = style["font_name_hanja"]
116
- except Exception:
117
- pass
116
+ except Exception as e:
117
+ print(f"[WARN] {e}", file=sys.stderr)
118
118
  if "font_name_japanese" in style:
119
119
  try:
120
120
  pset.FaceNameJapanese = style["font_name_japanese"]
121
- except Exception:
122
- pass
121
+ except Exception as e:
122
+ print(f"[WARN] {e}", file=sys.stderr)
123
123
 
124
124
  act.Execute("CharShape", pset.HSet)
125
125
 
@@ -137,14 +137,14 @@ def insert_text_with_style(hwp, text, style=None):
137
137
  try:
138
138
  pset.SpacingHangul = saved_char_spacing
139
139
  pset.SpacingLatin = saved_char_spacing
140
- except Exception:
141
- pass
140
+ except Exception as e:
141
+ print(f"[WARN] {e}", file=sys.stderr)
142
142
  if saved_width_ratio is not None:
143
143
  try:
144
144
  pset.RatioHangul = saved_width_ratio
145
145
  pset.RatioLatin = saved_width_ratio
146
- except Exception:
147
- pass
146
+ except Exception as e:
147
+ print(f"[WARN] {e}", file=sys.stderr)
148
148
  act.Execute("CharShape", pset.HSet)
149
149
 
150
150
 
@@ -170,42 +170,42 @@ def set_paragraph_style(hwp, style=None):
170
170
  if "align" in style:
171
171
  try:
172
172
  pset.AlignType = align_map.get(style["align"], 0)
173
- except Exception:
174
- pass
173
+ except Exception as e:
174
+ print(f"[WARN] {e}", file=sys.stderr)
175
175
  if "line_spacing" in style:
176
176
  try:
177
177
  pset.LineSpacingType = style.get("line_spacing_type", 0)
178
- except Exception:
179
- pass
178
+ except Exception as e:
179
+ print(f"[WARN] {e}", file=sys.stderr)
180
180
  try:
181
181
  pset.LineSpacing = int(style["line_spacing"])
182
- except Exception:
183
- pass
182
+ except Exception as e:
183
+ print(f"[WARN] {e}", file=sys.stderr)
184
184
  if "space_before" in style:
185
185
  try:
186
186
  pset.PrevSpacing = int(style["space_before"] * 100)
187
- except Exception:
188
- pass
187
+ except Exception as e:
188
+ print(f"[WARN] {e}", file=sys.stderr)
189
189
  if "space_after" in style:
190
190
  try:
191
191
  pset.NextSpacing = int(style["space_after"] * 100)
192
- except Exception:
193
- pass
192
+ except Exception as e:
193
+ print(f"[WARN] {e}", file=sys.stderr)
194
194
  if "indent" in style:
195
195
  try:
196
196
  pset.Indentation = int(style["indent"] * 100)
197
- except Exception:
198
- pass
197
+ except Exception as e:
198
+ print(f"[WARN] {e}", file=sys.stderr)
199
199
  if "left_margin" in style:
200
200
  try:
201
201
  pset.LeftMargin = int(style["left_margin"] * 100)
202
- except Exception:
203
- pass
202
+ except Exception as e:
203
+ print(f"[WARN] {e}", file=sys.stderr)
204
204
  if "right_margin" in style:
205
205
  try:
206
206
  pset.RightMargin = int(style["right_margin"] * 100)
207
- except Exception:
208
- pass
207
+ except Exception as e:
208
+ print(f"[WARN] {e}", file=sys.stderr)
209
209
 
210
210
  act.Execute("ParaShape", pset.HSet)
211
211
 
@@ -346,8 +346,8 @@ def get_cell_format(hwp, table_idx, cell_tab):
346
346
  hwp.HAction.Run("SelectAll")
347
347
  text_preview = hwp.GetTextFile("TEXT", "saveblock").strip()[:100]
348
348
  hwp.HAction.Run("Cancel")
349
- except Exception:
350
- pass
349
+ except Exception as e:
350
+ print(f"[WARN] {e}", file=sys.stderr)
351
351
 
352
352
  char = get_char_shape(hwp)
353
353
  para = get_para_shape(hwp)
@@ -362,8 +362,8 @@ def get_cell_format(hwp, table_idx, cell_tab):
362
362
  finally:
363
363
  try:
364
364
  hwp.Cancel()
365
- except Exception:
366
- pass
365
+ except Exception as e:
366
+ print(f"[WARN] {e}", file=sys.stderr)
367
367
 
368
368
 
369
369
  def get_table_format_summary(hwp, table_idx, sample_tabs=None):
@@ -419,8 +419,8 @@ def _navigate_to_tab(hwp, table_idx, target_tab, current_tab):
419
419
  if moves < 0:
420
420
  try:
421
421
  hwp.Cancel()
422
- except Exception:
423
- pass
422
+ except Exception as e:
423
+ print(f"[WARN] {e}", file=sys.stderr)
424
424
  hwp.get_into_nth_table(table_idx)
425
425
  moves = target_tab
426
426
  for _ in range(moves):
@@ -477,8 +477,8 @@ def fill_table_cells_by_tab(hwp, table_idx, cells):
477
477
  finally:
478
478
  try:
479
479
  hwp.Cancel()
480
- except Exception:
481
- pass
480
+ except Exception as e:
481
+ print(f"[WARN] {e}", file=sys.stderr)
482
482
 
483
483
  return result
484
484
 
@@ -530,8 +530,8 @@ def smart_fill_table_cells(hwp, table_idx, cells):
530
530
  finally:
531
531
  try:
532
532
  hwp.Cancel()
533
- except Exception:
534
- pass
533
+ except Exception as e:
534
+ print(f"[WARN] {e}", file=sys.stderr)
535
535
 
536
536
  return result
537
537
 
@@ -539,17 +539,73 @@ def smart_fill_table_cells(hwp, table_idx, cells):
539
539
  def insert_markdown(hwp, md_text):
540
540
  """마크다운 텍스트를 한글 서식으로 변환하여 삽입.
541
541
 
542
- 지원: # 제목, **굵게**, *기울임*, - 목록, 일반 텍스트.
542
+ 지원: # 제목, **굵게**, *기울임*, - 목록, | 표 |, > 인용, --- 구분선.
543
+ BUG-5 fix: 마크다운 표 파싱 추가.
543
544
  """
544
545
  import re
545
546
 
546
547
  lines = md_text.split('\n')
547
548
  inserted = 0
549
+ i = 0
550
+
551
+ while i < len(lines):
552
+ stripped = lines[i].strip()
548
553
 
549
- for line in lines:
550
- stripped = line.strip()
551
554
  if not stripped:
552
555
  hwp.insert_text('\r\n')
556
+ i += 1
557
+ continue
558
+
559
+ # 수평선 (---, ***, ___)
560
+ if re.match(r'^[-*_]{3,}$', stripped):
561
+ hwp.insert_text('─' * 40 + '\r\n')
562
+ inserted += 1
563
+ i += 1
564
+ continue
565
+
566
+ # 마크다운 표 (| 로 시작하는 연속된 줄)
567
+ if stripped.startswith('|') and '|' in stripped[1:]:
568
+ table_lines = []
569
+ while i < len(lines) and lines[i].strip().startswith('|'):
570
+ row_text = lines[i].strip()
571
+ # 구분선(|---|---|) 건너뛰기
572
+ if re.match(r'^\|[\s\-:]+\|', row_text):
573
+ i += 1
574
+ continue
575
+ # 셀 파싱
576
+ cells = [c.strip() for c in row_text.split('|')]
577
+ cells = [c for c in cells if c] # 빈 문자열 제거
578
+ table_lines.append(cells)
579
+ i += 1
580
+ # 표 생성
581
+ if table_lines:
582
+ rows = len(table_lines)
583
+ cols = max(len(row) for row in table_lines)
584
+ hwp.create_table(rows, cols)
585
+ for r, row in enumerate(table_lines):
586
+ for c in range(cols):
587
+ val = row[c] if c < len(row) else ''
588
+ if val:
589
+ if r == 0:
590
+ insert_text_with_style(hwp, val, {"bold": True})
591
+ else:
592
+ hwp.insert_text(val)
593
+ if c < cols - 1 or r < rows - 1:
594
+ hwp.TableRightCell()
595
+ try:
596
+ hwp.Cancel()
597
+ except Exception:
598
+ pass
599
+ hwp.insert_text('\r\n')
600
+ inserted += 1
601
+ continue
602
+
603
+ # 인용문 (>)
604
+ if stripped.startswith('>'):
605
+ quote_text = stripped.lstrip('>').strip()
606
+ hwp.insert_text(' │ ' + quote_text + '\r\n')
607
+ inserted += 1
608
+ i += 1
553
609
  continue
554
610
 
555
611
  # 제목 (# ~ ###)
@@ -557,30 +613,32 @@ def insert_markdown(hwp, md_text):
557
613
  if heading_match:
558
614
  level = len(heading_match.group(1))
559
615
  title_text = heading_match.group(2)
560
- # 제목: Bold + 큰 글씨
561
616
  sizes = {1: 22, 2: 16, 3: 13}
562
617
  insert_text_with_style(hwp, title_text + '\r\n', {
563
618
  "bold": True,
564
619
  "font_size": sizes.get(level, 13),
565
620
  })
566
621
  inserted += 1
622
+ i += 1
567
623
  continue
568
624
 
569
- # 목록 (- 또는 * 또는 숫자.)
625
+ # 목록 (- 또는 *)
570
626
  list_match = re.match(r'^[\-\*]\s+(.+)$', stripped)
571
627
  if list_match:
572
628
  hwp.insert_text(' ◦ ' + list_match.group(1) + '\r\n')
573
629
  inserted += 1
630
+ i += 1
574
631
  continue
575
632
 
633
+ # 번호 목록
576
634
  numbered_match = re.match(r'^(\d+)\.\s+(.+)$', stripped)
577
635
  if numbered_match:
578
636
  hwp.insert_text(' ' + numbered_match.group(1) + '. ' + numbered_match.group(2) + '\r\n')
579
637
  inserted += 1
638
+ i += 1
580
639
  continue
581
640
 
582
641
  # 인라인 서식 처리 (**굵게**, *기울임*)
583
- # 간단 처리: **text** → text (bold로 삽입), 나머지 일반
584
642
  parts = re.split(r'(\*\*[^*]+\*\*|\*[^*]+\*)', stripped)
585
643
  for part in parts:
586
644
  if part.startswith('**') and part.endswith('**'):
@@ -591,6 +649,7 @@ def insert_markdown(hwp, md_text):
591
649
  hwp.insert_text(part)
592
650
  hwp.insert_text('\r\n')
593
651
  inserted += 1
652
+ i += 1
594
653
 
595
654
  return {"status": "ok", "lines_inserted": inserted}
596
655
 
@@ -864,8 +923,8 @@ def set_cell_background_color(hwp, table_idx, cells):
864
923
  finally:
865
924
  try:
866
925
  hwp.Cancel()
867
- except Exception:
868
- pass
926
+ except Exception as e:
927
+ print(f"[WARN] {e}", file=sys.stderr)
869
928
 
870
929
  return {"status": "ok", **result}
871
930
 
@@ -929,5 +988,5 @@ def set_table_border_style(hwp, table_idx, cells=None, style=None):
929
988
  finally:
930
989
  try:
931
990
  hwp.Cancel()
932
- except Exception:
933
- pass
991
+ except Exception as e:
992
+ print(f"[WARN] {e}", file=sys.stderr)
@@ -25,7 +25,13 @@ def validate_file_path(file_path, must_exist=True):
25
25
 
26
26
 
27
27
  def _execute_all_replace(hwp, find_str, replace_str, use_regex=False):
28
- """AllReplace 공통 함수. find_replace/find_replace_multi/generate_multi에서 사용."""
28
+ """AllReplace 공통 함수. BUG-2 fix: COM 반환값 대신 전후 텍스트 비교로 검증."""
29
+ # 치환 전 텍스트 캡처
30
+ try:
31
+ before = hwp.get_text_file("TEXT", "")
32
+ except Exception:
33
+ before = ""
34
+
29
35
  act = hwp.HAction
30
36
  pset = hwp.HParameterSet.HFindReplace
31
37
  act.GetDefault("AllReplace", pset.HSet)
@@ -37,7 +43,14 @@ def _execute_all_replace(hwp, find_str, replace_str, use_regex=False):
37
43
  pset.FindJaso = 0
38
44
  pset.AllWordForms = 0
39
45
  pset.SeveralWords = 0
40
- return bool(act.Execute("AllReplace", pset.HSet))
46
+ act.Execute("AllReplace", pset.HSet)
47
+
48
+ # 치환 후 텍스트 비교로 실제 변경 여부 판단
49
+ try:
50
+ after = hwp.get_text_file("TEXT", "")
51
+ except Exception:
52
+ after = ""
53
+ return before != after
41
54
 
42
55
 
43
56
  def respond(req_id, success, data=None, error=None):
@@ -98,8 +111,8 @@ def dispatch(hwp, method, params):
98
111
  # 파일 열기 전 다이얼로그 자동 처리 재확인
99
112
  try:
100
113
  hwp.XHwpMessageBoxMode = 1
101
- except Exception:
102
- pass
114
+ except Exception as e:
115
+ print(f"[WARN] {e}", file=sys.stderr)
103
116
 
104
117
  result = hwp.open(file_path)
105
118
  if not result:
@@ -116,8 +129,8 @@ def dispatch(hwp, method, params):
116
129
  result["pages"] = 0
117
130
  try:
118
131
  result["current_path"] = _current_doc_path or ""
119
- except Exception:
120
- pass
132
+ except Exception as e:
133
+ print(f"[WARN] {e}", file=sys.stderr)
121
134
  return result
122
135
 
123
136
  if method == "analyze_document":
@@ -167,6 +180,11 @@ def dispatch(hwp, method, params):
167
180
  return {"status": "ok", "path": save_path, "file_size": file_size}
168
181
 
169
182
  if method == "close_document":
183
+ # BUG-8 fix: XHwpMessageBoxMode 복원
184
+ try:
185
+ hwp.XHwpMessageBoxMode = 0
186
+ except Exception as e:
187
+ print(f"[WARN] {e}", file=sys.stderr)
170
188
  hwp.close()
171
189
  _current_doc_path = None
172
190
  return {"status": "ok"}
@@ -184,15 +202,15 @@ def dispatch(hwp, method, params):
184
202
  pset.FindString = search_text
185
203
  pset.Direction = 0
186
204
  pset.IgnoreMessage = 1
187
- found = act.Execute("FindReplace", pset.HSet)
188
- if not found:
189
- break
190
- # 찾은 위치에서 컨텍스트 추출
205
+ act.Execute("FindReplace", pset.HSet)
206
+ # BUG-3 fix: 반환값 대신 선택 영역 존재 여부로 판단
191
207
  context = ""
192
208
  try:
193
209
  context = hwp.GetTextFile("TEXT", "saveblock").strip()[:200]
194
210
  except Exception:
195
211
  pass
212
+ if not context:
213
+ break # 선택 영역이 없으면 더 이상 찾을 수 없음
196
214
  hwp.HAction.Run("Cancel")
197
215
  results.append({
198
216
  "index": i + 1,
@@ -229,13 +247,18 @@ def dispatch(hwp, method, params):
229
247
  pset.FindString = params["find"]
230
248
  pset.Direction = 0
231
249
  pset.IgnoreMessage = 1
232
- found = act.Execute("FindReplace", pset.HSet)
250
+ act.Execute("FindReplace", pset.HSet)
233
251
 
234
- if not found:
252
+ # BUG-4 fix: 반환값 대신 선택 영역으로 찾기 성공 판단
253
+ try:
254
+ selected = hwp.GetTextFile("TEXT", "saveblock").strip()
255
+ except Exception:
256
+ selected = ""
257
+ if not selected:
235
258
  return {"status": "not_found", "find": params["find"]}
236
259
 
237
- # 찾은 텍스트 끝으로 커서 이동 (선택 해제)
238
- hwp.HAction.Run("Cancel")
260
+ # BUG-4 fix: Cancel 대신 MoveRight — 찾은 텍스트 끝으로 커서 이동
261
+ hwp.HAction.Run("MoveRight")
239
262
 
240
263
  # 색상 설정 (옵션)
241
264
  color = params.get("color") # [r, g, b]
@@ -340,8 +363,8 @@ def dispatch(hwp, method, params):
340
363
  # BreakSection으로 페이지 분리 후 파일 삽입
341
364
  try:
342
365
  hwp.HAction.Run("BreakSection")
343
- except Exception:
344
- pass
366
+ except Exception as e:
367
+ print(f"[WARN] {e}", file=sys.stderr)
345
368
  hwp.insert_file(merge_path)
346
369
  return {"status": "ok", "merged_file": merge_path, "pages": hwp.PageCount}
347
370
 
@@ -441,9 +464,8 @@ def dispatch(hwp, method, params):
441
464
  for r, row in enumerate(data):
442
465
  for c, val in enumerate(row):
443
466
  if val:
444
- hwp.HAction.Run("SelectAll")
467
+ # BUG-1 fix: SelectAll 제거 — 새 표의 빈 셀에 직접 삽입
445
468
  if header_style and r == 0:
446
- # 헤더행: Bold + 가운데 정렬
447
469
  from hwp_editor import insert_text_with_style
448
470
  insert_text_with_style(hwp, str(val), {"bold": True})
449
471
  else:
@@ -453,8 +475,8 @@ def dispatch(hwp, method, params):
453
475
  hwp.TableRightCell()
454
476
  try:
455
477
  hwp.Cancel()
456
- except Exception:
457
- pass
478
+ except Exception as e:
479
+ print(f"[WARN] {e}", file=sys.stderr)
458
480
  # 헤더행 배경색 적용 (옵션)
459
481
  if header_style and rows > 0:
460
482
  try:
@@ -490,15 +512,15 @@ def dispatch(hwp, method, params):
490
512
  for r, row in enumerate(all_data):
491
513
  for c, val in enumerate(row):
492
514
  if val:
493
- hwp.HAction.Run("SelectAll")
515
+ # BUG-1 fix: SelectAll 제거
494
516
  hwp.insert_text(str(val))
495
517
  filled += 1
496
518
  if c < len(row) - 1 or r < rows - 1:
497
519
  hwp.TableRightCell()
498
520
  try:
499
521
  hwp.Cancel()
500
- except Exception:
501
- pass
522
+ except Exception as e:
523
+ print(f"[WARN] {e}", file=sys.stderr)
502
524
  return {"status": "ok", "file": os.path.basename(csv_path), "rows": rows, "cols": cols, "filled": filled}
503
525
 
504
526
  if method == "insert_heading":
@@ -710,9 +732,8 @@ def dispatch(hwp, method, params):
710
732
  for r, row in enumerate(data):
711
733
  for c, val in enumerate(row):
712
734
  if val:
713
- hwp.HAction.Run("SelectAll")
735
+ # BUG-1 fix: SelectAll 제거
714
736
  if r == 0:
715
- # 헤더행: Bold
716
737
  insert_text_with_style(hwp, str(val), {"bold": True})
717
738
  else:
718
739
  hwp.insert_text(str(val))
@@ -721,16 +742,16 @@ def dispatch(hwp, method, params):
721
742
  hwp.TableRightCell()
722
743
  try:
723
744
  hwp.Cancel()
724
- except Exception:
725
- pass
745
+ except Exception as e:
746
+ print(f"[WARN] {e}", file=sys.stderr)
726
747
  # 헤더행 + ■ 셀 배경색 적용
727
748
  try:
728
749
  from hwp_editor import set_cell_background_color
729
750
  style_cells = [{"tab": i, "color": "#D9D9D9"} for i in range(cols)] # 헤더: 연회색
730
751
  style_cells += [{"tab": t, "color": "#C0C0C0"} for t in active_cells] # ■셀: 음영
731
752
  set_cell_background_color(hwp, -1, style_cells)
732
- except Exception:
733
- pass
753
+ except Exception as e:
754
+ print(f"[WARN] {e}", file=sys.stderr)
734
755
  return {"status": "ok", "rows": rows, "cols": cols, "filled": filled, "active_cells": len(active_cells)}
735
756
 
736
757
  if method == "insert_date_code":
@@ -898,8 +919,8 @@ def dispatch(hwp, method, params):
898
919
  count += 1
899
920
  hwp.ReleaseScan()
900
921
  text1 = "\n".join(parts)
901
- except Exception:
902
- pass
922
+ except Exception as e:
923
+ print(f"[WARN] {e}", file=sys.stderr)
903
924
  hwp.close()
904
925
  # 문서 2 텍스트 추출
905
926
  hwp.open(path2)
@@ -917,8 +938,8 @@ def dispatch(hwp, method, params):
917
938
  count += 1
918
939
  hwp.ReleaseScan()
919
940
  text2 = "\n".join(parts)
920
- except Exception:
921
- pass
941
+ except Exception as e:
942
+ print(f"[WARN] {e}", file=sys.stderr)
922
943
  hwp.close()
923
944
  # diff 계산
924
945
  lines1 = text1.split("\n")
@@ -945,8 +966,8 @@ def dispatch(hwp, method, params):
945
966
  count += 1
946
967
  hwp.ReleaseScan()
947
968
  text = "".join(parts)
948
- except Exception:
949
- pass
969
+ except Exception as e:
970
+ print(f"[WARN] {e}", file=sys.stderr)
950
971
  chars_total = len(text)
951
972
  chars_no_space = len(text.replace(" ", "").replace("\n", "").replace("\r", "").replace("\t", ""))
952
973
  words = len(text.split())
@@ -1046,8 +1067,8 @@ def dispatch(hwp, method, params):
1046
1067
  count += 1
1047
1068
  hwp.ReleaseScan()
1048
1069
  text = "\n".join(parts)
1049
- except Exception:
1050
- pass
1070
+ except Exception as e:
1071
+ print(f"[WARN] {e}", file=sys.stderr)
1051
1072
  # 패턴 감지: ( ), [ ], ___, ☐, □, ○, ◯, 빈칸+콜론
1052
1073
  patterns = [
1053
1074
  (r'\(\s*\)', 'bracket_empty', '빈 괄호'),