{"version":3,"sources":["/Users/mong-e/workspace/kordoc/dist/parser-CSYU3CAW.cjs","../src/pdf/image-extract.ts","../src/pdf/quality.ts","../src/pdf/text-clean.ts","../src/pdf/formula-ocr.ts","../src/pdf/polyfill.ts","../src/pdf/parser.ts"],"names":[],"mappings":"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","file":"/Users/mong-e/workspace/kordoc/dist/parser-CSYU3CAW.cjs","sourcesContent":[null,"/**\n * PDF 이미지 XObject 바이트 추출 — 페이지 operatorList의 paint 이미지를\n * page.objs/commonObjs에서 디코딩된 픽셀로 받아 PNG로 인코딩한다.\n *\n * getOperatorList()가 이미 디코딩 비용을 지불하므로(선 감지용으로 항상 호출)\n * 여기서는 RGBA 변환 + PNG deflate만 추가된다. 순수 JS (node:zlib) 전용.\n */\n\nimport { OPS, ImageKind } from \"pdfjs-dist/legacy/build/pdf.mjs\"\nimport { encodePng } from \"../image/transcode.js\"\nimport type { ExtractedImage, IRBlock, ParseWarning } from \"../types.js\"\n\n/** 디코딩된 이미지 픽셀 (pdfjs page.objs 반환 형태의 최소 부분) */\ninterface PdfImgData {\n  width: number\n  height: number\n  kind?: number\n  data?: Uint8Array | Uint8ClampedArray\n}\n\n/** 페이지 프록시의 최소 형태 — objs/commonObjs 만 사용 */\ninterface PdfObjects {\n  get(id: string, callback?: (data: unknown) => void): unknown\n}\ninterface PdfPageObjs {\n  objs: PdfObjects\n  commonObjs: PdfObjects\n}\n\n/** 개별 이미지 디코딩 대기 상한 — 실패 시에도 null 로 resolve 되므로 안전망 성격 */\nconst IMAGE_RESOLVE_TIMEOUT_MS = 5_000\n\n/**\n * 이미지 객체 대기 해제 — 디코딩은 getOperatorList() resolve 후에도 워커에서\n * 비동기로 진행되므로(pdfjs buildPaintImageXObject 는 await 하지 않음) 동기\n * has()/get() 은 이미 완료된 것만 잡힌다. 콜백형 get()으로 완료를 기다리며,\n * 디코딩 실패는 pdfjs 가 null 로 resolve 하므로 무한 대기는 없다.\n */\nfunction resolveImgData(page: PdfPageObjs, objId: string): Promise<PdfImgData | null> {\n  // canvas.js 규약과 동일 — \"g_\" 접두사는 문서 공용 객체\n  const store = objId.startsWith(\"g_\") ? page.commonObjs : page.objs\n  return new Promise(resolve => {\n    let done = false\n    const timer = setTimeout(() => { if (!done) { done = true; resolve(null) } }, IMAGE_RESOLVE_TIMEOUT_MS)\n    try {\n      store.get(objId, (data: unknown) => {\n        if (!done) { done = true; clearTimeout(timer); resolve((data ?? null) as PdfImgData | null) }\n      })\n    } catch {\n      if (!done) { done = true; clearTimeout(timer); resolve(null) }\n    }\n  })\n}\n\n/** 폭·높이 하한 — 괘선/불릿 등 장식 조각 제외 */\nconst MIN_DIM = 8\n/** 총 픽셀 상한 — transcode.ts MAX_PIXELS와 동일 근거 (deflateSync 동기 블로킹 방지) */\nconst MAX_PIXELS = 36_000_000\n/** 문서당 추출 이미지 수 상한 */\nconst MAX_IMAGES_PER_DOC = 200\n/** 문서당 PNG 누적 바이트 상한 (128MB) */\nconst MAX_TOTAL_IMAGE_BYTES = 128 * 1024 * 1024\n\n/** 문서 단위 추출 상태 — 페이지 간 중복(로고·워터마크) 억제 + 상한 추적 */\nexport interface PdfImageState {\n  imageIndex: number\n  totalBytes: number\n  /** 내용 해시 → 파일명 (이전 페이지에서 이미 추출된 이미지) */\n  seen: Map<string, string>\n  capWarned: boolean\n}\n\nexport function createPdfImageState(): PdfImageState {\n  return { imageIndex: 0, totalBytes: 0, seen: new Map(), capWarned: false }\n}\n\n/** FNV-1a 32bit — 픽셀 버퍼 내용 해시 (crypto 불필요, 충돌은 dims 결합으로 완화) */\nfunction fnv1a(data: Uint8Array | Uint8ClampedArray): number {\n  let h = 0x811c9dc5\n  for (let i = 0; i < data.length; i++) {\n    h ^= data[i]\n    h = Math.imul(h, 0x01000193)\n  }\n  return h >>> 0\n}\n\n/** kind별 픽셀 → 8bit RGBA. 미지원/불일치 형태는 null. */\nfunction toRgba(img: PdfImgData): Uint8Array | null {\n  const { width: w, height: h, kind, data } = img\n  if (!data || !w || !h) return null\n  const rgba = new Uint8Array(w * h * 4)\n\n  if (kind === ImageKind.RGBA_32BPP) {\n    if (data.length < w * h * 4) return null\n    rgba.set(data.subarray(0, w * h * 4))\n    return rgba\n  }\n  if (kind === ImageKind.RGB_24BPP) {\n    if (data.length < w * h * 3) return null\n    for (let i = 0, s = 0, d = 0; i < w * h; i++, s += 3, d += 4) {\n      rgba[d] = data[s]; rgba[d + 1] = data[s + 1]; rgba[d + 2] = data[s + 2]; rgba[d + 3] = 255\n    }\n    return rgba\n  }\n  if (kind === ImageKind.GRAYSCALE_1BPP) {\n    // 행 단위 비트 패킹 (stride = ceil(w/8)), 1=백 0=흑\n    const stride = (w + 7) >> 3\n    if (data.length < stride * h) return null\n    for (let y = 0; y < h; y++) {\n      for (let x = 0; x < w; x++) {\n        const bit = (data[y * stride + (x >> 3)] >> (7 - (x & 7))) & 1\n        const v = bit ? 255 : 0\n        const d = (y * w + x) * 4\n        rgba[d] = v; rgba[d + 1] = v; rgba[d + 2] = v; rgba[d + 3] = 255\n      }\n    }\n    return rgba\n  }\n  return null\n}\n\n/**\n * 한 페이지의 operatorList에서 이미지를 추출해 image 블록·바이너리로 반환.\n * 같은 페이지 내 반복 paint(타일링)는 1회만, 이전 페이지와 동일 내용(로고·\n * 워터마크)은 블록·바이너리 모두 재방출하지 않는다.\n */\nexport async function extractPageImages(\n  page: PdfPageObjs,\n  fnArray: Uint32Array | number[],\n  argsArray: unknown[][],\n  pageNumber: number,\n  state: PdfImageState,\n  warnings: ParseWarning[],\n): Promise<{ blocks: IRBlock[]; images: ExtractedImage[] }> {\n  const blocks: IRBlock[] = []\n  const images: ExtractedImage[] = []\n  const pageSeenIds = new Set<string>()\n\n  for (let i = 0; i < fnArray.length; i++) {\n    const op = fnArray[i]\n    let imgData: PdfImgData | null = null\n    let dedupeId: string | null = null\n\n    if (op === OPS.paintImageXObject || op === OPS.paintImageXObjectRepeat) {\n      const objId = argsArray[i]?.[0]\n      if (typeof objId !== \"string\" || pageSeenIds.has(objId)) continue\n      pageSeenIds.add(objId)\n      dedupeId = objId\n    } else if (op !== OPS.paintInlineImageXObject) {\n      continue\n    }\n\n    // 상한 도달 검사 — resolveImgData(이미지당 최대 5초 대기) 앞에서 중단해야\n    // 상한 이후 이미지들이 페이지마다 디코딩 대기 시간만 소모하지 않는다\n    if (state.imageIndex >= MAX_IMAGES_PER_DOC || state.totalBytes >= MAX_TOTAL_IMAGE_BYTES) {\n      if (!state.capWarned) {\n        state.capWarned = true\n        warnings.push({ page: pageNumber, message: `이미지 추출 상한 도달 (${MAX_IMAGES_PER_DOC}개/128MB) — 이후 이미지 생략`, code: \"SKIPPED_IMAGE\" })\n      }\n      continue\n    }\n\n    if (dedupeId) {\n      imgData = await resolveImgData(page, dedupeId)\n    } else {\n      imgData = argsArray[i]?.[0] as PdfImgData\n    }\n\n    if (!imgData?.data || !imgData.width || !imgData.height) continue\n    const { width: w, height: h } = imgData\n    if (w < MIN_DIM || h < MIN_DIM) continue // 괘선/불릿 장식\n    if (w * h > MAX_PIXELS) {\n      warnings.push({ page: pageNumber, message: `이미지 크기 초과로 추출 생략 (${w}×${h})`, code: \"SKIPPED_IMAGE\" })\n      continue\n    }\n\n    // 페이지 간 내용 중복 — 로고·워터마크 재추출 방지\n    const hash = `${w}x${h}k${imgData.kind ?? \"?\"}h${fnv1a(imgData.data)}${dedupeId ? \"\" : \"inline\"}`\n    if (state.seen.has(hash)) continue\n\n    const rgba = toRgba(imgData)\n    if (!rgba) continue // 미지원 픽셀 형태 (bitmap 전용 등)\n\n    const png = encodePng(w, h, rgba)\n    state.imageIndex++\n    state.totalBytes += png.length\n    const filename = `image_${String(state.imageIndex).padStart(3, \"0\")}.png`\n    state.seen.set(hash, filename)\n    images.push({ filename, data: png, mimeType: \"image/png\" })\n    blocks.push({ type: \"image\", text: filename, pageNumber })\n  }\n\n  return { blocks, images }\n}\n\n/**\n * 페이지별 image 블록을 본문 블록 열에 주입 — 각 페이지의 마지막 블록 뒤.\n * 페이지 루프 중 바로 끼워 넣으면 페이지 경계에서 표 블록 인접성이 깨져\n * mergeCrossPageTables가 무산되므로, 병합이 끝난 뒤 호출해야 한다.\n */\nexport function injectPageImageBlocks(blocks: IRBlock[], pageImages: Map<number, IRBlock[]>): void {\n  if (pageImages.size === 0) return\n\n  // 페이지별 마지막 블록 위치 → 위치별 페이지 역인덱스\n  const lastIndexForPage = new Map<number, number>()\n  for (let i = 0; i < blocks.length; i++) {\n    const p = blocks[i].pageNumber\n    if (p !== undefined) lastIndexForPage.set(p, i)\n  }\n  const pageAtIndex = new Map<number, number>()\n  for (const [p, last] of lastIndexForPage) pageAtIndex.set(last, p)\n\n  const result: IRBlock[] = []\n  const injected = new Set<number>()\n  for (let i = 0; i < blocks.length; i++) {\n    result.push(blocks[i])\n    const p = pageAtIndex.get(i)\n    if (p !== undefined && pageImages.has(p)) {\n      result.push(...pageImages.get(p)!)\n      injected.add(p)\n    }\n  }\n  // 텍스트 블록이 하나도 없는 페이지(전면 이미지 등) — 페이지 순서 위치에 삽입\n  for (const p of [...pageImages.keys()].sort((a, b) => a - b)) {\n    if (injected.has(p)) continue\n    let at = result.length\n    for (let i = 0; i < result.length; i++) {\n      const bp = result[i].pageNumber\n      if (bp !== undefined && bp > p) { at = i; break }\n    }\n    result.splice(at, 0, ...pageImages.get(p)!)\n  }\n\n  blocks.length = 0\n  blocks.push(...result)\n}\n","/**\n * PDF 페이지별 텍스트 품질 신호 수집.\n *\n * pdfjs가 텍스트층을 추출했더라도, 폰트의 ToUnicode/CMap이 불완전하면\n * 한글이 PUA(Private Use Area) 글리프 코드로 그대로 떨어진다. 또 일부 PDF는\n * NUL/제어문자가 본문에 섞여 있다. 본 모듈은 그런 신호를 페이지 단위로 수집해\n * \"OCR 검토 필요\" 판정에 사용한다.\n *\n * 더 까다로운 케이스: ToUnicode가 \"잘못\" 매핑되면 글리프가 PUA/FFFD가 아니라\n * 정상 한글 음절 영역(0xAC00-0xD7A3)의 엉뚱한 글자로 떨어진다(\"GPU쭒컫 많핂슪\").\n * 이건 PUA/제어/대체문자 신호로 못 잡는다. 대신 자소를 분해해 종성(받침) 분포를\n * 본다 — CID 스크램블은 받침을 균등하게 흩뿌리므로, 자연 한국어(받침없음 다수 +\n * 겹받침 희소)와 통계적으로 갈린다. 이것이 garbled_hangul 신호다.\n */\n\nexport interface PageQuality {\n  /** 1-based 페이지 번호 */\n  page: number\n  /** 공백 제외 문자 수 */\n  textChars: number\n  /** 한글 음절(0xAC00-0xD7A3) 비율 (0~1) */\n  hangulRatio: number\n  /** C0/C1 제어문자 비율 (tab/newline/CR 제외) */\n  controlCharRatio: number\n  /** U+FFFD replacement character 비율 */\n  replacementCharRatio: number\n  /** PUA 비율 — 글꼴 매핑 실패의 핵심 신호 */\n  puaRatio: number\n  /** 한글 음절 중 받침 없는 음절 비율 (자연 한국어 ~0.5, mojibake ~0.04) */\n  hangulNoBatchimRatio: number\n  /** 한글 음절 중 겹받침/희귀 받침 비율 (자연 한국어 <0.06, mojibake ~0.5) */\n  hangulRareBatchimRatio: number\n  /** OCR 검토 권장 여부 (pdfjs 텍스트층 기준 원신호 — OCR 적용 후에도 보존) */\n  needsOcr: boolean\n  /** needsOcr=true일 때 사유 (단일 신호로 충분, 가장 강한 신호 선택) */\n  ocrReason?: \"low_text\" | \"high_pua\" | \"high_control\" | \"high_replacement\" | \"garbled_hangul\"\n  /** 이 페이지에 OCR 이 실제 적용되어 본문이 OCR 결과로 대체됨 */\n  ocrApplied?: boolean\n}\n\n/**\n * 희귀 종성(받침) 인덱스 집합. 종성 인덱스 = (code - 0xAC00) % 28,\n * 0 = 받침 없음. 겹받침(ㄳㄵㄶㄺㄻㄼㄽㄾㄿㅀㅄ) + 거의 안 쓰는 홑받침(ㅋㅌㅍ).\n * 자연 한국어에서 이들의 합산 비율은 매우 낮다(<6%).\n */\nconst RARE_JONG = new Set<number>([3, 5, 6, 9, 10, 11, 12, 13, 14, 15, 18, 24, 25, 26])\n\n/** garbled_hangul 판정 최소 한글 음절 수 — 통계적 유의성 확보 */\nconst MOJIBAKE_MIN_HANGUL = 30\n/** 받침 없는 음절 비율이 이 값 미만이면 비정상 (자연 한국어는 훨씬 높음) */\nconst MOJIBAKE_MAX_NOBATCHIM = 0.15\n/** 희귀 받침 비율이 이 값 이상이면 비정상 (자연 한국어는 훨씬 낮음) */\nconst MOJIBAKE_MIN_RAREBATCHIM = 0.25\n\n/** 페이지 텍스트에서 품질 메트릭을 계산한다. */\nexport function computePageQuality(page: number, text: string): PageQuality {\n  let total = 0\n  let hangul = 0\n  let hangulNoBatchim = 0\n  let hangulRareBatchim = 0\n  let control = 0\n  let replacement = 0\n  let pua = 0\n\n  for (let i = 0; i < text.length; i++) {\n    const code = text.charCodeAt(i)\n    // 공백류는 분모에서 제외\n    if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) continue\n    total++\n\n    // C0 제어문자 (0x00-0x1F 중 tab/lf/cr 제외) + DEL(0x7F) + C1(0x80-0x9F)\n    if ((code < 0x20) || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {\n      control++\n      continue\n    }\n    if (code === 0xfffd) {\n      replacement++\n      continue\n    }\n    // 한글 음절 — 종성(받침) 분포로 mojibake 판정\n    if (code >= 0xac00 && code <= 0xd7a3) {\n      hangul++\n      const jong = (code - 0xac00) % 28\n      if (jong === 0) hangulNoBatchim++\n      else if (RARE_JONG.has(jong)) hangulRareBatchim++\n      continue\n    }\n    // PUA: BMP(E000-F8FF) + SPUA-A(F0000-FFFFD) + SPUA-B(100000-10FFFD)\n    // 서로게이트 페어는 high만 검사해도 충분 (Plane 15/16 high surrogate 범위 DB80-DBFF)\n    if ((code >= 0xe000 && code <= 0xf8ff) || (code >= 0xdb80 && code <= 0xdbff)) {\n      pua++\n      continue\n    }\n  }\n\n  const denom = total || 1\n  const puaRatio = pua / denom\n  const controlCharRatio = control / denom\n  const replacementCharRatio = replacement / denom\n  const hangulNoBatchimRatio = hangul > 0 ? hangulNoBatchim / hangul : 0\n  const hangulRareBatchimRatio = hangul > 0 ? hangulRareBatchim / hangul : 0\n\n  // 받침 분포 이상 = ToUnicode 오매핑 mojibake. 받침없음 희소 + 희귀받침 과다를\n  // 둘 다(AND) 만족해야 발화 — 자연 한국어(받침없음 다수)에서 오탐 방지.\n  const garbledHangul =\n    hangul >= MOJIBAKE_MIN_HANGUL &&\n    hangulNoBatchimRatio < MOJIBAKE_MAX_NOBATCHIM &&\n    hangulRareBatchimRatio >= MOJIBAKE_MIN_RAREBATCHIM\n\n  let needsOcr = false\n  let ocrReason: PageQuality[\"ocrReason\"] | undefined\n  // 우선순위: low_text > high_pua > high_control > high_replacement > garbled_hangul\n  if (total < LOW_TEXT_THRESHOLD) { needsOcr = true; ocrReason = \"low_text\" }\n  else if (puaRatio >= HIGH_PUA_THRESHOLD) { needsOcr = true; ocrReason = \"high_pua\" }\n  else if (controlCharRatio >= HIGH_CONTROL_THRESHOLD) { needsOcr = true; ocrReason = \"high_control\" }\n  else if (replacementCharRatio >= HIGH_REPLACEMENT_THRESHOLD) { needsOcr = true; ocrReason = \"high_replacement\" }\n  else if (garbledHangul) { needsOcr = true; ocrReason = \"garbled_hangul\" }\n\n  return {\n    page,\n    textChars: total,\n    hangulRatio: hangul / denom,\n    controlCharRatio,\n    replacementCharRatio,\n    puaRatio,\n    hangulNoBatchimRatio,\n    hangulRareBatchimRatio,\n    needsOcr,\n    ocrReason,\n  }\n}\n\n/** 페이지별 품질에서 문서 단위 요약을 계산한다. */\nexport interface DocumentQualitySummary {\n  totalPages: number\n  totalTextChars: number\n  avgHangulRatio: number\n  avgControlCharRatio: number\n  avgReplacementCharRatio: number\n  avgPuaRatio: number\n  /** textChars 매우 적은 페이지 수 (이미지/빈 페이지 후보) */\n  lowTextPageCount: number\n  /** PUA 비율 임계 초과 페이지 수 (글꼴 매핑 깨짐 후보) */\n  highPuaPageCount: number\n  /** 문서 전체에 OCR 검토가 권장되는지 — needsOcr 페이지 비율 또는 평균 신호 기반 */\n  needsOcr: boolean\n  /** needsOcr=true인 페이지 번호 목록 */\n  ocrCandidatePages: number[]\n}\n\nconst LOW_TEXT_THRESHOLD = 20\nconst HIGH_PUA_THRESHOLD = 0.2\nconst HIGH_CONTROL_THRESHOLD = 0.05\nconst HIGH_REPLACEMENT_THRESHOLD = 0.05\n/** 페이지 중 needsOcr 비율이 이 값 이상이면 문서 전체에 OCR 권장 */\nconst DOC_NEEDS_OCR_PAGE_RATIO = 0.3\n\n/**\n * 비표시 제어문자를 제거한다. C0(NUL 등, tab/lf/cr 제외) + DEL + C1.\n * PUA는 사용자가 글꼴 매핑 실패를 시각적으로 확인할 수 있도록 보존.\n */\nexport function stripControlChars(text: string): string {\n  // eslint-disable-next-line no-control-regex\n  return text.replace(/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F\\x80-\\x9F]/g, \"\")\n}\n\nexport function summarizeDocumentQuality(pages: PageQuality[]): DocumentQualitySummary {\n  if (pages.length === 0) {\n    return {\n      totalPages: 0,\n      totalTextChars: 0,\n      avgHangulRatio: 0,\n      avgControlCharRatio: 0,\n      avgReplacementCharRatio: 0,\n      avgPuaRatio: 0,\n      lowTextPageCount: 0,\n      highPuaPageCount: 0,\n      needsOcr: false,\n      ocrCandidatePages: [],\n    }\n  }\n\n  let textChars = 0\n  let hangul = 0\n  let control = 0\n  let replacement = 0\n  let pua = 0\n  let lowText = 0\n  let highPua = 0\n  const ocrCandidatePages: number[] = []\n\n  for (const p of pages) {\n    textChars += p.textChars\n    hangul += p.hangulRatio\n    control += p.controlCharRatio\n    replacement += p.replacementCharRatio\n    pua += p.puaRatio\n    if (p.textChars < LOW_TEXT_THRESHOLD) lowText++\n    if (p.puaRatio >= HIGH_PUA_THRESHOLD) highPua++\n    // OCR 이 이미 적용된 페이지는 후보에서 제외 — \"OCR 했는데 또 하라\" 자기모순 방지\n    if (p.needsOcr && !p.ocrApplied) ocrCandidatePages.push(p.page)\n  }\n\n  const n = pages.length\n  return {\n    totalPages: n,\n    totalTextChars: textChars,\n    avgHangulRatio: hangul / n,\n    avgControlCharRatio: control / n,\n    avgReplacementCharRatio: replacement / n,\n    avgPuaRatio: pua / n,\n    lowTextPageCount: lowText,\n    highPuaPageCount: highPua,\n    needsOcr: ocrCandidatePages.length / n >= DOC_NEEDS_OCR_PAGE_RATIO,\n    ocrCandidatePages,\n  }\n}\n","/**\n * PDF 마크다운 최종 정리.\n *\n * 페이지 번호 제거, 균등배분 후처리, 취소선 복원, 한글 줄바꿈 병합.\n * blocksToMarkdown 이후의 문자열 수준 후처리를 담당한다.\n */\n\nimport type { IRBlock } from \"../types.js\"\nimport { stripControlChars } from \"./quality.js\"\nimport { collapseEvenSpacing } from \"./text-line.js\"\n\n/** 블록 트리의 텍스트에서 비표시 제어문자를 in-place로 제거한다. */\nexport function sanitizeBlockControlChars(blocks: IRBlock[]): void {\n  for (const b of blocks) {\n    if (b.text) b.text = stripControlChars(b.text)\n    if (b.table) {\n      for (const row of b.table.cells) {\n        for (const cell of row) {\n          if (cell.text) cell.text = stripControlChars(cell.text)\n        }\n      }\n    }\n    if (b.children) sanitizeBlockControlChars(b.children)\n  }\n}\n\nexport function cleanPdfText(text: string): string {\n  return mergeKoreanLines(\n    stripControlChars(text)\n      // 문서 시작 단독 페이지 번호\n      .replace(/^\\d{1,4}\\n/, \"\")\n      // \"- 2 -\" 스타일 페이지 번호 (독립 라인 및 목록 항목 형태 포함)\n      .replace(/^[\\s]*[-–—]\\s*[-–—]?\\d+[-–—]?[\\s]*[-–—]?[\\s]*$/gm, \"\")\n      // \"1 / 5\" 스타일 페이지 번호\n      .replace(/^\\s*\\d+\\s*\\/\\s*\\d+\\s*$/gm, \"\")\n      // 단독 페이지 번호 (줄 끝에 혼자 있는 숫자)\n      .replace(/\\n\\d{1,4}\\n/g, \"\\n\")\n      // 문서 마지막 단독 페이지 번호\n      .replace(/\\n\\d{1,4}$/, \"\")\n      // 단독 숫자 헤딩 제거 (\"# 6\\n재무과\" → \"\\n재무과\")\n      .replace(/^#{1,6}\\s*\\d{1,4}\\s*$/gm, \"\")\n  )\n    // 균등배분 문자열 후처리 (pdfjs가 합친 TextItem + buildGridTable 셀 텍스트)\n    // LaTeX 수식 라인 ($...$ / $$...$$) 은 공백이 토큰 구분자라 collapse 시 `\\cdot d` → `\\cdotd` 로 망가짐 — skip\n    .replace(/^(?!\\| ---).*$/gm, line => {\n      if (/^\\s*\\${1,2}.+\\${1,2}\\s*$/.test(line)) return line\n      return collapseEvenSpacing(line)\n    })\n    // 마커 뒤 2글자 균등배분 합침 (\"□ 일 시\" → \"□ 일시\", \"□ 장 소\" → \"□ 장소\")\n    .replace(/([□■◆○●▶ㅇ])\\s+([가-힣])\\s+([가-힣])/g, \"$1 $2$3\")\n    // 취소선 복원: builder escapeGfm이 ~를 \\~로 이스케이프 — 쌍(~~)만 되살림\n    .replace(/\\\\~\\\\~/g, \"~~\")\n    // 인접 취소선 run이 붙어 생긴 빈 마크(~~~~) 정리\n    .replace(/~~~~/g, \"\")\n    .replace(/\\n{3,}/g, \"\\n\\n\")\n    .trim()\n}\n\nfunction startsWithMarker(line: string): boolean {\n  const t = line.trimStart()\n  return /^[가-힣ㄱ-ㅎ][.)]/.test(t) || /^\\d+[.)]/.test(t) || /^\\([가-힣ㄱ-ㅎ\\d]+\\)/.test(t) ||\n    /^[○●※▶▷◆◇■□★☆\\-·]\\s/.test(t) || /^제\\d+[조항호장절]/.test(t)\n}\n\nfunction isStandaloneHeader(line: string): boolean {\n  return /^제\\d+[조항호장절](\\([^)]*\\))?(\\s+\\S+){0,7}$/.test(line.trim())\n}\n\nfunction mergeKoreanLines(text: string): string {\n  if (!text) return \"\"\n  const lines = text.split(\"\\n\")\n  if (lines.length <= 1) return text\n  const result: string[] = [lines[0]]\n\n  for (let i = 1; i < lines.length; i++) {\n    const prev = result[result.length - 1]\n    const curr = lines[i]\n    const currTrimmed = curr.trim()\n    // 마크다운 헤딩/테이블/구분선은 병합하지 않음\n    if (/^#{1,6}\\s/.test(prev) || /^#{1,6}\\s/.test(curr) || /^\\|/.test(currTrimmed) || /^---/.test(currTrimmed)) {\n      result.push(curr)\n      continue\n    }\n    // 쉼표로 끝나는 줄 + 다음 줄 = 연속 문장\n    if (/,$/.test(prev.trim()) && currTrimmed.length > 0) {\n      result[result.length - 1] = prev + \"\\n\" + curr\n      continue\n    }\n    // (※ 로 시작하는 줄 = 이전 줄의 부연설명\n    if (/^\\(※/.test(currTrimmed)) {\n      result[result.length - 1] = prev + \" \" + currTrimmed\n      continue\n    }\n    // 한글 줄바꿈 병합 — 마커(○, □ 등)로 시작하는 이전 줄은 합치지 않음\n    if (/[가-힣·,\\-]$/.test(prev) && /^[가-힣(]/.test(curr) &&\n        !startsWithMarker(curr) && !isStandaloneHeader(prev) &&\n        !startsWithMarker(prev)) {\n      result[result.length - 1] = prev + \" \" + curr\n    } else {\n      result.push(curr)\n    }\n  }\n  return result.join(\"\\n\")\n}\n","/**\n * 수식 OCR 통합 (optional)\n *\n * 페이지 이미지 렌더 후 수식만 검출/인식해 blocks 에 formula paragraph 를 삽입.\n * 모델은 최초 실행 시 자동 다운로드 (./formula/index.js 에 위임).\n */\n\nimport type { IRBlock, ParseWarning } from \"../types.js\"\n\n/**\n * 수식 OCR 을 적용하여 blocks 에 formula paragraph 를 삽입한다.\n *\n * 좌표 매핑:\n *   - pdfium 픽셀 bbox (top-left origin) → PDF 포인트 (bottom-left origin) 변환\n *   - 수식 bbox 의 y center 와 같은 페이지 내 pdfjs block 의 y center 비교로 삽입 위치 결정\n *   - pdfjs 가 이미 뽑은 수식 흔적(block) 과 겹치면 해당 block 제거 (중복 방지)\n *\n * 실패/trivial 수식(latex === \"\") 은 삽입하지 않는다.\n */\nexport async function applyFormulaOcr(\n  buffer: ArrayBuffer,\n  blocks: IRBlock[],\n  pageFilter: Set<number> | null,\n  effectivePageCount: number,\n  warnings: ParseWarning[],\n  _onProgress?: (current: number, total: number) => void,\n): Promise<void> {\n  const formulaMod = await import(\"./formula/index.js\")\n  const { FormulaPipeline, ensureFormulaModels } = formulaMod\n\n  // 모델 준비 — 없으면 자동 다운로드. 진행률은 stderr 로 출력.\n  await ensureFormulaModels((p) => {\n    if (p.phase === \"download\" && p.total) {\n      const pct = Math.floor((p.downloaded / p.total) * 100)\n      process.stderr.write(`\\r[kordoc-formula] ${p.spec.name} ${pct}% (${formatMb(p.downloaded)}/${formatMb(p.total)})`)\n      if (p.downloaded >= p.total) process.stderr.write(\"\\n\")\n    } else if (p.phase === \"verify\") {\n      process.stderr.write(`[kordoc-formula] ${p.spec.name} SHA-256 검증 중...\\n`)\n    } else if (p.phase === \"done\") {\n      process.stderr.write(`[kordoc-formula] ${p.spec.name} 준비 완료\\n`)\n    } else if (p.phase === \"skip\") {\n      // 조용히 스킵\n    }\n  })\n\n  const pipeline = await FormulaPipeline.create()\n  try {\n    // MAX_PAGES 상한을 수식 OCR 에도 적용 — 필터 없으면 텍스트 추출과 같은 범위만\n    // (없으면 대형 PDF 에서 상한 밖 페이지까지 ONNX 추론 + 결과가 문서 끝에 오배치)\n    const effectiveFilter = pageFilter\n      ?? new Set(Array.from({ length: effectivePageCount }, (_, i) => i + 1))\n    const pagesResult = await pipeline.runOnBuffer(buffer, effectiveFilter)\n\n    if (pagesResult.length === 0) return\n\n    let insertedCount = 0\n    let removedDupCount = 0\n\n    for (const page of pagesResult) {\n      const pageNumber = page.pageNumber\n      const pdfHeight = page.pdfHeight\n      const scaleX = page.renderedWidth > 0 ? page.pdfWidth / page.renderedWidth : 0.5\n      const scaleY = page.renderedHeight > 0 ? page.pdfHeight / page.renderedHeight : 0.5\n\n      // 1) 수식 → (PDF 포인트 bbox, latex) 정규화 + trivial 제외\n      interface FormulaCandidate {\n        block: IRBlock\n        pdfBbox: { x1: number; x2: number; yTop: number; yBottom: number }\n        centerY: number // PDF bottom-up\n      }\n      const candidates: FormulaCandidate[] = []\n      for (const r of page.regions) {\n        if (!r.latex || !r.latex.trim()) continue\n        const wrapped = r.kind === \"display\" ? `$$${r.latex}$$` : `$${r.latex}$`\n\n        const x1 = r.bbox.x1 * scaleX\n        const x2 = r.bbox.x2 * scaleX\n        // pdfium 픽셀 y → PDF bottom-up\n        const yTop = pdfHeight - r.bbox.y1 * scaleY\n        const yBottom = pdfHeight - r.bbox.y2 * scaleY\n        const centerY = (yTop + yBottom) / 2\n        const width = x2 - x1\n        const height = yTop - yBottom\n\n        candidates.push({\n          block: {\n            type: \"paragraph\",\n            text: wrapped,\n            pageNumber,\n            bbox: { page: pageNumber, x: x1, y: yBottom, width, height },\n          },\n          pdfBbox: { x1, x2, yTop, yBottom },\n          centerY,\n        })\n      }\n      if (candidates.length === 0) continue\n\n      // 2) 같은 페이지의 pdfjs block 중 수식 bbox 와 크게 겹치는 것 제거\n      //    (pdfjs 가 수식을 텍스트로 파편 추출한 경우 — overlap ratio ≥ 0.6 이면 중복으로 간주)\n      const OVERLAP_THRESHOLD = 0.6\n      const indicesToRemove = new Set<number>()\n      for (let i = 0; i < blocks.length; i++) {\n        const b = blocks[i]\n        if (b.pageNumber !== pageNumber) continue\n        if (b.type === \"table\") continue // 표는 건드리지 않음\n        if (!b.bbox || b.bbox.width <= 0 || b.bbox.height <= 0) continue\n        const blockArea = b.bbox.width * b.bbox.height\n        if (blockArea <= 0) continue\n\n        for (const c of candidates) {\n          const ox1 = Math.max(b.bbox.x, c.pdfBbox.x1)\n          const ox2 = Math.min(b.bbox.x + b.bbox.width, c.pdfBbox.x2)\n          const oy1 = Math.max(b.bbox.y, c.pdfBbox.yBottom)\n          const oy2 = Math.min(b.bbox.y + b.bbox.height, c.pdfBbox.yTop)\n          const interArea = Math.max(0, ox2 - ox1) * Math.max(0, oy2 - oy1)\n          if (interArea / blockArea >= OVERLAP_THRESHOLD) {\n            indicesToRemove.add(i)\n            break\n          }\n        }\n      }\n\n      if (indicesToRemove.size > 0) {\n        // 내림차순으로 제거해야 인덱스가 밀리지 않음\n        const sorted = [...indicesToRemove].sort((a, b) => b - a)\n        for (const idx of sorted) blocks.splice(idx, 1)\n        removedDupCount += indicesToRemove.size\n      }\n\n      // 3) 각 수식을 y 좌표 기준 적절한 위치에 삽입\n      //    수식들을 위→아래(centerY 큰 것부터) 정렬 후, 각 수식마다 현재 blocks 에서\n      //    \"center y < 수식 center y\" 인 첫 블록(= 수식보다 아래) 앞에 삽입.\n      candidates.sort((a, b) => b.centerY - a.centerY)\n\n      for (const c of candidates) {\n        let insertIdx = -1\n        let pageFirstIdx = -1\n        let pageLastIdx = -1\n        for (let i = 0; i < blocks.length; i++) {\n          const b = blocks[i]\n          if (b.pageNumber !== pageNumber) continue\n          if (pageFirstIdx === -1) pageFirstIdx = i\n          pageLastIdx = i\n          if (!b.bbox) continue\n          const blockCenter = b.bbox.y + b.bbox.height / 2\n          if (blockCenter < c.centerY) {\n            insertIdx = i\n            break\n          }\n        }\n\n        if (insertIdx !== -1) {\n          blocks.splice(insertIdx, 0, c.block)\n        } else if (pageLastIdx !== -1) {\n          blocks.splice(pageLastIdx + 1, 0, c.block)\n        } else {\n          // 해당 페이지에 텍스트 블록 없음 — 맨 끝에 추가\n          blocks.push(c.block)\n        }\n        insertedCount++\n      }\n    }\n\n    if (insertedCount > 0 || removedDupCount > 0) {\n      process.stderr.write(\n        `[kordoc-formula] ${insertedCount}개 수식 삽입, ${removedDupCount}개 중복 block 제거 (${pagesResult.length}개 페이지)\\n`,\n      )\n    }\n  } finally {\n    await pipeline.destroy().catch(() => {})\n  }\n}\n\nfunction formatMb(bytes: number): string {\n  return `${(bytes / 1024 / 1024).toFixed(1)}MB`\n}\n","/**\n * 서버리스/Node.js 환경에서 pdfjs-dist가 요구하는 브라우저 API polyfill.\n * pdfjs-dist import보다 먼저 실행되어야 함 (ES 모듈 호이스팅 대응으로 별도 파일 분리).\n *\n * 1. DOMMatrix / Path2D polyfill — pdfjs-dist가 참조하지만 Node.js에 없음\n * 2. pdfjsWorker 사전 주입 — fake worker의 동적 import를 우회\n */\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst g = globalThis as any\n\nif (typeof g.DOMMatrix === \"undefined\") {\n  g.DOMMatrix = class DOMMatrix {\n    m: number[] = [1, 0, 0, 1, 0, 0]\n    constructor(init?: number[]) { if (init) this.m = init }\n  }\n}\n\nif (typeof g.Path2D === \"undefined\") {\n  g.Path2D = class Path2D {}\n}\n\n// worker 모듈 static import → fake worker가 동적 import를 건너뜀\n// @ts-expect-error pdfjs-dist worker has no type declarations\nimport * as pdfjsWorker from \"pdfjs-dist/legacy/build/pdf.worker.mjs\"\ng.pdfjsWorker = pdfjsWorker\n","/**\n * PDF 텍스트 추출 (pdfjs-dist static import 기반)\n *\n * polyfill을 먼저 import해야 DOMMatrix/Path2D/pdfjsWorker가 주입됨.\n * ES 모듈 호이스팅 때문에 별도 파일로 분리되어 있음.\n *\n * 이 파일은 엔트리(파이프라인 오케스트레이션)와 메타데이터 추출만 담당한다.\n * 세부 단계는 모듈로 분리: text-line(아이템/줄), xy-cut(읽기 순서),\n * columns(열 감지), page-blocks(블록 추출), block-detect(후처리 감지),\n * text-clean(마크다운 정리), formula-ocr(수식).\n */\n\nimport type { InternalParseResult, IRBlock, DocumentMetadata, ExtractedImage, ParseOptions, ParseWarning, OutlineItem } from \"../types.js\"\nimport { KordocError } from \"../utils.js\"\nimport { parsePageRange } from \"../page-range.js\"\nimport { blocksToMarkdown } from \"../table/builder.js\"\nimport { extractImageRegions } from \"./line-detector.js\"\nimport { createPdfImageState, extractPageImages, injectPageImageBlocks } from \"./image-extract.js\"\nimport { computePageQuality, summarizeDocumentQuality, type PageQuality } from \"./quality.js\"\nimport { type PdfTextItem, normalizeItems, filterHiddenText } from \"./text-line.js\"\nimport { extractPageBlocksWithLines, mergeCrossPageTables } from \"./page-blocks.js\"\nimport { computeMedianFontSizeFromFreq, detectHeadings, detectMarkerHeadings, detectTableCaptions, detectKoreanListBlocks, removeHeaderFooterBlocks } from \"./block-detect.js\"\nimport { sanitizeBlockControlChars, cleanPdfText } from \"./text-clean.js\"\nimport { applyFormulaOcr } from \"./formula-ocr.js\"\n// polyfill 먼저 (ES 모듈 호이스팅되므로 별도 파일 필수)\nimport \"./polyfill.js\"\nimport { getDocument, GlobalWorkerOptions } from \"pdfjs-dist/legacy/build/pdf.mjs\"\nimport { createRequire } from \"node:module\"\nimport { dirname, join } from \"node:path\"\n\n// 기존 공개 API 경로 유지 — 이동된 함수의 re-export\nexport { mergeCrossPageTables }\nexport { cleanPdfText }\nexport { detectTableCaptions, detectKoreanListBlocks, removeHeaderFooterBlocks }\n\n// worker 비활성화 (polyfill에서 pdfjsWorker를 이미 주입했으므로)\nGlobalWorkerOptions.workerSrc = \"\"\n\n// ─── 안전 한계값 (구조적 파싱과 무관) ────────────────\nconst MAX_PAGES = 5000\nconst MAX_TOTAL_TEXT = 100 * 1024 * 1024 // 100MB\n/** PDF 로딩 타임아웃 (30초) — 악성/대용량 PDF 무한 대기 방지 */\nconst PDF_LOAD_TIMEOUT_MS = 30_000\n\n// CID 폰트 자산(cmaps/standard_fonts) 경로 — 미지정이면 CMap 필요 폰트의 텍스트가\n// \"loadFont failed: cMapUrl 필요\" 경고와 함께 통째로 소실된다 (성과계획서류 숫자 전멸).\n// pdfjs-dist 패키지 위치에서 해석하고, 실패 시 미지정(기존 동작) 유지.\nconst pdfjsAssets: { cMapUrl?: string; cMapPacked?: boolean; standardFontDataUrl?: string } = {}\ntry {\n  const _require = createRequire(import.meta.url)\n  const pkgDir = dirname(_require.resolve(\"pdfjs-dist/package.json\"))\n  pdfjsAssets.cMapUrl = join(pkgDir, \"cmaps\") + \"/\"\n  pdfjsAssets.cMapPacked = true\n  pdfjsAssets.standardFontDataUrl = join(pkgDir, \"standard_fonts\") + \"/\"\n} catch { /* optional dep — 경로 해석 실패 시 cMap 없이 진행 */ }\n\n/** getDocument + 타임아웃 래퍼 */\nasync function loadPdfWithTimeout(buffer: ArrayBuffer) {\n  const loadingTask = getDocument({\n    data: new Uint8Array(buffer),\n    useSystemFonts: true,\n    disableFontFace: true,\n    isEvalSupported: false,\n    ...pdfjsAssets,\n  })\n  let timer: ReturnType<typeof setTimeout> | undefined\n  try {\n    return await Promise.race([\n      loadingTask.promise,\n      new Promise<never>((_, reject) => {\n        timer = setTimeout(() => { loadingTask.destroy(); reject(new KordocError(\"PDF 로딩 타임아웃 (30초 초과)\")) }, PDF_LOAD_TIMEOUT_MS)\n      }),\n    ])\n  } finally {\n    if (timer !== undefined) clearTimeout(timer)\n  }\n}\n\nexport async function parsePdfDocument(buffer: ArrayBuffer, options?: ParseOptions): Promise<InternalParseResult> {\n  // pdfjs-dist 는 전달받은 buffer 의 underlying storage 를 detach 할 수 있다.\n  // 수식/텍스트 OCR 은 같은 버퍼를 pdfium 으로 재사용해야 하므로 옵션 on 일 때만 clone 을 보관.\n  const formulaBuffer: ArrayBuffer | null = options?.formulaOcr ? buffer.slice(0) : null\n  const ocrBuffer: ArrayBuffer | null = options?.ocr ? buffer.slice(0) : null\n  const doc = await loadPdfWithTimeout(buffer)\n\n  try {\n    const pageCount = doc.numPages\n    if (pageCount === 0) throw new KordocError(\"PDF에 페이지가 없습니다.\")\n\n    // 메타데이터 추출 (best-effort)\n    const metadata: DocumentMetadata = { pageCount }\n    await extractPdfMetadata(doc, metadata)\n\n    const blocks: IRBlock[] = []\n    const warnings: ParseWarning[] = []\n    const pageQuality: PageQuality[] = []\n    let totalChars = 0\n    let totalTextBytes = 0\n    const effectivePageCount = Math.min(pageCount, MAX_PAGES)\n\n    // 페이지 범위 필터링\n    const pageFilter = options?.pages ? parsePageRange(options.pages, effectivePageCount) : null\n    const totalTarget = pageFilter ? pageFilter.size : effectivePageCount\n\n    // 전체 문서의 폰트 크기 빈도 수집 (헤딩 감지용) — 빈도 Map으로 메모리 절약\n    const fontSizeFreq = new Map<number, number>()\n    const pageHeights = new Map<number, number>()\n    // 큰 이미지가 있는 페이지 (needsOcr 경고 노이즈 필터 + SKIPPED_IMAGE)\n    const pagesWithLargeImage = new Set<number>()\n    // 텍스트 없는 큰 이미지 영역: page → count\n    const skippedImagePages = new Map<number, number>()\n    // 이미지 XObject 바이트 추출 상태 (문서 단위 중복 억제·상한).\n    // image 블록은 페이지 경계 표 병합(mergeCrossPageTables)의 인접성을 깨지 않도록\n    // 페이지별로 모아뒀다가 병합 후 주입한다.\n    const imageState = createPdfImageState()\n    const extractedImages: ExtractedImage[] = []\n    const pageImageBlocks = new Map<number, IRBlock[]>()\n\n    let parsedPages = 0\n    for (let i = 1; i <= effectivePageCount; i++) {\n      if (pageFilter && !pageFilter.has(i)) continue\n      try {\n        const page = await doc.getPage(i)\n        const tc = await page.getTextContent()\n        const viewport = page.getViewport({ scale: 1 })\n        pageHeights.set(i, viewport.height)\n        const rawItems = tc.items as PdfTextItem[]\n        const items = normalizeItems(rawItems)\n\n        // hidden text 필터링 + 경고 수집\n        const { visible, hiddenCount } = filterHiddenText(items, viewport.width, viewport.height)\n        if (hiddenCount > 0) {\n          warnings.push({ page: i, message: `${hiddenCount}개 숨겨진 텍스트 요소 필터링됨`, code: \"HIDDEN_TEXT_FILTERED\" })\n        }\n\n        // 폰트 크기 빈도 수집\n        for (const item of visible) {\n          if (item.fontSize > 0) fontSizeFreq.set(item.fontSize, (fontSizeFreq.get(item.fontSize) || 0) + 1)\n        }\n\n        // 선 기반 테이블 감지를 위한 operatorList\n        const opList = await page.getOperatorList()\n\n        // 이미지 영역 감지 — 텍스트 없는 큰 이미지는 무음 정보손실이므로 가시화 (ODL 아이디어)\n        const pageArea = viewport.width * viewport.height\n        if (pageArea > 0) {\n          const imageRegions = extractImageRegions(opList.fnArray, opList.argsArray)\n          let uncovered = 0\n          for (const r of imageRegions) {\n            const area = (r.x2 - r.x1) * (r.y2 - r.y1)\n            if (area < pageArea * 0.05) continue // 작은 장식 이미지 무시\n            pagesWithLargeImage.add(i)\n            const hasText = visible.some(it => {\n              const cx = it.x + it.w / 2\n              const cy = it.y + (it.h || it.fontSize) / 2\n              return cx >= r.x1 && cx <= r.x2 && cy >= r.y1 && cy <= r.y2\n            })\n            if (!hasText) uncovered++\n          }\n          if (uncovered > 0) skippedImagePages.set(i, uncovered)\n        }\n\n        const pageBlocks = extractPageBlocksWithLines(visible, i, opList, viewport.width, viewport.height)\n        for (const b of pageBlocks) blocks.push(b)\n\n        // 이미지 XObject 바이트 추출 — 블록 주입은 표 병합 후(injectPageImageBlocks)\n        try {\n          const { blocks: imgBlocks, images: pageImages } = await extractPageImages(page, opList.fnArray, opList.argsArray, i, imageState, warnings)\n          if (imgBlocks.length > 0) pageImageBlocks.set(i, imgBlocks)\n          extractedImages.push(...pageImages)\n        } catch { /* 이미지 추출 실패가 텍스트 파싱을 막지 않도록 */ }\n\n        // 이미지 기반 PDF 감지 + 크기 제한용 문자 수 집계 + 페이지 품질 신호\n        let pageText = \"\"\n        for (const b of pageBlocks) {\n          let t = b.text || \"\"\n          // 표 블록은 text 없이 table만 가지므로 셀 텍스트도 집계 — 괘선 양식(표-전용)\n          // 문서가 totalChars=0으로 이미지 기반 오판되어 정상 파싱 표가 OCR로\n          // 대체되는 것 방지. 페이지 품질(computePageQuality) 집계도 같은 뿌리.\n          if (b.type === \"table\" && b.table) {\n            const cellText = b.table.cells.map(row => row.map(c => c.text).join(\" \")).join(\"\\n\")\n            t = t ? t + \"\\n\" + cellText : cellText\n          }\n          totalChars += t.replace(/\\s/g, \"\").length\n          totalTextBytes += t.length * 2\n          pageText += pageText ? \"\\n\" + t : t\n        }\n        pageQuality.push(computePageQuality(i, pageText))\n        if (totalTextBytes > MAX_TOTAL_TEXT) throw new KordocError(\"텍스트 추출 크기 초과\")\n        parsedPages++\n        options?.onProgress?.(parsedPages, totalTarget)\n      } catch (pageErr) {\n        // 크기 초과는 전체 중단\n        if (pageErr instanceof KordocError) throw pageErr\n        warnings.push({ page: i, message: `페이지 ${i} 파싱 실패: ${pageErr instanceof Error ? pageErr.message : \"알 수 없는 오류\"}`, code: \"PARTIAL_PARSE\" })\n      }\n    }\n\n    const parsedPageCount = parsedPages || (pageFilter ? pageFilter.size : effectivePageCount)\n    // 문서 단위 이미지 기반 판정 (평균 10자/페이지 미만 = 텍스트층 부재)\n    const isImageBased = totalChars / Math.max(parsedPageCount, 1) < 10\n\n    // ── OCR 실행 (옵션) — 페이지 단위 선정·병합 ──\n    // 대상: \"force\"=전 페이지 / 문서가 이미지 기반=전 페이지 /\n    //       그 외=품질 신호가 OCR 을 권하는 페이지만 (깨진 텍스트층 포함 — F1,\n    //       혼합 문서의 스캔 페이지 포함 — F2). 정상 페이지 파싱 결과는 유지 (F3).\n    const ocrDone = new Set<number>()\n    if (options?.ocr && ocrBuffer) {\n      const inScope = (p: number) => !pageFilter || pageFilter.has(p)\n      const targets = new Set<number>()\n      if (options.ocr === \"force\" || isImageBased) {\n        for (let i = 1; i <= effectivePageCount; i++) if (inScope(i)) targets.add(i)\n      } else {\n        for (const pq of pageQuality) {\n          if (!pq.needsOcr) continue\n          // low_text 는 빈 페이지(표지/간지)일 수 있으므로 큰 이미지가 있는 페이지만\n          if (pq.ocrReason === \"low_text\" && !pagesWithLargeImage.has(pq.page)) continue\n          targets.add(pq.page)\n        }\n      }\n      if (targets.size > 0) {\n        try {\n          const { runPdfOcr } = await import(\"../ocr/pdf-ocr.js\")\n          const mode = typeof options.ocr === \"function\" ? options.ocr : (\"builtin\" as const)\n          const ocrPageBlocks = await runPdfOcr(ocrBuffer, targets, mode, warnings, options.onProgress)\n          if (ocrPageBlocks.size > 0) {\n            // 페이지 단위 교체: OCR 성공 페이지의 기존(깨진/빈) 블록 제거 후 병합\n            for (const p of ocrPageBlocks.keys()) ocrDone.add(p)\n            const merged = blocks.filter(b => !(b.pageNumber && ocrDone.has(b.pageNumber)))\n            for (const obs of ocrPageBlocks.values()) merged.push(...obs)\n            merged.sort((a, b) => (a.pageNumber ?? 0) - (b.pageNumber ?? 0)) // stable — 페이지 내 순서 보존\n            blocks.length = 0\n            blocks.push(...merged)\n            for (const pq of pageQuality) if (ocrDone.has(pq.page)) pq.ocrApplied = true\n            warnings.push({\n              message: `${ocrDone.size}개 페이지에 OCR 적용 (${mode === \"builtin\" ? \"내장 PP-OCRv5\" : \"사용자 프로바이더\"})`,\n              code: \"OCR_APPLIED\",\n            })\n          }\n        } catch (e) {\n          // 환경 오류(의존성·모델) — 아래 NEEDS_OCR 폴백이 가시화, 원인은 별도 경고로 보존 (F6)\n          warnings.push({\n            message: `OCR 실행 불가: ${e instanceof Error ? e.message : String(e)}`,\n            code: \"OCR_FAILED\",\n          })\n        }\n      }\n    }\n\n    if (isImageBased && ocrDone.size === 0) {\n      // OCR 미설정/실패 — 빈 출력을 무경고로 내보내지 않고 경고 + 플래그로 가시화 (v3.0)\n      warnings.push({\n        message: `이미지 기반 PDF (${pageCount}페이지, 텍스트 ${totalChars}자) — 텍스트 레이어가 없어 OCR이 필요합니다`,\n        code: \"NEEDS_OCR\",\n      })\n    }\n\n    // 페이지 단위 needsOcr 경고 — 텍스트+스캔 혼합 문서에서 스캔 페이지 무음 손실 방지.\n    // low_text는 빈 페이지(표지/간지)일 수 있으므로 큰 이미지가 있는 페이지만 경고.\n    // OCR 이 적용된 페이지는 해소된 것이므로 제외.\n    if (!isImageBased) {\n      const OCR_REASON_MESSAGES: Record<string, string> = {\n        low_text: \"텍스트가 거의 없는 페이지 (스캔/이미지 추정)\",\n        high_pua: \"글꼴 매핑 실패 (PUA 비율 높음) — 추출 텍스트 신뢰 불가\",\n        high_control: \"제어문자 비율 높음 — 추출 텍스트 신뢰 불가\",\n        high_replacement: \"대체문자(U+FFFD) 비율 높음 — 추출 텍스트 신뢰 불가\",\n        garbled_hangul: \"글꼴 매핑 실패 (한글 자소 분포 이상) — 추출 텍스트가 깨졌을 수 있음\",\n      }\n      for (const pq of pageQuality) {\n        if (!pq.needsOcr || !pq.ocrReason || pq.ocrApplied) continue\n        if (pq.ocrReason === \"low_text\" && !pagesWithLargeImage.has(pq.page)) continue\n        warnings.push({ page: pq.page, message: `${OCR_REASON_MESSAGES[pq.ocrReason]} — OCR 검토 필요`, code: \"NEEDS_OCR\" })\n      }\n    }\n\n    // 텍스트 없는 큰 이미지 영역 경고 — 그림/차트/도장 무음 누락 가시화\n    // (문서 전체가 이미지 기반이면 위의 NEEDS_OCR 단일 경고로 충분)\n    if (!isImageBased) {\n      for (const [page, count] of [...skippedImagePages.entries()].sort((a, b) => a[0] - b[0])) {\n        warnings.push({ page, message: `${count}개 이미지 영역에 추출 가능한 텍스트 없음 (그림/차트/도장 내용 누락 가능)`, code: \"SKIPPED_IMAGE\" })\n      }\n    }\n\n    // 머리글/바닥글 필터링 (기본 ON — 명시적 false일 때만 비활성화)\n    if (options?.removeHeaderFooter !== false && parsedPageCount >= 3) {\n      const removed = removeHeaderFooterBlocks(blocks, pageHeights, warnings)\n      // 필터링된 블록 제거 (뒤에서부터 삭제)\n      for (let ri = removed.length - 1; ri >= 0; ri--) {\n        blocks.splice(removed[ri], 1)\n      }\n    }\n\n    // 페이지 걸친 표 병합 — 머리글/바닥글 제거 후 인접해진 표를 하나로\n    // (ODL TableBorderProcessor.checkNeighborTables 포팅)\n    mergeCrossPageTables(blocks)\n\n    // 추출 이미지 참조를 페이지 말미 위치에 주입 (표 병합 뒤 — 인접성 보존)\n    injectPageImageBlocks(blocks, pageImageBlocks)\n\n    // 수식 OCR (선택) — 기본 텍스트 추출과 별개로 페이지 이미지 렌더 후 수식만 검출/인식.\n    // 실패 시 경고만 기록하고 일반 텍스트 추출 결과는 그대로 반환한다.\n    if (options?.formulaOcr && formulaBuffer) {\n      try {\n        await applyFormulaOcr(formulaBuffer, blocks, pageFilter, effectivePageCount, warnings, options.onProgress)\n      } catch (e) {\n        warnings.push({\n          message: `수식 OCR 실패: ${e instanceof Error ? e.message : String(e)}`,\n          code: \"PARTIAL_PARSE\",\n        })\n      }\n    }\n\n    // 헤딩 감지: 폰트 크기 기반\n    const medianFontSize = computeMedianFontSizeFromFreq(fontSizeFreq)\n    if (medianFontSize > 0) {\n      detectHeadings(blocks, medianFontSize)\n    }\n\n    // □/■ 마커 기반 서브헤딩 감지 (ODL 패턴)\n    detectMarkerHeadings(blocks)\n\n    // 표 캡션 감지 — 표 직전/직후 '표 N./그림 N' 패턴 텍스트를 IRTable.caption으로\n    detectTableCaptions(blocks)\n\n    // 한국어 리스트 감지 — 공문서 계층 라벨(1.→가.→1)→가)→①) 시퀀스 검증\n    detectKoreanListBlocks(blocks)\n\n    // outline 구축\n    const outline: OutlineItem[] = blocks\n      .filter(b => b.type === \"heading\" && b.level && b.text)\n      .map(b => ({ level: b.level!, text: b.text!, pageNumber: b.pageNumber }))\n\n    // 메트릭 수집 끝났으니 블록 텍스트의 C0/C1 제어문자(NUL 등) 정리\n    sanitizeBlockControlChars(blocks)\n\n    // blocksToMarkdown로 통일 — 헤딩 마크다운 반영 (HWP5/HWPX와 일관성)\n    let markdown = cleanPdfText(blocksToMarkdown(blocks))\n\n    return {\n      markdown,\n      blocks,\n      metadata,\n      outline: outline.length > 0 ? outline : undefined,\n      warnings: warnings.length > 0 ? warnings : undefined,\n      isImageBased: isImageBased || undefined,\n      pageQuality,\n      qualitySummary: summarizeDocumentQuality(pageQuality),\n      images: extractedImages.length > 0 ? extractedImages : undefined,\n    }\n  } finally {\n    await doc.destroy().catch(() => {})\n  }\n}\n\n// ─── PDF 메타데이터 추출 ────────────────────────────\n\nasync function extractPdfMetadata(doc: { getMetadata(): Promise<unknown> }, metadata: DocumentMetadata): Promise<void> {\n  try {\n    const result = await doc.getMetadata() as { info?: Record<string, unknown> } | null\n    if (!result?.info) return\n    const info = result.info\n\n    if (typeof info.Title === \"string\" && info.Title.trim()) metadata.title = info.Title.trim()\n    if (typeof info.Author === \"string\" && info.Author.trim()) metadata.author = info.Author.trim()\n    if (typeof info.Creator === \"string\" && info.Creator.trim()) metadata.creator = info.Creator.trim()\n    if (typeof info.Subject === \"string\" && info.Subject.trim()) metadata.description = info.Subject.trim()\n    if (typeof info.Keywords === \"string\" && info.Keywords.trim()) {\n      metadata.keywords = info.Keywords.split(/[,;]/).map((k: string) => k.trim()).filter(Boolean)\n    }\n    if (typeof info.CreationDate === \"string\") metadata.createdAt = parsePdfDate(info.CreationDate)\n    if (typeof info.ModDate === \"string\") metadata.modifiedAt = parsePdfDate(info.ModDate)\n  } catch {\n    // best-effort\n  }\n}\n\n/** PDF 날짜 형식 (D:YYYYMMDDHHmmSS) → ISO 8601 변환 */\nfunction parsePdfDate(dateStr: string): string | undefined {\n  const m = dateStr.match(/D:(\\d{4})(\\d{2})?(\\d{2})?(\\d{2})?(\\d{2})?(\\d{2})?/)\n  if (!m) return undefined\n  const [, year, month = \"01\", day = \"01\", hour = \"00\", min = \"00\", sec = \"00\"] = m\n  return `${year}-${month}-${day}T${hour}:${min}:${sec}`\n}\n\n/** 메타데이터만 추출 (전체 파싱 없이) — MCP parse_metadata용 */\nexport async function extractPdfMetadataOnly(buffer: ArrayBuffer): Promise<DocumentMetadata> {\n  const doc = await loadPdfWithTimeout(buffer)\n\n  try {\n    const metadata: DocumentMetadata = { pageCount: doc.numPages }\n    await extractPdfMetadata(doc, metadata)\n    return metadata\n  } finally {\n    await doc.destroy().catch(() => {})\n  }\n}\n"]}