CLI — @uurtech/jdf-cli
Command-line tool for the two unattended workflows the desktop reader can't cover: PDF → JDF for RAG / CI ingestion of legacy documents, and JSON → JDF for wrapping the output of LLMs and code generators into a validated, renderable document. Markdown import is a convenience.
Install
# run on demand (no install)
npx @uurtech/jdf-cli validate doc.jdf
npx @uurtech/jdf-cli convert paper.pdf
# or install globally
npm install -g @uurtech/jdf-cli
jdf --help
Commands
jdf validate <file.jdf|file.jdfx>
Runs Ajv against spec/jdf-schema.json and reports path-level errors with warnings vs hard failures separated. For .jdfx bundles the validator opens the zip, validates document.json, and prints the asset count from the manifest.
$ jdf validate spec/examples/hello-world.jdf
✓ Valid: hello-world.jdf
Format: 1.0.0
Title: Hello, JDF
Pages: 2
Elements: 14
Failures look like:
$ jdf validate broken.jdf
✗ Invalid: broken.jdf
/pages/0/elements/3/heading — must be one of [true, 1, 2, 3, 4, 5, 6]
/pages/0/elements/5 — required property "type" missing
Exit code: 0 if valid, 1 if not. Drop into a CI step:
# .github/workflows/ci.yml
- run: npx @uurtech/jdf-cli validate docs/whitepaper.jdf
jdf convert <file.pdf> [-o output.{jdf,jdfx}] [--json]
Convert a PDF to a JDF document. Same algorithm the desktop reader uses, packaged as a node CLI — both surfaces import @jdf/pdf-import from packages/jdf-pdf-import/, so output is bit-identical for the same PDF. The reader uses the browser entry point (DOM canvas, real Web Worker); the CLI uses the node entry point (@napi-rs/canvas, in-process).
jdf convert paper.pdf # pure-text PDF → paper.jdf
jdf convert contract.pdf # with images → contract.jdfx (auto)
jdf convert contract.pdf -o contract.jdf --json # force pure JSON
# (RAG / CI consumers prefer one text file)
The --json flag forces a single .jdf file even when the PDF embeds images — assets are base64-inlined. Pipelines that consume one text file per document (RAG ingestion, CI gates, agent context windows) should turn this on.
Per text run the importer extracts position (mm), font family, font size (pt), bold / italic, color, opacity, and link annotations. Vector shapes (rect, line, path with Bezier curves) and embedded raster images are preserved at their original transforms. Invisible OCR layers (text rendering mode 3) are filtered out.
jdf convert <file.json> [-o output.{jdf,jdfx}] [--json]
Wrap raw JSON into a validated JDF document. Three input shapes are accepted:
- Full JDF document — has
$jdfandpages. The CLI validates and re-emits (no transformation). - Bare element array — wrapped into a single-page A4 document. The CLI fills in
$jdf,meta, and a default page. - Partial —
{ "elements": [...] }or{ "pages": [...] }with optionalmeta,styles,resources. Filled in with sensible defaults (1.0.0, A4, no margins).
jdf convert response.json -o report.jdf # LLM output → JDF
jdf convert elements.json # bare array → 1-page A4 doc
jdf convert partial.json # partial → filled-in doc
Validation runs after every JSON import. If the resulting document doesn't match the schema, the CLI exits with code 1 and prints the path-level errors. This is what makes it safe to gate CI on model output — a malformed JSON response from an LLM never silently ships as a broken document.
jdf convert <file.md> [-o output.{jdf,jdfx}]
Convenience: convert Markdown to JDF using marked-style parsing with full GFM (tables, blockquotes, fenced code, links, images, task lists, hr, strikethrough). Image references resolve against the source file's directory and are read from disk during import.
Auto file-shape selection
When you don't pass -o, the importer picks the right output:
- Document has no embedded assets → writes
file.jdf(plain JSON). - Document has at least one embedded image / font → writes
file.jdfx(zip bundle:document.json+manifest.json+assets/).
Pass --json or specify -o foo.jdf explicitly to keep assets base64-inlined in resources.images. The schema is identical either way.
jdf chunk <file.{jdf,jdfx}> [--strategy section|element|fixed] [--format jsonl|json|inline] [--max-tokens N] [-o out]
Turn a document into retrieval-ready chunks — offline, deterministic, zero network. Because JDF already carries a heading hierarchy (heading / tocLevel) and typed elements, chunking reads structure instead of guessing at it. Same input + same options → byte-identical chunks (and stable content hashes), which is what makes incremental re-embedding possible.
jdf chunk report.jdf # → report.chunks.jsonl (section strategy)
jdf chunk report.jdf --strategy element # one chunk per element
jdf chunk report.jdf --format inline # write an "index" block back into the .jdf
jdf chunk report.jdf --max-tokens 256 -o out.jsonl
Each chunk line carries everything a vector store needs — no re-derivation:
{"id":"p3e7", "text":"…", "path":["Report","Pricing"],
"page":3, "types":["text","table"], "tokens":142, "hash":"ab12cd…"}
- section (default) — each heading starts a chunk holding the heading + its following content; oversized sections split on element boundaries.
- element — one chunk per element, breadcrumb tracked from headings.
- fixed — accumulate to a
--max-tokensbudget.
Tables serialize as Header: value | Header: value rows — the column semantics stay attached to each value, so a retriever matches "Price: $4,800" instead of a bare number floating away from its column. This is the single biggest RAG win over PDF, where table structure is lost during extraction.
--format inline writes the chunks into the document itself under a top-level index block. Renderers ignore it (data-only); a pipeline reads it instead of recomputing. The .jdf stays schema-valid.
jdf embed <file.{jdf,jdfx}> [--provider ollama|openai] [--model NAME] [--strategy …] [--incremental] [-o out]
Compute embeddings for the chunks. Builds on jdf chunk — same deterministic boundaries, same hashes — and writes an embeddings sidecar (file.embeddings.json). Unlike convert/chunk, this is the one step that can touch a model, so it's opt-in and never part of conversion.
jdf embed report.jdf # local via Ollama (default) — no data leaves the machine
jdf embed report.jdf --incremental # only re-embed chunks whose hash changed
jdf embed report.jdf --provider openai # remote API (prompts for endpoint/key, or reads env)
- --provider ollama (default) — fully local. Embeddings run on your machine; nothing is sent anywhere. If nothing is listening on the Ollama port the CLI offers to start it via Docker (
ollama/ollama) and pulls the model. OpenAI's models can't run locally, so local-first means Ollama. - --provider openai — remote API. Reads
OPENAI_API_KEY/OPENAI_BASE_URL, or prompts for them on an interactive terminal. PointOPENAI_BASE_URLat Azure / a proxy / a local gateway to keep data in your network.
--incremental is the ingestion speed win. It reads the previous sidecar and skips every chunk whose content hash is unchanged. Edit one paragraph in a 500-page document and you re-embed one chunk, not five hundred — because JDF is diffable JSON and the chunker is deterministic. Embeddings are cache: delete them and regenerate any time; they are never the source of truth.
Schema validation in CI
If your project generates .jdf files programmatically, validate them on every PR. Example workflow:
name: Validate JDF docs
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npx @uurtech/jdf-cli validate docs/*.jdf
RAG ingestion pipeline
Drop a PDF into a corpus loader, get a structured JDF tree out — your retriever chunks by element type instead of token windows that slice tables in half:
# Bash — PDF corpus → validated JDF → chunks → embeddings, end to end
for pdf in corpus/*.pdf; do
out="${pdf%.pdf}.jdf"
npx @uurtech/jdf-cli convert "$pdf" -o "$out" --json || exit 1
npx @uurtech/jdf-cli validate "$out" || exit 1
npx @uurtech/jdf-cli chunk "$out" # → *.chunks.jsonl
npx @uurtech/jdf-cli embed "$out" --incremental # local, skips unchanged
done
Re-run the loop after editing a few documents and --incremental re-embeds only the chunks that actually changed — the diffable-JSON payoff for ingestion cost.
LLM output gate
Models emit JSON. The CLI accepts that JSON and either passes it through (if it's already a valid JDF document) or wraps it (if it's a bare element array / partial). Validation runs at the end — non-zero exit fails the build:
# inside a workflow that calls a model
- run: |
node generate-doc.js > dist/output.json
npx @uurtech/jdf-cli convert dist/output.json -o dist/output.jdf
# exit 1 here means the model violated the JDF schema
PDF AcroForm → JDF Forms
The CLI's PDF importer also walks AcroForm widget annotations and emits matching JDF form elements. Filled values in the source PDF survive the conversion — the resulting .jdf renders in jdf.js with the same fields pre-populated, and the user can keep editing in any browser without an Acrobat install.
# Convert an existing fillable PDF to JDF (values + structure preserved)
$ jdf convert w9-form.pdf -o w9-form.jdf
$ jdf validate w9-form.jdf
# Now host it; users fill the rest in their browser:
<jdf src="w9-form.jdf" save-button="Save"></jdf>
Full mapping table (single-line vs multi-line text, checkbox, combo / list dropdowns, signatures) is on the JDF Forms page.