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 +96 -0
- package/dist/hwp-bridge.d.ts +1 -0
- package/dist/hwp-bridge.js +18 -4
- package/dist/hwpx-engine.d.ts +20 -1
- package/dist/hwpx-engine.js +110 -3
- package/dist/index.js +1 -1
- package/dist/tools/analysis-tools.js +19 -1
- package/dist/tools/editing-tools.js +87 -5
- package/package.json +2 -2
- package/python/hwp_analyzer.py +15 -12
- package/python/hwp_editor.py +109 -50
- package/python/hwp_service.py +58 -37
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 모두 지원합니다.
|
package/dist/hwp-bridge.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/hwp-bridge.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
87
|
-
|
|
88
|
-
this.
|
|
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
|
}
|
package/dist/hwpx-engine.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/hwpx-engine.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|
package/python/hwp_analyzer.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/python/hwp_editor.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
991
|
+
except Exception as e:
|
|
992
|
+
print(f"[WARN] {e}", file=sys.stderr)
|
package/python/hwp_service.py
CHANGED
|
@@ -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 공통 함수.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
250
|
+
act.Execute("FindReplace", pset.HSet)
|
|
233
251
|
|
|
234
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1070
|
+
except Exception as e:
|
|
1071
|
+
print(f"[WARN] {e}", file=sys.stderr)
|
|
1051
1072
|
# 패턴 감지: ( ), [ ], ___, ☐, □, ○, ◯, 빈칸+콜론
|
|
1052
1073
|
patterns = [
|
|
1053
1074
|
(r'\(\s*\)', 'bracket_empty', '빈 괄호'),
|