diff --git a/messages/de.json b/messages/de.json index c9ee37e66..70cf23d03 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9511,7 +9511,7 @@ "local-empty": "Es wurde kein lesbarer Text gefunden. Das sieht nach einem Scan aus; ein KI-Vision-Anbieter kann ihn lesen.", "local-unsupported": "Dieser Dateityp kann für die Suche nicht gelesen werden.", "decrypt-error": "Die gespeicherte Datei konnte zum Lesen nicht geöffnet werden.", - "raster-failed": "Die PDF-Seiten konnten zum Lesen nicht gerendert werden.", + "raster-failed": "Die PDF-Seiten konnten zum Lesen nicht gerendert werden. Gescannte Fax-Kompressionsformate sind eine bekannte Ursache.", "provider-error": "Der KI-Anbieter konnte dieses Dokument beim letzten Versuch nicht lesen.", "pdf-needs-anthropic": "Der eingerichtete KI-Anbieter kann keine PDF-Dateien lesen. Ein Claude-Anbieter oder lokale OCR kann es.", "empty-transcription": "Der KI-Anbieter hat für dieses Dokument keinen Text geliefert." diff --git a/messages/en.json b/messages/en.json index 865889b30..ef0b410b2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9511,7 +9511,7 @@ "local-empty": "No readable text was found. This looks like a scan; an AI vision provider can read it.", "local-unsupported": "This file type can't be read for search.", "decrypt-error": "The stored file couldn't be opened for reading.", - "raster-failed": "The PDF pages couldn't be rendered for reading.", + "raster-failed": "The PDF pages couldn't be rendered for reading. Scanned fax compression formats are a known cause.", "provider-error": "The AI provider couldn't read this document on the last try.", "pdf-needs-anthropic": "The configured AI provider can't read PDF files. A Claude provider or local OCR can.", "empty-transcription": "The AI provider returned no text for this document." diff --git a/messages/es.json b/messages/es.json index 91a02a17a..82a37da51 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9511,7 +9511,7 @@ "local-empty": "No se encontró texto legible. Parece un documento escaneado; un proveedor de visión de IA puede leerlo.", "local-unsupported": "Este tipo de archivo no se puede leer para la búsqueda.", "decrypt-error": "El archivo guardado no se pudo abrir para su lectura.", - "raster-failed": "Las páginas del PDF no se pudieron procesar para su lectura.", + "raster-failed": "Las páginas del PDF no se pudieron procesar para su lectura. Los formatos de compresión de fax escaneados son una causa conocida.", "provider-error": "El proveedor de IA no pudo leer este documento en el último intento.", "pdf-needs-anthropic": "El proveedor de IA configurado no puede leer archivos PDF. Un proveedor Claude o el OCR local sí pueden.", "empty-transcription": "El proveedor de IA no devolvió texto para este documento." diff --git a/messages/fr.json b/messages/fr.json index fa5c95084..5eeb7c25e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9511,7 +9511,7 @@ "local-empty": "Aucun texte lisible n’a été trouvé. Cela ressemble à un scan ; un fournisseur de vision IA peut le lire.", "local-unsupported": "Ce type de fichier ne peut pas être lu pour la recherche.", "decrypt-error": "Le fichier stocké n’a pas pu être ouvert pour la lecture.", - "raster-failed": "Les pages du PDF n’ont pas pu être rendues pour la lecture.", + "raster-failed": "Les pages du PDF n’ont pas pu être rendues pour la lecture. Les formats de compression de fax numérisés en sont une cause connue.", "provider-error": "Le fournisseur d’IA n’a pas pu lire ce document lors de la dernière tentative.", "pdf-needs-anthropic": "Le fournisseur d’IA configuré ne peut pas lire les fichiers PDF. Un fournisseur Claude ou l’OCR local le peut.", "empty-transcription": "Le fournisseur d’IA n’a renvoyé aucun texte pour ce document." diff --git a/messages/it.json b/messages/it.json index 6786af624..203b69f05 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9511,7 +9511,7 @@ "local-empty": "Non è stato trovato testo leggibile. Sembra una scansione; un provider di visione IA può leggerla.", "local-unsupported": "Questo tipo di file non può essere letto per la ricerca.", "decrypt-error": "Il file archiviato non può essere aperto per la lettura.", - "raster-failed": "Le pagine del PDF non possono essere elaborate per la lettura.", + "raster-failed": "Le pagine del PDF non possono essere elaborate per la lettura. I formati di compressione fax digitalizzati sono una causa nota.", "provider-error": "Il provider IA non è riuscito a leggere questo documento all’ultimo tentativo.", "pdf-needs-anthropic": "Il provider IA configurato non può leggere i file PDF. Un provider Claude o l’OCR locale sì.", "empty-transcription": "Il provider IA non ha restituito testo per questo documento." diff --git a/messages/pl.json b/messages/pl.json index 07c5e2a05..e0d72c8e7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9511,7 +9511,7 @@ "local-empty": "Nie znaleziono czytelnego tekstu. To wygląda na skan; dostawca wizji AI może go odczytać.", "local-unsupported": "Tego typu pliku nie można odczytać na potrzeby wyszukiwania.", "decrypt-error": "Nie udało się otworzyć zapisanego pliku do odczytu.", - "raster-failed": "Nie udało się przetworzyć stron PDF do odczytu.", + "raster-failed": "Nie udało się przetworzyć stron PDF do odczytu. Znaną przyczyną są skany w faksowych formatach kompresji.", "provider-error": "Dostawca AI nie zdołał odczytać tego dokumentu przy ostatniej próbie.", "pdf-needs-anthropic": "Skonfigurowany dostawca AI nie odczytuje plików PDF. Poradzi sobie z tym dostawca Claude lub lokalny OCR.", "empty-transcription": "Dostawca AI nie zwrócił tekstu dla tego dokumentu." diff --git a/src/lib/documents/__tests__/prepare-vision-raster-failure.test.ts b/src/lib/documents/__tests__/prepare-vision-raster-failure.test.ts new file mode 100644 index 000000000..05af83fdc --- /dev/null +++ b/src/lib/documents/__tests__/prepare-vision-raster-failure.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; +import { Buffer } from "node:buffer"; + +/** + * Refs #776 — the raster-failed chain, end to end through the REAL + * rasterizer: a PDF that pdfjs cannot open must surface as + * `{ ok: false, reason: "rasterFailed" }` from `prepareVisionInput` (for an + * image-only provider), which the index tree records as + * `lastIndexOutcome: "raster-failed"` — never silently, never mislabelled as + * "needs Anthropic". + * + * Fixture note (deliberate abstention, verified 2026-08-13): the reporter's + * residual scan is a CCITT Group 4 (`/CCITTFaxDecode`, `/K -1`) single-image + * PDF — a codec class pdfjs rasterization is known to fail on. A minimal + * programmatically generated CCITT-G4 PDF was probed against THIS repo's + * pdfjs: it renders the page as blank (`ok: true`, 1 page) rather than + * throwing, because pdfjs skips the undecodable image object — so a tiny + * in-repo CCITT fixture cannot deterministically go red on this version and + * would be flaky across pdfjs upgrades. The deterministic stand-in below (a + * PDF magic header over an unparseable body) exercises the same failure arm: + * pdfjs throws at open, `rasterizePdf` reports `render-failed` (with the + * pdfjs error text annotated), and the mapping to `rasterFailed` is what the + * outcome column persists. + */ + +vi.mock("@/lib/documents/store", () => ({ + decryptDocumentContent: vi.fn(), +})); + +import { prepareVisionInput } from "../ai-route-support"; +import { rasterizePdf } from "../rasterize-pdf"; +import { decryptDocumentContent } from "@/lib/documents/store"; + +/** PDF magic bytes over a body pdfjs cannot parse. */ +const UNRENDERABLE_PDF = Buffer.concat([ + Buffer.from("%PDF-1.4\n"), + Buffer.from(Array.from({ length: 64 }, (_, i) => (i * 37) % 256)), +]); + +const DOC = { + id: "doc-1", + kind: "OTHER", + contentEncrypted: new Uint8Array([1]), + contentCodec: "binary2", + mimeType: "application/pdf", + status: "STORED", +}; + +describe("raster-failed end to end (#776)", () => { + it("rasterizePdf reports render-failed for a PDF pdfjs cannot open", async () => { + const result = await rasterizePdf(UNRENDERABLE_PDF); + expect(result).toEqual({ ok: false, reason: "render-failed" }); + }); + + it("prepareVisionInput maps it to rasterFailed for an image-only provider", async () => { + vi.mocked(decryptDocumentContent).mockReturnValue( + UNRENDERABLE_PDF as never, + ); + // pdfSupported=false → the raster path runs for real. + const vision = await prepareVisionInput(DOC as never, false); + expect(vision).toEqual({ ok: false, reason: "rasterFailed" }); + }); + + it("a PDF-capable provider never rasterizes, so the same bytes stay readable natively", async () => { + vi.mocked(decryptDocumentContent).mockReturnValue( + UNRENDERABLE_PDF as never, + ); + const vision = await prepareVisionInput(DOC as never, true); + expect(vision).toMatchObject({ ok: true }); + }); +}); diff --git a/src/lib/documents/rasterize-pdf.ts b/src/lib/documents/rasterize-pdf.ts index 3b183fa6c..402f61695 100644 --- a/src/lib/documents/rasterize-pdf.ts +++ b/src/lib/documents/rasterize-pdf.ts @@ -198,7 +198,16 @@ export async function rasterizePdf( } } - if (images.length === 0) return { ok: false, reason: "render-failed" }; + if (images.length === 0) { + // Refs #776 — the zero-pages shape must be as loud as a throw: a + // document that "rendered" nothing is a failed rasterization, and the + // wide event is what lets an operator see WHY a scan stayed unread. + annotate({ + action: { name: "documents.rasterize.failed" }, + meta: { reason: "zero_pages", pageCount: doc.numPages }, + }); + return { ok: false, reason: "render-failed" }; + } annotate({ action: { name: "documents.rasterize.ok" }, meta: { pages: images.length, cappedFrom: doc.numPages },