{"version":3,"file":"pdf-3rCh0vDg.mjs","names":[],"sources":["../src/lib/pdf.ts"],"sourcesContent":["/**\n * PDF text extraction for Anthropic `document` blocks.\n *\n * Why this exists: GitHub Copilot has no native PDF understanding. Claude Code\n * emits raw `document` blocks (with base64-encoded PDF bytes) for attachments\n * <=3 MB; for larger PDFs it pre-extracts to images on its side. Since Copilot\n * also can't reliably understand images of PDF pages without significant\n * cost, we take the pragmatic approach: extract text in the proxy and inject\n * as a text block.\n *\n * Trade-offs (documented in README):\n *   - Visual content (diagrams, scans, image-only PDFs) is lost.\n *   - Tables/columns may flow incorrectly.\n *   - Password-protected PDFs are rejected with a Claude-Code-recognizable\n *     error message so the user gets actionable feedback.\n */\n\nimport consola from \"consola\"\nimport { PDFParse } from \"pdf-parse\"\n\nimport { HTTPError } from \"./error\"\n\n/** Claude Code's `errors.ts` matches \"password\" + \"protected\" / \"encrypted\"\n *  to surface a friendly UX. Use a phrase that hits that pattern. */\nconst PASSWORD_PROTECTED_MESSAGE =\n  \"Cannot read password-protected or encrypted PDF document\"\n\nconst INVALID_PDF_MESSAGE = \"Invalid or corrupted PDF document\"\n\nconst EMPTY_PDF_MESSAGE = \"PDF document contains no extractable text\"\n\n/** Trim text block to keep payloads sane. Most PDFs we see are <100 pages. */\nconst MAX_EXTRACTED_CHARS = 500_000\n\nexport interface ExtractedPdf {\n  text: string\n  pageCount: number\n  truncated: boolean\n}\n\nfunction decodeBase64(data: string): Uint8Array {\n  // Bun.atob returns a binary string; convert to Uint8Array.\n  const binary = atob(data)\n  const bytes = new Uint8Array(binary.length)\n  for (let i = 0; i < binary.length; i++) {\n    bytes[i] = binary.codePointAt(i) ?? 0\n  }\n  return bytes\n}\n\nfunction isLikelyPasswordProtected(error: unknown): boolean {\n  const msg = (\n    error instanceof Error ?\n      error.message\n    : String(error)).toLowerCase()\n  return (\n    msg.includes(\"password\")\n    || msg.includes(\"encrypted\")\n    || (msg.includes(\"invalid pdf\") && msg.includes(\"encrypt\"))\n  )\n}\n\nfunction pdfErrorResponse(message: string): HTTPError {\n  return new HTTPError(\n    message,\n    new Response(\n      JSON.stringify({\n        type: \"error\",\n        error: {\n          type: \"invalid_request_error\",\n          message,\n        },\n      }),\n      {\n        status: 400,\n        statusText: \"Bad Request\",\n        headers: { \"content-type\": \"application/json\" },\n      },\n    ),\n  )\n}\n\n/**\n * Extract text from a base64-encoded PDF. Throws HTTPError (Anthropic-shaped)\n * for invalid / password-protected / empty PDFs.\n */\nexport async function extractPdfText(\n  base64Data: string,\n): Promise<ExtractedPdf> {\n  let bytes: Uint8Array\n  try {\n    bytes = decodeBase64(base64Data)\n  } catch {\n    throw pdfErrorResponse(INVALID_PDF_MESSAGE)\n  }\n\n  // Magic byte check: PDFs start with \"%PDF-\"\n  if (\n    bytes.length < 5\n    || bytes[0] !== 0x25\n    || bytes[1] !== 0x50\n    || bytes[2] !== 0x44\n    || bytes[3] !== 0x46\n    || bytes[4] !== 0x2d\n  ) {\n    throw pdfErrorResponse(INVALID_PDF_MESSAGE)\n  }\n\n  let parsed: { text: string; total: number }\n  try {\n    const parser = new PDFParse({ data: bytes })\n    const result = await parser.getText()\n    parsed = { text: result.text, total: result.total }\n  } catch (error) {\n    consola.warn(\"PDF parse failed:\", error)\n    if (isLikelyPasswordProtected(error)) {\n      throw pdfErrorResponse(PASSWORD_PROTECTED_MESSAGE)\n    }\n    throw pdfErrorResponse(INVALID_PDF_MESSAGE)\n  }\n\n  const text = parsed.text.trim()\n  if (text.length === 0) {\n    throw pdfErrorResponse(EMPTY_PDF_MESSAGE)\n  }\n\n  const truncated = text.length > MAX_EXTRACTED_CHARS\n  return {\n    text:\n      truncated ?\n        text.slice(0, MAX_EXTRACTED_CHARS)\n        + \"\\n\\n[…document truncated for length…]\"\n      : text,\n    pageCount: parsed.total,\n    truncated,\n  }\n}\n"],"mappings":"qGAwBA,MAGM,EAAsB,oCAKtB,EAAsB,IAQ5B,SAAS,EAAa,EAA0B,CAE9C,IAAM,EAAS,KAAK,EAAK,CACnB,EAAQ,IAAI,WAAW,EAAO,OAAO,CAC3C,IAAK,IAAI,EAAI,EAAG,EAAI,EAAO,OAAQ,IACjC,EAAM,GAAK,EAAO,YAAY,EAAE,EAAI,EAEtC,OAAO,EAGT,SAAS,EAA0B,EAAyB,CAC1D,IAAM,GACJ,aAAiB,MACf,EAAM,QACN,OAAO,EAAM,EAAE,aAAa,CAChC,OACE,EAAI,SAAS,WAAW,EACrB,EAAI,SAAS,YAAY,EACxB,EAAI,SAAS,cAAc,EAAI,EAAI,SAAS,UAAU,CAI9D,SAAS,EAAiB,EAA4B,CACpD,OAAO,IAAI,EACT,EACA,IAAI,SACF,KAAK,UAAU,CACb,KAAM,QACN,MAAO,CACL,KAAM,wBACN,UACD,CACF,CAAC,CACF,CACE,OAAQ,IACR,WAAY,cACZ,QAAS,CAAE,eAAgB,mBAAoB,CAChD,CACF,CACF,CAOH,eAAsB,EACpB,EACuB,CACvB,IAAI,EACJ,GAAI,CACF,EAAQ,EAAa,EAAW,MAC1B,CACN,MAAM,EAAiB,EAAoB,CAI7C,GACE,EAAM,OAAS,GACZ,EAAM,KAAO,IACb,EAAM,KAAO,IACb,EAAM,KAAO,IACb,EAAM,KAAO,IACb,EAAM,KAAO,GAEhB,MAAM,EAAiB,EAAoB,CAG7C,IAAI,EACJ,GAAI,CAEF,IAAM,EAAS,MADA,IAAI,EAAS,CAAE,KAAM,EAAO,CAAC,CAChB,SAAS,CACrC,EAAS,CAAE,KAAM,EAAO,KAAM,MAAO,EAAO,MAAO,OAC5C,EAAO,CAKd,MAJA,EAAQ,KAAK,oBAAqB,EAAM,CACpC,EAA0B,EAAM,CAC5B,EAAiB,2DAA2B,CAE9C,EAAiB,EAAoB,CAG7C,IAAM,EAAO,EAAO,KAAK,MAAM,CAC/B,GAAI,EAAK,SAAW,EAClB,MAAM,EAAiB,4CAAkB,CAG3C,IAAM,EAAY,EAAK,OAAS,EAChC,MAAO,CACL,KACE,EACE,EAAK,MAAM,EAAG,EAAoB,CAChC;;mCACF,EACJ,UAAW,EAAO,MAClB,YACD"}