kordoc 4.2.9 → 4.3.1

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 (166) hide show
  1. package/README.md +922 -885
  2. package/dist/{-ECUCTQTR.js → -RH6BEVTT.js} +28 -20
  3. package/dist/{chunk-J2RZ444H.js → chunk-3FFPHTXD.js} +1 -1
  4. package/dist/chunk-3FFPHTXD.js.map +1 -0
  5. package/dist/{chunk-PATPZTH7.cjs → chunk-5F5UCACZ.cjs} +6 -6
  6. package/dist/chunk-5F5UCACZ.cjs.map +1 -0
  7. package/dist/{chunk-5RPYBC2Q.js → chunk-6GBLFQMA.js} +1 -1
  8. package/dist/chunk-6GBLFQMA.js.map +1 -0
  9. package/dist/{chunk-GEPINOL6.js → chunk-6XPXIG57.js} +2 -2
  10. package/dist/chunk-6XPXIG57.js.map +1 -0
  11. package/dist/{chunk-4ZWKFDUC.cjs → chunk-7DZPPUQD.cjs} +44 -13
  12. package/dist/chunk-7DZPPUQD.cjs.map +1 -0
  13. package/dist/{chunk-MHMVFI2N.js → chunk-BPJSTJBN.js} +4 -4
  14. package/dist/chunk-BPJSTJBN.js.map +1 -0
  15. package/dist/{chunk-XU4VWOKL.js → chunk-BYEQDLZS.js} +4 -3
  16. package/dist/chunk-BYEQDLZS.js.map +1 -0
  17. package/dist/{chunk-3PLF5UIS.js → chunk-CD5X6UG5.js} +37 -6
  18. package/dist/chunk-CD5X6UG5.js.map +1 -0
  19. package/dist/chunk-DCZVOIEO.cjs.map +1 -1
  20. package/dist/{chunk-STYN45MS.js → chunk-FUXDD36A.js} +4 -4
  21. package/dist/chunk-FUXDD36A.js.map +1 -0
  22. package/dist/chunk-GS7T56RP.cjs.map +1 -1
  23. package/dist/{chunk-YR4PF56B.js → chunk-GTPEKBN6.js} +2 -2
  24. package/dist/chunk-GTPEKBN6.js.map +1 -0
  25. package/dist/{chunk-27Z4PUZZ.js → chunk-HPRK67YB.js} +38 -7
  26. package/dist/chunk-HPRK67YB.js.map +1 -0
  27. package/dist/{chunk-NIISBGSR.js → chunk-HZU7Y22R.js} +71 -29
  28. package/dist/chunk-HZU7Y22R.js.map +1 -0
  29. package/dist/{chunk-7ZARSBQ3.js → chunk-LEC2HG7A.js} +4 -3
  30. package/dist/chunk-LEC2HG7A.js.map +1 -0
  31. package/dist/{chunk-H7OWEYH2.cjs → chunk-MGGU3ZRK.cjs} +1 -1
  32. package/dist/chunk-MGGU3ZRK.cjs.map +1 -0
  33. package/dist/chunk-MOL7MDBG.js +0 -0
  34. package/dist/{chunk-DZIXKL7E.js → chunk-O5EK52TG.js} +1 -1
  35. package/dist/chunk-O5EK52TG.js.map +1 -0
  36. package/dist/{chunk-LXE7OR6N.js → chunk-PV3NVBGL.js} +3 -3
  37. package/dist/chunk-PV3NVBGL.js.map +1 -0
  38. package/dist/{chunk-G3DMDTZI.js → chunk-RO4Y4BFI.js} +1511 -1135
  39. package/dist/chunk-RO4Y4BFI.js.map +1 -0
  40. package/dist/{chunk-WTT2KJOM.js → chunk-SA22NK56.js} +14 -10
  41. package/dist/chunk-SA22NK56.js.map +1 -0
  42. package/dist/{chunk-GZ7W64QU.cjs → chunk-VQB5P5KJ.cjs} +4 -3
  43. package/dist/chunk-VQB5P5KJ.cjs.map +1 -0
  44. package/dist/{chunk-7FCNOKOR.js → chunk-W34QSJFF.js} +1 -1
  45. package/dist/chunk-W34QSJFF.js.map +1 -0
  46. package/dist/{chunk-VEHC4EJL.js → chunk-YDCD4AV4.js} +5 -5
  47. package/dist/chunk-YDCD4AV4.js.map +1 -0
  48. package/dist/{chunk-NNDVFNMJ.js → chunk-YNJDNP44.js} +1 -1
  49. package/dist/chunk-YNJDNP44.js.map +1 -0
  50. package/dist/{chunk-3TSTZGCJ.js → chunk-ZJ75YDW7.js} +4 -3
  51. package/dist/chunk-ZJ75YDW7.js.map +1 -0
  52. package/dist/{chunk-6GM2QLM3.cjs → chunk-ZMKTKJZN.cjs} +21 -21
  53. package/dist/chunk-ZMKTKJZN.cjs.map +1 -0
  54. package/dist/{chunk-LM7C6HI2.js → chunk-ZSL6MQVE.js} +1 -1
  55. package/dist/chunk-ZSL6MQVE.js.map +1 -0
  56. package/dist/{chunk-KTPZYXN2.js → chunk-ZYYTU7NY.js} +2 -2
  57. package/dist/chunk-ZYYTU7NY.js.map +1 -0
  58. package/dist/chunks-JEYQWXIZ.js +10 -0
  59. package/dist/cli.js +75 -39
  60. package/dist/cli.js.map +1 -1
  61. package/dist/{formula-SXC6LRRC.js → formula-7ZPC54JO.js} +2 -2
  62. package/dist/formula-7ZPC54JO.js.map +1 -0
  63. package/dist/{formula-KSNAEBZ2.js → formula-EQRHSMYW.js} +2 -2
  64. package/dist/formula-EQRHSMYW.js.map +1 -0
  65. package/dist/{formula-SB3NHDUM.cjs → formula-IWV5IJ3Y.cjs} +8 -8
  66. package/dist/formula-IWV5IJ3Y.cjs.map +1 -0
  67. package/dist/{image-ocr-PS7XCV6K.js → image-ocr-6MO4FXJK.js} +8 -6
  68. package/dist/image-ocr-6MO4FXJK.js.map +1 -0
  69. package/dist/{image-ocr-J5656BEE.cjs → image-ocr-MGVXT6XD.cjs} +13 -11
  70. package/dist/image-ocr-MGVXT6XD.cjs.map +1 -0
  71. package/dist/{image-ocr-KZTNJ62U.js → image-ocr-TWUU2RNG.js} +10 -8
  72. package/dist/image-ocr-TWUU2RNG.js.map +1 -0
  73. package/dist/index.cjs +1993 -1845
  74. package/dist/index.cjs.map +1 -1
  75. package/dist/index.d.cts +75 -4
  76. package/dist/index.d.ts +75 -4
  77. package/dist/index.js +2128 -1980
  78. package/dist/index.js.map +1 -1
  79. package/dist/mcp.js +123 -78
  80. package/dist/mcp.js.map +1 -1
  81. package/dist/{models-KA4GLQK5.js → models-6VMAVZR6.js} +3 -3
  82. package/dist/page-range-737B4EZW.js +0 -0
  83. package/dist/page-range-P7SDW6LR.cjs.map +1 -1
  84. package/dist/{parser-CSYU3CAW.cjs → parser-JFGLN4DA.cjs} +25 -25
  85. package/dist/parser-JFGLN4DA.cjs.map +1 -0
  86. package/dist/{parser-WNFWMBSL.js → parser-VR2BTUT7.js} +8 -8
  87. package/dist/parser-VR2BTUT7.js.map +1 -0
  88. package/dist/{parser-A34NAYKA.js → parser-YA7HSSPK.js} +6 -6
  89. package/dist/parser-YA7HSSPK.js.map +1 -0
  90. package/dist/pdf-ocr-4LISPZEM.cjs +13 -0
  91. package/dist/pdf-ocr-4LISPZEM.cjs.map +1 -0
  92. package/dist/pdf-ocr-AIWB2QCS.js +12 -0
  93. package/dist/pdf-ocr-QMOKUMC2.js +15 -0
  94. package/dist/{profile-io-43MF52H6.js → profile-io-QPIMGVO3.js} +3 -3
  95. package/dist/{rasterize-OG7KXBJS.js → rasterize-AFYGNKXG.js} +2 -2
  96. package/dist/rasterize-AFYGNKXG.js.map +1 -0
  97. package/dist/{redact-7ILEAUTS.js → redact-W5K3ILQH.js} +2 -2
  98. package/dist/render-PSPTMZIZ.js +10 -0
  99. package/dist/seal-4XBJ7ISE.js +10 -0
  100. package/dist/{setup-GUQT5ELJ.js → setup-I4I75QJU.js} +1 -1
  101. package/dist/setup-I4I75QJU.js.map +1 -0
  102. package/dist/{watch-SOHQMZN2.js → watch-D3JJEOAB.js} +12 -14
  103. package/dist/watch-D3JJEOAB.js.map +1 -0
  104. package/package.json +101 -98
  105. package/templates//352/260/204/354/235/264/352/270/260/354/225/210/353/254/270_/354/204/234/354/213/235.hwpx +0 -0
  106. package/templates//352/260/204/354/235/264/352/270/260/354/225/210/353/254/270_/354/230/210/354/213/234/352/260/222.json +9 -0
  107. package/templates//354/235/274/353/260/230/352/270/260/354/225/210/353/254/270_/354/204/234/354/213/235.hwpx +0 -0
  108. package/templates//354/235/274/353/260/230/352/270/260/354/225/210/353/254/270_/354/230/210/354/213/234/352/260/222.json +25 -0
  109. package/dist/chunk-27Z4PUZZ.js.map +0 -1
  110. package/dist/chunk-3PLF5UIS.js.map +0 -1
  111. package/dist/chunk-3TSTZGCJ.js.map +0 -1
  112. package/dist/chunk-4ZWKFDUC.cjs.map +0 -1
  113. package/dist/chunk-5RPYBC2Q.js.map +0 -1
  114. package/dist/chunk-6GM2QLM3.cjs.map +0 -1
  115. package/dist/chunk-7FCNOKOR.js.map +0 -1
  116. package/dist/chunk-7ZARSBQ3.js.map +0 -1
  117. package/dist/chunk-DZIXKL7E.js.map +0 -1
  118. package/dist/chunk-G3DMDTZI.js.map +0 -1
  119. package/dist/chunk-GEPINOL6.js.map +0 -1
  120. package/dist/chunk-GZ7W64QU.cjs.map +0 -1
  121. package/dist/chunk-H7OWEYH2.cjs.map +0 -1
  122. package/dist/chunk-J2RZ444H.js.map +0 -1
  123. package/dist/chunk-KTPZYXN2.js.map +0 -1
  124. package/dist/chunk-LM7C6HI2.js.map +0 -1
  125. package/dist/chunk-LXE7OR6N.js.map +0 -1
  126. package/dist/chunk-MHMVFI2N.js.map +0 -1
  127. package/dist/chunk-NIISBGSR.js.map +0 -1
  128. package/dist/chunk-NNDVFNMJ.js.map +0 -1
  129. package/dist/chunk-PATPZTH7.cjs.map +0 -1
  130. package/dist/chunk-STYN45MS.js.map +0 -1
  131. package/dist/chunk-TCJRGFYY.js +0 -280
  132. package/dist/chunk-TCJRGFYY.js.map +0 -1
  133. package/dist/chunk-VEHC4EJL.js.map +0 -1
  134. package/dist/chunk-WTT2KJOM.js.map +0 -1
  135. package/dist/chunk-XU4VWOKL.js.map +0 -1
  136. package/dist/chunk-YR4PF56B.js.map +0 -1
  137. package/dist/chunks-VFIMYK36.js +0 -10
  138. package/dist/detect-FX2ZHKZP.js +0 -26
  139. package/dist/formula-KSNAEBZ2.js.map +0 -1
  140. package/dist/formula-SB3NHDUM.cjs.map +0 -1
  141. package/dist/formula-SXC6LRRC.js.map +0 -1
  142. package/dist/image-ocr-J5656BEE.cjs.map +0 -1
  143. package/dist/image-ocr-KZTNJ62U.js.map +0 -1
  144. package/dist/image-ocr-PS7XCV6K.js.map +0 -1
  145. package/dist/parser-A34NAYKA.js.map +0 -1
  146. package/dist/parser-CSYU3CAW.cjs.map +0 -1
  147. package/dist/parser-WNFWMBSL.js.map +0 -1
  148. package/dist/pdf-ocr-KBMWPG2L.js +0 -12
  149. package/dist/pdf-ocr-NPZG67FN.js +0 -15
  150. package/dist/pdf-ocr-O44DAGTY.cjs +0 -13
  151. package/dist/pdf-ocr-O44DAGTY.cjs.map +0 -1
  152. package/dist/rasterize-OG7KXBJS.js.map +0 -1
  153. package/dist/render-336CJHAS.js +0 -10
  154. package/dist/seal-TM2XCE2R.js +0 -10
  155. package/dist/seal-TM2XCE2R.js.map +0 -1
  156. package/dist/setup-GUQT5ELJ.js.map +0 -1
  157. package/dist/watch-SOHQMZN2.js.map +0 -1
  158. /package/dist/{-ECUCTQTR.js.map → -RH6BEVTT.js.map} +0 -0
  159. /package/dist/{chunks-VFIMYK36.js.map → chunks-JEYQWXIZ.js.map} +0 -0
  160. /package/dist/{detect-FX2ZHKZP.js.map → models-6VMAVZR6.js.map} +0 -0
  161. /package/dist/{models-KA4GLQK5.js.map → pdf-ocr-AIWB2QCS.js.map} +0 -0
  162. /package/dist/{pdf-ocr-KBMWPG2L.js.map → pdf-ocr-QMOKUMC2.js.map} +0 -0
  163. /package/dist/{pdf-ocr-NPZG67FN.js.map → profile-io-QPIMGVO3.js.map} +0 -0
  164. /package/dist/{profile-io-43MF52H6.js.map → redact-W5K3ILQH.js.map} +0 -0
  165. /package/dist/{redact-7ILEAUTS.js.map → render-PSPTMZIZ.js.map} +0 -0
  166. /package/dist/{render-336CJHAS.js.map → seal-4XBJ7ISE.js.map} +0 -0
