From 77d464da24785598b3e90deb8eebbbed0a44ff90 Mon Sep 17 00:00:00 2001 From: Gerard-Devlin Date: Mon, 8 Jun 2026 01:32:22 +0800 Subject: [PATCH] Extract PDF attachment text with MuPDF --- .../components/claude-chat/chat-composer.tsx | 131 ++++++++++++----- apps/desktop/src/lib/pdf-text-extractor.ts | 133 ++++++++++++++++++ 2 files changed, 227 insertions(+), 37 deletions(-) create mode 100644 apps/desktop/src/lib/pdf-text-extractor.ts diff --git a/apps/desktop/src/components/claude-chat/chat-composer.tsx b/apps/desktop/src/components/claude-chat/chat-composer.tsx index c283062..623c2bd 100644 --- a/apps/desktop/src/components/claude-chat/chat-composer.tsx +++ b/apps/desktop/src/components/claude-chat/chat-composer.tsx @@ -35,6 +35,7 @@ import { } from "@/stores/claude-chat-store"; import { useDocumentStore, type ProjectFile } from "@/stores/document-store"; import { getUniqueTargetName } from "@/lib/tauri/fs"; +import { createPdfTextSidecar, isPdfPath } from "@/lib/pdf-text-extractor"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { cn } from "@/lib/utils"; import { SlashCommandPicker, type SlashCommand } from "./slash-command-picker"; @@ -246,8 +247,56 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { .catch(() => setSlashCommands([])); }, [slashQuery !== null, projectRoot]); + const buildPinnedContextForFile = useCallback( + async ( + file: ProjectFile, + ): Promise<{ context: PinnedContext; createdSidecar: boolean }> => { + if (projectRoot && file.type === "pdf") { + try { + const sidecar = await createPdfTextSidecar( + projectRoot, + file.relativePath, + file.absolutePath, + ); + + return { + context: { + label: `@${file.relativePath}`, + filePath: sidecar.sidecarRelativePath, + selectedText: sidecar.contextText, + }, + createdSidecar: true, + }; + } catch (err) { + log.error("Failed to extract PDF text", { + path: file.relativePath, + error: String(err), + }); + } + } + + const isTextFile = + file.type === "tex" || + file.type === "bib" || + file.type === "style" || + file.type === "other"; + + return { + context: { + label: `@${file.relativePath}`, + filePath: file.relativePath, + selectedText: isTextFile + ? (file.content ?? "") + : `[Referenced file: ${file.relativePath} (${file.type} file)]`, + }, + createdSidecar: false, + }; + }, + [projectRoot], + ); + const selectMention = useCallback( - (file: ProjectFile) => { + async (file: ProjectFile) => { // Replace @query with empty and pin the file as context const textarea = textareaRef.current; if (!textarea) return; @@ -261,26 +310,16 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { setMentionQuery(null); // Pin the whole file as context - const isTextFile = - file.type === "tex" || - file.type === "bib" || - file.type === "style" || - file.type === "other"; - setPinnedContexts((prev) => [ - ...prev, - { - label: `@${file.relativePath}`, - filePath: file.relativePath, - selectedText: isTextFile - ? (file.content ?? "") - : `[Referenced file: ${file.relativePath} (${file.type} file)]`, - }, - ]); + const { context, createdSidecar } = await buildPinnedContextForFile(file); + if (createdSidecar) { + await refreshFiles(); + } + setPinnedContexts((prev) => [...prev, context]); // Refocus textarea setTimeout(() => textarea.focus(), 0); }, - [input], + [buildPinnedContextForFile, input, refreshFiles], ); const selectSlashCommand = useCallback((command: SlashCommand) => { @@ -323,6 +362,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { // Pin each file as context const storeFiles = useDocumentStore.getState().files; const newContexts: PinnedContext[] = []; + let createdPdfSidecar = false; for (const relativePath of importedPaths) { const imported = storeFiles.find( @@ -330,18 +370,10 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { ); if (imported) { - const isText = - imported.type === "tex" || - imported.type === "bib" || - imported.type === "style" || - imported.type === "other"; - newContexts.push({ - label: `@${relativePath}`, - filePath: relativePath, - selectedText: isText - ? (imported.content ?? "") - : `[Attached file: ${relativePath} (${imported.type} file)]`, - }); + const { context, createdSidecar } = + await buildPinnedContextForFile(imported); + createdPdfSidecar ||= createdSidecar; + newContexts.push(context); } else { // File imported but type might be filtered out — still pin as reference newContexts.push({ @@ -353,6 +385,10 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { } if (newContexts.length > 0) { + if (createdPdfSidecar) { + await refreshFiles(); + } + setPinnedContexts((prev) => { // Deduplicate by label const existingLabels = new Set(prev.map((c) => c.label)); @@ -450,15 +486,36 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { const buffer = await file.arrayBuffer(); await writeFile(fullPath, new Uint8Array(buffer)); - // Determine if it's a text file - const isText = file.type.startsWith("text/"); - const content = isText - ? await file.text() - : `[Attached file: ${uniqueName} (${file.type})]`; + let contextFilePath = uniqueName; + let content: string; + + if (isPdfPath(uniqueName) || file.type === "application/pdf") { + try { + const sidecar = await createPdfTextSidecar( + projectRoot, + uniqueName, + fullPath, + ); + contextFilePath = sidecar.sidecarRelativePath; + content = sidecar.contextText; + } catch (err) { + log.error("Failed to extract pasted PDF text", { + fileName: uniqueName, + error: String(err), + }); + content = `[Attached file: ${uniqueName} (${file.type})]`; + } + } else { + // Determine if it's a text file + const isText = file.type.startsWith("text/"); + content = isText + ? await file.text() + : `[Attached file: ${uniqueName} (${file.type})]`; + } newContexts.push({ label: `@${uniqueName}`, - filePath: uniqueName, + filePath: contextFilePath, selectedText: content, }); } catch (err) { @@ -564,7 +621,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); - selectMention(mentionFiles[mentionIndex]); + void selectMention(mentionFiles[mentionIndex]); return; } if (e.key === "Escape") { @@ -804,7 +861,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { )} onMouseDown={(e) => { e.preventDefault(); // prevent textarea blur - selectMention(file); + void selectMention(file); }} onMouseEnter={() => setMentionIndex(i)} > diff --git a/apps/desktop/src/lib/pdf-text-extractor.ts b/apps/desktop/src/lib/pdf-text-extractor.ts new file mode 100644 index 0000000..fa096b6 --- /dev/null +++ b/apps/desktop/src/lib/pdf-text-extractor.ts @@ -0,0 +1,133 @@ +import { join } from "@tauri-apps/api/path"; +import { readFile, writeTextFile } from "@tauri-apps/plugin-fs"; +import { getMupdfClient } from "@/lib/mupdf/mupdf-client"; +import type { StructuredTextData } from "@/lib/mupdf/types"; + +const PDF_CONTEXT_CHAR_LIMIT = 80_000; + +export interface PdfTextSidecar { + pageCount: number; + sidecarRelativePath: string; + sidecarAbsolutePath: string; + sidecarContent: string; + contextText: string; +} + +export function isPdfPath(path: string): boolean { + return path.toLowerCase().endsWith(".pdf"); +} + +function structuredTextToPlainText(data: StructuredTextData): string { + const lines: string[] = []; + + for (const block of data.blocks || []) { + if (block.type !== "text") continue; + + let addedBlockLine = false; + for (const line of block.lines || []) { + const text = line.text?.trimEnd(); + if (!text) continue; + + lines.push(text); + addedBlockLine = true; + } + + if (addedBlockLine) { + lines.push(""); + } + } + + return lines.join("\n").trim(); +} + +async function extractPdfText(absolutePath: string): Promise<{ + pageCount: number; + pages: string[]; +}> { + const bytes = await readFile(absolutePath); + const buffer = new Uint8Array(bytes).buffer; + const client = getMupdfClient(); + const docId = await client.openDocument(buffer, "application/pdf"); + + try { + const pageCount = await client.countPages(docId); + const pages: string[] = []; + + for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) { + const structuredText = await client.getPageText(docId, pageIndex); + pages.push(structuredTextToPlainText(structuredText)); + } + + return { pageCount, pages }; + } finally { + await client.closeDocument(docId).catch(() => {}); + } +} + +function buildSidecarContent( + pdfRelativePath: string, + pageCount: number, + pages: string[], +): string { + const body = pages + .map((text, index) => { + const pageText = text.trim() || "[No extractable text on this page]"; + return `## Page ${index + 1}\n\n${pageText}`; + }) + .join("\n\n---\n\n"); + + return [ + `# Extracted PDF Text: ${pdfRelativePath}`, + "", + `Pages: ${pageCount}`, + "", + body, + "", + ].join("\n"); +} + +function buildContextText( + pdfRelativePath: string, + sidecarRelativePath: string, + pageCount: number, + sidecarContent: string, +): string { + const truncated = + sidecarContent.length > PDF_CONTEXT_CHAR_LIMIT + ? `${sidecarContent.slice(0, PDF_CONTEXT_CHAR_LIMIT)}\n\n[Truncated for chat context. Full extracted text is available at ${sidecarRelativePath}.]` + : sidecarContent; + + return [ + `[PDF attachment: ${pdfRelativePath}]`, + `[ClaudePrism extracted ${pageCount} page(s) with built-in MuPDF.]`, + `[Use this extracted text first. If you need more, read ${sidecarRelativePath}; do not rely on the raw PDF reader unless Poppler is installed.]`, + "", + truncated, + ].join("\n"); +} + +export async function createPdfTextSidecar( + projectRoot: string, + pdfRelativePath: string, + pdfAbsolutePath: string, +): Promise { + const { pageCount, pages } = await extractPdfText(pdfAbsolutePath); + const sidecarRelativePath = `${pdfRelativePath}.txt`; + const sidecarAbsolutePath = await join(projectRoot, sidecarRelativePath); + const sidecarContent = buildSidecarContent(pdfRelativePath, pageCount, pages); + + await writeTextFile(sidecarAbsolutePath, sidecarContent); + + return { + pageCount, + sidecarRelativePath, + sidecarAbsolutePath, + sidecarContent, + contextText: buildContextText( + pdfRelativePath, + sidecarRelativePath, + pageCount, + sidecarContent, + ), + }; +}