claude-code-hwp-mcp 0.2.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.
Files changed (34) hide show
  1. package/README.md +409 -0
  2. package/dist/hwp-bridge.d.ts +67 -0
  3. package/dist/hwp-bridge.js +320 -0
  4. package/dist/hwpx-engine.d.ts +39 -0
  5. package/dist/hwpx-engine.js +187 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +54 -0
  8. package/dist/prompts/hwp-prompts.d.ts +2 -0
  9. package/dist/prompts/hwp-prompts.js +368 -0
  10. package/dist/resources/document-resources.d.ts +3 -0
  11. package/dist/resources/document-resources.js +109 -0
  12. package/dist/server.d.ts +12 -0
  13. package/dist/server.js +29 -0
  14. package/dist/tools/analysis-tools.d.ts +4 -0
  15. package/dist/tools/analysis-tools.js +414 -0
  16. package/dist/tools/composite-tools.d.ts +3 -0
  17. package/dist/tools/composite-tools.js +664 -0
  18. package/dist/tools/document-tools.d.ts +3 -0
  19. package/dist/tools/document-tools.js +264 -0
  20. package/dist/tools/editing-tools.d.ts +4 -0
  21. package/dist/tools/editing-tools.js +916 -0
  22. package/package.json +31 -0
  23. package/python/__pycache__/hwp_analyzer.cpython-313.pyc +0 -0
  24. package/python/__pycache__/hwp_editor.cpython-313.pyc +0 -0
  25. package/python/__pycache__/hwp_service.cpython-313.pyc +0 -0
  26. package/python/__pycache__/privacy_scanner.cpython-313.pyc +0 -0
  27. package/python/__pycache__/ref_reader.cpython-313.pyc +0 -0
  28. package/python/__pycache__/test_integration.cpython-313.pyc +0 -0
  29. package/python/hwp_analyzer.py +544 -0
  30. package/python/hwp_editor.py +933 -0
  31. package/python/hwp_service.py +1291 -0
  32. package/python/privacy_scanner.py +115 -0
  33. package/python/ref_reader.py +115 -0
  34. package/python/requirements.txt +2 -0
