Import PDFs and Scans With Your Own Parser
Updog Importer reads CSV, TSV, XLSX, XLS, XLSB, ODS, JSON and XML, and it reads every one of them in the browser, on the machine that opened the page.
A PDF, a scanned document, a photograph of a table, or an export in a format one vendor invented reaches the same drop zone and finds no parser there. What reads a document today is a model or an OCR service, and either one takes a server, an API key, and a contract with whoever runs it.
Updog Importer lets you plug in your own parser. You declare the format and give it a function, every file that matches goes to that function, and the rows that come back walk the path a CSV walks.
What it takes to read a PDF
A PDF describes a page. It holds letters and numbers with a position for each of them, so a table inside it is an arrangement on that page, and the file carries no rows and no columns unless somebody tagged it with them. A reader has to put the table back together from those positions, decide where one column ends and the next begins, join a cell that wrapped onto a second line, and drop the header that repeats at the top of every page. Words arrive without spaces in them, because a gap between two words on a PDF page can be a jump to the next position with no character behind it.
A scan or a photograph carries no letters at all. It carries pixels, so something has to recognise the characters first, on a page that can be rotated, shadowed, or shot at an angle by a phone. The rows and columns are then inferred from that same picture.
Document parsers, OCR engines and vision models do that work, and it is why data extraction is bought as a service.
Where the document reader lives
Updog Importer runs no server. So the SDK gives you an API for doing that reading on your side.
The person drops the file into the importer. The SDK hands it to your function. Your function posts it to your endpoint. Your endpoint sends it to your parser. The parser answers with rows. Your endpoint returns those rows to your function. Your function returns them to the SDK. The SDK stages them as a preview card, and the import continues from there.
We are not in that chain. You already hold that data and answer for it, and the contract for reading it is one you can sign.
What a custom format claims
One customFormats entry holds a label, the extensions it claims, an optional list of MIME types, and the function that reads the file.
customFormats={[ { label: "PDF", extensions: [".pdf"], handle: async (file, { signal }) => { const body = new FormData(); body.append("file", file); const res = await fetch("/api/extract", { method: "POST", body, signal }); return res.json(); }, },]}That entry puts .pdf into the accept list for the drop zone, the browse button and the grid's own drop zone. A PDF dropped there gets a card right away with a skeleton where the preview will go, and Next stays disabled while that card is pending.
extensions matches the end of the file name whatever the case, and the leading dot is required, so [".pdf"] claims a document whatever the case of its name and ["pdf"] claims nothing. A photograph is easier to claim by MIME, and one entry with mimeTypes: ["image/*"] covers a PNG, a JPEG and a HEIC from a phone whatever the file is called. A file goes to the first format that claims it, and a file no format claims takes the built-in path unchanged.
What your service receives and returns
The handler runs in the person's browser, so the request to your service is yours to shape. The entry above posts the file as multipart form data and passes the abort signal straight to fetch.
Your endpoint receives that file and sends it to whatever reads documents for you, a hosted model, a self-hosted one, or a document parser with no AI in it. Name the keys you want back in the prompt, because those keys become the headers Updog scores against your schema.
// your serverexport async function POST(request: Request) { const form = await request.formData(); const file = form.get("file") as File;
const answer = await yourModel.extract({ document: await file.arrayBuffer(), instruction: "Read the table in this document. Return JSON, one object per row, " + "with the keys sku, description, quantity, unit and unitPrice.", });
return Response.json(answer.rows);}What travels back to the browser is an array of plain objects.
[ { "sku": "A-1042", "description": "Steel bracket", "quantity": 12, "unit": "pcs", "unitPrice": 4.5 }, { "sku": "A-1043", "description": "Steel bracket, wide", "quantity": 4, "unit": "pcs", "unitPrice": 6 }]The keys become the column headers, in the order they first appear across the array, and a key missing from one object reads as an empty cell in that row. A handler has three shapes to answer in.
Plain rows. A Record<string, unknown>[] stages one card under the file's name, which is the shape a single-table document returns. Name the keys the way your schema names its columns and the matcher does the rest.
Named tables. A { name, rows }[] stages one card per table. One document can hold several tables.
[ { "name": "Line items", "rows": [{ "sku": "A-1042", "quantity": 12 }] }, { "name": "Totals", "rows": [{ "label": "VAT", "amount": 61.2 }] }]A file. A File in a format the SDK reads goes through the built-in parser, and its card takes that file's name. Hand one back when your service emits clean CSV or XLSX.
What runs while an extraction waits
Built-in reads run one file at a time, in drop order, through a single worker, which keeps a million-row XLSX from competing with three other files for memory. Custom handlers run outside that queue and beside each other. Reading a file is bounded by the machine. Extracting a document is bounded by somebody else's service.
Drop three spreadsheets and a scan together and the spreadsheets read while the scan is still out. The scan's card sits pending with a cancel button in its corner. The person clicking that button aborts the signal your handler received, and a result that arrives after the cancellation is discarded. Pass that signal to your fetch so the request to your server stops with it.
Updog Importer puts no timeout on handle. An extraction takes as long as your service takes, and any number we picked would be invented, so the person cancels by hand. A handler that ignores the signal keeps running.
What the person sees when a document fails
Throwing fails the file, and the message you throw reaches the screen word for word. "This scan is too blurry to read. Try a clearer copy." arrives exactly like that, the card leaves the list, and the rest of the batch keeps going. Catch the low-level error and throw a sentence a person can act on, which keeps a stack trace and your endpoint name off the screen. A throw with no message falls back to "Could not process the file".
An empty array is a different outcome. The document was read and held no table, and the person is told that no tables were found.
Underneath both, your onError receives a PARSE_ERROR with the source set to custom-handler and the original thrown value in originalError. The person reads a plain sentence and your monitoring gets the real error. The prop reference sits in the import docs.
Conclusion
PDFs and images are one use of this. A custom format takes any file type your product has to support, and the path through the importer is the same one. If you have a way to parse that format, Updog Importer can import files in it.