fix(invoice-inbox): switch from pdfjs-dist to unpdf (#409)
* fix(invoice-inbox): switch from pdfjs-dist to unpdf for PDF text extraction After three rounds of fighting pdfjs on Vercel (#407 stubbed DOM globals, #408 tried to ship the worker file via outputFileTracingIncludes), text extraction still failed in prod with "Setting up fake worker failed" — Next's tracer can't reliably include pdfjs-dist's worker file when the package is marked as a server external. unpdf is a serverless-first wrapper around pdfjs (by unjs) that ships its own bundled pdfjs build with no canvas/worker dependencies. Drop-in replacement: extractText returns merged page text directly. - Remove DOM stubs, serverExternalPackages, outputFileTracingIncludes - Replace pdfjs-dist with unpdf (no transitive deps) - Update test mock from getDocument → extractText All 45 invoice-inbox unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): make unpdf import static, rename pdfjs test labels Per Greptile review on #409: 1. CRITICAL: tryExtractPdfText was still using await import('unpdf'), a dynamic import. CLAUDE.md forbids dynamic imports in extensions precisely because Next.js bundling can't reliably trace them — which is the same class of failure that caused the pdfjs prod bug. unpdf bundles statically (no canvas/worker), so a top-level static import is safe and correct. 2. NIT: two test descriptions still said "pdfjs" after the mock rename. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,27 +1,16 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
|
||||
|
||||
// Mock pdfjs-dist so we can drive the regex extractors with canned text
|
||||
// Mock unpdf so we can drive the regex extractors with canned text
|
||||
// without building actual PDF binaries.
|
||||
const mockGetDocument = vi.fn()
|
||||
const mockExtractText = vi.fn()
|
||||
|
||||
vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
|
||||
getDocument: (...args: unknown[]) => mockGetDocument(...args),
|
||||
vi.mock('unpdf', () => ({
|
||||
extractText: (...args: unknown[]) => mockExtractText(...args),
|
||||
}))
|
||||
|
||||
function fakePdf(text: string) {
|
||||
return {
|
||||
promise: Promise.resolve({
|
||||
numPages: 1,
|
||||
getPage: () =>
|
||||
Promise.resolve({
|
||||
getTextContent: () =>
|
||||
Promise.resolve({
|
||||
items: text.split(/\s+/).map((str) => ({ str })),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
return Promise.resolve({ totalPages: 1, text })
|
||||
}
|
||||
|
||||
describe('extractInvoiceFields', () => {
|
||||
@@ -40,8 +29,8 @@ describe('extractInvoiceFields', () => {
|
||||
expect(data.supplier.orgNumber).toBeNull()
|
||||
})
|
||||
|
||||
it('returns empty result when pdfjs extracts no text (image-only PDF)', async () => {
|
||||
mockGetDocument.mockReturnValueOnce(fakePdf(''))
|
||||
it('returns empty result when unpdf extracts no text (image-only PDF)', async () => {
|
||||
mockExtractText.mockReturnValueOnce(fakePdf(''))
|
||||
const { data, rawText } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
@@ -53,7 +42,7 @@ describe('extractInvoiceFields', () => {
|
||||
|
||||
it('extracts a Luhn-valid org number', async () => {
|
||||
// 5560125790 is a valid Swedish AB org-nr (Luhn-checked)
|
||||
mockGetDocument.mockReturnValueOnce(fakePdf('Lev: Acme AB Org.nr 556012-5790 Faktura'))
|
||||
mockExtractText.mockReturnValueOnce(fakePdf('Lev: Acme AB Org.nr 556012-5790 Faktura'))
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
@@ -63,7 +52,7 @@ describe('extractInvoiceFields', () => {
|
||||
})
|
||||
|
||||
it('rejects org-nrs with bad Luhn digit', async () => {
|
||||
mockGetDocument.mockReturnValueOnce(fakePdf('Org.nr 556012-5791'))
|
||||
mockExtractText.mockReturnValueOnce(fakePdf('Org.nr 556012-5791'))
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
@@ -73,7 +62,7 @@ describe('extractInvoiceFields', () => {
|
||||
})
|
||||
|
||||
it('extracts a Luhn-valid OCR reference', async () => {
|
||||
mockGetDocument.mockReturnValueOnce(fakePdf('OCR-nummer: 12345674'))
|
||||
mockExtractText.mockReturnValueOnce(fakePdf('OCR-nummer: 12345674'))
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
@@ -84,7 +73,7 @@ describe('extractInvoiceFields', () => {
|
||||
|
||||
it('extracts a Luhn-valid bankgiro', async () => {
|
||||
// 991-2346 is the canonical test bankgiro (Luhn-valid) used in lib/bankgiro/__tests__
|
||||
mockGetDocument.mockReturnValueOnce(fakePdf('Bankgiro 991-2346 Plusgiro'))
|
||||
mockExtractText.mockReturnValueOnce(fakePdf('Bankgiro 991-2346 Plusgiro'))
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
@@ -94,7 +83,7 @@ describe('extractInvoiceFields', () => {
|
||||
})
|
||||
|
||||
it('parses Swedish-formatted totals', async () => {
|
||||
mockGetDocument.mockReturnValueOnce(
|
||||
mockExtractText.mockReturnValueOnce(
|
||||
fakePdf('Att betala 12 345,67 kr')
|
||||
)
|
||||
const { data } = await extractInvoiceFields({
|
||||
@@ -106,7 +95,7 @@ describe('extractInvoiceFields', () => {
|
||||
})
|
||||
|
||||
it('parses Förfallodatum and normalizes to ISO', async () => {
|
||||
mockGetDocument.mockReturnValueOnce(fakePdf('Förfallodatum 2026-06-15'))
|
||||
mockExtractText.mockReturnValueOnce(fakePdf('Förfallodatum 2026-06-15'))
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
@@ -116,7 +105,7 @@ describe('extractInvoiceFields', () => {
|
||||
})
|
||||
|
||||
it('extracts an invoice number after Fakturanr', async () => {
|
||||
mockGetDocument.mockReturnValueOnce(fakePdf('Fakturanr F-2024-001 Datum'))
|
||||
mockExtractText.mockReturnValueOnce(fakePdf('Fakturanr F-2024-001 Datum'))
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
@@ -126,7 +115,7 @@ describe('extractInvoiceFields', () => {
|
||||
})
|
||||
|
||||
it('keeps SEK as default currency when no foreign code is present', async () => {
|
||||
mockGetDocument.mockReturnValueOnce(fakePdf('Total 100 kr'))
|
||||
mockExtractText.mockReturnValueOnce(fakePdf('Total 100 kr'))
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
@@ -135,8 +124,8 @@ describe('extractInvoiceFields', () => {
|
||||
expect(data.invoice.currency).toBe('SEK')
|
||||
})
|
||||
|
||||
it('returns empty result when pdfjs throws', async () => {
|
||||
mockGetDocument.mockImplementationOnce(() => {
|
||||
it('returns empty result when unpdf throws', async () => {
|
||||
mockExtractText.mockImplementationOnce(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
const { data, rawText } = await extractInvoiceFields({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Deterministic Swedish invoice field extraction.
|
||||
//
|
||||
// Replaces the deleted AI classifier. We pull text out of the PDF with
|
||||
// pdfjs-dist and run regex extractors against it. Each extractor is
|
||||
// independent — a missing field stays null rather than dragging down a
|
||||
// neighbour. Validators (Luhn for org-nr/OCR/bankgiro) keep false
|
||||
// positives near zero.
|
||||
// unpdf (a serverless-friendly pdfjs wrapper) and run regex extractors
|
||||
// against it. Each extractor is independent — a missing field stays null
|
||||
// rather than dragging down a neighbour. Validators (Luhn for
|
||||
// org-nr/OCR/bankgiro) keep false positives near zero.
|
||||
//
|
||||
// Image-only PDFs and non-PDF mime types come back with all fields null.
|
||||
// The inbox item is still created so the user can register manually.
|
||||
@@ -12,21 +12,11 @@
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
||||
import { validateOcrReference, validateBankgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
|
||||
// pdfjs-dist references DOMMatrix/ImageData/Path2D at module load. On
|
||||
// Vercel's Node runtime these globals don't exist; without stubs the
|
||||
// dynamic import throws "DOMMatrix is not defined" before getDocument()
|
||||
// runs. We only call getTextContent (no rendering), so empty-class stubs
|
||||
// are enough — pdfjs never invokes any methods on them.
|
||||
{
|
||||
const g = globalThis as unknown as { DOMMatrix?: unknown; ImageData?: unknown; Path2D?: unknown }
|
||||
if (typeof g.DOMMatrix === 'undefined') g.DOMMatrix = class {}
|
||||
if (typeof g.ImageData === 'undefined') g.ImageData = class {}
|
||||
if (typeof g.Path2D === 'undefined') g.Path2D = class {}
|
||||
}
|
||||
import { extractText } from 'unpdf'
|
||||
|
||||
// Below this we treat the document as image-only / unreadable and skip
|
||||
// regex extraction. pdfjs-dist returns near-zero text for scanned PDFs.
|
||||
// regex extraction. The PDF text extractor returns near-zero text for
|
||||
// scanned PDFs.
|
||||
const MIN_TEXT_CHARS_FOR_EXTRACTION = 10
|
||||
|
||||
export interface ExtractionInput {
|
||||
@@ -108,26 +98,10 @@ async function tryExtractPdfText(input: ExtractionInput): Promise<string | null>
|
||||
if (input.mimeType !== 'application/pdf') return null
|
||||
|
||||
try {
|
||||
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs')
|
||||
const loadingTask = pdfjs.getDocument({
|
||||
data: new Uint8Array(input.buffer),
|
||||
isEvalSupported: false,
|
||||
disableFontFace: true,
|
||||
})
|
||||
const pdf = await loadingTask.promise
|
||||
|
||||
const pages: string[] = []
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i)
|
||||
const content = await page.getTextContent()
|
||||
const pageText = content.items
|
||||
.map((item) => ('str' in item ? item.str : ''))
|
||||
.join(' ')
|
||||
pages.push(pageText)
|
||||
}
|
||||
return pages.join('\n').replace(/[ \t]+/g, ' ').trim()
|
||||
const { text } = await extractText(new Uint8Array(input.buffer), { mergePages: true })
|
||||
return text.replace(/[ \t]+/g, ' ').trim()
|
||||
} catch (err) {
|
||||
console.warn('[invoice-inbox/extract] pdfjs failed:', err instanceof Error ? err.message : err)
|
||||
console.warn('[invoice-inbox/extract] pdf text extraction failed:', err instanceof Error ? err.message : err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,19 +20,6 @@ const cspDirectives = [
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
// Keep pdfjs-dist out of the bundle. The legacy build references
|
||||
// @napi-rs/canvas at module load and breaks Vercel's bundling step.
|
||||
// Text extraction works in pure Node once DOM globals are stubbed at
|
||||
// module load (see extensions/general/invoice-inbox/lib/extract-invoice-fields.ts).
|
||||
serverExternalPackages: ['pdfjs-dist'],
|
||||
// Force the pdfjs worker file into the function bundle. Next.js's tracer
|
||||
// can't see it (loaded dynamically by name), so without this it's missing
|
||||
// in /var/task and getDocument() fails with "Setting up fake worker failed".
|
||||
// Scoped to the invoice-inbox sub-path — other extensions don't use pdfjs
|
||||
// and shouldn't pay the ~1 MB worker cost.
|
||||
outputFileTracingIncludes: {
|
||||
'/api/extensions/ext/invoice-inbox/**': ['./node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs'],
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
|
||||
Generated
+15
-1803
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -45,7 +45,6 @@
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdfjs-dist": "^5.4.530",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
@@ -57,6 +56,7 @@
|
||||
"sharp": "^0.34.5",
|
||||
"svix": "^1.85.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"unpdf": "^1.6.2",
|
||||
"web-push": "^3.6.7",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
Reference in New Issue
Block a user