{"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"]}