---
title: "Document reading"
description: "Read 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."
section: "interfaces"
status: "Preview · ships as a beta API"
spec: "documents"
canonical: "https://mainsdk.dev/docs/interfaces/documents"
updated: "2026-10-28"
---

# Document reading

> Read 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.

Status: Preview · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/documents · Updated: 2026-10-28

`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.

> **Note:** Built for 0.4 (spec `documents`, ADR-0058). It runs from the repository and is not published to npm yet. The built-in reader and renderer, the `textract`, `azure-document`, `google-document`, `unstructured` and `ai` OCR drivers, and the `gotenberg` and `docraptor` render drivers are in the code; the OCR and render drivers are tested on recorded responses, not against the live services.

## At a glance

- Status: Preview · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/documents.md
- Category: Files, sharing and media

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)`

In main.yaml:

```yaml
documents:
  driver: builtin
  ocr: { driver: textract }
  limits: { max-size: 50MB, max-pages: 500, inline-pages: 20 }
```

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

Pick one in main.yaml with `documents.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in | `builtin` | yes |  |
| Amazon Textract | `textract` |  |  |
| Azure AI Document Intelligence | `azure-document` |  |  |
| Google Document AI | `google-document` |  |  |
| Unstructured | `unstructured` |  |  |
| A model through ai | `ai` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-DOC-0010` | check | A PDF or Office library used outside a driver |
| `MAIN-DOC-0001` | runtime | The file isn't a supported document type, or its bytes don't match its declared type (415) |
| `MAIN-DOC-0002` | runtime | Over a limit: size, pages, ZIP expansion, or more pages than `inline-pages` for `parse` (413) |
| `MAIN-DOC-0003` | runtime | Pages have no text layer and OCR is off or no OCR driver is set (422) |
| `MAIN-DOC-0004` | runtime | The documents driver doesn't offer this (501) |
| `MAIN-DOC-0005` | runtime | The PDF is encrypted or password protected (422) |
| `MAIN-DOC-0006` | runtime | Active content was found and not run (logged) |
| `MAIN-DOC-0008` | runtime | A labelled document would go to a driver outside the tenant's region or not meeting the data rules (403) |
| `MAIN-DOC-0009` | runtime | No document job with that id in the current tenant (404) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| PDF text layer read natively | 1 | 0.4 | `documents#pdf-text` |
| DOCX, XLSX and PPTX read natively | 1 | 0.4 | `documents#office` |
| Limits and active content checks before parsing | 1 | 0.4 | `documents#limits-active` |
| Large documents as a workflow | 1 | 0.4 | `documents#jobs` |
| OCR, layout and tables through Textract, Azure, Google and Unstructured | 1 | 0.4 | `documents#ocr-drivers` |
| OCR through a model with every ai guard | 1 | 0.4 | `documents#ai-driver` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
documents:
  driver: builtin
```

```ts title="lib/read-invoice.ts"
import { 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`](https://mainsdk.dev/docs/interfaces/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

```yaml title="main.yaml"
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](https://mainsdk.dev/docs/interfaces/pdf) |
| `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`](https://mainsdk.dev/docs/interfaces/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`.

```ts title="lib/contracts.ts"
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"`.

```ts title="lib/scan.ts"
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`](https://mainsdk.dev/docs/interfaces/ai). The parsed text goes in as data, never as instructions.

```ts title="lib/receipt.ts"
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](https://mainsdk.dev/docs/interfaces/pdf) and the guide [Make, read and convert documents](https://mainsdk.dev/docs/guides/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](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

```yaml title="main.yaml"
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](https://mainsdk.dev/docs/guides/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:

```ts title="lib/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" });
});
```

## Security and privacy

- Files are read through [`files`](https://mainsdk.dev/docs/interfaces/files) with the caller's rights; `documents` never reads a path.
- Parsed text is untrusted: `untrusted` is always `true`, and it never becomes instructions to a model.
- Limits are checked before parsing: size, pages and ZIP expansion.
- Active content is reported in `findings` and 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

- [Make, read and convert documents](https://mainsdk.dev/docs/guides/documents)
- [PDF documents](https://mainsdk.dev/docs/interfaces/pdf)
- [AI calls](https://mainsdk.dev/docs/interfaces/ai)
- [File uploads](https://mainsdk.dev/docs/interfaces/files)
