kordoc 4.21.21 → 4.21.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +5 -4
  2. package/dist/{-ZYF6HX4M.js → -PLOEMNCA.js} +11 -11
  3. package/dist/{chunk-RBRHO7WL.js → chunk-3BJW5KXR.js} +2 -2
  4. package/dist/{chunk-FBJVDBMO.js → chunk-64MCJ5GC.js} +2 -2
  5. package/dist/{chunk-BC54LBST.js → chunk-64V65GZM.js} +3 -3
  6. package/dist/{chunk-QHNVH4TC.js → chunk-6LJ22335.js} +6 -6
  7. package/dist/{chunk-TRO4CQHJ.js → chunk-6Z4NAN36.js} +4 -4
  8. package/dist/{chunk-PTF4NCFM.js → chunk-7MIA3IFO.js} +2 -2
  9. package/dist/{chunk-7YAJCXCD.js → chunk-7TLZ56ZK.js} +2 -2
  10. package/dist/{chunk-ZZIQWSGC.js → chunk-BMQATDUY.js} +2 -2
  11. package/dist/{chunk-YHPJTLCA.js → chunk-BZC24YXS.js} +2 -2
  12. package/dist/{chunk-J5PBI3ZC.js → chunk-EL7BGP7X.js} +3 -3
  13. package/dist/{chunk-5HTFWUJE.js → chunk-G4DVIZTZ.js} +2 -2
  14. package/dist/{chunk-YQZOPT3A.js → chunk-GYQGKVTQ.js} +5 -5
  15. package/dist/{chunk-YQZOPT3A.js.map → chunk-GYQGKVTQ.js.map} +1 -1
  16. package/dist/{chunk-T7PN5D4D.cjs → chunk-H5Q6J2MB.cjs} +6 -6
  17. package/dist/{chunk-T7PN5D4D.cjs.map → chunk-H5Q6J2MB.cjs.map} +1 -1
  18. package/dist/{chunk-HWIW3WK2.js → chunk-H6EXISSP.js} +3 -3
  19. package/dist/{chunk-H2MS4VG3.cjs → chunk-HY5O4B6O.cjs} +3 -3
  20. package/dist/{chunk-H2MS4VG3.cjs.map → chunk-HY5O4B6O.cjs.map} +1 -1
  21. package/dist/{chunk-BG6LUCQL.js → chunk-K7FP2VOU.js} +2 -2
  22. package/dist/{chunk-KTLHADU4.js → chunk-KFJP2HYL.js} +2 -2
  23. package/dist/{chunk-DTZIZ5PP.cjs → chunk-LNKHTD47.cjs} +13 -13
  24. package/dist/{chunk-DTZIZ5PP.cjs.map → chunk-LNKHTD47.cjs.map} +1 -1
  25. package/dist/{chunk-2ZGP7A5T.js → chunk-LZVOL2H2.js} +2 -2
  26. package/dist/{chunk-CY4QM2KQ.js → chunk-M5SPGZKY.js} +2 -2
  27. package/dist/{chunk-YM3K3LCD.js → chunk-NT4LPP2I.js} +10 -10
  28. package/dist/{chunk-T3DTTK23.js → chunk-PQW2RPZZ.js} +2 -2
  29. package/dist/{chunk-3TCF5HPP.js → chunk-PY72S2AG.js} +2 -2
  30. package/dist/{chunk-MCUX2Y2C.cjs → chunk-QSOWCFOV.cjs} +2 -2
  31. package/dist/{chunk-MCUX2Y2C.cjs.map → chunk-QSOWCFOV.cjs.map} +1 -1
  32. package/dist/{chunk-57VNFMMS.cjs → chunk-QY627PK3.cjs} +3 -3
  33. package/dist/{chunk-57VNFMMS.cjs.map → chunk-QY627PK3.cjs.map} +1 -1
  34. package/dist/{chunk-KBV4WFZ6.js → chunk-SQSGBKCL.js} +4 -4
  35. package/dist/{chunk-KBV4WFZ6.js.map → chunk-SQSGBKCL.js.map} +1 -1
  36. package/dist/{chunk-TMUUDMDH.cjs → chunk-SZM6BSK4.cjs} +23 -23
  37. package/dist/{chunk-TMUUDMDH.cjs.map → chunk-SZM6BSK4.cjs.map} +1 -1
  38. package/dist/{chunk-WGMYVRSA.js → chunk-VUSPHKQ6.js} +2 -2
  39. package/dist/{chunk-FZMGF6XU.js → chunk-ZQZDGIN4.js} +2 -2
  40. package/dist/chunks-YV22WKEN.js +10 -0
  41. package/dist/cli.js +30 -30
  42. package/dist/{formula-P7M7IH2L.js → formula-I7GNHDP4.js} +4 -4
  43. package/dist/{formula-6XNIXZ5L.js → formula-L4ISZCZ7.js} +3 -3
  44. package/dist/{formula-A3I5VAP7.cjs → formula-LDXFM42U.cjs} +9 -9
  45. package/dist/{formula-A3I5VAP7.cjs.map → formula-LDXFM42U.cjs.map} +1 -1
  46. package/dist/{image-ocr-3C6W3KZO.js → image-ocr-BSAPJIFP.js} +6 -6
  47. package/dist/{image-ocr-ITNY454I.js → image-ocr-I7IVLLPD.js} +9 -9
  48. package/dist/{image-ocr-UJRSQ2T3.cjs → image-ocr-NCKZ4EOH.cjs} +15 -15
  49. package/dist/{image-ocr-UJRSQ2T3.cjs.map → image-ocr-NCKZ4EOH.cjs.map} +1 -1
  50. package/dist/index.cjs +276 -276
  51. package/dist/index.js +5 -5
  52. package/dist/mcp.js +22 -22
  53. package/dist/{model-bundle-LAWQH75O.js → model-bundle-M3GRBX2A.js} +5 -5
  54. package/dist/{models-6QTD2CFM.js → models-5RXMJK6N.js} +5 -5
  55. package/dist/{parse-worker-v2-FEPJG3ER.js → parse-worker-v2-72KKZMDO.js} +12 -12
  56. package/dist/{parser-56I364YM.js → parser-AHSXHMAF.js} +8 -8
  57. package/dist/{parser-KFCY3NIN.js → parser-QLNX2EFP.js} +11 -11
  58. package/dist/{parser-VKWUMUML.cjs → parser-XQL37S4F.cjs} +136 -136
  59. package/dist/{parser-VKWUMUML.cjs.map → parser-XQL37S4F.cjs.map} +1 -1
  60. package/dist/pdf-ocr-CR4W75XF.js +22 -0
  61. package/dist/pdf-ocr-FL32Z4MB.js +18 -0
  62. package/dist/pdf-ocr-V42HOZKB.cjs +19 -0
  63. package/dist/{pdf-ocr-OKAHNEJA.cjs.map → pdf-ocr-V42HOZKB.cjs.map} +1 -1
  64. package/dist/{profile-io-OCITHUQF.js → profile-io-FTGHBFAH.js} +3 -3
  65. package/dist/{rasterize-SFCG4YOI.js → rasterize-346O4XV4.js} +3 -3
  66. package/dist/{redact-doc-5SMPXIGG.js → redact-doc-O6UVSWKP.js} +11 -11
  67. package/dist/{render-FGPG5A2N.js → render-CUR3VCK5.js} +7 -7
  68. package/dist/seal-QUUK6RBL.js +10 -0
  69. package/dist/{setup-JQVYZO7I.js → setup-6EA7CJZU.js} +3 -3
  70. package/dist/visual-EMOEYBC4.js +20 -0
  71. package/dist/{watch-C77BUSP7.js → watch-EQRSWNAQ.js} +12 -12
  72. package/package.json +1 -1
  73. package/dist/chunks-MDMJRCJ7.js +0 -10
  74. package/dist/pdf-ocr-MME37BL4.js +0 -18
  75. package/dist/pdf-ocr-OKAHNEJA.cjs +0 -19
  76. package/dist/pdf-ocr-ZZ6AJL6Z.js +0 -22
  77. package/dist/seal-GA7NZDTG.js +0 -10
  78. package/dist/visual-YMG644YW.js +0 -20
  79. /package/dist/{-ZYF6HX4M.js.map → -PLOEMNCA.js.map} +0 -0
  80. /package/dist/{chunk-RBRHO7WL.js.map → chunk-3BJW5KXR.js.map} +0 -0
  81. /package/dist/{chunk-FBJVDBMO.js.map → chunk-64MCJ5GC.js.map} +0 -0
  82. /package/dist/{chunk-BC54LBST.js.map → chunk-64V65GZM.js.map} +0 -0
  83. /package/dist/{chunk-QHNVH4TC.js.map → chunk-6LJ22335.js.map} +0 -0
  84. /package/dist/{chunk-TRO4CQHJ.js.map → chunk-6Z4NAN36.js.map} +0 -0
  85. /package/dist/{chunk-PTF4NCFM.js.map → chunk-7MIA3IFO.js.map} +0 -0
  86. /package/dist/{chunk-7YAJCXCD.js.map → chunk-7TLZ56ZK.js.map} +0 -0
  87. /package/dist/{chunk-ZZIQWSGC.js.map → chunk-BMQATDUY.js.map} +0 -0
  88. /package/dist/{chunk-YHPJTLCA.js.map → chunk-BZC24YXS.js.map} +0 -0
  89. /package/dist/{chunk-J5PBI3ZC.js.map → chunk-EL7BGP7X.js.map} +0 -0
  90. /package/dist/{chunk-5HTFWUJE.js.map → chunk-G4DVIZTZ.js.map} +0 -0
  91. /package/dist/{chunk-HWIW3WK2.js.map → chunk-H6EXISSP.js.map} +0 -0
  92. /package/dist/{chunk-BG6LUCQL.js.map → chunk-K7FP2VOU.js.map} +0 -0
  93. /package/dist/{chunk-KTLHADU4.js.map → chunk-KFJP2HYL.js.map} +0 -0
  94. /package/dist/{chunk-2ZGP7A5T.js.map → chunk-LZVOL2H2.js.map} +0 -0
  95. /package/dist/{chunk-CY4QM2KQ.js.map → chunk-M5SPGZKY.js.map} +0 -0
  96. /package/dist/{chunk-YM3K3LCD.js.map → chunk-NT4LPP2I.js.map} +0 -0
  97. /package/dist/{chunk-T3DTTK23.js.map → chunk-PQW2RPZZ.js.map} +0 -0
  98. /package/dist/{chunk-3TCF5HPP.js.map → chunk-PY72S2AG.js.map} +0 -0
  99. /package/dist/{chunk-WGMYVRSA.js.map → chunk-VUSPHKQ6.js.map} +0 -0
  100. /package/dist/{chunk-FZMGF6XU.js.map → chunk-ZQZDGIN4.js.map} +0 -0
  101. /package/dist/{chunks-MDMJRCJ7.js.map → chunks-YV22WKEN.js.map} +0 -0
  102. /package/dist/{formula-P7M7IH2L.js.map → formula-I7GNHDP4.js.map} +0 -0
  103. /package/dist/{formula-6XNIXZ5L.js.map → formula-L4ISZCZ7.js.map} +0 -0
  104. /package/dist/{image-ocr-3C6W3KZO.js.map → image-ocr-BSAPJIFP.js.map} +0 -0
  105. /package/dist/{image-ocr-ITNY454I.js.map → image-ocr-I7IVLLPD.js.map} +0 -0
  106. /package/dist/{model-bundle-LAWQH75O.js.map → model-bundle-M3GRBX2A.js.map} +0 -0
  107. /package/dist/{models-6QTD2CFM.js.map → models-5RXMJK6N.js.map} +0 -0
  108. /package/dist/{parse-worker-v2-FEPJG3ER.js.map → parse-worker-v2-72KKZMDO.js.map} +0 -0
  109. /package/dist/{parser-56I364YM.js.map → parser-AHSXHMAF.js.map} +0 -0
  110. /package/dist/{parser-KFCY3NIN.js.map → parser-QLNX2EFP.js.map} +0 -0
  111. /package/dist/{pdf-ocr-MME37BL4.js.map → pdf-ocr-CR4W75XF.js.map} +0 -0
  112. /package/dist/{pdf-ocr-ZZ6AJL6Z.js.map → pdf-ocr-FL32Z4MB.js.map} +0 -0
  113. /package/dist/{profile-io-OCITHUQF.js.map → profile-io-FTGHBFAH.js.map} +0 -0
  114. /package/dist/{rasterize-SFCG4YOI.js.map → rasterize-346O4XV4.js.map} +0 -0
  115. /package/dist/{redact-doc-5SMPXIGG.js.map → redact-doc-O6UVSWKP.js.map} +0 -0
  116. /package/dist/{render-FGPG5A2N.js.map → render-CUR3VCK5.js.map} +0 -0
  117. /package/dist/{seal-GA7NZDTG.js.map → seal-QUUK6RBL.js.map} +0 -0
  118. /package/dist/{setup-JQVYZO7I.js.map → setup-6EA7CJZU.js.map} +0 -0
  119. /package/dist/{visual-YMG644YW.js.map → visual-EMOEYBC4.js.map} +0 -0
  120. /package/dist/{watch-C77BUSP7.js.map → watch-EQRSWNAQ.js.map} +0 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/mong-e/workspace/kordoc/dist/parser-VKWUMUML.cjs","../src/pdf/image-extract.ts","../src/pdf/quality.ts","../src/pdf/vector-glyphs.ts","../src/pdf/contact-table.ts","../src/pdf/table-pages.ts","../src/pdf/table-parts.ts","../src/pdf/cell-continuation.ts","../src/pdf/symbol-glyphs.ts","../src/pdf/selection-context.ts","../src/pdf/symbol-fonts.ts","../src/pdf/equation-runs.ts","../src/pdf/glyph-names.ts","../src/pdf/occluded-text.ts","../src/pdf/vertical-text.ts","../src/pdf/tracked-text.ts","../src/pdf/endnotes.ts","../src/pdf/tab-leaders.ts","../src/pdf/two-up.ts","../src/pdf/footnotes.ts","../src/pdf/heading-demote.ts","../src/pdf/text-clean.ts","../src/pdf/links.ts","../src/pdf/formula-ocr.ts","../src/pdf/polyfill.ts","../src/pdf/parser.ts"],"names":["OPS","page","k","R","top","g","bar","inside","spread","spaced","out","run","key"],"mappings":"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","file":"/Users/mong-e/workspace/kordoc/dist/parser-VKWUMUML.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 { createHash } from \"node:crypto\"\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 /** 문서 공용 객체 id(\"g_…\") → 내용 해시 키. 같은 id 는 늘 같은 디코딩 객체(commonObjs)라 다시 훑지 않는다 */\n globalKeys: Map<string, string>\n capWarned: boolean\n}\n\nexport function createPdfImageState(): PdfImageState {\n return { imageIndex: 0, totalBytes: 0, seen: new Map(), globalKeys: new Map(), capWarned: false }\n}\n\n/** 픽셀 버퍼 내용 해시 — 네이티브 SHA-1. 종전 FNV-1a JS 루프는 맥미니에서 0.93GB/s(SHA-1 2.97GB/s)라, 쪽마다 같은 그림을\n * 새로 그리는 행정업무운영 편람 384쪽(그림 968번·8.1GB)에서 파싱 시간의 78%를 먹었다. 키는 중복 판정에만 쓴다 */\nfunction contentHash(data: Uint8Array | Uint8ClampedArray): string {\n return createHash(\"sha1\").update(data).digest(\"hex\")\n}\n\n/** 바이트를 안 뽑을 때(images:false) toRgba 가 변환 대신 돌려주는 표지 — 판정(null 여부)만 같게 */\nconst NO_BYTES = new Uint8Array(0)\n\n/** kind별 픽셀 → 8bit RGBA. 미지원/불일치 형태는 null. convert=false 면 판정만 하고 NO_BYTES (w×h×4 할당·변환 생략) */\nfunction toRgba(img: PdfImgData, convert = true): Uint8Array | null {\n const { width: w, height: h, kind, data } = img\n if (!data || !w || !h) return null\n\n if (kind === ImageKind.RGBA_32BPP) {\n if (data.length < w * h * 4) return null\n if (!convert) return NO_BYTES\n const rgba = new Uint8Array(w * h * 4)\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 if (!convert) return NO_BYTES\n const rgba = new Uint8Array(w * h * 4)\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 if (!convert) return NO_BYTES\n const rgba = new Uint8Array(w * h * 4)\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 *\n * `withBytes` 가 false(images:false)면 PNG 인코딩을 건너뛰고 image 블록만 낸다.\n * 거르기·중복 판정은 같아서 자리 표시가 바이트를 뽑을 때와 같다. 바이트 누적 상한은\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 withBytes = true,\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 globalId = dedupeId?.startsWith(\"g_\") ? dedupeId : undefined\n let hash = globalId ? state.globalKeys.get(globalId) : undefined\n if (hash === undefined) {\n hash = `${w}x${h}k${imgData.kind ?? \"?\"}h${contentHash(imgData.data)}${dedupeId ? \"\" : \"inline\"}`\n if (globalId) state.globalKeys.set(globalId, hash)\n }\n if (state.seen.has(hash)) continue\n\n const rgba = toRgba(imgData, withBytes)\n if (!rgba) continue // 미지원 픽셀 형태 (bitmap 전용 등)\n\n state.imageIndex++\n const filename = `image_${String(state.imageIndex).padStart(3, \"0\")}.png`\n state.seen.set(hash, filename)\n if (withBytes) {\n const png = encodePng(w, h, rgba)\n state.totalBytes += png.length\n images.push({ filename, data: png, mimeType: \"image/png\" })\n }\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 for (const b of result) blocks.push(b) // push(...result) 는 블록 약 13만 개부터 RangeError — 그림 1장 든 3,500쪽 문서가 통째 실패했다\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 * 텍스트층 밖 신호 하나: 글자를 채운 곡선 경로로 그린 쪽(vector-glyphs.ts 가 연산자 목록 기하로 센 글자 수를\n * 받는다). 텍스트층에는 ASCII 몇 자만 남아 위 신호로는 멀쩡해 보인다. 이것이 vector_text 신호다.\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?: \"vector_text\" | \"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/**\n * vector_text 하한: 텍스트층 밖 곡선 글자(vector-glyphs.ts 글줄다운 조각의 글자 수). 코퍼스 비-cairo 표지 로고 곡선\n * 글자는 34개 이하(국가교통통계 표지 로고 3개 34·기록물관리 지침 표지 30)라 못 넘고, 글줄 몇 개면 넘는다\n */\nconst VECTOR_TEXT_MIN_GLYPHS = 40\n/**\n * 곡선 글자가 쪽 글자(곡선 + 텍스트층)에서 차지할 최소 비율 — 텍스트층 글이 대부분인 쪽(차트 라벨만 곡선인 보도자료\n * 안내서 62/434 = 0.14)을 OCR 로 갈아엎지 않는다. 대가: 텍스트층 ASCII 가 대부분인 cairo 숫자 칸 표 쪽(632 + 곡선 65 =\n * 0.09)과, 한글 대부분이 텍스트층에 있고 일부만 곡선인 cairo 쪽(0.10~0.24)은 OCR 대상이 아니다\n */\nconst VECTOR_TEXT_MIN_SHARE = 0.25\n\n/** 페이지 텍스트에서 품질 메트릭을 계산한다. vectorGlyphs 는 텍스트층 밖 곡선 글자 수 (vector-glyphs.ts) */\nexport function computePageQuality(page: number, text: string, vectorGlyphs = 0): 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 // 우선순위: vector_text > low_text > high_pua > high_control > high_replacement > garbled_hangul.\n // vector_text 가 low_text 보다 앞선다 — 텍스트층이 비어도 빈 쪽(표지·간지)이 아니라 글이 곡선으로 가득한 쪽이다\n if (vectorGlyphs >= VECTOR_TEXT_MIN_GLYPHS && vectorGlyphs >= (vectorGlyphs + total) * VECTOR_TEXT_MIN_SHARE) {\n needsOcr = true; ocrReason = \"vector_text\"\n }\n else 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 * 벡터 글자 감지 — 글자를 글꼴이 아니라 채운 곡선 경로로 그린 쪽 (needsOcr 사유 vector_text).\n *\n * rhwp 자체 렌더러(Producer \"cairo 1.18.0\")는 DejaVu 글꼴에 없는 한글을 자모를 조합한 채움 경로로 그린다.\n * 텍스트층에는 ASCII·기호만 남고(한글 0자) 눈으로는 한글이 읽힌다 — rhwp/hwpx_sample2 1쪽 연산자는\n * constructPath 975·fill 857 vs showText 66. \"글꼴을 곡선으로\" 인쇄한 PDF 도 같은 모양이다. 텍스트층만 보는\n * 품질 신호(quality.ts)는 이런 쪽을 못 알아봐(ASCII 가 조금 있어 low_text 도 아님) 한글 없는 마크다운을 경고 없이 냈다.\n *\n * 판정은 연산자 목록 기하로만 한다:\n * ① 글자 모양 채움 경로 — 높이 3~40pt·폭 ≤ 높이×2·선분 8개+·닫힌 윤곽 2개+(한글 음절은 초성·중성이 떨어진\n * 윤곽이라 2개 이상이다. 원·사각·삼각·화살표 같은 도형 조각과 라틴 한 획 글자는 여기서 빠진다), 가운데가\n * 텍스트층 글 상자 밖(투명 텍스트층을 얹은 윤곽 PDF 는 글이 있는 쪽이다).\n * ② 글줄 조각(run) — 세로 가운데가 반 높이 안인 경로를 한 줄로, 줄 안에서 x 틈 1.5em 이내를 한 조각으로.\n * ③ 글줄다움 — 3개+·윗선이나 밑선이 가지런함(중앙 편차 ≤ 0.05em)·가로 겹침 드묾(≤ 20%)·한 색(≥ 90%)·\n * 모양이 제각각(서로 다른 모양 ≥ 50%).\n * 코퍼스 PDF 1,561개(16,775쪽) 실측 — 비-cairo 15,875쪽 가운데 ①의 경로가 있는 쪽 275, ② 조각이 있는 쪽 71,\n * ③을 넘는 글자가 있는 쪽 39. 그 가운데 가장 많은 쪽이 68개(광진소식 지면: 텍스트층 0자, 본문 전부 곡선 — 진짜\n * 벡터 글자)이고 다음이 차트 라벨 62(보도자료 안내서, 텍스트층 372자)·표지 로고 34·30 이다. ③이 떨어낸 것:\n * 표지·간지 원형 아이콘 무리(ice-arc: 정렬 편차 0.1~0.3em·겹침 0.2~0.75), 서식 점선 칸 줄(synam-001: 모양 1종\n * 반복), 클립아트 세부(hwp3-sample11 네트워크 그림: 겹침 ~1.0·색 제각각). ③의 조건을 하나씩 빼 봐도 오탐은 없지만\n * (여유가 줄 뿐), ③을 통째로 빼면 비-cairo 17쪽이 판정을 넘는다 — 텍스트층 0자 광진소식 5쪽 말고도 삽화·아이콘 쪽 12\n * (cbe-record-guide 표지·간지 6, ice-arc-2026 간지 6). 쪽 판정(글자 수·비율)은 quality.ts 가 한다.\n */\n\nimport { OPS } from \"pdfjs-dist/legacy/build/pdf.mjs\"\n\n/** 글자 모양 채움 경로 (쪽 좌표, CTM 적용) */\nexport interface GlyphPath {\n x1: number; y1: number; x2: number; y2: number\n /** 선분(직선·곡선) 수 */\n segs: number\n /** 닫힌 윤곽(부분 경로) 수 */\n subs: number\n /** 채움 색 (0xRRGGBB) */\n color: number\n /** 이 경로를 만든 연산자 번호 (constructPath 들 + 채움) — OCR 경로가 괘선만 남길 때 뺀다 */\n ops: number[]\n}\n\nconst GLYPH_MIN_H = 3\nconst GLYPH_MAX_H = 40\nconst GLYPH_MAX_ASPECT = 2\nconst GLYPH_MIN_SEGS = 8\nconst GLYPH_MIN_SUBS = 2\n/** 줄 안 이웃 경로 x 틈 상한 (em) — 낱말 사이 띄어쓰기·양쪽 정렬 벌어짐까지 한 조각 */\nconst RUN_GAP_EM = 1.5\nconst RUN_MIN = 3\n/** 윗선·밑선 중앙 편차 상한 (em) — cairo 글줄 0.00~0.07, 아이콘 무리 0.1~0.3 */\nconst RUN_ALIGN_EM = 0.05\n/** 앞 경로 끝보다 0.1em 넘게 앞에서 시작하는 이웃 비율 상한 — 글자는 차례로 놓인다 */\nconst RUN_MAX_OVERLAP = 0.2\nconst RUN_MIN_COLOR = 0.9\nconst RUN_MIN_SHAPES = 0.5\n\ntype Mat = number[]\nconst mul = (m: Mat, t: Mat): Mat => [\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 * 글자 모양 채움 경로 수집. pdfjs v4 constructPath 인자 [subOps, coords, minMax] 의 minMax 는 곡선 끝점을\n * 빼먹으므로 좌표를 직접 훑는다. 사각형(re)만으로 된 채움(칸 음영·막대·체크박스)은 글자가 아니다.\n */\nexport function collectGlyphPaths(fnArray: Uint32Array | number[], argsArray: unknown[][]): GlyphPath[] {\n const out: GlyphPath[] = []\n let ctm: Mat = [1, 0, 0, 1, 0, 0]\n let color = 0\n const stack: Array<[Mat, number]> = []\n let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity\n let segs = 0, subs = 0, rects = 0\n let ops: number[] = []\n const add = (x: number, y: number) => {\n const tx = ctm[0] * x + ctm[2] * y + ctm[4], ty = ctm[1] * x + ctm[3] * y + ctm[5]\n if (tx < x1) x1 = tx\n if (tx > x2) x2 = tx\n if (ty < y1) y1 = ty\n if (ty > y2) y2 = ty\n }\n const reset = () => { x1 = Infinity; y1 = Infinity; x2 = -Infinity; y2 = -Infinity; segs = 0; subs = 0; rects = 0; ops = [] }\n for (let i = 0; i < fnArray.length; i++) {\n const op = fnArray[i]\n const args = argsArray[i]\n switch (op) {\n case OPS.save: stack.push([ctm, color]); break\n case OPS.restore: [ctm, color] = stack.pop() ?? [[1, 0, 0, 1, 0, 0], 0]; break\n case OPS.transform: ctm = mul(ctm, args as number[]); break\n case OPS.paintFormXObjectBegin: {\n stack.push([ctm, color])\n const m = (args as unknown[])[0]\n if (Array.isArray(m) && m.length >= 6) ctm = mul(ctm, m as number[])\n break\n }\n case OPS.paintFormXObjectEnd: [ctm, color] = stack.pop() ?? [[1, 0, 0, 1, 0, 0], 0]; break\n case OPS.setFillRGBColor: {\n const c = args as unknown as ArrayLike<number>\n color = (c[0] << 16) | (c[1] << 8) | c[2]\n break\n }\n case OPS.constructPath: {\n const [subOps, coords] = args as [number[], number[]]\n if (!Array.isArray(subOps) || !Array.isArray(coords)) break\n ops.push(i)\n let ci = 0\n for (const s of subOps) {\n if (s === OPS.moveTo) { add(coords[ci], coords[ci + 1]); ci += 2; subs++ }\n else if (s === OPS.lineTo) { add(coords[ci], coords[ci + 1]); ci += 2; segs++ }\n else if (s === OPS.curveTo) { add(coords[ci], coords[ci + 1]); add(coords[ci + 2], coords[ci + 3]); add(coords[ci + 4], coords[ci + 5]); ci += 6; segs++ }\n else if (s === OPS.curveTo2 || s === OPS.curveTo3) { add(coords[ci], coords[ci + 1]); add(coords[ci + 2], coords[ci + 3]); ci += 4; segs++ }\n else if (s === OPS.rectangle) {\n const rx = coords[ci], ry = coords[ci + 1], rw = coords[ci + 2], rh = coords[ci + 3]\n add(rx, ry); add(rx + rw, ry + rh); add(rx, ry + rh); add(rx + rw, ry)\n ci += 4; rects++; subs++; segs += 4\n }\n }\n break\n }\n case OPS.fill: case OPS.eoFill: case OPS.fillStroke: case OPS.eoFillStroke:\n case OPS.closeFillStroke: case OPS.closeEOFillStroke: {\n const w = x2 - x1, h = y2 - y1\n if (segs > rects * 4 && h >= GLYPH_MIN_H && h <= GLYPH_MAX_H && w <= h * GLYPH_MAX_ASPECT &&\n segs >= GLYPH_MIN_SEGS && subs >= GLYPH_MIN_SUBS) {\n out.push({ x1, y1, x2, y2, segs, subs, color, ops: [...ops, i] })\n }\n reset()\n break\n }\n case OPS.stroke: case OPS.closeStroke: case OPS.endPath:\n reset()\n break\n }\n }\n return out\n}\n\n/** 텍스트층 글 상자 (NormItem 과 같은 모양 — y 는 베이스라인) */\nexport interface TextBox { text: string; x: number; y: number; w: number; h: number }\n\n/** 가운데가 텍스트층 글 상자 안 — 글 위에 겹쳐 그린 윤곽(투명 텍스트층을 얹은 윤곽 PDF, 글자 테두리 효과) */\nfunction coveredByText(b: GlyphPath, texts: TextBox[]): boolean {\n const cx = (b.x1 + b.x2) / 2, cy = (b.y1 + b.y2) / 2\n for (const t of texts) {\n if (cx >= t.x - 1 && cx <= t.x + t.w + 1 && cy >= t.y - t.h * 0.3 - 1 && cy <= t.y + t.h + 1 && t.text.trim()) return true\n }\n return false\n}\n\nconst median = (a: number[]): number => { const s = a.slice().sort((x, y) => x - y); return s[s.length >> 1] }\nconst spread = (a: number[]): number => { const c = median(a); return median(a.map(v => Math.abs(v - c))) }\n\n/** 글줄다움 — 머리 주석 ③ (m 은 x 순) */\nfunction isTextRun(m: GlyphPath[], em: number): boolean {\n if (m.length < RUN_MIN) return false\n if (Math.min(spread(m.map(b => b.y2)), spread(m.map(b => b.y1))) > em * RUN_ALIGN_EM) return false\n let overlaps = 0\n for (let i = 1; i < m.length; i++) if (m[i].x1 < m[i - 1].x2 - em * 0.1) overlaps++\n if (overlaps > (m.length - 1) * RUN_MAX_OVERLAP) return false\n const colors = new Map<number, number>()\n for (const b of m) colors.set(b.color, (colors.get(b.color) ?? 0) + 1)\n if (Math.max(...colors.values()) < m.length * RUN_MIN_COLOR) return false\n const shapes = new Set(m.map(b => `${Math.round((b.x2 - b.x1) * 2)}:${Math.round((b.y2 - b.y1) * 2)}:${b.segs}`))\n return shapes.size >= m.length * RUN_MIN_SHAPES\n}\n\nexport interface VectorGlyphScan {\n /** 글줄다운 조각에 든 글자 모양 경로 수 — 텍스트층에 없는 곡선 글자 수 추정 */\n glyphs: number\n /** 글자 모양 채움 경로 전부 (텍스트층이 덮은 것 포함) */\n paths: GlyphPath[]\n}\n\n/** 쪽의 벡터 글자 신호. texts 는 그 쪽 텍스트층 글 상자 */\nexport function scanVectorGlyphs(fnArray: Uint32Array | number[], argsArray: unknown[][], texts: TextBox[]): VectorGlyphScan {\n const paths = collectGlyphPaths(fnArray, argsArray)\n if (paths.length < RUN_MIN) return { glyphs: 0, paths }\n const free = paths.filter(b => !coveredByText(b, texts))\n // 줄 묶기: 세로 가운데 순으로 훑어 첫 경로 가운데에서 반 높이 안이면 같은 줄\n const sorted = free.slice().sort((a, b) => (a.y1 + a.y2) - (b.y1 + b.y2))\n let glyphs = 0\n let row: GlyphPath[] = []\n let rowCy = 0, rowH = 0\n const flushRow = () => {\n if (row.length < RUN_MIN) return\n row.sort((a, b) => a.x1 - b.x1)\n const em = median(row.map(b => b.y2 - b.y1))\n let start = 0, maxX = row[0].x2\n for (let i = 1; i <= row.length; i++) {\n if (i < row.length && row[i].x1 - maxX <= em * RUN_GAP_EM) { if (row[i].x2 > maxX) maxX = row[i].x2; continue }\n const run = row.slice(start, i)\n if (isTextRun(run, em)) glyphs += run.length\n if (i < row.length) { start = i; maxX = row[i].x2 }\n }\n }\n for (const b of sorted) {\n const cy = (b.y1 + b.y2) / 2, h = b.y2 - b.y1\n if (row.length && Math.abs(cy - rowCy) <= Math.max(h, rowH) * 0.5) { row.push(b); continue }\n flushRow()\n row = [b]; rowCy = cy; rowH = h\n }\n flushRow()\n return { glyphs, paths }\n}\n\n/**\n * OCR 로 가는 벡터 글자 쪽의 그래픽 — 글자 모양 경로와 클립을 뺀 연산자 목록(괘선·채움·그림). 그 쪽 표 구조를 래스터\n * 괘선 감지 대신 실제 괘선으로 복원한다(pdf-ocr.ts).\n *\n * 클립을 빼는 까닭: 클립 칸 격자(clip-cells.ts)는 한컴 PDF 내보내기의 \"칸마다 W n 클립\" 관행에 기댄 경로인데, 글자를 곡선으로\n * 그린 쪽은 코퍼스에서 전부 비한컴 제작기(rhwp cairo)이고 한컴 PDF 1,480개(10,976쪽)에는 한 쪽도 없다. cairo 는 음영 칸과\n * 그림에만 클립을 깔아(hwpx_sample2 2쪽: 클립 17 = 같은 기하 채움 17) 부분 클립 격자가 선 격자와 한 표를 둘로 쪼갠다 — kftc\n * 붙임 표는 머리 행이 따로 1×5 표, 본문 행은 3×6 에 섞였다. 텍스트층 없음 13쌍 46표 OCR 실측: 클립 포함 exact 16·F1 0.510·\n * NED 0.310, 클립 뺌 exact 20·F1 0.587·NED 0.445 (종전 래스터 괘선 14·0.502·0.295)\n */\nexport function ocrVectorOps(\n opList: { fnArray: Uint32Array | number[]; argsArray: unknown[][] },\n paths: GlyphPath[],\n): { fnArray: number[]; argsArray: unknown[][] } {\n const drop = new Set<number>()\n for (const p of paths) for (const i of p.ops) drop.add(i)\n const fnArray: number[] = []\n const argsArray: unknown[][] = []\n for (let i = 0; i < opList.fnArray.length; i++) {\n const op = opList.fnArray[i]\n if (drop.has(i) || op === OPS.clip || op === OPS.eoClip) continue\n fnArray.push(op)\n argsArray.push(opList.argsArray[i])\n }\n return { fnArray, argsArray }\n}\n","/**\n * 보도자료 연락처 표 — \"담당 부서 | 부서 | 책임자·담당자 | 직위 | 이름 | 연락처\". HWPX 원본은 코퍼스 470여 개가 모두 이 6열이지만,\n * 칸 클립 없이 점선으로 테두리만 그린 구버전 한컴 PDF 는 직위·이름·연락처 사이 세로선이 없고 이름과 연락처가 한 글 조각이라\n * 4열(\"과 장 정희철 (044-214-2730)\" 한 칸)로 읽힌다. 끝 칸이 행마다 \"직위 이름 (연락처)\" 꼴이면 세 칸으로 가르고(담당자가 여럿이면\n * 줄마다 한 사람 — 세 칸에 줄째 모은다, 원본도 칸 안 줄마다 한 사람), 앞 두 열의 병합 칸(표 전체든 부서 블록이든)은 글줄이 병합\n * 행 수 이하면 줄마다 제 행 칸으로 나눈다(가로선이 없을 뿐 원본은 행마다 칸이다)\n */\n\nimport type { IRBlock, IRCell } from \"../types.js\"\n\nexport const CONTACT_HEAD = /^(?:담당\\s*부서|<[^<>]+>)$/\nexport const CONTACT_ROLE = /^(?:책임자|담당자)$/\n/** 직위(띄어 쓴 두 글자 \"과 장\" 포함) · 이름 2~4자 · 연락처(괄호 전화·전자우편). 원문이 이름에 연락처를 붙여 쓰거나(\"김형수(044-201-3488)\")\n * 닫는 괄호를 빠뜨린(\"양재훈 (02-2100-1685\") 행도 있다 — 한 행이라도 안 맞으면 표 전체를 4열로 두었다. 모든 사람이 이름에 연락처를 붙여\n * 쓴 표는 원문도 직위·이름·연락처를 한 칸에 썼다(156775911 HWPX 4열 \"과 장 김형수(044-201-3488)\") — 가르지 않는다 */\nconst PERSON = /^(.+?)\\s+([가-힣]{2,4})\\s*(\\([^()]*\\d[^()]*\\)?|\\S+@\\S+)$/\n/** 이름과 연락처 사이가 띄어 있나 */\nconst spaced = (m: RegExpExecArray): boolean => /\\s/.test(m[0][m[0].length - m[3].length - 1] ?? \"\")\n\nexport function splitContactTables(blocks: IRBlock[]): void {\n for (const b of blocks) {\n const t = b.type === \"table\" ? b.table : undefined\n if (!t || t.cols !== 4 || t.rows < 2) continue\n const rows = t.cells\n if (!CONTACT_HEAD.test(rows[0][0].text.replace(/\\s+/g, \" \").trim())) continue\n // 줄마다 \"직위 이름 (연락처)\" 면 여러 사람, 아니면 칸 통째로 한 사람(줄 꺾인 칸) — 통째 먼저 재면 앞 사람 전체가 직위 자리에 든다\n const people = rows.map(r => {\n const lines = r[3].text.split(\"\\n\").map(line => PERSON.exec(line.replace(/\\s+/g, \" \").trim()))\n if (lines.length > 1 && lines.every(Boolean)) return lines as RegExpExecArray[]\n const whole = PERSON.exec(r[3].text.replace(/\\s+/g, \" \").trim())\n return whole ? [whole] : null\n })\n if (people.some(p => !p) || rows.some(r => !CONTACT_ROLE.test(r[2].text.replace(/\\s+/g, \"\")) || r[2].rowSpan !== 1 || r[3].rowSpan !== 1)) continue\n if (!people.some(p => p!.some(spaced))) continue\n // 앞 두 열 — 병합 칸만 줄마다 그 병합 행들로 나눈다 (행마다 칸이면 그대로)\n const lead: string[][] = []\n let ok = true\n for (const c of [0, 1]) {\n const col = rows.map(() => \"\")\n for (let r = 0; r < t.rows;) {\n const cell = rows[r][c], span = Math.min(Math.max(1, cell.rowSpan), t.rows - r)\n const lines = span === 1 ? [cell.text] : cell.text.split(\"\\n\").map(s => s.trim()).filter(Boolean)\n if (lines.length > span) { ok = false; break }\n lines.forEach((line, k) => { col[r + k] = line })\n r += span\n }\n if (!ok) break\n lead.push(col)\n }\n if (!ok) continue\n const one = (text: string): IRCell => ({ text, colSpan: 1, rowSpan: 1 })\n const part = (i: number, k: number) => one(people[i]!.map(p => p[k]).join(\"\\n\"))\n t.cells = rows.map((r, i) => [one(lead[0][i]), one(lead[1][i]), one(r[2].text), part(i, 1), part(i, 2), part(i, 3)])\n t.cols = 6\n }\n}\n","/**\n * 쪽 넘김으로 이은 표의 쪽 귀속 (#136) — 잇기(table-parts·cell-continuation)가 뒤 쪽 조각이 놓인 쪽을 남기고, 틀 풀기는 행마다 그 쪽을,\n * 쪽별 마크다운(pages)은 쪽이 바뀌는 자리에서 표를 다시 가른다. 쪽은 칸 객체에 단다(CELL_PAGES) — 틀 풀기가 표를 새로 짜도 칸 객체는\n * 그대로 옮긴다. 한 칸 안에서 쪽을 넘은 글은 칸 블록의 pageNumber나 글 안 자리(CELL_SPLITS)로 안다. 문서 마크다운은 바꾸지 않는다\n */\n\nimport type { IRBlock, IRCell, IRTable } from \"../types.js\"\nimport { CELL_PAGES } from \"../table/layout-frames.js\"\n\n/** 한 칸 글 안에서 뒤 쪽 글이 시작하는 자리들 (글자 자리 오름차순) — 쪼개진 행·칸 이어짐 */\ntype Split = { at: number; page: number }\nconst CELL_SPLITS = new WeakMap<IRCell, Split[]>()\n\n/** 같은 글이 옮겨 간 칸에서 쪼갠 자리를 다시 찾는다 — 잇기가 줄을 다듬어도 자리 뒤 20자는 그대로다. from 앞은 보지 않는다 */\nfunction relocate(src: IRCell, dst: IRCell, from = 0): Split[] {\n const out: Split[] = []\n for (const s of CELL_SPLITS.get(src) ?? []) {\n const at = dst.text.indexOf(src.text.slice(s.at, s.at + 20).trimEnd(), Math.max(from, out.at(-1)?.at ?? 0))\n if (at >= 0) out.push({ at, page: s.page })\n }\n return out\n}\n\nconst setSplits = (cell: IRCell, splits: Split[]): void => {\n if (splits.length) CELL_SPLITS.set(cell, splits.sort((a, b) => a.at - b.at))\n}\n\n/** 칸 이어짐(글만 든 칸) — 이을 글 add 가 cell 글 뒤 어디서 시작하는지 남긴다. add 안에 앞서 남긴 자리도 옮긴다 */\nexport function markContinuedText(cell: IRCell, add: IRCell, page: number): void {\n if (!add.text.trim()) return\n const base = cell.text.trim() ? cell.text.length + 1 : 0\n setSplits(cell, [\n ...(CELL_SPLITS.get(cell) ?? []),\n { at: base, page },\n ...(CELL_SPLITS.get(add) ?? []).map(s => ({ at: base + s.at, page: s.page })),\n ])\n}\n\n/** 행마다 그 행에서 시작하는 칸 (병합에 덮인 자리는 빼고) — 칸과 열 번호 */\nfunction rowStarts(t: IRTable): Array<Array<{ cell: IRCell; c: number }>> {\n const covered = new Set<number>()\n const rows: Array<Array<{ cell: IRCell; c: number }>> = []\n for (let r = 0; r < t.rows; r++) {\n const starts: Array<{ cell: IRCell; c: number }> = []\n for (let c = 0; c < t.cols; c++) {\n if (covered.has(r * 100000 + c)) continue\n const cell: IRCell | undefined = t.cells[r]?.[c]\n if (!cell) continue\n for (let dr = 0; dr < cell.rowSpan; dr++) for (let dc = 0; dc < cell.colSpan; dc++) if (dr || dc) covered.add((r + dr) * 100000 + c + dc)\n starts.push({ cell, c })\n }\n rows.push(starts)\n }\n return rows\n}\n\n/** 행 r 을 덮는 칸 (위에서 세로 병합으로 내려온 칸 포함) */\nconst covering = (t: IRTable, r: number): IRCell[] =>\n rowStarts(t).slice(0, r + 1).flatMap((starts, rr) => starts.filter(s => rr + s.cell.rowSpan > r).map(s => s.cell))\n\n/** 칸이 놓인 쪽 — 쪽 표시, 없으면 칸 블록이 모두 쪽을 알 때 그 첫 쪽 (앞 쪽 조각이 비어 칸 글 전부가 뒤 쪽인 칸 이어짐) */\nfunction cellPage(cell: IRCell): number | undefined {\n const p = CELL_PAGES.get(cell)\n if (p !== undefined || !cell.blocks?.length || cell.blocks.some(b => b.pageNumber === undefined)) return p\n return Math.min(...cell.blocks.map(b => b.pageNumber!))\n}\n\n/** 표 행마다 쪽 — 그 행에서 시작하는 칸의 쪽(여럿이면 큰 쪽). 쪽 모르는 글 칸 행은 표 블록 쪽, 덮인 자리·빈 칸뿐인 행은 윗행 쪽.\n * 쪽은 아래로 줄지 않는다 */\nfunction tableRowPages(t: IRTable, pageNumber: number | undefined): Array<number | undefined> {\n const pages: Array<number | undefined> = []\n let prev = pageNumber\n for (const starts of rowStarts(t)) {\n let mark: number | undefined, text = false\n for (const { cell } of starts) {\n const p = cellPage(cell)\n if (p !== undefined) mark = Math.max(mark ?? p, p)\n else if (cell.text.trim() || cell.blocks?.length) text = true\n }\n const page = mark ?? (text ? pageNumber : prev)\n prev = page !== undefined && prev !== undefined ? Math.max(prev, page) : page ?? prev\n pages.push(prev)\n }\n return pages\n}\n\n/**\n * 이은 표 칸에 쪽을 단다 — 잇기 경로는 칸을 새로 짜기도 해 조각 칸의 표시가 옮겨 오지 않는다. 어느 경로든 앞 조각 행이 위에 그대로\n * 놓이고 뒤 조각은 끝 행들을 차지하니(되풀이 머리 행은 빠진다) 행 자리로 옮긴다. 쪼개진 행(뒤 조각 첫 행을 앞 조각 마지막 행에 합침)은\n * 앞 쪽 행으로 두고 칸 글 안 뒤 쪽 자리를 남긴다 — 앞 조각 칸 글로 시작해 글이 늘어난 칸만 (되풀이 머리 행만 빠진 잇기는 늘지 않는다).\n * 앞 조각 행이 줄었으면 자리를 모르니 달지 않는다\n */\nexport function markJoinedPages(table: IRTable, prev: IRBlock, curr: IRBlock): void {\n const pt = prev.table!, ct = curr.table!\n if (!curr.pageNumber || table.rows < pt.rows) return\n const pp = tableRowPages(pt, prev.pageNumber), cp = tableRowPages(ct, curr.pageNumber)\n const off = table.rows - ct.rows\n const rows = rowStarts(table), ptRows = rowStarts(pt), ctRows = rowStarts(ct)\n rows.forEach((starts, r) => {\n const page = r < pt.rows ? pp[r] : cp[r - off]\n for (const { cell } of starts) {\n if (page !== undefined && page !== prev.pageNumber) CELL_PAGES.set(cell, page)\n // 앞서 남긴 쪼갠 자리 — 잇기가 칸을 새로 짜면 글이 같은 칸으로 옮긴다\n const src = (r < pt.rows ? ptRows[r] : ctRows[r - off])?.find(s => s.cell !== cell && s.cell.text === cell.text && CELL_SPLITS.has(s.cell))\n if (src) setSplits(cell, relocate(src.cell, cell))\n }\n })\n const cr = pt.rows - 1 - off, page = cp[cr]\n if (cr < 0 || page === undefined || page === prev.pageNumber) return\n const tails = ctRows[cr].filter(s => s.cell.text.trim())\n // 뒤 조각 칸 블록이 앞 칸에 옮겨 붙었으면(appendCell) 그 블록에 쪽을 단다\n for (const { cell } of ctRows[cr]) for (const b of cell.blocks ?? []) b.pageNumber ??= page\n const heads = covering(pt, pt.rows - 1)\n for (const cell of covering(table, pt.rows - 1)) {\n if (cell.blocks?.length) continue\n const head = heads.filter(h => h.text.trim() && cell.text.startsWith(h.text.trimEnd()) && cell.text.trim().length > h.text.trim().length)\n .sort((a, b) => b.text.length - a.text.length)[0]\n if (!head) continue\n const from = head.text.trimEnd().length\n // 뒤 조각 칸 글은 잇기가 줄을 다듬어 끝까지 같지 않을 수 있다 — 머리 20자가 앞 글 뒤에 놓인 자리로 찾는다\n const hit = tails.map(t => ({ t: t.cell, at: cell.text.indexOf(t.cell.text.trim().slice(0, 20), from) })).filter(h => h.at >= from)\n .sort((a, b) => a.at - b.at)[0]\n if (!hit) continue\n setSplits(cell, [...relocate(head, cell), { at: hit.at, page }, ...relocate(hit.t, cell, hit.at)])\n }\n}\n\n/** 칸 안에서 쪽을 넘은 글 — 앞 쪽 몫, 뒤 쪽 첫 몫과 그 쪽(더 뒤 쪽 몫은 꼬리 칸에 남아 다시 가른다). 없으면 null */\nfunction splitCell(cell: IRCell, page: number | undefined): { head: Partial<IRCell>; tail: Partial<IRCell>; page: number; rest: Split[] } | null {\n if (page === undefined) return null\n if (cell.blocks?.length) {\n const k = cell.blocks.findIndex(b => (b.pageNumber ?? page) > page)\n if (k <= 0) return null\n const head = cell.blocks.slice(0, k), tail = cell.blocks.slice(k)\n const text = (bs: IRBlock[]): string => bs.map(b => b.text ?? \"\").filter(Boolean).join(\"\\n\")\n return { head: { text: text(head), blocks: head }, tail: { text: text(tail), blocks: tail }, page: tail[0].pageNumber!, rest: [] }\n }\n const splits = CELL_SPLITS.get(cell) ?? []\n const k = splits.findIndex(s => s.page > page && s.at < cell.text.length)\n if (k < 0) return null\n const s = splits[k]\n const rest = cell.text.slice(s.at), lead = rest.length - rest.trimStart().length\n return {\n head: { text: cell.text.slice(0, s.at).trimEnd() },\n tail: { text: rest.trim() },\n page: s.page,\n rest: splits.slice(k + 1).map(x => ({ at: x.at - s.at - lead, page: x.page })).filter(x => x.at > 0),\n }\n}\n\n/**\n * 칸 안에서 쪽을 넘은 글을 그 행 아래 새 행으로 뗀다 — 새 행은 뗀 칸들이 덮는 마지막 행 바로 아래, 그 자리를 지나 내려가는 세로 병합\n * 칸은 한 행 더 덮는다. 세 쪽에 걸친 칸은 꼬리 칸을 다시 가른다. 뗄 칸이 없으면 표를 그대로 돌려준다\n */\nfunction splitCrossPageRows(t: IRTable, pageNumber: number | undefined): IRTable {\n const pages = tableRowPages(t, pageNumber)\n const rows = rowStarts(t)\n for (let r = 0; r < t.rows; r++) {\n const splits = rows[r].map(s => ({ ...s, split: splitCell(s.cell, pages[r]) })).filter(s => s.split)\n if (!splits.length) continue\n // 칸을 고쳐 복제해도 쪽 표시·남은 쪼갠 자리는 따라간다\n const clone = (cell: IRCell, patch: Partial<IRCell>): IRCell => {\n const out = { ...cell, ...patch }\n const p = CELL_PAGES.get(cell)\n if (p !== undefined) CELL_PAGES.set(out, p)\n if (!(\"text\" in patch) && CELL_SPLITS.has(cell)) CELL_SPLITS.set(out, CELL_SPLITS.get(cell)!)\n return out\n }\n const e = Math.max(...splits.map(s => r + s.cell.rowSpan - 1))\n const cells = t.cells.map(row => row.slice())\n const row: IRCell[] = Array.from({ length: t.cols }, () => ({ text: \"\", colSpan: 1, rowSpan: 1 }))\n for (const { cell, c, split } of splits) {\n cells[r][c] = clone(cell, split!.head)\n const tail: IRCell = { ...cell, ...split!.tail, rowSpan: 1 }\n CELL_PAGES.set(tail, split!.page)\n setSplits(tail, split!.rest)\n row[c] = tail\n }\n rows.forEach((starts, rr) => {\n for (const { cell, c } of starts) {\n if (splits.some(s => s.cell === cell) || rr > e || rr + cell.rowSpan - 1 <= e) continue\n cells[rr][c] = clone(cells[rr][c], { rowSpan: cell.rowSpan + 1 })\n }\n })\n cells.splice(e + 1, 0, row)\n return splitCrossPageRows({ ...t, rows: t.rows + 1, cells }, pageNumber)\n }\n return t\n}\n\n/**\n * 쪽별 사영용 블록 — 쪽 넘김으로 이은 표를 행의 쪽이 바뀌는 자리에서 다시 쪽마다 가른다 (#136). 이은 표는 첫 쪽 블록 하나라 뒤 쪽 행이\n * 앞 쪽 항목에 들어가고, 쪽 내용이 표뿐이면 그 쪽 항목이 비거나 빠졌다. 문서 블록·마크다운은 이은 그대로 두고 새 배열만 낸다.\n * 쪽 경계를 넘는 세로 병합 칸은 앞 쪽 끝 행에서 자른다\n */\nexport function splitPageTables(blocks: IRBlock[]): IRBlock[] {\n return blocks.flatMap(b => {\n if (b.type !== \"table\" || !b.table) return [b]\n const t = splitCrossPageRows(b.table, b.pageNumber)\n const pages = tableRowPages(t, b.pageNumber)\n const cuts: number[] = [0]\n for (let r = 1; r < t.rows; r++) if (pages[r] !== pages[r - 1]) cuts.push(r)\n if (cuts.length === 1) return t === b.table ? [b] : [{ ...b, table: t }]\n return cuts.map((from, k) => {\n const end = cuts[k + 1] ?? t.rows\n const cells = t.cells.slice(from, end).map((row, dr) => row.map(cell =>\n cell && from + dr + cell.rowSpan > end ? { ...cell, rowSpan: end - from - dr } : cell))\n const table: IRTable = { ...t, rows: end - from, cells, ...(k ? { caption: undefined, captionBlocks: undefined } : {}) }\n return { ...b, table, pageNumber: pages[from] }\n })\n })\n}\n","/**\n * 쪽 넘김으로 갈라진 표 잇기 (mergeCrossPageTables) — 클립 표 조각은 열 경계 합집합 격자에 두 조각을 다시 놓는다.\n *\n * 한컴 PDF 는 쪽마다 그 쪽에 그려진 칸만 클립으로 깐다. 긴 서식 표(규제영향분석서 24×13)가 쪽을 넘으면 앞 조각은\n * 앞쪽 행들이 쓰는 열 경계만(13×9), 뒤 조각은 뒤쪽 행들이 쓰는 열 경계만(11×6) 갖고, 앞 쪽에서 세로 병합으로\n * 넘어온 칸(\"기타\"·\"12.규제일몰제\")은 글이 없어 뒤 쪽에 클립조차 없다. 열 수가 달라 종전 병합(열 수 동일)은\n * 두 조각을 따로 냈다. 여기서는\n * 1) 두 조각의 열 경계 x 를 합쳐 공통 격자를 만들고 각 칸을 x 로 다시 놓은 뒤,\n * 2) 뒤 조각 첫 행부터 칸이 비어 있는 열은 앞 조각 마지막 행에서 그 열을 덮던 칸의 세로 병합을 이어 늘린다.\n * 원본(HWPX) 표 격자는 모든 행의 경계 합집합이라 이 재배치가 같은 격자를 되살린다.\n */\n\nimport type { IRBlock, IRCell, IRTable } from \"../types.js\"\nimport { CELL_LINES, CLIP_TABLES, EMPTY_PARTS, FILLER_CELLS, IMAGE_CELLS, PART_COLXS, ROW_RULES, TABLE_COLXS, TABLE_ROWYS, TABLE_TAIL, type LineBox } from \"./table-meta.js\"\nimport { CONTACT_HEAD, CONTACT_ROLE } from \"./contact-table.js\"\nimport { markJoinedPages } from \"./table-pages.js\"\nimport { startsNewItem, type WrapLexicon } from \"./line-wrap.js\"\nimport { NO_EDGES, joinCellEdges } from \"./cell-edges.js\"\nimport { CELL_EDGES } from \"../table/layout-frames.js\"\n\n/** 글 이어짐은 표시 서식이 아니라 실제 글로 판정한다. 출력 칸의 밑줄·취소선은 그대로 둔다. */\nconst flowText = (text: string): string => text.replace(/<\\/?u>|~~/g, \"\")\n\n/** 두 조각의 경계를 같은 것으로 보는 거리 (pt) — 클립 좌표 오차 0.05pt, 조각 간 반올림 여유 */\nconst PART_COL_TOL = 1\n/** 쪽 조각끼리 짜임이 다를 때 같은 경계로 보는 거리 (pt) — 쪽 안 클립 격자(clip-cells CLIP_COORD_TOL)와 같다 */\nconst PART_COL_SNAP = 0.3\n\ninterface Anchor { r: number; c: number; rs: number; cs: number; cell: IRCell }\n\n/** IR 격자 → 앵커 목록 (병합으로 덮인 칸 제외, 행 우선) */\nfunction anchorsOf(table: IRTable): Anchor[] {\n const out: Anchor[] = []\n const covered = new Set<number>()\n for (let r = 0; r < table.rows; r++) {\n for (let c = 0; c < table.cols; c++) {\n if (covered.has(r * 100000 + c)) continue\n const cell = table.cells[r]?.[c]\n if (!cell) continue\n out.push({ r, c, rs: cell.rowSpan, cs: cell.colSpan, cell })\n for (let dr = 0; dr < cell.rowSpan; dr++) {\n for (let dc = 0; dc < cell.colSpan; dc++) if (dr || dc) covered.add((r + dr) * 100000 + (c + dc))\n }\n }\n }\n return out\n}\n\n/** 오름차순 좌표 합집합 — 앞 조각 경계는 그대로 두고(조각 안 경계는 클립 격자가 0.3pt 로 이미 가름), 뒤 조각 경계는 앞 조각 경계와\n * 하나씩 짝지어(가까운 쌍부터) 같은 경계로 본다. 짝 없는 뒤 조각 경계는 새 경계다. 두 조각 경계가 같은 수로 차례차례 1pt 안이면 같은\n * 짜임이 쪽마다 조금 어긋난 것(세금포인트 보도자료 108.06/108.54·151.48/150.52·183.50/184.46)이라 PART_COL_TOL 로 짝짓고, 짜임이\n * 다르면 클립 좌표 오차(PART_COL_SNAP) 안만 짝짓는다 — 쪽마다 칸 짜임이 바뀌는 표는 1pt 떨어진 경계도 원본 격자에서 따로 선다\n * (석유사업법 과태료 금액 칸 189.4/190.3·338.1/339.1·445.4/446.4, 원본 76열 — 1pt 로 짝지으면 62열)\n * @returns U 합집합, map 뒤 조각 경계 → 합집합 좌표 */\nfunction unionCoords(a: number[], b: number[]): { U: number[]; map: Map<number, number> } {\n // 경계 수가 같고 모두 차례로 PART_COL_TOL 안이면 같은 짜임이 조금 어긋난 것 — 그 밖에는 클립 좌표 오차(PART_COL_SNAP) 안만 같은 경계\n const drift = a.length === b.length && a.every((x, i) => Math.abs(x - b[i]) <= PART_COL_TOL)\n const tol = drift ? PART_COL_TOL : PART_COL_SNAP\n const pairs: Array<[number, number, number]> = []\n for (const v of b) for (const x of a) if (Math.abs(x - v) <= tol) pairs.push([Math.abs(x - v), v, x])\n pairs.sort((p, q) => p[0] - q[0])\n const map = new Map<number, number>(), taken = new Set<number>()\n for (const [, v, x] of pairs) if (!map.has(v) && !taken.has(x)) { map.set(v, x); taken.add(x) }\n const out = [...a]\n for (const v of b) if (!map.has(v)) { map.set(v, v); out.push(v) }\n return { U: out.sort((x, y) => x - y), map }\n}\n\nconst indexOf = (coords: number[], x: number): number => {\n let best = -1, bestD = Infinity\n for (let i = 0; i < coords.length; i++) {\n const d = Math.abs(coords[i] - x)\n if (d < bestD) { bestD = d; best = i }\n }\n return bestD <= PART_COL_TOL ? best : -1\n}\n\nconst rowText = (anchors: Anchor[], r: number): string =>\n anchors.filter(a => a.r === r).map(a => flowText(a.cell.text).replace(/\\s+/g, \"\")).join(\"|\")\n/** 행 글(rowText)에 칸 구분자 말고 글이 있나 */\nconst hasText = (t: string): boolean => t.replace(/\\|/g, \"\") !== \"\"\n\n/**\n * 두 조각을 합집합 격자로 잇는다. 경계 대응이 안 되면(조각 경계가 합집합에서 사라짐) null.\n * @param pcx·ccx 앞·뒤 조각의 열 경계 x (오름차순, 뒤 조각은 앞 조각 자리로 옮긴 좌표)\n * @param dx 뒤 조각을 옮긴 거리 — 뒤 조각 칸 글줄도 같이 옮겨 앞 조각 글줄과 맞댄다 (짝·홀 쪽 대칭 여백)\n * @param prevBottom 앞 조각 밑변 y — 쪽 경계에 걸친 세로 병합 칸 글을 가린다 (mergeStraddlingCells)\n * @returns split — 경계 행을 쪼개진 행으로 보고 합쳤는지\n */\nexport function joinSplitParts(prev: IRTable, pcx: number[], curr: IRTable, ccx: number[], dx = 0, prevBottom?: number, lex?: WrapLexicon): { table: IRTable; colXs: number[]; split: boolean; header: boolean } | null {\n if (pcx.length !== prev.cols + 1 || ccx.length !== curr.cols + 1) return null\n const { U, map } = unionCoords(pcx, ccx)\n const cxU = ccx.map(v => map.get(v) ?? v)\n const cols = U.length - 1\n if (cols < 1) return null\n const pa = anchorsOf(prev), ca = anchorsOf(curr)\n\n // 반복 머리 행 — 한컴은 머리 칸이 있는 표만 조각마다 머리 행(여러 줄일 수 있음)을 되풀이한다. 표 첫 조각은 머리 행 위에\n // 제목 상자가 붙어 나올 수 있어(편람 [별표 4] \"[별표4] <개정…>\" 행) 앞 조각 두 행 아래까지 어긋나게 맞춰 본다 (어긋난 맞춤은 글 있는 행만)\n let skip = 0, head = 0\n while (skip < Math.min(3, curr.rows - 1, prev.rows) && rowText(ca, skip) !== \"\" && rowText(ca, skip) === rowText(pa, skip)) skip++\n for (let h = 1; h <= 2 && !skip; h++) {\n head = h\n while (skip < Math.min(3, curr.rows - 1, prev.rows - h) && hasText(rowText(ca, skip)) && rowText(ca, skip) === rowText(pa, h + skip)) skip++\n }\n // 되풀이 머리 행은 원래 머리 행과 칸 자리(x 범위)까지 같다 — 한컴은 머리 행을 같은 칸 기하로 다시 그린다(짝·홀 쪽 여백만큼 통째로\n // 옮겨질 뿐). 글만 같고 칸 자리가 다르면 같은 머리를 단 새 표다(경찰복제 특수복식 \"경찰악대원\" 11×5 다음 쪽 \"도형 | 형태 및 규격 |\n // 색상 및 재질\" 11×5: 칸 경계가 2.8~8.5pt 어긋남, HWPX 두 표). 글 없는 행은 머리 행 증거가 아니라 거기서 되풀이를 끊는다\n const sameSpans = (u: Anchor[], d: Anchor[]): boolean => {\n if (u.length !== d.length || !u.length) return false\n const shift = pcx[u[u.length - 1].c + u[u.length - 1].cs] - ccx[d[d.length - 1].c + d[d.length - 1].cs]\n return u.every((a, n) => Math.abs(pcx[a.c] - ccx[d[n].c] - shift) <= CONTINUATION_COL_TOL && Math.abs(pcx[a.c + a.cs] - ccx[d[n].c + d[n].cs] - shift) <= CONTINUATION_COL_TOL)\n }\n for (let k = 0; k < skip; k++) {\n if (sameSpans(pa.filter(a => a.r === head + k), ca.filter(a => a.r === k))) continue\n if (hasText(rowText(ca, k))) return null\n skip = k\n }\n\n const rows = prev.rows + curr.rows - skip\n const grid: IRCell[][] = Array.from({ length: rows }, () => Array.from({ length: cols }, () => ({ text: \"\", colSpan: 1, rowSpan: 1 })))\n const owner: (Anchor | null)[][] = Array.from({ length: rows }, () => new Array<Anchor | null>(cols).fill(null))\n /** 보이는 변 곁정보(cell-edges)를 가진 칸을 놓았나 — 그러면 빈 자리 채움 칸도 자기 테두리 없는 칸으로 둔다 */\n let edged = false\n const place = (a: Anchor, x: number[], rowOff: number, shift = 0): boolean => {\n const c1 = indexOf(U, x[a.c]), c2 = indexOf(U, x[a.c + a.cs])\n if (c1 < 0 || c2 <= c1) return false\n const r = a.r + rowOff\n const placed: Anchor = { r, c: c1, rs: a.rs, cs: c2 - c1, cell: a.cell }\n for (let dr = 0; dr < a.rs; dr++) for (let dc = c1; dc < c2; dc++) {\n if (r + dr < rows) owner[r + dr][dc] = placed\n }\n grid[r][c1] = { ...a.cell, colSpan: c2 - c1, rowSpan: a.rs }\n // 곁정보는 복사한 칸으로 옮긴다 — 세 쪽 넘게 이어질 때 다음 이음(글줄)과 후행 빈 열 정리(그림 칸)가 본다\n const lines = CELL_LINES.get(a.cell)\n if (lines) CELL_LINES.set(grid[r][c1], shift ? lines.map(l => ({ ...l, l: l.l + shift, r: l.r + shift })) : lines)\n if (IMAGE_CELLS.has(a.cell)) IMAGE_CELLS.add(grid[r][c1])\n const edges = CELL_EDGES.get(a.cell)\n if (edges) { CELL_EDGES.set(grid[r][c1], edges); edged = true }\n return true\n }\n // 채움 칸(클립 없던 자리)은 놓지 않는다 — 빈 자리로 남아 아래 세로 병합 잇기가 채운다\n for (const a of pa) if (!FILLER_CELLS.has(a.cell) && !place(a, pcx, 0)) return null\n for (const a of ca) {\n if (a.r < skip || FILLER_CELLS.has(a.cell)) continue\n if (!place(a, cxU, prev.rows - skip, dx)) return null\n }\n\n // 뒤 조각 첫 행부터 비어 있는 열 — 앞 조각 마지막 행에서 그 열을 덮던 칸의 세로 병합을 잇는다\n const first = prev.rows\n for (let c = 0; c < cols; c++) {\n const above = owner[first - 1]?.[c]\n if (!above) continue\n let r = first\n while (r < rows && owner[r][c] === null) r++\n if (r === first) continue\n // 이어 늘릴 칸은 그 열 폭을 전부 덮는 칸이어야 한다 — 자기 폭 안의 모든 열이 같은 구간만큼 비어 있을 때만\n const span = r - first\n let ok = true\n for (let dc = above.c; dc < above.c + above.cs && ok; dc++) {\n for (let rr = first; rr < first + span; rr++) if (owner[rr][dc] !== null) { ok = false; break }\n }\n if (!ok) continue\n const newRs = above.rs + span\n grid[above.r][above.c].rowSpan = newRs\n const grown: Anchor = { ...above, rs: newRs }\n for (let rr = above.r; rr < above.r + newRs; rr++) for (let dc = above.c; dc < above.c + above.cs; dc++) owner[rr][dc] = grown\n }\n\n // 아무 칸도 덮지 않은 자리는 채움 칸으로 남긴다 — 이 표가 다시 앞 쪽 조각과 이어질 때(세 쪽 넘게) 빈 자리로 보고 세로 병합을 잇게\n for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) if (owner[r][c] === null) FILLER_CELLS.add(grid[r][c])\n // 채움 칸은 자기 테두리가 없다 — 곁정보 없는 칸은 layout-frames 가 네 변 다 보이는 칸으로 본다 (둘레 칸의 변이 선을 댄다)\n if (edged) for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) if (owner[r][c] === null) CELL_EDGES.set(grid[r][c], NO_EDGES)\n // 앞 조각 왼쪽에 빠진 열(prevLacksLeftCols) — 쪽 전체를 덮은 세로 병합 칸이 그 쪽에서 클립 없이 지나간 자리다. 쪼개진 행 판정에는 두\n // 조각 경계를 가로지르는 한 칸으로 보인다(뒤 조각 첫머리 빈 자리까지). \"두 쪽 모두 클립 없는 빈 칸 조각\" 증거로 쓰면 조세특례 국가전략기술\n // \"반도체\" 칸 옆 \"너. …\" / 다음 쪽 \"더. …\" 가 한 행으로 합쳐진다. 채움 칸 표시는 그대로라 앞 쪽 조각과 다시 이을 때 세로 병합으로 이어진다\n for (let c = 0; c < cols && U[c + 1] <= pcx[0] + PART_COL_TOL; c++) {\n let r = prev.rows\n while (r < rows && owner[r][c] === null) r++\n const through: Anchor = { r: 0, c, rs: r, cs: 1, cell: grid[0][c] }\n for (let rr = 0; rr < r; rr++) owner[rr][c] = through\n }\n const table: IRTable = { rows, cols, cells: grid, hasHeader: prev.hasHeader, ...(prev.caption ? { caption: prev.caption } : {}) }\n // 쪽 경계 괘선 — 두 조각 모두 칸 밑변마다 괘선을 긋는데 앞 조각 밑변·뒤 조각 윗변만 안 그었으면(open) 쪼개진 행 증거다(mergeSplitRow 3).\n // 두 쪽 모두 쪽 경계에 괘선을 그었으면(ruled) 칸 조각 이어짐만으로는 세로 병합 칸이 넘어간 새 행과 못 가른다. 짝·홀 쪽으로 옮겨 맞댄\n // 조각(shifted)은 쪽마다 새로 놓인 상자일 수 있다 — 한 칸 제목 행을 되풀이했으면(편람 \"작 성 방 법\" 상자 \"1. … 8.\" / \"9. …\", HWPX 는\n // 두 표) 목록 번호 이어짐을 증거로 쓰지 않고, 아니어도 앞 조각 목록 번호가 둘 이상일 때만 쓴다 (편람 전송상태값 상자 \"…가. …\" / \"나. …\")\n const pr = ROW_RULES.get(prev), cr = ROW_RULES.get(curr)\n const shifted = dx !== 0\n const cut: Cut = {\n open: !!pr && !!cr && !pr.bottom && !cr.top && pr.innerOpen + cr.innerOpen === 0 && pr.innerRuled + cr.innerRuled > 0,\n ruled: !!pr && !!cr && pr.bottom && cr.top,\n list: !shifted ? \"any\" : skip > 0 && ca.filter(a => a.r < skip && a.cell.text.trim()).length < 2 ? \"none\" : \"multi\",\n }\n let split = rows > prev.rows && mergeSplitRow(table, owner, prev.rows, U, false, cut)\n // 세로 병합 칸을 이은 뒤 다시 본다 — 뒤 조각 이름표가 여러 행을 덮어 쪼개진 행 판정에서 빠졌던 행(\"일몰설정 / 예외기준\" 이름표 옆\n // \"…적용 되어야 / 하는 규제\", 규제영향분석서)은 이름표가 한 칸이 되면 칸 조각 이어짐으로 판정된다\n // (글 이어짐 증거만 — 이어 늘린 빈 칸 조각은 증거로 쓰지 않는다: 시험기준표 \"KS M ISO 2507-1,\" / \"KS M ISO 2507-2\" 는 두 행)\n if (rows > prev.rows && !split && prevBottom !== undefined && mergeStraddlingCells(table, owner, prev.rows, prevBottom, lex, U)) split = mergeSplitRow(table, owner, prev.rows, U, true, { ...cut, open: false })\n // 이은 표의 괘선 — 세 쪽 넘게 이어질 때 앞 조각과 다시 견준다. 잇지 않은 쪽 경계는 칸 밑변 하나로 센다\n if (pr && cr) {\n const cutRuled = pr.bottom || cr.top\n ROW_RULES.set(table, { top: pr.top, bottom: cr.bottom, innerRuled: pr.innerRuled + cr.innerRuled + (!split && cutRuled ? 1 : 0), innerOpen: pr.innerOpen + cr.innerOpen + (!split && !cutRuled ? 1 : 0) })\n }\n // 머리 행 증거는 되풀이된 행에 글 있는 칸이 둘 이상일 때만 — 한 칸짜리 제목 행은 같은 제목의 상자를 쪽마다 새로 놓은 것과 구별이\n // 안 된다 (편람 기안문 \"작성방법\" 상자가 짝·홀 쪽에 하나씩 놓여 한 표로 이어졌다, HWPX 는 두 표)\n return { table, colXs: U, split, header: ca.filter(a => a.r < skip && a.cell.text.trim()).length >= 2 }\n}\n\n/**\n * 쪽 경계에 걸친 세로 병합 칸 — 행은 새로 시작해도(쪼개진 행 아님) 앞 쪽에서 두 행 이상 덮은 칸의 짧은 글(가운데 정렬)이 앞 조각\n * 바닥에 붙어 있으면 그 글은 쪽 경계에 걸쳐 다음 쪽 첫 행 칸으로 이어진다 (시험기준표 분류 칸 \"건축구조용 표면처리 / 경량형강\n * (KS D 3854)\"·\"석고보드 / (GB-R)\"). 두 칸을 한 세로 병합 칸으로 잇는다. 쪽 넘김 후보를 HWPX 로 대조한 세 세트(시험기준표·법령 별표\n * 2세트)에서 이 모양 11건이 모두 같은 칸 — 새 분류 칸은 글이 칸 가운데라 앞 조각 바닥에서 떨어져 있고, 긴 글 칸은\n * 줄 수로 거른다(위→아래로 차 바닥에 닿아도 다음 쪽이 새 칸일 수 있다: 복제 규칙 \"라. 항공경찰관 점퍼 …\" 12줄 → \"마. …\").\n * 쪽 경계가 행 묶음 안을 지날 때만 본다 — 다른 열 칸이 경계를 넘어 이어져야 한다(뒤 조각 첫 행 자리에 클립 없음, 11건 모두).\n * 모든 칸이 경계에서 끝나면 다음 쪽 첫 행은 새 묶음이다 (연락처 표 되풀이된 \"<공동>\" 5건, 과제 목록 두 행 칸 \"4-29\" 한 줄이\n * 바닥 가까이 있어도 다음 쪽 \"4-30\" 은 새 과제)\n */\nfunction mergeStraddlingCells(table: IRTable, owner: (Anchor | null)[][], first: number, prevBottom: number, lex?: WrapLexicon, colXs?: number[]): boolean {\n const last = first - 1\n const straddles = owner[first].some((o, c) => o !== null && o === owner[last][c])\n // 뒤 조각 첫 행에서 글 있는 새 칸이 처음 나오는 열 — 빈 이어짐은 그보다 왼쪽(이름표 열)만 본다. 왼쪽 이름표 열에 새 묶음 이름이\n // 있으면 그 행은 새 묶음이고 오른쪽 빈 칸(비고)은 새 칸이다(시험기준표 \"드레인보드\" 행 비고 칸)\n let firstNew = table.cols\n for (let c = 0; c < table.cols; c++) {\n const d = owner[first][c]\n if (d && d.r === first && d !== owner[last][c] && hasContent(table.cells[d.r][d.c])) { firstNew = c; break }\n }\n // 두 조각 글이 문서 어휘로 어절 중간에서 갈린 열이 하나라도 있으면(줄 꺾임 이음 \"\") 쪽 경계가 세로 병합 칸들을 가로질러 자른\n // 것이다 — 같은 모양(앞 조각 한 행 → 뒤 조각 여러 행)의 다른 이름표 칸도 글 위치(바닥 근접)와 무관하게 잇는다\n // (시험기준표 \"급속함수량측 / 정기 사용불가\" 옆 \"시멘트안정처리 / 기층\", 문서 안 \"…측정기\")\n let midWord = false\n if (lex) for (let c = 0; c < table.cols && !midWord;) {\n const u = owner[last][c], d = owner[first][c]\n if (!u) { c++; continue }\n c = u.c + u.cs\n if (!d || d === u || u.r + u.rs - 1 !== last || d.r !== first || d.c !== u.c || d.cs !== u.cs) continue\n // 쪽 경계 병합 모양(앞 조각 끝 행 한 칸 → 뒤 조각 여러 행)인 열에서, 두 글자+두 글자 문서 어휘 증거로만 (줄 꺾임 판정의 한 글자\n // 쌍·조각 규칙은 칸 조각 쌍에 잡음이 많다 — \"경도 / 체결 축력\", \"슬럼프 … / 공기량\")\n if (u.r !== last || d.rs < 2) continue\n const tail = table.cells[u.r][u.c].text.trim().split(/\\s+/).pop() ?? \"\", head = table.cells[first][d.c].text.trim().split(/\\s+/)[0] ?? \"\"\n if (/^[가-힣]{2,}$/.test(tail) && /^[가-힣]{2,}/.test(head) && lex.evidence2(tail, head) === \"\") midWord = true\n }\n // 긴 글 칸도 글이 이어지면 쪽 경계에 걸친 칸이다 — 다른 열 칸이 경계를 넘어 이어지고(straddles) 앞 조각에서 여러 행을 덮은 칸의 끝줄이\n // 칸 오른끝까지 차 문장이 다음 쪽 칸으로 넘어갔다(continuesAcross). 같은 모양(앞 조각 행 범위·뒤 조각 행 수)의 옆 칸도 줄 수·바닥 거리와\n // 무관하게 같은 병합 칸의 나머지다 (경찰복제 특수복식 교통경찰관 17→18쪽: \"…주머니를 작게 한\" / \"다. 나) 하의 …\" 옆 색상 칸\n // \"가) 상의\" / \"(1) 아이보리화이트색\" — HWPX 두 칸 모두 3행 병합). 끝줄이 짧게 끝난 긴 칸(\"라. 항공경찰관 점퍼\" 12줄 → \"마.\")은 해당 없다\n const crossed = new Set<string>()\n const shape = (u: Anchor, d: Anchor): string => `${u.r}:${u.rs}:${d.rs}`\n if (straddles && colXs) for (let c = 0; c < table.cols;) {\n const u = owner[last][c], d = owner[first][c]\n if (!u) { c++; continue }\n c = u.c + u.cs\n if (!d || d === u || u.r + u.rs - 1 !== last || d.r !== first || d.c !== u.c || d.cs !== u.cs || u.rs < 2) continue\n const a = table.cells[u.r][u.c], b = table.cells[first][d.c]\n if (hasContent(a) && hasContent(b) && continuesAcross(a, b, colXs[u.c], colXs[u.c + u.cs])) crossed.add(shape(u, d))\n }\n // 다른 열이 다음 항목으로 새 행을 열었나(\"가) …\" / 다음 쪽 \"나) …\") — 그 옆에서 글이 쪽을 넘어 이어지는 칸은 두 행을 덮는 칸이다\n let itemBreak = false\n for (let c = 0; c < table.cols && !itemBreak;) {\n const u = owner[last][c], d = owner[first][c]\n if (!u) { c++; continue }\n c = u.c + u.cs\n if (d && d !== u && d.r === first) itemBreak = nextItemHead(table.cells[u.r][u.c].text, table.cells[d.r][d.c].text)\n }\n let merged = false\n for (let c = 0; c < table.cols;) {\n const u = owner[last][c], d = owner[first][c]\n if (!u) { c++; continue }\n c = u.c + u.cs\n if (!d || d === u || u.r + u.rs - 1 !== last || d.r !== first || d.c !== u.c || d.cs !== u.cs) continue\n const a = table.cells[u.r][u.c], b = table.cells[first][d.c]\n // 글이 칸 오른끝까지 차 다음 쪽 칸으로 이어진 칸 — 다른 열이 다음 항목으로 새 행을 열고(itemBreak) 경계를 넘어 이어지는 칸도\n // 있으면(straddles) 앞 조각 끝 행 하나·뒤 조각 한 행이어도 쪽 경계에 걸친 병합 칸이다(보안심사대행기관 처분기준 \"2) … 대행기관\n // 지정기\" / 다음 쪽 \"준에 미달하게 된 경우\" 옆 \"가) …\" / \"나) …\"). 항목 증거가 없으면 좁은 칸을 우연히 채운 끝줄과 못 가른다\n // (시험기준표 \"겉모양, 치수, 무게\" / 다음 쪽 \"화학성분\" 은 두 행). 뒤 조각이 새 항목 머리로 열면 끝줄이 우연히 찬 것이다\n // (총포화약법 행정처분기준 \"커. … 2) 2회 이상 위반\" / 다음 쪽 \"터. 수출하기 위한 …\")\n const flowsOn = itemBreak && straddles && !!colXs && hasContent(a) && hasContent(b) && longLastLine(a) && !startsNewItem(a.text, b.text)\n && continuesAcross(a, b, colXs[u.c], colXs[u.c + u.cs])\n // 빈 이어짐 — 앞 조각에서 두 행 이상 덮은 이름표 칸 아래 뒤 조각 첫 칸이 비었으면 그 칸의 나머지다. 새 묶음이면 이름표가 있다\n // (aift 기업 현황 \"자본잠식현황\" 앞 쪽 2행 + 다음 쪽 빈 4행, \"자본총계\" 2행 + 빈 1행 — 모든 칸이 쪽 경계에서 끝나도).\n // 글이 이어진 칸과 같은 모양(crossed)이면 아래 짧은 글 조건을 보지 않는다\n if (!flowsOn && !crossed.has(shape(u, d)) && !(u.rs >= 2 && u.c + u.cs <= firstNew && hasContent(a) && !hasContent(b))) {\n if (!straddles && !midWord) continue\n // 앞 조각에 끝 행 하나만 보인 칸도 뒤 조각 칸이 두 행 이상을 덮으면 쪽 경계에 걸친 병합 칸이다(성능시험 TRL 표 \"제품화 / 단계\",\n // 시험기준표 \"플라이애시 / 시멘트(KS L 5211)\") — 뒤 조각 한 행 칸은 새 칸일 수 있어 그대로 둔다\n if (u.r >= last && d.rs < 2) continue\n const U = CELL_LINES.get(a)\n if (!U?.length || !hasContent(b)) continue\n if (!(straddles && filledToBottom(U, prevBottom) && clauseContinues(a, b))) {\n if (U.length > STRADDLE_MAX_LINES) continue\n const lu = U[U.length - 1]\n // 앞 조각에 한 행만 보인 칸은 글이 쪽 경계에 걸쳐야 한다(가운데 정렬 병합 칸 글이 바닥 반 줄 안) — 한 행짜리 새 칸 글은 행 가운데에 선다\n if (!midWord && lu.y - prevBottom > (u.r >= last ? STRADDLE_BOTTOM_ONE_ROW : STRADDLE_BOTTOM) * (lu.h || 10)) continue\n }\n }\n appendCell(a, b)\n a.rowSpan = first + d.rs - u.r\n table.cells[first][d.c] = { text: \"\", colSpan: 1, rowSpan: 1 }\n const grown: Anchor = { ...u, rs: a.rowSpan }\n for (let rr = u.r; rr < u.r + grown.rs; rr++) for (let dc = u.c; dc < u.c + u.cs; dc++) owner[rr][dc] = grown\n merged = true\n }\n return merged\n}\n\n/** 칸 끝줄이 글자 여섯 개 폭 이상 — 좁은 칸에서 끝줄이 칸을 채운 짧은 낱말(\"시정⏎명령\" / 다음 쪽 \"업무⏎정지 1개월\")은 글 이어짐 증거가 아니다 */\nfunction longLastLine(c: IRCell): boolean {\n const L = CELL_LINES.get(c), l = L?.[L.length - 1]\n return !!l && l.r - l.l >= 6 * (l.h || 10)\n}\n\n/**\n * 위에서부터 흐른 칸 글이 앞 조각 바닥까지 찼다 — 끝줄 아래 남은 자리가 줄 간격(글줄 사이 기준선 거리)에 글자 크기\n * FILL_SLACK 배를 더한 것보다 작아 다음 줄이 들어갈 수 없었다. 가운데 정렬 병합 칸(바닥 반 줄 안, STRADDLE_BOTTOM)과 달리\n * 위 정렬 칸은 글이 여러 줄이어도 끝줄이 바닥에 붙지 않는다 (출입국관리법 과태료 \"차. …준용하는\" 끝줄 기준선이 밑변 위 25pt,\n * 줄 간격 22pt·12pt 글자)\n */\nfunction filledToBottom(U: ReadonlyArray<{ y: number; h: number }>, bottom: number): boolean {\n if (U.length < 2) return false\n const lu = U[U.length - 1]\n return lu.y - bottom < (U[0].y - lu.y) / (U.length - 1) + FILL_SLACK * (lu.h || 10)\n}\n\n/** 끝줄이 칸 글 폭 오른끝에 이만큼(글자 크기 배) 안이면 꽉 찬 줄 */\nconst FULL_LINE_TOL = 1\n/** 끝줄 폭이 칸 폭의 이 비율 이상이어야 꽉 찬 줄 (좁은 칸의 짧은 낱말 제외) */\nconst FULL_LINE_MIN_FRAC = 0.5\n/** 앞 쪽 한 줄짜리 칸: 끝줄과 칸 오른끝 사이 최대 거리 — 셀 안쪽 여백(기본 1.8mm ≈ 5pt) + 여유(pt), 어절 하나 폭(글자 크기 배) */\nconst FULL_LINE_EDGE_MIN = 12\nconst SINGLE_LINE_WORD_GAP = 3.5\n/** 왼쪽 정렬 증거 — 왼끝에서 시작해 오른끝보다 글자 크기의 이 배수 넘게 짧은 줄 */\nconst SHORT_LINE_GAP = 1.5\n/** 쪽 경계에 걸친 세로 병합 칸 — 글 줄 수 상한, 끝줄 기준선이 앞 조각 밑변 위 글자 크기의 이 배수 안 */\nconst STRADDLE_MAX_LINES = 3\nconst STRADDLE_BOTTOM = 1.2\nconst STRADDLE_BOTTOM_ONE_ROW = 0.55\n/** 바닥까지 찬 글 — 줄 간격에 더하는 여유(글자 크기 배): 끝줄 아래 글자 내림(0.25배)과 칸 아래 여백 */\nconst FILL_SLACK = 0.5\n/** 칸 조각 이어짐 증거로 보는 빈 자리 열의 최소 폭 (pt) — 클립 격자의 셀 최소 폭과 같다 */\nconst CARRIED_MIN_COL_W = 4\n/** 끝난 이름표 — 한 줄 칸 오른쪽에 남은 자리가 글자 크기의 이 배수 이상 (숫자 머리 \"4.\" 한 어절이 들어간다) */\nconst LABEL_ROOM = 1.5\n/** 내어쓰기 이어짐 — 앞 끝줄이 칸 글 왼끝보다 글자 크기의 이 배수 이상 들어가 있고, 뒤 첫 줄 왼끝이 이 거리(pt) 안에서 같다 */\nconst HANGING_MIN = 0.5\nconst HANGING_TOL = 1\n\n/** 항목 머리 차례 — 한글 가나다(가~하, 거~허, 고~호 …)·숫자 */\nconst KO_ITEMS = \"가나다라마바사아자차카타파하거너더러머버서어저처커터퍼허고노도로모보소오조초코토포호구누두루무부수우주추쿠투푸후\"\nconst ITEM_MARK = /^\\s*(?:([가-힣])[.)]|(\\d{1,3})[.)]|\\(([가-힣\\d]{1,3})\\))(?=\\s)/\n\n/** 뒤 글이 앞 글 첫머리 항목의 바로 다음 항목(같은 꼴)으로 시작하는가 — \"커.\" 다음 \"터.\", \"3.\" 다음 \"4.\", \"(나)\" 다음 \"(다)\" */\nexport function nextItemHead(prev: string, next: string): boolean {\n const a = ITEM_MARK.exec(prev), b = ITEM_MARK.exec(next)\n if (!a || !b) return false\n const succ = (x: string, y: string) => /^\\d+$/.test(x) ? /^\\d+$/.test(y) && Number(y) === Number(x) + 1\n : KO_ITEMS.indexOf(x) >= 0 && KO_ITEMS.indexOf(y) === KO_ITEMS.indexOf(x) + 1\n // \"가.\" 다음 \"나.\", \"가)\" 다음 \"나)\" — 꼴(마침표·닫는 괄호)이 같아야 한다 (보안심사대행기관 처분기준 \"가) 등록요건 중 일부…\" / 다음 쪽 \"나) …\")\n if (a[1] && b[1]) return succ(a[1], b[1]) && prev.trimStart()[1] === next.trimStart()[1]\n if (a[2] && b[2]) return succ(a[2], b[2]) && prev.trimStart()[a[2].length] === next.trimStart()[b[2].length]\n if (a[3] && b[3]) return succ(a[3], b[3])\n return false\n}\n\n/**\n * 앞 쪽 칸(u)의 글이 뒤 쪽 칸(d)으로 이어지는지 — 앞 쪽 끝줄이 칸 글 폭 오른끝까지 차 있으면 문단 중간에서 끊긴 것이다\n * (줄은 다음 어절이 안 들어갈 때만 바뀐다). 가운데 정렬 칸은 끝줄이 가장 긴 줄일 뿐이어도 오른끝에 닿으므로, 왼끝에서\n * 시작해 오른끝에 못 미친 줄(문단 끝줄)이 두 조각 어딘가에 있는 칸만 본다.\n */\nfunction continuesAcross(u: IRCell, d: IRCell, x1: number, x2: number): boolean {\n const U = CELL_LINES.get(u), D = CELL_LINES.get(d)\n if (!U?.length || !D?.length) return false\n const last = U[U.length - 1]\n const fs = last.h || 10\n let minL = Infinity, maxR = -Infinity\n for (const l of U) { minL = Math.min(minL, l.l); maxR = Math.max(maxR, l.r) }\n for (const l of D) { minL = Math.min(minL, l.l); maxR = Math.max(maxR, l.r) }\n // 칸 글 오른끝 — 가장 긴 줄의 오른끝과 칸 오른끝(왼쪽 안쪽 여백을 오른쪽에도 둔) 가운데 먼 쪽. 줄이 모두 짧은 칸은 끝줄이 가장 긴\n // 줄이어도 꽉 찬 게 아니다 (편람 대비표 \"제44조의2 [이동 …]\" 두 줄이 다 334pt 에서 끝나는데 칸 글 오른끝은 371pt). 가운데 정렬 칸은\n // 가장 긴 줄의 좌우 여백이 같아 두 값이 같다\n maxR = Math.max(maxR, x2 - (minL - x1))\n if (maxR - last.r > FULL_LINE_TOL * fs) return false\n // 앞 쪽에 한 줄뿐이면 그 줄이 곧 가장 긴 줄이라 위 비교는 늘 참이다 — 그때는 칸 오른끝에서 어절 하나 폭 안까지 닿아야 한다\n // (오른쪽이 들쭉날쭉한 칸은 다음 어절이 안 들어가 그만큼 남기고 줄이 바뀐다: \"…규범적 / 성격의 규제\" 37pt, 13pt 글자)\n if (U.length === 1 && x2 - last.r > Math.max(FULL_LINE_EDGE_MIN, SINGLE_LINE_WORD_GAP * fs)) return false\n // 내어쓴 자리에서 시작한 끝줄은 그 자리부터 칸 오른끝까지가 줄 폭이다 (좁은 칸 \"(나) 그 / 밖 의\" 둘째 줄 31pt, 칸 75pt)\n const indented = last.l - minL >= HANGING_MIN * fs\n if (last.r - last.l < FULL_LINE_MIN_FRAC * (indented ? x2 - last.l : x2 - x1)) return false\n // 문단 끝줄은 칸 글 왼끝에서 시작하거나, 내어쓴 자리에서 시작하면 같은 자리의 다른 줄과 오른끝이 달라야 한다 — 가운데 정렬 줄은\n // 왼끝이 같으면 폭도 같다. 내어쓰기가 글자 하나보다 깊은 칸(10pt 글자에 13pt 내어쓰기, 안전인증기관 기준 \"가. … / 1) …\")은 끝줄이\n // 칸 글 왼끝에서 글자 하나 넘게 들어가 있다. 이 넓힌 증거와 아래 내어쓰기 첫 줄은 뒤 쪽 글이 새 항목 머리로 시작하면 쓰지 않는다 —\n // 앞 끝줄이 문단 끝인데 우연히 꽉 찬 것이다 (폐기물관리법 과태료 \"…갱신하지 않은 경우\" / 다음 쪽 새 행 \"부. 법 제40조제8항에 …\")\n const newItem = startsNewItem(u.text, d.text)\n const all = D.length ? U.concat(D) : U\n const leftAligned = (l: LineBox) => maxR - l.r > SHORT_LINE_GAP * fs\n && (l.l - minL <= fs || (!newItem && all.some(o => o !== l && Math.abs(o.l - l.l) <= HANGING_TOL && Math.abs(o.r - l.r) >= HANGING_MIN * fs)))\n // 양쪽 맞춤이라 모든 줄이 꽉 차 문단 끝줄이 없는 칸 — 앞 쪽 끝줄이 내어쓴 자리(문단 둘째 줄 이후)에서 시작하고 뒤 쪽 첫 줄이\n // 같은 자리에서 이어지면 같은 문단이다 (제약기업 인증 규정 별첨 대비표 \"1. 다음 각 목의 … 해 / 당하는 … 받지 않거 / 나, 그 행정처분을…\":\n // 93 → 104 → 104). 새 문단이면 뒤 첫 줄이 문단 머리 자리에서 시작한다 (물관리 제안요청서 \"‧다국적 …\" 다음 \"‧수질오염 …\")\n const hanging = U.length >= 2 && U.every(l => maxR - l.r <= FULL_LINE_TOL * fs)\n && indented && Math.abs(D[0].l - last.l) <= HANGING_TOL\n // 문단 머리 줄에서 끊긴 내어쓰기 — 앞 쪽 끝줄이 문단 머리 자리(내어쓰기 전)에서 꽉 차고, 뒤 쪽 줄이 모두 그보다 들어간 한 자리에서\n // 꽉 차게 이어진다 (석유사업법 과태료 \"4) 법 제38조의\" / 다음 쪽 \"2제1항을 위반 / 하여 석유제품 / …\" 66 → 75). 새 칸의 문단이면 뒤 첫 줄이\n // 머리 자리에서 시작하거나(내어쓰기) 둘째 줄이 첫 줄보다 나온다(들여쓰기)\n const hangingHead = !indented && D.length >= 2 && D[0].l - last.l >= HANGING_MIN * fs && maxR - D[0].r <= FULL_LINE_TOL * fs\n && D.every(l => Math.abs(l.l - D[0].l) <= HANGING_TOL)\n // 내어쓰기 첫 줄 — 앞 쪽에 문단 첫 줄 한 줄만 보이고 뒤 쪽 첫 줄이 그보다 들어간 자리(내어쓴 자리)에서 새 항목 머리 없이 이어진다\n // (도시정비법 과징금 \"3) 건설업자 또는 등록사업자가\" 75pt / 다음 쪽 \"법 제132조제2항을 …\" 86pt). 새 행이면 뒤 첫 줄은 항목 머리 자리에서\n // 시작한다. 가운데·오른쪽 정렬 칸도 짧은 뒷줄은 들어가 시작하므로, 뒤 첫 줄이 칸 폭 절반 넘게 차 오른끝을 같이 쓰거나(양쪽 맞춤 — 오른쪽\n // 정렬 짧은 글 \"사무관\" / \"과장\" 과 가른다) 오른끝이 짧게 끝나며 가운데가 어긋나야 한다(가운데 정렬은 두 줄 가운데가 같다)\n const d0 = D[0]\n // 뒤 쪽 둘째 줄부터가 첫 줄보다 나오면 첫 줄만 들여 쓴 새 문단이다 — 내어쓰기 이어짐이면 뒤 줄이 모두 그 자리 안쪽에 선다\n const hangStart = U.length === 1 && !newItem && d0.l - last.l >= HANGING_MIN * fs && D.every(l => l.l >= d0.l - HANGING_TOL)\n && ((Math.abs(d0.r - last.r) <= HANGING_TOL && d0.r - d0.l >= FULL_LINE_MIN_FRAC * (x2 - x1))\n || (last.r - d0.r > SHORT_LINE_GAP * fs && Math.abs(d0.l + d0.r - last.l - last.r) / 2 >= HANGING_MIN * fs))\n return U.some(leftAligned) || D.some(leftAligned) || hanging || hangingHead || hangStart\n}\n\n/** 개조식 절 제목 줄(□) */\nconst OUTLINE_HEAD = /^[□■]\\s*\\S/\n/** 절 제목 아래 단계 항목 부호 */\nconst OUTLINE_ITEM = /^[ㅇ○◦\\-‐–·․‧※*]\\s*\\S/\n\n/**\n * 개조식 위계 이어짐 — 앞 쪽 칸 글이 □ 절 제목 아래 항목이고 뒤 쪽 칸 글이 그 아래 단계 부호(ㅇ·-·※)로 시작하면 절 안 목록이\n * 다음 쪽으로 넘어간 것이다 (새 칸이면 절 제목부터 시작한다). 칸 안 문단 경계에서 쪽이 넘어가 글 이어짐 증거가 없는 행을 잡는다\n * (과제 품목 명세서 \"□ 개발내용 / ㅇ … / - … 주사제형화 기술 개발\" / 다음 쪽 \"ㅇ PFC 나노산소운반체의 …\")\n */\nfunction outlineContinues(u: IRCell, d: IRCell): boolean {\n const U = flowText(u.text).split(\"\\n\").map(l => l.trim()).filter(Boolean)\n const D = flowText(d.text).split(\"\\n\").map(l => l.trim()).filter(Boolean)\n return U.length >= 2 && D.length >= 1 && U.some(l => OUTLINE_HEAD.test(l)) && OUTLINE_ITEM.test(D[0])\n}\n\n/** 한 줄로 끝난 왼쪽 정렬 칸 — 오른쪽에 남은 자리(왼쪽 안쪽 여백만큼 뺀)가 글자 크기의 LABEL_ROOM 배 이상이라 다음 어절이 들어갈 수 있었다.\n * 다음 조각 첫 어절을 알면 그 폭(한글·한자 1em, 나머지 0.55em)도 들어가야 한다 — \"…적용 되어야\" 뒤 1.5em 남짓한 자리에 \"하는\"(2em)은\n * 안 들어가 줄이 바뀐 것이다(규제영향분석서 12.규제일몰제). 가운데 정렬 칸은 좌우 여백이 같아 해당하지 않는다 */\nfunction lineEnded(c: IRCell, x1: number, x2: number, next?: IRCell): boolean {\n const L = CELL_LINES.get(c)\n if (!L || L.length !== 1) return false\n const l = L[0], h = l.h || 10\n // 뒤 조각 첫 줄이 앞 줄보다 들어간 자리에서 시작하면 끝난 이름표 뒤의 새 이름표가 아니라 앞 문단의 아래 항목·내어쓴 줄이다\n // (군인연금법 유족 증명서류 \"1. 가족관계증명서상 자녀인 경우\" 168pt / 다음 쪽 \"가. 군인등의 …\" 180pt — 같은 칸 목록이 쪽을 넘었다)\n const d0 = next ? CELL_LINES.get(next)?.[0] : undefined\n if (d0 && d0.l - l.l >= HANGING_MIN * h) return false\n return (x2 - l.r) - (l.l - x1) >= Math.max(LABEL_ROOM, firstWordUnits(next?.text ?? \"\")) * h\n}\n\n/** 첫 어절 폭(글자 크기 배) — 한글·한자 1, 나머지 0.55. 여는 괄호 뒤 공백은 텍스트층이 끼운 것이라 다음 낱말과 한 어절로 본다\n * (선관위 자격증 \"( 자격증종류는 별표 12에 의함)\") */\nfunction firstWordUnits(text: string): number {\n const word = flowText(text).trim().replace(/^([(\\[「『<〈])\\s+/, \"$1\").split(/\\s+/)[0] ?? \"\"\n let units = 0\n for (const ch of word) units += /[가-힣\\u3400-\\u9fff]/.test(ch) ? 1 : 0.55\n return units\n}\n\n/**\n * 칸 조각 b 를 a 뒤에 잇는다. 어느 한쪽이 블록(칸 안 표를 품은 틀 칸의 문단·표)을 가지면 블록으로 잇는다 — 렌더는 블록을 먼저\n * 보므로 글만 이으면 사라진다 (코드 예시 틀 칸이 쪽을 넘을 때 다음 쪽 코드가 통째로 빠졌다). 블록 없는 쪽 글은 줄마다 문단으로\n */\nfunction appendCell(a: IRCell, b: IRCell): void {\n if (a.blocks?.length || b.blocks?.length) {\n const asBlocks = (c: IRCell): IRBlock[] => c.blocks?.length ? c.blocks\n : c.text.split(\"\\n\").map(l => l.trim()).filter(Boolean).map((text): IRBlock => ({ type: \"paragraph\", text }))\n a.blocks = [...asBlocks(a), ...asBlocks(b)]\n }\n if (b.text.trim()) a.text = a.text.trim() ? a.text + \"\\n\" + b.text : b.text\n joinCellEdges(a, b)\n}\n\n/** 칸에 내용이 있나 — 글·블록·그림 (그림은 PDF 에서 칸 글과 따로 뽑혀 text 가 빈다) */\nconst hasContent = (cell: IRCell): boolean => !!cell.text.trim() || !!cell.blocks?.length || IMAGE_CELLS.has(cell)\n\n/**\n * 쪽 넘김으로 쪼개진 행 — 뒤 조각 첫 행을 앞 조각 마지막 행에 합친다. 한컴은 칸 단위로 나누지 않는 표의 긴 행을 글줄\n * 사이에서 끊어 두 쪽에 나눠 그리고, 쪽 끝 행은 쪼개졌든 아니든 본문 바닥까지 늘려 그리므로 기하로는 구별이 안 된다.\n * 두 행의 칸 짜임(열 범위)이 같고, 다음 증거 가운데 하나가 있을 때만 합친다.\n * 1) 글 이어짐 — 어느 한 열에서 글이 앞 쪽 끝줄을 꽉 채우고 뒤 쪽으로 이어지거나(continuesAcross) 문장 중간에서 끊겼다(clauseContinues),\n * 뒤 쪽 첫 줄 번호가 앞 쪽 칸 목록의 다음 차례다(listContinues).\n * 2) 칸 조각 이어짐 — 한컴은 칸의 첫 조각에는 글이 없어도 클립을 깐다(편람 대비표 빈 시행규칙 칸). 앞 쪽 끝 행에서 시작한\n * 칸(또는 앞 쪽에서도 클립이 없던 칸)이 다음 쪽에 클립이 없으면 그 칸은 다음 쪽 첫 행 띠까지 이어진 것이다. 글 있는 열 쌍이\n * 하나뿐이고 새 칸 증거(앞 쪽 빈 칸 뒤의 글)가 없으면 행이 쪼개진 것으로 본다. 칸 안 문단 경계에서 쪽이 넘어가 글 증거가 없는\n * 행(편람 대비표 \"…제1항 / ② 중앙행정기관…\")을 여기서 잡는다. 쪽 넘김 후보를 HWPX 로 같은 칸인지 확인한 대조: 표 GT 한컴\n * 407쌍에서 이 조건 42건이 전부 쪼개진 행, 따로 뗀 법령 서식·별표 4세트(rhwp 변환 HWPX)에서 121건 중 새 행 2건.\n * 글 있는 열 쌍이 둘 이상이면 세로 병합 칸만 이어지고 행은 새로 시작하는 경우(시험기준표 \"플라이애시 / 시멘트\")와\n * 섞여 쓰지 않는다(아래 carriedSplit 의 두 예외). 위에서 내려온 세로 병합 칸이 클립 없이 넘어간 것은 새 행에서도 똑같아 증거가 아니다\n * 3) 괘선 없는 쪽 경계 — 두 조각 모두 칸 밑변마다 괘선을 긋는데 쪽 경계만 앞 조각 밑변·뒤 조각 윗변 괘선이 없다(한컴은 쪽을 넘는 칸의\n * 잘린 변에 테두리를 긋지 않는다). 법령 별표 272건 쪽 넘김 후보에서 이 모양 40건이 모두 쪼개진 행, 새 행 0건\n * (군인연금법 유족 증명서류 \"바. 「군인 재해보 / 상법」 …\" 옆 \"1. … 자녀인 경우 / 가. 군인등의 …\")\n */\n/** 문장 중간에서 끊긴 끝 어절 — 받침에 맞는 목적격 조사(받침 뒤 \"을\"·모음 뒤 \"를\" — \"마을\" 은 아니다)나 관형형·연결 어미(뒤에 서술어가 와야 하는 -어야 포함) */\nconst CLAUSE_OPEN_ENDING = /(?:하는|되는|하고|하며|하여|되어|되고|되며|이며|으며|어야|아야|여야)$/\nconst batchim = (ch: string): boolean => { const k = ch.charCodeAt(0) - 0xac00; return k >= 0 && k < 11172 && k % 28 !== 0 }\n/**\n * 글 이어짐(문장) — 앞 쪽 칸 글이 문장 중간에서 끊기고 뒤 쪽 칸이 새 항목 머리가 아닌 한글로 시작한다. 가운데 정렬 칸은 끝줄이 오른끝에\n * 닿는지(continuesAcross)로 못 가려(규제영향분석서 정성분석 \"…폐기 사실을 / 입력하므로 제도 도입에 따른…\") 말 자체로 본다\n */\nfunction clauseContinues(u: IRCell, d: IRCell): boolean {\n const tail = flowText(u.text).trim().split(/\\s+/).pop() ?? \"\", head = flowText(d.text).trim()\n if (!/^[가-힣]{2,}$/.test(tail) || !/^[가-힣]/.test(head) || startsNewItem(u.text, head)) return false\n const last = tail[tail.length - 1], before = tail[tail.length - 2]\n return (last === \"을\" && batchim(before)) || (last === \"를\" && !batchim(before)) || CLAUSE_OPEN_ENDING.test(tail)\n}\n\n/**\n * 수량 이어짐 — 앞 쪽 칸 글이 수량(숫자·%·단위)으로 끝나고 뒤 쪽 칸이 수량에만 붙는 비교 의존 명사(\"미만\"·\"이상\"·\"이하\"·\"초과\")로 연다.\n * 좁은 칸에 가운데 정렬한 짧은 머리 칸은 끝줄이 칸을 채우지 않아 글 이어짐(continuesAcross)으로 못 가른다(대기환경보전법 기본부과금\n * 부과계수 \"30%\" / 다음 쪽 \"미만\"). 법령 별표·표·글 정답지 1,016 PDF 쪽 넘김 행 거절 342건 가운데 이 꼴은 그 한 건뿐이다\n */\nfunction quantityContinues(u: IRCell, d: IRCell): boolean {\n return /\\d[\\d,.]*\\s*(?:%|[가-힣]{1,2})?$/.test(flowText(u.text).trim()) && /^(?:미만|이상|이하|초과)(?![가-힣])/.test(flowText(d.text).trim())\n}\n\n/**\n * 줄 넘침 — 앞 쪽 끝줄에 뒤 쪽 첫 어절을 붙이면 칸 폭을 넘는다(가운데 정렬이어도 그 어절은 그 줄에 못 들어가 줄이 바뀌었다). 좁은 칸의\n * 짧은 글은 새 칸 글과도 곧잘 들어맞아 홀로는 증거가 못 되고, 칸 조각 이어짐(carried)과 함께 볼 때만 쓴다\n * (양곡가공업자 처분기준 \"영업정지 / 3개월\" 57pt 칸, 선관위 자격증 \"방송 / 통신\" 40pt 칸)\n */\nfunction wordOverflows(u: IRCell, d: IRCell, x1: number, x2: number): boolean {\n const U = CELL_LINES.get(u)\n if (!U?.length || startsNewItem(u.text, d.text)) return false\n const last = U[U.length - 1], units = firstWordUnits(d.text)\n return units > 0 && last.r - last.l + units * (last.h || 10) > x2 - x1\n}\n\n/** 한글 항목 번호 차례 */\nconst KO_ORDER = \"가나다라마바사아자차카타파하\"\n/** 줄 머리 항목 번호 — 꼴(1.·1)·(1)·가.·가)·(가)·①·A.)과 차례 */\nfunction itemMark(line: string): { style: string; n: number } | null {\n line = flowText(line).trim()\n let m = /^(\\d{1,2})([.)])(?!\\d)/.exec(line)\n if (m) return { style: \"1\" + m[2], n: +m[1] }\n if ((m = /^\\((\\d{1,2})\\)/.exec(line))) return { style: \"(1)\", n: +m[1] }\n if ((m = /^([가-하])([.)])/.exec(line)) && KO_ORDER.includes(m[1])) return { style: \"가\" + m[2], n: KO_ORDER.indexOf(m[1]) }\n if ((m = /^\\(([가-하])\\)/.exec(line)) && KO_ORDER.includes(m[1])) return { style: \"(가)\", n: KO_ORDER.indexOf(m[1]) }\n if ((m = /^[①-⑳]/.exec(line))) return { style: \"①\", n: m[0].charCodeAt(0) }\n if ((m = /^([A-Z])\\.\\s/.exec(line))) return { style: \"A.\", n: m[1].charCodeAt(0) }\n return null\n}\n\n/**\n * 목록 이어짐 — 뒤 쪽 칸 첫 줄의 항목 번호가 앞 쪽 칸 목록의 다음 차례다(같은 꼴의 마지막 번호 + 1). 칸 안 문단 경계에서 쪽이 넘어가\n * 글 이어짐 증거가 없는 행(진단기관 지정기준 \"가. … 바. …\" / 다음 쪽 \"사. …\", 직무교육기관 장비 \"1) … 8) …\" / \"9) …\")을 잡는다.\n * 앞 쪽 칸 목록은 번호가 둘 이상이거나 칸 첫 줄이 아닌 자리에서 시작해야 한다 — 행마다 항목 하나를 둔 표는 칸 첫 줄이 번호이고\n * 다음 행이 다음 번호로 시작한다(과태료 부과기준 \"다. …\" 행 다음 \"라. …\" 행)\n */\nfunction listContinues(u: IRCell, d: IRCell, multi = false): boolean {\n const head = itemMark(d.text.trim())\n if (!head) return false\n const marks = u.text.split(\"\\n\").map(l => itemMark(l.trim()))\n let last = -1, count = 0\n marks.forEach((m, i) => { if (m?.style === head.style) { last = i; count++ } })\n return last >= 0 && marks[last]!.n + 1 === head.n && (count >= 2 || (!multi && last > 0))\n}\n\n/** 뒤 쪽 첫 행에 글 있는 칸이 하나뿐이고 그 칸이 개조식 위계로 이어진다 — 다른 열에 새 글(이름표)이 오면 새 행이다 */\nfunction outlineOnly(pairs: Array<[Anchor, Anchor]>, cell: (a: Anchor) => IRCell): boolean {\n const filled = pairs.filter(([, d]) => hasContent(d.cell))\n return filled.length === 1 && outlineContinues(cell(filled[0][0]), cell(filled[0][1]))\n}\n\n/** 쪽 경계 괘선과 목록 번호 증거 범위 — joinSplitParts 가 두 조각 곁정보로 정한다 */\ninterface Cut { open: boolean; ruled: boolean; list: \"any\" | \"multi\" | \"none\" }\nconst NO_CUT: Cut = { open: false, ruled: false, list: \"any\" }\n\nfunction mergeSplitRow(table: IRTable, owner: (Anchor | null)[][], first: number, colXs: number[], textOnly = false, cut: Cut = NO_CUT): boolean {\n const last = first - 1\n // 열마다 앞 행 칸과 뒤 행 칸을 맞춘다 — 두 행을 다 덮는 세로 병합 칸(앞 쪽에서 넘어와 이어 늘린 칸)은 그대로 두고,\n // 나머지는 앞 행에서 끝나는 칸과 뒤 행에서 시작하는 칸의 열 범위가 같아야 한다\n const pairs: Array<[Anchor, Anchor]> = []\n let carried = false\n for (let c = 0; c < table.cols;) {\n const a = owner[last][c], b = owner[first][c]\n // 두 쪽 모두 클립이 없던 자리 — 글 없는 칸이 쪽을 넘은 부분이다 (행정업무운영 편람 신구조문 대비표: 시행규칙 쪽이 빈 조문 행이\n // 네 쪽에 걸쳐 끊겨 103행 → 119행). 칸이 들어설 수 없는 좁은 열은 조각끼리 왼끝이 조금 어긋나 합집합 격자에 생긴 틈이라 증거가 아니다\n // (서식 쪽마다 왼끝 56.6/58.0 → 1.4pt 틈 열: 바닥글 \"210mm×297mm…\" 행이 다음 쪽 \"(4쪽 중 제3쪽)\" 행을 삼켰다)\n if (!a && !b) { if (colXs[c + 1] - colXs[c] >= CARRIED_MIN_COL_W) carried = true; c++; continue }\n if (!a || !b) return false\n if (a !== b) {\n if (a.c !== b.c || a.cs !== b.cs || a.r + a.rs - 1 !== last || b.r !== first) return false\n // 뒤 조각 칸이 여러 행을 덮으면 쪽 경계에 걸친 세로 병합 칸의 나머지다(외국환거래법 \"다. 등록 또는 인가의 내용 / 이나 조건을\n // 위반한 경우\" 가 다음 쪽 두 행을 덮는다). 앞 쪽 칸이 비었으면 새로 시작한 세로 병합 이름표(가산대상 자격증 빈 칸 아래 \"임업\"),\n // 앞 쪽에서도 여러 행을 덮었으면 행은 새로 시작하고 이름표만 쪽 경계를 넘은 것이다(과태료 기준 \"…준용하는 / 경우를 포함한다)\" 옆\n // \"5회 | 70만원\" / \"6회 | 90만원\" — mergeStraddlingCells 가 잇는다)\n if (b.rs > 1 && (a.rs > 1 || !hasContent(a.cell))) return false\n pairs.push([a, b])\n } else if (a.r === last && a.rs === 2) carried = true // 앞 쪽 끝 행에서 시작한 칸을 뒤 쪽 첫 행 빈 자리로만 이어 늘렸다 — 더 아래까지\n // 비어 있으면 뒤 쪽 여러 행을 덮는 세로 병합 칸이다(규제영향분석서 \"기타\"). 쪼개진 행의 이어진 조각은 첫 행 띠 하나다\n c = a.c + a.cs\n }\n // 뒤 쪽 칸 조각의 글 없는 클립은 증거가 아니다 — 한컴 PDF 판(1.3.0.546·538)과 칸에 따라 이어진 빈 조각에도 클립을 깐다\n // (법령 별표 쪼개진 행 17건에 있음). 앞 쪽 빈 칸 뒤에 글이 오는 것만 새 칸 증거다 (글은 칸 위에서부터 흐른다)\n const newCell = pairs.some(([u, d]) => !hasContent(u.cell) && hasContent(d.cell))\n // 글줄은 격자에 놓은 복사본 칸에서 본다 — 옮겨 맞댄 뒤 조각(짝·홀 쪽·단 넘김)은 복사본에만 옮긴 글줄이 있다\n const cell = (a: Anchor): IRCell => table.cells[a.r][a.c]\n // 번호 열이 다음 차례로 이어지면 새 행이다 — 쪼개진 행의 이어진 조각은 번호 칸이 빈다. 옆 칸 끝줄이 마침 칸을 꽉 채워 글 이어짐으로\n // 보였다(허용물질 \"12 | 사람의 배설물(오줌만인 경우는 제외한다)\" 다음 쪽 \"13 | 벌레 등 자연적으로 생긴 유기체\")\n const serial = (c: IRCell): number | null => { const t = flowText(c.text).trim(); return /^\\d{1,3}$/.test(t) ? +t : null }\n if (pairs.some(([u, d]) => { const a = serial(cell(u)), b = serial(cell(d)); return a !== null && b === a + 1 })) return false\n // 글 있는 열 쌍이 둘 이상이면 세로 병합 칸만 넘어가고 행은 새로 시작하는 표(규제영향분석서 \"10.영향평가 여부\" 옆 \"기술영향평가 |\n // 경쟁영향평가 | …\" / 다음 쪽 \"해당없음 | 해당없음 | …\")와 가려야 한다. 쪼개진 행 증거로 쓰는 경우 —\n // · 글 있는 뒤 조각 칸이 모두 여러 행을 덮는다: 쪽 경계에 걸린 행의 세로 병합 칸들이 함께 넘어갔다. 새 행이면 한 행 칸이 적어도\n // 하나 있다 (선관위 자격증 \"방송 / 통신\"·\"기술사 / (자격증종류는 …)\" 가 다음 쪽 두 행을 덮고, 옆 \"통신사\" 칸은 클립 없이 이어진다)\n // · 쪽 경계에 괘선이 없고 글 쌍 하나라도 줄 넘침이다 (양곡가공업자 \"영업정지 / 3개월\")\n // 이때 다른 열의 한 줄 끝난 글은 칸 안 문단 경계다(선관위 \"기능장\" / \"기사(6) …\") — 끝난 이름표 모순(lineEnded)을 보지 않는다.\n // 법령 별표 272건에서 칸 조각 이어짐 + 글 쌍 둘 이상 35건이 모두 쪼개진 행, 표 GT 에서 새 행 1건(위 규제영향분석서, 괘선 있는 쪽 경계)\n const contentPairs = pairs.filter(([u, d]) => hasContent(u.cell) && hasContent(d.cell))\n const carriedSplit = !textOnly && carried && !newCell && (contentPairs.length <= 1 || contentPairs.every(([, d]) => d.rs > 1)\n || (!cut.ruled && contentPairs.some(([u, d]) => wordOverflows(cell(u), cell(d), colXs[u.c], colXs[u.c + u.cs]))))\n if (!carriedSplit) {\n // 앞 쪽 칸이 한 줄로 끝난 이름표(다음 어절이 들어갈 자리가 남음)인데 뒤 쪽 같은 열에 다른 글이 있으면 그 열은 새 칸이다 — 끝난 칸의\n // 이어진 조각은 글이 없어 클립조차 없다. 다른 열 끝줄이 꽉 찬 것(글 이어짐)보다 이 모순이 앞선다 (편람 [별표 4] 가로 판\n // \"8. 글자 | …꽉 찬 끝줄\" 다음 쪽 \"9. 한글과 함께 적는 외국글자 | 가. 단어를 …\", 시험기준표 \"액성한계·소성한계 | KS F 2303\" 다음 쪽\n // \"세립토 비율 | KS F 2309\"). 같은 글이면 문단마다 붙는 표지다 (신구조문 대비표 \"<신 설>\" 이 큰 행 두 조각에 하나씩)\n const norm = (c: IRCell): string => flowText(c.text).replace(/\\s+/g, \"\")\n if (pairs.some(([u, d]) => hasContent(d.cell) && norm(cell(d)) !== norm(cell(u)) && lineEnded(cell(u), colXs[u.c], colXs[u.c + u.cs], cell(d)))) return false\n // 한 칸은 앞 조각 머리 항목의 바로 다음 항목으로 시작하고(\"커. …\" / \"터. …\"), 다른 칸은 두 조각 모두 서로 다른 한 줄 값이면(\"법 제47조\" /\n // \"법 제45조\") 새 행이다 — 좁은 칸 끝줄 \"2) 2회 이상 위반\" 이 꽉 차 보여 다음 쪽 새 행을 앞 행에 잇고 ○ 표시까지 섞었다(총포화약법\n // 행정처분기준, 4.17.0 회귀). 칸 안 목록이 \"가. …\" / \"나. …\" 로 이어지는 대비표는 다른 칸 값이 같거나(<신 설>) 비어 있다\n const oneLine = (c: IRCell): boolean => CELL_LINES.get(c)?.length === 1\n if (pairs.some(([u, d]) => nextItemHead(cell(u).text, cell(d).text))\n && pairs.some(([u, d]) => hasContent(u.cell) && hasContent(d.cell) && oneLine(cell(u)) && oneLine(cell(d)) && norm(cell(u)) !== norm(cell(d)))) return false\n const flows = ([u, d]: [Anchor, Anchor]): boolean => continuesAcross(cell(u), cell(d), colXs[u.c], colXs[u.c + u.cs]) || clauseContinues(cell(u), cell(d))\n || quantityContinues(cell(u), cell(d))\n // 괘선 없는 쪽 경계(3)는 글 증거 없이 합친다 — 앞 쪽 빈 칸 뒤 글(새 칸)이 없을 때만 (쪽 끝 빈 행 다음 새 표 행, 노인복지법 운영기준)\n if (!(cut.open && !newCell)) {\n // 그 칸 글이 스스로 이어지지 않는 여러 행 칸은 다음 쪽에서 새로 시작한 세로 병합 칸이다 (시험기준표 \"KS D 3502 / KS F 4603\" 아래\n // 세 행을 덮는 새 \"KS F 4603\")\n if (pairs.some(p => p[1].rs > 1 && !flows(p))) return false\n // 다른 글 이어짐 증거는 뒤 조각에서 한 행인 칸에서만 본다 — 여러 행을 덮는 칸은 쪽 경계에 걸친 세로 병합 칸이라 그 글이 이어져도\n // 행은 새로 시작할 수 있다 (특수복식 \"2) 여자 경찰 / 공무원 근무복\" 칸 옆 \"2) 여자 경찰공무원 근무복\" 다음 행 \"가) 상의 앞면, 뒷면\")\n const one = pairs.filter(([, d]) => d.rs === 1)\n if (!outlineOnly(pairs, cell) && !one.some(flows) && (cut.list === \"none\" || !one.some(([u, d]) => listContinues(cell(u), cell(d), cut.list === \"multi\")))) return false\n }\n }\n for (const [u, d] of pairs) {\n appendCell(table.cells[u.r][u.c], table.cells[d.r][d.c])\n // 뒤 조각에서 여러 행을 덮던 칸은 이은 칸이 그만큼 더 덮는다 (아래 줄이기가 경계 행 하나를 뺀다)\n if (d.rs > 1) table.cells[u.r][u.c].rowSpan = u.rs + d.rs\n }\n // 위에서 내려와 두 행에 걸친 세로 병합 칸은 한 행 줄어든다\n for (let r = 0; r < first; r++) for (let c = 0; c < table.cols; c++) {\n const o = owner[r][c]\n if (o && o.r === r && o.c === c && r + table.cells[r][c].rowSpan > first) table.cells[r][c].rowSpan--\n }\n table.cells.splice(first, 1)\n table.rows--\n return true\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 * 두 조각 사이에 자기 쪽 표 조각과 가로로 겹치지 않는 블록(besideOwn)이나 쪽 끝·첫머리 띠의 쪽 번호(pageNumberBetween)만 끼어 있으면\n * 인접으로 본다 (그 블록은 제자리에 둔다).\n * 같은 쪽 2단 넘김은 먼저 mergeColumnFlow 가 잇는다.\n */\nconst NEIGHBOR_TABLE_EPSILON = 0.2\n\n/**\n * 표 두 조각 사이에 끼어도 이음을 막지 않는 블록 — 자기 쪽 표 조각과 가로 범위가 겹치지 않는 글(쪽 가장자리 장 표시 세로글 —\n * 행정업무운영 편람), 앞 표가 2단 지면의 오른쪽 단일 때 그 왼쪽 단에 놓인 글·표(입찰동향 붙임: 오른쪽 단 입찰내역표 앞에\n * 왼쪽 단 지역별 현황표)\n */\nfunction besideOwn(b: IRBlock, p: IRBlock, c: IRBlock): boolean {\n if (!b.bbox) return false\n if (b.type === \"table\") return leftColumnOf(b, p)\n const t = (b.pageNumber === p.pageNumber ? p : c).bbox!\n const bx1 = b.bbox.x, bx2 = b.bbox.x + b.bbox.width\n return bx2 <= t.x + 1 || bx1 >= t.x + t.width - 1\n}\n\n/** 쪽 번호 한 줄 — \"21\", \"- 21 -\" */\nconst PAGE_NUMBER_TEXT = /^[-–—]?\\s*\\d{1,4}\\s*[-–—]?$/\n\n/**\n * 두 조각 사이의 쪽 번호 — 앞 조각 아래 쪽 끝 띠나 뒤 조각 위 쪽 첫머리 띠에 홀로 놓인 쪽 번호 문단. 머리글·바닥글 제거는 3쪽 이상\n * 파싱할 때만 돌아 두 쪽만 뽑거나(-p 29-30) 제거를 끄면(--no-header-footer) 쪽 번호가 표 폭 안에 남아 이음을 막았다(응급의료기관 평가\n * 기준집 p.29-30: 앞 쪽 끝 행 가중치 1.5 칸이 다음 쪽 두 행에 걸친 표가 두 표로 갈려 다음 쪽 행의 가중치가 빈 칸). 쪽 높이를 모르면 두지 않는다\n */\nfunction pageNumberBetween(b: IRBlock, p: IRBlock, c: IRBlock, pageHeights?: Map<number, number>): boolean {\n if (b.type !== \"paragraph\" || !b.bbox || !PAGE_NUMBER_TEXT.test(b.text?.trim() ?? \"\")) return false\n const h = b.pageNumber ? pageHeights?.get(b.pageNumber) : undefined\n if (!h) return false\n if (b.pageNumber === p.pageNumber) return b.bbox.y + b.bbox.height <= Math.min(p.bbox!.y, h * PAGE_EDGE_BAND) + 1\n if (b.pageNumber === c.pageNumber) return b.bbox.y >= Math.max(c.bbox!.y + c.bbox!.height, h * (1 - PAGE_EDGE_BAND)) - 1\n return false\n}\n\n/** 같은 쪽에서 표 t 의 왼쪽 단에 온전히 놓인 블록 */\nfunction leftColumnOf(b: IRBlock, t: IRBlock): boolean {\n return !!b.bbox && b.pageNumber === t.pageNumber && b.bbox.x + b.bbox.width <= t.bbox!.x + 1\n}\n\n/**\n * 이은 표를 앞 조각(blocks[i]) 자리에 두고 뒤 조각(blocks[j])을 뺀다. 건너뛴 블록 가운데 앞 조각 왼쪽 단의 것이 있으면 이은 표를\n * 그 뒤로 옮긴다 — 블록은 윗변 순이라 2단 지면 오른쪽 단 표가 왼쪽 단 글·표보다 먼저 나오는데, 읽는 순서로는 왼쪽 단이 앞이다\n * (입찰동향 붙임: 오른쪽 단에서 시작해 다음 쪽으로 이어진 입찰내역표가 왼쪽 단 현황표 두 개보다 앞에 놓여 표 순서가 뒤바뀜)\n */\nfunction placeJoined(blocks: IRBlock[], i: number, j: number, table: IRTable): void {\n const prev = blocks[i]\n markJoinedPages(table, prev, blocks[j])\n blocks[i] = { ...prev, table }\n blocks.splice(j, 1)\n let k = j - 1\n while (k > i && !leftColumnOf(blocks[k], prev)) k--\n if (k > i) blocks.splice(k, 0, blocks.splice(i, 1)[0])\n}\n\n/** 같은 쪽에서 표 테두리 안에 든 블록 — 칸 안 예시 상자처럼 표 칸에 붙지 못하고 표 뒤에 따로 나온 조각 */\nfunction insideTable(b: IRBlock, t: IRBlock): boolean {\n if (!b.bbox || b.pageNumber !== t.pageNumber) return false\n const o = t.bbox!\n return b.bbox.x >= o.x - 1 && b.bbox.x + b.bbox.width <= o.x + o.width + 1 && b.bbox.y >= o.y - 1 && b.bbox.y + b.bbox.height <= o.y + o.height + 1\n}\n\n/** 첫 행 전체를 차지하는 단위 표기 */\nfunction startsWithUnitRow(table: IRTable): boolean {\n return /^\\s*\\(\\s*단위\\s*[::]/.test(table.cells[0]?.[0]?.text ?? \"\")\n && table.cells[0]?.slice(1).every(c => !c.text.trim())\n}\n\n/** 두 첫 행이 같은 머리 모양인가 — 칸마다 열·행 병합이 같고, 세로 병합 칸(두 행 이상)을 하나 이상 품었다 */\nfunction sameHeadShape(a: IRCell[] | undefined, b: IRCell[] | undefined): boolean {\n if (!a || !b || a.length !== b.length) return false\n let tall = false\n for (let c = 0; c < a.length; c++) {\n if (a[c].colSpan !== b[c].colSpan || a[c].rowSpan !== b[c].rowSpan) return false\n if (a[c].rowSpan >= 2 && a[c].text.trim() && b[c].text.trim()) tall = true\n }\n return tall\n}\n\nexport function mergeCrossPageTables(blocks: IRBlock[], pageHeights?: Map<number, number>, lex?: WrapLexicon): void {\n mergeColumnFlow(blocks, pageHeights, lex)\n for (let i = blocks.length - 2; i >= 0; i--) {\n const tail = tailOf(blocks[i])\n if (tail.type !== \"table\" || !tail.table || !tail.bbox || !tail.pageNumber) continue\n // 다음 표 — 다음 쪽까지만 훑는다 (글만 긴 문서에서 블록마다 끝까지 훑지 않게). 앞 표 테두리 안에 든 표(칸 안 예시 상자가 따로 나온\n // 것)와 앞 표 왼쪽 단의 표(2단 지면)는 건너뛴다 — 편람 [별표 4] 서식 설계기준표는 \"3. 쪽번호\" 칸 안 예시 상자가 쪽 마지막 표가\n // 되어 다음 쪽 조각과 끊겼다\n let j = i + 1\n const skip = (b: IRBlock): boolean => b.type !== \"table\" || [blocks[i], tail].some(t => insideTable(b, t) || leftColumnOf(b, t))\n while (j < blocks.length && skip(blocks[j]) && (blocks[j].pageNumber ?? 0) <= tail.pageNumber + 1) j++\n const curr = blocks[j]\n // 붙인 이어짐 조각과 같은 쪽에 이어 놓인 표는 종전처럼 표 첫 쪽에서 본다 (규제영향분석서 연도별 편익 표: 다음 쪽 첫머리 조각 바로 아래\n // \"근거설명\" 행 조각 — 조각 쪽 기준으로는 같은 쪽이라 잇지 못했다)\n const prev = tail !== blocks[i] && curr?.pageNumber === tail.pageNumber ? blocks[i] : tail\n if (!prev.table || !prev.bbox || !prev.pageNumber) continue\n if (!curr || curr.type !== \"table\" || !curr.table || !curr.bbox || curr.pageNumber !== prev.pageNumber + 1) continue\n // 단위 행이 양쪽에 다시 나타나면 각 쪽에서 새 표를 시작한 것이다.\n if (startsWithUnitRow(prev.table) && startsWithUnitRow(curr.table)) continue\n const joined = j === i + 1 || blocks.slice(i + 1, j).every(b => besideOwn(b, prev, curr) || insideTable(b, prev) || pageNumberBetween(b, prev, curr, pageHeights))\n ? (looksContinued(prev, curr, pageHeights) && !restartsTable(blocks, i, curr.table, pageHeights) ? joinClipParts(prev, curr, pageHeights, lex) ?? false : null)\n : null\n if (joined) {\n // 한컴 클립 표 조각 — 열 경계 합집합 격자로 이었다 (쪽마다 열 구성이 달라도)\n placeJoined(blocks, i, j, joined)\n continue\n }\n // 앞 표의 이어짐이 못 된 빈 조각은 버리고 같은 앞 표로 그다음 표를 다시 본다 — 쪽 첫머리 장식 띠 같은 빈 클립 표가 진짜\n // 이어짐을 가로막지 않게 (빈 조각의 뒤쪽 이음은 이미 앞선 차례에 시도했다)\n if (EMPTY_PARTS.has(curr.table)) { blocks.splice(j, 1); i++; continue }\n if (joined === null) continue\n if (prev.table.cols !== curr.table.cols || prev.table.renderAsTable !== curr.table.renderAsTable || EMPTY_PARTS.has(prev.table)) 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 // 열 경계까지 같아야 한 표의 이어짐 — 열 수·좌우 끝만 보면 다음 쪽 첫머리의 \"붙임 2 | 제목\" 머리상자(1×3)가\n // 앞 쪽 일정표(3열)의 꼬리 행으로 붙는다(보도자료 붙임 실측). 경계 좌표가 없는 표(클러스터)는 종전대로.\n // 짝·홀 쪽 대칭 여백(책자형 편람: 쪽마다 본문이 11.4~12.5pt 옮겨짐)은 모든 경계가 같은 거리만큼 옮겨진 것이라 같은 경계로 본다\n // — 선 표 쪽 넘김 후보 가운데 이렇게 옮겨진 15건 중 사이 글 없이 쪽 끝·첫머리에 놓인 10건이 모두 같은 표다. 클립 표는\n // joinClipParts 가 쪼개진 행·반복 머리 행 증거가 있을 때만 옮겨 잇는다 (여기서 옮김을 받으면 연달아 놓인 Q&A 상자가 12×5 로 이어진다)\n const px = TABLE_COLXS.get(prev.table), cx = TABLE_COLXS.get(curr.table)\n if (px && cx && !shiftedSame(px, cx, !CLIP_TABLES.has(prev.table) && !CLIP_TABLES.has(curr.table))) continue\n\n // 다음 표 첫 행이 앞 표 첫 행과 같은 모양(칸마다 열·행 병합이 같고 세로 병합 칸을 품은 머리)인데 글이 다르면 새 표의 머리다\n // — 쪽마다 새로 놓인 같은 틀 상자(\"일 러 두 기\" 다음 쪽 \"목 차\", 보도자료 표지 상자). 되풀이 머리 행은 글이 같다\n if (!rowTextsEqual(prev.table.cells[0], curr.table.cells[0]) && sameHeadShape(prev.table.cells[0], curr.table.cells[0])) continue\n\n // 반복 헤더 행 제거: 다음 표 첫 행들이 이전 표 첫 행들과 같으면 중복 헤더. 머리가 여러 행이면(첫 행 칸이 세로 병합으로\n // 그 깊이를 알린다 — \"구분|장비분류|구급차 구분\" 위, \"특수구급차|일반구급차\" 아래) 그 깊이까지 본다. 첫 행만 빼면 둘째 머리 행이\n // 본문 행으로 남아 첫 행 세로 병합 자리가 빈 칸으로 드러났다(구급차 관리·운용 안내 [별표 16] 30~31쪽)\n let currCells = curr.table.cells\n const headRows = repeatedHeaderRows(prev.table, curr.table)\n if (headRows < 0) continue\n if (headRows) currCells = currCells.slice(headRows)\n // 앞 조각 마지막 행에서 끝나는 세로 병합 칸(상위 항목 \"나. 응급 처치용 의료장비\")이 다음 쪽 조각 첫 행에서 글 없는 세로 병합 칸으로\n // 다시 그려졌으면 같은 칸의 이어짐이다 — 앞 칸 병합을 늘리고 뒤 빈 칸은 덮인 자리로 둔다. 글 없는 한 행 칸은 증거로 쓰지 않는다\n if (currCells.length) continueRowSpans(prev.table.cells, currCells)\n if (currCells.length === 0) {\n blocks.splice(j, 1)\n continue\n }\n\n // 선 격자도 반복 머리행 아래의 본문이 쪽 중간에서 끊긴다. 기존 칸·문장 이어짐 검증을 재사용한다.\n // 한 줄로 끝난 이름표·새 항목은 mergeSplitRow가 거절하며, 글줄 증거 없는 표는 종전대로 행을 붙인다.\n if (px && cx && !CLIP_TABLES.has(prev.table) && !CLIP_TABLES.has(curr.table)\n && currCells.length < curr.table.cells.length && curr.table.cells[0].filter(c => c.text.trim()).length >= 2\n && prev.table.cells.at(-1)!.concat(currCells[0]).every(c => c.rowSpan === 1 && c.colSpan === 1 && CELL_LINES.has(c))) {\n const dx = px[0] - cx[0]\n const part = joinSplitParts(prev.table, px, curr.table, cx.map(x => x + dx), dx, undefined, lex)\n if (part?.split && part.table.rows === prev.table.rows + currCells.length - 1) {\n const last = prev.table.rows - 1\n for (let c = 0; c < part.table.cols; c++) {\n const U = CELL_LINES.get(prev.table.cells[last][c])!, D = CELL_LINES.get(currCells[0][c])!\n CELL_LINES.set(part.table.cells[last][c], U.concat(D.map(l => ({ ...l, l: l.l + dx, r: l.r + dx }))))\n }\n TABLE_COLXS.set(part.table, part.colXs)\n placeJoined(blocks, i, j, part.table)\n continue\n }\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 ...(prev.table.renderAsTable ? { renderAsTable: true } : {}),\n }\n if (px ?? cx) TABLE_COLXS.set(merged, (px ?? cx)!)\n if (CLIP_TABLES.has(prev.table) || CLIP_TABLES.has(curr.table)) CLIP_TABLES.add(merged)\n placeJoined(blocks, i, j, merged)\n }\n // 잇지 못한 빈 클립 표 조각은 버린다 (쪽 추출 단계에서 빈 표를 버리던 종전 동작)\n for (let i = blocks.length - 1; i >= 0; i--) {\n const t = blocks[i].table\n if (blocks[i].type === \"table\" && t && EMPTY_PARTS.has(t)) blocks.splice(i, 1)\n }\n}\n\n/**\n * 같은 쪽 단 넘김 — 2단 지면(가로 쪽 두 단 편람 [별표 4], 보도자료 붙임 두 쪽 모아 찍기)에서 왼쪽 단 바닥까지 찬 클립 표 조각과 같은\n * 쪽 오른쪽 단 첫머리의 표 조각을 잇는다. 쪽 넘김과 같은 모양(앞 조각이 바닥까지, 뒤 조각이 첫머리부터)에 뒤 조각이 단 거리만큼\n * 옮겨져 있어 증거도 쪽 넘김 평행 이동과 같다(쪼개진 행 또는 글 있는 머리 행 되풀이). 블록 순서는 윗변 기준이라 오른쪽 단 조각이\n * 먼저 나오기도 해 순서와 무관하게 짝을 찾고, 이은 표는 두 자리 중 앞선 자리에 둔다. 이은 표의 bbox 는 x·윗변을 왼쪽 조각(열 경계\n * 좌표계)에서, 밑변을 오른쪽 조각(흐름의 끝)에서 가져온다 — 다음 쪽 이음은 흐름 끝이 쪽 바닥까지 찼는지 봐야 한다\n */\nfunction mergeColumnFlow(blocks: IRBlock[], pageHeights?: Map<number, number>, lex?: WrapLexicon): void {\n if (!pageHeights) return\n for (let i = 0; i < blocks.length; i++) {\n const L = blocks[i]\n if (L.type !== \"table\" || !L.table || !L.bbox || !L.pageNumber || !CLIP_TABLES.has(L.table) || EMPTY_PARTS.has(L.table)) continue\n const ph = pageHeights.get(L.pageNumber)\n if (!ph || L.bbox.y > ph * PAGE_EDGE_BAND) continue\n // 오른쪽 단 첫머리 표 — 같은 쪽(블록은 쪽 순서라 같은 쪽 구간만 훑는다), L 오른쪽에 온전히, 쪽 첫머리 띠 안에서 시작하는 가장 위 표\n let s0 = i, s1 = i\n while (s0 > 0 && blocks[s0 - 1].pageNumber === L.pageNumber) s0--\n while (s1 + 1 < blocks.length && blocks[s1 + 1].pageNumber === L.pageNumber) s1++\n let k = -1\n for (let q = s0; q <= s1; q++) {\n const R = blocks[q]\n if (q === i || R.type !== \"table\" || !R.table || !R.bbox || !leftColumnOf(L, R)) continue\n const top = R.bbox.y + R.bbox.height\n if (top < ph * (1 - PAGE_EDGE_BAND)) continue\n if (k < 0 || top > blocks[k].bbox!.y + blocks[k].bbox!.height) k = q\n }\n if (k < 0) continue\n const R = blocks[k]\n if (Math.abs(R.bbox!.width - L.bbox.width) > Math.max(R.bbox!.width, L.bbox.width) * NEIGHBOR_TABLE_EPSILON) continue\n if (!looksContinued(L, R) || restartsTable(blocks, i, R.table!, pageHeights)) continue\n const joined = joinClipParts(L, R, pageHeights, lex)\n if (!joined) continue\n const top = L.bbox.y + L.bbox.height\n const at = Math.min(i, k)\n blocks[at] = { ...L, table: joined, bbox: { ...L.bbox, y: R.bbox!.y, height: Math.max(0, top - R.bbox!.y) } }\n blocks.splice(Math.max(i, k), 1)\n i = at - 1 // 이은 표가 다시 오른쪽 단으로 이어질 수 있다 (세 단)\n }\n}\n\n/**\n * 클립 표 두 조각이 한 표의 쪽 넘김인지 보고 이어 붙인다. 열 경계가 같으면 종전처럼 이어짐으로 보고, 다르면\n * 쪽 넘김 기하(앞 조각이 쪽 밑까지, 뒤 조각이 쪽 위부터 — PAGE_EDGE_BAND)와 오른쪽 끝 일치·뒤 조각 왼쪽 끝이 앞 조각\n * 경계 위에 있음을 요구한다(뒤 쪽에 세로 병합 이어진 칸이 비어 왼쪽 열이 빠진 경우). 앞 조각 쪽에서 왼쪽 열이 빠진 경우\n * (prevLacksLeftCols)와 뒤 조각 경계가 모두 앞 조각 경계 위에 있는 경우(양옆 열이 빠진 쪼개진 끝 행)도 잇는다.\n */\nfunction joinClipParts(prev: IRBlock, curr: IRBlock, pageHeights?: Map<number, number>, lex?: WrapLexicon): IRTable | null {\n const pt = prev.table!, ct = curr.table!\n if (!CLIP_TABLES.has(pt) || !CLIP_TABLES.has(ct)) return null\n const px = TABLE_COLXS.get(pt), cx = TABLE_COLXS.get(ct)\n if (!px || !cx) return null\n let xs = cx, shifted = false, dx = 0, foreign = false, within = false\n if (!shiftedSame(px, cx, false)) {\n // 열 구성이 다르면 쪽 넘김 기하를 반드시 확인 (looksContinued 는 쪽 높이를 모르면 통과시킨다)\n if (!pageHeights?.get(prev.pageNumber!) || !pageHeights?.get(curr.pageNumber!)) return null\n // 짝·홀 쪽 대칭 여백(11.4pt)이나 2단 지면의 단 넘김(420pt)으로 표 전체가 옮겨졌으면 뒤 조각을 앞 조각 자리로 옮겨 맞댄다. 다만\n // 같은 틀 상자가 쪽마다 새로 놓인 것(편람 Q&A 상자 55개)과 가르려고 경계 행이 쪼개진 행이거나 글 있는 머리 행이 되풀이될 때만\n // 잇는다 — 새 상자의 첫 행은 빈 칸이고 앞 상자 끝 행과 칸 짜임이 달라 둘 다 될 수 없다. 이 조건 없이 옮김만 넣으면 연달아\n // 놓인 상자끼리 이어져 편람 −14표 (클립 표 평행 이동 후보 47건 중 같은 표 5건)\n dx = px[px.length - 1] - cx[cx.length - 1]\n // 뒤 조각의 경계가 모두 앞 조각 경계 위에 있으면 옮겨진 것이 아니라 양옆 열이 빠진 조각이다 — 쪼개진 끝 행에서 글이 넘어간 열만\n // 클립이 깔리고 글 없이 넘어간 좌우 열은 클립이 없다(총포 행정처분기준 \"너. …전자장치를 임의로 / 제거 또는 훼손\" 은 위반사항·적용법령\n // 두 열만 다음 쪽 첫머리에, 9열 중 오른쪽 6열이 빠져 오른끝이 196pt 어긋남). 경계가 우연히 맞물린 새 표와 가르려고 옮긴 조각처럼\n // 쪼개진 행 증거를 요구한다\n within = Math.abs(dx) > CONTINUATION_COL_TOL && cx.length >= 3 && cx.every(x => px.some(p => Math.abs(p - x) <= CONTINUATION_COL_TOL))\n if (Math.abs(dx) > CONTINUATION_COL_TOL && !within) { xs = cx.map(x => x + dx); shifted = true } else dx = 0\n if (!px.some(x => Math.abs(x - xs[0]) <= CONTINUATION_COL_TOL) && !prevLacksLeftCols(prev, px, xs, pageHeights)) return null\n // 뒤 조각이 절 제목 상자 꼴로 시작하고 안쪽 열 경계가 앞 조각 경계 어디에도 맞물리지 않으면 새로 놓인 상자다 — 옮긴 조각처럼\n // 쪼개진 행·글 있는 머리 행 되풀이가 있을 때만 잇는다(서식 7열 표 뒤 다음 쪽 \"목 차\" 상자, \"3 | 기대성과 및 기대효과\"·\"5 | 시험방법\"\n // 번호 머리 상자, \"② 세미나, 포럼, 언론 활동\" 제목 상자 — tac-img-02·pr-1674). 쪽마다 열 짜임을 바꾸며 이어지는 별표·서식은 첫 행이\n // 앞 쪽 비고의 이어진 문단(\"가. 제조(수입)업무의 …\", 과징금 산정기준)이거나 안쪽 경계 일부가 맞물린다(규제영향분석서)\n // 뒤 조각이 이미 그다음 쪽 조각과 이어진 표면 그 첫 조각의 경계로도 본다 — 뒤쪽 쪽들의 열 짜임이 섞여 들어와 새 상자로 보이면 안 된다\n // (자동차종합검사 시설기준: 셋째 쪽 전폭 행 조각이 다섯째 쪽 \"구 분 | 자 격 | 직 무\" 조각과 먼저 이어져 둘째 쪽과 끊겼다)\n const unrelated = (b: number[]): boolean => {\n const inner = b.slice(1, -1).map(x => x + dx)\n return inner.length > 0 && !inner.some(x => px.some(p => Math.abs(p - x) <= CONTINUATION_COL_TOL))\n }\n // 첫 행부터 칸이 셋 이상인 뒤 조각도 같다 — 제 열 짜임으로 시작한 새 표다. 한 표의 쪽 넘김이면 두 조각이 한 격자의 경계를 나눠\n // 쓰는데, 안쪽 경계가 하나도 안 맞물리면 합집합 격자는 행마다 제 병합만 남은 짜깁기가 된다(안전보건표지 [별표 6] 1쪽 13열 표 →\n // 2쪽 \"4. | 401 녹십자표지 | …\" 10열 표, 안쪽 경계 12개·9개 가운데 맞물림 0 — HWPX 두 표). 첫 행이 한 칸인 이어진 문단은 그대로 잇는다\n foreign = unrelated(cx) && unrelated(PART_COLXS.get(ct) ?? cx)\n && (headingRow(ct.cells[0] ?? []) || px.length > FOREIGN_FIRST_ROW_CELLS && anchorsOf(ct).filter(a => a.r === 0).length >= FOREIGN_FIRST_ROW_CELLS)\n }\n const res = joinSplitParts(pt, px, ct, xs, dx, prev.bbox?.y, lex)\n if (!res || ((shifted || foreign || within) && !res.split && !res.header)) return null\n TABLE_COLXS.set(res.table, res.colXs)\n const ys = TABLE_ROWYS.get(pt)\n if (ys) TABLE_ROWYS.set(res.table, ys)\n PART_COLXS.set(res.table, PART_COLXS.get(pt) ?? px)\n CLIP_TABLES.add(res.table)\n return res.table\n}\n\n/**\n * 앞 조각에 왼쪽 열이 빠졌나 — 앞 조각 왼끝이 뒤 조각 안쪽 경계 위에 있고 앞 조각이 쪽 머리부터 바닥까지 찼다. 왼쪽 열의 세로 병합\n * 칸이 쪽 전체를 덮고 그 쪽에 글이 없으면 한컴은 그 칸에 클립을 깔지 않아, 가운데 쪽 조각만 왼쪽 열 없이 나온다(해양경찰 관할구역\n * \"동해지방해양경찰청\" 칸, 체류자격 첨부서류 \"교수(E-1)\" 칸). 쪽 전체를 차지한 조각은 표 첫머리가 아니라 사슬 가운데라 머리 행이 없다\n */\nfunction prevLacksLeftCols(prev: IRBlock, px: number[], xs: number[], pageHeights?: Map<number, number>): boolean {\n const ph = pageHeights?.get(prev.pageNumber!)\n if (!ph || prev.bbox!.y + prev.bbox!.height < ph * (1 - PAGE_EDGE_BAND)) return false\n return xs[0] < px[0] && xs.slice(1, -1).some(x => Math.abs(x - px[0]) <= CONTINUATION_COL_TOL)\n}\n\n/** 절 제목 상자 꼴의 첫 행 — 짧은 제목 한 칸(공백 뺀 8자 이하), 원문자로 시작하는 제목 한 칸, 번호 칸 + 제목 칸 */\nfunction headingRow(row: IRCell[]): boolean {\n const heads = row.map(c => flowText(c.text).replace(/\\s+/g, \"\")).filter(Boolean)\n if (heads.length === 1) return heads[0].length <= 8 || /^[①-⑳❶-❿]/.test(heads[0])\n return heads.length === 2 && /^(?:\\d{1,2}|[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+|[①-⑳❶-❿])$/.test(heads[0])\n}\n\n/** 붙임·참고·별지 등 첨부 머리표 — 쪽 첫머리에 이런 칸으로 시작하는 표는 앞 쪽 표의 이어짐이 아니다 */\nconst ANNEX_HEAD_RE = /^\\s*[<\\[(【]?\\s*(?:붙\\s*임|참\\s*고|별\\s*첨|별\\s*지|별\\s*표|첨\\s*부|부\\s*록)(?:\\s*\\d|\\s*$|\\s*[>\\])】])|^\\s*■/\n\n/**\n * 쪽 넘김 이어짐 판정 — 앞 표가 쪽 아래 PAGE_EDGE_BAND 안까지 차고 뒤 표가 쪽 위 PAGE_EDGE_BAND 안에서 시작하며, 뒤 표\n * 첫 칸이 첨부 머리표가 아니어야 한다. 보도자료·편람 쪽 넘김 후보 278건 대조(HWPX 로 같은 표인지 확인): 쪽 위쪽에 놓인\n * 머리 상자끼리(붙임 2 → 붙임 3, 시험지 쪽머리표)와 쪽 끝 담당자 표 → 다음 쪽 \"붙임\" 상자를 잇던 오병합이 이 두 조건으로\n * 빠지고, 참 이어짐은 1건만 놓친다. 쪽 높이를 모르면(외부 호출) 기하 조건은 건너뛴다\n */\nfunction looksContinued(prev: IRBlock, curr: IRBlock, pageHeights?: Map<number, number>): boolean {\n const ph = pageHeights?.get(prev.pageNumber!), ch = pageHeights?.get(curr.pageNumber!)\n if (ph && ch && ((prev.bbox!.y > ph * PAGE_EDGE_BAND && !pushedLead(prev, curr)) || curr.bbox!.y + curr.bbox!.height < ch * (1 - PAGE_EDGE_BAND))) return false\n const firstRow = curr.table!.cells[0] ?? []\n const firstText = firstRow.find(c => c.text.trim())?.text ?? \"\"\n if (!ANNEX_HEAD_RE.test(firstText)) return true\n // 신구조문 대비표 행 — 현행·개정안 두 칸 이상에 같은 \"[별표 2] …\" 가 들면 첨부 머리표가 아니라 대비표 본문이다 (머리표는 제목 한 칸)\n const filled = firstRow.map(c => flowText(c.text).replace(/\\s+/g, \"\")).filter(Boolean)\n return filled.length >= 2 && filled.every(t => t === filled[0])\n}\n\n/**\n * 쪽 넘김으로 밀려난 그림 행 묶음 — 앞 조각이 쪽 끝 띠 위에서 끝났어도 뒤 조각 첫 행 묶음(첫 행에서 세로 병합으로 이어진 행들)에 그림 칸이\n * 있고 그 높이가 앞 쪽 남은 자리(앞 조각 밑변에서 쪽 밑 끝까지)보다 높으면 그 자리에 들어갈 수 없어 다음 쪽으로 넘어간 것이다. 한컴은\n * 칸 그림을 쪽 경계에서 쪼개지 않아 복장 도면 같은 큰 그림이 든 표는 앞 쪽 바닥이 크게 빈다(경찰복제 특수복식 80×27: 3쪽 밑변 142pt\n * ← 4쪽 \"방한외투 및 방한복\" 세 행 187pt, 4쪽 258pt ← 5쪽 \"상의\" 여섯 행 387pt, 5쪽 152pt ← 6쪽 \"하의\" 세 행 245pt). 머리 행으로\n * 시작하는 새 표는 첫 묶음이 그림 없는 한 행(21pt)이라 해당하지 않는다(같은 문서의 쪽마다 놓인 11×5 표들). 글만 든 행은 쪽 경계에서\n * 쪼개질 수 있어 밀려남의 증거가 아니다 — 쪽 나누기로 다음 쪽에 새로 놓인 서식 표와 가르지 못한다\n */\nfunction pushedLead(prev: IRBlock, curr: IRBlock): boolean {\n if (!CLIP_TABLES.has(prev.table!) || !CLIP_TABLES.has(curr.table!)) return false\n const lead = leadRows(curr.table!)\n return lead.image && lead.height > prev.bbox!.y\n}\n\n/** 표 첫 행 묶음(첫 행에서 세로 병합 칸으로 이어진 행들)의 높이(첫 조각 행 경계로, 모르면 0)와 그림 칸이 들었는지 */\nfunction leadRows(t: IRTable): { height: number; image: boolean } {\n const ys = TABLE_ROWYS.get(t)\n if (!ys || ys.length < 2) return { height: 0, image: false }\n const anchors = anchorsOf(t)\n let end = 1, image = false\n for (let r = 0; r < end; r++) {\n for (const a of anchors) {\n if (a.r !== r) continue\n end = Math.max(end, r + a.rs)\n if (IMAGE_CELLS.has(a.cell)) image = true\n }\n }\n return { height: ys[0] - ys[Math.min(end, ys.length - 1)], image }\n}\n\n/** 표 끝 자리 — 마지막 행 칸이 다음 쪽으로 넘어가 그 조각을 붙인 표(TABLE_TAIL)는 그 쪽과 조각 밑변을, 아니면 블록 그대로. 쪽 넘김\n * 잇기는 앞 표가 끝난 쪽·자리를 봐야 한다 — 위험물 안전관리자 교육 [별표 4] 틀 표는 2쪽 끝 칸이 3쪽을 통째로 채우고 4쪽 조각으로\n * 이어지는데, 3쪽 조각을 2쪽 표에 붙인 뒤 표가 2쪽에 있는 것으로 보여 4쪽 조각과 잇지 못했다 (HWPX 19×7 한 표) */\nfunction tailOf(b: IRBlock): IRBlock {\n const t = b.table && TABLE_TAIL.get(b.table)\n return t && b.bbox ? { ...b, pageNumber: t.page, bbox: { ...b.bbox, page: t.page, y: t.y, height: t.height } } : b\n}\n\n/** 표 첫 행 앵커 — 열 경계 x 범위와 글 */\nfunction firstRowSig(t: IRTable): Array<{ x1: number; x2: number; t: string }> | null {\n const x = TABLE_COLXS.get(t)\n if (!x || x.length !== t.cols + 1) return null\n return anchorsOf(t).filter(a => a.r === 0).map(a => ({ x1: x[a.c], x2: x[a.c + a.cs], t: flowText(a.cell.text).replace(/\\s+/g, \"\") }))\n}\n\n/**\n * 뒤 표 첫 행이 앞 조각이 속한 표(쪽 넘김 사슬 첫 조각)의 첫 행을 다시 시작하는가 — 앵커 칸 짜임(x 범위, 대칭 여백\n * 이동 허용)이 같고 이름표 칸 둘 이상이 같은 글인데 값 칸이 하나 이상 다르다. 한 서식을 품목마다 되풀이한 문서(과제 품목 명세서\n * 5벌, 서식마다 두 쪽)에서 앞 서식 끝 조각은 본문 바닥 근처에서 끝나고 다음 쪽 첫머리가 새 서식이라 쪽 넘김 기하로는 이어짐과\n * 같다(141×22 한 표로 뭉침). 반복 머리 행은 글이 모두 같아 해당 없고, 이어진 조각의 첫 행은 표 첫 행과 칸 짜임부터 다르다\n */\nfunction restartsTable(blocks: IRBlock[], i: number, curr: IRTable, pageHeights?: Map<number, number>): boolean {\n const cs = firstRowSig(curr)\n if (!cs) return false\n const head = blocks[chainHead(blocks, i, pageHeights)].table!\n if (cs.length === 1 && restartsTitledForm(head, curr)) return true\n if (cs.length < RESTART_MIN_ANCHORS) return false\n // 보도자료 연락처 표의 다음 부처 묶음 — \"담당 부서 | 부처 | 책임자 | …\" 를 되풀이해도 한 표다 (HWPX 연락처 표 522개 중 여러 묶음 72개,\n // 묶음마다 따로 둔 표 0개). 정부합동 보도자료에서 쪽 끝 묶음과 다음 쪽 묶음이 두 표로 갈렸다\n if (CONTACT_HEAD.test(cs[0].t) && cs.some(c => CONTACT_ROLE.test(c.t))) return false\n const hs = firstRowSig(head)\n if (!hs || hs.length !== cs.length) return false\n const dx = hs[hs.length - 1].x2 - cs[cs.length - 1].x2\n if (!hs.every((h, n) => Math.abs(h.x1 - cs[n].x1 - dx) <= CONTINUATION_COL_TOL && Math.abs(h.x2 - cs[n].x2 - dx) <= CONTINUATION_COL_TOL)) return false\n let same = 0, diff = 0\n for (let n = 0; n < hs.length; n++) {\n if (!hs[n].t && !cs[n].t) continue\n if (hs[n].t === cs[n].t) same++\n else diff++\n }\n return same >= 2 && diff >= 1\n}\n\n/**\n * 전폭 제목 칸으로 시작하는 서식의 되풀이 — 두 표 첫 행이 모두 전폭 한 칸이고 글이 다르며, 둘째 행의 칸 짜임과 첫 칸 이름표가 같다.\n * \"3) 공공데이터 자동수집 / 목표 | … / 세부 목표 | …\" 상자를 과제마다 놓은 계획서(tac-img-02)에서 쪽 끝 상자와 다음 쪽 첫 상자가\n * 쪽 넘김 기하로 이어져 6×2 로 뭉쳤다. 이어진 조각이면 첫 행이 제목 칸일 때 그 아래 행 이름표까지 표 첫머리와 같을 까닭이 없다\n */\nfunction restartsTitledForm(head: IRTable, curr: IRTable): boolean {\n if (head.rows < 2 || curr.rows < 2 || head.cols !== curr.cols) return false\n const full = (t: IRTable) => t.cells[0][0]?.colSpan === t.cols\n if (!full(head) || !full(curr)) return false\n const norm = (c: IRCell | undefined) => flowText(c?.text ?? \"\").replace(/\\s+/g, \"\")\n const ht = norm(head.cells[0][0]), ct = norm(curr.cells[0][0])\n if (!ht || !ct || ht === ct) return false\n // 제목 칸만 — 앞 쪽에서 넘어온 본문 칸(\"ㅇ …\"·\"- …\" 긴 글)으로 시작하는 이어진 조각끼리는 짜임이 같아도 새 표가 아니다(form-002)\n const titleLike = (t: string) => t.length <= TITLE_ROW_MAX_CHARS && !/^[ㅇ○◦•·\\-–※*□■▪☞]/.test(t)\n if (!titleLike(ht) || !titleLike(ct)) return false\n const spans = (t: IRTable) => anchorsOf(t).filter(a => a.r === 1).map(a => `${a.c}:${a.cs}`).join(\",\")\n const label = norm(head.cells[1][0])\n return spans(head) === spans(curr) && label !== \"\" && label === norm(curr.cells[1][0])\n}\n\n/**\n * blocks[i] 표 조각이 속한 쪽 넘김 사슬의 첫 조각 위치 — 앞 쪽 마지막 표가 이음 모양(사이 글 없음·쪽 끝과 첫머리·열 경계 호환)이면\n * 거슬러 올라간다 (뒤에서부터 잇는 본 판정 전이라 아직 안 이은 조각끼리 본다). 이어진 조각의 첫 행은 데이터 행이라 거기와\n * 견주면 값이 되풀이되는 표(평가지표표 \"실증을 통한 내부평가\")가 새 표로 잘린다 — 표 첫 행과만 견준다\n */\nfunction chainHead(blocks: IRBlock[], i: number, pageHeights?: Map<number, number>): number {\n let k = i\n for (let steps = 0; steps < RESTART_LOOKBACK_PAGES; steps++) {\n const cur = blocks[k]\n let p = k - 1\n while (p >= 0 && blocks[p].type !== \"table\" && (blocks[p].pageNumber ?? 0) >= (cur.pageNumber ?? 0) - 1) p--\n if (p < 0 || blocks[p].type !== \"table\" || !blocks[p].table || !blocks[p].bbox) break\n // 칸 안 예시 상자처럼 앞 표 테두리 안에 든 표면 그 앞 표로\n for (let q = p - 1; q >= 0 && blocks[q].pageNumber === blocks[p].pageNumber; q--) {\n if (blocks[q].type === \"table\" && blocks[q].bbox && insideTable(blocks[p], blocks[q])) { p = q; break }\n }\n const pb = tailOf(blocks[p])\n if (pb.pageNumber !== (cur.pageNumber ?? 0) - 1) break\n if (!blocks.slice(p + 1, k).every(b => besideOwn(b, pb, cur) || insideTable(b, pb) || pageNumberBetween(b, pb, cur, pageHeights))) break\n if (!looksContinued(pb, cur, pageHeights)) break\n const px = TABLE_COLXS.get(pb.table!), cx = TABLE_COLXS.get(cur.table!)\n if (!px || !cx) break\n const dx = px[px.length - 1] - cx[cx.length - 1]\n if (!shiftedSame(px, cx) && !px.some(x => Math.abs(x - (cx[0] + dx)) <= CONTINUATION_COL_TOL) && !prevLacksLeftCols(pb, px, cx, pageHeights)) break\n k = p\n }\n return k\n}\n\n/** 쪽 넘김 조각 경계에서 끊긴 세로 병합 칸 잇기 — 앞 조각 끝 행까지 걸친 글 있는 병합 원점과 같은 열·폭의 뒤 조각 첫 행 빈 병합 칸 */\nconst ROW_CONTINUATIONS = new WeakMap<IRCell, IRCell>()\n\nfunction continueRowSpans(prevCells: IRCell[][], currCells: IRCell[][]): void {\n const first = currCells[0]\n const last = prevCells.length\n for (let c = 0; c < first.length; c++) {\n const cell = first[c]\n if (hasContent(cell) || (cell.rowSpan || 1) < 2) continue\n let anchor: IRCell | undefined\n for (let r = last - 1; r >= 0 && !anchor; r--) {\n const a = prevCells[r]?.[c]\n if (a && r + (a.rowSpan || 1) === last && (a.colSpan || 1) === (cell.colSpan || 1)\n && (hasContent(a) || a.rowSpan >= 2)) anchor = a\n }\n if (!anchor) continue\n // 역순 이음에서 가운데 쪽의 빈 원점은 아직 이름표를 모른다. 실제 쪽 경계의 조각 관계만 보관하고 이름표를 찾았을 때 잇는다.\n if (!hasContent(anchor)) { ROW_CONTINUATIONS.set(anchor, cell); continue }\n for (let part: IRCell | undefined = cell; part;) {\n const next = ROW_CONTINUATIONS.get(part)\n anchor.rowSpan = (anchor.rowSpan || 1) + part.rowSpan\n // 원점 객체를 유지해야 앞서 보관한 사슬이 끊기지 않는다. 덮인 자리는 격자 자리표만 남긴다.\n part.rowSpan = 1\n part.colSpan = 1\n ROW_CONTINUATIONS.delete(part)\n part = next\n }\n }\n}\n\n/** 첫 행에서 시작한 머리 병합의 전이적 깊이까지, 글과 앵커 모양이 모두 같을 때만 머리를 통째로 뺀다. -1은 다른 머리. */\nfunction repeatedHeaderRows(prev: IRTable, curr: IRTable): number {\n if (!rowTextsEqual(prev.cells[0], curr.cells[0])) return 0\n const pa = anchorsOf(prev), ca = anchorsOf(curr)\n let depth = 1\n for (let r = 0; r < depth && r < prev.rows; r++) {\n for (const a of pa) if (a.r === r) depth = Math.max(depth, r + a.rs)\n }\n if (depth >= curr.rows || depth > prev.rows) return -1\n const p = pa.filter(a => a.r < depth), d = ca.filter(a => a.r < depth)\n const norm = (text: string) => flowText(text).replace(/\\s+/g, \"\")\n return p.length === d.length && p.every((a, i) => a.r === d[i].r && a.c === d[i].c && a.rs === d[i].rs\n && a.cs === d[i].cs && norm(a.cell.text) === norm(d[i].cell.text)) ? depth : -1\n}\n\n/** 두 행의 셀 텍스트가 모두 동일한지 (공백 정규화 후 비교) */\nfunction rowTextsEqual(a: IRCell[], b: IRCell[]): boolean {\n if (a.length !== b.length) return false\n const norm = (t: string) => flowText(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/** 쪽 넘김 이음 판정 — 열 경계가 이 거리(pt) 안에서 전부 맞아야 같은 표 */\nconst CONTINUATION_COL_TOL = 2\n/** 안쪽 열 경계가 앞 조각과 하나도 안 맞물리는 뒤 조각이 새 표 — 첫 행 칸이 이만큼 이상 (제 열 짜임으로 시작), 앞 조각도 열이 이만큼\n * 이상. 1열 앞 조각(별표 제목 틀·서식 머리 칸)은 안쪽 경계가 없어 맞물림을 따질 수 없다 (과징금 부과기준 1쪽 4×1 → 2쪽 6×6, HWPX 한 표) */\nconst FOREIGN_FIRST_ROW_CELLS = 3\n\n/** 두 조각의 열 경계가 같은가 — allowShift 면 모든 경계가 같은 거리만큼 옮겨진 것도 같다고 본다 (짝·홀 쪽 대칭 여백) */\nfunction shiftedSame(px: number[], cx: number[], allowShift = true): boolean {\n if (px.length !== cx.length) return false\n const dx = allowShift ? px[px.length - 1] - cx[cx.length - 1] : 0\n return px.every((x, k) => Math.abs(x - cx[k] - dx) <= CONTINUATION_COL_TOL)\n}\n/** 쪽 넘김 조각 판정 — 앞 조각 밑변이 쪽 아래 이 비율 안, 뒤 조각 윗변이 쪽 위 이 비율 안 (본문 여백 안쪽까지 찬 표) */\nconst PAGE_EDGE_BAND = 0.16\n/** 새 표 시작 판정 — 첫 행 앵커가 이만큼 이상인 표만 (칸 짜임이 같다는 게 우연이 아닐 만큼), 사슬 첫 조각은 이 쪽 수까지 거슬러 찾는다 */\nconst RESTART_MIN_ANCHORS = 3\n/** 되풀이 서식의 전폭 제목 칸 글 길이 상한 (공백 뺀 글자) */\nconst TITLE_ROW_MAX_CHARS = 40\nconst RESTART_LOOKBACK_PAGES = 5\n","/**\n * 쪽을 넘는 칸 잇기 (mergeContinuedCells) — 앞 쪽 칸의 이어짐으로 판정된 1칸 조각(CONT_PARTS, clip-cells)을 앞 쪽 표의 그 칸에 붙인다.\n *\n * 한컴 PDF 는 쪽을 넘는 칸을 쪽마다 그 쪽에 그려진 부분만 클립으로 깐다. 여러 칸 행이 넘어가면 뒤 쪽에도 칸 묶음이 생겨 쪽 넘김\n * 잇기(table-parts)가 잇지만, 한 칸만 넘어가면(1열 표의 본문 칸, 규제영향분석서 \"근거설명 | 내용\" 의 내용 칸) 뒤 쪽 조각은 1칸이다.\n * 그 조각을 새 행으로 붙이면 한 칸이 쪽 수만큼 행으로 갈리고, 조각에 든 표·글은 원본(HWPX) 칸 밖에 놓인다. 여기서는 조각의 글과\n * 표를 앞 쪽 칸 뒤에 이어 붙이고, 칸 안에서 쪽 경계로 갈린 표는 쪽 넘김 잇기로 다시 잇는다.\n */\n\nimport type { IRBlock, IRCell, IRTable } from \"../types.js\"\nimport { CELL_LINES, CLIP_TABLES, CONT_PARTS, EMPTY_PARTS, TABLE_COLXS, TABLE_TAIL } from \"./table-meta.js\"\nimport { mergeCrossPageTables } from \"./table-parts.js\"\nimport { joinCellEdges } from \"./cell-edges.js\"\nimport { markContinuedText } from \"./table-pages.js\"\n\n/** 조각의 좌우 변과 앞 표 열 경계를 같은 것으로 보는 거리 (pt) — 격자 열 경계는 클립 좌표 묶음(0.3pt)의 평균 */\nconst COL_MATCH_TOL = 0.5\nconst CONTINUED_TABLE_PAGE_BAND = 0.16\n\n/** 표 마지막 행까지 내려오는 칸 가운데 좌우 변이 x1~x2 인 칸 (열 경계를 모르는 1열 표는 그 칸) */\nfunction lastRowCell(t: IRTable, x1: number, x2: number): IRCell | undefined {\n const xs = TABLE_COLXS.get(t)\n const covered = new Set<number>()\n for (let r = 0; r < t.rows; r++) {\n for (let c = 0; c < t.cols; c++) {\n if (covered.has(r * 100000 + c)) continue\n const cell = t.cells[r]?.[c]\n if (!cell) continue\n for (let dr = 0; dr < cell.rowSpan; dr++) for (let dc = 0; dc < cell.colSpan; dc++) covered.add((r + dr) * 100000 + c + dc)\n if (r + cell.rowSpan !== t.rows) continue\n if (xs ? Math.abs(xs[c] - x1) <= COL_MATCH_TOL && Math.abs(xs[c + cell.colSpan] - x2) <= COL_MATCH_TOL : t.cols === 1) return cell\n }\n }\n return undefined\n}\n\n/** 칸 내용을 블록으로 — blocks 가 없으면 text 줄마다 문단 (틀 칸 blocks 와 같은 모양) */\nconst cellBlocks = (cell: IRCell, pageNumber?: number): IRBlock[] =>\n cell.blocks ?? cell.text.split(\"\\n\").map(t => t.trim()).filter(Boolean).map(text => ({ type: \"paragraph\" as const, text, pageNumber }))\n\n/** 표 블록들을 거꾸로 훑어 이어짐 조각을 앞 쪽 표 칸에 붙이고 조각은 뺀다 (세 쪽 넘게 이어지면 뒤 조각부터 앞 조각에 모인다) */\nexport function mergeContinuedCells(blocks: IRBlock[], pageHeights?: Map<number, number>): void {\n for (let j = blocks.length - 1; j > 0; j--) {\n const part = blocks[j]\n const from = part.table ? CONT_PARTS.get(part.table) : undefined\n if (!from || !part.table) continue\n // 앞 표 — 사이 글은 앞 쪽 꼬리말·이 쪽 머리말 띠뿐이다 (clip-cells 가 조각을 쪽 첫 내용, 앞 칸을 쪽 마지막 내용으로 골랐다)\n let i = j - 1\n while (i >= 0 && blocks[i].type !== \"table\") i--\n const prev = blocks[i]\n if (i < 0 || !prev.table || prev.pageNumber !== (part.pageNumber ?? 0) - 1) continue\n const cell = lastRowCell(prev.table, from.x1, from.x2)\n const add = part.table.cells[0]?.[0]\n if (!cell || !add) continue\n const at = cell.blocks || add.blocks ? cellBlocks(cell, prev.pageNumber).length : -1\n if (cell.blocks || add.blocks) cell.blocks = [...cellBlocks(cell, prev.pageNumber), ...cellBlocks(add, part.pageNumber)]\n else if (part.pageNumber) markContinuedText(cell, add, part.pageNumber)\n cell.text = [cell.text, add.text].filter(s => s.trim()).join(\"\\n\")\n joinCellEdges(cell, add)\n // 표는 이 조각이 놓인 쪽에서 끝난다 — 다음 쪽 표 조각이 이 칸의 나머지로 시작하면(세 쪽에 걸친 칸) 쪽 넘김 잇기가 여기서 잇는다.\n // 칸 글줄도 이어 붙여 끝줄이 이 조각의 끝줄이 되게 한다 (글 이어짐 판정)\n const lines = CELL_LINES.get(add)\n if (lines?.length) CELL_LINES.set(cell, [...(CELL_LINES.get(cell) ?? []), ...lines])\n if (part.pageNumber && part.bbox) TABLE_TAIL.set(prev.table, TABLE_TAIL.get(part.table) ?? { page: part.pageNumber, y: part.bbox.y, height: part.bbox.height })\n // 앞 쪽 조각이 빈 칸뿐이던 표(쪽 끝에 머리 행만 남은 칸)도 이어진 글을 받았으면 더는 빈 조각이 아니다 — 쪽 넘김 잇기가 버리지 않게\n if (cell.text.trim() || cell.blocks?.length) EMPTY_PARTS.delete(prev.table)\n blocks.splice(j, 1)\n // 칸 안에서 쪽 경계로 갈린 표 (반제품 아이스팩 기준 틀의 2×2 계산 예시: 첫 행만 앞 쪽에 남은 것)\n if (cell.blocks && at > 0) {\n const nextAt = cell.blocks[at]\n fillNestedContinuation(cell.blocks, at)\n if (cell.blocks[at] === nextAt) unwrapContinuedTable(cell.blocks, at, pageHeights)\n }\n if (cell.blocks) mergeCrossPageTables(cell.blocks, pageHeights)\n }\n}\n\n/** 이어진 바깥 칸의 다음 쪽 전체 클립이 1×1 감싸개가 되면 그 안의 표 머리/본문이\n * 서로 다른 깊이에 놓인다. 두 표의 열 경계와 쪽 끝·첫머리 위치가 맞을 때만 감싸개를\n * 풀어 기존 쪽 넘김 표 병합에 넘긴다. 감싸개 안의 뒤 문단·표 순서는 그대로 둔다. */\nfunction unwrapContinuedTable(blocks: IRBlock[], at: number, pageHeights?: Map<number, number>): void {\n const prev = blocks[at - 1], frame = blocks[at]\n if (!prev?.table || !frame?.table || frame.table.rows !== 1 || frame.table.cols !== 1 || !CLIP_TABLES.has(frame.table)) return\n const inner = frame.table.cells[0]?.[0]?.blocks\n const next = inner?.[0]\n if (!inner || !next?.table || !prev.bbox || !next.bbox || !prev.pageNumber || next.pageNumber !== prev.pageNumber + 1\n || !CLIP_TABLES.has(prev.table) || !CLIP_TABLES.has(next.table) || prev.table.cols !== next.table.cols) return\n const px = TABLE_COLXS.get(prev.table), nx = TABLE_COLXS.get(next.table)\n if (!px || !nx || px.length !== nx.length || px.some((x, i) => Math.abs(x - nx[i]) > COL_MATCH_TOL)) return\n const ph = pageHeights?.get(prev.pageNumber), nh = pageHeights?.get(next.pageNumber)\n if (!ph || !nh || prev.bbox.y > ph * CONTINUED_TABLE_PAGE_BAND\n || next.bbox.y + next.bbox.height < nh * (1 - CONTINUED_TABLE_PAGE_BAND)) return\n blocks.splice(at, 1, ...inner)\n}\n\n/** 이미 이어짐으로 확인된 바깥 칸의 쪽 경계에서, 중첩표의 빈 마지막 칸을 같은 폭의 1칸 조각으로 채운다.\n * 빈 칸은 앞 쪽에 테두리만 그려지고 글은 다음 쪽에 시작할 수 있다. 새 행으로 붙이면 유령 빈 행이 남는다.\n * 내용 있는 칸은 새 비고/상자와 구별할 증거가 부족하므로 이 경로에서 합치지 않는다. */\nfunction fillNestedContinuation(blocks: IRBlock[], at: number): void {\n const prev = blocks[at - 1], next = blocks[at]\n const t = prev?.table, u = next?.table\n if (!t || !u || u.rows !== 1 || u.cols !== 1 || !CLIP_TABLES.has(t) || !CLIP_TABLES.has(u)\n || next.pageNumber !== (prev.pageNumber ?? 0) + 1) return\n const xs = TABLE_COLXS.get(u)\n const cell = xs ? lastRowCell(t, xs[0], xs[xs.length - 1]) : undefined\n const add = u.cells[0]?.[0]\n if (!cell || !add || cell.text.trim() || cell.blocks?.length) return\n cell.text = add.text\n if (add.blocks) cell.blocks = add.blocks\n joinCellEdges(cell, add)\n blocks.splice(at, 1)\n}\n","/**\n * 유니코드 없는 기호 글리프 되살리기 — 한컴 PDF 는 ToUnicode 를 못 만든 글리프(자동 글머리표·자동 번호·일부 괄호·칸 채움 글자)를\n * 전부 U+F000 으로 내고(text-clean stripNoUnicodeGlyph 가 지운다), 글자는 글꼴 서브셋의 윤곽으로만 남는다. pdf.js 가 변환한 글꼴\n * (fontExtraProperties 로 쥔 OpenType)에서 그 글리프의 TrueType 윤곽을 읽어 모양이 분명한 기호만 글자로 되살린다.\n * 글 벤치 54문서 F000 글리프 157종을 HWPX 원문 글자와 대조한 실측(2026-10-10): 되살린 263곳이 모두 원문과 같다 — 작은 오른쪽 삼각형 \"▸\" 114·\n * 큰 네모 테두리 \"□\" 67·겹 테두리 모서리(아래 오른쪽 \"》\" 58, 위 왼쪽 \"《\" 9 + 원문에서 문맥을 못 찾은 49 — 한컴 글꼴이 겹화살괄호를 이 꼴로 그린다)·\n * 일곱 점 화살표 \"↓\" 8·큰 삼각형 \"►\" 7. 칸 채움 막대(가는 가로 막대 194곳)는 원문에 없는 글자라 되살리지 않고, 원문자 숫자(① … 112곳)는\n * 숫자를 가려 읽지 못해 그대로 둔다\n */\n\nimport { OPS } from \"pdfjs-dist/legacy/build/pdf.mjs\"\nimport type { PdfTextItem } from \"./text-line.js\"\n\nexport const NO_UNICODE = \"\"\n\ntype Point = [number, number, boolean] // x, y, 곡선 위 점(on-curve)\ninterface Outline { contours: Point[][]; upm: number }\n\nconst u16 = (b: Uint8Array, o: number): number => (b[o] << 8) | b[o + 1]\nconst i16 = (b: Uint8Array, o: number): number => { const v = u16(b, o); return v & 0x8000 ? v - 0x10000 : v }\nconst u32 = (b: Uint8Array, o: number): number => ((b[o] << 24) >>> 0) + (b[o + 1] << 16) + (b[o + 2] << 8) + b[o + 3]\n\n/** OpenType 표 목록 */\nfunction tableDir(b: Uint8Array): Map<string, number> {\n const out = new Map<string, number>()\n if (b.length < 12) return out\n const n = u16(b, 4)\n for (let i = 0; i < n && 12 + 16 * i + 16 <= b.length; i++) {\n const r = 12 + 16 * i\n out.set(String.fromCharCode(b[r], b[r + 1], b[r + 2], b[r + 3]), u32(b, r + 8))\n }\n return out\n}\n\n/** cmap(형식 4·12)에서 글자 코드의 글리프 번호 */\nfunction glyphId(b: Uint8Array, cmap: number, code: number): number {\n const n = u16(b, cmap + 2)\n for (let i = 0; i < n; i++) {\n const off = cmap + u32(b, cmap + 4 + 8 * i + 4), fmt = u16(b, off)\n if (fmt === 4) {\n const segX2 = u16(b, off + 6), ends = off + 14, starts = ends + segX2 + 2, deltas = starts + segX2, ranges = deltas + segX2\n for (let s = 0; s < segX2 / 2; s++) {\n const end = u16(b, ends + 2 * s), start = u16(b, starts + 2 * s)\n if (code < start || code > end) continue\n const delta = i16(b, deltas + 2 * s), ro = u16(b, ranges + 2 * s)\n if (!ro) return (code + delta) & 0xffff\n const g = u16(b, ranges + 2 * s + ro + 2 * (code - start))\n return g ? (g + delta) & 0xffff : -1\n }\n } else if (fmt === 12) {\n const groups = u32(b, off + 12)\n for (let g = 0; g < groups; g++) {\n const r = off + 16 + 12 * g, sc = u32(b, r), ec = u32(b, r + 4)\n if (code >= sc && code <= ec) return u32(b, r + 8) + code - sc\n }\n }\n }\n return -1\n}\n\n/** TrueType 단순 글리프 윤곽 — 합성 글리프·CFF 글꼴은 읽지 않는다 */\nexport function readOutline(b: Uint8Array, code: number): Outline | null {\n const t = tableDir(b)\n const cmap = t.get(\"cmap\"), head = t.get(\"head\"), loca = t.get(\"loca\"), glyf = t.get(\"glyf\")\n if (cmap === undefined || head === undefined || loca === undefined || glyf === undefined) return null\n const gid = glyphId(b, cmap, code)\n if (gid < 0) return null\n const longLoca = i16(b, head + 50) === 1\n const a = longLoca ? u32(b, loca + 4 * gid) : 2 * u16(b, loca + 2 * gid)\n const z = longLoca ? u32(b, loca + 4 * gid + 4) : 2 * u16(b, loca + 2 * gid + 2)\n if (z <= a) return null\n const g = glyf + a, nc = i16(b, g)\n if (nc <= 0 || nc > 8) return null\n const ends: number[] = []\n for (let i = 0; i < nc; i++) ends.push(u16(b, g + 10 + 2 * i))\n const np = ends[nc - 1] + 1\n if (np > 400) return null\n let o = g + 10 + 2 * nc\n o += 2 + u16(b, o)\n const flags: number[] = []\n while (flags.length < np) {\n const f = b[o++]\n flags.push(f)\n if (f & 8) for (let r = b[o++]; r > 0; r--) flags.push(f)\n }\n const xs: number[] = [], ys: number[] = []\n let x = 0, y = 0\n for (const f of flags) { if (f & 2) { const d = b[o++]; x += f & 16 ? d : -d } else if (!(f & 16)) { x += i16(b, o); o += 2 } xs.push(x) }\n for (const f of flags) { if (f & 4) { const d = b[o++]; y += f & 32 ? d : -d } else if (!(f & 32)) { y += i16(b, o); o += 2 } ys.push(y) }\n const contours: Point[][] = []\n let s = 0\n for (const e of ends) { contours.push(xs.slice(s, e + 1).map((px, k) => [px, ys[s + k], !!(flags[s + k] & 1)])); s = e + 1 }\n return { contours, upm: u16(b, head + 18) || 1000 }\n}\n\ninterface Box { x1: number; y1: number; x2: number; y2: number }\nconst boxOf = (pts: Point[]): Box => {\n let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity\n for (const [x, y] of pts) { if (x < x1) x1 = x; if (x > x2) x2 = x; if (y < y1) y1 = y; if (y > y2) y2 = y }\n return { x1, y1, x2, y2 }\n}\n/** 곡선 위 점에서 겹친 점을 뺀 꼭짓점 */\nconst corners = (pts: Point[]): Point[] => {\n const out: Point[] = []\n for (const p of pts) if (p[2] && !out.some(q => Math.abs(q[0] - p[0]) < 2 && Math.abs(q[1] - p[1]) < 2)) out.push(p)\n return out\n}\nconst inside = (a: Box, b: Box): boolean => a.x1 >= b.x1 && a.x2 <= b.x2 && a.y1 >= b.y1 && a.y2 <= b.y2\n\n/** 윤곽 모양 → 기호 (모르면 null) */\nexport function classifyOutline({ contours, upm }: Outline): string | null {\n const boxes = contours.map(boxOf)\n const all = boxOf(contours.flat())\n const w = (all.x2 - all.x1) / upm, h = (all.y2 - all.y1) / upm\n if (w <= 0 || h <= 0) return null\n if (contours.length === 1) {\n const c = corners(contours[0])\n // 삼각형 — 꼭짓점 셋, 한 변이 세로(가로)로 곧고 맞은편 꼭짓점이 가운데 높이(너비)\n if (c.length === 3 && contours[0].every(p => p[2])) {\n const left = c.filter(p => Math.abs(p[0] - all.x1) <= 0.02 * upm), right = c.filter(p => Math.abs(p[0] - all.x2) <= 0.02 * upm)\n const midY = (all.y1 + all.y2) / 2, apexMid = (p: Point) => Math.abs(p[1] - midY) <= 0.15 * (all.y2 - all.y1)\n if (left.length === 2 && right.length === 1 && apexMid(right[0])) return Math.max(w, h) < 0.35 ? \"▸\" : w > h ? \"►\" : \"▶\"\n return null\n }\n // 화살표 — 꼭짓점 일곱(촉 셋 + 대 넷)\n if (c.length === 7 && contours[0].every(p => p[2]) && Math.max(w, h) >= 0.6) {\n const tip = (pick: (p: Point) => number, edge: number) => c.filter(p => Math.abs(pick(p) - edge) <= 0.02 * upm).length === 1\n if (h > w && tip(p => p[1], all.y1)) return \"↓\"\n if (w > h && tip(p => p[0], all.x2)) return \"➡\"\n }\n return null\n }\n if (contours.length === 2) {\n const [a, b] = boxes\n const outer = inside(b, a) ? 0 : inside(a, b) ? 1 : -1\n if (outer < 0) return null\n const ob = boxes[outer], ib = boxes[1 - outer]\n const ow = ob.x2 - ob.x1, oh = ob.y2 - ob.y1\n // 네모 테두리 — 바깥·안쪽 모두 정사각에 가깝고 글자 칸만 하다\n if (Math.min(w, h) >= 0.75 && Math.abs(ow - oh) <= 0.08 * upm && Math.abs((ib.x2 - ib.x1) - (ib.y2 - ib.y1)) <= 0.08 * upm) return \"□\"\n // 겹 테두리 모서리 — 꼭짓점 여섯인 ㄱ자 둘(속 빈 꺾쇠). 위 왼쪽 모서리면 여는 괄호, 아래 오른쪽 모서리면 닫는 괄호\n const oc = corners(contours[outer])\n if (oc.length === 6 && corners(contours[1 - outer]).length === 6 && w <= 0.4 && h >= 0.35) {\n const has = (x: number, y: number) => oc.some(p => Math.abs(p[0] - x) <= 0.02 * upm && Math.abs(p[1] - y) <= 0.02 * upm)\n const topLeft = has(ob.x1, ob.y2), bottomRight = has(ob.x2, ob.y1), topRight = has(ob.x2, ob.y2), bottomLeft = has(ob.x1, ob.y1)\n if (topLeft && !bottomRight && topRight && bottomLeft) return \"《\"\n if (bottomRight && !topLeft && topRight && bottomLeft) return \"》\"\n }\n }\n return null\n}\n\ninterface FontObj { data?: Uint8Array }\n\n/**\n * 쪽 글 아이템의 U+F000 을 그 자리 글리프 모양의 기호로 바꾼다(제자리). 글꼴마다 글리프 흐름(showText)의 F000 글리프를 차례로\n * 아이템 글의 F000 과 짝짓는다 — 두 흐름의 F000 수가 다르면 그 글꼴은 손대지 않는다\n */\nexport function restoreUnmappedGlyphs(items: PdfTextItem[], fnArray: ArrayLike<number>, argsArray: ArrayLike<unknown>,\n fontObj: (loadedName: string) => FontObj | null | undefined): void {\n if (!items.some(it => typeof it.str === \"string\" && it.str.includes(NO_UNICODE))) return\n const queues = new Map<string, string[]>()\n const cache = new Map<string, string | null>()\n const saved: string[] = []\n let font = \"\"\n for (let i = 0; i < fnArray.length; i++) {\n const fn = fnArray[i], args = (argsArray as unknown[][])[i]\n if (fn === OPS.setFont) font = String(args[0])\n else if (fn === OPS.save || fn === OPS.paintFormXObjectBegin) saved.push(font)\n else if (fn === OPS.restore || fn === OPS.paintFormXObjectEnd) font = saved.pop() ?? font\n else if (fn === OPS.showText) {\n for (const g of args[0] as unknown[]) {\n const glyph = g as { unicode?: unknown; fontChar?: unknown } | null\n if (!glyph || glyph.unicode !== NO_UNICODE || typeof glyph.fontChar !== \"string\") continue\n const key = font + \"\\u0000\" + glyph.fontChar\n if (!cache.has(key)) {\n let sym: string | null = null\n try {\n const data = fontObj(font)?.data\n const code = glyph.fontChar.codePointAt(0)\n const outline = data && code !== undefined ? readOutline(data, code) : null\n if (outline) sym = classifyOutline(outline)\n } catch { /* 깨진 글꼴 — 되살리지 않는다 */ }\n cache.set(key, sym)\n }\n let q = queues.get(font)\n if (!q) queues.set(font, q = [])\n q.push(cache.get(key) ?? NO_UNICODE)\n }\n }\n }\n const byFont = new Map<string, PdfTextItem[]>()\n for (const it of items) {\n if (typeof it.str !== \"string\" || !it.str.includes(NO_UNICODE)) continue\n const f = it.fontName ?? \"\"\n let list = byFont.get(f)\n if (!list) byFont.set(f, list = [])\n list.push(it)\n }\n for (const [f, list] of byFont) {\n const q = queues.get(f)\n const want = list.reduce((n, it) => n + (it.str.match(//g)?.length ?? 0), 0)\n if (!q || q.length !== want) continue\n let k = 0\n for (const it of list) it.str = it.str.replace(//g, () => q[k++])\n }\n}\n","/** Selected-page conversion still needs nearby repetition proof for page margins. */\nimport type { IRBlock } from \"../types.js\"\nimport { normalizeItems, filterHiddenText, groupByY, mergeLineSimple, computeBBox, dominantStyle, type PdfTextItem } from \"./text-line.js\"\n\n/** At most eight unselected neighbors per requested page, including alternating heads at either document edge. */\nexport function selectionContextPages(selected: Set<number>, pageCount: number): number[] {\n const out = new Set<number>()\n for (const page of selected) {\n for (let distance = 1; distance <= 4; distance++) {\n for (const neighbor of [page - distance, page + distance]) {\n if (neighbor < 1 || neighbor > pageCount || selected.has(neighbor)) continue\n out.add(neighbor)\n }\n }\n }\n return [...out].sort((a, b) => a - b)\n}\n\n/** Lightweight context only: no graphics, tables, images, OCR, output or progress events. */\nexport function marginContextBlocks(raw: PdfTextItem[], page: number, view: number[]): IRBlock[] {\n const [x1, y1, x2, y2] = view\n const width = x2 - x1, height = y2 - y1\n const items = filterHiddenText(normalizeItems(raw), width, height, x1, y1).visible\n for (const item of items) { item.x -= x1; item.y -= y1 }\n return groupByY(items).flatMap(line => {\n const bbox = computeBBox(line, page)\n if (bbox.y + bbox.height < height * 0.88 && bbox.y > height * 0.12) return []\n return [{ type: \"paragraph\" as const, text: mergeLineSimple(line), pageNumber: page, bbox, style: dominantStyle(line) }]\n })\n}\n","/**\n * 심볼 폰트(Wingdings 등) 글리프 코드 → 유니코드 복원.\n *\n * 한컴 PDF 는 서식의 처리절차 화살표·체크박스를 Wingdings 심볼 폰트로 찍는다. pdfjs 는\n * 심볼릭 폰트의 코드를 Latin-1 문자로 그대로 돌려주므로(0xE8 → \"è\") 마크다운에 깨진\n * 라틴 문자가 남는다 (법제처 별지서식 PDF 실측, v4.12.1). 폰트 실명(commonObjs)이\n * Wingdings 이면 코드를 표준 유니코드로 되돌린다.\n *\n * 코드표는 src/shared/symbol-fonts.ts (HWP/HWPX 심볼 PUA 와 공용 SSOT).\n */\n\nimport type { NormItem } from \"./text-line.js\"\nimport { WINGDINGS } from \"../shared/symbol-fonts.js\"\n\n/** WinAnsi(CP1252) 0x80~0x9F 구간 — pdfjs 가 이미 CP1252 문자로 돌려준 경우 코드 복원 */\nconst WINANSI_REVERSE: Record<number, number> = {\n 0x20AC: 0x80, 0x201A: 0x82, 0x0192: 0x83, 0x201E: 0x84, 0x2026: 0x85, 0x2020: 0x86, 0x2021: 0x87,\n 0x02C6: 0x88, 0x2030: 0x89, 0x0160: 0x8A, 0x2039: 0x8B, 0x0152: 0x8C, 0x017D: 0x8E, 0x2018: 0x91,\n 0x2019: 0x92, 0x201C: 0x93, 0x201D: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97, 0x02DC: 0x98,\n 0x2122: 0x99, 0x0161: 0x9A, 0x203A: 0x9B, 0x0153: 0x9C, 0x017E: 0x9E, 0x0178: 0x9F,\n}\n\n/** 폰트 실명이 심볼 폰트인지 — 서브셋 접두(ABCDEF+)·\"-Regular\" 접미 무관 */\nexport function symbolFontTable(fontName: string | undefined): readonly string[] | undefined {\n if (!fontName) return undefined\n // Wingdings 2·3, Webdings 는 별도 코드표 — 매핑 없이 두면 오히려 잘못된 글자가 되므로 제외\n if (/wingdings(?![\\s-]*[23])/i.test(fontName)) return WINGDINGS\n return undefined\n}\n\n/** 한 문자열을 심볼 코드표로 되돌린다 — 표 밖 코드(제어·공백)는 그대로 */\nexport function remapSymbolText(text: string, table: readonly string[]): string {\n let out = \"\"\n for (const ch of text) {\n let code = ch.codePointAt(0)!\n if (code >= 0x80 && WINANSI_REVERSE[code] !== undefined) code = WINANSI_REVERSE[code]\n if (code >= 0x21 && code <= 0xFF) {\n const mapped = table[code - 0x21]\n out += mapped !== undefined && mapped !== \"\" ? mapped : ch\n } else {\n out += ch\n }\n }\n return out\n}\n\n/**\n * 페이지 텍스트 아이템의 심볼 폰트 글리프를 유니코드로 복원 (제자리).\n * @param resolveFontName pdfjs loadedName(g_d0_f6) → 폰트 실명. 미로드·실패 시 undefined\n * @returns 바뀐 아이템 수\n */\nexport function remapSymbolFontItems(items: NormItem[], resolveFontName: (loadedName: string) => string | undefined): number {\n const cache = new Map<string, readonly string[] | undefined>()\n const marlett = new Map<string, boolean>()\n let changed = 0\n let box: NormItem | null = null\n const drop = new Set<NormItem>()\n for (const it of items) {\n if (!it.fontName || !it.text) continue\n // Windows 양식 선택 상자(Marlett) — 상자 테두리 조각 g·f·e·d·c 를 같은 자리에 겹쳐 찍고 체크는 b (한컴 PDF 양식 개체,\n // form-002 \"gfedc 원천기술형\"). 테두리 첫 조각을 ☐ 로 두고 나머지는 지우며, 같은 자리의 b 는 그 상자를 ☑ 로 바꾼다\n if (!marlett.has(it.fontName)) marlett.set(it.fontName, /marlett/i.test(resolveFontName(it.fontName) ?? \"\"))\n if (marlett.get(it.fontName)) {\n const t = it.text.trim()\n if (t === \"g\") { it.text = \"☐\"; box = it; changed++ }\n else if (/^[c-f]$/.test(t)) { drop.add(it); changed++ }\n else if (t === \"b\" && box && Math.abs(box.x - it.x) <= 1 && Math.abs(box.y - it.y) <= 1) { box.text = \"☑\"; drop.add(it); changed++ }\n continue\n }\n let table = cache.get(it.fontName)\n if (!cache.has(it.fontName)) {\n table = symbolFontTable(resolveFontName(it.fontName))\n cache.set(it.fontName, table)\n }\n if (!table) continue\n const mapped = remapSymbolText(it.text, table)\n if (mapped !== it.text) { it.text = mapped; changed++ }\n }\n if (drop.size) for (let k = items.length - 1; k >= 0; k--) if (drop.has(items[k])) items.splice(k, 1)\n return changed\n}\n","/**\n * 한컴 수식 글꼴(HyhwpEQ) 글 → `$…$` 수식 스팬.\n *\n * 한컴 PDF 는 수식을 수식 글꼴 글자로 찍는데, 숫자·변수·연산자가 사용자 영역(U+E0xx) 전용 코드라 종전엔 뒤 단계가 지워\n * \"함수 의 극댓값이\", \"① ② ③\"(선택지 숫자 소실)이 됐다. 수식 글꼴 글자를 가까운 것끼리 묶고, 코드를 되살리고(수능\n * 모의고사 PDF 4종을 한컴 수식 script·쪽 그림과 대조한 표), 분수 막대(U+E06D) 위·아래 글을 \\frac{}{}, 올라간 글을 ^{} 로\n * 조립해 `$…$` 로 감싼다 — HWPX·HWP5 경로가 같은 수식을 `$…$` 로 내는 것과 같은 모양. 표에 없는 코드는 버린다(종전과 같음).\n * 리터럴 `$` 이스케이프(escapeLiteralDollar) 뒤에 불러야 스팬 기호가 살아남는다.\n */\n\nimport type { NormItem } from \"./text-line.js\"\n\n/** 한컴 수식 글꼴 실명 (서브셋 접두 뗀 이름) */\nconst EQ_FONT_RE = /^(?:Hy)?hwpEQ/i\n/** 분수 막대 */\nconst BAR = 0x6d\n\n/** 그리스 소문자 — U+E09D 부터 α β γ … (θ=E0A4·π=E0AC·σ=E0AE 로 순서 확인) */\nconst GREEK = [\"alpha\", \"beta\", \"gamma\", \"delta\", \"epsilon\", \"zeta\", \"eta\", \"theta\", \"iota\", \"kappa\", \"lambda\", \"mu\", \"nu\", \"xi\", \"o\", \"pi\", \"rho\", \"sigma\", \"tau\", \"upsilon\", \"phi\", \"chi\", \"psi\", \"omega\"]\n/** 수식 글꼴 안의 일반 유니코드 기호 → LaTeX */\nconst PLAIN: Record<string, string> = {\n \"→\": \"\\\\to \", \"≤\": \"\\\\le \", \"≥\": \"\\\\ge \", \"×\": \"\\\\times \", \"·\": \"\\\\cdot \", \"⋅\": \"\\\\cdot \", \"∙\": \"\\\\cdot \", \"∞\": \"\\\\infty \",\n \"−\": \"-\", \"⋯\": \"\\\\cdots \", \"∠\": \"\\\\angle \", \"∴\": \"\\\\therefore \", \"∵\": \"\\\\because \", \"∩\": \"\\\\cap \", \"∪\": \"\\\\cup \",\n \"≠\": \"\\\\ne \", \"°\": \"^{\\\\circ}\", \"⊥\": \"\\\\perp \", \"∏\": \"\\\\prod \", \"±\": \"\\\\pm \", \"∉\": \"\\\\notin \", \"⊂\": \"\\\\subset \", \"⇒\": \"\\\\Rightarrow \",\n}\n/** 기호 코드 (U+E000 + 코드) — 수능 모의고사 PDF 4종 문맥과 한컴 수식 script 로 확인한 것만 */\nconst SYMBOLS: Record<number, string> = {\n 0x44: \"(\", 0x45: \")\", 0x46: \"-\", 0x47: \"=\", 0x48: \"+\", 0x49: \"[\", 0x4a: \"]\", 0x4b: \"\\\\{\", 0x4c: \"\\\\}\", 0x4d: \"|\", 0x4f: \":\",\n 0x52: \",\", 0x53: \".\", 0x55: \"<\", 0x56: \">\", 0x5b: \"\\\\int \", 0x5c: \"\\\\sqrt\", 0x67: \"\\\\sum \",\n}\n\n/** U+E000 + 코드 → 글 (확인한 것만 — 여러 줄 괄호 조각·벡터 화살표 등은 버린다) */\nfunction decodeEq(ch: string): string {\n const cp = ch.codePointAt(0)!\n if (cp < 0xe000 || cp > 0xe0ff) return PLAIN[ch] ?? ch\n const c = cp - 0xe000\n if (c <= 0x19) return String.fromCharCode(0x41 + c) // 이탤릭 A~Z (S_n·X·Y 확인)\n if (c >= 0x34 && c <= 0x3c) return String(c - 0x33) // 1~9\n if (c === 0x3d) return \"0\"\n if (c >= 0xe5 && c <= 0xfe) return String.fromCharCode(0x61 + c - 0xe5) // 이탤릭 a~z\n if (c >= 0x9d && c < 0x9d + GREEK.length) return `\\\\${GREEK[c - 0x9d]} `\n return SYMBOLS[c] ?? \"\"\n}\n\n/** 수식 안 로마자 함수 이름 — 한컴은 똑바로 선 글자(ASCII)로 찍는다 */\nconst FUNC_RE = /^(lim|sin|cos|tan|log|ln|exp|max|min)$/\n\ninterface Tok { text: string; x: number; y: number; w: number; size: number; bar: boolean }\n\n/** 묶음 안 조각 → LaTeX 에 가까운 글. 짧은 막대부터(안쪽 분수 먼저) 위·아래 조각을 분수로, 올라간 작은 조각은 위첨자로 */\nfunction layout(toks: Tok[]): string {\n let rest = toks.filter(t => t.bar || t.text.trim())\n // 근호 — √ 바로 오른쪽에서 시작하는 막대가 윗줄, 그 아래 글이 근호 안\n for (const rt of rest.filter(t => t.text === \"\\\\sqrt\").sort((a, b) => b.x - a.x)) {\n const size = Math.max(rt.size, 1)\n const bar = rest.find(t => t.bar && t.x >= rt.x && t.x <= rt.x + rt.w + 2 && Math.abs(t.y - rt.y) <= size * 1.5)\n if (!bar) continue\n const inner = rest.filter(t => !t.bar && t !== rt && t.x + t.w / 2 > bar.x && t.x + t.w / 2 < bar.x + bar.w && bar.y - t.y > 0 && bar.y - t.y <= size * 1.4)\n const tok: Tok = { text: `\\\\sqrt{${linear(inner)}}`, x: rt.x, w: bar.x + bar.w - rt.x, y: inner[0]?.y ?? rt.y, size: Math.max(rt.size, ...inner.map(t => t.size)), bar: false }\n rest = [...rest.filter(t => t !== rt && t !== bar && !inner.includes(t)), tok]\n }\n const bars = rest.filter(t => t.bar).sort((a, b) => a.w - b.w)\n for (const bar of bars) {\n const inX = (t: Tok) => t !== bar && t.x + t.w / 2 >= bar.x - 1 && t.x + t.w / 2 <= bar.x + bar.w + 1\n const num = rest.filter(t => inX(t) && t.y > bar.y), den = rest.filter(t => inX(t) && t.y < bar.y)\n if (!num.length || !den.length) continue\n const fsize = Math.max(...num.map(t => t.size), ...den.map(t => t.size))\n const frac: Tok = { text: `\\\\frac{${linear(num)}}{${linear(den)}}`, x: bar.x, w: bar.w, y: Math.round(bar.y + fsize * 0.3), size: fsize, bar: false }\n rest = [...rest.filter(t => t !== bar && !num.includes(t) && !den.includes(t)), frac]\n }\n return linear(rest.filter(t => !t.bar))\n}\n\n/** 한 줄로 — x 순, 기준선보다 확실히 올라간 작은 조각은 ^{}, 내려간 작은 조각은 _{} */\nfunction linear(toks: Tok[]): string {\n const sorted = [...toks].sort((a, b) => a.x - b.x)\n if (!sorted.length) return \"\"\n const size = Math.max(...sorted.map(t => t.size))\n const base = sorted.find(t => t.size === size)!.y\n let out = \"\", scr = \"\", kind = \"\"\n const flushScr = () => { if (scr) { out += `${kind}{${scr.trim()}}`; scr = \"\"; kind = \"\" } }\n for (const t of sorted) {\n const small = t.size < size * 0.9\n const k = small && t.y > base + size * 0.2 ? \"^\" : (small && t.y < base - size * 0.1) || t.y < base - size * 0.3 ? \"_\" : \"\"\n if (k) { if (kind && kind !== k) flushScr(); kind = k; scr += t.text; continue }\n flushScr()\n out += (FUNC_RE.test(t.text) ? `\\\\${t.text} ` : t.text)\n }\n flushScr()\n return out.replace(/\\s+/g, \" \").trim()\n}\n\nconst isBarItem = (it: NormItem) => it.text.length === 1 && it.text.codePointAt(0) === 0xe000 + BAR\n\n/** 조각 글자 크기 — 분수 막대는 가로로 늘려 찍어 글자 크기가 막대 길이만큼 커진다(47pt 막대 → 95). 크기 판단에서 뺀다 */\nconst sizeOf = (it: NormItem) => (isBarItem(it) ? 0 : it.fontSize)\n\n/** 두 수식 조각이 한 수식인가 — 같은 줄(첨자 포함)로 붙어 있거나, 분수 막대 위·아래에 놓였다 */\nfunction near(a: NormItem, b: NormItem): boolean {\n const size = Math.max(sizeOf(a), sizeOf(b), 1)\n const gap = Math.max(a.x, b.x) - Math.min(a.x + a.w, b.x + b.w)\n const dy = Math.abs(a.y - b.y)\n if (gap <= size * 0.8 && dy <= size * 0.6) return true\n // 분수 막대 — 막대 폭 안에 가운데가 놓이고 막대 바로 위·아래(글자 크기 1.2배 안)인 조각만 분자·분모\n for (const [bar, o] of [[a, b], [b, a]] as const) {\n if (!isBarItem(bar) || isBarItem(o)) continue\n const cx = o.x + o.w / 2\n if (cx > bar.x && cx < bar.x + bar.w && dy > 0 && dy <= size * 1.2) return true\n }\n return false\n}\n\nexport function wrapEquationRuns(items: NormItem[], faceName: (fontName: string) => string | undefined): number {\n const eqIdx: number[] = []\n items.forEach((it, i) => { if (it.text.trim() && EQ_FONT_RE.test(faceName(it.fontName) ?? \"\")) eqIdx.push(i) })\n if (!eqIdx.length) return 0\n // 근접 union-find — 목록은 줄 정렬이라 분자·분모·첨자가 멀리 떨어져 들어온다\n const parent = eqIdx.map((_, k) => k)\n const find = (k: number): number => { while (parent[k] !== k) { parent[k] = parent[parent[k]]; k = parent[k] } return k }\n for (let a = 0; a < eqIdx.length; a++) {\n for (let b = a + 1; b < eqIdx.length; b++) {\n const A = items[eqIdx[a]], B = items[eqIdx[b]]\n if (A.y - B.y > Math.max(sizeOf(A), sizeOf(B), 12) * 3) break // y 내림차순 — 멀리 내려가면 그만\n if (near(A, B)) parent[find(a)] = find(b)\n }\n }\n const clusters = new Map<number, number[]>()\n eqIdx.forEach((idx, k) => { const r = find(k); const arr = clusters.get(r); if (arr) arr.push(idx); else clusters.set(r, [idx]) })\n const drop = new Set<number>()\n const added: NormItem[] = []\n for (const members of clusters.values()) {\n const cur = members.map(i => items[i])\n for (const i of members) drop.add(i)\n const toks: Tok[] = cur.map(it => {\n const bar = isBarItem(it)\n const text = bar ? \"\" : FUNC_RE.test(it.text.trim()) ? it.text.trim() : [...it.text].map(decodeEq).join(\"\")\n return { text, x: it.x, y: it.y, w: it.w, size: sizeOf(it), bar }\n })\n const tex = layout(toks)\n if (!tex) continue // 되살릴 글이 없는 조각(표에 없는 코드) — 종전처럼 버린다\n // 줄 위치 — 본문 크기 조각이 가장 많이 놓인 기준선. 분수뿐인 덩어리는 막대 높이(분자·분모 가운데)에 둔다\n const size = Math.max(...cur.map(sizeOf))\n const mains = cur.filter(i => !isBarItem(i) && i.fontSize === size)\n const ys = new Map<number, number>()\n for (const i of mains) ys.set(i.y, (ys.get(i.y) ?? 0) + 1)\n const top = [...ys].sort((p, q) => q[1] - p[1])\n const bar = cur.find(isBarItem)\n const lineY = bar && top.length > 1 && top[0][1] === top[1][1] ? Math.round(bar.y + size * 0.3) : top[0]?.[0] ?? cur[0].y\n const base = { ...(mains[0] ?? cur[0]), y: lineY, fontSize: size || cur[0].fontSize }\n const x1 = Math.min(...cur.map(i => i.x)), x2 = Math.max(...cur.map(i => i.x + i.w))\n added.push({ ...base, text: `$${tex}$`, x: x1, w: x2 - x1, hasSpaceBefore: true })\n }\n const kept = items.filter((_, i) => !drop.has(i))\n items.length = 0\n items.push(...kept, ...added)\n items.sort((a, b) => b.y - a.y || a.x - b.x)\n return added.length\n}\n","/**\n * 글리프 이름으로 글자 복원 — ToUnicode 없이 Custom 인코딩 /Differences 에 글리프 이름만 둔 글꼴.\n *\n * 조판 프로그램(InDesign 등)은 옛 숫자·작은 대문자·합자를 \"seven.oldstyle\"·\"c.sc\"·\"f_l\" 같은 이름으로 싣는다.\n * pdfjs 는 이런 이름을 표준 글리프 목록에서 못 찾아 코드값을 그대로(제어 문자) 돌려주고, 제어 문자는 뒤에서 지워져\n * 숫자가 통째로 사라진다(\"May 1965\" → \"May .\"). Adobe 글리프 목록 규칙대로 첫 \".\" 뒤 접미를 떼고 \"_\" 합자를 나눠\n * 이름을 글자로 바꾼다. 작은 대문자(.sc·.smcp·.c2sc)는 지면에 대문자로 보이므로 대문자로 낸다.\n */\n\nimport { OPS, normalizeUnicode } from \"pdfjs-dist/legacy/build/pdf.mjs\"\nimport type { NormItem, PdfTextItem } from \"./text-line.js\"\n\nconst NAMED: Record<string, string> = {\n zero: \"0\", one: \"1\", two: \"2\", three: \"3\", four: \"4\", five: \"5\", six: \"6\", seven: \"7\", eight: \"8\", nine: \"9\",\n period: \".\", comma: \",\", colon: \":\", semicolon: \";\", hyphen: \"-\", endash: \"–\", emdash: \"—\", space: \" \",\n parenleft: \"(\", parenright: \")\", bracketleft: \"[\", bracketright: \"]\", slash: \"/\", ampersand: \"&\",\n quoteleft: \"‘\", quoteright: \"’\", quotedblleft: \"“\", quotedblright: \"”\", quotesingle: \"'\", quotedbl: \"\\\"\",\n bullet: \"•\", openbullet: \"◦\", whitebullet: \"◦\",\n question: \"?\", exclam: \"!\", percent: \"%\", dollar: \"$\", numbersign: \"#\", asterisk: \"*\", plus: \"+\", equal: \"=\",\n}\n\n/** 글리프 이름 → 글자 (모르는 이름은 undefined) */\nexport function glyphNameText(name: string): string | undefined {\n const dot = name.indexOf(\".\")\n const base = dot > 0 ? name.slice(0, dot) : name\n const smallCaps = dot > 0 && /^(?:sc|smcp|c2sc)$/.test(name.slice(dot + 1))\n let out = \"\"\n for (const part of base.split(\"_\")) {\n const uni = /^uni([0-9A-F]{4})$/.exec(part) ?? /^u([0-9A-F]{4,6})$/.exec(part)\n const code = uni ? parseInt(uni[1], 16) : undefined\n if (code !== undefined && (code > 0x10ffff || code >= 0xd800 && code <= 0xdfff)) return undefined\n const ch = /^[A-Za-z]$/.test(part) ? part : NAMED[part] ?? (code !== undefined ? String.fromCodePoint(code) : undefined)\n if (ch === undefined) return undefined\n out += ch\n }\n return smallCaps ? out.toUpperCase() : out\n}\n\n/** 제어 문자로 남은 코드(탭·줄바꿈 제외)를 글꼴 /Differences 의 글리프 이름으로 되살린다 (제자리). 바뀐 아이템 수 */\nexport function remapControlGlyphs(items: NormItem[], differencesOf: (loadedName: string) => ArrayLike<string | undefined> | undefined): number {\n let changed = 0\n for (const it of items) {\n if (!it.fontName || !/[\\u0001-\\u0008\\u000B\\u000C\\u000E-\\u001F]/.test(it.text)) continue\n const diffs = differencesOf(it.fontName)\n if (!diffs) continue\n let out = \"\"\n for (const ch of it.text) {\n const code = ch.charCodeAt(0)\n const name = code < 0x20 && code !== 9 && code !== 10 && code !== 13 ? diffs[code] : undefined\n out += (name && glyphNameText(name)) ?? ch\n }\n if (out !== it.text) { it.text = out; changed++ }\n }\n return changed\n}\n\nconst isSmallCapName = (name: string) => /^[^.]+\\.(?:sc|smcp|c2sc)$/.test(name)\n/** 글자 대신 공백·빈 글·제어 문자로 나오는 코드 — 탭·줄바꿈(9·10·13)과 C1(0x80~0x9F)도 여기 든다 */\nconst isRestorableCode = (code: number) => code < 0x20 || (code >= 0x80 && code <= 0x9f)\n\ninterface OpGlyph { originalCharCode?: number; unicode?: string }\n\n/**\n * TeX CM 수식 글꼴을 다시 매긴 하위 글꼴(Springer 계열 조판, ODL 028~031) — 이름·코드가 가리키는 글자가 아니라 원래 기호다.\n * Symbols 는 라틴-1 이름에 기호를 얹었고(thorn \"+\", onequarter \"=\", eth \"(\", Thorn \")\", onehalf \"[\", C138 \"]\" — 정답 문맥 대조)\n * \"C숫자\" 이름은 TeX cmsy 원래 코드(0 −, 1 ·, 2 ×, 6 ±, 24 ∼). Italic 은 cmmi 인코딩이라 0x3A 가 \".\", 0x3D 가 \"/\".\n */\nconst TEX_CM_SYMBOL_NAMES: Record<string, string> = {\n thorn: \"+\", onequarter: \"=\", eth: \"(\", Thorn: \")\", onehalf: \"[\", C138: \"]\", C0: \"−\", C1: \"·\", C2: \"×\", C6: \"±\", C24: \"∼\",\n}\nconst TEX_CM_ITALIC_CODES: Record<number, string> = { 58: \".\", 61: \"/\" }\nconst texCmGlyph = (face: string | undefined, code: number, name: string | undefined): string | undefined =>\n !face ? undefined\n : /TeXCMMathsSymbols/.test(face) ? (name ? TEX_CM_SYMBOL_NAMES[name] : undefined)\n : /TeXCMMathsItalic/.test(face) ? TEX_CM_ITALIC_CODES[code] : undefined\n\n/**\n * 글리프 이름으로만 알 수 있는 글자를 pdfjs 텍스트 아이템에 되살린다 (normalizeItems 전, 제자리). 바뀐 아이템 수.\n *\n * pdfjs 텍스트 아이템에는 유니코드 글만 남아 글리프 이름이 사라진다. 두 경우가 그 이름을 봐야 풀린다:\n * - 작은 대문자: Brill-Roman 은 /h.smcp·/B.c2sc 를 ToUnicode 로 소문자에 매겨 지면의 \"H. HUMPHREY\" 가\n * \"h. humphrey\" 로 나온다(ODL 010·013). 소문자 글리프와 글이 같아 아이템만으로는 못 가른다.\n * - 공백·빈 글로 사라지는 코드: GaramondPremrPro(ToUnicode 없음)는 `9 /nine.oldstyle`·`129 /F.a` 라 pdfjs 가\n * 9 를 탭(→ 공백), 129 를 빈 글로 돌려 \"May 1965\" → \"May 1 65\", \"FIGURE\" → \"IGURE\" (ODL 005·006).\n * remapControlGlyphs 는 공백 정리 뒤의 제어 문자만 보므로 여기서 먼저 푼다.\n * 연산자 목록의 showText 글리프(originalCharCode)를 글꼴마다 콘텐츠 순서로 텍스트 아이템 글자에 맞춰 짚는다.\n * pdfjs 가 틈에 넣은 공백은 건너뛰고, 한 글꼴이라도 맞춤이 어긋나면 그 글꼴은 손대지 않는다.\n * 작은 대문자·되살릴 코드 이름이 /Differences 에 있는 글꼴만 — 한컴 PDF(CID 글꼴)는 대상이 없다.\n */\nexport function restoreNamedGlyphs(\n items: PdfTextItem[], fnArray: ArrayLike<number>, argsArray: ArrayLike<unknown>,\n differencesOf: (loadedName: string) => ArrayLike<string | undefined> | undefined,\n faceOf: (loadedName: string) => string | undefined = () => undefined,\n): number {\n const targets = new Map<string, ArrayLike<string | undefined> | null>()\n for (const it of items) {\n if (!it.fontName || targets.has(it.fontName)) continue\n const diffs = differencesOf(it.fontName)\n if (/TeXCMMaths(?:Symbols|Italic)/.test(faceOf(it.fontName) ?? \"\")) { targets.set(it.fontName, diffs ?? []); continue }\n let hit = false\n if (diffs) for (let c = 0; c < diffs.length && !hit; c++) {\n const name = diffs[c]\n hit = !!name && (isSmallCapName(name) || (isRestorableCode(c) && glyphNameText(name) !== undefined))\n }\n targets.set(it.fontName, hit ? diffs! : null)\n }\n if (![...targets.values()].some(Boolean)) return 0\n\n // 글꼴별 글리프 흐름 — 폼 XObject·q/Q 가 글꼴을 되돌리므로 저장 스택을 따른다\n const streams = new Map<string, OpGlyph[]>()\n const saved: string[] = []\n let font = \"\"\n for (let i = 0; i < fnArray.length; i++) {\n const fn = fnArray[i]\n const args = (argsArray as unknown[][])[i]\n if (fn === OPS.setFont) font = String(args[0])\n else if (fn === OPS.save || fn === OPS.paintFormXObjectBegin) saved.push(font)\n else if (fn === OPS.restore || fn === OPS.paintFormXObjectEnd) font = saved.pop() ?? font\n else if (fn === OPS.showText && targets.get(font)) {\n let list = streams.get(font)\n if (!list) streams.set(font, list = [])\n for (const g of args[0] as unknown[]) if (g && typeof g === \"object\") list.push(g as OpGlyph)\n }\n }\n\n const failed = new Set<string>()\n const outs = new Map<string, { item: PdfTextItem; out: string }[]>()\n const cursor = new Map<string, number>()\n for (const it of items) {\n const fontName = it.fontName ?? \"\"\n const diffs = targets.get(fontName)\n if (!diffs || failed.has(fontName) || typeof it.str !== \"string\") continue\n const glyphs = streams.get(fontName) ?? []\n const done = outs.get(fontName) ?? []\n outs.set(fontName, done)\n const s = it.str\n let gi = cursor.get(fontName) ?? 0, k = 0, out = \"\"\n while (k < s.length) {\n const g = glyphs[gi]\n // 글리프가 다 떨어진 뒤 pdfjs 가 틈에 넣은 공백 아이템(\" \")은 짝이 없어도 된다(ODL 031 수식 글꼴)\n if (!g) { if (/^\\s*$/.test(s.slice(k))) { out += s.slice(k); break } failed.add(fontName); break }\n const code = g.originalCharCode ?? -1\n const name = diffs[code]\n const raw = g.unicode ?? \"\"\n const exp = /^\\p{Cf}$/u.test(raw) ? \"\" : normalizeUnicode(raw)\n // 되살릴 코드: 글꼴이 이름을 붙였는데 pdfjs 가 공백·빈 글·제어 문자로 돌려준 것\n // ToUnicode 가 대체 문자(U+FFFD)로 매긴 코드도 이름으로 되살린다 — \"one.SP\"·\"two.SP\" 쪽번호·그림 번호 숫자(ODL 001·015)\n const tex = texCmGlyph(faceOf(fontName), code, name)\n if (tex !== undefined && (exp === \"\" || s.startsWith(exp, k))) { out += tex; k += exp.length; gi++; continue }\n const named = name && isRestorableCode(code) && (exp === \"\" || /^[\\s\\u0000-\\u001f\\u0080-\\u009f\\ufffd]$/.test(exp)) ? glyphNameText(name) : undefined\n if (/^\\s/.test(exp)) {\n // 공백 글리프는 다음 글자 앞 \" \" 로 나오거나 아무것도 안 남긴다. 아이템 첫머리에서 짝이 없으면 앞 아이템 끝에 그린 글자\n // (ODL 006 쪽번호 \"19\": \"1\" 아이템 뒤 9 가 아무것도 안 남기고 다음 줄 아이템이 이어진다)\n if (s[k] === \" \") { out += named ?? \" \"; k++ }\n else if (named) {\n const prev = k === 0 ? [...done].reverse().find(d => d.out !== \"\") : undefined\n if (prev) prev.out += named\n else out += named\n }\n gi++\n continue\n }\n if (exp === \"\") { out += named ?? \"\"; gi++; continue }\n if (s.startsWith(exp, k)) {\n out += named ?? (name && isSmallCapName(name) ? exp.toUpperCase() : exp)\n k += exp.length; gi++\n continue\n }\n if (s[k] === \" \") { out += \" \"; k++; continue } // pdfjs 가 글자 틈에 넣은 공백\n // 쪽 밖이라 pdfjs 가 버린 글리프 — 조금 앞에서 맞으면 건너뛴다\n let j = gi + 1\n while (j < glyphs.length && j <= gi + 8 && !s.startsWith(normalizeUnicode(glyphs[j].unicode ?? \"\") || \"\\u0000\", k)) j++\n if (j < glyphs.length && j <= gi + 8) { gi = j; continue }\n failed.add(fontName)\n break\n }\n cursor.set(fontName, gi)\n done.push({ item: it, out })\n }\n\n let changed = 0\n for (const [fontName, done] of outs) {\n if (failed.has(fontName)) continue\n for (const { item, out } of done) if (out !== item.str) { item.str = out; changed++ }\n }\n return changed\n}\n","/**\n * 가려진 글 — 먼저 찍은 글 위에 불투명 사각형을 칠하면 그 글은 보이지 않는다. InDesign 계열 보고서는 쪽 머리글·쪽 번호를\n * 찍고 쪽 전체 배경을 덮어 칠한 뒤 본문을 흰 글씨로 찍는다(ODL 079·080 장 첫 쪽).\n *\n * 연산자 목록에서 불투명 사각형 채움마다 그때까지 찍은 글자 수와 채움 영역을 기록하고, 텍스트 아이템을 같은 글자 흐름으로\n * 짚어 채움보다 먼저 찍혔고 영역 안에 드는 아이템을 고른다. 아이템 글과 연산자 글이 그 지점까지 글자 그대로 같을 때만\n * 짚는다(주석 모양·합자로 흐름이 어긋나면 손대지 않는다). pdfjs v4 사각형 경로만 본다.\n */\n\nimport { OPS, normalizeUnicode } from \"pdfjs-dist/legacy/build/pdf.mjs\"\nimport type { PdfTextItem } from \"./text-line.js\"\n\ninterface Rect { x1: number; y1: number; x2: number; y2: number }\n\nconst glyphText = (s: string) => normalizeUnicode(s).replace(/\\s+/g, \"\")\n\nexport function occludedTextItems(items: PdfTextItem[], fnArray: ArrayLike<number>, argsArray: ArrayLike<unknown>): Set<PdfTextItem> {\n const covers: Array<{ at: number; rect: Rect }> = []\n let drawn = \"\"\n let ctm = [1, 0, 0, 1, 0, 0]\n let alpha = 1, normalBlend = true\n let clip: Rect | null | undefined = undefined // undefined = 쪽 전체, null = 사각형 아닌 클립(판단 안 함)\n let path: Rect[] = []\n let pathOk = true\n const stack: Array<{ ctm: number[]; alpha: number; normalBlend: boolean; clip: Rect | null | undefined }> = []\n const box = (x: number, y: number, w: number, h: number): Rect => {\n const xs: number[] = [], ys: number[] = []\n for (const [px, py] of [[x, y], [x + w, y], [x, y + h], [x + w, y + h]]) {\n xs.push(ctm[0] * px + ctm[2] * py + ctm[4]); ys.push(ctm[1] * px + ctm[3] * py + ctm[5])\n }\n return { x1: Math.min(...xs), y1: Math.min(...ys), x2: Math.max(...xs), y2: Math.max(...ys) }\n }\n const meet = (a: Rect, b: Rect): Rect => ({ x1: Math.max(a.x1, b.x1), y1: Math.max(a.y1, b.y1), x2: Math.min(a.x2, b.x2), y2: Math.min(a.y2, b.y2) })\n\n for (let i = 0; i < fnArray.length; i++) {\n const fn = fnArray[i]\n const args = (argsArray as unknown[][])[i]\n if (fn === OPS.save || fn === OPS.paintFormXObjectBegin) {\n stack.push({ ctm: ctm.slice(), alpha, normalBlend, clip })\n const m = fn === OPS.paintFormXObjectBegin ? (args as unknown[])[0] : null\n if (Array.isArray(m) && m.length >= 6) ctm = mul(ctm, m as number[])\n } else if (fn === OPS.restore || fn === OPS.paintFormXObjectEnd) {\n const s = stack.pop()\n if (s) ({ ctm, alpha, normalBlend, clip } = s)\n } else if (fn === OPS.transform) ctm = mul(ctm, args as number[])\n else if (fn === OPS.setGState) {\n const entries = (args as unknown[])[0]\n if (Array.isArray(entries)) for (const e of entries) {\n if (!Array.isArray(e)) continue\n if (e[0] === \"ca\" && typeof e[1] === \"number\") alpha = e[1]\n else if (e[0] === \"BM\") normalBlend = e[1] === \"source-over\" || e[1] === \"Normal\"\n else if (e[0] === \"SMask\" && e[1]) normalBlend = false\n }\n } else if (fn === OPS.showText) {\n for (const g of (args as unknown[])[0] as unknown[]) {\n if (g && typeof g === \"object\" && typeof (g as { unicode?: unknown }).unicode === \"string\") drawn += glyphText((g as { unicode: string }).unicode)\n }\n } else if (fn === OPS.constructPath) {\n const sub = (args as unknown[])[0]\n const coords = (args as unknown[])[1] as number[]\n if (!Array.isArray(sub)) { pathOk = false; continue }\n let ci = 0\n for (const op of sub as number[]) {\n if (op === OPS.rectangle) { path.push(box(coords[ci], coords[ci + 1], coords[ci + 2], coords[ci + 3])); ci += 4 }\n else { pathOk = false; ci += op === OPS.curveTo ? 6 : op === OPS.curveTo2 || op === OPS.curveTo3 ? 4 : op === OPS.closePath ? 0 : 2 }\n }\n } else if (fn === OPS.clip || fn === OPS.eoClip) {\n clip = clip === null || !pathOk || path.length !== 1 ? null : clip ? meet(clip, path[0]) : path[0]\n } else if (fn === OPS.fill || fn === OPS.eoFill || fn === OPS.fillStroke || fn === OPS.eoFillStroke || fn === OPS.endPath ||\n fn === OPS.stroke || fn === OPS.closeStroke || fn === OPS.closeFillStroke || fn === OPS.closeEOFillStroke) {\n const fills = fn !== OPS.endPath && fn !== OPS.stroke && fn !== OPS.closeStroke\n if (fills && pathOk && path.length === 1 && alpha >= 1 && normalBlend && clip !== null && drawn.length > 0) {\n const rect = clip ? meet(path[0], clip) : path[0]\n if (rect.x2 > rect.x1 && rect.y2 > rect.y1) covers.push({ at: drawn.length, rect })\n }\n path = []; pathOk = true\n }\n }\n\n const hidden = new Set<PdfTextItem>()\n if (covers.length === 0) return hidden\n let pos = 0\n for (const it of items) {\n const t = glyphText(it.str ?? \"\")\n const start = pos\n pos += t.length\n if (!t) continue\n // 아이템 글이 연산자 글과 이 지점까지 어긋나면 이후는 짚을 수 없다\n if (drawn.slice(start, pos) !== t) break\n const [a, b, , , e, f] = it.transform\n const size = Math.hypot(a, b) || it.height\n const r: Rect = { x1: e, y1: f, x2: e + it.width, y2: f + size }\n if (covers.some(c => c.at >= pos && r.x1 >= c.rect.x1 - 1 && r.x2 <= c.rect.x2 + 1 && r.y1 >= c.rect.y1 - 1 && r.y2 <= c.rect.y2 + 1)) hidden.add(it)\n }\n return hidden\n}\n\nfunction mul(m: number[], t: number[]): number[] {\n return [\n m[0] * t[0] + m[2] * t[1], m[1] * t[0] + m[3] * t[1],\n m[0] * t[2] + m[2] * t[3], m[1] * t[2] + m[3] * t[3],\n m[0] * t[4] + m[2] * t[5] + m[4], m[1] * t[4] + m[3] * t[5] + m[5],\n ]\n}\n","/**\n * 세로쓰기 글상자 — 한글 글자를 한 자씩 위에서 아래로 쌓은 기둥이 오른쪽에서 왼쪽으로 늘어선 영역(rhwp tbox-v-flow-01).\n * 가로 줄로 읽으면 기둥마다 한 글자씩 섞여 \"오걸그모별나…\"가 된다. 기둥 둘 이상이 윗머리를 맞춰 나란하고 그 영역에 다른\n * 글이 없을 때만 기둥마다 한 줄로 잇고(글자 높이보다 넓게 벌어진 자리는 띄어쓰기), 영역 안에 오른쪽 기둥부터 위→아래로\n * 한 줄씩 놓는다. 표의 세로 머리 칸(\"구⏎분\")은 기둥이 하나라 그대로다.\n */\n\nimport type { NormItem } from \"./text-line.js\"\n\nconst CJK = /^[ㄱ-ㆎ가-힣一-鿿]$/\nconst PUNCT = /^[、。,.,.·ㆍ!?]$/\n\ninterface Column { x: number; items: NormItem[] }\n\nexport function joinVerticalColumns(items: NormItem[]): NormItem[] {\n const singles = items.filter(i => !i.rotated && CJK.test(i.text) && i.fontSize > 0)\n if (singles.length < 8) return items\n // 같은 x(글자 폭의 0.15배 안)의 한 글자들 → 기둥 후보\n const cols: Column[] = []\n for (const it of [...singles].sort((a, b) => a.x - b.x)) {\n const c = cols.find(col => Math.abs(col.x - it.x) <= it.fontSize * 0.15)\n if (c) c.items.push(it)\n else cols.push({ x: it.x, items: [it] })\n }\n // 기둥: 네 글자 이상이 글자 높이 1~2배 간격으로 이어짐\n const columns = cols.filter(c => {\n if (c.items.length < 4) return false\n c.items.sort((a, b) => b.y - a.y)\n const fs = c.items[0].fontSize\n for (let k = 1; k < c.items.length; k++) {\n const step = c.items[k - 1].y - c.items[k].y\n if (step < fs * 0.8 || step > fs * 2) return false\n }\n return true\n }).sort((a, b) => b.x - a.x)\n if (columns.length < 2) return items\n\n // 오른쪽부터 이웃 기둥끼리 묶음: 간격이 글자 폭 1.2~4배(연 사이 빈 기둥 하나), 윗머리가 한 글자 안\n const groups: Column[][] = []\n for (const col of columns) {\n const g = groups[groups.length - 1]\n const last = g?.[g.length - 1]\n const fs = col.items[0].fontSize\n if (last && last.x - col.x >= fs * 1.2 && last.x - col.x <= fs * 4 && Math.abs(last.items[0].y - col.items[0].y) <= fs * 1.2) g.push(col)\n else groups.push([col])\n }\n\n const drop = new Set<NormItem>()\n const add: NormItem[] = []\n for (const g of groups) {\n if (g.length < 2) continue\n const fs = g[0].items[0].fontSize\n // 세로쓰기는 기둥 안 글자 간격이 기둥 사이보다 좁다 — 행마다 \"팀장\"·\"대리\"가 쌓인 표 칸은 행 간격이 글자 간격보다 넓다\n const med = (v: number[]) => v.sort((a, b) => a - b)[v.length >> 1]\n const step = med(g.flatMap(c => c.items.slice(1).map((it, k) => c.items[k].y - it.y)))\n const colGap = med(g.slice(1).map((c, k) => g[k].x - c.x))\n if (step > colGap * 0.8 || step > fs * 1.15) continue\n // 문장이면 기둥 안에 낱말 틈(글자 높이 1.3배 넘는 간격)이 있다 — 지역명·직위를 한 칸에 한 글자씩 쌓은 표 열은 간격이 고르다\n const gapped = g.filter(c => c.items.slice(1).some((it, k) => c.items[k].y - it.y > fs * 1.3)).length\n if (gapped * 2 < g.length) continue\n const x1 = Math.min(...g.map(c => c.x)), x2 = Math.max(...g.map(c => c.x)) + fs\n const top = Math.max(...g.map(c => c.items[0].y)) + fs * 0.2\n const bottom = Math.min(...g.map(c => c.items[c.items.length - 1].y)) - fs * 0.2\n const members = new Set(g.flatMap(c => c.items))\n // 세로 구두점(\"、\")은 기둥 오른쪽으로 비켜 찍힌다 — 가장 가까운 왼쪽 기둥에 넣는다\n const inside = items.filter(i => !members.has(i) && i.x + i.w / 2 >= x1 - 1 && i.x + i.w / 2 <= x2 + 1 && i.y <= top && i.y >= bottom - fs)\n if (inside.some(i => !PUNCT.test(i.text))) continue\n // 기둥 하나가 한 문단이다(시·연 줄) — 새 줄들은 문단 간격으로 벌려 놓되, 그 자리에 다른 글이 있으면 영역 안에 촘촘히\n const spread = fs * 2.2\n const lineH = items.some(i => !members.has(i) && !inside.includes(i) && i.x + i.w > x1 && i.x < x2 && i.y < bottom && i.y >= top - spread * g.length)\n ? (top - bottom) / g.length : spread\n for (const p of inside) {\n const col = [...g].filter(c => c.x <= p.x).sort((a, b) => b.x - a.x)[0]\n if (col) { col.items.push(p); members.add(p) }\n }\n g.forEach((col, k) => {\n col.items.sort((a, b) => b.y - a.y)\n let text = \"\"\n for (let n = 0; n < col.items.length; n++) {\n const it = col.items[n]\n if (n > 0 && col.items[n - 1].y - it.y > fs * 1.3 && !PUNCT.test(it.text)) text += \" \"\n text += it.text\n }\n // 폭은 글자 수의 반 — 글상자 틀(1칸 표) 안에서 칸 폭을 채우고 꺾인 줄로 읽혀 다음 기둥에 이어 붙지 않게\n add.push({ ...col.items[0], text, x: x1, y: top - fs * 0.2 - k * lineH, w: [...text].length * fs * 0.5, h: fs })\n })\n for (const m of members) drop.add(m)\n }\n return drop.size ? [...items.filter(i => !drop.has(i)), ...add] : items\n}\n","/**\n * 자간 벌린 글 — 글자 간격(Tc)을 넓힌 줄은 pdfjs 가 글자마다 공백을 넣어 \"E M A I L R E B E C C A\" 가 되고 진짜 낱말 사이 공백과\n * 구별되지 않는다(ODL 103 \"EMAIL REBECCA.ALLEN@MSJ.EDU FOR MORE INFORMATION\"). 연산자 목록의 글리프 흐름에는 진짜 공백 글리프만\n * 있으니, 한 글자·공백이 번갈아 나오는 아이템을 같은 글꼴의 글리프 흐름으로 다시 짠다. 흐름이 어긋나는 글꼴은 손대지 않는다.\n */\n\nimport { OPS, normalizeUnicode } from \"pdfjs-dist/legacy/build/pdf.mjs\"\nimport type { PdfTextItem } from \"./text-line.js\"\n\n// 한글 균등배분(\"홍 보 지 원 반\")은 normalizeItems·셀 글이 따로 다룬다 — 라틴·숫자·기호만\nconst TRACKED = /^(?:[^\\s\\u1100-\\u11ff\\u3130-\\u318f\\uac00-\\ud7a3] ){3,}[^\\s\\u1100-\\u11ff\\u3130-\\u318f\\uac00-\\ud7a3]$/\n\nexport function restoreTrackedSpacing(items: PdfTextItem[], fnArray: ArrayLike<number>, argsArray: ArrayLike<unknown>): number {\n if (!items.some(it => TRACKED.test(it.str ?? \"\"))) return 0\n // 글꼴별 글리프 글 흐름 (공백 글리프 포함)\n const streams = new Map<string, string[]>()\n const saved: string[] = []\n let font = \"\"\n for (let i = 0; i < fnArray.length; i++) {\n const fn = fnArray[i]\n const args = (argsArray as unknown[][])[i]\n if (fn === OPS.setFont) font = String(args[0])\n else if (fn === OPS.save || fn === OPS.paintFormXObjectBegin) saved.push(font)\n else if (fn === OPS.restore || fn === OPS.paintFormXObjectEnd) font = saved.pop() ?? font\n else if (fn === OPS.showText) {\n let list = streams.get(font)\n if (!list) streams.set(font, list = [])\n for (const g of args[0] as unknown[]) {\n if (g && typeof g === \"object\" && typeof (g as { unicode?: unknown }).unicode === \"string\") list.push(normalizeUnicode((g as { unicode: string }).unicode))\n }\n }\n }\n const cursor = new Map<string, number>()\n const failed = new Set<string>()\n let changed = 0\n for (const it of items) {\n const f = it.fontName ?? \"\"\n const glyphs = streams.get(f)\n if (!glyphs || failed.has(f) || typeof it.str !== \"string\") continue\n const want = it.str.replace(/\\s+/g, \"\")\n if (!want) continue\n let gi = cursor.get(f) ?? 0\n // 아이템 첫 글자에 맞는 글리프까지 공백은 건너뛴다\n while (gi < glyphs.length && /^\\s*$/.test(glyphs[gi])) gi++\n let got = \"\", text = \"\"\n while (gi < glyphs.length && got.length < want.length) {\n const g = glyphs[gi++]\n text += g\n got += g.replace(/\\s+/g, \"\")\n }\n if (got !== want) { failed.add(f); continue }\n cursor.set(f, gi)\n if (TRACKED.test(it.str)) {\n const rebuilt = text.replace(/\\s+/g, \" \").trim()\n if (rebuilt.split(\" \").length < it.str.split(\" \").length) { it.str = rebuilt; changed++ }\n }\n }\n return changed\n}\n\n/**\n * pdfjs 가 글자 틈(TJ 자간 이동·위치 이동)을 보고 만들어 넣은 공백 아이템에 synthetic 표시 — 글리프 흐름에 공백 글리프가 없는 자리다.\n * 균등배분 판정은 \"명시적 공백 글리프\"에서만 run 을 끊어야 하는데(Type3 한 글자씩 배치 + 공백 글리프, \"아침 브리핑\"), 합성 공백까지\n * 경계로 보면 배분 정렬 칸 \"보 [-777.8] 험 [-777.8] 업\" 이 틈마다 끊겨 \"보 험 업\" 으로 남았다(해외직접투자 보도자료).\n * 공백만 든 아이템을 제 글꼴 흐름의 커서 자리 글리프와 맞춘다: 공백 글리프면 진짜, 아니면 합성. 흐름이 어긋나는 글꼴은 손대지 않는다.\n */\nexport function markSyntheticSpaces(items: PdfTextItem[], fnArray: ArrayLike<number>, argsArray: ArrayLike<unknown>): void {\n if (!items.some(it => typeof it.str === \"string\" && it.str.length > 0 && !it.str.trim())) return\n const streams = new Map<string, string[]>()\n const saved: string[] = []\n let font = \"\"\n for (let i = 0; i < fnArray.length; i++) {\n const fn = fnArray[i]\n const args = (argsArray as unknown[][])[i]\n if (fn === OPS.setFont) font = String(args[0])\n else if (fn === OPS.save || fn === OPS.paintFormXObjectBegin) saved.push(font)\n else if (fn === OPS.restore || fn === OPS.paintFormXObjectEnd) font = saved.pop() ?? font\n else if (fn === OPS.showText) {\n let list = streams.get(font)\n if (!list) streams.set(font, list = [])\n for (const g of args[0] as unknown[]) {\n if (g && typeof g === \"object\" && typeof (g as { unicode?: unknown }).unicode === \"string\") list.push(normalizeUnicode((g as { unicode: string }).unicode))\n }\n }\n }\n const cursor = new Map<string, number>()\n const failed = new Set<string>()\n const marks: PdfTextItem[] = []\n for (const it of items) {\n const f = it.fontName ?? \"\"\n const glyphs = streams.get(f)\n if (!glyphs || failed.has(f) || typeof it.str !== \"string\" || !it.str) continue\n let gi = cursor.get(f) ?? 0\n if (!it.str.trim()) {\n if (gi < glyphs.length && /^\\s+$/.test(glyphs[gi])) {\n while (gi < glyphs.length && /^\\s+$/.test(glyphs[gi])) gi++\n cursor.set(f, gi)\n } else marks.push(it)\n continue\n }\n const want = it.str.replace(/\\s+/g, \"\")\n while (gi < glyphs.length && /^\\s*$/.test(glyphs[gi])) gi++\n let got = \"\"\n while (gi < glyphs.length && got.length < want.length) got += glyphs[gi++].replace(/\\s+/g, \"\")\n if (got !== want) { failed.add(f); continue }\n cursor.set(f, gi)\n }\n for (const it of marks) if (!failed.has(it.fontName ?? \"\")) (it as PdfTextItem & { synthetic?: boolean }).synthetic = true\n}\n\n/**\n * 줄 끝 공백 글리프 — 아이템 글 뒤 글리프가 공백 글리프이고 다음 글 아이템이 다른 줄이면 spaceAfter 표시. 한컴 PDF 는 어절마다 뒤\n * 공백 글리프까지 찍어(\"원활한␣\") 어절 경계에서 꺾인 줄 끝에도 공백 글리프가 남고, 어절 가운데서 꺾인 줄 끝(\"업무⏎수행에\")에는 없다.\n * pdfjs 는 줄 끝 공백을 아이템 글에서 지워 이 차이가 글에 안 남는다. hwpx↔pdf 744쌍 줄 잇기 판정 4.4만 곳 실측: 표시된 꺾임 1.2만 곳 중\n * 원문이 붙여 쓴 곳 0. 공백 글리프는 다른 글꼴로 찍히기도 해 글꼴을 가리지 않고 연산 순서 흐름으로 맞춘다 — 글리프 이름 복원 따위가\n * 아이템 글을 바꾸기 전에 불러야 흐름과 글자 그대로 맞고, 어긋나면 그 쪽의 나머지는 손대지 않는다.\n * 줄 끝 공백 글리프를 찍는 쪽(줄 끝 EMIT_MIN_LINES 곳 이상 가운데 EMIT_MIN_RATIO 넘게)이면 공백 없는 줄 끝에 spaceAfter=false 도 단다 —\n * 찍지 않는 제작기(rhwp 렌더·일부 워드 PDF)에서는 공백 없음이 정보가 아니다\n */\nexport function markTrailingSpaceGlyphs(items: PdfTextItem[], fnArray: ArrayLike<number>, argsArray: ArrayLike<unknown>): void {\n const glyphs: string[] = []\n for (let i = 0; i < fnArray.length; i++) {\n if (fnArray[i] !== OPS.showText) continue\n for (const g of (argsArray as unknown[][])[i][0] as unknown[]) {\n if (g && typeof g === \"object\" && typeof (g as { unicode?: unknown }).unicode === \"string\") glyphs.push(normalizeUnicode((g as { unicode: string }).unicode))\n }\n }\n const text = items.filter(it => typeof it.str === \"string\" && it.str.trim())\n const ends: Array<[PdfTextItem, boolean]> = []\n let gi = 0\n for (let k = 0; k < text.length; k++) {\n const it = text[k]\n const want = it.str.replace(/\\s+/g, \"\")\n while (gi < glyphs.length && /^\\s*$/.test(glyphs[gi])) gi++\n let got = \"\"\n while (gi < glyphs.length && got.length < want.length) got += glyphs[gi++].replace(/\\s+/g, \"\")\n if (got !== want) break\n let next = gi\n while (next < glyphs.length && glyphs[next] === \"\") next++\n const after = text[k + 1]\n if (after && Math.abs(after.transform[5] - it.transform[5]) < 3) continue\n ends.push([it, next < glyphs.length && /^\\s+$/.test(glyphs[next])])\n }\n const spaced = ends.filter(([, s]) => s).length\n const emits = ends.length >= EMIT_MIN_LINES && spaced >= ends.length * EMIT_MIN_RATIO\n for (const [it, s] of ends) if (s || emits) (it as PdfTextItem & { spaceAfter?: boolean }).spaceAfter = s\n}\n/** 줄 끝 공백 글리프를 찍는 쪽 판정 — 줄 끝 수 하한과 공백 글리프 비율 (hwpx↔pdf 744쌍 문서별 비율: 찍는 제작기 0.2 이상) */\nconst EMIT_MIN_LINES = 5\nconst EMIT_MIN_RATIO = 0.2\n","/**\n * 미주를 참조 자리로 — 한컴 미주는 본문에 참조 표시(\"문1)\")를 찍고 문서 끝에 같은 표시로 시작하는 미주 본문을 모아 찍는다.\n * HWPX·HWP5 파서는 미주를 참조 자리에 끼워 내므로(notes.ts) PDF 만 해설이 문서 끝에 몰려 순서가 어긋났다(수능 모의고사 해설).\n * 문서 뒤쪽의 미주 머리 블록마다 같은 표시가 앞쪽 본문(표 칸 글 포함)에 딱 한 번 나오고, 그 짝이 셋 이상 본문 순서대로 이어질 때만\n * 미주 묶음(머리 블록부터 다음 머리 전까지)을 참조 블록 바로 뒤로 옮긴다. 목차의 번호·표 번호는 참조에서 빼고,\n * 블록은 합치지 않고 옮기기만 한다(미주 안 표·수식 구조 유지).\n */\n\nimport type { IRBlock } from \"../types.js\"\n\n// 한컴 미주 번호 문1)와 숫자 주석 번호 1)·(1)만 후보로 본다.\n// 표1) 같은 표 번호나 수식 f(1)은 주석 머리가 아니다.\nconst NOTE_MARK = /^\\s*(문\\d{1,5}[))]|\\d{1,5}\\)|\\(\\d{1,5}\\))/\n\nfunction isContentsReference(text: string, at: number): boolean {\n // 목차가 한 표 블록에 통째로 담기기도 한다. 그 표 안의 번호는 본문 참조가 아니다.\n if (/(?:^|\\n)\\s*\\|?\\s*(?:목\\s*차|(?:table of )?contents\\b)/i.test(text.slice(0, 120))) return true\n const lineStart = text.lastIndexOf(\"\\n\", at - 1) + 1\n const lineEnd = text.indexOf(\"\\n\", at)\n const line = text.slice(lineStart, lineEnd < 0 ? text.length : lineEnd)\n return /[·⋯.…]{5,}/.test(line)\n}\n\nfunction blockText(b: IRBlock): string {\n if (b.table) return b.table.cells.flat().map(c => c.text).join(\"\\n\")\n return b.text ?? \"\"\n}\n\nexport function relocateEndnotes(blocks: IRBlock[]): IRBlock[] {\n const texts = blocks.map(blockText)\n /** end 앞 블록에서 mark 가 나오는 블록 번호(한 블록에 두 번이면 두 번) — cap 개까지만 */\n const occurrences = (mark: string, end: number, cap: number): number[] => {\n const out: number[] = []\n for (let j = 0; j < end && out.length < cap; j++) {\n for (let at = texts[j].indexOf(mark); at >= 0 && out.length < cap; at = texts[j].indexOf(mark, at + mark.length)) {\n // 목차 줄·문단 첫머리 번호(숫자 표시)도 등장으로 센다 — 유일성 보호는 그대로 두고, 유일한 등장이 이런 자리면 참조가 아니다(-1)\n const notRef = isContentsReference(texts[j], at) || (!mark.startsWith(\"문\") && !texts[j].slice(0, at).trim())\n out.push(notRef ? -1 : j)\n }\n }\n return out\n }\n const cands: Array<{ idx: number; mark: string; shape: string }> = []\n for (let i = 0; i < blocks.length; i++) {\n const b = blocks[i]\n if (b.type !== \"paragraph\" && b.type !== \"list\" && b.type !== \"heading\") continue\n const m = NOTE_MARK.exec(texts[i])\n if (m) cands.push({ idx: i, mark: m[1], shape: m[1].replace(/\\d+/g, \"#\") })\n }\n const heads: Array<{ idx: number; ref: number; shape: string }> = []\n for (const c of cands) {\n const refs = occurrences(c.mark, c.idx, 2)\n if (refs.length === 1 && refs[0] >= 0) heads.push({ idx: c.idx, ref: refs[0], shape: c.shape })\n }\n // 미주 머리는 한 가지 번호 꼴(\"문#)\")이다 — 미주 안 소항목(\"ⅰ)\"·\"(나)\")이 앞 미주 글과 짝지어져 구간을 끊지 않게 가장 흔한 꼴만\n const shapes = new Map<string, number>()\n for (const h of heads) shapes.set(h.shape, (shapes.get(h.shape) ?? 0) + 1)\n const main = [...shapes].sort((a, b) => b[1] - a[1])[0]?.[0]\n if (!main || !/#/.test(main)) return blocks\n // 문서 끝 쪽으로 이어진 짝 — 참조가 본문 순서대로 늘어나는 마지막 연속 구간\n const lastRun = (hs: typeof heads): typeof heads => {\n let run: typeof heads = []\n for (const h of hs) {\n if (run.length && (h.ref < run[run.length - 1].ref || h.ref >= run[0].idx)) run = []\n run.push(h)\n }\n return run\n }\n const firstRun = lastRun(heads.filter(h => h.shape === main))\n if (firstRun.length < 3) return blocks\n // 선택과목마다 번호가 다시 시작하는 미주(\"문23)\" 이 앞쪽 과목마다 한 번씩) — 미주 구간 앞쪽에 k번 나오는 표시는\n // 구간 안 같은 표시 머리 k개와 차례로 짝짓는다. 개수가 다르면 그 표시는 머리로 보지 않는다\n const tailByMark = new Map<string, number[]>()\n for (const c of cands) if (c.idx >= firstRun[0].idx && c.shape === main) tailByMark.set(c.mark, [...(tailByMark.get(c.mark) ?? []), c.idx])\n const paired: typeof heads = []\n for (const [mark, idxs] of tailByMark) {\n const refs = occurrences(mark, firstRun[0].idx, idxs.length + 1)\n if (refs.length === idxs.length && refs.every(r => r >= 0)) idxs.forEach((idx, k) => paired.push({ idx, ref: refs[k], shape: main }))\n }\n const run = lastRun(paired.sort((a, b) => a.idx - b.idx))\n if (run.length < 3) return blocks\n // 미주 묶음은 번호가 1부터 거의 빈틈없이 이어진다(선택과목마다 앞 번호로 되돌아가는 것 허용, 머리가 본문 줄에 섞여 빠진 몇 개 허용)\n // — 절마다 다시 시작하는 목록의 뒤 토막(\"(7)(8)(9)\", RFP)·마지막 쪽 아래 각주(\"5)~9)\")는 1에서 시작하지 않는다\n const nums = run.map(h => Number(/\\d+/.exec(texts[h.idx])![0]))\n if (nums[0] !== 1 || new Set(nums).size < Math.max(...nums) * 0.8) return blocks\n const start = run[0].idx\n // 구간 안에 짝 없는 머리가 있으면(다른 번호 체계) 손대지 않는다 — 앞 미주에 이어 붙은 본문 문단은 괜찮다\n const groups = run.map((h, k) => ({ ref: h.ref, blocks: blocks.slice(h.idx, k + 1 < run.length ? run[k + 1].idx : blocks.length) }))\n const byRef = new Map<number, IRBlock[]>()\n for (const g of groups) byRef.set(g.ref, [...(byRef.get(g.ref) ?? []), ...g.blocks])\n const out: IRBlock[] = []\n for (let i = 0; i < start; i++) {\n out.push(blocks[i])\n const notes = byRef.get(i)\n if (notes) out.push(...notes)\n }\n return out\n}\n","/**\n * 탭 채움 리더 — 목차 탭의 점선 채움은 가운뎃점으로 찍힌다. 제작기·판에 따라 점마다 따로(SO-SUEOP \"I.소설의 이해 ······ 3\", 점마다\n * 글자 위치 이동), 두세 점씩 조각으로(pcccr 첨부), 한 글자열로(행정업무운영 편람 목차 \"········\") 나온다. 채움선은 글이 아니다 —\n * HWPX·HWP5 파서도 탭 채움을 내지 않는다. 채움은 쪽 번호 앞에 선다: 같은 줄에서 가운뎃점만 든 조각들이 글자 크기보다 좁은 간격으로\n * 이어져 점이 넷 이상이고 오른쪽에 쪽 번호가 따라오면 탭 하나로 바꾼다(자리는 남긴다 — 지우면 쪽 번호가 앞 글에 붙는다, 균등배분 붙임\n * \"1 총 칙 1\" → \"총칙1\"). 쪽 번호 없는 점 줄은 글로 친 구분선이다(DOCX 서식 \"······\" — 원본 글). 마침표 리더(Word·LibreOffice 목차\n * \"....\")는 ODL 정답이 글로 담아 그대로 둔다.\n * 채움 끝 점이 쪽 번호와 한 조각(\"·1\")이거나 첫 점이 제목 조각 끝에 붙은 것(\"…결과(요약) ·\"), 쪽 번호 대신 괄호 쪽 표기(\"(페이지 표기)\",\n * 양식 견본 목차)가 따라오는 것도 같은 채움이다(벼·고추 재배면적조사 목차, 산업기술 과제 계획서 양식)\n */\n\nimport type { NormItem } from \"./text-line.js\"\n\nconst MIN_DOTS = 4\n/** 글 속 채움 — 가운뎃점 넷 이상 뒤 쪽 번호 */\nconst INLINE = /\\s*·{4,}\\s*(?=\\d{1,4}\\s*$)/\nconst PAGE_NO = /^\\d{1,4}$/\nconst DOTS = /^·+$/\n/** 채움 끝 점과 쪽 번호가 한 조각 */\nconst DOTS_PAGE = /^·+(\\d{1,4})$/\n/** 쪽 번호 자리의 괄호 쪽 표기 */\nconst PAREN_PAGE = /^\\(\\s*(?:페이지|쪽)/\n/** 제목 조각 끝에 붙은 채움 — 쪽 번호는 다음 조각 (\"7. 환 경 녹 지 국 ·······\" | \"3\", 여수 업무계획 목차) */\nconst TRAILING = /([^\\s·])\\s*·{4,}\\s*$/\n\nexport function dropTabLeaderDots(items: NormItem[]): NormItem[] {\n if (!items.some(it => it.text.includes(\"·\"))) return items\n const sameLine = (a: NormItem, b: NormItem) => Math.abs(a.y - b.y) <= Math.max(a.fontSize, b.fontSize) * 0.3\n // 채움 오른쪽 같은 줄에 쪽 번호 아이템이 있는가 — 바로 뒤 글이 괄호 쪽 표기여도(점 조각 폭이 점 간격보다 넓어 뒤 글이 마지막 점 오른끝 앞에서\n // 시작하기도 해, 괄호는 마지막 점 시작보다 오른쪽으로 본다)\n const pageNoAfter = (right: number, ref: NormItem) => items.some(it => PAGE_NO.test(it.text.trim()) && sameLine(it, ref) && it.x >= right - 2) ||\n PAREN_PAGE.test(items.filter(it => sameLine(it, ref) && it.x > ref.x && !DOTS.test(it.text.trim())).sort((a, b) => a.x - b.x)\n .slice(0, 2).map(it => it.text.trim()).join(\"\"))\n const drop = new Set<NormItem>()\n const edit = new Map<NormItem, { text: string; right?: number }>()\n for (const it of items) if (!DOTS.test(it.text.trim()) && it.text.includes(\"····\") && INLINE.test(it.text)) edit.set(it, { text: it.text.replace(INLINE, \"\\t\") })\n // 제목 끝 채움은 탭으로 바꾸고 오른끝을 쪽 번호 앞까지 늘린다 — 채움 폭만큼 남은 틈이 공백이 되면 \"제목\\t 3\"\n for (const it of items) {\n if (edit.has(it) || !it.text.includes(\"····\") || !TRAILING.test(it.text)) continue\n const page = items.find(p => PAGE_NO.test(p.text.trim()) && sameLine(p, it) && p.x >= it.x + it.w - 2)\n if (page) edit.set(it, { text: it.text.replace(TRAILING, \"$1\\t\"), right: page.x })\n }\n // 가운뎃점만 든 조각 — 같은 기준선에서 이웃 조각 사이(앞 조각 끝 → 뒤 조각 시작)가 글자 크기의 0.7배 이하면 한 채움.\n // 좌표가 정수로 반올림돼(normalizeItems) 점 하나짜리 조각은 겹치거나 2~5pt 로 흔들린다\n const byLine = new Map<number, NormItem[]>()\n for (const d of items) if (DOTS.test(d.text.trim()) || DOTS_PAGE.test(d.text.trim())) byLine.set(Math.round(d.y * 2), [...(byLine.get(Math.round(d.y * 2)) ?? []), d])\n for (const line of byLine.values()) {\n line.sort((a, b) => a.x - b.x)\n let run: NormItem[] = []\n const flush = () => {\n const last = run[run.length - 1]\n const tailPage = DOTS_PAGE.exec(last.text.trim())?.[1]\n const dots = run.reduce((n, d) => n + d.text.trim().replace(/\\d+$/, \"\").length, 0)\n if (dots >= MIN_DOTS && (tailPage ? run.length > 1 : pageNoAfter(last.x + last.w, last))) {\n const body = tailPage ? run.slice(0, -1) : run\n edit.set(run[0], { text: \"\\t\", right: tailPage ? last.x : last.x + last.w })\n for (const d of body.slice(1)) drop.add(d)\n if (tailPage) edit.set(last, { text: tailPage })\n // 첫 점이 붙은 제목 조각(\"…결과(요약) ·\")은 그 점을 뗀다 — 조각 폭이 뒤 공백까지 잡혀 채움과 겹치기도 한다(\"1. 기 획 실 ·\" 오른끝이 첫 점보다 11pt 뒤)\n const head = items.find(it => it !== run[0] && sameLine(it, run[0]) && /[^\\s·]\\s*·+$/.test(it.text) &&\n it.x < run[0].x && run[0].x - (it.x + it.w) <= run[0].fontSize * 0.7)\n if (head && !edit.has(head)) edit.set(head, { text: head.text.replace(/\\s*·+$/, \"\") })\n }\n run = []\n }\n for (const d of line) {\n const prev = run[run.length - 1]\n if (prev && (DOTS_PAGE.test(prev.text.trim()) || !(d.x > prev.x && d.x - (prev.x + prev.w) <= Math.max(prev.fontSize, d.fontSize) * 0.7))) flush()\n run.push(d)\n }\n if (run.length) flush()\n }\n if (!drop.size && !edit.size) return items\n return items.filter(it => !drop.has(it)).map(it => {\n const e = edit.get(it)\n return e ? { ...it, text: e.text, w: (e.right ?? it.x + it.w) - it.x } : it\n })\n}\n","/**\n * 가로 쪽 두 쪽 모아찍기(2-up) — 서식 두 쪽을 가로 한 면에 나란히 찍은 쪽(행정업무운영 편람 부록 별지 서식)은 왼쪽 쪽을 다 읽고\n * 오른쪽 쪽을 읽는다. 블록은 쪽 위→아래로 서 있어, 오른쪽 서식 표의 윗변이 왼쪽 서식 제목보다 높으면 오른쪽 표가 먼저 나왔다.\n * 가로 쪽이고, 가운데 띠를 가로지르는 블록이 없고(쪽 번호 꼴 제외), 좌우 반쪽 모두 쪽 높이 절반 넘게 내용이 찰 때만 좌 → 우로 다시 선다\n */\n\nimport type { IRBlock } from \"../types.js\"\n\nexport function orderTwoUpPage(blocks: IRBlock[], pageWidth: number, pageHeight: number): IRBlock[] {\n if (pageWidth < pageHeight * 1.2 || blocks.length < 2) return blocks\n const mid = pageWidth / 2, tol = pageWidth * 0.01\n const left: IRBlock[] = [], right: IRBlock[] = [], rest: IRBlock[] = []\n for (const b of blocks) {\n if (!b.bbox) return blocks\n if (b.type === \"paragraph\" && /^[-–—-\\s\\d]+$/.test(b.text ?? \"\")) rest.push(b)\n else if (b.bbox.x + b.bbox.width <= mid + tol) left.push(b)\n else if (b.bbox.x >= mid - tol) right.push(b)\n else return blocks\n }\n const span = (bs: IRBlock[]) => Math.max(...bs.map(b => b.bbox!.y + b.bbox!.height)) - Math.min(...bs.map(b => b.bbox!.y))\n if (!left.length || !right.length || span(left) < pageHeight * 0.5 || span(right) < pageHeight * 0.5) return blocks\n return [...left, ...right, ...rest]\n}\n","/**\n * 각주를 참조 문단으로 — HWPX·HWP5 파서는 각주를 참조가 있는 문단 끝에 \" (주: 6) …)\" 로 끼운다(notes.ts, footnoteText).\n * PDF 는 각주를 쪽 아래에 따로 찍어 본문 순서·어절이 어긋났다(선박 코드 부속서 91개). 쪽 아래 문단이 본문 위첨자 참조 표시와\n * 같은 \"N)\" 로 시작하면 그 문단(과 표시 없이 이어지는 문단)을 참조 문단의 footnoteText 로 옮긴다. 한 문단의 각주 여럿은 \"; \" 로 잇는다.\n * 위첨자 표시 = 앞 글자에 붙어(간격 1.5pt 이하) 작고(0.85배 이하) 기준선이 올라간 \"N)\" 아이템 — 목록 번호 \"1)\" 과 가른다.\n * 각주 띠는 바로 위(20pt 안)에 각주 구분선(왼쪽 여백부터 긋는 짧은 가로선, 한컴·워드 기본)이 있어야 한다 — 쪽 아래에 글로 친\n * 가짜 각주(KS 표준안 \"8) Xerox®는 …\" 문단)는 원본에서도 본문 문단이다. 이어짐 문단은 앞 문단에 줄 높이 1.5배 안으로 붙고\n * 쪽 번호 꼴(\"- 3 -\")이 아닌 것만\n */\n\nimport type { IRBlock } from \"../types.js\"\nimport type { NormItem } from \"./text-line.js\"\n\nconst MARK = /^\\d{1,3}\\)$/\n\nexport interface NoteMark { mark: string; y: number }\n/** 쪽마다 위첨자 참조 표시와 각주 구분선 y */\nexport interface PageNotes { marks: NoteMark[]; seps: number[] }\n\n/** 각주 구분선 후보 — 쪽 아래 60% 안 짧은 가로선(폭 30pt 이상·쪽 폭 45% 이하) */\nexport function footnoteSeparators(horizontals: Array<{ x1: number; x2: number; y1: number }>, pageWidth: number, pageHeight: number): number[] {\n return horizontals.filter(l => l.x2 - l.x1 >= 30 && l.x2 - l.x1 <= pageWidth * 0.45 && l.y1 < pageHeight * 0.6).map(l => l.y1)\n}\n\nexport function superscriptNoteMarks(items: NormItem[]): NoteMark[] {\n const out: NoteMark[] = []\n for (const m of items) {\n if (!MARK.test(m.text)) continue\n const glued = items.some(p => p !== m && Math.abs(p.x + p.w - m.x) <= 1.5 && m.fontSize <= p.fontSize * 0.85 &&\n m.y - p.y >= p.fontSize * 0.1 && m.y - p.y <= p.fontSize * 0.6 && /[\\p{L}\\p{N}.,)'\"’”」』]$/u.test(p.text))\n if (glued && !out.some(o => o.mark === m.text)) out.push({ mark: m.text, y: m.y })\n }\n return out\n}\n\n/** 글 속 참조 표시 자리 — 앞 글자가 공백이 아닌 곳 */\nfunction refAt(text: string, mark: string): boolean {\n for (let at = text.indexOf(mark); at >= 0; at = text.indexOf(mark, at + 1)) if (at > 0 && !/\\s/.test(text[at - 1])) return true\n return false\n}\n\nexport function inlineFootnotes(blocks: IRBlock[], pages: Map<number, PageNotes>): IRBlock[] {\n if (!pages.size) return blocks\n const byPage = new Map<number, IRBlock[]>()\n for (const b of blocks) if (b.pageNumber !== undefined) byPage.set(b.pageNumber, [...(byPage.get(b.pageNumber) ?? []), b])\n const drop = new Set<IRBlock>()\n const isText = (b: IRBlock) => (b.type === \"paragraph\" || b.type === \"list\") && !!b.text\n const top = (b: IRBlock) => b.bbox!.y + b.bbox!.height\n for (const [page, { marks, seps }] of pages) {\n const pb = byPage.get(page)\n if (!pb || !seps.length) continue\n // 표시로 시작하고 참조 표시보다 아래, 구분선 아래에 놓인 문단\n const markOf = (b: IRBlock) => b.bbox ? marks.find(m => {\n const t = b.text!.trimStart()\n return t.startsWith(m.mark) && /^\\s/.test(t.slice(m.mark.length)) && top(b) < m.y && seps.some(y => y >= top(b))\n }) : undefined\n let start = -1\n for (let j = pb.length - 1; j >= 0 && isText(pb[j]); j--) if (markOf(pb[j])) start = j\n if (start < 0) continue\n // 첫 각주 바로 위(20pt 안)에 구분선\n if (!seps.some(y => y >= top(pb[start]) && y - top(pb[start]) <= 20)) continue\n const notes: Array<{ text: string; blocks: IRBlock[]; mark: string }> = []\n let open = true\n for (let j = start; j < pb.length; j++) {\n const b = pb[j], m = markOf(b)\n if (m) { notes.push({ text: b.text!.trim(), blocks: [b], mark: m.mark }); open = true; continue }\n const prev = pb[j - 1], line = (b.style?.fontSize ?? b.bbox?.height ?? 10) * 1.5\n if (open && b.bbox && prev.bbox && prev.bbox.y - top(b) <= line && !/^[-–—\\s\\d]+$/.test(b.text!)) {\n notes[notes.length - 1].text += \" \" + b.text!.trim()\n notes[notes.length - 1].blocks.push(b)\n } else open = false\n }\n const body = pb.slice(0, start)\n for (const n of notes) {\n const host = body.find(b => (b.type === \"paragraph\" || b.type === \"list\" || b.type === \"heading\") && !!b.text && refAt(b.text, n.mark))\n if (!host) continue\n host.footnoteText = host.footnoteText ? `${host.footnoteText}; ${n.text}` : n.text\n for (const b of n.blocks) drop.add(b)\n }\n }\n return drop.size ? blocks.filter(b => !drop.has(b)) : blocks\n}\n","/**\n * PDF 헤딩 강등 — 승격 패스들이 끝난 뒤, 제목이 될 수 없는 역할의 줄을 본문으로 되돌린다.\n *\n * 승격 패스는 글꼴 크기·서체 차이만 보므로 쪽 머리말·꼬리말, 캡션, 번호 붙은 수식 줄,\n * 문장 중간에서 끊긴 줄도 올린다. 여기서는 문자열 정답이 아니라 역할 증거(쪽 가장자리 띠와\n * 쪽번호, 캡션 표지, 수식 번호·관계 기호, 소문자로 시작하는 이어진 문장)로만 판단한다.\n */\n\nimport type { IRBlock } from \"../types.js\"\nimport { TOC_BLOCKS } from \"./table-roles.js\"\n\nconst PAGE_NUMBER = /^(?:\\d{1,4}|[ivxlc]{1,7})$/i\nconst CAPTION = /^(?:Table|Figure|Fig\\.?)\\s*\\d+(?:\\.\\d+)*\\s*[.:]/i\nconst EQUATION_NUMBER = /\\t\\(\\d{1,3}[a-z]?\\)\\s*$/\n/** 별행 수식 — 관계 기호·근호·큰 연산자가 든 줄은 절 제목이 아니다 (#89 \"MultiHead(Q, K, V) = Concat(…)\") */\nconst DISPLAY_MATH = /=|[√∑∏∫∂∇≤≥≈≠∈∀∃]/\n\n/** A running head sits in the outer band of the page with nothing beyond it and\n * spreads its parts to the page edges (tab-separated), usually with a page number. */\nfunction isRunningHead(block: IRBlock, page: IRBlock[], pageHeight: number | undefined): boolean {\n const box = block.bbox, text = block.text?.trim()\n if (!box || !text || !pageHeight) return false\n const top = box.y + box.height >= pageHeight * 0.9\n const bottom = box.y <= pageHeight * 0.1\n if (!top && !bottom) return false\n const others = page.filter(o => o !== block && o.bbox && o.type !== \"image\" && o.type !== \"separator\")\n if (top && others.some(o => o.bbox!.y + o.bbox!.height > box.y + box.height)) return false\n if (bottom && others.some(o => o.bbox!.y < box.y)) return false\n // A title always has content after it: a single line closing the page's bottom band is a footer.\n if (bottom && box.height <= (block.style?.fontSize ?? 0) * 1.6) return true\n if (!text.includes(\"\\t\")) return false\n const parts = text.split(/\\t+/).map(part => part.trim()).filter(Boolean)\n if (parts.length >= 2 && (PAGE_NUMBER.test(parts[0]) || PAGE_NUMBER.test(parts[parts.length - 1]))) return true\n const left = Math.min(...page.filter(o => o.bbox).map(o => o.bbox!.x))\n const right = Math.max(...page.filter(o => o.bbox).map(o => o.bbox!.x + o.bbox!.width))\n return right > left && box.width >= (right - left) * 0.6\n}\n\nfunction unbalancedClose(text: string): boolean {\n return (text.match(/\\)/g)?.length ?? 0) > (text.match(/\\(/g)?.length ?? 0)\n}\n\nexport function demoteNonHeadingRoles(blocks: IRBlock[], pageHeights: Map<number, number>, faceNames?: Map<string, string>): void {\n const byPage = new Map<number, IRBlock[]>()\n for (const block of blocks) {\n const page = byPage.get(block.pageNumber ?? 0) ?? []\n page.push(block)\n byPage.set(block.pageNumber ?? 0, page)\n }\n // The page's prose style (face + size carrying most paragraph text): a sentence-long\n // block set in it has no typographic distinction left to make it a title.\n const bodyStyle = new Map<number, string>()\n const bodySizes = new Map<number, number>()\n for (const [pageNumber, page] of byPage) {\n const chars = new Map<string, number>()\n const sizes = new Map<number, number>()\n let proseChars = 0\n for (const b of page) {\n if ((b.type !== \"paragraph\" && b.type !== \"heading\") || !b.text || !b.style?.fontName || !b.style.fontSize) continue\n const key = `${b.style.fontName}:${b.style.fontSize}`\n chars.set(key, (chars.get(key) ?? 0) + b.text.length)\n if (b.type === \"paragraph\" && b.style.fontName === \"ocr\") {\n sizes.set(b.style.fontSize, (sizes.get(b.style.fontSize) ?? 0) + b.text.length)\n proseChars += b.text.length\n }\n }\n const [key, count] = [...chars].sort((a, b) => b[1] - a[1])[0] ?? []\n if (key && count! >= 300) bodyStyle.set(pageNumber, key)\n if (proseChars >= 300) {\n let cumulative = 0\n for (const [size, count] of [...sizes].sort((a, b) => a[0] - b[0])) {\n cumulative += count\n if (cumulative > proseChars / 2) { bodySizes.set(pageNumber, size); break }\n }\n }\n }\n // A wrapped explanation followed immediately by a same-style statutory reference\n // is prose, even when small-font tables dominate the document's item median.\n const citedProse = new Set<IRBlock>()\n // A sequential, aligned run containing wrapped prose is a numbered body list.\n // Short chapter labels and visibly bold title faces do not supply this evidence.\n const numberedProse = new Set<IRBlock>()\n for (const page of byPage.values()) {\n const numbered = page.filter(b => (b.type === \"heading\" || b.type === \"paragraph\") &&\n /^[①-⑮]\\s*/.test(b.text?.trim() ?? \"\") && b.bbox && b.style?.fontName && b.style.fontSize &&\n !/Bold|Black|Heavy|Semibold/i.test(faceNames?.get(b.style.fontName) ?? \"\"))\n let chain: IRBlock[] = []\n const finish = () => {\n if (chain.length < 3 || !chain.some(b => (b.text?.length ?? 0) >= 40 &&\n b.bbox!.height >= b.style!.fontSize! * 2.4)) return\n for (const b of chain) numberedProse.add(b)\n const first = chain[0], size = first.style!.fontSize!\n for (const b of page) if (/^[○●•◦]\\s/.test(b.text?.trim() ?? \"\") &&\n b.style?.fontName === first.style?.fontName && b.style?.fontSize === size && b.bbox &&\n Math.abs(b.bbox.x - first.bbox!.x) <= size * 0.5 && (b.text?.length ?? 0) >= 15) numberedProse.add(b)\n }\n for (const b of numbered) {\n const prev = chain.at(-1), size = b.style!.fontSize!\n if (!prev || b.text!.trim().charCodeAt(0) !== prev.text!.trim().charCodeAt(0) + 1 ||\n b.style!.fontName !== prev.style!.fontName || size !== prev.style!.fontSize ||\n Math.abs(b.bbox!.x - prev.bbox!.x) > size * 0.5 ||\n prev.bbox!.y - (b.bbox!.y + b.bbox!.height) > size * 8) { finish(); chain = [] }\n chain.push(b)\n }\n finish()\n }\n for (let i = 0; i + 1 < blocks.length; i++) {\n const prose = blocks[i], reference = blocks[i + 1]\n const a = prose.bbox, b = reference.bbox, size = prose.style?.fontSize ?? 0\n const source = reference.text?.replace(/<[^>]+>/g, \"\").trim() ?? \"\"\n if ((prose.type !== \"heading\" && prose.type !== \"paragraph\" && prose.type !== \"list\") ||\n (reference.type !== \"heading\" && reference.type !== \"paragraph\") ||\n (prose.text?.length ?? 0) < 40 || !a || !b || size <= 0 ||\n a.height < size * 2.4 || prose.pageNumber !== reference.pageNumber ||\n prose.style?.fontName !== reference.style?.fontName || prose.style?.fontSize !== reference.style?.fontSize ||\n (Math.abs(a.x - b.x) > size && !(/^[○●•◦]\\s/.test(prose.text ?? \"\") && b.x >= a.x && b.x - a.x <= size * 2)) || a.y - (b.y + b.height) < 0 || a.y - (b.y + b.height) > size ||\n !/^[((].*(?:제\\s*\\d+\\s*조|(?:Article|Section)\\s+\\d+).*[))]$/i.test(source)) continue\n citedProse.add(prose)\n citedProse.add(reference)\n }\n for (let i = 0; i < blocks.length; i++) {\n const block = blocks[i]\n if (block.type !== \"heading\" || !block.text) continue\n const text = block.text.replace(/<[^>]+>/g, \"\").trim()\n const fsz = block.style?.fontSize ?? 0\n // A part title between two runs of contents entries is itself an entry.\n const tocEntry = i > 0 && TOC_BLOCKS.has(blocks[i - 1]) && TOC_BLOCKS.has(blocks[i + 1])\n const page = byPage.get(block.pageNumber ?? 0) ?? []\n const proseStyle = block.style?.fontName && bodyStyle.get(block.pageNumber ?? 0) === `${block.style.fontName}:${block.style.fontSize}` &&\n text.length > 60\n // A bare section number is the first part of the title that follows it.\n const next = blocks[i + 1]\n // 제목 가운데 위에 더 큰 글자로 따로 선 장 번호(\"2\" ⏎ \"The Lost Homeland\")는 따로 선 제목이다(ODL 021)\n const nb = next?.bbox, bb = block.bbox\n const chapterNumber = !!nb && !!bb && fsz >= (next?.style?.fontSize ?? 0) * 1.2 && Math.abs(bb.x - nb.x) > fsz &&\n Math.abs((bb.x + bb.width / 2) - (nb.x + nb.width / 2)) < nb.width * 0.1\n if (/^\\d+(?:\\.\\d+)*\\.?$/.test(text) && next?.type === \"heading\" && next.text && next.pageNumber === block.pageNumber && !chapterNumber) {\n next.text = `${block.text.trim()} ${next.text.trim()}`\n blocks.splice(i--, 1)\n continue\n }\n // 제목이 두 줄로 꺾여 둘째 줄이 소문자 낱말로 시작하면(같은 서체·크기, 바로 아래) 소문자 시작 강등에 걸려 본문으로 떨어진다 — 제목에 잇는다.\n // 줄 간격이 넓어(1.9em) 쌓인 제목 줄 잇기(mergeStackedHeadingLines)를 못 받은 둘째 줄은 아직 제목이다(ODL 199 슬라이드 패널 제목 \"…E2E performance ⏎ evaluation1\").\n // 캡션(\"Table 2: …\") 아래 제목 모양 줄은 캡션에 잇지 않고 따로 강등한다(종전 동작, pdf-structure-roles 테스트 고정)\n if ((next?.type === \"paragraph\" || next?.type === \"heading\" && !CAPTION.test(text)) && next.pageNumber === block.pageNumber && next.text && next.bbox && block.bbox &&\n next.style?.fontName === block.style?.fontName && next.style?.fontSize === block.style?.fontSize && fsz > 0 &&\n /^[a-z]/.test(next.text.trim()) && next.text.trim().length <= 40 && !/[.:;!?]$/.test(next.text.trim()) &&\n block.bbox.y - (next.bbox.y + next.bbox.height) < fsz * 1.2 &&\n Math.abs(next.bbox.x - block.bbox.x) < fsz) {\n block.text = `${block.text.trim()} ${next.text.trim()}`\n block.bbox = { ...block.bbox, y: next.bbox.y, height: block.bbox.y + block.bbox.height - next.bbox.y }\n blocks.splice(i + 1, 1)\n }\n // 두 제목 줄 사이에 다른 서체로 끼운 이음 기호(\"WHY IT IS IMPORTANT ⏎ & ⏎ WHAT YOU CAN DO\")는 한 제목이다\n const after = blocks[i + 2]\n if ((next?.type === \"paragraph\" || next?.type === \"heading\") && /^(?:&|\\+|and|or)$/i.test(next.text?.trim() ?? \"\") &&\n after?.type === \"heading\" && after.text && after.pageNumber === block.pageNumber && next.pageNumber === block.pageNumber &&\n after.style?.fontSize === block.style?.fontSize) {\n block.text = `${block.text.trim()} ${next.text!.trim()} ${after.text.trim()}`\n blocks.splice(i + 1, 2)\n }\n // 본문 크기보다 작아 읽을 수 없는 크기(7.5pt 미만)의 글은 제목이 아니다 — 슬라이드 차트 축 라벨(\"Parsing-F1\" 6pt)\n const tiny = fsz > 0 && fsz < 7.5\n // 같은 줄 왼쪽에 떨어져 놓인 소문자 항목 부호(\"n.\")는 본문 항목의 굵은 도입문이다 — 부호와 합쳐 문단으로\n const prev = blocks[i - 1]\n if (prev?.type === \"paragraph\" && prev.pageNumber === block.pageNumber && /^[a-z]{1,3}[.)]$/.test(prev.text?.trim() ?? \"\") &&\n prev.bbox && block.bbox && Math.abs(prev.bbox.y - block.bbox.y) < 2 && prev.bbox.x + prev.bbox.width <= block.bbox.x) {\n prev.text = `${prev.text!.trim()} ${block.text.trim()}`\n prev.bbox = { ...prev.bbox, width: block.bbox.x + block.bbox.width - prev.bbox.x }\n blocks.splice(i--, 1)\n continue\n }\n // 제목 앞 절 번호가 다른 서체라 따로 떨어진 것 — 같은 줄 왼쪽(\"4 | Al-Sadu Symbols\")이나 바로 위 왼끝 맞춘 큰 번호(\"4⏎Basis Fields\")는 제목의 일부다\n if (prev?.type === \"paragraph\" && prev.pageNumber === block.pageNumber && /^\\d{1,2}(?:\\.\\d{1,2})*\\.?$/.test(prev.text?.trim() ?? \"\") &&\n prev.bbox && block.bbox && fsz > 0 && (\n Math.abs(prev.bbox.y - block.bbox.y) < 2 && prev.bbox.x + prev.bbox.width <= block.bbox.x && block.bbox.x - (prev.bbox.x + prev.bbox.width) < fsz * 3 ||\n Math.abs(prev.bbox.x - block.bbox.x) < 2 && (prev.style?.fontSize ?? 0) >= fsz && prev.bbox.y - (block.bbox.y + block.bbox.height) < fsz * 1.5)) {\n block.text = `${prev.text!.trim()} ${block.text.trim()}`\n blocks.splice(i - 1, 1)\n // 붙인 제목도 아래 강등 판정을 다시 받는다 — 슬라이드 차트 값 라벨 \"82.65\" 가 옆 라벨 \"9\" 를 절 번호로 붙여 글자 없는·작은 글 강등을 건너뛰고 제목으로 남았다(ODL 199)\n i -= 2\n continue\n }\n // OCR 본문 두 배 크기의 표시 제목은 아래에 더 큰 제목이 있어도 작은 머리표가 아니다.\n // 텍스트층의 실제 글자 크기는 이 보정에서 제외한다 — 슬라이드의 작은 주석은 본문 기준이 아니다.\n const box = block.bbox, size = block.style?.fontSize ?? 0\n const kicker = !!box && size > 0 && next?.type === \"heading\" && next.pageNumber === block.pageNumber && !!next.bbox &&\n (block.style?.fontName !== \"ocr\" || size < (bodySizes.get(block.pageNumber ?? 0) ?? Infinity) * 2) &&\n (next.style?.fontSize ?? 0) >= size * 1.3 && box.y - (next.bbox.y + next.bbox.height) <= size * 3 &&\n Math.min(box.x + box.width, next.bbox.x + next.bbox.width) - Math.max(box.x, next.bbox.x) >= Math.min(box.width, next.bbox.width) * 0.5 &&\n !page.some(o => o !== block && o.bbox && o.bbox.y > box.y + box.height && o.type !== \"image\")\n // 제목 바로 아래 본문 크기 기울임 줄(저자·소속 \"Staff of the … Directorate\")은 부제가 아니라 필자 줄이다\n const face = faceNames?.get(block.style?.fontName ?? \"\") ?? \"\"\n const [bodyFace, bodySize] = (bodyStyle.get(block.pageNumber ?? 0) ?? \"\").split(\":\")\n const byline = /Italic|Oblique/i.test(face) && !/Bold|Black|Heavy|Semibold/i.test(face) && !!bodyFace &&\n size <= Number(bodySize) + 0.5 && blocks[i - 1]?.type === \"heading\" && blocks[i - 1].pageNumber === block.pageNumber\n // OCR 로 읽은 제목 후보는 글자 상자 높이만 근거라 인포그래픽 글 조각이 올라온다 — 문장부호로 시작하거나 \"…\"(인식 끊김)가 들었거나,\n // 글자가 둘 미만이거나, 일곱 어절 넘는 문장이면 제목이 아니다 (ODL 141 \". Uploading you … llection\", \"C\")\n const ocrFragment = block.style?.fontName === \"ocr\" && (/^[.,;:…·•-]/.test(text) || text.includes(\"…\") ||\n (text.match(/\\p{L}/gu)?.length ?? 0) < 2 || text.split(/\\s+/).length > 7)\n // OCR 높이는 글자 크기의 근사다. 양쪽 본문 줄과 겹치는 같은 열의 상자는 독립 제목의 크기 증거가 아니다.\n const crowdedOcr = block.style?.fontName === \"ocr\" && !!box &&\n [prev, next].every(o => o?.type === \"paragraph\" && o.pageNumber === block.pageNumber &&\n o.style?.fontName === \"ocr\" && !!o.bbox && (o.text?.length ?? 0) >= 15 &&\n Math.abs(o.bbox.x - box.x) <= size * 0.25 &&\n Math.min(o.bbox.y + o.bbox.height, box.y + box.height) - Math.max(o.bbox.y, box.y) >= o.bbox.height * 0.2) &&\n prev!.bbox!.y > box.y && next!.bbox!.y < box.y\n if (numberedProse.has(block) || citedProse.has(block) || ocrFragment || crowdedOcr || tocEntry || proseStyle || kicker || byline || tiny || !/\\p{L}/u.test(text) && !chapterNumber || /^[a-z]/.test(text) || CAPTION.test(text) || EQUATION_NUMBER.test(block.text) || DISPLAY_MATH.test(text) ||\n // 닫는 괄호가 여는 괄호보다 많으면 앞 줄에서 이어진 문장 조각이다 (\"Fact-checking) and is used …\") — \"1)\"·\"가)\" 앞머리 번호는 빼고 센다\n unbalancedClose(text.replace(/^\\s*[\\dA-Za-z가-힣ⅰ-ⅹ]{1,3}\\)\\s*/, \"\")) ||\n isRunningHead(block, page, pageHeights.get(block.pageNumber ?? 0))) {\n block.type = \"paragraph\"\n block.level = undefined\n }\n }\n}\n","/**\n * PDF 마크다운 최종 정리.\n *\n * 페이지 번호 제거, 균등배분 후처리, 취소선 복원, 한글 줄바꿈 병합.\n * blocksToMarkdown 이후의 문자열 수준 후처리를 담당한다.\n */\n\nimport { latinSoftWrap } from \"./cell-text.js\"\nimport type { IRBlock, IRTable } from \"../types.js\"\nimport { stripControlChars } from \"./quality.js\"\nimport { collapseEvenSpacing } from \"./text-line.js\"\nimport { wrapJoiner } from \"./line-wrap.js\"\nimport { FRAME_TITLE_BLOCKS } from \"./table-meta.js\"\n\n/**\n * 한컴 PDF 가 가운뎃점(ㆍ U+318D)을 조합형 중성 아래아(ᆞ U+119E)로 내는 것을 되돌린다.\n * 실측(v4.12.3, 별지서식·별표 573쌍): PDF 출력의 가운뎃점 3,238자 중 340자(41문서)가 U+119E,\n * HWP5 원본은 전부 U+318D. 옛한글 자모 결합(앞이 초성 U+1100~115F)만 남긴다.\n */\nexport function normalizeAraea(text: string): string {\n return text.replace(/(?<![\\u1100-\\u115F])\\u119E/g, \"\\u318D\")\n}\n\n/**\n * 한컴 PDF 의 \"유니코드 없는 글리프\" 자리표시 U+F000 제거. 한컴 PDF 는 ToUnicode 를 못 만든 글리프를 전부 U+F000 으로\n * 낸다 — 자동 글머리표·자동 번호(□·①), 일부 괄호(《), 칸 채움 글자까지 같은 코드라(보도자료 246건 실측: 줄머리 33·줄 안\n * 125) 원래 글자를 되살릴 수 없고, 남기면 두부 글자로 찍힌다. 페이지 품질 신호(PUA 비율) 계산 뒤에 지운다\n */\nconst stripNoUnicodeGlyph = (text: string): string => (text.includes(\"\\uF000\") ? text.replace(/\\uF000/g, \"\") : text)\n\n/**\n * 홀로 선 첫소리 자모(U+1100~1112, 뒤에 가운뎃소리 없음) → 호환 자모(ㄱ~ㅎ). 한컴 PDF 가 글머리 \"ㅇ\" 을 조합형 첫소리 ᄋ(U+110B)로\n * 내는 문서가 있다(규제영향분석서 \"ᄋ (추진배경)\", 환승센터 보도자료 \"ᄋ 수도권\") — 원문(HWPX)은 U+3147. 가운뎃소리가 뒤따르는\n * 옛한글 음절 조합은 그대로 둔다(normalizeAraea 와 같은 원칙)\n */\nconst CHOSEONG_COMPAT = \"ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ\"\nconst normalizeLoneChoseong = (text: string): string =>\n /[\\u1100-\\u1112]/.test(text) ? text.replace(/[\\u1100-\\u1112](?![\\u1160-\\u11A7\\uD7B0-\\uD7C6])/g, c => CHOSEONG_COMPAT[c.charCodeAt(0) - 0x1100]) : text\n\nconst cleanChars = (text: string): string => normalizeLoneChoseong(normalizeAraea(stripNoUnicodeGlyph(stripControlChars(text))))\n\n/** 블록 트리의 텍스트에서 비표시 제어문자 제거 + 조합형 가운뎃점 정규화 (in-place, 셀 blocks 포함) */\nexport function sanitizeBlockControlChars(blocks: IRBlock[]): void {\n for (const b of blocks) {\n if (b.text) b.text = cleanChars(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 = cleanChars(cell.text)\n if (cell.blocks) sanitizeBlockControlChars(cell.blocks)\n }\n }\n }\n if (b.children) sanitizeBlockControlChars(b.children)\n }\n}\n\n/**\n * 최상위 1×1 상자를 원문 문단·중첩표 순서대로 편다. 기하 없는 종전 셀은 줄 경계를 보존한다.\n * 1×1 셀 줄바꿈은 표 셀 줄바꿈 보존 정책(v4.12.1 1열 다행)과 같이 지켜야 하는데, 1×1 은\n * tableToMarkdown 이 \"줄\\n줄\" 로 내고 cleanPdfText 의 mergeKoreanLines 가 한글 줄을 이어 붙여\n * \"선 서 나는 헌법을 …\"(선서문 안쪽 상자, 제목 줄+본문 줄 결합)이 됐다. 문단 블록 사이는 빈 줄이라\n * 병합 대상이 아니다. 실측 파급: 별지서식·별표·보고서 897 PDF 중 10문서 12표.\n */\n/** 영문 캡션 표지 한 칸 — \"Figure 4\", \"Diagram 5\", \"Table 2.1\" */\nconst CAPTION_LABEL = /^(?:figure|fig\\.|diagram|table|chart|graph|exhibit|box)\\s*[\\dIVX][\\w.\\-]*$/i\n\n/** 캡션 표지 칸 + 캡션 글 칸 1×2 표(워드 캡션 상자) → 캡션 문단 한 줄 */\nfunction captionTableText(t: IRTable | undefined): string | null {\n if (!t || t.rows !== 1 || t.cols !== 2) return null\n const [label, body] = t.cells[0]\n if (!label || !body || label.blocks?.length || body.blocks?.length || !CAPTION_LABEL.test(label.text.trim()) || !body.text.trim()) return null\n return `${label.text.trim()} ${body.text.replace(/\\s*\\n\\s*/g, \" \").trim()}`\n}\n\nexport function splitSingleCellTables(blocks: IRBlock[]): IRBlock[] {\n const out: IRBlock[] = []\n for (const b of blocks) {\n const t = b.type === \"table\" ? b.table : undefined\n const title = t && FRAME_TITLE_BLOCKS.get(t)\n if (title) { out.push({ ...title, text: `${t!.cells[0][0].text.trim()} ${t!.cells[0][2].text.trim()}` }); continue }\n // 캡션 상자 — 표가 아니라 캡션 문단이다(1칸 틀 안에 든 것도)\n // 틀 칸 글은 비었거나 안쪽 표 글을 되풀이한 것\n const only = t && t.rows === 1 && t.cols === 1 && t.cells[0]?.[0]?.blocks?.length === 1 ? t.cells[0][0].blocks[0] : undefined\n const squash = (s: string) => s.replace(/\\s/g, \"\")\n const inner = only && (!squash(t!.cells[0][0].text) ||\n (only.type === \"table\" && squash(t!.cells[0][0].text) === squash(only.table!.cells.flat().map((c) => c.text).join(\"\")))) ? only : undefined\n const caption = captionTableText(t) ?? (inner?.type === \"table\" ? captionTableText(inner.table) : null)\n if (caption) { out.push({ type: \"paragraph\", text: caption, pageNumber: b.pageNumber, bbox: b.bbox }); continue }\n const cell = t && t.rows === 1 && t.cols === 1 ? t.cells[0]?.[0] : undefined\n if (!cell) { out.push(b); continue }\n const lines = (cell.text ?? \"\").split(/\\n/).map((l) => l.trim()).filter(Boolean)\n // 표에 붙은 캡션은 푼 줄 앞 문단으로 남긴다 (표를 풀면서 캡션까지 사라지던 것)\n if (t?.caption?.trim()) out.push({ type: \"paragraph\", text: t.caption.trim(), pageNumber: b.pageNumber, bbox: b.bbox })\n if (cell.blocks?.length) { out.push(...splitSingleCellTables(cell.blocks)); continue }\n for (const text of lines) out.push({ type: \"paragraph\", text, pageNumber: b.pageNumber, bbox: b.bbox })\n }\n return out\n}\n\n/**\n * 표 칸의 라틴 글 꺾인 줄을 공백으로 잇는다 — 영문 조판은 칸 폭에서 낱말 단위로 줄을 꺾을 뿐 문단을 가르지 않는다\n * (\"GATS XVII⏎Reservation⏎(1994)\"). 한글 줄(한컴 칸 문단 경계는 칸 글 조립의 꺾임 판정이 맡음)·숫자만 든 줄(쌓인 값)·\n * 새 항목(부호·번호) 줄은 그대로 둔다. 칸 글 줄을 문단으로 다시 푸는 단계(1×1 표 풀기) 뒤에 부른다\n */\nexport function joinLatinCellWraps(blocks: IRBlock[]): void {\n for (const b of blocks) {\n if (b.type !== \"table\" || !b.table) continue\n for (const row of b.table.cells) for (const cell of row) {\n if (cell.text.includes(\"\\n\")) {\n const lines = cell.text.split(\"\\n\")\n const out = [lines[0]]\n for (const line of lines.slice(1)) {\n if (latinSoftWrap(out[out.length - 1], line)) out[out.length - 1] = out[out.length - 1].trimEnd() + \" \" + line.trim()\n else out.push(line)\n }\n cell.text = out.join(\"\\n\")\n }\n if (cell.blocks) joinLatinCellWraps(cell.blocks)\n }\n }\n}\n\n/**\n * @param opts.keepLoneNumbers 홀로 선 숫자 줄을 남긴다 — 파서가 쪽 가장자리 띠의 쪽번호 문단을 이미 뺐을 때(본문 한가운데 차트 축 눈금·장 번호는 글이다)\n */\nexport function cleanPdfText(text: string, opts?: { keepLoneNumbers?: boolean }): string {\n let clean = normalizeAraea(stripControlChars(text))\n if (!opts?.keepLoneNumbers) {\n clean = clean\n // 문서 시작 단독 페이지 번호\n .replace(/^\\d{1,4}\\n/, \"\")\n // 단독 페이지 번호 (줄 끝에 혼자 있는 숫자)\n .replace(/\\n\\d{1,4}\\n/g, \"\\n\")\n // 문서 마지막 단독 페이지 번호\n .replace(/\\n\\d{1,4}$/, \"\")\n }\n // 단독 숫자 헤딩 제거 (\"# 6\\n재무과\" → \"\\n재무과\") — 파서 경로(keepLoneNumbers)는 글자 없는 제목을 이미 강등했고 남은 것은 장 번호다\n if (!opts?.keepLoneNumbers) clean = clean.replace(/^#{1,6}\\s*\\d{1,4}\\s*$/gm, \"\")\n return mergeKoreanLines(\n clean\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(/\\.(?: \\.){3,}/g, m => \".\".repeat((m.length + 1) >> 1))\n )\n // 균등배분 문자열 후처리 (pdfjs가 합친 TextItem + buildGridTable 셀 텍스트) — 홀로 선 한 글자 셋 이상 연속만 붙인다\n // (collapseEvenSpacing whole=false). 줄 전체 한 글자 비율 규칙은 기호·등호 토큰까지 한 글자로 세어 원문 띄어쓰기를 통째로\n // 지웠고(\"□ 개 요\" → \"□개요\", \"N = 잠수펌프의 수\" → \"N=잠수펌프의수\"), 표 칸은 이미 칸 조립(cleanCellText)·builder sanitizeText\n // 가 다룬다 — 이 단계의 whole 규칙을 끄면 hwpx↔pdf 752쌍 중 30문서 나아지고 2문서(각 1~2어절) 나빠진다\n // LaTeX 수식 라인 ($...$ / $$...$$) 은 공백이 토큰 구분자라 collapse 시 `\\cdot d` → `\\cdotd` 로 망가짐 — skip\n .replace(/^(?!\\| ---).*$/gm, line => {\n if (/^\\s*\\${1,2}.+\\${1,2}\\s*$/.test(line)) return line\n // 표 행은 칸마다(칸 안 <br> 줄마다) — 행 전체를 한 줄로 세면 칸 구분자 \"|\" 가 한 글자 토큰으로 잡혀 짧은 칸이 늘어선 머리 행이\n // 통째로 붙었다(\"| 시 간 | 분 | 내 용 | 비 고 |\" → \"|시간|분|내용|비고|\", 보도자료 세부 일정표 — 원문 칸 글은 \"시 간\", 86문서)\n if (line.startsWith(\"|\")) {\n return line.replace(/(?:\\\\.|[^|\\\\])+/g, cell => cell.split(\"<br>\").map(seg => seg.replace(/\\S(?:.*\\S)?/, t => collapseEvenSpacing(t, false))).join(\"<br>\"))\n }\n // 마크다운 머리 표지(\"# \"·\"- \")는 한 글자 토큰으로 세지 않는다 — \"# 목 차\" 가 \"#목차\"(헤딩 표지 깨짐)로 붙던 것\n const mark = /^(?:#{1,6}|-) /.exec(line)?.[0] ?? \"\"\n return mark + collapseEvenSpacing(line.slice(mark.length), false)\n })\n // (종전 \"마커 뒤 2글자 균등배분 합침\" — \"□ 일 시\" → \"□ 일시\" — 은 뺐다: 원문(HWPX)이 공백을 쳐서 띄운 표제라 hwpx↔pdf 에서\n // 붙이면 틀린 곳이 맞는 곳보다 많았다(규칙을 빼면 11문서 나아지고 1문서 나빠짐). 뒤 글자가 긴 낱말 머리면 \"□ 본 보도자료\" →\n // \"□ 본보도자료\" 로 낱말을 깨기도 했다(47문서))\n // 취소선 복원: builder escapeGfm이 ~를 \\~로 이스케이프 — 쌍(~~)만 되살림\n .replace(/\\\\~\\\\~/g, \"~~\")\n // 인접 취소선 run이 붙어 생긴 빈 마크(~~~~) 정리\n .replace(/~~~~/g, \"\")\n // 내용이 사라져 빈 밑줄 쌍(<u></u>) 정리 (escapeGfm은 <>를 건드리지 않아 복원 불필요)\n .replace(/<u>\\s*<\\/u>/g, \"\")\n .replace(/<(sup|sub)>\\s*<\\/\\1>/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 // 이음자는 어절 판정(wrapJoiner, 좌표·문서 어휘 없이 조사·어미 형태만) — 무조건 공백이면 블록 안 어절 중간 꺾임이 \"이정표입 니다\"\n if (/[가-힣·,\\-]$/.test(prev) && /^[가-힣(]/.test(curr) &&\n !startsWithMarker(curr) && !isStandaloneHeader(prev) &&\n !startsWithMarker(prev)) {\n result[result.length - 1] = prev + wrapJoiner(prev, curr) + curr\n } else {\n result.push(curr)\n }\n }\n return result.join(\"\\n\")\n}\n","/**\n * PDF 링크 어노테이션 → 마크다운 링크.\n *\n * /Annots 의 Link(/URI) 어노테이션 rect 를 텍스트 아이템과 상관해 해당 run 을\n * [text](url) 로 감싼다. pdfjs getAnnotations() 가 주는 rect 는 PDF 사용자 좌표\n * (pt, bottom-up)로 텍스트 아이템과 같은 공간. escapeGfm 은 `](스킴…)` 링크\n * 스팬을 보호하므로 마커가 그대로 살아남는다.\n */\n\nimport { sanitizeHref } from \"../utils.js\"\nimport type { NormItem } from \"./text-line.js\"\n\n/** pdfjs getAnnotations() 반환 원소 중 이 모듈이 쓰는 필드 */\nexport interface PdfAnnotation {\n subtype?: string\n url?: string\n rect?: number[]\n}\n\n/** rect 와 아이템의 세로 겹침 판정 여유 (pt) */\nconst LINK_Y_TOL = 2\n/** 아이템이 rect 에 속한다고 볼 최소 수평 겹침 비율 */\nconst LINK_MIN_OVERLAP_RATIO = 0.5\n\n/**\n * Link 어노테이션 rect 안의 텍스트 run 을 [text](url) 로 감싼다.\n * 시각적 줄 단위로 래핑 — 여러 줄에 걸친 링크는 줄마다 같은 url 로 반복.\n * rect 안에 텍스트가 없는 어노테이션(아이콘 링크 등)은 무시.\n */\nexport function applyLinkAnnotations(items: NormItem[], annots: PdfAnnotation[]): void {\n if (items.length === 0 || annots.length === 0) return\n const wrapped = new Set<NormItem>() // 겹치는 어노테이션의 이중 래핑 방지\n\n for (const a of annots) {\n if (a.subtype !== \"Link\" || !a.url || !a.rect || a.rect.length < 4) continue\n const url = sanitizeHref(a.url)\n if (!url) continue\n const x1 = Math.min(a.rect[0], a.rect[2]), x2 = Math.max(a.rect[0], a.rect[2])\n const y1 = Math.min(a.rect[1], a.rect[3]), y2 = Math.max(a.rect[1], a.rect[3])\n\n const matches: NormItem[] = []\n for (const item of items) {\n if (wrapped.has(item) || item.w <= 0 || !item.text.trim()) continue\n // baseline 이 rect 세로 범위 안 (밑줄형 링크 rect 가 baseline 아래로 살짝\n // 내려가는 경우 대비 여유 2pt)\n if (item.y < y1 - LINK_Y_TOL || item.y > y2 + LINK_Y_TOL) continue\n const overlap = Math.min(x2, item.x + item.w) - Math.max(x1, item.x)\n if (overlap / item.w < LINK_MIN_OVERLAP_RATIO) continue\n matches.push(item)\n }\n if (matches.length === 0) continue\n\n // 줄 단위 그룹핑 (y ±3, 취소선/밑줄 래핑과 동일 컨벤션) 후 x 순 래핑\n const lines = new Map<number, NormItem[]>()\n for (const m of matches) {\n const key = Math.round(m.y / 3)\n const arr = lines.get(key) || []\n arr.push(m)\n lines.set(key, arr)\n }\n for (const arr of lines.values()) {\n arr.sort((p, q) => p.x - q.x)\n arr[0].text = \"[\" + arr[0].text\n arr[arr.length - 1].text = arr[arr.length - 1].text + `](${url})`\n for (const m of arr) wrapped.add(m)\n }\n }\n}\n\n/** 주소 비교 키 — 마크다운 이스케이프·공백(줄 꺾임)·퍼센트 인코딩·스킴·www.·끝 문장부호를 걷는다 */\nfunction addressKey(s: string): string {\n let t = s.replace(/\\\\(.)/g, \"$1\").replace(/\\s+/g, \"\")\n try { t = decodeURI(t) } catch { /* 깨진 퍼센트 인코딩은 그대로 견준다 */ }\n return t.toLowerCase().replace(/^(?:[a-z][a-z0-9+.-]*:)?(?:\\/\\/)?(?:www\\.)?/, \"\").replace(/[/.,;:)\\]]+$/, \"\")\n}\n\n/** 마크다운 링크 한 개 — 그림 참조(`![…](…)`)는 뺀다 */\nconst MD_LINK = String.raw`(?<!!)\\[([^\\]\\n]*)\\]\\(([^)\\s]+)\\)`\n\n/**\n * 줄마다 감싼 링크를 문서 마크다운에서 다시 한 링크로 — 여러 줄에 걸친 링크 하나가 \"[You can read](u) [more about](u)\"처럼\n * 같은 url 을 줄 수만큼 되풀이하던 것을 사이 공백째 잇는다. 링크 글에 그어진 밑줄은 링크 표시일 뿐이라 링크 하나를 통째로\n * 감싼 `<u>` 는 뗀다. 링크 글이 주소 자체인 링크는 글만 남긴다\n */\nexport function mergeLinkRuns(markdown: string): string {\n // 줄마다 밑줄을 감싸면 두 줄 링크가 <u>[Our</u> <u>Mental Shortcuts](u)</u> 로 엇갈린다 — 링크 글 안의 줄 경계 밑줄 표지도 뗀다\n let out = markdown.replace(new RegExp(String.raw`<u>${MD_LINK}</u>`, \"g\"),\n (_m, text: string, url: string) => `[${text.replace(/<\\/u>(\\s*)<u>/g, \"$1\")}](${url})`)\n const run = new RegExp(MD_LINK + String.raw`(\\s+)` + String.raw`(?<!!)\\[([^\\]\\n]*)\\]\\(\\2\\)`, \"g\")\n for (let prev = \"\"; prev !== out;) {\n prev = out\n out = out.replace(run, (_m, a: string, url: string, _gap: string, b: string) => `[${a} ${b}](${url})`)\n }\n // 링크 글이 주소 자체면 [주소](주소) 는 같은 글을 되풀이할 뿐이다 — 찍힌 글만 남긴다 (ODL 158 유튜브 주소·192 참고문헌 주소)\n return out.replace(new RegExp(MD_LINK, \"g\"), (m, text: string, url: string) => text.trim() && addressKey(text) === addressKey(url) ? text : m)\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 { escapeLiteralTags, stripScriptTags } from \"../script-tags.js\"\nimport type { InternalParseResult, IRBlock, DocumentMetadata, ExtractedImage, ParseOptions, ParseWarning, OutlineItem } from \"../types.js\"\nimport { KordocError } from \"../utils.js\"\nimport { parsePageRange, hasRequestedPagesAfter } from \"../page-range.js\"\nimport { blocksToPages } from \"../page-markdown.js\"\nimport { assignTableIds, blocksToMarkdown, escapeLiteralDollar } from \"../table/builder.js\"\nimport { unframeLayoutTables, CONTENT_CELLS, CELL_BASELINES } from \"../table/layout-frames.js\"\nimport { CELL_LINES, CLIP_TABLES, IMAGE_CELLS } from \"./table-meta.js\"\nimport { extractImageRegions, extractLines } from \"./line-detector.js\"\nimport { mergeOcrImageRegions, type ImageRegion } from \"./ocr-region-merge.js\"\nimport { createPdfImageState, extractPageImages, injectPageImageBlocks } from \"./image-extract.js\"\nimport { computePageQuality, summarizeDocumentQuality, type PageQuality } from \"./quality.js\"\nimport { scanVectorGlyphs, ocrVectorOps } from \"./vector-glyphs.js\"\nimport { type PdfTextItem, type NormItem, normalizeItems, filterHiddenText } from \"./text-line.js\"\nimport { extractPageBlocksWithLines, type PageCarry } from \"./page-blocks.js\"\nimport { WrapLexicon, joinPageBreakWraps, splitPageBreakWraps } from \"./line-wrap.js\"\nimport { mergeCrossPageTables } from \"./table-parts.js\"\nimport { splitPageTables } from \"./table-pages.js\"\nimport { mergeContinuedCells } from \"./cell-continuation.js\"\nimport { restoreUnmappedGlyphs } from \"./symbol-glyphs.js\"\nimport { trimTrailingEmptyTableCols } from \"./table-trim.js\"\nimport { selectionContextPages, marginContextBlocks } from \"./selection-context.js\"\nimport { restoreImageBullets } from \"./image-bullets.js\"\nimport { detectRightArrowRegions } from \"./flow-boxes.js\"\nimport { remapSymbolFontItems } from \"./symbol-fonts.js\"\nimport { wrapEquationRuns } from \"./equation-runs.js\"\nimport { remapControlGlyphs, restoreNamedGlyphs } from \"./glyph-names.js\"\nimport { occludedTextItems } from \"./occluded-text.js\"\nimport { joinVerticalColumns } from \"./vertical-text.js\"\nimport { restoreTrackedSpacing, markSyntheticSpaces, markTrailingSpaceGlyphs } from \"./tracked-text.js\"\nimport { relocateEndnotes } from \"./endnotes.js\"\nimport { dropTabLeaderDots } from \"./tab-leaders.js\"\nimport { orderTwoUpPage } from \"./two-up.js\"\nimport { removeSideTabs } from \"./side-tabs.js\"\nimport { superscriptNoteMarks, inlineFootnotes, footnoteSeparators, type PageNotes } from \"./footnotes.js\"\nimport { demoteNonHeadingRoles } from \"./heading-demote.js\"\nimport { computeMedianFontSizeFromFreq, detectHeadings, mergeStackedHeadingLines, detectTypographyHeadings, detectDocumentStyleHeadings, detectSiblingStyleHeadings, detectRepeatedPageLabels, detectPageLeadHeadings, refineDocumentStyleHeadings, detectMarkerHeadings, detectTableCaptions, detectKoreanListBlocks, removeHeaderFooterBlocks, runningLinePatterns, stripAbsorbedRunningLines } from \"./block-detect.js\"\nimport { sanitizeBlockControlChars, cleanPdfText, splitSingleCellTables, joinLatinCellWraps } from \"./text-clean.js\"\nimport { applyLinkAnnotations, mergeLinkRuns } from \"./links.js\"\nimport { applyFormulaOcr } from \"./formula-ocr.js\"\n// polyfill 먼저 (ES 모듈 호이스팅되므로 별도 파일 필수)\nimport \"./polyfill.js\"\nimport { getDocument, GlobalWorkerOptions, OPS } from \"pdfjs-dist/legacy/build/pdf.mjs\"\nimport { createRequire } from \"node:module\"\nimport { dirname, join } from \"node:path\"\nimport { ocrModelsCached } from \"../ocr/models.js\"\nimport { splitContactTables } from \"./contact-table.js\"\n\n// 기존 공개 API 경로 유지 — 이동된 함수의 re-export\n\n/**\n * 그림 영역 OCR 대상 — `ocr: true` 는 글 없는 그림 후보 전부(쪽 면적 2%+·머리 띠 로고), 기본값의 자동 OCR 은 쪽 면적 5% 넘는 큰\n * 그림만 읽는다(작은 아이콘·로고는 잡음 글이 되기 쉽고 쪽당 시간을 키운다). 큰 그림이 없는 쪽은 뺀다\n */\nexport function ocrImageRegions(regions: Map<number, ImageRegion[]>, large: Set<ImageRegion>, all: boolean): Map<number, ImageRegion[]> {\n if (all) return regions\n const out = new Map<number, ImageRegion[]>()\n for (const [p, rs] of regions) {\n const big = rs.filter(r => large.has(r))\n if (big.length) out.set(p, big)\n }\n return out\n}\n\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.js 는 글꼴을 사전 단위로 문서 끝까지 쥔다\n * (사전당 약 1.5MB, #137 제안요청서 30쪽 글꼴 사전 2,965개 = 힙 4.2GB). 코퍼스 PDF 1,911개의 문서당 글꼴 사전은 최대 155개라\n * 보통 문서는 비우지 않는다(비우면 다음 쪽 글꼴을 다시 변환한다) */\nconst FONT_CACHE_LIMIT = 256\n\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/**\n * 글꼴을 받기만 하는 문서 (#137) — pdf.js 는 FontFace 를 못 쓰면(disableFontFace, Node 의 FontFace 부재) 글자마다 글리프 윤곽 경로를\n * 만들고, 그 렌더러가 글꼴 프로그램의 글리프를 전부 쪼개 글꼴 사전마다 따로 쥔다. kordoc 은 pdf.js 로 그리지 않아(OCR·수식 래스터는\n * pdfium) 경로가 필요 없다. 이 문서를 ownerDocument 로 주면 FontLoader 가 @font-face 규칙을 넣고(버림) Node 의 동기 글꼴 적재로\n * 끝나 경로를 만들지 않는다. Bullzip PDF Printer 공시 첨부(글꼴 파일 2개를 글꼴 사전 400여 개가 나눠 씀, 글리프 6.5만 개): 1쪽\n * getOperatorList 힙 3.6GB → 0.6GB. 쓰는 멤버는 FontLoader.insertRule·clear 뿐이다(pdfjs 4.10)\n */\nconst HEADLESS_FONT_DOCUMENT = {\n createElement: () => ({ sheet: { cssRules: [], insertRule() {} }, remove() {} }),\n documentElement: { getElementsByTagName: () => [{ append() {} }] },\n} as unknown as HTMLDocument\n\n/** getDocument 옵션 (data 제외) */\nexport const PDFJS_DOCUMENT_OPTIONS = {\n useSystemFonts: true,\n // 글꼴 /Differences 글리프 이름 — ToUnicode 없는 옛 숫자·작은 대문자 복원(glyph-names.ts)\n fontExtraProperties: true,\n disableFontFace: false,\n ownerDocument: HEADLESS_FONT_DOCUMENT,\n isEvalSupported: false,\n verbosity: 0, // 오류만 — 경고(\"Warning: Indexing all PDF objects\")를 console.log 로 stdout 에 찍어 MCP·CLI JSON 을 깼다\n ...pdfjsAssets,\n}\n\n/** getDocument + 타임아웃 래퍼 */\nasync function loadPdfWithTimeout(buffer: ArrayBuffer) {\n const loadingTask = getDocument({\n // pdfjs transfers its input to the worker; retain the caller's buffer for reuse.\n data: new Uint8Array(buffer.slice(0)),\n ...PDFJS_DOCUMENT_OPTIONS,\n })\n let timer: ReturnType<typeof setTimeout> | undefined\n try {\n return await Promise.race([\n loadingTask.promise.catch((e: unknown) => {\n if (e instanceof Error && e.name === \"PasswordException\") throw new KordocError(\"암호로 보호된 PDF 파일입니다 (PDF 열기 암호는 지원하지 않습니다)\")\n throw e\n }),\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 receives a copy; both OCR paths can reuse the caller's original bytes.\n const formulaBuffer: ArrayBuffer | null = options?.formulaOcr ? buffer : null\n // ocr 을 지정하지 않으면(false 아님) 텍스트층 없는 쪽과 큰 그림 속 글을 자동 OCR — 내장 모델이 이미 캐시에 있을 때만(다운로드하지 않는다)\n const autoOcr = options?.ocr === undefined && await ocrModelsCached()\n const ocrBuffer: ArrayBuffer | null = options?.ocr || autoOcr ? buffer : 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) — PDF는 항상 실제 페이지 단위 (#66)\n const metadata: DocumentMetadata = { pageCount, pageMode: \"layout\" }\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 if (pageCount > MAX_PAGES && (!options?.pages || hasRequestedPagesAfter(options.pages, MAX_PAGES, pageCount))) {\n warnings.push({ message: `${pageCount}쪽 중 앞 ${MAX_PAGES}쪽만 파싱했습니다 (쪽 수 상한)`, code: \"PARTIAL_PARSE\" })\n }\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 const pageWidths = new Map<number, number>()\n // 글꼴 id → 실제 서체 이름(서브셋 접두 뗌) — 제목 강등의 기울임·굵기 증거\n const faceNames = new Map<string, string>()\n // 큰 이미지가 있는 페이지 (needsOcr 경고 노이즈 필터 + SKIPPED_IMAGE)\n const pagesWithLargeImage = new Set<number>()\n // 텍스트 없는 큰 이미지 영역: page → count\n const skippedImagePages = new Map<number, number>()\n const uncoveredImageRegions = new Map<number, ImageRegion[]>()\n // 그 가운데 쪽 면적 5% 넘는 큰 그림 — 기본값(자동 OCR)은 이것만 읽는다 (ocrImageRegions)\n const largeImageRegions = new Set<ImageRegion>()\n // 이미지 XObject 바이트 추출 상태 (문서 단위 중복 억제·상한).\n // image 블록은 페이지 경계 표 병합(mergeCrossPageTables)의 인접성을 깨지 않도록\n // 페이지별로 모아뒀다가 병합 후 주입한다.\n const imageState = createPdfImageState()\n const imageBulletShapes = new Map<string, boolean>()\n const imageArrowDirections = new Map<string, -1 | 0 | 1>()\n const extractedImages: ExtractedImage[] = []\n const pageImageBlocks = new Map<number, IRBlock[]>()\n // 쪽을 넘는 칸 — 앞 쪽 마지막 칸을 다음 쪽 클립 판정에 넘긴다 (clip-cells continues, 문서 단계 mergeContinuedCells)\n const carry: PageCarry = {}\n // OCR 로 갈 벡터 글자 쪽의 그래픽(글자 경로·클립 뺀 연산자 목록) — OCR 글자에 이 쪽의 실제 괘선을 붙인다\n const vectorPageOps = new Map<number, { fnArray: number[]; argsArray: unknown[][] }>()\n // 줄 꺾임 이음의 문서 어휘 증거 — 쪽을 처리할 때마다 그 쪽 줄 글이 더해진다 (line-wrap.ts)\n const wrapLexicon = new WrapLexicon()\n // 쪽마다 본문 위첨자 각주 참조 표시(\"권고사항⁶⁾\") — 쪽 아래 각주를 참조 문단으로 옮길 때 쓴다 (footnotes.ts)\n const noteMarks = new Map<number, PageNotes>()\n\n // A selected page alone cannot establish the existing three-page repetition rule.\n // Read only nearby text margins; context never enters output, OCR, counts or progress.\n const headerContext: IRBlock[] = []\n if (pageFilter && options?.removeHeaderFooter !== false && pageFilter.size < effectivePageCount) {\n for (const number of selectionContextPages(pageFilter, effectivePageCount)) {\n let contextPage: Awaited<ReturnType<typeof doc.getPage>> | undefined\n try {\n contextPage = await doc.getPage(number)\n const content = await contextPage.getTextContent()\n pageHeights.set(number, contextPage.view[3] - contextPage.view[1])\n headerContext.push(...marginContextBlocks(content.items as PdfTextItem[], number, contextPage.view))\n } catch { /* unavailable context leaves selected content intact */ }\n finally { contextPage?.cleanup() }\n }\n }\n\n let parsedPages = 0\n const loadedFonts = new Set<string>()\n for (let i = 1; i <= effectivePageCount; i++) {\n if (pageFilter && !pageFilter.has(i)) continue\n let loadedPage: Awaited<ReturnType<typeof doc.getPage>> | undefined\n try {\n const page = await doc.getPage(i)\n loadedPage = page\n const tc = await page.getTextContent()\n // Text and paths use unrotated user coordinates, including the CropBox origin.\n const [viewX1, viewY1, viewX2, viewY2] = page.view\n const pageW = viewX2 - viewX1, pageH = viewY2 - viewY1\n pageHeights.set(i, pageH)\n pageWidths.set(i, pageW)\n const rawItems = tc.items as PdfTextItem[]\n // 선 기반 테이블 감지를 위한 operatorList — 글리프 이름 복원(restoreNamedGlyphs)이 공백 정리 전에 써서 먼저 받는다\n const rawOps = await page.getOperatorList()\n for (let k = 0; k < rawOps.fnArray.length; k++) if (rawOps.fnArray[k] === OPS.setFont) loadedFonts.add(rawOps.argsArray[k][0])\n // 폰트 실명·/Differences 는 operatorList 로드 뒤에야 commonObjs 에 있다\n const fontObj = (loadedName: string) => {\n try { return page.commonObjs.has(loadedName) ? page.commonObjs.get(loadedName) as { name?: string; isType3Font?: boolean } | null : undefined }\n catch { return undefined }\n }\n const differencesOf = (loadedName: string) => (fontObj(loadedName) as { differences?: ArrayLike<string | undefined> } | null | undefined)?.differences\n // 줄 끝 공백 글리프(어절 경계에서 꺾인 줄) — 아래 복원들이 아이템 글을 바꾸기 전에 흐름과 맞춘다\n markTrailingSpaceGlyphs(rawItems, rawOps.fnArray, rawOps.argsArray)\n restoreNamedGlyphs(rawItems, rawOps.fnArray, rawOps.argsArray, differencesOf, n => fontObj(n)?.name)\n // 유니코드 없는 기호 글리프(U+F000)는 글꼴 윤곽 모양으로 되살린다 — 한컴 자동 글머리표 \"▸\"·\"□\"·겹화살괄호 (symbol-glyphs.ts)\n restoreUnmappedGlyphs(rawItems, rawOps.fnArray, rawOps.argsArray, n => fontObj(n) as { data?: Uint8Array } | null | undefined)\n // 자간 벌린 글(\"E M A I L\") — 글리프 흐름의 진짜 공백으로 낱말 경계를 되살린다\n restoreTrackedSpacing(rawItems, rawOps.fnArray, rawOps.argsArray)\n // pdfjs 가 글자 틈으로 만든 공백 아이템 표시 — 균등배분 판정이 진짜 공백 글리프에서만 끊도록\n markSyntheticSpaces(rawItems, rawOps.fnArray, rawOps.argsArray)\n // 뒤에 칠한 불투명 사각형에 가려진 글(쪽 배경 아래 깔린 머리글 등)은 보이지 않는다\n const occluded = occludedTextItems(rawItems, rawOps.fnArray, rawOps.argsArray)\n const items = normalizeItems(occluded.size ? rawItems.filter(it => !occluded.has(it)) : rawItems)\n\n // hidden text 필터링 + 경고 수집\n const filtered = filterHiddenText(items, pageW, pageH, viewX1, viewY1)\n // 세로쓰기 글상자(한 자씩 쌓은 기둥이 오른쪽→왼쪽)는 기둥마다 한 줄로\n const joined = joinVerticalColumns(filtered.visible)\n // 탭 채움 리더(가운뎃점)는 글이 아니다 — 목차 줄이 \"제목 ···· 3\" 로 남던 것. 글꼴 크기 빈도(제목 판정의 본문 크기)는 빼기 전\n // 아이템으로 센다 — 목차 긴 문서에서 리더 점 수천 개가 빠지면 중앙값이 흔들려 제목 판정이 통째로 바뀐다(강원 정보화 계획 402 → 233)\n const visible = dropTabLeaderDots(joined)\n const hiddenCount = filtered.hiddenCount + occluded.size\n if (hiddenCount > 0) {\n warnings.push({ page: i, message: `${hiddenCount}개 숨겨진 텍스트 요소 필터링됨`, code: \"HIDDEN_TEXT_FILTERED\" })\n }\n\n // 폰트 크기 빈도 수집\n for (const item of joined) {\n if (item.fontSize > 0) fontSizeFreq.set(item.fontSize, (fontSizeFreq.get(item.fontSize) || 0) + 1)\n }\n\n // 링크 어노테이션(/Annots /URI) → [text](url) 래핑 — 실패해도 텍스트 파싱 진행\n try {\n const annots = await page.getAnnotations()\n applyLinkAnnotations(visible, annots)\n } catch { /* 어노테이션 파싱 실패 무시 */ }\n\n // Downstream table/header/page-break geometry assumes an origin of (0,0).\n // Translate both text and graphics, after annotations have matched user coordinates.\n // Filtering alone would retain text but misclassify a repeated body as a header.\n const shifted = viewX1 !== 0 || viewY1 !== 0\n if (shifted) for (const item of visible) { item.x -= viewX1; item.y -= viewY1 }\n const opList = shifted ? {\n fnArray: [OPS.transform, ...rawOps.fnArray],\n argsArray: [[1, 0, 0, 1, -viewX1, -viewY1], ...rawOps.argsArray],\n } : rawOps\n\n // 심볼 폰트(Wingdings) 글리프 복원\n remapSymbolFontItems(visible, (loadedName) => fontObj(loadedName)?.name)\n remapControlGlyphs(visible, differencesOf)\n await restoreImageBullets(visible, page, opList.fnArray, opList.argsArray, imageBulletShapes)\n // 글꼴 id(g_d0_fN)는 글꼴 객체마다 다르다. 크롬은 한 서체를 Type3 글꼴 객체 여러 개(256자마다 새 객체)로 쪼개\n // 같은 본문이 \"다른 서체\"로 보여 제목으로 승격된다(#89). Type3 는 서브셋 접두(ABCDEF+)를 뗀 서체 이름으로 맞춘다.\n // 다른 글꼴은 같은 이름의 서브셋 객체 차이가 굵게 흉내 낸 제목의 유일한 증거일 수 있어 그대로 둔다(ODL 181 Calibri).\n // 서브셋 접두가 없는 Type3 이름(cairo \"T2\"·\"T3\")은 쪽마다 다른 글꼴에 되쓰여 합치지 않는다\n const faces = new Map<string, string>()\n for (const it of visible) {\n if (!it.fontName) continue\n let face = faces.get(it.fontName)\n if (face === undefined) {\n const obj = fontObj(it.fontName)\n face = (obj?.isType3Font && /^[A-Z]{6}\\+./.test(obj.name ?? \"\") && obj.name!.slice(7)) || it.fontName\n faces.set(it.fontName, face)\n }\n it.fontName = face\n if (!faceNames.has(face)) faceNames.set(face, (fontObj(face)?.name ?? \"\").replace(/^[A-Z]{6}\\+/, \"\"))\n }\n // 리터럴 $ 는 \\$ — $…$ 는 수식 스팬 전용(IR 규약, HWPX·HWP5 와 같음). 종전엔 \"단가(US $) … 금액(US $)\" 사이가\n // 마크다운에서 수식으로 읽혀 사라졌다(야생생물 신고서·어셈블리 \"lda $30,-16($30)\"). 심볼 글꼴 복원 뒤라야 글자 표가 안 어긋난다\n for (const it of visible) if (it.text.includes(\"$\")) it.text = escapeLiteralDollar(it.text)\n // 태그 모양 원문 글자 \"<sub>\"·\"</sup>\" 는 \\<sub> — 첨자 끔(기본)·평문이 원문 글자까지 걷던 것 (#122, escapeLiteralTags)\n for (const it of visible) if (it.text.includes(\"<\")) it.text = escapeLiteralTags(it.text)\n // 한컴 수식 글꼴 글자 → $…$ 수식 스팬 (HWPX·HWP5 수식 출력과 같은 모양, 리터럴 $ 이스케이프 뒤)\n wrapEquationRuns(visible, face => faceNames.get(face))\n\n // 이미지 영역 감지 — 텍스트 없는 큰 이미지는 무음 정보손실이므로 가시화 (ODL 아이디어)\n const pageArea = pageW * pageH\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 // 쪽 면적 2% 넘는 그림은 OCR 영역 후보(로고 글 \"LAW LIBRARY\", ODL 085), 5% 넘는 것만 \"읽지 못한 그림\" 경고 — 1% 로 내리면\n // 아이콘이 잡음 글(\"0 0\")로 들어와 제목 구조가 흐트러진다(ODL 200 실측)\n // 쪽 머리 띠(위 20%)의 작은 로고(0.5% 이상)도 — 기관·사업 이름이 로고 글로만 있다(ODL 147·148·150 \"ECO Circle\")\n const headerLogo = area >= pageArea * 0.005 && r.y1 >= pageH * 0.8\n if (area < pageArea * 0.02 && !headerLogo) continue // 작은 장식 이미지 무시\n const large = area >= pageArea * 0.05\n if (large) 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) {\n if (large) uncovered++\n if (page.rotate % 360 === 0 && viewX1 === 0 && viewY1 === 0) {\n const regions = uncoveredImageRegions.get(i) ?? []\n regions.push(r)\n uncoveredImageRegions.set(i, regions)\n if (large) largeImageRegions.add(r)\n }\n }\n }\n if (uncovered > 0) skippedImagePages.set(i, uncovered)\n }\n\n // 본문 글 밖 여백에 세로로 돌려 찍은 글(arXiv 도장)은 가로 줄 흐름에 섞이면 옆 본문 줄에 붙는다 — 따로 떼어 쪽 첫 문단으로\n const marks = superscriptNoteMarks(visible)\n if (marks.length) noteMarks.set(i, { marks, seps: footnoteSeparators(extractLines(opList.fnArray, opList.argsArray).horizontals, pageW, pageH) })\n const stamp = marginStamp(visible)\n const flow = stamp.length ? visible.filter(it => !stamp.includes(it)) : visible\n // 가로 쪽 두 쪽 모아찍기는 왼쪽 쪽 → 오른쪽 쪽\n const rightArrows = options?.tables !== false ? await detectRightArrowRegions(page, opList.fnArray, opList.argsArray, imageArrowDirections) : []\n const pageBlocks = orderTwoUpPage(extractPageBlocksWithLines(flow, i, opList, pageW, pageH, undefined, options?.tables !== false, carry, wrapLexicon, rightArrows), pageW, pageH)\n if (stamp.length) pageBlocks.unshift({ type: \"paragraph\", text: [...stamp].sort((a, b) => a.y - b.y).map(it => it.text).join(\" \"), pageNumber: i })\n for (const b of pageBlocks) blocks.push(b)\n\n // 이미지 XObject 바이트 추출 — 블록 주입은 표 병합 후(injectPageImageBlocks)\n // images:false 면 PNG 인코딩을 건너뛰고 자리 표시 블록만 받는다\n try {\n const { blocks: imgBlocks, images: pageImages } = await extractPageImages(page, opList.fnArray, opList.argsArray, i, imageState, warnings, options?.images !== false)\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 // 텍스트층 밖 곡선 글자 — 글자를 채운 경로로 그린 쪽은 텍스트층 신호만으론 멀쩡해 보인다 (vector-glyphs.ts)\n const vector = scanVectorGlyphs(opList.fnArray, opList.argsArray, visible)\n const quality = computePageQuality(i, pageText, vector.glyphs)\n pageQuality.push(quality)\n // 회전·원점 이동 쪽은 pdfium 래스터 좌표가 사용자 공간과 어긋나 종전대로(래스터 괘선) 둔다\n if (options?.ocr && quality.ocrReason === \"vector_text\" && page.rotate % 360 === 0 && page.view[0] === 0 && page.view[1] === 0) {\n vectorPageOps.set(i, ocrVectorOps(opList, vector.paths))\n }\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 } finally {\n // Release page-local decoded images/operators on success and failure.\n // commonObjs (shared fonts/images) remains available to later pages.\n loadedPage?.cleanup()\n }\n if (loadedFonts.size > FONT_CACHE_LIMIT) {\n await doc.cleanup()\n loadedFonts.clear()\n }\n }\n\n const parsedPageCount = parsedPages || (pageFilter ? pageFilter.size : effectivePageCount)\n // 문서 단위 이미지 기반 판정 (평균 10자/페이지 미만 = 텍스트층 부재)\n const isImageBased = pageFilter?.size !== 0 && totalChars / Math.max(parsedPageCount, 1) < 10\n\n // ── OCR 실행 (옵션) — 페이지 단위 선정·병합 ──\n // 대상: \"force\"=전 페이지 / 문서가 이미지 기반=전 페이지 /\n // 그 외=품질 신호가 OCR 을 권하는 페이지만 (깨진 텍스트층 포함 — F1,\n // 혼합 문서의 스캔 페이지 포함 — F2). 정상 페이지 파싱 결과는 유지 (F3).\n const ocrDone = new Set<number>()\n // 그림 영역 OCR — ocr: true 는 후보 전부, 자동 OCR(기본값)은 큰 그림만\n const ocrRegions = options?.ocr === true || autoOcr ? ocrImageRegions(uncoveredImageRegions, largeImageRegions, options?.ocr === true) : new Map<number, ImageRegion[]>()\n if (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 // 자동 OCR 은 텍스트층이 없는 쪽만 — 글꼴 매핑이 깨진 쪽(텍스트층은 있다)은 ocr: true 로\n if (autoOcr && pq.ocrReason !== \"low_text\" && pq.ocrReason !== \"vector_text\") continue\n targets.add(pq.page)\n }\n for (const p of ocrRegions.keys()) targets.add(p)\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 // 텍스트층이 멀쩡한 쪽은 그림 영역만 읽는다 (쪽 전체를 갈아 끼우는 쪽 — 스캔·깨진 텍스트층 — 은 쪽 전체)\n const regionPages = new Map([...ocrRegions].filter(([p]) =>\n options?.ocr !== \"force\" && !isImageBased && !pageQuality.find(q => q.page === p)?.needsOcr))\n const ocrPageBlocks = await runPdfOcr(ocrBuffer, targets, mode, warnings, options?.onProgress, options?.tables !== false, vectorPageOps, regionPages)\n // OCR 글은 첨자를 가르지 않는다 — 검출 박스 높이·위치로는 기준선을 믿을 수 없다(scriptTags 를 켜도)\n for (const obs of ocrPageBlocks.values()) stripScriptTags({ blocks: obs })\n if (ocrPageBlocks.size > 0) {\n const replacePages = new Set<number>()\n for (const [p, obs] of ocrPageBlocks) {\n const needsOcr = pageQuality.find(q => q.page === p)?.needsOcr\n if (options?.ocr === \"force\" || isImageBased || needsOcr) {\n replacePages.add(p)\n ocrDone.add(p)\n continue\n }\n const regions = ocrRegions.get(p)\n if (!regions || mergeOcrImageRegions(blocks, p, regions, obs) === 0) continue\n ocrDone.add(p)\n // The extracted image remains in result.images; its Markdown placeholder\n // is replaced only when it was the sole image on the page.\n if (pageImageBlocks.get(p)?.length === 1) pageImageBlocks.delete(p)\n }\n if (replacePages.size) {\n const merged = blocks.filter(b => !(b.pageNumber && replacePages.has(b.pageNumber)))\n for (const [p, obs] of ocrPageBlocks) if (replacePages.has(p)) merged.push(...obs)\n merged.sort((a, b) => (a.pageNumber ?? 0) - (b.pageNumber ?? 0))\n blocks.length = 0\n blocks.push(...merged)\n }\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이 필요합니다. ocr: true (CLI --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 vector_text: \"글자를 곡선(벡터 경로)으로 그린 페이지 (텍스트층에 글자 없음)\",\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 검토 필요 (ocr: true / CLI --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 if (ocrDone.has(page)) continue\n warnings.push({ page, message: `${count}개 이미지 영역에 추출 가능한 텍스트 없음 (그림/차트/도장 내용 누락 가능 — 그림 속 글은 ocr: true / CLI --ocr 로 읽습니다)`, code: \"SKIPPED_IMAGE\" })\n }\n }\n\n // 머리글/바닥글 필터링 (기본 ON — 명시적 false일 때만 비활성화)\n if (options?.removeHeaderFooter !== false && (parsedPageCount >= 3 || headerContext.length)) {\n const removed = removeHeaderFooterBlocks(blocks, pageHeights, warnings, noteMarks, false, headerContext)\n const running = runningLinePatterns(removed.map(ri => blocks[ri]), pageHeights)\n // 필터링된 블록 제거 (뒤에서부터 삭제)\n for (let ri = removed.length - 1; ri >= 0; ri--) {\n blocks.splice(removed[ri], 1)\n }\n // 쪽 높이 문단·쪽 끝까지 내려온 표에 흡수된 같은 머리·바닥글\n const stripped = stripAbsorbedRunningLines(blocks, pageHeights, running)\n if (stripped !== blocks) { blocks.length = 0; blocks.push(...stripped) }\n // 쪽 옆 띠의 장·절 색인 탭(좌우 바깥 띠에 되풀이되는 짧은 글)\n const kept = removeSideTabs(blocks, pageWidths)\n if (kept !== blocks) { blocks.length = 0; blocks.push(...kept) }\n }\n\n // 쪽을 넘는 칸의 1칸 조각을 앞 쪽 표 그 칸에 붙인 뒤(행으로 갈리지 않게) 페이지 걸친 표 병합 —\n // 머리글/바닥글 제거 후 인접해진 표를 하나로 (ODL TableBorderProcessor.checkNeighborTables 포팅)\n mergeContinuedCells(blocks, pageHeights)\n mergeCrossPageTables(blocks, pageHeights, wrapLexicon)\n // 괘선 상자 머리말·꼬리말(표 블록) — 쪽 넘김 표 병합 뒤라야 상자가 갈라 두던 이웃 쪽 표를 잇지 않는다 (block-detect.ts)\n if (options?.removeHeaderFooter !== false && parsedPageCount >= 3) {\n const boxes = removeHeaderFooterBlocks(blocks, pageHeights, warnings, noteMarks, true)\n for (let ri = boxes.length - 1; ri >= 0; ri--) blocks.splice(boxes[ri], 1)\n }\n // 칸 클립 없는 PDF 의 보도자료 연락처 표 4열 → HWPX 서식 6열 (contact-table.ts)\n splitContactTables(blocks)\n // 후행 빈 열 정리 — HWP 계열 표 빌더와 같은 규칙 (병합 뒤: 쪽마다 같은 열 구조일 때 이어 붙인 다음)\n if (!options?.keepTrailingEmptyCols) trimTrailingEmptyTableCols(blocks)\n // 쪽 넘김으로 꺾인 본문 문단 잇기 — 머리말·꼬리말 제거 뒤, 쪽 끝 그림 주입 전 (line-wrap.ts)\n joinPageBreakWraps(blocks, wrapLexicon)\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 detectDocumentStyleHeadings(blocks)\n detectTypographyHeadings(blocks)\n detectSiblingStyleHeadings(blocks)\n detectRepeatedPageLabels(blocks)\n detectPageLeadHeadings(blocks)\n mergeStackedHeadingLines(blocks, medianFontSize)\n refineDocumentStyleHeadings(blocks)\n\n // □/■ 마커 기반 서브헤딩 감지 (ODL 패턴)\n detectMarkerHeadings(blocks)\n // 승격이 끝난 뒤 머리말·캡션·수식 번호 줄처럼 제목이 될 수 없는 역할을 되돌림\n demoteNonHeadingRoles(blocks, pageHeights, faceNames)\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 // 1×1 상자는 원문 기하에서 얻은 문단·중첩표 순서로 편다. keep 모드의 원 구조는 반환 blocks 에 남긴다.\n let outBlocks = splitSingleCellTables(blocks)\n if (options?.layoutTables !== \"keep\") {\n blocks.length = 0\n for (const block of outBlocks) blocks.push(block)\n }\n // 문서 끝에 모인 미주(해설)를 본문 참조 자리 뒤로 — HWPX·HWP5 출력과 같은 순서\n // 쪽 아래 각주를 참조 문단 끝 \" (주: …)\" 로 — HWPX·HWP5 출력과 같은 자리\n outBlocks = inlineFootnotes(outBlocks, noteMarks)\n outBlocks = relocateEndnotes(outBlocks)\n // 쪽번호는 쪽 위·아래 가장자리 띠의 숫자 문단뿐이다 — 본문 한가운데 홀로 선 숫자(차트 축 눈금 \"0\"·\"500\", 장 번호 \"2\")는 글이다\n // removeHeaderFooter: false 면 쪽번호도 글로 남긴다(머리글·바닥글과 같은 쪽 가장자리 띠)\n if (options?.removeHeaderFooter !== false) outBlocks = outBlocks.filter(b => {\n if (b.type !== \"paragraph\" || !/^\\s*\\d{1,4}\\s*$/.test(b.text ?? \"\")) return true\n const h = pageHeights.get(b.pageNumber ?? 0)\n if (!b.bbox || !h) return false\n return b.bbox.y > h * 0.1 && b.bbox.y + b.bbox.height < h * 0.9\n })\n joinLatinCellWraps(outBlocks)\n // 보이지 않는 틀 표 풀기 (v4.17.0, table/layout-frames) — 표 잇기(쪽 넘김·칸 이어짐)가 끝나 칸마다 보이는 변(cell-edges)이 제자리에\n // 있고 쪽번호 거르기(bbox 없는 문단을 버린다)도 지난 뒤. 새로 짠 표에는 PDF 표 곁정보(table-meta)가 없지만 여기서부터는 그 곁정보를\n // 보는 단계가 없다. 결과 blocks(쪽 넘김 잇기 뒤 원 목록)에도 같은 풀이를 쓴다 — 표 블록마다 한 번만 푼다(푸는 동안 칸을 제자리 고친다).\n // 클립 격자 풀기는 한컴 PDF 만 — 다른 제작기 클립 표는 테두리 없는 머리 행이 글로 풀려 표가 깨진다\n // (ODL 064 \"PORT | SHIPCALLS\" 머리 행: 한컴 원본 그림이 아니라 이 규칙의 정답이 없다)\n if (options?.layoutTables !== \"keep\") {\n const hancom = await isHancomPdf(doc)\n const shown = new Map<IRBlock, IRBlock[]>()\n // 그림 칸(IMAGE_CELLS)은 글이 비어도 내용이 있다 — 빈 여백 행으로 접히지 않게 (칸 안 표까지). 칸 글줄 기준선은 목차 행 짝짓기가 본다\n const markContent = (bs: IRBlock[] | undefined): void => {\n for (const b of bs ?? []) if (b.table) for (const row of b.table.cells) for (const c of row) {\n if (IMAGE_CELLS.has(c)) CONTENT_CELLS.add(c)\n const lines = CELL_LINES.get(c)\n if (lines) CELL_BASELINES.set(c, lines.map(l => l.y))\n markContent(c.blocks)\n }\n }\n const visual = (bs: IRBlock[]): IRBlock[] => bs.flatMap(b => {\n if (b.type !== \"table\") return [b]\n // 다른 제작기의 클립 머리행은 보존한다. 선 격자 밖에서 합성한 단위행은 제작기와 관계없이 문단이다.\n if (!hancom && b.table && CLIP_TABLES.has(b.table)) return [b]\n let v = shown.get(b)\n if (!v) { markContent([b]); shown.set(b, v = unframeLayoutTables([b], !!options?.keepTrailingEmptyCols)) }\n return v\n })\n outBlocks = visual(outBlocks)\n const kept = visual(blocks)\n blocks.length = 0\n for (const b of kept) blocks.push(b) // 큰 문서(수십만 블록)는 펼침 인자 상한을 넘는다\n }\n\n // blocksToMarkdown로 통일 — 헤딩 마크다운 반영 (HWP5/HWPX와 일관성)\n const finishMarkdown = (bs: IRBlock[]): string => mergeLinkRuns(cleanPdfText(blocksToMarkdown(bs, { tableFormat: options?.tableFormat }), { keepLoneNumbers: true }))\n // gfm 표 id 는 반환 blocks(IR) 순서로 먼저 — 마크다운용 outBlocks 는 같은 표 객체를 공유하므로 청크·쪽 렌더와 id 가 맞는다\n if (options?.tableFormat === \"gfm\") assignTableIds(blocks)\n let markdown = finishMarkdown(outBlocks)\n\n return {\n markdown,\n pages: blocksToPages(splitPageTables(splitPageBreakWraps(outBlocks)), finishMarkdown),\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 — Producer \"Hancom PDF 1.3.0.xxx\" 또는 Creator \"Hwp 2018·2020 …\" (법령 별표 273건 271건·표 정답 세트 한컴 산출물 실측).\n * 정보 사전을 못 읽거나 다른 제작기면 아니다 */\nasync function isHancomPdf(doc: { getMetadata(): Promise<unknown> }): Promise<boolean> {\n try {\n const info = (await doc.getMetadata() as { info?: Record<string, unknown> } | null)?.info\n return [info?.Producer, info?.Creator].some(v => typeof v === \"string\" && /^\\s*(?:hancom\\b|hwp\\b)/i.test(v))\n } catch {\n return false\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\n/** 쪽 여백의 세로 글 — 돌린 글 띠(시계 반대 90°는 기준점 왼쪽으로 글자 높이만큼)의 높이 범위에 놓인 가로 글이 모두 한쪽에만 있을 때 */\nfunction marginStamp(items: NormItem[]): NormItem[] {\n const rotated = items.filter(it => it.rotated)\n if (rotated.length === 0) return []\n const flat = items.filter(it => !it.rotated)\n if (flat.length < 20) return []\n const outside = (it: NormItem) => {\n const x1 = it.x - it.fontSize, x2 = it.x + it.w + it.fontSize\n const beside = flat.filter(f => f.y >= it.y && f.y <= it.y + it.rotated!)\n return beside.length >= 5 && (beside.every(f => f.x >= x2) || beside.every(f => f.x + f.w <= x1))\n }\n return rotated.every(outside) ? rotated : []\n}\n"]}
1
+ {"version":3,"sources":["/Users/mong-e/workspace/kordoc/dist/parser-XQL37S4F.cjs","../src/pdf/image-extract.ts","../src/pdf/quality.ts","../src/pdf/vector-glyphs.ts","../src/pdf/contact-table.ts","../src/pdf/table-pages.ts","../src/pdf/table-parts.ts","../src/pdf/cell-continuation.ts","../src/pdf/symbol-glyphs.ts","../src/pdf/selection-context.ts","../src/pdf/symbol-fonts.ts","../src/pdf/equation-runs.ts","../src/pdf/glyph-names.ts","../src/pdf/occluded-text.ts","../src/pdf/vertical-text.ts","../src/pdf/tracked-text.ts","../src/pdf/endnotes.ts","../src/pdf/tab-leaders.ts","../src/pdf/two-up.ts","../src/pdf/footnotes.ts","../src/pdf/heading-demote.ts","../src/pdf/text-clean.ts","../src/pdf/links.ts","../src/pdf/formula-ocr.ts","../src/pdf/polyfill.ts","../src/pdf/parser.ts"],"names":["OPS","page","k","R","top","g","bar","inside","spread","spaced","out","run","key"],"mappings":"AAAA;AACE;AACA;AACF,wDAA6B;AAC7B;AACE;AACA;AACF,wDAA6B;AAC7B;AACE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACF,wDAA6B;AAC7B;AACE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACF,wDAA6B;AAC7B,gCAA6B;AAC7B;AACE;AACA;AACF,wDAA6B;AAC7B;AACE;AACF,wDAA6B;AAC7B;AACA;AC5EA,gCAA2B;AAC3B,yDAA+B;AAsB/B,IAAM,yBAAA,EAA2B,GAAA;AAQjC,SAAS,cAAA,CAAe,IAAA,EAAmB,KAAA,EAA2C;AAEpF,EAAA,MAAM,MAAA,EAAQ,KAAA,CAAM,UAAA,CAAW,IAAI,EAAA,EAAI,IAAA,CAAK,WAAA,EAAa,IAAA,CAAK,IAAA;AAC9D,EAAA,OAAO,IAAI,OAAA,CAAQ,CAAA,OAAA,EAAA,GAAW;AAC5B,IAAA,IAAI,KAAA,EAAO,KAAA;AACX,IAAA,MAAM,MAAA,EAAQ,UAAA,CAAW,CAAA,EAAA,GAAM;AAAE,MAAA,GAAA,CAAI,CAAC,IAAA,EAAM;AAAE,QAAA,KAAA,EAAO,IAAA;AAAM,QAAA,OAAA,CAAQ,IAAI,CAAA;AAAA,MAAE;AAAA,IAAE,CAAA,EAAG,wBAAwB,CAAA;AACtG,IAAA,IAAI;AACF,MAAA,KAAA,CAAM,GAAA,CAAI,KAAA,EAAO,CAAC,IAAA,EAAA,GAAkB;AAClC,QAAA,GAAA,CAAI,CAAC,IAAA,EAAM;AAAE,UAAA,KAAA,EAAO,IAAA;AAAM,UAAA,YAAA,CAAa,KAAK,CAAA;AAAG,UAAA,OAAA,kBAAS,IAAA,UAAQ,MAA0B,CAAA;AAAA,QAAE;AAAA,MAC9F,CAAC,CAAA;AAAA,IACH,EAAA,WAAQ;AACN,MAAA,GAAA,CAAI,CAAC,IAAA,EAAM;AAAE,QAAA,KAAA,EAAO,IAAA;AAAM,QAAA,YAAA,CAAa,KAAK,CAAA;AAAG,QAAA,OAAA,CAAQ,IAAI,CAAA;AAAA,MAAE;AAAA,IAC/D;AAAA,EACF,CAAC,CAAA;AACH;AAGA,IAAM,QAAA,EAAU,CAAA;AAEhB,IAAM,WAAA,EAAa,IAAA;AAEnB,IAAM,mBAAA,EAAqB,GAAA;AAE3B,IAAM,sBAAA,EAAwB,IAAA,EAAM,KAAA,EAAO,IAAA;AAapC,SAAS,mBAAA,CAAA,EAAqC;AACnD,EAAA,OAAO,EAAE,UAAA,EAAY,CAAA,EAAG,UAAA,EAAY,CAAA,EAAG,IAAA,kBAAM,IAAI,GAAA,CAAI,CAAA,EAAG,UAAA,kBAAY,IAAI,GAAA,CAAI,CAAA,EAAG,SAAA,EAAW,MAAM,CAAA;AAClG;AAIA,SAAS,WAAA,CAAY,IAAA,EAA8C;AACjE,EAAA,OAAO,gCAAA,MAAiB,CAAA,CAAE,MAAA,CAAO,IAAI,CAAA,CAAE,MAAA,CAAO,KAAK,CAAA;AACrD;AAGA,IAAM,SAAA,EAAW,IAAI,UAAA,CAAW,CAAC,CAAA;AAGjC,SAAS,MAAA,CAAO,GAAA,EAAiB,QAAA,EAAU,IAAA,EAAyB;AAClE,EAAA,MAAM,EAAE,KAAA,EAAO,CAAA,EAAG,MAAA,EAAQ,CAAA,EAAG,IAAA,EAAM,KAAK,EAAA,EAAI,GAAA;AAC5C,EAAA,GAAA,CAAI,CAAC,KAAA,GAAQ,CAAC,EAAA,GAAK,CAAC,CAAA,EAAG,OAAO,IAAA;AAE9B,EAAA,GAAA,CAAI,KAAA,IAAS,iBAAA,CAAU,UAAA,EAAY;AACjC,IAAA,GAAA,CAAI,IAAA,CAAK,OAAA,EAAS,EAAA,EAAI,EAAA,EAAI,CAAA,EAAG,OAAO,IAAA;AACpC,IAAA,GAAA,CAAI,CAAC,OAAA,EAAS,OAAO,QAAA;AACrB,IAAA,MAAM,KAAA,EAAO,IAAI,UAAA,CAAW,EAAA,EAAI,EAAA,EAAI,CAAC,CAAA;AACrC,IAAA,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,QAAA,CAAS,CAAA,EAAG,EAAA,EAAI,EAAA,EAAI,CAAC,CAAC,CAAA;AACpC,IAAA,OAAO,IAAA;AAAA,EACT;AACA,EAAA,GAAA,CAAI,KAAA,IAAS,iBAAA,CAAU,SAAA,EAAW;AAChC,IAAA,GAAA,CAAI,IAAA,CAAK,OAAA,EAAS,EAAA,EAAI,EAAA,EAAI,CAAA,EAAG,OAAO,IAAA;AACpC,IAAA,GAAA,CAAI,CAAC,OAAA,EAAS,OAAO,QAAA;AACrB,IAAA,MAAM,KAAA,EAAO,IAAI,UAAA,CAAW,EAAA,EAAI,EAAA,EAAI,CAAC,CAAA;AACrC,IAAA,IAAA,CAAA,IAAS,EAAA,EAAI,CAAA,EAAG,EAAA,EAAI,CAAA,EAAG,EAAA,EAAI,CAAA,EAAG,EAAA,EAAI,EAAA,EAAI,CAAA,EAAG,CAAA,EAAA,EAAK,EAAA,GAAK,CAAA,EAAG,EAAA,GAAK,CAAA,EAAG;AAC5D,MAAA,IAAA,CAAK,CAAC,EAAA,EAAI,IAAA,CAAK,CAAC,CAAA;AAAG,MAAA,IAAA,CAAK,EAAA,EAAI,CAAC,EAAA,EAAI,IAAA,CAAK,EAAA,EAAI,CAAC,CAAA;AAAG,MAAA,IAAA,CAAK,EAAA,EAAI,CAAC,EAAA,EAAI,IAAA,CAAK,EAAA,EAAI,CAAC,CAAA;AAAG,MAAA,IAAA,CAAK,EAAA,EAAI,CAAC,EAAA,EAAI,GAAA;AAAA,IACzF;AACA,IAAA,OAAO,IAAA;AAAA,EACT;AACA,EAAA,GAAA,CAAI,KAAA,IAAS,iBAAA,CAAU,cAAA,EAAgB;AAErC,IAAA,MAAM,OAAA,EAAU,EAAA,EAAI,EAAA,GAAM,CAAA;AAC1B,IAAA,GAAA,CAAI,IAAA,CAAK,OAAA,EAAS,OAAA,EAAS,CAAA,EAAG,OAAO,IAAA;AACrC,IAAA,GAAA,CAAI,CAAC,OAAA,EAAS,OAAO,QAAA;AACrB,IAAA,MAAM,KAAA,EAAO,IAAI,UAAA,CAAW,EAAA,EAAI,EAAA,EAAI,CAAC,CAAA;AACrC,IAAA,IAAA,CAAA,IAAS,EAAA,EAAI,CAAA,EAAG,EAAA,EAAI,CAAA,EAAG,CAAA,EAAA,EAAK;AAC1B,MAAA,IAAA,CAAA,IAAS,EAAA,EAAI,CAAA,EAAG,EAAA,EAAI,CAAA,EAAG,CAAA,EAAA,EAAK;AAC1B,QAAA,MAAM,IAAA,EAAO,IAAA,CAAK,EAAA,EAAI,OAAA,EAAA,CAAU,EAAA,GAAK,CAAA,CAAE,EAAA,GAAM,EAAA,EAAA,CAAK,EAAA,EAAI,CAAA,EAAA,EAAO,CAAA;AAC7D,QAAA,MAAM,EAAA,EAAI,IAAA,EAAM,IAAA,EAAM,CAAA;AACtB,QAAA,MAAM,EAAA,EAAA,CAAK,EAAA,EAAI,EAAA,EAAI,CAAA,EAAA,EAAK,CAAA;AACxB,QAAA,IAAA,CAAK,CAAC,EAAA,EAAI,CAAA;AAAG,QAAA,IAAA,CAAK,EAAA,EAAI,CAAC,EAAA,EAAI,CAAA;AAAG,QAAA,IAAA,CAAK,EAAA,EAAI,CAAC,EAAA,EAAI,CAAA;AAAG,QAAA,IAAA,CAAK,EAAA,EAAI,CAAC,EAAA,EAAI,GAAA;AAAA,MAC/D;AAAA,IACF;AACA,IAAA,OAAO,IAAA;AAAA,EACT;AACA,EAAA,OAAO,IAAA;AACT;AAWA,MAAA,SAAsB,iBAAA,CACpB,IAAA,EACA,OAAA,EACA,SAAA,EACA,UAAA,EACA,KAAA,EACA,QAAA,EACA,UAAA,EAAY,IAAA,EAC8C;AAC1D,EAAA,MAAM,OAAA,EAAoB,CAAC,CAAA;AAC3B,EAAA,MAAM,OAAA,EAA2B,CAAC,CAAA;AAClC,EAAA,MAAM,YAAA,kBAAc,IAAI,GAAA,CAAY,CAAA;AAEpC,EAAA,IAAA,CAAA,IAAS,EAAA,EAAI,CAAA,EAAG,EAAA,EAAI,OAAA,CAAQ,MAAA,EAAQ,CAAA,EAAA,EAAK;AACvC,IAAA,MAAM,GAAA,EAAK,OAAA,CAAQ,CAAC,CAAA;AACpB,IAAA,IAAI,QAAA,EAA6B,IAAA;AACjC,IAAA,IAAI,SAAA,EAA0B,IAAA;AAE9B,IAAA,GAAA,CAAI,GAAA,IAAO,WAAA,CAAI,kBAAA,GAAqB,GAAA,IAAO,WAAA,CAAI,uBAAA,EAAyB;AACtE,MAAA,MAAM,MAAA,kBAAQ,SAAA,qBAAU,CAAC,CAAA,4BAAA,CAAI,CAAC,GAAA;AAC9B,MAAA,GAAA,CAAI,OAAO,MAAA,IAAU,SAAA,GAAY,WAAA,CAAY,GAAA,CAAI,KAAK,CAAA,EAAG,QAAA;AACzD,MAAA,WAAA,CAAY,GAAA,CAAI,KAAK,CAAA;AACrB,MAAA,SAAA,EAAW,KAAA;AAAA,IACb,EAAA,KAAA,GAAA,CAAW,GAAA,IAAO,WAAA,CAAI,uBAAA,EAAyB;AAC7C,MAAA,QAAA;AAAA,IACF;AAIA,IAAA,GAAA,CAAI,KAAA,CAAM,WAAA,GAAc,mBAAA,GAAsB,KAAA,CAAM,WAAA,GAAc,qBAAA,EAAuB;AACvF,MAAA,GAAA,CAAI,CAAC,KAAA,CAAM,SAAA,EAAW;AACpB,QAAA,KAAA,CAAM,UAAA,EAAY,IAAA;AAClB,QAAA,QAAA,CAAS,IAAA,CAAK,EAAE,IAAA,EAAM,UAAA,EAAY,OAAA,EAAS,CAAA,2DAAA,EAAiB,kBAAkB,CAAA,iEAAA,CAAA,EAAwB,IAAA,EAAM,gBAAgB,CAAC,CAAA;AAAA,MAC/H;AACA,MAAA,QAAA;AAAA,IACF;AAEA,IAAA,GAAA,CAAI,QAAA,EAAU;AACZ,MAAA,QAAA,EAAU,MAAM,cAAA,CAAe,IAAA,EAAM,QAAQ,CAAA;AAAA,IAC/C,EAAA,KAAO;AACL,MAAA,QAAA,kBAAU,SAAA,qBAAU,CAAC,CAAA,4BAAA,CAAI,CAAC,GAAA;AAAA,IAC5B;AAEA,IAAA,GAAA,CAAI,iBAAC,OAAA,6BAAS,OAAA,GAAQ,CAAC,OAAA,CAAQ,MAAA,GAAS,CAAC,OAAA,CAAQ,MAAA,EAAQ,QAAA;AACzD,IAAA,MAAM,EAAE,KAAA,EAAO,CAAA,EAAG,MAAA,EAAQ,EAAE,EAAA,EAAI,OAAA;AAChC,IAAA,GAAA,CAAI,EAAA,EAAI,QAAA,GAAW,EAAA,EAAI,OAAA,EAAS,QAAA;AAChC,IAAA,GAAA,CAAI,EAAA,EAAI,EAAA,EAAI,UAAA,EAAY;AACtB,MAAA,QAAA,CAAS,IAAA,CAAK,EAAE,IAAA,EAAM,UAAA,EAAY,OAAA,EAAS,CAAA,8EAAA,EAAqB,CAAC,CAAA,IAAA,EAAI,CAAC,CAAA,CAAA,CAAA,EAAK,IAAA,EAAM,gBAAgB,CAAC,CAAA;AAClG,MAAA,QAAA;AAAA,IACF;AAGA,IAAA,MAAM,SAAA,kBAAW,QAAA,6BAAU,UAAA,mBAAW,IAAI,IAAA,EAAI,SAAA,EAAW,KAAA,CAAA;AACzD,IAAA,IAAI,KAAA,EAAO,SAAA,EAAW,KAAA,CAAM,UAAA,CAAW,GAAA,CAAI,QAAQ,EAAA,EAAI,KAAA,CAAA;AACvD,IAAA,GAAA,CAAI,KAAA,IAAS,KAAA,CAAA,EAAW;AACtB,MAAA,KAAA,EAAO,CAAA,EAAA;AACH,MAAA;AACN,IAAA;AACU,IAAA;AAEJ,IAAA;AACK,IAAA;AAEL,IAAA;AACA,IAAA;AACA,IAAA;AACF,IAAA;AACI,MAAA;AACA,MAAA;AACC,MAAA;AACT,IAAA;AACO,IAAA;AACT,EAAA;AAES,EAAA;AACX;AAOgB;AACV,EAAA;AAGE,EAAA;AACG,EAAA;AACG,IAAA;AACA,IAAA;AACZ,EAAA;AACM,EAAA;AACM,EAAA;AAEN,EAAA;AACA,EAAA;AACG,EAAA;AACA,IAAA;AACG,IAAA;AACA,IAAA;AACD,MAAA;AACP,MAAA;AACF,IAAA;AACF,EAAA;AAEW,EAAA;AACL,IAAA;AACK,IAAA;AACA,IAAA;AACD,MAAA;AACF,MAAA;AAAmC,QAAA;AAAG,QAAA;AAAM,MAAA;AAClD,IAAA;AACO,IAAA;AACT,EAAA;AAEO,EAAA;AACI,EAAA;AACb;ADIc;AACA;AEnNR;AAGA;AAEA;AAEA;AAMA;AAMA;AAGU;AACF,EAAA;AACR,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACM,EAAA;AAED,EAAA;AACD,IAAA;AAEF,IAAA;AACJ,IAAA;AAGK,IAAA;AACH,MAAA;AACA,MAAA;AACF,IAAA;AACI,IAAA;AACF,MAAA;AACA,MAAA;AACF,IAAA;AAEI,IAAA;AACF,MAAA;AACM,MAAA;AACF,MAAA;AAAY,MAAA;AAEhB,MAAA;AACF,IAAA;AAGK,IAAA;AACH,MAAA;AACA,MAAA;AACF,IAAA;AACF,EAAA;AAEM,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AAIA,EAAA;AAKF,EAAA;AACA,EAAA;AAGA,EAAA;AACF,IAAA;AAAiB,IAAA;AAEV,EAAA;AAA8B,IAAA;AAAiB,IAAA;AAC/C,EAAA;AAAkC,IAAA;AAAiB,IAAA;AACnD,EAAA;AAA8C,IAAA;AAAiB,IAAA;AAC/D,EAAA;AAAsD,IAAA;AAAiB,IAAA;AACvE,EAAA;AAAiB,IAAA;AAAiB,IAAA;AAA6B,EAAA;AAEjE,EAAA;AACL,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACF,EAAA;AACF;AAoBM;AACA;AACA;AACA;AAEA;AAMU;AAEF,EAAA;AACd;AAEgB;AACJ,EAAA;AACD,IAAA;AACL,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACF,IAAA;AACF,EAAA;AAEI,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACM,EAAA;AACN,EAAA;AACA,EAAA;AACE,EAAA;AAEK,EAAA;AACT,IAAA;AACU,IAAA;AACV,IAAA;AACA,IAAA;AACS,IAAA;AACH,IAAA;AACA,IAAA;AAEA,IAAA;AACR,EAAA;AAEU,EAAA;AACH,EAAA;AACL,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACU,IAAA;AACV,IAAA;AACF,EAAA;AACF;AF+Jc;AACA;AGnXL;AAeH;AACA;AACA;AACA;AACA;AAEA;AACU;AAEV;AAEA;AACA;AACA;AAGe;AACT,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACZ;AAMgB;AACW,EAAA;AACT,EAAA;AACJ,EAAA;AACN,EAAA;AACG,EAAA;AACE,EAAA;AACU,EAAA;AACT,EAAA;AACJ,IAAA;AACG,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACX,EAAA;AACM,EAAA;AAAqB,IAAA;AAAe,IAAA;AAAe,IAAA;AAAgB,IAAA;AAAkB,IAAA;AAAU,IAAA;AAAW,IAAA;AAAU,IAAA;AAAE,EAAA;AACnH,EAAA;AACD,IAAA;AACA,IAAA;AACE,IAAA;AACDA,MAAAA;AAAgB,QAAA;AAAoB,QAAA;AACpCA,MAAAA;AAAmB,QAAA;AAAiD,QAAA;AACpEA,MAAAA;AAAqB,QAAA;AAA4B,QAAA;AACjDA,MAAAA;AACG,QAAA;AACA,QAAA;AACF,QAAA;AACJ,QAAA;AACF,MAAA;AACKA,MAAAA;AAA+B,QAAA;AAAiD,QAAA;AAChFA,MAAAA;AACG,QAAA;AACN,QAAA;AACA,QAAA;AACF,MAAA;AACKA,MAAAA;AACG,QAAA;AACD,QAAA;AACD,QAAA;AACA,QAAA;AACJ,QAAA;AACM,UAAA;AAAoB,YAAA;AAAiC,YAAA;AAAS,YAAA;AAAO,UAAA;AAC5C,YAAA;AAAiC,YAAA;AAAS,YAAA;AAAO,UAAA;AAChD,YAAA;AAAiC,YAAA;AAAqC,YAAA;AAAqC,YAAA;AAAS,YAAA;AAAO,UAAA;AACpG,YAAA;AAAiC,YAAA;AAAqC,YAAA;AAAS,YAAA;AAAO,UAAA;AAEzI,YAAA;AACA,YAAA;AAAa,YAAA;AAAuB,YAAA;AAAkB,YAAA;AACtD,YAAA;AAAS,YAAA;AAAS,YAAA;AAAQ,YAAA;AAC5B,UAAA;AACF,QAAA;AACA,QAAA;AACF,MAAA;AACKA,MAAAA;AAAeA,MAAAA;AAAiBA,MAAAA;AAAqBA,MAAAA;AACrDA,MAAAA;AAA0BA,MAAAA;AACvB,QAAA;AACF,QAAA;AAEE,UAAA;AACN,QAAA;AACM,QAAA;AACN,QAAA;AACF,MAAA;AACKA,MAAAA;AAAiBA,MAAAA;AAAsBA,MAAAA;AACpC,QAAA;AACN,QAAA;AACJ,IAAA;AACF,EAAA;AACO,EAAA;AACT;AAMS;AACK,EAAA;AACD,EAAA;AACC,IAAA;AACZ,EAAA;AACO,EAAA;AACT;AAEgB;AAAsC,EAAA;AAA0C,EAAA;AAAa;AAC7F;AAAoC,EAAA;AAAkB,EAAA;AAAoC;AAGjG;AACD,EAAA;AACG,EAAA;AACL,EAAA;AACK,EAAA;AACL,EAAA;AACE,EAAA;AACK,EAAA;AACF,EAAA;AACH,EAAA;AACC,EAAA;AACT;AAUgB;AACR,EAAA;AACI,EAAA;AACJ,EAAA;AAEA,EAAA;AACF,EAAA;AACoB,EAAA;AACZ,EAAA;AACN,EAAA;AACI,IAAA;AACE,IAAA;AACJ,IAAA;AACF,IAAA;AACK,IAAA;AACC,MAAA;AAAyD,QAAA;AAAoC,QAAA;AAAS,MAAA;AACxG,MAAA;AACF,MAAA;AACI,MAAA;AAAc,QAAA;AAAW,QAAA;AAAiB,MAAA;AACpD,IAAA;AACF,EAAA;AACW,EAAA;AACH,IAAA;AACE,IAAA;AAAiE,MAAA;AAAS,MAAA;AAAS,IAAA;AAClF,IAAA;AACD,IAAA;AAAW,IAAA;AAAW,IAAA;AAChC,EAAA;AACS,EAAA;AACA,EAAA;AACX;AAYgB;AAIR,EAAA;AACK,EAAA;AACL,EAAA;AACA,EAAA;AACG,EAAA;AACD,IAAA;AACG,IAAA;AACD,IAAA;AACE,IAAA;AACZ,EAAA;AACS,EAAA;AACX;AHyXc;AACA;AIxlBD;AACA;AAIE;AAEC;AAEA;AACH,EAAA;AACC,IAAA;AACA,IAAA;AACJ,IAAA;AACD,IAAA;AAEC,IAAA;AACE,MAAA;AACF,MAAA;AACE,MAAA;AACC,MAAA;AACR,IAAA;AACG,IAAA;AACC,IAAA;AAEC,IAAA;AACG,IAAA;AACT,IAAA;AACQ,MAAA;AACN,MAAA;AACQ,QAAA;AACA,QAAA;AACF,QAAA;AAAuB,UAAA;AAAY,UAAA;AAAM,QAAA;AACvC,QAAA;AAA2B,UAAA;AAAe,QAAA;AAC3C,QAAA;AACP,MAAA;AACS,MAAA;AACJ,MAAA;AACP,IAAA;AACS,IAAA;AACH,IAAA;AACA,IAAA;AACI,IAAA;AACD,IAAA;AACX,EAAA;AACF;AJwlBc;AACA;AKroBR;AAGG;AACc,EAAA;AACV,EAAA;AACH,IAAA;AACI,IAAA;AACZ,EAAA;AACO,EAAA;AACT;AAEM;AACO,EAAA;AACb;AAGgB;AACL,EAAA;AACH,EAAA;AACI,EAAA;AACJ,IAAA;AACE,IAAA;AACF,IAAA;AACL,EAAA;AACH;AAGS;AACD,EAAA;AACA,EAAA;AACG,EAAA;AACD,IAAA;AACG,IAAA;AACH,MAAA;AACE,MAAA;AACD,MAAA;AACL,MAAA;AACO,MAAA;AACT,IAAA;AACU,IAAA;AACZ,EAAA;AACO,EAAA;AACT;AAGM;AAIG;AACG,EAAA;AACA,EAAA;AACE,EAAA;AACd;AAIS;AACD,EAAA;AACK,EAAA;AACA,EAAA;AACqB,IAAA;AAC9B,IAAA;AACQ,MAAA;AACF,MAAA;AAA6C,MAAA;AAEnD,IAAA;AACM,IAAA;AACC,IAAA;AACD,IAAA;AACR,EAAA;AACO,EAAA;AACT;AAQgB;AACH,EAAA;AACD,EAAA;AACC,EAAA;AACC,EAAA;AACN,EAAA;AACD,EAAA;AACGC,IAAAA;AACN,IAAA;AACMA,MAAAA;AAEE,MAAA;AACG,MAAA;AACX,IAAA;AACD,EAAA;AACU,EAAA;AACF,EAAA;AACH,EAAA;AAEK,EAAA;AACL,EAAA;AACK,EAAA;AACA,IAAA;AACH,IAAA;AAEK,IAAA;AACL,IAAA;AAEA,IAAA;AAEI,IAAA;AACA,IAAA;AACZ,EAAA;AACF;AAGS;AACH,EAAA;AACK,EAAA;AACDC,IAAAA;AACG,IAAA;AACH,IAAA;AACA,IAAA;AACG,IAAA;AACX,EAAA;AACM,EAAA;AACI,EAAA;AACC,EAAA;AACD,EAAA;AACJ,EAAA;AACC,EAAA;AACG,IAAA;AACA,IAAA;AACA,IAAA;AACF,IAAA;AACR,EAAA;AACF;AAMS;AACD,EAAA;AACA,EAAA;AACG,EAAA;AACD,IAAA;AACD,IAAA;AAEC,IAAA;AACE,MAAA;AACA,MAAA;AACF,MAAA;AACE,MAAA;AACC,MAAA;AACT,IAAA;AACU,IAAA;AACJ,IAAA;AACA,IAAA;AACN,IAAA;AACS,MAAA;AACD,MAAA;AACN,MAAA;AACA,MAAA;AACK,MAAA;AACP,IAAA;AACK,IAAA;AACH,MAAA;AACM,QAAA;AACE,QAAA;AACR,MAAA;AACD,IAAA;AACK,IAAA;AACC,IAAA;AACT,EAAA;AACO,EAAA;AACT;AAOgB;AACP,EAAA;AACC,IAAA;AACI,IAAA;AACJ,IAAA;AACA,IAAA;AACG,IAAA;AACA,IAAA;AACF,IAAA;AACC,MAAA;AACA,MAAA;AAEA,MAAA;AACC,MAAA;AACR,IAAA;AACF,EAAA;AACH;AL6lBc;AACA;AM5xBR;AAGA;AAEA;AAKG;AACe,EAAA;AAChB,EAAA;AACG,EAAA;AACE,IAAA;AACH,MAAA;AACE,MAAA;AACD,MAAA;AACD,MAAA;AACJ,MAAA;AACE,QAAA;AACF,MAAA;AACF,IAAA;AACF,EAAA;AACO,EAAA;AACT;AAQS;AAED,EAAA;AACM,EAAA;AACN,EAAA;AACK,EAAA;AACC,EAAA;AACA,EAAA;AACA,EAAA;AAA8D,IAAA;AAAiB,IAAA;AAAG,EAAA;AAClF,EAAA;AACD,EAAA;AAAmC,IAAA;AAAiB,IAAA;AAAE,EAAA;AACrD,EAAA;AACd;AAEgB;AACH,EAAA;AACF,EAAA;AACG,IAAA;AACF,IAAA;AAAiB,MAAA;AAAU,MAAA;AAAE,IAAA;AACvC,EAAA;AACO,EAAA;AACT;AAEgB;AAGA;AASA;AACN,EAAA;AACG,EAAA;AACC,EAAA;AACN,EAAA;AACK,EAAA;AACA,EAAA;AAIA,EAAA;AACJ,EAAA;AACE,EAAA;AACA,IAAA;AACA,IAAA;AACT,EAAA;AAIM,EAAA;AACE,IAAA;AACA,IAAA;AACG,IAAA;AACX,EAAA;AACS,EAAA;AACH,IAAA;AACA,IAAA;AACG,IAAA;AACT,EAAA;AAEM,EAAA;AACA,EAAA;AACA,EAAA;AAEM,EAAA;AACN,EAAA;AACE,IAAA;AACG,IAAA;AACC,IAAA;AACJ,IAAA;AACG,IAAA;AACC,MAAA;AACV,IAAA;AACU,IAAA;AAEJ,IAAA;AACK,IAAA;AACP,IAAA;AACE,IAAA;AACF,IAAA;AAAS,MAAA;AAA4C,MAAA;AAAK,IAAA;AACvD,IAAA;AACT,EAAA;AAEW,EAAA;AACA,EAAA;AACC,IAAA;AACL,IAAA;AACP,EAAA;AAGM,EAAA;AACG,EAAA;AACD,IAAA;AACD,IAAA;AACG,IAAA;AACD,IAAA;AACG,IAAA;AAEJ,IAAA;AACG,IAAA;AACA,IAAA;AACP,MAAA;AAAiF,QAAA;AAAO,QAAA;AAAM,MAAA;AAChG,IAAA;AACS,IAAA;AACH,IAAA;AACD,IAAA;AACC,IAAA;AACG,IAAA;AACX,EAAA;AAGS,EAAA;AAEL,EAAA;AAAgB,IAAA;AAAkH,EAAA;AAI7H,EAAA;AACC,IAAA;AACD,IAAA;AACD,IAAA;AACG,IAAA;AACX,EAAA;AACM,EAAA;AAKK,EAAA;AACL,EAAA;AACW,EAAA;AACP,IAAA;AACC,IAAA;AACF,IAAA;AACT,EAAA;AACY,EAAA;AAID,EAAA;AAED,EAAA;AACF,IAAA;AACI,IAAA;AACZ,EAAA;AAGS,EAAA;AACX;AAYS;AACD,EAAA;AACA,EAAA;AAGF,EAAA;AACK,EAAA;AACG,IAAA;AACD,IAAA;AAA8E,MAAA;AAAc,MAAA;AAAM,IAAA;AAC7G,EAAA;AAII,EAAA;AACK,EAAA;AACG,IAAA;AACF,IAAA;AAAE,MAAA;AAAK,MAAA;AAAS,IAAA;AACd,IAAA;AACA,IAAA;AAGJ,IAAA;AACA,IAAA;AACF,IAAA;AACN,EAAA;AAKM,EAAA;AACA,EAAA;AACF,EAAA;AACQ,IAAA;AACF,IAAA;AAAE,MAAA;AAAK,MAAA;AAAS,IAAA;AACd,IAAA;AACA,IAAA;AACA,IAAA;AACN,IAAA;AACN,EAAA;AAEI,EAAA;AACK,EAAA;AACG,IAAA;AACF,IAAA;AAAE,MAAA;AAAK,MAAA;AAAS,IAAA;AACd,IAAA;AACD,IAAA;AACX,EAAA;AACI,EAAA;AACK,EAAA;AACG,IAAA;AACF,IAAA;AAAE,MAAA;AAAK,MAAA;AAAS,IAAA;AACd,IAAA;AACA,IAAA;AACA,IAAA;AAMJ,IAAA;AAKD,IAAA;AACE,MAAA;AAGC,MAAA;AACA,MAAA;AACE,MAAA;AACF,MAAA;AACE,QAAA;AACA,QAAA;AAED,QAAA;AACP,MAAA;AACF,IAAA;AACA,IAAA;AACE,IAAA;AACI,IAAA;AACA,IAAA;AACG,IAAA;AACA,IAAA;AACX,EAAA;AACO,EAAA;AACT;AAGS;AACG,EAAA;AACD,EAAA;AACX;AAQS;AACD,EAAA;AACK,EAAA;AACD,EAAA;AACZ;AAGM;AAEA;AAEA;AACA;AAEA;AAEA;AACA;AACA;AAEA;AAEA;AAEA;AAEA;AACA;AAGA;AACA;AAGU;AACJ,EAAA;AACC,EAAA;AACL,EAAA;AAGM,EAAA;AACA,EAAA;AACA,EAAA;AACL,EAAA;AACT;AAOS;AACG,EAAA;AACF,EAAA;AACF,EAAA;AACK,EAAA;AACA,EAAA;AACA,EAAA;AAAiB,IAAA;AAA4B,IAAA;AAAoB,EAAA;AACjE,EAAA;AAAiB,IAAA;AAA4B,IAAA;AAAoB,EAAA;AAIhE,EAAA;AACD,EAAA;AAGL,EAAA;AAEA,EAAA;AACG,EAAA;AAKH,EAAA;AACM,EAAA;AACN,EAAA;AAKA,EAAA;AAKA,EAAA;AAMK,EAAA;AAEL,EAAA;AAGG,EAAA;AACX;AAGM;AAEA;AAOG;AACG,EAAA;AACA,EAAA;AACD,EAAA;AACX;AAKS;AACG,EAAA;AACE,EAAA;AACA,EAAA;AAGD,EAAA;AACD,EAAA;AACG,EAAA;AACf;AAIS;AACD,EAAA;AACM,EAAA;AACD,EAAA;AACJ,EAAA;AACT;AAMS;AACD,EAAA;AACE,IAAA;AAEJ,IAAA;AACJ,EAAA;AACW,EAAA;AACX,EAAA;AACF;AAGM;AAoBA;AACU;AAAqC,EAAA;AAAuC,EAAA;AAA+B;AAKlH;AACD,EAAA;AACD,EAAA;AACC,EAAA;AACE,EAAA;AACV;AAOS;AACA,EAAA;AACT;AAOS;AACG,EAAA;AACF,EAAA;AACF,EAAA;AACC,EAAA;AACT;AAGM;AAEG;AACA,EAAA;AACC,EAAA;AACD,EAAA;AACE,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACF,EAAA;AACT;AAQS;AACD,EAAA;AACK,EAAA;AACL,EAAA;AACK,EAAA;AACL,EAAA;AAA2B,IAAA;AAA+B,MAAA;AAAG,MAAA;AAAQ,IAAA;AAAG,EAAA;AACvE,EAAA;AACT;AAGS;AACD,EAAA;AACC,EAAA;AACT;AAIoB;AAEX;AACD,EAAA;AAGA,EAAA;AACF,EAAA;AACK,EAAA;AACG,IAAA;AAIA,IAAA;AAAU,MAAA;AAA8D,MAAA;AAAK,MAAA;AAAS,IAAA;AACtF,IAAA;AACA,IAAA;AACF,MAAA;AAKA,MAAA;AACA,MAAA;AACR,IAAA;AAEU,IAAA;AACZ,EAAA;AAGM,EAAA;AAEA,EAAA;AAGA,EAAA;AAAmD,IAAA;AAAgC,IAAA;AAAgC,EAAA;AAC/G,EAAA;AAA6B,IAAA;AAA6C,IAAA;AAAqC,EAAA;AAQnH,EAAA;AACA,EAAA;AAED,EAAA;AAKG,IAAA;AACI,IAAA;AAIJ,IAAA;AACI,IAAA;AAEJ,IAAA;AAGI,IAAA;AAGJ,MAAA;AAGE,MAAA;AACD,MAAA;AACP,IAAA;AACF,EAAA;AACY,EAAA;AACV,IAAA;AAEM,IAAA;AACR,EAAA;AAES,EAAA;AACG,IAAA;AACD,IAAA;AACX,EAAA;AACY,EAAA;AACN,EAAA;AACC,EAAA;AACT;AAeM;AAOG;AACM,EAAA;AACP,EAAA;AACK,EAAA;AACC,EAAA;AACL,EAAA;AACT;AAGM;AAOG;AACD,EAAA;AACM,EAAA;AACJ,EAAA;AACF,EAAA;AACA,EAAA;AACC,EAAA;AACT;AAGS;AACI,EAAA;AACb;AAOS;AACD,EAAA;AACN,EAAA;AACY,EAAA;AACL,EAAA;AACK,EAAA;AACD,EAAA;AACA,EAAA;AACb;AAGS;AACA,EAAA;AACK,EAAA;AACH,EAAA;AACX;AAGS;AACA,EAAA;AAET;AAGS;AACI,EAAA;AACA,EAAA;AACF,EAAA;AACE,IAAA;AACA,IAAA;AACX,EAAA;AACO,EAAA;AACT;AAEgB;AACd,EAAA;AACS,EAAA;AACD,IAAA;AACG,IAAA;AAID,IAAA;AACF,IAAA;AACC,IAAA;AACD,IAAA;AAGA,IAAA;AACI,IAAA;AACL,IAAA;AAED,IAAA;AACE,IAAA;AAGF,IAAA;AAEF,MAAA;AACA,MAAA;AACF,IAAA;AAGI,IAAA;AAAsC,MAAA;AAAc,MAAA;AAAK,MAAA;AAAS,IAAA;AAClE,IAAA;AACK,IAAA;AAGH,IAAA;AACA,IAAA;AACA,IAAA;AACF,IAAA;AAME,IAAA;AACI,IAAA;AAIL,IAAA;AAKD,IAAA;AACE,IAAA;AACF,IAAA;AACA,IAAA;AAGA,IAAA;AACA,IAAA;AACK,MAAA;AACP,MAAA;AACF,IAAA;AAIU,IAAA;AAGF,MAAA;AACA,MAAA;AACF,MAAA;AACI,QAAA;AACN,QAAA;AACE,UAAA;AACA,UAAA;AACF,QAAA;AACA,QAAA;AACA,QAAA;AACA,QAAA;AACF,MAAA;AACF,IAAA;AAEM,IAAA;AACE,MAAA;AACA,MAAA;AACE,MAAA;AACR,MAAA;AACA,MAAA;AACS,MAAA;AACX,IAAA;AACU,IAAA;AACN,IAAA;AACJ,IAAA;AACF,EAAA;AAES,EAAA;AACG,IAAA;AACN,IAAA;AACN,EAAA;AACF;AASS;AACF,EAAA;AACI,EAAA;AACG,IAAA;AACJ,IAAA;AACA,IAAA;AACD,IAAA;AAEI,IAAA;AACF,IAAA;AACA,IAAA;AACC,IAAA;AACC,IAAA;AACDC,MAAAA;AACF,MAAA;AACEC,MAAAA;AACFA,MAAAA;AACI,MAAA;AACV,IAAA;AACW,IAAA;AACD,IAAA;AACD,IAAA;AACJ,IAAA;AACC,IAAA;AACD,IAAA;AACC,IAAA;AACA,IAAA;AACG,IAAA;AACF,IAAA;AACE,IAAA;AACX,EAAA;AACF;AAQS;AACI,EAAA;AACN,EAAA;AACM,EAAA;AACC,EAAA;AACH,EAAA;AACJ,EAAA;AAEE,IAAA;AAKG,IAAA;AAKC,IAAA;AACA,IAAA;AAAqD,MAAA;AAAkB,MAAA;AAAsB,IAAA;AAC9F,IAAA;AAOF,IAAA;AACE,MAAA;AACC,MAAA;AACT,IAAA;AAIU,IAAA;AAEZ,EAAA;AACY,EAAA;AACP,EAAA;AACO,EAAA;AACD,EAAA;AACH,EAAA;AACG,EAAA;AACC,EAAA;AACD,EAAA;AACb;AAOS;AACI,EAAA;AACA,EAAA;AACA,EAAA;AACb;AAGS;AACD,EAAA;AACI,EAAA;AACH,EAAA;AACT;AAGM;AAQG;AACI,EAAA;AACD,EAAA;AACJ,EAAA;AACA,EAAA;AACD,EAAA;AAEC,EAAA;AACC,EAAA;AACT;AAUS;AACF,EAAA;AACC,EAAA;AACM,EAAA;AACd;AAGS;AACI,EAAA;AACA,EAAA;AACL,EAAA;AACI,EAAA;AACD,EAAA;AACP,IAAA;AACQ,MAAA;AACA,MAAA;AACF,MAAA;AACN,IAAA;AACF,EAAA;AACS,EAAA;AACX;AAKS;AACK,EAAA;AACA,EAAA;AACd;AAGS;AACG,EAAA;AACE,EAAA;AACL,EAAA;AACT;AAQS;AACI,EAAA;AACF,EAAA;AACH,EAAA;AACC,EAAA;AACA,EAAA;AAGH,EAAA;AACO,EAAA;AACA,EAAA;AACA,EAAA;AACH,EAAA;AACG,EAAA;AACF,EAAA;AACE,IAAA;AACC,IAAA;AACL,IAAA;AACP,EAAA;AACO,EAAA;AACT;AAOS;AACE,EAAA;AACH,EAAA;AACI,EAAA;AACJ,EAAA;AACK,EAAA;AACC,EAAA;AAEN,EAAA;AACD,EAAA;AACC,EAAA;AACA,EAAA;AACC,EAAA;AACT;AAOS;AACC,EAAA;AACC,EAAA;AACD,IAAA;AACE,IAAA;AACD,IAAA;AACC,IAAA;AAEC,IAAA;AACH,MAAA;AAAyF,QAAA;AAAG,QAAA;AAAM,MAAA;AACxG,IAAA;AACM,IAAA;AACC,IAAA;AACF,IAAA;AACA,IAAA;AACC,IAAA;AACD,IAAA;AACC,IAAA;AACD,IAAA;AACD,IAAA;AACN,EAAA;AACO,EAAA;AACT;AAGM;AAEG;AACD,EAAA;AACA,EAAA;AACG,EAAA;AACD,IAAA;AACF,IAAA;AACA,IAAA;AACK,IAAA;AACD,MAAA;AACF,MAAA;AAEN,IAAA;AACK,IAAA;AAEA,IAAA;AAAsB,MAAA;AAAqC,MAAA;AAAS,IAAA;AAChE,IAAA;AACD,MAAA;AACC,MAAA;AAEF,MAAA;AACA,MAAA;AACL,MAAA;AACO,MAAA;AACT,IAAA;AACF,EAAA;AACF;AAGS;AACF,EAAA;AACM,EAAA;AACC,EAAA;AACH,EAAA;AACP,IAAA;AACF,EAAA;AACI,EAAA;AACM,EAAA;AACJ,EAAA;AACG,EAAA;AAEX;AAGS;AACD,EAAA;AACA,EAAA;AACG,EAAA;AACE,IAAA;AACX,EAAA;AAES,EAAA;AACX;AAGM;AAGA;AAGG;AACA,EAAA;AACI,EAAA;AACD,EAAA;AACZ;AAEM;AAEA;AAEA;AACA;AN2ZQ;AACA;AO/hDR;AACA;AAGG;AACI,EAAA;AACL,EAAA;AACG,EAAA;AACE,IAAA;AACH,MAAA;AACE,MAAA;AACD,MAAA;AACL,MAAA;AACQ,MAAA;AACJ,MAAA;AACN,IAAA;AACF,EAAA;AACO,EAAA;AACT;AAGM;AAIU;AACL,EAAA;AACD,IAAA;AACA,IAAA;AACD,IAAA;AAEG,IAAA;AACD,IAAA;AACD,IAAA;AACE,IAAA;AACF,IAAA;AACA,IAAA;AACD,IAAA;AACC,IAAA;AACG,IAAA;AACA,IAAA;AACJ,IAAA;AACL,IAAA;AAGM,IAAA;AACF,IAAA;AACK,IAAA;AAEA,IAAA;AACF,IAAA;AAEE,IAAA;AACD,MAAA;AACN,MAAA;AACI,MAAA;AACN,IAAA;AACS,IAAA;AACX,EAAA;AACF;AAKS;AACD,EAAA;AACK,EAAA;AACL,EAAA;AACA,EAAA;AACD,EAAA;AAEM,EAAA;AACC,EAAA;AACD,EAAA;AACC,EAAA;AAEL,EAAA;AACT;AAKS;AACD,EAAA;AACI,EAAA;AACC,EAAA;AAEA,EAAA;AACL,EAAA;AACM,EAAA;AACP,EAAA;AACO,EAAA;AACJ,EAAA;AACR,EAAA;AACO,EAAA;AACT;AP0gDc;AACA;AQhnDL;AAGI;AAKe;AACA;AAAkC,EAAA;AAAsB,EAAA;AAAyB;AACjF;AAGnB;AACK,EAAA;AACN,EAAA;AACI,EAAA;AACD,EAAA;AACG,IAAA;AACF,IAAA;AACV,EAAA;AACO,EAAA;AACT;AAGS;AACG,EAAA;AACD,EAAA;AACD,IAAA;AACF,IAAA;AACI,MAAA;AACN,MAAA;AACQ,QAAA;AACF,QAAA;AACE,QAAA;AACD,QAAA;AACCC,QAAAA;AACN,QAAA;AACF,MAAA;AACF,IAAA;AACQ,MAAA;AACN,MAAA;AACQ,QAAA;AACF,QAAA;AACN,MAAA;AACF,IAAA;AACF,EAAA;AACO,EAAA;AACT;AAGgB;AACJ,EAAA;AACJ,EAAA;AACF,EAAA;AACQ,EAAA;AACC,EAAA;AACP,EAAA;AACI,EAAA;AACA,EAAA;AACE,EAAA;AACF,EAAA;AACA,EAAA;AACJ,EAAA;AACG,EAAA;AACE,EAAA;AACF,EAAA;AACDA,EAAAA;AACC,EAAA;AACH,EAAA;AACC,EAAA;AACK,IAAA;AACJ,IAAA;AACK,IAAA;AACb,EAAA;AACsB,EAAA;AACX,EAAA;AACA,EAAA;AAAsB,IAAA;AAAW,MAAA;AAAiB,MAAA;AAAgB,IAAA;AAA6B,MAAA;AAAgB,MAAA;AAAE,IAAA;AAAW,IAAA;AAAE,EAAA;AAC9H,EAAA;AAAsB,IAAA;AAAW,MAAA;AAAiB,MAAA;AAAgB,IAAA;AAA6B,MAAA;AAAgB,MAAA;AAAE,IAAA;AAAW,IAAA;AAAE,EAAA;AACnI,EAAA;AACE,EAAA;AACG,EAAA;AAAsB,IAAA;AAAwF,IAAA;AAAE,EAAA;AAClH,EAAA;AACX;AAGe;AACJ,EAAA;AACG,EAAA;AAAwB,IAAA;AAAoB,IAAA;AAAoB,IAAA;AAAoB,IAAA;AAAW,EAAA;AAClG,EAAA;AACX;AAEgB;AACO,EAAA;AACV,EAAA;AACJ,EAAA;AACT;AACgB;AAGA;AACR,EAAA;AACM,EAAA;AACD,EAAA;AACF,EAAA;AACL,EAAA;AACQ,IAAA;AAEJ,IAAA;AACE,MAAA;AACA,MAAA;AACF,MAAA;AACG,MAAA;AACT,IAAA;AAEM,IAAA;AACE,MAAA;AACE,MAAA;AACA,MAAA;AACV,IAAA;AACO,IAAA;AACT,EAAA;AACI,EAAA;AACQ,IAAA;AACJ,IAAA;AACF,IAAA;AACE,IAAA;AACA,IAAA;AAEG,IAAA;AAEH,IAAA;AACC,IAAA;AACC,MAAA;AACA,MAAA;AACF,MAAA;AACA,MAAA;AACN,IAAA;AACF,EAAA;AACO,EAAA;AACT;AAQgB;AAEH,EAAA;AACL,EAAA;AACA,EAAA;AACA,EAAA;AACK,EAAA;AACF,EAAA;AACD,IAAA;AACF,IAAA;AACK,IAAA;AACA,IAAA;AACA,IAAA;AACP,MAAA;AACQ,QAAA;AACD,QAAA;AACC,QAAA;AACD,QAAA;AACC,UAAA;AACA,UAAA;AACF,YAAA;AACA,YAAA;AACA,YAAA;AACA,YAAA;AACF,UAAA;AAAiC,UAAA;AACjC,UAAA;AACF,QAAA;AACI,QAAA;AACC,QAAA;AACH,QAAA;AACJ,MAAA;AACF,IAAA;AACF,EAAA;AACM,EAAA;AACK,EAAA;AACL,IAAA;AACM,IAAA;AACN,IAAA;AACO,IAAA;AACD,IAAA;AACZ,EAAA;AACY,EAAA;AACA,IAAA;AACJ,IAAA;AACI,IAAA;AACF,IAAA;AACR,IAAA;AACF,EAAA;AACF;ARmnDc;AACA;AS7zDE;AACF,EAAA;AACD,EAAA;AACA,IAAA;AACP,MAAA;AACM,QAAA;AACA,QAAA;AACN,MAAA;AACF,IAAA;AACF,EAAA;AACW,EAAA;AACb;AAGgB;AACH,EAAA;AACL,EAAA;AACA,EAAA;AACK,EAAA;AAA2B,IAAA;AAAc,IAAA;AAAG,EAAA;AAChD,EAAA;AACC,IAAA;AACG,IAAA;AACC,IAAA;AACX,EAAA;AACH;ATg0Dc;AACA;AU/0DR;AACI,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AACpF,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AACpF,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AACpF,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AAChF;AAGgB;AACT,EAAA;AAED,EAAA;AACG,EAAA;AACT;AAGgB;AACJ,EAAA;AACC,EAAA;AACL,IAAA;AACA,IAAA;AACA,IAAA;AACI,MAAA;AACC,MAAA;AACF,IAAA;AACE,MAAA;AACT,IAAA;AACF,EAAA;AACO,EAAA;AACT;AAOgB;AACR,EAAA;AACA,EAAA;AACF,EAAA;AACuB,EAAA;AACrB,EAAA;AACK,EAAA;AACD,IAAA;AAGH,IAAA;AACD,IAAA;AACI,MAAA;AACF,MAAA;AAAgB,QAAA;AAAkB,QAAA;AAAI,QAAA;AAAU,MAAA;AACjB,QAAA;AAAS,QAAA;AAAU,MAAA;AACyC,QAAA;AAAiB,QAAA;AAAS,QAAA;AAAU,MAAA;AACnI,MAAA;AACF,IAAA;AACI,IAAA;AACC,IAAA;AACK,MAAA;AACF,MAAA;AACR,IAAA;AACK,IAAA;AACC,IAAA;AACF,IAAA;AAAyB,MAAA;AAAe,MAAA;AAAU,IAAA;AACxD,EAAA;AACS,EAAA;AAAe,IAAA;AAA4E,EAAA;AAC7F,EAAA;AACT;AVy2Dc;AACA;AW76DR;AAEM;AAGG;AAEuB;AAC/B,EAAA;AAAc,EAAA;AAAc,EAAA;AAAc,EAAA;AAAiB,EAAA;AAAgB,EAAA;AAAgB,EAAA;AAAgB,EAAA;AAC3G,EAAA;AAAU,EAAA;AAAiB,EAAA;AAAiB,EAAA;AAAqB,EAAA;AAAmB,EAAA;AAAe,EAAA;AACnG,EAAA;AAAc,EAAA;AAAkB,EAAA;AAAgB,EAAA;AAAgB,EAAA;AAAc,EAAA;AAAiB,EAAA;AAAkB,EAAA;AACxH;AAEwC;AAChC,EAAA;AAAW,EAAA;AAAW,EAAA;AAAW,EAAA;AAAW,EAAA;AAAW,EAAA;AAAW,EAAA;AAAW,EAAA;AAAa,EAAA;AAAa,EAAA;AAAW,EAAA;AAClH,EAAA;AAAW,EAAA;AAAW,EAAA;AAAW,EAAA;AAAW,EAAA;AAAgB,EAAA;AAAgB,EAAA;AACpF;AAGS;AACI,EAAA;AACF,EAAA;AACC,EAAA;AACK,EAAA;AACN,EAAA;AACC,EAAA;AACD,EAAA;AACA,EAAA;AACF,EAAA;AACT;AAGgB;AAKP;AACI,EAAA;AAEA,EAAA;AACH,IAAA;AACA,IAAA;AACI,IAAA;AACJ,IAAA;AACA,IAAA;AACE,IAAA;AACV,EAAA;AACM,EAAA;AACK,EAAA;AACH,IAAA;AACA,IAAA;AACG,IAAA;AACH,IAAA;AACA,IAAA;AACE,IAAA;AACV,EAAA;AACO,EAAA;AACT;AAGS;AACD,EAAA;AACM,EAAA;AACN,EAAA;AACA,EAAA;AACI,EAAA;AACJ,EAAA;AAA4B,IAAA;AAAS,MAAA;AAAgC,MAAA;AAAW,MAAA;AAAG,IAAA;AAAE,EAAA;AAChF,EAAA;AACH,IAAA;AACI,IAAA;AACH,IAAA;AAAM,MAAA;AAAuC,MAAA;AAAU,MAAA;AAAQ,MAAA;AAAS,IAAA;AACtE,IAAA;AACD,IAAA;AACV,EAAA;AACS,EAAA;AACE,EAAA;AACb;AAEM;AAGU;AAGF;AACN,EAAA;AACM,EAAA;AACD,EAAA;AACA,EAAA;AAEC,EAAA;AACL,IAAA;AACC,IAAA;AACG,IAAA;AACX,EAAA;AACO,EAAA;AACT;AAEgB;AACR,EAAA;AACA,EAAA;AAA4B,IAAA;AAA4E,EAAA;AACnG,EAAA;AAEL,EAAA;AACA,EAAA;AAAuC,IAAA;AAA2B,MAAA;AAA2B,MAAA;AAAU,IAAA;AAAS,IAAA;AAAE,EAAA;AAC/G,EAAA;AACE,IAAA;AACD,MAAA;AACA,MAAA;AACF,MAAA;AACN,IAAA;AACF,EAAA;AACM,EAAA;AACA,EAAA;AAAgC,IAAA;AAAe,IAAA;AAAgC,IAAA;AAAoB,IAAA;AAAwB,EAAA;AAC3H,EAAA;AACA,EAAA;AACK,EAAA;AACH,IAAA;AACN,IAAA;AACM,IAAA;AACEC,MAAAA;AACA,MAAA;AACC,MAAA;AACR,IAAA;AACK,IAAA;AACI,IAAA;AAEJ,IAAA;AACA,IAAA;AACA,IAAA;AACN,IAAA;AACM,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACR,EAAA;AACM,EAAA;AACA,EAAA;AACK,EAAA;AACC,EAAA;AACL,EAAA;AACT;AXk9Dc;AACA;AYvmEL;AAG6B;AAC9B,EAAA;AAAU,EAAA;AAAU,EAAA;AAAY,EAAA;AAAW,EAAA;AAAW,EAAA;AAAU,EAAA;AAAY,EAAA;AAAY,EAAA;AAAW,EAAA;AACjG,EAAA;AAAY,EAAA;AAAY,EAAA;AAAgB,EAAA;AAAa,EAAA;AAAa,EAAA;AAAa,EAAA;AAAY,EAAA;AACxF,EAAA;AAAiB,EAAA;AAAK,EAAA;AAAkB,EAAA;AAA0B,EAAA;AAAgB,EAAA;AAClF,EAAA;AAAiB,EAAA;AAAK,EAAA;AAAmB,EAAA;AAAoB,EAAA;AAA4B,EAAA;AAC5F,EAAA;AAAiB,EAAA;AAAK,EAAA;AACpB,EAAA;AAAa,EAAA;AAAc,EAAA;AAAa,EAAA;AAAiB,EAAA;AAAe,EAAA;AAAW,EAAA;AAAY,EAAA;AAC3G;AAGgB;AACF,EAAA;AACN,EAAA;AACA,EAAA;AACI,EAAA;AACC,EAAA;AACH,IAAA;AACA,IAAA;AACF,IAAA;AACE,IAAA;AACF,IAAA;AACG,IAAA;AACT,EAAA;AACO,EAAA;AACT;AAGgB;AACV,EAAA;AACO,EAAA;AACD,IAAA;AACF,IAAA;AACD,IAAA;AACK,IAAA;AACV,IAAA;AACQ,MAAA;AACA,MAAA;AACE,MAAA;AACV,IAAA;AACI,IAAA;AAAsB,MAAA;AAAY,MAAA;AAAU,IAAA;AAClD,EAAA;AACO,EAAA;AACT;AAEM;AAEA;AASA;AACG,EAAA;AAAiB,EAAA;AAAU,EAAA;AAAY,EAAA;AAAc,EAAA;AAAW,EAAA;AAAS,EAAA;AAAS,EAAA;AAAS,EAAA;AAAS,EAAA;AAAU,EAAA;AACvH;AACM;AACA;AAkBU;AAKR,EAAA;AACK,EAAA;AACD,IAAA;AACF,IAAA;AACF,IAAA;AAA0E,MAAA;AAA+B,MAAA;AAAS,IAAA;AAC5G,IAAA;AACC,IAAA;AACH,MAAA;AACE,MAAA;AACV,IAAA;AACQ,IAAA;AACV,EAAA;AACS,EAAA;AAGH,EAAA;AACA,EAAA;AACK,EAAA;AACF,EAAA;AACD,IAAA;AACA,IAAA;AACF,IAAA;AACK,IAAA;AACA,IAAA;AACA,IAAA;AACH,MAAA;AACC,MAAA;AACL,MAAA;AACF,IAAA;AACF,EAAA;AAEM,EAAA;AACA,EAAA;AACA,EAAA;AACK,EAAA;AACH,IAAA;AACA,IAAA;AACD,IAAA;AACC,IAAA;AACA,IAAA;AACG,IAAA;AACC,IAAA;AACD,IAAA;AACF,IAAA;AACCD,MAAAA;AAEDA,MAAAA;AAAS,QAAA;AAA4B,UAAA;AAAmB,UAAA;AAAM,QAAA;AAAE,QAAA;AAAsB,QAAA;AAAM,MAAA;AAC3F,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AAGA,MAAA;AACF,MAAA;AAA6D,QAAA;AAAiB,QAAA;AAAY,QAAA;AAAM,QAAA;AAAS,MAAA;AACvG,MAAA;AACF,MAAA;AAGI,QAAA;AAAc,UAAA;AAAqB,UAAA;AAAI,QAAA;AAE3C,UAAA;AACI,UAAA;AACC,UAAA;AACP,QAAA;AACA,QAAA;AACA,QAAA;AACF,MAAA;AACI,MAAA;AAAc,QAAA;AAAoB,QAAA;AAAM,QAAA;AAAS,MAAA;AAC/C,MAAA;AACJ,QAAA;AACK,QAAA;AAAY,QAAA;AACjB,QAAA;AACF,MAAA;AACO,MAAA;AAAa,QAAA;AAAY,QAAA;AAAK,QAAA;AAAS,MAAA;AAEtC,MAAA;AACD,MAAA;AACC,MAAA;AAAqC,QAAA;AAAG,QAAA;AAAS,MAAA;AAClD,MAAA;AACP,MAAA;AACF,IAAA;AACO,IAAA;AACG,IAAA;AACZ,EAAA;AAEI,EAAA;AACQ,EAAA;AACN,IAAA;AACJ,IAAA;AAA+D,MAAA;AAAW,MAAA;AAAU,IAAA;AACtF,EAAA;AACO,EAAA;AACT;AZ0oEc;AACA;Aa5zEL;AAKH;AAEU;AACR,EAAA;AACM,EAAA;AACD,EAAA;AACC,EAAA;AACwB,EAAA;AAChB,EAAA;AAChB,EAAA;AACE,EAAA;AACM,EAAA;AACJ,IAAA;AACN,IAAA;AACU,MAAA;AAA6C,MAAA;AACvD,IAAA;AACS,IAAA;AACX,EAAA;AACM,EAAA;AAEG,EAAA;AACD,IAAA;AACA,IAAA;AACF,IAAA;AACI,MAAA;AACA,MAAA;AACF,MAAA;AACN,IAAA;AACQ,MAAA;AACE,MAAA;AACV,IAAA;AACS,IAAA;AACD,MAAA;AACF,MAAA;AACG,QAAA;AACC,QAAA;AAAoD,QAAA;AACe,QAAA;AAE3E,MAAA;AACF,IAAA;AACE,MAAA;AACMA,QAAAA;AACN,MAAA;AACF,IAAA;AACQ,MAAA;AACA,MAAA;AACD,MAAA;AAAsB,QAAA;AAAgB,QAAA;AAAS,MAAA;AAChD,MAAA;AACJ,MAAA;AACM,QAAA;AAAwB,UAAA;AAA4E,UAAA;AAAQ,QAAA;AACzG,UAAA;AAAgB,UAAA;AAA6G,QAAA;AACtI,MAAA;AACF,IAAA;AACS,MAAA;AACT,IAAA;AAEQ,MAAA;AACF,MAAA;AACI,QAAA;AACF,QAAA;AACN,MAAA;AACQ,MAAA;AAAG,MAAA;AACb,IAAA;AACF,EAAA;AAEM,EAAA;AACK,EAAA;AACD,EAAA;AACC,EAAA;AACC,IAAA;AACJ,IAAA;AACG,IAAA;AACD,IAAA;AAEE,IAAA;AACA,IAAA;AACJ,IAAA;AACU,IAAA;AACZ,IAAA;AACN,EAAA;AACO,EAAA;AACT;AAEa;AACJ,EAAA;AACK,IAAA;AAA2B,IAAA;AAC3B,IAAA;AAA2B,IAAA;AAC3B,IAAA;AAAkC,IAAA;AAC9C,EAAA;AACF;Abi0Ec;AACA;Ach6EF;AACE;AAIE;AACR,EAAA;AACM,EAAA;AAEN,EAAA;AACK,EAAA;AACC,IAAA;AACD,IAAA;AACC,IAAA;AACZ,EAAA;AAEM,EAAA;AACE,IAAA;AACE,IAAA;AACF,IAAA;AACG,IAAA;AACD,MAAA;AACF,MAAA;AACN,IAAA;AACO,IAAA;AACG,EAAA;AACA,EAAA;AAGN,EAAA;AACK,EAAA;AACHA,IAAAA;AACA,IAAA;AACA,IAAA;AACF,IAAA;AACC,IAAA;AACP,EAAA;AAEM,EAAA;AACkB,EAAA;AACbA,EAAAA;AACH,IAAA;AACA,IAAA;AAEA,IAAA;AACA,IAAA;AACA,IAAA;AACF,IAAA;AAEE,IAAA;AACF,IAAA;AACE,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AAEAE,IAAAA;AACFA,IAAAA;AAEEC,IAAAA;AACA,IAAA;AAEN,IAAA;AACQ,MAAA;AACF,MAAA;AAAW,QAAA;AAAe,QAAA;AAAe,MAAA;AAC/C,IAAA;AACE,IAAA;AACI,MAAA;AACA,MAAA;AACJ,MAAA;AACQ,QAAA;AACF,QAAA;AACJ,QAAA;AACF,MAAA;AAEI,MAAA;AACL,IAAA;AACD,IAAA;AACF,EAAA;AACY,EAAA;AACd;Adu5Ec;AACA;Ae3+EL;AAIO;AAEA;AACH,EAAA;AAEL,EAAA;AACA,EAAA;AACK,EAAA;AACF,EAAA;AACD,IAAA;AACA,IAAA;AACF,IAAA;AACK,IAAA;AACA,IAAA;AACA,IAAA;AACH,MAAA;AACC,MAAA;AACL,MAAA;AACMH,QAAAA;AACN,MAAA;AACF,IAAA;AACF,EAAA;AACM,EAAA;AACA,EAAA;AACF,EAAA;AACO,EAAA;AACC,IAAA;AACJ,IAAA;AACD,IAAA;AACC,IAAA;AACK,IAAA;AACF,IAAA;AAEF,IAAA;AACG,IAAA;AACH,IAAA;AACCA,MAAAA;AACEA,MAAAA;AACDA,MAAAA;AACT,IAAA;AACI,IAAA;AAAuB,MAAA;AAAQ,MAAA;AAAS,IAAA;AACrC,IAAA;AACH,IAAA;AACI,MAAA;AACF,MAAA;AAA2D,QAAA;AAAe,QAAA;AAAU,MAAA;AAC1F,IAAA;AACF,EAAA;AACO,EAAA;AACT;AAQgB;AACH,EAAA;AACL,EAAA;AACA,EAAA;AACK,EAAA;AACF,EAAA;AACD,IAAA;AACA,IAAA;AACF,IAAA;AACK,IAAA;AACA,IAAA;AACA,IAAA;AACH,MAAA;AACC,MAAA;AACL,MAAA;AACMA,QAAAA;AACN,MAAA;AACF,IAAA;AACF,EAAA;AACM,EAAA;AACA,EAAA;AACA,EAAA;AACK,EAAA;AACC,IAAA;AACJ,IAAA;AACD,IAAA;AACI,IAAA;AACD,IAAA;AACF,MAAA;AACF,QAAA;AACA,QAAA;AACK,MAAA;AACP,MAAA;AACF,IAAA;AACM,IAAA;AACC,IAAA;AACG,IAAA;AACH,IAAA;AACH,IAAA;AAAuB,MAAA;AAAQ,MAAA;AAAS,IAAA;AACrC,IAAA;AACT,EAAA;AACW,EAAA;AACb;AAWgB;AACR,EAAA;AACG,EAAA;AACH,IAAA;AACJ,IAAA;AACMA,MAAAA;AACN,IAAA;AACF,EAAA;AACM,EAAA;AACA,EAAA;AACG,EAAA;AACA,EAAA;AACD,IAAA;AACA,IAAA;AACC,IAAA;AACG,IAAA;AACH,IAAA;AACH,IAAA;AACA,IAAA;AACG,IAAA;AACD,IAAA;AACF,IAAA;AACM,IAAA;AACZ,EAAA;AACMI,EAAAA;AACA,EAAA;AACM,EAAA;AACd;AAEM;AACA;Af89EQ;AACA;AgBxmFR;AAEG;AAEH,EAAA;AACE,EAAA;AACA,EAAA;AACA,EAAA;AACC,EAAA;AACT;AAES;AACM,EAAA;AACJ,EAAA;AACX;AAEgB;AACR,EAAA;AAEA,EAAA;AACEC,IAAAA;AACG,IAAA;AACP,MAAA;AAEQ,QAAA;AACF,QAAA;AACN,MAAA;AACF,IAAA;AACOA,IAAAA;AACT,EAAA;AACM,EAAA;AACG,EAAA;AACG,IAAA;AACJ,IAAA;AACI,IAAA;AACH,IAAA;AACT,EAAA;AACM,EAAA;AACK,EAAA;AACH,IAAA;AACG,IAAA;AACX,EAAA;AAEM,EAAA;AACK,EAAA;AACL,EAAA;AACD,EAAA;AAEC,EAAA;AACAC,IAAAA;AACJ,IAAA;AACMA,MAAAA;AACA,MAAA;AACN,IAAA;AACOA,IAAAA;AACT,EAAA;AACM,EAAA;AACF,EAAA;AAGE,EAAA;AACK,EAAA;AACL,EAAA;AACM,EAAA;AACJ,IAAA;AACG,IAAA;AACX,EAAA;AACY,EAAA;AACJ,EAAA;AAGF,EAAA;AACI,EAAA;AACJ,EAAA;AAEA,EAAA;AACA,EAAA;AACKN,EAAAA;AACY,EAAA;AACd,EAAA;AACE,IAAA;AACH,IAAA;AACK,IAAA;AACb,EAAA;AACO,EAAA;AACT;AhB6lFc;AACA;AiBlrFR;AAES;AACC;AACH;AAEP;AAEA;AAEA;AAEU;AACH,EAAA;AACL,EAAA;AAGA,EAAA;AAGA,EAAA;AACA,EAAA;AACK,EAAA;AAEA,EAAA;AACA,IAAA;AACH,IAAA;AACI,IAAA;AACZ,EAAA;AAGM,EAAA;AACK,EAAA;AACA,EAAA;AACC,IAAA;AACY,IAAA;AAChB,IAAA;AACE,MAAA;AACA,MAAA;AACA,MAAA;AACF,MAAA;AACI,QAAA;AACD,QAAA;AACL,QAAA;AACI,QAAA;AAEE,QAAA;AAEF,QAAA;AACN,MAAA;AACO,MAAA;AACT,IAAA;AACA,IAAA;AACQ,MAAA;AACF,MAAA;AACA,MAAA;AACN,IAAA;AACQ,IAAA;AACV,EAAA;AACU,EAAA;AACH,EAAA;AACK,IAAA;AACH,IAAA;AACR,EAAA;AACH;AjBsqFc;AACA;AkB5uFE;AACV,EAAA;AACQ,EAAA;AACN,EAAA;AACK,EAAA;AACF,IAAA;AACD,IAAA;AACG,IAAA;AACA,IAAA;AACJ,IAAA;AACP,EAAA;AACM,EAAA;AACI,EAAA;AACC,EAAA;AACb;AlB8uFc;AACA;AmBxvFD;AAOG;AACP,EAAA;AACT;AAEgB;AACU,EAAA;AACb,EAAA;AACC,IAAA;AACJ,IAAA;AAEF,IAAA;AACN,EAAA;AACO,EAAA;AACT;AAGS;AACE,EAAA;AACF,EAAA;AACT;AAEgB;AACH,EAAA;AACL,EAAA;AACK,EAAA;AACL,EAAA;AACA,EAAA;AACM,EAAA;AACA,EAAA;AACJ,IAAA;AACD,IAAA;AAEC,IAAA;AACE,MAAA;AACC,MAAA;AACJ,IAAA;AACD,IAAA;AACK,IAAA;AACL,IAAA;AAEM,IAAA;AACJ,IAAA;AACF,IAAA;AACK,IAAA;AACD,MAAA;AACC,MAAA;AAAQ,QAAA;AAA2D,QAAA;AAAa,QAAA;AAAS,MAAA;AAC1F,MAAA;AACF,MAAA;AACI,QAAA;AACA,QAAA;AACD,MAAA;AACT,IAAA;AACM,IAAA;AACN,IAAA;AACQ,MAAA;AACD,MAAA;AACA,MAAA;AACL,MAAA;AACF,IAAA;AACF,EAAA;AACY,EAAA;AACd;AnBivFc;AACA;AoBxzFR;AACU;AACV;AAEA;AAIG;AACK,EAAA;AACA,EAAA;AACA,EAAA;AACN,EAAA;AACM,EAAA;AACN,EAAA;AACK,EAAA;AACP,EAAA;AAEA,EAAA;AACM,EAAA;AACJ,EAAA;AACI,EAAA;AACJ,EAAA;AACA,EAAA;AACC,EAAA;AACT;AAES;AACC,EAAA;AACV;AAEgB;AACR,EAAA;AACK,EAAA;AACH,IAAA;AACI,IAAA;AACH,IAAA;AACT,EAAA;AAGM,EAAA;AACA,EAAA;AACM,EAAA;AACJ,IAAA;AACA,IAAA;AACF,IAAA;AACJ,IAAA;AACS,MAAA;AACDO,MAAAA;AACA,MAAA;AACA,MAAA;AACE,QAAA;AACN,QAAA;AACF,MAAA;AACF,IAAA;AACO,IAAA;AACH,IAAA;AACA,IAAA;AACE,MAAA;AACJ,MAAA;AACE,QAAA;AACI,QAAA;AAA+B,UAAA;AAAiC,UAAA;AAAM,QAAA;AAC5E,MAAA;AACF,IAAA;AACF,EAAA;AAGM,EAAA;AAGA,EAAA;AACK,EAAA;AACH,IAAA;AAGF,IAAA;AACE,IAAA;AACA,MAAA;AAEJ,MAAA;AACM,MAAA;AACN,MAAA;AAGF,IAAA;AACA,IAAA;AACQ,MAAA;AACD,MAAA;AAGyD,QAAA;AAAU,QAAA;AAAW,MAAA;AAC7E,MAAA;AACR,IAAA;AACO,IAAA;AACT,EAAA;AACS,EAAA;AACD,IAAA;AACI,IAAA;AACJ,IAAA;AACK,IAAA;AAOX,IAAA;AACA,IAAA;AACF,EAAA;AACS,EAAA;AACD,IAAA;AACI,IAAA;AACJ,IAAA;AACA,IAAA;AAEA,IAAA;AACA,IAAA;AACA,IAAA;AAGA,IAAA;AAEA,IAAA;AACA,IAAA;AAEF,IAAA;AACG,MAAA;AACE,MAAA;AACP,MAAA;AACF,IAAA;AAIK,IAAA;AAKG,MAAA;AACA,MAAA;AACC,MAAA;AACT,IAAA;AAEM,IAAA;AACD,IAAA;AAGG,MAAA;AACC,MAAA;AACT,IAAA;AAEM,IAAA;AAEA,IAAA;AACI,IAAA;AAEH,MAAA;AACA,MAAA;AACE,MAAA;AACP,MAAA;AACF,IAAA;AAEU,IAAA;AAIF,MAAA;AACC,MAAA;AAEF,MAAA;AACL,MAAA;AACF,IAAA;AAGM,IAAA;AACA,IAAA;AAMA,IAAA;AACC,IAAA;AACD,IAAA;AAIA,IAAA;AAGA,IAAA;AAMF,IAAA;AAEA,IAAA;AAEI,MAAA;AACA,MAAA;AACR,IAAA;AACF,EAAA;AACF;ApB2vFc;AACA;AqBh8FE;AACF,EAAA;AACd;AAOM;AAOA;AACA;AAGA;AAGU;AACH,EAAA;AACH,IAAA;AACA,IAAA;AACJ,MAAA;AACE,QAAA;AACM,UAAA;AACA,UAAA;AACN,QAAA;AACF,MAAA;AACF,IAAA;AACM,IAAA;AACR,EAAA;AACF;AAUM;AAGG;AACK,EAAA;AACL,EAAA;AACF,EAAA;AACK,EAAA;AACZ;AAEgB;AACS,EAAA;AACZ,EAAA;AACC,IAAA;AACJ,IAAA;AACF,IAAA;AAAa,MAAA;AAAyF,MAAA;AAAS,IAAA;AAG7G,IAAA;AACA,IAAA;AACA,IAAA;AAEA,IAAA;AACF,IAAA;AAAe,MAAA;AAAoF,MAAA;AAAS,IAAA;AAC1G,IAAA;AACD,IAAA;AAAY,MAAA;AAAS,MAAA;AAAS,IAAA;AAC7B,IAAA;AAEC,IAAA;AACE,IAAA;AAAsB,MAAA;AAA6C,MAAA;AAAS,IAAA;AACrF,IAAA;AACF,EAAA;AACO,EAAA;AACT;AAOgB;AACH,EAAA;AACH,IAAA;AACN,IAAA;AACM,MAAA;AACI,QAAA;AACA,QAAA;AACN,QAAA;AACM,UAAA;AACC,UAAA;AACP,QAAA;AACK,QAAA;AACP,MAAA;AACI,MAAA;AACN,IAAA;AACF,EAAA;AACF;AAKgB;AACF,EAAA;AACD,EAAA;AACD,IAAA;AAOV,EAAA;AAEW,EAAA;AACJ,EAAA;AAGF,IAAA;AAWM,EAAA;AACH,IAAA;AAGK,IAAA;AACA,MAAA;AACT,IAAA;AAEM,IAAA;AACC,IAAA;AAMA,EAAA;AAQb;AAES;AACG,EAAA;AACH,EAAA;AAET;AAES;AACA,EAAA;AACT;AAES;AACI,EAAA;AACL,EAAA;AACI,EAAA;AACJ,EAAA;AAEG,EAAA;AACD,IAAA;AACA,IAAA;AACA,IAAA;AAEF,IAAA;AACK,MAAA;AACP,MAAA;AACF,IAAA;AAES,IAAA;AACA,MAAA;AACP,MAAA;AACF,IAAA;AAEI,IAAA;AACK,MAAA;AACP,MAAA;AACF,IAAA;AAGI,IAAA;AAGK,MAAA;AACF,IAAA;AACE,MAAA;AACT,IAAA;AACF,EAAA;AACO,EAAA;AACT;ArBs3Fc;AACA;AsBnkGR;AAEA;AAOU;AACJ,EAAA;AACJ,EAAA;AAEK,EAAA;AACH,IAAA;AACA,IAAA;AACI,IAAA;AACJ,IAAA;AACA,IAAA;AAEA,IAAA;AACN,IAAA;AACM,MAAA;AAGA,MAAA;AACE,MAAA;AACF,MAAA;AACI,MAAA;AACV,IAAA;AACI,IAAA;AAGE,IAAA;AACN,IAAA;AACQ,MAAA;AACA,MAAA;AACF,MAAA;AACE,MAAA;AACR,IAAA;AACA,IAAA;AACM,MAAA;AACG,MAAA;AACC,MAAA;AACR,MAAA;AACF,IAAA;AACF,EAAA;AACF;AAGS;AACG,EAAA;AACN,EAAA;AAAM,IAAA;AAAqB,EAAA;AAA4B,EAAA;AAClD,EAAA;AACX;AAGgB;AAOA;AAEJ,EAAA;AAAqB,IAAA;AACxB,IAAA;AAAiF,EAAA;AAC5E,EAAA;AACH,EAAA;AACA,IAAA;AACG,IAAA;AACZ,EAAA;AAEW,EAAA;AACb;AtBijGc;AACA;AuB9nGd;AAQQ,EAAA;AACE,EAAA;AAGF,EAAA;AACE,IAAA;AACE,MAAA;AACE,MAAA;AACF,MAAA;AACR,IAAA;AACU,MAAA;AAAgE;AAC1E,IAAA;AACU,MAAA;AAAsD;AAChE,IAAA;AAEA,IAAA;AACD,EAAA;AAEK,EAAA;AACF,EAAA;AAGI,IAAA;AAEA,IAAA;AAEF,IAAA;AAEA,IAAA;AACA,IAAA;AAEJ,IAAA;AACQ,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AAQA,MAAA;AACN,MAAA;AACO,QAAA;AACC,QAAA;AAEA,QAAA;AACA,QAAA;AAEA,QAAA;AACA,QAAA;AACA,QAAA;AACA,QAAA;AACA,QAAA;AAEN,QAAA;AACE,UAAA;AACE,YAAA;AACA,YAAA;AACA,YAAA;AACA,YAAA;AACF,UAAA;AACA,UAAA;AACA,UAAA;AACD,QAAA;AACH,MAAA;AACI,MAAA;AAIE,MAAA;AACA,MAAA;AACN,MAAA;AACQ,QAAA;AACA,QAAA;AACA,QAAA;AACD,QAAA;AACC,QAAA;AACF,QAAA;AAEJ,QAAA;AACE,UAAA;AACA,UAAA;AACA,UAAA;AACA,UAAA;AACA,UAAA;AACI,UAAA;AACF,YAAA;AACA,YAAA;AACF,UAAA;AACF,QAAA;AACF,MAAA;AAEI,MAAA;AAEI,QAAA;AACN,QAAA;AACA,QAAA;AACF,MAAA;AAKA,MAAA;AAEA,MAAA;AACM,QAAA;AACA,QAAA;AACA,QAAA;AACJ,QAAA;AACE,UAAA;AACI,UAAA;AACA,UAAA;AACJ,UAAA;AACI,UAAA;AACJ,UAAA;AACI,UAAA;AACF,YAAA;AACA,YAAA;AACF,UAAA;AACF,QAAA;AAEI,QAAA;AACF,UAAA;AACF,QAAA;AACE,UAAA;AACF,QAAA;AAEE,UAAA;AACF,QAAA;AACA,QAAA;AACF,MAAA;AACF,IAAA;AAEI,IAAA;AACM,MAAA;AACN,QAAA;AAAgG;AAClG,MAAA;AACF,IAAA;AACA,EAAA;AACM,IAAA;AAAiC,IAAA;AACzC,EAAA;AACF;AAES;AACI,EAAA;AACb;AvB0lGc;AACA;AwBlvGF;AAfF;AAEG;AACT,EAAA;AACkB,mBAAA;AAClB,IAAA;AAAmC,MAAA;AAAoB,IAAA;AACzD,EAAA;AACF;AAEa;AACA,EAAA;AAAc,EAAA;AAC3B;AAKE;AxBiwGY;AACA;AyBruGL;AACA;AACA;AAUO;AACL,EAAA;AACG,EAAA;AACA,EAAA;AACJ,IAAA;AACE,IAAA;AACV,EAAA;AACO,EAAA;AACT;AAOA;AAGM;AACA;AAIA;AAGA;AAKA;AACF;AACI,EAAA;AACA,EAAA;AACM,EAAA;AACA,EAAA;AACA,EAAA;AACN;AAA6C;AAS/C;AACJ,EAAA;AAAkE,EAAA;AAAY,EAAA;AAC9E,EAAA;AAAiE,EAAA;AACnE;AAGa;AACX,EAAA;AAAgB;AAEhB,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACW,EAAA;AAAA;AACR,EAAA;AACL;AAGA;AACQ,EAAA;AAA0B;AAEpB,IAAA;AACP,IAAA;AACJ,EAAA;AACG,EAAA;AACA,EAAA;AACK,IAAA;AACL,MAAA;AACM,QAAA;AACE,QAAA;AACP,MAAA;AACG,MAAA;AACF,QAAA;AAA2B,UAAA;AAAuB,UAAA;AAAmD,QAAA;AACtG,MAAA;AACF,IAAA;AACD,EAAA;AACI,IAAA;AACN,EAAA;AACF;AAEA;AAEQ,EAAA;AAEA,EAAA;AACA,EAAA;AACM,EAAA;AAER,EAAA;AACI,IAAA;AACF,IAAA;AAGE,IAAA;AACA,IAAA;AAEA,IAAA;AACA,IAAA;AACA,IAAA;AACF,IAAA;AACA,IAAA;AACE,IAAA;AAGA,IAAA;AACF,IAAA;AACF,MAAA;AACF,IAAA;AACM,IAAA;AAGA,IAAA;AACA,IAAA;AACA,IAAA;AAEA,IAAA;AAEA,IAAA;AAEA,IAAA;AACA,IAAA;AAEA,IAAA;AAIA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AAEA,IAAA;AAEA,IAAA;AAEA,IAAA;AAEA,IAAA;AAIA,IAAA;AACF,IAAA;AACF,MAAA;AACM,QAAA;AACA,QAAA;AACF,UAAA;AACA,UAAA;AACA,UAAA;AACA,UAAA;AACF,QAAA;AAAmE,QAAA;AACzD,0BAAA;AAAuB,QAAA;AACnC,MAAA;AACF,IAAA;AAEI,IAAA;AACE,IAAA;AACG,IAAA;AACH,MAAA;AACA,MAAA;AACA,MAAA;AACI,QAAA;AACN,QAAA;AACM,QAAA;AAEA,QAAA;AACA,QAAA;AACN,QAAA;AACA,QAAA;AACM,QAAA;AAEA,QAAA;AACN,QAAA;AAEM,QAAA;AACA,UAAA;AAAE,YAAA;AAAwI,UAAA;AACtI,YAAA;AAAiB,UAAA;AAC3B,QAAA;AACM,QAAA;AAEN,QAAA;AACA,QAAA;AAEA,QAAA;AAEA,QAAA;AAEA,QAAA;AAEM,QAAA;AACA,QAAA;AAGA,QAAA;AAEA,QAAA;AAGA,QAAA;AACA,QAAA;AACF,QAAA;AACF,UAAA;AACF,QAAA;AAGA,QAAA;AACM,UAAA;AACN,QAAA;AAGI,QAAA;AACF,UAAA;AACA,UAAA;AACF,QAAA;AAA+B,QAAA;AAKzB,QAAA;AACF,QAAA;AAAuC,UAAA;AAAkB,UAAA;AAAiB,QAAA;AACxE,QAAA;AACJ,UAAA;AACA,UAAA;AACE,QAAA;AAGJ,QAAA;AACA,QAAA;AACM,QAAA;AAKA,QAAA;AACN,QAAA;AACM,UAAA;AACA,UAAA;AACA,UAAA;AACF,YAAA;AACA,YAAA;AACA,YAAA;AACF,UAAA;AACG,UAAA;AACC,UAAA;AACN,QAAA;AAGA,QAAA;AAEA,QAAA;AAEA,QAAA;AAGM,QAAA;AACF,QAAA;AACF,UAAA;AACI,UAAA;AACJ,UAAA;AACE,YAAA;AAIA,YAAA;AACA,YAAA;AACA,YAAA;AACA,YAAA;AACA,YAAA;AACE,cAAA;AACA,cAAA;AACA,cAAA;AACD,YAAA;AACD,YAAA;AACE,cAAA;AACA,cAAA;AACE,gBAAA;AACA,gBAAA;AACA,gBAAA;AACA,gBAAA;AACF,cAAA;AACF,YAAA;AACF,UAAA;AACI,UAAA;AACN,QAAA;AAGM,QAAA;AACF,QAAA;AACE,QAAA;AACA,QAAA;AAEA,QAAA;AACA,QAAA;AACF,QAAA;AACJ,QAAA;AAII,QAAA;AACF,UAAA;AACI,UAAA;AACJ,UAAA;AACF,QAAA;AAA0C,QAAA;AAGtC,QAAA;AACJ,QAAA;AACM,UAAA;AAIA,UAAA;AACF,YAAA;AACA,YAAA;AACF,UAAA;AACA,UAAA;AACA,UAAA;AACA,UAAA;AACF,QAAA;AAEM,QAAA;AACA,QAAA;AACN,QAAA;AAEI,QAAA;AACF,UAAA;AACF,QAAA;AACI,QAAA;AACJ,QAAA;AACA,wBAAA;AACF,MAAA;AAEM,QAAA;AACJ,QAAA;AACF,MAAA;AAGE,wBAAA;AACF,MAAA;AACI,MAAA;AACI,QAAA;AACN,QAAA;AACF,MAAA;AACF,IAAA;AAEM,IAAA;AAEA,IAAA;AAMA,IAAA;AAEA,IAAA;AACF,IAAA;AACI,MAAA;AACA,MAAA;AACF,MAAA;AACF,QAAA;AACK,MAAA;AACL,QAAA;AACM,UAAA;AAEA,UAAA;AAEA,UAAA;AACJ,UAAA;AACF,QAAA;AACA,QAAA;AACF,MAAA;AACI,MAAA;AACE,QAAA;AACF,UAAA;AACA,UAAA;AAEA,UAAA;AAEA,UAAA;AAEA,UAAA;AACI,UAAA;AACF,YAAA;AACA,YAAA;AACE,cAAA;AACA,cAAA;AACE,gBAAA;AACA,gBAAA;AACA,gBAAA;AACF,cAAA;AACA,cAAA;AACA,cAAA;AACA,cAAA;AAGA,cAAA;AACF,YAAA;AACA,YAAA;AACE,cAAA;AACA,cAAA;AACA,cAAA;AACA,cAAA;AACA,cAAA;AACF,YAAA;AACA,YAAA;AACA,YAAA;AACE,cAAA;AACA,cAAA;AACD,YAAA;AACH,UAAA;AACF,QAAA;AAEE,UAAA;AACE,YAAA;AACA,YAAA;AACD,UAAA;AACH,QAAA;AACF,MAAA;AACF,IAAA;AAEI,IAAA;AAEF,MAAA;AACE,QAAA;AACM,QAAA;AACP,MAAA;AACH,IAAA;AAKK,IAAA;AACG,MAAA;AACJ,QAAA;AACA,QAAA;AACA,QAAA;AACA,QAAA;AACA,QAAA;AACA,QAAA;AACF,MAAA;AACA,MAAA;AACO,QAAA;AACD,QAAA;AACJ,QAAA;AACF,MAAA;AACF,IAAA;AAIK,IAAA;AACH,MAAA;AACM,QAAA;AACJ,QAAA;AACF,MAAA;AACF,IAAA;AAGI,IAAA;AACI,MAAA;AACA,MAAA;AAEN,MAAA;AACE,QAAA;AACF,MAAA;AAEM,MAAA;AACF,MAAA;AAAuB,QAAA;AAAmB,QAAA;AAAyB,MAAA;AAEjE,MAAA;AACF,MAAA;AAAmB,QAAA;AAAmB,QAAA;AAAqB,MAAA;AACjE,IAAA;AAIA,IAAA;AACA,IAAA;AAEI,IAAA;AACI,MAAA;AACN,MAAA;AACF,IAAA;AAEA,IAAA;AAEK,IAAA;AAEL,IAAA;AAGA,IAAA;AAII,IAAA;AACE,MAAA;AACI,QAAA;AACR,MAAA;AACE,QAAA;AACE,UAAA;AACA,UAAA;AACD,QAAA;AACH,MAAA;AACF,IAAA;AAGM,IAAA;AACF,IAAA;AACF,MAAA;AACF,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AAGA,IAAA;AAEA,IAAA;AAGA,IAAA;AAGA,IAAA;AAGM,IAAA;AAKN,IAAA;AAEI,IAAA;AACA,IAAA;AACK,MAAA;AACP,MAAA;AACF,IAAA;AAGA,IAAA;AACA,IAAA;AAGI,IAAA;AACI,MAAA;AACA,MAAA;AACC,MAAA;AACA,MAAA;AACR,IAAA;AACD,IAAA;AAMI,IAAA;AACI,MAAA;AACA,MAAA;AAEA,MAAA;AACJ,QAAA;AACM,UAAA;AACJ,UAAA;AACI,UAAA;AACJ,UAAA;AACF,QAAA;AACF,MAAA;AACM,MAAA;AACE,QAAA;AAED,QAAA;AACD,QAAA;AACC,QAAA;AAAK,UAAA;AAAkB,UAAA;AAA6E,QAAA;AACzG,QAAA;AACD,MAAA;AACD,MAAA;AACM,MAAA;AACC,MAAA;AACP,MAAA;AACF,IAAA;AAGM,IAAA;AAEF,IAAA;AACA,IAAA;AAEG,IAAA;AACL,MAAA;AACO,MAAA;AACP,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACQ,MAAA;AACV,IAAA;AACA,EAAA;AACU,IAAA;AAAwB,IAAA;AACpC,EAAA;AACF;AAIA;AACM,EAAA;AACI,IAAA;AACD,IAAA;AACC,IAAA;AAEF,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACF,MAAA;AACF,IAAA;AACI,IAAA;AACA,IAAA;AACE,EAAA;AAER,EAAA;AACF;AAIA;AACM,EAAA;AACI,IAAA;AACE,IAAA;AACF,EAAA;AACC,IAAA;AACT,EAAA;AACF;AAGS;AACG,EAAA;AACF,EAAA;AACC,EAAA;AACC,EAAA;AACZ;AAGA;AACc,EAAA;AAER,EAAA;AACI,IAAA;AACA,IAAA;AACC,IAAA;AACP,EAAA;AACU,IAAA;AAAwB,IAAA;AACpC,EAAA;AACF;AAGS;AACD,EAAA;AACM,EAAA;AACN,EAAA;AACG,EAAA;AACH,EAAA;AACE,IAAA;AACA,IAAA;AACC,IAAA;AACT,EAAA;AACO,EAAA;AACT;AzBwjGc;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"/Users/mong-e/workspace/kordoc/dist/parser-XQL37S4F.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 { createHash } from \"node:crypto\"\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 /** 문서 공용 객체 id(\"g_…\") → 내용 해시 키. 같은 id 는 늘 같은 디코딩 객체(commonObjs)라 다시 훑지 않는다 */\n globalKeys: Map<string, string>\n capWarned: boolean\n}\n\nexport function createPdfImageState(): PdfImageState {\n return { imageIndex: 0, totalBytes: 0, seen: new Map(), globalKeys: new Map(), capWarned: false }\n}\n\n/** 픽셀 버퍼 내용 해시 — 네이티브 SHA-1. 종전 FNV-1a JS 루프는 맥미니에서 0.93GB/s(SHA-1 2.97GB/s)라, 쪽마다 같은 그림을\n * 새로 그리는 행정업무운영 편람 384쪽(그림 968번·8.1GB)에서 파싱 시간의 78%를 먹었다. 키는 중복 판정에만 쓴다 */\nfunction contentHash(data: Uint8Array | Uint8ClampedArray): string {\n return createHash(\"sha1\").update(data).digest(\"hex\")\n}\n\n/** 바이트를 안 뽑을 때(images:false) toRgba 가 변환 대신 돌려주는 표지 — 판정(null 여부)만 같게 */\nconst NO_BYTES = new Uint8Array(0)\n\n/** kind별 픽셀 → 8bit RGBA. 미지원/불일치 형태는 null. convert=false 면 판정만 하고 NO_BYTES (w×h×4 할당·변환 생략) */\nfunction toRgba(img: PdfImgData, convert = true): Uint8Array | null {\n const { width: w, height: h, kind, data } = img\n if (!data || !w || !h) return null\n\n if (kind === ImageKind.RGBA_32BPP) {\n if (data.length < w * h * 4) return null\n if (!convert) return NO_BYTES\n const rgba = new Uint8Array(w * h * 4)\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 if (!convert) return NO_BYTES\n const rgba = new Uint8Array(w * h * 4)\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 if (!convert) return NO_BYTES\n const rgba = new Uint8Array(w * h * 4)\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 *\n * `withBytes` 가 false(images:false)면 PNG 인코딩을 건너뛰고 image 블록만 낸다.\n * 거르기·중복 판정은 같아서 자리 표시가 바이트를 뽑을 때와 같다. 바이트 누적 상한은\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 withBytes = true,\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 globalId = dedupeId?.startsWith(\"g_\") ? dedupeId : undefined\n let hash = globalId ? state.globalKeys.get(globalId) : undefined\n if (hash === undefined) {\n hash = `${w}x${h}k${imgData.kind ?? \"?\"}h${contentHash(imgData.data)}${dedupeId ? \"\" : \"inline\"}`\n if (globalId) state.globalKeys.set(globalId, hash)\n }\n if (state.seen.has(hash)) continue\n\n const rgba = toRgba(imgData, withBytes)\n if (!rgba) continue // 미지원 픽셀 형태 (bitmap 전용 등)\n\n state.imageIndex++\n const filename = `image_${String(state.imageIndex).padStart(3, \"0\")}.png`\n state.seen.set(hash, filename)\n if (withBytes) {\n const png = encodePng(w, h, rgba)\n state.totalBytes += png.length\n images.push({ filename, data: png, mimeType: \"image/png\" })\n }\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 for (const b of result) blocks.push(b) // push(...result) 는 블록 약 13만 개부터 RangeError — 그림 1장 든 3,500쪽 문서가 통째 실패했다\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 * 텍스트층 밖 신호 하나: 글자를 채운 곡선 경로로 그린 쪽(vector-glyphs.ts 가 연산자 목록 기하로 센 글자 수를\n * 받는다). 텍스트층에는 ASCII 몇 자만 남아 위 신호로는 멀쩡해 보인다. 이것이 vector_text 신호다.\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?: \"vector_text\" | \"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/**\n * vector_text 하한: 텍스트층 밖 곡선 글자(vector-glyphs.ts 글줄다운 조각의 글자 수). 코퍼스 비-cairo 표지 로고 곡선\n * 글자는 34개 이하(국가교통통계 표지 로고 3개 34·기록물관리 지침 표지 30)라 못 넘고, 글줄 몇 개면 넘는다\n */\nconst VECTOR_TEXT_MIN_GLYPHS = 40\n/**\n * 곡선 글자가 쪽 글자(곡선 + 텍스트층)에서 차지할 최소 비율 — 텍스트층 글이 대부분인 쪽(차트 라벨만 곡선인 보도자료\n * 안내서 62/434 = 0.14)을 OCR 로 갈아엎지 않는다. 대가: 텍스트층 ASCII 가 대부분인 cairo 숫자 칸 표 쪽(632 + 곡선 65 =\n * 0.09)과, 한글 대부분이 텍스트층에 있고 일부만 곡선인 cairo 쪽(0.10~0.24)은 OCR 대상이 아니다\n */\nconst VECTOR_TEXT_MIN_SHARE = 0.25\n\n/** 페이지 텍스트에서 품질 메트릭을 계산한다. vectorGlyphs 는 텍스트층 밖 곡선 글자 수 (vector-glyphs.ts) */\nexport function computePageQuality(page: number, text: string, vectorGlyphs = 0): 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 // 우선순위: vector_text > low_text > high_pua > high_control > high_replacement > garbled_hangul.\n // vector_text 가 low_text 보다 앞선다 — 텍스트층이 비어도 빈 쪽(표지·간지)이 아니라 글이 곡선으로 가득한 쪽이다\n if (vectorGlyphs >= VECTOR_TEXT_MIN_GLYPHS && vectorGlyphs >= (vectorGlyphs + total) * VECTOR_TEXT_MIN_SHARE) {\n needsOcr = true; ocrReason = \"vector_text\"\n }\n else 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 * 벡터 글자 감지 — 글자를 글꼴이 아니라 채운 곡선 경로로 그린 쪽 (needsOcr 사유 vector_text).\n *\n * rhwp 자체 렌더러(Producer \"cairo 1.18.0\")는 DejaVu 글꼴에 없는 한글을 자모를 조합한 채움 경로로 그린다.\n * 텍스트층에는 ASCII·기호만 남고(한글 0자) 눈으로는 한글이 읽힌다 — rhwp/hwpx_sample2 1쪽 연산자는\n * constructPath 975·fill 857 vs showText 66. \"글꼴을 곡선으로\" 인쇄한 PDF 도 같은 모양이다. 텍스트층만 보는\n * 품질 신호(quality.ts)는 이런 쪽을 못 알아봐(ASCII 가 조금 있어 low_text 도 아님) 한글 없는 마크다운을 경고 없이 냈다.\n *\n * 판정은 연산자 목록 기하로만 한다:\n * ① 글자 모양 채움 경로 — 높이 3~40pt·폭 ≤ 높이×2·선분 8개+·닫힌 윤곽 2개+(한글 음절은 초성·중성이 떨어진\n * 윤곽이라 2개 이상이다. 원·사각·삼각·화살표 같은 도형 조각과 라틴 한 획 글자는 여기서 빠진다), 가운데가\n * 텍스트층 글 상자 밖(투명 텍스트층을 얹은 윤곽 PDF 는 글이 있는 쪽이다).\n * ② 글줄 조각(run) — 세로 가운데가 반 높이 안인 경로를 한 줄로, 줄 안에서 x 틈 1.5em 이내를 한 조각으로.\n * ③ 글줄다움 — 3개+·윗선이나 밑선이 가지런함(중앙 편차 ≤ 0.05em)·가로 겹침 드묾(≤ 20%)·한 색(≥ 90%)·\n * 모양이 제각각(서로 다른 모양 ≥ 50%).\n * 코퍼스 PDF 1,561개(16,775쪽) 실측 — 비-cairo 15,875쪽 가운데 ①의 경로가 있는 쪽 275, ② 조각이 있는 쪽 71,\n * ③을 넘는 글자가 있는 쪽 39. 그 가운데 가장 많은 쪽이 68개(광진소식 지면: 텍스트층 0자, 본문 전부 곡선 — 진짜\n * 벡터 글자)이고 다음이 차트 라벨 62(보도자료 안내서, 텍스트층 372자)·표지 로고 34·30 이다. ③이 떨어낸 것:\n * 표지·간지 원형 아이콘 무리(ice-arc: 정렬 편차 0.1~0.3em·겹침 0.2~0.75), 서식 점선 칸 줄(synam-001: 모양 1종\n * 반복), 클립아트 세부(hwp3-sample11 네트워크 그림: 겹침 ~1.0·색 제각각). ③의 조건을 하나씩 빼 봐도 오탐은 없지만\n * (여유가 줄 뿐), ③을 통째로 빼면 비-cairo 17쪽이 판정을 넘는다 — 텍스트층 0자 광진소식 5쪽 말고도 삽화·아이콘 쪽 12\n * (cbe-record-guide 표지·간지 6, ice-arc-2026 간지 6). 쪽 판정(글자 수·비율)은 quality.ts 가 한다.\n */\n\nimport { OPS } from \"pdfjs-dist/legacy/build/pdf.mjs\"\n\n/** 글자 모양 채움 경로 (쪽 좌표, CTM 적용) */\nexport interface GlyphPath {\n x1: number; y1: number; x2: number; y2: number\n /** 선분(직선·곡선) 수 */\n segs: number\n /** 닫힌 윤곽(부분 경로) 수 */\n subs: number\n /** 채움 색 (0xRRGGBB) */\n color: number\n /** 이 경로를 만든 연산자 번호 (constructPath 들 + 채움) — OCR 경로가 괘선만 남길 때 뺀다 */\n ops: number[]\n}\n\nconst GLYPH_MIN_H = 3\nconst GLYPH_MAX_H = 40\nconst GLYPH_MAX_ASPECT = 2\nconst GLYPH_MIN_SEGS = 8\nconst GLYPH_MIN_SUBS = 2\n/** 줄 안 이웃 경로 x 틈 상한 (em) — 낱말 사이 띄어쓰기·양쪽 정렬 벌어짐까지 한 조각 */\nconst RUN_GAP_EM = 1.5\nconst RUN_MIN = 3\n/** 윗선·밑선 중앙 편차 상한 (em) — cairo 글줄 0.00~0.07, 아이콘 무리 0.1~0.3 */\nconst RUN_ALIGN_EM = 0.05\n/** 앞 경로 끝보다 0.1em 넘게 앞에서 시작하는 이웃 비율 상한 — 글자는 차례로 놓인다 */\nconst RUN_MAX_OVERLAP = 0.2\nconst RUN_MIN_COLOR = 0.9\nconst RUN_MIN_SHAPES = 0.5\n\ntype Mat = number[]\nconst mul = (m: Mat, t: Mat): Mat => [\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 * 글자 모양 채움 경로 수집. pdfjs v4 constructPath 인자 [subOps, coords, minMax] 의 minMax 는 곡선 끝점을\n * 빼먹으므로 좌표를 직접 훑는다. 사각형(re)만으로 된 채움(칸 음영·막대·체크박스)은 글자가 아니다.\n */\nexport function collectGlyphPaths(fnArray: Uint32Array | number[], argsArray: unknown[][]): GlyphPath[] {\n const out: GlyphPath[] = []\n let ctm: Mat = [1, 0, 0, 1, 0, 0]\n let color = 0\n const stack: Array<[Mat, number]> = []\n let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity\n let segs = 0, subs = 0, rects = 0\n let ops: number[] = []\n const add = (x: number, y: number) => {\n const tx = ctm[0] * x + ctm[2] * y + ctm[4], ty = ctm[1] * x + ctm[3] * y + ctm[5]\n if (tx < x1) x1 = tx\n if (tx > x2) x2 = tx\n if (ty < y1) y1 = ty\n if (ty > y2) y2 = ty\n }\n const reset = () => { x1 = Infinity; y1 = Infinity; x2 = -Infinity; y2 = -Infinity; segs = 0; subs = 0; rects = 0; ops = [] }\n for (let i = 0; i < fnArray.length; i++) {\n const op = fnArray[i]\n const args = argsArray[i]\n switch (op) {\n case OPS.save: stack.push([ctm, color]); break\n case OPS.restore: [ctm, color] = stack.pop() ?? [[1, 0, 0, 1, 0, 0], 0]; break\n case OPS.transform: ctm = mul(ctm, args as number[]); break\n case OPS.paintFormXObjectBegin: {\n stack.push([ctm, color])\n const m = (args as unknown[])[0]\n if (Array.isArray(m) && m.length >= 6) ctm = mul(ctm, m as number[])\n break\n }\n case OPS.paintFormXObjectEnd: [ctm, color] = stack.pop() ?? [[1, 0, 0, 1, 0, 0], 0]; break\n case OPS.setFillRGBColor: {\n const c = args as unknown as ArrayLike<number>\n color = (c[0] << 16) | (c[1] << 8) | c[2]\n break\n }\n case OPS.constructPath: {\n const [subOps, coords] = args as [number[], number[]]\n if (!Array.isArray(subOps) || !Array.isArray(coords)) break\n ops.push(i)\n let ci = 0\n for (const s of subOps) {\n if (s === OPS.moveTo) { add(coords[ci], coords[ci + 1]); ci += 2; subs++ }\n else if (s === OPS.lineTo) { add(coords[ci], coords[ci + 1]); ci += 2; segs++ }\n else if (s === OPS.curveTo) { add(coords[ci], coords[ci + 1]); add(coords[ci + 2], coords[ci + 3]); add(coords[ci + 4], coords[ci + 5]); ci += 6; segs++ }\n else if (s === OPS.curveTo2 || s === OPS.curveTo3) { add(coords[ci], coords[ci + 1]); add(coords[ci + 2], coords[ci + 3]); ci += 4; segs++ }\n else if (s === OPS.rectangle) {\n const rx = coords[ci], ry = coords[ci + 1], rw = coords[ci + 2], rh = coords[ci + 3]\n add(rx, ry); add(rx + rw, ry + rh); add(rx, ry + rh); add(rx + rw, ry)\n ci += 4; rects++; subs++; segs += 4\n }\n }\n break\n }\n case OPS.fill: case OPS.eoFill: case OPS.fillStroke: case OPS.eoFillStroke:\n case OPS.closeFillStroke: case OPS.closeEOFillStroke: {\n const w = x2 - x1, h = y2 - y1\n if (segs > rects * 4 && h >= GLYPH_MIN_H && h <= GLYPH_MAX_H && w <= h * GLYPH_MAX_ASPECT &&\n segs >= GLYPH_MIN_SEGS && subs >= GLYPH_MIN_SUBS) {\n out.push({ x1, y1, x2, y2, segs, subs, color, ops: [...ops, i] })\n }\n reset()\n break\n }\n case OPS.stroke: case OPS.closeStroke: case OPS.endPath:\n reset()\n break\n }\n }\n return out\n}\n\n/** 텍스트층 글 상자 (NormItem 과 같은 모양 — y 는 베이스라인) */\nexport interface TextBox { text: string; x: number; y: number; w: number; h: number }\n\n/** 가운데가 텍스트층 글 상자 안 — 글 위에 겹쳐 그린 윤곽(투명 텍스트층을 얹은 윤곽 PDF, 글자 테두리 효과) */\nfunction coveredByText(b: GlyphPath, texts: TextBox[]): boolean {\n const cx = (b.x1 + b.x2) / 2, cy = (b.y1 + b.y2) / 2\n for (const t of texts) {\n if (cx >= t.x - 1 && cx <= t.x + t.w + 1 && cy >= t.y - t.h * 0.3 - 1 && cy <= t.y + t.h + 1 && t.text.trim()) return true\n }\n return false\n}\n\nconst median = (a: number[]): number => { const s = a.slice().sort((x, y) => x - y); return s[s.length >> 1] }\nconst spread = (a: number[]): number => { const c = median(a); return median(a.map(v => Math.abs(v - c))) }\n\n/** 글줄다움 — 머리 주석 ③ (m 은 x 순) */\nfunction isTextRun(m: GlyphPath[], em: number): boolean {\n if (m.length < RUN_MIN) return false\n if (Math.min(spread(m.map(b => b.y2)), spread(m.map(b => b.y1))) > em * RUN_ALIGN_EM) return false\n let overlaps = 0\n for (let i = 1; i < m.length; i++) if (m[i].x1 < m[i - 1].x2 - em * 0.1) overlaps++\n if (overlaps > (m.length - 1) * RUN_MAX_OVERLAP) return false\n const colors = new Map<number, number>()\n for (const b of m) colors.set(b.color, (colors.get(b.color) ?? 0) + 1)\n if (Math.max(...colors.values()) < m.length * RUN_MIN_COLOR) return false\n const shapes = new Set(m.map(b => `${Math.round((b.x2 - b.x1) * 2)}:${Math.round((b.y2 - b.y1) * 2)}:${b.segs}`))\n return shapes.size >= m.length * RUN_MIN_SHAPES\n}\n\nexport interface VectorGlyphScan {\n /** 글줄다운 조각에 든 글자 모양 경로 수 — 텍스트층에 없는 곡선 글자 수 추정 */\n glyphs: number\n /** 글자 모양 채움 경로 전부 (텍스트층이 덮은 것 포함) */\n paths: GlyphPath[]\n}\n\n/** 쪽의 벡터 글자 신호. texts 는 그 쪽 텍스트층 글 상자 */\nexport function scanVectorGlyphs(fnArray: Uint32Array | number[], argsArray: unknown[][], texts: TextBox[]): VectorGlyphScan {\n const paths = collectGlyphPaths(fnArray, argsArray)\n if (paths.length < RUN_MIN) return { glyphs: 0, paths }\n const free = paths.filter(b => !coveredByText(b, texts))\n // 줄 묶기: 세로 가운데 순으로 훑어 첫 경로 가운데에서 반 높이 안이면 같은 줄\n const sorted = free.slice().sort((a, b) => (a.y1 + a.y2) - (b.y1 + b.y2))\n let glyphs = 0\n let row: GlyphPath[] = []\n let rowCy = 0, rowH = 0\n const flushRow = () => {\n if (row.length < RUN_MIN) return\n row.sort((a, b) => a.x1 - b.x1)\n const em = median(row.map(b => b.y2 - b.y1))\n let start = 0, maxX = row[0].x2\n for (let i = 1; i <= row.length; i++) {\n if (i < row.length && row[i].x1 - maxX <= em * RUN_GAP_EM) { if (row[i].x2 > maxX) maxX = row[i].x2; continue }\n const run = row.slice(start, i)\n if (isTextRun(run, em)) glyphs += run.length\n if (i < row.length) { start = i; maxX = row[i].x2 }\n }\n }\n for (const b of sorted) {\n const cy = (b.y1 + b.y2) / 2, h = b.y2 - b.y1\n if (row.length && Math.abs(cy - rowCy) <= Math.max(h, rowH) * 0.5) { row.push(b); continue }\n flushRow()\n row = [b]; rowCy = cy; rowH = h\n }\n flushRow()\n return { glyphs, paths }\n}\n\n/**\n * OCR 로 가는 벡터 글자 쪽의 그래픽 — 글자 모양 경로와 클립을 뺀 연산자 목록(괘선·채움·그림). 그 쪽 표 구조를 래스터\n * 괘선 감지 대신 실제 괘선으로 복원한다(pdf-ocr.ts).\n *\n * 클립을 빼는 까닭: 클립 칸 격자(clip-cells.ts)는 한컴 PDF 내보내기의 \"칸마다 W n 클립\" 관행에 기댄 경로인데, 글자를 곡선으로\n * 그린 쪽은 코퍼스에서 전부 비한컴 제작기(rhwp cairo)이고 한컴 PDF 1,480개(10,976쪽)에는 한 쪽도 없다. cairo 는 음영 칸과\n * 그림에만 클립을 깔아(hwpx_sample2 2쪽: 클립 17 = 같은 기하 채움 17) 부분 클립 격자가 선 격자와 한 표를 둘로 쪼갠다 — kftc\n * 붙임 표는 머리 행이 따로 1×5 표, 본문 행은 3×6 에 섞였다. 텍스트층 없음 13쌍 46표 OCR 실측: 클립 포함 exact 16·F1 0.510·\n * NED 0.310, 클립 뺌 exact 20·F1 0.587·NED 0.445 (종전 래스터 괘선 14·0.502·0.295)\n */\nexport function ocrVectorOps(\n opList: { fnArray: Uint32Array | number[]; argsArray: unknown[][] },\n paths: GlyphPath[],\n): { fnArray: number[]; argsArray: unknown[][] } {\n const drop = new Set<number>()\n for (const p of paths) for (const i of p.ops) drop.add(i)\n const fnArray: number[] = []\n const argsArray: unknown[][] = []\n for (let i = 0; i < opList.fnArray.length; i++) {\n const op = opList.fnArray[i]\n if (drop.has(i) || op === OPS.clip || op === OPS.eoClip) continue\n fnArray.push(op)\n argsArray.push(opList.argsArray[i])\n }\n return { fnArray, argsArray }\n}\n","/**\n * 보도자료 연락처 표 — \"담당 부서 | 부서 | 책임자·담당자 | 직위 | 이름 | 연락처\". HWPX 원본은 코퍼스 470여 개가 모두 이 6열이지만,\n * 칸 클립 없이 점선으로 테두리만 그린 구버전 한컴 PDF 는 직위·이름·연락처 사이 세로선이 없고 이름과 연락처가 한 글 조각이라\n * 4열(\"과 장 정희철 (044-214-2730)\" 한 칸)로 읽힌다. 끝 칸이 행마다 \"직위 이름 (연락처)\" 꼴이면 세 칸으로 가르고(담당자가 여럿이면\n * 줄마다 한 사람 — 세 칸에 줄째 모은다, 원본도 칸 안 줄마다 한 사람), 앞 두 열의 병합 칸(표 전체든 부서 블록이든)은 글줄이 병합\n * 행 수 이하면 줄마다 제 행 칸으로 나눈다(가로선이 없을 뿐 원본은 행마다 칸이다)\n */\n\nimport type { IRBlock, IRCell } from \"../types.js\"\n\nexport const CONTACT_HEAD = /^(?:담당\\s*부서|<[^<>]+>)$/\nexport const CONTACT_ROLE = /^(?:책임자|담당자)$/\n/** 직위(띄어 쓴 두 글자 \"과 장\" 포함) · 이름 2~4자 · 연락처(괄호 전화·전자우편). 원문이 이름에 연락처를 붙여 쓰거나(\"김형수(044-201-3488)\")\n * 닫는 괄호를 빠뜨린(\"양재훈 (02-2100-1685\") 행도 있다 — 한 행이라도 안 맞으면 표 전체를 4열로 두었다. 모든 사람이 이름에 연락처를 붙여\n * 쓴 표는 원문도 직위·이름·연락처를 한 칸에 썼다(156775911 HWPX 4열 \"과 장 김형수(044-201-3488)\") — 가르지 않는다 */\nconst PERSON = /^(.+?)\\s+([가-힣]{2,4})\\s*(\\([^()]*\\d[^()]*\\)?|\\S+@\\S+)$/\n/** 이름과 연락처 사이가 띄어 있나 */\nconst spaced = (m: RegExpExecArray): boolean => /\\s/.test(m[0][m[0].length - m[3].length - 1] ?? \"\")\n\nexport function splitContactTables(blocks: IRBlock[]): void {\n for (const b of blocks) {\n const t = b.type === \"table\" ? b.table : undefined\n if (!t || t.cols !== 4 || t.rows < 2) continue\n const rows = t.cells\n if (!CONTACT_HEAD.test(rows[0][0].text.replace(/\\s+/g, \" \").trim())) continue\n // 줄마다 \"직위 이름 (연락처)\" 면 여러 사람, 아니면 칸 통째로 한 사람(줄 꺾인 칸) — 통째 먼저 재면 앞 사람 전체가 직위 자리에 든다\n const people = rows.map(r => {\n const lines = r[3].text.split(\"\\n\").map(line => PERSON.exec(line.replace(/\\s+/g, \" \").trim()))\n if (lines.length > 1 && lines.every(Boolean)) return lines as RegExpExecArray[]\n const whole = PERSON.exec(r[3].text.replace(/\\s+/g, \" \").trim())\n return whole ? [whole] : null\n })\n if (people.some(p => !p) || rows.some(r => !CONTACT_ROLE.test(r[2].text.replace(/\\s+/g, \"\")) || r[2].rowSpan !== 1 || r[3].rowSpan !== 1)) continue\n if (!people.some(p => p!.some(spaced))) continue\n // 앞 두 열 — 병합 칸만 줄마다 그 병합 행들로 나눈다 (행마다 칸이면 그대로)\n const lead: string[][] = []\n let ok = true\n for (const c of [0, 1]) {\n const col = rows.map(() => \"\")\n for (let r = 0; r < t.rows;) {\n const cell = rows[r][c], span = Math.min(Math.max(1, cell.rowSpan), t.rows - r)\n const lines = span === 1 ? [cell.text] : cell.text.split(\"\\n\").map(s => s.trim()).filter(Boolean)\n if (lines.length > span) { ok = false; break }\n lines.forEach((line, k) => { col[r + k] = line })\n r += span\n }\n if (!ok) break\n lead.push(col)\n }\n if (!ok) continue\n const one = (text: string): IRCell => ({ text, colSpan: 1, rowSpan: 1 })\n const part = (i: number, k: number) => one(people[i]!.map(p => p[k]).join(\"\\n\"))\n t.cells = rows.map((r, i) => [one(lead[0][i]), one(lead[1][i]), one(r[2].text), part(i, 1), part(i, 2), part(i, 3)])\n t.cols = 6\n }\n}\n","/**\n * 쪽 넘김으로 이은 표의 쪽 귀속 (#136) — 잇기(table-parts·cell-continuation)가 뒤 쪽 조각이 놓인 쪽을 남기고, 틀 풀기는 행마다 그 쪽을,\n * 쪽별 마크다운(pages)은 쪽이 바뀌는 자리에서 표를 다시 가른다. 쪽은 칸 객체에 단다(CELL_PAGES) — 틀 풀기가 표를 새로 짜도 칸 객체는\n * 그대로 옮긴다. 한 칸 안에서 쪽을 넘은 글은 칸 블록의 pageNumber나 글 안 자리(CELL_SPLITS)로 안다. 문서 마크다운은 바꾸지 않는다\n */\n\nimport type { IRBlock, IRCell, IRTable } from \"../types.js\"\nimport { CELL_PAGES } from \"../table/layout-frames.js\"\n\n/** 한 칸 글 안에서 뒤 쪽 글이 시작하는 자리들 (글자 자리 오름차순) — 쪼개진 행·칸 이어짐 */\ntype Split = { at: number; page: number }\nconst CELL_SPLITS = new WeakMap<IRCell, Split[]>()\n\n/** 같은 글이 옮겨 간 칸에서 쪼갠 자리를 다시 찾는다 — 잇기가 줄을 다듬어도 자리 뒤 20자는 그대로다. from 앞은 보지 않는다 */\nfunction relocate(src: IRCell, dst: IRCell, from = 0): Split[] {\n const out: Split[] = []\n for (const s of CELL_SPLITS.get(src) ?? []) {\n const at = dst.text.indexOf(src.text.slice(s.at, s.at + 20).trimEnd(), Math.max(from, out.at(-1)?.at ?? 0))\n if (at >= 0) out.push({ at, page: s.page })\n }\n return out\n}\n\nconst setSplits = (cell: IRCell, splits: Split[]): void => {\n if (splits.length) CELL_SPLITS.set(cell, splits.sort((a, b) => a.at - b.at))\n}\n\n/** 칸 이어짐(글만 든 칸) — 이을 글 add 가 cell 글 뒤 어디서 시작하는지 남긴다. add 안에 앞서 남긴 자리도 옮긴다 */\nexport function markContinuedText(cell: IRCell, add: IRCell, page: number): void {\n if (!add.text.trim()) return\n const base = cell.text.trim() ? cell.text.length + 1 : 0\n setSplits(cell, [\n ...(CELL_SPLITS.get(cell) ?? []),\n { at: base, page },\n ...(CELL_SPLITS.get(add) ?? []).map(s => ({ at: base + s.at, page: s.page })),\n ])\n}\n\n/** 행마다 그 행에서 시작하는 칸 (병합에 덮인 자리는 빼고) — 칸과 열 번호 */\nfunction rowStarts(t: IRTable): Array<Array<{ cell: IRCell; c: number }>> {\n const covered = new Set<number>()\n const rows: Array<Array<{ cell: IRCell; c: number }>> = []\n for (let r = 0; r < t.rows; r++) {\n const starts: Array<{ cell: IRCell; c: number }> = []\n for (let c = 0; c < t.cols; c++) {\n if (covered.has(r * 100000 + c)) continue\n const cell: IRCell | undefined = t.cells[r]?.[c]\n if (!cell) continue\n for (let dr = 0; dr < cell.rowSpan; dr++) for (let dc = 0; dc < cell.colSpan; dc++) if (dr || dc) covered.add((r + dr) * 100000 + c + dc)\n starts.push({ cell, c })\n }\n rows.push(starts)\n }\n return rows\n}\n\n/** 행 r 을 덮는 칸 (위에서 세로 병합으로 내려온 칸 포함) */\nconst covering = (t: IRTable, r: number): IRCell[] =>\n rowStarts(t).slice(0, r + 1).flatMap((starts, rr) => starts.filter(s => rr + s.cell.rowSpan > r).map(s => s.cell))\n\n/** 칸이 놓인 쪽 — 쪽 표시, 없으면 칸 블록이 모두 쪽을 알 때 그 첫 쪽 (앞 쪽 조각이 비어 칸 글 전부가 뒤 쪽인 칸 이어짐) */\nfunction cellPage(cell: IRCell): number | undefined {\n const p = CELL_PAGES.get(cell)\n if (p !== undefined || !cell.blocks?.length || cell.blocks.some(b => b.pageNumber === undefined)) return p\n return Math.min(...cell.blocks.map(b => b.pageNumber!))\n}\n\n/** 표 행마다 쪽 — 그 행에서 시작하는 칸의 쪽(여럿이면 큰 쪽). 쪽 모르는 글 칸 행은 표 블록 쪽, 덮인 자리·빈 칸뿐인 행은 윗행 쪽.\n * 쪽은 아래로 줄지 않는다 */\nfunction tableRowPages(t: IRTable, pageNumber: number | undefined): Array<number | undefined> {\n const pages: Array<number | undefined> = []\n let prev = pageNumber\n for (const starts of rowStarts(t)) {\n let mark: number | undefined, text = false\n for (const { cell } of starts) {\n const p = cellPage(cell)\n if (p !== undefined) mark = Math.max(mark ?? p, p)\n else if (cell.text.trim() || cell.blocks?.length) text = true\n }\n const page = mark ?? (text ? pageNumber : prev)\n prev = page !== undefined && prev !== undefined ? Math.max(prev, page) : page ?? prev\n pages.push(prev)\n }\n return pages\n}\n\n/**\n * 이은 표 칸에 쪽을 단다 — 잇기 경로는 칸을 새로 짜기도 해 조각 칸의 표시가 옮겨 오지 않는다. 어느 경로든 앞 조각 행이 위에 그대로\n * 놓이고 뒤 조각은 끝 행들을 차지하니(되풀이 머리 행은 빠진다) 행 자리로 옮긴다. 쪼개진 행(뒤 조각 첫 행을 앞 조각 마지막 행에 합침)은\n * 앞 쪽 행으로 두고 칸 글 안 뒤 쪽 자리를 남긴다 — 앞 조각 칸 글로 시작해 글이 늘어난 칸만 (되풀이 머리 행만 빠진 잇기는 늘지 않는다).\n * 앞 조각 행이 줄었으면 자리를 모르니 달지 않는다\n */\nexport function markJoinedPages(table: IRTable, prev: IRBlock, curr: IRBlock): void {\n const pt = prev.table!, ct = curr.table!\n if (!curr.pageNumber || table.rows < pt.rows) return\n const pp = tableRowPages(pt, prev.pageNumber), cp = tableRowPages(ct, curr.pageNumber)\n const off = table.rows - ct.rows\n const rows = rowStarts(table), ptRows = rowStarts(pt), ctRows = rowStarts(ct)\n rows.forEach((starts, r) => {\n const page = r < pt.rows ? pp[r] : cp[r - off]\n for (const { cell } of starts) {\n if (page !== undefined && page !== prev.pageNumber) CELL_PAGES.set(cell, page)\n // 앞서 남긴 쪼갠 자리 — 잇기가 칸을 새로 짜면 글이 같은 칸으로 옮긴다\n const src = (r < pt.rows ? ptRows[r] : ctRows[r - off])?.find(s => s.cell !== cell && s.cell.text === cell.text && CELL_SPLITS.has(s.cell))\n if (src) setSplits(cell, relocate(src.cell, cell))\n }\n })\n const cr = pt.rows - 1 - off, page = cp[cr]\n if (cr < 0 || page === undefined || page === prev.pageNumber) return\n const tails = ctRows[cr].filter(s => s.cell.text.trim())\n // 뒤 조각 칸 블록이 앞 칸에 옮겨 붙었으면(appendCell) 그 블록에 쪽을 단다\n for (const { cell } of ctRows[cr]) for (const b of cell.blocks ?? []) b.pageNumber ??= page\n const heads = covering(pt, pt.rows - 1)\n for (const cell of covering(table, pt.rows - 1)) {\n if (cell.blocks?.length) continue\n const head = heads.filter(h => h.text.trim() && cell.text.startsWith(h.text.trimEnd()) && cell.text.trim().length > h.text.trim().length)\n .sort((a, b) => b.text.length - a.text.length)[0]\n if (!head) continue\n const from = head.text.trimEnd().length\n // 뒤 조각 칸 글은 잇기가 줄을 다듬어 끝까지 같지 않을 수 있다 — 머리 20자가 앞 글 뒤에 놓인 자리로 찾는다\n const hit = tails.map(t => ({ t: t.cell, at: cell.text.indexOf(t.cell.text.trim().slice(0, 20), from) })).filter(h => h.at >= from)\n .sort((a, b) => a.at - b.at)[0]\n if (!hit) continue\n setSplits(cell, [...relocate(head, cell), { at: hit.at, page }, ...relocate(hit.t, cell, hit.at)])\n }\n}\n\n/** 칸 안에서 쪽을 넘은 글 — 앞 쪽 몫, 뒤 쪽 첫 몫과 그 쪽(더 뒤 쪽 몫은 꼬리 칸에 남아 다시 가른다). 없으면 null */\nfunction splitCell(cell: IRCell, page: number | undefined): { head: Partial<IRCell>; tail: Partial<IRCell>; page: number; rest: Split[] } | null {\n if (page === undefined) return null\n if (cell.blocks?.length) {\n const k = cell.blocks.findIndex(b => (b.pageNumber ?? page) > page)\n if (k <= 0) return null\n const head = cell.blocks.slice(0, k), tail = cell.blocks.slice(k)\n const text = (bs: IRBlock[]): string => bs.map(b => b.text ?? \"\").filter(Boolean).join(\"\\n\")\n return { head: { text: text(head), blocks: head }, tail: { text: text(tail), blocks: tail }, page: tail[0].pageNumber!, rest: [] }\n }\n const splits = CELL_SPLITS.get(cell) ?? []\n const k = splits.findIndex(s => s.page > page && s.at < cell.text.length)\n if (k < 0) return null\n const s = splits[k]\n const rest = cell.text.slice(s.at), lead = rest.length - rest.trimStart().length\n return {\n head: { text: cell.text.slice(0, s.at).trimEnd() },\n tail: { text: rest.trim() },\n page: s.page,\n rest: splits.slice(k + 1).map(x => ({ at: x.at - s.at - lead, page: x.page })).filter(x => x.at > 0),\n }\n}\n\n/**\n * 칸 안에서 쪽을 넘은 글을 그 행 아래 새 행으로 뗀다 — 새 행은 뗀 칸들이 덮는 마지막 행 바로 아래, 그 자리를 지나 내려가는 세로 병합\n * 칸은 한 행 더 덮는다. 세 쪽에 걸친 칸은 꼬리 칸을 다시 가른다. 뗄 칸이 없으면 표를 그대로 돌려준다\n */\nfunction splitCrossPageRows(t: IRTable, pageNumber: number | undefined): IRTable {\n const pages = tableRowPages(t, pageNumber)\n const rows = rowStarts(t)\n for (let r = 0; r < t.rows; r++) {\n const splits = rows[r].map(s => ({ ...s, split: splitCell(s.cell, pages[r]) })).filter(s => s.split)\n if (!splits.length) continue\n // 칸을 고쳐 복제해도 쪽 표시·남은 쪼갠 자리는 따라간다\n const clone = (cell: IRCell, patch: Partial<IRCell>): IRCell => {\n const out = { ...cell, ...patch }\n const p = CELL_PAGES.get(cell)\n if (p !== undefined) CELL_PAGES.set(out, p)\n if (!(\"text\" in patch) && CELL_SPLITS.has(cell)) CELL_SPLITS.set(out, CELL_SPLITS.get(cell)!)\n return out\n }\n const e = Math.max(...splits.map(s => r + s.cell.rowSpan - 1))\n const cells = t.cells.map(row => row.slice())\n const row: IRCell[] = Array.from({ length: t.cols }, () => ({ text: \"\", colSpan: 1, rowSpan: 1 }))\n for (const { cell, c, split } of splits) {\n cells[r][c] = clone(cell, split!.head)\n const tail: IRCell = { ...cell, ...split!.tail, rowSpan: 1 }\n CELL_PAGES.set(tail, split!.page)\n setSplits(tail, split!.rest)\n row[c] = tail\n }\n rows.forEach((starts, rr) => {\n for (const { cell, c } of starts) {\n if (splits.some(s => s.cell === cell) || rr > e || rr + cell.rowSpan - 1 <= e) continue\n cells[rr][c] = clone(cells[rr][c], { rowSpan: cell.rowSpan + 1 })\n }\n })\n cells.splice(e + 1, 0, row)\n return splitCrossPageRows({ ...t, rows: t.rows + 1, cells }, pageNumber)\n }\n return t\n}\n\n/**\n * 쪽별 사영용 블록 — 쪽 넘김으로 이은 표를 행의 쪽이 바뀌는 자리에서 다시 쪽마다 가른다 (#136). 이은 표는 첫 쪽 블록 하나라 뒤 쪽 행이\n * 앞 쪽 항목에 들어가고, 쪽 내용이 표뿐이면 그 쪽 항목이 비거나 빠졌다. 문서 블록·마크다운은 이은 그대로 두고 새 배열만 낸다.\n * 쪽 경계를 넘는 세로 병합 칸은 앞 쪽 끝 행에서 자른다\n */\nexport function splitPageTables(blocks: IRBlock[]): IRBlock[] {\n return blocks.flatMap(b => {\n if (b.type !== \"table\" || !b.table) return [b]\n const t = splitCrossPageRows(b.table, b.pageNumber)\n const pages = tableRowPages(t, b.pageNumber)\n const cuts: number[] = [0]\n for (let r = 1; r < t.rows; r++) if (pages[r] !== pages[r - 1]) cuts.push(r)\n if (cuts.length === 1) return t === b.table ? [b] : [{ ...b, table: t }]\n return cuts.map((from, k) => {\n const end = cuts[k + 1] ?? t.rows\n const cells = t.cells.slice(from, end).map((row, dr) => row.map(cell =>\n cell && from + dr + cell.rowSpan > end ? { ...cell, rowSpan: end - from - dr } : cell))\n const table: IRTable = { ...t, rows: end - from, cells, ...(k ? { caption: undefined, captionBlocks: undefined } : {}) }\n return { ...b, table, pageNumber: pages[from] }\n })\n })\n}\n","/**\n * 쪽 넘김으로 갈라진 표 잇기 (mergeCrossPageTables) — 클립 표 조각은 열 경계 합집합 격자에 두 조각을 다시 놓는다.\n *\n * 한컴 PDF 는 쪽마다 그 쪽에 그려진 칸만 클립으로 깐다. 긴 서식 표(규제영향분석서 24×13)가 쪽을 넘으면 앞 조각은\n * 앞쪽 행들이 쓰는 열 경계만(13×9), 뒤 조각은 뒤쪽 행들이 쓰는 열 경계만(11×6) 갖고, 앞 쪽에서 세로 병합으로\n * 넘어온 칸(\"기타\"·\"12.규제일몰제\")은 글이 없어 뒤 쪽에 클립조차 없다. 열 수가 달라 종전 병합(열 수 동일)은\n * 두 조각을 따로 냈다. 여기서는\n * 1) 두 조각의 열 경계 x 를 합쳐 공통 격자를 만들고 각 칸을 x 로 다시 놓은 뒤,\n * 2) 뒤 조각 첫 행부터 칸이 비어 있는 열은 앞 조각 마지막 행에서 그 열을 덮던 칸의 세로 병합을 이어 늘린다.\n * 원본(HWPX) 표 격자는 모든 행의 경계 합집합이라 이 재배치가 같은 격자를 되살린다.\n */\n\nimport type { IRBlock, IRCell, IRTable } from \"../types.js\"\nimport { CELL_LINES, CLIP_TABLES, EMPTY_PARTS, FILLER_CELLS, IMAGE_CELLS, PART_COLXS, ROW_RULES, TABLE_COLXS, TABLE_ROWYS, TABLE_TAIL, type LineBox } from \"./table-meta.js\"\nimport { CONTACT_HEAD, CONTACT_ROLE } from \"./contact-table.js\"\nimport { markJoinedPages } from \"./table-pages.js\"\nimport { startsNewItem, type WrapLexicon } from \"./line-wrap.js\"\nimport { NO_EDGES, joinCellEdges } from \"./cell-edges.js\"\nimport { CELL_EDGES } from \"../table/layout-frames.js\"\n\n/** 글 이어짐은 표시 서식이 아니라 실제 글로 판정한다. 출력 칸의 밑줄·취소선은 그대로 둔다. */\nconst flowText = (text: string): string => text.replace(/<\\/?u>|~~/g, \"\")\n\n/** 두 조각의 경계를 같은 것으로 보는 거리 (pt) — 클립 좌표 오차 0.05pt, 조각 간 반올림 여유 */\nconst PART_COL_TOL = 1\n/** 쪽 조각끼리 짜임이 다를 때 같은 경계로 보는 거리 (pt) — 쪽 안 클립 격자(clip-cells CLIP_COORD_TOL)와 같다 */\nconst PART_COL_SNAP = 0.3\n\ninterface Anchor { r: number; c: number; rs: number; cs: number; cell: IRCell }\n\n/** IR 격자 → 앵커 목록 (병합으로 덮인 칸 제외, 행 우선) */\nfunction anchorsOf(table: IRTable): Anchor[] {\n const out: Anchor[] = []\n const covered = new Set<number>()\n for (let r = 0; r < table.rows; r++) {\n for (let c = 0; c < table.cols; c++) {\n if (covered.has(r * 100000 + c)) continue\n const cell = table.cells[r]?.[c]\n if (!cell) continue\n out.push({ r, c, rs: cell.rowSpan, cs: cell.colSpan, cell })\n for (let dr = 0; dr < cell.rowSpan; dr++) {\n for (let dc = 0; dc < cell.colSpan; dc++) if (dr || dc) covered.add((r + dr) * 100000 + (c + dc))\n }\n }\n }\n return out\n}\n\n/** 오름차순 좌표 합집합 — 앞 조각 경계는 그대로 두고(조각 안 경계는 클립 격자가 0.3pt 로 이미 가름), 뒤 조각 경계는 앞 조각 경계와\n * 하나씩 짝지어(가까운 쌍부터) 같은 경계로 본다. 짝 없는 뒤 조각 경계는 새 경계다. 두 조각 경계가 같은 수로 차례차례 1pt 안이면 같은\n * 짜임이 쪽마다 조금 어긋난 것(세금포인트 보도자료 108.06/108.54·151.48/150.52·183.50/184.46)이라 PART_COL_TOL 로 짝짓고, 짜임이\n * 다르면 클립 좌표 오차(PART_COL_SNAP) 안만 짝짓는다 — 쪽마다 칸 짜임이 바뀌는 표는 1pt 떨어진 경계도 원본 격자에서 따로 선다\n * (석유사업법 과태료 금액 칸 189.4/190.3·338.1/339.1·445.4/446.4, 원본 76열 — 1pt 로 짝지으면 62열)\n * @returns U 합집합, map 뒤 조각 경계 → 합집합 좌표 */\nfunction unionCoords(a: number[], b: number[]): { U: number[]; map: Map<number, number> } {\n // 경계 수가 같고 모두 차례로 PART_COL_TOL 안이면 같은 짜임이 조금 어긋난 것 — 그 밖에는 클립 좌표 오차(PART_COL_SNAP) 안만 같은 경계\n const drift = a.length === b.length && a.every((x, i) => Math.abs(x - b[i]) <= PART_COL_TOL)\n const tol = drift ? PART_COL_TOL : PART_COL_SNAP\n const pairs: Array<[number, number, number]> = []\n for (const v of b) for (const x of a) if (Math.abs(x - v) <= tol) pairs.push([Math.abs(x - v), v, x])\n pairs.sort((p, q) => p[0] - q[0])\n const map = new Map<number, number>(), taken = new Set<number>()\n for (const [, v, x] of pairs) if (!map.has(v) && !taken.has(x)) { map.set(v, x); taken.add(x) }\n const out = [...a]\n for (const v of b) if (!map.has(v)) { map.set(v, v); out.push(v) }\n return { U: out.sort((x, y) => x - y), map }\n}\n\nconst indexOf = (coords: number[], x: number): number => {\n let best = -1, bestD = Infinity\n for (let i = 0; i < coords.length; i++) {\n const d = Math.abs(coords[i] - x)\n if (d < bestD) { bestD = d; best = i }\n }\n return bestD <= PART_COL_TOL ? best : -1\n}\n\nconst rowText = (anchors: Anchor[], r: number): string =>\n anchors.filter(a => a.r === r).map(a => flowText(a.cell.text).replace(/\\s+/g, \"\")).join(\"|\")\n/** 행 글(rowText)에 칸 구분자 말고 글이 있나 */\nconst hasText = (t: string): boolean => t.replace(/\\|/g, \"\") !== \"\"\n\n/**\n * 두 조각을 합집합 격자로 잇는다. 경계 대응이 안 되면(조각 경계가 합집합에서 사라짐) null.\n * @param pcx·ccx 앞·뒤 조각의 열 경계 x (오름차순, 뒤 조각은 앞 조각 자리로 옮긴 좌표)\n * @param dx 뒤 조각을 옮긴 거리 — 뒤 조각 칸 글줄도 같이 옮겨 앞 조각 글줄과 맞댄다 (짝·홀 쪽 대칭 여백)\n * @param prevBottom 앞 조각 밑변 y — 쪽 경계에 걸친 세로 병합 칸 글을 가린다 (mergeStraddlingCells)\n * @returns split — 경계 행을 쪼개진 행으로 보고 합쳤는지\n */\nexport function joinSplitParts(prev: IRTable, pcx: number[], curr: IRTable, ccx: number[], dx = 0, prevBottom?: number, lex?: WrapLexicon): { table: IRTable; colXs: number[]; split: boolean; header: boolean } | null {\n if (pcx.length !== prev.cols + 1 || ccx.length !== curr.cols + 1) return null\n const { U, map } = unionCoords(pcx, ccx)\n const cxU = ccx.map(v => map.get(v) ?? v)\n const cols = U.length - 1\n if (cols < 1) return null\n const pa = anchorsOf(prev), ca = anchorsOf(curr)\n\n // 반복 머리 행 — 한컴은 머리 칸이 있는 표만 조각마다 머리 행(여러 줄일 수 있음)을 되풀이한다. 표 첫 조각은 머리 행 위에\n // 제목 상자가 붙어 나올 수 있어(편람 [별표 4] \"[별표4] <개정…>\" 행) 앞 조각 두 행 아래까지 어긋나게 맞춰 본다 (어긋난 맞춤은 글 있는 행만)\n let skip = 0, head = 0\n while (skip < Math.min(3, curr.rows - 1, prev.rows) && rowText(ca, skip) !== \"\" && rowText(ca, skip) === rowText(pa, skip)) skip++\n for (let h = 1; h <= 2 && !skip; h++) {\n head = h\n while (skip < Math.min(3, curr.rows - 1, prev.rows - h) && hasText(rowText(ca, skip)) && rowText(ca, skip) === rowText(pa, h + skip)) skip++\n }\n // 되풀이 머리 행은 원래 머리 행과 칸 자리(x 범위)까지 같다 — 한컴은 머리 행을 같은 칸 기하로 다시 그린다(짝·홀 쪽 여백만큼 통째로\n // 옮겨질 뿐). 글만 같고 칸 자리가 다르면 같은 머리를 단 새 표다(경찰복제 특수복식 \"경찰악대원\" 11×5 다음 쪽 \"도형 | 형태 및 규격 |\n // 색상 및 재질\" 11×5: 칸 경계가 2.8~8.5pt 어긋남, HWPX 두 표). 글 없는 행은 머리 행 증거가 아니라 거기서 되풀이를 끊는다\n const sameSpans = (u: Anchor[], d: Anchor[]): boolean => {\n if (u.length !== d.length || !u.length) return false\n const shift = pcx[u[u.length - 1].c + u[u.length - 1].cs] - ccx[d[d.length - 1].c + d[d.length - 1].cs]\n return u.every((a, n) => Math.abs(pcx[a.c] - ccx[d[n].c] - shift) <= CONTINUATION_COL_TOL && Math.abs(pcx[a.c + a.cs] - ccx[d[n].c + d[n].cs] - shift) <= CONTINUATION_COL_TOL)\n }\n for (let k = 0; k < skip; k++) {\n if (sameSpans(pa.filter(a => a.r === head + k), ca.filter(a => a.r === k))) continue\n if (hasText(rowText(ca, k))) return null\n skip = k\n }\n\n const rows = prev.rows + curr.rows - skip\n const grid: IRCell[][] = Array.from({ length: rows }, () => Array.from({ length: cols }, () => ({ text: \"\", colSpan: 1, rowSpan: 1 })))\n const owner: (Anchor | null)[][] = Array.from({ length: rows }, () => new Array<Anchor | null>(cols).fill(null))\n /** 보이는 변 곁정보(cell-edges)를 가진 칸을 놓았나 — 그러면 빈 자리 채움 칸도 자기 테두리 없는 칸으로 둔다 */\n let edged = false\n const place = (a: Anchor, x: number[], rowOff: number, shift = 0): boolean => {\n const c1 = indexOf(U, x[a.c]), c2 = indexOf(U, x[a.c + a.cs])\n if (c1 < 0 || c2 <= c1) return false\n const r = a.r + rowOff\n const placed: Anchor = { r, c: c1, rs: a.rs, cs: c2 - c1, cell: a.cell }\n for (let dr = 0; dr < a.rs; dr++) for (let dc = c1; dc < c2; dc++) {\n if (r + dr < rows) owner[r + dr][dc] = placed\n }\n grid[r][c1] = { ...a.cell, colSpan: c2 - c1, rowSpan: a.rs }\n // 곁정보는 복사한 칸으로 옮긴다 — 세 쪽 넘게 이어질 때 다음 이음(글줄)과 후행 빈 열 정리(그림 칸)가 본다\n const lines = CELL_LINES.get(a.cell)\n if (lines) CELL_LINES.set(grid[r][c1], shift ? lines.map(l => ({ ...l, l: l.l + shift, r: l.r + shift })) : lines)\n if (IMAGE_CELLS.has(a.cell)) IMAGE_CELLS.add(grid[r][c1])\n const edges = CELL_EDGES.get(a.cell)\n if (edges) { CELL_EDGES.set(grid[r][c1], edges); edged = true }\n return true\n }\n // 채움 칸(클립 없던 자리)은 놓지 않는다 — 빈 자리로 남아 아래 세로 병합 잇기가 채운다\n for (const a of pa) if (!FILLER_CELLS.has(a.cell) && !place(a, pcx, 0)) return null\n for (const a of ca) {\n if (a.r < skip || FILLER_CELLS.has(a.cell)) continue\n if (!place(a, cxU, prev.rows - skip, dx)) return null\n }\n\n // 뒤 조각 첫 행부터 비어 있는 열 — 앞 조각 마지막 행에서 그 열을 덮던 칸의 세로 병합을 잇는다\n const first = prev.rows\n for (let c = 0; c < cols; c++) {\n const above = owner[first - 1]?.[c]\n if (!above) continue\n let r = first\n while (r < rows && owner[r][c] === null) r++\n if (r === first) continue\n // 이어 늘릴 칸은 그 열 폭을 전부 덮는 칸이어야 한다 — 자기 폭 안의 모든 열이 같은 구간만큼 비어 있을 때만\n const span = r - first\n let ok = true\n for (let dc = above.c; dc < above.c + above.cs && ok; dc++) {\n for (let rr = first; rr < first + span; rr++) if (owner[rr][dc] !== null) { ok = false; break }\n }\n if (!ok) continue\n const newRs = above.rs + span\n grid[above.r][above.c].rowSpan = newRs\n const grown: Anchor = { ...above, rs: newRs }\n for (let rr = above.r; rr < above.r + newRs; rr++) for (let dc = above.c; dc < above.c + above.cs; dc++) owner[rr][dc] = grown\n }\n\n // 아무 칸도 덮지 않은 자리는 채움 칸으로 남긴다 — 이 표가 다시 앞 쪽 조각과 이어질 때(세 쪽 넘게) 빈 자리로 보고 세로 병합을 잇게\n for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) if (owner[r][c] === null) FILLER_CELLS.add(grid[r][c])\n // 채움 칸은 자기 테두리가 없다 — 곁정보 없는 칸은 layout-frames 가 네 변 다 보이는 칸으로 본다 (둘레 칸의 변이 선을 댄다)\n if (edged) for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) if (owner[r][c] === null) CELL_EDGES.set(grid[r][c], NO_EDGES)\n // 앞 조각 왼쪽에 빠진 열(prevLacksLeftCols) — 쪽 전체를 덮은 세로 병합 칸이 그 쪽에서 클립 없이 지나간 자리다. 쪼개진 행 판정에는 두\n // 조각 경계를 가로지르는 한 칸으로 보인다(뒤 조각 첫머리 빈 자리까지). \"두 쪽 모두 클립 없는 빈 칸 조각\" 증거로 쓰면 조세특례 국가전략기술\n // \"반도체\" 칸 옆 \"너. …\" / 다음 쪽 \"더. …\" 가 한 행으로 합쳐진다. 채움 칸 표시는 그대로라 앞 쪽 조각과 다시 이을 때 세로 병합으로 이어진다\n for (let c = 0; c < cols && U[c + 1] <= pcx[0] + PART_COL_TOL; c++) {\n let r = prev.rows\n while (r < rows && owner[r][c] === null) r++\n const through: Anchor = { r: 0, c, rs: r, cs: 1, cell: grid[0][c] }\n for (let rr = 0; rr < r; rr++) owner[rr][c] = through\n }\n const table: IRTable = { rows, cols, cells: grid, hasHeader: prev.hasHeader, ...(prev.caption ? { caption: prev.caption } : {}) }\n // 쪽 경계 괘선 — 두 조각 모두 칸 밑변마다 괘선을 긋는데 앞 조각 밑변·뒤 조각 윗변만 안 그었으면(open) 쪼개진 행 증거다(mergeSplitRow 3).\n // 두 쪽 모두 쪽 경계에 괘선을 그었으면(ruled) 칸 조각 이어짐만으로는 세로 병합 칸이 넘어간 새 행과 못 가른다. 짝·홀 쪽으로 옮겨 맞댄\n // 조각(shifted)은 쪽마다 새로 놓인 상자일 수 있다 — 한 칸 제목 행을 되풀이했으면(편람 \"작 성 방 법\" 상자 \"1. … 8.\" / \"9. …\", HWPX 는\n // 두 표) 목록 번호 이어짐을 증거로 쓰지 않고, 아니어도 앞 조각 목록 번호가 둘 이상일 때만 쓴다 (편람 전송상태값 상자 \"…가. …\" / \"나. …\")\n const pr = ROW_RULES.get(prev), cr = ROW_RULES.get(curr)\n const shifted = dx !== 0\n const cut: Cut = {\n open: !!pr && !!cr && !pr.bottom && !cr.top && pr.innerOpen + cr.innerOpen === 0 && pr.innerRuled + cr.innerRuled > 0,\n ruled: !!pr && !!cr && pr.bottom && cr.top,\n list: !shifted ? \"any\" : skip > 0 && ca.filter(a => a.r < skip && a.cell.text.trim()).length < 2 ? \"none\" : \"multi\",\n }\n let split = rows > prev.rows && mergeSplitRow(table, owner, prev.rows, U, false, cut)\n // 세로 병합 칸을 이은 뒤 다시 본다 — 뒤 조각 이름표가 여러 행을 덮어 쪼개진 행 판정에서 빠졌던 행(\"일몰설정 / 예외기준\" 이름표 옆\n // \"…적용 되어야 / 하는 규제\", 규제영향분석서)은 이름표가 한 칸이 되면 칸 조각 이어짐으로 판정된다\n // (글 이어짐 증거만 — 이어 늘린 빈 칸 조각은 증거로 쓰지 않는다: 시험기준표 \"KS M ISO 2507-1,\" / \"KS M ISO 2507-2\" 는 두 행)\n if (rows > prev.rows && !split && prevBottom !== undefined && mergeStraddlingCells(table, owner, prev.rows, prevBottom, lex, U)) split = mergeSplitRow(table, owner, prev.rows, U, true, { ...cut, open: false })\n // 이은 표의 괘선 — 세 쪽 넘게 이어질 때 앞 조각과 다시 견준다. 잇지 않은 쪽 경계는 칸 밑변 하나로 센다\n if (pr && cr) {\n const cutRuled = pr.bottom || cr.top\n ROW_RULES.set(table, { top: pr.top, bottom: cr.bottom, innerRuled: pr.innerRuled + cr.innerRuled + (!split && cutRuled ? 1 : 0), innerOpen: pr.innerOpen + cr.innerOpen + (!split && !cutRuled ? 1 : 0) })\n }\n // 머리 행 증거는 되풀이된 행에 글 있는 칸이 둘 이상일 때만 — 한 칸짜리 제목 행은 같은 제목의 상자를 쪽마다 새로 놓은 것과 구별이\n // 안 된다 (편람 기안문 \"작성방법\" 상자가 짝·홀 쪽에 하나씩 놓여 한 표로 이어졌다, HWPX 는 두 표)\n return { table, colXs: U, split, header: ca.filter(a => a.r < skip && a.cell.text.trim()).length >= 2 }\n}\n\n/**\n * 쪽 경계에 걸친 세로 병합 칸 — 행은 새로 시작해도(쪼개진 행 아님) 앞 쪽에서 두 행 이상 덮은 칸의 짧은 글(가운데 정렬)이 앞 조각\n * 바닥에 붙어 있으면 그 글은 쪽 경계에 걸쳐 다음 쪽 첫 행 칸으로 이어진다 (시험기준표 분류 칸 \"건축구조용 표면처리 / 경량형강\n * (KS D 3854)\"·\"석고보드 / (GB-R)\"). 두 칸을 한 세로 병합 칸으로 잇는다. 쪽 넘김 후보를 HWPX 로 대조한 세 세트(시험기준표·법령 별표\n * 2세트)에서 이 모양 11건이 모두 같은 칸 — 새 분류 칸은 글이 칸 가운데라 앞 조각 바닥에서 떨어져 있고, 긴 글 칸은\n * 줄 수로 거른다(위→아래로 차 바닥에 닿아도 다음 쪽이 새 칸일 수 있다: 복제 규칙 \"라. 항공경찰관 점퍼 …\" 12줄 → \"마. …\").\n * 쪽 경계가 행 묶음 안을 지날 때만 본다 — 다른 열 칸이 경계를 넘어 이어져야 한다(뒤 조각 첫 행 자리에 클립 없음, 11건 모두).\n * 모든 칸이 경계에서 끝나면 다음 쪽 첫 행은 새 묶음이다 (연락처 표 되풀이된 \"<공동>\" 5건, 과제 목록 두 행 칸 \"4-29\" 한 줄이\n * 바닥 가까이 있어도 다음 쪽 \"4-30\" 은 새 과제)\n */\nfunction mergeStraddlingCells(table: IRTable, owner: (Anchor | null)[][], first: number, prevBottom: number, lex?: WrapLexicon, colXs?: number[]): boolean {\n const last = first - 1\n const straddles = owner[first].some((o, c) => o !== null && o === owner[last][c])\n // 뒤 조각 첫 행에서 글 있는 새 칸이 처음 나오는 열 — 빈 이어짐은 그보다 왼쪽(이름표 열)만 본다. 왼쪽 이름표 열에 새 묶음 이름이\n // 있으면 그 행은 새 묶음이고 오른쪽 빈 칸(비고)은 새 칸이다(시험기준표 \"드레인보드\" 행 비고 칸)\n let firstNew = table.cols\n for (let c = 0; c < table.cols; c++) {\n const d = owner[first][c]\n if (d && d.r === first && d !== owner[last][c] && hasContent(table.cells[d.r][d.c])) { firstNew = c; break }\n }\n // 두 조각 글이 문서 어휘로 어절 중간에서 갈린 열이 하나라도 있으면(줄 꺾임 이음 \"\") 쪽 경계가 세로 병합 칸들을 가로질러 자른\n // 것이다 — 같은 모양(앞 조각 한 행 → 뒤 조각 여러 행)의 다른 이름표 칸도 글 위치(바닥 근접)와 무관하게 잇는다\n // (시험기준표 \"급속함수량측 / 정기 사용불가\" 옆 \"시멘트안정처리 / 기층\", 문서 안 \"…측정기\")\n let midWord = false\n if (lex) for (let c = 0; c < table.cols && !midWord;) {\n const u = owner[last][c], d = owner[first][c]\n if (!u) { c++; continue }\n c = u.c + u.cs\n if (!d || d === u || u.r + u.rs - 1 !== last || d.r !== first || d.c !== u.c || d.cs !== u.cs) continue\n // 쪽 경계 병합 모양(앞 조각 끝 행 한 칸 → 뒤 조각 여러 행)인 열에서, 두 글자+두 글자 문서 어휘 증거로만 (줄 꺾임 판정의 한 글자\n // 쌍·조각 규칙은 칸 조각 쌍에 잡음이 많다 — \"경도 / 체결 축력\", \"슬럼프 … / 공기량\")\n if (u.r !== last || d.rs < 2) continue\n const tail = table.cells[u.r][u.c].text.trim().split(/\\s+/).pop() ?? \"\", head = table.cells[first][d.c].text.trim().split(/\\s+/)[0] ?? \"\"\n if (/^[가-힣]{2,}$/.test(tail) && /^[가-힣]{2,}/.test(head) && lex.evidence2(tail, head) === \"\") midWord = true\n }\n // 긴 글 칸도 글이 이어지면 쪽 경계에 걸친 칸이다 — 다른 열 칸이 경계를 넘어 이어지고(straddles) 앞 조각에서 여러 행을 덮은 칸의 끝줄이\n // 칸 오른끝까지 차 문장이 다음 쪽 칸으로 넘어갔다(continuesAcross). 같은 모양(앞 조각 행 범위·뒤 조각 행 수)의 옆 칸도 줄 수·바닥 거리와\n // 무관하게 같은 병합 칸의 나머지다 (경찰복제 특수복식 교통경찰관 17→18쪽: \"…주머니를 작게 한\" / \"다. 나) 하의 …\" 옆 색상 칸\n // \"가) 상의\" / \"(1) 아이보리화이트색\" — HWPX 두 칸 모두 3행 병합). 끝줄이 짧게 끝난 긴 칸(\"라. 항공경찰관 점퍼\" 12줄 → \"마.\")은 해당 없다\n const crossed = new Set<string>()\n const shape = (u: Anchor, d: Anchor): string => `${u.r}:${u.rs}:${d.rs}`\n if (straddles && colXs) for (let c = 0; c < table.cols;) {\n const u = owner[last][c], d = owner[first][c]\n if (!u) { c++; continue }\n c = u.c + u.cs\n if (!d || d === u || u.r + u.rs - 1 !== last || d.r !== first || d.c !== u.c || d.cs !== u.cs || u.rs < 2) continue\n const a = table.cells[u.r][u.c], b = table.cells[first][d.c]\n if (hasContent(a) && hasContent(b) && continuesAcross(a, b, colXs[u.c], colXs[u.c + u.cs])) crossed.add(shape(u, d))\n }\n // 다른 열이 다음 항목으로 새 행을 열었나(\"가) …\" / 다음 쪽 \"나) …\") — 그 옆에서 글이 쪽을 넘어 이어지는 칸은 두 행을 덮는 칸이다\n let itemBreak = false\n for (let c = 0; c < table.cols && !itemBreak;) {\n const u = owner[last][c], d = owner[first][c]\n if (!u) { c++; continue }\n c = u.c + u.cs\n if (d && d !== u && d.r === first) itemBreak = nextItemHead(table.cells[u.r][u.c].text, table.cells[d.r][d.c].text)\n }\n let merged = false\n for (let c = 0; c < table.cols;) {\n const u = owner[last][c], d = owner[first][c]\n if (!u) { c++; continue }\n c = u.c + u.cs\n if (!d || d === u || u.r + u.rs - 1 !== last || d.r !== first || d.c !== u.c || d.cs !== u.cs) continue\n const a = table.cells[u.r][u.c], b = table.cells[first][d.c]\n // 글이 칸 오른끝까지 차 다음 쪽 칸으로 이어진 칸 — 다른 열이 다음 항목으로 새 행을 열고(itemBreak) 경계를 넘어 이어지는 칸도\n // 있으면(straddles) 앞 조각 끝 행 하나·뒤 조각 한 행이어도 쪽 경계에 걸친 병합 칸이다(보안심사대행기관 처분기준 \"2) … 대행기관\n // 지정기\" / 다음 쪽 \"준에 미달하게 된 경우\" 옆 \"가) …\" / \"나) …\"). 항목 증거가 없으면 좁은 칸을 우연히 채운 끝줄과 못 가른다\n // (시험기준표 \"겉모양, 치수, 무게\" / 다음 쪽 \"화학성분\" 은 두 행). 뒤 조각이 새 항목 머리로 열면 끝줄이 우연히 찬 것이다\n // (총포화약법 행정처분기준 \"커. … 2) 2회 이상 위반\" / 다음 쪽 \"터. 수출하기 위한 …\")\n const flowsOn = itemBreak && straddles && !!colXs && hasContent(a) && hasContent(b) && longLastLine(a) && !startsNewItem(a.text, b.text)\n && continuesAcross(a, b, colXs[u.c], colXs[u.c + u.cs])\n // 빈 이어짐 — 앞 조각에서 두 행 이상 덮은 이름표 칸 아래 뒤 조각 첫 칸이 비었으면 그 칸의 나머지다. 새 묶음이면 이름표가 있다\n // (aift 기업 현황 \"자본잠식현황\" 앞 쪽 2행 + 다음 쪽 빈 4행, \"자본총계\" 2행 + 빈 1행 — 모든 칸이 쪽 경계에서 끝나도).\n // 글이 이어진 칸과 같은 모양(crossed)이면 아래 짧은 글 조건을 보지 않는다\n if (!flowsOn && !crossed.has(shape(u, d)) && !(u.rs >= 2 && u.c + u.cs <= firstNew && hasContent(a) && !hasContent(b))) {\n if (!straddles && !midWord) continue\n // 앞 조각에 끝 행 하나만 보인 칸도 뒤 조각 칸이 두 행 이상을 덮으면 쪽 경계에 걸친 병합 칸이다(성능시험 TRL 표 \"제품화 / 단계\",\n // 시험기준표 \"플라이애시 / 시멘트(KS L 5211)\") — 뒤 조각 한 행 칸은 새 칸일 수 있어 그대로 둔다\n if (u.r >= last && d.rs < 2) continue\n const U = CELL_LINES.get(a)\n if (!U?.length || !hasContent(b)) continue\n if (!(straddles && filledToBottom(U, prevBottom) && clauseContinues(a, b))) {\n if (U.length > STRADDLE_MAX_LINES) continue\n const lu = U[U.length - 1]\n // 앞 조각에 한 행만 보인 칸은 글이 쪽 경계에 걸쳐야 한다(가운데 정렬 병합 칸 글이 바닥 반 줄 안) — 한 행짜리 새 칸 글은 행 가운데에 선다\n if (!midWord && lu.y - prevBottom > (u.r >= last ? STRADDLE_BOTTOM_ONE_ROW : STRADDLE_BOTTOM) * (lu.h || 10)) continue\n }\n }\n appendCell(a, b)\n a.rowSpan = first + d.rs - u.r\n table.cells[first][d.c] = { text: \"\", colSpan: 1, rowSpan: 1 }\n const grown: Anchor = { ...u, rs: a.rowSpan }\n for (let rr = u.r; rr < u.r + grown.rs; rr++) for (let dc = u.c; dc < u.c + u.cs; dc++) owner[rr][dc] = grown\n merged = true\n }\n return merged\n}\n\n/** 칸 끝줄이 글자 여섯 개 폭 이상 — 좁은 칸에서 끝줄이 칸을 채운 짧은 낱말(\"시정⏎명령\" / 다음 쪽 \"업무⏎정지 1개월\")은 글 이어짐 증거가 아니다 */\nfunction longLastLine(c: IRCell): boolean {\n const L = CELL_LINES.get(c), l = L?.[L.length - 1]\n return !!l && l.r - l.l >= 6 * (l.h || 10)\n}\n\n/**\n * 위에서부터 흐른 칸 글이 앞 조각 바닥까지 찼다 — 끝줄 아래 남은 자리가 줄 간격(글줄 사이 기준선 거리)에 글자 크기\n * FILL_SLACK 배를 더한 것보다 작아 다음 줄이 들어갈 수 없었다. 가운데 정렬 병합 칸(바닥 반 줄 안, STRADDLE_BOTTOM)과 달리\n * 위 정렬 칸은 글이 여러 줄이어도 끝줄이 바닥에 붙지 않는다 (출입국관리법 과태료 \"차. …준용하는\" 끝줄 기준선이 밑변 위 25pt,\n * 줄 간격 22pt·12pt 글자)\n */\nfunction filledToBottom(U: ReadonlyArray<{ y: number; h: number }>, bottom: number): boolean {\n if (U.length < 2) return false\n const lu = U[U.length - 1]\n return lu.y - bottom < (U[0].y - lu.y) / (U.length - 1) + FILL_SLACK * (lu.h || 10)\n}\n\n/** 끝줄이 칸 글 폭 오른끝에 이만큼(글자 크기 배) 안이면 꽉 찬 줄 */\nconst FULL_LINE_TOL = 1\n/** 끝줄 폭이 칸 폭의 이 비율 이상이어야 꽉 찬 줄 (좁은 칸의 짧은 낱말 제외) */\nconst FULL_LINE_MIN_FRAC = 0.5\n/** 앞 쪽 한 줄짜리 칸: 끝줄과 칸 오른끝 사이 최대 거리 — 셀 안쪽 여백(기본 1.8mm ≈ 5pt) + 여유(pt), 어절 하나 폭(글자 크기 배) */\nconst FULL_LINE_EDGE_MIN = 12\nconst SINGLE_LINE_WORD_GAP = 3.5\n/** 왼쪽 정렬 증거 — 왼끝에서 시작해 오른끝보다 글자 크기의 이 배수 넘게 짧은 줄 */\nconst SHORT_LINE_GAP = 1.5\n/** 쪽 경계에 걸친 세로 병합 칸 — 글 줄 수 상한, 끝줄 기준선이 앞 조각 밑변 위 글자 크기의 이 배수 안 */\nconst STRADDLE_MAX_LINES = 3\nconst STRADDLE_BOTTOM = 1.2\nconst STRADDLE_BOTTOM_ONE_ROW = 0.55\n/** 바닥까지 찬 글 — 줄 간격에 더하는 여유(글자 크기 배): 끝줄 아래 글자 내림(0.25배)과 칸 아래 여백 */\nconst FILL_SLACK = 0.5\n/** 칸 조각 이어짐 증거로 보는 빈 자리 열의 최소 폭 (pt) — 클립 격자의 셀 최소 폭과 같다 */\nconst CARRIED_MIN_COL_W = 4\n/** 끝난 이름표 — 한 줄 칸 오른쪽에 남은 자리가 글자 크기의 이 배수 이상 (숫자 머리 \"4.\" 한 어절이 들어간다) */\nconst LABEL_ROOM = 1.5\n/** 내어쓰기 이어짐 — 앞 끝줄이 칸 글 왼끝보다 글자 크기의 이 배수 이상 들어가 있고, 뒤 첫 줄 왼끝이 이 거리(pt) 안에서 같다 */\nconst HANGING_MIN = 0.5\nconst HANGING_TOL = 1\n\n/** 항목 머리 차례 — 한글 가나다(가~하, 거~허, 고~호 …)·숫자 */\nconst KO_ITEMS = \"가나다라마바사아자차카타파하거너더러머버서어저처커터퍼허고노도로모보소오조초코토포호구누두루무부수우주추쿠투푸후\"\nconst ITEM_MARK = /^\\s*(?:([가-힣])[.)]|(\\d{1,3})[.)]|\\(([가-힣\\d]{1,3})\\))(?=\\s)/\n\n/** 뒤 글이 앞 글 첫머리 항목의 바로 다음 항목(같은 꼴)으로 시작하는가 — \"커.\" 다음 \"터.\", \"3.\" 다음 \"4.\", \"(나)\" 다음 \"(다)\" */\nexport function nextItemHead(prev: string, next: string): boolean {\n const a = ITEM_MARK.exec(prev), b = ITEM_MARK.exec(next)\n if (!a || !b) return false\n const succ = (x: string, y: string) => /^\\d+$/.test(x) ? /^\\d+$/.test(y) && Number(y) === Number(x) + 1\n : KO_ITEMS.indexOf(x) >= 0 && KO_ITEMS.indexOf(y) === KO_ITEMS.indexOf(x) + 1\n // \"가.\" 다음 \"나.\", \"가)\" 다음 \"나)\" — 꼴(마침표·닫는 괄호)이 같아야 한다 (보안심사대행기관 처분기준 \"가) 등록요건 중 일부…\" / 다음 쪽 \"나) …\")\n if (a[1] && b[1]) return succ(a[1], b[1]) && prev.trimStart()[1] === next.trimStart()[1]\n if (a[2] && b[2]) return succ(a[2], b[2]) && prev.trimStart()[a[2].length] === next.trimStart()[b[2].length]\n if (a[3] && b[3]) return succ(a[3], b[3])\n return false\n}\n\n/**\n * 앞 쪽 칸(u)의 글이 뒤 쪽 칸(d)으로 이어지는지 — 앞 쪽 끝줄이 칸 글 폭 오른끝까지 차 있으면 문단 중간에서 끊긴 것이다\n * (줄은 다음 어절이 안 들어갈 때만 바뀐다). 가운데 정렬 칸은 끝줄이 가장 긴 줄일 뿐이어도 오른끝에 닿으므로, 왼끝에서\n * 시작해 오른끝에 못 미친 줄(문단 끝줄)이 두 조각 어딘가에 있는 칸만 본다.\n */\nfunction continuesAcross(u: IRCell, d: IRCell, x1: number, x2: number): boolean {\n const U = CELL_LINES.get(u), D = CELL_LINES.get(d)\n if (!U?.length || !D?.length) return false\n const last = U[U.length - 1]\n const fs = last.h || 10\n let minL = Infinity, maxR = -Infinity\n for (const l of U) { minL = Math.min(minL, l.l); maxR = Math.max(maxR, l.r) }\n for (const l of D) { minL = Math.min(minL, l.l); maxR = Math.max(maxR, l.r) }\n // 칸 글 오른끝 — 가장 긴 줄의 오른끝과 칸 오른끝(왼쪽 안쪽 여백을 오른쪽에도 둔) 가운데 먼 쪽. 줄이 모두 짧은 칸은 끝줄이 가장 긴\n // 줄이어도 꽉 찬 게 아니다 (편람 대비표 \"제44조의2 [이동 …]\" 두 줄이 다 334pt 에서 끝나는데 칸 글 오른끝은 371pt). 가운데 정렬 칸은\n // 가장 긴 줄의 좌우 여백이 같아 두 값이 같다\n maxR = Math.max(maxR, x2 - (minL - x1))\n if (maxR - last.r > FULL_LINE_TOL * fs) return false\n // 앞 쪽에 한 줄뿐이면 그 줄이 곧 가장 긴 줄이라 위 비교는 늘 참이다 — 그때는 칸 오른끝에서 어절 하나 폭 안까지 닿아야 한다\n // (오른쪽이 들쭉날쭉한 칸은 다음 어절이 안 들어가 그만큼 남기고 줄이 바뀐다: \"…규범적 / 성격의 규제\" 37pt, 13pt 글자)\n if (U.length === 1 && x2 - last.r > Math.max(FULL_LINE_EDGE_MIN, SINGLE_LINE_WORD_GAP * fs)) return false\n // 내어쓴 자리에서 시작한 끝줄은 그 자리부터 칸 오른끝까지가 줄 폭이다 (좁은 칸 \"(나) 그 / 밖 의\" 둘째 줄 31pt, 칸 75pt)\n const indented = last.l - minL >= HANGING_MIN * fs\n if (last.r - last.l < FULL_LINE_MIN_FRAC * (indented ? x2 - last.l : x2 - x1)) return false\n // 문단 끝줄은 칸 글 왼끝에서 시작하거나, 내어쓴 자리에서 시작하면 같은 자리의 다른 줄과 오른끝이 달라야 한다 — 가운데 정렬 줄은\n // 왼끝이 같으면 폭도 같다. 내어쓰기가 글자 하나보다 깊은 칸(10pt 글자에 13pt 내어쓰기, 안전인증기관 기준 \"가. … / 1) …\")은 끝줄이\n // 칸 글 왼끝에서 글자 하나 넘게 들어가 있다. 이 넓힌 증거와 아래 내어쓰기 첫 줄은 뒤 쪽 글이 새 항목 머리로 시작하면 쓰지 않는다 —\n // 앞 끝줄이 문단 끝인데 우연히 꽉 찬 것이다 (폐기물관리법 과태료 \"…갱신하지 않은 경우\" / 다음 쪽 새 행 \"부. 법 제40조제8항에 …\")\n const newItem = startsNewItem(u.text, d.text)\n const all = D.length ? U.concat(D) : U\n const leftAligned = (l: LineBox) => maxR - l.r > SHORT_LINE_GAP * fs\n && (l.l - minL <= fs || (!newItem && all.some(o => o !== l && Math.abs(o.l - l.l) <= HANGING_TOL && Math.abs(o.r - l.r) >= HANGING_MIN * fs)))\n // 양쪽 맞춤이라 모든 줄이 꽉 차 문단 끝줄이 없는 칸 — 앞 쪽 끝줄이 내어쓴 자리(문단 둘째 줄 이후)에서 시작하고 뒤 쪽 첫 줄이\n // 같은 자리에서 이어지면 같은 문단이다 (제약기업 인증 규정 별첨 대비표 \"1. 다음 각 목의 … 해 / 당하는 … 받지 않거 / 나, 그 행정처분을…\":\n // 93 → 104 → 104). 새 문단이면 뒤 첫 줄이 문단 머리 자리에서 시작한다 (물관리 제안요청서 \"‧다국적 …\" 다음 \"‧수질오염 …\")\n const hanging = U.length >= 2 && U.every(l => maxR - l.r <= FULL_LINE_TOL * fs)\n && indented && Math.abs(D[0].l - last.l) <= HANGING_TOL\n // 문단 머리 줄에서 끊긴 내어쓰기 — 앞 쪽 끝줄이 문단 머리 자리(내어쓰기 전)에서 꽉 차고, 뒤 쪽 줄이 모두 그보다 들어간 한 자리에서\n // 꽉 차게 이어진다 (석유사업법 과태료 \"4) 법 제38조의\" / 다음 쪽 \"2제1항을 위반 / 하여 석유제품 / …\" 66 → 75). 새 칸의 문단이면 뒤 첫 줄이\n // 머리 자리에서 시작하거나(내어쓰기) 둘째 줄이 첫 줄보다 나온다(들여쓰기)\n const hangingHead = !indented && D.length >= 2 && D[0].l - last.l >= HANGING_MIN * fs && maxR - D[0].r <= FULL_LINE_TOL * fs\n && D.every(l => Math.abs(l.l - D[0].l) <= HANGING_TOL)\n // 내어쓰기 첫 줄 — 앞 쪽에 문단 첫 줄 한 줄만 보이고 뒤 쪽 첫 줄이 그보다 들어간 자리(내어쓴 자리)에서 새 항목 머리 없이 이어진다\n // (도시정비법 과징금 \"3) 건설업자 또는 등록사업자가\" 75pt / 다음 쪽 \"법 제132조제2항을 …\" 86pt). 새 행이면 뒤 첫 줄은 항목 머리 자리에서\n // 시작한다. 가운데·오른쪽 정렬 칸도 짧은 뒷줄은 들어가 시작하므로, 뒤 첫 줄이 칸 폭 절반 넘게 차 오른끝을 같이 쓰거나(양쪽 맞춤 — 오른쪽\n // 정렬 짧은 글 \"사무관\" / \"과장\" 과 가른다) 오른끝이 짧게 끝나며 가운데가 어긋나야 한다(가운데 정렬은 두 줄 가운데가 같다)\n const d0 = D[0]\n // 뒤 쪽 둘째 줄부터가 첫 줄보다 나오면 첫 줄만 들여 쓴 새 문단이다 — 내어쓰기 이어짐이면 뒤 줄이 모두 그 자리 안쪽에 선다\n const hangStart = U.length === 1 && !newItem && d0.l - last.l >= HANGING_MIN * fs && D.every(l => l.l >= d0.l - HANGING_TOL)\n && ((Math.abs(d0.r - last.r) <= HANGING_TOL && d0.r - d0.l >= FULL_LINE_MIN_FRAC * (x2 - x1))\n || (last.r - d0.r > SHORT_LINE_GAP * fs && Math.abs(d0.l + d0.r - last.l - last.r) / 2 >= HANGING_MIN * fs))\n return U.some(leftAligned) || D.some(leftAligned) || hanging || hangingHead || hangStart\n}\n\n/** 개조식 절 제목 줄(□) */\nconst OUTLINE_HEAD = /^[□■]\\s*\\S/\n/** 절 제목 아래 단계 항목 부호 */\nconst OUTLINE_ITEM = /^[ㅇ○◦\\-‐–·․‧※*]\\s*\\S/\n\n/**\n * 개조식 위계 이어짐 — 앞 쪽 칸 글이 □ 절 제목 아래 항목이고 뒤 쪽 칸 글이 그 아래 단계 부호(ㅇ·-·※)로 시작하면 절 안 목록이\n * 다음 쪽으로 넘어간 것이다 (새 칸이면 절 제목부터 시작한다). 칸 안 문단 경계에서 쪽이 넘어가 글 이어짐 증거가 없는 행을 잡는다\n * (과제 품목 명세서 \"□ 개발내용 / ㅇ … / - … 주사제형화 기술 개발\" / 다음 쪽 \"ㅇ PFC 나노산소운반체의 …\")\n */\nfunction outlineContinues(u: IRCell, d: IRCell): boolean {\n const U = flowText(u.text).split(\"\\n\").map(l => l.trim()).filter(Boolean)\n const D = flowText(d.text).split(\"\\n\").map(l => l.trim()).filter(Boolean)\n return U.length >= 2 && D.length >= 1 && U.some(l => OUTLINE_HEAD.test(l)) && OUTLINE_ITEM.test(D[0])\n}\n\n/** 한 줄로 끝난 왼쪽 정렬 칸 — 오른쪽에 남은 자리(왼쪽 안쪽 여백만큼 뺀)가 글자 크기의 LABEL_ROOM 배 이상이라 다음 어절이 들어갈 수 있었다.\n * 다음 조각 첫 어절을 알면 그 폭(한글·한자 1em, 나머지 0.55em)도 들어가야 한다 — \"…적용 되어야\" 뒤 1.5em 남짓한 자리에 \"하는\"(2em)은\n * 안 들어가 줄이 바뀐 것이다(규제영향분석서 12.규제일몰제). 가운데 정렬 칸은 좌우 여백이 같아 해당하지 않는다 */\nfunction lineEnded(c: IRCell, x1: number, x2: number, next?: IRCell): boolean {\n const L = CELL_LINES.get(c)\n if (!L || L.length !== 1) return false\n const l = L[0], h = l.h || 10\n // 뒤 조각 첫 줄이 앞 줄보다 들어간 자리에서 시작하면 끝난 이름표 뒤의 새 이름표가 아니라 앞 문단의 아래 항목·내어쓴 줄이다\n // (군인연금법 유족 증명서류 \"1. 가족관계증명서상 자녀인 경우\" 168pt / 다음 쪽 \"가. 군인등의 …\" 180pt — 같은 칸 목록이 쪽을 넘었다)\n const d0 = next ? CELL_LINES.get(next)?.[0] : undefined\n if (d0 && d0.l - l.l >= HANGING_MIN * h) return false\n return (x2 - l.r) - (l.l - x1) >= Math.max(LABEL_ROOM, firstWordUnits(next?.text ?? \"\")) * h\n}\n\n/** 첫 어절 폭(글자 크기 배) — 한글·한자 1, 나머지 0.55. 여는 괄호 뒤 공백은 텍스트층이 끼운 것이라 다음 낱말과 한 어절로 본다\n * (선관위 자격증 \"( 자격증종류는 별표 12에 의함)\") */\nfunction firstWordUnits(text: string): number {\n const word = flowText(text).trim().replace(/^([(\\[「『<〈])\\s+/, \"$1\").split(/\\s+/)[0] ?? \"\"\n let units = 0\n for (const ch of word) units += /[가-힣\\u3400-\\u9fff]/.test(ch) ? 1 : 0.55\n return units\n}\n\n/**\n * 칸 조각 b 를 a 뒤에 잇는다. 어느 한쪽이 블록(칸 안 표를 품은 틀 칸의 문단·표)을 가지면 블록으로 잇는다 — 렌더는 블록을 먼저\n * 보므로 글만 이으면 사라진다 (코드 예시 틀 칸이 쪽을 넘을 때 다음 쪽 코드가 통째로 빠졌다). 블록 없는 쪽 글은 줄마다 문단으로\n */\nfunction appendCell(a: IRCell, b: IRCell): void {\n if (a.blocks?.length || b.blocks?.length) {\n const asBlocks = (c: IRCell): IRBlock[] => c.blocks?.length ? c.blocks\n : c.text.split(\"\\n\").map(l => l.trim()).filter(Boolean).map((text): IRBlock => ({ type: \"paragraph\", text }))\n a.blocks = [...asBlocks(a), ...asBlocks(b)]\n }\n if (b.text.trim()) a.text = a.text.trim() ? a.text + \"\\n\" + b.text : b.text\n joinCellEdges(a, b)\n}\n\n/** 칸에 내용이 있나 — 글·블록·그림 (그림은 PDF 에서 칸 글과 따로 뽑혀 text 가 빈다) */\nconst hasContent = (cell: IRCell): boolean => !!cell.text.trim() || !!cell.blocks?.length || IMAGE_CELLS.has(cell)\n\n/**\n * 쪽 넘김으로 쪼개진 행 — 뒤 조각 첫 행을 앞 조각 마지막 행에 합친다. 한컴은 칸 단위로 나누지 않는 표의 긴 행을 글줄\n * 사이에서 끊어 두 쪽에 나눠 그리고, 쪽 끝 행은 쪼개졌든 아니든 본문 바닥까지 늘려 그리므로 기하로는 구별이 안 된다.\n * 두 행의 칸 짜임(열 범위)이 같고, 다음 증거 가운데 하나가 있을 때만 합친다.\n * 1) 글 이어짐 — 어느 한 열에서 글이 앞 쪽 끝줄을 꽉 채우고 뒤 쪽으로 이어지거나(continuesAcross) 문장 중간에서 끊겼다(clauseContinues),\n * 뒤 쪽 첫 줄 번호가 앞 쪽 칸 목록의 다음 차례다(listContinues).\n * 2) 칸 조각 이어짐 — 한컴은 칸의 첫 조각에는 글이 없어도 클립을 깐다(편람 대비표 빈 시행규칙 칸). 앞 쪽 끝 행에서 시작한\n * 칸(또는 앞 쪽에서도 클립이 없던 칸)이 다음 쪽에 클립이 없으면 그 칸은 다음 쪽 첫 행 띠까지 이어진 것이다. 글 있는 열 쌍이\n * 하나뿐이고 새 칸 증거(앞 쪽 빈 칸 뒤의 글)가 없으면 행이 쪼개진 것으로 본다. 칸 안 문단 경계에서 쪽이 넘어가 글 증거가 없는\n * 행(편람 대비표 \"…제1항 / ② 중앙행정기관…\")을 여기서 잡는다. 쪽 넘김 후보를 HWPX 로 같은 칸인지 확인한 대조: 표 GT 한컴\n * 407쌍에서 이 조건 42건이 전부 쪼개진 행, 따로 뗀 법령 서식·별표 4세트(rhwp 변환 HWPX)에서 121건 중 새 행 2건.\n * 글 있는 열 쌍이 둘 이상이면 세로 병합 칸만 이어지고 행은 새로 시작하는 경우(시험기준표 \"플라이애시 / 시멘트\")와\n * 섞여 쓰지 않는다(아래 carriedSplit 의 두 예외). 위에서 내려온 세로 병합 칸이 클립 없이 넘어간 것은 새 행에서도 똑같아 증거가 아니다\n * 3) 괘선 없는 쪽 경계 — 두 조각 모두 칸 밑변마다 괘선을 긋는데 쪽 경계만 앞 조각 밑변·뒤 조각 윗변 괘선이 없다(한컴은 쪽을 넘는 칸의\n * 잘린 변에 테두리를 긋지 않는다). 법령 별표 272건 쪽 넘김 후보에서 이 모양 40건이 모두 쪼개진 행, 새 행 0건\n * (군인연금법 유족 증명서류 \"바. 「군인 재해보 / 상법」 …\" 옆 \"1. … 자녀인 경우 / 가. 군인등의 …\")\n */\n/** 문장 중간에서 끊긴 끝 어절 — 받침에 맞는 목적격 조사(받침 뒤 \"을\"·모음 뒤 \"를\" — \"마을\" 은 아니다)나 관형형·연결 어미(뒤에 서술어가 와야 하는 -어야 포함) */\nconst CLAUSE_OPEN_ENDING = /(?:하는|되는|하고|하며|하여|되어|되고|되며|이며|으며|어야|아야|여야)$/\nconst batchim = (ch: string): boolean => { const k = ch.charCodeAt(0) - 0xac00; return k >= 0 && k < 11172 && k % 28 !== 0 }\n/**\n * 글 이어짐(문장) — 앞 쪽 칸 글이 문장 중간에서 끊기고 뒤 쪽 칸이 새 항목 머리가 아닌 한글로 시작한다. 가운데 정렬 칸은 끝줄이 오른끝에\n * 닿는지(continuesAcross)로 못 가려(규제영향분석서 정성분석 \"…폐기 사실을 / 입력하므로 제도 도입에 따른…\") 말 자체로 본다\n */\nfunction clauseContinues(u: IRCell, d: IRCell): boolean {\n const tail = flowText(u.text).trim().split(/\\s+/).pop() ?? \"\", head = flowText(d.text).trim()\n if (!/^[가-힣]{2,}$/.test(tail) || !/^[가-힣]/.test(head) || startsNewItem(u.text, head)) return false\n const last = tail[tail.length - 1], before = tail[tail.length - 2]\n return (last === \"을\" && batchim(before)) || (last === \"를\" && !batchim(before)) || CLAUSE_OPEN_ENDING.test(tail)\n}\n\n/**\n * 수량 이어짐 — 앞 쪽 칸 글이 수량(숫자·%·단위)으로 끝나고 뒤 쪽 칸이 수량에만 붙는 비교 의존 명사(\"미만\"·\"이상\"·\"이하\"·\"초과\")로 연다.\n * 좁은 칸에 가운데 정렬한 짧은 머리 칸은 끝줄이 칸을 채우지 않아 글 이어짐(continuesAcross)으로 못 가른다(대기환경보전법 기본부과금\n * 부과계수 \"30%\" / 다음 쪽 \"미만\"). 법령 별표·표·글 정답지 1,016 PDF 쪽 넘김 행 거절 342건 가운데 이 꼴은 그 한 건뿐이다\n */\nfunction quantityContinues(u: IRCell, d: IRCell): boolean {\n return /\\d[\\d,.]*\\s*(?:%|[가-힣]{1,2})?$/.test(flowText(u.text).trim()) && /^(?:미만|이상|이하|초과)(?![가-힣])/.test(flowText(d.text).trim())\n}\n\n/**\n * 줄 넘침 — 앞 쪽 끝줄에 뒤 쪽 첫 어절을 붙이면 칸 폭을 넘는다(가운데 정렬이어도 그 어절은 그 줄에 못 들어가 줄이 바뀌었다). 좁은 칸의\n * 짧은 글은 새 칸 글과도 곧잘 들어맞아 홀로는 증거가 못 되고, 칸 조각 이어짐(carried)과 함께 볼 때만 쓴다\n * (양곡가공업자 처분기준 \"영업정지 / 3개월\" 57pt 칸, 선관위 자격증 \"방송 / 통신\" 40pt 칸)\n */\nfunction wordOverflows(u: IRCell, d: IRCell, x1: number, x2: number): boolean {\n const U = CELL_LINES.get(u)\n if (!U?.length || startsNewItem(u.text, d.text)) return false\n const last = U[U.length - 1], units = firstWordUnits(d.text)\n return units > 0 && last.r - last.l + units * (last.h || 10) > x2 - x1\n}\n\n/** 한글 항목 번호 차례 */\nconst KO_ORDER = \"가나다라마바사아자차카타파하\"\n/** 줄 머리 항목 번호 — 꼴(1.·1)·(1)·가.·가)·(가)·①·A.)과 차례 */\nfunction itemMark(line: string): { style: string; n: number } | null {\n line = flowText(line).trim()\n let m = /^(\\d{1,2})([.)])(?!\\d)/.exec(line)\n if (m) return { style: \"1\" + m[2], n: +m[1] }\n if ((m = /^\\((\\d{1,2})\\)/.exec(line))) return { style: \"(1)\", n: +m[1] }\n if ((m = /^([가-하])([.)])/.exec(line)) && KO_ORDER.includes(m[1])) return { style: \"가\" + m[2], n: KO_ORDER.indexOf(m[1]) }\n if ((m = /^\\(([가-하])\\)/.exec(line)) && KO_ORDER.includes(m[1])) return { style: \"(가)\", n: KO_ORDER.indexOf(m[1]) }\n if ((m = /^[①-⑳]/.exec(line))) return { style: \"①\", n: m[0].charCodeAt(0) }\n if ((m = /^([A-Z])\\.\\s/.exec(line))) return { style: \"A.\", n: m[1].charCodeAt(0) }\n return null\n}\n\n/**\n * 목록 이어짐 — 뒤 쪽 칸 첫 줄의 항목 번호가 앞 쪽 칸 목록의 다음 차례다(같은 꼴의 마지막 번호 + 1). 칸 안 문단 경계에서 쪽이 넘어가\n * 글 이어짐 증거가 없는 행(진단기관 지정기준 \"가. … 바. …\" / 다음 쪽 \"사. …\", 직무교육기관 장비 \"1) … 8) …\" / \"9) …\")을 잡는다.\n * 앞 쪽 칸 목록은 번호가 둘 이상이거나 칸 첫 줄이 아닌 자리에서 시작해야 한다 — 행마다 항목 하나를 둔 표는 칸 첫 줄이 번호이고\n * 다음 행이 다음 번호로 시작한다(과태료 부과기준 \"다. …\" 행 다음 \"라. …\" 행)\n */\nfunction listContinues(u: IRCell, d: IRCell, multi = false): boolean {\n const head = itemMark(d.text.trim())\n if (!head) return false\n const marks = u.text.split(\"\\n\").map(l => itemMark(l.trim()))\n let last = -1, count = 0\n marks.forEach((m, i) => { if (m?.style === head.style) { last = i; count++ } })\n return last >= 0 && marks[last]!.n + 1 === head.n && (count >= 2 || (!multi && last > 0))\n}\n\n/** 뒤 쪽 첫 행에 글 있는 칸이 하나뿐이고 그 칸이 개조식 위계로 이어진다 — 다른 열에 새 글(이름표)이 오면 새 행이다 */\nfunction outlineOnly(pairs: Array<[Anchor, Anchor]>, cell: (a: Anchor) => IRCell): boolean {\n const filled = pairs.filter(([, d]) => hasContent(d.cell))\n return filled.length === 1 && outlineContinues(cell(filled[0][0]), cell(filled[0][1]))\n}\n\n/** 쪽 경계 괘선과 목록 번호 증거 범위 — joinSplitParts 가 두 조각 곁정보로 정한다 */\ninterface Cut { open: boolean; ruled: boolean; list: \"any\" | \"multi\" | \"none\" }\nconst NO_CUT: Cut = { open: false, ruled: false, list: \"any\" }\n\nfunction mergeSplitRow(table: IRTable, owner: (Anchor | null)[][], first: number, colXs: number[], textOnly = false, cut: Cut = NO_CUT): boolean {\n const last = first - 1\n // 열마다 앞 행 칸과 뒤 행 칸을 맞춘다 — 두 행을 다 덮는 세로 병합 칸(앞 쪽에서 넘어와 이어 늘린 칸)은 그대로 두고,\n // 나머지는 앞 행에서 끝나는 칸과 뒤 행에서 시작하는 칸의 열 범위가 같아야 한다\n const pairs: Array<[Anchor, Anchor]> = []\n let carried = false\n for (let c = 0; c < table.cols;) {\n const a = owner[last][c], b = owner[first][c]\n // 두 쪽 모두 클립이 없던 자리 — 글 없는 칸이 쪽을 넘은 부분이다 (행정업무운영 편람 신구조문 대비표: 시행규칙 쪽이 빈 조문 행이\n // 네 쪽에 걸쳐 끊겨 103행 → 119행). 칸이 들어설 수 없는 좁은 열은 조각끼리 왼끝이 조금 어긋나 합집합 격자에 생긴 틈이라 증거가 아니다\n // (서식 쪽마다 왼끝 56.6/58.0 → 1.4pt 틈 열: 바닥글 \"210mm×297mm…\" 행이 다음 쪽 \"(4쪽 중 제3쪽)\" 행을 삼켰다)\n if (!a && !b) { if (colXs[c + 1] - colXs[c] >= CARRIED_MIN_COL_W) carried = true; c++; continue }\n if (!a || !b) return false\n if (a !== b) {\n if (a.c !== b.c || a.cs !== b.cs || a.r + a.rs - 1 !== last || b.r !== first) return false\n // 뒤 조각 칸이 여러 행을 덮으면 쪽 경계에 걸친 세로 병합 칸의 나머지다(외국환거래법 \"다. 등록 또는 인가의 내용 / 이나 조건을\n // 위반한 경우\" 가 다음 쪽 두 행을 덮는다). 앞 쪽 칸이 비었으면 새로 시작한 세로 병합 이름표(가산대상 자격증 빈 칸 아래 \"임업\"),\n // 앞 쪽에서도 여러 행을 덮었으면 행은 새로 시작하고 이름표만 쪽 경계를 넘은 것이다(과태료 기준 \"…준용하는 / 경우를 포함한다)\" 옆\n // \"5회 | 70만원\" / \"6회 | 90만원\" — mergeStraddlingCells 가 잇는다)\n if (b.rs > 1 && (a.rs > 1 || !hasContent(a.cell))) return false\n pairs.push([a, b])\n } else if (a.r === last && a.rs === 2) carried = true // 앞 쪽 끝 행에서 시작한 칸을 뒤 쪽 첫 행 빈 자리로만 이어 늘렸다 — 더 아래까지\n // 비어 있으면 뒤 쪽 여러 행을 덮는 세로 병합 칸이다(규제영향분석서 \"기타\"). 쪼개진 행의 이어진 조각은 첫 행 띠 하나다\n c = a.c + a.cs\n }\n // 뒤 쪽 칸 조각의 글 없는 클립은 증거가 아니다 — 한컴 PDF 판(1.3.0.546·538)과 칸에 따라 이어진 빈 조각에도 클립을 깐다\n // (법령 별표 쪼개진 행 17건에 있음). 앞 쪽 빈 칸 뒤에 글이 오는 것만 새 칸 증거다 (글은 칸 위에서부터 흐른다)\n const newCell = pairs.some(([u, d]) => !hasContent(u.cell) && hasContent(d.cell))\n // 글줄은 격자에 놓은 복사본 칸에서 본다 — 옮겨 맞댄 뒤 조각(짝·홀 쪽·단 넘김)은 복사본에만 옮긴 글줄이 있다\n const cell = (a: Anchor): IRCell => table.cells[a.r][a.c]\n // 번호 열이 다음 차례로 이어지면 새 행이다 — 쪼개진 행의 이어진 조각은 번호 칸이 빈다. 옆 칸 끝줄이 마침 칸을 꽉 채워 글 이어짐으로\n // 보였다(허용물질 \"12 | 사람의 배설물(오줌만인 경우는 제외한다)\" 다음 쪽 \"13 | 벌레 등 자연적으로 생긴 유기체\")\n const serial = (c: IRCell): number | null => { const t = flowText(c.text).trim(); return /^\\d{1,3}$/.test(t) ? +t : null }\n if (pairs.some(([u, d]) => { const a = serial(cell(u)), b = serial(cell(d)); return a !== null && b === a + 1 })) return false\n // 글 있는 열 쌍이 둘 이상이면 세로 병합 칸만 넘어가고 행은 새로 시작하는 표(규제영향분석서 \"10.영향평가 여부\" 옆 \"기술영향평가 |\n // 경쟁영향평가 | …\" / 다음 쪽 \"해당없음 | 해당없음 | …\")와 가려야 한다. 쪼개진 행 증거로 쓰는 경우 —\n // · 글 있는 뒤 조각 칸이 모두 여러 행을 덮는다: 쪽 경계에 걸린 행의 세로 병합 칸들이 함께 넘어갔다. 새 행이면 한 행 칸이 적어도\n // 하나 있다 (선관위 자격증 \"방송 / 통신\"·\"기술사 / (자격증종류는 …)\" 가 다음 쪽 두 행을 덮고, 옆 \"통신사\" 칸은 클립 없이 이어진다)\n // · 쪽 경계에 괘선이 없고 글 쌍 하나라도 줄 넘침이다 (양곡가공업자 \"영업정지 / 3개월\")\n // 이때 다른 열의 한 줄 끝난 글은 칸 안 문단 경계다(선관위 \"기능장\" / \"기사(6) …\") — 끝난 이름표 모순(lineEnded)을 보지 않는다.\n // 법령 별표 272건에서 칸 조각 이어짐 + 글 쌍 둘 이상 35건이 모두 쪼개진 행, 표 GT 에서 새 행 1건(위 규제영향분석서, 괘선 있는 쪽 경계)\n const contentPairs = pairs.filter(([u, d]) => hasContent(u.cell) && hasContent(d.cell))\n const carriedSplit = !textOnly && carried && !newCell && (contentPairs.length <= 1 || contentPairs.every(([, d]) => d.rs > 1)\n || (!cut.ruled && contentPairs.some(([u, d]) => wordOverflows(cell(u), cell(d), colXs[u.c], colXs[u.c + u.cs]))))\n if (!carriedSplit) {\n // 앞 쪽 칸이 한 줄로 끝난 이름표(다음 어절이 들어갈 자리가 남음)인데 뒤 쪽 같은 열에 다른 글이 있으면 그 열은 새 칸이다 — 끝난 칸의\n // 이어진 조각은 글이 없어 클립조차 없다. 다른 열 끝줄이 꽉 찬 것(글 이어짐)보다 이 모순이 앞선다 (편람 [별표 4] 가로 판\n // \"8. 글자 | …꽉 찬 끝줄\" 다음 쪽 \"9. 한글과 함께 적는 외국글자 | 가. 단어를 …\", 시험기준표 \"액성한계·소성한계 | KS F 2303\" 다음 쪽\n // \"세립토 비율 | KS F 2309\"). 같은 글이면 문단마다 붙는 표지다 (신구조문 대비표 \"<신 설>\" 이 큰 행 두 조각에 하나씩)\n const norm = (c: IRCell): string => flowText(c.text).replace(/\\s+/g, \"\")\n if (pairs.some(([u, d]) => hasContent(d.cell) && norm(cell(d)) !== norm(cell(u)) && lineEnded(cell(u), colXs[u.c], colXs[u.c + u.cs], cell(d)))) return false\n // 한 칸은 앞 조각 머리 항목의 바로 다음 항목으로 시작하고(\"커. …\" / \"터. …\"), 다른 칸은 두 조각 모두 서로 다른 한 줄 값이면(\"법 제47조\" /\n // \"법 제45조\") 새 행이다 — 좁은 칸 끝줄 \"2) 2회 이상 위반\" 이 꽉 차 보여 다음 쪽 새 행을 앞 행에 잇고 ○ 표시까지 섞었다(총포화약법\n // 행정처분기준, 4.17.0 회귀). 칸 안 목록이 \"가. …\" / \"나. …\" 로 이어지는 대비표는 다른 칸 값이 같거나(<신 설>) 비어 있다\n const oneLine = (c: IRCell): boolean => CELL_LINES.get(c)?.length === 1\n if (pairs.some(([u, d]) => nextItemHead(cell(u).text, cell(d).text))\n && pairs.some(([u, d]) => hasContent(u.cell) && hasContent(d.cell) && oneLine(cell(u)) && oneLine(cell(d)) && norm(cell(u)) !== norm(cell(d)))) return false\n const flows = ([u, d]: [Anchor, Anchor]): boolean => continuesAcross(cell(u), cell(d), colXs[u.c], colXs[u.c + u.cs]) || clauseContinues(cell(u), cell(d))\n || quantityContinues(cell(u), cell(d))\n // 괘선 없는 쪽 경계(3)는 글 증거 없이 합친다 — 앞 쪽 빈 칸 뒤 글(새 칸)이 없을 때만 (쪽 끝 빈 행 다음 새 표 행, 노인복지법 운영기준)\n if (!(cut.open && !newCell)) {\n // 그 칸 글이 스스로 이어지지 않는 여러 행 칸은 다음 쪽에서 새로 시작한 세로 병합 칸이다 (시험기준표 \"KS D 3502 / KS F 4603\" 아래\n // 세 행을 덮는 새 \"KS F 4603\")\n if (pairs.some(p => p[1].rs > 1 && !flows(p))) return false\n // 다른 글 이어짐 증거는 뒤 조각에서 한 행인 칸에서만 본다 — 여러 행을 덮는 칸은 쪽 경계에 걸친 세로 병합 칸이라 그 글이 이어져도\n // 행은 새로 시작할 수 있다 (특수복식 \"2) 여자 경찰 / 공무원 근무복\" 칸 옆 \"2) 여자 경찰공무원 근무복\" 다음 행 \"가) 상의 앞면, 뒷면\")\n const one = pairs.filter(([, d]) => d.rs === 1)\n if (!outlineOnly(pairs, cell) && !one.some(flows) && (cut.list === \"none\" || !one.some(([u, d]) => listContinues(cell(u), cell(d), cut.list === \"multi\")))) return false\n }\n }\n for (const [u, d] of pairs) {\n appendCell(table.cells[u.r][u.c], table.cells[d.r][d.c])\n // 뒤 조각에서 여러 행을 덮던 칸은 이은 칸이 그만큼 더 덮는다 (아래 줄이기가 경계 행 하나를 뺀다)\n if (d.rs > 1) table.cells[u.r][u.c].rowSpan = u.rs + d.rs\n }\n // 위에서 내려와 두 행에 걸친 세로 병합 칸은 한 행 줄어든다\n for (let r = 0; r < first; r++) for (let c = 0; c < table.cols; c++) {\n const o = owner[r][c]\n if (o && o.r === r && o.c === c && r + table.cells[r][c].rowSpan > first) table.cells[r][c].rowSpan--\n }\n table.cells.splice(first, 1)\n table.rows--\n return true\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 * 두 조각 사이에 자기 쪽 표 조각과 가로로 겹치지 않는 블록(besideOwn)이나 쪽 끝·첫머리 띠의 쪽 번호(pageNumberBetween)만 끼어 있으면\n * 인접으로 본다 (그 블록은 제자리에 둔다).\n * 같은 쪽 2단 넘김은 먼저 mergeColumnFlow 가 잇는다.\n */\nconst NEIGHBOR_TABLE_EPSILON = 0.2\n\n/**\n * 표 두 조각 사이에 끼어도 이음을 막지 않는 블록 — 자기 쪽 표 조각과 가로 범위가 겹치지 않는 글(쪽 가장자리 장 표시 세로글 —\n * 행정업무운영 편람), 앞 표가 2단 지면의 오른쪽 단일 때 그 왼쪽 단에 놓인 글·표(입찰동향 붙임: 오른쪽 단 입찰내역표 앞에\n * 왼쪽 단 지역별 현황표)\n */\nfunction besideOwn(b: IRBlock, p: IRBlock, c: IRBlock): boolean {\n if (!b.bbox) return false\n if (b.type === \"table\") return leftColumnOf(b, p)\n const t = (b.pageNumber === p.pageNumber ? p : c).bbox!\n const bx1 = b.bbox.x, bx2 = b.bbox.x + b.bbox.width\n return bx2 <= t.x + 1 || bx1 >= t.x + t.width - 1\n}\n\n/** 쪽 번호 한 줄 — \"21\", \"- 21 -\" */\nconst PAGE_NUMBER_TEXT = /^[-–—]?\\s*\\d{1,4}\\s*[-–—]?$/\n\n/**\n * 두 조각 사이의 쪽 번호 — 앞 조각 아래 쪽 끝 띠나 뒤 조각 위 쪽 첫머리 띠에 홀로 놓인 쪽 번호 문단. 머리글·바닥글 제거는 3쪽 이상\n * 파싱할 때만 돌아 두 쪽만 뽑거나(-p 29-30) 제거를 끄면(--no-header-footer) 쪽 번호가 표 폭 안에 남아 이음을 막았다(응급의료기관 평가\n * 기준집 p.29-30: 앞 쪽 끝 행 가중치 1.5 칸이 다음 쪽 두 행에 걸친 표가 두 표로 갈려 다음 쪽 행의 가중치가 빈 칸). 쪽 높이를 모르면 두지 않는다\n */\nfunction pageNumberBetween(b: IRBlock, p: IRBlock, c: IRBlock, pageHeights?: Map<number, number>): boolean {\n if (b.type !== \"paragraph\" || !b.bbox || !PAGE_NUMBER_TEXT.test(b.text?.trim() ?? \"\")) return false\n const h = b.pageNumber ? pageHeights?.get(b.pageNumber) : undefined\n if (!h) return false\n if (b.pageNumber === p.pageNumber) return b.bbox.y + b.bbox.height <= Math.min(p.bbox!.y, h * PAGE_EDGE_BAND) + 1\n if (b.pageNumber === c.pageNumber) return b.bbox.y >= Math.max(c.bbox!.y + c.bbox!.height, h * (1 - PAGE_EDGE_BAND)) - 1\n return false\n}\n\n/** 같은 쪽에서 표 t 의 왼쪽 단에 온전히 놓인 블록 */\nfunction leftColumnOf(b: IRBlock, t: IRBlock): boolean {\n return !!b.bbox && b.pageNumber === t.pageNumber && b.bbox.x + b.bbox.width <= t.bbox!.x + 1\n}\n\n/**\n * 이은 표를 앞 조각(blocks[i]) 자리에 두고 뒤 조각(blocks[j])을 뺀다. 건너뛴 블록 가운데 앞 조각 왼쪽 단의 것이 있으면 이은 표를\n * 그 뒤로 옮긴다 — 블록은 윗변 순이라 2단 지면 오른쪽 단 표가 왼쪽 단 글·표보다 먼저 나오는데, 읽는 순서로는 왼쪽 단이 앞이다\n * (입찰동향 붙임: 오른쪽 단에서 시작해 다음 쪽으로 이어진 입찰내역표가 왼쪽 단 현황표 두 개보다 앞에 놓여 표 순서가 뒤바뀜)\n */\nfunction placeJoined(blocks: IRBlock[], i: number, j: number, table: IRTable): void {\n const prev = blocks[i]\n markJoinedPages(table, prev, blocks[j])\n blocks[i] = { ...prev, table }\n blocks.splice(j, 1)\n let k = j - 1\n while (k > i && !leftColumnOf(blocks[k], prev)) k--\n if (k > i) blocks.splice(k, 0, blocks.splice(i, 1)[0])\n}\n\n/** 같은 쪽에서 표 테두리 안에 든 블록 — 칸 안 예시 상자처럼 표 칸에 붙지 못하고 표 뒤에 따로 나온 조각 */\nfunction insideTable(b: IRBlock, t: IRBlock): boolean {\n if (!b.bbox || b.pageNumber !== t.pageNumber) return false\n const o = t.bbox!\n return b.bbox.x >= o.x - 1 && b.bbox.x + b.bbox.width <= o.x + o.width + 1 && b.bbox.y >= o.y - 1 && b.bbox.y + b.bbox.height <= o.y + o.height + 1\n}\n\n/** 첫 행 전체를 차지하는 단위 표기 */\nfunction startsWithUnitRow(table: IRTable): boolean {\n return /^\\s*\\(\\s*단위\\s*[::]/.test(table.cells[0]?.[0]?.text ?? \"\")\n && table.cells[0]?.slice(1).every(c => !c.text.trim())\n}\n\n/** 두 첫 행이 같은 머리 모양인가 — 칸마다 열·행 병합이 같고, 세로 병합 칸(두 행 이상)을 하나 이상 품었다 */\nfunction sameHeadShape(a: IRCell[] | undefined, b: IRCell[] | undefined): boolean {\n if (!a || !b || a.length !== b.length) return false\n let tall = false\n for (let c = 0; c < a.length; c++) {\n if (a[c].colSpan !== b[c].colSpan || a[c].rowSpan !== b[c].rowSpan) return false\n if (a[c].rowSpan >= 2 && a[c].text.trim() && b[c].text.trim()) tall = true\n }\n return tall\n}\n\nexport function mergeCrossPageTables(blocks: IRBlock[], pageHeights?: Map<number, number>, lex?: WrapLexicon): void {\n mergeColumnFlow(blocks, pageHeights, lex)\n for (let i = blocks.length - 2; i >= 0; i--) {\n const tail = tailOf(blocks[i])\n if (tail.type !== \"table\" || !tail.table || !tail.bbox || !tail.pageNumber) continue\n // 다음 표 — 다음 쪽까지만 훑는다 (글만 긴 문서에서 블록마다 끝까지 훑지 않게). 앞 표 테두리 안에 든 표(칸 안 예시 상자가 따로 나온\n // 것)와 앞 표 왼쪽 단의 표(2단 지면)는 건너뛴다 — 편람 [별표 4] 서식 설계기준표는 \"3. 쪽번호\" 칸 안 예시 상자가 쪽 마지막 표가\n // 되어 다음 쪽 조각과 끊겼다\n let j = i + 1\n const skip = (b: IRBlock): boolean => b.type !== \"table\" || [blocks[i], tail].some(t => insideTable(b, t) || leftColumnOf(b, t))\n while (j < blocks.length && skip(blocks[j]) && (blocks[j].pageNumber ?? 0) <= tail.pageNumber + 1) j++\n const curr = blocks[j]\n // 붙인 이어짐 조각과 같은 쪽에 이어 놓인 표는 종전처럼 표 첫 쪽에서 본다 (규제영향분석서 연도별 편익 표: 다음 쪽 첫머리 조각 바로 아래\n // \"근거설명\" 행 조각 — 조각 쪽 기준으로는 같은 쪽이라 잇지 못했다)\n const prev = tail !== blocks[i] && curr?.pageNumber === tail.pageNumber ? blocks[i] : tail\n if (!prev.table || !prev.bbox || !prev.pageNumber) continue\n if (!curr || curr.type !== \"table\" || !curr.table || !curr.bbox || curr.pageNumber !== prev.pageNumber + 1) continue\n // 단위 행이 양쪽에 다시 나타나면 각 쪽에서 새 표를 시작한 것이다.\n if (startsWithUnitRow(prev.table) && startsWithUnitRow(curr.table)) continue\n const joined = j === i + 1 || blocks.slice(i + 1, j).every(b => besideOwn(b, prev, curr) || insideTable(b, prev) || pageNumberBetween(b, prev, curr, pageHeights))\n ? (looksContinued(prev, curr, pageHeights) && !restartsTable(blocks, i, curr.table, pageHeights) ? joinClipParts(prev, curr, pageHeights, lex) ?? false : null)\n : null\n if (joined) {\n // 한컴 클립 표 조각 — 열 경계 합집합 격자로 이었다 (쪽마다 열 구성이 달라도)\n placeJoined(blocks, i, j, joined)\n continue\n }\n // 앞 표의 이어짐이 못 된 빈 조각은 버리고 같은 앞 표로 그다음 표를 다시 본다 — 쪽 첫머리 장식 띠 같은 빈 클립 표가 진짜\n // 이어짐을 가로막지 않게 (빈 조각의 뒤쪽 이음은 이미 앞선 차례에 시도했다)\n if (EMPTY_PARTS.has(curr.table)) { blocks.splice(j, 1); i++; continue }\n if (joined === null) continue\n if (prev.table.cols !== curr.table.cols || prev.table.renderAsTable !== curr.table.renderAsTable || EMPTY_PARTS.has(prev.table)) 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 // 열 경계까지 같아야 한 표의 이어짐 — 열 수·좌우 끝만 보면 다음 쪽 첫머리의 \"붙임 2 | 제목\" 머리상자(1×3)가\n // 앞 쪽 일정표(3열)의 꼬리 행으로 붙는다(보도자료 붙임 실측). 경계 좌표가 없는 표(클러스터)는 종전대로.\n // 짝·홀 쪽 대칭 여백(책자형 편람: 쪽마다 본문이 11.4~12.5pt 옮겨짐)은 모든 경계가 같은 거리만큼 옮겨진 것이라 같은 경계로 본다\n // — 선 표 쪽 넘김 후보 가운데 이렇게 옮겨진 15건 중 사이 글 없이 쪽 끝·첫머리에 놓인 10건이 모두 같은 표다. 클립 표는\n // joinClipParts 가 쪼개진 행·반복 머리 행 증거가 있을 때만 옮겨 잇는다 (여기서 옮김을 받으면 연달아 놓인 Q&A 상자가 12×5 로 이어진다)\n const px = TABLE_COLXS.get(prev.table), cx = TABLE_COLXS.get(curr.table)\n if (px && cx && !shiftedSame(px, cx, !CLIP_TABLES.has(prev.table) && !CLIP_TABLES.has(curr.table))) continue\n\n // 다음 표 첫 행이 앞 표 첫 행과 같은 모양(칸마다 열·행 병합이 같고 세로 병합 칸을 품은 머리)인데 글이 다르면 새 표의 머리다\n // — 쪽마다 새로 놓인 같은 틀 상자(\"일 러 두 기\" 다음 쪽 \"목 차\", 보도자료 표지 상자). 되풀이 머리 행은 글이 같다\n if (!rowTextsEqual(prev.table.cells[0], curr.table.cells[0]) && sameHeadShape(prev.table.cells[0], curr.table.cells[0])) continue\n\n // 반복 헤더 행 제거: 다음 표 첫 행들이 이전 표 첫 행들과 같으면 중복 헤더. 머리가 여러 행이면(첫 행 칸이 세로 병합으로\n // 그 깊이를 알린다 — \"구분|장비분류|구급차 구분\" 위, \"특수구급차|일반구급차\" 아래) 그 깊이까지 본다. 첫 행만 빼면 둘째 머리 행이\n // 본문 행으로 남아 첫 행 세로 병합 자리가 빈 칸으로 드러났다(구급차 관리·운용 안내 [별표 16] 30~31쪽)\n let currCells = curr.table.cells\n const headRows = repeatedHeaderRows(prev.table, curr.table)\n if (headRows < 0) continue\n if (headRows) currCells = currCells.slice(headRows)\n // 앞 조각 마지막 행에서 끝나는 세로 병합 칸(상위 항목 \"나. 응급 처치용 의료장비\")이 다음 쪽 조각 첫 행에서 글 없는 세로 병합 칸으로\n // 다시 그려졌으면 같은 칸의 이어짐이다 — 앞 칸 병합을 늘리고 뒤 빈 칸은 덮인 자리로 둔다. 글 없는 한 행 칸은 증거로 쓰지 않는다\n if (currCells.length) continueRowSpans(prev.table.cells, currCells)\n if (currCells.length === 0) {\n blocks.splice(j, 1)\n continue\n }\n\n // 선 격자도 반복 머리행 아래의 본문이 쪽 중간에서 끊긴다. 기존 칸·문장 이어짐 검증을 재사용한다.\n // 한 줄로 끝난 이름표·새 항목은 mergeSplitRow가 거절하며, 글줄 증거 없는 표는 종전대로 행을 붙인다.\n if (px && cx && !CLIP_TABLES.has(prev.table) && !CLIP_TABLES.has(curr.table)\n && currCells.length < curr.table.cells.length && curr.table.cells[0].filter(c => c.text.trim()).length >= 2\n && prev.table.cells.at(-1)!.concat(currCells[0]).every(c => c.rowSpan === 1 && c.colSpan === 1 && CELL_LINES.has(c))) {\n const dx = px[0] - cx[0]\n const part = joinSplitParts(prev.table, px, curr.table, cx.map(x => x + dx), dx, undefined, lex)\n if (part?.split && part.table.rows === prev.table.rows + currCells.length - 1) {\n const last = prev.table.rows - 1\n for (let c = 0; c < part.table.cols; c++) {\n const U = CELL_LINES.get(prev.table.cells[last][c])!, D = CELL_LINES.get(currCells[0][c])!\n CELL_LINES.set(part.table.cells[last][c], U.concat(D.map(l => ({ ...l, l: l.l + dx, r: l.r + dx }))))\n }\n TABLE_COLXS.set(part.table, part.colXs)\n placeJoined(blocks, i, j, part.table)\n continue\n }\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 ...(prev.table.renderAsTable ? { renderAsTable: true } : {}),\n }\n if (px ?? cx) TABLE_COLXS.set(merged, (px ?? cx)!)\n if (CLIP_TABLES.has(prev.table) || CLIP_TABLES.has(curr.table)) CLIP_TABLES.add(merged)\n placeJoined(blocks, i, j, merged)\n }\n // 잇지 못한 빈 클립 표 조각은 버린다 (쪽 추출 단계에서 빈 표를 버리던 종전 동작)\n for (let i = blocks.length - 1; i >= 0; i--) {\n const t = blocks[i].table\n if (blocks[i].type === \"table\" && t && EMPTY_PARTS.has(t)) blocks.splice(i, 1)\n }\n}\n\n/**\n * 같은 쪽 단 넘김 — 2단 지면(가로 쪽 두 단 편람 [별표 4], 보도자료 붙임 두 쪽 모아 찍기)에서 왼쪽 단 바닥까지 찬 클립 표 조각과 같은\n * 쪽 오른쪽 단 첫머리의 표 조각을 잇는다. 쪽 넘김과 같은 모양(앞 조각이 바닥까지, 뒤 조각이 첫머리부터)에 뒤 조각이 단 거리만큼\n * 옮겨져 있어 증거도 쪽 넘김 평행 이동과 같다(쪼개진 행 또는 글 있는 머리 행 되풀이). 블록 순서는 윗변 기준이라 오른쪽 단 조각이\n * 먼저 나오기도 해 순서와 무관하게 짝을 찾고, 이은 표는 두 자리 중 앞선 자리에 둔다. 이은 표의 bbox 는 x·윗변을 왼쪽 조각(열 경계\n * 좌표계)에서, 밑변을 오른쪽 조각(흐름의 끝)에서 가져온다 — 다음 쪽 이음은 흐름 끝이 쪽 바닥까지 찼는지 봐야 한다\n */\nfunction mergeColumnFlow(blocks: IRBlock[], pageHeights?: Map<number, number>, lex?: WrapLexicon): void {\n if (!pageHeights) return\n for (let i = 0; i < blocks.length; i++) {\n const L = blocks[i]\n if (L.type !== \"table\" || !L.table || !L.bbox || !L.pageNumber || !CLIP_TABLES.has(L.table) || EMPTY_PARTS.has(L.table)) continue\n const ph = pageHeights.get(L.pageNumber)\n if (!ph || L.bbox.y > ph * PAGE_EDGE_BAND) continue\n // 오른쪽 단 첫머리 표 — 같은 쪽(블록은 쪽 순서라 같은 쪽 구간만 훑는다), L 오른쪽에 온전히, 쪽 첫머리 띠 안에서 시작하는 가장 위 표\n let s0 = i, s1 = i\n while (s0 > 0 && blocks[s0 - 1].pageNumber === L.pageNumber) s0--\n while (s1 + 1 < blocks.length && blocks[s1 + 1].pageNumber === L.pageNumber) s1++\n let k = -1\n for (let q = s0; q <= s1; q++) {\n const R = blocks[q]\n if (q === i || R.type !== \"table\" || !R.table || !R.bbox || !leftColumnOf(L, R)) continue\n const top = R.bbox.y + R.bbox.height\n if (top < ph * (1 - PAGE_EDGE_BAND)) continue\n if (k < 0 || top > blocks[k].bbox!.y + blocks[k].bbox!.height) k = q\n }\n if (k < 0) continue\n const R = blocks[k]\n if (Math.abs(R.bbox!.width - L.bbox.width) > Math.max(R.bbox!.width, L.bbox.width) * NEIGHBOR_TABLE_EPSILON) continue\n if (!looksContinued(L, R) || restartsTable(blocks, i, R.table!, pageHeights)) continue\n const joined = joinClipParts(L, R, pageHeights, lex)\n if (!joined) continue\n const top = L.bbox.y + L.bbox.height\n const at = Math.min(i, k)\n blocks[at] = { ...L, table: joined, bbox: { ...L.bbox, y: R.bbox!.y, height: Math.max(0, top - R.bbox!.y) } }\n blocks.splice(Math.max(i, k), 1)\n i = at - 1 // 이은 표가 다시 오른쪽 단으로 이어질 수 있다 (세 단)\n }\n}\n\n/**\n * 클립 표 두 조각이 한 표의 쪽 넘김인지 보고 이어 붙인다. 열 경계가 같으면 종전처럼 이어짐으로 보고, 다르면\n * 쪽 넘김 기하(앞 조각이 쪽 밑까지, 뒤 조각이 쪽 위부터 — PAGE_EDGE_BAND)와 오른쪽 끝 일치·뒤 조각 왼쪽 끝이 앞 조각\n * 경계 위에 있음을 요구한다(뒤 쪽에 세로 병합 이어진 칸이 비어 왼쪽 열이 빠진 경우). 앞 조각 쪽에서 왼쪽 열이 빠진 경우\n * (prevLacksLeftCols)와 뒤 조각 경계가 모두 앞 조각 경계 위에 있는 경우(양옆 열이 빠진 쪼개진 끝 행)도 잇는다.\n */\nfunction joinClipParts(prev: IRBlock, curr: IRBlock, pageHeights?: Map<number, number>, lex?: WrapLexicon): IRTable | null {\n const pt = prev.table!, ct = curr.table!\n if (!CLIP_TABLES.has(pt) || !CLIP_TABLES.has(ct)) return null\n const px = TABLE_COLXS.get(pt), cx = TABLE_COLXS.get(ct)\n if (!px || !cx) return null\n let xs = cx, shifted = false, dx = 0, foreign = false, within = false\n if (!shiftedSame(px, cx, false)) {\n // 열 구성이 다르면 쪽 넘김 기하를 반드시 확인 (looksContinued 는 쪽 높이를 모르면 통과시킨다)\n if (!pageHeights?.get(prev.pageNumber!) || !pageHeights?.get(curr.pageNumber!)) return null\n // 짝·홀 쪽 대칭 여백(11.4pt)이나 2단 지면의 단 넘김(420pt)으로 표 전체가 옮겨졌으면 뒤 조각을 앞 조각 자리로 옮겨 맞댄다. 다만\n // 같은 틀 상자가 쪽마다 새로 놓인 것(편람 Q&A 상자 55개)과 가르려고 경계 행이 쪼개진 행이거나 글 있는 머리 행이 되풀이될 때만\n // 잇는다 — 새 상자의 첫 행은 빈 칸이고 앞 상자 끝 행과 칸 짜임이 달라 둘 다 될 수 없다. 이 조건 없이 옮김만 넣으면 연달아\n // 놓인 상자끼리 이어져 편람 −14표 (클립 표 평행 이동 후보 47건 중 같은 표 5건)\n dx = px[px.length - 1] - cx[cx.length - 1]\n // 뒤 조각의 경계가 모두 앞 조각 경계 위에 있으면 옮겨진 것이 아니라 양옆 열이 빠진 조각이다 — 쪼개진 끝 행에서 글이 넘어간 열만\n // 클립이 깔리고 글 없이 넘어간 좌우 열은 클립이 없다(총포 행정처분기준 \"너. …전자장치를 임의로 / 제거 또는 훼손\" 은 위반사항·적용법령\n // 두 열만 다음 쪽 첫머리에, 9열 중 오른쪽 6열이 빠져 오른끝이 196pt 어긋남). 경계가 우연히 맞물린 새 표와 가르려고 옮긴 조각처럼\n // 쪼개진 행 증거를 요구한다\n within = Math.abs(dx) > CONTINUATION_COL_TOL && cx.length >= 3 && cx.every(x => px.some(p => Math.abs(p - x) <= CONTINUATION_COL_TOL))\n if (Math.abs(dx) > CONTINUATION_COL_TOL && !within) { xs = cx.map(x => x + dx); shifted = true } else dx = 0\n if (!px.some(x => Math.abs(x - xs[0]) <= CONTINUATION_COL_TOL) && !prevLacksLeftCols(prev, px, xs, pageHeights)) return null\n // 뒤 조각이 절 제목 상자 꼴로 시작하고 안쪽 열 경계가 앞 조각 경계 어디에도 맞물리지 않으면 새로 놓인 상자다 — 옮긴 조각처럼\n // 쪼개진 행·글 있는 머리 행 되풀이가 있을 때만 잇는다(서식 7열 표 뒤 다음 쪽 \"목 차\" 상자, \"3 | 기대성과 및 기대효과\"·\"5 | 시험방법\"\n // 번호 머리 상자, \"② 세미나, 포럼, 언론 활동\" 제목 상자 — tac-img-02·pr-1674). 쪽마다 열 짜임을 바꾸며 이어지는 별표·서식은 첫 행이\n // 앞 쪽 비고의 이어진 문단(\"가. 제조(수입)업무의 …\", 과징금 산정기준)이거나 안쪽 경계 일부가 맞물린다(규제영향분석서)\n // 뒤 조각이 이미 그다음 쪽 조각과 이어진 표면 그 첫 조각의 경계로도 본다 — 뒤쪽 쪽들의 열 짜임이 섞여 들어와 새 상자로 보이면 안 된다\n // (자동차종합검사 시설기준: 셋째 쪽 전폭 행 조각이 다섯째 쪽 \"구 분 | 자 격 | 직 무\" 조각과 먼저 이어져 둘째 쪽과 끊겼다)\n const unrelated = (b: number[]): boolean => {\n const inner = b.slice(1, -1).map(x => x + dx)\n return inner.length > 0 && !inner.some(x => px.some(p => Math.abs(p - x) <= CONTINUATION_COL_TOL))\n }\n // 첫 행부터 칸이 셋 이상인 뒤 조각도 같다 — 제 열 짜임으로 시작한 새 표다. 한 표의 쪽 넘김이면 두 조각이 한 격자의 경계를 나눠\n // 쓰는데, 안쪽 경계가 하나도 안 맞물리면 합집합 격자는 행마다 제 병합만 남은 짜깁기가 된다(안전보건표지 [별표 6] 1쪽 13열 표 →\n // 2쪽 \"4. | 401 녹십자표지 | …\" 10열 표, 안쪽 경계 12개·9개 가운데 맞물림 0 — HWPX 두 표). 첫 행이 한 칸인 이어진 문단은 그대로 잇는다\n foreign = unrelated(cx) && unrelated(PART_COLXS.get(ct) ?? cx)\n && (headingRow(ct.cells[0] ?? []) || px.length > FOREIGN_FIRST_ROW_CELLS && anchorsOf(ct).filter(a => a.r === 0).length >= FOREIGN_FIRST_ROW_CELLS)\n }\n const res = joinSplitParts(pt, px, ct, xs, dx, prev.bbox?.y, lex)\n if (!res || ((shifted || foreign || within) && !res.split && !res.header)) return null\n TABLE_COLXS.set(res.table, res.colXs)\n const ys = TABLE_ROWYS.get(pt)\n if (ys) TABLE_ROWYS.set(res.table, ys)\n PART_COLXS.set(res.table, PART_COLXS.get(pt) ?? px)\n CLIP_TABLES.add(res.table)\n return res.table\n}\n\n/**\n * 앞 조각에 왼쪽 열이 빠졌나 — 앞 조각 왼끝이 뒤 조각 안쪽 경계 위에 있고 앞 조각이 쪽 머리부터 바닥까지 찼다. 왼쪽 열의 세로 병합\n * 칸이 쪽 전체를 덮고 그 쪽에 글이 없으면 한컴은 그 칸에 클립을 깔지 않아, 가운데 쪽 조각만 왼쪽 열 없이 나온다(해양경찰 관할구역\n * \"동해지방해양경찰청\" 칸, 체류자격 첨부서류 \"교수(E-1)\" 칸). 쪽 전체를 차지한 조각은 표 첫머리가 아니라 사슬 가운데라 머리 행이 없다\n */\nfunction prevLacksLeftCols(prev: IRBlock, px: number[], xs: number[], pageHeights?: Map<number, number>): boolean {\n const ph = pageHeights?.get(prev.pageNumber!)\n if (!ph || prev.bbox!.y + prev.bbox!.height < ph * (1 - PAGE_EDGE_BAND)) return false\n return xs[0] < px[0] && xs.slice(1, -1).some(x => Math.abs(x - px[0]) <= CONTINUATION_COL_TOL)\n}\n\n/** 절 제목 상자 꼴의 첫 행 — 짧은 제목 한 칸(공백 뺀 8자 이하), 원문자로 시작하는 제목 한 칸, 번호 칸 + 제목 칸 */\nfunction headingRow(row: IRCell[]): boolean {\n const heads = row.map(c => flowText(c.text).replace(/\\s+/g, \"\")).filter(Boolean)\n if (heads.length === 1) return heads[0].length <= 8 || /^[①-⑳❶-❿]/.test(heads[0])\n return heads.length === 2 && /^(?:\\d{1,2}|[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+|[①-⑳❶-❿])$/.test(heads[0])\n}\n\n/** 붙임·참고·별지 등 첨부 머리표 — 쪽 첫머리에 이런 칸으로 시작하는 표는 앞 쪽 표의 이어짐이 아니다 */\nconst ANNEX_HEAD_RE = /^\\s*[<\\[(【]?\\s*(?:붙\\s*임|참\\s*고|별\\s*첨|별\\s*지|별\\s*표|첨\\s*부|부\\s*록)(?:\\s*\\d|\\s*$|\\s*[>\\])】])|^\\s*■/\n\n/**\n * 쪽 넘김 이어짐 판정 — 앞 표가 쪽 아래 PAGE_EDGE_BAND 안까지 차고 뒤 표가 쪽 위 PAGE_EDGE_BAND 안에서 시작하며, 뒤 표\n * 첫 칸이 첨부 머리표가 아니어야 한다. 보도자료·편람 쪽 넘김 후보 278건 대조(HWPX 로 같은 표인지 확인): 쪽 위쪽에 놓인\n * 머리 상자끼리(붙임 2 → 붙임 3, 시험지 쪽머리표)와 쪽 끝 담당자 표 → 다음 쪽 \"붙임\" 상자를 잇던 오병합이 이 두 조건으로\n * 빠지고, 참 이어짐은 1건만 놓친다. 쪽 높이를 모르면(외부 호출) 기하 조건은 건너뛴다\n */\nfunction looksContinued(prev: IRBlock, curr: IRBlock, pageHeights?: Map<number, number>): boolean {\n const ph = pageHeights?.get(prev.pageNumber!), ch = pageHeights?.get(curr.pageNumber!)\n if (ph && ch && ((prev.bbox!.y > ph * PAGE_EDGE_BAND && !pushedLead(prev, curr)) || curr.bbox!.y + curr.bbox!.height < ch * (1 - PAGE_EDGE_BAND))) return false\n const firstRow = curr.table!.cells[0] ?? []\n const firstText = firstRow.find(c => c.text.trim())?.text ?? \"\"\n if (!ANNEX_HEAD_RE.test(firstText)) return true\n // 신구조문 대비표 행 — 현행·개정안 두 칸 이상에 같은 \"[별표 2] …\" 가 들면 첨부 머리표가 아니라 대비표 본문이다 (머리표는 제목 한 칸)\n const filled = firstRow.map(c => flowText(c.text).replace(/\\s+/g, \"\")).filter(Boolean)\n return filled.length >= 2 && filled.every(t => t === filled[0])\n}\n\n/**\n * 쪽 넘김으로 밀려난 그림 행 묶음 — 앞 조각이 쪽 끝 띠 위에서 끝났어도 뒤 조각 첫 행 묶음(첫 행에서 세로 병합으로 이어진 행들)에 그림 칸이\n * 있고 그 높이가 앞 쪽 남은 자리(앞 조각 밑변에서 쪽 밑 끝까지)보다 높으면 그 자리에 들어갈 수 없어 다음 쪽으로 넘어간 것이다. 한컴은\n * 칸 그림을 쪽 경계에서 쪼개지 않아 복장 도면 같은 큰 그림이 든 표는 앞 쪽 바닥이 크게 빈다(경찰복제 특수복식 80×27: 3쪽 밑변 142pt\n * ← 4쪽 \"방한외투 및 방한복\" 세 행 187pt, 4쪽 258pt ← 5쪽 \"상의\" 여섯 행 387pt, 5쪽 152pt ← 6쪽 \"하의\" 세 행 245pt). 머리 행으로\n * 시작하는 새 표는 첫 묶음이 그림 없는 한 행(21pt)이라 해당하지 않는다(같은 문서의 쪽마다 놓인 11×5 표들). 글만 든 행은 쪽 경계에서\n * 쪼개질 수 있어 밀려남의 증거가 아니다 — 쪽 나누기로 다음 쪽에 새로 놓인 서식 표와 가르지 못한다\n */\nfunction pushedLead(prev: IRBlock, curr: IRBlock): boolean {\n if (!CLIP_TABLES.has(prev.table!) || !CLIP_TABLES.has(curr.table!)) return false\n const lead = leadRows(curr.table!)\n return lead.image && lead.height > prev.bbox!.y\n}\n\n/** 표 첫 행 묶음(첫 행에서 세로 병합 칸으로 이어진 행들)의 높이(첫 조각 행 경계로, 모르면 0)와 그림 칸이 들었는지 */\nfunction leadRows(t: IRTable): { height: number; image: boolean } {\n const ys = TABLE_ROWYS.get(t)\n if (!ys || ys.length < 2) return { height: 0, image: false }\n const anchors = anchorsOf(t)\n let end = 1, image = false\n for (let r = 0; r < end; r++) {\n for (const a of anchors) {\n if (a.r !== r) continue\n end = Math.max(end, r + a.rs)\n if (IMAGE_CELLS.has(a.cell)) image = true\n }\n }\n return { height: ys[0] - ys[Math.min(end, ys.length - 1)], image }\n}\n\n/** 표 끝 자리 — 마지막 행 칸이 다음 쪽으로 넘어가 그 조각을 붙인 표(TABLE_TAIL)는 그 쪽과 조각 밑변을, 아니면 블록 그대로. 쪽 넘김\n * 잇기는 앞 표가 끝난 쪽·자리를 봐야 한다 — 위험물 안전관리자 교육 [별표 4] 틀 표는 2쪽 끝 칸이 3쪽을 통째로 채우고 4쪽 조각으로\n * 이어지는데, 3쪽 조각을 2쪽 표에 붙인 뒤 표가 2쪽에 있는 것으로 보여 4쪽 조각과 잇지 못했다 (HWPX 19×7 한 표) */\nfunction tailOf(b: IRBlock): IRBlock {\n const t = b.table && TABLE_TAIL.get(b.table)\n return t && b.bbox ? { ...b, pageNumber: t.page, bbox: { ...b.bbox, page: t.page, y: t.y, height: t.height } } : b\n}\n\n/** 표 첫 행 앵커 — 열 경계 x 범위와 글 */\nfunction firstRowSig(t: IRTable): Array<{ x1: number; x2: number; t: string }> | null {\n const x = TABLE_COLXS.get(t)\n if (!x || x.length !== t.cols + 1) return null\n return anchorsOf(t).filter(a => a.r === 0).map(a => ({ x1: x[a.c], x2: x[a.c + a.cs], t: flowText(a.cell.text).replace(/\\s+/g, \"\") }))\n}\n\n/**\n * 뒤 표 첫 행이 앞 조각이 속한 표(쪽 넘김 사슬 첫 조각)의 첫 행을 다시 시작하는가 — 앵커 칸 짜임(x 범위, 대칭 여백\n * 이동 허용)이 같고 이름표 칸 둘 이상이 같은 글인데 값 칸이 하나 이상 다르다. 한 서식을 품목마다 되풀이한 문서(과제 품목 명세서\n * 5벌, 서식마다 두 쪽)에서 앞 서식 끝 조각은 본문 바닥 근처에서 끝나고 다음 쪽 첫머리가 새 서식이라 쪽 넘김 기하로는 이어짐과\n * 같다(141×22 한 표로 뭉침). 반복 머리 행은 글이 모두 같아 해당 없고, 이어진 조각의 첫 행은 표 첫 행과 칸 짜임부터 다르다\n */\nfunction restartsTable(blocks: IRBlock[], i: number, curr: IRTable, pageHeights?: Map<number, number>): boolean {\n const cs = firstRowSig(curr)\n if (!cs) return false\n const head = blocks[chainHead(blocks, i, pageHeights)].table!\n if (cs.length === 1 && restartsTitledForm(head, curr)) return true\n if (cs.length < RESTART_MIN_ANCHORS) return false\n // 보도자료 연락처 표의 다음 부처 묶음 — \"담당 부서 | 부처 | 책임자 | …\" 를 되풀이해도 한 표다 (HWPX 연락처 표 522개 중 여러 묶음 72개,\n // 묶음마다 따로 둔 표 0개). 정부합동 보도자료에서 쪽 끝 묶음과 다음 쪽 묶음이 두 표로 갈렸다\n if (CONTACT_HEAD.test(cs[0].t) && cs.some(c => CONTACT_ROLE.test(c.t))) return false\n const hs = firstRowSig(head)\n if (!hs || hs.length !== cs.length) return false\n const dx = hs[hs.length - 1].x2 - cs[cs.length - 1].x2\n if (!hs.every((h, n) => Math.abs(h.x1 - cs[n].x1 - dx) <= CONTINUATION_COL_TOL && Math.abs(h.x2 - cs[n].x2 - dx) <= CONTINUATION_COL_TOL)) return false\n let same = 0, diff = 0\n for (let n = 0; n < hs.length; n++) {\n if (!hs[n].t && !cs[n].t) continue\n if (hs[n].t === cs[n].t) same++\n else diff++\n }\n return same >= 2 && diff >= 1\n}\n\n/**\n * 전폭 제목 칸으로 시작하는 서식의 되풀이 — 두 표 첫 행이 모두 전폭 한 칸이고 글이 다르며, 둘째 행의 칸 짜임과 첫 칸 이름표가 같다.\n * \"3) 공공데이터 자동수집 / 목표 | … / 세부 목표 | …\" 상자를 과제마다 놓은 계획서(tac-img-02)에서 쪽 끝 상자와 다음 쪽 첫 상자가\n * 쪽 넘김 기하로 이어져 6×2 로 뭉쳤다. 이어진 조각이면 첫 행이 제목 칸일 때 그 아래 행 이름표까지 표 첫머리와 같을 까닭이 없다\n */\nfunction restartsTitledForm(head: IRTable, curr: IRTable): boolean {\n if (head.rows < 2 || curr.rows < 2 || head.cols !== curr.cols) return false\n const full = (t: IRTable) => t.cells[0][0]?.colSpan === t.cols\n if (!full(head) || !full(curr)) return false\n const norm = (c: IRCell | undefined) => flowText(c?.text ?? \"\").replace(/\\s+/g, \"\")\n const ht = norm(head.cells[0][0]), ct = norm(curr.cells[0][0])\n if (!ht || !ct || ht === ct) return false\n // 제목 칸만 — 앞 쪽에서 넘어온 본문 칸(\"ㅇ …\"·\"- …\" 긴 글)으로 시작하는 이어진 조각끼리는 짜임이 같아도 새 표가 아니다(form-002)\n const titleLike = (t: string) => t.length <= TITLE_ROW_MAX_CHARS && !/^[ㅇ○◦•·\\-–※*□■▪☞]/.test(t)\n if (!titleLike(ht) || !titleLike(ct)) return false\n const spans = (t: IRTable) => anchorsOf(t).filter(a => a.r === 1).map(a => `${a.c}:${a.cs}`).join(\",\")\n const label = norm(head.cells[1][0])\n return spans(head) === spans(curr) && label !== \"\" && label === norm(curr.cells[1][0])\n}\n\n/**\n * blocks[i] 표 조각이 속한 쪽 넘김 사슬의 첫 조각 위치 — 앞 쪽 마지막 표가 이음 모양(사이 글 없음·쪽 끝과 첫머리·열 경계 호환)이면\n * 거슬러 올라간다 (뒤에서부터 잇는 본 판정 전이라 아직 안 이은 조각끼리 본다). 이어진 조각의 첫 행은 데이터 행이라 거기와\n * 견주면 값이 되풀이되는 표(평가지표표 \"실증을 통한 내부평가\")가 새 표로 잘린다 — 표 첫 행과만 견준다\n */\nfunction chainHead(blocks: IRBlock[], i: number, pageHeights?: Map<number, number>): number {\n let k = i\n for (let steps = 0; steps < RESTART_LOOKBACK_PAGES; steps++) {\n const cur = blocks[k]\n let p = k - 1\n while (p >= 0 && blocks[p].type !== \"table\" && (blocks[p].pageNumber ?? 0) >= (cur.pageNumber ?? 0) - 1) p--\n if (p < 0 || blocks[p].type !== \"table\" || !blocks[p].table || !blocks[p].bbox) break\n // 칸 안 예시 상자처럼 앞 표 테두리 안에 든 표면 그 앞 표로\n for (let q = p - 1; q >= 0 && blocks[q].pageNumber === blocks[p].pageNumber; q--) {\n if (blocks[q].type === \"table\" && blocks[q].bbox && insideTable(blocks[p], blocks[q])) { p = q; break }\n }\n const pb = tailOf(blocks[p])\n if (pb.pageNumber !== (cur.pageNumber ?? 0) - 1) break\n if (!blocks.slice(p + 1, k).every(b => besideOwn(b, pb, cur) || insideTable(b, pb) || pageNumberBetween(b, pb, cur, pageHeights))) break\n if (!looksContinued(pb, cur, pageHeights)) break\n const px = TABLE_COLXS.get(pb.table!), cx = TABLE_COLXS.get(cur.table!)\n if (!px || !cx) break\n const dx = px[px.length - 1] - cx[cx.length - 1]\n if (!shiftedSame(px, cx) && !px.some(x => Math.abs(x - (cx[0] + dx)) <= CONTINUATION_COL_TOL) && !prevLacksLeftCols(pb, px, cx, pageHeights)) break\n k = p\n }\n return k\n}\n\n/** 쪽 넘김 조각 경계에서 끊긴 세로 병합 칸 잇기 — 앞 조각 끝 행까지 걸친 글 있는 병합 원점과 같은 열·폭의 뒤 조각 첫 행 빈 병합 칸 */\nconst ROW_CONTINUATIONS = new WeakMap<IRCell, IRCell>()\n\nfunction continueRowSpans(prevCells: IRCell[][], currCells: IRCell[][]): void {\n const first = currCells[0]\n const last = prevCells.length\n for (let c = 0; c < first.length; c++) {\n const cell = first[c]\n if (hasContent(cell) || (cell.rowSpan || 1) < 2) continue\n let anchor: IRCell | undefined\n for (let r = last - 1; r >= 0 && !anchor; r--) {\n const a = prevCells[r]?.[c]\n if (a && r + (a.rowSpan || 1) === last && (a.colSpan || 1) === (cell.colSpan || 1)\n && (hasContent(a) || a.rowSpan >= 2)) anchor = a\n }\n if (!anchor) continue\n // 역순 이음에서 가운데 쪽의 빈 원점은 아직 이름표를 모른다. 실제 쪽 경계의 조각 관계만 보관하고 이름표를 찾았을 때 잇는다.\n if (!hasContent(anchor)) { ROW_CONTINUATIONS.set(anchor, cell); continue }\n for (let part: IRCell | undefined = cell; part;) {\n const next = ROW_CONTINUATIONS.get(part)\n anchor.rowSpan = (anchor.rowSpan || 1) + part.rowSpan\n // 원점 객체를 유지해야 앞서 보관한 사슬이 끊기지 않는다. 덮인 자리는 격자 자리표만 남긴다.\n part.rowSpan = 1\n part.colSpan = 1\n ROW_CONTINUATIONS.delete(part)\n part = next\n }\n }\n}\n\n/** 첫 행에서 시작한 머리 병합의 전이적 깊이까지, 글과 앵커 모양이 모두 같을 때만 머리를 통째로 뺀다. -1은 다른 머리. */\nfunction repeatedHeaderRows(prev: IRTable, curr: IRTable): number {\n if (!rowTextsEqual(prev.cells[0], curr.cells[0])) return 0\n const pa = anchorsOf(prev), ca = anchorsOf(curr)\n let depth = 1\n for (let r = 0; r < depth && r < prev.rows; r++) {\n for (const a of pa) if (a.r === r) depth = Math.max(depth, r + a.rs)\n }\n if (depth >= curr.rows || depth > prev.rows) return -1\n const p = pa.filter(a => a.r < depth), d = ca.filter(a => a.r < depth)\n const norm = (text: string) => flowText(text).replace(/\\s+/g, \"\")\n return p.length === d.length && p.every((a, i) => a.r === d[i].r && a.c === d[i].c && a.rs === d[i].rs\n && a.cs === d[i].cs && norm(a.cell.text) === norm(d[i].cell.text)) ? depth : -1\n}\n\n/** 두 행의 셀 텍스트가 모두 동일한지 (공백 정규화 후 비교) */\nfunction rowTextsEqual(a: IRCell[], b: IRCell[]): boolean {\n if (a.length !== b.length) return false\n const norm = (t: string) => flowText(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/** 쪽 넘김 이음 판정 — 열 경계가 이 거리(pt) 안에서 전부 맞아야 같은 표 */\nconst CONTINUATION_COL_TOL = 2\n/** 안쪽 열 경계가 앞 조각과 하나도 안 맞물리는 뒤 조각이 새 표 — 첫 행 칸이 이만큼 이상 (제 열 짜임으로 시작), 앞 조각도 열이 이만큼\n * 이상. 1열 앞 조각(별표 제목 틀·서식 머리 칸)은 안쪽 경계가 없어 맞물림을 따질 수 없다 (과징금 부과기준 1쪽 4×1 → 2쪽 6×6, HWPX 한 표) */\nconst FOREIGN_FIRST_ROW_CELLS = 3\n\n/** 두 조각의 열 경계가 같은가 — allowShift 면 모든 경계가 같은 거리만큼 옮겨진 것도 같다고 본다 (짝·홀 쪽 대칭 여백) */\nfunction shiftedSame(px: number[], cx: number[], allowShift = true): boolean {\n if (px.length !== cx.length) return false\n const dx = allowShift ? px[px.length - 1] - cx[cx.length - 1] : 0\n return px.every((x, k) => Math.abs(x - cx[k] - dx) <= CONTINUATION_COL_TOL)\n}\n/** 쪽 넘김 조각 판정 — 앞 조각 밑변이 쪽 아래 이 비율 안, 뒤 조각 윗변이 쪽 위 이 비율 안 (본문 여백 안쪽까지 찬 표) */\nconst PAGE_EDGE_BAND = 0.16\n/** 새 표 시작 판정 — 첫 행 앵커가 이만큼 이상인 표만 (칸 짜임이 같다는 게 우연이 아닐 만큼), 사슬 첫 조각은 이 쪽 수까지 거슬러 찾는다 */\nconst RESTART_MIN_ANCHORS = 3\n/** 되풀이 서식의 전폭 제목 칸 글 길이 상한 (공백 뺀 글자) */\nconst TITLE_ROW_MAX_CHARS = 40\nconst RESTART_LOOKBACK_PAGES = 5\n","/**\n * 쪽을 넘는 칸 잇기 (mergeContinuedCells) — 앞 쪽 칸의 이어짐으로 판정된 1칸 조각(CONT_PARTS, clip-cells)을 앞 쪽 표의 그 칸에 붙인다.\n *\n * 한컴 PDF 는 쪽을 넘는 칸을 쪽마다 그 쪽에 그려진 부분만 클립으로 깐다. 여러 칸 행이 넘어가면 뒤 쪽에도 칸 묶음이 생겨 쪽 넘김\n * 잇기(table-parts)가 잇지만, 한 칸만 넘어가면(1열 표의 본문 칸, 규제영향분석서 \"근거설명 | 내용\" 의 내용 칸) 뒤 쪽 조각은 1칸이다.\n * 그 조각을 새 행으로 붙이면 한 칸이 쪽 수만큼 행으로 갈리고, 조각에 든 표·글은 원본(HWPX) 칸 밖에 놓인다. 여기서는 조각의 글과\n * 표를 앞 쪽 칸 뒤에 이어 붙이고, 칸 안에서 쪽 경계로 갈린 표는 쪽 넘김 잇기로 다시 잇는다.\n */\n\nimport type { IRBlock, IRCell, IRTable } from \"../types.js\"\nimport { CELL_LINES, CLIP_TABLES, CONT_PARTS, EMPTY_PARTS, TABLE_COLXS, TABLE_TAIL } from \"./table-meta.js\"\nimport { mergeCrossPageTables } from \"./table-parts.js\"\nimport { joinCellEdges } from \"./cell-edges.js\"\nimport { markContinuedText } from \"./table-pages.js\"\n\n/** 조각의 좌우 변과 앞 표 열 경계를 같은 것으로 보는 거리 (pt) — 격자 열 경계는 클립 좌표 묶음(0.3pt)의 평균 */\nconst COL_MATCH_TOL = 0.5\nconst CONTINUED_TABLE_PAGE_BAND = 0.16\n\n/** 표 마지막 행까지 내려오는 칸 가운데 좌우 변이 x1~x2 인 칸 (열 경계를 모르는 1열 표는 그 칸) */\nfunction lastRowCell(t: IRTable, x1: number, x2: number): IRCell | undefined {\n const xs = TABLE_COLXS.get(t)\n const covered = new Set<number>()\n for (let r = 0; r < t.rows; r++) {\n for (let c = 0; c < t.cols; c++) {\n if (covered.has(r * 100000 + c)) continue\n const cell = t.cells[r]?.[c]\n if (!cell) continue\n for (let dr = 0; dr < cell.rowSpan; dr++) for (let dc = 0; dc < cell.colSpan; dc++) covered.add((r + dr) * 100000 + c + dc)\n if (r + cell.rowSpan !== t.rows) continue\n if (xs ? Math.abs(xs[c] - x1) <= COL_MATCH_TOL && Math.abs(xs[c + cell.colSpan] - x2) <= COL_MATCH_TOL : t.cols === 1) return cell\n }\n }\n return undefined\n}\n\n/** 칸 내용을 블록으로 — blocks 가 없으면 text 줄마다 문단 (틀 칸 blocks 와 같은 모양) */\nconst cellBlocks = (cell: IRCell, pageNumber?: number): IRBlock[] =>\n cell.blocks ?? cell.text.split(\"\\n\").map(t => t.trim()).filter(Boolean).map(text => ({ type: \"paragraph\" as const, text, pageNumber }))\n\n/** 표 블록들을 거꾸로 훑어 이어짐 조각을 앞 쪽 표 칸에 붙이고 조각은 뺀다 (세 쪽 넘게 이어지면 뒤 조각부터 앞 조각에 모인다) */\nexport function mergeContinuedCells(blocks: IRBlock[], pageHeights?: Map<number, number>): void {\n for (let j = blocks.length - 1; j > 0; j--) {\n const part = blocks[j]\n const from = part.table ? CONT_PARTS.get(part.table) : undefined\n if (!from || !part.table) continue\n // 앞 표 — 사이 글은 앞 쪽 꼬리말·이 쪽 머리말 띠뿐이다 (clip-cells 가 조각을 쪽 첫 내용, 앞 칸을 쪽 마지막 내용으로 골랐다)\n let i = j - 1\n while (i >= 0 && blocks[i].type !== \"table\") i--\n const prev = blocks[i]\n if (i < 0 || !prev.table || prev.pageNumber !== (part.pageNumber ?? 0) - 1) continue\n const cell = lastRowCell(prev.table, from.x1, from.x2)\n const add = part.table.cells[0]?.[0]\n if (!cell || !add) continue\n const at = cell.blocks || add.blocks ? cellBlocks(cell, prev.pageNumber).length : -1\n if (cell.blocks || add.blocks) cell.blocks = [...cellBlocks(cell, prev.pageNumber), ...cellBlocks(add, part.pageNumber)]\n else if (part.pageNumber) markContinuedText(cell, add, part.pageNumber)\n cell.text = [cell.text, add.text].filter(s => s.trim()).join(\"\\n\")\n joinCellEdges(cell, add)\n // 표는 이 조각이 놓인 쪽에서 끝난다 — 다음 쪽 표 조각이 이 칸의 나머지로 시작하면(세 쪽에 걸친 칸) 쪽 넘김 잇기가 여기서 잇는다.\n // 칸 글줄도 이어 붙여 끝줄이 이 조각의 끝줄이 되게 한다 (글 이어짐 판정)\n const lines = CELL_LINES.get(add)\n if (lines?.length) CELL_LINES.set(cell, [...(CELL_LINES.get(cell) ?? []), ...lines])\n if (part.pageNumber && part.bbox) TABLE_TAIL.set(prev.table, TABLE_TAIL.get(part.table) ?? { page: part.pageNumber, y: part.bbox.y, height: part.bbox.height })\n // 앞 쪽 조각이 빈 칸뿐이던 표(쪽 끝에 머리 행만 남은 칸)도 이어진 글을 받았으면 더는 빈 조각이 아니다 — 쪽 넘김 잇기가 버리지 않게\n if (cell.text.trim() || cell.blocks?.length) EMPTY_PARTS.delete(prev.table)\n blocks.splice(j, 1)\n // 칸 안에서 쪽 경계로 갈린 표 (반제품 아이스팩 기준 틀의 2×2 계산 예시: 첫 행만 앞 쪽에 남은 것)\n if (cell.blocks && at > 0) {\n const nextAt = cell.blocks[at]\n fillNestedContinuation(cell.blocks, at)\n if (cell.blocks[at] === nextAt) unwrapContinuedTable(cell.blocks, at, pageHeights)\n }\n if (cell.blocks) mergeCrossPageTables(cell.blocks, pageHeights)\n }\n}\n\n/** 이어진 바깥 칸의 다음 쪽 전체 클립이 1×1 감싸개가 되면 그 안의 표 머리/본문이\n * 서로 다른 깊이에 놓인다. 두 표의 열 경계와 쪽 끝·첫머리 위치가 맞을 때만 감싸개를\n * 풀어 기존 쪽 넘김 표 병합에 넘긴다. 감싸개 안의 뒤 문단·표 순서는 그대로 둔다. */\nfunction unwrapContinuedTable(blocks: IRBlock[], at: number, pageHeights?: Map<number, number>): void {\n const prev = blocks[at - 1], frame = blocks[at]\n if (!prev?.table || !frame?.table || frame.table.rows !== 1 || frame.table.cols !== 1 || !CLIP_TABLES.has(frame.table)) return\n const inner = frame.table.cells[0]?.[0]?.blocks\n const next = inner?.[0]\n if (!inner || !next?.table || !prev.bbox || !next.bbox || !prev.pageNumber || next.pageNumber !== prev.pageNumber + 1\n || !CLIP_TABLES.has(prev.table) || !CLIP_TABLES.has(next.table) || prev.table.cols !== next.table.cols) return\n const px = TABLE_COLXS.get(prev.table), nx = TABLE_COLXS.get(next.table)\n if (!px || !nx || px.length !== nx.length || px.some((x, i) => Math.abs(x - nx[i]) > COL_MATCH_TOL)) return\n const ph = pageHeights?.get(prev.pageNumber), nh = pageHeights?.get(next.pageNumber)\n if (!ph || !nh || prev.bbox.y > ph * CONTINUED_TABLE_PAGE_BAND\n || next.bbox.y + next.bbox.height < nh * (1 - CONTINUED_TABLE_PAGE_BAND)) return\n blocks.splice(at, 1, ...inner)\n}\n\n/** 이미 이어짐으로 확인된 바깥 칸의 쪽 경계에서, 중첩표의 빈 마지막 칸을 같은 폭의 1칸 조각으로 채운다.\n * 빈 칸은 앞 쪽에 테두리만 그려지고 글은 다음 쪽에 시작할 수 있다. 새 행으로 붙이면 유령 빈 행이 남는다.\n * 내용 있는 칸은 새 비고/상자와 구별할 증거가 부족하므로 이 경로에서 합치지 않는다. */\nfunction fillNestedContinuation(blocks: IRBlock[], at: number): void {\n const prev = blocks[at - 1], next = blocks[at]\n const t = prev?.table, u = next?.table\n if (!t || !u || u.rows !== 1 || u.cols !== 1 || !CLIP_TABLES.has(t) || !CLIP_TABLES.has(u)\n || next.pageNumber !== (prev.pageNumber ?? 0) + 1) return\n const xs = TABLE_COLXS.get(u)\n const cell = xs ? lastRowCell(t, xs[0], xs[xs.length - 1]) : undefined\n const add = u.cells[0]?.[0]\n if (!cell || !add || cell.text.trim() || cell.blocks?.length) return\n cell.text = add.text\n if (add.blocks) cell.blocks = add.blocks\n joinCellEdges(cell, add)\n blocks.splice(at, 1)\n}\n","/**\n * 유니코드 없는 기호 글리프 되살리기 — 한컴 PDF 는 ToUnicode 를 못 만든 글리프(자동 글머리표·자동 번호·일부 괄호·칸 채움 글자)를\n * 전부 U+F000 으로 내고(text-clean stripNoUnicodeGlyph 가 지운다), 글자는 글꼴 서브셋의 윤곽으로만 남는다. pdf.js 가 변환한 글꼴\n * (fontExtraProperties 로 쥔 OpenType)에서 그 글리프의 TrueType 윤곽을 읽어 모양이 분명한 기호만 글자로 되살린다.\n * 글 벤치 54문서 F000 글리프 157종을 HWPX 원문 글자와 대조한 실측(2026-10-10): 되살린 263곳이 모두 원문과 같다 — 작은 오른쪽 삼각형 \"▸\" 114·\n * 큰 네모 테두리 \"□\" 67·겹 테두리 모서리(아래 오른쪽 \"》\" 58, 위 왼쪽 \"《\" 9 + 원문에서 문맥을 못 찾은 49 — 한컴 글꼴이 겹화살괄호를 이 꼴로 그린다)·\n * 일곱 점 화살표 \"↓\" 8·큰 삼각형 \"►\" 7. 칸 채움 막대(가는 가로 막대 194곳)는 원문에 없는 글자라 되살리지 않고, 원문자 숫자(① … 112곳)는\n * 숫자를 가려 읽지 못해 그대로 둔다\n */\n\nimport { OPS } from \"pdfjs-dist/legacy/build/pdf.mjs\"\nimport type { PdfTextItem } from \"./text-line.js\"\n\nexport const NO_UNICODE = \"\"\n\ntype Point = [number, number, boolean] // x, y, 곡선 위 점(on-curve)\ninterface Outline { contours: Point[][]; upm: number }\n\nconst u16 = (b: Uint8Array, o: number): number => (b[o] << 8) | b[o + 1]\nconst i16 = (b: Uint8Array, o: number): number => { const v = u16(b, o); return v & 0x8000 ? v - 0x10000 : v }\nconst u32 = (b: Uint8Array, o: number): number => ((b[o] << 24) >>> 0) + (b[o + 1] << 16) + (b[o + 2] << 8) + b[o + 3]\n\n/** OpenType 표 목록 */\nfunction tableDir(b: Uint8Array): Map<string, number> {\n const out = new Map<string, number>()\n if (b.length < 12) return out\n const n = u16(b, 4)\n for (let i = 0; i < n && 12 + 16 * i + 16 <= b.length; i++) {\n const r = 12 + 16 * i\n out.set(String.fromCharCode(b[r], b[r + 1], b[r + 2], b[r + 3]), u32(b, r + 8))\n }\n return out\n}\n\n/** cmap(형식 4·12)에서 글자 코드의 글리프 번호 */\nfunction glyphId(b: Uint8Array, cmap: number, code: number): number {\n const n = u16(b, cmap + 2)\n for (let i = 0; i < n; i++) {\n const off = cmap + u32(b, cmap + 4 + 8 * i + 4), fmt = u16(b, off)\n if (fmt === 4) {\n const segX2 = u16(b, off + 6), ends = off + 14, starts = ends + segX2 + 2, deltas = starts + segX2, ranges = deltas + segX2\n for (let s = 0; s < segX2 / 2; s++) {\n const end = u16(b, ends + 2 * s), start = u16(b, starts + 2 * s)\n if (code < start || code > end) continue\n const delta = i16(b, deltas + 2 * s), ro = u16(b, ranges + 2 * s)\n if (!ro) return (code + delta) & 0xffff\n const g = u16(b, ranges + 2 * s + ro + 2 * (code - start))\n return g ? (g + delta) & 0xffff : -1\n }\n } else if (fmt === 12) {\n const groups = u32(b, off + 12)\n for (let g = 0; g < groups; g++) {\n const r = off + 16 + 12 * g, sc = u32(b, r), ec = u32(b, r + 4)\n if (code >= sc && code <= ec) return u32(b, r + 8) + code - sc\n }\n }\n }\n return -1\n}\n\n/** TrueType 단순 글리프 윤곽 — 합성 글리프·CFF 글꼴은 읽지 않는다 */\nexport function readOutline(b: Uint8Array, code: number): Outline | null {\n const t = tableDir(b)\n const cmap = t.get(\"cmap\"), head = t.get(\"head\"), loca = t.get(\"loca\"), glyf = t.get(\"glyf\")\n if (cmap === undefined || head === undefined || loca === undefined || glyf === undefined) return null\n const gid = glyphId(b, cmap, code)\n if (gid < 0) return null\n const longLoca = i16(b, head + 50) === 1\n const a = longLoca ? u32(b, loca + 4 * gid) : 2 * u16(b, loca + 2 * gid)\n const z = longLoca ? u32(b, loca + 4 * gid + 4) : 2 * u16(b, loca + 2 * gid + 2)\n if (z <= a) return null\n const g = glyf + a, nc = i16(b, g)\n if (nc <= 0 || nc > 8) return null\n const ends: number[] = []\n for (let i = 0; i < nc; i++) ends.push(u16(b, g + 10 + 2 * i))\n const np = ends[nc - 1] + 1\n if (np > 400) return null\n let o = g + 10 + 2 * nc\n o += 2 + u16(b, o)\n const flags: number[] = []\n while (flags.length < np) {\n const f = b[o++]\n flags.push(f)\n if (f & 8) for (let r = b[o++]; r > 0; r--) flags.push(f)\n }\n const xs: number[] = [], ys: number[] = []\n let x = 0, y = 0\n for (const f of flags) { if (f & 2) { const d = b[o++]; x += f & 16 ? d : -d } else if (!(f & 16)) { x += i16(b, o); o += 2 } xs.push(x) }\n for (const f of flags) { if (f & 4) { const d = b[o++]; y += f & 32 ? d : -d } else if (!(f & 32)) { y += i16(b, o); o += 2 } ys.push(y) }\n const contours: Point[][] = []\n let s = 0\n for (const e of ends) { contours.push(xs.slice(s, e + 1).map((px, k) => [px, ys[s + k], !!(flags[s + k] & 1)])); s = e + 1 }\n return { contours, upm: u16(b, head + 18) || 1000 }\n}\n\ninterface Box { x1: number; y1: number; x2: number; y2: number }\nconst boxOf = (pts: Point[]): Box => {\n let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity\n for (const [x, y] of pts) { if (x < x1) x1 = x; if (x > x2) x2 = x; if (y < y1) y1 = y; if (y > y2) y2 = y }\n return { x1, y1, x2, y2 }\n}\n/** 곡선 위 점에서 겹친 점을 뺀 꼭짓점 */\nconst corners = (pts: Point[]): Point[] => {\n const out: Point[] = []\n for (const p of pts) if (p[2] && !out.some(q => Math.abs(q[0] - p[0]) < 2 && Math.abs(q[1] - p[1]) < 2)) out.push(p)\n return out\n}\nconst inside = (a: Box, b: Box): boolean => a.x1 >= b.x1 && a.x2 <= b.x2 && a.y1 >= b.y1 && a.y2 <= b.y2\n\n/** 윤곽 모양 → 기호 (모르면 null) */\nexport function classifyOutline({ contours, upm }: Outline): string | null {\n const boxes = contours.map(boxOf)\n const all = boxOf(contours.flat())\n const w = (all.x2 - all.x1) / upm, h = (all.y2 - all.y1) / upm\n if (w <= 0 || h <= 0) return null\n if (contours.length === 1) {\n const c = corners(contours[0])\n // 삼각형 — 꼭짓점 셋, 한 변이 세로(가로)로 곧고 맞은편 꼭짓점이 가운데 높이(너비)\n if (c.length === 3 && contours[0].every(p => p[2])) {\n const left = c.filter(p => Math.abs(p[0] - all.x1) <= 0.02 * upm), right = c.filter(p => Math.abs(p[0] - all.x2) <= 0.02 * upm)\n const midY = (all.y1 + all.y2) / 2, apexMid = (p: Point) => Math.abs(p[1] - midY) <= 0.15 * (all.y2 - all.y1)\n if (left.length === 2 && right.length === 1 && apexMid(right[0])) return Math.max(w, h) < 0.35 ? \"▸\" : w > h ? \"►\" : \"▶\"\n return null\n }\n // 화살표 — 꼭짓점 일곱(촉 셋 + 대 넷)\n if (c.length === 7 && contours[0].every(p => p[2]) && Math.max(w, h) >= 0.6) {\n const tip = (pick: (p: Point) => number, edge: number) => c.filter(p => Math.abs(pick(p) - edge) <= 0.02 * upm).length === 1\n if (h > w && tip(p => p[1], all.y1)) return \"↓\"\n if (w > h && tip(p => p[0], all.x2)) return \"➡\"\n }\n return null\n }\n if (contours.length === 2) {\n const [a, b] = boxes\n const outer = inside(b, a) ? 0 : inside(a, b) ? 1 : -1\n if (outer < 0) return null\n const ob = boxes[outer], ib = boxes[1 - outer]\n const ow = ob.x2 - ob.x1, oh = ob.y2 - ob.y1\n // 네모 테두리 — 바깥·안쪽 모두 정사각에 가깝고 글자 칸만 하다\n if (Math.min(w, h) >= 0.75 && Math.abs(ow - oh) <= 0.08 * upm && Math.abs((ib.x2 - ib.x1) - (ib.y2 - ib.y1)) <= 0.08 * upm) return \"□\"\n // 겹 테두리 모서리 — 꼭짓점 여섯인 ㄱ자 둘(속 빈 꺾쇠). 위 왼쪽 모서리면 여는 괄호, 아래 오른쪽 모서리면 닫는 괄호\n const oc = corners(contours[outer])\n if (oc.length === 6 && corners(contours[1 - outer]).length === 6 && w <= 0.4 && h >= 0.35) {\n const has = (x: number, y: number) => oc.some(p => Math.abs(p[0] - x) <= 0.02 * upm && Math.abs(p[1] - y) <= 0.02 * upm)\n const topLeft = has(ob.x1, ob.y2), bottomRight = has(ob.x2, ob.y1), topRight = has(ob.x2, ob.y2), bottomLeft = has(ob.x1, ob.y1)\n if (topLeft && !bottomRight && topRight && bottomLeft) return \"《\"\n if (bottomRight && !topLeft && topRight && bottomLeft) return \"》\"\n }\n }\n return null\n}\n\ninterface FontObj { data?: Uint8Array }\n\n/**\n * 쪽 글 아이템의 U+F000 을 그 자리 글리프 모양의 기호로 바꾼다(제자리). 글꼴마다 글리프 흐름(showText)의 F000 글리프를 차례로\n * 아이템 글의 F000 과 짝짓는다 — 두 흐름의 F000 수가 다르면 그 글꼴은 손대지 않는다\n */\nexport function restoreUnmappedGlyphs(items: PdfTextItem[], fnArray: ArrayLike<number>, argsArray: ArrayLike<unknown>,\n fontObj: (loadedName: string) => FontObj | null | undefined): void {\n if (!items.some(it => typeof it.str === \"string\" && it.str.includes(NO_UNICODE))) return\n const queues = new Map<string, string[]>()\n const cache = new Map<string, string | null>()\n const saved: string[] = []\n let font = \"\"\n for (let i = 0; i < fnArray.length; i++) {\n const fn = fnArray[i], args = (argsArray as unknown[][])[i]\n if (fn === OPS.setFont) font = String(args[0])\n else if (fn === OPS.save || fn === OPS.paintFormXObjectBegin) saved.push(font)\n else if (fn === OPS.restore || fn === OPS.paintFormXObjectEnd) font = saved.pop() ?? font\n else if (fn === OPS.showText) {\n for (const g of args[0] as unknown[]) {\n const glyph = g as { unicode?: unknown; fontChar?: unknown } | null\n if (!glyph || glyph.unicode !== NO_UNICODE || typeof glyph.fontChar !== \"string\") continue\n const key = font + \"\\u0000\" + glyph.fontChar\n if (!cache.has(key)) {\n let sym: string | null = null\n try {\n const data = fontObj(font)?.data\n const code = glyph.fontChar.codePointAt(0)\n const outline = data && code !== undefined ? readOutline(data, code) : null\n if (outline) sym = classifyOutline(outline)\n } catch { /* 깨진 글꼴 — 되살리지 않는다 */ }\n cache.set(key, sym)\n }\n let q = queues.get(font)\n if (!q) queues.set(font, q = [])\n q.push(cache.get(key) ?? NO_UNICODE)\n }\n }\n }\n const byFont = new Map<string, PdfTextItem[]>()\n for (const it of items) {\n if (typeof it.str !== \"string\" || !it.str.includes(NO_UNICODE)) continue\n const f = it.fontName ?? \"\"\n let list = byFont.get(f)\n if (!list) byFont.set(f, list = [])\n list.push(it)\n }\n for (const [f, list] of byFont) {\n const q = queues.get(f)\n const want = list.reduce((n, it) => n + (it.str.match(//g)?.length ?? 0), 0)\n if (!q || q.length !== want) continue\n let k = 0\n for (const it of list) it.str = it.str.replace(//g, () => q[k++])\n }\n}\n","/** Selected-page conversion still needs nearby repetition proof for page margins. */\nimport type { IRBlock } from \"../types.js\"\nimport { normalizeItems, filterHiddenText, groupByY, mergeLineSimple, computeBBox, dominantStyle, type PdfTextItem } from \"./text-line.js\"\n\n/** At most eight unselected neighbors per requested page, including alternating heads at either document edge. */\nexport function selectionContextPages(selected: Set<number>, pageCount: number): number[] {\n const out = new Set<number>()\n for (const page of selected) {\n for (let distance = 1; distance <= 4; distance++) {\n for (const neighbor of [page - distance, page + distance]) {\n if (neighbor < 1 || neighbor > pageCount || selected.has(neighbor)) continue\n out.add(neighbor)\n }\n }\n }\n return [...out].sort((a, b) => a - b)\n}\n\n/** Lightweight context only: no graphics, tables, images, OCR, output or progress events. */\nexport function marginContextBlocks(raw: PdfTextItem[], page: number, view: number[]): IRBlock[] {\n const [x1, y1, x2, y2] = view\n const width = x2 - x1, height = y2 - y1\n const items = filterHiddenText(normalizeItems(raw), width, height, x1, y1).visible\n for (const item of items) { item.x -= x1; item.y -= y1 }\n return groupByY(items).flatMap(line => {\n const bbox = computeBBox(line, page)\n if (bbox.y + bbox.height < height * 0.88 && bbox.y > height * 0.12) return []\n return [{ type: \"paragraph\" as const, text: mergeLineSimple(line), pageNumber: page, bbox, style: dominantStyle(line) }]\n })\n}\n","/**\n * 심볼 폰트(Wingdings 등) 글리프 코드 → 유니코드 복원.\n *\n * 한컴 PDF 는 서식의 처리절차 화살표·체크박스를 Wingdings 심볼 폰트로 찍는다. pdfjs 는\n * 심볼릭 폰트의 코드를 Latin-1 문자로 그대로 돌려주므로(0xE8 → \"è\") 마크다운에 깨진\n * 라틴 문자가 남는다 (법제처 별지서식 PDF 실측, v4.12.1). 폰트 실명(commonObjs)이\n * Wingdings 이면 코드를 표준 유니코드로 되돌린다.\n *\n * 코드표는 src/shared/symbol-fonts.ts (HWP/HWPX 심볼 PUA 와 공용 SSOT).\n */\n\nimport type { NormItem } from \"./text-line.js\"\nimport { WINGDINGS } from \"../shared/symbol-fonts.js\"\n\n/** WinAnsi(CP1252) 0x80~0x9F 구간 — pdfjs 가 이미 CP1252 문자로 돌려준 경우 코드 복원 */\nconst WINANSI_REVERSE: Record<number, number> = {\n 0x20AC: 0x80, 0x201A: 0x82, 0x0192: 0x83, 0x201E: 0x84, 0x2026: 0x85, 0x2020: 0x86, 0x2021: 0x87,\n 0x02C6: 0x88, 0x2030: 0x89, 0x0160: 0x8A, 0x2039: 0x8B, 0x0152: 0x8C, 0x017D: 0x8E, 0x2018: 0x91,\n 0x2019: 0x92, 0x201C: 0x93, 0x201D: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97, 0x02DC: 0x98,\n 0x2122: 0x99, 0x0161: 0x9A, 0x203A: 0x9B, 0x0153: 0x9C, 0x017E: 0x9E, 0x0178: 0x9F,\n}\n\n/** 폰트 실명이 심볼 폰트인지 — 서브셋 접두(ABCDEF+)·\"-Regular\" 접미 무관 */\nexport function symbolFontTable(fontName: string | undefined): readonly string[] | undefined {\n if (!fontName) return undefined\n // Wingdings 2·3, Webdings 는 별도 코드표 — 매핑 없이 두면 오히려 잘못된 글자가 되므로 제외\n if (/wingdings(?![\\s-]*[23])/i.test(fontName)) return WINGDINGS\n return undefined\n}\n\n/** 한 문자열을 심볼 코드표로 되돌린다 — 표 밖 코드(제어·공백)는 그대로 */\nexport function remapSymbolText(text: string, table: readonly string[]): string {\n let out = \"\"\n for (const ch of text) {\n let code = ch.codePointAt(0)!\n if (code >= 0x80 && WINANSI_REVERSE[code] !== undefined) code = WINANSI_REVERSE[code]\n if (code >= 0x21 && code <= 0xFF) {\n const mapped = table[code - 0x21]\n out += mapped !== undefined && mapped !== \"\" ? mapped : ch\n } else {\n out += ch\n }\n }\n return out\n}\n\n/**\n * 페이지 텍스트 아이템의 심볼 폰트 글리프를 유니코드로 복원 (제자리).\n * @param resolveFontName pdfjs loadedName(g_d0_f6) → 폰트 실명. 미로드·실패 시 undefined\n * @returns 바뀐 아이템 수\n */\nexport function remapSymbolFontItems(items: NormItem[], resolveFontName: (loadedName: string) => string | undefined): number {\n const cache = new Map<string, readonly string[] | undefined>()\n const marlett = new Map<string, boolean>()\n let changed = 0\n let box: NormItem | null = null\n const drop = new Set<NormItem>()\n for (const it of items) {\n if (!it.fontName || !it.text) continue\n // Windows 양식 선택 상자(Marlett) — 상자 테두리 조각 g·f·e·d·c 를 같은 자리에 겹쳐 찍고 체크는 b (한컴 PDF 양식 개체,\n // form-002 \"gfedc 원천기술형\"). 테두리 첫 조각을 ☐ 로 두고 나머지는 지우며, 같은 자리의 b 는 그 상자를 ☑ 로 바꾼다\n if (!marlett.has(it.fontName)) marlett.set(it.fontName, /marlett/i.test(resolveFontName(it.fontName) ?? \"\"))\n if (marlett.get(it.fontName)) {\n const t = it.text.trim()\n if (t === \"g\") { it.text = \"☐\"; box = it; changed++ }\n else if (/^[c-f]$/.test(t)) { drop.add(it); changed++ }\n else if (t === \"b\" && box && Math.abs(box.x - it.x) <= 1 && Math.abs(box.y - it.y) <= 1) { box.text = \"☑\"; drop.add(it); changed++ }\n continue\n }\n let table = cache.get(it.fontName)\n if (!cache.has(it.fontName)) {\n table = symbolFontTable(resolveFontName(it.fontName))\n cache.set(it.fontName, table)\n }\n if (!table) continue\n const mapped = remapSymbolText(it.text, table)\n if (mapped !== it.text) { it.text = mapped; changed++ }\n }\n if (drop.size) for (let k = items.length - 1; k >= 0; k--) if (drop.has(items[k])) items.splice(k, 1)\n return changed\n}\n","/**\n * 한컴 수식 글꼴(HyhwpEQ) 글 → `$…$` 수식 스팬.\n *\n * 한컴 PDF 는 수식을 수식 글꼴 글자로 찍는데, 숫자·변수·연산자가 사용자 영역(U+E0xx) 전용 코드라 종전엔 뒤 단계가 지워\n * \"함수 의 극댓값이\", \"① ② ③\"(선택지 숫자 소실)이 됐다. 수식 글꼴 글자를 가까운 것끼리 묶고, 코드를 되살리고(수능\n * 모의고사 PDF 4종을 한컴 수식 script·쪽 그림과 대조한 표), 분수 막대(U+E06D) 위·아래 글을 \\frac{}{}, 올라간 글을 ^{} 로\n * 조립해 `$…$` 로 감싼다 — HWPX·HWP5 경로가 같은 수식을 `$…$` 로 내는 것과 같은 모양. 표에 없는 코드는 버린다(종전과 같음).\n * 리터럴 `$` 이스케이프(escapeLiteralDollar) 뒤에 불러야 스팬 기호가 살아남는다.\n */\n\nimport type { NormItem } from \"./text-line.js\"\n\n/** 한컴 수식 글꼴 실명 (서브셋 접두 뗀 이름) */\nconst EQ_FONT_RE = /^(?:Hy)?hwpEQ/i\n/** 분수 막대 */\nconst BAR = 0x6d\n\n/** 그리스 소문자 — U+E09D 부터 α β γ … (θ=E0A4·π=E0AC·σ=E0AE 로 순서 확인) */\nconst GREEK = [\"alpha\", \"beta\", \"gamma\", \"delta\", \"epsilon\", \"zeta\", \"eta\", \"theta\", \"iota\", \"kappa\", \"lambda\", \"mu\", \"nu\", \"xi\", \"o\", \"pi\", \"rho\", \"sigma\", \"tau\", \"upsilon\", \"phi\", \"chi\", \"psi\", \"omega\"]\n/** 수식 글꼴 안의 일반 유니코드 기호 → LaTeX */\nconst PLAIN: Record<string, string> = {\n \"→\": \"\\\\to \", \"≤\": \"\\\\le \", \"≥\": \"\\\\ge \", \"×\": \"\\\\times \", \"·\": \"\\\\cdot \", \"⋅\": \"\\\\cdot \", \"∙\": \"\\\\cdot \", \"∞\": \"\\\\infty \",\n \"−\": \"-\", \"⋯\": \"\\\\cdots \", \"∠\": \"\\\\angle \", \"∴\": \"\\\\therefore \", \"∵\": \"\\\\because \", \"∩\": \"\\\\cap \", \"∪\": \"\\\\cup \",\n \"≠\": \"\\\\ne \", \"°\": \"^{\\\\circ}\", \"⊥\": \"\\\\perp \", \"∏\": \"\\\\prod \", \"±\": \"\\\\pm \", \"∉\": \"\\\\notin \", \"⊂\": \"\\\\subset \", \"⇒\": \"\\\\Rightarrow \",\n}\n/** 기호 코드 (U+E000 + 코드) — 수능 모의고사 PDF 4종 문맥과 한컴 수식 script 로 확인한 것만 */\nconst SYMBOLS: Record<number, string> = {\n 0x44: \"(\", 0x45: \")\", 0x46: \"-\", 0x47: \"=\", 0x48: \"+\", 0x49: \"[\", 0x4a: \"]\", 0x4b: \"\\\\{\", 0x4c: \"\\\\}\", 0x4d: \"|\", 0x4f: \":\",\n 0x52: \",\", 0x53: \".\", 0x55: \"<\", 0x56: \">\", 0x5b: \"\\\\int \", 0x5c: \"\\\\sqrt\", 0x67: \"\\\\sum \",\n}\n\n/** U+E000 + 코드 → 글 (확인한 것만 — 여러 줄 괄호 조각·벡터 화살표 등은 버린다) */\nfunction decodeEq(ch: string): string {\n const cp = ch.codePointAt(0)!\n if (cp < 0xe000 || cp > 0xe0ff) return PLAIN[ch] ?? ch\n const c = cp - 0xe000\n if (c <= 0x19) return String.fromCharCode(0x41 + c) // 이탤릭 A~Z (S_n·X·Y 확인)\n if (c >= 0x34 && c <= 0x3c) return String(c - 0x33) // 1~9\n if (c === 0x3d) return \"0\"\n if (c >= 0xe5 && c <= 0xfe) return String.fromCharCode(0x61 + c - 0xe5) // 이탤릭 a~z\n if (c >= 0x9d && c < 0x9d + GREEK.length) return `\\\\${GREEK[c - 0x9d]} `\n return SYMBOLS[c] ?? \"\"\n}\n\n/** 수식 안 로마자 함수 이름 — 한컴은 똑바로 선 글자(ASCII)로 찍는다 */\nconst FUNC_RE = /^(lim|sin|cos|tan|log|ln|exp|max|min)$/\n\ninterface Tok { text: string; x: number; y: number; w: number; size: number; bar: boolean }\n\n/** 묶음 안 조각 → LaTeX 에 가까운 글. 짧은 막대부터(안쪽 분수 먼저) 위·아래 조각을 분수로, 올라간 작은 조각은 위첨자로 */\nfunction layout(toks: Tok[]): string {\n let rest = toks.filter(t => t.bar || t.text.trim())\n // 근호 — √ 바로 오른쪽에서 시작하는 막대가 윗줄, 그 아래 글이 근호 안\n for (const rt of rest.filter(t => t.text === \"\\\\sqrt\").sort((a, b) => b.x - a.x)) {\n const size = Math.max(rt.size, 1)\n const bar = rest.find(t => t.bar && t.x >= rt.x && t.x <= rt.x + rt.w + 2 && Math.abs(t.y - rt.y) <= size * 1.5)\n if (!bar) continue\n const inner = rest.filter(t => !t.bar && t !== rt && t.x + t.w / 2 > bar.x && t.x + t.w / 2 < bar.x + bar.w && bar.y - t.y > 0 && bar.y - t.y <= size * 1.4)\n const tok: Tok = { text: `\\\\sqrt{${linear(inner)}}`, x: rt.x, w: bar.x + bar.w - rt.x, y: inner[0]?.y ?? rt.y, size: Math.max(rt.size, ...inner.map(t => t.size)), bar: false }\n rest = [...rest.filter(t => t !== rt && t !== bar && !inner.includes(t)), tok]\n }\n const bars = rest.filter(t => t.bar).sort((a, b) => a.w - b.w)\n for (const bar of bars) {\n const inX = (t: Tok) => t !== bar && t.x + t.w / 2 >= bar.x - 1 && t.x + t.w / 2 <= bar.x + bar.w + 1\n const num = rest.filter(t => inX(t) && t.y > bar.y), den = rest.filter(t => inX(t) && t.y < bar.y)\n if (!num.length || !den.length) continue\n const fsize = Math.max(...num.map(t => t.size), ...den.map(t => t.size))\n const frac: Tok = { text: `\\\\frac{${linear(num)}}{${linear(den)}}`, x: bar.x, w: bar.w, y: Math.round(bar.y + fsize * 0.3), size: fsize, bar: false }\n rest = [...rest.filter(t => t !== bar && !num.includes(t) && !den.includes(t)), frac]\n }\n return linear(rest.filter(t => !t.bar))\n}\n\n/** 한 줄로 — x 순, 기준선보다 확실히 올라간 작은 조각은 ^{}, 내려간 작은 조각은 _{} */\nfunction linear(toks: Tok[]): string {\n const sorted = [...toks].sort((a, b) => a.x - b.x)\n if (!sorted.length) return \"\"\n const size = Math.max(...sorted.map(t => t.size))\n const base = sorted.find(t => t.size === size)!.y\n let out = \"\", scr = \"\", kind = \"\"\n const flushScr = () => { if (scr) { out += `${kind}{${scr.trim()}}`; scr = \"\"; kind = \"\" } }\n for (const t of sorted) {\n const small = t.size < size * 0.9\n const k = small && t.y > base + size * 0.2 ? \"^\" : (small && t.y < base - size * 0.1) || t.y < base - size * 0.3 ? \"_\" : \"\"\n if (k) { if (kind && kind !== k) flushScr(); kind = k; scr += t.text; continue }\n flushScr()\n out += (FUNC_RE.test(t.text) ? `\\\\${t.text} ` : t.text)\n }\n flushScr()\n return out.replace(/\\s+/g, \" \").trim()\n}\n\nconst isBarItem = (it: NormItem) => it.text.length === 1 && it.text.codePointAt(0) === 0xe000 + BAR\n\n/** 조각 글자 크기 — 분수 막대는 가로로 늘려 찍어 글자 크기가 막대 길이만큼 커진다(47pt 막대 → 95). 크기 판단에서 뺀다 */\nconst sizeOf = (it: NormItem) => (isBarItem(it) ? 0 : it.fontSize)\n\n/** 두 수식 조각이 한 수식인가 — 같은 줄(첨자 포함)로 붙어 있거나, 분수 막대 위·아래에 놓였다 */\nfunction near(a: NormItem, b: NormItem): boolean {\n const size = Math.max(sizeOf(a), sizeOf(b), 1)\n const gap = Math.max(a.x, b.x) - Math.min(a.x + a.w, b.x + b.w)\n const dy = Math.abs(a.y - b.y)\n if (gap <= size * 0.8 && dy <= size * 0.6) return true\n // 분수 막대 — 막대 폭 안에 가운데가 놓이고 막대 바로 위·아래(글자 크기 1.2배 안)인 조각만 분자·분모\n for (const [bar, o] of [[a, b], [b, a]] as const) {\n if (!isBarItem(bar) || isBarItem(o)) continue\n const cx = o.x + o.w / 2\n if (cx > bar.x && cx < bar.x + bar.w && dy > 0 && dy <= size * 1.2) return true\n }\n return false\n}\n\nexport function wrapEquationRuns(items: NormItem[], faceName: (fontName: string) => string | undefined): number {\n const eqIdx: number[] = []\n items.forEach((it, i) => { if (it.text.trim() && EQ_FONT_RE.test(faceName(it.fontName) ?? \"\")) eqIdx.push(i) })\n if (!eqIdx.length) return 0\n // 근접 union-find — 목록은 줄 정렬이라 분자·분모·첨자가 멀리 떨어져 들어온다\n const parent = eqIdx.map((_, k) => k)\n const find = (k: number): number => { while (parent[k] !== k) { parent[k] = parent[parent[k]]; k = parent[k] } return k }\n for (let a = 0; a < eqIdx.length; a++) {\n for (let b = a + 1; b < eqIdx.length; b++) {\n const A = items[eqIdx[a]], B = items[eqIdx[b]]\n if (A.y - B.y > Math.max(sizeOf(A), sizeOf(B), 12) * 3) break // y 내림차순 — 멀리 내려가면 그만\n if (near(A, B)) parent[find(a)] = find(b)\n }\n }\n const clusters = new Map<number, number[]>()\n eqIdx.forEach((idx, k) => { const r = find(k); const arr = clusters.get(r); if (arr) arr.push(idx); else clusters.set(r, [idx]) })\n const drop = new Set<number>()\n const added: NormItem[] = []\n for (const members of clusters.values()) {\n const cur = members.map(i => items[i])\n for (const i of members) drop.add(i)\n const toks: Tok[] = cur.map(it => {\n const bar = isBarItem(it)\n const text = bar ? \"\" : FUNC_RE.test(it.text.trim()) ? it.text.trim() : [...it.text].map(decodeEq).join(\"\")\n return { text, x: it.x, y: it.y, w: it.w, size: sizeOf(it), bar }\n })\n const tex = layout(toks)\n if (!tex) continue // 되살릴 글이 없는 조각(표에 없는 코드) — 종전처럼 버린다\n // 줄 위치 — 본문 크기 조각이 가장 많이 놓인 기준선. 분수뿐인 덩어리는 막대 높이(분자·분모 가운데)에 둔다\n const size = Math.max(...cur.map(sizeOf))\n const mains = cur.filter(i => !isBarItem(i) && i.fontSize === size)\n const ys = new Map<number, number>()\n for (const i of mains) ys.set(i.y, (ys.get(i.y) ?? 0) + 1)\n const top = [...ys].sort((p, q) => q[1] - p[1])\n const bar = cur.find(isBarItem)\n const lineY = bar && top.length > 1 && top[0][1] === top[1][1] ? Math.round(bar.y + size * 0.3) : top[0]?.[0] ?? cur[0].y\n const base = { ...(mains[0] ?? cur[0]), y: lineY, fontSize: size || cur[0].fontSize }\n const x1 = Math.min(...cur.map(i => i.x)), x2 = Math.max(...cur.map(i => i.x + i.w))\n added.push({ ...base, text: `$${tex}$`, x: x1, w: x2 - x1, hasSpaceBefore: true })\n }\n const kept = items.filter((_, i) => !drop.has(i))\n items.length = 0\n items.push(...kept, ...added)\n items.sort((a, b) => b.y - a.y || a.x - b.x)\n return added.length\n}\n","/**\n * 글리프 이름으로 글자 복원 — ToUnicode 없이 Custom 인코딩 /Differences 에 글리프 이름만 둔 글꼴.\n *\n * 조판 프로그램(InDesign 등)은 옛 숫자·작은 대문자·합자를 \"seven.oldstyle\"·\"c.sc\"·\"f_l\" 같은 이름으로 싣는다.\n * pdfjs 는 이런 이름을 표준 글리프 목록에서 못 찾아 코드값을 그대로(제어 문자) 돌려주고, 제어 문자는 뒤에서 지워져\n * 숫자가 통째로 사라진다(\"May 1965\" → \"May .\"). Adobe 글리프 목록 규칙대로 첫 \".\" 뒤 접미를 떼고 \"_\" 합자를 나눠\n * 이름을 글자로 바꾼다. 작은 대문자(.sc·.smcp·.c2sc)는 지면에 대문자로 보이므로 대문자로 낸다.\n */\n\nimport { OPS, normalizeUnicode } from \"pdfjs-dist/legacy/build/pdf.mjs\"\nimport type { NormItem, PdfTextItem } from \"./text-line.js\"\n\nconst NAMED: Record<string, string> = {\n zero: \"0\", one: \"1\", two: \"2\", three: \"3\", four: \"4\", five: \"5\", six: \"6\", seven: \"7\", eight: \"8\", nine: \"9\",\n period: \".\", comma: \",\", colon: \":\", semicolon: \";\", hyphen: \"-\", endash: \"–\", emdash: \"—\", space: \" \",\n parenleft: \"(\", parenright: \")\", bracketleft: \"[\", bracketright: \"]\", slash: \"/\", ampersand: \"&\",\n quoteleft: \"‘\", quoteright: \"’\", quotedblleft: \"“\", quotedblright: \"”\", quotesingle: \"'\", quotedbl: \"\\\"\",\n bullet: \"•\", openbullet: \"◦\", whitebullet: \"◦\",\n question: \"?\", exclam: \"!\", percent: \"%\", dollar: \"$\", numbersign: \"#\", asterisk: \"*\", plus: \"+\", equal: \"=\",\n}\n\n/** 글리프 이름 → 글자 (모르는 이름은 undefined) */\nexport function glyphNameText(name: string): string | undefined {\n const dot = name.indexOf(\".\")\n const base = dot > 0 ? name.slice(0, dot) : name\n const smallCaps = dot > 0 && /^(?:sc|smcp|c2sc)$/.test(name.slice(dot + 1))\n let out = \"\"\n for (const part of base.split(\"_\")) {\n const uni = /^uni([0-9A-F]{4})$/.exec(part) ?? /^u([0-9A-F]{4,6})$/.exec(part)\n const code = uni ? parseInt(uni[1], 16) : undefined\n if (code !== undefined && (code > 0x10ffff || code >= 0xd800 && code <= 0xdfff)) return undefined\n const ch = /^[A-Za-z]$/.test(part) ? part : NAMED[part] ?? (code !== undefined ? String.fromCodePoint(code) : undefined)\n if (ch === undefined) return undefined\n out += ch\n }\n return smallCaps ? out.toUpperCase() : out\n}\n\n/** 제어 문자로 남은 코드(탭·줄바꿈 제외)를 글꼴 /Differences 의 글리프 이름으로 되살린다 (제자리). 바뀐 아이템 수 */\nexport function remapControlGlyphs(items: NormItem[], differencesOf: (loadedName: string) => ArrayLike<string | undefined> | undefined): number {\n let changed = 0\n for (const it of items) {\n if (!it.fontName || !/[\\u0001-\\u0008\\u000B\\u000C\\u000E-\\u001F]/.test(it.text)) continue\n const diffs = differencesOf(it.fontName)\n if (!diffs) continue\n let out = \"\"\n for (const ch of it.text) {\n const code = ch.charCodeAt(0)\n const name = code < 0x20 && code !== 9 && code !== 10 && code !== 13 ? diffs[code] : undefined\n out += (name && glyphNameText(name)) ?? ch\n }\n if (out !== it.text) { it.text = out; changed++ }\n }\n return changed\n}\n\nconst isSmallCapName = (name: string) => /^[^.]+\\.(?:sc|smcp|c2sc)$/.test(name)\n/** 글자 대신 공백·빈 글·제어 문자로 나오는 코드 — 탭·줄바꿈(9·10·13)과 C1(0x80~0x9F)도 여기 든다 */\nconst isRestorableCode = (code: number) => code < 0x20 || (code >= 0x80 && code <= 0x9f)\n\ninterface OpGlyph { originalCharCode?: number; unicode?: string }\n\n/**\n * TeX CM 수식 글꼴을 다시 매긴 하위 글꼴(Springer 계열 조판, ODL 028~031) — 이름·코드가 가리키는 글자가 아니라 원래 기호다.\n * Symbols 는 라틴-1 이름에 기호를 얹었고(thorn \"+\", onequarter \"=\", eth \"(\", Thorn \")\", onehalf \"[\", C138 \"]\" — 정답 문맥 대조)\n * \"C숫자\" 이름은 TeX cmsy 원래 코드(0 −, 1 ·, 2 ×, 6 ±, 24 ∼). Italic 은 cmmi 인코딩이라 0x3A 가 \".\", 0x3D 가 \"/\".\n */\nconst TEX_CM_SYMBOL_NAMES: Record<string, string> = {\n thorn: \"+\", onequarter: \"=\", eth: \"(\", Thorn: \")\", onehalf: \"[\", C138: \"]\", C0: \"−\", C1: \"·\", C2: \"×\", C6: \"±\", C24: \"∼\",\n}\nconst TEX_CM_ITALIC_CODES: Record<number, string> = { 58: \".\", 61: \"/\" }\nconst texCmGlyph = (face: string | undefined, code: number, name: string | undefined): string | undefined =>\n !face ? undefined\n : /TeXCMMathsSymbols/.test(face) ? (name ? TEX_CM_SYMBOL_NAMES[name] : undefined)\n : /TeXCMMathsItalic/.test(face) ? TEX_CM_ITALIC_CODES[code] : undefined\n\n/**\n * 글리프 이름으로만 알 수 있는 글자를 pdfjs 텍스트 아이템에 되살린다 (normalizeItems 전, 제자리). 바뀐 아이템 수.\n *\n * pdfjs 텍스트 아이템에는 유니코드 글만 남아 글리프 이름이 사라진다. 두 경우가 그 이름을 봐야 풀린다:\n * - 작은 대문자: Brill-Roman 은 /h.smcp·/B.c2sc 를 ToUnicode 로 소문자에 매겨 지면의 \"H. HUMPHREY\" 가\n * \"h. humphrey\" 로 나온다(ODL 010·013). 소문자 글리프와 글이 같아 아이템만으로는 못 가른다.\n * - 공백·빈 글로 사라지는 코드: GaramondPremrPro(ToUnicode 없음)는 `9 /nine.oldstyle`·`129 /F.a` 라 pdfjs 가\n * 9 를 탭(→ 공백), 129 를 빈 글로 돌려 \"May 1965\" → \"May 1 65\", \"FIGURE\" → \"IGURE\" (ODL 005·006).\n * remapControlGlyphs 는 공백 정리 뒤의 제어 문자만 보므로 여기서 먼저 푼다.\n * 연산자 목록의 showText 글리프(originalCharCode)를 글꼴마다 콘텐츠 순서로 텍스트 아이템 글자에 맞춰 짚는다.\n * pdfjs 가 틈에 넣은 공백은 건너뛰고, 한 글꼴이라도 맞춤이 어긋나면 그 글꼴은 손대지 않는다.\n * 작은 대문자·되살릴 코드 이름이 /Differences 에 있는 글꼴만 — 한컴 PDF(CID 글꼴)는 대상이 없다.\n */\nexport function restoreNamedGlyphs(\n items: PdfTextItem[], fnArray: ArrayLike<number>, argsArray: ArrayLike<unknown>,\n differencesOf: (loadedName: string) => ArrayLike<string | undefined> | undefined,\n faceOf: (loadedName: string) => string | undefined = () => undefined,\n): number {\n const targets = new Map<string, ArrayLike<string | undefined> | null>()\n for (const it of items) {\n if (!it.fontName || targets.has(it.fontName)) continue\n const diffs = differencesOf(it.fontName)\n if (/TeXCMMaths(?:Symbols|Italic)/.test(faceOf(it.fontName) ?? \"\")) { targets.set(it.fontName, diffs ?? []); continue }\n let hit = false\n if (diffs) for (let c = 0; c < diffs.length && !hit; c++) {\n const name = diffs[c]\n hit = !!name && (isSmallCapName(name) || (isRestorableCode(c) && glyphNameText(name) !== undefined))\n }\n targets.set(it.fontName, hit ? diffs! : null)\n }\n if (![...targets.values()].some(Boolean)) return 0\n\n // 글꼴별 글리프 흐름 — 폼 XObject·q/Q 가 글꼴을 되돌리므로 저장 스택을 따른다\n const streams = new Map<string, OpGlyph[]>()\n const saved: string[] = []\n let font = \"\"\n for (let i = 0; i < fnArray.length; i++) {\n const fn = fnArray[i]\n const args = (argsArray as unknown[][])[i]\n if (fn === OPS.setFont) font = String(args[0])\n else if (fn === OPS.save || fn === OPS.paintFormXObjectBegin) saved.push(font)\n else if (fn === OPS.restore || fn === OPS.paintFormXObjectEnd) font = saved.pop() ?? font\n else if (fn === OPS.showText && targets.get(font)) {\n let list = streams.get(font)\n if (!list) streams.set(font, list = [])\n for (const g of args[0] as unknown[]) if (g && typeof g === \"object\") list.push(g as OpGlyph)\n }\n }\n\n const failed = new Set<string>()\n const outs = new Map<string, { item: PdfTextItem; out: string }[]>()\n const cursor = new Map<string, number>()\n for (const it of items) {\n const fontName = it.fontName ?? \"\"\n const diffs = targets.get(fontName)\n if (!diffs || failed.has(fontName) || typeof it.str !== \"string\") continue\n const glyphs = streams.get(fontName) ?? []\n const done = outs.get(fontName) ?? []\n outs.set(fontName, done)\n const s = it.str\n let gi = cursor.get(fontName) ?? 0, k = 0, out = \"\"\n while (k < s.length) {\n const g = glyphs[gi]\n // 글리프가 다 떨어진 뒤 pdfjs 가 틈에 넣은 공백 아이템(\" \")은 짝이 없어도 된다(ODL 031 수식 글꼴)\n if (!g) { if (/^\\s*$/.test(s.slice(k))) { out += s.slice(k); break } failed.add(fontName); break }\n const code = g.originalCharCode ?? -1\n const name = diffs[code]\n const raw = g.unicode ?? \"\"\n const exp = /^\\p{Cf}$/u.test(raw) ? \"\" : normalizeUnicode(raw)\n // 되살릴 코드: 글꼴이 이름을 붙였는데 pdfjs 가 공백·빈 글·제어 문자로 돌려준 것\n // ToUnicode 가 대체 문자(U+FFFD)로 매긴 코드도 이름으로 되살린다 — \"one.SP\"·\"two.SP\" 쪽번호·그림 번호 숫자(ODL 001·015)\n const tex = texCmGlyph(faceOf(fontName), code, name)\n if (tex !== undefined && (exp === \"\" || s.startsWith(exp, k))) { out += tex; k += exp.length; gi++; continue }\n const named = name && isRestorableCode(code) && (exp === \"\" || /^[\\s\\u0000-\\u001f\\u0080-\\u009f\\ufffd]$/.test(exp)) ? glyphNameText(name) : undefined\n if (/^\\s/.test(exp)) {\n // 공백 글리프는 다음 글자 앞 \" \" 로 나오거나 아무것도 안 남긴다. 아이템 첫머리에서 짝이 없으면 앞 아이템 끝에 그린 글자\n // (ODL 006 쪽번호 \"19\": \"1\" 아이템 뒤 9 가 아무것도 안 남기고 다음 줄 아이템이 이어진다)\n if (s[k] === \" \") { out += named ?? \" \"; k++ }\n else if (named) {\n const prev = k === 0 ? [...done].reverse().find(d => d.out !== \"\") : undefined\n if (prev) prev.out += named\n else out += named\n }\n gi++\n continue\n }\n if (exp === \"\") { out += named ?? \"\"; gi++; continue }\n if (s.startsWith(exp, k)) {\n out += named ?? (name && isSmallCapName(name) ? exp.toUpperCase() : exp)\n k += exp.length; gi++\n continue\n }\n if (s[k] === \" \") { out += \" \"; k++; continue } // pdfjs 가 글자 틈에 넣은 공백\n // 쪽 밖이라 pdfjs 가 버린 글리프 — 조금 앞에서 맞으면 건너뛴다\n let j = gi + 1\n while (j < glyphs.length && j <= gi + 8 && !s.startsWith(normalizeUnicode(glyphs[j].unicode ?? \"\") || \"\\u0000\", k)) j++\n if (j < glyphs.length && j <= gi + 8) { gi = j; continue }\n failed.add(fontName)\n break\n }\n cursor.set(fontName, gi)\n done.push({ item: it, out })\n }\n\n let changed = 0\n for (const [fontName, done] of outs) {\n if (failed.has(fontName)) continue\n for (const { item, out } of done) if (out !== item.str) { item.str = out; changed++ }\n }\n return changed\n}\n","/**\n * 가려진 글 — 먼저 찍은 글 위에 불투명 사각형을 칠하면 그 글은 보이지 않는다. InDesign 계열 보고서는 쪽 머리글·쪽 번호를\n * 찍고 쪽 전체 배경을 덮어 칠한 뒤 본문을 흰 글씨로 찍는다(ODL 079·080 장 첫 쪽).\n *\n * 연산자 목록에서 불투명 사각형 채움마다 그때까지 찍은 글자 수와 채움 영역을 기록하고, 텍스트 아이템을 같은 글자 흐름으로\n * 짚어 채움보다 먼저 찍혔고 영역 안에 드는 아이템을 고른다. 아이템 글과 연산자 글이 그 지점까지 글자 그대로 같을 때만\n * 짚는다(주석 모양·합자로 흐름이 어긋나면 손대지 않는다). pdfjs v4 사각형 경로만 본다.\n */\n\nimport { OPS, normalizeUnicode } from \"pdfjs-dist/legacy/build/pdf.mjs\"\nimport type { PdfTextItem } from \"./text-line.js\"\n\ninterface Rect { x1: number; y1: number; x2: number; y2: number }\n\nconst glyphText = (s: string) => normalizeUnicode(s).replace(/\\s+/g, \"\")\n\nexport function occludedTextItems(items: PdfTextItem[], fnArray: ArrayLike<number>, argsArray: ArrayLike<unknown>): Set<PdfTextItem> {\n const covers: Array<{ at: number; rect: Rect }> = []\n let drawn = \"\"\n let ctm = [1, 0, 0, 1, 0, 0]\n let alpha = 1, normalBlend = true\n let clip: Rect | null | undefined = undefined // undefined = 쪽 전체, null = 사각형 아닌 클립(판단 안 함)\n let path: Rect[] = []\n let pathOk = true\n const stack: Array<{ ctm: number[]; alpha: number; normalBlend: boolean; clip: Rect | null | undefined }> = []\n const box = (x: number, y: number, w: number, h: number): Rect => {\n const xs: number[] = [], ys: number[] = []\n for (const [px, py] of [[x, y], [x + w, y], [x, y + h], [x + w, y + h]]) {\n xs.push(ctm[0] * px + ctm[2] * py + ctm[4]); ys.push(ctm[1] * px + ctm[3] * py + ctm[5])\n }\n return { x1: Math.min(...xs), y1: Math.min(...ys), x2: Math.max(...xs), y2: Math.max(...ys) }\n }\n const meet = (a: Rect, b: Rect): Rect => ({ x1: Math.max(a.x1, b.x1), y1: Math.max(a.y1, b.y1), x2: Math.min(a.x2, b.x2), y2: Math.min(a.y2, b.y2) })\n\n for (let i = 0; i < fnArray.length; i++) {\n const fn = fnArray[i]\n const args = (argsArray as unknown[][])[i]\n if (fn === OPS.save || fn === OPS.paintFormXObjectBegin) {\n stack.push({ ctm: ctm.slice(), alpha, normalBlend, clip })\n const m = fn === OPS.paintFormXObjectBegin ? (args as unknown[])[0] : null\n if (Array.isArray(m) && m.length >= 6) ctm = mul(ctm, m as number[])\n } else if (fn === OPS.restore || fn === OPS.paintFormXObjectEnd) {\n const s = stack.pop()\n if (s) ({ ctm, alpha, normalBlend, clip } = s)\n } else if (fn === OPS.transform) ctm = mul(ctm, args as number[])\n else if (fn === OPS.setGState) {\n const entries = (args as unknown[])[0]\n if (Array.isArray(entries)) for (const e of entries) {\n if (!Array.isArray(e)) continue\n if (e[0] === \"ca\" && typeof e[1] === \"number\") alpha = e[1]\n else if (e[0] === \"BM\") normalBlend = e[1] === \"source-over\" || e[1] === \"Normal\"\n else if (e[0] === \"SMask\" && e[1]) normalBlend = false\n }\n } else if (fn === OPS.showText) {\n for (const g of (args as unknown[])[0] as unknown[]) {\n if (g && typeof g === \"object\" && typeof (g as { unicode?: unknown }).unicode === \"string\") drawn += glyphText((g as { unicode: string }).unicode)\n }\n } else if (fn === OPS.constructPath) {\n const sub = (args as unknown[])[0]\n const coords = (args as unknown[])[1] as number[]\n if (!Array.isArray(sub)) { pathOk = false; continue }\n let ci = 0\n for (const op of sub as number[]) {\n if (op === OPS.rectangle) { path.push(box(coords[ci], coords[ci + 1], coords[ci + 2], coords[ci + 3])); ci += 4 }\n else { pathOk = false; ci += op === OPS.curveTo ? 6 : op === OPS.curveTo2 || op === OPS.curveTo3 ? 4 : op === OPS.closePath ? 0 : 2 }\n }\n } else if (fn === OPS.clip || fn === OPS.eoClip) {\n clip = clip === null || !pathOk || path.length !== 1 ? null : clip ? meet(clip, path[0]) : path[0]\n } else if (fn === OPS.fill || fn === OPS.eoFill || fn === OPS.fillStroke || fn === OPS.eoFillStroke || fn === OPS.endPath ||\n fn === OPS.stroke || fn === OPS.closeStroke || fn === OPS.closeFillStroke || fn === OPS.closeEOFillStroke) {\n const fills = fn !== OPS.endPath && fn !== OPS.stroke && fn !== OPS.closeStroke\n if (fills && pathOk && path.length === 1 && alpha >= 1 && normalBlend && clip !== null && drawn.length > 0) {\n const rect = clip ? meet(path[0], clip) : path[0]\n if (rect.x2 > rect.x1 && rect.y2 > rect.y1) covers.push({ at: drawn.length, rect })\n }\n path = []; pathOk = true\n }\n }\n\n const hidden = new Set<PdfTextItem>()\n if (covers.length === 0) return hidden\n let pos = 0\n for (const it of items) {\n const t = glyphText(it.str ?? \"\")\n const start = pos\n pos += t.length\n if (!t) continue\n // 아이템 글이 연산자 글과 이 지점까지 어긋나면 이후는 짚을 수 없다\n if (drawn.slice(start, pos) !== t) break\n const [a, b, , , e, f] = it.transform\n const size = Math.hypot(a, b) || it.height\n const r: Rect = { x1: e, y1: f, x2: e + it.width, y2: f + size }\n if (covers.some(c => c.at >= pos && r.x1 >= c.rect.x1 - 1 && r.x2 <= c.rect.x2 + 1 && r.y1 >= c.rect.y1 - 1 && r.y2 <= c.rect.y2 + 1)) hidden.add(it)\n }\n return hidden\n}\n\nfunction mul(m: number[], t: number[]): number[] {\n return [\n m[0] * t[0] + m[2] * t[1], m[1] * t[0] + m[3] * t[1],\n m[0] * t[2] + m[2] * t[3], m[1] * t[2] + m[3] * t[3],\n m[0] * t[4] + m[2] * t[5] + m[4], m[1] * t[4] + m[3] * t[5] + m[5],\n ]\n}\n","/**\n * 세로쓰기 글상자 — 한글 글자를 한 자씩 위에서 아래로 쌓은 기둥이 오른쪽에서 왼쪽으로 늘어선 영역(rhwp tbox-v-flow-01).\n * 가로 줄로 읽으면 기둥마다 한 글자씩 섞여 \"오걸그모별나…\"가 된다. 기둥 둘 이상이 윗머리를 맞춰 나란하고 그 영역에 다른\n * 글이 없을 때만 기둥마다 한 줄로 잇고(글자 높이보다 넓게 벌어진 자리는 띄어쓰기), 영역 안에 오른쪽 기둥부터 위→아래로\n * 한 줄씩 놓는다. 표의 세로 머리 칸(\"구⏎분\")은 기둥이 하나라 그대로다.\n */\n\nimport type { NormItem } from \"./text-line.js\"\n\nconst CJK = /^[ㄱ-ㆎ가-힣一-鿿]$/\nconst PUNCT = /^[、。,.,.·ㆍ!?]$/\n\ninterface Column { x: number; items: NormItem[] }\n\nexport function joinVerticalColumns(items: NormItem[]): NormItem[] {\n const singles = items.filter(i => !i.rotated && CJK.test(i.text) && i.fontSize > 0)\n if (singles.length < 8) return items\n // 같은 x(글자 폭의 0.15배 안)의 한 글자들 → 기둥 후보\n const cols: Column[] = []\n for (const it of [...singles].sort((a, b) => a.x - b.x)) {\n const c = cols.find(col => Math.abs(col.x - it.x) <= it.fontSize * 0.15)\n if (c) c.items.push(it)\n else cols.push({ x: it.x, items: [it] })\n }\n // 기둥: 네 글자 이상이 글자 높이 1~2배 간격으로 이어짐\n const columns = cols.filter(c => {\n if (c.items.length < 4) return false\n c.items.sort((a, b) => b.y - a.y)\n const fs = c.items[0].fontSize\n for (let k = 1; k < c.items.length; k++) {\n const step = c.items[k - 1].y - c.items[k].y\n if (step < fs * 0.8 || step > fs * 2) return false\n }\n return true\n }).sort((a, b) => b.x - a.x)\n if (columns.length < 2) return items\n\n // 오른쪽부터 이웃 기둥끼리 묶음: 간격이 글자 폭 1.2~4배(연 사이 빈 기둥 하나), 윗머리가 한 글자 안\n const groups: Column[][] = []\n for (const col of columns) {\n const g = groups[groups.length - 1]\n const last = g?.[g.length - 1]\n const fs = col.items[0].fontSize\n if (last && last.x - col.x >= fs * 1.2 && last.x - col.x <= fs * 4 && Math.abs(last.items[0].y - col.items[0].y) <= fs * 1.2) g.push(col)\n else groups.push([col])\n }\n\n const drop = new Set<NormItem>()\n const add: NormItem[] = []\n for (const g of groups) {\n if (g.length < 2) continue\n const fs = g[0].items[0].fontSize\n // 세로쓰기는 기둥 안 글자 간격이 기둥 사이보다 좁다 — 행마다 \"팀장\"·\"대리\"가 쌓인 표 칸은 행 간격이 글자 간격보다 넓다\n const med = (v: number[]) => v.sort((a, b) => a - b)[v.length >> 1]\n const step = med(g.flatMap(c => c.items.slice(1).map((it, k) => c.items[k].y - it.y)))\n const colGap = med(g.slice(1).map((c, k) => g[k].x - c.x))\n if (step > colGap * 0.8 || step > fs * 1.15) continue\n // 문장이면 기둥 안에 낱말 틈(글자 높이 1.3배 넘는 간격)이 있다 — 지역명·직위를 한 칸에 한 글자씩 쌓은 표 열은 간격이 고르다\n const gapped = g.filter(c => c.items.slice(1).some((it, k) => c.items[k].y - it.y > fs * 1.3)).length\n if (gapped * 2 < g.length) continue\n const x1 = Math.min(...g.map(c => c.x)), x2 = Math.max(...g.map(c => c.x)) + fs\n const top = Math.max(...g.map(c => c.items[0].y)) + fs * 0.2\n const bottom = Math.min(...g.map(c => c.items[c.items.length - 1].y)) - fs * 0.2\n const members = new Set(g.flatMap(c => c.items))\n // 세로 구두점(\"、\")은 기둥 오른쪽으로 비켜 찍힌다 — 가장 가까운 왼쪽 기둥에 넣는다\n const inside = items.filter(i => !members.has(i) && i.x + i.w / 2 >= x1 - 1 && i.x + i.w / 2 <= x2 + 1 && i.y <= top && i.y >= bottom - fs)\n if (inside.some(i => !PUNCT.test(i.text))) continue\n // 기둥 하나가 한 문단이다(시·연 줄) — 새 줄들은 문단 간격으로 벌려 놓되, 그 자리에 다른 글이 있으면 영역 안에 촘촘히\n const spread = fs * 2.2\n const lineH = items.some(i => !members.has(i) && !inside.includes(i) && i.x + i.w > x1 && i.x < x2 && i.y < bottom && i.y >= top - spread * g.length)\n ? (top - bottom) / g.length : spread\n for (const p of inside) {\n const col = [...g].filter(c => c.x <= p.x).sort((a, b) => b.x - a.x)[0]\n if (col) { col.items.push(p); members.add(p) }\n }\n g.forEach((col, k) => {\n col.items.sort((a, b) => b.y - a.y)\n let text = \"\"\n for (let n = 0; n < col.items.length; n++) {\n const it = col.items[n]\n if (n > 0 && col.items[n - 1].y - it.y > fs * 1.3 && !PUNCT.test(it.text)) text += \" \"\n text += it.text\n }\n // 폭은 글자 수의 반 — 글상자 틀(1칸 표) 안에서 칸 폭을 채우고 꺾인 줄로 읽혀 다음 기둥에 이어 붙지 않게\n add.push({ ...col.items[0], text, x: x1, y: top - fs * 0.2 - k * lineH, w: [...text].length * fs * 0.5, h: fs })\n })\n for (const m of members) drop.add(m)\n }\n return drop.size ? [...items.filter(i => !drop.has(i)), ...add] : items\n}\n","/**\n * 자간 벌린 글 — 글자 간격(Tc)을 넓힌 줄은 pdfjs 가 글자마다 공백을 넣어 \"E M A I L R E B E C C A\" 가 되고 진짜 낱말 사이 공백과\n * 구별되지 않는다(ODL 103 \"EMAIL REBECCA.ALLEN@MSJ.EDU FOR MORE INFORMATION\"). 연산자 목록의 글리프 흐름에는 진짜 공백 글리프만\n * 있으니, 한 글자·공백이 번갈아 나오는 아이템을 같은 글꼴의 글리프 흐름으로 다시 짠다. 흐름이 어긋나는 글꼴은 손대지 않는다.\n */\n\nimport { OPS, normalizeUnicode } from \"pdfjs-dist/legacy/build/pdf.mjs\"\nimport type { PdfTextItem } from \"./text-line.js\"\n\n// 한글 균등배분(\"홍 보 지 원 반\")은 normalizeItems·셀 글이 따로 다룬다 — 라틴·숫자·기호만\nconst TRACKED = /^(?:[^\\s\\u1100-\\u11ff\\u3130-\\u318f\\uac00-\\ud7a3] ){3,}[^\\s\\u1100-\\u11ff\\u3130-\\u318f\\uac00-\\ud7a3]$/\n\nexport function restoreTrackedSpacing(items: PdfTextItem[], fnArray: ArrayLike<number>, argsArray: ArrayLike<unknown>): number {\n if (!items.some(it => TRACKED.test(it.str ?? \"\"))) return 0\n // 글꼴별 글리프 글 흐름 (공백 글리프 포함)\n const streams = new Map<string, string[]>()\n const saved: string[] = []\n let font = \"\"\n for (let i = 0; i < fnArray.length; i++) {\n const fn = fnArray[i]\n const args = (argsArray as unknown[][])[i]\n if (fn === OPS.setFont) font = String(args[0])\n else if (fn === OPS.save || fn === OPS.paintFormXObjectBegin) saved.push(font)\n else if (fn === OPS.restore || fn === OPS.paintFormXObjectEnd) font = saved.pop() ?? font\n else if (fn === OPS.showText) {\n let list = streams.get(font)\n if (!list) streams.set(font, list = [])\n for (const g of args[0] as unknown[]) {\n if (g && typeof g === \"object\" && typeof (g as { unicode?: unknown }).unicode === \"string\") list.push(normalizeUnicode((g as { unicode: string }).unicode))\n }\n }\n }\n const cursor = new Map<string, number>()\n const failed = new Set<string>()\n let changed = 0\n for (const it of items) {\n const f = it.fontName ?? \"\"\n const glyphs = streams.get(f)\n if (!glyphs || failed.has(f) || typeof it.str !== \"string\") continue\n const want = it.str.replace(/\\s+/g, \"\")\n if (!want) continue\n let gi = cursor.get(f) ?? 0\n // 아이템 첫 글자에 맞는 글리프까지 공백은 건너뛴다\n while (gi < glyphs.length && /^\\s*$/.test(glyphs[gi])) gi++\n let got = \"\", text = \"\"\n while (gi < glyphs.length && got.length < want.length) {\n const g = glyphs[gi++]\n text += g\n got += g.replace(/\\s+/g, \"\")\n }\n if (got !== want) { failed.add(f); continue }\n cursor.set(f, gi)\n if (TRACKED.test(it.str)) {\n const rebuilt = text.replace(/\\s+/g, \" \").trim()\n if (rebuilt.split(\" \").length < it.str.split(\" \").length) { it.str = rebuilt; changed++ }\n }\n }\n return changed\n}\n\n/**\n * pdfjs 가 글자 틈(TJ 자간 이동·위치 이동)을 보고 만들어 넣은 공백 아이템에 synthetic 표시 — 글리프 흐름에 공백 글리프가 없는 자리다.\n * 균등배분 판정은 \"명시적 공백 글리프\"에서만 run 을 끊어야 하는데(Type3 한 글자씩 배치 + 공백 글리프, \"아침 브리핑\"), 합성 공백까지\n * 경계로 보면 배분 정렬 칸 \"보 [-777.8] 험 [-777.8] 업\" 이 틈마다 끊겨 \"보 험 업\" 으로 남았다(해외직접투자 보도자료).\n * 공백만 든 아이템을 제 글꼴 흐름의 커서 자리 글리프와 맞춘다: 공백 글리프면 진짜, 아니면 합성. 흐름이 어긋나는 글꼴은 손대지 않는다.\n */\nexport function markSyntheticSpaces(items: PdfTextItem[], fnArray: ArrayLike<number>, argsArray: ArrayLike<unknown>): void {\n if (!items.some(it => typeof it.str === \"string\" && it.str.length > 0 && !it.str.trim())) return\n const streams = new Map<string, string[]>()\n const saved: string[] = []\n let font = \"\"\n for (let i = 0; i < fnArray.length; i++) {\n const fn = fnArray[i]\n const args = (argsArray as unknown[][])[i]\n if (fn === OPS.setFont) font = String(args[0])\n else if (fn === OPS.save || fn === OPS.paintFormXObjectBegin) saved.push(font)\n else if (fn === OPS.restore || fn === OPS.paintFormXObjectEnd) font = saved.pop() ?? font\n else if (fn === OPS.showText) {\n let list = streams.get(font)\n if (!list) streams.set(font, list = [])\n for (const g of args[0] as unknown[]) {\n if (g && typeof g === \"object\" && typeof (g as { unicode?: unknown }).unicode === \"string\") list.push(normalizeUnicode((g as { unicode: string }).unicode))\n }\n }\n }\n const cursor = new Map<string, number>()\n const failed = new Set<string>()\n const marks: PdfTextItem[] = []\n for (const it of items) {\n const f = it.fontName ?? \"\"\n const glyphs = streams.get(f)\n if (!glyphs || failed.has(f) || typeof it.str !== \"string\" || !it.str) continue\n let gi = cursor.get(f) ?? 0\n if (!it.str.trim()) {\n if (gi < glyphs.length && /^\\s+$/.test(glyphs[gi])) {\n while (gi < glyphs.length && /^\\s+$/.test(glyphs[gi])) gi++\n cursor.set(f, gi)\n } else marks.push(it)\n continue\n }\n const want = it.str.replace(/\\s+/g, \"\")\n while (gi < glyphs.length && /^\\s*$/.test(glyphs[gi])) gi++\n let got = \"\"\n while (gi < glyphs.length && got.length < want.length) got += glyphs[gi++].replace(/\\s+/g, \"\")\n if (got !== want) { failed.add(f); continue }\n cursor.set(f, gi)\n }\n for (const it of marks) if (!failed.has(it.fontName ?? \"\")) (it as PdfTextItem & { synthetic?: boolean }).synthetic = true\n}\n\n/**\n * 줄 끝 공백 글리프 — 아이템 글 뒤 글리프가 공백 글리프이고 다음 글 아이템이 다른 줄이면 spaceAfter 표시. 한컴 PDF 는 어절마다 뒤\n * 공백 글리프까지 찍어(\"원활한␣\") 어절 경계에서 꺾인 줄 끝에도 공백 글리프가 남고, 어절 가운데서 꺾인 줄 끝(\"업무⏎수행에\")에는 없다.\n * pdfjs 는 줄 끝 공백을 아이템 글에서 지워 이 차이가 글에 안 남는다. hwpx↔pdf 744쌍 줄 잇기 판정 4.4만 곳 실측: 표시된 꺾임 1.2만 곳 중\n * 원문이 붙여 쓴 곳 0. 공백 글리프는 다른 글꼴로 찍히기도 해 글꼴을 가리지 않고 연산 순서 흐름으로 맞춘다 — 글리프 이름 복원 따위가\n * 아이템 글을 바꾸기 전에 불러야 흐름과 글자 그대로 맞고, 어긋나면 그 쪽의 나머지는 손대지 않는다.\n * 줄 끝 공백 글리프를 찍는 쪽(줄 끝 EMIT_MIN_LINES 곳 이상 가운데 EMIT_MIN_RATIO 넘게)이면 공백 없는 줄 끝에 spaceAfter=false 도 단다 —\n * 찍지 않는 제작기(rhwp 렌더·일부 워드 PDF)에서는 공백 없음이 정보가 아니다\n */\nexport function markTrailingSpaceGlyphs(items: PdfTextItem[], fnArray: ArrayLike<number>, argsArray: ArrayLike<unknown>): void {\n const glyphs: string[] = []\n for (let i = 0; i < fnArray.length; i++) {\n if (fnArray[i] !== OPS.showText) continue\n for (const g of (argsArray as unknown[][])[i][0] as unknown[]) {\n if (g && typeof g === \"object\" && typeof (g as { unicode?: unknown }).unicode === \"string\") glyphs.push(normalizeUnicode((g as { unicode: string }).unicode))\n }\n }\n const text = items.filter(it => typeof it.str === \"string\" && it.str.trim())\n const ends: Array<[PdfTextItem, boolean]> = []\n let gi = 0\n for (let k = 0; k < text.length; k++) {\n const it = text[k]\n const want = it.str.replace(/\\s+/g, \"\")\n while (gi < glyphs.length && /^\\s*$/.test(glyphs[gi])) gi++\n let got = \"\"\n while (gi < glyphs.length && got.length < want.length) got += glyphs[gi++].replace(/\\s+/g, \"\")\n if (got !== want) break\n let next = gi\n while (next < glyphs.length && glyphs[next] === \"\") next++\n const after = text[k + 1]\n if (after && Math.abs(after.transform[5] - it.transform[5]) < 3) continue\n ends.push([it, next < glyphs.length && /^\\s+$/.test(glyphs[next])])\n }\n const spaced = ends.filter(([, s]) => s).length\n const emits = ends.length >= EMIT_MIN_LINES && spaced >= ends.length * EMIT_MIN_RATIO\n for (const [it, s] of ends) if (s || emits) (it as PdfTextItem & { spaceAfter?: boolean }).spaceAfter = s\n}\n/** 줄 끝 공백 글리프를 찍는 쪽 판정 — 줄 끝 수 하한과 공백 글리프 비율 (hwpx↔pdf 744쌍 문서별 비율: 찍는 제작기 0.2 이상) */\nconst EMIT_MIN_LINES = 5\nconst EMIT_MIN_RATIO = 0.2\n","/**\n * 미주를 참조 자리로 — 한컴 미주는 본문에 참조 표시(\"문1)\")를 찍고 문서 끝에 같은 표시로 시작하는 미주 본문을 모아 찍는다.\n * HWPX·HWP5 파서는 미주를 참조 자리에 끼워 내므로(notes.ts) PDF 만 해설이 문서 끝에 몰려 순서가 어긋났다(수능 모의고사 해설).\n * 문서 뒤쪽의 미주 머리 블록마다 같은 표시가 앞쪽 본문(표 칸 글 포함)에 딱 한 번 나오고, 그 짝이 셋 이상 본문 순서대로 이어질 때만\n * 미주 묶음(머리 블록부터 다음 머리 전까지)을 참조 블록 바로 뒤로 옮긴다. 목차의 번호·표 번호는 참조에서 빼고,\n * 블록은 합치지 않고 옮기기만 한다(미주 안 표·수식 구조 유지).\n */\n\nimport type { IRBlock } from \"../types.js\"\n\n// 한컴 미주 번호 문1)와 숫자 주석 번호 1)·(1)만 후보로 본다.\n// 표1) 같은 표 번호나 수식 f(1)은 주석 머리가 아니다.\nconst NOTE_MARK = /^\\s*(문\\d{1,5}[))]|\\d{1,5}\\)|\\(\\d{1,5}\\))/\n\nfunction isContentsReference(text: string, at: number): boolean {\n // 목차가 한 표 블록에 통째로 담기기도 한다. 그 표 안의 번호는 본문 참조가 아니다.\n if (/(?:^|\\n)\\s*\\|?\\s*(?:목\\s*차|(?:table of )?contents\\b)/i.test(text.slice(0, 120))) return true\n const lineStart = text.lastIndexOf(\"\\n\", at - 1) + 1\n const lineEnd = text.indexOf(\"\\n\", at)\n const line = text.slice(lineStart, lineEnd < 0 ? text.length : lineEnd)\n return /[·⋯.…]{5,}/.test(line)\n}\n\nfunction blockText(b: IRBlock): string {\n if (b.table) return b.table.cells.flat().map(c => c.text).join(\"\\n\")\n return b.text ?? \"\"\n}\n\nexport function relocateEndnotes(blocks: IRBlock[]): IRBlock[] {\n const texts = blocks.map(blockText)\n /** end 앞 블록에서 mark 가 나오는 블록 번호(한 블록에 두 번이면 두 번) — cap 개까지만 */\n const occurrences = (mark: string, end: number, cap: number): number[] => {\n const out: number[] = []\n for (let j = 0; j < end && out.length < cap; j++) {\n for (let at = texts[j].indexOf(mark); at >= 0 && out.length < cap; at = texts[j].indexOf(mark, at + mark.length)) {\n // 목차 줄·문단 첫머리 번호(숫자 표시)도 등장으로 센다 — 유일성 보호는 그대로 두고, 유일한 등장이 이런 자리면 참조가 아니다(-1)\n const notRef = isContentsReference(texts[j], at) || (!mark.startsWith(\"문\") && !texts[j].slice(0, at).trim())\n out.push(notRef ? -1 : j)\n }\n }\n return out\n }\n const cands: Array<{ idx: number; mark: string; shape: string }> = []\n for (let i = 0; i < blocks.length; i++) {\n const b = blocks[i]\n if (b.type !== \"paragraph\" && b.type !== \"list\" && b.type !== \"heading\") continue\n const m = NOTE_MARK.exec(texts[i])\n if (m) cands.push({ idx: i, mark: m[1], shape: m[1].replace(/\\d+/g, \"#\") })\n }\n const heads: Array<{ idx: number; ref: number; shape: string }> = []\n for (const c of cands) {\n const refs = occurrences(c.mark, c.idx, 2)\n if (refs.length === 1 && refs[0] >= 0) heads.push({ idx: c.idx, ref: refs[0], shape: c.shape })\n }\n // 미주 머리는 한 가지 번호 꼴(\"문#)\")이다 — 미주 안 소항목(\"ⅰ)\"·\"(나)\")이 앞 미주 글과 짝지어져 구간을 끊지 않게 가장 흔한 꼴만\n const shapes = new Map<string, number>()\n for (const h of heads) shapes.set(h.shape, (shapes.get(h.shape) ?? 0) + 1)\n const main = [...shapes].sort((a, b) => b[1] - a[1])[0]?.[0]\n if (!main || !/#/.test(main)) return blocks\n // 문서 끝 쪽으로 이어진 짝 — 참조가 본문 순서대로 늘어나는 마지막 연속 구간\n const lastRun = (hs: typeof heads): typeof heads => {\n let run: typeof heads = []\n for (const h of hs) {\n if (run.length && (h.ref < run[run.length - 1].ref || h.ref >= run[0].idx)) run = []\n run.push(h)\n }\n return run\n }\n const firstRun = lastRun(heads.filter(h => h.shape === main))\n if (firstRun.length < 3) return blocks\n // 선택과목마다 번호가 다시 시작하는 미주(\"문23)\" 이 앞쪽 과목마다 한 번씩) — 미주 구간 앞쪽에 k번 나오는 표시는\n // 구간 안 같은 표시 머리 k개와 차례로 짝짓는다. 개수가 다르면 그 표시는 머리로 보지 않는다\n const tailByMark = new Map<string, number[]>()\n for (const c of cands) if (c.idx >= firstRun[0].idx && c.shape === main) tailByMark.set(c.mark, [...(tailByMark.get(c.mark) ?? []), c.idx])\n const paired: typeof heads = []\n for (const [mark, idxs] of tailByMark) {\n const refs = occurrences(mark, firstRun[0].idx, idxs.length + 1)\n if (refs.length === idxs.length && refs.every(r => r >= 0)) idxs.forEach((idx, k) => paired.push({ idx, ref: refs[k], shape: main }))\n }\n const run = lastRun(paired.sort((a, b) => a.idx - b.idx))\n if (run.length < 3) return blocks\n // 미주 묶음은 번호가 1부터 거의 빈틈없이 이어진다(선택과목마다 앞 번호로 되돌아가는 것 허용, 머리가 본문 줄에 섞여 빠진 몇 개 허용)\n // — 절마다 다시 시작하는 목록의 뒤 토막(\"(7)(8)(9)\", RFP)·마지막 쪽 아래 각주(\"5)~9)\")는 1에서 시작하지 않는다\n const nums = run.map(h => Number(/\\d+/.exec(texts[h.idx])![0]))\n if (nums[0] !== 1 || new Set(nums).size < Math.max(...nums) * 0.8) return blocks\n const start = run[0].idx\n // 구간 안에 짝 없는 머리가 있으면(다른 번호 체계) 손대지 않는다 — 앞 미주에 이어 붙은 본문 문단은 괜찮다\n const groups = run.map((h, k) => ({ ref: h.ref, blocks: blocks.slice(h.idx, k + 1 < run.length ? run[k + 1].idx : blocks.length) }))\n const byRef = new Map<number, IRBlock[]>()\n for (const g of groups) byRef.set(g.ref, [...(byRef.get(g.ref) ?? []), ...g.blocks])\n const out: IRBlock[] = []\n for (let i = 0; i < start; i++) {\n out.push(blocks[i])\n const notes = byRef.get(i)\n if (notes) out.push(...notes)\n }\n return out\n}\n","/**\n * 탭 채움 리더 — 목차 탭의 점선 채움은 가운뎃점으로 찍힌다. 제작기·판에 따라 점마다 따로(SO-SUEOP \"I.소설의 이해 ······ 3\", 점마다\n * 글자 위치 이동), 두세 점씩 조각으로(pcccr 첨부), 한 글자열로(행정업무운영 편람 목차 \"········\") 나온다. 채움선은 글이 아니다 —\n * HWPX·HWP5 파서도 탭 채움을 내지 않는다. 채움은 쪽 번호 앞에 선다: 같은 줄에서 가운뎃점만 든 조각들이 글자 크기보다 좁은 간격으로\n * 이어져 점이 넷 이상이고 오른쪽에 쪽 번호가 따라오면 탭 하나로 바꾼다(자리는 남긴다 — 지우면 쪽 번호가 앞 글에 붙는다, 균등배분 붙임\n * \"1 총 칙 1\" → \"총칙1\"). 쪽 번호 없는 점 줄은 글로 친 구분선이다(DOCX 서식 \"······\" — 원본 글). 마침표 리더(Word·LibreOffice 목차\n * \"....\")는 ODL 정답이 글로 담아 그대로 둔다.\n * 채움 끝 점이 쪽 번호와 한 조각(\"·1\")이거나 첫 점이 제목 조각 끝에 붙은 것(\"…결과(요약) ·\"), 쪽 번호 대신 괄호 쪽 표기(\"(페이지 표기)\",\n * 양식 견본 목차)가 따라오는 것도 같은 채움이다(벼·고추 재배면적조사 목차, 산업기술 과제 계획서 양식)\n */\n\nimport type { NormItem } from \"./text-line.js\"\n\nconst MIN_DOTS = 4\n/** 글 속 채움 — 가운뎃점 넷 이상 뒤 쪽 번호 */\nconst INLINE = /\\s*·{4,}\\s*(?=\\d{1,4}\\s*$)/\nconst PAGE_NO = /^\\d{1,4}$/\nconst DOTS = /^·+$/\n/** 채움 끝 점과 쪽 번호가 한 조각 */\nconst DOTS_PAGE = /^·+(\\d{1,4})$/\n/** 쪽 번호 자리의 괄호 쪽 표기 */\nconst PAREN_PAGE = /^\\(\\s*(?:페이지|쪽)/\n/** 제목 조각 끝에 붙은 채움 — 쪽 번호는 다음 조각 (\"7. 환 경 녹 지 국 ·······\" | \"3\", 여수 업무계획 목차) */\nconst TRAILING = /([^\\s·])\\s*·{4,}\\s*$/\n\nexport function dropTabLeaderDots(items: NormItem[]): NormItem[] {\n if (!items.some(it => it.text.includes(\"·\"))) return items\n const sameLine = (a: NormItem, b: NormItem) => Math.abs(a.y - b.y) <= Math.max(a.fontSize, b.fontSize) * 0.3\n // 채움 오른쪽 같은 줄에 쪽 번호 아이템이 있는가 — 바로 뒤 글이 괄호 쪽 표기여도(점 조각 폭이 점 간격보다 넓어 뒤 글이 마지막 점 오른끝 앞에서\n // 시작하기도 해, 괄호는 마지막 점 시작보다 오른쪽으로 본다)\n const pageNoAfter = (right: number, ref: NormItem) => items.some(it => PAGE_NO.test(it.text.trim()) && sameLine(it, ref) && it.x >= right - 2) ||\n PAREN_PAGE.test(items.filter(it => sameLine(it, ref) && it.x > ref.x && !DOTS.test(it.text.trim())).sort((a, b) => a.x - b.x)\n .slice(0, 2).map(it => it.text.trim()).join(\"\"))\n const drop = new Set<NormItem>()\n const edit = new Map<NormItem, { text: string; right?: number }>()\n for (const it of items) if (!DOTS.test(it.text.trim()) && it.text.includes(\"····\") && INLINE.test(it.text)) edit.set(it, { text: it.text.replace(INLINE, \"\\t\") })\n // 제목 끝 채움은 탭으로 바꾸고 오른끝을 쪽 번호 앞까지 늘린다 — 채움 폭만큼 남은 틈이 공백이 되면 \"제목\\t 3\"\n for (const it of items) {\n if (edit.has(it) || !it.text.includes(\"····\") || !TRAILING.test(it.text)) continue\n const page = items.find(p => PAGE_NO.test(p.text.trim()) && sameLine(p, it) && p.x >= it.x + it.w - 2)\n if (page) edit.set(it, { text: it.text.replace(TRAILING, \"$1\\t\"), right: page.x })\n }\n // 가운뎃점만 든 조각 — 같은 기준선에서 이웃 조각 사이(앞 조각 끝 → 뒤 조각 시작)가 글자 크기의 0.7배 이하면 한 채움.\n // 좌표가 정수로 반올림돼(normalizeItems) 점 하나짜리 조각은 겹치거나 2~5pt 로 흔들린다\n const byLine = new Map<number, NormItem[]>()\n for (const d of items) if (DOTS.test(d.text.trim()) || DOTS_PAGE.test(d.text.trim())) byLine.set(Math.round(d.y * 2), [...(byLine.get(Math.round(d.y * 2)) ?? []), d])\n for (const line of byLine.values()) {\n line.sort((a, b) => a.x - b.x)\n let run: NormItem[] = []\n const flush = () => {\n const last = run[run.length - 1]\n const tailPage = DOTS_PAGE.exec(last.text.trim())?.[1]\n const dots = run.reduce((n, d) => n + d.text.trim().replace(/\\d+$/, \"\").length, 0)\n if (dots >= MIN_DOTS && (tailPage ? run.length > 1 : pageNoAfter(last.x + last.w, last))) {\n const body = tailPage ? run.slice(0, -1) : run\n edit.set(run[0], { text: \"\\t\", right: tailPage ? last.x : last.x + last.w })\n for (const d of body.slice(1)) drop.add(d)\n if (tailPage) edit.set(last, { text: tailPage })\n // 첫 점이 붙은 제목 조각(\"…결과(요약) ·\")은 그 점을 뗀다 — 조각 폭이 뒤 공백까지 잡혀 채움과 겹치기도 한다(\"1. 기 획 실 ·\" 오른끝이 첫 점보다 11pt 뒤)\n const head = items.find(it => it !== run[0] && sameLine(it, run[0]) && /[^\\s·]\\s*·+$/.test(it.text) &&\n it.x < run[0].x && run[0].x - (it.x + it.w) <= run[0].fontSize * 0.7)\n if (head && !edit.has(head)) edit.set(head, { text: head.text.replace(/\\s*·+$/, \"\") })\n }\n run = []\n }\n for (const d of line) {\n const prev = run[run.length - 1]\n if (prev && (DOTS_PAGE.test(prev.text.trim()) || !(d.x > prev.x && d.x - (prev.x + prev.w) <= Math.max(prev.fontSize, d.fontSize) * 0.7))) flush()\n run.push(d)\n }\n if (run.length) flush()\n }\n if (!drop.size && !edit.size) return items\n return items.filter(it => !drop.has(it)).map(it => {\n const e = edit.get(it)\n return e ? { ...it, text: e.text, w: (e.right ?? it.x + it.w) - it.x } : it\n })\n}\n","/**\n * 가로 쪽 두 쪽 모아찍기(2-up) — 서식 두 쪽을 가로 한 면에 나란히 찍은 쪽(행정업무운영 편람 부록 별지 서식)은 왼쪽 쪽을 다 읽고\n * 오른쪽 쪽을 읽는다. 블록은 쪽 위→아래로 서 있어, 오른쪽 서식 표의 윗변이 왼쪽 서식 제목보다 높으면 오른쪽 표가 먼저 나왔다.\n * 가로 쪽이고, 가운데 띠를 가로지르는 블록이 없고(쪽 번호 꼴 제외), 좌우 반쪽 모두 쪽 높이 절반 넘게 내용이 찰 때만 좌 → 우로 다시 선다\n */\n\nimport type { IRBlock } from \"../types.js\"\n\nexport function orderTwoUpPage(blocks: IRBlock[], pageWidth: number, pageHeight: number): IRBlock[] {\n if (pageWidth < pageHeight * 1.2 || blocks.length < 2) return blocks\n const mid = pageWidth / 2, tol = pageWidth * 0.01\n const left: IRBlock[] = [], right: IRBlock[] = [], rest: IRBlock[] = []\n for (const b of blocks) {\n if (!b.bbox) return blocks\n if (b.type === \"paragraph\" && /^[-–—-\\s\\d]+$/.test(b.text ?? \"\")) rest.push(b)\n else if (b.bbox.x + b.bbox.width <= mid + tol) left.push(b)\n else if (b.bbox.x >= mid - tol) right.push(b)\n else return blocks\n }\n const span = (bs: IRBlock[]) => Math.max(...bs.map(b => b.bbox!.y + b.bbox!.height)) - Math.min(...bs.map(b => b.bbox!.y))\n if (!left.length || !right.length || span(left) < pageHeight * 0.5 || span(right) < pageHeight * 0.5) return blocks\n return [...left, ...right, ...rest]\n}\n","/**\n * 각주를 참조 문단으로 — HWPX·HWP5 파서는 각주를 참조가 있는 문단 끝에 \" (주: 6) …)\" 로 끼운다(notes.ts, footnoteText).\n * PDF 는 각주를 쪽 아래에 따로 찍어 본문 순서·어절이 어긋났다(선박 코드 부속서 91개). 쪽 아래 문단이 본문 위첨자 참조 표시와\n * 같은 \"N)\" 로 시작하면 그 문단(과 표시 없이 이어지는 문단)을 참조 문단의 footnoteText 로 옮긴다. 한 문단의 각주 여럿은 \"; \" 로 잇는다.\n * 위첨자 표시 = 앞 글자에 붙어(간격 1.5pt 이하) 작고(0.85배 이하) 기준선이 올라간 \"N)\" 아이템 — 목록 번호 \"1)\" 과 가른다.\n * 각주 띠는 바로 위(20pt 안)에 각주 구분선(왼쪽 여백부터 긋는 짧은 가로선, 한컴·워드 기본)이 있어야 한다 — 쪽 아래에 글로 친\n * 가짜 각주(KS 표준안 \"8) Xerox®는 …\" 문단)는 원본에서도 본문 문단이다. 이어짐 문단은 앞 문단에 줄 높이 1.5배 안으로 붙고\n * 쪽 번호 꼴(\"- 3 -\")이 아닌 것만\n */\n\nimport type { IRBlock } from \"../types.js\"\nimport type { NormItem } from \"./text-line.js\"\n\nconst MARK = /^\\d{1,3}\\)$/\n\nexport interface NoteMark { mark: string; y: number }\n/** 쪽마다 위첨자 참조 표시와 각주 구분선 y */\nexport interface PageNotes { marks: NoteMark[]; seps: number[] }\n\n/** 각주 구분선 후보 — 쪽 아래 60% 안 짧은 가로선(폭 30pt 이상·쪽 폭 45% 이하) */\nexport function footnoteSeparators(horizontals: Array<{ x1: number; x2: number; y1: number }>, pageWidth: number, pageHeight: number): number[] {\n return horizontals.filter(l => l.x2 - l.x1 >= 30 && l.x2 - l.x1 <= pageWidth * 0.45 && l.y1 < pageHeight * 0.6).map(l => l.y1)\n}\n\nexport function superscriptNoteMarks(items: NormItem[]): NoteMark[] {\n const out: NoteMark[] = []\n for (const m of items) {\n if (!MARK.test(m.text)) continue\n const glued = items.some(p => p !== m && Math.abs(p.x + p.w - m.x) <= 1.5 && m.fontSize <= p.fontSize * 0.85 &&\n m.y - p.y >= p.fontSize * 0.1 && m.y - p.y <= p.fontSize * 0.6 && /[\\p{L}\\p{N}.,)'\"’”」』]$/u.test(p.text))\n if (glued && !out.some(o => o.mark === m.text)) out.push({ mark: m.text, y: m.y })\n }\n return out\n}\n\n/** 글 속 참조 표시 자리 — 앞 글자가 공백이 아닌 곳 */\nfunction refAt(text: string, mark: string): boolean {\n for (let at = text.indexOf(mark); at >= 0; at = text.indexOf(mark, at + 1)) if (at > 0 && !/\\s/.test(text[at - 1])) return true\n return false\n}\n\nexport function inlineFootnotes(blocks: IRBlock[], pages: Map<number, PageNotes>): IRBlock[] {\n if (!pages.size) return blocks\n const byPage = new Map<number, IRBlock[]>()\n for (const b of blocks) if (b.pageNumber !== undefined) byPage.set(b.pageNumber, [...(byPage.get(b.pageNumber) ?? []), b])\n const drop = new Set<IRBlock>()\n const isText = (b: IRBlock) => (b.type === \"paragraph\" || b.type === \"list\") && !!b.text\n const top = (b: IRBlock) => b.bbox!.y + b.bbox!.height\n for (const [page, { marks, seps }] of pages) {\n const pb = byPage.get(page)\n if (!pb || !seps.length) continue\n // 표시로 시작하고 참조 표시보다 아래, 구분선 아래에 놓인 문단\n const markOf = (b: IRBlock) => b.bbox ? marks.find(m => {\n const t = b.text!.trimStart()\n return t.startsWith(m.mark) && /^\\s/.test(t.slice(m.mark.length)) && top(b) < m.y && seps.some(y => y >= top(b))\n }) : undefined\n let start = -1\n for (let j = pb.length - 1; j >= 0 && isText(pb[j]); j--) if (markOf(pb[j])) start = j\n if (start < 0) continue\n // 첫 각주 바로 위(20pt 안)에 구분선\n if (!seps.some(y => y >= top(pb[start]) && y - top(pb[start]) <= 20)) continue\n const notes: Array<{ text: string; blocks: IRBlock[]; mark: string }> = []\n let open = true\n for (let j = start; j < pb.length; j++) {\n const b = pb[j], m = markOf(b)\n if (m) { notes.push({ text: b.text!.trim(), blocks: [b], mark: m.mark }); open = true; continue }\n const prev = pb[j - 1], line = (b.style?.fontSize ?? b.bbox?.height ?? 10) * 1.5\n if (open && b.bbox && prev.bbox && prev.bbox.y - top(b) <= line && !/^[-–—\\s\\d]+$/.test(b.text!)) {\n notes[notes.length - 1].text += \" \" + b.text!.trim()\n notes[notes.length - 1].blocks.push(b)\n } else open = false\n }\n const body = pb.slice(0, start)\n for (const n of notes) {\n const host = body.find(b => (b.type === \"paragraph\" || b.type === \"list\" || b.type === \"heading\") && !!b.text && refAt(b.text, n.mark))\n if (!host) continue\n host.footnoteText = host.footnoteText ? `${host.footnoteText}; ${n.text}` : n.text\n for (const b of n.blocks) drop.add(b)\n }\n }\n return drop.size ? blocks.filter(b => !drop.has(b)) : blocks\n}\n","/**\n * PDF 헤딩 강등 — 승격 패스들이 끝난 뒤, 제목이 될 수 없는 역할의 줄을 본문으로 되돌린다.\n *\n * 승격 패스는 글꼴 크기·서체 차이만 보므로 쪽 머리말·꼬리말, 캡션, 번호 붙은 수식 줄,\n * 문장 중간에서 끊긴 줄도 올린다. 여기서는 문자열 정답이 아니라 역할 증거(쪽 가장자리 띠와\n * 쪽번호, 캡션 표지, 수식 번호·관계 기호, 소문자로 시작하는 이어진 문장)로만 판단한다.\n */\n\nimport type { IRBlock } from \"../types.js\"\nimport { TOC_BLOCKS } from \"./table-roles.js\"\n\nconst PAGE_NUMBER = /^(?:\\d{1,4}|[ivxlc]{1,7})$/i\nconst CAPTION = /^(?:Table|Figure|Fig\\.?)\\s*\\d+(?:\\.\\d+)*\\s*[.:]/i\nconst EQUATION_NUMBER = /\\t\\(\\d{1,3}[a-z]?\\)\\s*$/\n/** 별행 수식 — 관계 기호·근호·큰 연산자가 든 줄은 절 제목이 아니다 (#89 \"MultiHead(Q, K, V) = Concat(…)\") */\nconst DISPLAY_MATH = /=|[√∑∏∫∂∇≤≥≈≠∈∀∃]/\n\n/** A running head sits in the outer band of the page with nothing beyond it and\n * spreads its parts to the page edges (tab-separated), usually with a page number. */\nfunction isRunningHead(block: IRBlock, page: IRBlock[], pageHeight: number | undefined): boolean {\n const box = block.bbox, text = block.text?.trim()\n if (!box || !text || !pageHeight) return false\n const top = box.y + box.height >= pageHeight * 0.9\n const bottom = box.y <= pageHeight * 0.1\n if (!top && !bottom) return false\n const others = page.filter(o => o !== block && o.bbox && o.type !== \"image\" && o.type !== \"separator\")\n if (top && others.some(o => o.bbox!.y + o.bbox!.height > box.y + box.height)) return false\n if (bottom && others.some(o => o.bbox!.y < box.y)) return false\n // A title always has content after it: a single line closing the page's bottom band is a footer.\n if (bottom && box.height <= (block.style?.fontSize ?? 0) * 1.6) return true\n if (!text.includes(\"\\t\")) return false\n const parts = text.split(/\\t+/).map(part => part.trim()).filter(Boolean)\n if (parts.length >= 2 && (PAGE_NUMBER.test(parts[0]) || PAGE_NUMBER.test(parts[parts.length - 1]))) return true\n const left = Math.min(...page.filter(o => o.bbox).map(o => o.bbox!.x))\n const right = Math.max(...page.filter(o => o.bbox).map(o => o.bbox!.x + o.bbox!.width))\n return right > left && box.width >= (right - left) * 0.6\n}\n\nfunction unbalancedClose(text: string): boolean {\n return (text.match(/\\)/g)?.length ?? 0) > (text.match(/\\(/g)?.length ?? 0)\n}\n\nexport function demoteNonHeadingRoles(blocks: IRBlock[], pageHeights: Map<number, number>, faceNames?: Map<string, string>): void {\n const byPage = new Map<number, IRBlock[]>()\n for (const block of blocks) {\n const page = byPage.get(block.pageNumber ?? 0) ?? []\n page.push(block)\n byPage.set(block.pageNumber ?? 0, page)\n }\n // The page's prose style (face + size carrying most paragraph text): a sentence-long\n // block set in it has no typographic distinction left to make it a title.\n const bodyStyle = new Map<number, string>()\n const bodySizes = new Map<number, number>()\n for (const [pageNumber, page] of byPage) {\n const chars = new Map<string, number>()\n const sizes = new Map<number, number>()\n let proseChars = 0\n for (const b of page) {\n if ((b.type !== \"paragraph\" && b.type !== \"heading\") || !b.text || !b.style?.fontName || !b.style.fontSize) continue\n const key = `${b.style.fontName}:${b.style.fontSize}`\n chars.set(key, (chars.get(key) ?? 0) + b.text.length)\n if (b.type === \"paragraph\" && b.style.fontName === \"ocr\") {\n sizes.set(b.style.fontSize, (sizes.get(b.style.fontSize) ?? 0) + b.text.length)\n proseChars += b.text.length\n }\n }\n const [key, count] = [...chars].sort((a, b) => b[1] - a[1])[0] ?? []\n if (key && count! >= 300) bodyStyle.set(pageNumber, key)\n if (proseChars >= 300) {\n let cumulative = 0\n for (const [size, count] of [...sizes].sort((a, b) => a[0] - b[0])) {\n cumulative += count\n if (cumulative > proseChars / 2) { bodySizes.set(pageNumber, size); break }\n }\n }\n }\n // A wrapped explanation followed immediately by a same-style statutory reference\n // is prose, even when small-font tables dominate the document's item median.\n const citedProse = new Set<IRBlock>()\n // A sequential, aligned run containing wrapped prose is a numbered body list.\n // Short chapter labels and visibly bold title faces do not supply this evidence.\n const numberedProse = new Set<IRBlock>()\n for (const page of byPage.values()) {\n const numbered = page.filter(b => (b.type === \"heading\" || b.type === \"paragraph\") &&\n /^[①-⑮]\\s*/.test(b.text?.trim() ?? \"\") && b.bbox && b.style?.fontName && b.style.fontSize &&\n !/Bold|Black|Heavy|Semibold/i.test(faceNames?.get(b.style.fontName) ?? \"\"))\n let chain: IRBlock[] = []\n const finish = () => {\n if (chain.length < 3 || !chain.some(b => (b.text?.length ?? 0) >= 40 &&\n b.bbox!.height >= b.style!.fontSize! * 2.4)) return\n for (const b of chain) numberedProse.add(b)\n const first = chain[0], size = first.style!.fontSize!\n for (const b of page) if (/^[○●•◦]\\s/.test(b.text?.trim() ?? \"\") &&\n b.style?.fontName === first.style?.fontName && b.style?.fontSize === size && b.bbox &&\n Math.abs(b.bbox.x - first.bbox!.x) <= size * 0.5 && (b.text?.length ?? 0) >= 15) numberedProse.add(b)\n }\n for (const b of numbered) {\n const prev = chain.at(-1), size = b.style!.fontSize!\n if (!prev || b.text!.trim().charCodeAt(0) !== prev.text!.trim().charCodeAt(0) + 1 ||\n b.style!.fontName !== prev.style!.fontName || size !== prev.style!.fontSize ||\n Math.abs(b.bbox!.x - prev.bbox!.x) > size * 0.5 ||\n prev.bbox!.y - (b.bbox!.y + b.bbox!.height) > size * 8) { finish(); chain = [] }\n chain.push(b)\n }\n finish()\n }\n for (let i = 0; i + 1 < blocks.length; i++) {\n const prose = blocks[i], reference = blocks[i + 1]\n const a = prose.bbox, b = reference.bbox, size = prose.style?.fontSize ?? 0\n const source = reference.text?.replace(/<[^>]+>/g, \"\").trim() ?? \"\"\n if ((prose.type !== \"heading\" && prose.type !== \"paragraph\" && prose.type !== \"list\") ||\n (reference.type !== \"heading\" && reference.type !== \"paragraph\") ||\n (prose.text?.length ?? 0) < 40 || !a || !b || size <= 0 ||\n a.height < size * 2.4 || prose.pageNumber !== reference.pageNumber ||\n prose.style?.fontName !== reference.style?.fontName || prose.style?.fontSize !== reference.style?.fontSize ||\n (Math.abs(a.x - b.x) > size && !(/^[○●•◦]\\s/.test(prose.text ?? \"\") && b.x >= a.x && b.x - a.x <= size * 2)) || a.y - (b.y + b.height) < 0 || a.y - (b.y + b.height) > size ||\n !/^[((].*(?:제\\s*\\d+\\s*조|(?:Article|Section)\\s+\\d+).*[))]$/i.test(source)) continue\n citedProse.add(prose)\n citedProse.add(reference)\n }\n for (let i = 0; i < blocks.length; i++) {\n const block = blocks[i]\n if (block.type !== \"heading\" || !block.text) continue\n const text = block.text.replace(/<[^>]+>/g, \"\").trim()\n const fsz = block.style?.fontSize ?? 0\n // A part title between two runs of contents entries is itself an entry.\n const tocEntry = i > 0 && TOC_BLOCKS.has(blocks[i - 1]) && TOC_BLOCKS.has(blocks[i + 1])\n const page = byPage.get(block.pageNumber ?? 0) ?? []\n const proseStyle = block.style?.fontName && bodyStyle.get(block.pageNumber ?? 0) === `${block.style.fontName}:${block.style.fontSize}` &&\n text.length > 60\n // A bare section number is the first part of the title that follows it.\n const next = blocks[i + 1]\n // 제목 가운데 위에 더 큰 글자로 따로 선 장 번호(\"2\" ⏎ \"The Lost Homeland\")는 따로 선 제목이다(ODL 021)\n const nb = next?.bbox, bb = block.bbox\n const chapterNumber = !!nb && !!bb && fsz >= (next?.style?.fontSize ?? 0) * 1.2 && Math.abs(bb.x - nb.x) > fsz &&\n Math.abs((bb.x + bb.width / 2) - (nb.x + nb.width / 2)) < nb.width * 0.1\n if (/^\\d+(?:\\.\\d+)*\\.?$/.test(text) && next?.type === \"heading\" && next.text && next.pageNumber === block.pageNumber && !chapterNumber) {\n next.text = `${block.text.trim()} ${next.text.trim()}`\n blocks.splice(i--, 1)\n continue\n }\n // 제목이 두 줄로 꺾여 둘째 줄이 소문자 낱말로 시작하면(같은 서체·크기, 바로 아래) 소문자 시작 강등에 걸려 본문으로 떨어진다 — 제목에 잇는다.\n // 줄 간격이 넓어(1.9em) 쌓인 제목 줄 잇기(mergeStackedHeadingLines)를 못 받은 둘째 줄은 아직 제목이다(ODL 199 슬라이드 패널 제목 \"…E2E performance ⏎ evaluation1\").\n // 캡션(\"Table 2: …\") 아래 제목 모양 줄은 캡션에 잇지 않고 따로 강등한다(종전 동작, pdf-structure-roles 테스트 고정)\n if ((next?.type === \"paragraph\" || next?.type === \"heading\" && !CAPTION.test(text)) && next.pageNumber === block.pageNumber && next.text && next.bbox && block.bbox &&\n next.style?.fontName === block.style?.fontName && next.style?.fontSize === block.style?.fontSize && fsz > 0 &&\n /^[a-z]/.test(next.text.trim()) && next.text.trim().length <= 40 && !/[.:;!?]$/.test(next.text.trim()) &&\n block.bbox.y - (next.bbox.y + next.bbox.height) < fsz * 1.2 &&\n Math.abs(next.bbox.x - block.bbox.x) < fsz) {\n block.text = `${block.text.trim()} ${next.text.trim()}`\n block.bbox = { ...block.bbox, y: next.bbox.y, height: block.bbox.y + block.bbox.height - next.bbox.y }\n blocks.splice(i + 1, 1)\n }\n // 두 제목 줄 사이에 다른 서체로 끼운 이음 기호(\"WHY IT IS IMPORTANT ⏎ & ⏎ WHAT YOU CAN DO\")는 한 제목이다\n const after = blocks[i + 2]\n if ((next?.type === \"paragraph\" || next?.type === \"heading\") && /^(?:&|\\+|and|or)$/i.test(next.text?.trim() ?? \"\") &&\n after?.type === \"heading\" && after.text && after.pageNumber === block.pageNumber && next.pageNumber === block.pageNumber &&\n after.style?.fontSize === block.style?.fontSize) {\n block.text = `${block.text.trim()} ${next.text!.trim()} ${after.text.trim()}`\n blocks.splice(i + 1, 2)\n }\n // 본문 크기보다 작아 읽을 수 없는 크기(7.5pt 미만)의 글은 제목이 아니다 — 슬라이드 차트 축 라벨(\"Parsing-F1\" 6pt)\n const tiny = fsz > 0 && fsz < 7.5\n // 같은 줄 왼쪽에 떨어져 놓인 소문자 항목 부호(\"n.\")는 본문 항목의 굵은 도입문이다 — 부호와 합쳐 문단으로\n const prev = blocks[i - 1]\n if (prev?.type === \"paragraph\" && prev.pageNumber === block.pageNumber && /^[a-z]{1,3}[.)]$/.test(prev.text?.trim() ?? \"\") &&\n prev.bbox && block.bbox && Math.abs(prev.bbox.y - block.bbox.y) < 2 && prev.bbox.x + prev.bbox.width <= block.bbox.x) {\n prev.text = `${prev.text!.trim()} ${block.text.trim()}`\n prev.bbox = { ...prev.bbox, width: block.bbox.x + block.bbox.width - prev.bbox.x }\n blocks.splice(i--, 1)\n continue\n }\n // 제목 앞 절 번호가 다른 서체라 따로 떨어진 것 — 같은 줄 왼쪽(\"4 | Al-Sadu Symbols\")이나 바로 위 왼끝 맞춘 큰 번호(\"4⏎Basis Fields\")는 제목의 일부다\n if (prev?.type === \"paragraph\" && prev.pageNumber === block.pageNumber && /^\\d{1,2}(?:\\.\\d{1,2})*\\.?$/.test(prev.text?.trim() ?? \"\") &&\n prev.bbox && block.bbox && fsz > 0 && (\n Math.abs(prev.bbox.y - block.bbox.y) < 2 && prev.bbox.x + prev.bbox.width <= block.bbox.x && block.bbox.x - (prev.bbox.x + prev.bbox.width) < fsz * 3 ||\n Math.abs(prev.bbox.x - block.bbox.x) < 2 && (prev.style?.fontSize ?? 0) >= fsz && prev.bbox.y - (block.bbox.y + block.bbox.height) < fsz * 1.5)) {\n block.text = `${prev.text!.trim()} ${block.text.trim()}`\n blocks.splice(i - 1, 1)\n // 붙인 제목도 아래 강등 판정을 다시 받는다 — 슬라이드 차트 값 라벨 \"82.65\" 가 옆 라벨 \"9\" 를 절 번호로 붙여 글자 없는·작은 글 강등을 건너뛰고 제목으로 남았다(ODL 199)\n i -= 2\n continue\n }\n // OCR 본문 두 배 크기의 표시 제목은 아래에 더 큰 제목이 있어도 작은 머리표가 아니다.\n // 텍스트층의 실제 글자 크기는 이 보정에서 제외한다 — 슬라이드의 작은 주석은 본문 기준이 아니다.\n const box = block.bbox, size = block.style?.fontSize ?? 0\n const kicker = !!box && size > 0 && next?.type === \"heading\" && next.pageNumber === block.pageNumber && !!next.bbox &&\n (block.style?.fontName !== \"ocr\" || size < (bodySizes.get(block.pageNumber ?? 0) ?? Infinity) * 2) &&\n (next.style?.fontSize ?? 0) >= size * 1.3 && box.y - (next.bbox.y + next.bbox.height) <= size * 3 &&\n Math.min(box.x + box.width, next.bbox.x + next.bbox.width) - Math.max(box.x, next.bbox.x) >= Math.min(box.width, next.bbox.width) * 0.5 &&\n !page.some(o => o !== block && o.bbox && o.bbox.y > box.y + box.height && o.type !== \"image\")\n // 제목 바로 아래 본문 크기 기울임 줄(저자·소속 \"Staff of the … Directorate\")은 부제가 아니라 필자 줄이다\n const face = faceNames?.get(block.style?.fontName ?? \"\") ?? \"\"\n const [bodyFace, bodySize] = (bodyStyle.get(block.pageNumber ?? 0) ?? \"\").split(\":\")\n const byline = /Italic|Oblique/i.test(face) && !/Bold|Black|Heavy|Semibold/i.test(face) && !!bodyFace &&\n size <= Number(bodySize) + 0.5 && blocks[i - 1]?.type === \"heading\" && blocks[i - 1].pageNumber === block.pageNumber\n // OCR 로 읽은 제목 후보는 글자 상자 높이만 근거라 인포그래픽 글 조각이 올라온다 — 문장부호로 시작하거나 \"…\"(인식 끊김)가 들었거나,\n // 글자가 둘 미만이거나, 일곱 어절 넘는 문장이면 제목이 아니다 (ODL 141 \". Uploading you … llection\", \"C\")\n const ocrFragment = block.style?.fontName === \"ocr\" && (/^[.,;:…·•-]/.test(text) || text.includes(\"…\") ||\n (text.match(/\\p{L}/gu)?.length ?? 0) < 2 || text.split(/\\s+/).length > 7)\n // OCR 높이는 글자 크기의 근사다. 양쪽 본문 줄과 겹치는 같은 열의 상자는 독립 제목의 크기 증거가 아니다.\n const crowdedOcr = block.style?.fontName === \"ocr\" && !!box &&\n [prev, next].every(o => o?.type === \"paragraph\" && o.pageNumber === block.pageNumber &&\n o.style?.fontName === \"ocr\" && !!o.bbox && (o.text?.length ?? 0) >= 15 &&\n Math.abs(o.bbox.x - box.x) <= size * 0.25 &&\n Math.min(o.bbox.y + o.bbox.height, box.y + box.height) - Math.max(o.bbox.y, box.y) >= o.bbox.height * 0.2) &&\n prev!.bbox!.y > box.y && next!.bbox!.y < box.y\n if (numberedProse.has(block) || citedProse.has(block) || ocrFragment || crowdedOcr || tocEntry || proseStyle || kicker || byline || tiny || !/\\p{L}/u.test(text) && !chapterNumber || /^[a-z]/.test(text) || CAPTION.test(text) || EQUATION_NUMBER.test(block.text) || DISPLAY_MATH.test(text) ||\n // 닫는 괄호가 여는 괄호보다 많으면 앞 줄에서 이어진 문장 조각이다 (\"Fact-checking) and is used …\") — \"1)\"·\"가)\" 앞머리 번호는 빼고 센다\n unbalancedClose(text.replace(/^\\s*[\\dA-Za-z가-힣ⅰ-ⅹ]{1,3}\\)\\s*/, \"\")) ||\n isRunningHead(block, page, pageHeights.get(block.pageNumber ?? 0))) {\n block.type = \"paragraph\"\n block.level = undefined\n }\n }\n}\n","/**\n * PDF 마크다운 최종 정리.\n *\n * 페이지 번호 제거, 균등배분 후처리, 취소선 복원, 한글 줄바꿈 병합.\n * blocksToMarkdown 이후의 문자열 수준 후처리를 담당한다.\n */\n\nimport { latinSoftWrap } from \"./cell-text.js\"\nimport type { IRBlock, IRTable } from \"../types.js\"\nimport { stripControlChars } from \"./quality.js\"\nimport { collapseEvenSpacing } from \"./text-line.js\"\nimport { wrapJoiner } from \"./line-wrap.js\"\nimport { FRAME_TITLE_BLOCKS } from \"./table-meta.js\"\n\n/**\n * 한컴 PDF 가 가운뎃점(ㆍ U+318D)을 조합형 중성 아래아(ᆞ U+119E)로 내는 것을 되돌린다.\n * 실측(v4.12.3, 별지서식·별표 573쌍): PDF 출력의 가운뎃점 3,238자 중 340자(41문서)가 U+119E,\n * HWP5 원본은 전부 U+318D. 옛한글 자모 결합(앞이 초성 U+1100~115F)만 남긴다.\n */\nexport function normalizeAraea(text: string): string {\n return text.replace(/(?<![\\u1100-\\u115F])\\u119E/g, \"\\u318D\")\n}\n\n/**\n * 한컴 PDF 의 \"유니코드 없는 글리프\" 자리표시 U+F000 제거. 한컴 PDF 는 ToUnicode 를 못 만든 글리프를 전부 U+F000 으로\n * 낸다 — 자동 글머리표·자동 번호(□·①), 일부 괄호(《), 칸 채움 글자까지 같은 코드라(보도자료 246건 실측: 줄머리 33·줄 안\n * 125) 원래 글자를 되살릴 수 없고, 남기면 두부 글자로 찍힌다. 페이지 품질 신호(PUA 비율) 계산 뒤에 지운다\n */\nconst stripNoUnicodeGlyph = (text: string): string => (text.includes(\"\\uF000\") ? text.replace(/\\uF000/g, \"\") : text)\n\n/**\n * 홀로 선 첫소리 자모(U+1100~1112, 뒤에 가운뎃소리 없음) → 호환 자모(ㄱ~ㅎ). 한컴 PDF 가 글머리 \"ㅇ\" 을 조합형 첫소리 ᄋ(U+110B)로\n * 내는 문서가 있다(규제영향분석서 \"ᄋ (추진배경)\", 환승센터 보도자료 \"ᄋ 수도권\") — 원문(HWPX)은 U+3147. 가운뎃소리가 뒤따르는\n * 옛한글 음절 조합은 그대로 둔다(normalizeAraea 와 같은 원칙)\n */\nconst CHOSEONG_COMPAT = \"ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ\"\nconst normalizeLoneChoseong = (text: string): string =>\n /[\\u1100-\\u1112]/.test(text) ? text.replace(/[\\u1100-\\u1112](?![\\u1160-\\u11A7\\uD7B0-\\uD7C6])/g, c => CHOSEONG_COMPAT[c.charCodeAt(0) - 0x1100]) : text\n\nconst cleanChars = (text: string): string => normalizeLoneChoseong(normalizeAraea(stripNoUnicodeGlyph(stripControlChars(text))))\n\n/** 블록 트리의 텍스트에서 비표시 제어문자 제거 + 조합형 가운뎃점 정규화 (in-place, 셀 blocks 포함) */\nexport function sanitizeBlockControlChars(blocks: IRBlock[]): void {\n for (const b of blocks) {\n if (b.text) b.text = cleanChars(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 = cleanChars(cell.text)\n if (cell.blocks) sanitizeBlockControlChars(cell.blocks)\n }\n }\n }\n if (b.children) sanitizeBlockControlChars(b.children)\n }\n}\n\n/**\n * 최상위 1×1 상자를 원문 문단·중첩표 순서대로 편다. 기하 없는 종전 셀은 줄 경계를 보존한다.\n * 1×1 셀 줄바꿈은 표 셀 줄바꿈 보존 정책(v4.12.1 1열 다행)과 같이 지켜야 하는데, 1×1 은\n * tableToMarkdown 이 \"줄\\n줄\" 로 내고 cleanPdfText 의 mergeKoreanLines 가 한글 줄을 이어 붙여\n * \"선 서 나는 헌법을 …\"(선서문 안쪽 상자, 제목 줄+본문 줄 결합)이 됐다. 문단 블록 사이는 빈 줄이라\n * 병합 대상이 아니다. 실측 파급: 별지서식·별표·보고서 897 PDF 중 10문서 12표.\n */\n/** 영문 캡션 표지 한 칸 — \"Figure 4\", \"Diagram 5\", \"Table 2.1\" */\nconst CAPTION_LABEL = /^(?:figure|fig\\.|diagram|table|chart|graph|exhibit|box)\\s*[\\dIVX][\\w.\\-]*$/i\n\n/** 캡션 표지 칸 + 캡션 글 칸 1×2 표(워드 캡션 상자) → 캡션 문단 한 줄 */\nfunction captionTableText(t: IRTable | undefined): string | null {\n if (!t || t.rows !== 1 || t.cols !== 2) return null\n const [label, body] = t.cells[0]\n if (!label || !body || label.blocks?.length || body.blocks?.length || !CAPTION_LABEL.test(label.text.trim()) || !body.text.trim()) return null\n return `${label.text.trim()} ${body.text.replace(/\\s*\\n\\s*/g, \" \").trim()}`\n}\n\nexport function splitSingleCellTables(blocks: IRBlock[]): IRBlock[] {\n const out: IRBlock[] = []\n for (const b of blocks) {\n const t = b.type === \"table\" ? b.table : undefined\n const title = t && FRAME_TITLE_BLOCKS.get(t)\n if (title) { out.push({ ...title, text: `${t!.cells[0][0].text.trim()} ${t!.cells[0][2].text.trim()}` }); continue }\n // 캡션 상자 — 표가 아니라 캡션 문단이다(1칸 틀 안에 든 것도)\n // 틀 칸 글은 비었거나 안쪽 표 글을 되풀이한 것\n const only = t && t.rows === 1 && t.cols === 1 && t.cells[0]?.[0]?.blocks?.length === 1 ? t.cells[0][0].blocks[0] : undefined\n const squash = (s: string) => s.replace(/\\s/g, \"\")\n const inner = only && (!squash(t!.cells[0][0].text) ||\n (only.type === \"table\" && squash(t!.cells[0][0].text) === squash(only.table!.cells.flat().map((c) => c.text).join(\"\")))) ? only : undefined\n const caption = captionTableText(t) ?? (inner?.type === \"table\" ? captionTableText(inner.table) : null)\n if (caption) { out.push({ type: \"paragraph\", text: caption, pageNumber: b.pageNumber, bbox: b.bbox }); continue }\n const cell = t && t.rows === 1 && t.cols === 1 ? t.cells[0]?.[0] : undefined\n if (!cell) { out.push(b); continue }\n const lines = (cell.text ?? \"\").split(/\\n/).map((l) => l.trim()).filter(Boolean)\n // 표에 붙은 캡션은 푼 줄 앞 문단으로 남긴다 (표를 풀면서 캡션까지 사라지던 것)\n if (t?.caption?.trim()) out.push({ type: \"paragraph\", text: t.caption.trim(), pageNumber: b.pageNumber, bbox: b.bbox })\n if (cell.blocks?.length) { out.push(...splitSingleCellTables(cell.blocks)); continue }\n for (const text of lines) out.push({ type: \"paragraph\", text, pageNumber: b.pageNumber, bbox: b.bbox })\n }\n return out\n}\n\n/**\n * 표 칸의 라틴 글 꺾인 줄을 공백으로 잇는다 — 영문 조판은 칸 폭에서 낱말 단위로 줄을 꺾을 뿐 문단을 가르지 않는다\n * (\"GATS XVII⏎Reservation⏎(1994)\"). 한글 줄(한컴 칸 문단 경계는 칸 글 조립의 꺾임 판정이 맡음)·숫자만 든 줄(쌓인 값)·\n * 새 항목(부호·번호) 줄은 그대로 둔다. 칸 글 줄을 문단으로 다시 푸는 단계(1×1 표 풀기) 뒤에 부른다\n */\nexport function joinLatinCellWraps(blocks: IRBlock[]): void {\n for (const b of blocks) {\n if (b.type !== \"table\" || !b.table) continue\n for (const row of b.table.cells) for (const cell of row) {\n if (cell.text.includes(\"\\n\")) {\n const lines = cell.text.split(\"\\n\")\n const out = [lines[0]]\n for (const line of lines.slice(1)) {\n if (latinSoftWrap(out[out.length - 1], line)) out[out.length - 1] = out[out.length - 1].trimEnd() + \" \" + line.trim()\n else out.push(line)\n }\n cell.text = out.join(\"\\n\")\n }\n if (cell.blocks) joinLatinCellWraps(cell.blocks)\n }\n }\n}\n\n/**\n * @param opts.keepLoneNumbers 홀로 선 숫자 줄을 남긴다 — 파서가 쪽 가장자리 띠의 쪽번호 문단을 이미 뺐을 때(본문 한가운데 차트 축 눈금·장 번호는 글이다)\n */\nexport function cleanPdfText(text: string, opts?: { keepLoneNumbers?: boolean }): string {\n let clean = normalizeAraea(stripControlChars(text))\n if (!opts?.keepLoneNumbers) {\n clean = clean\n // 문서 시작 단독 페이지 번호\n .replace(/^\\d{1,4}\\n/, \"\")\n // 단독 페이지 번호 (줄 끝에 혼자 있는 숫자)\n .replace(/\\n\\d{1,4}\\n/g, \"\\n\")\n // 문서 마지막 단독 페이지 번호\n .replace(/\\n\\d{1,4}$/, \"\")\n }\n // 단독 숫자 헤딩 제거 (\"# 6\\n재무과\" → \"\\n재무과\") — 파서 경로(keepLoneNumbers)는 글자 없는 제목을 이미 강등했고 남은 것은 장 번호다\n if (!opts?.keepLoneNumbers) clean = clean.replace(/^#{1,6}\\s*\\d{1,4}\\s*$/gm, \"\")\n return mergeKoreanLines(\n clean\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(/\\.(?: \\.){3,}/g, m => \".\".repeat((m.length + 1) >> 1))\n )\n // 균등배분 문자열 후처리 (pdfjs가 합친 TextItem + buildGridTable 셀 텍스트) — 홀로 선 한 글자 셋 이상 연속만 붙인다\n // (collapseEvenSpacing whole=false). 줄 전체 한 글자 비율 규칙은 기호·등호 토큰까지 한 글자로 세어 원문 띄어쓰기를 통째로\n // 지웠고(\"□ 개 요\" → \"□개요\", \"N = 잠수펌프의 수\" → \"N=잠수펌프의수\"), 표 칸은 이미 칸 조립(cleanCellText)·builder sanitizeText\n // 가 다룬다 — 이 단계의 whole 규칙을 끄면 hwpx↔pdf 752쌍 중 30문서 나아지고 2문서(각 1~2어절) 나빠진다\n // LaTeX 수식 라인 ($...$ / $$...$$) 은 공백이 토큰 구분자라 collapse 시 `\\cdot d` → `\\cdotd` 로 망가짐 — skip\n .replace(/^(?!\\| ---).*$/gm, line => {\n if (/^\\s*\\${1,2}.+\\${1,2}\\s*$/.test(line)) return line\n // 표 행은 칸마다(칸 안 <br> 줄마다) — 행 전체를 한 줄로 세면 칸 구분자 \"|\" 가 한 글자 토큰으로 잡혀 짧은 칸이 늘어선 머리 행이\n // 통째로 붙었다(\"| 시 간 | 분 | 내 용 | 비 고 |\" → \"|시간|분|내용|비고|\", 보도자료 세부 일정표 — 원문 칸 글은 \"시 간\", 86문서)\n if (line.startsWith(\"|\")) {\n return line.replace(/(?:\\\\.|[^|\\\\])+/g, cell => cell.split(\"<br>\").map(seg => seg.replace(/\\S(?:.*\\S)?/, t => collapseEvenSpacing(t, false))).join(\"<br>\"))\n }\n // 마크다운 머리 표지(\"# \"·\"- \")는 한 글자 토큰으로 세지 않는다 — \"# 목 차\" 가 \"#목차\"(헤딩 표지 깨짐)로 붙던 것\n const mark = /^(?:#{1,6}|-) /.exec(line)?.[0] ?? \"\"\n return mark + collapseEvenSpacing(line.slice(mark.length), false)\n })\n // (종전 \"마커 뒤 2글자 균등배분 합침\" — \"□ 일 시\" → \"□ 일시\" — 은 뺐다: 원문(HWPX)이 공백을 쳐서 띄운 표제라 hwpx↔pdf 에서\n // 붙이면 틀린 곳이 맞는 곳보다 많았다(규칙을 빼면 11문서 나아지고 1문서 나빠짐). 뒤 글자가 긴 낱말 머리면 \"□ 본 보도자료\" →\n // \"□ 본보도자료\" 로 낱말을 깨기도 했다(47문서))\n // 취소선 복원: builder escapeGfm이 ~를 \\~로 이스케이프 — 쌍(~~)만 되살림\n .replace(/\\\\~\\\\~/g, \"~~\")\n // 인접 취소선 run이 붙어 생긴 빈 마크(~~~~) 정리\n .replace(/~~~~/g, \"\")\n // 내용이 사라져 빈 밑줄 쌍(<u></u>) 정리 (escapeGfm은 <>를 건드리지 않아 복원 불필요)\n .replace(/<u>\\s*<\\/u>/g, \"\")\n .replace(/<(sup|sub)>\\s*<\\/\\1>/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 // 이음자는 어절 판정(wrapJoiner, 좌표·문서 어휘 없이 조사·어미 형태만) — 무조건 공백이면 블록 안 어절 중간 꺾임이 \"이정표입 니다\"\n if (/[가-힣·,\\-]$/.test(prev) && /^[가-힣(]/.test(curr) &&\n !startsWithMarker(curr) && !isStandaloneHeader(prev) &&\n !startsWithMarker(prev)) {\n result[result.length - 1] = prev + wrapJoiner(prev, curr) + curr\n } else {\n result.push(curr)\n }\n }\n return result.join(\"\\n\")\n}\n","/**\n * PDF 링크 어노테이션 → 마크다운 링크.\n *\n * /Annots 의 Link(/URI) 어노테이션 rect 를 텍스트 아이템과 상관해 해당 run 을\n * [text](url) 로 감싼다. pdfjs getAnnotations() 가 주는 rect 는 PDF 사용자 좌표\n * (pt, bottom-up)로 텍스트 아이템과 같은 공간. escapeGfm 은 `](스킴…)` 링크\n * 스팬을 보호하므로 마커가 그대로 살아남는다.\n */\n\nimport { sanitizeHref } from \"../utils.js\"\nimport type { NormItem } from \"./text-line.js\"\n\n/** pdfjs getAnnotations() 반환 원소 중 이 모듈이 쓰는 필드 */\nexport interface PdfAnnotation {\n subtype?: string\n url?: string\n rect?: number[]\n}\n\n/** rect 와 아이템의 세로 겹침 판정 여유 (pt) */\nconst LINK_Y_TOL = 2\n/** 아이템이 rect 에 속한다고 볼 최소 수평 겹침 비율 */\nconst LINK_MIN_OVERLAP_RATIO = 0.5\n\n/**\n * Link 어노테이션 rect 안의 텍스트 run 을 [text](url) 로 감싼다.\n * 시각적 줄 단위로 래핑 — 여러 줄에 걸친 링크는 줄마다 같은 url 로 반복.\n * rect 안에 텍스트가 없는 어노테이션(아이콘 링크 등)은 무시.\n */\nexport function applyLinkAnnotations(items: NormItem[], annots: PdfAnnotation[]): void {\n if (items.length === 0 || annots.length === 0) return\n const wrapped = new Set<NormItem>() // 겹치는 어노테이션의 이중 래핑 방지\n\n for (const a of annots) {\n if (a.subtype !== \"Link\" || !a.url || !a.rect || a.rect.length < 4) continue\n const url = sanitizeHref(a.url)\n if (!url) continue\n const x1 = Math.min(a.rect[0], a.rect[2]), x2 = Math.max(a.rect[0], a.rect[2])\n const y1 = Math.min(a.rect[1], a.rect[3]), y2 = Math.max(a.rect[1], a.rect[3])\n\n const matches: NormItem[] = []\n for (const item of items) {\n if (wrapped.has(item) || item.w <= 0 || !item.text.trim()) continue\n // baseline 이 rect 세로 범위 안 (밑줄형 링크 rect 가 baseline 아래로 살짝\n // 내려가는 경우 대비 여유 2pt)\n if (item.y < y1 - LINK_Y_TOL || item.y > y2 + LINK_Y_TOL) continue\n const overlap = Math.min(x2, item.x + item.w) - Math.max(x1, item.x)\n if (overlap / item.w < LINK_MIN_OVERLAP_RATIO) continue\n matches.push(item)\n }\n if (matches.length === 0) continue\n\n // 줄 단위 그룹핑 (y ±3, 취소선/밑줄 래핑과 동일 컨벤션) 후 x 순 래핑\n const lines = new Map<number, NormItem[]>()\n for (const m of matches) {\n const key = Math.round(m.y / 3)\n const arr = lines.get(key) || []\n arr.push(m)\n lines.set(key, arr)\n }\n for (const arr of lines.values()) {\n arr.sort((p, q) => p.x - q.x)\n arr[0].text = \"[\" + arr[0].text\n arr[arr.length - 1].text = arr[arr.length - 1].text + `](${url})`\n for (const m of arr) wrapped.add(m)\n }\n }\n}\n\n/** 주소 비교 키 — 마크다운 이스케이프·공백(줄 꺾임)·퍼센트 인코딩·스킴·www.·끝 문장부호를 걷는다 */\nfunction addressKey(s: string): string {\n let t = s.replace(/\\\\(.)/g, \"$1\").replace(/\\s+/g, \"\")\n try { t = decodeURI(t) } catch { /* 깨진 퍼센트 인코딩은 그대로 견준다 */ }\n return t.toLowerCase().replace(/^(?:[a-z][a-z0-9+.-]*:)?(?:\\/\\/)?(?:www\\.)?/, \"\").replace(/[/.,;:)\\]]+$/, \"\")\n}\n\n/** 마크다운 링크 한 개 — 그림 참조(`![…](…)`)는 뺀다 */\nconst MD_LINK = String.raw`(?<!!)\\[([^\\]\\n]*)\\]\\(([^)\\s]+)\\)`\n\n/**\n * 줄마다 감싼 링크를 문서 마크다운에서 다시 한 링크로 — 여러 줄에 걸친 링크 하나가 \"[You can read](u) [more about](u)\"처럼\n * 같은 url 을 줄 수만큼 되풀이하던 것을 사이 공백째 잇는다. 링크 글에 그어진 밑줄은 링크 표시일 뿐이라 링크 하나를 통째로\n * 감싼 `<u>` 는 뗀다. 링크 글이 주소 자체인 링크는 글만 남긴다\n */\nexport function mergeLinkRuns(markdown: string): string {\n // 줄마다 밑줄을 감싸면 두 줄 링크가 <u>[Our</u> <u>Mental Shortcuts](u)</u> 로 엇갈린다 — 링크 글 안의 줄 경계 밑줄 표지도 뗀다\n let out = markdown.replace(new RegExp(String.raw`<u>${MD_LINK}</u>`, \"g\"),\n (_m, text: string, url: string) => `[${text.replace(/<\\/u>(\\s*)<u>/g, \"$1\")}](${url})`)\n const run = new RegExp(MD_LINK + String.raw`(\\s+)` + String.raw`(?<!!)\\[([^\\]\\n]*)\\]\\(\\2\\)`, \"g\")\n for (let prev = \"\"; prev !== out;) {\n prev = out\n out = out.replace(run, (_m, a: string, url: string, _gap: string, b: string) => `[${a} ${b}](${url})`)\n }\n // 링크 글이 주소 자체면 [주소](주소) 는 같은 글을 되풀이할 뿐이다 — 찍힌 글만 남긴다 (ODL 158 유튜브 주소·192 참고문헌 주소)\n return out.replace(new RegExp(MD_LINK, \"g\"), (m, text: string, url: string) => text.trim() && addressKey(text) === addressKey(url) ? text : m)\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 { escapeLiteralTags, stripScriptTags } from \"../script-tags.js\"\nimport type { InternalParseResult, IRBlock, DocumentMetadata, ExtractedImage, ParseOptions, ParseWarning, OutlineItem } from \"../types.js\"\nimport { KordocError } from \"../utils.js\"\nimport { parsePageRange, hasRequestedPagesAfter } from \"../page-range.js\"\nimport { blocksToPages } from \"../page-markdown.js\"\nimport { assignTableIds, blocksToMarkdown, escapeLiteralDollar } from \"../table/builder.js\"\nimport { unframeLayoutTables, CONTENT_CELLS, CELL_BASELINES } from \"../table/layout-frames.js\"\nimport { CELL_LINES, CLIP_TABLES, IMAGE_CELLS } from \"./table-meta.js\"\nimport { extractImageRegions, extractLines } from \"./line-detector.js\"\nimport { mergeOcrImageRegions, type ImageRegion } from \"./ocr-region-merge.js\"\nimport { createPdfImageState, extractPageImages, injectPageImageBlocks } from \"./image-extract.js\"\nimport { computePageQuality, summarizeDocumentQuality, type PageQuality } from \"./quality.js\"\nimport { scanVectorGlyphs, ocrVectorOps } from \"./vector-glyphs.js\"\nimport { type PdfTextItem, type NormItem, normalizeItems, filterHiddenText } from \"./text-line.js\"\nimport { extractPageBlocksWithLines, type PageCarry } from \"./page-blocks.js\"\nimport { WrapLexicon, joinPageBreakWraps, splitPageBreakWraps } from \"./line-wrap.js\"\nimport { mergeCrossPageTables } from \"./table-parts.js\"\nimport { splitPageTables } from \"./table-pages.js\"\nimport { mergeContinuedCells } from \"./cell-continuation.js\"\nimport { restoreUnmappedGlyphs } from \"./symbol-glyphs.js\"\nimport { trimTrailingEmptyTableCols } from \"./table-trim.js\"\nimport { selectionContextPages, marginContextBlocks } from \"./selection-context.js\"\nimport { restoreImageBullets } from \"./image-bullets.js\"\nimport { detectRightArrowRegions } from \"./flow-boxes.js\"\nimport { remapSymbolFontItems } from \"./symbol-fonts.js\"\nimport { wrapEquationRuns } from \"./equation-runs.js\"\nimport { remapControlGlyphs, restoreNamedGlyphs } from \"./glyph-names.js\"\nimport { occludedTextItems } from \"./occluded-text.js\"\nimport { joinVerticalColumns } from \"./vertical-text.js\"\nimport { restoreTrackedSpacing, markSyntheticSpaces, markTrailingSpaceGlyphs } from \"./tracked-text.js\"\nimport { relocateEndnotes } from \"./endnotes.js\"\nimport { dropTabLeaderDots } from \"./tab-leaders.js\"\nimport { orderTwoUpPage } from \"./two-up.js\"\nimport { removeSideTabs } from \"./side-tabs.js\"\nimport { superscriptNoteMarks, inlineFootnotes, footnoteSeparators, type PageNotes } from \"./footnotes.js\"\nimport { demoteNonHeadingRoles } from \"./heading-demote.js\"\nimport { computeMedianFontSizeFromFreq, detectHeadings, mergeStackedHeadingLines, detectTypographyHeadings, detectDocumentStyleHeadings, detectSiblingStyleHeadings, detectRepeatedPageLabels, detectPageLeadHeadings, refineDocumentStyleHeadings, detectMarkerHeadings, detectTableCaptions, detectKoreanListBlocks, removeHeaderFooterBlocks, runningLinePatterns, stripAbsorbedRunningLines } from \"./block-detect.js\"\nimport { sanitizeBlockControlChars, cleanPdfText, splitSingleCellTables, joinLatinCellWraps } from \"./text-clean.js\"\nimport { applyLinkAnnotations, mergeLinkRuns } from \"./links.js\"\nimport { applyFormulaOcr } from \"./formula-ocr.js\"\n// polyfill 먼저 (ES 모듈 호이스팅되므로 별도 파일 필수)\nimport \"./polyfill.js\"\nimport { getDocument, GlobalWorkerOptions, OPS } from \"pdfjs-dist/legacy/build/pdf.mjs\"\nimport { createRequire } from \"node:module\"\nimport { dirname, join } from \"node:path\"\nimport { ocrModelsCached } from \"../ocr/models.js\"\nimport { splitContactTables } from \"./contact-table.js\"\n\n// 기존 공개 API 경로 유지 — 이동된 함수의 re-export\n\n/**\n * 그림 영역 OCR 대상 — `ocr: true` 는 글 없는 그림 후보 전부(쪽 면적 2%+·머리 띠 로고), 기본값의 자동 OCR 은 쪽 면적 5% 넘는 큰\n * 그림만 읽는다(작은 아이콘·로고는 잡음 글이 되기 쉽고 쪽당 시간을 키운다). 큰 그림이 없는 쪽은 뺀다\n */\nexport function ocrImageRegions(regions: Map<number, ImageRegion[]>, large: Set<ImageRegion>, all: boolean): Map<number, ImageRegion[]> {\n if (all) return regions\n const out = new Map<number, ImageRegion[]>()\n for (const [p, rs] of regions) {\n const big = rs.filter(r => large.has(r))\n if (big.length) out.set(p, big)\n }\n return out\n}\n\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.js 는 글꼴을 사전 단위로 문서 끝까지 쥔다\n * (사전당 약 1.5MB, #137 제안요청서 30쪽 글꼴 사전 2,965개 = 힙 4.2GB). 코퍼스 PDF 1,911개의 문서당 글꼴 사전은 최대 155개라\n * 보통 문서는 비우지 않는다(비우면 다음 쪽 글꼴을 다시 변환한다) */\nconst FONT_CACHE_LIMIT = 256\n\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/**\n * 글꼴을 받기만 하는 문서 (#137) — pdf.js 는 FontFace 를 못 쓰면(disableFontFace, Node 의 FontFace 부재) 글자마다 글리프 윤곽 경로를\n * 만들고, 그 렌더러가 글꼴 프로그램의 글리프를 전부 쪼개 글꼴 사전마다 따로 쥔다. kordoc 은 pdf.js 로 그리지 않아(OCR·수식 래스터는\n * pdfium) 경로가 필요 없다. 이 문서를 ownerDocument 로 주면 FontLoader 가 @font-face 규칙을 넣고(버림) Node 의 동기 글꼴 적재로\n * 끝나 경로를 만들지 않는다. Bullzip PDF Printer 공시 첨부(글꼴 파일 2개를 글꼴 사전 400여 개가 나눠 씀, 글리프 6.5만 개): 1쪽\n * getOperatorList 힙 3.6GB → 0.6GB. 쓰는 멤버는 FontLoader.insertRule·clear 뿐이다(pdfjs 4.10)\n */\nconst HEADLESS_FONT_DOCUMENT = {\n createElement: () => ({ sheet: { cssRules: [], insertRule() {} }, remove() {} }),\n documentElement: { getElementsByTagName: () => [{ append() {} }] },\n} as unknown as HTMLDocument\n\n/** getDocument 옵션 (data 제외) */\nexport const PDFJS_DOCUMENT_OPTIONS = {\n useSystemFonts: true,\n // 글꼴 /Differences 글리프 이름 — ToUnicode 없는 옛 숫자·작은 대문자 복원(glyph-names.ts)\n fontExtraProperties: true,\n disableFontFace: false,\n ownerDocument: HEADLESS_FONT_DOCUMENT,\n isEvalSupported: false,\n verbosity: 0, // 오류만 — 경고(\"Warning: Indexing all PDF objects\")를 console.log 로 stdout 에 찍어 MCP·CLI JSON 을 깼다\n ...pdfjsAssets,\n}\n\n/** getDocument + 타임아웃 래퍼 */\nasync function loadPdfWithTimeout(buffer: ArrayBuffer) {\n const loadingTask = getDocument({\n // pdfjs transfers its input to the worker; retain the caller's buffer for reuse.\n data: new Uint8Array(buffer.slice(0)),\n ...PDFJS_DOCUMENT_OPTIONS,\n })\n let timer: ReturnType<typeof setTimeout> | undefined\n try {\n return await Promise.race([\n loadingTask.promise.catch((e: unknown) => {\n if (e instanceof Error && e.name === \"PasswordException\") throw new KordocError(\"암호로 보호된 PDF 파일입니다 (PDF 열기 암호는 지원하지 않습니다)\")\n throw e\n }),\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 receives a copy; both OCR paths can reuse the caller's original bytes.\n const formulaBuffer: ArrayBuffer | null = options?.formulaOcr ? buffer : null\n // ocr 을 지정하지 않으면(false 아님) 텍스트층 없는 쪽과 큰 그림 속 글을 자동 OCR — 내장 모델이 이미 캐시에 있을 때만(다운로드하지 않는다)\n const autoOcr = options?.ocr === undefined && await ocrModelsCached()\n const ocrBuffer: ArrayBuffer | null = options?.ocr || autoOcr ? buffer : 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) — PDF는 항상 실제 페이지 단위 (#66)\n const metadata: DocumentMetadata = { pageCount, pageMode: \"layout\" }\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 if (pageCount > MAX_PAGES && (!options?.pages || hasRequestedPagesAfter(options.pages, MAX_PAGES, pageCount))) {\n warnings.push({ message: `${pageCount}쪽 중 앞 ${MAX_PAGES}쪽만 파싱했습니다 (쪽 수 상한)`, code: \"PARTIAL_PARSE\" })\n }\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 const pageWidths = new Map<number, number>()\n // 글꼴 id → 실제 서체 이름(서브셋 접두 뗌) — 제목 강등의 기울임·굵기 증거\n const faceNames = new Map<string, string>()\n // 큰 이미지가 있는 페이지 (needsOcr 경고 노이즈 필터 + SKIPPED_IMAGE)\n const pagesWithLargeImage = new Set<number>()\n // 텍스트 없는 큰 이미지 영역: page → count\n const skippedImagePages = new Map<number, number>()\n const uncoveredImageRegions = new Map<number, ImageRegion[]>()\n // 그 가운데 쪽 면적 5% 넘는 큰 그림 — 기본값(자동 OCR)은 이것만 읽는다 (ocrImageRegions)\n const largeImageRegions = new Set<ImageRegion>()\n // 이미지 XObject 바이트 추출 상태 (문서 단위 중복 억제·상한).\n // image 블록은 페이지 경계 표 병합(mergeCrossPageTables)의 인접성을 깨지 않도록\n // 페이지별로 모아뒀다가 병합 후 주입한다.\n const imageState = createPdfImageState()\n const imageBulletShapes = new Map<string, boolean>()\n const imageArrowDirections = new Map<string, -1 | 0 | 1>()\n const extractedImages: ExtractedImage[] = []\n const pageImageBlocks = new Map<number, IRBlock[]>()\n // 쪽을 넘는 칸 — 앞 쪽 마지막 칸을 다음 쪽 클립 판정에 넘긴다 (clip-cells continues, 문서 단계 mergeContinuedCells)\n const carry: PageCarry = {}\n // OCR 로 갈 벡터 글자 쪽의 그래픽(글자 경로·클립 뺀 연산자 목록) — OCR 글자에 이 쪽의 실제 괘선을 붙인다\n const vectorPageOps = new Map<number, { fnArray: number[]; argsArray: unknown[][] }>()\n // 줄 꺾임 이음의 문서 어휘 증거 — 쪽을 처리할 때마다 그 쪽 줄 글이 더해진다 (line-wrap.ts)\n const wrapLexicon = new WrapLexicon()\n // 쪽마다 본문 위첨자 각주 참조 표시(\"권고사항⁶⁾\") — 쪽 아래 각주를 참조 문단으로 옮길 때 쓴다 (footnotes.ts)\n const noteMarks = new Map<number, PageNotes>()\n\n // A selected page alone cannot establish the existing three-page repetition rule.\n // Read only nearby text margins; context never enters output, OCR, counts or progress.\n const headerContext: IRBlock[] = []\n if (pageFilter && options?.removeHeaderFooter !== false && pageFilter.size < effectivePageCount) {\n for (const number of selectionContextPages(pageFilter, effectivePageCount)) {\n let contextPage: Awaited<ReturnType<typeof doc.getPage>> | undefined\n try {\n contextPage = await doc.getPage(number)\n const content = await contextPage.getTextContent()\n pageHeights.set(number, contextPage.view[3] - contextPage.view[1])\n headerContext.push(...marginContextBlocks(content.items as PdfTextItem[], number, contextPage.view))\n } catch { /* unavailable context leaves selected content intact */ }\n finally { contextPage?.cleanup() }\n }\n }\n\n let parsedPages = 0\n const loadedFonts = new Set<string>()\n for (let i = 1; i <= effectivePageCount; i++) {\n if (pageFilter && !pageFilter.has(i)) continue\n let loadedPage: Awaited<ReturnType<typeof doc.getPage>> | undefined\n try {\n const page = await doc.getPage(i)\n loadedPage = page\n const tc = await page.getTextContent()\n // Text and paths use unrotated user coordinates, including the CropBox origin.\n const [viewX1, viewY1, viewX2, viewY2] = page.view\n const pageW = viewX2 - viewX1, pageH = viewY2 - viewY1\n pageHeights.set(i, pageH)\n pageWidths.set(i, pageW)\n const rawItems = tc.items as PdfTextItem[]\n // 선 기반 테이블 감지를 위한 operatorList — 글리프 이름 복원(restoreNamedGlyphs)이 공백 정리 전에 써서 먼저 받는다\n const rawOps = await page.getOperatorList()\n for (let k = 0; k < rawOps.fnArray.length; k++) if (rawOps.fnArray[k] === OPS.setFont) loadedFonts.add(rawOps.argsArray[k][0])\n // 폰트 실명·/Differences 는 operatorList 로드 뒤에야 commonObjs 에 있다\n const fontObj = (loadedName: string) => {\n try { return page.commonObjs.has(loadedName) ? page.commonObjs.get(loadedName) as { name?: string; isType3Font?: boolean } | null : undefined }\n catch { return undefined }\n }\n const differencesOf = (loadedName: string) => (fontObj(loadedName) as { differences?: ArrayLike<string | undefined> } | null | undefined)?.differences\n // 줄 끝 공백 글리프(어절 경계에서 꺾인 줄) — 아래 복원들이 아이템 글을 바꾸기 전에 흐름과 맞춘다\n markTrailingSpaceGlyphs(rawItems, rawOps.fnArray, rawOps.argsArray)\n restoreNamedGlyphs(rawItems, rawOps.fnArray, rawOps.argsArray, differencesOf, n => fontObj(n)?.name)\n // 유니코드 없는 기호 글리프(U+F000)는 글꼴 윤곽 모양으로 되살린다 — 한컴 자동 글머리표 \"▸\"·\"□\"·겹화살괄호 (symbol-glyphs.ts)\n restoreUnmappedGlyphs(rawItems, rawOps.fnArray, rawOps.argsArray, n => fontObj(n) as { data?: Uint8Array } | null | undefined)\n // 자간 벌린 글(\"E M A I L\") — 글리프 흐름의 진짜 공백으로 낱말 경계를 되살린다\n restoreTrackedSpacing(rawItems, rawOps.fnArray, rawOps.argsArray)\n // pdfjs 가 글자 틈으로 만든 공백 아이템 표시 — 균등배분 판정이 진짜 공백 글리프에서만 끊도록\n markSyntheticSpaces(rawItems, rawOps.fnArray, rawOps.argsArray)\n // 뒤에 칠한 불투명 사각형에 가려진 글(쪽 배경 아래 깔린 머리글 등)은 보이지 않는다\n const occluded = occludedTextItems(rawItems, rawOps.fnArray, rawOps.argsArray)\n const items = normalizeItems(occluded.size ? rawItems.filter(it => !occluded.has(it)) : rawItems)\n\n // hidden text 필터링 + 경고 수집\n const filtered = filterHiddenText(items, pageW, pageH, viewX1, viewY1)\n // 세로쓰기 글상자(한 자씩 쌓은 기둥이 오른쪽→왼쪽)는 기둥마다 한 줄로\n const joined = joinVerticalColumns(filtered.visible)\n // 탭 채움 리더(가운뎃점)는 글이 아니다 — 목차 줄이 \"제목 ···· 3\" 로 남던 것. 글꼴 크기 빈도(제목 판정의 본문 크기)는 빼기 전\n // 아이템으로 센다 — 목차 긴 문서에서 리더 점 수천 개가 빠지면 중앙값이 흔들려 제목 판정이 통째로 바뀐다(강원 정보화 계획 402 → 233)\n const visible = dropTabLeaderDots(joined)\n const hiddenCount = filtered.hiddenCount + occluded.size\n if (hiddenCount > 0) {\n warnings.push({ page: i, message: `${hiddenCount}개 숨겨진 텍스트 요소 필터링됨`, code: \"HIDDEN_TEXT_FILTERED\" })\n }\n\n // 폰트 크기 빈도 수집\n for (const item of joined) {\n if (item.fontSize > 0) fontSizeFreq.set(item.fontSize, (fontSizeFreq.get(item.fontSize) || 0) + 1)\n }\n\n // 링크 어노테이션(/Annots /URI) → [text](url) 래핑 — 실패해도 텍스트 파싱 진행\n try {\n const annots = await page.getAnnotations()\n applyLinkAnnotations(visible, annots)\n } catch { /* 어노테이션 파싱 실패 무시 */ }\n\n // Downstream table/header/page-break geometry assumes an origin of (0,0).\n // Translate both text and graphics, after annotations have matched user coordinates.\n // Filtering alone would retain text but misclassify a repeated body as a header.\n const shifted = viewX1 !== 0 || viewY1 !== 0\n if (shifted) for (const item of visible) { item.x -= viewX1; item.y -= viewY1 }\n const opList = shifted ? {\n fnArray: [OPS.transform, ...rawOps.fnArray],\n argsArray: [[1, 0, 0, 1, -viewX1, -viewY1], ...rawOps.argsArray],\n } : rawOps\n\n // 심볼 폰트(Wingdings) 글리프 복원\n remapSymbolFontItems(visible, (loadedName) => fontObj(loadedName)?.name)\n remapControlGlyphs(visible, differencesOf)\n await restoreImageBullets(visible, page, opList.fnArray, opList.argsArray, imageBulletShapes)\n // 글꼴 id(g_d0_fN)는 글꼴 객체마다 다르다. 크롬은 한 서체를 Type3 글꼴 객체 여러 개(256자마다 새 객체)로 쪼개\n // 같은 본문이 \"다른 서체\"로 보여 제목으로 승격된다(#89). Type3 는 서브셋 접두(ABCDEF+)를 뗀 서체 이름으로 맞춘다.\n // 다른 글꼴은 같은 이름의 서브셋 객체 차이가 굵게 흉내 낸 제목의 유일한 증거일 수 있어 그대로 둔다(ODL 181 Calibri).\n // 서브셋 접두가 없는 Type3 이름(cairo \"T2\"·\"T3\")은 쪽마다 다른 글꼴에 되쓰여 합치지 않는다\n const faces = new Map<string, string>()\n for (const it of visible) {\n if (!it.fontName) continue\n let face = faces.get(it.fontName)\n if (face === undefined) {\n const obj = fontObj(it.fontName)\n face = (obj?.isType3Font && /^[A-Z]{6}\\+./.test(obj.name ?? \"\") && obj.name!.slice(7)) || it.fontName\n faces.set(it.fontName, face)\n }\n it.fontName = face\n if (!faceNames.has(face)) faceNames.set(face, (fontObj(face)?.name ?? \"\").replace(/^[A-Z]{6}\\+/, \"\"))\n }\n // 리터럴 $ 는 \\$ — $…$ 는 수식 스팬 전용(IR 규약, HWPX·HWP5 와 같음). 종전엔 \"단가(US $) … 금액(US $)\" 사이가\n // 마크다운에서 수식으로 읽혀 사라졌다(야생생물 신고서·어셈블리 \"lda $30,-16($30)\"). 심볼 글꼴 복원 뒤라야 글자 표가 안 어긋난다\n for (const it of visible) if (it.text.includes(\"$\")) it.text = escapeLiteralDollar(it.text)\n // 태그 모양 원문 글자 \"<sub>\"·\"</sup>\" 는 \\<sub> — 첨자 끔(기본)·평문이 원문 글자까지 걷던 것 (#122, escapeLiteralTags)\n for (const it of visible) if (it.text.includes(\"<\")) it.text = escapeLiteralTags(it.text)\n // 한컴 수식 글꼴 글자 → $…$ 수식 스팬 (HWPX·HWP5 수식 출력과 같은 모양, 리터럴 $ 이스케이프 뒤)\n wrapEquationRuns(visible, face => faceNames.get(face))\n\n // 이미지 영역 감지 — 텍스트 없는 큰 이미지는 무음 정보손실이므로 가시화 (ODL 아이디어)\n const pageArea = pageW * pageH\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 // 쪽 면적 2% 넘는 그림은 OCR 영역 후보(로고 글 \"LAW LIBRARY\", ODL 085), 5% 넘는 것만 \"읽지 못한 그림\" 경고 — 1% 로 내리면\n // 아이콘이 잡음 글(\"0 0\")로 들어와 제목 구조가 흐트러진다(ODL 200 실측)\n // 쪽 머리 띠(위 20%)의 작은 로고(0.5% 이상)도 — 기관·사업 이름이 로고 글로만 있다(ODL 147·148·150 \"ECO Circle\")\n const headerLogo = area >= pageArea * 0.005 && r.y1 >= pageH * 0.8\n if (area < pageArea * 0.02 && !headerLogo) continue // 작은 장식 이미지 무시\n const large = area >= pageArea * 0.05\n if (large) 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) {\n if (large) uncovered++\n if (page.rotate % 360 === 0 && viewX1 === 0 && viewY1 === 0) {\n const regions = uncoveredImageRegions.get(i) ?? []\n regions.push(r)\n uncoveredImageRegions.set(i, regions)\n if (large) largeImageRegions.add(r)\n }\n }\n }\n if (uncovered > 0) skippedImagePages.set(i, uncovered)\n }\n\n // 본문 글 밖 여백에 세로로 돌려 찍은 글(arXiv 도장)은 가로 줄 흐름에 섞이면 옆 본문 줄에 붙는다 — 따로 떼어 쪽 첫 문단으로\n const marks = superscriptNoteMarks(visible)\n if (marks.length) noteMarks.set(i, { marks, seps: footnoteSeparators(extractLines(opList.fnArray, opList.argsArray).horizontals, pageW, pageH) })\n const stamp = marginStamp(visible)\n const flow = stamp.length ? visible.filter(it => !stamp.includes(it)) : visible\n // 가로 쪽 두 쪽 모아찍기는 왼쪽 쪽 → 오른쪽 쪽\n const rightArrows = options?.tables !== false ? await detectRightArrowRegions(page, opList.fnArray, opList.argsArray, imageArrowDirections) : []\n const pageBlocks = orderTwoUpPage(extractPageBlocksWithLines(flow, i, opList, pageW, pageH, undefined, options?.tables !== false, carry, wrapLexicon, rightArrows), pageW, pageH)\n if (stamp.length) pageBlocks.unshift({ type: \"paragraph\", text: [...stamp].sort((a, b) => a.y - b.y).map(it => it.text).join(\" \"), pageNumber: i })\n for (const b of pageBlocks) blocks.push(b)\n\n // 이미지 XObject 바이트 추출 — 블록 주입은 표 병합 후(injectPageImageBlocks)\n // images:false 면 PNG 인코딩을 건너뛰고 자리 표시 블록만 받는다\n try {\n const { blocks: imgBlocks, images: pageImages } = await extractPageImages(page, opList.fnArray, opList.argsArray, i, imageState, warnings, options?.images !== false)\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 // 텍스트층 밖 곡선 글자 — 글자를 채운 경로로 그린 쪽은 텍스트층 신호만으론 멀쩡해 보인다 (vector-glyphs.ts)\n const vector = scanVectorGlyphs(opList.fnArray, opList.argsArray, visible)\n const quality = computePageQuality(i, pageText, vector.glyphs)\n pageQuality.push(quality)\n // 회전·원점 이동 쪽은 pdfium 래스터 좌표가 사용자 공간과 어긋나 종전대로(래스터 괘선) 둔다\n if (options?.ocr && quality.ocrReason === \"vector_text\" && page.rotate % 360 === 0 && page.view[0] === 0 && page.view[1] === 0) {\n vectorPageOps.set(i, ocrVectorOps(opList, vector.paths))\n }\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 } finally {\n // Release page-local decoded images/operators on success and failure.\n // commonObjs (shared fonts/images) remains available to later pages.\n loadedPage?.cleanup()\n }\n if (loadedFonts.size > FONT_CACHE_LIMIT) {\n await doc.cleanup()\n loadedFonts.clear()\n }\n }\n\n const parsedPageCount = parsedPages || (pageFilter ? pageFilter.size : effectivePageCount)\n // 문서 단위 이미지 기반 판정 (평균 10자/페이지 미만 = 텍스트층 부재)\n const isImageBased = pageFilter?.size !== 0 && totalChars / Math.max(parsedPageCount, 1) < 10\n\n // ── OCR 실행 (옵션) — 페이지 단위 선정·병합 ──\n // 대상: \"force\"=전 페이지 / 문서가 이미지 기반=전 페이지 /\n // 그 외=품질 신호가 OCR 을 권하는 페이지만 (깨진 텍스트층 포함 — F1,\n // 혼합 문서의 스캔 페이지 포함 — F2). 정상 페이지 파싱 결과는 유지 (F3).\n const ocrDone = new Set<number>()\n // 그림 영역 OCR — ocr: true 는 후보 전부, 자동 OCR(기본값)은 큰 그림만\n const ocrRegions = options?.ocr === true || autoOcr ? ocrImageRegions(uncoveredImageRegions, largeImageRegions, options?.ocr === true) : new Map<number, ImageRegion[]>()\n if (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 // 자동 OCR 은 텍스트층이 없는 쪽만 — 글꼴 매핑이 깨진 쪽(텍스트층은 있다)은 ocr: true 로\n if (autoOcr && pq.ocrReason !== \"low_text\" && pq.ocrReason !== \"vector_text\") continue\n targets.add(pq.page)\n }\n for (const p of ocrRegions.keys()) targets.add(p)\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 // 텍스트층이 멀쩡한 쪽은 그림 영역만 읽는다 (쪽 전체를 갈아 끼우는 쪽 — 스캔·깨진 텍스트층 — 은 쪽 전체)\n const regionPages = new Map([...ocrRegions].filter(([p]) =>\n options?.ocr !== \"force\" && !isImageBased && !pageQuality.find(q => q.page === p)?.needsOcr))\n const ocrPageBlocks = await runPdfOcr(ocrBuffer, targets, mode, warnings, options?.onProgress, options?.tables !== false, vectorPageOps, regionPages)\n // OCR 글은 첨자를 가르지 않는다 — 검출 박스 높이·위치로는 기준선을 믿을 수 없다(scriptTags 를 켜도)\n for (const obs of ocrPageBlocks.values()) stripScriptTags({ blocks: obs })\n if (ocrPageBlocks.size > 0) {\n const replacePages = new Set<number>()\n for (const [p, obs] of ocrPageBlocks) {\n const needsOcr = pageQuality.find(q => q.page === p)?.needsOcr\n if (options?.ocr === \"force\" || isImageBased || needsOcr) {\n replacePages.add(p)\n ocrDone.add(p)\n continue\n }\n const regions = ocrRegions.get(p)\n if (!regions || mergeOcrImageRegions(blocks, p, regions, obs) === 0) continue\n ocrDone.add(p)\n // The extracted image remains in result.images; its Markdown placeholder\n // is replaced only when it was the sole image on the page.\n if (pageImageBlocks.get(p)?.length === 1) pageImageBlocks.delete(p)\n }\n if (replacePages.size) {\n const merged = blocks.filter(b => !(b.pageNumber && replacePages.has(b.pageNumber)))\n for (const [p, obs] of ocrPageBlocks) if (replacePages.has(p)) merged.push(...obs)\n merged.sort((a, b) => (a.pageNumber ?? 0) - (b.pageNumber ?? 0))\n blocks.length = 0\n blocks.push(...merged)\n }\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이 필요합니다. ocr: true (CLI --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 vector_text: \"글자를 곡선(벡터 경로)으로 그린 페이지 (텍스트층에 글자 없음)\",\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 검토 필요 (ocr: true / CLI --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 if (ocrDone.has(page)) continue\n warnings.push({ page, message: `${count}개 이미지 영역에 추출 가능한 텍스트 없음 (그림/차트/도장 내용 누락 가능 — 그림 속 글은 ocr: true / CLI --ocr 로 읽습니다)`, code: \"SKIPPED_IMAGE\" })\n }\n }\n\n // 머리글/바닥글 필터링 (기본 ON — 명시적 false일 때만 비활성화)\n if (options?.removeHeaderFooter !== false && (parsedPageCount >= 3 || headerContext.length)) {\n const removed = removeHeaderFooterBlocks(blocks, pageHeights, warnings, noteMarks, false, headerContext)\n const running = runningLinePatterns(removed.map(ri => blocks[ri]), pageHeights)\n // 필터링된 블록 제거 (뒤에서부터 삭제)\n for (let ri = removed.length - 1; ri >= 0; ri--) {\n blocks.splice(removed[ri], 1)\n }\n // 쪽 높이 문단·쪽 끝까지 내려온 표에 흡수된 같은 머리·바닥글\n const stripped = stripAbsorbedRunningLines(blocks, pageHeights, running)\n if (stripped !== blocks) { blocks.length = 0; blocks.push(...stripped) }\n // 쪽 옆 띠의 장·절 색인 탭(좌우 바깥 띠에 되풀이되는 짧은 글)\n const kept = removeSideTabs(blocks, pageWidths)\n if (kept !== blocks) { blocks.length = 0; blocks.push(...kept) }\n }\n\n // 쪽을 넘는 칸의 1칸 조각을 앞 쪽 표 그 칸에 붙인 뒤(행으로 갈리지 않게) 페이지 걸친 표 병합 —\n // 머리글/바닥글 제거 후 인접해진 표를 하나로 (ODL TableBorderProcessor.checkNeighborTables 포팅)\n mergeContinuedCells(blocks, pageHeights)\n mergeCrossPageTables(blocks, pageHeights, wrapLexicon)\n // 괘선 상자 머리말·꼬리말(표 블록) — 쪽 넘김 표 병합 뒤라야 상자가 갈라 두던 이웃 쪽 표를 잇지 않는다 (block-detect.ts)\n if (options?.removeHeaderFooter !== false && parsedPageCount >= 3) {\n const boxes = removeHeaderFooterBlocks(blocks, pageHeights, warnings, noteMarks, true)\n for (let ri = boxes.length - 1; ri >= 0; ri--) blocks.splice(boxes[ri], 1)\n }\n // 칸 클립 없는 PDF 의 보도자료 연락처 표 4열 → HWPX 서식 6열 (contact-table.ts)\n splitContactTables(blocks)\n // 후행 빈 열 정리 — HWP 계열 표 빌더와 같은 규칙 (병합 뒤: 쪽마다 같은 열 구조일 때 이어 붙인 다음)\n if (!options?.keepTrailingEmptyCols) trimTrailingEmptyTableCols(blocks)\n // 쪽 넘김으로 꺾인 본문 문단 잇기 — 머리말·꼬리말 제거 뒤, 쪽 끝 그림 주입 전 (line-wrap.ts)\n joinPageBreakWraps(blocks, wrapLexicon)\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 detectDocumentStyleHeadings(blocks)\n detectTypographyHeadings(blocks)\n detectSiblingStyleHeadings(blocks)\n detectRepeatedPageLabels(blocks)\n detectPageLeadHeadings(blocks)\n mergeStackedHeadingLines(blocks, medianFontSize)\n refineDocumentStyleHeadings(blocks)\n\n // □/■ 마커 기반 서브헤딩 감지 (ODL 패턴)\n detectMarkerHeadings(blocks)\n // 승격이 끝난 뒤 머리말·캡션·수식 번호 줄처럼 제목이 될 수 없는 역할을 되돌림\n demoteNonHeadingRoles(blocks, pageHeights, faceNames)\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 // 1×1 상자는 원문 기하에서 얻은 문단·중첩표 순서로 편다. keep 모드의 원 구조는 반환 blocks 에 남긴다.\n let outBlocks = splitSingleCellTables(blocks)\n if (options?.layoutTables !== \"keep\") {\n blocks.length = 0\n for (const block of outBlocks) blocks.push(block)\n }\n // 문서 끝에 모인 미주(해설)를 본문 참조 자리 뒤로 — HWPX·HWP5 출력과 같은 순서\n // 쪽 아래 각주를 참조 문단 끝 \" (주: …)\" 로 — HWPX·HWP5 출력과 같은 자리\n outBlocks = inlineFootnotes(outBlocks, noteMarks)\n outBlocks = relocateEndnotes(outBlocks)\n // 쪽번호는 쪽 위·아래 가장자리 띠의 숫자 문단뿐이다 — 본문 한가운데 홀로 선 숫자(차트 축 눈금 \"0\"·\"500\", 장 번호 \"2\")는 글이다\n // removeHeaderFooter: false 면 쪽번호도 글로 남긴다(머리글·바닥글과 같은 쪽 가장자리 띠)\n if (options?.removeHeaderFooter !== false) outBlocks = outBlocks.filter(b => {\n if (b.type !== \"paragraph\" || !/^\\s*\\d{1,4}\\s*$/.test(b.text ?? \"\")) return true\n const h = pageHeights.get(b.pageNumber ?? 0)\n if (!b.bbox || !h) return false\n return b.bbox.y > h * 0.1 && b.bbox.y + b.bbox.height < h * 0.9\n })\n joinLatinCellWraps(outBlocks)\n // 보이지 않는 틀 표 풀기 (v4.17.0, table/layout-frames) — 표 잇기(쪽 넘김·칸 이어짐)가 끝나 칸마다 보이는 변(cell-edges)이 제자리에\n // 있고 쪽번호 거르기(bbox 없는 문단을 버린다)도 지난 뒤. 새로 짠 표에는 PDF 표 곁정보(table-meta)가 없지만 여기서부터는 그 곁정보를\n // 보는 단계가 없다. 결과 blocks(쪽 넘김 잇기 뒤 원 목록)에도 같은 풀이를 쓴다 — 표 블록마다 한 번만 푼다(푸는 동안 칸을 제자리 고친다).\n // 클립 격자 풀기는 한컴 PDF 만 — 다른 제작기 클립 표는 테두리 없는 머리 행이 글로 풀려 표가 깨진다\n // (ODL 064 \"PORT | SHIPCALLS\" 머리 행: 한컴 원본 그림이 아니라 이 규칙의 정답이 없다)\n if (options?.layoutTables !== \"keep\") {\n const hancom = await isHancomPdf(doc)\n const shown = new Map<IRBlock, IRBlock[]>()\n // 그림 칸(IMAGE_CELLS)은 글이 비어도 내용이 있다 — 빈 여백 행으로 접히지 않게 (칸 안 표까지). 칸 글줄 기준선은 목차 행 짝짓기가 본다\n const markContent = (bs: IRBlock[] | undefined): void => {\n for (const b of bs ?? []) if (b.table) for (const row of b.table.cells) for (const c of row) {\n if (IMAGE_CELLS.has(c)) CONTENT_CELLS.add(c)\n const lines = CELL_LINES.get(c)\n if (lines) CELL_BASELINES.set(c, lines.map(l => l.y))\n markContent(c.blocks)\n }\n }\n const visual = (bs: IRBlock[]): IRBlock[] => bs.flatMap(b => {\n if (b.type !== \"table\") return [b]\n // 다른 제작기의 클립 머리행은 보존한다. 선 격자 밖에서 합성한 단위행은 제작기와 관계없이 문단이다.\n if (!hancom && b.table && CLIP_TABLES.has(b.table)) return [b]\n let v = shown.get(b)\n if (!v) { markContent([b]); shown.set(b, v = unframeLayoutTables([b], !!options?.keepTrailingEmptyCols)) }\n return v\n })\n outBlocks = visual(outBlocks)\n const kept = visual(blocks)\n blocks.length = 0\n for (const b of kept) blocks.push(b) // 큰 문서(수십만 블록)는 펼침 인자 상한을 넘는다\n }\n\n // blocksToMarkdown로 통일 — 헤딩 마크다운 반영 (HWP5/HWPX와 일관성)\n const finishMarkdown = (bs: IRBlock[]): string => mergeLinkRuns(cleanPdfText(blocksToMarkdown(bs, { tableFormat: options?.tableFormat }), { keepLoneNumbers: true }))\n // gfm 표 id 는 반환 blocks(IR) 순서로 먼저 — 마크다운용 outBlocks 는 같은 표 객체를 공유하므로 청크·쪽 렌더와 id 가 맞는다\n if (options?.tableFormat === \"gfm\") assignTableIds(blocks)\n let markdown = finishMarkdown(outBlocks)\n\n return {\n markdown,\n pages: blocksToPages(splitPageTables(splitPageBreakWraps(outBlocks)), finishMarkdown),\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 — Producer \"Hancom PDF 1.3.0.xxx\" 또는 Creator \"Hwp 2018·2020 …\" (법령 별표 273건 271건·표 정답 세트 한컴 산출물 실측).\n * 정보 사전을 못 읽거나 다른 제작기면 아니다 */\nasync function isHancomPdf(doc: { getMetadata(): Promise<unknown> }): Promise<boolean> {\n try {\n const info = (await doc.getMetadata() as { info?: Record<string, unknown> } | null)?.info\n return [info?.Producer, info?.Creator].some(v => typeof v === \"string\" && /^\\s*(?:hancom\\b|hwp\\b)/i.test(v))\n } catch {\n return false\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\n/** 쪽 여백의 세로 글 — 돌린 글 띠(시계 반대 90°는 기준점 왼쪽으로 글자 높이만큼)의 높이 범위에 놓인 가로 글이 모두 한쪽에만 있을 때 */\nfunction marginStamp(items: NormItem[]): NormItem[] {\n const rotated = items.filter(it => it.rotated)\n if (rotated.length === 0) return []\n const flat = items.filter(it => !it.rotated)\n if (flat.length < 20) return []\n const outside = (it: NormItem) => {\n const x1 = it.x - it.fontSize, x2 = it.x + it.w + it.fontSize\n const beside = flat.filter(f => f.y >= it.y && f.y <= it.y + it.rotated!)\n return beside.length >= 5 && (beside.every(f => f.x >= x2) || beside.every(f => f.x + f.w <= x1))\n }\n return rotated.every(outside) ? rotated : []\n}\n"]}