/** kordoc CLI — 모두 파싱해버리겠다 */ import { readFileSync, writeFileSync, mkdirSync, statSync } from "fs" import { basename, dirname, resolve, extname } from "path" import { Command } from "commander" import { parse, detectFormat, detectZipFormat, fillFormFields, extractFormFields, blocksToMarkdown, markdownToHwpx, fillHwpx } from "./index.js" import type { ParseOptions } from "./types.js" import { VERSION, toArrayBuffer, sanitizeError } from "./utils.js" const program = new Command() program .name("kordoc") .description("모두 파싱해버리겠다 — HWP, HWPX, PDF, XLSX, DOCX → Markdown") .version(VERSION) .argument("", "변환할 파일 경로 (HWP, HWPX, PDF, XLSX, DOCX)") .option("-o, --output ", "출력 파일 경로 (단일 파일 시)") .option("-d, --out-dir ", "출력 디렉토리 (다중 파일 시)") .option("-p, --pages ", "페이지/섹션 범위 (예: 1-3, 1,3,5)") .option("--format ", "출력 형식: markdown (기본) 또는 json", "markdown") .option("--no-header-footer", "PDF 머리글/바닥글 자동 제거") .option("--formula-ocr", "PDF 수식 OCR 활성화 (MFD+MFR ONNX, 첫 사용 시 모델 ~155MB 자동 다운로드)") .option("--silent", "진행 메시지 숨기기") .action(async (files: string[], opts) => { const validFormats = ["markdown", "json"] if (!validFormats.includes(opts.format)) { process.stderr.write(`[kordoc] 지원하지 않는 형식: ${opts.format} (markdown 또는 json)\n`) process.exit(1) } for (let fi = 0; fi < files.length; fi++) { const filePath = files[fi] const absPath = resolve(filePath) const fileName = basename(absPath) const filePrefix = files.length > 1 ? `[${fi + 1}/${files.length}] ` : "" try { const fileSize = statSync(absPath).size if (fileSize > 500 * 1024 * 1024) { process.stderr.write(`\n[kordoc] SKIP: ${fileName} — 파일이 너무 큽니다 (${(fileSize / 1024 / 1024).toFixed(1)}MB)\n`) process.exitCode = 1 continue } const buffer = readFileSync(absPath) const arrayBuffer = toArrayBuffer(buffer) const format = detectFormat(arrayBuffer) if (!opts.silent) { process.stderr.write(`[kordoc] ${filePrefix}${fileName} (${format}) ...`) } const parseOptions: ParseOptions = { filePath: absPath } if (opts.pages) parseOptions.pages = opts.pages as string if (opts.headerFooter === false) parseOptions.removeHeaderFooter = false if (opts.formulaOcr) parseOptions.formulaOcr = true if (!opts.silent) { parseOptions.onProgress = (current: number, total: number) => { process.stderr.write(`\r[kordoc] ${filePrefix}${fileName} (${format}) [${current}/${total}]`) } } const result = await parse(arrayBuffer, parseOptions) if (!result.success) { process.stderr.write(` FAIL\n`) process.stderr.write(` → ${result.error}\n`) process.exitCode = 1 continue } if (!opts.silent) process.stderr.write(` OK\n`) let markdown = result.markdown // --out-dir 시 이미지 참조 경로에 images/ 접두사 추가 if (opts.outDir && result.images?.length) { markdown = markdown.replace(/!\[image\]\(image_/g, "![image](images/image_") } const output = opts.format === "json" ? JSON.stringify(result, (_key, value) => value instanceof Uint8Array ? Buffer.from(value).toString("base64") : value , 2) : markdown // 이미지 저장 (--out-dir 또는 --output 시) const saveImages = (dir: string) => { if (!result.images?.length) return const imgDir = resolve(dir, "images") mkdirSync(imgDir, { recursive: true }) for (const img of result.images) { writeFileSync(resolve(imgDir, img.filename), img.data) } if (!opts.silent) process.stderr.write(` → ${result.images.length}개 이미지 → ${imgDir}\n`) } if (opts.output && files.length === 1) { writeFileSync(opts.output, output, "utf-8") if (!opts.silent) process.stderr.write(` → ${opts.output}\n`) saveImages(resolve(opts.output, "..")) } else if (opts.outDir) { mkdirSync(opts.outDir, { recursive: true }) const outExt = opts.format === "json" ? ".json" : ".md" const outPath = resolve(opts.outDir, fileName.replace(/\.[^.]+$/, outExt)) writeFileSync(outPath, output, "utf-8") if (!opts.silent) process.stderr.write(` → ${outPath}\n`) saveImages(opts.outDir) } else { process.stdout.write(output + "\n") } } catch (err) { process.stderr.write(`\n[kordoc] ERROR: ${fileName} — ${sanitizeError(err)}\n`) process.exitCode = 1 } } }) program .command("watch ") .description("디렉토리 감시 — 새 문서 자동 변환") .option("--webhook ", "결과 전송 웹훅 URL") .option("-d, --out-dir ", "변환 결과 출력 디렉토리") .option("-p, --pages ", "페이지/섹션 범위") .option("--format ", "출력 형식: markdown 또는 json", "markdown") .option("--silent", "진행 메시지 숨기기") .action(async (dir: string, opts) => { const { watchDirectory } = await import("./watch.js") await watchDirectory({ dir, outDir: opts.outDir, webhook: opts.webhook, format: opts.format, pages: opts.pages, silent: opts.silent, }) }) program .command("fill