@ararahq/pluma
v0.4.2
Published
Bounded context and streaming exports for datasets too large for AI, plus browserless Markdown to PDF.
Maintainers
Readme
pluma
The context and export engine for datasets too large for AI. Pluma compiles CSV, XLSX, and Parquet into an integrity-checked .pluma package, exposes only the exact context an agent asks for, records the blocks used as provenance, and streams the result back to CSV, XLSX, or Parquet.
The same package includes Pluma Docs: the original open-source Markdown-to-PDF engine, with real typography and no Chromium, LaTeX, Python process, external binary, or hosted dependency.
npm install @ararahq/plumaDataset quickstart
pluma context compile transactions.csv -o transactions.pluma
pluma context inspect transactions.pluma --view schema --token-budget 2000 --tokenizer o200k_base
pluma context query transactions.pluma --plan query.json --token-budget 4000 --tokenizer o200k_base
pluma context export transactions.pluma -o result.parquet --format parquetFrom Node:
import { compileContext, openContext } from "@ararahq/pluma"
const job = compileContext("transactions.csv", { output: "transactions.pluma" })
for await (const progress of job.events()) console.error(progress)
await job.result()
const context = openContext("transactions.pluma")
const result = context.query({
filter: [{ column: "status", op: "eq", value: "paid" }],
groupBy: ["country"],
metrics: [{ column: "amount", aggregate: "sum", as: "revenue" }],
limit: 100,
})
console.log(result.payload({
tokenBudget: 4_000,
tokenizer: { id: "o200k_base", version: "1" },
}))Stream directly from a database or cursor without collecting the result set:
import { compileRows } from "@ararahq/pluma"
const source = {
identity: { snapshot: transactionSnapshot, fingerprint: `sha256:${snapshotHash}` },
open: (cursor?: string) => database.stream(
"SELECT id, status, amount FROM transactions WHERE id > ? ORDER BY id",
[cursor ?? "0"],
),
cursor: (row: { id: bigint }) => String(row.id),
}
await compileRows(source, {
output: "transactions.pluma",
relation: { id: "transactions", name: "Transactions", fields },
indexes: [{ columns: ["id"] }],
materializedAggregates: [{
groupBy: ["status"],
metrics: [{ column: "amount", aggregate: "sum", as: "total" }],
}],
}).result()context.explain(plan) makes complexity visible before execution. A compatible ordered index selects candidates in O(log N + K); a declared exact aggregate is looked up in O(log N + G). Exact arbitrary aggregates that are not covered still report full_scan and execute in O(N)—Pluma never labels a scan logarithmic.
For large outputs, context.queryStream(plan) and context.exportQuery(plan, { format, output }) preserve backpressure and return evidence without materializing every result row in the application heap.
The compiler hashes sources incrementally, writes bounded Arrow IPC blocks, commits packages atomically, and verifies every artifact before reading it. Query results include the package fingerprint, normalized plan hash, blocks read, and row counts. Token budgets use the named tokenizer rather than a character-count estimate.
Open format
A .pluma package is a normal directory, not a proprietary binary:
transactions.pluma/
manifest.json
blocks/*.arrow
views/overview.md
views/schema.md
provenance/source.jsonThe manifest and package runtime are MIT. You can inspect, verify, move, or rebuild a package without Pluma Cloud.
See the versioned capability and complexity matrix for the exact Node, CLI, MCP, and Cloud surface.
Pluma Docs
Built for developers shipping customer-facing deliverables: invoices, proposals, reports, audits, contracts, changelogs — and LLM output, which is already Markdown.
On an M-series Mac, the included wall-clock benchmark renders
the example document in about 25 ms after warm-up. Run npm run bench on the
machine that will execute your workload: results vary by document and hardware.
When Chrome is installed the script also reports a separate headless-print timing;
it does not claim output parity, process cold-start time, or memory measurements.
Document quickstart
pluma document.mdThat's it. document.pdf comes out with sensible defaults: A4, justified paragraphs, ruled tables, highlighted code blocks, page numbers. Nothing to configure.
From Node:
import { renderPdf } from "@ararahq/pluma"
import { writeFileSync } from "node:fs"
const pdf = renderPdf("# Hello\n\nA **bold** start.")
writeFileSync("hello.pdf", pdf)renderPdf is synchronous and returns a Uint8Array — write it to disk, stream it in an HTTP response, attach it to an email.
Document metadata
YAML frontmatter becomes the document header:
---
title: Commercial proposal
subtitle: Scope and pricing
author: Ana Silva
date: August 2026
---
## First sectiontitle renders at 24 pt over an accent rule; subtitle, author and date compose the byline. All optional — omit the frontmatter and the document starts at your first heading.
Your visual identity
Drop a brand.json next to your documents:
{
"$schema": "https://raw.githubusercontent.com/ararahq/pluma/main/schema/brand.schema.json",
"fonts": { "body": "Geist", "mono": "Geist Mono" },
"colors": { "accent": "#0e7490", "link": "#0e7490" },
"logo": { "path": "assets/logo.png", "width": "3.2cm" },
"footer": "Acme Corp — acme.com"
}pluma proposal.mdNo flags needed: pluma auto-discovers brand.json next to the input file, and a fonts/ directory next to it (drop your .ttf/.otf files there). The logo lands at the top, the accent colors the title rule, the footer shows on every page with automatic page numbers, and the whole document uses your typeface.
Every token is optional and falls back to a clean default:
| Token | Controls |
| --- | --- |
| fonts.body / fonts.heading / fonts.mono | body, heading and code typefaces |
| fonts.size | base size in pt (default 10.5) |
| colors.text / muted / faint | text hierarchy |
| colors.accent | title rule |
| colors.link / border / codeBackground | links, rules, code blocks |
| page.paper / marginX / marginY | paper size (a4, us-letter, ...) and margins |
| logo.path / logo.width | header logo |
| footer | footer text (page number is automatic) |
The $schema line gives you validation and autocomplete in any editor.
Three ways to attach a brand, from most to least specific:
--brand path.jsonflag (orbrand:option in the API)brand: ./path.jsonin the document's frontmatterbrand.jsonsitting next to the.mdfile
Iterating on a document
pluma proposal.md --watchRecompiles on every save (~50 ms). Keep the PDF open in a viewer that auto-reloads (macOS Preview, most Linux viewers, VS Code) and write.
Page control
Directives are HTML comments — invisible on GitHub, in editors, everywhere else Markdown renders:
<!-- pagebreak -->
## This section starts on a fresh page
<!-- columns:2 -->
This content flows across two balanced columns. Still plain Markdown inside.
<!-- /columns -->Free-form layout: Typst islands
When Markdown can't express what you need — mixed font sizes in one line, callout boxes, grids — open a typst fenced block. Its content goes straight to the engine:
```typst
#text(size: 18pt, weight: 700)[Big opener,]
#text(size: 8pt, style: "italic")[ small italic aside.]
#block(fill: rgb("#ecfeff"), radius: 6pt, inset: 12pt)[A callout box.]
```Markdown covers 95% of a document; islands cover the rest with the full Typst language. Islands execute layout code — only use them with documents from trusted authors. Regular Markdown text is always escaped and cannot inject code.
For AI agents
pluma is designed to be driven by LLMs as much as by humans:
- Built-in MCP server —
pluma mcpspeaks MCP over stdio and exposesrender_pdf,read_pdf,read_html, andread_url. Safe mode is the default: document paths stay inside the current working directory, and raw Typst, images, custom font paths, and filesystem roots are rejected. Usepluma mcp --trusted-localonly for agents and documents you trust. Zero extra dependencies. --json— every completed CLI operation emits one JSON line with anok: trueenvelope while preserving its useful fields. Rendering returnsoutputandbytes; PDF/HTML/URL reads retainmarkdown, metadata, counts, links, and warnings;--typstreturnstypst. Failures use{"ok":false,"error":{"message":"..."}}. Watch mode emits one envelope per rebuild. Human status text stays on stderr.- Forgiving input — a document wrapped in a
```markdownfence (a common LLM habit) is unwrapped automatically. - llms.txt — the entire library documented in one page for models, shipped inside the npm package (
node_modules/@ararahq/pluma/llms.txt). - brand JSON Schema — lets models (and editors) generate valid brand files first try.
Typical AI pipeline, three lines:
const markdown = await llm.generate(reportPrompt) // LLMs speak Markdown natively
const pdf = renderPdf(markdown, { brand }) // ~50 ms, no browser
await send(pdf)Included document readers
PDF, HTML, and URL readers are included for workflows that also need source material as Markdown. They are local utilities, not a claim that Pluma replaces OCR or browser-based extraction platforms.
import { readPdf, readHtml, readUrl } from "@ararahq/pluma"
const report = readPdf("./report.pdf")
console.log(report.markdown)
console.log(report.meta.pageCount)
const page = readHtml(html, { baseUrl: "https://example.com/post" })
const remote = await readUrl("https://example.com/post", { maxTokens: 4_000 })readPdf accepts a path, Buffer, or Uint8Array. It reconstructs headings, paragraphs, lists, links, tables, page order, and repeated headers/footers from the PDF text layer. readPdfPages offers an async page iterator for large files, and countPdfPages reads the page tree without decoding page contents.
readHtml and readUrl extract the main article or page content, preserve semantic structures such as code and tables, resolve relative links, and return title, canonical URL, author, dates, JSON-LD, links, word count, and warnings.
pluma report.pdf # writes report.md
pluma page.html -o page.md # local HTML to Markdown
pluma https://example.com/post # fetched page to stdout
pluma report.pdf --pages 1-3 --json # structured result for an agentRepresentative JSON results:
{"ok":true,"output":"report.pdf","bytes":18422}
{"ok":true,"markdown":"# Report\n...","meta":{"pageCount":3},"warnings":[]}
{"ok":false,"error":{"message":"Input file does not exist"}}PDF reading requires an existing text layer. It does not perform OCR, decode images, or accept non-empty passwords. Web fetching supports HTTP(S), explicit timeouts, redirect limits, and response-size limits. readUrl resolves every redirect hop and permits only public network addresses by default; trusted intranet callers must opt in explicitly with { networkPolicy: "any" } (or --trusted-local in the CLI/MCP server).
Safe hosted rendering
renderPdf remains the unrestricted local API, including custom themes, filesystem assets, fonts, and trusted raw Typst islands. Hosted or multi-tenant services should use renderPdfCloudSafe: it rejects raw Typst, Markdown images, and filesystem-backed brand fields, validates an allowlisted brand subset, and returns the generated PDF with its exact page count.
const { pdf, pageCount } = renderPdfCloudSafe(markdown, {
brand: { colors: { accent: "#0e7490" }, footer: "Acme" },
})API
import {
renderPdf, // (markdown, options?) => Uint8Array
renderPdfCloudSafe, // restricted hosted wrapper => { pdf, pageCount }
readPdf, // PDF path/bytes => Markdown + pages + metadata
readPdfPages, // async page iterator
readHtml, // HTML string => Markdown + metadata
readUrl, // fetched web page => Markdown + metadata
markdownToTypstSource, // (markdown, options?) => string — inspect the generated Typst
createTheme, // (brand) => Theme
normalizeInput, // strips a wrapping ```markdown fence
} from "@ararahq/pluma"
renderPdf(markdown, {
brand: { fonts: { body: "Geist" }, footer: "Acme" }, // same shape as brand.json
fontPaths: ["./fonts"], // directories with .ttf/.otf files
root: "./docs", // base dir for logo and image paths
meta: { title: "Override frontmatter" },
})Errors are typed: InvalidBrandError for bad hex colors or lengths (values are validated, never interpolated raw), UnknownThemeError for a bad theme name.
Full control when tokens aren't enough — a theme is just a Typst preamble:
renderPdf(markdown, {
theme: {
name: "compact",
preamble: (meta) => `#set page(paper: "a5", margin: 1.5cm)\n#set text(size: 9pt)`,
},
})CLI reference
pluma <input.md> [options] Markdown -> PDF
pluma <input.pdf> [options] PDF -> Markdown
pluma <input.html> [options] HTML -> Markdown
pluma <https://...> [options] URL -> Markdown
pluma mcp [--trusted-local] MCP server over stdio (safe by default)
-o, --output <file> output path (default: <input>.pdf)
-w, --watch recompile on save
-b, --brand <file> brand JSON (overrides discovery)
-f, --fonts <dir> font directory, repeatable (overrides discovery)
-t, --theme <name> built-in theme
--typst print generated Typst source instead of compiling
--pages <range> PDF pages, for example 1-3,7
--mode <mode> HTML mode: article, page, or raw
--no-images omit images from HTML/URL Markdown
--max-tokens <n> truncate HTML/URL output at a block boundary
--trusted-local allow raw Typst/filesystem access in MCP
--json {"ok":...} JSON envelopes on stdoutMarkdown support
Headings 1–6, paragraphs, bold / italic / strikethrough, inline and fenced code with syntax highlighting, links, images (paths resolve relative to the document), blockquotes, nested ordered and unordered lists, GFM tables with per-column alignment, horizontal rules. Raw HTML is dropped, except the directives above.
Requirements
Node 20+ (or Bun). The Typst engine ships as a prebuilt native binding for macOS, Linux and Windows (x64 and arm64) — no install step, no runtime downloads.
Pluma Cloud
The managed product handles direct uploads, durable jobs, isolated workers, encrypted object storage, retention, API keys, billing, and downloadable exports. The open-source compiler remains useful by itself; Cloud is for teams that do not want to operate the queue, storage, worker isolation, retries, upgrades, or access controls.
License
MIT © AraraHQ. The HTML article-extraction scoring includes a TypeScript adaptation of Mozilla Readability; see NOTICE. Example fonts (Geist) are © Vercel under the SIL Open Font License 1.1 — see examples/fonts/LICENSE-Geist.txt.
