@clazic/kordoc 2.1.6 → 2.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 +67 -4
  2. package/dist/chunk-JJMA5HGQ.js +9617 -0
  3. package/dist/chunk-JJMA5HGQ.js.map +1 -0
  4. package/dist/{chunk-TFYOEQE2.js → chunk-XWET7ONC.js} +2 -2
  5. package/dist/chunk-ZWE3DS7E.js +39 -0
  6. package/dist/cli.js +114 -11
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.cjs +3000 -179
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +36 -4
  11. package/dist/index.d.ts +36 -4
  12. package/dist/index.js +3024 -178
  13. package/dist/index.js.map +1 -1
  14. package/dist/mcp.js +100 -7
  15. package/dist/mcp.js.map +1 -1
  16. package/dist/{page-range-737B4EZW.js → page-range-ALIRXAL5.js} +2 -1
  17. package/dist/provider-XVKP5OGI.js +167 -0
  18. package/dist/provider-XVKP5OGI.js.map +1 -0
  19. package/dist/resolve-Y3KMGD3R.js +187 -0
  20. package/dist/resolve-Y3KMGD3R.js.map +1 -0
  21. package/dist/tesseract-provider-MZ37ZKQW.js +31 -0
  22. package/dist/tesseract-provider-MZ37ZKQW.js.map +1 -0
  23. package/dist/{utils-7JE5SKSL.js → utils-4NP2VUFW.js} +3 -2
  24. package/dist/utils-4NP2VUFW.js.map +1 -0
  25. package/dist/{watch-XALC6VOR.js → watch-4VVWG2WC.js} +4 -3
  26. package/dist/{watch-XALC6VOR.js.map → watch-4VVWG2WC.js.map} +1 -1
  27. package/package.json +4 -2
  28. package/dist/chunk-H7HMKSLX.js +0 -5494
  29. package/dist/chunk-H7HMKSLX.js.map +0 -1
  30. package/dist/provider-A4FHJSID.js +0 -38
  31. package/dist/provider-A4FHJSID.js.map +0 -1
  32. /package/dist/{chunk-TFYOEQE2.js.map → chunk-XWET7ONC.js.map} +0 -0
  33. /package/dist/{page-range-737B4EZW.js.map → chunk-ZWE3DS7E.js.map} +0 -0
  34. /package/dist/{utils-7JE5SKSL.js.map → page-range-ALIRXAL5.js.map} +0 -0
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ import "./chunk-ZWE3DS7E.js";
3
+
4
+ // src/ocr/auto-detect.ts
5
+ import { execSync } from "child_process";
6
+ import { createRequire } from "module";
7
+ var CLI_PRIORITY = ["gemini", "claude", "codex", "ollama"];
8
+ function detectAvailableOcr() {
9
+ for (const cli of CLI_PRIORITY) {
10
+ if (isCliInstalled(cli)) return cli;
11
+ }
12
+ if (isTesseractAvailable()) return "tesseract";
13
+ return null;
14
+ }
15
+ function isCliInstalled(name) {
16
+ try {
17
+ const cmd = process.platform === "win32" ? "where" : "which";
18
+ execSync(`${cmd} ${name}`, { stdio: "ignore", timeout: 3e3 });
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+ function isTesseractAvailable() {
25
+ try {
26
+ const require2 = createRequire(import.meta.url);
27
+ require2.resolve("tesseract.js");
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ function validateOcrMode(mode) {
34
+ if (mode === "auto" || mode === "off") return;
35
+ if (mode === "tesseract") {
36
+ if (!isTesseractAvailable()) {
37
+ throw new Error(
38
+ "tesseract.js\uAC00 \uC124\uCE58\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.\n\uC124\uCE58: npm install tesseract.js"
39
+ );
40
+ }
41
+ return;
42
+ }
43
+ if (!isCliInstalled(mode)) {
44
+ throw new Error(`'${mode}' CLI\uAC00 \uC124\uCE58\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.
45
+ ${getInstallGuide(mode)}`);
46
+ }
47
+ }
48
+ function getInstallGuide(mode) {
49
+ const guides = {
50
+ gemini: "\uC124\uCE58: https://ai.google.dev/gemini-api/docs/cli",
51
+ claude: "\uC124\uCE58: npm install -g @anthropic-ai/claude-code \uB610\uB294 https://claude.ai/code",
52
+ codex: "\uC124\uCE58: npm install -g @openai/codex \uB610\uB294 https://github.com/openai/codex",
53
+ ollama: "\uC124\uCE58: brew install ollama \uB610\uB294 https://ollama.com/download"
54
+ };
55
+ return guides[mode] || `'${mode}'\uC744(\uB97C) \uC124\uCE58\uD574\uC8FC\uC138\uC694.`;
56
+ }
57
+
58
+ // src/ocr/cli-provider.ts
59
+ import { spawnSync } from "child_process";
60
+ import { writeFileSync, unlinkSync, mkdtempSync } from "fs";
61
+ import { join } from "path";
62
+ import { tmpdir } from "os";
63
+ var OCR_PROMPT = "\uC774 PDF \uD398\uC774\uC9C0 \uC774\uBBF8\uC9C0\uC5D0\uC11C \uD14D\uC2A4\uD2B8\uC640 \uD14C\uC774\uBE14\uC744 \uCD94\uCD9C\uD558\uC5EC \uC21C\uC218 Markdown\uC73C\uB85C \uBCC0\uD658\uD558\uC138\uC694.\n\uADDC\uCE59:\n- \uD14C\uC774\uBE14\uC740 Markdown \uD14C\uC774\uBE14 \uBB38\uBC95 \uC0AC\uC6A9 (| \uAD6C\uBD84, |---|---| \uD5E4\uB354 \uAD6C\uBD84\uC120 \uD3EC\uD568)\n- \uBCD1\uD569\uB41C \uC140\uC740 \uD574\uB2F9 \uC704\uCE58\uC5D0 \uB0B4\uC6A9 \uAE30\uC7AC\n- \uD5E4\uB529\uC740 \uAE00\uC790 \uD06C\uAE30\uC5D0 \uB530\uB77C ## ~ ###### \uC0AC\uC6A9\n- \uB9AC\uC2A4\uD2B8\uB294 - \uB610\uB294 1. \uC0AC\uC6A9\n- \uC774\uBBF8\uC9C0, \uB3C4\uD615 \uB4F1 \uBE44\uD14D\uC2A4\uD2B8 \uC694\uC18C\uB294 \uBB34\uC2DC\n- \uC6D0\uBB38\uC758 \uC77D\uAE30 \uC21C\uC11C\uC640 \uAD6C\uC870\uB97C \uC720\uC9C0\n- ```\uB85C \uAC10\uC2F8\uC9C0 \uB9D0\uACE0 \uC21C\uC218 Markdown\uB9CC \uCD9C\uB825";
64
+ var _tempDir = null;
65
+ function getTempDir() {
66
+ if (!_tempDir) _tempDir = mkdtempSync(join(tmpdir(), "kordoc-ocr-"));
67
+ return _tempDir;
68
+ }
69
+ function createCliOcrProvider(mode) {
70
+ return async (pageImage, pageNumber) => {
71
+ const tempPath = join(getTempDir(), `page-${pageNumber}.png`);
72
+ try {
73
+ writeFileSync(tempPath, pageImage);
74
+ let output;
75
+ if (mode === "ollama") {
76
+ output = await callOllamaApi(tempPath);
77
+ } else {
78
+ output = callCli(mode, tempPath);
79
+ }
80
+ return { markdown: stripCodeFence(output.trim()) };
81
+ } finally {
82
+ try {
83
+ unlinkSync(tempPath);
84
+ } catch {
85
+ }
86
+ }
87
+ };
88
+ }
89
+ function callCli(mode, imagePath) {
90
+ const args = buildCliArgs(mode, imagePath);
91
+ const result = spawnSync(mode === "codex" ? "codex" : mode, args, {
92
+ encoding: "utf-8",
93
+ timeout: 6e4,
94
+ maxBuffer: 10 * 1024 * 1024
95
+ });
96
+ if (result.error) {
97
+ throw new Error(`${mode} CLI \uC2E4\uD589 \uC2E4\uD328: ${result.error.message}`);
98
+ }
99
+ if (result.status !== 0) {
100
+ const errMsg = result.stderr?.trim() || `exit code ${result.status}`;
101
+ throw new Error(`${mode} OCR \uC2E4\uD328: ${errMsg}`);
102
+ }
103
+ return result.stdout || "";
104
+ }
105
+ function buildCliArgs(mode, imagePath) {
106
+ const promptWithImage = `${OCR_PROMPT}
107
+
108
+ \uC774\uBBF8\uC9C0: @${imagePath}`;
109
+ const promptOnly = OCR_PROMPT;
110
+ switch (mode) {
111
+ case "gemini":
112
+ return ["--prompt", promptWithImage, "--yolo"];
113
+ case "claude":
114
+ return ["--print", promptWithImage];
115
+ case "codex":
116
+ return ["exec", "--image", imagePath, promptOnly];
117
+ default:
118
+ throw new Error(`\uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 CLI: ${mode}`);
119
+ }
120
+ }
121
+ async function callOllamaApi(imagePath) {
122
+ const { readFileSync } = await import("fs");
123
+ const imageBase64 = readFileSync(imagePath).toString("base64");
124
+ const model = process.env.KORDOC_OLLAMA_MODEL || "gemma4:27b";
125
+ const host = process.env.KORDOC_OLLAMA_HOST || "http://localhost:11434";
126
+ const response = await fetch(`${host}/api/chat`, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify({
130
+ model,
131
+ messages: [{
132
+ role: "user",
133
+ content: OCR_PROMPT,
134
+ images: [imageBase64]
135
+ }],
136
+ stream: false
137
+ }),
138
+ signal: AbortSignal.timeout(6e4)
139
+ });
140
+ if (!response.ok) {
141
+ throw new Error(`Ollama API \uC624\uB958: ${response.status} ${response.statusText}`);
142
+ }
143
+ const data = await response.json();
144
+ return data.message?.content || "";
145
+ }
146
+ function stripCodeFence(text) {
147
+ const match = text.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```\s*$/m);
148
+ return match ? match[1].trim() : text;
149
+ }
150
+
151
+ // src/ocr/resolve.ts
152
+ async function resolveOcrProvider(mode, warnings) {
153
+ if (mode === "off") {
154
+ throw new Error("OCR\uC774 \uBE44\uD65C\uC131\uD654\uB418\uC5B4 \uC788\uC2B5\uB2C8\uB2E4 (--ocr off).");
155
+ }
156
+ if (mode !== "auto") {
157
+ validateOcrMode(mode);
158
+ if (mode === "tesseract") {
159
+ const { createTesseractProvider } = await import("./tesseract-provider-MZ37ZKQW.js");
160
+ return createTesseractProvider();
161
+ }
162
+ return createCliOcrProvider(mode);
163
+ }
164
+ const detected = detectAvailableOcr();
165
+ if (!detected) {
166
+ throw new Error("\uC0AC\uC6A9 \uAC00\uB2A5\uD55C OCR \uB3C4\uAD6C\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
167
+ }
168
+ if (detected !== "gemini") {
169
+ warnings?.push({
170
+ message: `OCR: '${detected}' \uC0AC\uC6A9 \uC911 (gemini CLI\uAC00 \uC5C6\uC5B4 fallback). \uB354 \uB098\uC740 \uD488\uC9C8\uC744 \uC704\uD574 gemini CLI \uC124\uCE58\uB97C \uAD8C\uC7A5\uD569\uB2C8\uB2E4.`,
171
+ code: "OCR_CLI_FALLBACK"
172
+ });
173
+ }
174
+ if (detected === "tesseract") {
175
+ warnings?.push({
176
+ message: "tesseract.js\uB294 \uD14C\uC774\uBE14 \uAD6C\uC870\uB97C \uBCF5\uC6D0\uD558\uC9C0 \uBABB\uD569\uB2C8\uB2E4. Vision LLM CLI(gemini/claude/codex) \uC124\uCE58\uB97C \uAD8C\uC7A5\uD569\uB2C8\uB2E4.",
177
+ code: "OCR_CLI_FALLBACK"
178
+ });
179
+ const { createTesseractProvider } = await import("./tesseract-provider-MZ37ZKQW.js");
180
+ return createTesseractProvider();
181
+ }
182
+ return createCliOcrProvider(detected);
183
+ }
184
+ export {
185
+ resolveOcrProvider
186
+ };
187
+ //# sourceMappingURL=resolve-Y3KMGD3R.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/ocr/auto-detect.ts","../src/ocr/cli-provider.ts","../src/ocr/resolve.ts"],"sourcesContent":["/**\n * OCR CLI 자동 탐색\n *\n * 탐색 순서: gemini → claude → codex → ollama → tesseract.js\n * CLI는 which(unix) / where(win) 명령어로 PATH 존재 확인.\n * tesseract.js는 createRequire로 resolve 시도.\n */\n\nimport { execSync } from \"child_process\"\nimport { createRequire } from \"module\"\nimport type { OcrMode } from \"../types.js\"\n\n/** CLI 탐색 우선순위 */\nconst CLI_PRIORITY = [\"gemini\", \"claude\", \"codex\", \"ollama\"] as const\n\n/**\n * 시스템에 설치된 OCR 도구를 우선순위대로 탐색.\n * @returns 사용 가능한 OcrMode 또는 null\n */\nexport function detectAvailableOcr(): OcrMode | null {\n // 1. CLI 프로그램 탐색 (gemini → claude → codex → ollama)\n for (const cli of CLI_PRIORITY) {\n if (isCliInstalled(cli)) return cli\n }\n\n // 2. tesseract.js npm 패키지 탐색\n if (isTesseractAvailable()) return \"tesseract\"\n\n return null\n}\n\n/**\n * 특정 CLI가 시스템 PATH에 있는지 확인.\n * which(unix) 또는 where(win32) 사용.\n */\nfunction isCliInstalled(name: string): boolean {\n try {\n const cmd = process.platform === \"win32\" ? \"where\" : \"which\"\n execSync(`${cmd} ${name}`, { stdio: \"ignore\", timeout: 3000 })\n return true\n } catch {\n return false\n }\n}\n\n/**\n * tesseract.js npm 패키지가 설치되어 있는지 확인.\n * ESM 환경에서 createRequire를 사용하여 resolve 시도.\n */\nfunction isTesseractAvailable(): boolean {\n try {\n const require = createRequire(import.meta.url)\n require.resolve(\"tesseract.js\")\n return true\n } catch {\n return false\n }\n}\n\n/**\n * 수동 지정된 OcrMode 유효성 검증.\n * --ocr gemini 등 강제 지정 시 호출.\n * @throws 해당 CLI/패키지가 설치되지 않은 경우 Error\n */\nexport function validateOcrMode(mode: OcrMode): void {\n if (mode === \"auto\" || mode === \"off\") return\n\n if (mode === \"tesseract\") {\n if (!isTesseractAvailable()) {\n throw new Error(\n \"tesseract.js가 설치되지 않았습니다.\\n\" +\n \"설치: npm install tesseract.js\"\n )\n }\n return\n }\n\n if (!isCliInstalled(mode)) {\n throw new Error(`'${mode}' CLI가 설치되지 않았습니다.\\n${getInstallGuide(mode)}`)\n }\n}\n\n/** CLI별 설치 안내 메시지 */\nfunction getInstallGuide(mode: string): string {\n const guides: Record<string, string> = {\n gemini: \"설치: https://ai.google.dev/gemini-api/docs/cli\",\n claude: \"설치: npm install -g @anthropic-ai/claude-code 또는 https://claude.ai/code\",\n codex: \"설치: npm install -g @openai/codex 또는 https://github.com/openai/codex\",\n ollama: \"설치: brew install ollama 또는 https://ollama.com/download\",\n }\n return guides[mode] || `'${mode}'을(를) 설치해주세요.`\n}\n\n/**\n * OCR 도구를 하나도 찾지 못했을 때 표시할 에러 메시지.\n */\nexport function getNoOcrErrorMessage(): string {\n return [\n \"이미지 기반 PDF입니다. OCR을 위해 다음 중 하나를 설치하세요:\",\n \"\",\n \" [권장] Gemini CLI: https://ai.google.dev/gemini-api/docs/cli\",\n \" Claude CLI: npm install -g @anthropic-ai/claude-code\",\n \" Codex CLI: npm install -g @openai/codex\",\n \" Ollama: brew install ollama (+ ollama pull gemma4:27b)\",\n \" Tesseract: npm install tesseract.js\",\n \"\",\n \"설치 후 다시 실행하면 자동으로 감지됩니다.\",\n \"특정 도구 지정: kordoc parse <파일> --ocr gemini\",\n ].join(\"\\n\")\n}\n","/**\n * CLI 기반 OCR 프로바이더\n *\n * gemini / claude / codex / ollama CLI를 subprocess로 호출하여\n * PDF 페이지 이미지를 Markdown으로 변환.\n *\n * 이미지 전달 방식:\n * - gemini: -p \"프롬프트 @이미지경로\" (@ 파일 참조)\n * - claude: -p \"프롬프트 @이미지경로\" (@ 파일 참조, --print 모드)\n * - codex: exec -i 이미지경로 \"프롬프트\" (-i/--image 플래그)\n * - ollama: REST API (localhost:11434) — CLI는 이미지 입력 미지원\n */\n\nimport { spawnSync } from \"child_process\"\nimport { writeFileSync, unlinkSync, mkdtempSync } from \"fs\"\nimport { join } from \"path\"\nimport { tmpdir } from \"os\"\nimport type { OcrMode, StructuredOcrResult } from \"../types.js\"\n\n/** OCR 프롬프트 — 모든 CLI 공통 */\nconst OCR_PROMPT =\n \"이 PDF 페이지 이미지에서 텍스트와 테이블을 추출하여 순수 Markdown으로 변환하세요.\\n\" +\n \"규칙:\\n\" +\n \"- 테이블은 Markdown 테이블 문법 사용 (| 구분, |---|---| 헤더 구분선 포함)\\n\" +\n \"- 병합된 셀은 해당 위치에 내용 기재\\n\" +\n \"- 헤딩은 글자 크기에 따라 ## ~ ###### 사용\\n\" +\n \"- 리스트는 - 또는 1. 사용\\n\" +\n \"- 이미지, 도형 등 비텍스트 요소는 무시\\n\" +\n \"- 원문의 읽기 순서와 구조를 유지\\n\" +\n \"- ```로 감싸지 말고 순수 Markdown만 출력\"\n\n/** 임시 디렉토리 (프로세스당 1회 생성) */\nlet _tempDir: string | null = null\nfunction getTempDir(): string {\n if (!_tempDir) _tempDir = mkdtempSync(join(tmpdir(), \"kordoc-ocr-\"))\n return _tempDir\n}\n\n/**\n * CLI OcrProvider 생성.\n *\n * @param mode - 사용할 CLI (gemini, claude, codex, ollama)\n * @returns OcrProvider 함수 (StructuredOcrResult 반환)\n */\nexport function createCliOcrProvider(\n mode: Exclude<OcrMode, \"auto\" | \"off\" | \"tesseract\">\n): (pageImage: Uint8Array, pageNumber: number, mimeType: \"image/png\") => Promise<StructuredOcrResult> {\n return async (pageImage: Uint8Array, pageNumber: number): Promise<StructuredOcrResult> => {\n const tempPath = join(getTempDir(), `page-${pageNumber}.png`)\n\n try {\n writeFileSync(tempPath, pageImage)\n\n let output: string\n if (mode === \"ollama\") {\n output = await callOllamaApi(tempPath)\n } else {\n output = callCli(mode, tempPath)\n }\n\n return { markdown: stripCodeFence(output.trim()) }\n } finally {\n try { unlinkSync(tempPath) } catch { /* 임시 파일 정리 실패 무시 */ }\n }\n }\n}\n\n/**\n * CLI 실행 — gemini / claude / codex\n *\n * @throws CLI 실행 실패 또는 타임아웃(60초) 시 Error\n */\nfunction callCli(mode: string, imagePath: string): string {\n const args = buildCliArgs(mode, imagePath)\n\n const result = spawnSync(mode === \"codex\" ? \"codex\" : mode, args, {\n encoding: \"utf-8\",\n timeout: 60_000,\n maxBuffer: 10 * 1024 * 1024,\n })\n\n if (result.error) {\n throw new Error(`${mode} CLI 실행 실패: ${result.error.message}`)\n }\n if (result.status !== 0) {\n const errMsg = result.stderr?.trim() || `exit code ${result.status}`\n throw new Error(`${mode} OCR 실패: ${errMsg}`)\n }\n\n return result.stdout || \"\"\n}\n\n/**\n * CLI별 인자 배열 생성.\n *\n * gemini: [\"--prompt\", \"프롬프트 @이미지경로\", \"--yolo\"]\n * - -y/--yolo: 자동 승인 (OCR은 도구 사용 없으므로 실질적 영향 없음)\n * - @ 파일 참조로 이미지를 컨텍스트에 포함\n *\n * claude: [\"--print\", \"프롬프트 @이미지경로\"]\n * - --print(-p): 비대화형 출력 모드\n * - @ 파일 참조로 이미지를 컨텍스트에 포함\n *\n * codex: [\"exec\", \"--image\", 이미지경로, \"프롬프트\"]\n * - exec: 비대화형 실행 서브커맨드\n * - -i/--image: 이미지 첨부 플래그 (codex exec --help 로 확인됨)\n *\n * ⚠️ CLI 버전에 따라 문법이 다를 수 있음. 업데이트 시 --help 재확인 필요.\n */\nfunction buildCliArgs(mode: string, imagePath: string): string[] {\n const promptWithImage = `${OCR_PROMPT}\\n\\n이미지: @${imagePath}`\n const promptOnly = OCR_PROMPT\n\n switch (mode) {\n case \"gemini\":\n return [\"--prompt\", promptWithImage, \"--yolo\"]\n\n case \"claude\":\n return [\"--print\", promptWithImage]\n\n case \"codex\":\n return [\"exec\", \"--image\", imagePath, promptOnly]\n\n default:\n throw new Error(`지원하지 않는 CLI: ${mode}`)\n }\n}\n\n/**\n * Ollama REST API 호출 — CLI는 이미지 입력을 지원하지 않으므로 API 직접 사용.\n *\n * 기본 모델: KORDOC_OLLAMA_MODEL 환경변수 또는 \"gemma4:27b\"\n * 기본 호스트: KORDOC_OLLAMA_HOST 환경변수 또는 \"http://localhost:11434\"\n *\n * @throws Ollama 서버 미실행 또는 응답 오류 시 Error\n */\nasync function callOllamaApi(imagePath: string): Promise<string> {\n const { readFileSync } = await import(\"fs\")\n const imageBase64 = readFileSync(imagePath).toString(\"base64\")\n\n const model = process.env.KORDOC_OLLAMA_MODEL || \"gemma4:27b\"\n const host = process.env.KORDOC_OLLAMA_HOST || \"http://localhost:11434\"\n\n const response = await fetch(`${host}/api/chat`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n model,\n messages: [{\n role: \"user\",\n content: OCR_PROMPT,\n images: [imageBase64],\n }],\n stream: false,\n }),\n signal: AbortSignal.timeout(60_000),\n })\n\n if (!response.ok) {\n throw new Error(`Ollama API 오류: ${response.status} ${response.statusText}`)\n }\n\n const data = await response.json() as { message?: { content?: string } }\n return data.message?.content || \"\"\n}\n\n/**\n * LLM 출력에서 코드 펜스 제거.\n * LLM이 가끔 결과를 ```markdown ... ``` 으로 감싸는 경우 처리.\n */\nfunction stripCodeFence(text: string): string {\n const match = text.match(/^```(?:markdown|md)?\\s*\\n([\\s\\S]*?)\\n```\\s*$/m)\n return match ? match[1].trim() : text\n}\n","/**\n * OCR 프로바이더 팩토리\n *\n * ocrMode에 따라 적절한 OcrProvider를 생성하여 반환.\n * - \"auto\": 설치된 CLI 자동 탐색 (gemini → claude → codex → ollama → tesseract)\n * - 특정 CLI: 해당 CLI 사용 (미설치 시 에러)\n * - \"off\": 에러 throw\n */\n\nimport type { OcrMode, OcrProvider, ParseWarning } from \"../types.js\"\nimport { detectAvailableOcr, validateOcrMode } from \"./auto-detect.js\"\nimport { createCliOcrProvider } from \"./cli-provider.js\"\n\n/**\n * ocrMode에 따라 OcrProvider를 생성.\n *\n * @param mode - OCR 모드\n * @param warnings - 경고 수집 배열 (fallback 발생 시 경고 추가)\n * @returns OcrProvider 함수\n * @throws 사용 가능한 OCR 도구가 없거나 mode=\"off\"인 경우\n */\nexport async function resolveOcrProvider(\n mode: OcrMode,\n warnings?: ParseWarning[]\n): Promise<OcrProvider> {\n if (mode === \"off\") {\n throw new Error(\"OCR이 비활성화되어 있습니다 (--ocr off).\")\n }\n\n // ── 수동 지정 모드 ──────────────────────────────────\n if (mode !== \"auto\") {\n validateOcrMode(mode)\n\n if (mode === \"tesseract\") {\n const { createTesseractProvider } = await import(\"./tesseract-provider.js\")\n return createTesseractProvider()\n }\n\n return createCliOcrProvider(mode)\n }\n\n // ── 자동 탐색 모드 ───────────────────────────────────\n const detected = detectAvailableOcr()\n\n if (!detected) {\n throw new Error(\"사용 가능한 OCR 도구를 찾을 수 없습니다.\")\n }\n\n // gemini가 아닌 경우 fallback 경고\n if (detected !== \"gemini\") {\n warnings?.push({\n message: `OCR: '${detected}' 사용 중 (gemini CLI가 없어 fallback). 더 나은 품질을 위해 gemini CLI 설치를 권장합니다.`,\n code: \"OCR_CLI_FALLBACK\",\n })\n }\n\n if (detected === \"tesseract\") {\n warnings?.push({\n message: \"tesseract.js는 테이블 구조를 복원하지 못합니다. Vision LLM CLI(gemini/claude/codex) 설치를 권장합니다.\",\n code: \"OCR_CLI_FALLBACK\",\n })\n const { createTesseractProvider } = await import(\"./tesseract-provider.js\")\n return createTesseractProvider()\n }\n\n return createCliOcrProvider(detected)\n}\n"],"mappings":";;;;AAQA,SAAS,gBAAgB;AACzB,SAAS,qBAAqB;AAI9B,IAAM,eAAe,CAAC,UAAU,UAAU,SAAS,QAAQ;AAMpD,SAAS,qBAAqC;AAEnD,aAAW,OAAO,cAAc;AAC9B,QAAI,eAAe,GAAG,EAAG,QAAO;AAAA,EAClC;AAGA,MAAI,qBAAqB,EAAG,QAAO;AAEnC,SAAO;AACT;AAMA,SAAS,eAAe,MAAuB;AAC7C,MAAI;AACF,UAAM,MAAM,QAAQ,aAAa,UAAU,UAAU;AACrD,aAAS,GAAG,GAAG,IAAI,IAAI,IAAI,EAAE,OAAO,UAAU,SAAS,IAAK,CAAC;AAC7D,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAMA,SAAS,uBAAgC;AACvC,MAAI;AACF,UAAMA,WAAU,cAAc,YAAY,GAAG;AAC7C,IAAAA,SAAQ,QAAQ,cAAc;AAC9B,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAOO,SAAS,gBAAgB,MAAqB;AACnD,MAAI,SAAS,UAAU,SAAS,MAAO;AAEvC,MAAI,SAAS,aAAa;AACxB,QAAI,CAAC,qBAAqB,GAAG;AAC3B,YAAM,IAAI;AAAA,QACR;AAAA,MAEF;AAAA,IACF;AACA;AAAA,EACF;AAEA,MAAI,CAAC,eAAe,IAAI,GAAG;AACzB,UAAM,IAAI,MAAM,IAAI,IAAI;AAAA,EAAuB,gBAAgB,IAAI,CAAC,EAAE;AAAA,EACxE;AACF;AAGA,SAAS,gBAAgB,MAAsB;AAC7C,QAAM,SAAiC;AAAA,IACrC,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR,OAAQ;AAAA,IACR,QAAQ;AAAA,EACV;AACA,SAAO,OAAO,IAAI,KAAK,IAAI,IAAI;AACjC;;;AC9EA,SAAS,iBAAiB;AAC1B,SAAS,eAAe,YAAY,mBAAmB;AACvD,SAAS,YAAY;AACrB,SAAS,cAAc;AAIvB,IAAM,aACJ;AAWF,IAAI,WAA0B;AAC9B,SAAS,aAAqB;AAC5B,MAAI,CAAC,SAAU,YAAW,YAAY,KAAK,OAAO,GAAG,aAAa,CAAC;AACnE,SAAO;AACT;AAQO,SAAS,qBACd,MACoG;AACpG,SAAO,OAAO,WAAuB,eAAqD;AACxF,UAAM,WAAW,KAAK,WAAW,GAAG,QAAQ,UAAU,MAAM;AAE5D,QAAI;AACF,oBAAc,UAAU,SAAS;AAEjC,UAAI;AACJ,UAAI,SAAS,UAAU;AACrB,iBAAS,MAAM,cAAc,QAAQ;AAAA,MACvC,OAAO;AACL,iBAAS,QAAQ,MAAM,QAAQ;AAAA,MACjC;AAEA,aAAO,EAAE,UAAU,eAAe,OAAO,KAAK,CAAC,EAAE;AAAA,IACnD,UAAE;AACA,UAAI;AAAE,mBAAW,QAAQ;AAAA,MAAE,QAAQ;AAAA,MAAuB;AAAA,IAC5D;AAAA,EACF;AACF;AAOA,SAAS,QAAQ,MAAc,WAA2B;AACxD,QAAM,OAAO,aAAa,MAAM,SAAS;AAEzC,QAAM,SAAS,UAAU,SAAS,UAAU,UAAU,MAAM,MAAM;AAAA,IAChE,UAAU;AAAA,IACV,SAAS;AAAA,IACT,WAAW,KAAK,OAAO;AAAA,EACzB,CAAC;AAED,MAAI,OAAO,OAAO;AAChB,UAAM,IAAI,MAAM,GAAG,IAAI,mCAAe,OAAO,MAAM,OAAO,EAAE;AAAA,EAC9D;AACA,MAAI,OAAO,WAAW,GAAG;AACvB,UAAM,SAAS,OAAO,QAAQ,KAAK,KAAK,aAAa,OAAO,MAAM;AAClE,UAAM,IAAI,MAAM,GAAG,IAAI,sBAAY,MAAM,EAAE;AAAA,EAC7C;AAEA,SAAO,OAAO,UAAU;AAC1B;AAmBA,SAAS,aAAa,MAAc,WAA6B;AAC/D,QAAM,kBAAkB,GAAG,UAAU;AAAA;AAAA,uBAAa,SAAS;AAC3D,QAAM,aAAa;AAEnB,UAAQ,MAAM;AAAA,IACZ,KAAK;AACH,aAAO,CAAC,YAAY,iBAAiB,QAAQ;AAAA,IAE/C,KAAK;AACH,aAAO,CAAC,WAAW,eAAe;AAAA,IAEpC,KAAK;AACH,aAAO,CAAC,QAAQ,WAAW,WAAW,UAAU;AAAA,IAElD;AACE,YAAM,IAAI,MAAM,8CAAgB,IAAI,EAAE;AAAA,EAC1C;AACF;AAUA,eAAe,cAAc,WAAoC;AAC/D,QAAM,EAAE,aAAa,IAAI,MAAM,OAAO,IAAI;AAC1C,QAAM,cAAc,aAAa,SAAS,EAAE,SAAS,QAAQ;AAE7D,QAAM,QAAQ,QAAQ,IAAI,uBAAuB;AACjD,QAAM,OAAO,QAAQ,IAAI,sBAAsB;AAE/C,QAAM,WAAW,MAAM,MAAM,GAAG,IAAI,aAAa;AAAA,IAC/C,QAAQ;AAAA,IACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,IAC9C,MAAM,KAAK,UAAU;AAAA,MACnB;AAAA,MACA,UAAU,CAAC;AAAA,QACT,MAAM;AAAA,QACN,SAAS;AAAA,QACT,QAAQ,CAAC,WAAW;AAAA,MACtB,CAAC;AAAA,MACD,QAAQ;AAAA,IACV,CAAC;AAAA,IACD,QAAQ,YAAY,QAAQ,GAAM;AAAA,EACpC,CAAC;AAED,MAAI,CAAC,SAAS,IAAI;AAChB,UAAM,IAAI,MAAM,4BAAkB,SAAS,MAAM,IAAI,SAAS,UAAU,EAAE;AAAA,EAC5E;AAEA,QAAM,OAAO,MAAM,SAAS,KAAK;AACjC,SAAO,KAAK,SAAS,WAAW;AAClC;AAMA,SAAS,eAAe,MAAsB;AAC5C,QAAM,QAAQ,KAAK,MAAM,+CAA+C;AACxE,SAAO,QAAQ,MAAM,CAAC,EAAE,KAAK,IAAI;AACnC;;;ACxJA,eAAsB,mBACpB,MACA,UACsB;AACtB,MAAI,SAAS,OAAO;AAClB,UAAM,IAAI,MAAM,sFAA+B;AAAA,EACjD;AAGA,MAAI,SAAS,QAAQ;AACnB,oBAAgB,IAAI;AAEpB,QAAI,SAAS,aAAa;AACxB,YAAM,EAAE,wBAAwB,IAAI,MAAM,OAAO,kCAAyB;AAC1E,aAAO,wBAAwB;AAAA,IACjC;AAEA,WAAO,qBAAqB,IAAI;AAAA,EAClC;AAGA,QAAM,WAAW,mBAAmB;AAEpC,MAAI,CAAC,UAAU;AACb,UAAM,IAAI,MAAM,sGAA2B;AAAA,EAC7C;AAGA,MAAI,aAAa,UAAU;AACzB,cAAU,KAAK;AAAA,MACb,SAAS,SAAS,QAAQ;AAAA,MAC1B,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AAEA,MAAI,aAAa,aAAa;AAC5B,cAAU,KAAK;AAAA,MACb,SAAS;AAAA,MACT,MAAM;AAAA,IACR,CAAC;AACD,UAAM,EAAE,wBAAwB,IAAI,MAAM,OAAO,kCAAyB;AAC1E,WAAO,wBAAwB;AAAA,EACjC;AAEA,SAAO,qBAAqB,QAAQ;AACtC;","names":["require"]}
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ import "./chunk-ZWE3DS7E.js";
3
+
4
+ // src/ocr/tesseract-provider.ts
5
+ async function createTesseractProvider() {
6
+ let tesseract;
7
+ try {
8
+ tesseract = await import("tesseract.js");
9
+ } catch {
10
+ throw new Error(
11
+ "tesseract.js\uAC00 \uC124\uCE58\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.\n\uC124\uCE58: npm install tesseract.js"
12
+ );
13
+ }
14
+ const worker = await tesseract.createWorker("kor+eng");
15
+ let terminated = false;
16
+ const provider = async (pageImage, _pageNumber, _mimeType) => {
17
+ const { data } = await worker.recognize(pageImage);
18
+ return data.text;
19
+ };
20
+ provider.terminate = async () => {
21
+ if (!terminated) {
22
+ await worker.terminate();
23
+ terminated = true;
24
+ }
25
+ };
26
+ return provider;
27
+ }
28
+ export {
29
+ createTesseractProvider
30
+ };
31
+ //# sourceMappingURL=tesseract-provider-MZ37ZKQW.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/ocr/tesseract-provider.ts"],"sourcesContent":["/**\n * Tesseract.js 기반 OCR 프로바이더\n *\n * tesseract.js를 선택적 의존성으로 동적 import하여 텍스트 추출.\n * Vision LLM CLI가 없을 때의 최후 fallback.\n *\n * 제한사항:\n * - 순수 텍스트만 반환 (테이블/헤딩 구조 복원 불가)\n * - 한글 인식률 약 85-90% (깨끗한 이미지 기준)\n * - 설치 필요: npm install tesseract.js\n */\n\nimport type { OcrProvider } from \"../types.js\"\n\n/**\n * Tesseract.js OcrProvider 생성.\n *\n * 워커를 1회 생성하여 재사용 (매 페이지마다 초기화 방지).\n * 문서 처리 완료 후 terminate()로 워커 정리 필요.\n *\n * @returns OcrProvider 함수\n * @throws tesseract.js 미설치 시 Error\n */\nexport async function createTesseractProvider(): Promise<OcrProvider & { terminate: () => Promise<void> }> {\n // 동적 import — 미설치 시 명확한 에러\n let tesseract: typeof import(\"tesseract.js\")\n try {\n tesseract = await import(\"tesseract.js\")\n } catch {\n throw new Error(\n \"tesseract.js가 설치되지 않았습니다.\\n\" +\n \"설치: npm install tesseract.js\"\n )\n }\n\n // kor+eng: 한글 + 영문 동시 인식 (한국 공문서 특성)\n const worker = await tesseract.createWorker(\"kor+eng\")\n let terminated = false\n\n const provider = async (\n pageImage: Uint8Array,\n _pageNumber: number,\n _mimeType: \"image/png\"\n ): Promise<string> => {\n const { data } = await worker.recognize(pageImage)\n return data.text\n }\n\n // 외부에서 호출하여 워커 정리\n ;(provider as OcrProvider & { terminate: () => Promise<void> }).terminate = async () => {\n if (!terminated) {\n await worker.terminate()\n terminated = true\n }\n }\n\n return provider as OcrProvider & { terminate: () => Promise<void> }\n}\n"],"mappings":";;;;AAuBA,eAAsB,0BAAqF;AAEzG,MAAI;AACJ,MAAI;AACF,gBAAY,MAAM,OAAO,cAAc;AAAA,EACzC,QAAQ;AACN,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AAGA,QAAM,SAAS,MAAM,UAAU,aAAa,SAAS;AACrD,MAAI,aAAa;AAEjB,QAAM,WAAW,OACf,WACA,aACA,cACoB;AACpB,UAAM,EAAE,KAAK,IAAI,MAAM,OAAO,UAAU,SAAS;AACjD,WAAO,KAAK;AAAA,EACd;AAGC,EAAC,SAA8D,YAAY,YAAY;AACtF,QAAI,CAAC,YAAY;AACf,YAAM,OAAO,UAAU;AACvB,mBAAa;AAAA,IACf;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}
@@ -8,7 +8,8 @@ import {
8
8
  sanitizeError,
9
9
  sanitizeHref,
10
10
  toArrayBuffer
11
- } from "./chunk-TFYOEQE2.js";
11
+ } from "./chunk-XWET7ONC.js";
12
+ import "./chunk-ZWE3DS7E.js";
12
13
  export {
13
14
  KordocError,
14
15
  VERSION,
@@ -19,4 +20,4 @@ export {
19
20
  sanitizeHref,
20
21
  toArrayBuffer
21
22
  };
22
- //# sourceMappingURL=utils-7JE5SKSL.js.map
23
+ //# sourceMappingURL=utils-4NP2VUFW.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -2,11 +2,12 @@
2
2
  import {
3
3
  detectFormat,
4
4
  parse
5
- } from "./chunk-H7HMKSLX.js";
5
+ } from "./chunk-JJMA5HGQ.js";
6
6
  import {
7
7
  toArrayBuffer
8
- } from "./chunk-TFYOEQE2.js";
8
+ } from "./chunk-XWET7ONC.js";
9
9
  import "./chunk-MOL7MDBG.js";
