hwpx-kit-cli 0.1.1__py3-none-any.whl

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.
@@ -0,0 +1,46 @@
1
+ """PDF·DOCX·XLSX 텍스트 추출 — 순수 파이썬 (kordoc 흡수, 2026-07-10).
2
+
3
+ Node/kordoc 의존을 '한글 없는 환경의 구형 .hwp' 하나로 좁히기 위한 자체 리더.
4
+ 출력은 read 명령의 content로 그대로 나가는 Markdown 유사 텍스트.
5
+ """
6
+ from __future__ import annotations
7
+
8
+
9
+ def read_pdf(path: str) -> str:
10
+ from pypdf import PdfReader
11
+
12
+ reader = PdfReader(path)
13
+ pages = []
14
+ for i, page in enumerate(reader.pages):
15
+ text = (page.extract_text() or "").strip()
16
+ pages.append(f"## p.{i + 1}\n\n{text}" if len(reader.pages) > 1 else text)
17
+ return "\n\n".join(pages)
18
+
19
+
20
+ def read_docx(path: str) -> str:
21
+ import docx
22
+
23
+ doc = docx.Document(path)
24
+ parts: list[str] = []
25
+ for para in doc.paragraphs:
26
+ if para.text.strip():
27
+ parts.append(para.text)
28
+ for table in doc.tables:
29
+ for row in table.rows:
30
+ cells = [cell.text.strip().replace("\n", " ") for cell in row.cells]
31
+ parts.append("| " + " | ".join(cells) + " |")
32
+ return "\n".join(parts)
33
+
34
+
35
+ def read_xlsx(path: str) -> str:
36
+ import openpyxl
37
+
38
+ wb = openpyxl.load_workbook(path, data_only=True, read_only=True)
39
+ parts: list[str] = []
40
+ for ws in wb.worksheets:
41
+ parts.append(f"## {ws.title}")
42
+ for row in ws.iter_rows(values_only=True):
43
+ if any(v is not None for v in row):
44
+ parts.append("| " + " | ".join("" if v is None else str(v) for v in row) + " |")
45
+ wb.close()
46
+ return "\n".join(parts)
hwpx_kit/cli.py ADDED
@@ -0,0 +1,259 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+
8
+ from hwpx_kit.commands.analyze import run_analyze
9
+ from hwpx_kit.commands.convert import run_convert
10
+ from hwpx_kit.commands.export import run_export
11
+ from hwpx_kit.commands.fill import run_fill
12
+ from hwpx_kit.commands.fmt import run_fmt
13
+ from hwpx_kit.commands.generate import run_generate
14
+ from hwpx_kit.commands.outline import run_outline
15
+ from hwpx_kit.commands.page_break import run_page_break
16
+ from hwpx_kit.commands.read import run_read
17
+ from hwpx_kit.commands.render import run_render
18
+ from hwpx_kit.commands.row_height import parse_rows_spec, run_row_height
19
+ from hwpx_kit.commands.table_clear import run_table_clear
20
+ from hwpx_kit.commands.table_copy import run_table_copy
21
+ from hwpx_kit.commands.table_map_cmd import run_table_map
22
+ from hwpx_kit.commands.table_new import run_table_new
23
+ from hwpx_kit.commands.table_set import parse_assignments, run_table_set
24
+ from hwpx_kit.commands.validate import run_validate
25
+ from hwpx_kit.output import envelope, print_result
26
+
27
+
28
+ def _build_parser() -> argparse.ArgumentParser:
29
+ from importlib.metadata import version
30
+
31
+ p = argparse.ArgumentParser(prog="hwpx-kit", description="HWPX 양식 자동화 CLI")
32
+ p.add_argument("--version", action="version", version=f"hwpx-kit {version('hwpx-kit')}")
33
+ sub = p.add_subparsers(dest="command", required=True)
34
+
35
+ pa = sub.add_parser("analyze", help="양식에서 채울 수 있는 필드 탐지")
36
+ pa.add_argument("file")
37
+ pa.add_argument("--json", action="store_true")
38
+
39
+ pf = sub.add_parser("fill", help="필드에 값 채워 사본 저장 (원본 불변)")
40
+ pf.add_argument("file")
41
+ pf.add_argument("--data", required=True, help="fill_key: 값 매핑 JSON 파일")
42
+ pf.add_argument("--out", required=True, help="출력 hwpx 경로")
43
+ pf.add_argument("--json", action="store_true")
44
+
45
+ pr = sub.add_parser("read", help="본문을 Markdown/텍스트로 추출")
46
+ pr.add_argument("file")
47
+ pr.add_argument("--format", default="md", choices=["md", "text"])
48
+ pr.add_argument("--json", action="store_true")
49
+
50
+ pv = sub.add_parser("validate", help="hwpx 구조 검증")
51
+ pv.add_argument("file")
52
+ pv.add_argument("--json", action="store_true")
53
+
54
+ pc = sub.add_parser("convert", help=".hwp를 .hwpx로 변환 (Windows + 한글 필요)")
55
+ pc.add_argument("file")
56
+ pc.add_argument("--out", help="출력 hwpx 경로 (기본: 같은 이름 .hwpx)")
57
+ pc.add_argument("--json", action="store_true")
58
+
59
+ px = sub.add_parser("export", help=".hwpx를 다른 형식으로 내보내기 — 워드(docx) (Windows + 한글 필요)")
60
+ px.add_argument("file")
61
+ px.add_argument("--to", default="docx", choices=["docx"], help="출력 형식 (기본 docx)")
62
+ px.add_argument("--out", help="출력 경로 (기본: 같은 이름에 형식 확장자)")
63
+ px.add_argument("--json", action="store_true")
64
+
65
+ pn = sub.add_parser("render", help="레이아웃 보존 SVG 렌더 — 브라우저로 결과 확인 (kordoc 필요)")
66
+ pn.add_argument("file")
67
+ pn.add_argument("--out", help="출력 SVG 경로 (기본: 같은 이름 .svg)")
68
+ pn.add_argument("--json", action="store_true")
69
+
70
+ pg = sub.add_parser("generate", help="Markdown → 공문서 HWPX 생성 (kordoc 필요)")
71
+ pg.add_argument("file", help="입력 Markdown 파일")
72
+ pg.add_argument("--out", required=True, help="출력 hwpx 경로")
73
+ pg.add_argument("--preset", help="kordoc 공문서 프리셋 (예: 보고서)")
74
+ pg.add_argument("--json", action="store_true")
75
+
76
+ ph = sub.add_parser("row-height", help="표 기준 행 높이를 지정 행들에 복사 (행 추가 후 높이 정돈)")
77
+ ph.add_argument("file")
78
+ ph.add_argument("--table", type=int, required=True, help="표 인덱스 (analyze의 table_index, 0-기준)")
79
+ ph.add_argument("--like", type=int, required=True, help="기준 행 (0-기준)")
80
+ ph.add_argument("--rows", required=True, help="대상 행: '3-7' 범위 / '3,5' 나열 / 혼합")
81
+ ph.add_argument("--out", required=True, help="출력 hwpx 경로 (원본 불변)")
82
+ ph.add_argument("--json", action="store_true")
83
+
84
+ pcl = sub.add_parser("table-clear", help="표의 지정 행 셀 내용 비우기 (구조는 유지 — 잔존 내용 정리용)")
85
+ pcl.add_argument("file")
86
+ pcl.add_argument("--table", type=int, required=True, help="표 인덱스 (0-기준)")
87
+ pcl.add_argument("--rows", help="대상 행 '1-20'/'1,3' (생략 시 표 전체)")
88
+ pcl.add_argument("--out", required=True, help="출력 hwpx 경로 (원본 불변)")
89
+ pcl.add_argument("--json", action="store_true")
90
+
91
+ pb = sub.add_parser("page-break", help="문단(또는 표) 앞 쪽나눔 — 끼워넣은 장을 새 쪽에서 시작시킴")
92
+ pb.add_argument("file")
93
+ pb.add_argument("--at-text", help="대상 문단 원문 (공백 정규화 전체 일치)")
94
+ pb.add_argument("--table", type=int, help="이 표가 든 문단에 적용 (0-기준)")
95
+ pb.add_argument("--out", required=True, help="출력 hwpx 경로 (원본 불변)")
96
+ pb.add_argument("--json", action="store_true")
97
+
98
+ pm = sub.add_parser("table-map", help="표 셀 상태 덤프 — 좌표·텍스트·병합 anchor (검사용)")
99
+ pm.add_argument("file")
100
+ pm.add_argument("--table", type=int, help="특정 표만 (0-기준, 생략 시 전체)")
101
+ pm.add_argument("--json", action="store_true")
102
+
103
+ pnw = sub.add_parser("table-new", help="임의 R×C 새 표 생성 (--like-table로 기존 표 서식 차용)")
104
+ pnw.add_argument("file")
105
+ pnw.add_argument("--rows", type=int, required=True)
106
+ pnw.add_argument("--cols", type=int, required=True)
107
+ pnw.add_argument("--after-text", help="삽입 위치 문단 원문")
108
+ pnw.add_argument("--after-table", type=int, help="이 표 뒤(같은 문단)에 삽입")
109
+ pnw.add_argument("--like-table", type=int, help="테두리 서식을 빌릴 기존 표 인덱스")
110
+ pnw.add_argument("--out", required=True, help="출력 hwpx 경로 (원본 불변)")
111
+ pnw.add_argument("--json", action="store_true")
112
+
113
+ po = sub.add_parser("outline", help="문단·표 배치 지도 (앵커 탐색용, 읽기 전용)")
114
+ po.add_argument("file")
115
+ po.add_argument("--json", action="store_true")
116
+
117
+ pcp = sub.add_parser("table-copy", help="표 통째 복제를 지정 문단에 삽입 (장 헤더 박스 늘리기 등)")
118
+ pcp.add_argument("file")
119
+ pcp.add_argument("--table", type=int, required=True, help="복제할 표 인덱스 (0-기준)")
120
+ pcp.add_argument("--after-text", help="삽입 위치 문단의 원문 (read 출력에서 복사)")
121
+ pcp.add_argument("--after-table", type=int, help="이 표 뒤(같은 문단)에 삽입 — 표 사이에 문단이 없을 때")
122
+ pcp.add_argument("--out", required=True, help="출력 hwpx 경로 (원본 불변)")
123
+ pcp.add_argument("--json", action="store_true")
124
+
125
+ pts = sub.add_parser("table-set", help="표 셀 좌표 지정 쓰기 (table-clear로 비운 셀에 새 항목 기입)")
126
+ pts.add_argument("file")
127
+ pts.add_argument("--table", type=int, required=True, help="표 인덱스 (0-기준)")
128
+ pts.add_argument("--set", action="append", metavar="R,C=값",
129
+ help="셀 기입 (반복 지정 가능, 0-기준 좌표) — 5개 이하 소량용")
130
+ pts.add_argument("--data", help='셀 기입 JSON 파일 {"R,C": "값"} — 대량 기입은 이쪽 (명령 길이 한계 회피)')
131
+ pts.add_argument("--out", required=True, help="출력 hwpx 경로 (원본 불변)")
132
+ pts.add_argument("--json", action="store_true")
133
+
134
+ pt = sub.add_parser("fmt", help="공문 표기 변환 — 금액 한글화·날짜(요일)·만나이 (파일 불필요)")
135
+ pt.add_argument("--amount", help="금액 (정수, 콤마 허용)")
136
+ pt.add_argument("--style", default="gongmun", choices=["gongmun", "ilgeum"],
137
+ help="금액 표기: gongmun=법정 공문(기본) / ilgeum=민간 관습(일금…원정)")
138
+ pt.add_argument("--date", help="날짜 YYYYMMDD → YYYY.M.D.(요일)")
139
+ pt.add_argument("--age", help="생년월일 YYMMDD → YYMMDD(만나이)")
140
+ pt.add_argument("--base", help="만나이 기준일 YYYYMMDD (기본 오늘)")
141
+ pt.add_argument("--json", action="store_true")
142
+
143
+ return p
144
+
145
+
146
+ def main(argv: list[str] | None = None) -> int:
147
+ # 파이프/리다이렉트 시 Windows 기본이 cp949 — JSON 소비자를 위해 UTF-8 고정.
148
+ # stderr(엔진 한글 경고)도 동일 — 로그 리다이렉트 시 깨짐 방지
149
+ if hasattr(sys.stdout, "reconfigure"):
150
+ sys.stdout.reconfigure(encoding="utf-8")
151
+ if hasattr(sys.stderr, "reconfigure"):
152
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
153
+ args = _build_parser().parse_args(argv)
154
+ as_json = args.json
155
+
156
+ # fmt는 파일 입력이 없다 — 파일 가드는 파일 위치인수가 있는 명령에만
157
+ if args.command != "fmt" and not os.path.exists(args.file):
158
+ env = envelope(
159
+ args.command, ok=False,
160
+ error={"code": "FILE_NOT_FOUND", "message": f"파일이 없습니다: {args.file}"},
161
+ )
162
+ print_result(env, as_json)
163
+ return 1
164
+
165
+ try:
166
+ exit_code = 0
167
+ if args.command == "analyze":
168
+ data = run_analyze(args.file)
169
+ elif args.command == "fill":
170
+ with open(args.data, encoding="utf-8") as fh:
171
+ mapping = json.load(fh)
172
+ data = run_fill(args.file, mapping, args.out)
173
+ if data["unmatched"]:
174
+ exit_code = 2
175
+ elif args.command == "read":
176
+ data = run_read(args.file, fmt=args.format)
177
+ elif args.command == "convert":
178
+ data = run_convert(args.file, out_path=args.out)
179
+ elif args.command == "export":
180
+ data = run_export(args.file, to=args.to, out_path=args.out)
181
+ elif args.command == "fmt":
182
+ data = run_fmt(
183
+ amount=args.amount, date=args.date, age=args.age,
184
+ base=args.base, style=args.style,
185
+ )
186
+ elif args.command == "row-height":
187
+ data = run_row_height(
188
+ args.file, table=args.table, like=args.like,
189
+ rows=parse_rows_spec(args.rows), out_path=args.out,
190
+ )
191
+ elif args.command == "table-clear":
192
+ data = run_table_clear(
193
+ args.file, table=args.table,
194
+ rows=parse_rows_spec(args.rows) if args.rows else None,
195
+ out_path=args.out,
196
+ )
197
+ elif args.command == "table-set":
198
+ assignments = []
199
+ if args.set:
200
+ assignments += parse_assignments(args.set)
201
+ if args.data:
202
+ from hwpx_kit.commands.table_set import load_assignments_file
203
+
204
+ assignments += load_assignments_file(args.data)
205
+ if not assignments:
206
+ raise ValueError("--set 또는 --data 중 하나는 지정해야 합니다.")
207
+ data = run_table_set(
208
+ args.file, table=args.table,
209
+ assignments=assignments, out_path=args.out,
210
+ )
211
+ elif args.command == "table-copy":
212
+ data = run_table_copy(
213
+ args.file, table=args.table,
214
+ after_text=args.after_text, out_path=args.out,
215
+ after_table=args.after_table,
216
+ )
217
+ elif args.command == "outline":
218
+ data = run_outline(args.file)
219
+ elif args.command == "page-break":
220
+ data = run_page_break(
221
+ args.file, at_text=args.at_text, table=args.table, out_path=args.out,
222
+ )
223
+ elif args.command == "table-map":
224
+ data = run_table_map(args.file, table=args.table)
225
+ elif args.command == "table-new":
226
+ data = run_table_new(
227
+ args.file, rows=args.rows, cols=args.cols,
228
+ after_text=args.after_text, like_table=args.like_table,
229
+ out_path=args.out, after_table=args.after_table,
230
+ )
231
+ elif args.command == "render":
232
+ data = run_render(args.file, out_path=args.out)
233
+ elif args.command == "generate":
234
+ data = run_generate(args.file, args.out, preset=args.preset)
235
+ else:
236
+ data = run_validate(args.file)
237
+ if not data["valid"]:
238
+ exit_code = 2
239
+ except Exception as exc:
240
+ env = envelope(
241
+ args.command, ok=False,
242
+ error={"code": "COMMAND_FAILED", "message": str(exc)},
243
+ )
244
+ print_result(env, as_json)
245
+ return 1
246
+
247
+ env = envelope(args.command, ok=True, data=data)
248
+ if as_json:
249
+ print_result(env, as_json=True)
250
+ else:
251
+ if args.command == "read":
252
+ print(data["content"])
253
+ else:
254
+ print_result(env, as_json=False)
255
+ return exit_code
256
+
257
+
258
+ if __name__ == "__main__":
259
+ sys.exit(main())
File without changes
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from collections import Counter
6
+
7
+ from hwpx_kit.adapter.hwpx_engine import HwpxEngineAdapter
8
+
9
+ # 라벨은 단어 문자(한글/영숫자)를 포함해야 함 — 구두점 단독 셀 배제
10
+ _WORD_RE = re.compile(r"\w", re.UNICODE)
11
+ _WS_RE = re.compile(r"\s+")
12
+ _MAX_LABEL_LEN = 20
13
+
14
+
15
+ def _normalize_label(text: str) -> str:
16
+ """엔진(fill_by_path)과 같은 공백 정규화 — 개행 든 라벨('담당 부서\\n<총괄>')도
17
+ 한 줄 라벨로 취급해 fill_key가 엔진 매칭을 그대로 통과하게 한다."""
18
+ return _WS_RE.sub(" ", text).strip()
19
+
20
+
21
+ def _is_label_text(text: str) -> bool:
22
+ if not text:
23
+ return False
24
+ if len(text) > _MAX_LABEL_LEN:
25
+ return False
26
+ return bool(_WORD_RE.search(text))
27
+
28
+
29
+ def find_label_candidates(table_map: dict) -> list[dict]:
30
+ """라벨 후보: 텍스트가 있고 바로 오른쪽 셀이 비어 있는 셀."""
31
+ candidates = []
32
+ for table in table_map.get("tables", []):
33
+ cells = {(c["row"], c["col"]): c for c in table.get("cells", [])}
34
+ for (row, col), cell in cells.items():
35
+ if cell.get("is_anchor") is False:
36
+ # 병합 셀의 격자 복제 — 논리 셀은 anchor 좌표 하나뿐
37
+ continue
38
+ text = _normalize_label(cell.get("text") or "")
39
+ if not _is_label_text(text):
40
+ continue
41
+ right = cells.get((row, col + 1))
42
+ if right is None:
43
+ continue
44
+ right_text = (right.get("text") or "").strip()
45
+ if _normalize_label(right_text) == text:
46
+ # 병합 셀은 같은 텍스트가 격자 전체에 복제되어 나타남
47
+ continue
48
+ candidates.append(
49
+ {
50
+ "table_index": table.get("table_index", 0),
51
+ "label": text,
52
+ "row": row,
53
+ "col": col,
54
+ "fill_key": f"table:{text}",
55
+ "prefilled": bool(right_text),
56
+ "current": right_text,
57
+ "ambiguous": False,
58
+ }
59
+ )
60
+ counts = Counter(c["label"] for c in candidates)
61
+ for c in candidates:
62
+ c["ambiguous"] = counts[c["label"]] > 1
63
+ return candidates
64
+
65
+
66
+ def _assign_nth_keys(ad: HwpxEngineAdapter, candidates: list[dict]) -> None:
67
+ """중복 라벨 후보의 fill_key에 #N 접미사 부여.
68
+
69
+ 중복 판정·번호 모두 fill과 같은 열거(label_positions_map, anchor 셀
70
+ 전체) 기준 — analyze 후보로는 유일해 보여도(두 번째 출현에 오른쪽 칸이
71
+ 없는 경우 등) fill이 ambiguous로 거부할 수 있어, fill 관점의 출현 수로
72
+ 판정해야 'analyze 키 그대로 fill' 계약이 지켜진다.
73
+ """
74
+ if not candidates:
75
+ return
76
+ posmap = ad.label_positions_map()
77
+ for c in candidates:
78
+ matches = posmap.get(ad.normalize_label(c["label"]), [])
79
+ if len(matches) < 2:
80
+ continue
81
+ c["ambiguous"] = True
82
+ for i, m in enumerate(matches):
83
+ if (m["table_index"], m["row"], m["col"]) == (c["table_index"], c["row"], c["col"]):
84
+ c["fill_key"] = f"table:{c['label']}#{i + 1}"
85
+ break
86
+
87
+
88
+ def run_analyze(path: str) -> dict:
89
+ ad = HwpxEngineAdapter.open(path)
90
+ fields: list[dict] = []
91
+
92
+ for ff in ad.form_fields():
93
+ fields.append(
94
+ {
95
+ "type": "clickhere",
96
+ "fill_key": f"clickhere:{ff.name or ff.index}",
97
+ "name": ff.name,
98
+ "index": ff.index,
99
+ "current": ff.current,
100
+ }
101
+ )
102
+
103
+ for m in ad.markers():
104
+ fields.append(
105
+ {
106
+ "type": "marker",
107
+ "fill_key": f"marker:{m.key}",
108
+ "key": m.key,
109
+ "paragraph_index": m.paragraph_index,
110
+ "context": m.context,
111
+ }
112
+ )
113
+
114
+ table_map = ad.table_map()
115
+ candidates = find_label_candidates(table_map)
116
+ _assign_nth_keys(ad, candidates)
117
+ for cand in candidates:
118
+ fields.append({"type": "table_label", **cand})
119
+
120
+ paragraph_count = len(ad.export_text().splitlines())
121
+ return {
122
+ "file": os.path.abspath(path),
123
+ "fields": fields,
124
+ "tables": {"count": len(table_map.get("tables", []))},
125
+ "structure": {"paragraphs": paragraph_count},
126
+ }
@@ -0,0 +1,52 @@
1
+ """구형 .hwp → .hwpx 변환.
2
+
3
+ Windows + 한글(한컴오피스) 설치 환경에서만 동작 — 한글을 COM으로 백그라운드
4
+ 구동해 다른 형식으로 저장한다. 서버/타 OS에서는 명확한 에러를 낸다.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+
10
+ from hwpx_kit.output import quiet_engine
11
+
12
+ # visible=False 팝업 무한대기 방지 — 자동응답: 확인(0x1) / 확인·취소→확인(0x10)
13
+ # / 종료·재시도·무시→무시(0x400) / 예·아니오·취소→예(0x1000) / 예·아니오→예(0x10000)
14
+ # / 재시도·취소→취소(0x200000)
15
+ AUTO_ANSWER_MODE = 0x1 | 0x10 | 0x400 | 0x1000 | 0x10000 | 0x200000
16
+
17
+
18
+ def _load_hwp_com():
19
+ """pyhwpx.Hwp 클래스를 반환. 불가 환경이면 RuntimeError."""
20
+ try:
21
+ from pyhwpx import Hwp
22
+ except ImportError as exc:
23
+ raise RuntimeError(
24
+ "hwp 변환에는 Windows + 한글(한컴오피스) + pyhwpx가 필요합니다. "
25
+ "설치: pip install pyhwpx"
26
+ ) from exc
27
+ return Hwp
28
+
29
+
30
+ def run_convert(path: str, out_path: str | None = None) -> dict:
31
+ if not path.lower().endswith(".hwp"):
32
+ raise ValueError("convert 입력은 .hwp 파일이어야 합니다 (.hwpx는 변환 불필요)")
33
+ src = os.path.abspath(path)
34
+ out = os.path.abspath(out_path) if out_path else src[: -len(".hwp")] + ".hwpx"
35
+ if out == src:
36
+ raise ValueError("출력 경로가 원본과 같습니다.")
37
+
38
+ # pyhwpx는 RegisterModule 실패 등을 print()로 stdout에 씀 —
39
+ # --json 봉투 오염 방지 (절대 규칙: JSON 모드 stdout은 봉투 한 줄만)
40
+ with quiet_engine():
41
+ hwp_cls = _load_hwp_com()
42
+ hwp = hwp_cls(visible=False)
43
+ try:
44
+ if hasattr(hwp, "set_message_box_mode"):
45
+ hwp.set_message_box_mode(AUTO_ANSWER_MODE)
46
+ if not hwp.open(src):
47
+ raise RuntimeError(f"한글이 파일을 열지 못했습니다: {src}")
48
+ if not hwp.save_as(out, format="HWPX"):
49
+ raise RuntimeError(f"HWPX 저장에 실패했습니다: {out}")
50
+ finally:
51
+ hwp.quit()
52
+ return {"file": src, "out": out}
@@ -0,0 +1,44 @@
1
+ """hwpx → 타 형식 내보내기 (현재 docx).
2
+
3
+ Windows + 한글(한컴오피스) COM 경유 — convert와 같은 제약.
4
+ 한글 SaveAs의 워드 형식 문자열은 "OOXML"이다 ("DOCX"/"MSWORD"는 조용히
5
+ 실패 — 2026-07-10 실탐침). 다른 형식 저장 시 경고 팝업이 뜰 수 있는데
6
+ visible=False라 보이지 않아 무한대기가 됨 — 메시지박스 자동응답 필수.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ from hwpx_kit.commands import convert as _convert
13
+ from hwpx_kit.commands.convert import AUTO_ANSWER_MODE
14
+ from hwpx_kit.output import quiet_engine
15
+
16
+ # to → (한글 SaveAs format 문자열, 확장자)
17
+ _FORMATS = {"docx": ("OOXML", ".docx")}
18
+
19
+
20
+ def run_export(path: str, to: str = "docx", out_path: str | None = None) -> dict:
21
+ if not path.lower().endswith(".hwpx"):
22
+ raise ValueError("export 입력은 .hwpx 파일이어야 합니다 (.hwp는 먼저 convert)")
23
+ if to not in _FORMATS:
24
+ raise ValueError(f"지원 형식: {', '.join(_FORMATS)} (요청: {to})")
25
+ save_format, ext = _FORMATS[to]
26
+
27
+ src = os.path.abspath(path)
28
+ out = os.path.abspath(out_path) if out_path else src[: -len(".hwpx")] + ext
29
+ if out == src:
30
+ raise ValueError("출력 경로가 원본과 같습니다.")
31
+
32
+ with quiet_engine():
33
+ hwp_cls = _convert._load_hwp_com()
34
+ hwp = hwp_cls(visible=False)
35
+ try:
36
+ if hasattr(hwp, "set_message_box_mode"):
37
+ hwp.set_message_box_mode(AUTO_ANSWER_MODE)
38
+ if not hwp.open(src):
39
+ raise RuntimeError(f"한글이 파일을 열지 못했습니다: {src}")
40
+ if not hwp.save_as(out, format=save_format):
41
+ raise RuntimeError(f"{to} 저장에 실패했습니다: {out}")
42
+ finally:
43
+ hwp.quit()
44
+ return {"file": src, "out": out, "format": to}
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from hwpx_kit.adapter.hwpx_engine import HwpxEngineAdapter
6
+
7
+ # 라벨 끝의 출현 인덱스: "부서#2" → ("부서", 2). 중복 라벨 구분용
8
+ _NTH_RE = re.compile(r"^(.+?)\s*#(\d+)$")
9
+
10
+ _DIRECTIONS = {"left", "right", "up", "down"}
11
+
12
+
13
+ def _parse_table_spec(spec: str) -> tuple[str, list[str], int | None]:
14
+ """"라벨#N > 방향..." 분해. 방향 토큰은 끝에서부터 유효한 것만 소비 —
15
+ 라벨 자체에 '>'가 들어가도('담당 부서 <총괄>') 잘리지 않는다."""
16
+ parts = spec.split(">")
17
+ directions: list[str] = []
18
+ while len(parts) > 1 and parts[-1].strip().casefold() in _DIRECTIONS:
19
+ directions.insert(0, parts.pop().strip().casefold())
20
+ label = ">".join(parts).strip()
21
+ if not directions:
22
+ directions = ["right"]
23
+ nth = None
24
+ nth_match = _NTH_RE.match(label)
25
+ if nth_match:
26
+ label, nth = nth_match.group(1), int(nth_match.group(2))
27
+ return label, directions, nth
28
+
29
+
30
+ def _apply_one(ad: HwpxEngineAdapter, fill_key: str, value: str) -> str | None:
31
+ """성공 시 None, 실패 시 사유 문자열 반환."""
32
+ if fill_key.startswith("clickhere:"):
33
+ name = fill_key[len("clickhere:"):]
34
+ try:
35
+ ad.fill_form_field(name, value)
36
+ return None
37
+ except Exception as exc: # 엔진이 없는 필드에 던지는 예외를 사유로 변환
38
+ return f"누름틀 채우기 실패: {exc}"
39
+
40
+ if fill_key.startswith("marker:"):
41
+ key = fill_key[len("marker:"):]
42
+ count = ad.replace_marker(key, value)
43
+ return None if count > 0 else "문서에서 마커를 찾지 못함"
44
+
45
+ if fill_key.startswith("text:"):
46
+ search = fill_key[len("text:"):]
47
+ count = ad.replace_text(search, value)
48
+ if count == 0:
49
+ # 런이 쪼개진 긴 문장 — 문단 전체 일치로 폴백
50
+ count = ad.replace_paragraph_text(search, value)
51
+ return None if count > 0 else "문서에서 해당 문구를 찾지 못함"
52
+
53
+ if fill_key.startswith("delete:"):
54
+ search = fill_key[len("delete:"):]
55
+ count = ad.delete_paragraph_text(search)
56
+ return None if count > 0 else "문서에서 해당 문단을 찾지 못함"
57
+
58
+ if fill_key.startswith("bold:"):
59
+ count = ad.apply_run_format(fill_key[len("bold:"):], bold=True)
60
+ return None if count > 0 else "문서에서 해당 문구를 찾지 못함"
61
+
62
+ if fill_key.startswith("underline:"):
63
+ count = ad.apply_run_format(fill_key[len("underline:"):], underline=True)
64
+ return None if count > 0 else "문서에서 해당 문구를 찾지 못함"
65
+
66
+ if fill_key.startswith("table:"):
67
+ label, directions, nth = _parse_table_spec(fill_key[len("table:"):])
68
+ result = ad.fill_at_label(label, directions, value, nth=nth)
69
+ if result.get("applied_count", 0) > 0:
70
+ return None
71
+ failed = result.get("failed") or [{}]
72
+ reason = failed[0].get("reason", "라벨 없음")
73
+ if "ambiguous" in str(reason):
74
+ reason = f"{reason} — 같은 라벨이 여러 곳. analyze의 table:라벨#N 키로 지정"
75
+ return f"표 채우기 실패: {reason}"
76
+
77
+ return "알 수 없는 fill_key 형식 (clickhere:/marker:/table:/text:/delete:/bold:/underline: 중 하나여야 함)"
78
+
79
+
80
+ def run_fill(path: str, data: dict[str, str], out_path: str) -> dict:
81
+ ad = HwpxEngineAdapter.open(path)
82
+ applied: list[str] = []
83
+ unmatched: list[dict] = []
84
+ for fill_key, value in data.items():
85
+ reason = _apply_one(ad, fill_key, str(value))
86
+ if reason is None:
87
+ applied.append(fill_key)
88
+ else:
89
+ unmatched.append({"key": fill_key, "reason": reason})
90
+ saved = ad.save_copy(out_path)
91
+ return {"out": saved, "applied": applied, "unmatched": unmatched}
@@ -0,0 +1,29 @@
1
+ """fmt 명령 — 결정론 표기 변환 (파일 입력 없음).
2
+
3
+ 정확히 하나의 변환 대상(amount/date/age)을 받아 envelope로 반환.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from hwpx_kit.format import format_amount, gongmun_date, korean_age
8
+
9
+
10
+ def run_fmt(
11
+ amount: str | None = None,
12
+ date: str | None = None,
13
+ age: str | None = None,
14
+ base: str | None = None,
15
+ style: str = "gongmun",
16
+ ) -> dict:
17
+ targets = [k for k, v in (("amount", amount), ("date", date), ("age", age)) if v is not None]
18
+ if len(targets) != 1:
19
+ raise ValueError("--amount / --date / --age 중 정확히 하나를 지정하세요.")
20
+
21
+ if amount is not None:
22
+ cleaned = amount.replace(",", "").strip()
23
+ if not cleaned.isdigit():
24
+ raise ValueError(f"금액은 정수여야 합니다: {amount}")
25
+ result = format_amount(int(cleaned), style=style)
26
+ return {"kind": "amount", "input": amount, "style": style, "result": result}
27
+ if date is not None:
28
+ return {"kind": "date", "input": date, "result": gongmun_date(date)}
29
+ return {"kind": "age", "input": age, "base": base, "result": korean_age(age, base=base)}
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from hwpx_kit.adapter.kordoc_engine import KordocAdapter
6
+
7
+
8
+ def run_generate(markdown_path: str, out_path: str, preset: str | None = None) -> dict:
9
+ if not markdown_path.lower().endswith((".md", ".markdown")):
10
+ raise ValueError("generate 입력은 Markdown 파일이어야 합니다.")
11
+ saved = KordocAdapter.generate_hwpx(markdown_path, out_path, preset=preset)
12
+ return {"file": os.path.abspath(markdown_path), "out": saved, "preset": preset}