kordoc 4.1.0 → 4.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 (109) hide show
  1. package/README.md +25 -5
  2. package/dist/{-AS4IABL2.js → -SXQIZHYA.js} +10 -9
  3. package/dist/{parser-PEOYBXBO.js → chunk-26AJJCFF.js} +1339 -2023
  4. package/dist/chunk-26AJJCFF.js.map +1 -0
  5. package/dist/{parser-XX4QTDFQ.js → chunk-4JPPLIIV.js} +1337 -2019
  6. package/dist/chunk-4JPPLIIV.js.map +1 -0
  7. package/dist/chunk-4VNLZ2GZ.js +13 -0
  8. package/dist/chunk-4VNLZ2GZ.js.map +1 -0
  9. package/dist/{chunk-37HJHCPA.js → chunk-6SHO3BNV.js} +3 -3
  10. package/dist/{chunk-UOO7LSDG.js → chunk-7FCNOKOR.js} +1 -9
  11. package/dist/chunk-7FCNOKOR.js.map +1 -0
  12. package/dist/{chunk-SSTK6IKK.js → chunk-CMN3SYZ3.js} +14 -5
  13. package/dist/chunk-CMN3SYZ3.js.map +1 -0
  14. package/dist/{chunk-F2ZU3IZG.js → chunk-ELOV2GIJ.js} +2 -2
  15. package/dist/chunk-F2HYH62H.cjs +130 -0
  16. package/dist/chunk-F2HYH62H.cjs.map +1 -0
  17. package/dist/{chunk-O5VS7RIR.js → chunk-FSXLSHCW.js} +3 -3
  18. package/dist/{chunk-O5VS7RIR.js.map → chunk-FSXLSHCW.js.map} +1 -1
  19. package/dist/chunk-GEPINOL6.js +83 -0
  20. package/dist/chunk-GEPINOL6.js.map +1 -0
  21. package/dist/chunk-H7OWEYH2.cjs +243 -0
  22. package/dist/chunk-H7OWEYH2.cjs.map +1 -0
  23. package/dist/{chunk-L2XQQTOY.js → chunk-IGGRLWT5.js} +126 -46
  24. package/dist/chunk-IGGRLWT5.js.map +1 -0
  25. package/dist/chunk-J2RZ444H.js +243 -0
  26. package/dist/chunk-J2RZ444H.js.map +1 -0
  27. package/dist/chunk-JP5YH3Z4.js +130 -0
  28. package/dist/chunk-JP5YH3Z4.js.map +1 -0
  29. package/dist/{parser-N4A7UGDK.cjs → chunk-LHROM2BO.cjs} +1300 -1985
  30. package/dist/chunk-LHROM2BO.cjs.map +1 -0
  31. package/dist/chunk-NNDVFNMJ.js +246 -0
  32. package/dist/chunk-NNDVFNMJ.js.map +1 -0
  33. package/dist/{chunk-WO52HVQJ.js → chunk-PLIY23LY.js} +2 -2
  34. package/dist/{chunk-GQVSHGG4.js → chunk-V6U6YS5B.js} +2 -2
  35. package/dist/{chunk-4KI23VHA.js → chunk-WF2E5P2G.js} +16 -130
  36. package/dist/chunk-WF2E5P2G.js.map +1 -0
  37. package/dist/{chunk-AX2R5Q2N.js → chunk-WQLHPOMJ.js} +3 -3
  38. package/dist/{chunk-37VKMUST.cjs → chunk-XTCT6KQ5.cjs} +34 -148
  39. package/dist/chunk-XTCT6KQ5.cjs.map +1 -0
  40. package/dist/chunks-ZQEFAZNC.js +10 -0
  41. package/dist/cli.js +76 -19
  42. package/dist/cli.js.map +1 -1
  43. package/dist/{formula-RXVSQPXI.js → formula-KSNAEBZ2.js} +23 -232
  44. package/dist/formula-KSNAEBZ2.js.map +1 -0
  45. package/dist/{formula-5NKVS2LR.cjs → formula-SB3NHDUM.cjs} +30 -240
  46. package/dist/formula-SB3NHDUM.cjs.map +1 -0
  47. package/dist/{formula-JCNF43NE.js → formula-SXC6LRRC.js} +23 -233
  48. package/dist/formula-SXC6LRRC.js.map +1 -0
  49. package/dist/index.cjs +527 -447
  50. package/dist/index.cjs.map +1 -1
  51. package/dist/index.d.cts +7 -3
  52. package/dist/index.d.ts +7 -3
  53. package/dist/index.js +129 -49
  54. package/dist/index.js.map +1 -1
  55. package/dist/mcp.js +21 -19
  56. package/dist/mcp.js.map +1 -1
  57. package/dist/models-KA4GLQK5.js +25 -0
  58. package/dist/parser-ND2YGQZR.js +800 -0
  59. package/dist/parser-ND2YGQZR.js.map +1 -0
  60. package/dist/parser-RBZHA6SY.js +804 -0
  61. package/dist/parser-RBZHA6SY.js.map +1 -0
  62. package/dist/parser-WDZOMDG4.cjs +803 -0
  63. package/dist/parser-WDZOMDG4.cjs.map +1 -0
  64. package/dist/pdf-ocr-2Z7VZIFI.cjs +439 -0
  65. package/dist/pdf-ocr-2Z7VZIFI.cjs.map +1 -0
  66. package/dist/pdf-ocr-FJFXFADQ.js +438 -0
  67. package/dist/pdf-ocr-FJFXFADQ.js.map +1 -0
  68. package/dist/pdf-ocr-VYE4DHD4.js +386 -0
  69. package/dist/pdf-ocr-VYE4DHD4.js.map +1 -0
  70. package/dist/{profile-io-PLDQFPJA.js → profile-io-TWHDIVPL.js} +3 -3
  71. package/dist/{rasterize-WWNQLKYW.js → rasterize-W2NEZUSW.js} +2 -2
  72. package/dist/render-4MG5QUDG.js +10 -0
  73. package/dist/seal-D2QNXJVO.js +10 -0
  74. package/dist/seal-D2QNXJVO.js.map +1 -0
  75. package/dist/{watch-SFHXZALC.js → watch-VWXJZT2R.js} +11 -10
  76. package/dist/watch-VWXJZT2R.js.map +1 -0
  77. package/package.json +2 -1
  78. package/dist/chunk-37VKMUST.cjs.map +0 -1
  79. package/dist/chunk-4KI23VHA.js.map +0 -1
  80. package/dist/chunk-L2XQQTOY.js.map +0 -1
  81. package/dist/chunk-SSTK6IKK.js.map +0 -1
  82. package/dist/chunk-UOO7LSDG.js.map +0 -1
  83. package/dist/chunks-WVMGF7JL.js +0 -10
  84. package/dist/formula-5NKVS2LR.cjs.map +0 -1
  85. package/dist/formula-JCNF43NE.js.map +0 -1
  86. package/dist/formula-RXVSQPXI.js.map +0 -1
  87. package/dist/parser-N4A7UGDK.cjs.map +0 -1
  88. package/dist/parser-PEOYBXBO.js.map +0 -1
  89. package/dist/parser-XX4QTDFQ.js.map +0 -1
  90. package/dist/provider-4FAYHJ6N.cjs +0 -39
  91. package/dist/provider-4FAYHJ6N.cjs.map +0 -1
  92. package/dist/provider-C6IOGIS5.js +0 -37
  93. package/dist/provider-C6IOGIS5.js.map +0 -1
  94. package/dist/provider-UIBW2MIH.js +0 -39
  95. package/dist/provider-UIBW2MIH.js.map +0 -1
  96. package/dist/render-T7S6H6AN.js +0 -10
  97. package/dist/seal-7PTL6MXH.js +0 -10
  98. package/dist/watch-SFHXZALC.js.map +0 -1
  99. /package/dist/{-AS4IABL2.js.map → -SXQIZHYA.js.map} +0 -0
  100. /package/dist/{chunk-37HJHCPA.js.map → chunk-6SHO3BNV.js.map} +0 -0
  101. /package/dist/{chunk-F2ZU3IZG.js.map → chunk-ELOV2GIJ.js.map} +0 -0
  102. /package/dist/{chunk-WO52HVQJ.js.map → chunk-PLIY23LY.js.map} +0 -0
  103. /package/dist/{chunk-GQVSHGG4.js.map → chunk-V6U6YS5B.js.map} +0 -0
  104. /package/dist/{chunk-AX2R5Q2N.js.map → chunk-WQLHPOMJ.js.map} +0 -0
  105. /package/dist/{chunks-WVMGF7JL.js.map → chunks-ZQEFAZNC.js.map} +0 -0
  106. /package/dist/{profile-io-PLDQFPJA.js.map → models-KA4GLQK5.js.map} +0 -0
  107. /package/dist/{render-T7S6H6AN.js.map → profile-io-TWHDIVPL.js.map} +0 -0
  108. /package/dist/{rasterize-WWNQLKYW.js.map → rasterize-W2NEZUSW.js.map} +0 -0
  109. /package/dist/{seal-7PTL6MXH.js.map → render-4MG5QUDG.js.map} +0 -0
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ ensureModelsIn,
4
+ getModelStatusIn,
5
+ getModelsDir
6
+ } from "./chunk-NNDVFNMJ.js";
7
+
8
+ // src/ocr/models.ts
9
+ import { join } from "path";
10
+ var OCR_DET_MODEL = {
11
+ name: "PP-OCRv5 mobile det",
12
+ filename: "det.onnx",
13
+ url: "https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_det_onnx/resolve/main/inference.onnx",
14
+ sha256: "a431985659dc921974177a95adcfbb90fd9e51989a5e04d70d0b75f597b6e61d",
15
+ sizeMb: 5
16
+ };
17
+ var OCR_REC_MODEL = {
18
+ name: "PP-OCRv5 korean rec",
19
+ filename: "rec_korean.onnx",
20
+ url: "https://huggingface.co/PaddlePaddle/korean_PP-OCRv5_mobile_rec_onnx/resolve/main/inference.onnx",
21
+ sha256: "92f0b7785e64fc9090106a241cf4c1eb97472824558272751b88a2a4476d3a08",
22
+ sizeMb: 13
23
+ };
24
+ var OCR_REC_DICT = {
25
+ name: "PP-OCRv5 korean dict",
26
+ filename: "rec_korean.yml",
27
+ url: "https://huggingface.co/PaddlePaddle/korean_PP-OCRv5_mobile_rec_onnx/resolve/main/inference.yml",
28
+ sha256: "f757fa1c40e99edcf27e9cce879b93eb2a51fa46f5ef39095689b8c37dd75998",
29
+ sizeMb: 1
30
+ };
31
+ var ALL_OCR_MODELS = [OCR_DET_MODEL, OCR_REC_MODEL, OCR_REC_DICT];
32
+ function getOcrModelsDir() {
33
+ return getModelsDir("ppocr");
34
+ }
35
+ async function ensureOcrModels(onProgress) {
36
+ return ensureModelsIn(getOcrModelsDir(), ALL_OCR_MODELS, onProgress);
37
+ }
38
+ async function getOcrModelStatus() {
39
+ return getModelStatusIn(getOcrModelsDir(), ALL_OCR_MODELS);
40
+ }
41
+ function parseCharacterDict(yml) {
42
+ const lines = yml.split("\n");
43
+ const chars = [];
44
+ let inDict = false;
45
+ let dictIndent = -1;
46
+ for (const line of lines) {
47
+ if (!inDict) {
48
+ const m2 = /^(\s*)character_dict:\s*$/.exec(line);
49
+ if (m2) {
50
+ inDict = true;
51
+ dictIndent = m2[1].length;
52
+ }
53
+ continue;
54
+ }
55
+ const m = /^(\s*)- (.*)$/.exec(line);
56
+ if (m && m[1].length >= dictIndent) {
57
+ let v = m[2];
58
+ if (v.length >= 2 && (v.startsWith("'") && v.endsWith("'") || v.startsWith('"') && v.endsWith('"'))) {
59
+ v = v.slice(1, -1).replace(/''/g, "'");
60
+ }
61
+ chars.push(v);
62
+ continue;
63
+ }
64
+ if (line.trim() !== "") break;
65
+ }
66
+ return chars;
67
+ }
68
+ function ocrModelPath(spec) {
69
+ return join(getOcrModelsDir(), spec.filename);
70
+ }
71
+
72
+ export {
73
+ OCR_DET_MODEL,
74
+ OCR_REC_MODEL,
75
+ OCR_REC_DICT,
76
+ ALL_OCR_MODELS,
77
+ getOcrModelsDir,
78
+ ensureOcrModels,
79
+ getOcrModelStatus,
80
+ parseCharacterDict,
81
+ ocrModelPath
82
+ };
83
+ //# sourceMappingURL=chunk-GEPINOL6.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/ocr/models.ts"],"sourcesContent":["/**\n * 내장 텍스트 OCR 모델 (PP-OCRv5 korean) 스펙 + 다운로드/검증.\n *\n * 캐시 위치: `~/.cache/kordoc/models/ppocr/` — 총 ~18MB (det 4.6 + rec 12.8 + dict 0.1)\n * 다운로드 인프라는 수식 OCR 과 공용 (formula/models.ts ensureModelsIn).\n *\n * 모델 출처: PaddlePaddle 공식 HuggingFace org 의 ONNX 변환본 (Apache-2.0).\n * SHA-256 은 실제 다운로드 + HF API lfs.oid 대조로 검증됨 — 변경 금지.\n * 한국어 사전은 rec 리포의 inference.yml `PostProcess.character_dict` 에\n * 내장 (11,945자 — 완성형 한글 11,172 음절 전량 + 자모/라틴/기호).\n */\n\nimport { join } from \"path\"\nimport {\n type ModelSpec,\n type ModelStatus,\n type ProgressHandler,\n ensureModelsIn,\n getModelStatusIn,\n getModelsDir,\n} from \"../pdf/formula/models.js\"\n\nexport const OCR_DET_MODEL: ModelSpec = {\n name: \"PP-OCRv5 mobile det\",\n filename: \"det.onnx\",\n url: \"https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_det_onnx/resolve/main/inference.onnx\",\n sha256: \"a431985659dc921974177a95adcfbb90fd9e51989a5e04d70d0b75f597b6e61d\",\n sizeMb: 5,\n}\n\nexport const OCR_REC_MODEL: ModelSpec = {\n name: \"PP-OCRv5 korean rec\",\n filename: \"rec_korean.onnx\",\n url: \"https://huggingface.co/PaddlePaddle/korean_PP-OCRv5_mobile_rec_onnx/resolve/main/inference.onnx\",\n sha256: \"92f0b7785e64fc9090106a241cf4c1eb97472824558272751b88a2a4476d3a08\",\n sizeMb: 13,\n}\n\nexport const OCR_REC_DICT: ModelSpec = {\n name: \"PP-OCRv5 korean dict\",\n filename: \"rec_korean.yml\",\n url: \"https://huggingface.co/PaddlePaddle/korean_PP-OCRv5_mobile_rec_onnx/resolve/main/inference.yml\",\n sha256: \"f757fa1c40e99edcf27e9cce879b93eb2a51fa46f5ef39095689b8c37dd75998\",\n sizeMb: 1,\n}\n\nexport const ALL_OCR_MODELS: ReadonlyArray<ModelSpec> = [OCR_DET_MODEL, OCR_REC_MODEL, OCR_REC_DICT]\n\nexport function getOcrModelsDir(): string {\n return getModelsDir(\"ppocr\")\n}\n\n/** 모든 텍스트 OCR 모델 다운로드/검증 (있으면 skip) */\nexport async function ensureOcrModels(onProgress?: ProgressHandler): Promise<void> {\n return ensureModelsIn(getOcrModelsDir(), ALL_OCR_MODELS, onProgress)\n}\n\n/** 텍스트 OCR 모델 상태 (다운로드 없이 확인만) */\nexport async function getOcrModelStatus(): Promise<ModelStatus[]> {\n return getModelStatusIn(getOcrModelsDir(), ALL_OCR_MODELS)\n}\n\n/**\n * inference.yml 에서 `PostProcess.character_dict` 리스트를 추출.\n * 전체 YAML 파서 없이 해당 블록의 `- <char>` 라인만 순서대로 읽는다\n * (공식 yml 구조 고정 — 들여쓰기 2칸, 한 줄 한 글자).\n *\n * CTC 클래스 배열: index 0 = blank, 1..N = 사전 순서, N+1(마지막) = space.\n */\nexport function parseCharacterDict(yml: string): string[] {\n const lines = yml.split(\"\\n\")\n const chars: string[] = []\n let inDict = false\n let dictIndent = -1\n for (const line of lines) {\n if (!inDict) {\n const m = /^(\\s*)character_dict:\\s*$/.exec(line)\n if (m) {\n inDict = true\n dictIndent = m[1].length\n }\n continue\n }\n // 리스트 항목 — YAML 은 키와 같은 들여쓰기의 \"- x\" 도 허용 (공식 yml 실측: 둘 다 2칸)\n const m = /^(\\s*)- (.*)$/.exec(line)\n if (m && m[1].length >= dictIndent) {\n let v = m[2]\n // YAML 인용 문자 처리 ('#' 같은 예약 문자는 인용됨)\n if (v.length >= 2 && ((v.startsWith(\"'\") && v.endsWith(\"'\")) || (v.startsWith('\"') && v.endsWith('\"')))) {\n v = v.slice(1, -1).replace(/''/g, \"'\")\n }\n chars.push(v)\n continue\n }\n // 들여쓰기가 얕아지면 블록 종료 (빈 줄은 통과)\n if (line.trim() !== \"\") break\n }\n return chars\n}\n\nexport function ocrModelPath(spec: ModelSpec): string {\n return join(getOcrModelsDir(), spec.filename)\n}\n"],"mappings":";;;;;;;;AAYA,SAAS,YAAY;AAUd,IAAM,gBAA2B;AAAA,EACtC,MAAM;AAAA,EACN,UAAU;AAAA,EACV,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,QAAQ;AACV;AAEO,IAAM,gBAA2B;AAAA,EACtC,MAAM;AAAA,EACN,UAAU;AAAA,EACV,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,QAAQ;AACV;AAEO,IAAM,eAA0B;AAAA,EACrC,MAAM;AAAA,EACN,UAAU;AAAA,EACV,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,QAAQ;AACV;AAEO,IAAM,iBAA2C,CAAC,eAAe,eAAe,YAAY;AAE5F,SAAS,kBAA0B;AACxC,SAAO,aAAa,OAAO;AAC7B;AAGA,eAAsB,gBAAgB,YAA6C;AACjF,SAAO,eAAe,gBAAgB,GAAG,gBAAgB,UAAU;AACrE;AAGA,eAAsB,oBAA4C;AAChE,SAAO,iBAAiB,gBAAgB,GAAG,cAAc;AAC3D;AASO,SAAS,mBAAmB,KAAuB;AACxD,QAAM,QAAQ,IAAI,MAAM,IAAI;AAC5B,QAAM,QAAkB,CAAC;AACzB,MAAI,SAAS;AACb,MAAI,aAAa;AACjB,aAAW,QAAQ,OAAO;AACxB,QAAI,CAAC,QAAQ;AACX,YAAMA,KAAI,4BAA4B,KAAK,IAAI;AAC/C,UAAIA,IAAG;AACL,iBAAS;AACT,qBAAaA,GAAE,CAAC,EAAE;AAAA,MACpB;AACA;AAAA,IACF;AAEA,UAAM,IAAI,gBAAgB,KAAK,IAAI;AACnC,QAAI,KAAK,EAAE,CAAC,EAAE,UAAU,YAAY;AAClC,UAAI,IAAI,EAAE,CAAC;AAEX,UAAI,EAAE,UAAU,MAAO,EAAE,WAAW,GAAG,KAAK,EAAE,SAAS,GAAG,KAAO,EAAE,WAAW,GAAG,KAAK,EAAE,SAAS,GAAG,IAAK;AACvG,YAAI,EAAE,MAAM,GAAG,EAAE,EAAE,QAAQ,OAAO,GAAG;AAAA,MACvC;AACA,YAAM,KAAK,CAAC;AACZ;AAAA,IACF;AAEA,QAAI,KAAK,KAAK,MAAM,GAAI;AAAA,EAC1B;AACA,SAAO;AACT;AAEO,SAAS,aAAa,MAAyB;AACpD,SAAO,KAAK,gBAAgB,GAAG,KAAK,QAAQ;AAC9C;","names":["m"]}
@@ -0,0 +1,243 @@
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }// src/pdf/formula/models.ts
2
+ var _crypto = require('crypto');
3
+ var _fs = require('fs');
4
+ var _promises = require('fs/promises');
5
+
6
+ var _os = require('os');
7
+ var _path = require('path');
8
+ var _promises3 = require('stream/promises');
9
+ var _stream = require('stream');
10
+ var MFD_MODEL = {
11
+ name: "Pix2Text MFD",
12
+ filename: "mfd.onnx",
13
+ url: "https://huggingface.co/breezedeus/pix2text-mfd/resolve/main/mfd-v20240618.onnx",
14
+ sha256: "51a8854743b17ae654729af8db82a630c1ccfa06debf4856c8b28055f87d02c1",
15
+ sizeMb: 42
16
+ };
17
+ var MFR_ENCODER_MODEL = {
18
+ name: "Pix2Text MFR encoder",
19
+ filename: "encoder_model.onnx",
20
+ url: "https://huggingface.co/breezedeus/pix2text-mfr/resolve/main/encoder_model.onnx",
21
+ sha256: "bd8d5c322792e9ec45793af5569e9748f82a3d728a9e00213dbfc56c1486f37d",
22
+ sizeMb: 87
23
+ };
24
+ var MFR_DECODER_MODEL = {
25
+ name: "Pix2Text MFR decoder",
26
+ filename: "decoder_model.onnx",
27
+ url: "https://huggingface.co/breezedeus/pix2text-mfr/resolve/main/decoder_model.onnx",
28
+ sha256: "fd0f92d7a012f3dae41e1ac79421aea0ea888b5a66cb3f9a004e424f82f3daed",
29
+ sizeMb: 30
30
+ };
31
+ var MFR_TOKENIZER = {
32
+ name: "Pix2Text MFR tokenizer",
33
+ filename: "tokenizer.json",
34
+ url: "https://huggingface.co/breezedeus/pix2text-mfr/resolve/main/tokenizer.json",
35
+ sha256: "3e2ab757277d22639bec28c9d7972e352d3d1dba223051fa674002dc5ab64df3",
36
+ sizeMb: 1
37
+ };
38
+ var ALL_FORMULA_MODELS = [
39
+ MFD_MODEL,
40
+ MFR_ENCODER_MODEL,
41
+ MFR_DECODER_MODEL,
42
+ MFR_TOKENIZER
43
+ ];
44
+ function getModelsDir(subdir) {
45
+ const override = process.env.KORDOC_MODEL_CACHE;
46
+ if (override && override.trim()) {
47
+ return _path.join.call(void 0, override, subdir);
48
+ }
49
+ return _path.join.call(void 0, _os.homedir.call(void 0, ), ".cache", "kordoc", "models", subdir);
50
+ }
51
+ function getFormulaModelsDir() {
52
+ return getModelsDir("pix2text");
53
+ }
54
+ async function getModelStatusIn(dir, specs) {
55
+ const result = [];
56
+ for (const spec of specs) {
57
+ const localPath = _path.join.call(void 0, dir, spec.filename);
58
+ let exists = false;
59
+ try {
60
+ const s = await _promises.stat.call(void 0, localPath);
61
+ exists = s.isFile() && s.size > 0;
62
+ } catch (e2) {
63
+ exists = false;
64
+ }
65
+ if (!exists) {
66
+ result.push({ spec, localPath, exists: false, verified: false });
67
+ continue;
68
+ }
69
+ try {
70
+ const actual = await sha256OfFile(localPath);
71
+ if (actual === spec.sha256) {
72
+ result.push({ spec, localPath, exists: true, verified: true });
73
+ } else {
74
+ result.push({
75
+ spec,
76
+ localPath,
77
+ exists: true,
78
+ verified: false,
79
+ invalidReason: `SHA256 mismatch: expected ${spec.sha256}, got ${actual}`
80
+ });
81
+ }
82
+ } catch (e) {
83
+ result.push({
84
+ spec,
85
+ localPath,
86
+ exists: true,
87
+ verified: false,
88
+ invalidReason: `SHA compute failed: ${e.message}`
89
+ });
90
+ }
91
+ }
92
+ return result;
93
+ }
94
+ async function getFormulaModelStatus() {
95
+ return getModelStatusIn(getFormulaModelsDir(), ALL_FORMULA_MODELS);
96
+ }
97
+ async function ensureModelsIn(dir, specs, onProgress) {
98
+ await _promises.mkdir.call(void 0, dir, { recursive: true });
99
+ for (const spec of specs) {
100
+ const localPath = _path.join.call(void 0, dir, spec.filename);
101
+ if (await isExistingValid(localPath, spec.sha256)) {
102
+ _optionalChain([onProgress, 'optionalCall', _ => _({
103
+ spec,
104
+ downloaded: 0,
105
+ total: null,
106
+ phase: "skip",
107
+ message: "\uC774\uBBF8 \uC874\uC7AC + SHA \uC77C\uCE58"
108
+ })]);
109
+ continue;
110
+ }
111
+ try {
112
+ await _promises.unlink.call(void 0, localPath);
113
+ } catch (e3) {
114
+ }
115
+ await downloadToFile(spec, localPath, onProgress);
116
+ }
117
+ }
118
+ async function ensureFormulaModels(onProgress) {
119
+ return ensureModelsIn(getFormulaModelsDir(), ALL_FORMULA_MODELS, onProgress);
120
+ }
121
+ async function ensureSingleModel(spec, onProgress) {
122
+ const dir = getFormulaModelsDir();
123
+ await _promises.mkdir.call(void 0, dir, { recursive: true });
124
+ const localPath = _path.join.call(void 0, dir, spec.filename);
125
+ if (await isExistingValid(localPath, spec.sha256)) {
126
+ _optionalChain([onProgress, 'optionalCall', _2 => _2({ spec, downloaded: 0, total: null, phase: "skip" })]);
127
+ return;
128
+ }
129
+ try {
130
+ await _promises.unlink.call(void 0, localPath);
131
+ } catch (e4) {
132
+ }
133
+ await downloadToFile(spec, localPath, onProgress);
134
+ }
135
+ async function isExistingValid(localPath, sha256Expected) {
136
+ try {
137
+ const s = await _promises.stat.call(void 0, localPath);
138
+ if (!s.isFile() || s.size === 0) return false;
139
+ } catch (e5) {
140
+ return false;
141
+ }
142
+ try {
143
+ const actual = await sha256OfFile(localPath);
144
+ return actual === sha256Expected;
145
+ } catch (e6) {
146
+ return false;
147
+ }
148
+ }
149
+ async function downloadToFile(spec, localPath, onProgress) {
150
+ const partPath = `${localPath}.part`;
151
+ await _promises.mkdir.call(void 0, _path.dirname.call(void 0, localPath), { recursive: true });
152
+ const resp = await fetch(spec.url, {
153
+ headers: {
154
+ // HF CDN 은 UA 없으면 가끔 403 을 뱉는다
155
+ "User-Agent": "kordoc-formula-ocr/1.0 (+https://github.com/chrisryugj/kordoc)"
156
+ }
157
+ });
158
+ if (!resp.ok || !resp.body) {
159
+ throw new Error(
160
+ `${spec.name} \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: HTTP ${resp.status} ${resp.statusText} (${spec.url})`
161
+ );
162
+ }
163
+ const lenHeader = resp.headers.get("content-length");
164
+ const total = lenHeader ? Number.parseInt(lenHeader, 10) : null;
165
+ let downloaded = 0;
166
+ const ws = _fs.createWriteStream.call(void 0, partPath);
167
+ try {
168
+ const reader = _stream.Readable.fromWeb(resp.body);
169
+ reader.on("data", (chunk) => {
170
+ downloaded += chunk.length;
171
+ _optionalChain([onProgress, 'optionalCall', _3 => _3({
172
+ spec,
173
+ downloaded,
174
+ total,
175
+ phase: "download"
176
+ })]);
177
+ });
178
+ await _promises3.pipeline.call(void 0, reader, ws);
179
+ } catch (e) {
180
+ try {
181
+ await _promises.unlink.call(void 0, partPath);
182
+ } catch (e7) {
183
+ }
184
+ throw new Error(`${spec.name} \uC2A4\uD2B8\uB9AC\uBC0D \uC2E4\uD328: ${e.message}`);
185
+ }
186
+ _optionalChain([onProgress, 'optionalCall', _4 => _4({
187
+ spec,
188
+ downloaded,
189
+ total,
190
+ phase: "verify"
191
+ })]);
192
+ let actual;
193
+ try {
194
+ actual = await sha256OfFile(partPath);
195
+ } catch (e) {
196
+ try {
197
+ await _promises.unlink.call(void 0, partPath);
198
+ } catch (e8) {
199
+ }
200
+ throw new Error(`${spec.name} SHA \uACC4\uC0B0 \uC2E4\uD328: ${e.message}`);
201
+ }
202
+ if (actual !== spec.sha256) {
203
+ try {
204
+ await _promises.unlink.call(void 0, partPath);
205
+ } catch (e9) {
206
+ }
207
+ throw new Error(
208
+ `${spec.name} SHA256 mismatch: expected ${spec.sha256}, got ${actual} \u2014 \uBAA8\uB378 URL \uC774 \uC624\uC5FC\uB418\uC5C8\uAC70\uB098 \uC804\uC1A1 \uC911 \uC190\uC0C1\uB418\uC5C8\uC2B5\uB2C8\uB2E4.`
209
+ );
210
+ }
211
+ await _promises.rename.call(void 0, partPath, localPath);
212
+ _optionalChain([onProgress, 'optionalCall', _5 => _5({
213
+ spec,
214
+ downloaded,
215
+ total,
216
+ phase: "done"
217
+ })]);
218
+ }
219
+ async function sha256OfFile(p) {
220
+ const h = _crypto.createHash.call(void 0, "sha256");
221
+ const stream = _fs.createReadStream.call(void 0, p);
222
+ await _promises3.pipeline.call(void 0, stream, async function* (src) {
223
+ for await (const chunk of src) {
224
+ h.update(chunk);
225
+ }
226
+ });
227
+ return h.digest("hex");
228
+ }
229
+
230
+
231
+
232
+
233
+
234
+
235
+
236
+
237
+
238
+
239
+
240
+
241
+
242
+ exports.MFD_MODEL = MFD_MODEL; exports.MFR_ENCODER_MODEL = MFR_ENCODER_MODEL; exports.MFR_DECODER_MODEL = MFR_DECODER_MODEL; exports.MFR_TOKENIZER = MFR_TOKENIZER; exports.ALL_FORMULA_MODELS = ALL_FORMULA_MODELS; exports.getModelsDir = getModelsDir; exports.getFormulaModelsDir = getFormulaModelsDir; exports.getFormulaModelStatus = getFormulaModelStatus; exports.ensureModelsIn = ensureModelsIn; exports.ensureFormulaModels = ensureFormulaModels; exports.ensureSingleModel = ensureSingleModel;
243
+ //# sourceMappingURL=chunk-H7OWEYH2.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["/Users/chris_gomdori/workspace/kordoc/dist/chunk-H7OWEYH2.cjs","../src/pdf/formula/models.ts"],"names":[],"mappings":"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","file":"/Users/chris_gomdori/workspace/kordoc/dist/chunk-H7OWEYH2.cjs","sourcesContent":[null,"/**\n * 수식 OCR 모델 자동 다운로드 + SHA-256 검증\n *\n * 캐시 위치: `~/.cache/kordoc/models/pix2text/`\n * 총 ~155MB (MFD 44MB + MFR encoder 87MB + MFR decoder 30MB + tokenizer 40KB)\n *\n * HF URL + SHA 는 Docufinder archive/rust-formula-ocr-wip 에서 검증 완료됨.\n * 모델 버전 갱신 시 URL + SHA 를 함께 갱신해야 한다.\n */\n\nimport { createHash } from \"crypto\"\nimport { createReadStream } from \"fs\"\nimport { mkdir, stat, unlink, rename } from \"fs/promises\"\nimport { createWriteStream } from \"fs\"\nimport { homedir } from \"os\"\nimport { join, dirname } from \"path\"\nimport { pipeline } from \"stream/promises\"\nimport { Readable } from \"stream\"\n\nexport interface ModelSpec {\n name: string\n filename: string\n url: string\n sha256: string\n sizeMb: number\n}\n\n/** 모델 스펙 (SHA-256 은 실제 다운로드로 검증됨 — 변경 금지) */\nexport const MFD_MODEL: ModelSpec = {\n name: \"Pix2Text MFD\",\n filename: \"mfd.onnx\",\n url: \"https://huggingface.co/breezedeus/pix2text-mfd/resolve/main/mfd-v20240618.onnx\",\n sha256: \"51a8854743b17ae654729af8db82a630c1ccfa06debf4856c8b28055f87d02c1\",\n sizeMb: 42,\n}\n\nexport const MFR_ENCODER_MODEL: ModelSpec = {\n name: \"Pix2Text MFR encoder\",\n filename: \"encoder_model.onnx\",\n url: \"https://huggingface.co/breezedeus/pix2text-mfr/resolve/main/encoder_model.onnx\",\n sha256: \"bd8d5c322792e9ec45793af5569e9748f82a3d728a9e00213dbfc56c1486f37d\",\n sizeMb: 87,\n}\n\nexport const MFR_DECODER_MODEL: ModelSpec = {\n name: \"Pix2Text MFR decoder\",\n filename: \"decoder_model.onnx\",\n url: \"https://huggingface.co/breezedeus/pix2text-mfr/resolve/main/decoder_model.onnx\",\n sha256: \"fd0f92d7a012f3dae41e1ac79421aea0ea888b5a66cb3f9a004e424f82f3daed\",\n sizeMb: 30,\n}\n\nexport const MFR_TOKENIZER: ModelSpec = {\n name: \"Pix2Text MFR tokenizer\",\n filename: \"tokenizer.json\",\n url: \"https://huggingface.co/breezedeus/pix2text-mfr/resolve/main/tokenizer.json\",\n sha256: \"3e2ab757277d22639bec28c9d7972e352d3d1dba223051fa674002dc5ab64df3\",\n sizeMb: 1,\n}\n\nexport const ALL_FORMULA_MODELS: ReadonlyArray<ModelSpec> = [\n MFD_MODEL,\n MFR_ENCODER_MODEL,\n MFR_DECODER_MODEL,\n MFR_TOKENIZER,\n]\n\n/**\n * 모델 캐시 하위 디렉토리 경로. 환경 변수 `KORDOC_MODEL_CACHE` 가 설정되면\n * 해당 경로를 우선 사용, 없으면 `~/.cache/kordoc/models/<subdir>/`.\n * (수식 OCR 외 텍스트 OCR 등 다른 모델군도 공용 — subdir 로 격리)\n */\nexport function getModelsDir(subdir: string): string {\n const override = process.env.KORDOC_MODEL_CACHE\n if (override && override.trim()) {\n return join(override, subdir)\n }\n return join(homedir(), \".cache\", \"kordoc\", \"models\", subdir)\n}\n\nexport function getFormulaModelsDir(): string {\n return getModelsDir(\"pix2text\")\n}\n\nexport interface ModelStatus {\n spec: ModelSpec\n localPath: string\n exists: boolean\n /** SHA 검증까지 끝났는가 (exists && valid) */\n verified: boolean\n /** 검증 실패 시 이유 */\n invalidReason?: string\n}\n\n/** 지정 모델군의 존재/유효성 상태 반환 (다운로드 없이 확인만) */\nexport async function getModelStatusIn(dir: string, specs: ReadonlyArray<ModelSpec>): Promise<ModelStatus[]> {\n const result: ModelStatus[] = []\n for (const spec of specs) {\n const localPath = join(dir, spec.filename)\n let exists = false\n try {\n const s = await stat(localPath)\n exists = s.isFile() && s.size > 0\n } catch {\n exists = false\n }\n if (!exists) {\n result.push({ spec, localPath, exists: false, verified: false })\n continue\n }\n try {\n const actual = await sha256OfFile(localPath)\n if (actual === spec.sha256) {\n result.push({ spec, localPath, exists: true, verified: true })\n } else {\n result.push({\n spec,\n localPath,\n exists: true,\n verified: false,\n invalidReason: `SHA256 mismatch: expected ${spec.sha256}, got ${actual}`,\n })\n }\n } catch (e) {\n result.push({\n spec,\n localPath,\n exists: true,\n verified: false,\n invalidReason: `SHA compute failed: ${(e as Error).message}`,\n })\n }\n }\n return result\n}\n\n/** 모든 수식 모델의 존재/유효성 상태 반환 (다운로드 없이 확인만) */\nexport async function getFormulaModelStatus(): Promise<ModelStatus[]> {\n return getModelStatusIn(getFormulaModelsDir(), ALL_FORMULA_MODELS)\n}\n\nexport interface DownloadProgress {\n spec: ModelSpec\n /** 현재까지 다운로드한 바이트 */\n downloaded: number\n /** 전체 바이트 (알 수 있으면) */\n total: number | null\n /** \"download\" | \"verify\" | \"done\" | \"skip\" */\n phase: \"download\" | \"verify\" | \"done\" | \"skip\" | \"error\"\n message?: string\n}\n\nexport type ProgressHandler = (p: DownloadProgress) => void\n\n/**\n * 지정 모델군을 다운로드/검증한다.\n * - 이미 있고 SHA 일치 → skip\n * - 없음 → 다운로드 후 SHA 검증. 실패 시 파일 삭제.\n */\nexport async function ensureModelsIn(dir: string, specs: ReadonlyArray<ModelSpec>, onProgress?: ProgressHandler): Promise<void> {\n await mkdir(dir, { recursive: true })\n\n for (const spec of specs) {\n const localPath = join(dir, spec.filename)\n\n if (await isExistingValid(localPath, spec.sha256)) {\n onProgress?.({\n spec,\n downloaded: 0,\n total: null,\n phase: \"skip\",\n message: \"이미 존재 + SHA 일치\",\n })\n continue\n }\n\n // 기존 파일 있지만 SHA 불일치 → 삭제\n try {\n await unlink(localPath)\n } catch {\n // 없을 수 있음\n }\n\n await downloadToFile(spec, localPath, onProgress)\n }\n}\n\n/** 필요한 모든 수식 모델을 다운로드/검증한다. */\nexport async function ensureFormulaModels(onProgress?: ProgressHandler): Promise<void> {\n return ensureModelsIn(getFormulaModelsDir(), ALL_FORMULA_MODELS, onProgress)\n}\n\n/** 단일 모델만 확인/다운로드 (진단 UI 에서 개별 상태 새로고침용) */\nexport async function ensureSingleModel(spec: ModelSpec, onProgress?: ProgressHandler): Promise<void> {\n const dir = getFormulaModelsDir()\n await mkdir(dir, { recursive: true })\n const localPath = join(dir, spec.filename)\n if (await isExistingValid(localPath, spec.sha256)) {\n onProgress?.({ spec, downloaded: 0, total: null, phase: \"skip\" })\n return\n }\n try {\n await unlink(localPath)\n } catch {}\n await downloadToFile(spec, localPath, onProgress)\n}\n\nasync function isExistingValid(localPath: string, sha256Expected: string): Promise<boolean> {\n try {\n const s = await stat(localPath)\n if (!s.isFile() || s.size === 0) return false\n } catch {\n return false\n }\n try {\n const actual = await sha256OfFile(localPath)\n return actual === sha256Expected\n } catch {\n return false\n }\n}\n\nasync function downloadToFile(\n spec: ModelSpec,\n localPath: string,\n onProgress?: ProgressHandler,\n): Promise<void> {\n // 먼저 .part 로 받고 검증 후 rename — 중단된 다운로드가 \"정상 파일\"로 오인되는 걸 방지\n const partPath = `${localPath}.part`\n await mkdir(dirname(localPath), { recursive: true })\n\n const resp = await fetch(spec.url, {\n headers: {\n // HF CDN 은 UA 없으면 가끔 403 을 뱉는다\n \"User-Agent\": \"kordoc-formula-ocr/1.0 (+https://github.com/chrisryugj/kordoc)\",\n },\n })\n if (!resp.ok || !resp.body) {\n throw new Error(\n `${spec.name} 다운로드 실패: HTTP ${resp.status} ${resp.statusText} (${spec.url})`,\n )\n }\n\n const lenHeader = resp.headers.get(\"content-length\")\n const total = lenHeader ? Number.parseInt(lenHeader, 10) : null\n let downloaded = 0\n\n const ws = createWriteStream(partPath)\n try {\n const reader = Readable.fromWeb(resp.body as unknown as import(\"stream/web\").ReadableStream)\n reader.on(\"data\", (chunk: Buffer | Uint8Array) => {\n downloaded += chunk.length\n onProgress?.({\n spec,\n downloaded,\n total,\n phase: \"download\",\n })\n })\n await pipeline(reader, ws)\n } catch (e) {\n try {\n await unlink(partPath)\n } catch {}\n throw new Error(`${spec.name} 스트리밍 실패: ${(e as Error).message}`)\n }\n\n onProgress?.({\n spec,\n downloaded,\n total,\n phase: \"verify\",\n })\n\n // SHA 검증\n let actual: string\n try {\n actual = await sha256OfFile(partPath)\n } catch (e) {\n try {\n await unlink(partPath)\n } catch {}\n throw new Error(`${spec.name} SHA 계산 실패: ${(e as Error).message}`)\n }\n\n if (actual !== spec.sha256) {\n try {\n await unlink(partPath)\n } catch {}\n throw new Error(\n `${spec.name} SHA256 mismatch: expected ${spec.sha256}, got ${actual} — 모델 URL 이 오염되었거나 전송 중 손상되었습니다.`,\n )\n }\n\n await rename(partPath, localPath)\n onProgress?.({\n spec,\n downloaded,\n total,\n phase: \"done\",\n })\n}\n\nasync function sha256OfFile(p: string): Promise<string> {\n const h = createHash(\"sha256\")\n const stream = createReadStream(p)\n await pipeline(stream, async function* (src) {\n for await (const chunk of src) {\n h.update(chunk)\n // yield 안 함 (소비만)\n }\n })\n return h.digest(\"hex\")\n}\n"]}