10
+ import "./chunk-ZWE3DS7E.js";
10
11
 
11
12
  // src/watch.ts
12
13
  import { watch, readFileSync, writeFileSync, mkdirSync, statSync, existsSync } from "fs";
@@ -125,4 +126,4 @@ async function sendWebhook(url, payload) {
125
126
  export {
126
127
  watchDirectory
127
128
  };
128
- //# sourceMappingURL=watch-XALC6VOR.js.map
129
+ //# sourceMappingURL=watch-4VVWG2WC.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/watch.ts"],"sourcesContent":["/** 디렉토리 감시 모드 — 새 문서 자동 변환 + Webhook 알림 */\n\nimport { watch, readFileSync, writeFileSync, mkdirSync, statSync, existsSync } from \"fs\"\nimport { basename, resolve, extname } from \"path\"\nimport { parse, detectFormat } from \"./index.js\"\nimport { toArrayBuffer } from \"./utils.js\"\nimport type { WatchOptions } from \"./types.js\"\n\nconst SUPPORTED_EXTENSIONS = new Set([\".hwp\", \".hwpx\", \".pdf\", \".xlsx\", \".docx\"])\nconst DEBOUNCE_MS = 1000\n/** 파일 쓰기 완료 판정: 연속 2회 동일 크기 확인 간격 */\nconst STABLE_CHECK_MS = 300\nconst MAX_FILE_SIZE = 500 * 1024 * 1024\n\n/**\n * 디렉토리를 감시하여 새 문서 파일을 자동 변환.\n *\n * @example\n * ```bash\n * kordoc watch ./incoming -d ./output --webhook https://api.example.com/docs\n * ```\n */\nexport async function watchDirectory(options: WatchOptions): Promise<void> {\n const { dir, outDir, webhook, format = \"markdown\", pages, silent } = options\n\n if (!existsSync(dir)) throw new Error(`디렉토리를 찾을 수 없습니다: ${dir}`)\n if (webhook) validateWebhookUrl(webhook)\n if (outDir) mkdirSync(outDir, { recursive: true })\n\n const log = silent ? () => {} : (msg: string) => process.stderr.write(msg + \"\\n\")\n log(`[kordoc watch] 감시 시작: ${resolve(dir)}`)\n if (outDir) log(`[kordoc watch] 출력: ${resolve(outDir)}`)\n if (webhook) log(`[kordoc watch] 웹훅: ${webhook}`)\n\n // 디바운스 맵\n const pending = new Map<string, ReturnType<typeof setTimeout>>()\n\n /** 파일 크기가 안정화될 때까지 대기 (쓰기 완료 감지) */\n const waitForStableSize = async (absPath: string): Promise<number> => {\n let prevSize = statSync(absPath).size\n await new Promise(r => setTimeout(r, STABLE_CHECK_MS))\n if (!existsSync(absPath)) return 0\n const currSize = statSync(absPath).size\n if (currSize !== prevSize) {\n // 크기가 변했으면 한 번 더 대기\n await new Promise(r => setTimeout(r, STABLE_CHECK_MS))\n if (!existsSync(absPath)) return 0\n return statSync(absPath).size\n }\n return currSize\n }\n\n const processFile = async (filePath: string) => {\n const ext = extname(filePath).toLowerCase()\n if (!SUPPORTED_EXTENSIONS.has(ext)) return\n\n const fileName = basename(filePath)\n try {\n const absPath = resolve(dir, filePath)\n // 경로 순회 방지 — 감시 디렉토리 외부 파일 차단\n const realDir = resolve(dir)\n if (!absPath.startsWith(realDir)) return\n if (!existsSync(absPath)) return\n\n const fileSize = await waitForStableSize(absPath)\n if (fileSize > MAX_FILE_SIZE || fileSize === 0) return\n\n log(`[kordoc watch] 변환 중: ${fileName}`)\n\n const buffer = readFileSync(absPath)\n const arrayBuffer = toArrayBuffer(buffer)\n const parseOptions = pages ? { pages } : undefined\n const result = await parse(arrayBuffer, parseOptions)\n\n if (!result.success) {\n log(`[kordoc watch] 실패: ${fileName} — ${result.error}`)\n await sendWebhook(webhook, { file: fileName, format: detectFormat(arrayBuffer), success: false, error: result.error })\n return\n }\n\n const output = format === \"json\" ? JSON.stringify(result, null, 2) : result.markdown\n\n if (outDir) {\n const outExt = format === \"json\" ? \".json\" : \".md\"\n const outPath = resolve(outDir, fileName.replace(/\\.[^.]+$/, outExt))\n writeFileSync(outPath, output, \"utf-8\")\n log(`[kordoc watch] 완료: ${fileName} → ${basename(outPath)}`)\n } else {\n process.stdout.write(output + \"\\n\")\n }\n\n await sendWebhook(webhook, {\n file: fileName,\n format: result.fileType,\n success: true,\n markdown: format === \"markdown\" ? output.substring(0, 1000) : undefined,\n })\n } catch (err) {\n log(`[kordoc watch] 에러: ${fileName} — ${err instanceof Error ? err.message : err}`)\n }\n }\n\n // fs.watch recursive (Node 18+ Windows/macOS, Node 19+ Linux)\n watch(dir, { recursive: true }, (event, filename) => {\n if (!filename) return\n const filePath = filename.toString()\n\n // 디바운스\n const existing = pending.get(filePath)\n if (existing) clearTimeout(existing)\n pending.set(filePath, setTimeout(() => {\n pending.delete(filePath)\n processFile(filePath).catch(() => {})\n }, DEBOUNCE_MS))\n })\n\n // 프로세스 종료 방지 (Ctrl+C로 종료)\n return new Promise(() => {})\n}\n\n/** Webhook URL 검증 — SSRF 방지: http/https만 허용, localhost/private IP 차단 */\nfunction validateWebhookUrl(url: string): void {\n let parsed: URL\n try {\n parsed = new URL(url)\n } catch {\n throw new Error(`유효하지 않은 webhook URL: ${url}`)\n }\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\n throw new Error(`허용되지 않는 webhook 프로토콜: ${parsed.protocol}`)\n }\n const hostname = parsed.hostname.toLowerCase()\n if (\n hostname === \"localhost\" ||\n hostname === \"[::1]\" ||\n hostname.startsWith(\"127.\") ||\n hostname.startsWith(\"10.\") ||\n hostname.startsWith(\"192.168.\") ||\n /^172\\.(1[6-9]|2\\d|3[01])\\./.test(hostname) ||\n hostname === \"0.0.0.0\" ||\n hostname.startsWith(\"169.254.\") ||\n hostname.endsWith(\".local\") ||\n // IPv6 사설 대역\n hostname.startsWith(\"[fc\") ||\n hostname.startsWith(\"[fd\") ||\n hostname.startsWith(\"[fe80:\") ||\n hostname === \"[::0]\" ||\n hostname === \"[::]\" ||\n // 클라우드 메타데이터 엔드포인트\n hostname === \"metadata.google.internal\" ||\n hostname === \"metadata.google\" ||\n // 16진수/8진수 IP 인코딩 우회 방지\n /^0x[0-9a-f]+$/i.test(hostname) ||\n /^0[0-7]+$/.test(hostname)\n ) {\n throw new Error(`내부 네트워크 대상 webhook은 허용되지 않습니다: ${hostname}`)\n }\n}\n\nasync function sendWebhook(url: string | undefined, payload: Record<string, unknown>): Promise<void> {\n if (!url) return\n try {\n validateWebhookUrl(url)\n await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ ...payload, timestamp: new Date().toISOString() }),\n })\n } catch {\n // webhook 실패는 조용히 무시\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/watch.ts"],"sourcesContent":["/** 디렉토리 감시 모드 — 새 문서 자동 변환 + Webhook 알림 */\n\nimport { watch, readFileSync, writeFileSync, mkdirSync, statSync, existsSync } from \"fs\"\nimport { basename, resolve, extname } from \"path\"\nimport { parse, detectFormat } from \"./index.js\"\nimport { toArrayBuffer } from \"./utils.js\"\nimport type { WatchOptions } from \"./types.js\"\n\nconst SUPPORTED_EXTENSIONS = new Set([\".hwp\", \".hwpx\", \".pdf\", \".xlsx\", \".docx\"])\nconst DEBOUNCE_MS = 1000\n/** 파일 쓰기 완료 판정: 연속 2회 동일 크기 확인 간격 */\nconst STABLE_CHECK_MS = 300\nconst MAX_FILE_SIZE = 500 * 1024 * 1024\n\n/**\n * 디렉토리를 감시하여 새 문서 파일을 자동 변환.\n *\n * @example\n * ```bash\n * kordoc watch ./incoming -d ./output --webhook https://api.example.com/docs\n * ```\n */\nexport async function watchDirectory(options: WatchOptions): Promise<void> {\n const { dir, outDir, webhook, format = \"markdown\", pages, silent } = options\n\n if (!existsSync(dir)) throw new Error(`디렉토리를 찾을 수 없습니다: ${dir}`)\n if (webhook) validateWebhookUrl(webhook)\n if (outDir) mkdirSync(outDir, { recursive: true })\n\n const log = silent ? () => {} : (msg: string) => process.stderr.write(msg + \"\\n\")\n log(`[kordoc watch] 감시 시작: ${resolve(dir)}`)\n if (outDir) log(`[kordoc watch] 출력: ${resolve(outDir)}`)\n if (webhook) log(`[kordoc watch] 웹훅: ${webhook}`)\n\n // 디바운스 맵\n const pending = new Map<string, ReturnType<typeof setTimeout>>()\n\n /** 파일 크기가 안정화될 때까지 대기 (쓰기 완료 감지) */\n const waitForStableSize = async (absPath: string): Promise<number> => {\n let prevSize = statSync(absPath).size\n await new Promise(r => setTimeout(r, STABLE_CHECK_MS))\n if (!existsSync(absPath)) return 0\n const currSize = statSync(absPath).size\n if (currSize !== prevSize) {\n // 크기가 변했으면 한 번 더 대기\n await new Promise(r => setTimeout(r, STABLE_CHECK_MS))\n if (!existsSync(absPath)) return 0\n return statSync(absPath).size\n }\n return currSize\n }\n\n const processFile = async (filePath: string) => {\n const ext = extname(filePath).toLowerCase()\n if (!SUPPORTED_EXTENSIONS.has(ext)) return\n\n const fileName = basename(filePath)\n try {\n const absPath = resolve(dir, filePath)\n // 경로 순회 방지 — 감시 디렉토리 외부 파일 차단\n const realDir = resolve(dir)\n if (!absPath.startsWith(realDir)) return\n if (!existsSync(absPath)) return\n\n const fileSize = await waitForStableSize(absPath)\n if (fileSize > MAX_FILE_SIZE || fileSize === 0) return\n\n log(`[kordoc watch] 변환 중: ${fileName}`)\n\n const buffer = readFileSync(absPath)\n const arrayBuffer = toArrayBuffer(buffer)\n const parseOptions = pages ? { pages } : undefined\n const result = await parse(arrayBuffer, parseOptions)\n\n if (!result.success) {\n log(`[kordoc watch] 실패: ${fileName} — ${result.error}`)\n await sendWebhook(webhook, { file: fileName, format: detectFormat(arrayBuffer), success: false, error: result.error })\n return\n }\n\n const output = format === \"json\" ? JSON.stringify(result, null, 2) : result.markdown\n\n if (outDir) {\n const outExt = format === \"json\" ? \".json\" : \".md\"\n const outPath = resolve(outDir, fileName.replace(/\\.[^.]+$/, outExt))\n writeFileSync(outPath, output, \"utf-8\")\n log(`[kordoc watch] 완료: ${fileName} → ${basename(outPath)}`)\n } else {\n process.stdout.write(output + \"\\n\")\n }\n\n await sendWebhook(webhook, {\n file: fileName,\n format: result.fileType,\n success: true,\n markdown: format === \"markdown\" ? output.substring(0, 1000) : undefined,\n })\n } catch (err) {\n log(`[kordoc watch] 에러: ${fileName} — ${err instanceof Error ? err.message : err}`)\n }\n }\n\n // fs.watch recursive (Node 18+ Windows/macOS, Node 19+ Linux)\n watch(dir, { recursive: true }, (event, filename) => {\n if (!filename) return\n const filePath = filename.toString()\n\n // 디바운스\n const existing = pending.get(filePath)\n if (existing) clearTimeout(existing)\n pending.set(filePath, setTimeout(() => {\n pending.delete(filePath)\n processFile(filePath).catch(() => {})\n }, DEBOUNCE_MS))\n })\n\n // 프로세스 종료 방지 (Ctrl+C로 종료)\n return new Promise(() => {})\n}\n\n/** Webhook URL 검증 — SSRF 방지: http/https만 허용, localhost/private IP 차단 */\nfunction validateWebhookUrl(url: string): void {\n let parsed: URL\n try {\n parsed = new URL(url)\n } catch {\n throw new Error(`유효하지 않은 webhook URL: ${url}`)\n }\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\n throw new Error(`허용되지 않는 webhook 프로토콜: ${parsed.protocol}`)\n }\n const hostname = parsed.hostname.toLowerCase()\n if (\n hostname === \"localhost\" ||\n hostname === \"[::1]\" ||\n hostname.startsWith(\"127.\") ||\n hostname.startsWith(\"10.\") ||\n hostname.startsWith(\"192.168.\") ||\n /^172\\.(1[6-9]|2\\d|3[01])\\./.test(hostname) ||\n hostname === \"0.0.0.0\" ||\n hostname.startsWith(\"169.254.\") ||\n hostname.endsWith(\".local\") ||\n // IPv6 사설 대역\n hostname.startsWith(\"[fc\") ||\n hostname.startsWith(\"[fd\") ||\n hostname.startsWith(\"[fe80:\") ||\n hostname === \"[::0]\" ||\n hostname === \"[::]\" ||\n // 클라우드 메타데이터 엔드포인트\n hostname === \"metadata.google.internal\" ||\n hostname === \"metadata.google\" ||\n // 16진수/8진수 IP 인코딩 우회 방지\n /^0x[0-9a-f]+$/i.test(hostname) ||\n /^0[0-7]+$/.test(hostname)\n ) {\n throw new Error(`내부 네트워크 대상 webhook은 허용되지 않습니다: ${hostname}`)\n }\n}\n\nasync function sendWebhook(url: string | undefined, payload: Record<string, unknown>): Promise<void> {\n if (!url) return\n try {\n validateWebhookUrl(url)\n await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ ...payload, timestamp: new Date().toISOString() }),\n })\n } catch {\n // webhook 실패는 조용히 무시\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clazic/kordoc",
3
- "version": "2.1.6",
3
+ "version": "2.2.0",
4
4
  "description": "Parse Korean documents (HWP, HWPX, PDF, XLSX, DOCX) to Markdown",
5
5
  "type": "module",
6
6
  "exports": {
@@ -26,7 +26,8 @@
26
26
  "test": "node --import tsx --test tests/*.test.ts",
27
27
  "prepublishOnly": "npm run test && npm run build",
28
28
  "build:worker": "tsup --config tsup.worker.config.ts",
29
- "deploy:worker": "npm run build:worker && npx wrangler deploy"
29
+ "deploy:worker": "npm run build:worker && npx wrangler deploy",
30
+ "build:bin": "npm run build && pkg dist-binary/cli-bin.cjs --targets node18-macos-arm64,node18-macos-x64 --output kordoc"
30
31
  },
31
32
  "keywords": [
32
33
  "hwp",
@@ -58,6 +59,7 @@
58
59
  "@xmldom/xmldom": "^0.9.8",
59
60
  "cfb": "1.2.2",
60
61
  "commander": "^13.0.0",
62
+ "exceljs": "^4.4.0",
61
63
  "jszip": "^3.10.1",
62
64
  "pdfjs-dist": "^4.10.38",
63
65
  "zod": "^3.23.0"