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:
Jakob Wennberg
2026-05-07 11:32:50 +02:00
committed by GitHub
parent 3a74f0ab61
commit 8676f10fdd
5 changed files with 43 additions and 1881 deletions
@@ -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
}
}
-13
View File
@@ -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 [
{
+15 -1803
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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"