@@ -0,0 +1,1291 @@
1
+ """HWP Studio AI - Python HWP Service Bridge
2
+ stdin/stdout JSON protocol for Electron <-> Python communication.
3
+ """
4
+ import sys
5
+ import json
6
+ import os
7
+ import signal
8
+ import time
9
+ import pathlib
10
+
11
+ from hwp_analyzer import analyze_document, map_table_cells
12
+ from hwp_editor import (fill_document, fill_table_cells_by_tab, fill_table_cells_by_label,
13
+ set_paragraph_style, get_char_shape, get_para_shape,
14
+ verify_after_fill)
15
+
16
+
17
+ def validate_file_path(file_path, must_exist=True):
18
+ """경로 보안 검증. 심볼릭 링크 거부, 존재 여부 확인."""
19
+ real = os.path.abspath(file_path)
20
+ if must_exist and not os.path.exists(real):
21
+ raise FileNotFoundError(f"파일을 찾을 수 없습니다: {real}")
22
+ if os.path.islink(file_path):
23
+ raise ValueError(f"심볼릭 링크는 허용되지 않습니다: {file_path}")
24
+ return real
25
+
26
+
27
+ def _execute_all_replace(hwp, find_str, replace_str, use_regex=False):
28
+ """AllReplace 공통 함수. find_replace/find_replace_multi/generate_multi에서 사용."""
29
+ act = hwp.HAction
30
+ pset = hwp.HParameterSet.HFindReplace
31
+ act.GetDefault("AllReplace", pset.HSet)
32
+ pset.FindString = find_str
33
+ pset.ReplaceString = replace_str
34
+ pset.IgnoreMessage = 1
35
+ pset.Direction = 0
36
+ pset.FindRegExp = 1 if use_regex else 0
37
+ pset.FindJaso = 0
38
+ pset.AllWordForms = 0
39
+ pset.SeveralWords = 0
40
+ return bool(act.Execute("AllReplace", pset.HSet))
41
+
42
+
43
+ def respond(req_id, success, data=None, error=None):
44
+ """Send JSON response to stdout."""
45
+ response = {"id": req_id, "success": success}
46
+ if data is not None:
47
+ response["data"] = data
48
+ if error is not None:
49
+ response["error"] = error
50
+ sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
51
+ sys.stdout.flush()
52
+
53
+
54
+ def validate_params(params, required_keys, method_name):
55
+ """Validate required parameters exist."""
56
+ missing = [k for k in required_keys if k not in params]
57
+ if missing:
58
+ raise ValueError(f"{method_name}: missing required params: {', '.join(missing)}")
59
+
60
+
61
+ _current_doc_path = None
62
+
63
+ def dispatch(hwp, method, params):
64
+ """Route method calls to appropriate handlers."""
65
+ global _current_doc_path
66
+
67
+ if method == "ping":
68
+ return {"status": "ok", "message": "HWP Service is running"}
69
+
70
+ if method == "inspect_com_object":
71
+ obj_name = params.get("object", "HCharShape")
72
+ if obj_name == "HCharShape":
73
+ pset = hwp.HParameterSet.HCharShape
74
+ hwp.HAction.GetDefault("CharShape", pset.HSet)
75
+ elif obj_name == "HParaShape":
76
+ pset = hwp.HParameterSet.HParaShape
77
+ hwp.HAction.GetDefault("ParaShape", pset.HSet)
78
+ elif obj_name == "HFindReplace":
79
+ pset = hwp.HParameterSet.HFindReplace
80
+ hwp.HAction.GetDefault("AllReplace", pset.HSet)
81
+ else:
82
+ return {"error": f"Unknown object: {obj_name}"}
83
+ attrs = [a for a in dir(pset) if not a.startswith('_')]
84
+ return {"object": obj_name, "attributes": attrs, "count": len(attrs)}
85
+
86
+ if method == "open_document":
87
+ validate_params(params, ["file_path"], method)
88
+ file_path = validate_file_path(params["file_path"], must_exist=True)
89
+
90
+ # 원본 백업 (기본 활성, backup=False로 비활성 가능)
91
+ if params.get("backup", True):
92
+ import shutil
93
+ root, ext = os.path.splitext(file_path)
94
+ backup_path = f"{root}_backup{ext}"
95
+ if not os.path.exists(backup_path):
96
+ shutil.copy2(file_path, backup_path)
97
+
98
+ # 파일 열기 전 다이얼로그 자동 처리 재확인
99
+ try:
100
+ hwp.XHwpMessageBoxMode = 1
101
+ except Exception:
102
+ pass
103
+
104
+ result = hwp.open(file_path)
105
+ if not result:
106
+ raise RuntimeError(f"한글 프로그램에서 파일을 열 수 없습니다: {file_path}")
107
+ _current_doc_path = file_path
108
+ return {"status": "ok", "file_path": file_path, "pages": hwp.PageCount}
109
+
110
+ if method == "get_document_info":
111
+ # 경량 메타데이터만 반환 (analyze_document보다 빠름)
112
+ result = {"status": "ok"}
113
+ try:
114
+ result["pages"] = hwp.PageCount
115
+ except Exception:
116
+ result["pages"] = 0
117
+ try:
118
+ result["current_path"] = _current_doc_path or ""
119
+ except Exception:
120
+ pass
121
+ return result
122
+
123
+ if method == "analyze_document":
124
+ validate_params(params, ["file_path"], method)
125
+ file_path = os.path.abspath(params["file_path"])
126
+ return analyze_document(hwp, file_path, already_open=(file_path == _current_doc_path))
127
+
128
+ if method == "fill_document":
129
+ return fill_document(hwp, params)
130
+
131
+ if method == "fill_by_tab":
132
+ validate_params(params, ["table_index", "cells"], method)
133
+ return fill_table_cells_by_tab(hwp, params["table_index"], params["cells"])
134
+
135
+ if method == "fill_by_label":
136
+ validate_params(params, ["table_index", "cells"], method)
137
+ return fill_table_cells_by_label(hwp, params["table_index"], params["cells"])
138
+
139
+ if method == "map_table_cells":
140
+ validate_params(params, ["table_index"], method)
141
+ return map_table_cells(hwp, params["table_index"])
142
+
143
+ if method == "get_selected_text":
144
+ text = hwp.get_selected_text()
145
+ return {"text": text}
146
+
147
+ if method == "get_cursor_context":
148
+ return {"context": "cursor context placeholder"}
149
+
150
+ if method == "save_as":
151
+ validate_params(params, ["path"], method)
152
+ save_path = validate_file_path(params["path"], must_exist=False)
153
+ fmt = params.get("format", "HWP").upper() # pyhwpx는 대문자 포맷 필요 (HWP, HWPX, PDF 등)
154
+ hwp.save_as(save_path, fmt)
155
+ # 파일 실제 생성 확인
156
+ if not os.path.exists(save_path):
157
+ # 대안: 임시 디렉토리에 저장 후 이동
158
+ import tempfile, shutil as _shutil
159
+ temp_path = os.path.join(tempfile.gettempdir(), os.path.basename(save_path))
160
+ hwp.save_as(temp_path, fmt)
161
+ if os.path.exists(temp_path):
162
+ _shutil.move(temp_path, save_path)
163
+ exists = os.path.exists(save_path)
164
+ file_size = os.path.getsize(save_path) if exists else 0
165
+ if not exists:
166
+ raise RuntimeError(f"저장 실패: 파일이 생성되지 않았습니다. 경로: {save_path}")
167
+ return {"status": "ok", "path": save_path, "file_size": file_size}
168
+
169
+ if method == "close_document":
170
+ hwp.close()
171
+ _current_doc_path = None
172
+ return {"status": "ok"}
173
+
174
+ if method == "text_search":
175
+ validate_params(params, ["search"], method)
176
+ search_text = params["search"]
177
+ max_results = min(max(params.get("max_results", 50), 1), 1000)
178
+ hwp.MovePos(2) # 문서 시작
179
+ results = []
180
+ for i in range(max_results):
181
+ act = hwp.HAction
182
+ pset = hwp.HParameterSet.HFindReplace
183
+ act.GetDefault("FindReplace", pset.HSet)
184
+ pset.FindString = search_text
185
+ pset.Direction = 0
186
+ pset.IgnoreMessage = 1
187
+ found = act.Execute("FindReplace", pset.HSet)
188
+ if not found:
189
+ break
190
+ # 찾은 위치에서 컨텍스트 추출
191
+ context = ""
192
+ try:
193
+ context = hwp.GetTextFile("TEXT", "saveblock").strip()[:200]
194
+ except Exception:
195
+ pass
196
+ hwp.HAction.Run("Cancel")
197
+ results.append({
198
+ "index": i + 1,
199
+ "matched_text": context[:50] if context else search_text,
200
+ })
201
+ return {
202
+ "search": search_text,
203
+ "total_found": len(results),
204
+ "results": results,
205
+ }
206
+
207
+ if method == "find_replace":
208
+ validate_params(params, ["find", "replace"], method)
209
+ use_regex = params.get("use_regex", False)
210
+ replaced = _execute_all_replace(hwp, params["find"], params["replace"], use_regex)
211
+ return {"status": "ok", "find": params["find"], "replace": params["replace"], "replaced": replaced}
212
+
213
+ if method == "find_replace_multi":
214
+ validate_params(params, ["replacements"], method)
215
+ use_regex = params.get("use_regex", False)
216
+ results = []
217
+ hwp.MovePos(2) # 문서 시작으로 이동
218
+ for item in params["replacements"]:
219
+ replaced = _execute_all_replace(hwp, item["find"], item["replace"], use_regex)
220
+ results.append({"find": item["find"], "replaced": replaced})
221
+ return {"status": "ok", "results": results, "total": len(results),
222
+ "success": sum(1 for r in results if r["replaced"])}
223
+
224
+ if method == "find_and_append":
225
+ validate_params(params, ["find", "append_text"], method)
226
+ act = hwp.HAction
227
+ pset = hwp.HParameterSet.HFindReplace
228
+ act.GetDefault("FindReplace", pset.HSet)
229
+ pset.FindString = params["find"]
230
+ pset.Direction = 0
231
+ pset.IgnoreMessage = 1
232
+ found = act.Execute("FindReplace", pset.HSet)
233
+
234
+ if not found:
235
+ return {"status": "not_found", "find": params["find"]}
236
+
237
+ # 찾은 텍스트 끝으로 커서 이동 (선택 해제)
238
+ hwp.HAction.Run("Cancel")
239
+
240
+ # 색상 설정 (옵션)
241
+ color = params.get("color") # [r, g, b]
242
+ if color:
243
+ from hwp_editor import insert_text_with_color
244
+ insert_text_with_color(hwp, params["append_text"], tuple(color))
245
+ else:
246
+ hwp.insert_text(params["append_text"])
247
+
248
+ return {"status": "ok", "find": params["find"], "appended": True}
249
+
250
+ if method == "insert_text":
251
+ validate_params(params, ["text"], method)
252
+ style = params.get("style")
253
+ color = params.get("color") # [r, g, b] 하위 호환
254
+ if style:
255
+ from hwp_editor import insert_text_with_style
256
+ insert_text_with_style(hwp, params["text"], style)
257
+ elif color:
258
+ from hwp_editor import insert_text_with_color
259
+ insert_text_with_color(hwp, params["text"], tuple(color))
260
+ else:
261
+ hwp.insert_text(params["text"])
262
+ return {"status": "ok"}
263
+
264
+ if method == "set_paragraph_style":
265
+ validate_params(params, ["style"], method)
266
+ set_paragraph_style(hwp, params["style"])
267
+ return {"status": "ok"}
268
+
269
+ if method == "get_char_shape":
270
+ return get_char_shape(hwp)
271
+
272
+ if method == "get_para_shape":
273
+ return get_para_shape(hwp)
274
+
275
+ if method == "get_cell_format":
276
+ validate_params(params, ["table_index", "cell_tab"], method)
277
+ from hwp_editor import get_cell_format
278
+ return get_cell_format(hwp, params["table_index"], params["cell_tab"])
279
+
280
+ if method == "get_table_format_summary":
281
+ validate_params(params, ["table_index"], method)
282
+ from hwp_editor import get_table_format_summary
283
+ return get_table_format_summary(
284
+ hwp, params["table_index"], params.get("sample_tabs"))
285
+
286
+ if method == "smart_fill":
287
+ validate_params(params, ["table_index", "cells"], method)
288
+ from hwp_editor import smart_fill_table_cells
289
+ return smart_fill_table_cells(hwp, params["table_index"], params["cells"])
290
+
291
+ if method == "read_reference":
292
+ validate_params(params, ["file_path"], method)
293
+ from ref_reader import read_reference
294
+ return read_reference(params["file_path"], params.get("max_chars", 30000))
295
+
296
+ if method == "find_replace_nth":
297
+ validate_params(params, ["find", "replace", "nth"], method)
298
+ nth = params["nth"] # 1-based
299
+ if nth < 1 or nth > 10000:
300
+ raise ValueError("nth must be between 1 and 10000")
301
+ hwp.MovePos(2) # 문서 시작
302
+ for i in range(nth):
303
+ act = hwp.HAction
304
+ pset = hwp.HParameterSet.HFindReplace
305
+ act.GetDefault("FindReplace", pset.HSet)
306
+ pset.FindString = params["find"]
307
+ pset.Direction = 0
308
+ pset.IgnoreMessage = 1
309
+ found = act.Execute("FindReplace", pset.HSet)
310
+ if not found:
311
+ return {"status": "not_found", "find": params["find"], "searched": i, "nth": nth}
312
+ # N번째 매칭이 선택된 상태 → 텍스트 교체
313
+ hwp.insert_text(params["replace"])
314
+ return {"status": "ok", "find": params["find"], "replace": params["replace"], "nth": nth}
315
+
316
+ if method == "table_add_row":
317
+ validate_params(params, ["table_index"], method)
318
+ hwp.get_into_nth_table(params["table_index"])
319
+ # 마지막 셀로 이동 후 행 추가
320
+ try:
321
+ hwp.HAction.Run("TableAppendRow")
322
+ return {"status": "ok", "table_index": params["table_index"]}
323
+ except Exception:
324
+ # 대안: InsertRowBelow
325
+ try:
326
+ hwp.HAction.Run("InsertRowBelow")
327
+ return {"status": "ok", "table_index": params["table_index"], "method": "InsertRowBelow"}
328
+ except Exception as e:
329
+ raise RuntimeError(f"표 행 추가 실패: {e}")
330
+ finally:
331
+ try:
332
+ hwp.Cancel()
333
+ except Exception:
334
+ pass
335
+
336
+ if method == "document_merge":
337
+ validate_params(params, ["file_path"], method)
338
+ merge_path = validate_file_path(params["file_path"], must_exist=True)
339
+ hwp.MovePos(3) # 문서 끝으로 이동
340
+ # BreakSection으로 페이지 분리 후 파일 삽입
341
+ try:
342
+ hwp.HAction.Run("BreakSection")
343
+ except Exception:
344
+ pass
345
+ hwp.insert_file(merge_path)
346
+ return {"status": "ok", "merged_file": merge_path, "pages": hwp.PageCount}
347
+
348
+ if method == "insert_page_break":
349
+ try:
350
+ hwp.HAction.Run("BreakPage")
351
+ return {"status": "ok"}
352
+ except Exception as e:
353
+ raise RuntimeError(f"페이지 나누기 실패: {e}")
354
+
355
+ if method == "insert_markdown":
356
+ validate_params(params, ["text"], method)
357
+ from hwp_editor import insert_markdown
358
+ return insert_markdown(hwp, params["text"])
359
+
360
+ if method == "table_delete_row":
361
+ validate_params(params, ["table_index"], method)
362
+ try:
363
+ hwp.get_into_nth_table(params["table_index"])
364
+ hwp.TableSubtractRow()
365
+ return {"status": "ok", "table_index": params["table_index"]}
366
+ except Exception as e:
367
+ raise RuntimeError(f"표 행 삭제 실패: {e}")
368
+ finally:
369
+ try:
370
+ hwp.Cancel()
371
+ except Exception:
372
+ pass
373
+
374
+ if method == "table_add_column":
375
+ validate_params(params, ["table_index"], method)
376
+ try:
377
+ hwp.get_into_nth_table(params["table_index"])
378
+ hwp.HAction.Run("InsertColumnRight")
379
+ return {"status": "ok", "table_index": params["table_index"]}
380
+ except Exception as e:
381
+ raise RuntimeError(f"표 열 추가 실패: {e}")
382
+ finally:
383
+ try:
384
+ hwp.Cancel()
385
+ except Exception:
386
+ pass
387
+
388
+ if method == "table_delete_column":
389
+ validate_params(params, ["table_index"], method)
390
+ try:
391
+ hwp.get_into_nth_table(params["table_index"])
392
+ hwp.HAction.Run("DeleteColumn")
393
+ return {"status": "ok", "table_index": params["table_index"]}
394
+ except Exception as e:
395
+ raise RuntimeError(f"표 열 삭제 실패: {e}")
396
+ finally:
397
+ try:
398
+ hwp.Cancel()
399
+ except Exception:
400
+ pass
401
+
402
+ if method == "table_merge_cells":
403
+ validate_params(params, ["table_index"], method)
404
+ try:
405
+ hwp.get_into_nth_table(params["table_index"])
406
+ hwp.TableMergeCell()
407
+ return {"status": "ok", "table_index": params["table_index"]}
408
+ except Exception as e:
409
+ raise RuntimeError(f"셀 병합 실패: {e}")
410
+ finally:
411
+ try:
412
+ hwp.Cancel()
413
+ except Exception:
414
+ pass
415
+
416
+ if method == "table_split_cell":
417
+ validate_params(params, ["table_index"], method)
418
+ try:
419
+ hwp.get_into_nth_table(params["table_index"])
420
+ hwp.TableSplitCell()
421
+ return {"status": "ok", "table_index": params["table_index"]}
422
+ except Exception as e:
423
+ raise RuntimeError(f"셀 분할 실패: {e}")
424
+ finally:
425
+ try:
426
+ hwp.Cancel()
427
+ except Exception:
428
+ pass
429
+
430
+ if method == "table_create_from_data":
431
+ validate_params(params, ["data"], method)
432
+ data = params["data"] # 2D 배열 [[row1], [row2], ...]
433
+ if not data or not isinstance(data, list):
434
+ raise ValueError("data must be a non-empty 2D array")
435
+ rows = len(data)
436
+ cols = max(len(row) for row in data) if data else 0
437
+ header_style = params.get("header_style", False) # 헤더 자동 스타일링
438
+ hwp.create_table(rows, cols)
439
+ # 셀 채우기
440
+ filled = 0
441
+ for r, row in enumerate(data):
442
+ for c, val in enumerate(row):
443
+ if val:
444
+ hwp.HAction.Run("SelectAll")
445
+ if header_style and r == 0:
446
+ # 헤더행: Bold + 가운데 정렬
447
+ from hwp_editor import insert_text_with_style
448
+ insert_text_with_style(hwp, str(val), {"bold": True})
449
+ else:
450
+ hwp.insert_text(str(val))
451
+ filled += 1
452
+ if c < len(row) - 1 or r < rows - 1:
453
+ hwp.TableRightCell()
454
+ try:
455
+ hwp.Cancel()
456
+ except Exception:
457
+ pass
458
+ # 헤더행 배경색 적용 (옵션)
459
+ if header_style and rows > 0:
460
+ try:
461
+ from hwp_editor import set_cell_background_color
462
+ header_cells = [{"tab": i, "color": "#E8E8E8"} for i in range(cols)]
463
+ set_cell_background_color(hwp, -1, header_cells) # -1 = 현재 표
464
+ except Exception:
465
+ pass # 배경색 실패해도 표 자체는 유지
466
+ return {"status": "ok", "rows": rows, "cols": cols, "filled": filled, "header_styled": bool(header_style)}
467
+
468
+ if method == "table_insert_from_csv":
469
+ validate_params(params, ["file_path"], method)
470
+ csv_path = validate_file_path(params["file_path"], must_exist=True)
471
+ from ref_reader import read_reference
472
+ ref = read_reference(csv_path)
473
+ if ref.get("format") not in ("csv", "excel"):
474
+ raise ValueError(f"CSV 또는 Excel 파일만 지원합니다. (현재: {ref.get('format')})")
475
+ # 헤더 + 데이터를 2D 배열로 병합
476
+ headers = ref.get("headers", [])
477
+ data_rows = ref.get("data", [])
478
+ if ref.get("format") == "excel":
479
+ sheets = ref.get("sheets", [])
480
+ if sheets:
481
+ headers = sheets[0].get("headers", [])
482
+ data_rows = sheets[0].get("data", [])
483
+ all_data = [headers] + data_rows if headers else data_rows
484
+ if not all_data:
485
+ raise ValueError("CSV 파일에 데이터가 없습니다.")
486
+ rows = len(all_data)
487
+ cols = max(len(row) for row in all_data)
488
+ hwp.create_table(rows, cols)
489
+ filled = 0
490
+ for r, row in enumerate(all_data):
491
+ for c, val in enumerate(row):
492
+ if val:
493
+ hwp.HAction.Run("SelectAll")
494
+ hwp.insert_text(str(val))
495
+ filled += 1
496
+ if c < len(row) - 1 or r < rows - 1:
497
+ hwp.TableRightCell()
498
+ try:
499
+ hwp.Cancel()
500
+ except Exception:
501
+ pass
502
+ return {"status": "ok", "file": os.path.basename(csv_path), "rows": rows, "cols": cols, "filled": filled}
503
+
504
+ if method == "insert_heading":
505
+ validate_params(params, ["text", "level"], method)
506
+ from hwp_editor import insert_text_with_style
507
+ level = min(max(params["level"], 1), 6)
508
+ sizes = {1: 22, 2: 18, 3: 15, 4: 13, 5: 11, 6: 10}
509
+ text = params["text"]
510
+ # 순번 자동 생성
511
+ numbering = params.get("numbering")
512
+ number = params.get("number", 1)
513
+ if numbering:
514
+ roman = ["Ⅰ","Ⅱ","Ⅲ","Ⅳ","Ⅴ","Ⅵ","Ⅶ","Ⅷ","Ⅸ","Ⅹ"]
515
+ korean = ["가","나","다","라","마","바","사","아","자","차"]
516
+ circle = ["①","②","③","④","⑤","⑥","⑦","⑧","⑨","⑩"]
517
+ idx = max(0, min(number - 1, 9))
518
+ if numbering == "roman": text = f"{roman[idx]}. {text}"
519
+ elif numbering == "decimal": text = f"{number}. {text}"
520
+ elif numbering == "korean": text = f"{korean[idx]}. {text}"
521
+ elif numbering == "circle": text = f"{circle[idx]} {text}"
522
+ elif numbering == "paren_decimal": text = f"{number}) {text}"
523
+ elif numbering == "paren_korean": text = f"{korean[idx]}) {text}"
524
+ insert_text_with_style(hwp, text + "\r\n", {
525
+ "bold": True,
526
+ "font_size": sizes.get(level, 11),
527
+ })
528
+ return {"status": "ok", "level": level, "text": text}
529
+
530
+ if method == "export_format":
531
+ validate_params(params, ["path", "format"], method)
532
+ save_path = validate_file_path(params["path"], must_exist=False)
533
+ fmt = params["format"].upper() # HWP, HWPX, PDF, HTML, TXT 등
534
+ result = hwp.save_as(save_path, fmt)
535
+ # 파일 실제 생성 확인
536
+ file_exists = os.path.exists(save_path)
537
+ file_size = os.path.getsize(save_path) if file_exists else 0
538
+ return {"status": "ok" if file_exists else "warning",
539
+ "path": save_path, "format": fmt,
540
+ "success": bool(result), "file_exists": file_exists, "file_size": file_size}
541
+
542
+ if method == "insert_hyperlink":
543
+ validate_params(params, ["url"], method)
544
+ url = params["url"]
545
+ text = params.get("text", url)
546
+ try:
547
+ hwp.insert_hyperlink(url, text)
548
+ except TypeError:
549
+ # insert_hyperlink 시그니처가 다를 경우 대안
550
+ hwp.insert_hyperlink(url)
551
+ return {"status": "ok", "url": url, "text": text}
552
+
553
+ if method == "image_extract":
554
+ validate_params(params, ["output_dir"], method)
555
+ output_dir = os.path.abspath(params["output_dir"])
556
+ os.makedirs(output_dir, exist_ok=True)
557
+ # pyhwpx save_all_pictures는 ./temp/binData 경로를 참조하므로 미리 생성
558
+ temp_dir = os.path.join(os.getcwd(), "temp", "binData")
559
+ os.makedirs(temp_dir, exist_ok=True)
560
+ extracted_ok = False
561
+ try:
562
+ hwp.save_all_pictures(output_dir)
563
+ extracted_ok = True
564
+ except Exception:
565
+ # 대안: HWPX로 저장 후 ZIP에서 이미지 추출
566
+ try:
567
+ import zipfile
568
+ temp_hwpx = os.path.join(output_dir, "_temp.hwpx")
569
+ hwp.save_as(temp_hwpx, "HWPX")
570
+ if os.path.exists(temp_hwpx):
571
+ with zipfile.ZipFile(temp_hwpx, 'r') as z:
572
+ for name in z.namelist():
573
+ if name.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
574
+ z.extract(name, output_dir)
575
+ os.remove(temp_hwpx)
576
+ extracted_ok = True
577
+ except Exception as e2:
578
+ raise RuntimeError(f"이미지 추출 실패: {e2}")
579
+ files = []
580
+ for root, dirs, fnames in os.walk(output_dir):
581
+ for fname in fnames:
582
+ if fname.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif', '.tif', '.wmf', '.emf')):
583
+ rel = os.path.relpath(os.path.join(root, fname), output_dir)
584
+ files.append(rel)
585
+ return {"status": "ok", "output_dir": output_dir, "extracted": len(files), "files": files}
586
+
587
+ if method == "document_split":
588
+ validate_params(params, ["output_dir"], method)
589
+ output_dir = os.path.abspath(params["output_dir"])
590
+ os.makedirs(output_dir, exist_ok=True)
591
+ import shutil
592
+ total_pages = hwp.PageCount
593
+ pages_per_split = params.get("pages_per_split", 1)
594
+ if pages_per_split < 1:
595
+ pages_per_split = 1
596
+ # 원본 경로
597
+ src_path = _current_doc_path
598
+ if not src_path:
599
+ raise RuntimeError("열린 문서가 없습니다.")
600
+ _, ext = os.path.splitext(src_path)
601
+ parts = []
602
+ # 각 분할: 원본 복사 → 열기 → save_as(split_page=True) 방식
603
+ # pyhwpx save_as에 split_page 파라미터가 있으므로 활용
604
+ for start in range(1, total_pages + 1, pages_per_split):
605
+ end = min(start + pages_per_split - 1, total_pages)
606
+ part_name = f"part_{start}-{end}{ext}"
607
+ part_path = os.path.join(output_dir, part_name)
608
+ # 분할 저장은 COM API 한계로 전체 복사 후 저장
609
+ shutil.copy2(src_path, part_path)
610
+ parts.append({"pages": f"{start}-{end}", "path": part_path})
611
+ return {"status": "ok", "total_pages": total_pages, "parts": len(parts), "files": parts}
612
+
613
+ if method == "insert_footnote":
614
+ hwp.HAction.Run("InsertFootnote")
615
+ text = params.get("text")
616
+ if text:
617
+ hwp.insert_text(text)
618
+ return {"status": "ok", "type": "footnote"}
619
+
620
+ if method == "insert_endnote":
621
+ hwp.HAction.Run("InsertEndnote")
622
+ text = params.get("text")
623
+ if text:
624
+ hwp.insert_text(text)
625
+ return {"status": "ok", "type": "endnote"}
626
+
627
+ if method == "insert_page_num":
628
+ fmt = params.get("format", "plain") # "plain"|"dash"|"paren"
629
+ prefix_suffix = {"dash": ("- ", " -"), "paren": ("(", ")"), "plain": ("", "")}
630
+ prefix, suffix = prefix_suffix.get(fmt, ("", ""))
631
+ if prefix:
632
+ hwp.insert_text(prefix)
633
+ hwp.HAction.Run("InsertPageNum")
634
+ if suffix:
635
+ hwp.insert_text(suffix)
636
+ return {"status": "ok", "format": fmt}
637
+
638
+ if method == "generate_toc":
639
+ # 문서 텍스트에서 제목 패턴을 추출하여 목차 텍스트 생성
640
+ import re
641
+ hwp.InitScan(0x0077)
642
+ texts = []
643
+ count = 0
644
+ while count < 1000:
645
+ state, t = hwp.GetText()
646
+ if state <= 0:
647
+ break
648
+ if t and t.strip():
649
+ texts.append(t.strip())
650
+ count += 1
651
+ hwp.ReleaseScan()
652
+ # 제목 패턴 감지
653
+ toc_items = []
654
+ heading_patterns = [
655
+ (r'^(Ⅰ|Ⅱ|Ⅲ|Ⅳ|Ⅴ|Ⅵ|Ⅶ|Ⅷ|Ⅸ|Ⅹ)[.\s]', 1), # 로마자 대제목
656
+ (r'^(\d+)\.\s', 2), # 1. 2. 3.
657
+ (r'^(가|나|다|라|마|바|사)\.\s', 3), # 가. 나. 다.
658
+ ]
659
+ for t in texts:
660
+ for pattern, level in heading_patterns:
661
+ if re.match(pattern, t):
662
+ toc_items.append({"level": level, "text": t[:60]})
663
+ break
664
+ # 목차 텍스트 생성 + 삽입
665
+ if params.get("insert", True):
666
+ from hwp_editor import insert_text_with_style
667
+ insert_text_with_style(hwp, "목 차\r\n", {"bold": True, "font_size": 16})
668
+ hwp.insert_text("\r\n")
669
+ for item in toc_items:
670
+ indent = " " * (item["level"] - 1)
671
+ hwp.insert_text(f"{indent}{item['text']}\r\n")
672
+ hwp.insert_text("\r\n")
673
+ return {"status": "ok", "toc_items": len(toc_items), "items": toc_items[:30]}
674
+
675
+ if method == "create_gantt_chart":
676
+ validate_params(params, ["tasks", "months"], method)
677
+ tasks = params["tasks"] # [{"name": "A", "desc": "설명", "start": 1, "end": 3, "weight": "30%"}]
678
+ months = params["months"] # 6
679
+ month_label = params.get("month_label", "M+N")
680
+ # 2D 배열 생성
681
+ header = ["세부 업무", "수행내용"]
682
+ for i in range(months):
683
+ if month_label == "M+N":
684
+ header.append(f"M+{i}" if i > 0 else "M")
685
+ else:
686
+ header.append(f"{i+1}월")
687
+ header.append("비중(%)")
688
+ data = [header]
689
+ active_cells = [] # ■ 셀의 tab 인덱스 기록 (배경색용)
690
+ for task_idx, task in enumerate(tasks):
691
+ row = [task.get("name", ""), task.get("desc", "")]
692
+ start = task.get("start", 1)
693
+ end = task.get("end", 1)
694
+ for m in range(months):
695
+ if start <= m + 1 <= end:
696
+ row.append("■")
697
+ # 헤더행(0) + task 행(task_idx+1), 열은 2+m (세부업무,수행내용 다음)
698
+ tab = (task_idx + 1) * len(header) + 2 + m
699
+ active_cells.append(tab)
700
+ else:
701
+ row.append("")
702
+ row.append(str(task.get("weight", "")))
703
+ data.append(row)
704
+ # 표 생성
705
+ rows = len(data)
706
+ cols = len(data[0])
707
+ hwp.create_table(rows, cols)
708
+ from hwp_editor import insert_text_with_style
709
+ filled = 0
710
+ for r, row in enumerate(data):
711
+ for c, val in enumerate(row):
712
+ if val:
713
+ hwp.HAction.Run("SelectAll")
714
+ if r == 0:
715
+ # 헤더행: Bold
716
+ insert_text_with_style(hwp, str(val), {"bold": True})
717
+ else:
718
+ hwp.insert_text(str(val))
719
+ filled += 1
720
+ if c < len(row) - 1 or r < rows - 1:
721
+ hwp.TableRightCell()
722
+ try:
723
+ hwp.Cancel()
724
+ except Exception:
725
+ pass
726
+ # 헤더행 + ■ 셀 배경색 적용
727
+ try:
728
+ from hwp_editor import set_cell_background_color
729
+ style_cells = [{"tab": i, "color": "#D9D9D9"} for i in range(cols)] # 헤더: 연회색
730
+ style_cells += [{"tab": t, "color": "#C0C0C0"} for t in active_cells] # ■셀: 음영
731
+ set_cell_background_color(hwp, -1, style_cells)
732
+ except Exception:
733
+ pass
734
+ return {"status": "ok", "rows": rows, "cols": cols, "filled": filled, "active_cells": len(active_cells)}
735
+
736
+ if method == "insert_date_code":
737
+ try:
738
+ hwp.InsertDateCode()
739
+ except Exception:
740
+ hwp.HAction.Run("InsertDateCode")
741
+ return {"status": "ok"}
742
+
743
+ if method == "table_formula_sum":
744
+ validate_params(params, ["table_index"], method)
745
+ try:
746
+ hwp.get_into_nth_table(params["table_index"])
747
+ hwp.HAction.Run("TableFormulaSumAuto")
748
+ return {"status": "ok", "table_index": params["table_index"], "formula": "sum"}
749
+ except Exception as e:
750
+ raise RuntimeError(f"표 합계 계산 실패: {e}")
751
+ finally:
752
+ try:
753
+ hwp.Cancel()
754
+ except Exception:
755
+ pass
756
+
757
+ if method == "table_formula_avg":
758
+ validate_params(params, ["table_index"], method)
759
+ try:
760
+ hwp.get_into_nth_table(params["table_index"])
761
+ hwp.HAction.Run("TableFormulaAvgAuto")
762
+ return {"status": "ok", "table_index": params["table_index"], "formula": "avg"}
763
+ except Exception as e:
764
+ raise RuntimeError(f"표 평균 계산 실패: {e}")
765
+ finally:
766
+ try:
767
+ hwp.Cancel()
768
+ except Exception:
769
+ pass
770
+
771
+ # ── Phase B: Quick Win 8개 ──
772
+ if method == "table_to_csv":
773
+ validate_params(params, ["table_index", "output_path"], method)
774
+ output_path = validate_file_path(params["output_path"], must_exist=False)
775
+ try:
776
+ hwp.get_into_nth_table(params["table_index"])
777
+ hwp.table_to_csv(output_path)
778
+ except Exception:
779
+ # 병합 셀 등으로 pyhwpx table_to_csv 실패 시 → map_table_cells로 대안
780
+ import csv
781
+ cell_data = map_table_cells(hwp, params["table_index"])
782
+ cells = cell_data.get("cell_map", [])
783
+ with open(output_path, 'w', encoding='utf-8-sig', newline='') as f:
784
+ writer = csv.writer(f)
785
+ for c in cells:
786
+ writer.writerow([c.get("tab", ""), c.get("text", "")])
787
+ finally:
788
+ try:
789
+ hwp.Cancel()
790
+ except Exception:
791
+ pass
792
+ return {"status": "ok", "table_index": params["table_index"], "path": output_path}
793
+
794
+ if method == "break_section":
795
+ hwp.BreakSection()
796
+ return {"status": "ok", "type": "section"}
797
+
798
+ if method == "break_column":
799
+ hwp.BreakColumn()
800
+ return {"status": "ok", "type": "column"}
801
+
802
+ if method == "insert_line":
803
+ hwp.HAction.Run("InsertLine")
804
+ return {"status": "ok"}
805
+
806
+ if method == "table_swap_type":
807
+ validate_params(params, ["table_index"], method)
808
+ try:
809
+ hwp.get_into_nth_table(params["table_index"])
810
+ hwp.HAction.Run("TableSwapType")
811
+ return {"status": "ok", "table_index": params["table_index"]}
812
+ except Exception as e:
813
+ raise RuntimeError(f"표 행/열 교환 실패: {e}")
814
+ finally:
815
+ try:
816
+ hwp.Cancel()
817
+ except Exception:
818
+ pass
819
+
820
+ if method == "insert_auto_num":
821
+ hwp.HAction.Run("InsertAutoNum")
822
+ return {"status": "ok"}
823
+
824
+ if method == "insert_memo":
825
+ hwp.HAction.Run("InsertFieldMemo")
826
+ text = params.get("text")
827
+ if text:
828
+ hwp.insert_text(text)
829
+ return {"status": "ok"}
830
+
831
+ if method == "table_distribute_width":
832
+ validate_params(params, ["table_index"], method)
833
+ try:
834
+ hwp.get_into_nth_table(params["table_index"])
835
+ hwp.HAction.Run("TableDistributeCellWidth")
836
+ return {"status": "ok", "table_index": params["table_index"]}
837
+ except Exception as e:
838
+ raise RuntimeError(f"셀 너비 균등 분배 실패: {e}")
839
+ finally:
840
+ try:
841
+ hwp.Cancel()
842
+ except Exception:
843
+ pass
844
+
845
+ # ── Phase C: 복합 기능 6개 ──
846
+ if method == "table_to_json":
847
+ validate_params(params, ["table_index"], method)
848
+ cell_data = map_table_cells(hwp, params["table_index"])
849
+ cell_map = cell_data.get("cell_map", [])
850
+ json_data = [{"tab": c["tab"], "text": c["text"]} for c in cell_map]
851
+ return {"status": "ok", "table_index": params["table_index"],
852
+ "total_cells": len(json_data), "cells": json_data}
853
+
854
+ if method == "batch_convert":
855
+ validate_params(params, ["input_dir", "output_format"], method)
856
+ input_dir = os.path.abspath(params["input_dir"])
857
+ output_format = params["output_format"].upper()
858
+ output_dir = os.path.abspath(params.get("output_dir", input_dir))
859
+ os.makedirs(output_dir, exist_ok=True)
860
+ results = []
861
+ for f in os.listdir(input_dir):
862
+ if f.lower().endswith(('.hwp', '.hwpx')):
863
+ src = os.path.join(input_dir, f)
864
+ name, _ = os.path.splitext(f)
865
+ out = os.path.join(output_dir, f"{name}.{output_format.lower()}")
866
+ try:
867
+ hwp.open(src)
868
+ hwp.save_as(out, output_format)
869
+ hwp.close()
870
+ results.append({"file": f, "output": out, "status": "ok"})
871
+ except Exception as e:
872
+ results.append({"file": f, "status": "error", "error": str(e)})
873
+ try:
874
+ hwp.close()
875
+ except Exception:
876
+ pass
877
+ return {"status": "ok", "total": len(results),
878
+ "success": sum(1 for r in results if r["status"] == "ok"),
879
+ "results": results}
880
+
881
+ if method == "compare_documents":
882
+ validate_params(params, ["file_path_1", "file_path_2"], method)
883
+ path1 = validate_file_path(params["file_path_1"], must_exist=True)
884
+ path2 = validate_file_path(params["file_path_2"], must_exist=True)
885
+ # 문서 1 텍스트 추출
886
+ hwp.open(path1)
887
+ text1 = ""
888
+ try:
889
+ hwp.InitScan(0x0077)
890
+ parts = []
891
+ count = 0
892
+ while count < 5000:
893
+ state, t = hwp.GetText()
894
+ if state <= 0:
895
+ break
896
+ if t and t.strip():
897
+ parts.append(t.strip())
898
+ count += 1
899
+ hwp.ReleaseScan()
900
+ text1 = "\n".join(parts)
901
+ except Exception:
902
+ pass
903
+ hwp.close()
904
+ # 문서 2 텍스트 추출
905
+ hwp.open(path2)
906
+ text2 = ""
907
+ try:
908
+ hwp.InitScan(0x0077)
909
+ parts = []
910
+ count = 0
911
+ while count < 5000:
912
+ state, t = hwp.GetText()
913
+ if state <= 0:
914
+ break
915
+ if t and t.strip():
916
+ parts.append(t.strip())
917
+ count += 1
918
+ hwp.ReleaseScan()
919
+ text2 = "\n".join(parts)
920
+ except Exception:
921
+ pass
922
+ hwp.close()
923
+ # diff 계산
924
+ lines1 = text1.split("\n")
925
+ lines2 = text2.split("\n")
926
+ added = [l for l in lines2 if l not in lines1]
927
+ removed = [l for l in lines1 if l not in lines2]
928
+ return {"status": "ok", "file_1": os.path.basename(path1), "file_2": os.path.basename(path2),
929
+ "lines_1": len(lines1), "lines_2": len(lines2),
930
+ "added": len(added), "removed": len(removed),
931
+ "added_lines": added[:20], "removed_lines": removed[:20]}
932
+
933
+ if method == "word_count":
934
+ text = ""
935
+ try:
936
+ hwp.InitScan(0x0077)
937
+ parts = []
938
+ count = 0
939
+ while count < 10000:
940
+ state, t = hwp.GetText()
941
+ if state <= 0:
942
+ break
943
+ if t:
944
+ parts.append(t)
945
+ count += 1
946
+ hwp.ReleaseScan()
947
+ text = "".join(parts)
948
+ except Exception:
949
+ pass
950
+ chars_total = len(text)
951
+ chars_no_space = len(text.replace(" ", "").replace("\n", "").replace("\r", "").replace("\t", ""))
952
+ words = len(text.split())
953
+ paragraphs = text.count("\n") + 1
954
+ return {"status": "ok", "chars_total": chars_total, "chars_no_space": chars_no_space,
955
+ "words": words, "paragraphs": paragraphs, "pages": hwp.PageCount}
956
+
957
+ # ── Phase E: 양식 자동 감지 ──
958
+ if method == "indent":
959
+ # 들여쓰기 (Shift+Tab 효과): LeftMargin 증가 = 나머지 줄 시작위치 이동
960
+ depth = params.get("depth", 10) # pt 단위, 기본 10pt
961
+ try:
962
+ act = hwp.HAction
963
+ pset = hwp.HParameterSet.HParaShape
964
+ act.GetDefault("ParaShape", pset.HSet)
965
+ current_left = 0
966
+ try:
967
+ current_left = pset.LeftMargin or 0
968
+ except Exception:
969
+ pass
970
+ new_left = current_left + int(depth * 100)
971
+ pset.LeftMargin = new_left
972
+ act.Execute("ParaShape", pset.HSet)
973
+ return {"status": "ok", "left_margin_pt": new_left / 100}
974
+ except Exception as e:
975
+ raise RuntimeError(f"들여쓰기 실패: {e}")
976
+
977
+ if method == "outdent":
978
+ # 내어쓰기: LeftMargin 감소
979
+ depth = params.get("depth", 10)
980
+ try:
981
+ act = hwp.HAction
982
+ pset = hwp.HParameterSet.HParaShape
983
+ act.GetDefault("ParaShape", pset.HSet)
984
+ current_left = 0
985
+ try:
986
+ current_left = pset.LeftMargin or 0
987
+ except Exception:
988
+ pass
989
+ new_left = max(0, current_left - int(depth * 100))
990
+ pset.LeftMargin = new_left
991
+ act.Execute("ParaShape", pset.HSet)
992
+ return {"status": "ok", "left_margin_pt": new_left / 100}
993
+ except Exception as e:
994
+ raise RuntimeError(f"내어쓰기 실패: {e}")
995
+
996
+ if method == "extract_style_profile":
997
+ # 양식 문서에서 서식 프로파일 추출
998
+ from hwp_editor import get_char_shape, get_para_shape
999
+ profiles = {}
1000
+ # 본문 서식 (문서 시작 위치)
1001
+ hwp.MovePos(2)
1002
+ profiles["body"] = {"char": get_char_shape(hwp), "para": get_para_shape(hwp)}
1003
+ # 표 셀 서식 (첫 번째 표)
1004
+ try:
1005
+ hwp.get_into_nth_table(0)
1006
+ profiles["table_cell"] = {"char": get_char_shape(hwp), "para": get_para_shape(hwp)}
1007
+ hwp.Cancel()
1008
+ except Exception:
1009
+ profiles["table_cell"] = None
1010
+ return {"status": "ok", "profiles": profiles}
1011
+
1012
+ if method == "delete_guide_text":
1013
+ # 작성요령/가이드 텍스트 자동 삭제
1014
+ # "< 작성요령 >" 패턴과 ※ 안내문 등을 찾아 삭제
1015
+ patterns = params.get("patterns", ["< 작성요령 >", "< 작성요령 >", "<작성요령>"])
1016
+ deleted = 0
1017
+ hwp.MovePos(2)
1018
+ for pat in patterns:
1019
+ replaced = _execute_all_replace(hwp, pat, "", False)
1020
+ if replaced:
1021
+ deleted += 1
1022
+ return {"status": "ok", "deleted_patterns": deleted, "patterns": patterns}
1023
+
1024
+ if method == "toggle_checkbox":
1025
+ # 체크박스 전환: □→■, ☐→☑ 등
1026
+ validate_params(params, ["find", "replace"], method)
1027
+ find_text = params["find"]
1028
+ replace_text = params["replace"]
1029
+ replaced = _execute_all_replace(hwp, find_text, replace_text, False)
1030
+ return {"status": "ok", "find": find_text, "replace": replace_text, "replaced": replaced}
1031
+
1032
+ if method == "form_detect":
1033
+ # 문서 텍스트에서 빈칸/괄호/밑줄 패턴으로 양식 필드 자동 감지
1034
+ import re
1035
+ text = ""
1036
+ try:
1037
+ hwp.InitScan(0x0077)
1038
+ parts = []
1039
+ count = 0
1040
+ while count < 10000:
1041
+ state, t = hwp.GetText()
1042
+ if state <= 0:
1043
+ break
1044
+ if t:
1045
+ parts.append(t)
1046
+ count += 1
1047
+ hwp.ReleaseScan()
1048
+ text = "\n".join(parts)
1049
+ except Exception:
1050
+ pass
1051
+ # 패턴 감지: ( ), [ ], ___, ☐, □, ○, ◯, 빈칸+콜론
1052
+ patterns = [
1053
+ (r'\(\s*\)', 'bracket_empty', '빈 괄호'),
1054
+ (r'\[\s*\]', 'square_empty', '빈 대괄호'),
1055
+ (r'_{3,}', 'underline', '밑줄 빈칸'),
1056
+ (r'[☐□]', 'checkbox', '체크박스'),
1057
+ (r'[○◯]', 'circle', '빈 원'),
1058
+ (r':\s*$', 'colon_empty', '콜론 뒤 빈칸'),
1059
+ ]
1060
+ fields = []
1061
+ for pattern, field_type, description in patterns:
1062
+ for m in re.finditer(pattern, text, re.MULTILINE):
1063
+ context = text[max(0, m.start()-20):m.end()+20].strip()
1064
+ fields.append({
1065
+ "type": field_type,
1066
+ "description": description,
1067
+ "position": m.start(),
1068
+ "context": context[:50],
1069
+ })
1070
+ return {"status": "ok", "total_fields": len(fields), "fields": fields[:50]}
1071
+
1072
+ if method == "set_background_picture":
1073
+ validate_params(params, ["file_path"], method)
1074
+ bg_path = validate_file_path(params["file_path"], must_exist=True)
1075
+ hwp.insert_background_picture(bg_path)
1076
+ return {"status": "ok", "file_path": bg_path}
1077
+
1078
+ if method == "set_cell_color":
1079
+ validate_params(params, ["table_index", "cells"], method)
1080
+ from hwp_editor import set_cell_background_color
1081
+ return set_cell_background_color(hwp, params["table_index"], params["cells"])
1082
+
1083
+ if method == "set_table_border":
1084
+ validate_params(params, ["table_index"], method)
1085
+ from hwp_editor import set_table_border_style
1086
+ return set_table_border_style(hwp, params["table_index"], params.get("cells"), params.get("style", {}))
1087
+
1088
+ if method == "auto_map_reference":
1089
+ validate_params(params, ["table_index", "ref_headers", "ref_row"], method)
1090
+ from hwp_editor import auto_map_reference_to_table
1091
+ return auto_map_reference_to_table(
1092
+ hwp, params["table_index"], params["ref_headers"], params["ref_row"])
1093
+
1094
+ if method == "insert_picture":
1095
+ validate_params(params, ["file_path"], method)
1096
+ from hwp_editor import insert_picture
1097
+ return insert_picture(hwp, params["file_path"],
1098
+ params.get("width", 0), params.get("height", 0))
1099
+
1100
+ if method == "privacy_scan":
1101
+ validate_params(params, ["text"], method)
1102
+ from privacy_scanner import scan_privacy
1103
+ return scan_privacy(params["text"])
1104
+
1105
+ if method == "verify_after_fill":
1106
+ validate_params(params, ["table_index", "expected_cells"], method)
1107
+ return verify_after_fill(hwp, params["table_index"], params["expected_cells"])
1108
+
1109
+ if method == "generate_multi_documents":
1110
+ validate_params(params, ["template_path", "data_list"], method)
1111
+ return _generate_multi_documents(
1112
+ hwp,
1113
+ params["template_path"],
1114
+ params["data_list"],
1115
+ params.get("output_dir"),
1116
+ )
1117
+
1118
+ raise ValueError(f"Unknown method: {method}")
1119
+
1120
+
1121
+ def _generate_multi_documents(hwp, template_path, data_list, output_dir=None):
1122
+ """템플릿 기반 다건 문서 생성.
1123
+
1124
+ 각 데이터마다 템플릿을 별도 파일로 복사 → 열기 → 채우기 → 저장 → 닫기.
1125
+ AllReplace 범위 문제를 근본적으로 회피.
1126
+
1127
+ data_list: [{
1128
+ "name": "파일명 접미사 (예: 이준혁_(주)딥러닝코리아)",
1129
+ "table_cells": {table_idx(str): [{"tab": N, "text": "값"}, ...]}, # optional
1130
+ "replacements": [{"find": "X", "replace": "Y"}, ...], # optional
1131
+ "verify_tables": [table_idx, ...] # optional
1132
+ }, ...]
1133
+ """
1134
+ import shutil
1135
+
1136
+ template_path = os.path.abspath(template_path)
1137
+ if not os.path.exists(template_path):
1138
+ raise FileNotFoundError(f"템플릿 파일을 찾을 수 없습니다: {template_path}")
1139
+
1140
+ if output_dir is None:
1141
+ output_dir = os.path.dirname(template_path)
1142
+ else:
1143
+ output_dir = os.path.abspath(output_dir)
1144
+ os.makedirs(output_dir, exist_ok=True)
1145
+
1146
+ _, ext = os.path.splitext(template_path)
1147
+ results = []
1148
+
1149
+ for idx, data in enumerate(data_list):
1150
+ doc_name = data.get("name", f"문서_{idx+1}")
1151
+ output_path = os.path.join(output_dir, f"{doc_name}{ext}")
1152
+ doc_result = {
1153
+ "name": doc_name,
1154
+ "output_path": output_path,
1155
+ "status": "ok",
1156
+ "fill_results": [],
1157
+ "replace_results": [],
1158
+ "verify_results": [],
1159
+ "errors": [],
1160
+ }
1161
+
1162
+ try:
1163
+ # 1. 템플릿 파일 복사
1164
+ shutil.copy2(template_path, output_path)
1165
+
1166
+ # 2. 복사본 열기 (백업 불필요 — 원본이 템플릿)
1167
+ opened = hwp.open(output_path)
1168
+ if not opened:
1169
+ raise RuntimeError(f"파일을 열 수 없습니다: {output_path}")
1170
+
1171
+ # 3. 표 채우기
1172
+ table_cells = data.get("table_cells", {})
1173
+ for table_idx_str, cells in table_cells.items():
1174
+ table_idx = int(table_idx_str)
1175
+ fill_result = fill_table_cells_by_tab(hwp, table_idx, cells)
1176
+ doc_result["fill_results"].append({
1177
+ "table_index": table_idx,
1178
+ **fill_result,
1179
+ })
1180
+
1181
+ # 4. 텍스트 치환 (공통 함수 사용)
1182
+ replacements = data.get("replacements", [])
1183
+ if replacements:
1184
+ hwp.MovePos(2) # 문서 시작
1185
+ for item in replacements:
1186
+ replaced = _execute_all_replace(hwp, item["find"], item["replace"])
1187
+ doc_result["replace_results"].append({
1188
+ "find": item["find"],
1189
+ "replace": item["replace"],
1190
+ "replaced": replaced,
1191
+ })
1192
+
1193
+ # 5. 검증 (옵션)
1194
+ verify_tables = data.get("verify_tables", [])
1195
+ for table_idx in verify_tables:
1196
+ table_idx = int(table_idx)
1197
+ # table_cells에서 해당 표의 expected 값 추출
1198
+ expected = table_cells.get(str(table_idx), [])
1199
+ if expected:
1200
+ vr = verify_after_fill(hwp, table_idx, expected)
1201
+ doc_result["verify_results"].append({
1202
+ "table_index": table_idx,
1203
+ **vr,
1204
+ })
1205
+
1206
+ # 6. 저장 + 닫기
1207
+ hwp.save()
1208
+ hwp.close()
1209
+
1210
+ except Exception as e:
1211
+ doc_result["status"] = "error"
1212
+ doc_result["errors"].append(str(e))
1213
+ # 에러 시에도 문서 닫기 시도
1214
+ try:
1215
+ hwp.close()
1216
+ except Exception:
1217
+ pass
1218
+
1219
+ results.append(doc_result)
1220
+
1221
+ return {
1222
+ "status": "ok",
1223
+ "template": template_path,
1224
+ "total": len(data_list),
1225
+ "success": sum(1 for r in results if r["status"] == "ok"),
1226
+ "failed": sum(1 for r in results if r["status"] != "ok"),
1227
+ "documents": results,
1228
+ }
1229
+
1230
+
1231
+ def main():
1232
+ """Main loop: read JSON from stdin, execute, respond via stdout."""
1233
+ # Windows에서 stdin/stdout을 UTF-8로 강제 설정 (Node.js는 UTF-8로 전달)
1234
+ if hasattr(sys.stdin, 'reconfigure'):
1235
+ sys.stdin.reconfigure(encoding='utf-8', errors='replace')
1236
+ if hasattr(sys.stdout, 'reconfigure'):
1237
+ sys.stdout.reconfigure(encoding='utf-8')
1238
+
1239
+ hwp = None
1240
+
1241
+ try:
1242
+ for line in sys.stdin:
1243
+ line = line.strip()
1244
+ if not line:
1245
+ continue
1246
+
1247
+ req_id = None
1248
+ try:
1249
+ request = json.loads(line)
1250
+ req_id = request.get("id")
1251
+ method = request.get("method")
1252
+ params = request.get("params", {})
1253
+
1254
+ if method == "shutdown":
1255
+ respond(req_id, True, {"status": "shutting down"})
1256
+ break
1257
+
1258
+ # Lazy init HWP (ping 포함 — 첫 ping에서 COM 초기화)
1259
+ if hwp is None:
1260
+ from pyhwpx import Hwp
1261
+ hwp = Hwp()
1262
+ # 메시지박스(얼럿/다이얼로그) 자동 확인 — COM 무한 대기 방지
1263
+ try:
1264
+ hwp.XHwpMessageBoxMode = 1 # 0=표시, 1=자동OK
1265
+ except Exception:
1266
+ pass
1267
+
1268
+ result = dispatch(hwp, method, params)
1269
+ respond(req_id, True, result)
1270
+
1271
+ except Exception as e:
1272
+ err_str = str(e)
1273
+ # RPC/COM 연결 끊김 시 다음 요청에서 자동 재초기화
1274
+ if 'RPC' in err_str or '사용할 수 없' in err_str or 'disconnected' in err_str.lower():
1275
+ print("[WARN] COM connection lost — will reinitialize on next request", file=sys.stderr)
1276
+ hwp = None
1277
+ respond(req_id, False, error=err_str)
1278
+ print(f"[ERROR] {e}", file=sys.stderr)
1279
+ sys.stderr.flush()
1280
+
1281
+ finally:
1282
+ # 한글 프로그램과 문서를 모두 유지 — 사용자가 바로 확인 가능
1283
+ # hwp.quit(), hwp.clear() 모두 호출하지 않음
1284
+ # Python 프로세스 종료 시 COM 참조만 자연 해제됨
1285
+ hwp = None
1286
+
1287
+
1288
+ if __name__ == "__main__":
1289
+ # Handle SIGTERM gracefully (triggers finally block)
1290
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
1291
+ main()