JDFJDF/ docs

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.

Why this CLI matters: the same algorithm that powers the desktop reader's PDF import also runs from the command line, in CI, in a Lambda, in your RAG pipeline. Output is bit-identical between desktop and CLI for the same input. The CLI is the bridge between the world's existing PDFs and an AI workflow that wants structured data.

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:

  1. Full JDF document — has $jdf and pages. The CLI validates and re-emits (no transformation).
  2. Bare element array — wrapped into a single-page A4 document. The CLI fills in $jdf, meta, and a default page.
  3. Partial{ "elements": [...] } or { "pages": [...] } with optional meta, 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:

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…"}

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)

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