@@ -1 +0,0 @@
1
- {"version":3,"sources":["/Users/mong-e/workspace/kordoc/dist/chunk-4ZWKFDUC.cjs","../src/pdf/image-regions.ts","../src/pdf/line-extract.ts","../src/pdf/vertical-bridge.ts","../src/pdf/line-types.ts","../src/pdf/table-grid.ts","../src/pdf/cell-extract.ts","../src/pdf/cell-text.ts","../src/pdf/undersegmented.ts","../src/pdf/text-line.ts","../src/pdf/block-detect.ts","../src/pdf/cluster-detector.ts","../src/pdf/xy-cut.ts","../src/pdf/columns.ts","../src/pdf/page-blocks.ts"],"names":["OPS","totalCells"],"mappings":"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","file":"/Users/mong-e/workspace/kordoc/dist/chunk-4ZWKFDUC.cjs","sourcesContent":[null,"/**\n * 페이지 내 이미지 XObject 영역 추출 (정보손실 가시화용) — line-detector.ts에서 분리.\n */\n\nimport { OPS } from \"pdfjs-dist/legacy/build/pdf.mjs\"\n\nexport interface ImageRegion {\n x1: number; y1: number; x2: number; y2: number\n}\n\n/** 2D 어파인 행렬 곱 — t 적용 후 m 적용 (pdfjs Util.transform과 동일 순서) */\nfunction multiplyTransform(m: number[], t: number[]): number[] {\n return [\n m[0] * t[0] + m[2] * t[1],\n m[1] * t[0] + m[3] * t[1],\n m[0] * t[2] + m[2] * t[3],\n m[1] * t[2] + m[3] * t[3],\n m[0] * t[4] + m[2] * t[5] + m[4],\n m[1] * t[4] + m[3] * t[5] + m[5],\n ]\n}\n\n/**\n * pdfjs operatorList에서 이미지 paint 영역을 추출.\n * save/restore/transform으로 CTM을 추적하고, 이미지는 단위 정사각형(0,0)-(1,1)에\n * CTM을 적용한 bbox로 계산한다 (PDF 이미지 렌더링 규약).\n */\nexport function extractImageRegions(\n fnArray: Uint32Array | number[],\n argsArray: unknown[][],\n): ImageRegion[] {\n const regions: ImageRegion[] = []\n let ctm = [1, 0, 0, 1, 0, 0]\n const stack: number[][] = []\n\n for (let i = 0; i < fnArray.length; i++) {\n const op = fnArray[i]\n switch (op) {\n case OPS.save:\n stack.push(ctm)\n break\n case OPS.restore:\n ctm = stack.pop() || [1, 0, 0, 1, 0, 0]\n break\n case OPS.transform: {\n const t = argsArray[i] as number[]\n if (Array.isArray(t) && t.length >= 6) ctm = multiplyTransform(ctm, t)\n break\n }\n case OPS.paintImageXObject:\n case OPS.paintInlineImageXObject:\n case OPS.paintImageMaskXObject:\n case OPS.paintImageXObjectRepeat: {\n // 단위 정사각형 4꼭짓점에 CTM 적용\n const corners = [[0, 0], [1, 0], [0, 1], [1, 1]]\n let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity\n for (const [u, v] of corners) {\n const x = ctm[0] * u + ctm[2] * v + ctm[4]\n const y = ctm[1] * u + ctm[3] * v + ctm[5]\n if (x < x1) x1 = x\n if (x > x2) x2 = x\n if (y < y1) y1 = y\n if (y > y2) y2 = y\n }\n if (x2 - x1 > 0 && y2 - y1 > 0) regions.push({ x1, y1, x2, y2 })\n break\n }\n }\n }\n return regions\n}\n","/**\n * PDF 그래픽 명령에서 수평/수직 선을 추출하는 모듈 (line-detector.ts에서 분리).\n *\n * 선 전처리 파이프라인은 OpenDataLoader PDF의 LinesPreprocessingConsumer를\n * 참고하여 TypeScript로 clean-room 재구현한 것입니다.\n * Original algorithm: Copyright 2025-2026 Hancom, Inc. (Apache 2.0)\n * https://github.com/opendataloader-project/opendataloader-pdf\n */\n\nimport { OPS } from \"pdfjs-dist/legacy/build/pdf.mjs\"\nimport type { LineSegment } from \"./line-types.js\"\n\n// ─── pdfjs-dist v5 DrawOPS ──\nconst enum DrawOPS {\n moveTo = 0,\n lineTo = 1,\n curveTo = 2,\n quadraticCurveTo = 3,\n closePath = 4,\n}\n\n/** 수평/수직 판별 허용 오차 (pt) */\nconst ORIENTATION_TOL = 2\n/** 최소 선 길이 — 짧은 장식선(체크박스 테두리 등) 무시 */\nconst MIN_LINE_LENGTH = 15\n/** 굵은 선 필터 — ODL: MAX_LINE_WIDTH = 5.0 (배경 채움/장식 사각형 제외) */\nconst MAX_LINE_WIDTH = 5.0\n\n// ─── 선 추출 ──────────────────────────────────────────\n\n/**\n * pdfjs operatorList에서 수평/수직 선을 추출.\n * constructPath(91) 내의 moveTo→lineTo, rectangle 패턴을 인식.\n */\nexport function extractLines(\n fnArray: Uint32Array | number[],\n argsArray: unknown[][],\n): { horizontals: LineSegment[]; verticals: LineSegment[] } {\n const horizontals: LineSegment[] = []\n const verticals: LineSegment[] = []\n let lineWidth = 1\n\n // CTM 추적 — 경로 좌표는 구성 시점의 CTM 공간에 있다. 콘텐츠 스트림이\n // 축소/플립 변환을 깔면(성과계획서류: [0.75,0,0,-0.75,0,H]) 선이 텍스트\n // (getTextContent는 CTM 적용 완료)와 다른 좌표계에 놓여 그리드-텍스트 매핑이\n // 전멸한다. extractImageRegions와 동일하게 save/restore/transform을 따라간다.\n let ctm = [1, 0, 0, 1, 0, 0]\n const ctmStack: number[][] = []\n const applyCtm = (x: number, y: number): [number, number] =>\n [ctm[0] * x + ctm[2] * y + ctm[4], ctm[1] * x + ctm[3] * y + ctm[5]]\n /** CTM 평균 스케일 — lineWidth를 사용자 공간 두께로 환산 */\n const ctmScale = () =>\n (Math.hypot(ctm[0], ctm[1]) + Math.hypot(ctm[2], ctm[3])) / 2\n\n let currentPath: Array<{ x1: number; y1: number; x2: number; y2: number }> = []\n let pathStartX = 0, pathStartY = 0\n let curX = 0, curY = 0\n\n /** 원시 좌표 세그먼트를 CTM 적용해 경로에 추가 */\n function pushSeg(x1: number, y1: number, x2: number, y2: number) {\n const [tx1, ty1] = applyCtm(x1, y1)\n const [tx2, ty2] = applyCtm(x2, y2)\n currentPath.push({ x1: tx1, y1: ty1, x2: tx2, y2: ty2 })\n }\n\n function pushRectangle(rx: number, ry: number, rw: number, rh: number) {\n // 얇은 사각형(선으로 그린 괘선) 판별은 CTM 적용 후 실제 두께 기준\n const effH = Math.abs(rh) * Math.hypot(ctm[2], ctm[3])\n const effW = Math.abs(rw) * Math.hypot(ctm[0], ctm[1])\n if (effH < ORIENTATION_TOL * 2) {\n pushSeg(rx, ry + rh / 2, rx + rw, ry + rh / 2)\n } else if (effW < ORIENTATION_TOL * 2) {\n pushSeg(rx + rw / 2, ry, rx + rw / 2, ry + rh)\n } else {\n pushSeg(rx, ry, rx + rw, ry)\n pushSeg(rx + rw, ry, rx + rw, ry + rh)\n pushSeg(rx + rw, ry + rh, rx, ry + rh)\n pushSeg(rx, ry + rh, rx, ry)\n }\n }\n\n function flushPath(isStroke: boolean, fromFill = false) {\n if (!isStroke) { currentPath = []; return }\n const effWidth = lineWidth * ctmScale()\n for (const seg of currentPath) {\n classifyAndAdd(seg, effWidth, horizontals, verticals, fromFill)\n }\n currentPath = []\n }\n\n for (let i = 0; i < fnArray.length; i++) {\n const op = fnArray[i]\n const args = argsArray[i]\n\n switch (op) {\n case OPS.setLineWidth:\n lineWidth = (args as number[])[0] || 1\n break\n\n case OPS.save:\n ctmStack.push(ctm.slice())\n break\n\n case OPS.restore:\n ctm = ctmStack.pop() ?? [1, 0, 0, 1, 0, 0]\n break\n\n case OPS.transform: {\n const t = args as number[]\n ctm = [\n ctm[0] * t[0] + ctm[2] * t[1],\n ctm[1] * t[0] + ctm[3] * t[1],\n ctm[0] * t[2] + ctm[2] * t[3],\n ctm[1] * t[2] + ctm[3] * t[3],\n ctm[0] * t[4] + ctm[2] * t[5] + ctm[4],\n ctm[1] * t[4] + ctm[3] * t[5] + ctm[5],\n ]\n break\n }\n\n case OPS.constructPath: {\n const arg0 = args[0]\n\n if (Array.isArray(arg0)) {\n // ── pdfjs-dist v4 형식 ──\n const subOps = arg0 as number[]\n const coords = (args as [number[], number[]])[1]\n let ci = 0\n\n for (const subOp of subOps) {\n if (subOp === OPS.moveTo) {\n curX = coords[ci++]; curY = coords[ci++]\n pathStartX = curX; pathStartY = curY\n } else if (subOp === OPS.lineTo) {\n const x2 = coords[ci++], y2 = coords[ci++]\n pushSeg(curX, curY, x2, y2)\n curX = x2; curY = y2\n } else if (subOp === OPS.rectangle) {\n const rx = coords[ci++], ry = coords[ci++]\n const rw = coords[ci++], rh = coords[ci++]\n pushRectangle(rx, ry, rw, rh)\n } else if (subOp === OPS.closePath) {\n if (curX !== pathStartX || curY !== pathStartY) {\n pushSeg(curX, curY, pathStartX, pathStartY)\n }\n curX = pathStartX; curY = pathStartY\n } else if (subOp === OPS.curveTo) {\n ci += 6\n } else if (subOp === OPS.curveTo2 || subOp === OPS.curveTo3) {\n ci += 4\n }\n }\n } else {\n // ── pdfjs-dist v5 형식 ──\n const afterOp = arg0 as number\n const dataArr = args[1] as unknown[]\n const pathData = dataArr?.[0] as Record<number, number> | undefined\n if (pathData && typeof pathData === \"object\") {\n const len = Object.keys(pathData).length\n let di = 0\n while (di < len) {\n const drawOp = pathData[di++]\n if (drawOp === DrawOPS.moveTo) {\n curX = pathData[di++]; curY = pathData[di++]\n pathStartX = curX; pathStartY = curY\n } else if (drawOp === DrawOPS.lineTo) {\n const x2 = pathData[di++], y2 = pathData[di++]\n pushSeg(curX, curY, x2, y2)\n curX = x2; curY = y2\n } else if (drawOp === DrawOPS.curveTo) {\n di += 6\n } else if (drawOp === DrawOPS.quadraticCurveTo) {\n di += 4\n } else if (drawOp === DrawOPS.closePath) {\n if (curX !== pathStartX || curY !== pathStartY) {\n pushSeg(curX, curY, pathStartX, pathStartY)\n }\n curX = pathStartX; curY = pathStartY\n } else {\n break\n }\n }\n }\n\n if (afterOp === OPS.stroke || afterOp === OPS.closeStroke) {\n flushPath(true)\n } else if (afterOp === OPS.fill || afterOp === OPS.eoFill) {\n flushPath(true, true) // 순수 fill — 그라디언트 밴드 스택(스테일 폭)\n } else if (afterOp === OPS.fillStroke || afterOp === OPS.eoFillStroke ||\n afterOp === OPS.closeFillStroke || afterOp === OPS.closeEOFillStroke) {\n flushPath(true) // fill+stroke — stroke 폭 유효\n } else if (afterOp === OPS.endPath) {\n flushPath(false)\n }\n }\n break\n }\n\n case OPS.stroke:\n case OPS.closeStroke:\n flushPath(true)\n break\n\n case OPS.fill:\n case OPS.eoFill:\n flushPath(true, true) // 순수 fill — 그라디언트 밴드 스택(스테일 폭)\n break\n case OPS.fillStroke:\n case OPS.eoFillStroke:\n case OPS.closeFillStroke:\n case OPS.closeEOFillStroke:\n flushPath(true) // fill+stroke — stroke 폭 유효\n break\n\n case OPS.endPath:\n flushPath(false)\n break\n }\n }\n\n return { horizontals, verticals }\n}\n\n\nfunction classifyAndAdd(\n seg: { x1: number; y1: number; x2: number; y2: number },\n lineWidth: number,\n horizontals: LineSegment[],\n verticals: LineSegment[],\n fromFill = false,\n) {\n const dx = Math.abs(seg.x2 - seg.x1)\n const dy = Math.abs(seg.y2 - seg.y1)\n const length = Math.sqrt(dx * dx + dy * dy)\n\n if (length < MIN_LINE_LENGTH) return\n\n if (dy <= ORIENTATION_TOL) {\n const y = (seg.y1 + seg.y2) / 2\n const x1 = Math.min(seg.x1, seg.x2)\n const x2 = Math.max(seg.x1, seg.x2)\n horizontals.push({ x1, y1: y, x2, y2: y, lineWidth, fromFill })\n } else if (dx <= ORIENTATION_TOL) {\n const x = (seg.x1 + seg.x2) / 2\n const y1 = Math.min(seg.y1, seg.y2)\n const y2 = Math.max(seg.y1, seg.y2)\n verticals.push({ x1: x, y1, x2: x, y2, lineWidth, fromFill })\n }\n}\n\n// ─── 선 전처리 파이프라인 (ODL LinesPreprocessingConsumer 포팅) ──\n\n/**\n * 선 전처리: 굵은 선 필터 → 음영 스택 제거 → 근접 선 병합 → 장식선 필터링\n * ODL의 LinesPreprocessingConsumer가 하는 핵심 로직.\n */\nexport function preprocessLines(\n horizontals: LineSegment[],\n verticals: LineSegment[],\n): { horizontals: LineSegment[]; verticals: LineSegment[] } {\n // 1. 굵은 선 필터링 (배경 채움 사각형, 장식 테두리 등)\n let h = horizontals.filter(l => l.lineWidth <= MAX_LINE_WIDTH)\n let v = verticals.filter(l => l.lineWidth <= MAX_LINE_WIDTH)\n\n // 1.5. 음영/그라디언트 스택 제거 — 근접 병합 전에 해야 실제 괘선을 삼키지 않음\n h = dropShadingStacks(h, \"h\")\n v = dropShadingStacks(v, \"v\")\n\n // 2. 근접 평행 선 병합 (인쇄 잔상, 이중선)\n h = mergeParallelLines(h, \"h\")\n v = mergeParallelLines(v, \"v\")\n\n return { horizontals: h, verticals: v }\n}\n\n// ─── 음영 스택 필터 ───────────────────────────────────\n\n/** 음영 스택 판정: 같은 범위 평행선의 연속 간격이 이 값 미만 (실제 괘선 행 높이는 6pt+) */\nconst STACK_GAP = 2\n/** 음영 스택 최소 줄 수 — 괘선 이중 인쇄(2줄)와 구분 */\nconst STACK_MIN_LINES = 6\n\n/**\n * 음영/그라디언트 스택 제거 — 한컴 PDF는 글상자 배경 그라디언트를 같은 범위의\n * 가는 평행선 수십 개(0.5pt 간격)로 그린다. 이대로 두면 근접 평행 선 병합이\n * 스택과 함께 주변의 실제 상하 테두리까지 연쇄 흡수해 박스 괘선이 소실된다.\n */\nfunction dropShadingStacks(lines: LineSegment[], dir: \"h\" | \"v\"): LineSegment[] {\n if (lines.length < STACK_MIN_LINES) return lines\n\n // 같은 (시작~끝) 범위끼리 그룹 — 스택은 같은 그리기 루프 산물이라 좌표가 동일\n const groups = new Map<string, LineSegment[]>()\n for (const l of lines) {\n const key = dir === \"h\"\n ? `${Math.round(l.x1)}:${Math.round(l.x2)}`\n : `${Math.round(l.y1)}:${Math.round(l.y2)}`\n const arr = groups.get(key)\n if (arr) arr.push(l)\n else groups.set(key, [l])\n }\n\n const dropped = new Set<LineSegment>()\n const coord = (l: LineSegment): number => (dir === \"h\" ? l.y1 : l.x1)\n for (const group of groups.values()) {\n if (group.length < STACK_MIN_LINES) continue\n group.sort((a, b) => coord(a) - coord(b))\n let runStart = 0\n for (let i = 1; i <= group.length; i++) {\n const gap = i < group.length ? coord(group[i]) - coord(group[i - 1]) : Infinity\n if (gap < STACK_GAP) continue\n if (i - runStart >= STACK_MIN_LINES) {\n // 말단 트리밍 — 스택은 같은 그리기 루프 산물이라 lineWidth·줄간격(pitch)이\n // 균일하다. 지배 폭과 다르거나 내부 pitch보다 크게 벌어진 말단 줄은\n // 플러시(패딩 0) 글상자의 실제 상하 테두리이므로 스택과 함께 삼키지 않는다.\n let s = runStart\n let e = i - 1\n const wKey = (l: LineSegment): number => Math.round(l.lineWidth * 100)\n const wCount = new Map<number, number>()\n for (let j = s; j <= e; j++) wCount.set(wKey(group[j]), (wCount.get(wKey(group[j])) ?? 0) + 1)\n let domW = 0\n let domN = 0\n for (const [w, n] of wCount) if (n > domN) { domW = w; domN = n }\n const pitches: number[] = []\n for (let j = s + 1; j <= e; j++) pitches.push(coord(group[j]) - coord(group[j - 1]))\n pitches.sort((a, b) => a - b)\n const medPitch = pitches[Math.floor(pitches.length / 2)] ?? 0\n // 스택 본체가 fill 유래(그라디언트)면, stroke 로 그은 말단(글상자 실 테두리)은\n // fromFill 이 달라, 스테일 폭이 우연히 스택과 같아도 트리밍해 보존한다 (pline-3)\n let fillN = 0\n for (let j = s; j <= e; j++) if (group[j].fromFill) fillN++\n const stackIsFill = fillN * 2 > (e - s + 1)\n const edgeAlien = (j: number, inwardGap: number): boolean =>\n (stackIsFill && !group[j].fromFill)\n || wKey(group[j]) !== domW || (medPitch > 0 && inwardGap > medPitch * 1.8)\n while (e - s + 1 >= STACK_MIN_LINES) {\n if (edgeAlien(s, coord(group[s + 1]) - coord(group[s]))) { s++; continue }\n if (edgeAlien(e, coord(group[e]) - coord(group[e - 1]))) { e--; continue }\n break\n }\n if (e - s + 1 >= STACK_MIN_LINES) {\n for (let j = s; j <= e; j++) dropped.add(group[j])\n }\n }\n runStart = i\n }\n }\n return dropped.size ? lines.filter(l => !dropped.has(l)) : lines\n}\n\n// ─── 개방 변 표 테두리 합성 ──────────────────────────\n\n/** 수평 괘선 끝점 정렬 그룹 허용 오차 (pt) */\nconst EDGE_ALIGN_TOL = 3\n/** 합성 최소 괘선 수 (3줄 = 2행 이상) */\nconst EDGE_MIN_RULES = 3\n/** 그룹 y-스팬 최소 (2행 × 최소 행 높이) — 장식 겹줄 배제 */\nconst EDGE_MIN_SPAN = 12\n/** 내부 수직선 판정: 변에서 이만큼 안쪽 (table-grid MIN_COL_WIDTH와 동일) */\nconst EDGE_INSET = 15\n/** 기존 수직선이 이 거리 안에 있으면 그 변은 이미 닫힌 것 */\nconst EDGE_NEAR = 10\n/** 선 교차 판정 여유 (table-grid CONNECT_TOL과 동일) */\nconst EDGE_CONNECT_TOL = 5\n/** 상하 스택 표 분할: 그룹 내 세로 간격이 중앙값의 이 배를 넘으면 표 경계로 보고 쪼갬 */\nconst EDGE_YGAP_SPLIT_K = 2.5\n/** y-간격 분할 절대 하한(pt) — 이중괘선 중앙값 붕괴 시 정상 행간을 표 경계로 오인하지 않게 */\nconst EDGE_YGAP_ABS_MIN = 30\n/** 체인 뷰: 같은 논리 괘선으로 묶는 y 허용 오차 (pt) */\nconst CHAIN_Y_TOL = 1.5\n/** 체인 뷰: 콜리니어 세그먼트 연결 최대 간격 (pt) */\nconst CHAIN_GAP = 3\n\n/**\n * 체인 뷰 — 같은 y의 콜리니어 세그먼트(셀 단위로 쪼개 그은 괘선)를 논리\n * 괘선 하나로 잇는다. closeOpenTableEdges의 끝점 정렬 판정 전용이며 물리\n * 수평선은 수정하지 않는다 (물리 병합은 셀 배치 변질 실측으로 폐기, 9차).\n * 근접 평행 겹줄(장식 이중선)도 한 논리 괘선으로 흡수된다.\n */\nfunction chainCollinearRules(horizontals: LineSegment[]): LineSegment[] {\n if (horizontals.length <= 1) return horizontals\n const sorted = [...horizontals].sort((a, b) => a.y1 - b.y1 || a.x1 - b.x1)\n const rules: LineSegment[] = []\n let bandStart = 0\n const flushBand = (end: number) => {\n const band = sorted.slice(bandStart, end).sort((a, b) => a.x1 - b.x1)\n let cur = { ...band[0] }\n for (let i = 1; i < band.length; i++) {\n const seg = band[i]\n if (seg.x1 - cur.x2 <= CHAIN_GAP) {\n if (seg.x2 > cur.x2) cur.x2 = seg.x2\n if (seg.lineWidth > cur.lineWidth) cur.lineWidth = seg.lineWidth\n } else {\n rules.push(cur)\n cur = { ...seg }\n }\n }\n rules.push(cur)\n }\n for (let i = 1; i <= sorted.length; i++) {\n if (i === sorted.length || sorted[i].y1 - sorted[bandStart].y1 > CHAIN_Y_TOL) {\n flushBand(i)\n bandStart = i\n }\n }\n return rules\n}\n\n/**\n * 개방 변 표 테두리 합성 — 한국 행정문서 표는 좌/우 바깥 테두리를 생략하는\n * 스타일이 흔하다 (수평 괘선은 전폭으로 긋고 수직선은 내부 구분선만).\n * 교차점(Vertex) 기반 그리드 구성은 수직선 없는 변의 열을 통째로 잃으므로,\n * 끝점이 정렬된 수평 괘선 묶음(≥3줄)에 그 묶음 괘선 2개 이상과 교차하는 내부\n * 수직선이 실존할 때에 한해 끝점 x에 가상 수직 테두리를 합성해 그리드를 닫는다.\n * 판정은 체인 뷰(콜리니어 세그먼트를 이은 논리 괘선) 기준 — 셀 단위로 쪼개\n * 그은 중간 괘선도 끝점 정렬 그룹에 합류한다.\n *\n * 전역 끝점 그룹핑이라 폭이 비슷한 표가 위아래로 쌓인 페이지에선 그룹 하나로\n * 뭉쳐 y-범위가 넓어지고, 가장자리에 이미 수직선이 있는 경우가 많아 발동이\n * 보수적이다 — 의도된 안전 특성 (컴포넌트 단위 정밀화는 실측에서 demote 연쇄\n * 부작용으로 보류, 9차 세션 기록 참조).\n */\nexport function closeOpenTableEdges(\n horizontals: LineSegment[],\n verticals: LineSegment[],\n): LineSegment[] {\n if (horizontals.length < EDGE_MIN_RULES) return verticals\n\n // 1) 끝점 정렬 그룹핑 (x1·x2 모두 근접) — 체인 뷰의 논리 괘선 기준\n const groups: LineSegment[][] = []\n for (const hl of chainCollinearRules(horizontals)) {\n let placed = false\n for (const g of groups) {\n if (Math.abs(g[0].x1 - hl.x1) <= EDGE_ALIGN_TOL && Math.abs(g[0].x2 - hl.x2) <= EDGE_ALIGN_TOL) {\n g.push(hl)\n placed = true\n break\n }\n }\n if (!placed) groups.push([hl])\n }\n\n // 1b) y-간격 상한 분할 — 폭이 같아 한 그룹에 묶인 상하 스택 표를, 표 사이의 큰 세로\n // 간격에서 쪼갠다(사이 본문을 관통하는 가상 테두리 용접 방지). 간격이 중앙값의\n // EDGE_YGAP_SPLIT_K배를 넘고 양쪽이 각각 최소 괘선 수를 만족할 때만 분할하므로,\n // 행 간격이 균일한 연속 단일 표에는 발동하지 않는다(무회귀).\n const splitGroups: LineSegment[][] = []\n for (const g of groups) {\n const sorted = [...g].sort((a, b) => a.y1 - b.y1)\n const gaps: number[] = []\n for (let i = 1; i < sorted.length; i++) gaps.push(sorted[i].y1 - sorted[i - 1].y1)\n const median = gaps.length ? [...gaps].sort((a, b) => a - b)[gaps.length >> 1] : 0\n const threshold = median * EDGE_YGAP_SPLIT_K\n let cur: LineSegment[] = sorted.length ? [sorted[0]] : []\n for (let i = 1; i < sorted.length; i++) {\n const yLo = sorted[i - 1].y1, yHi = sorted[i].y1\n const gap = yHi - yLo\n // 밴드 아래 행 수직선(위 끝이 밴드 하단에 닿음)과 위 행 수직선(아래 끝이 밴드 상단에\n // 닿음)이 같은 x 로 다리를 놓으면, 이 간격은 표 사이가 아니라 표 내부 병합 큰 행이므로\n // 분할하지 않는다 — 정당한 개방변 표를 두 표로 절단하던 회귀 방지 (pline-1).\n // 밴드 y구간을 관통하는 수직선(병합 셀 좌우변·내부 구분선)이 있으면 표 내부 병합 큰\n // 행이므로 분할하지 않는다. 표 사이 프로즈에는 관통 수직선이 없다 — 두 별개 표는 각자\n // 내부에서만 수직선이 끝나므로, 두 표 용접 방지(원 #3)와 양립한다 (pline-1).\n // 관통 수직선은 이 표의 가로 범위(밴드를 사이에 둔 괘선들의 x-구간) 안에 있어야\n // 다리로 인정한다 — 페이지 어디든 있는 무관한 수직선(좌측 여백선·변경바)이 정당한 표\n // 분할을 죽여 별개 표를 용접하던 것 방지 (pline-2: verticals 전역 스캔 over-reach).\n const gx1 = Math.min(sorted[i - 1].x1, sorted[i].x1)\n const gx2 = Math.max(sorted[i - 1].x2, sorted[i].x2)\n const bridged = verticals.some(vl =>\n vl.y1 <= yLo + EDGE_NEAR && vl.y2 >= yHi - EDGE_NEAR &&\n vl.x1 >= gx1 - EDGE_CONNECT_TOL && vl.x1 <= gx2 + EDGE_CONNECT_TOL)\n if (median > 0 && gap > threshold && gap > EDGE_YGAP_ABS_MIN && !bridged\n && cur.length >= EDGE_MIN_RULES && sorted.length - i >= EDGE_MIN_RULES) {\n splitGroups.push(cur)\n cur = []\n }\n cur.push(sorted[i])\n }\n if (cur.length) splitGroups.push(cur)\n }\n\n const synthesized: LineSegment[] = []\n for (const g of splitGroups) {\n if (g.length < EDGE_MIN_RULES) continue\n let yMin = Infinity, yMax = -Infinity, x1 = 0, x2 = 0\n for (const hl of g) {\n if (hl.y1 < yMin) yMin = hl.y1\n if (hl.y1 > yMax) yMax = hl.y1\n x1 += hl.x1\n x2 += hl.x2\n }\n x1 /= g.length\n x2 /= g.length\n if (yMax - yMin < EDGE_MIN_SPAN) continue\n\n // 2) 내부 수직선 실존 확인 — 그룹 괘선 2개 이상과 교차해야 표 기하\n const crossCount = (v: LineSegment) => {\n let n = 0\n for (const hl of g) {\n if (v.x1 >= hl.x1 - EDGE_CONNECT_TOL && v.x1 <= hl.x2 + EDGE_CONNECT_TOL &&\n hl.y1 >= v.y1 - EDGE_CONNECT_TOL && hl.y1 <= v.y2 + EDGE_CONNECT_TOL) n++\n }\n return n\n }\n const hasInterior = verticals.some(v =>\n v.x1 > x1 + EDGE_INSET && v.x1 < x2 - EDGE_INSET && crossCount(v) >= 2)\n if (!hasInterior) continue\n\n // 3) 변마다 근처에 기존 수직선이 없으면 가상 테두리 합성\n for (const edgeX of [x1, x2]) {\n const closed = verticals.some(v =>\n Math.abs(v.x1 - edgeX) <= EDGE_NEAR &&\n v.y1 <= yMax + EDGE_CONNECT_TOL && v.y2 >= yMin - EDGE_CONNECT_TOL)\n if (!closed) {\n synthesized.push({ x1: edgeX, y1: yMin, x2: edgeX, y2: yMax, lineWidth: 0.5 })\n }\n }\n }\n\n return synthesized.length ? [...verticals, ...synthesized] : verticals\n}\n\n\n/**\n * 근접 평행 선 병합 — 같은 방향의 가까운 선을 하나로 합침.\n * 이중선, 인쇄 잔상, PDF 렌더링 미세 차이로 인한 중복 선 제거.\n */\nfunction mergeParallelLines(lines: LineSegment[], dir: \"h\" | \"v\"): LineSegment[] {\n if (lines.length <= 1) return lines\n\n // 수평선: y로 정렬, 수직선: x로 정렬\n const sorted = [...lines].sort((a, b) => {\n const posA = dir === \"h\" ? a.y1 : a.x1\n const posB = dir === \"h\" ? b.y1 : b.x1\n if (Math.abs(posA - posB) > 0.1) return posA - posB\n // 같은 위치면 시작 좌표로\n return dir === \"h\" ? (a.x1 - b.x1) : (a.y1 - b.y1)\n })\n\n const MERGE_TOL = 3 // 3pt 이내 평행 선 병합\n\n const result: LineSegment[] = [sorted[0]]\n for (let i = 1; i < sorted.length; i++) {\n const prev = result[result.length - 1]\n const curr = sorted[i]\n\n const prevPos = dir === \"h\" ? prev.y1 : prev.x1\n const currPos = dir === \"h\" ? curr.y1 : curr.x1\n\n if (Math.abs(prevPos - currPos) <= MERGE_TOL) {\n // 범위가 겹치는지 확인\n const prevStart = dir === \"h\" ? prev.x1 : prev.y1\n const prevEnd = dir === \"h\" ? prev.x2 : prev.y2\n const currStart = dir === \"h\" ? curr.x1 : curr.y1\n const currEnd = dir === \"h\" ? curr.x2 : curr.y2\n\n const overlap = Math.min(prevEnd, currEnd) - Math.max(prevStart, currStart)\n const minLen = Math.min(prevEnd - prevStart, currEnd - currStart)\n\n if (overlap > minLen * 0.3) {\n // 병합: 범위 확장, lineWidth는 최대값 유지\n if (dir === \"h\") {\n prev.x1 = Math.min(prev.x1, curr.x1)\n prev.x2 = Math.max(prev.x2, curr.x2)\n prev.y1 = (prev.y1 + curr.y1) / 2\n prev.y2 = prev.y1\n } else {\n prev.y1 = Math.min(prev.y1, curr.y1)\n prev.y2 = Math.max(prev.y2, curr.y2)\n prev.x1 = (prev.x1 + curr.x1) / 2\n prev.x2 = prev.x1\n }\n prev.lineWidth = Math.max(prev.lineWidth, curr.lineWidth)\n continue\n }\n }\n result.push(curr)\n }\n return result\n}\n\n// ─── 페이지 경계(클립) 선 필터링 ──────────────────────\n\nexport function filterPageBorderLines(\n horizontals: LineSegment[],\n verticals: LineSegment[],\n pageWidth: number,\n pageHeight: number,\n): { horizontals: LineSegment[]; verticals: LineSegment[] } {\n const margin = 5\n return {\n horizontals: horizontals.filter(l =>\n !(Math.abs(l.y1) < margin || Math.abs(l.y1 - pageHeight) < margin) ||\n (l.x2 - l.x1) < pageWidth * 0.9\n ),\n verticals: verticals.filter(l =>\n !(Math.abs(l.x1) < margin || Math.abs(l.x1 - pageWidth) < margin) ||\n (l.y2 - l.y1) < pageHeight * 0.9\n ),\n }\n}\n\n","/**\n * 열 수직선 브리지 (line-detector.ts 계열 전처리).\n *\n * 예산서류 대형 표는 요약행 밴드(재원구분 시/구 행 등)에 수직 괘선을 아예\n * 긋지 않는 스타일이 있다. 이 무괘선 밴드에서 동일 열의 수직선이 위/아래로\n * 끊기면 Union-Find 그룹이 파편화되어 표 상단(헤더행·부서/정책 요약행)이\n * 그리드에서 탈락한다 — 세출예산 사업명세서 부서명 유실의 근본원인 (실측:\n * .claude/plans/budget-dept-merge-fix.md).\n *\n * 같은 열 x에서 끊긴 수직선 쌍을, 아래 조건을 모두 만족할 때만 잇는다:\n * (a) 간격이 CONNECT_TOL 초과(이하면 이미 연결됨) ~ 상한 이내\n * (b) 간격 내부에 그 x를 가로지르는 수평선이 실존 (내용 행 증거 —\n * 별개 표 사이 빈 공간에는 내부 수평선이 없다)\n * (c) 같은 y-밴드에서 3열 이상이 동시에 끊김 (한 표의 열 구조 증거)\n * (d) 밴드 내부 수평선의 끝점이 내부 열 경계와 정합 (셀 단위로 쪼개 그은\n * 행 괘선 증거 — 표 사이 단일 전폭 구분선/제목 밑줄 오탐 방지)\n *\n * 합성 세그먼트는 간격만이 아니라 위/아래 이웃 세그먼트 전체를 덮는다 —\n * cell-extract의 hasVerticalLine이 단일 세그먼트 75% 커버 기준이라, 간격만\n * 이으면 브리지·기존 세그먼트가 각각 병합 행 밴드의 75%에 못 미친다.\n */\n\nimport type { LineSegment } from \"./line-types.js\"\n\n/** 같은 논리 수직선으로 묶는 x 허용 오차 (pt) — table-grid CUT_VCHAIN_X_TOL과 동일 */\nconst BRIDGE_X_TOL = 1.5\n/** 브리지 최소 간격 (pt) — table-grid CONNECT_TOL 이하는 이미 같은 그룹 */\nconst BRIDGE_MIN_GAP = 5\n/** 브리지 최대 간격 (pt) — 무괘선 밴드 상한 (실측 74.3pt, 여유 포함) */\nconst BRIDGE_MAX_GAP = 120\n/** 후보 간 같은 y-밴드 판정 허용 오차 (pt) */\nconst BRIDGE_BAND_TOL = 6\n/** 같은 밴드에서 동시에 끊긴 최소 열 수 */\nconst BRIDGE_MIN_COLUMNS = 3\n/** 수평선 끝점-열 경계 정합 판정 허용 오차 (pt) */\nconst BRIDGE_ENDPOINT_TOL = 2\n/** 밴드 내부 수평선 판정 여유 (pt) — 밴드 가장자리 선(그리드 경계선) 배제 */\nconst BRIDGE_EDGE_EPS = 2\n\ninterface BridgeCandidate {\n /** 열 x 좌표 (아래/위 세그먼트 x 평균) */\n x: number\n /** 간격 하단 (아래 세그먼트 상단 y) */\n lo: number\n /** 간격 상단 (위 세그먼트 하단 y) */\n hi: number\n /** 합성 범위 하단 (아래 세그먼트 하단 y) */\n spanLo: number\n /** 합성 범위 상단 (위 세그먼트 상단 y) */\n spanHi: number\n lineWidth: number\n}\n\n/**\n * 무괘선 밴드로 끊긴 동일 열 수직선을 합성 세그먼트로 잇는다.\n * 조건 미달이면 입력을 그대로 반환한다.\n */\nexport function bridgeSplitColumnVerticals(\n horizontals: LineSegment[],\n verticals: LineSegment[],\n): LineSegment[] {\n if (verticals.length < 4 || horizontals.length === 0) return verticals\n\n // 1) x-밴드 그룹핑 후 각 밴드의 연속 세그먼트 쌍에서 간격 후보 수집\n const sorted = [...verticals].sort((a, b) => a.x1 - b.x1 || a.y1 - b.y1)\n const candidates: BridgeCandidate[] = []\n let bandStart = 0\n const collectBand = (end: number) => {\n const band = sorted.slice(bandStart, end).sort((a, b) => a.y1 - b.y1)\n for (let i = 1; i < band.length; i++) {\n const lower = band[i - 1], upper = band[i]\n const gap = upper.y1 - lower.y2\n if (gap <= BRIDGE_MIN_GAP || gap > BRIDGE_MAX_GAP) continue\n const x = (lower.x1 + upper.x1) / 2\n // (b) 간격 내부에 이 x를 가로지르는 수평선 실존\n const hasInteriorH = horizontals.some(h =>\n h.y1 > lower.y2 + BRIDGE_EDGE_EPS && h.y1 < upper.y1 - BRIDGE_EDGE_EPS &&\n h.x1 <= x + BRIDGE_ENDPOINT_TOL && h.x2 >= x - BRIDGE_ENDPOINT_TOL)\n if (!hasInteriorH) continue\n candidates.push({\n x, lo: lower.y2, hi: upper.y1,\n spanLo: lower.y1, spanHi: upper.y2,\n lineWidth: Math.min(lower.lineWidth, upper.lineWidth),\n })\n }\n }\n for (let i = 1; i <= sorted.length; i++) {\n if (i === sorted.length || sorted[i].x1 - sorted[bandStart].x1 > BRIDGE_X_TOL) {\n collectBand(i)\n bandStart = i\n }\n }\n if (candidates.length < BRIDGE_MIN_COLUMNS) return verticals\n\n // 2) 같은 y-밴드 후보 클러스터링\n const clusters: BridgeCandidate[][] = []\n for (const c of candidates) {\n let placed = false\n for (const cl of clusters) {\n if (Math.abs(cl[0].lo - c.lo) <= BRIDGE_BAND_TOL && Math.abs(cl[0].hi - c.hi) <= BRIDGE_BAND_TOL) {\n cl.push(c)\n placed = true\n break\n }\n }\n if (!placed) clusters.push([c])\n }\n\n const synthesized: LineSegment[] = []\n for (const cl of clusters) {\n // (c) 3열 이상 동시 단절\n if (cl.length < BRIDGE_MIN_COLUMNS) continue\n\n // (d) 밴드 내부 수평선 끝점이 내부 열 경계와 정합 — 셀 단위 행 괘선 증거.\n // 최좌/최우 열은 표 외곽이라 단일 전폭 구분선 끝점과도 일치하므로 제외.\n const xs = cl.map(c => c.x).sort((a, b) => a - b)\n const interiorXs = xs.slice(1, -1)\n const bandLo = Math.min(...cl.map(c => c.lo))\n const bandHi = Math.max(...cl.map(c => c.hi))\n const segmented = horizontals.some(h =>\n h.y1 > bandLo + BRIDGE_EDGE_EPS && h.y1 < bandHi - BRIDGE_EDGE_EPS &&\n interiorXs.some(ix =>\n Math.abs(h.x1 - ix) <= BRIDGE_ENDPOINT_TOL || Math.abs(h.x2 - ix) <= BRIDGE_ENDPOINT_TOL))\n if (!segmented) continue\n\n for (const c of cl) {\n synthesized.push({ x1: c.x, y1: c.spanLo, x2: c.x, y2: c.spanHi, lineWidth: c.lineWidth })\n }\n }\n\n return synthesized.length ? [...verticals, ...synthesized] : verticals\n}\n","/**\n * 선 기반 테이블 감지 공유 타입/상수.\n * line-detector.ts에서 분리 — 알고리즘 출처는 line-detector.ts 헤더 참조.\n */\n\n// ─── 타입 ─────────────────────────────────────────────\n\nexport interface LineSegment {\n x1: number; y1: number\n x2: number; y2: number\n lineWidth: number\n /** fill 연산(그라디언트 밴드 스택)에서 나온 선분 — PDF w 연산자는 stroke 전용이라\n * fill 선분의 lineWidth 는 마지막 stroke 상태를 상속한 스테일 값이다. 음영 스택 폭\n * 판별(dropShadingStacks)이 스테일 폭에 속지 않게 표기한다 (pline-3). */\n fromFill?: boolean\n}\n\nexport interface TableGrid {\n /** 행 Y 좌표 경계 (위→아래 내림차순) */\n rowYs: number[]\n /** 열 X 좌표 경계 (좌→우 오름차순) */\n colXs: number[]\n /** 테이블 바운딩 박스 */\n bbox: { x1: number; y1: number; x2: number; y2: number }\n /** 그리드 내 교차점 반경 (동적 tolerance용) */\n vertexRadius: number\n}\n\nexport interface ExtractedCell {\n row: number; col: number\n rowSpan: number; colSpan: number\n /** 셀 바운딩 박스 */\n bbox: { x1: number; y1: number; x2: number; y2: number }\n}\n\nexport interface TextItem {\n text: string\n x: number; y: number; w: number; h: number\n fontSize: number; fontName: string\n /** pdfjs 공백 아이템이 이 아이템 직전에 있었음 — 단어 경계 힌트 (parser.ts NormItem에서 전파) */\n hasSpaceBefore?: boolean\n}\n\n/** Vertex 기반 좌표 병합 시 radius 배수 — ODL: VERTEX_TABLE_FACTOR */\nexport const VERTEX_MERGE_FACTOR = 4\n","/**\n * 선 교차점(Vertex) 기반 테이블 그리드 구성 (line-detector.ts에서 분리).\n *\n * OpenDataLoader PDF의 TableBorderBuilder를 참고하여 TypeScript로\n * clean-room 재구현한 것입니다. Vertex 기반 동적 tolerance 포함.\n * Original algorithm: Copyright 2025-2026 Hancom, Inc. (Apache 2.0)\n * https://github.com/opendataloader-project/opendataloader-pdf\n * Core algorithm concepts from veraPDF-wcag-algs (GPLv3+/MPLv2+)\n */\n\nimport type { LineSegment, TableGrid } from \"./line-types.js\"\nimport { VERTEX_MERGE_FACTOR } from \"./line-types.js\"\n\n/** 선 교차점 (Vertex) — ODL의 핵심 개념 */\ninterface Vertex {\n x: number\n y: number\n /** 교차하는 선들의 최대 lineWidth → tolerance 계산에 사용 */\n radius: number\n}\n\n/** 두 선이 같은 테이블에 속하는지 판별하는 거리 */\nconst CONNECT_TOL = 5\n/** 최소 열 폭 (pt) — 이보다 좁은 열은 인접 열과 병합 */\nconst MIN_COL_WIDTH = 15\n/** 최소 행 높이 (pt) */\nconst MIN_ROW_HEIGHT = 6\n/** 좌표 병합 최소 tolerance (pt) — vertexRadius가 작아도 이 값 이하로 내려가지 않음 */\nconst MIN_COORD_MERGE_TOL = 8\n\n// ─── 적층 표 분리 (컷 라인) ──────────────────────────\n/** 컷 후보 수평선이 그룹 폭에서 차지해야 할 최소 비율 */\nconst CUT_FULLWIDTH_RATIO = 0.9\n/** 관통/소속 판정 y 여유 (pt) */\nconst CUT_CROSS_EPS = 2\n/** 컷 양쪽에 각각 필요한 최소 수직선 수 (독립 표 성립 조건) */\nconst CUT_MIN_SIDE_VERTICALS = 2\n/** 내부 수직선 판정 — 그룹 좌우 가장자리에서 이만큼 안쪽 (pt) */\nconst CUT_EDGE_MARGIN = 12\n/** 내부 수직선 x 동일 판정 tolerance (pt) */\nconst CUT_INTERIOR_MATCH_TOL = 8\n/** 내부 수직선 x-집합 겹침 허용 상한 — 초과하면 같은 열 구조의 연속(분리 금지) */\nconst CUT_MAX_INTERIOR_OVERLAP = 0.5\n/** 컷 체인 뷰: 같은 논리 수직선으로 묶는 x 허용 오차 (pt) */\nconst CUT_VCHAIN_X_TOL = 1.5\n/** 컷 체인 뷰: 콜리니어 수직 세그먼트 연결 최대 간격 (pt) — 한 표의 섹션별 세그먼트는\n * 정확히 맞닿고(gap≈0), 별개 표 사이에는 실간격(실측 2.9pt+)이 있다. line-extract\n * CHAIN_GAP(3)보다 좁게 잡아 별개 표의 경계 간격을 잇지 않는다 */\nconst CUT_VCHAIN_GAP = 1.0\n\n// ─── Vertex(교차점) 생성 ─────────────────────────────\n\n/**\n * 수평선과 수직선의 교차점(Vertex)을 생성.\n * ODL의 TableBorderBuilder.addLine()이 교차점을 자동 생성하는 것과 동일.\n * 각 Vertex는 교차하는 선들의 lineWidth로 radius를 계산 → 동적 tolerance.\n */\nfunction buildVertices(horizontals: LineSegment[], verticals: LineSegment[]): Vertex[] {\n const vertices: Vertex[] = []\n const tol = CONNECT_TOL\n\n for (const h of horizontals) {\n for (const v of verticals) {\n // 수평선의 X범위에 수직선의 X가 포함되고\n // 수직선의 Y범위에 수평선의 Y가 포함되면 → 교차\n if (v.x1 >= h.x1 - tol && v.x1 <= h.x2 + tol &&\n h.y1 >= v.y1 - tol && h.y1 <= v.y2 + tol) {\n const radius = Math.max(h.lineWidth, v.lineWidth, 1)\n vertices.push({ x: v.x1, y: h.y1, radius })\n }\n }\n }\n\n return vertices\n}\n\n/**\n * 근접 Vertex 병합 — 같은 교차점의 미세 위치 차이를 하나로 합침.\n * O(V²) 회피: 좌표 버킷 그리드로 근접 후보만 비교. mergeTol은 항상\n * VERTEX_MERGE_FACTOR × (전체 최대 radius) 이하이므로 셀 크기를 그 값으로 잡으면\n * ±1 셀 이웃만 봐도 기존 전수 비교와 결과(병합 집합·순서)가 동일하다.\n */\nfunction mergeVertices(vertices: Vertex[]): Vertex[] {\n if (vertices.length <= 1) return vertices\n\n let maxRadiusAll = 1\n for (const v of vertices) { if (v.radius > maxRadiusAll) maxRadiusAll = v.radius }\n const cell = Math.max(VERTEX_MERGE_FACTOR * maxRadiusAll, 1)\n const buckets = new Map<string, number[]>()\n for (let i = 0; i < vertices.length; i++) {\n const key = Math.floor(vertices[i].x / cell) + \",\" + Math.floor(vertices[i].y / cell)\n const arr = buckets.get(key)\n if (arr) arr.push(i)\n else buckets.set(key, [i])\n }\n\n const merged: Vertex[] = []\n const used = new Array(vertices.length).fill(false)\n\n for (let i = 0; i < vertices.length; i++) {\n if (used[i]) continue\n let sumX = vertices[i].x, sumY = vertices[i].y\n let maxRadius = vertices[i].radius\n let count = 1\n\n // ±1 셀 이웃의 j > i 후보만 — 기존 j 오름차순 스캔과 동일 순서 유지\n const cx = Math.floor(vertices[i].x / cell)\n const cy = Math.floor(vertices[i].y / cell)\n const candidates: number[] = []\n for (let dx = -1; dx <= 1; dx++) {\n for (let dy = -1; dy <= 1; dy++) {\n const arr = buckets.get((cx + dx) + \",\" + (cy + dy))\n if (!arr) continue\n for (const j of arr) { if (j > i && !used[j]) candidates.push(j) }\n }\n }\n candidates.sort((a, b) => a - b)\n\n for (const j of candidates) {\n if (used[j]) continue\n const mergeTol = VERTEX_MERGE_FACTOR * Math.max(maxRadius, vertices[j].radius)\n if (Math.abs(vertices[i].x - vertices[j].x) <= mergeTol &&\n Math.abs(vertices[i].y - vertices[j].y) <= mergeTol) {\n sumX += vertices[j].x\n sumY += vertices[j].y\n maxRadius = Math.max(maxRadius, vertices[j].radius)\n count++\n used[j] = true\n }\n }\n\n merged.push({ x: sumX / count, y: sumY / count, radius: maxRadius })\n }\n\n return merged\n}\n\n// ─── 테이블 그리드 구성 (Vertex 기반) ─────────────────\n\n/**\n * 수평/수직 선에서 테이블 그리드를 추출.\n * ODL과 동일한 흐름:\n * 1. 선 전처리 (preprocessLines — 호출측에서 수행)\n * 2. 교차점(Vertex) 생성 + 병합\n * 3. 교차하는 선들을 그룹화 (연결 컴포넌트)\n * 4. 각 그룹에서 Vertex의 X/Y 좌표를 동적 tolerance로 클러스터링\n * 5. 그리드 검증 (최소 열 폭, 최소 행 높이)\n */\nexport function buildTableGrids(\n horizontals: LineSegment[],\n verticals: LineSegment[],\n): TableGrid[] {\n if (horizontals.length < 2 || verticals.length < 2) return []\n\n // 1. 교차점 생성\n const allVertices = buildVertices(horizontals, verticals)\n const vertices = mergeVertices(allVertices)\n\n if (vertices.length < 4) return [] // 최소 4꼭짓점 필요 (사각형)\n\n // 전체 vertex의 대표 radius (동적 tolerance)\n const globalRadius = vertices.reduce((max, v) => Math.max(max, v.radius), 1)\n\n // 2. 선들을 교차 관계로 그룹화\n const allLines = [\n ...horizontals.map((l, i) => ({ ...l, type: \"h\" as const, id: i })),\n ...verticals.map((l, i) => ({ ...l, type: \"v\" as const, id: i + horizontals.length })),\n ]\n\n // 적층 표 분리 — 분리된 밴드는 공유 컷라인 위 vertex가 반대편 표의 수직선 x를\n // 나르므로(교차점이 경계선상에 생김) 전역 vertex 대신 밴드 자기 선으로 재계산한다\n const groups: Array<{ lines: TypedLine[]; fromSplit: boolean }> = []\n for (const g of groupConnectedLines(allLines)) {\n const bands = splitStackedGroup(g)\n for (const b of bands) groups.push({ lines: b, fromSplit: bands.length > 1 })\n }\n const grids: TableGrid[] = []\n\n for (const { lines: group, fromSplit } of groups) {\n const hLines = group.filter(l => l.type === \"h\")\n const vLines = group.filter(l => l.type === \"v\")\n\n if (hLines.length < 2 || vLines.length < 2) continue\n\n // 3. 이 그룹의 Vertex만 수집\n let gx1 = Infinity, gy1 = Infinity, gx2 = -Infinity, gy2 = -Infinity\n for (const l of vLines) { if (l.x1 < gx1) gx1 = l.x1; if (l.x1 > gx2) gx2 = l.x1 }\n for (const l of hLines) { if (l.y1 < gy1) gy1 = l.y1; if (l.y1 > gy2) gy2 = l.y1 }\n const groupBbox = {\n x1: gx1 - CONNECT_TOL,\n y1: gy1 - CONNECT_TOL,\n x2: gx2 + CONNECT_TOL,\n y2: gy2 + CONNECT_TOL,\n }\n\n const groupVertices = fromSplit\n ? mergeVertices(buildVertices(hLines, vLines))\n : vertices.filter(v =>\n v.x >= groupBbox.x1 && v.x <= groupBbox.x2 &&\n v.y >= groupBbox.y1 && v.y <= groupBbox.y2\n )\n\n // 그룹 vertex의 대표 radius\n const groupRadius = groupVertices.length > 0\n ? groupVertices.reduce((max, v) => Math.max(max, v.radius), 1)\n : globalRadius\n\n // 4. Vertex 기반 좌표 클러스터링 (동적 tolerance)\n const coordMergeTol = Math.max(VERTEX_MERGE_FACTOR * groupRadius, MIN_COORD_MERGE_TOL)\n\n // Y좌표: 수평선 y + Vertex y\n const rawYs = [\n ...hLines.map(l => l.y1),\n ...groupVertices.map(v => v.y),\n ]\n const rowYs = clusterCoordinates(rawYs, coordMergeTol).sort((a, b) => b - a)\n\n // X좌표: 수직선 x + Vertex x\n const rawXs = [\n ...vLines.map(l => l.x1),\n ...groupVertices.map(v => v.x),\n ]\n const colXs = clusterCoordinates(rawXs, coordMergeTol).sort((a, b) => a - b)\n\n if (rowYs.length < 2 || colXs.length < 2) continue\n\n // 5. 그리드 검증: 최소 열 폭, 최소 행 높이\n const validColXs = enforceMinWidth(colXs, MIN_COL_WIDTH)\n const validRowYs = enforceMinHeight(rowYs, MIN_ROW_HEIGHT)\n\n if (validRowYs.length < 2 || validColXs.length < 2) continue\n\n const bbox = {\n x1: validColXs[0], y1: validRowYs[validRowYs.length - 1],\n x2: validColXs[validColXs.length - 1], y2: validRowYs[0],\n }\n\n grids.push({ rowYs: validRowYs, colXs: validColXs, bbox, vertexRadius: groupRadius })\n }\n\n return mergeAdjacentGrids(grids)\n}\n\n/** 최소 열 폭 보장 — 너무 좁은 열은 인접 열과 병합 */\nfunction enforceMinWidth(colXs: number[], minWidth: number): number[] {\n if (colXs.length <= 2) return colXs\n const result: number[] = [colXs[0]]\n for (let i = 1; i < colXs.length; i++) {\n const prevX = result[result.length - 1]\n if (colXs[i] - prevX < minWidth && i < colXs.length - 1) {\n // 너무 좁으면 스킵 (다음 열과 병합)\n continue\n }\n result.push(colXs[i])\n }\n return result\n}\n\n/** 최소 행 높이 보장 — 너무 낮은 행은 인접 행과 병합 */\nfunction enforceMinHeight(rowYs: number[], minHeight: number): number[] {\n if (rowYs.length <= 2) return rowYs\n // rowYs는 내림차순 (위→아래)\n const result: number[] = [rowYs[0]]\n for (let i = 1; i < rowYs.length; i++) {\n const prevY = result[result.length - 1]\n if (prevY - rowYs[i] < minHeight && i < rowYs.length - 1) {\n continue\n }\n result.push(rowYs[i])\n }\n return result\n}\n\n/** 같은 열 구조를 가진 인접 그리드를 병합 */\nfunction mergeAdjacentGrids(grids: TableGrid[]): TableGrid[] {\n if (grids.length <= 1) return grids\n const sorted = [...grids].sort((a, b) => b.bbox.y2 - a.bbox.y2)\n const merged: TableGrid[] = [sorted[0]]\n\n for (let i = 1; i < sorted.length; i++) {\n const prev = merged[merged.length - 1]\n const curr = sorted[i]\n\n if (prev.colXs.length === curr.colXs.length) {\n const mergeTol = Math.max(VERTEX_MERGE_FACTOR * Math.max(prev.vertexRadius, curr.vertexRadius), 6) * 3\n const colMatch = prev.colXs.every((x, ci) => Math.abs(x - curr.colXs[ci]) <= mergeTol)\n const verticalGap = prev.bbox.y1 - curr.bbox.y2\n if (colMatch && verticalGap >= -CONNECT_TOL && verticalGap <= 20) {\n const allRowYs = [...new Set([...prev.rowYs, ...curr.rowYs])].sort((a, b) => b - a)\n merged[merged.length - 1] = {\n rowYs: allRowYs,\n colXs: prev.colXs,\n bbox: {\n x1: Math.min(prev.bbox.x1, curr.bbox.x1),\n y1: Math.min(prev.bbox.y1, curr.bbox.y1),\n x2: Math.max(prev.bbox.x2, curr.bbox.x2),\n y2: Math.max(prev.bbox.y2, curr.bbox.y2),\n },\n vertexRadius: Math.max(prev.vertexRadius, curr.vertexRadius),\n }\n continue\n }\n }\n merged.push(curr)\n }\n return merged\n}\n\n/** 좌표값 클러스터링 — 동적 tolerance 기반 (ODL의 vertex radius 반영) */\nfunction clusterCoordinates(values: number[], tolerance: number): number[] {\n if (values.length === 0) return []\n const sorted = [...values].sort((a, b) => a - b)\n const clusters: { sum: number; count: number }[] = [{ sum: sorted[0], count: 1 }]\n\n for (let i = 1; i < sorted.length; i++) {\n const last = clusters[clusters.length - 1]\n const avg = last.sum / last.count\n if (Math.abs(sorted[i] - avg) <= tolerance) {\n last.sum += sorted[i]\n last.count++\n } else {\n clusters.push({ sum: sorted[i], count: 1 })\n }\n }\n\n return clusters.map(c => c.sum / c.count)\n}\n\ntype TypedLine = LineSegment & { type: \"h\" | \"v\"; id: number }\n\n/**\n * 컷 관통 판정용 체인 뷰 — 같은 x의 콜리니어 수직 세그먼트(섹션별로 쪼개 그은 외곽선·\n * 구분선)를 논리 수직선 하나로 잇는다. 한 표의 세그먼트는 정확히 맞닿고(gap≈0) 별개 표\n * 사이에는 실간격이 있어 잇지 않는다. 판정 전용 — 물리 수직선은 수정하지 않는다\n * (물리 병합은 셀 배치 변질 실측으로 폐기 — line-extract chainCollinearRules 주석 참조).\n */\nfunction chainVerticals(vs: TypedLine[]): Array<{ y1: number; y2: number }> {\n if (vs.length <= 1) return vs.map(v => ({ y1: v.y1, y2: v.y2 }))\n const sorted = [...vs].sort((a, b) => a.x1 - b.x1 || a.y1 - b.y1)\n const rules: Array<{ y1: number; y2: number }> = []\n let bandStart = 0\n const flushBand = (end: number) => {\n const band = sorted.slice(bandStart, end).sort((a, b) => a.y1 - b.y1)\n let cur = { y1: band[0].y1, y2: band[0].y2 }\n for (let i = 1; i < band.length; i++) {\n const seg = band[i]\n if (seg.y1 - cur.y2 <= CUT_VCHAIN_GAP) {\n if (seg.y2 > cur.y2) cur.y2 = seg.y2\n } else {\n rules.push(cur)\n cur = { y1: seg.y1, y2: seg.y2 }\n }\n }\n rules.push(cur)\n }\n for (let i = 1; i <= sorted.length; i++) {\n if (i === sorted.length || sorted[i].x1 - sorted[bandStart].x1 > CUT_VCHAIN_X_TOL) {\n flushBand(i)\n bandStart = i\n }\n }\n return rules\n}\n\n/**\n * 적층 표 분리 — 위아래로 붙은 별개 표 두 개가 경계 수평선 하나를 공유해 Union-Find가\n * 한 그룹으로 묶은 경우(채용공고 머리 스트립+응시원서 본표 실측)를 세로 밴드로 분리.\n *\n * 컷 라인 판정: 그룹 전폭급 수평선 중\n * (a) 이를 관통하는 논리 수직선(체인 뷰)이 하나도 없고 — 별개 표는 외곽 수직선이 각자\n * 영역에서 끊기지만, 한 표 내부의 전폭 구조변화 경계(섹션 헤더 행 등)는 외곽\n * 수직선이 연속(또는 맞닿은 세그먼트 체인)으로 관통한다\n * (b) 양쪽에 독립 표를 이룰 수직선이 2+개씩 있으며\n * (c) 내부 수직선 x-집합이 절반 넘게 겹치지 않는 경우 — 같은 열 구조의 연속이면 한 표\n * 컷 라인 자체는 양쪽 밴드에 복제된다 (위 표의 하변이자 아래 표의 상변).\n */\nfunction splitStackedGroup(group: TypedLine[]): TypedLine[][] {\n const hs = group.filter(l => l.type === \"h\")\n const vs = group.filter(l => l.type === \"v\")\n if (hs.length < 3 || vs.length < 4) return [group]\n\n let gx1 = Infinity, gx2 = -Infinity\n for (const l of group) {\n if (l.x1 < gx1) gx1 = l.x1\n if (l.x2 > gx2) gx2 = l.x2\n }\n const groupW = gx2 - gx1\n if (groupW <= 0) return [group]\n\n const isInterior = (v: TypedLine) => v.x1 > gx1 + CUT_EDGE_MARGIN && v.x1 < gx2 - CUT_EDGE_MARGIN\n const chained = chainVerticals(vs)\n const cuts: number[] = []\n for (const h of hs) {\n const y = h.y1\n if (h.x2 - h.x1 < groupW * CUT_FULLWIDTH_RATIO) continue\n if (cuts.some(c => Math.abs(c - y) <= CUT_CROSS_EPS)) continue\n // (a) 관통 논리 수직선 존재 → 같은 표 내부 경계\n if (chained.some(v => v.y1 < y - CUT_CROSS_EPS && v.y2 > y + CUT_CROSS_EPS)) continue\n // (b) 양쪽 수직선 수 — 컷이 성립하려면 위아래 각각 표 꼴이어야 함\n const above = vs.filter(v => v.y1 >= y - CUT_CROSS_EPS)\n const below = vs.filter(v => v.y2 <= y + CUT_CROSS_EPS)\n if (above.length < CUT_MIN_SIDE_VERTICALS || below.length < CUT_MIN_SIDE_VERTICALS) continue\n // (c) 내부 수직선 x-집합 겹침\n const ia = above.filter(isInterior)\n const ib = below.filter(isInterior)\n if (ia.length === 0 || ib.length === 0) continue\n let matched = 0\n for (const a of ia) if (ib.some(b => Math.abs(a.x1 - b.x1) <= CUT_INTERIOR_MATCH_TOL)) matched++\n if (matched / Math.min(ia.length, ib.length) > CUT_MAX_INTERIOR_OVERLAP) continue\n cuts.push(y)\n }\n if (cuts.length === 0) return [group]\n\n cuts.sort((a, b) => b - a) // 위→아래\n // y 위치가 속한 밴드: 컷 c보다 위(y > c)면 그 컷 앞 밴드\n const bandOf = (y: number) => {\n let k = 0\n while (k < cuts.length && y < cuts[k]) k++\n return k\n }\n const bands: TypedLine[][] = Array.from({ length: cuts.length + 1 }, () => [])\n for (const v of vs) bands[bandOf((v.y1 + v.y2) / 2)].push(v) // (a)에 의해 컷을 안 걸침\n for (const h of hs) {\n const atCut = cuts.findIndex(c => Math.abs(h.y1 - c) <= CUT_CROSS_EPS)\n if (atCut >= 0) {\n // 공유 경계선 — 양쪽 밴드에 복제\n bands[atCut].push(h)\n bands[atCut + 1].push(h)\n } else {\n bands[bandOf(h.y1)].push(h)\n }\n }\n return bands.filter(b => b.length > 0)\n}\n\n/** 선 bbox 버킷 그리드 셀 크기 (pt) — 근접 후보 열거용 */\nconst GROUP_BUCKET_CELL = 100\n\n/**\n * 교차하는 선들을 Union-Find로 그룹화.\n * O(L²) 회피: 선 bbox(±CONNECT_TOL)를 좌표 버킷 그리드에 등록하고 같은 셀을\n * 공유하는 쌍만 교차 판정. 교차 가능한 쌍(bbox 겹침)은 반드시 셀을 공유하므로\n * 연결 컴포넌트가 동일하고, 그룹 출력 순서(컴포넌트 최소 선 인덱스 순·그룹 내\n * 인덱스 순)는 union 순서와 무관해 기존과 결과가 같다.\n */\nfunction groupConnectedLines(lines: TypedLine[]): TypedLine[][] {\n const parent = lines.map((_, i) => i)\n\n function find(x: number): number {\n while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x] }\n return x\n }\n function union(a: number, b: number) {\n const ra = find(a), rb = find(b)\n if (ra !== rb) parent[ra] = rb\n }\n\n const cellMap = new Map<string, number[]>()\n for (let i = 0; i < lines.length; i++) {\n const l = lines[i]\n const cx1 = Math.floor((Math.min(l.x1, l.x2) - CONNECT_TOL) / GROUP_BUCKET_CELL)\n const cx2 = Math.floor((Math.max(l.x1, l.x2) + CONNECT_TOL) / GROUP_BUCKET_CELL)\n const cy1 = Math.floor((Math.min(l.y1, l.y2) - CONNECT_TOL) / GROUP_BUCKET_CELL)\n const cy2 = Math.floor((Math.max(l.y1, l.y2) + CONNECT_TOL) / GROUP_BUCKET_CELL)\n for (let cx = cx1; cx <= cx2; cx++) {\n for (let cy = cy1; cy <= cy2; cy++) {\n const key = cx + \",\" + cy\n const arr = cellMap.get(key)\n if (arr) arr.push(i)\n else cellMap.set(key, [i])\n }\n }\n }\n\n const tested = new Set<number>()\n for (const arr of cellMap.values()) {\n for (let a = 0; a < arr.length; a++) {\n for (let b = a + 1; b < arr.length; b++) {\n const i = Math.min(arr[a], arr[b])\n const j = Math.max(arr[a], arr[b])\n const key = i * lines.length + j\n if (tested.has(key)) continue\n tested.add(key)\n if (linesIntersect(lines[i], lines[j])) {\n union(i, j)\n }\n }\n }\n }\n\n const groups = new Map<number, TypedLine[]>()\n for (let i = 0; i < lines.length; i++) {\n const root = find(i)\n if (!groups.has(root)) groups.set(root, [])\n groups.get(root)!.push(lines[i])\n }\n\n return [...groups.values()]\n}\n\n/** 수평선과 수직선의 교차 판정 (tolerance 포함) */\nfunction linesIntersect(a: TypedLine, b: TypedLine): boolean {\n if (a.type === b.type) {\n if (a.type === \"h\") {\n if (Math.abs(a.y1 - b.y1) > CONNECT_TOL) return false\n return Math.min(a.x2, b.x2) >= Math.max(a.x1, b.x1) - CONNECT_TOL\n } else {\n if (Math.abs(a.x1 - b.x1) > CONNECT_TOL) return false\n return Math.min(a.y2, b.y2) >= Math.max(a.y1, b.y1) - CONNECT_TOL\n }\n }\n\n const h = a.type === \"h\" ? a : b\n const v = a.type === \"h\" ? b : a\n const tol = CONNECT_TOL\n\n return (\n v.x1 >= h.x1 - tol && v.x1 <= h.x2 + tol &&\n h.y1 >= v.y1 - tol && h.y1 <= v.y2 + tol\n )\n}\n\n","/**\n * 테이블 그리드에서 병합 셀 구조 추출 (line-detector.ts에서 분리).\n *\n * OpenDataLoader PDF의 createMatrix() 알고리즘을 참고하여 TypeScript로\n * clean-room 재구현한 것입니다.\n * Original algorithm: Copyright 2025-2026 Hancom, Inc. (Apache 2.0)\n * https://github.com/opendataloader-project/opendataloader-pdf\n */\n\nimport type { LineSegment, TableGrid, ExtractedCell } from \"./line-types.js\"\nimport { VERTEX_MERGE_FACTOR } from \"./line-types.js\"\n\n// ─── 셀 구조 추출 (Vertex 기반 정밀 병합 셀 감지) ─────\n\n/**\n * 테이블 그리드에서 셀 목록을 추출.\n * ODL의 createMatrix() 알고리즘:\n * - 수직선 존재 여부로 colSpan 감지 (75% 커버 기준)\n * - 수평선 존재 여부로 rowSpan 감지 (75% 커버 기준)\n * - 우하단→좌상단 propagation으로 병합 셀 정리\n * - 중복 행/열 제거\n */\nexport function extractCells(\n grid: TableGrid,\n horizontals: LineSegment[],\n verticals: LineSegment[],\n): ExtractedCell[] {\n const { rowYs, colXs } = grid\n const numRows = rowYs.length - 1\n const numCols = colXs.length - 1\n if (numRows <= 0 || numCols <= 0) return []\n\n // 경계선 존재 여부를 행렬로 사전 계산\n // vBorders[r][c] = colXs[c]에 row r 구간의 수직선이 있는지\n const vBorders: boolean[][] = Array.from({ length: numRows },\n (_, r) => Array.from({ length: numCols + 1 },\n (_, c) => hasVerticalLine(verticals, colXs[c], rowYs[r], rowYs[r + 1], grid.vertexRadius)))\n\n // hBorders[r][c] = rowYs[r]에 col c 구간의 수평선이 있는지\n const hBorders: boolean[][] = Array.from({ length: numRows + 1 },\n (_, r) => Array.from({ length: numCols },\n (_, c) => hasHorizontalLine(horizontals, rowYs[r], colXs[c], colXs[c + 1], grid.vertexRadius)))\n\n // 셀이 이미 병합된 셀에 포함되는지 추적\n const occupied = Array.from({ length: numRows }, () => Array(numCols).fill(false))\n const cells: ExtractedCell[] = []\n\n for (let r = 0; r < numRows; r++) {\n for (let c = 0; c < numCols; c++) {\n if (occupied[r][c]) continue\n\n let colSpan = 1\n let rowSpan = 1\n\n // colSpan: 오른쪽 내부 경계에 수직선이 없으면 병합\n while (c + colSpan < numCols && !vBorders[r][c + colSpan]) {\n // 추가 검증: 확장하려는 영역의 모든 행에서 수직선이 없어야 함\n let canExpand = true\n for (let dr = 0; dr < rowSpan; dr++) {\n if (vBorders[r + dr][c + colSpan]) { canExpand = false; break }\n }\n if (!canExpand) break\n colSpan++\n }\n\n // rowSpan: 아래쪽 내부 경계에 수평선이 없으면 병합\n while (r + rowSpan < numRows) {\n let hasLine = false\n for (let dc = 0; dc < colSpan; dc++) {\n if (hBorders[r + rowSpan][c + dc]) { hasLine = true; break }\n }\n if (hasLine) break\n rowSpan++\n }\n\n // 병합 영역 마킹\n for (let dr = 0; dr < rowSpan; dr++) {\n for (let dc = 0; dc < colSpan; dc++) {\n occupied[r + dr][c + dc] = true\n }\n }\n\n cells.push({\n row: r, col: c, rowSpan, colSpan,\n bbox: {\n x1: colXs[c], y1: rowYs[r + rowSpan],\n x2: colXs[c + colSpan], y2: rowYs[r],\n },\n })\n }\n }\n\n return cells\n}\n\n/**\n * 특정 X 위치에 수직선이 Y 범위를 커버하는지 확인.\n * v2: 75% 커버 기준 + 동적 tolerance (vertex radius 기반)\n */\nfunction hasVerticalLine(\n verticals: LineSegment[], x: number, topY: number, botY: number, vertexRadius: number,\n): boolean {\n const tol = Math.max(VERTEX_MERGE_FACTOR * vertexRadius, 4)\n for (const v of verticals) {\n if (Math.abs(v.x1 - x) <= tol) {\n const cellH = Math.abs(topY - botY)\n if (cellH < 0.1) continue\n const overlapTop = Math.min(v.y2, topY)\n const overlapBot = Math.max(v.y1, botY)\n const overlap = overlapTop - overlapBot\n // 75% 커버 기준 (기존 50% → 병합 셀 내부 단선 오탐 방지)\n if (overlap >= cellH * 0.75) return true\n }\n }\n return false\n}\n\n/**\n * 특정 Y 위치에 수평선이 X 범위를 커버하는지 확인.\n * v2: 75% 커버 기준 + 동적 tolerance\n */\nfunction hasHorizontalLine(\n horizontals: LineSegment[], y: number, leftX: number, rightX: number, vertexRadius: number,\n): boolean {\n const tol = Math.max(VERTEX_MERGE_FACTOR * vertexRadius, 4)\n for (const h of horizontals) {\n if (Math.abs(h.y1 - y) <= tol) {\n const cellW = Math.abs(rightX - leftX)\n if (cellW < 0.1) continue\n const overlapLeft = Math.max(h.x1, leftX)\n const overlapRight = Math.min(h.x2, rightX)\n const overlap = overlapRight - overlapLeft\n if (overlap >= cellW * 0.75) return true\n }\n }\n return false\n}\n\n","/**\n * 셀 텍스트 매핑/조립 (line-detector.ts에서 분리).\n * 텍스트 아이템 → 셀 매핑(교차 비율), 셀 텍스트 문자열 조립(균등배분·탭 갭),\n * 셀 내 줄바꿈 병합.\n *\n * getIntersectionPercent 방식은 OpenDataLoader PDF 참고 clean-room 재구현.\n * Original algorithm: Copyright 2025-2026 Hancom, Inc. (Apache 2.0)\n * https://github.com/opendataloader-project/opendataloader-pdf\n */\n\nimport type { ExtractedCell, TextItem } from \"./line-types.js\"\n\n/** 셀 경계 내부 판별 여유 (텍스트 매핑용) */\nconst CELL_PADDING = 2\n\n/**\n * 공백 삽입 갭 임계값 — 폰트 크기 비례.\n * 절대 px(3px) 기준은 Type3 폰트(예: fontSize 10.5에서 단어 갭 2.7px)에서 공백이\n * 소실되고, 작은 폰트에서는 과다 삽입됨. fontSize×0.17 비례 기준으로 교체\n * (veraPDF wcag-algs TEXT_LINE_SPACE_RATIO 아이디어의 클린룸 재구현 — 코드 비복사).\n */\nexport const SPACE_GAP_RATIO = 0.17\n\nexport function spaceGapThreshold(fontSize: number): number {\n return Math.max(fontSize * SPACE_GAP_RATIO, 1)\n}\n\n/**\n * 텍스트 아이템을 셀에 매핑.\n * v2: ODL의 getIntersectionPercent 방식 — 텍스트 bbox와 셀 bbox의 교차 비율로 판별.\n * 중심점만 보는 기존 방식보다 정확 (긴 텍스트가 셀 경계를 걸치는 경우 처리).\n */\nexport function mapTextToCells(\n items: TextItem[],\n cells: ExtractedCell[],\n): Map<ExtractedCell, TextItem[]> {\n const result = new Map<ExtractedCell, TextItem[]>()\n for (const cell of cells) {\n result.set(cell, [])\n }\n\n for (const item of items) {\n const pad = CELL_PADDING\n\n let bestCell: ExtractedCell | null = null\n let bestScore = 0\n\n for (const cell of cells) {\n // 텍스트 bbox와 셀 bbox의 교차 영역 계산\n const ix1 = Math.max(item.x, cell.bbox.x1 - pad)\n const ix2 = Math.min(item.x + item.w, cell.bbox.x2 + pad)\n const iy1 = Math.max(item.y, cell.bbox.y1 - pad)\n const iy2 = Math.min(item.y + (item.h || item.fontSize), cell.bbox.y2 + pad)\n\n if (ix1 >= ix2 || iy1 >= iy2) continue\n\n const intersectArea = (ix2 - ix1) * (iy2 - iy1)\n const itemArea = Math.max(item.w, 1) * Math.max(item.h || item.fontSize, 1)\n const score = intersectArea / itemArea // ODL의 MIN_CELL_CONTENT_INTERSECTION_PERCENT\n\n if (score > bestScore) {\n bestScore = score\n bestCell = cell\n }\n }\n\n // 교차 비율 > 0.3이면 셀에 할당 (ODL은 0.6이지만 PDF 텍스트 좌표 오차 고려)\n if (bestCell && bestScore > 0.3) {\n result.get(bestCell)!.push(item)\n }\n }\n\n return result\n}\n\n/**\n * 셀 내 텍스트 아이템을 읽기 순서로 정렬 후 합치기.\n * Y 내림차순 (위→아래) → X 오름차순 (좌→우)\n */\nexport function cellTextToString(items: TextItem[]): string {\n if (items.length === 0) return \"\"\n if (items.length === 1) return items[0].text\n\n // Y좌표로 행 그룹핑 (tolerance: max(3, fontSize*0.6))\n const sorted = [...items].sort((a, b) => b.y - a.y || a.x - b.x)\n const lines: TextItem[][] = []\n let curLine: TextItem[] = [sorted[0]]\n let curY = sorted[0].y\n\n for (let i = 1; i < sorted.length; i++) {\n const tol = Math.max(3, Math.min(sorted[i].fontSize, curLine[0].fontSize) * 0.6)\n if (Math.abs(sorted[i].y - curY) <= tol) {\n curLine.push(sorted[i])\n } else {\n lines.push(curLine)\n curLine = [sorted[i]]\n curY = sorted[i].y\n }\n }\n lines.push(curLine)\n\n // 첨자 행 흡수 — 본문 행보다 살짝 떠 있는 작은 심볼 조각(▲·원문자·각주 마커)이\n // tolerance를 벗어나 별도 행이 된 것을 수직 겹침으로 되돌린다\n // (text-line.ts mergeSuperscriptLines와 동일 규칙: 조각 ≤3개·각 ≤8자·\n // 높이가 인접 행의 80% 이하·수직 겹침 ≥ 조각 높이 50%)\n const merged = mergeSuperscriptRows(lines)\n\n // 각 행을 텍스트로 변환 — 좌표 기반 균등배분 감지 포함\n const textLines = merged.map(line => {\n const s = line.sort((a, b) => a.x - b.x)\n if (s.length === 1) return s[0].text\n\n // 균등배분 구간 감지 (좌표 기반)\n const evenSpaced = detectEvenSpacedItems(s)\n\n let result = s[0].text\n for (let j = 1; j < s.length; j++) {\n // 균등배분 구간이면 무조건 공백 없이 합침\n if (evenSpaced[j]) {\n result += s[j].text\n continue\n }\n\n const gap = s[j].x - (s[j - 1].x + s[j - 1].w)\n const avgFs = (s[j].fontSize + s[j - 1].fontSize) / 2\n // pdfjs 공백 아이템 힌트 — 단어 경계 확정 (Type3 폰트 글자 분리 셀 텍스트 복원)\n if (s[j].hasSpaceBefore && gap >= avgFs * 0.05) {\n result += \" \" + s[j].text\n } else if (gap > spaceGapThreshold(avgFs)) {\n result += \" \" + s[j].text\n } else {\n result += s[j].text\n }\n }\n return result\n })\n\n return mergeCellTextLines(textLines)\n}\n\n/** 첨자 행 병합 — cellTextToString 행 그룹핑 결과에 적용 (규칙은 text-line.ts와 동일) */\nfunction mergeSuperscriptRows(lines: TextItem[][]): TextItem[][] {\n if (lines.length <= 1) return lines\n const band = (line: TextItem[]) => {\n let bottom = Infinity, top = -Infinity\n for (const i of line) {\n const h = i.h > 0 ? i.h : i.fontSize\n if (i.y < bottom) bottom = i.y\n if (i.y + h > top) top = i.y + h\n }\n return { bottom, top, height: top - bottom }\n }\n const isFrag = (line: TextItem[]) => {\n if (line.length > 8) return false\n let total = 0\n for (const i of line) total += i.text.trim().length\n return total > 0 && total <= 10\n }\n\n const result: TextItem[][] = [lines[0]]\n for (let i = 1; i < lines.length; i++) {\n const prev = result[result.length - 1]\n const curr = lines[i]\n const a = band(prev)\n const b = band(curr)\n const overlap = Math.min(a.top, b.top) - Math.max(a.bottom, b.bottom)\n const prevIsFrag = isFrag(prev) && a.height <= b.height * 0.8 && overlap >= a.height * 0.5\n const currIsFrag = isFrag(curr) && b.height <= a.height * 0.8 && overlap >= b.height * 0.5\n if (prevIsFrag || currIsFrag) {\n result[result.length - 1] = [...prev, ...curr]\n } else {\n result.push(curr)\n }\n }\n return result\n}\n\n/**\n * 좌표 기반 균등배분 감지 — TextItem 배열에서 한글 1~2자 아이템이\n * 일정 간격으로 3개+ 연속되면 균등배분으로 판단.\n * ODL TextLineProcessor의 핵심 로직을 좌표 기반으로 구현.\n */\nfunction detectEvenSpacedItems(items: TextItem[]): boolean[] {\n const result = new Array(items.length).fill(false)\n if (items.length < 3) return result\n\n let runStart = -1\n for (let i = 0; i < items.length; i++) {\n // 균등배분 = 한글 1자 개별 배치. 2자 단어는 균등배분이 아니라 실제 단어.\n const isShortKorean = /^[가-힣]{1}$/.test(items[i].text) || /^[\\d]{1}$/.test(items[i].text)\n\n // 명시적 공백 글리프가 직전에 있으면 단어 경계 — 균등배분 run 분리.\n // (Type3 폰트가 글자를 1자씩 배치하면서 공백 글리프를 따로 두는 경우,\n // 진짜 단어 경계를 균등배분으로 오판해 문장 전체가 붙는 것을 방지)\n if (isShortKorean && runStart >= 0 && items[i].hasSpaceBefore) {\n if (i - runStart >= 3) markEvenRun(items, result, runStart, i)\n runStart = i\n continue\n }\n\n // 이전 아이템과의 갭이 fontSize*3+ 이면 run 끊기 (다른 영역)\n if (isShortKorean && runStart >= 0 && i > 0) {\n const gap = items[i].x - (items[i - 1].x + items[i - 1].w)\n const maxRunGap = Math.max(items[i].fontSize * 3, 30)\n if (gap > maxRunGap) {\n if (i - runStart >= 3) markEvenRun(items, result, runStart, i)\n runStart = i\n continue\n }\n }\n\n if (isShortKorean) {\n if (runStart < 0) runStart = i\n } else {\n if (runStart >= 0 && i - runStart >= 3) {\n markEvenRun(items, result, runStart, i)\n }\n runStart = -1\n }\n }\n if (runStart >= 0 && items.length - runStart >= 3) {\n markEvenRun(items, result, runStart, items.length)\n }\n\n return result\n}\n\nfunction markEvenRun(items: TextItem[], result: boolean[], start: number, end: number): void {\n const gaps: number[] = []\n for (let i = start + 1; i < end; i++) {\n gaps.push(items[i].x - (items[i - 1].x + items[i - 1].w))\n }\n const posGaps = gaps.filter(g => g > 0)\n if (posGaps.length < 2) return\n\n let minGap = Infinity, maxGap = -Infinity\n for (const g of posGaps) { if (g < minGap) minGap = g; if (g > maxGap) maxGap = g }\n const avgFs = items[start].fontSize\n\n // 간격이 fontSize의 0.1~3배 사이이고, 최대/최소 비율 3배 이내\n if (minGap >= avgFs * 0.1 && maxGap <= avgFs * 3 && maxGap / Math.max(minGap, 0.1) <= 3) {\n for (let i = start + 1; i < end; i++) {\n result[i] = true\n }\n }\n}\n\nexport { detectEvenSpacedItems }\n\n/**\n * 셀 내 텍스트 아이템을 읽기 순서로 정렬 후 합치기 — 줄바꿈 병합 전용.\n * (cellTextToString 내부에서 사용)\n */\nfunction mergeCellTextLines(textLines: string[]): string {\n // 셀 내 줄바꿈 병합 — 잘린 단어/숫자 조각 복구\n if (textLines.length <= 1) return textLines[0] || \"\"\n const merged: string[] = [textLines[0]]\n for (let i = 1; i < textLines.length; i++) {\n const prev = merged[merged.length - 1]\n const curr = textLines[i]\n if (/[가-힣]$/.test(prev) && /^[가-힣]+$/.test(curr) && curr.length <= 8 && !curr.includes(\" \")) {\n merged[merged.length - 1] = prev + curr\n }\n else if (curr.trim().length <= 3 && /^[)\\]%}]/.test(curr.trim())) {\n merged[merged.length - 1] = prev + curr.trim()\n }\n else if (/[,(]$/.test(prev.trim()) && curr.trim().length <= 15) {\n merged[merged.length - 1] = prev + curr.trim()\n }\n else if (/[\\d,]$/.test(prev) && /^[\\d,]+[)\\]]?$/.test(curr.trim()) && curr.trim().length <= 10) {\n merged[merged.length - 1] = prev + curr.trim()\n }\n else {\n merged.push(curr)\n }\n }\n return merged.join(\"\\n\")\n}\n","/**\n * 과소분할 표 재구성 (line-detector.ts에서 분리).\n *\n * OpenDataLoader PDF의 TableStructureNormalizer를 참고하여 TypeScript로\n * clean-room 재구현한 것입니다.\n * Original algorithm: Copyright 2025-2026 Hancom, Inc. (Apache 2.0)\n * https://github.com/opendataloader-project/opendataloader-pdf\n */\n\nimport type { TextItem } from \"./line-types.js\"\nimport { cellTextToString } from \"./cell-text.js\"\n\n// ─── 과소분할 표 재구성 (ODL TableStructureNormalizer 포팅) ──\n//\n// 행 구분선이 생략된 표(헤더 아래만 선이 있는 한국 공문서 표)는 본문 전체가\n// 1~2행으로 합쳐진다. 셀 안에 텍스트 줄이 8개+ 뭉친 경우 줄의 centerY로\n// row band를 재유도해 행을 복원한다. 품질이 개선될 때만 교체.\n//\n// Original work: Copyright 2025-2026 Hancom Inc. (Apache-2.0)\n// https://github.com/opendataloader-project/opendataloader-pdf\n\nconst MAX_UNDERSEGMENTED_ROWS = 2\nconst MIN_UNDERSEGMENTED_COLUMNS = 3\nconst MIN_UNDERSEGMENTED_TEXT_LINES = 8\nconst MIN_ROW_BAND_MISMATCH = 2\nconst MIN_ROW_BAND_EPSILON = 3.0\nconst ROW_BAND_EPSILON_RATIO = 0.6\n\ninterface RowBand {\n centerY: number\n avgHeight: number\n topY: number\n bottomY: number\n lineCount: number\n /** 컬럼별 아이템 */\n itemsByCol: TextItem[][]\n}\n\n/** 아이템 중심 Y (h가 0이면 fontSize 대용) */\nfunction itemCenterY(item: TextItem): number {\n return item.y + (item.h > 0 ? item.h : item.fontSize) / 2\n}\n\nfunction itemHeight(item: TextItem): number {\n return item.h > 0 ? item.h : item.fontSize\n}\n\n/** 아이템을 colXs 경계 기준 컬럼에 배정 (중심 X 기준, 범위 밖이면 최근접) */\nfunction findColumnIndex(item: TextItem, colXs: number[]): number {\n const cx = item.x + item.w / 2\n for (let c = 0; c < colXs.length - 1; c++) {\n if (cx >= colXs[c] && cx <= colXs[c + 1]) return c\n }\n let best = 0\n let bestDist = Infinity\n for (let c = 0; c < colXs.length - 1; c++) {\n const center = (colXs[c] + colXs[c + 1]) / 2\n const d = Math.abs(cx - center)\n if (d < bestDist) { bestDist = d; best = c }\n }\n return best\n}\n\n/** 아이템들을 Y 기준 시각적 줄로 그룹핑 */\nfunction groupItemsToVisualLines(items: TextItem[]): TextItem[][] {\n if (items.length === 0) return []\n const sorted = [...items].sort((a, b) => b.y - a.y || a.x - b.x)\n const lines: TextItem[][] = []\n let cur: TextItem[] = [sorted[0]]\n let curY = sorted[0].y\n for (let i = 1; i < sorted.length; i++) {\n const tol = Math.max(3, Math.min(sorted[i].fontSize, cur[0].fontSize) * 0.6)\n if (Math.abs(sorted[i].y - curY) <= tol) {\n cur.push(sorted[i])\n } else {\n lines.push(cur)\n cur = [sorted[i]]\n curY = sorted[i].y\n }\n }\n lines.push(cur)\n return lines\n}\n\n/**\n * 과소분할 표 재구성. 조건(행≤2 + 열≥3 + dense 컬럼 2개+)을 만족하고\n * row band 재유도가 품질을 개선할 때만 새 셀 행렬을 반환, 아니면 null.\n *\n * @param originalCells 기존 셀 행렬 (품질 비교용)\n * @param colXs 그리드 열 경계\n * @param items 표 영역 내 텍스트 아이템\n */\nexport function normalizeUndersegmentedTable(\n originalCells: { text: string }[][],\n colXs: number[],\n items: TextItem[],\n): string[][] | null {\n const numRows = originalCells.length\n const numCols = colXs.length - 1\n if (numRows > MAX_UNDERSEGMENTED_ROWS || numCols < MIN_UNDERSEGMENTED_COLUMNS) return null\n if (items.length === 0) return null\n\n // 1) 컬럼별 의미있는 줄 수 — dense 컬럼(8줄+) 2개 이상이어야 과소분할로 판정\n const itemsByCol: TextItem[][] = Array.from({ length: numCols }, () => [])\n for (const item of items) {\n if (!item.text.trim()) continue\n itemsByCol[findColumnIndex(item, colXs)].push(item)\n }\n let denseColumns = 0\n for (const colItems of itemsByCol) {\n if (groupItemsToVisualLines(colItems).length >= MIN_UNDERSEGMENTED_TEXT_LINES) denseColumns++\n }\n if (denseColumns < 2) return null\n\n // 2) 전체 줄에서 row band 유도 — centerY 근접(epsilon) 또는 수직 겹침이면 같은 band\n const allLines = groupItemsToVisualLines(items.filter(i => i.text.trim()))\n const bands: RowBand[] = []\n for (const line of allLines) {\n let cy = 0, h = 0\n for (const it of line) { cy += itemCenterY(it); h += itemHeight(it) }\n cy /= line.length\n h /= line.length\n const top = cy + h / 2\n const bottom = cy - h / 2\n\n let matched: RowBand | null = null\n for (const band of bands) {\n const epsilon = Math.max(MIN_ROW_BAND_EPSILON, Math.min(band.avgHeight, h) * ROW_BAND_EPSILON_RATIO)\n if (Math.abs(band.centerY - cy) <= epsilon ||\n (bottom <= band.topY && top >= band.bottomY)) {\n matched = band\n break\n }\n }\n if (!matched) {\n matched = { centerY: 0, avgHeight: 0, topY: -Infinity, bottomY: Infinity, lineCount: 0, itemsByCol: Array.from({ length: numCols }, () => []) }\n bands.push(matched)\n }\n matched.centerY = (matched.centerY * matched.lineCount + cy) / (matched.lineCount + 1)\n matched.avgHeight = (matched.avgHeight * matched.lineCount + h) / (matched.lineCount + 1)\n matched.topY = Math.max(matched.topY, top)\n matched.bottomY = Math.min(matched.bottomY, bottom)\n matched.lineCount++\n for (const it of line) {\n matched.itemsByCol[findColumnIndex(it, colXs)].push(it)\n }\n }\n\n // 3) band 수가 기존 행 수 + 2 이상이어야 재구축 의미 있음\n if (bands.length < numRows + MIN_ROW_BAND_MISMATCH) return null\n\n bands.sort((a, b) => b.centerY - a.centerY)\n\n // 4) 셀 행렬 재구축\n const rebuilt: string[][] = bands.map(band =>\n band.itemsByCol.map(colItems => colItems.length > 0 ? cellTextToString(colItems) : \"\"),\n )\n\n // 5) 품질 검증: 비어있지 않은 행 수가 늘고, 비어있지 않은 열 수가 줄지 않아야 교체\n const countNonEmptyRows = (cells: { text: string }[][] | string[][]) =>\n cells.filter(row => row.some(c => (typeof c === \"string\" ? c : c.text).trim() !== \"\")).length\n const countNonEmptyCols = (cells: { text: string }[][] | string[][], cols: number) => {\n let n = 0\n for (let c = 0; c < cols; c++) {\n if (cells.some(row => row[c] != null && (typeof row[c] === \"string\" ? row[c] as string : (row[c] as { text: string }).text).trim() !== \"\")) n++\n }\n return n\n }\n\n if (countNonEmptyRows(rebuilt) <= countNonEmptyRows(originalCells)) return null\n if (countNonEmptyCols(rebuilt, numCols) < countNonEmptyCols(originalCells, numCols)) return null\n\n return rebuilt\n}\n","/**\n * PDF 텍스트 아이템/줄 수준 유틸.\n *\n * pdfjs TextItem → NormItem 정규화(가짜 볼드 dedupe, 균등배분 분해, 공백 힌트 전파),\n * 줄 그룹핑(groupByY, 첨자 병합), 줄 텍스트 조립(mergeLineSimple),\n * 균등배분 공백 제거(collapseEvenSpacing), bbox/스타일 계산.\n */\n\nimport type { BoundingBox } from \"../types.js\"\nimport { detectEvenSpacedItems, spaceGapThreshold } from \"./line-detector.js\"\n\nexport interface PdfTextItem {\n str: string\n transform: number[]\n width: number\n height: number\n fontName?: string\n}\n\nexport interface NormItem {\n text: string\n x: number\n y: number\n w: number\n h: number\n /** 폰트 높이(≈폰트 크기) — 헤딩 감지용 */\n fontSize: number\n fontName: string\n /** hidden text 여부 (투명/0pt) */\n isHidden: boolean\n /** pdfjs 공백 아이템이 이 아이템 직전에 있었음 — 단어 경계 힌트 */\n hasSpaceBefore?: boolean\n /** 취소선이 그어진 텍스트 (신구조문대비표 삭제 표시 등) */\n strike?: boolean\n}\n\n// ═══════════════════════════════════════════════════════\n// Hidden text 필터링 (prompt injection 방어)\n// ═══════════════════════════════════════════════════════\n\nexport function filterHiddenText(items: NormItem[], pageWidth: number, pageHeight: number): { visible: NormItem[]; hiddenCount: number } {\n let hiddenCount = 0\n const visible: NormItem[] = []\n\n for (const item of items) {\n // 0pt 폰트 / 너비 0 → 숨겨진 텍스트\n if (item.isHidden) { hiddenCount++; continue }\n // 페이지 범위 밖 (여백 10% 허용)\n const margin = Math.max(pageWidth, pageHeight) * 0.1\n if (item.x < -margin || item.x > pageWidth + margin || item.y < -margin || item.y > pageHeight + margin) {\n hiddenCount++; continue\n }\n visible.push(item)\n }\n\n return { visible, hiddenCount }\n}\n\n/**\n * 문자열 기반 균등배분 제거.\n * normalizeItems에서 분해 + 좌표 기반 감지가 주 경로이고, 여기는 안전망.\n * pdfjs가 이미 합친 \"홍 보 담 당 관\" 같은 TextItem 문자열에 적용.\n */\nexport function collapseEvenSpacing(text: string): string {\n // 1. 전체가 균등배분: 토큰의 70%가 1글자\n const tokens = text.split(\" \")\n const singleCharCount = tokens.filter(t => t.length === 1).length\n if (tokens.length >= 3 && singleCharCount / tokens.length >= 0.7) {\n return tokens.join(\"\")\n }\n\n // 2. 부분 균등배분: 한글 1자가 3개+ 연속 (2자 단어는 건드리지 않음)\n // \"홍 보 담 당 관\" → \"홍보담당관\", \"지 역 경 제 과\" → \"지역경제과\"\n // \"중동 사태 대응\" (2자 단어)는 매칭 안 됨 → 공백 유지\n return text.replace(\n /(?<![가-힣])[가-힣](?: [가-힣\\d]){2,}(?![가-힣])/g,\n match => match.replace(/ /g, \"\"),\n )\n}\n\n/** 아이템 그룹에서 바운딩 박스 계산 */\nexport function computeBBox(items: NormItem[], pageNum: number): BoundingBox {\n let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity\n for (const i of items) {\n if (i.x < minX) minX = i.x\n if (i.y < minY) minY = i.y\n if (i.x + i.w > maxX) maxX = i.x + i.w\n // h가 0인 경우 fontSize를 높이 대용으로 사용 (pdfjs가 height를 제공하지 않는 경우)\n const effectiveH = i.h > 0 ? i.h : i.fontSize\n if (i.y + effectiveH > maxY) maxY = i.y + effectiveH\n }\n return { page: pageNum, x: minX, y: minY, width: maxX - minX, height: maxY - minY }\n}\n\n/** 아이템 그룹의 대표 스타일 (최빈 폰트 크기) */\nexport function dominantStyle(items: NormItem[]): { fontSize: number; fontName?: string } | undefined {\n if (items.length === 0) return undefined\n // 최빈 폰트 크기 찾기\n const freq = new Map<number, number>()\n let maxCount = 0, dominantSize = 0\n for (const i of items) {\n if (i.fontSize <= 0) continue\n const count = (freq.get(i.fontSize) || 0) + 1\n freq.set(i.fontSize, count)\n if (count > maxCount) { maxCount = count; dominantSize = i.fontSize }\n }\n if (dominantSize === 0) return undefined\n // 대표 폰트명 (빈 문자열은 undefined로)\n const fontName = items.find(i => i.fontSize === dominantSize)?.fontName || undefined\n return fontName ? { fontSize: dominantSize, fontName } : { fontSize: dominantSize }\n}\n\nexport function normalizeItems(rawItems: PdfTextItem[]): NormItem[] {\n const items: NormItem[] = []\n // pdfjs 공백 아이템 위치 수집 — 단어 경계 힌트로 활용\n const spacePositions: { x: number; y: number }[] = []\n\n for (const i of rawItems) {\n if (typeof i.str !== \"string\") continue\n const x = Math.round(i.transform[4])\n const y = Math.round(i.transform[5])\n\n if (!i.str.trim()) {\n // 공백 전용 아이템: 위치만 기록 (단어 구분 힌트)\n spacePositions.push({ x, y })\n continue\n }\n\n // 회전 텍스트 대응: 90° 회전 시 [0,s,-s,0] 꼴로 대각 성분이 0이 되므로\n // 열벡터 노름으로 실제 글리프 스케일을 구한다 (사이드탭·회전 표가 hidden 오분류되던 버그)\n const scaleX = Math.hypot(i.transform[0], i.transform[1])\n const scaleY = Math.hypot(i.transform[2], i.transform[3])\n const fontSize = Math.round(Math.max(scaleY, scaleX))\n const w = Math.round(i.width)\n const h = Math.round(i.height)\n const isHidden = fontSize === 0 || (i.width === 0 && i.str.trim().length > 0)\n\n // letterSpacing이 적용된 숫자/기호 문자열 정규화\n // \"45 0 -7 3 40 )\" → \"450-7340)\" (전화번호, 금액 등)\n let text = i.str.trim()\n if (/^[\\d\\s\\-().·,☎]+$/.test(text) && /\\d/.test(text) && / /.test(text)) {\n text = text.replace(/ /g, \"\")\n }\n\n // 균등배분 TextItem 분해: \"홍 보 지 원 반\" → 개별 글자 아이템으로\n const split = splitEvenSpacedItem(text, x, w, fontSize)\n if (split) {\n for (const s of split) {\n items.push({ text: s.text, x: s.x, y, w: s.w, h, fontSize, fontName: i.fontName || \"\", isHidden })\n }\n } else {\n items.push({ text, x, y, w, h, fontSize, fontName: i.fontName || \"\", isHidden })\n }\n }\n\n const sorted = items.sort((a, b) => b.y - a.y || a.x - b.x)\n\n // 1. 가짜 볼드 중복 제거: 같은 텍스트가 거의 동일한 좌표(±3px)에 2~3회 겹쳐진 경우\n // PDF에서 볼드 효과를 위해 텍스트를 여러 번 렌더링하는 기법\n const deduped: NormItem[] = []\n for (let i = 0; i < sorted.length; i++) {\n let isDup = false\n // Y 정렬(desc)이므로 역순 스캔 — Y 차이가 tolerance를 넘으면 중단\n for (let j = deduped.length - 1; j >= 0; j--) {\n const prev = deduped[j]\n if (prev.y - sorted[i].y > 3) break // 이전 아이템이 너무 높음 → 중단\n // x 허용치는 글리프 폭 비례 — 고정 ±3px는 좁은 글리프(괄호 w≈3)에서 나란히\n // 놓인 진짜 두 글자(\"경북))\")까지 삼킨다. 가짜 볼드 오프셋은 폭의 절반 미만.\n const xTol = Math.min(3, Math.max(0.5, sorted[i].w * 0.5))\n if (Math.abs(prev.y - sorted[i].y) <= 3 &&\n prev.text === sorted[i].text && Math.abs(prev.x - sorted[i].x) <= xTol) {\n isDup = true\n break\n }\n }\n if (!isDup) deduped.push(sorted[i])\n }\n\n // 2. 공백 아이템 위치를 NormItem.hasSpaceBefore로 전파\n // 같은 Y라인(±3px)에서 공백 바로 오른쪽의 \"가장 가까운\" 아이템에만 표시.\n // (기존: 20px 윈도 내 모든 아이템 마킹 → \"기관 [공백] 내부에서\"의 '부'까지\n // 오마킹되어 \"내 부에서\" 과다 공백 발생 — 인접 아이템 1개로 제한)\n if (spacePositions.length > 0) {\n for (const sp of spacePositions) {\n let nearest: NormItem | null = null\n for (const item of deduped) {\n if (Math.abs(sp.y - item.y) > 3) continue\n const dist = item.x - sp.x\n if (dist >= -1 && dist <= 20 && (!nearest || item.x < nearest.x)) {\n nearest = item\n }\n }\n if (nearest) nearest.hasSpaceBefore = true\n }\n }\n\n return deduped\n}\n\n/**\n * 균등배분 TextItem 감지 및 분해.\n * \"홍 보 지 원 반\" (1자+공백 패턴) → [{text:\"홍\",x,w}, {text:\"보\",x,w}, ...]\n * 분해하면 이후 detectEvenSpacedItems가 좌표 기반으로 정확히 감지할 수 있음.\n */\nfunction splitEvenSpacedItem(\n text: string, itemX: number, itemW: number, fontSize: number,\n): { text: string; x: number; w: number }[] | null {\n // 한글/숫자 1자 + 공백이 3회+ 반복되는 패턴\n // \"홍 보 지 원 반\", \"세 무 1 과\", \"주 요 내 용\"\n if (!/^[가-힣\\d](?: [가-힣\\d]){2,}$/.test(text)) return null\n\n const chars = text.split(\" \")\n if (chars.length < 3) return null\n\n // 글자당 폭 계산 — 전체 width를 글자 수로 나눔\n const charW = itemW / chars.length\n // 글자 폭이 너무 크면 균등배분이 아님 (한 글자가 fontSize의 2배 넘으면 이상)\n if (charW > fontSize * 2) return null\n\n return chars.map((ch, idx) => ({\n text: ch,\n x: Math.round(itemX + idx * charW),\n w: Math.round(charW * 0.8), // 실제 글자 폭은 간격보다 좁음\n }))\n}\n\nexport function groupByY(items: NormItem[]): NormItem[][] {\n if (items.length === 0) return []\n const lines: NormItem[][] = []\n let curY = items[0].y\n let curLine: NormItem[] = [items[0]]\n\n for (let i = 1; i < items.length; i++) {\n // Y좌표 허용 오차 3px — PDF 렌더링 미세 오차 보정, 별표 행 경계 감지에 최적화된 값\n if (Math.abs(items[i].y - curY) > 3) {\n lines.push(curLine)\n curLine = []\n curY = items[i].y\n }\n curLine.push(items[i])\n }\n if (curLine.length > 0) lines.push(curLine)\n return lines\n}\n\n/**\n * 첨자 줄 병합 — 본문 줄보다 살짝 위에 뜬 작은 글자 조각(각주 마커 `*`, 원문자 ①,\n * 덧말)이 groupByY에서 별도 줄로 분리된 것을 본문 줄에 흡수한다.\n * 조각 줄(아이템 ≤3개·각 ≤8자·글자 박스가 인접 줄보다 확실히 작음)이 인접 줄과\n * 수직으로 겹치면 같은 시각적 줄이다. mergeLineSimple이 x순 정렬하므로\n * 병합 후 원래 인라인 위치(\"①근로자...\")가 복원된다.\n */\nexport function mergeSuperscriptLines(lines: NormItem[][]): NormItem[][] {\n if (lines.length <= 1) return lines\n const band = (line: NormItem[]) => {\n let bottom = Infinity, top = -Infinity\n for (const i of line) {\n const h = i.h > 0 ? i.h : i.fontSize\n if (i.y < bottom) bottom = i.y\n if (i.y + h > top) top = i.y + h\n }\n return { bottom, top, height: top - bottom }\n }\n // 조각 판정 — 글자 단위로 흩어진 소형 라벨(\"과기정통부\" 1자×5)도 흡수하도록\n // 아이템 수 대신 총 글자수로 제한 (높이비·수직겹침 가드가 과병합을 막는다)\n const isFrag = (line: NormItem[]) => {\n if (line.length > 8) return false\n let total = 0\n for (const i of line) total += i.text.trim().length\n return total > 0 && total <= 10\n }\n\n const result: NormItem[][] = [lines[0]]\n for (let i = 1; i < lines.length; i++) {\n const prev = result[result.length - 1]\n const curr = lines[i]\n const a = band(prev)\n const b = band(curr)\n const overlap = Math.min(a.top, b.top) - Math.max(a.bottom, b.bottom)\n const prevIsFrag = isFrag(prev) && a.height <= b.height * 0.8 && overlap >= a.height * 0.5\n const currIsFrag = isFrag(curr) && b.height <= a.height * 0.8 && overlap >= b.height * 0.5\n if (prevIsFrag || currIsFrag) {\n result[result.length - 1] = [...prev, ...curr]\n } else {\n result.push(curr)\n }\n }\n return result\n}\n\nexport function mergeLineSimple(items: NormItem[]): string {\n if (items.length <= 1) return items[0]?.text || \"\"\n const sorted = [...items].sort((a, b) => a.x - b.x)\n\n // 좌표 기반 균등배분 감지 (ODL TextLineProcessor 방식)\n const isEvenSpaced = detectEvenSpacedItems(sorted)\n\n let result = sorted[0].text\n for (let i = 1; i < sorted.length; i++) {\n const gap = sorted[i].x - (sorted[i - 1].x + sorted[i - 1].w)\n const avgFs = (sorted[i].fontSize + sorted[i - 1].fontSize) / 2\n\n // 탭 갭은 항상 탭으로 — 균등배분보다 우선\n // 기준: fontSize의 2배 이상 또는 30px+ (균등배분 간격은 보통 fontSize*1.5 이하)\n const tabThreshold = Math.max(avgFs * 2, 30)\n if (gap > tabThreshold) {\n result += \"\\t\"\n result += sorted[i].text\n continue\n }\n\n // 균등배분 구간이면 공백 없이 합침\n if (isEvenSpaced[i]) {\n result += sorted[i].text\n continue\n }\n\n // pdfjs 공백 아이템이 있었으면 단어 경계 — 갭 크기 무관하게 공백 삽입\n if (sorted[i].hasSpaceBefore && gap >= avgFs * 0.05) {\n result += \" \"\n result += sorted[i].text\n continue\n }\n // 마커(□○▶ 등) 뒤에 한글이 오면 항상 공백 보장 — \"□장소\" → \"□ 장소\"\n if (/[□■○●▶◆◇ㅇ]$/.test(sorted[i - 1].text) && /^[가-힣]/.test(sorted[i].text) && gap > 1) {\n result += \" \"\n result += sorted[i].text\n continue\n }\n // 폰트 크기 비례 공백 임계값 — 고정 px 기준은 Type3/대형 폰트에서 공백 소실·과다 유발\n if (gap > spaceGapThreshold(avgFs)) result += \" \"\n result += sorted[i].text\n }\n return result\n}\n","/**\n * IRBlock[] 후처리 감지/변환.\n *\n * 헤딩 승격(폰트 크기·□마커), 의사 테이블 demote, 표 캡션 연결,\n * 한국어 리스트(공문서 계층 라벨 시퀀스), key-value 특수표,\n * 머리글/바닥글 반복 패턴 제거.\n */\n\nimport type { IRBlock, IRTable, ParseWarning } from \"../types.js\"\nimport { HEADING_RATIO_H1, HEADING_RATIO_H2, HEADING_RATIO_H3 } from \"../types.js\"\nimport { collapseEvenSpacing } from \"./text-line.js\"\n\n// ═══════════════════════════════════════════════════════\n// 헤딩 감지 (폰트 크기 기반)\n// ═══════════════════════════════════════════════════════\n\nexport function computeMedianFontSizeFromFreq(freq: Map<number, number>): number {\n if (freq.size === 0) return 0\n let total = 0\n for (const count of freq.values()) total += count\n const sorted = [...freq.entries()].sort((a, b) => a[0] - b[0])\n const mid = Math.floor(total / 2)\n let cumulative = 0\n for (const [size, count] of sorted) {\n cumulative += count\n if (cumulative > mid) return size\n }\n return sorted[sorted.length - 1][0]\n}\n\n/**\n * 블록의 폰트 크기를 median과 비교하여 헤딩으로 승격.\n * - 150%+ → heading level 1\n * - 130%+ → heading level 2\n * - 115%+ → heading level 3\n * 조건: 짧은 텍스트 (200자 미만), 숫자만으로 구성되지 않음\n */\nexport function detectHeadings(blocks: IRBlock[], medianFontSize: number): void {\n for (const block of blocks) {\n if (block.type !== \"paragraph\" || !block.text || !block.style?.fontSize) continue\n const text = block.text.trim()\n if (text.length === 0 || text.length > 200) continue\n // 숫자만이면 헤딩 아님\n if (/^\\d+$/.test(text)) continue\n\n const ratio = block.style.fontSize / medianFontSize\n let level = 0\n if (ratio >= HEADING_RATIO_H1) level = 1\n else if (ratio >= HEADING_RATIO_H2) level = 2\n else if (ratio >= HEADING_RATIO_H3) level = 3\n\n if (level > 0) {\n block.type = \"heading\"\n block.level = level\n // PDF 균등배분 스페이스 제거 (\"기 본 현 황\" → \"기본현황\")\n // 한글 글자 사이에 단독 공백이 반복되면 균등배분으로 판단\n block.text = collapseEvenSpacing(text)\n }\n }\n}\n\n/**\n * 의사 테이블 감지: 실제 데이터 테이블이 아닌 텍스트가 우연히 테이블로 감지된 경우.\n */\nexport function shouldDemoteTable(table: IRTable): boolean {\n const allCells = table.cells.flatMap(row => row.map(c => c.text.trim())).filter(Boolean)\n const allText = allCells.join(\" \")\n\n // 라벨 헤더 표 가드: 2행2열+ + 첫 행 전체가 마커 없는 짧은 라벨 + 본문에\n // 내용 실존 (예: 채용분야|담당업무|우대조건, 성명|응시분야|비고) — 본문 셀의\n // ○/ㅇ 항목부호와 양식 표의 빈 기입란은 행정문서 표 관행이므로 강등하지 않음\n if (table.rows >= 2 && table.cols >= 2 &&\n table.cells[0].every(c => {\n const t = c.text.trim()\n return t.length > 0 && t.length <= 12 && !/[□■◆○●▶ㅇ<>]/.test(t)\n }) &&\n table.cells.slice(1).some(row => row.some(c => c.text.trim() !== \"\"))) return false\n\n // 텍스트 박스 패턴: 3행 이하 + 3열 이하 + <...> 또는 ㅇ 마커 포함 + 짧은 내용\n // 공문서 \"중점 추진사항\" 등 요약 박스. 긴 조문 셀을 가진 신구조문대비표(<신 설>\n // 표기가 <...> 마커로 오인됨)는 요약 박스가 아니므로 길이 가드로 제외 — 아래 200자\n // 초과 정상표 정책(allText.length > 200 → return false)과 정합시킨다 (pline-1).\n if (table.rows <= 3 && table.cols <= 3 && allText.length <= 200) {\n // 빈 셀이 과반 → 텍스트 박스 (테두리 안에 텍스트만 있는 형태)\n const totalCells = table.rows * table.cols\n const emptyCells = totalCells - allCells.length\n if (emptyCells >= totalCells * 0.3) return true\n\n // 마커 패턴 (ㅇ, □, ○, <> 등) → 텍스트성\n if (/[□■◆○●▶ㅇ]/.test(allText)) return true\n if (/<[^>]+>/.test(allText)) return true\n }\n\n if (allText.length > 200) return false\n // □, ○, ■ 마커 포함 + 3행 이하 → 텍스트성\n if (/[□■◆○●▶]/.test(allText) && table.rows <= 3) return true\n // 빈 셀이 과반 → 의사 테이블\n const totalCells = table.rows * table.cols\n const emptyCells = totalCells - allCells.length\n if (table.rows <= 2 && emptyCells > totalCells * 0.5) return true\n // 1행 + 숫자 데이터 없음 → 의사 테이블\n if (table.rows === 1 && !/\\d{2,}/.test(allText)) return true\n return false\n}\n\n/** demote된 테이블을 구조화된 텍스트로 변환 */\nexport function demoteTableToText(table: IRTable): string {\n const lines: string[] = []\n for (let r = 0; r < table.rows; r++) {\n const cells = table.cells[r].map(c => c.text.trim()).filter(Boolean)\n if (cells.length === 0) continue\n if (table.cols === 2 && cells.length === 2) {\n lines.push(`${cells[0]} : ${cells[1]}`)\n } else {\n // 각 셀 텍스트를 공백으로 합침 (br 태그는 줄바꿈으로 유지)\n lines.push(cells.join(\" \"))\n }\n }\n return lines.join(\"\\n\")\n}\n\n/** □/■ 마커 및 짧은 섹션명을 서브헤딩으로 변환 */\nexport function detectMarkerHeadings(blocks: IRBlock[]): void {\n for (let i = 0; i < blocks.length; i++) {\n const block = blocks[i]\n if (block.type !== \"paragraph\" || !block.text) continue\n const text = block.text.trim()\n // □/■ + 한글로 시작하는 짧은 텍스트 (50자 미만)\n if (text.length < 50 && /^[□■◆◇▶]\\s*[가-힣]/.test(text)) {\n block.type = \"heading\"\n block.level = 4\n continue\n }\n // 순수 한글 2-6자 + 앞뒤가 표/헤딩/빈블록 → 섹션 제목으로 추정\n // (예: \"사업설명\", \"사업효과\", \"추진경위\")\n if (/^[가-힣]{2,6}$/.test(text) && block.style?.fontSize) {\n const prev = blocks[i - 1]\n const next = blocks[i + 1]\n const prevIsStructural = !prev || prev.type === \"table\" || prev.type === \"heading\" || prev.type === \"separator\"\n const nextIsStructural = !next || next.type === \"table\" || next.type === \"heading\" || (next.type === \"paragraph\" && next.text && /^[□■◆○●]/.test(next.text.trim()))\n if (prevIsStructural || nextIsStructural) {\n block.type = \"heading\"\n block.level = 3\n }\n }\n }\n}\n\n// ═══════════════════════════════════════════════════════\n// 표 캡션 감지 (ODL CaptionProcessor의 패턴 기반 서브셋)\n// ═══════════════════════════════════════════════════════\n\n/**\n * 캡션 라벨 패턴 — '표 1.', '<표 2>', '[표 3-1]', '그림 4', 'Table 1', 'Figure 2' 등.\n * 숫자(또는 원문자)가 반드시 있어야 함 — '표지', '그림자' 같은 일반 단어 오탐 방지.\n */\nconst TABLE_CAPTION_RE = /^[<\\[(【〈]?\\s*(표|그림|도표|Table|Figure|Fig\\.?)\\s*[\\d①-⑮][\\d.\\-]*\\s*[\\])】〉>]?[.:]?\\s*/i\n\n/** 캡션 후보 최대 길이 */\nconst CAPTION_MAX_LENGTH = 100\n/** 캡션-표 수직 거리 한계 (pt) */\nconst CAPTION_MAX_GAP = 30\n\n/**\n * 표 블록 직전/직후의 짧은 캡션 패턴 텍스트를 IRTable.caption으로 연결하고\n * 해당 paragraph 블록은 제거한다 (중복 출력 방지 — builder가 표 위에 캡션 출력).\n */\nexport function detectTableCaptions(blocks: IRBlock[]): void {\n const isCaptionCandidate = (b: IRBlock | undefined, table: IRBlock): b is IRBlock => {\n if (!b || b.type !== \"paragraph\" || !b.text) return false\n if (b.pageNumber !== table.pageNumber) return false\n const text = b.text.trim()\n if (!text || text.length > CAPTION_MAX_LENGTH || text.includes(\"\\n\")) return false\n if (!TABLE_CAPTION_RE.test(text)) return false\n // 수직 근접 + 수평 겹침 검증 (bbox 있을 때만)\n if (b.bbox && table.bbox) {\n const capTop = b.bbox.y + b.bbox.height\n const capBottom = b.bbox.y\n const tblTop = table.bbox.y + table.bbox.height\n const tblBottom = table.bbox.y\n const gap = capBottom >= tblTop ? capBottom - tblTop : tblBottom - capTop\n if (gap > CAPTION_MAX_GAP) return false\n const overlap = Math.min(b.bbox.x + b.bbox.width, table.bbox.x + table.bbox.width) -\n Math.max(b.bbox.x, table.bbox.x)\n if (overlap < Math.min(b.bbox.width, table.bbox.width) * 0.3) return false\n }\n return true\n }\n\n for (let i = 0; i < blocks.length; i++) {\n const block = blocks[i]\n if (block.type !== \"table\" || !block.table || block.table.caption) continue\n\n // 직전 블록 우선 (한국 공문서는 표 위 캡션이 일반적), 다음 블록 차선\n if (isCaptionCandidate(blocks[i - 1], block)) {\n block.table.caption = blocks[i - 1].text!.trim()\n blocks.splice(i - 1, 1)\n i--\n } else if (isCaptionCandidate(blocks[i + 1], block)) {\n block.table.caption = blocks[i + 1].text!.trim()\n blocks.splice(i + 1, 1)\n }\n }\n}\n\n// ═══════════════════════════════════════════════════════\n// 한국어 리스트 감지 — 공문서 계층 라벨 시퀀스 검증\n// (ODL ListProcessor의 한국어 서브셋 — 가나다 시퀀스, '붙임' 패턴)\n// ═══════════════════════════════════════════════════════\n\n/** 한국 공문서 항목 기호 시퀀스 (가나다순) */\nconst KOREAN_LIST_SEQ = \"가나다라마바사아자차카타파하\"\n\ninterface ListLabel {\n family: \"arabicDot\" | \"korDot\" | \"arabicParen\" | \"korParen\" | \"circled\"\n ord: number\n}\n\n/** 블록 텍스트에서 리스트 라벨 파싱 — 시퀀스 검증 가능한 라벨만 */\nfunction parseListLabel(text: string): ListLabel | null {\n let m = text.match(/^(\\d{1,2})\\.(?!\\d)\\s+/)\n if (m) return { family: \"arabicDot\", ord: parseInt(m[1], 10) }\n m = text.match(/^([가-하])\\.\\s+/)\n if (m) {\n const idx = KOREAN_LIST_SEQ.indexOf(m[1])\n if (idx >= 0) return { family: \"korDot\", ord: idx + 1 }\n }\n m = text.match(/^(\\d{1,2})\\)\\s*/)\n if (m) return { family: \"arabicParen\", ord: parseInt(m[1], 10) }\n m = text.match(/^([가-하])\\)\\s*/)\n if (m) {\n const idx = KOREAN_LIST_SEQ.indexOf(m[1])\n if (idx >= 0) return { family: \"korParen\", ord: idx + 1 }\n }\n m = text.match(/^([①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮])\\s*/)\n if (m) return { family: \"circled\", ord: m[1].charCodeAt(0) - 0x2460 + 1 }\n return null\n}\n\n/** '붙임' 패턴 (ODL ATTACHMENTS_PATTERN) — 공문서 첨부 표기 */\nconst ATTACHMENT_RE = /^붙\\s*임\\s*(\\d+[.:]?)?\\s/\n\n/**\n * 라벨 시퀀스 검증 기반 한국어 리스트 감지.\n *\n * 1) paragraph 블록의 선두 라벨(1./가./1)/가)/①)을 파싱\n * 2) 같은 family의 라벨이 +1씩 증가하는 체인(2개+)만 리스트로 확정\n * — \"2026. 6. 9.\" 같은 날짜/단발 번호 오탐 방지\n * 3) 상위 family 항목 사이에 낀 하위 family 항목은 children으로 중첩 (들여쓰기)\n * 4) '붙임 1 ...' 패턴은 시퀀스 없이도 리스트 항목으로 인정\n */\nexport function detectKoreanListBlocks(blocks: IRBlock[]): void {\n // ── 1단계: 라벨 수집 ──\n interface Labeled {\n idx: number\n label: ListLabel\n }\n const labeled: Labeled[] = []\n for (let i = 0; i < blocks.length; i++) {\n const b = blocks[i]\n if ((b.type !== \"paragraph\" && b.type !== \"list\") || !b.text) continue\n const label = parseListLabel(b.text.trim())\n if (label) labeled.push({ idx: i, label })\n }\n\n // ── 2단계: family별 시퀀스 체인 검증 ──\n // 체인: 같은 family + ord가 +1씩 증가 + 블록 간격 ≤ 20 (사이에 하위 항목/본문 허용)\n const validated = new Set<number>()\n const byFamily = new Map<string, Labeled[]>()\n for (const l of labeled) {\n const arr = byFamily.get(l.label.family) || []\n arr.push(l)\n byFamily.set(l.label.family, arr)\n }\n for (const arr of byFamily.values()) {\n let chain: Labeled[] = []\n for (const item of arr) {\n const prev = chain[chain.length - 1]\n if (prev && item.label.ord === prev.label.ord + 1 && item.idx - prev.idx <= 20) {\n chain.push(item)\n } else {\n if (chain.length >= 2) for (const c of chain) validated.add(c.idx)\n chain = [item]\n }\n }\n if (chain.length >= 2) for (const c of chain) validated.add(c.idx)\n }\n\n // ── 3단계: 변환 + 중첩 ──\n // familyStack: 현재 리스트 run에서 등장한 family 순서 (얕은 → 깊은)\n let familyStack: string[] = []\n let lastTopLevelList: IRBlock | null = null\n // 직전에 리스트로 변환된 블록 인덱스 — 중첩(children 끌어올림)은 직전 블록이\n // 같은 리스트 run일 때만 허용 (사이에 낀 본문을 건너뛰면 블록 순서가 재배열됨)\n let prevListIdx = -2\n const toRemove = new Set<number>()\n\n for (let i = 0; i < blocks.length; i++) {\n const b = blocks[i]\n\n // 표/헤딩/구분선은 리스트 run 종료\n if (b.type === \"table\" || b.type === \"heading\" || b.type === \"separator\") {\n familyStack = []\n lastTopLevelList = null\n continue\n }\n if ((b.type !== \"paragraph\" && b.type !== \"list\") || !b.text) continue\n\n const text = b.text.trim()\n\n // '붙임' 패턴 — 시퀀스 불요\n if (b.type === \"paragraph\" && ATTACHMENT_RE.test(text)) {\n blocks[i] = { ...b, type: \"list\", listType: \"unordered\" }\n continue\n }\n\n if (!validated.has(i)) continue\n const label = parseListLabel(text)!\n\n // family 깊이 결정 — 처음 보는 family는 스택에 push\n let depth = familyStack.indexOf(label.family)\n if (depth < 0) {\n familyStack.push(label.family)\n depth = familyStack.length - 1\n } else {\n // 상위 family로 복귀하면 더 깊은 family 제거\n familyStack = familyStack.slice(0, depth + 1)\n }\n\n const listType: \"ordered\" | \"unordered\" = label.family === \"arabicDot\" ? \"ordered\" : \"unordered\"\n const listBlock: IRBlock = { ...b, type: \"list\", listType }\n\n if (depth === 0) {\n blocks[i] = listBlock\n lastTopLevelList = listBlock\n } else if (lastTopLevelList && i === prevListIdx + 1) {\n // 하위 항목 → 직전 상위 항목의 children으로 (마크다운 들여쓰기)\n // 연속성 조건(i === prevListIdx + 1): 사이에 본문이 끼면 끌어올리지 않는다\n if (!lastTopLevelList.children) lastTopLevelList.children = []\n lastTopLevelList.children.push(listBlock)\n toRemove.add(i)\n } else {\n // 상위 항목 없이 시작됐거나 본문으로 연속성이 끊긴 하위 family — 평면 리스트로\n blocks[i] = listBlock\n lastTopLevelList = listBlock\n }\n prevListIdx = i\n }\n\n // 제거는 뒤에서부터\n if (toRemove.size > 0) {\n const sorted = [...toRemove].sort((a, b) => b - a)\n for (const idx of sorted) blocks.splice(idx, 1)\n }\n}\n\n// ═══════════════════════════════════════════════════════\n// 리스트 감지 — paragraph 블록 중 번호 패턴을 list 블록으로 변환\n// ═══════════════════════════════════════════════════════\n\n/**\n * 연속된 paragraph 블록에서 번호 리스트 패턴을 감지하여 list 블록으로 변환.\n * \"비고\" 헤더 뒤에 오는 \"1.\", \"2.\" 패턴이 대표적.\n */\nexport function detectListBlocks(blocks: IRBlock[]): IRBlock[] {\n const result: IRBlock[] = []\n\n for (let i = 0; i < blocks.length; i++) {\n const block = blocks[i]\n\n if (block.type === \"paragraph\" && block.text) {\n const text = block.text.trim()\n // 번호 리스트: \"1.\", \"2.\" 등\n if (/^\\d+\\.\\s/.test(text)) {\n result.push({ ...block, type: \"list\", listType: \"ordered\", text: block.text })\n continue\n }\n // 비번호 리스트: ○, -, ·, ※, ▶ 등\n if (/^[○●·※▶▷◆◇\\-]\\s/.test(text)) {\n result.push({ ...block, type: \"list\", listType: \"unordered\", text: block.text })\n continue\n }\n }\n\n result.push(block)\n }\n\n return result\n}\n\n// ═══════════════════════════════════════════════════════\n// 한국어 특수 테이블 감지 — \"구분/항목/종류\" 패턴 기반 key-value 테이블\n// ═══════════════════════════════════════════════════════\n\n/**\n * ODL SpecialTableProcessor 포팅: 연속된 \"구분:\", \"항목:\", \"종류:\" 등\n * 한국어 key-value 패턴을 2열 테이블로 변환.\n *\n * 동작:\n * 1) paragraph 블록의 텍스트에서 한국어 key-value 패턴 감지\n * 2) \":\"가 있으면 key | value 2열, 없으면 colSpan=2 (전체 행)\n * 3) 연속된 패턴을 하나의 테이블로 그룹화\n */\nconst KOREAN_TABLE_HEADER_RE = /^\\(?(구분|항목|종류|분류|유형|대상|내용|기간|금액|비율|방법|절차|요건|조건|근거|목적|범위|기준)\\)?[:\\s]/\n\n/** KV 오탐 패턴: 시간(14:30), URL(://), 숫자:숫자(3:2) */\nconst KV_FALSE_POSITIVE_RE = /\\d{1,2}:\\d{2}|:\\/\\/|\\d+:\\d+/\n\nexport function detectSpecialKoreanTables(blocks: IRBlock[]): IRBlock[] {\n const result: IRBlock[] = []\n let kvLines: { key: string; value: string; block: IRBlock }[] = []\n\n const flushKvTable = () => {\n if (kvLines.length < 2) {\n // 2행 미만이면 테이블로 만들 가치 없음 → 원래 블록 복원\n for (const kv of kvLines) result.push(kv.block)\n kvLines = []\n return\n }\n\n // 2열 테이블 생성\n const cells: import(\"../types.js\").IRCell[][] = kvLines.map(kv => {\n if (kv.value) {\n return [\n { text: kv.key, colSpan: 1, rowSpan: 1 },\n { text: kv.value, colSpan: 1, rowSpan: 1 },\n ]\n }\n // \":\" 없는 줄 → 전체 행 (colSpan=2)\n return [\n { text: kv.key, colSpan: 2, rowSpan: 1 },\n { text: \"\", colSpan: 1, rowSpan: 1 },\n ]\n })\n\n const irTable: IRTable = {\n rows: cells.length,\n cols: 2,\n cells,\n hasHeader: true,\n }\n\n // 첫 블록의 위치 정보 사용\n const firstBlock = kvLines[0].block\n result.push({\n type: \"table\",\n table: irTable,\n pageNumber: firstBlock.pageNumber,\n bbox: firstBlock.bbox,\n })\n kvLines = []\n }\n\n for (const block of blocks) {\n if (block.type !== \"paragraph\" || !block.text) {\n flushKvTable()\n result.push(block)\n continue\n }\n\n const text = block.text.trim()\n\n // \"구분: xxx\" 또는 \"항목: xxx\" 패턴 매칭\n if (KOREAN_TABLE_HEADER_RE.test(text)) {\n const colonIdx = text.indexOf(\":\")\n if (colonIdx >= 0) {\n kvLines.push({\n key: text.slice(0, colonIdx).trim(),\n value: text.slice(colonIdx + 1).trim(),\n block,\n })\n } else {\n // \":\" 없이 공백으로 구분된 경우: \"구분 xxx\"\n const spaceIdx = text.search(/\\s/)\n if (spaceIdx > 0) {\n kvLines.push({\n key: text.slice(0, spaceIdx).trim(),\n value: text.slice(spaceIdx + 1).trim(),\n block,\n })\n } else {\n kvLines.push({ key: text, value: \"\", block })\n }\n }\n continue\n }\n\n // key-value 패턴이 아닌 블록이 나오면 축적된 것을 flush\n // 단, 이미 수집 중이고 현재 블록이 \"label: value\" 형태면 계속 수집\n if (kvLines.length > 0 && text.includes(\":\")) {\n // 오탐 제외: 시간(14:30), URL(http://), 숫자:숫자(3:2), 괄호 포함\n if (!KV_FALSE_POSITIVE_RE.test(text) && !text.includes(\"(\") && !text.includes(\")\")) {\n const colonIdx = text.indexOf(\":\")\n const key = text.slice(0, colonIdx).trim()\n // key가 순수 한글 2~8자 (공백/괄호 없음)면 유효한 key-value 라인\n if (/^[가-힣]+$/.test(key) && key.length >= 2 && key.length <= 8) {\n kvLines.push({\n key,\n value: text.slice(colonIdx + 1).trim(),\n block,\n })\n continue\n }\n }\n }\n\n flushKvTable()\n result.push(block)\n }\n\n flushKvTable()\n return result\n}\n\n// ─── 머리글/바닥글 감지 ────────────────────────────\n\n/**\n * 머리글/바닥글 감지 — 텍스트 반복 패턴 (숫자 normalization).\n *\n * v3.0.x: y 위치 클러스터 규칙(같은 y 버킷이 3+페이지 반복이면 텍스트가 달라도 제거)을\n * 삭제했다. 본문도 페이지마다 같은 y에서 시작/끝나므로 (균일한 상하 여백), 위치 반복만으로는\n * 머리글/바닥글과 본문 첫/마지막 줄을 구분할 수 없다 — 인사말씀·보고서류에서 본문 문단\n * 첫 줄과 섹션 제목(\"붙임 1\" 등)이 통째로 제거되는 사고가 corpus에서 다수 확인됨.\n * 페이지 번호(\"- 1 -\")처럼 가변 숫자가 있는 고정 문구는 # normalization으로 충분히 잡힌다.\n */\nexport function removeHeaderFooterBlocks(\n blocks: IRBlock[],\n pageHeights: Map<number, number>,\n warnings: ParseWarning[],\n): number[] {\n const ZONE_RATIO = 0.12 // 상하 12% (10% 초과 여백 대응)\n const MIN_REPEAT = 3 // 최소 3페이지 반복\n\n type ZoneEntry = { blockIdx: number; page: number; text: string }\n const topEntries: ZoneEntry[] = []\n const bottomEntries: ZoneEntry[] = []\n\n for (let bi = 0; bi < blocks.length; bi++) {\n const b = blocks[bi]\n if (!b.bbox || !b.pageNumber || !b.text?.trim()) continue\n const ph = pageHeights.get(b.bbox.page) || pageHeights.get(b.pageNumber)\n if (!ph) continue\n\n const blockTop = ph - (b.bbox.y + b.bbox.height)\n const blockBottom = ph - b.bbox.y\n const entry: ZoneEntry = { blockIdx: bi, page: b.pageNumber, text: b.text.trim() }\n\n // blockTop/blockBottom은 페이지 상단 기준 거리 — 하단 경계가 상단 12% 안이면\n // 머리글 영역(top), 상단 경계가 하단 12% 안이면 바닥글 영역(bottom)\n if (blockBottom <= ph * ZONE_RATIO) topEntries.push(entry)\n else if (blockTop >= ph * (1 - ZONE_RATIO)) bottomEntries.push(entry)\n }\n\n const removeSet = new Set<number>()\n\n for (const entries of [topEntries, bottomEntries]) {\n if (entries.length === 0) continue\n\n // (1) 텍스트 반복 패턴\n const patternCount = new Map<string, number>()\n const patternPages = new Map<string, Set<number>>()\n for (const e of entries) {\n const norm = e.text.replace(/\\d+/g, \"#\")\n patternCount.set(norm, (patternCount.get(norm) || 0) + 1)\n const pages = patternPages.get(norm) || new Set<number>()\n pages.add(e.page)\n patternPages.set(norm, pages)\n }\n const repeatedPatterns = new Set<string>()\n for (const [p, count] of patternCount) {\n // 서로 다른 페이지에서 MIN_REPEAT번 이상 등장\n if (count >= MIN_REPEAT && (patternPages.get(p)?.size ?? 0) >= MIN_REPEAT) {\n repeatedPatterns.add(p)\n }\n }\n\n // 제거 대상: 텍스트 반복 패턴 매칭.\n // 단 같은 페이지의 비후보 블록과 y대역이 겹치면(후보 높이의 ≥50%) 러닝헤더가\n // 아니라 본문 라인의 일부다 — \"붙임 N\" 태그가 제목과 같은 줄에 있는 첨부 표제를\n // 3페이지 반복 + 숫자 정규화가 헤더로 오검출해 통째로 삼키는 사고 방지.\n for (const e of entries) {\n const norm = e.text.replace(/\\d+/g, \"#\")\n if (!repeatedPatterns.has(norm)) continue\n const cand = blocks[e.blockIdx]\n const cb = cand.bbox!\n let sharesLine = false\n for (let bi = 0; bi < blocks.length; bi++) {\n if (bi === e.blockIdx) continue\n const o = blocks[bi]\n if (!o.bbox || o.bbox.page !== cb.page) continue\n const oNorm = o.text?.trim().replace(/\\d+/g, \"#\")\n if (oNorm && repeatedPatterns.has(oNorm)) continue // 후보끼리(제목+날짜 헤더)는 무시\n const overlap = Math.min(cb.y + cb.height, o.bbox.y + o.bbox.height) - Math.max(cb.y, o.bbox.y)\n if (overlap >= cb.height * 0.5) { sharesLine = true; break }\n }\n if (!sharesLine) removeSet.add(e.blockIdx)\n }\n }\n\n if (removeSet.size > 0) {\n warnings.push({ message: `${removeSet.size}개 머리글/바닥글 요소 제거됨`, code: \"HIDDEN_TEXT_FILTERED\" })\n }\n\n return [...removeSet].sort((a, b) => a - b)\n}\n","/**\n * 클러스터 기반 테이블 감지 — 선이 없는 PDF에서 텍스트 정렬 패턴으로 테이블 구조 추론.\n *\n * Original work: Copyright 2025-2026 Hancom, Inc.\n * Licensed under the Apache License, Version 2.0\n * https://github.com/opendataloader-project/opendataloader-pdf\n *\n * ODL의 ClusterTableConsumer를 kordoc 컨텍스트에 맞게 단순화한 구현.\n * Modifications: TypeScript 재구현, 최소 2열 감지, 한국어 PDF 특화 최적화.\n *\n * 핵심 아이디어:\n * 1. 균등배분(개별 글자) 아이템을 사전 병합하여 노이즈 제거\n * 2. 헤더 행(짧은 라벨 + 넓은 갭) 감지 → 열 앵커로 사용\n * 3. 헤더 없으면 기존 X좌표 클러스터링으로 열 감지\n * 4. 다중행 셀(같은 논리 행이 여러 Y에 걸침) 병합\n */\n\nimport type { IRTable, IRCell, BoundingBox } from \"../types.js\"\n\n/** parser.ts의 NormItem과 동일한 인터페이스 */\nexport interface ClusterItem {\n text: string\n x: number\n y: number\n w: number\n h: number\n fontSize: number\n fontName: string\n /** pdfjs 공백 아이템이 직전에 있었음 — 단어 경계 힌트 */\n hasSpaceBefore?: boolean\n}\n\n// ─── 상수 ──────────────────────────────────────────────\n/** baseline 그룹핑 허용 오차 (pt) */\nconst Y_TOL = 3\n/** 열 클러스터링 허용 오차 (pt) */\nconst COL_CLUSTER_TOL = 15\n/** 테이블로 인정하기 위한 최소 행 수 */\nconst MIN_ROWS = 3\n/** 테이블로 인정하기 위한 최소 열 수 */\nconst MIN_COLS = 2\n/** 같은 행 내 아이템 간 최소 갭 (테이블 컬럼 구분) — fontSize 배수 */\nconst MIN_GAP_FACTOR = 2.0\n/** 같은 행 내 아이템 간 최소 갭 절대값 (pt) */\nconst MIN_GAP_ABSOLUTE = 20\n/** 열에 값이 있는 행의 비율 최소 기준 */\nconst MIN_COL_FILL_RATIO = 0.4\n\ninterface RowGroup {\n y: number // 대표 Y좌표 (평균 baseline)\n items: ClusterItem[]\n}\n\ninterface ColCluster {\n x: number // 열 X좌표 (왼쪽 경계)\n count: number // 이 열에 속한 아이템 수\n}\n\n/** 헤더 감지 결과: 열 앵커 + 헤더 행 인덱스 */\ninterface HeaderResult {\n columns: ColCluster[]\n headerIdx: number\n}\n\nexport interface ClusterTableResult {\n table: IRTable\n bbox: BoundingBox\n usedItems: Set<ClusterItem>\n}\n\n/**\n * 클러스터 기반 테이블 감지. 선이 없는 PDF의 fallback 경로에서 호출.\n */\nexport function detectClusterTables(items: ClusterItem[], pageNum: number): ClusterTableResult[] {\n if (items.length < MIN_ROWS * MIN_COLS) return []\n\n // 0. 균등배분 아이템 사전 병합 (개별 글자 → 단어)\n const { merged, originMap } = mergeEvenSpacedClusters(items)\n\n // 1. Y좌표로 행 그룹핑 + 첨자 행 복원\n // 본문 줄에서 살짝 올라간 각주 마커(*)·덧말은 baseline 그룹핑(Y_TOL)에서 별도 행으로\n // 떨어진다. 이런 조각 행이 표 헤더/열 앵커로 오인되면 본문 문단이 통째로 표에 흡수되므로\n // 수직으로 겹치는 행은 같은 시각적 줄로 병합한다.\n const rows = mergeOverlappingRows(groupByBaseline(merged))\n if (rows.length < MIN_ROWS) return []\n\n const results: ClusterTableResult[] = []\n\n // 2. 헤더 행 기반 열 감지 시도\n const headerResult = detectHeaderRow(rows)\n\n if (headerResult) {\n // 헤더 기반: 헤더 이후 행에서 boundary 기반 열 구조 매칭\n const { columns, headerIdx } = headerResult\n const headerRow = rows[headerIdx]\n const headerItems = [...headerRow.items].sort((a, b) => a.x - b.x)\n const headerAndBelow = rows.slice(headerIdx)\n // 다중행 셀 병합\n const mergedRows = mergeMultiLineRows(headerAndBelow, columns)\n // boundary 기반 region 탐색 (proximity 대신 headerItems 범위 사용)\n const tableRegions = findTableRegionsByHeader(mergedRows, columns, headerItems)\n for (const region of tableRegions) {\n const table = buildClusterTable(region.rows, columns, pageNum)\n if (table) {\n expandUsedItems(table.usedItems, originMap)\n results.push(table)\n }\n }\n }\n\n if (results.length === 0) {\n // 3. 기존 방식: suspicious gaps + column clustering\n const suspiciousRows = rows.filter(row => hasSuspiciousGaps(row))\n if (suspiciousRows.length >= MIN_ROWS) {\n const columns = extractColumnClusters(suspiciousRows)\n if (columns.length >= MIN_COLS) {\n const tableRegions = findTableRegions(rows, columns)\n for (const region of tableRegions) {\n const mergedRows = mergeMultiLineRows(region.rows, columns)\n const table = buildClusterTable(mergedRows, columns, pageNum)\n if (table) {\n expandUsedItems(table.usedItems, originMap)\n results.push(table)\n }\n }\n }\n }\n }\n\n // 4. 2단 조판 본문 오인 강등 — 걸러진 아이템은 usedItems에서 빠져\n // XY-Cut(단 분리) 경로로 흘러가 올바른 읽기 순서로 복원된다.\n return results.filter(r => !isTwoColumnProse(r))\n}\n\n// ─── 2단 조판 본문 판별 ─────────────────────────────────\n\n/**\n * 감지된 \"표\"가 실은 2단 조판 본문(국회 속기록·고전 관보류)인지 판별.\n *\n * 2단 본문에서는 문단 끝의 짧은 줄 쌍(\"한 것이올시다.\" | \"다.\")이 헤더 조건을\n * 우연히 만족해 페이지 본문 전체가 2열 표로 흡수된다. 진짜 표와 가르는 신호:\n * 양쪽 \"열\" 모두 긴 문장 조각(라벨 열 부재) + 숫자 희박 + 마커 희박 +\n * 좌우 단 폭 대칭 + 각 단의 오른쪽 끝이 justify로 정렬.\n * (코퍼스 42건 전수 시뮬레이션에서 속기록 4페이지만 발화, 오발화 0 검증)\n */\nfunction isTwoColumnProse(r: ClusterTableResult): boolean {\n const t = r.table\n if (t.rows < 8) return false\n\n // 실질 열: 채움률 30%+ 인 열이 정확히 2개 (유령 열은 무시)\n const dense: number[] = []\n for (let c = 0; c < t.cols; c++) {\n const filled = t.cells.filter(row => row[c]?.text.trim()).length\n if (filled / t.rows >= 0.3) dense.push(c)\n }\n if (dense.length !== 2) return false\n\n // 두 실질 열 모두 평균 셀 길이가 길어야 함 (진짜 표엔 짧은 라벨/숫자 열 존재)\n for (const c of dense) {\n const lens = t.cells.map(row => row[c]?.text.replace(/\\s+/g, \"\").length ?? 0).filter(n => n > 0)\n const avg = lens.reduce((s, v) => s + v, 0) / (lens.length || 1)\n if (avg < 12) return false\n }\n\n return findTwoColumnProseCutX([...r.usedItems]) !== null\n}\n\n/**\n * 아이템 집합이 좌우 2단 조판 본문인지 — 줄-투표로 중앙 빈 띠를 찾고\n * 양쪽이 모두 prose(긴 줄 + 숫자·마커 희박 + justify 정렬 + 폭 대칭)인지 검사.\n * 2단 본문이면 단 사이 컷 x좌표를, 아니면 null을 반환.\n * page-blocks에서 레거시 컬럼 감지 가드 + 강제 단 분리에 쓰인다.\n */\nexport function findTwoColumnProseCutX(items: ClusterItem[]): number | null {\n const lines = groupByBaseline(items)\n if (lines.length < 8) return null\n\n let minX = Infinity\n let maxX = -Infinity\n for (const i of items) {\n if (i.x < minX) minX = i.x\n if (i.x + i.w > maxX) maxX = i.x + i.w\n }\n // 비유한/과대 span 가드 — 손상 PDF의 오염된 좌표(±Infinity, 1e9 등)가 스캔 루프를\n // 폭주시키는 것 차단 (fuzz: bflip에서 페이지당 수십억 회 반복 실측)\n if (!Number.isFinite(maxX - minX)) return null\n if (maxX - minX < 100) return null\n\n // 중앙부(30~70%)를 2pt 격자로 스캔: 각 x를 덮는 줄 수가 최소인 지점 = 단 사이 빈 띠.\n // 프로젝션 최대 갭 방식과 달리 전폭 제목 몇 줄이 있어도 빈 띠를 찾는다.\n // 후보 수 상한 400 — 정상 판형(span<2000pt)은 2pt 그대로, 오염 좌표만 성긴 샘플링\n const lo = minX + (maxX - minX) * 0.3\n const hi = minX + (maxX - minX) * 0.7\n const step = Math.max(2, (hi - lo) / 400)\n let cutX = 0\n let bestCover = Infinity\n for (let x = lo; x <= hi; x += step) {\n let cover = 0\n for (const line of lines) {\n if (line.items.some(i => i.x < x && i.x + i.w > x)) cover++\n }\n if (cover < bestCover) { bestCover = cover; cutX = x }\n }\n // 빈 띠를 15% 넘는 줄이 가로지르면 2단 아님\n if (bestCover / lines.length > 0.15) return null\n\n // 줄 다수(55%+)가 컷 양쪽에 모두 텍스트를 가져야 함 (한쪽 들여쓰기 문서 배제).\n // 55%: 상단 1/3이 전폭 목차인 속기록 1면(57%)까지 포함하는 값 — 코퍼스 전수에서\n // 타 문서 오발화 0 확인\n const left: ClusterItem[] = []\n const right: ClusterItem[] = []\n let twoSide = 0\n for (const line of lines) {\n let hasL = false\n let hasR = false\n for (const i of line.items) {\n if (i.x + i.w <= cutX) { left.push(i); hasL = true }\n else if (i.x >= cutX) { right.push(i); hasR = true }\n }\n if (hasL && hasR) twoSide++\n }\n if (twoSide / lines.length < 0.55) return null\n if (left.length < 5 || right.length < 5) return null\n\n // 숫자·기호 위주(예산/통계표) 제외\n const allText = items.map(i => i.text).join(\"\").replace(/\\s+/g, \"\")\n const digits = (allText.match(/[\\d,.%△—-]/g) || []).length\n if (allText.length === 0 || digits / allText.length > 0.15) return null\n\n // 각 단: 줄 텍스트 길이 + 마커 밀도 + 폭 + 오른쪽 끝 justify 비율\n const stats = [left, right].map(side => {\n let sMinX = Infinity\n let sMaxR = -Infinity\n let fsSum = 0\n const rowEnds = new Map<number, number>()\n const rowLens = new Map<number, number>()\n const rowFirst = new Map<number, ClusterItem>()\n for (const i of side) {\n if (i.x < sMinX) sMinX = i.x\n const rgt = i.x + i.w\n if (rgt > sMaxR) sMaxR = rgt\n fsSum += i.fontSize\n const key = Math.round(i.y / 4)\n rowEnds.set(key, Math.max(rowEnds.get(key) ?? -Infinity, rgt))\n rowLens.set(key, (rowLens.get(key) ?? 0) + i.text.replace(/\\s+/g, \"\").length)\n const f = rowFirst.get(key)\n if (!f || i.x < f.x) rowFirst.set(key, i)\n }\n const ends = [...rowEnds.values()].sort((a, b) => a - b)\n const p85 = ends[Math.floor(ends.length * 0.85)] ?? sMaxR\n const fs = fsSum / side.length\n const justified = ends.filter(e => Math.abs(e - p85) <= fs).length / ends.length\n const lens = [...rowLens.values()]\n const avgLen = lens.reduce((s, v) => s + v, 0) / (lens.length || 1)\n const markers = [...rowFirst.values()].filter(i => /^[•▪◦‣∙·\\-–—□■◇◆▶※*]/.test(i.text)).length\n return { width: sMaxR - sMinX, justified, avgLen, markerRatio: markers / (rowFirst.size || 1) }\n })\n\n // 양쪽 모두 긴 문장 줄 (진짜 표엔 짧은 라벨/숫자 열 존재)\n if (stats.some(s => s.avgLen < 12)) return null\n // 불릿/마커 구조(SWOT 4분면·불릿 리스트) 제외\n if (stats.some(s => s.markerRatio > 0.1)) return null\n const widthSym = Math.min(stats[0].width, stats[1].width) / Math.max(stats[0].width, stats[1].width)\n if (widthSym < 0.6) return null\n // 본문 단은 마지막 문단 줄을 제외한 대부분 행이 오른쪽 끝까지 채워진다\n if (Math.min(stats[0].justified, stats[1].justified) < 0.55) return null\n return cutX\n}\n\n// ─── 균등배분 사전 병합 ──────────────────────────────────\n\n/** 균등배분(1글자+균일간격) 아이템을 단어 단위로 병합. 원본→병합 매핑도 반환. */\nfunction mergeEvenSpacedClusters(\n items: ClusterItem[],\n): { merged: ClusterItem[]; originMap: Map<ClusterItem, ClusterItem[]> } {\n const originMap = new Map<ClusterItem, ClusterItem[]>()\n const rows = groupByBaseline(items)\n const merged: ClusterItem[] = []\n\n for (const row of rows) {\n const sorted = [...row.items].sort((a, b) => a.x - b.x)\n let i = 0\n while (i < sorted.length) {\n if (/^[가-힣\\d]$/.test(sorted[i].text)) {\n let runEnd = i + 1\n while (runEnd < sorted.length && /^[가-힣\\d]$/.test(sorted[runEnd].text)) {\n // 명시적 공백 글리프 = 단어 경계 → run 분리 (Type3 글자 분리 배치 오판 방지)\n if (sorted[runEnd].hasSpaceBefore) break\n const gap = sorted[runEnd].x - (sorted[runEnd - 1].x + sorted[runEnd - 1].w)\n const fs = sorted[runEnd].fontSize\n if (gap < fs * 0.1 || gap > fs * 3) break\n runEnd++\n }\n if (runEnd - i >= 3) {\n const gaps: number[] = []\n for (let g = i + 1; g < runEnd; g++) {\n gaps.push(sorted[g].x - (sorted[g - 1].x + sorted[g - 1].w))\n }\n let minG = Infinity, maxG = -Infinity\n for (const g of gaps) { if (g < minG) minG = g; if (g > maxG) maxG = g }\n if (minG > 0 && maxG / minG <= 3) {\n const run = sorted.slice(i, runEnd)\n const text = run.map(r => r.text).join(\"\")\n const first = run[0], last = run[runEnd - i - 1]\n const item: ClusterItem = {\n text, x: first.x, y: first.y,\n w: (last.x + last.w) - first.x, h: first.h,\n fontSize: first.fontSize, fontName: first.fontName,\n }\n originMap.set(item, run)\n merged.push(item)\n i = runEnd\n continue\n }\n }\n }\n merged.push(sorted[i])\n i++\n }\n }\n return { merged, originMap }\n}\n\n/** 병합된 ClusterItem의 usedItems를 원본 아이템으로 확장 */\nfunction expandUsedItems(usedItems: Set<ClusterItem>, originMap: Map<ClusterItem, ClusterItem[]>): void {\n const toAdd: ClusterItem[] = []\n for (const item of usedItems) {\n const origins = originMap.get(item)\n if (origins) for (const o of origins) toAdd.push(o)\n }\n for (const a of toAdd) usedItems.add(a)\n}\n\n// ─── 헤더 행 기반 열 감지 ───────────────────────────────\n\n/**\n * 상위 행에서 테이블 헤더 후보를 탐색.\n * 조건: 2~6개 짧은 아이템, 최소 1개 큰 갭(>2.5x fontSize), 넓은 X 범위,\n * 한글 포함, 후속 행에서 최소 MIN_ROWS개가 헤더 열에 매칭\n */\nfunction detectHeaderRow(rows: RowGroup[]): HeaderResult | null {\n const allItems = rows.flatMap(r => r.items)\n if (allItems.length === 0) return null\n let allMinX = Infinity, allMaxX = -Infinity\n for (const i of allItems) { if (i.x < allMinX) allMinX = i.x; const r = i.x + i.w; if (r > allMaxX) allMaxX = r }\n const pageSpan = allMaxX - allMinX\n if (pageSpan <= 0) return null\n\n // 전체 행에서 헤더 후보 탐색 — 가드가 충분히 엄격하므로 범위 제한 불필요\n for (let ri = 0; ri < rows.length; ri++) {\n const row = rows[ri]\n // 2~6 아이템\n if (row.items.length < MIN_COLS || row.items.length > 6) continue\n // 모든 아이템 짧아야 함 (각 8자 이내)\n if (row.items.some(i => i.text.length > 8)) continue\n // 한글 포함 아이템이 최소 1개\n if (!row.items.some(i => /[가-힣]/.test(i.text))) continue\n // 마커(□○●·※▶-) 시작 아이템 제외\n if (row.items.some(i => /^[□■○●·※▶▷◆◇\\-]/.test(i.text))) continue\n\n const sorted = [...row.items].sort((a, b) => a.x - b.x)\n const xSpan = (sorted[sorted.length - 1].x + sorted[sorted.length - 1].w) - sorted[0].x\n\n // X 범위가 전체의 40%+ 차지\n if (xSpan / pageSpan < 0.4) continue\n\n // 최소 1개 갭이 avgFontSize의 2.5배 이상\n const avgFs = sorted.reduce((s, i) => s + i.fontSize, 0) / sorted.length\n let hasLargeGap = false\n for (let i = 1; i < sorted.length; i++) {\n const gap = sorted[i].x - (sorted[i - 1].x + sorted[i - 1].w)\n if (gap >= avgFs * 2.5) { hasLargeGap = true; break }\n }\n if (!hasLargeGap) continue\n\n // 열 앵커 생성\n const columns: ColCluster[] = sorted.map(item => ({ x: item.x, count: 0 }))\n\n // 후속 행 검증: 헤더 이후 MIN_ROWS+ 행이 2+ 열에 매칭\n let matchCount = 0\n for (let j = ri + 1; j < rows.length && matchCount < MIN_ROWS + 2; j++) {\n const matched = countMatchedColumnsRange(rows[j], columns, sorted)\n if (matched >= MIN_COLS) matchCount++\n }\n if (matchCount < MIN_ROWS) continue\n\n return { columns, headerIdx: ri }\n }\n return null\n}\n\n/**\n * 수직으로 겹치는 인접 행 병합 — 첨자(각주 마커/덧말)가 별도 행으로 분리된 것 복원.\n * 첨자 조각 행: 아이템이 적고(≤3) 짧으며(≤8자), 겹치는 행보다 글자 박스가 확실히\n * 작은 행(높이 ≤0.8배). 이런 행만 본문 줄에 흡수한다.\n * (rowspan 라벨처럼 키가 큰 셀이 이웃 행을 덮는 경우는 병합하지 않음 — 정상 표 구조)\n */\nfunction mergeOverlappingRows(rows: RowGroup[]): RowGroup[] {\n if (rows.length <= 1) return rows\n const result: RowGroup[] = [rows[0]]\n for (let i = 1; i < rows.length; i++) {\n const prev = result[result.length - 1]\n const curr = rows[i]\n const a = rowBand(prev)\n const b = rowBand(curr)\n const overlap = Math.min(a.top, b.top) - Math.max(a.bottom, b.bottom)\n const prevIsFrag = isFragmentRow(prev) && a.height <= b.height * 0.8 && overlap >= a.height * 0.5\n const currIsFrag = isFragmentRow(curr) && b.height <= a.height * 0.8 && overlap >= b.height * 0.5\n if (prevIsFrag || currIsFrag) {\n // 본문 줄(흡수하는 쪽)의 y를 대표값으로 유지\n const baseY = prevIsFrag ? curr.y : prev.y\n result[result.length - 1] = { y: baseY, items: [...prev.items, ...curr.items] }\n } else {\n result.push(curr)\n }\n }\n return result\n}\n\n/** 첨자 후보 행: 아이템 ≤3개, 모두 짧은 텍스트(≤8자) */\nfunction isFragmentRow(row: RowGroup): boolean {\n return row.items.length <= 3 && row.items.every(i => i.text.length <= 8)\n}\n\n/** 행 아이템들의 수직 범위 (bottom=min y, top=max y+h) */\nfunction rowBand(row: RowGroup): { bottom: number; top: number; height: number } {\n let bottom = Infinity, top = -Infinity\n for (const i of row.items) {\n const h = i.h > 0 ? i.h : i.fontSize\n if (i.y < bottom) bottom = i.y\n if (i.y + h > top) top = i.y + h\n }\n return { bottom, top, height: top - bottom }\n}\n\n// ─── 다중행 셀 병합 ────────────────────────────────────\n\n/** 연속 행에서 MIN_COLS 미만 열만 사용하고 Y갭이 작으면 이전 행에 병합 */\nfunction mergeMultiLineRows(rows: RowGroup[], columns: ColCluster[]): RowGroup[] {\n if (rows.length <= 1) return rows\n const result: RowGroup[] = [rows[0]]\n const allFontSizes = rows.flatMap(r => r.items).map(i => i.fontSize)\n const avgFontSize = allFontSizes.length > 0\n ? allFontSizes.reduce((s, v) => s + v, 0) / allFontSizes.length : 12\n\n for (let i = 1; i < rows.length; i++) {\n const prev = result[result.length - 1]\n const curr = rows[i]\n const yGap = Math.abs(prev.y - curr.y)\n const matchedCols = countMatchedColumns(curr, columns)\n\n // 병합 조건: Y갭이 작고 + 아이템 수가 적음 (연속 행 = 1~2개 아이템)\n // 아이템이 많은 행(3+)은 독립적인 데이터 행 → 병합하지 않음\n if (yGap < avgFontSize * 1.8 && curr.items.length <= 2 && (matchedCols < MIN_COLS || curr.items.length === 1)) {\n result[result.length - 1] = {\n y: prev.y,\n items: [...prev.items, ...curr.items],\n }\n } else {\n result.push(curr)\n }\n }\n return result\n}\n\n// ─── 기본 유틸 ──────────────────────────────────────────\n\n/** 아이템을 baseline(Y좌표)으로 그룹핑 */\nfunction groupByBaseline(items: ClusterItem[]): RowGroup[] {\n if (items.length === 0) return []\n\n const sorted = [...items].sort((a, b) => b.y - a.y || a.x - b.x)\n const rows: RowGroup[] = []\n let curItems: ClusterItem[] = [sorted[0]]\n let curY = sorted[0].y\n\n for (let i = 1; i < sorted.length; i++) {\n if (Math.abs(sorted[i].y - curY) <= Y_TOL) {\n curItems.push(sorted[i])\n } else {\n rows.push({ y: curY, items: curItems })\n curItems = [sorted[i]]\n curY = sorted[i].y\n }\n }\n if (curItems.length > 0) rows.push({ y: curY, items: curItems })\n\n return rows\n}\n\n/** 행 내 아이템 간 \"의심스러운\" 갭 존재 여부 (테이블 열 구분 후보) */\nfunction hasSuspiciousGaps(row: RowGroup): boolean {\n if (row.items.length < 2) return false\n\n const sorted = [...row.items].sort((a, b) => a.x - b.x)\n // 가드: 2아이템 행에서 두 번째 아이템이 긴 텍스트면 들여쓰기 단락\n if (sorted.length === 2 && sorted[1].text.length > 20) return false\n\n const avgFontSize = sorted.reduce((s, i) => s + i.fontSize, 0) / sorted.length\n const minGap = Math.max(avgFontSize * MIN_GAP_FACTOR, MIN_GAP_ABSOLUTE)\n\n for (let i = 1; i < sorted.length; i++) {\n const gap = sorted[i].x - (sorted[i - 1].x + sorted[i - 1].w)\n if (gap >= minGap) return true\n }\n return false\n}\n\n/** 의심스러운 행들의 X좌표에서 열 클러스터 추출 */\nfunction extractColumnClusters(rows: RowGroup[]): ColCluster[] {\n const allX: number[] = []\n for (const row of rows) {\n for (const item of row.items) allX.push(item.x)\n }\n if (allX.length === 0) return []\n\n allX.sort((a, b) => a - b)\n\n const clusters: ColCluster[] = []\n let clusterStart = 0\n\n for (let i = 1; i <= allX.length; i++) {\n if (i === allX.length || allX[i] - allX[i - 1] > COL_CLUSTER_TOL) {\n const slice = allX.slice(clusterStart, i)\n const avg = Math.round(slice.reduce((s, v) => s + v, 0) / slice.length)\n clusters.push({ x: avg, count: slice.length })\n clusterStart = i\n }\n }\n\n const minCount = Math.max(2, Math.floor(rows.length * MIN_COL_FILL_RATIO))\n return clusters\n .filter(c => c.count >= minCount)\n .sort((a, b) => a.x - b.x)\n}\n\n/** 헤더 기반 테이블 영역 찾기 — boundary 기반 열 매칭, 관대한 종료 조건 */\nfunction findTableRegionsByHeader(\n allRows: RowGroup[], columns: ColCluster[], headerItems: ClusterItem[],\n): { rows: RowGroup[] }[] {\n const regions: { rows: RowGroup[] }[] = []\n let currentRegion: RowGroup[] = []\n let missStreak = 0 // 연속 비매칭 행 수\n\n for (const row of allRows) {\n const matchedCols = countMatchedColumnsRange(row, columns, headerItems)\n if (matchedCols >= MIN_COLS) {\n currentRegion.push(row)\n missStreak = 0\n } else if (currentRegion.length > 0 && (row.items.length <= 2 || missStreak === 0)) {\n // 단일 비매칭은 허용 (multi-line 셀 or 단일 아이템)\n currentRegion.push(row)\n missStreak++\n } else {\n // 연속 2+ 비매칭 → 테이블 종료\n // 끝에 붙은 비매칭 행 제거\n while (currentRegion.length > 0) {\n const last = currentRegion[currentRegion.length - 1]\n if (countMatchedColumnsRange(last, columns, headerItems) >= MIN_COLS) break\n currentRegion.pop()\n }\n if (currentRegion.length >= MIN_ROWS) {\n regions.push({ rows: [...currentRegion] })\n }\n currentRegion = []\n missStreak = 0\n }\n }\n\n // 끝에 붙은 비매칭 행 정리\n while (currentRegion.length > 0) {\n const last = currentRegion[currentRegion.length - 1]\n if (countMatchedColumnsRange(last, columns, headerItems) >= MIN_COLS) break\n currentRegion.pop()\n }\n if (currentRegion.length >= MIN_ROWS) {\n regions.push({ rows: currentRegion })\n }\n\n return regions\n}\n\n/** 연속된 테이블 행 영역 찾기 */\nfunction findTableRegions(allRows: RowGroup[], columns: ColCluster[]): { rows: RowGroup[] }[] {\n const regions: { rows: RowGroup[] }[] = []\n let currentRegion: RowGroup[] = []\n\n for (const row of allRows) {\n const matchedCols = countMatchedColumns(row, columns)\n if (matchedCols >= MIN_COLS) {\n currentRegion.push(row)\n } else if (row.items.length === 1) {\n if (currentRegion.length > 0) {\n currentRegion.push(row)\n }\n } else {\n if (currentRegion.length >= MIN_ROWS) {\n regions.push({ rows: [...currentRegion] })\n }\n currentRegion = []\n }\n }\n\n if (currentRegion.length >= MIN_ROWS) {\n regions.push({ rows: currentRegion })\n }\n\n return regions\n}\n\n/** 행의 아이템이 몇 개의 열에 매칭되는지 (X좌표 근접) */\nfunction countMatchedColumns(row: RowGroup, columns: ColCluster[]): number {\n const matched = new Set<number>()\n for (const item of row.items) {\n for (let ci = 0; ci < columns.length; ci++) {\n if (Math.abs(item.x - columns[ci].x) <= COL_CLUSTER_TOL * 2) {\n matched.add(ci)\n break\n }\n }\n }\n return matched.size\n}\n\n/**\n * 범위 기반 열 매칭 — 헤더 아이템의 x~x+w 범위 내에 있는지 확인.\n * 헤더 열 간 중간점을 경계로 사용하여 넓은 범위 매칭.\n */\nfunction countMatchedColumnsRange(\n row: RowGroup, columns: ColCluster[], headerItems: ClusterItem[],\n): number {\n // 열 경계 계산: 헤더 아이템 사이 중간점\n const boundaries: { left: number; right: number }[] = []\n for (let ci = 0; ci < headerItems.length; ci++) {\n const left = ci === 0 ? 0 : (headerItems[ci - 1].x + headerItems[ci - 1].w + headerItems[ci].x) / 2\n const right = ci === headerItems.length - 1\n ? Infinity\n : (headerItems[ci].x + headerItems[ci].w + headerItems[ci + 1].x) / 2\n boundaries.push({ left, right })\n }\n\n const matched = new Set<number>()\n for (const item of row.items) {\n for (let ci = 0; ci < boundaries.length; ci++) {\n if (item.x >= boundaries[ci].left && item.x < boundaries[ci].right) {\n matched.add(ci)\n break\n }\n }\n }\n return matched.size\n}\n\n/**\n * 행별 갭 분석으로 아이템을 열에 배정.\n *\n * 전역 경계선 대신 각 행을 개별 분석:\n * 1) 행 내 N-1개의 가장 큰 갭으로 N개 그룹 분할\n * 2) 각 그룹의 중심 X를 헤더 열 중심과 비교하여 최적 열 배정\n * 3) 갭이 불충분한 행(아이템 적음)은 헤더 중간점 기반 fallback\n *\n * 이 방식은 열 폭이 행마다 달라도 정확하게 분리.\n */\nfunction assignRowItems(\n items: ClusterItem[], columns: ColCluster[], numCols: number,\n): { col: number; items: ClusterItem[] }[] {\n if (items.length === 0) return []\n const sorted = [...items].sort((a, b) => a.x - b.x)\n\n // 헤더 열 중심 좌표\n const colCenters = columns.map(c => c.x)\n\n // 행 내 갭 계산\n const gaps: { idx: number; size: number }[] = []\n for (let i = 1; i < sorted.length; i++) {\n gaps.push({ idx: i, size: sorted[i].x - (sorted[i - 1].x + sorted[i - 1].w) })\n }\n\n // N-1개의 가장 큰 갭 선택 — 적응형 임계값\n // 아이템이 적은 행(헤더, 라벨 행): 낮은 절대 임계값 (모든 갭이 유의미할 가능성 높음)\n // 아이템이 많은 행(본문 데이터): 상대적 임계값 (워드 갭 vs 컬럼 갭 구분)\n const gapSizes = gaps.map(g => g.size).sort((a, b) => a - b)\n const medianGap = gapSizes.length > 0 ? gapSizes[Math.floor(gapSizes.length / 2)] : 0\n const gapThreshold = sorted.length <= numCols + 1\n ? 12 // 희소 행: 낮은 절대 임계값\n : Math.max(medianGap * 2.5, 12) // 밀집 행: 상대적 임계값\n const significantGaps = gaps\n .filter(g => g.size >= gapThreshold)\n .sort((a, b) => b.size - a.size)\n .slice(0, numCols - 1)\n .sort((a, b) => a.idx - b.idx) // 위치 순 복원\n\n // 가용 갭으로 그룹 분할 (갭이 부족해도 있는 만큼 분할)\n const groups: ClusterItem[][] = []\n let start = 0\n for (const gap of significantGaps) {\n groups.push(sorted.slice(start, gap.idx))\n start = gap.idx\n }\n groups.push(sorted.slice(start))\n\n // 각 그룹 → 가장 가까운 헤더 열에 배정\n const result: { col: number; items: ClusterItem[] }[] = []\n const usedCols = new Set<number>()\n // 그룹별 중심 X 계산\n const groupCenters = groups.map(g => {\n let minX = Infinity, maxX = -Infinity\n for (const i of g) { if (i.x < minX) minX = i.x; const r = i.x + i.w; if (r > maxX) maxX = r }\n return (minX + maxX) / 2\n })\n\n // 탐욕적 배정: 각 그룹을 가장 가까운 미사용 열에 배정\n const assignments: { gi: number; ci: number; dist: number }[] = []\n for (let gi = 0; gi < groups.length; gi++) {\n for (let ci = 0; ci < numCols; ci++) {\n assignments.push({ gi, ci, dist: Math.abs(groupCenters[gi] - colCenters[ci]) })\n }\n }\n assignments.sort((a, b) => a.dist - b.dist)\n\n const assignedGroups = new Set<number>()\n for (const { gi, ci } of assignments) {\n if (assignedGroups.has(gi) || usedCols.has(ci)) continue\n result.push({ col: ci, items: groups[gi] })\n assignedGroups.add(gi)\n usedCols.add(ci)\n }\n // 남은 그룹 (numGroups > numCols인 경우 — 가장 가까운 열에 추가)\n for (let gi = 0; gi < groups.length; gi++) {\n if (assignedGroups.has(gi)) continue\n let bestCol = 0, bestDist = Infinity\n for (let ci = 0; ci < numCols; ci++) {\n const d = Math.abs(groupCenters[gi] - colCenters[ci])\n if (d < bestDist) { bestDist = d; bestCol = ci }\n }\n result.push({ col: bestCol, items: groups[gi] })\n }\n\n return result\n}\n\n/** 클러스터 테이블을 IRTable로 구성 */\nfunction buildClusterTable(\n rows: RowGroup[],\n columns: ColCluster[],\n pageNum: number,\n): ClusterTableResult | null {\n const numCols = columns.length\n const numRows = rows.length\n\n if (numRows < MIN_ROWS || numCols < MIN_COLS) return null\n\n const cells: IRCell[][] = Array.from(\n { length: numRows },\n () => Array.from({ length: numCols }, () => ({ text: \"\", colSpan: 1, rowSpan: 1 })),\n )\n\n const usedItems = new Set<ClusterItem>()\n\n for (let r = 0; r < numRows; r++) {\n const row = rows[r]\n // 단일 아이템 행 → 전체 행 병합 (colSpan)\n if (row.items.length === 1 && numCols > 1) {\n cells[r][0] = { text: row.items[0].text, colSpan: numCols, rowSpan: 1 }\n usedItems.add(row.items[0])\n continue\n }\n\n // 행별 갭 분석 기반 열 배정\n const assignments = assignRowItems(row.items, columns, numCols)\n for (const { col, items } of assignments) {\n const text = items.map(i => i.text).join(\" \")\n const existing = cells[r][col].text\n cells[r][col].text = existing ? existing + \" \" + text : text\n for (const item of items) usedItems.add(item)\n }\n }\n\n // 검증: 빈 행이 너무 많으면 테이블 아님\n let emptyRows = 0\n for (const row of cells) {\n if (row.every(c => c.text === \"\")) emptyRows++\n }\n if (emptyRows > numRows * 0.5) return null\n\n // 검증: 모든 열에 최소 1개 값이 있어야 함\n for (let c = 0; c < numCols; c++) {\n const hasValue = cells.some(row => row[c].text !== \"\")\n if (!hasValue) return null\n }\n\n // 후처리 1: 단일 열 연속 행 → 이전 행에 역방향 병합 (multi-line 셀)\n // 조건: 1개 열만 내용 + col0 비어있음 + 리스트 마커(○-·)로 시작하지 않음\n for (let r = numRows - 1; r >= 1; r--) {\n const nonEmptyCols = cells[r].filter(c => c.text.trim()).length\n if (nonEmptyCols !== 1) continue\n if (cells[r][0].text.trim() !== \"\") continue\n const contentText = cells[r].find(c => c.text.trim())?.text.trim() || \"\"\n // 리스트 마커로 시작하면 새 항목 → 병합하지 않음\n if (/^[○●▶\\-·]/.test(contentText)) continue\n for (let pr = r - 1; pr >= 0; pr--) {\n if (cells[pr].some(c => c.text.trim())) {\n for (let c = 0; c < numCols; c++) {\n const prev = cells[pr][c].text.trim()\n const curr = cells[r][c].text.trim()\n if (curr) cells[pr][c].text = prev ? prev + \" \" + curr : curr\n }\n for (let c = 0; c < numCols; c++) cells[r][c].text = \"\"\n break\n }\n }\n }\n\n // 후처리 2: 번호만 있는 행(col0+마지막열만 값) → 다음 내용 행과 순방향 병합\n // 예: 목차 \"|3|||3|\" + \"|청사 내 에너지...|행정지원과||\" → \"|3|청사...|행정지원과|3|\"\n for (let r = 0; r < cells.length - 1; r++) {\n const row = cells[r]\n const hasCol0 = row[0].text.trim() !== \"\"\n const hasColLast = numCols > 1 && row[numCols - 1].text.trim() !== \"\"\n const midEmpty = row.slice(1, numCols - 1).every(c => c.text.trim() === \"\")\n if (hasCol0 && hasColLast && midEmpty) {\n // 다음 행에 col0이 비어있으면 병합\n const next = cells[r + 1]\n if (next[0].text.trim() === \"\" && next.some(c => c.text.trim())) {\n for (let c = 1; c < numCols; c++) {\n const curr = next[c].text.trim()\n if (curr) row[c].text = row[c].text.trim() ? row[c].text.trim() + \" \" + curr : curr\n }\n for (let c = 0; c < numCols; c++) next[c].text = \"\"\n }\n }\n }\n\n // 빈 행 제거\n const filteredCells = cells.filter(row => row.some(c => c.text.trim()))\n const finalRowCount = filteredCells.length\n\n // 검증\n if (finalRowCount < MIN_ROWS) return null\n\n const irTable: IRTable = {\n rows: finalRowCount,\n cols: numCols,\n cells: filteredCells,\n hasHeader: finalRowCount > 1,\n }\n\n // BBox 계산\n const allItems = rows.flatMap(r => r.items)\n let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity\n for (const i of allItems) {\n if (i.x < minX) minX = i.x\n if (i.y < minY) minY = i.y\n if (i.x + i.w > maxX) maxX = i.x + i.w\n const h = i.h > 0 ? i.h : i.fontSize\n if (i.y + h > maxY) maxY = i.y + h\n }\n\n return {\n table: irTable,\n bbox: { page: pageNum, x: minX, y: minY, width: maxX - minX, height: maxY - minY },\n usedItems,\n }\n}\n","/**\n * XY-Cut++ 읽기 순서 알고리즘 (arXiv:2504.10258)\n *\n * OpenDataLoader PDF의 XYCutPlusPlusSorter를 TypeScript로 포팅.\n * Original work: Copyright 2025-2026 Hancom Inc. (Apache-2.0)\n * https://github.com/opendataloader-project/opendataloader-pdf\n *\n * 기존 XY-Cut 대비 개선 3종:\n * ① cross-layout 전폭 요소(제목 등) 마스크 후 Y 위치로 재삽입\n * ② 좁은 요소(쪽번호류) 아웃라이어 필터 후 수직 컷 재시도\n * ③ 양방향(수평/수직) 컷을 모두 계산해 더 큰 갭 선택 + 최소 갭 5pt\n */\n\nimport type { NormItem } from \"./text-line.js\"\n\n/** 재귀 깊이 제한 — 수천 아이템의 pathological 레이아웃에서 스택 오버플로 방지 */\nconst MAX_XYCUT_DEPTH = 50\n/** 분할 최소 갭 (pt) — 미세 갭(1px급) 분할 방지 (ODL MIN_GAP_THRESHOLD) */\nconst XYCUT_MIN_GAP = 5\n/** cross-layout 판정: 최대폭 대비 비율 (ODL beta) — ODL 기본값 2.0 (사실상 비활성) */\nconst CROSS_LAYOUT_BETA = 2.0\n/** cross-layout 판정: 수평 겹침 비율 최소값 */\nconst CROSS_OVERLAP_RATIO = 0.1\n/** cross-layout 판정: 최소 겹침 요소 수 */\nconst CROSS_MIN_OVERLAPS = 2\n/** cross-layout 마스크 상한 — 전체의 20% 초과 마스크 시 비활성 (단일 컬럼 문서 보호) */\nconst CROSS_MAX_MASK_RATIO = 0.2\n/** 좁은 요소 아웃라이어 필터: 영역 폭 대비 비율 (쪽번호·각주 마커) */\nconst NARROW_ELEMENT_WIDTH_RATIO = 0.1\n\ninterface CutInfo {\n position: number\n gap: number\n}\n\nexport function xyCutOrder(items: NormItem[], gapThreshold: number, depth = 0): NormItem[][] {\n if (items.length === 0) return []\n if (items.length <= 2 || depth >= MAX_XYCUT_DEPTH) return [items]\n\n // Phase 1 (최상위에서만): cross-layout 전폭 요소 마스크\n if (depth === 0 && items.length >= 3) {\n const cross = identifyCrossLayoutItems(items)\n if (cross.size > 0 && cross.size <= items.length * CROSS_MAX_MASK_RATIO) {\n const rest = items.filter(i => !cross.has(i))\n if (rest.length > 0) {\n const groups = xyCutOrder(rest, gapThreshold, 1)\n return mergeCrossLayoutGroups(groups, [...cross])\n }\n }\n }\n\n // Phase 3: 양방향 컷 계산 → 더 큰 갭 선택 (기존: Y 무조건 우선 → 2단 인터리브)\n const minGap = Math.max(XYCUT_MIN_GAP, gapThreshold)\n const hCut = findHorizontalCut(items)\n const vCut = findVerticalCutWithOutlierFilter(items, minGap)\n\n const hValid = hCut.gap >= minGap\n const vValid = vCut.gap >= minGap\n\n // 축 선택: 기본 Y 우선 (한국 공문서는 단일 컬럼 위주 — 코퍼스 검증 결과 Y 우선이 안정적).\n // 단, 수직 갭이 수평 갭보다 명백히 크면(1.5×) 컬럼 분리로 보고 X 우선\n // → 2단 레이아웃에서 문단 간 수평 갭이 단 사이 수직 갭보다 먼저 잡혀 행 단위로\n // 인터리브되는 문제 방지 (XY-Cut++ 양방향 컷의 보수적 적용)\n let useHorizontal: boolean\n if (hValid && vValid) useHorizontal = vCut.gap <= hCut.gap * 1.5\n else if (hValid) useHorizontal = true\n else if (vValid) useHorizontal = false\n else return [items] // 분할 불가 → 리프 노드\n\n if (useHorizontal) {\n const upper = items.filter(i => i.y > hCut.position)\n const lower = items.filter(i => i.y <= hCut.position)\n if (upper.length > 0 && lower.length > 0 && upper.length < items.length) {\n return [...xyCutOrder(upper, gapThreshold, depth + 1), ...xyCutOrder(lower, gapThreshold, depth + 1)]\n }\n } else {\n const left = items.filter(i => i.x + i.w / 2 < vCut.position)\n const right = items.filter(i => i.x + i.w / 2 >= vCut.position)\n if (left.length > 0 && right.length > 0 && left.length < items.length) {\n return [...xyCutOrder(left, gapThreshold, depth + 1), ...xyCutOrder(right, gapThreshold, depth + 1)]\n }\n }\n\n return [items]\n}\n\n/**\n * cross-layout 요소 식별: 폭 ≥ beta×최대폭 + 다른 요소 2개 이상과 수평 겹침.\n * 전폭 제목/헤더가 컬럼 분할을 가로막는 것을 방지.\n */\nfunction identifyCrossLayoutItems(items: NormItem[]): Set<NormItem> {\n const cross = new Set<NormItem>()\n if (items.length < 3) return cross\n\n let maxWidth = 0\n for (const i of items) { if (i.w > maxWidth) maxWidth = i.w }\n const threshold = CROSS_LAYOUT_BETA * maxWidth\n\n for (const item of items) {\n if (item.w < threshold) continue\n let overlaps = 0\n for (const other of items) {\n if (other === item) continue\n const left = Math.max(item.x, other.x)\n const right = Math.min(item.x + item.w, other.x + other.w)\n const overlapW = right - left\n if (overlapW <= 0) continue\n const smaller = Math.min(item.w, other.w)\n if (smaller > 0 && overlapW / smaller >= CROSS_OVERLAP_RATIO) {\n overlaps++\n if (overlaps >= CROSS_MIN_OVERLAPS) break\n }\n }\n if (overlaps >= CROSS_MIN_OVERLAPS) cross.add(item)\n }\n return cross\n}\n\n/** cross-layout 요소를 Y 위치 기준으로 그룹 시퀀스에 재삽입 (각자 단독 그룹) */\nfunction mergeCrossLayoutGroups(groups: NormItem[][], cross: NormItem[]): NormItem[][] {\n if (cross.length === 0) return groups\n const sortedCross = [...cross].sort((a, b) => (b.y + b.h) - (a.y + a.h) || a.x - b.x)\n const groupTop = (g: NormItem[]) => {\n let top = -Infinity\n for (const i of g) { const t = i.y + i.h; if (t > top) top = t }\n return top\n }\n\n const result: NormItem[][] = []\n let gi = 0, ci = 0\n while (gi < groups.length || ci < sortedCross.length) {\n if (ci >= sortedCross.length) { result.push(groups[gi++]); continue }\n if (gi >= groups.length) { result.push([sortedCross[ci++]]); continue }\n const crossTop = sortedCross[ci].y + sortedCross[ci].h\n if (crossTop >= groupTop(groups[gi])) result.push([sortedCross[ci++]])\n else result.push(groups[gi++])\n }\n return result\n}\n\n/**\n * 수평 컷(Y축 분할) — Y 프로젝션에서 가장 넓은 갭.\n * 갭/분할점 계산은 기존 findYSplit과 동일 (y-h를 하단으로 보는 bbox 모델 유지 —\n * 코퍼스 검증 결과 모델 변경 시 행 분할점이 이동해 회귀 발생).\n */\nfunction findHorizontalCut(items: NormItem[]): CutInfo {\n if (items.length < 2) return { position: 0, gap: 0 }\n const sorted = [...items].sort((a, b) => b.y - a.y)\n let largestGap = 0\n let position = 0\n\n for (let i = 1; i < sorted.length; i++) {\n const prevBottom = sorted[i - 1].y - sorted[i - 1].h\n const currTop = sorted[i].y\n const gap = prevBottom - currTop\n if (gap > largestGap) {\n largestGap = gap\n position = (prevBottom + currTop) / 2\n }\n }\n return { position, gap: largestGap }\n}\n\n/**\n * 수직 컷(X축 분할) — 갭이 안 나오면 좁은 요소(쪽번호류) 제외 후 재시도.\n * 쪽번호가 2단 컬럼 사이 갭을 가로막는 경우 복구 (ODL ②).\n */\nfunction findVerticalCutWithOutlierFilter(items: NormItem[], minGap: number): CutInfo {\n const edgeCut = findVerticalCut(items)\n if (edgeCut.gap >= minGap) return edgeCut\n\n if (items.length >= 3) {\n let minX = Infinity, maxX = -Infinity\n for (const i of items) {\n if (i.x < minX) minX = i.x\n const r = i.x + i.w\n if (r > maxX) maxX = r\n }\n const narrowThreshold = (maxX - minX) * NARROW_ELEMENT_WIDTH_RATIO\n const filtered = items.filter(i => i.w >= narrowThreshold)\n // 아웃라이어는 소수여야 함 (쪽번호 1~2개) — 단어 단위 아이템이 대량 필터되면\n // 본문에서 가짜 컬럼 갭이 만들어지므로 70% 이상 유지될 때만 재시도\n if (filtered.length >= 2 && filtered.length < items.length && filtered.length >= items.length * 0.7) {\n const filteredCut = findVerticalCut(filtered)\n if (filteredCut.gap > edgeCut.gap && filteredCut.gap >= minGap) {\n return filteredCut\n }\n }\n }\n return edgeCut\n}\n\n/** 수직 컷 — X 프로젝션에서 가장 넓은 갭 */\nfunction findVerticalCut(items: NormItem[]): CutInfo {\n if (items.length < 2) return { position: 0, gap: 0 }\n const sorted = [...items].sort((a, b) => a.x - b.x || (a.x + a.w) - (b.x + b.w))\n let largestGap = 0\n let position = 0\n let prevRight: number | null = null\n\n for (const it of sorted) {\n const left = it.x\n const right = it.x + it.w\n if (prevRight !== null && left > prevRight) {\n const gap = left - prevRight\n if (gap > largestGap) {\n largestGap = gap\n position = (prevRight + left) / 2\n }\n }\n prevRight = prevRight === null ? right : Math.max(prevRight, right)\n }\n return { position, gap: largestGap }\n}\n","/**\n * 열 경계 감지 + 열 기반 텍스트/테이블 추출 (선 없는 PDF fallback 경로).\n *\n * x-히스토그램 클러스터링으로 열 경계를 찾고(detectColumns),\n * y-라인을 열에 배치해 마크다운 표를 조립한다(extractWithColumns → buildGridTable).\n */\n\nimport { safeMin, safeMax } from \"../utils.js\"\nimport { type NormItem, mergeLineSimple, collapseEvenSpacing } from \"./text-line.js\"\n\n// ═══════════════════════════════════════════════════════\n// 열 경계 감지 — 빈도 기반 x-히스토그램 클러스터링\n// ═══════════════════════════════════════════════════════\n\n/** prose 라인 판별: 아이템 간 gap이 모두 작으면 문장 (단어 나열) */\nfunction isProseSpread(items: NormItem[]): boolean {\n if (items.length < 4) return false\n const sorted = [...items].sort((a, b) => a.x - b.x)\n const gaps: number[] = []\n for (let i = 1; i < sorted.length; i++) {\n gaps.push(sorted[i].x - (sorted[i - 1].x + sorted[i - 1].w))\n }\n // gap의 최대값이 작고 평균 단어 길이가 짧으면 prose\n const maxGap = safeMax(gaps)\n const avgLen = items.reduce((s, i) => s + i.text.length, 0) / items.length\n // 짧은 단어들이 좁은 간격으로 나열 = prose (예: \"위 표 제3호나목에서 남은 유효기간...\")\n return maxGap < 40 && avgLen < 5\n}\n\nexport function detectColumns(yLines: NormItem[][]): number[] | null {\n const allItems = yLines.flat()\n if (allItems.length === 0) return null\n const pageWidth = safeMax(allItems.map(i => i.x + i.w)) - safeMin(allItems.map(i => i.x))\n if (pageWidth < 100) return null\n\n // \"비고\" 이전 아이템만 사용 (비고 이후는 prose)\n let bigoLineIdx = -1\n for (let i = 0; i < yLines.length; i++) {\n if (yLines[i].length <= 2 && yLines[i].some(item => item.text === \"비고\")) {\n bigoLineIdx = i\n break\n }\n }\n const tableYLines = bigoLineIdx >= 0 ? yLines.slice(0, bigoLineIdx) : yLines\n\n // Step 1: 모든 아이템의 x를 수집 (prose 라인 제외)\n // CLUSTER_TOL 22px — 한국 공문서 PDF 열 간격에 최적화, 별표 표 열 감지 핵심값\n const CLUSTER_TOL = 22\n const xClusters: { center: number; count: number; minX: number }[] = []\n\n for (const line of tableYLines) {\n if (isProseSpread(line)) continue\n for (const item of line) {\n let found = false\n for (const c of xClusters) {\n if (Math.abs(item.x - c.center) <= CLUSTER_TOL) {\n c.center = Math.round((c.center * c.count + item.x) / (c.count + 1))\n c.minX = Math.min(c.minX, item.x)\n c.count++\n found = true\n break\n }\n }\n if (!found) {\n xClusters.push({ center: item.x, count: 1, minX: item.x })\n }\n }\n }\n\n // Step 2: 빈도 피크 — 최소 3회 이상 등장 (단발성 텍스트 노이즈 제거)\n const peaks = xClusters\n .filter(c => c.count >= 3)\n .sort((a, b) => a.minX - b.minX)\n\n // 최소 3개 열이 있어야 테이블로 판별 — 2열은 일반 2단 레이아웃과 구분 불가\n if (peaks.length < 3) return null\n\n // Step 3: 가까운 피크 병합 — MERGE_TOL 40px (같은 논리 열의 미세 위치 차이 흡수)\n const MERGE_TOL = 40\n const merged: { center: number; count: number; minX: number }[] = [peaks[0]]\n for (let i = 1; i < peaks.length; i++) {\n const prev = merged[merged.length - 1]\n if (peaks[i].minX - prev.minX < MERGE_TOL) {\n // 빈도 높은 쪽 유지, 최소 x는 작은 값\n if (peaks[i].count > prev.count) {\n prev.center = peaks[i].center\n }\n prev.count += peaks[i].count\n prev.minX = Math.min(prev.minX, peaks[i].minX)\n } else {\n merged.push({ ...peaks[i] })\n }\n }\n\n // 열 경계 = 각 클러스터의 minX (왼쪽 정렬 기준), 병합 후 재검증\n const rawColumns = merged.filter(c => c.count >= 3).map(c => c.minX)\n if (rawColumns.length < 3) return null\n\n // 최소 열 폭 검증: 30px 미만인 열은 인접 열과 병합 (한 글자 열 방지)\n const MIN_DETECT_COL_WIDTH = 30\n const columns: number[] = [rawColumns[0]]\n for (let i = 1; i < rawColumns.length; i++) {\n if (rawColumns[i] - columns[columns.length - 1] < MIN_DETECT_COL_WIDTH) continue\n columns.push(rawColumns[i])\n }\n return columns.length >= 3 ? columns : null\n}\n\nfunction findColumn(x: number, columns: number[]): number {\n for (let i = columns.length - 1; i >= 0; i--) {\n // 10px 왼쪽 허용 오차 — 셀 내 텍스트 미세 좌측 이탈 보정\n if (x >= columns[i] - 10) return i\n }\n return 0\n}\n\n// ═══════════════════════════════════════════════════════\n// 열 기반 추출 — 테이블/텍스트 영역 분리\n// ═══════════════════════════════════════════════════════\n\nexport function extractWithColumns(yLines: NormItem[][], columns: number[]): string {\n const result: string[] = []\n const colMin = columns[0]\n const colMax = columns[columns.length - 1]\n\n // \"비고\" 라인 감지 — 이후는 텍스트로 처리\n let bigoIdx = -1\n for (let i = 0; i < yLines.length; i++) {\n if (yLines[i].length <= 2 && yLines[i].some(item => item.text === \"비고\")) {\n bigoIdx = i\n break\n }\n }\n\n // 테이블 시작: 첫 번째 다열(3+ 열 사용) 라인\n let tableStart = -1\n for (let i = 0; i < (bigoIdx >= 0 ? bigoIdx : yLines.length); i++) {\n const usedCols = new Set(yLines[i].map(item => findColumn(item.x, columns)))\n if (usedCols.size >= 3) {\n tableStart = i\n break\n }\n }\n\n const tableEnd = bigoIdx >= 0 ? bigoIdx : yLines.length\n\n // 테이블 시작 이전 = 텍스트\n for (let i = 0; i < (tableStart >= 0 ? tableStart : tableEnd); i++) {\n result.push(mergeLineSimple(yLines[i]))\n }\n\n // 테이블 영역: 모든 라인을 그리드에 포함 (단일 아이템 라인도)\n if (tableStart >= 0) {\n const tableLines = yLines.slice(tableStart, tableEnd)\n // 테이블 x범위 밖의 라인만 텍스트로 분리\n // 좌측 20px, 우측 200px 허용 — 비고/주석 열이 오른쪽에 넓게 위치하는 공문서 특성 반영\n const gridLines: NormItem[][] = []\n for (const line of tableLines) {\n const inRange = line.some(item =>\n item.x >= colMin - 20 && item.x <= colMax + 200\n )\n if (inRange && !isProseSpread(line)) {\n gridLines.push(line)\n } else {\n // 그리드 밖 라인은 현재까지 축적된 그리드 출력 후 텍스트로\n if (gridLines.length > 0) {\n result.push(buildGridTable(gridLines.splice(0), columns))\n }\n result.push(mergeLineSimple(line))\n }\n }\n if (gridLines.length > 0) {\n result.push(buildGridTable(gridLines, columns))\n }\n }\n\n // 비고 영역\n if (bigoIdx >= 0) {\n result.push(\"\")\n for (let i = bigoIdx; i < yLines.length; i++) {\n result.push(mergeLineSimple(yLines[i]))\n }\n }\n\n return result.join(\"\\n\")\n}\n\n// ═══════════════════════════════════════════════════════\n// 그리드 테이블 빌더 — y-라인을 열에 배치 후 행 병합\n// ═══════════════════════════════════════════════════════\n\nfunction buildGridTable(lines: NormItem[][], columns: number[]): string {\n const numCols = columns.length\n\n // Step 1: 각 y-라인을 열에 배치\n const yRows: string[][] = lines.map(items => {\n const row = Array(numCols).fill(\"\")\n for (const item of items) {\n const col = findColumn(item.x, columns)\n row[col] = row[col] ? row[col] + \" \" + item.text : item.text\n }\n return row\n })\n\n // Step 2: 행 병합 — 새 논리적 행 판별\n // 데이터 열 기준점 (가격 등이 들어가는 오른쪽 열들)\n const dataColStart = Math.max(2, Math.floor(numCols / 2))\n const merged: string[][] = []\n\n for (const row of yRows) {\n if (row.every(c => c === \"\")) continue\n\n if (merged.length === 0) {\n merged.push([...row])\n continue\n }\n\n const prev = merged[merged.length - 1]\n const filledCols = row.map((c, i) => c ? i : -1).filter(i => i >= 0)\n const filledCount = filledCols.length\n\n let isNewRow = false\n\n // Rule 1: col 0에 텍스트 (3글자 이상) → 새 행 (단, \"권\"처럼 짧은 건 continuation)\n if (row[0] && row[0].length >= 3) {\n isNewRow = true\n }\n\n // Rule 2: col 1에 텍스트 → 항상 새 행 (새 항목 시작)\n if (!isNewRow && numCols > 1 && row[1]) {\n isNewRow = true\n }\n\n // Rule 3: 데이터 열(3+)에 새 값이 있고 이전 행 데이터 열에도 이미 값 있음 → 새 가격 행\n if (!isNewRow) {\n const hasData = row.slice(dataColStart).some(c => c !== \"\")\n const prevHasData = prev.slice(dataColStart).some(c => c !== \"\")\n if (hasData && prevHasData) {\n isNewRow = true\n }\n }\n\n // Exception: filledCount=1이고 col 0에 짧은 텍스트(≤2자) → word continuation (예: \"권\", \"여권\")\n if (isNewRow && filledCount === 1 && row[0] && row[0].length <= 2) {\n isNewRow = false\n }\n\n if (isNewRow) {\n merged.push([...row])\n } else {\n for (let c = 0; c < numCols; c++) {\n if (row[c]) {\n prev[c] = prev[c] ? prev[c] + \" \" + row[c] : row[c]\n }\n }\n }\n }\n\n if (merged.length < 2) {\n return merged.map(r => r.filter(c => c).join(\" \")).join(\"\\n\")\n }\n\n // Step 3: 헤더 행 병합 — 첫 N행이 모두 데이터열(dataColStart+)에 값이 없으면 헤더\n let headerEnd = 0\n for (let r = 0; r < merged.length; r++) {\n const hasDataValues = merged[r].slice(dataColStart).some(c => c && /\\d/.test(c))\n if (hasDataValues) break\n headerEnd = r + 1\n }\n\n if (headerEnd > 1) {\n // 헤더 행들을 하나로 합침\n const headerRow = Array(numCols).fill(\"\")\n for (let r = 0; r < headerEnd; r++) {\n for (let c = 0; c < numCols; c++) {\n if (merged[r][c]) {\n headerRow[c] = headerRow[c] ? headerRow[c] + \" \" + merged[r][c] : merged[r][c]\n }\n }\n }\n merged.splice(0, headerEnd, headerRow)\n }\n\n // Step 3.5: 셀 텍스트 균등배분 공백 제거 (\"경 제 총 괄 반\" → \"경제총괄반\")\n for (const row of merged) {\n for (let c = 0; c < row.length; c++) {\n if (row[c]) row[c] = collapseEvenSpacing(row[c])\n }\n }\n\n // Step 3.6: 테이블 품질 검증 — 선 없는 fallback 경로에서는 보수적으로\n const totalCells = merged.length * numCols\n const filledCells = merged.reduce((s, row) => s + row.filter(c => c).length, 0)\n // 빈 셀 과반, 행이 2 미만, 또는 3행 이하+7열 이상 → 텍스트로 복원\n if (filledCells < totalCells * 0.35 || merged.length < 2 ||\n (merged.length <= 3 && numCols >= 7)) {\n return merged.map(r => r.filter(c => c).join(\"\\t\")).join(\"\\n\")\n }\n\n // Step 4: 마크다운 테이블 — 셀 텍스트의 `|`는 열 구분자를 깨뜨리므로 이스케이프,\n // `\\t`는 공백으로 (그리드 경로 builder.ts 셀 조립과 정합)\n const escCell = (c: string) => c.replace(/\\t/g, \" \").replace(/\\|/g, \"\\\\|\")\n const md: string[] = []\n md.push(\"| \" + merged[0].map(escCell).join(\" | \") + \" |\")\n md.push(\"| \" + merged[0].map(() => \"---\").join(\" | \") + \" |\")\n for (let r = 1; r < merged.length; r++) {\n md.push(\"| \" + merged[r].map(escCell).join(\" | \") + \" |\")\n }\n return md.join(\"\\n\")\n}\n","/**\n * 페이지 콘텐츠 추출 → IRBlock[] (v2: 바운딩 박스 + 페이지 번호)\n *\n * 선 기반 테이블 감지(line-detector) → 클러스터 감지(cluster-detector) →\n * XY-Cut 읽기 순서의 계층 fallback으로 페이지 텍스트를 블록화하고,\n * 페이지 걸친 표 병합까지 담당한다.\n */\n\nimport type { IRBlock, IRTable, BoundingBox } from \"../types.js\"\nimport { safeMin, safeMax } from \"../utils.js\"\nimport { extractLines, preprocessLines, filterPageBorderLines, closeOpenTableEdges, bridgeSplitColumnVerticals, buildTableGrids, extractCells, mapTextToCells, cellTextToString, normalizeUndersegmentedTable, type TextItem, type TableGrid, type LineSegment } from \"./line-detector.js\"\nimport { detectClusterTables, findTwoColumnProseCutX, type ClusterItem } from \"./cluster-detector.js\"\nimport { type NormItem, collapseEvenSpacing, computeBBox, dominantStyle, groupByY, mergeSuperscriptLines, mergeLineSimple } from \"./text-line.js\"\nimport { xyCutOrder } from \"./xy-cut.js\"\nimport { detectColumns, extractWithColumns } from \"./columns.js\"\nimport { shouldDemoteTable, demoteTableToText, detectListBlocks, detectSpecialKoreanTables } from \"./block-detect.js\"\n\n/**\n * 선 기반 테이블 감지를 우선 시도, 실패 시 기존 휴리스틱 fallback.\n * @param extraLines 그래픽 ops 밖에서 얻은 선 (래스터 괘선 감지 등, PDF pt·bottom-up)\n */\nexport function extractPageBlocksWithLines(\n items: NormItem[],\n pageNum: number,\n opList: { fnArray: Uint32Array | number[]; argsArray: unknown[][] },\n pageWidth: number,\n pageHeight: number,\n extraLines?: { horizontals: LineSegment[]; verticals: LineSegment[] },\n): IRBlock[] {\n if (items.length === 0) return []\n\n // 1단계: PDF 그래픽 명령에서 선 추출\n let { horizontals, verticals } = extractLines(opList.fnArray, opList.argsArray)\n if (extraLines) {\n horizontals = horizontals.concat(extraLines.horizontals)\n verticals = verticals.concat(extraLines.verticals)\n }\n ;({ horizontals, verticals } = filterPageBorderLines(horizontals, verticals, pageWidth, pageHeight))\n\n // 1.5단계: 선 전처리 (ODL LinesPreprocessingConsumer 포팅)\n // 굵은 선 필터 + 음영 스택 제거 + 근접 평행 선 병합\n ;({ horizontals, verticals } = preprocessLines(horizontals, verticals))\n\n // 1.6단계: 개방 변 표 테두리 합성 — 좌/우 바깥 테두리 생략 스타일(행정문서 관행)의\n // 가장자리 열 소실 방지. 내부 수직선이 실존하는 정렬 괘선 묶음에만 발동.\n verticals = closeOpenTableEdges(horizontals, verticals)\n\n // 1.65단계: 무괘선 요약행 밴드(예산서 재원구분 시/구 행 등)로 끊긴 동일 열\n // 수직선 브리지 — 표 파편화로 헤더행·부서/정책 요약행이 그리드에서 탈락하는 것 방지\n verticals = bridgeSplitColumnVerticals(horizontals, verticals)\n\n // 1.7단계: 취소선 감지 — 텍스트 중심을 가로지르는 얇은 수평선 (ODL StrikethroughProcessor)\n markStrikethroughItems(items, horizontals)\n wrapStrikethroughRuns(items)\n\n // 2단계: 선으로 테이블 그리드 구성\n const grids = buildTableGrids(horizontals, verticals)\n\n if (grids.length > 0) {\n return extractBlocksWithGrids(items, pageNum, grids, horizontals, verticals)\n }\n\n // Fallback: 기존 휴리스틱 (선이 없는 PDF)\n return extractPageBlocksFallback(items, pageNum, true)\n}\n\n// ─── 취소선 감지 (ODL StrikethroughProcessor 포팅) ─────\n// Original work: Copyright 2025-2026 Hancom Inc. (Apache-2.0)\n// https://github.com/opendataloader-project/opendataloader-pdf\n\n/** 취소선 최대 두께 (pt) — 굵은 선은 배경 채움/테두리 */\nconst STRIKE_MAX_THICKNESS = 2.0\n/** 취소선 두께 / 텍스트 높이 최대 비율 */\nconst STRIKE_MAX_THICKNESS_RATIO = 0.25\n/** 선 Y와 텍스트 중심 Y의 허용 오차 (텍스트 높이 비율) */\nconst STRIKE_CENTER_TOLERANCE = 0.25\n/** 선이 텍스트를 덮어야 하는 최소 수평 비율 */\nconst STRIKE_MIN_OVERLAP_RATIO = 0.8\n/** 선 폭 / 매칭 텍스트 총폭 최대 비율 — 표 구분선/배경선 오탐 방지 */\nconst STRIKE_MAX_LINE_TO_TEXT_RATIO = 1.5\n\n/**\n * 텍스트 중심을 가로지르는 얇은 수평선을 찾아 해당 아이템에 strike 마킹.\n * 법령 개정문(신구조문대비표)의 삭제 표시 텍스트 보존용.\n */\nfunction markStrikethroughItems(items: NormItem[], horizontals: LineSegment[]): void {\n if (items.length === 0 || horizontals.length === 0) return\n\n for (const line of horizontals) {\n if (line.lineWidth > STRIKE_MAX_THICKNESS) continue\n const matches: NormItem[] = []\n for (const item of items) {\n const h = item.h > 0 ? item.h : item.fontSize\n if (h <= 0 || item.w <= 0) continue\n if (line.lineWidth > h * STRIKE_MAX_THICKNESS_RATIO) continue\n // 글자 중심 근사: baseline(y) + 높이의 40% (한글 x-height 중앙)\n const centerY = item.y + h * 0.4\n if (Math.abs(line.y1 - centerY) > h * STRIKE_CENTER_TOLERANCE) continue\n const overlap = Math.min(line.x2, item.x + item.w) - Math.max(line.x1, item.x)\n if (overlap / item.w < STRIKE_MIN_OVERLAP_RATIO) continue\n matches.push(item)\n }\n if (matches.length === 0) continue\n // 선 폭이 매칭 텍스트 총폭의 1.5배 이내여야 취소선 (표 괘선 오탐 방지)\n let totalW = 0\n for (const m of matches) totalW += m.w\n if (totalW <= 0 || (line.x2 - line.x1) / totalW > STRIKE_MAX_LINE_TO_TEXT_RATIO) continue\n for (const m of matches) m.strike = true\n }\n}\n\n/**\n * strike 마킹된 연속 아이템 run을 ~~...~~ 마크다운으로 감싼다.\n * (같은 시각적 줄에서 인접한 마킹 아이템들을 하나의 run으로 묶음)\n */\nfunction wrapStrikethroughRuns(items: NormItem[]): void {\n const struck = items.filter(i => i.strike)\n if (struck.length === 0) return\n\n // 줄 단위 그룹핑 (y ±3) 후 x 순 정렬\n const lines = new Map<number, NormItem[]>()\n for (const item of struck) {\n const key = Math.round(item.y / 3)\n const arr = lines.get(key) || []\n arr.push(item)\n lines.set(key, arr)\n }\n for (const arr of lines.values()) {\n arr.sort((a, b) => a.x - b.x)\n arr[0].text = \"~~\" + arr[0].text\n arr[arr.length - 1].text = arr[arr.length - 1].text + \"~~\"\n }\n}\n\n// ─── 프로즈 박스 감지 (라벨탭 위 전폭 프로즈) ──────────\n/** 내부 수직 구분선 없는 전폭 행이 표 높이에서 차지해야 할 최소 비율 (기하 신호) */\nconst PROSEBOX_FULLWIDTH_MIN = 0.6\n/** 긴 프로즈 셀 판정 글자수 */\nconst PROSEBOX_LONG_CELL_CHARS = 80\n/** 프로즈 박스 판정 최소 긴 셀 수 */\nconst PROSEBOX_LONG_CELL_MIN = 3\n/** 긴 셀이 채운 셀에서 차지해야 할 최소 비율 (텍스트 신호) */\nconst PROSEBOX_LONG_CELL_RATIO = 0.4\n/** 열 경계 부근 수직선 매칭 tolerance (pt) */\nconst PROSEBOX_X_TOL = 8\n\n/** 열 경계 x 부근 수직선들이 [yMin,yMax]를 덮는 union 길이 (프로즈 박스 판정용) */\nfunction verticalCoverageAt(verticals: LineSegment[], x: number, yMin: number, yMax: number): number {\n const tol = PROSEBOX_X_TOL\n const spans: Array<[number, number]> = []\n for (const v of verticals) {\n if (Math.abs(v.x1 - x) > tol) continue\n const lo = Math.max(v.y1, yMin), hi = Math.min(v.y2, yMax)\n if (hi > lo) spans.push([lo, hi])\n }\n if (spans.length === 0) return 0\n spans.sort((a, b) => a[0] - b[0])\n let total = 0, s = spans[0][0], e = spans[0][1]\n for (let i = 1; i < spans.length; i++) {\n if (spans[i][0] <= e) { if (spans[i][1] > e) e = spans[i][1] }\n else { total += e - s; s = spans[i][0]; e = spans[i][1] }\n }\n return total + (e - s)\n}\n\n/**\n * 프로즈 박스 판정 — 상단 라벨탭(제목 칩)이 박스 테두리에 걸쳐 만든 가짜 열 위로\n * 본문이 전폭 프로즈로 흐르는 표(예: 검정고시 응시자격 박스). 셀 텍스트 조인(demote)은\n * 찢긴 조각을 그대로 이어 스크램블되므로, 이 표는 버리고 아이템을 프로즈 폴백(자연\n * 읽기순)으로 재추출한다. 판정은 두 신호의 교집합 —\n * (a) 기하: 내부 수직 구분선 없는 전폭 행의 높이 합이 표 높이의 60%+\n * (b) 텍스트: 80자+ 긴 셀이 3개+ 이고 채운 셀의 40%+\n * 기하만으론 다줄셀 정규표, 텍스트만으론 서술형 2열표(용어설명·Q&A)와 구분되지 않아\n * 둘 다 충족할 때만 발동한다.\n */\nfunction isProseBoxGrid(grid: TableGrid, verticals: LineSegment[], table: IRTable): boolean {\n const numCols = grid.colXs.length - 1\n if (numCols < 2 || grid.rowYs.length < 3) return false\n\n const gyMax = grid.rowYs[0], gyMin = grid.rowYs[grid.rowYs.length - 1]\n const span = gyMax - gyMin\n if (span <= 0) return false\n const interior = grid.colXs.slice(1, -1)\n let fullWidthHeight = 0\n for (let r = 0; r < grid.rowYs.length - 1; r++) {\n const top = grid.rowYs[r], bot = grid.rowYs[r + 1]\n const h = top - bot\n if (h <= 0) continue\n // 내부 열 경계 어느 하나라도 이 행의 절반 이상을 덮는 수직선이 있으면 구분된 행\n const hasDivider = interior.some(cx => verticalCoverageAt(verticals, cx, bot, top) >= h * 0.5)\n if (!hasDivider) fullWidthHeight += h\n }\n if (fullWidthHeight < span * PROSEBOX_FULLWIDTH_MIN) return false\n\n const texts = table.cells.flat().map(c => c.text.trim()).filter(Boolean)\n const longCells = texts.filter(s => s.length > PROSEBOX_LONG_CELL_CHARS).length\n if (longCells < PROSEBOX_LONG_CELL_MIN || longCells < texts.length * PROSEBOX_LONG_CELL_RATIO) return false\n\n return true\n}\n\n/**\n * 선 기반 그리드가 감지된 경우: 테이블 영역의 텍스트는 셀에 매핑,\n * 나머지는 일반 텍스트 블록으로 처리.\n */\nfunction extractBlocksWithGrids(\n items: NormItem[],\n pageNum: number,\n grids: TableGrid[],\n horizontals: LineSegment[],\n verticals: LineSegment[],\n): IRBlock[] {\n const blocks: IRBlock[] = []\n const usedItems = new Set<NormItem>()\n\n // 그리드를 Y좌표 내림차순 정렬 (위→아래)\n const sortedGrids = [...grids].sort((a, b) => b.bbox.y2 - a.bbox.y2)\n\n for (const grid of sortedGrids) {\n // 1행 다열 그리드는 테이블 헤더일 가능성 높음 → 스킵하여 클러스터 감지에 위임\n const numGridRows = grid.rowYs.length - 1\n const numGridCols = grid.colXs.length - 1\n if (numGridRows === 1 && numGridCols >= 2) continue\n // 1열 다행 그리드 (세로선 없는 표) → 스킵하여 클러스터 감지로 열 추론 위임\n // Why: 행 구분선만 있는 표는 builder.ts 의 1-col branch 에서 세로 일렬로 플래튼되어\n // 테이블 구조가 무너짐. 클러스터 기반 X좌표 정렬로 열을 복원할 기회 제공.\n if (numGridCols === 1 && numGridRows >= 2) continue\n\n // 그리드 영역 내 텍스트 아이템 수집\n const tableItems: NormItem[] = []\n const pad = 3\n const gridW = grid.bbox.x2 - grid.bbox.x1\n for (const item of items) {\n if (usedItems.has(item)) continue\n // Y 범위 체크\n if (item.y < grid.bbox.y1 - pad || item.y > grid.bbox.y2 + pad) continue\n // X 범위 체크 — 아이템의 시작과 끝이 모두 그리드 안에 있어야 함\n if (item.x < grid.bbox.x1 - pad || item.x + item.w > grid.bbox.x2 + pad) continue\n // 좁은 그리드(120px 미만)에서 큰 아이템이 경계에 걸치면 제외\n // 제목 텍스트가 인접 그리드에 잡히는 것을 방지\n if (gridW < 120 && item.x + item.w > grid.bbox.x2 - 2) continue\n tableItems.push(item)\n usedItems.add(item)\n }\n\n // 셀 추출\n const cells = extractCells(grid, horizontals, verticals)\n if (cells.length === 0) continue\n\n // 텍스트→셀 매핑 (hasSpaceBefore 전파 — 셀 텍스트 단어 공백 복원)\n const textItems: TextItem[] = tableItems.map(i => ({\n text: i.text, x: i.x, y: i.y, w: i.w, h: i.h,\n fontSize: i.fontSize, fontName: i.fontName, hasSpaceBefore: i.hasSpaceBefore,\n }))\n const cellTextMap = mapTextToCells(textItems, cells)\n\n // 셀 미배정 아이템 수집 — mapTextToCells는 교차비율 > 0.3만 배정하므로,\n // 그리드 bbox 안에 있지만 어느 셀에도 못 붙은 아이템(세로쓰기 헤더 등)을\n // usedItems에 남겨두면 표에도 프로즈에도 없이 무음 소멸한다.\n const assignedItems = new Set<TextItem>()\n for (const arr of cellTextMap.values()) for (const it of arr) assignedItems.add(it)\n\n // IRTable 구성\n const numRows = grid.rowYs.length - 1\n const numCols = grid.colXs.length - 1\n const irGrid: import(\"../types.js\").IRCell[][] = Array.from(\n { length: numRows },\n () => Array.from({ length: numCols }, () => ({ text: \"\", colSpan: 1, rowSpan: 1 })),\n )\n\n for (const cell of cells) {\n const cellItems = cellTextMap.get(cell) || []\n let text = cellTextToString(cellItems)\n // 셀 안의 페이지 번호 표시 제거 (\"- 2 -\" 등)\n text = text.replace(/^[\\s]*[-–—]\\s*\\d+\\s*[-–—][\\s]*$/gm, \"\").trim()\n // 셀 텍스트 균등배분 공백 제거 (\"경 제 총 괄 반\" → \"경제총괄반\")\n text = text.split(\"\\n\").map(line => collapseEvenSpacing(line)).join(\"\\n\")\n irGrid[cell.row][cell.col] = {\n text,\n colSpan: cell.colSpan,\n rowSpan: cell.rowSpan,\n }\n }\n\n // 과소분할 표 재구성 (ODL TableStructureNormalizer):\n // 행≤2 + 열≥3 + 셀 안에 텍스트 줄이 뭉친 표는 줄 centerY 기반 row band로 행 복원\n let finalGrid = irGrid\n let finalRows = numRows\n let rebuiltUsed = false\n if (numRows <= 2 && numCols >= 3) {\n const rebuilt = normalizeUndersegmentedTable(irGrid, grid.colXs, textItems)\n if (rebuilt) {\n rebuiltUsed = true\n finalGrid = rebuilt.map(row => row.map(rawText => {\n const cleaned = rawText.replace(/^[\\s]*[-–—]\\s*\\d+\\s*[-–—][\\s]*$/gm, \"\").trim()\n return {\n text: cleaned.split(\"\\n\").map(line => collapseEvenSpacing(line)).join(\"\\n\"),\n colSpan: 1,\n rowSpan: 1,\n }\n }))\n finalRows = finalGrid.length\n }\n }\n\n // 미배정 아이템을 프로즈 경로로 환원 — 과소분할 재구축(rebuiltUsed)은\n // textItems 전체(미배정 포함)를 셀에 재배치하므로 그때는 환원하지 않는다(중복 방지)\n if (!rebuiltUsed) {\n for (let ti = 0; ti < textItems.length; ti++) {\n if (!assignedItems.has(textItems[ti])) usedItems.delete(tableItems[ti])\n }\n }\n\n const irTable: IRTable = {\n rows: finalRows,\n cols: numCols,\n cells: finalGrid,\n hasHeader: finalRows > 1,\n }\n\n // 빈 테이블(모든 셀이 빈 문자열) 스킵\n const hasContent = finalGrid.some(row => row.some(cell => cell.text.trim() !== \"\"))\n if (!hasContent) continue\n\n // 프로즈 박스: 가짜 열 위로 전폭 프로즈가 흐르는 표 → 표를 버리고 아이템을\n // 프로즈 폴백으로 재추출 (셀 조인 demote는 찢긴 조각을 스크램블하므로 부적합)\n if (isProseBoxGrid(grid, verticals, irTable)) {\n for (const it of tableItems) usedItems.delete(it)\n continue\n }\n\n const tableBbox: BoundingBox = {\n page: pageNum,\n x: grid.bbox.x1, y: grid.bbox.y1,\n width: grid.bbox.x2 - grid.bbox.x1, height: grid.bbox.y2 - grid.bbox.y1,\n }\n\n // 의사 테이블 필터: 텍스트성 내용 → paragraph로 복원 (구조 보존)\n if (shouldDemoteTable(irTable)) {\n const demoted = demoteTableToText(irTable)\n if (demoted) {\n // 텍스트 박스(1x1 또는 1행 그리드) demote 시 앞뒤 줄바꿈으로 본문과 분리\n const text = numGridRows === 1 ? \"\\n\" + demoted + \"\\n\" : demoted\n blocks.push({ type: \"paragraph\", text, pageNumber: pageNum, bbox: tableBbox, style: dominantStyle(tableItems) })\n }\n continue\n }\n\n blocks.push({ type: \"table\", table: irTable, pageNumber: pageNum, bbox: tableBbox })\n }\n\n // 테이블에 속하지 않은 나머지 텍스트 → 일반 블록\n let remaining = items.filter(i => !usedItems.has(i))\n if (remaining.length > 0) {\n remaining.sort((a, b) => b.y - a.y || a.x - b.x)\n\n // 클러스터 기반 테이블 감지 (XY-Cut 전에 실행 — 테이블이 쪼개지지 않도록)\n const clusterItems: ClusterItem[] = remaining.map(i => ({\n text: i.text, x: i.x, y: i.y, w: i.w, h: i.h,\n fontSize: i.fontSize, fontName: i.fontName, hasSpaceBefore: i.hasSpaceBefore,\n }))\n const clusterResults = detectClusterTables(clusterItems, pageNum)\n if (clusterResults.length > 0) {\n const ciToIdx = new Map<ClusterItem, number>()\n for (let ci = 0; ci < clusterItems.length; ci++) ciToIdx.set(clusterItems[ci], ci)\n const usedClusterIndices = new Set<number>()\n for (const cr of clusterResults) {\n for (const ci of cr.usedItems) {\n const idx = ciToIdx.get(ci)\n if (idx !== undefined) usedClusterIndices.add(idx)\n }\n blocks.push({ type: \"table\", table: cr.table, pageNumber: pageNum, bbox: cr.bbox })\n }\n remaining = remaining.filter((_, idx) => !usedClusterIndices.has(idx))\n }\n\n // XY-Cut으로 왼쪽 본문과 오른쪽 부서명 등을 분리 후 개별 처리\n const groupSizes: number[] = []\n let finalTextBlocks: IRBlock[] = []\n if (remaining.length > 0) {\n const allY = remaining.map(i => i.y)\n const pageH = safeMax(allY) - safeMin(allY)\n const groups = xyCutOrder(remaining, Math.max(15, pageH * 0.03))\n const textBlocks: IRBlock[] = []\n for (const group of groups) {\n if (group.length === 0) continue\n const groupBlocks = extractPageBlocksFallback(group, pageNum)\n for (const b of groupBlocks) textBlocks.push(b)\n groupSizes.push(groupBlocks.length)\n }\n finalTextBlocks = detectListBlocks(textBlocks) // 1:1 변환 — 그룹 경계(groupSizes) 유지\n }\n\n // 그룹 단위 Y-정렬 — 블록 단위 Y-정렬은 XY-Cut이 정한 컬럼 읽기 순서(좌단\n // 전체 → 우단 전체)를 행 단위로 재인터리브하므로, XY-Cut 그룹을 한 단위로\n // 묶어 그룹 대표 Y(최상단)로만 정렬하고 그룹 내부 순서는 보존한다.\n // 표/demote 블록은 각자 단독 단위 (기존과 동일하게 Y 위치로 끼어듦).\n const units: IRBlock[][] = blocks.map(b => [b])\n let off = 0\n for (const size of groupSizes) {\n const unit = finalTextBlocks.slice(off, off + size)\n off += size\n if (unit.length > 0) units.push(unit)\n }\n const unitTopY = (u: IRBlock[]) => {\n let top = 0\n for (const b of u) {\n if (b.bbox && b.bbox.y + b.bbox.height > top) top = b.bbox.y + b.bbox.height\n }\n return top\n }\n units.sort((a, b) => unitTopY(b) - unitTopY(a)) // PDF는 y가 위가 큼 → 내림차순\n const ordered: IRBlock[] = []\n for (const u of units) for (const b of u) ordered.push(b)\n return mergeAdjacentTableBlocks(ordered)\n }\n\n return mergeAdjacentTableBlocks(blocks)\n}\n\n/**\n * 페이지 걸친 표 병합 — ODL TableBorderProcessor.checkNeighborTables 포팅.\n * Original work: Copyright 2025-2026 Hancom Inc. (Apache-2.0)\n *\n * 페이지 N의 마지막 표와 페이지 N+1의 첫 표가:\n * - 블록 배열에서 인접 (사이에 본문 블록 없음 — 머리글/바닥글 제거 후 기준)\n * - 열 수 동일\n * - 좌우 경계 근접 (폭 대비 0.2 비율 이내, ODL NEIGHBOUR_TABLE_EPSILON)\n * 이면 한 표로 병합. 반복 헤더 행(첫 행 텍스트 동일)은 제거.\n */\nconst NEIGHBOR_TABLE_EPSILON = 0.2\n\nexport function mergeCrossPageTables(blocks: IRBlock[]): void {\n for (let i = blocks.length - 2; i >= 0; i--) {\n const prev = blocks[i]\n const curr = blocks[i + 1]\n if (prev.type !== \"table\" || curr.type !== \"table\" || !prev.table || !curr.table) continue\n if (!prev.pageNumber || !curr.pageNumber || curr.pageNumber !== prev.pageNumber + 1) continue\n if (prev.table.cols !== curr.table.cols) continue\n if (!prev.bbox || !curr.bbox) continue\n\n // 좌우 경계 근접 검증 (폭 대비 비율)\n const width = Math.max(prev.bbox.width, curr.bbox.width, 1)\n const leftDiff = Math.abs(prev.bbox.x - curr.bbox.x)\n const rightDiff = Math.abs((prev.bbox.x + prev.bbox.width) - (curr.bbox.x + curr.bbox.width))\n if (leftDiff > width * NEIGHBOR_TABLE_EPSILON || rightDiff > width * NEIGHBOR_TABLE_EPSILON) continue\n\n // 반복 헤더 행 제거: 다음 표 첫 행이 이전 표 첫 행과 동일하면 중복 헤더\n let currCells = curr.table.cells\n if (currCells.length > 1 && prev.table.cells.length > 0 &&\n rowTextsEqual(prev.table.cells[0], currCells[0])) {\n currCells = currCells.slice(1)\n }\n if (currCells.length === 0) {\n blocks.splice(i + 1, 1)\n continue\n }\n\n const merged: IRTable = {\n rows: prev.table.rows + currCells.length,\n cols: prev.table.cols,\n cells: [...prev.table.cells, ...currCells],\n hasHeader: prev.table.hasHeader,\n caption: prev.table.caption,\n }\n blocks[i] = { ...prev, table: merged }\n blocks.splice(i + 1, 1)\n }\n}\n\n/** 두 행의 셀 텍스트가 모두 동일한지 (공백 정규화 후 비교) */\nfunction rowTextsEqual(a: import(\"../types.js\").IRCell[], b: import(\"../types.js\").IRCell[]): boolean {\n if (a.length !== b.length) return false\n const norm = (t: string) => t.replace(/\\s+/g, \"\")\n for (let i = 0; i < a.length; i++) {\n if (norm(a[i].text) !== norm(b[i].text)) return false\n }\n // 빈 행끼리의 비교는 의미 없음\n return a.some(c => c.text.trim() !== \"\")\n}\n\n/** 같은 열 수의 연속 테이블 블록을 하나로 합침 */\nfunction mergeAdjacentTableBlocks(blocks: IRBlock[]): IRBlock[] {\n if (blocks.length <= 1) return blocks\n const result: IRBlock[] = [blocks[0]]\n for (let i = 1; i < blocks.length; i++) {\n const prev = result[result.length - 1]\n const curr = blocks[i]\n if (prev.type === \"table\" && curr.type === \"table\" && prev.table && curr.table &&\n prev.table.cols === curr.table.cols) {\n // 합치기: prev의 cells에 curr의 cells 추가\n const merged: IRTable = {\n rows: prev.table.rows + curr.table.rows,\n cols: prev.table.cols,\n cells: [...prev.table.cells, ...curr.table.cells],\n hasHeader: prev.table.hasHeader,\n }\n result[result.length - 1] = { ...prev, table: merged }\n } else {\n result.push(curr)\n }\n }\n return result\n}\n\n/**\n * 2단 조판 본문을 읽기 순서 그룹으로 분리 — 전폭 줄(제목·목차)의 y를 경계로\n * 세로 밴드를 나누고, 각 밴드에서 좌단 전체 → 우단 전체 순으로 배열한다.\n */\nfunction splitTwoColumnProse(items: NormItem[], cutX: number): NormItem[][] {\n const left: NormItem[] = []\n const right: NormItem[] = []\n const cross: NormItem[] = []\n for (const i of items) {\n if (i.x + i.w <= cutX) left.push(i)\n else if (i.x >= cutX) right.push(i)\n else cross.push(i)\n }\n if (cross.length === 0) {\n return [left, right].filter(g => g.length > 0)\n }\n\n // 전폭 아이템을 y 근접(3pt)으로 경계 줄 묶음 (y 내림차순 = 위→아래)\n cross.sort((a, b) => b.y - a.y)\n const crossLines: NormItem[][] = []\n for (const c of cross) {\n const last = crossLines[crossLines.length - 1]\n if (last && Math.abs(last[0].y - c.y) <= 3) last.push(c)\n else crossLines.push([c])\n }\n // 경계 줄과 같은 y의 좌/우 아이템은 그 경계 줄에 편입 (목차 줄의 나란한 조각)\n const bandItem = (arr: NormItem[]) => arr.filter(i => {\n for (const cl of crossLines) {\n if (Math.abs(cl[0].y - i.y) <= 3) { cl.push(i); return false }\n }\n return true\n })\n const leftRest = bandItem(left)\n const rightRest = bandItem(right)\n\n // 밴드 k = 경계줄 k-1 아래 ~ 경계줄 k 위 (PDF y는 위가 큼)\n const boundYs = crossLines.map(cl => cl[0].y)\n const bandOf = (y: number) => {\n let k = 0\n while (k < boundYs.length && y < boundYs[k]) k++\n return k\n }\n const groups: NormItem[][] = []\n for (let k = 0; k <= crossLines.length; k++) {\n const L = leftRest.filter(i => bandOf(i.y) === k)\n const R = rightRest.filter(i => bandOf(i.y) === k)\n if (L.length > 0) groups.push(L)\n if (R.length > 0) groups.push(R)\n if (k < crossLines.length) groups.push(crossLines[k])\n }\n return groups\n}\n\n/**\n * 기존 휴리스틱 기반 페이지 블록 추출 (선이 없는 PDF 대비 fallback).\n *\n * fullPage: 페이지 전체 아이템으로 호출됐을 때만 true — 2단 조판 본문 감지는\n * 전체 지면 기준 신호라, XY-Cut 그룹(부분 집합) 재호출에서는 오발화하므로 끈다.\n */\nexport function extractPageBlocksFallback(items: NormItem[], pageNum: number, fullPage = false): IRBlock[] {\n if (items.length === 0) return []\n\n const blocks: IRBlock[] = []\n\n // 1단계: 클러스터 기반 테이블 감지 우선 (헤더 감지 시 정확도 높음)\n const clusterItems: ClusterItem[] = items.map(i => ({\n text: i.text, x: i.x, y: i.y, w: i.w, h: i.h,\n fontSize: i.fontSize, fontName: i.fontName, hasSpaceBefore: i.hasSpaceBefore,\n }))\n const clusterResults = detectClusterTables(clusterItems, pageNum)\n\n if (clusterResults.length > 0) {\n const ciToIdx = new Map<ClusterItem, number>()\n for (let ci = 0; ci < clusterItems.length; ci++) ciToIdx.set(clusterItems[ci], ci)\n const usedIndices = new Set<number>()\n for (const cr of clusterResults) {\n for (const ci of cr.usedItems) {\n const idx = ciToIdx.get(ci)\n if (idx !== undefined) usedIndices.add(idx)\n }\n blocks.push({ type: \"table\", table: cr.table, pageNumber: pageNum, bbox: cr.bbox })\n }\n\n // 테이블에 속하지 않은 나머지 텍스트 → 일반 블록\n const remaining = items.filter((_, idx) => !usedIndices.has(idx))\n if (remaining.length > 0) {\n const yLines = mergeSuperscriptLines(groupByY(remaining))\n for (const line of yLines) {\n const text = mergeLineSimple(line)\n if (!text.trim()) continue\n const bbox = computeBBox(line, pageNum)\n blocks.push({ type: \"paragraph\", text, pageNumber: pageNum, bbox, style: dominantStyle(line) })\n }\n }\n\n blocks.sort((a, b) => {\n const ay = a.bbox ? (a.bbox.y + a.bbox.height) : 0\n const by = b.bbox ? (b.bbox.y + b.bbox.height) : 0\n return by - ay\n })\n } else {\n // 2단계: 레거시 컬럼 감지 (3+ 열)\n // 2단 조판 본문(속기록류)은 들여쓰기 x-피크가 3+ 열로 오인돼 페이지 전체가\n // 행 인터리브 탭 텍스트로 뭉개진다 → 단 분리 경로에 위임\n const proseCutX = fullPage ? findTwoColumnProseCutX(items) : null\n const allYLines = mergeSuperscriptLines(groupByY(items))\n const columns = proseCutX !== null ? null : detectColumns(allYLines)\n\n if (columns && columns.length >= 3) {\n const tableText = extractWithColumns(allYLines, columns)\n const bbox = computeBBox(items, pageNum)\n blocks.push({ type: \"paragraph\", text: tableText, pageNumber: pageNum, bbox, style: dominantStyle(items) })\n } else {\n // 3단계: XY-Cut으로 읽기 순서 결정.\n // 2단 조판 본문은 전폭 제목/목차 줄이 X 프로젝션을 막아 XY-Cut이 단을 못\n // 가르는 경우가 있어(속기록 1면) 검출된 컷으로 직접 분리한다.\n const allY = items.map(i => i.y)\n const pageHeight = safeMax(allY) - safeMin(allY)\n const gapThreshold = Math.max(15, pageHeight * 0.03)\n\n const orderedGroups = proseCutX !== null\n ? splitTwoColumnProse(items, proseCutX)\n : xyCutOrder(items, gapThreshold)\n\n for (const group of orderedGroups) {\n if (group.length === 0) continue\n const yLines = mergeSuperscriptLines(groupByY(group))\n\n const groupColumns = detectColumns(yLines)\n if (groupColumns && groupColumns.length >= 3) {\n const tableText = extractWithColumns(yLines, groupColumns)\n const bbox = computeBBox(group, pageNum)\n blocks.push({ type: \"paragraph\", text: tableText, pageNumber: pageNum, bbox, style: dominantStyle(group) })\n } else {\n for (const line of yLines) {\n const text = mergeLineSimple(line)\n if (!text.trim()) continue\n const bbox = computeBBox(line, pageNum)\n blocks.push({ type: \"paragraph\", text, pageNumber: pageNum, bbox, style: dominantStyle(line) })\n }\n }\n }\n }\n }\n\n // 한국어 특수 테이블 감지 (구분/항목/종류 패턴)\n return detectSpecialKoreanTables(blocks)\n}\n"]}