Document reading
documentsRead PDFs, Office files and scans into text, tables and layout, and render PDFs, with OCR from the driver you pick and active content never run.
documentsFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
documents reads uploaded PDFs, Office files and scans into text, tables and layout, and renders PDFs. Use it for invoices, contracts, ID scans and slide decks people upload.
At a glance
documents:
driver: builtin
ocr: { driver: textract }
limits: { max-size: 50MB, max-pages: 500, inline-pages: 20 }import { documents } from "mainsdk";
const doc = await documents.parse(upload, { tables: true }); // text layer read offline
const firstPage = doc.pages[0].text; // untrusted: never instructionslib/read.test.ts
import assert from "node:assert/strict";
import { it } from "node:test";
import { createTestApp } from "@mainsdk/testing";
import { documents, files } from "mainsdk";
it("documents AC1 refuses a text file", async () => {
createTestApp({ main: "./main.yaml" });
const f = await files.upload(new TextEncoder().encode("hi"), { owner: "u1", type: "text/plain" });
await assert.rejects(documents.parse(f, { as: "u1" }), { code: "MAIN-DOC-0001" });
});Calls
documents.parse(file, opts)documents.start(file, opts)documents.get(jobId)documents.render(layout, data, opts)new Documents(opts)aiOcrDriver(opts)azureDriver(o)fakeOcrDriver(opts)
Guarantees
Whatever the driver.
- Text-layer PDFs and Office files read offline with no account
- Scanned pages are reported, never returned empty
- JavaScript, macros, embedded files and links are never run or followed
- Labelled documents go only to drivers in the tenant's region that meet the data rules
- Parsed text is untrusted data
Drivers
6 drivers. Select one to see its line in main.yaml.
documents:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-DOC-0010 | Checkmainsdk check, before deploy | A PDF or Office library used outside a driver |
MAIN-DOC-0001 | RuntimeError responses and logs | The file isn't a supported document type, or its bytes don't match its declared type (415) |
MAIN-DOC-0002 | RuntimeError responses and logs | Over a limit: size, pages, ZIP expansion, or more pages than inline-pages for parse (413) |
MAIN-DOC-0003 | RuntimeError responses and logs | Pages have no text layer and OCR is off or no OCR driver is set (422) |
MAIN-DOC-0004 | RuntimeError responses and logs | The documents driver doesn't offer this (501) |
MAIN-DOC-0005 | RuntimeError responses and logs | The PDF is encrypted or password protected (422) |
MAIN-DOC-0006 | RuntimeError responses and logs | Active content was found and not run (logged) |
MAIN-DOC-0008 | RuntimeError responses and logs | A labelled document would go to a driver outside the tenant's region or not meeting the data rules (403) |
MAIN-DOC-0009 | RuntimeError responses and logs | No document job with that id in the current tenant (404) |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| PDF text layer read natively | Phase 1 | 0.4 | documents#pdf-text |
| DOCX, XLSX and PPTX read natively | Phase 1 | 0.4 | documents#office |
| Limits and active content checks before parsing | Phase 1 | 0.4 | documents#limits-active |
| Large documents as a workflow | Phase 1 | 0.4 | documents#jobs |
| OCR, layout and tables through Textract, Azure, Google and Unstructured | Phase 1 | 0.4 | documents#ocr-drivers |
| OCR through a model with every ai guard | Phase 1 | 0.4 | documents#ai-driver |
Quick start
documents:
driver: builtinimport { documents } from "mainsdk";
import type { FileRef } from "mainsdk/files";
export async function readInvoice(upload: FileRef, userId: string) {
const doc = await documents.parse(upload, { as: userId, tables: true });
return doc.pages.map((p) => p.text).join("\n"); // untrusted: data, never instructions
}as says whose rights read the file through files. When the call runs inside a request with a signed-in person you can leave it out; with neither, the call fails with MAIN-FILES-0002.
What parse returns
documents.parse(file, opts) returns a ParsedDocument:
| Field | What it holds |
|---|---|
type | pdf, docx, xlsx, pptx or image, read from the file's bytes, not its name |
pages | One entry per page, sheet or slide: n, text, source (text-layer, ocr or none), and with layout: true blocks (heading, paragraph, list, figure, header, footer, each with a box), and with tables: true tables (rows of cell text) |
meta | title, pageCount, language |
findings | Active content found and not run: javascript, embedded-file, macro, external-link, form, and no-text-layer |
via | The driver that read it (builtin, or the OCR driver) |
cost | What the read cost; zero for the built-in reader |
untrusted | Always true |
Options: ocr (auto, off or force), layout, tables, pages ([first, last]), language and as.
Configuration
documents:
driver: builtin # builtin | textract | azure-document | google-document | unstructured | ai
ocr:
driver: textract # defaults to the driver above when it is not builtin
region-endpoints: { eu: eu-west-1, us: us-east-1, in: ap-south-1 }
render:
driver: builtin # builtin | gotenberg | docraptor
fonts: [assets/fonts/Inter.ttf]
limits: { max-size: 50MB, max-pages: 500, inline-pages: 20, max-zip-ratio: 100 }
permissions:
disk:
read: [templates, assets/fonts] # render templates and fonts are read from disk| Key | Default | What it does |
|---|---|---|
driver | builtin | The reader. Anything else also becomes the OCR driver unless ocr.driver says otherwise |
ocr.driver | none | Reads pages with no text layer; without it, those pages fail with MAIN-DOC-0003 |
ocr.region-endpoints | none | The endpoint for each tenant region. A labelled document with no endpoint for its region is refused |
ocr.key, ocr.processor, ocr.host | driver default | Provider key (a secret: reference), Google Document AI processor, Azure resource host |
render.driver | builtin | The PDF renderer; see PDF documents |
render.fonts, render.url, render.key | none | TrueType fonts to embed, the Gotenberg URL, the DocRaptor key |
limits.max-size | 50MB | Largest file |
limits.max-pages | 500 | Most pages |
limits.inline-pages | 20 | parse refuses larger documents; use documents.start |
limits.max-zip-ratio | 100 | Office files and PDFs that expand past this ratio are refused |
limits.max-cost | none | Most one document may cost on an OCR driver |
An unknown key under documents stops the app at load with MAIN-CONFIG-0030 and names the key. The hosts each driver reaches are added to network.allow for you. The ai OCR driver needs a multimodal model call brought by the app (documentsVision) until ai ships image parts; main.yaml alone is not enough for it.
What you get
- Text-layer PDFs and Office files are read in your app, with no account and no dependency.
- Scanned pages go to the OCR driver you pick, in the tenant's region.
- Rendering PDFs is the other half of the interface: see
pdf. - Active content is reported and never run.
Examples
Large documents off the request path
documents.start does not read the file in the request. It returns a job; read the result later with documents.get.
import { documents } from "mainsdk";
import type { FileRef } from "mainsdk/files";
export async function startContractRead(upload: FileRef, userId: string) {
const job = documents.start(upload, { as: userId, layout: true });
return job.id; // state: queued -> running -> ready | failed
}
export function contractResult(jobId: string) {
const job = documents.get(jobId); // another tenant's job id fails with MAIN-DOC-0009
return job.state === "ready" ? job.result : undefined;
}Scans
A page with no text layer is refused with MAIN-DOC-0003 until you set an OCR driver and pass ocr: "auto".
import { documents } from "mainsdk";
import type { FileRef } from "mainsdk/files";
export async function readScan(upload: FileRef, userId: string) {
const doc = await documents.parse(upload, { as: userId, ocr: "auto" });
return doc.pages.map((p) => ({ n: p.n, source: p.source, text: p.text }));
}Typed fields from a scan
Parse first, then extract with a schema through ai. The parsed text goes in as data, never as instructions.
import { ai, documents } from "mainsdk";
import { s } from "mainsdk/schema";
import type { FileRef } from "mainsdk/files";
export async function receiptTotal(upload: FileRef, userId: string) {
const doc = await documents.parse(upload, { as: userId, ocr: "auto" });
const text = doc.pages.map((p) => p.text).join("\n");
const out = await ai.extract({
input: text,
schema: s.object({ total: s.number(), currency: s.string() }),
model: "anthropic/claude-sonnet-4-5",
feature: "receipts",
});
return out.value;
}Make a PDF
documents.render(layout | template, data, { owner }) writes a PDF through files and returns its FileRef. See PDF documents and the guide Make, read and convert documents.
Reference
| Call | What it does |
|---|---|
new Documents(opts) | The interface class. opts takes files and optional log, tenancy, limits, ocr (without one, scans fail with MAIN-DOC-0003), regionEndpoints, html, fonts and readFont. |
azureDriver({ host, key, fetch }) | The azure-document OCR driver: submits the file, polls the operation and reads pages, paragraphs and tables. It supports layout, tables and handwriting. sleep, pollMs and maxPolls tune the polling. |
aiOcrDriver({ vision, model?, price? }) | The ai OCR driver: sends one image part per page through the multimodal ai interface, so every ai guard applies. |
fakeOcrDriver(opts?) | A driver for tests and development. It reads nothing, returns the text you script per page (text(page)) and records every request. |
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-DOC-0001 | Unsupported type, or the bytes are not the declared type | Send a PDF, DOCX, XLSX, PPTX, PNG, JPEG or TIFF file |
MAIN-DOC-0002 | Over a limit: size, pages, ZIP expansion, or more pages than inline-pages for parse | Use documents.start, or raise documents.limits on purpose |
MAIN-DOC-0003 | Pages have no text layer and OCR is off or has no driver | Set documents.ocr.driver and parse with ocr: "auto" |
MAIN-DOC-0004 | The driver does not offer what you asked (layout, tables) | Choose a driver that does, or parse without it |
MAIN-DOC-0005 | The PDF is encrypted or password protected | Remove the password and send the file again |
MAIN-DOC-0007 | A render layout, template, font or value is invalid | Fix what the error names |
MAIN-DOC-0008 | A labelled document would go to an OCR driver outside the tenant's region, or one that does not meet ai.data | Add ocr.region-endpoints for the region, or pick a driver that meets ai.data |
MAIN-DOC-0009 | No parse job with that id in this tenant | Use an id returned in this tenant |
MAIN-DOC-0006 is logged, not thrown: a file holds JavaScript, macros, embedded files or links, and none is run or followed. It appears in findings. mainsdk check reports MAIN-DOC-0010 for a PDF or Office library used outside a driver and MAIN-DOC-0011 for a complex-script locale on the built-in renderer. Every code is in check codes.
Swapping drivers
documents:
driver: textract # was builtin
ocr:
region-endpoints: { eu: eu-west-1 }Your calls stay the same. driver picks the reader and, unless ocr.driver says otherwise, the OCR driver; render.driver picks the PDF renderer separately. See Swap a driver.
Testing
The built-in reader and renderer run offline, so tests need no service. A test stores a file through files and parses it:
import assert from "node:assert/strict";
import { it } from "node:test";
import { createTestApp } from "@mainsdk/testing";
import { documents, files } from "mainsdk";
it("documents AC1 refuses a text file", async () => {
createTestApp({ main: "./main.yaml" });
const f = await files.upload(new TextEncoder().encode("hi"), { owner: "u1", type: "text/plain" });
await assert.rejects(documents.parse(f, { as: "u1" }), { code: "MAIN-DOC-0001" });
});Security and privacy
- Files are read through
fileswith the caller's rights;documentsnever reads a path. - Parsed text is untrusted:
untrustedis alwaystrue, and it never becomes instructions to a model. - Limits are checked before parsing: size, pages and ZIP expansion.
- Active content is reported in
findingsand never run or followed. - Labelled documents go only to OCR drivers with an endpoint in the tenant's region that meet
ai.data. The audit event for each parse names the file, never its text.
Next
Was this page helpful?