Skip to content

document-data-automation/docforge

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

docforge

Render one document per row from a CSV / Excel / JSON data file and a Jinja2 template.

docforge is a small, zero-config CLI for mail-merge–style batch document generation. Point it at a data file and a template and it writes one rendered document per row — letters, invoices, contracts, reports, READMEs, anything text-shaped.

git clone https://github.com/document-data-automation/docforge.git
cd docforge
docforge --template examples/invoice.md --data examples/customers.csv --out ./out/
# ...or without installing anything: python -m docforge --template examples/invoice.md ...

What's new in 0.2.0 — document-oriented Jinja filters (currency, date, default_if_empty, slugify, wrap), global variables via --var, row filtering with --where/--limit, STDIN input (--data - + --format), {% include %} partials, and an audit --manifest. All 0.1.0 commands and the library API are unchanged. See the CHANGELOG.

For the broader pattern behind this tool, see Word document templating & batch processing.

Why

Mail merge is a surprisingly common need (per-customer letters, per-store reports, per-employee onboarding packets), and the usual answers are either heavyweight (Word + VBA) or scattered across several Python libraries. docforge is one command, one dependency (Jinja2), and a sensible default filename scheme.

It pairs well with the patterns covered at document-data-automation.com — Python workflows for office documents and tabular data.

Install

docforge isn't published to any package index — clone the repo and use it directly:

git clone https://github.com/document-data-automation/docforge.git
cd docforge

# option A: run straight from the clone, no install (needs Jinja2 importable)
python -m docforge --template examples/invoice.md --data examples/customers.csv --out ./out/

# option B: install into your environment to get a `docforge` command
pip install .            # core (CSV/JSON/JSONL); or: pip install ".[xlsx]" for Excel input

Python 3.9+. The only runtime dependency is Jinja2; openpyxl is an optional extra, needed only to read .xlsx data files.

Quickstart

A template (invoice.md):

# Invoice {{ customer_id }}

**Customer:** {{ name }}
**Plan:** {{ plan }}
**Amount due:** {{ amount_due | currency }}
**Due date:** {{ due_date | date('%B %d, %Y') }}

A data file (customers.csv):

customer_id,name,plan,amount_due,due_date
C-001,Ada Lovelace,Pro,1290.5,2026-06-15
C-002,Grace Hopper,Enterprise,4999,2026-06-20

Run:

docforge \
  --template invoice.md \
  --data customers.csv \
  --out ./invoices/ \
  --name "{{ customer_id }}{{ _ext }}"

You get ./invoices/C-001.md and ./invoices/C-002.md, with amounts rendered as $1,290.50 and dates as June 15, 2026.

Supported data formats

Format Extensions Notes
CSV .csv Auto-detects ,, ;, \t, |; BOM-safe
JSON .json Top-level array of objects, or a single object
JSON Lines .jsonl, .ndjson One JSON object per line
Excel .xlsx, .xlsm First sheet, first row = header (needs [xlsx] extra)

Templating

Templates use Jinja2, so you get conditionals, filters, and loops:

Hi {{ name | title }},

{% if plan == "Enterprise" %}
Thanks for being on our top tier.
{% else %}
Curious about Enterprise? Reply to this email.
{% endif %}

Any column in your data file becomes a variable. Two extras are injected automatically:

  • _index — 1-based row number
  • _ext — the template file's extension (e.g. .md, .txt, .html)

By default, referencing a variable that isn't in the row is an error (catches typos early). Pass --lenient to render missing variables as empty strings instead.

Custom filters

docforge registers a handful of document-oriented filters on top of the Jinja built-ins. They work in both the body and --name templates.

Filter Example Result
currency {{ 1290.5 | currency }} $1,290.50
{{ 1290.5 | currency('€', 0) }} €1,291
date {{ due_date | date('%B %d, %Y') }} June 15, 2026
default_if_empty {{ note | default_if_empty('—') }} (if blank)
slugify {{ name | slugify }} ada-lovelace
wrap {{ bio | wrap(60) }} text wrapped to 60 cols
  • currency(symbol='$', decimals=2) — parses numbers even when they carry symbols or thousands separators ("$1,234.50" works), then reformats.
  • date(fmt='%Y-%m-%d') — accepts datetime/date objects and strings in many common shapes (ISO 8601, M/D/Y, D-M-Y, Month D, Y, …) and reformats via strftime.
  • default_if_empty(default='') — falls back when the value is None or blank/whitespace (but not for 0).
  • slugify — lowercases and turns runs of non-word characters into single dashes — handy for filenames.
  • wrap(width=70) — hard-wraps text with textwrap.

Global variables (--var)

Inject values available to every row and to the filename template — a run date, a company name, a batch id:

docforge ... --var company="Acme Co" --var run_date=2026-07-18

--var is repeatable. On a key collision, the row's own field wins, so globals act as defaults.

Shared partials ({% include %})

The render environment is rooted at the template's directory, so a template can pull in shared headers/footers:

{% include "partials/footer.md" %}

Paths resolve relative to the template file's folder. (Using docforge as a library, pass template_dir= to render_template to enable this.)

Output filenames

--name is itself a Jinja template. The default is {{ _index }}{{ _ext }} (1.md, 2.md, …). For stable, human-readable names, base it on an ID:

docforge --name "{{ customer_id }}-{{ due_date }}{{ _ext }}" ...

Characters that would break on Windows or POSIX (<>:"/\|?* and control chars) are replaced with _. Filename collisions across rows abort the run so you don't silently overwrite output.

Bundling output

docforge ... --zip invoices.zip

Writes individual files into --out and a single zip archive for easy email/upload.

Selecting rows (--where, --limit)

Render only the rows you want. --where is repeatable with AND semantics and supports = and !=:

docforge ... --where "plan=Pro" --where "region!=EU" --limit 50

--limit N caps the number of rows rendered (applied after filtering) — great for a quick sample run.

Reading from STDIN (--data -, --format)

Pipe data straight in. Because there's no extension to sniff, pass --format:

psql -c "COPY (…) TO STDOUT WITH CSV HEADER" | \
  docforge -t invoice.md -d - --format csv -o ./out/

--format csv|json|jsonl|xlsx also overrides the inferred format for a mis-extensioned file (e.g. a .txt that's really JSON).

Audit manifest (--manifest)

Write a record of exactly what was produced — one entry per rendered row with its _index, output filename, and the first couple of data columns:

docforge ... --manifest run-manifest.csv   # or .json

CSV and JSON are both supported (chosen by the file extension).

Other flags

  • --dry-run — print the filenames that would be written (to stdout); write nothing.
  • --lenient — missing template variables become empty strings instead of errors.
  • --version — print the version.

Progress and summary messages go to stderr, so stdout stays clean for piping.

As a library

from docforge import load_rows, render_template, render_filename

rows = load_rows("customers.csv")          # or load_rows("-", fmt="csv") for STDIN
template = open("invoice.md").read()
for i, row in enumerate(rows, start=1):
    ctx = {**row, "_index": i, "_ext": ".md"}
    name = render_filename("{{ customer_id }}{{ _ext }}", ctx)
    body = render_template(template, ctx, template_dir="./")  # enables {% include %}
    open(f"out/{name}", "w").write(body)

The custom filters are exported too, as docforge.DOCUMENT_FILTERS, if you want to register them on your own Jinja environment. load_rows takes an optional fmt= and render_template an optional template_dir=; both keep their original positional signatures.

Development

pip install -e ".[dev]"
pytest

License

MIT. See LICENSE.


Built and maintained by document-data-automation — Python workflows for turning repetitive office document and data tasks into reliable automations.

About

Render one document per row from a CSV/Excel/JSON data file and a Jinja2 template. Mail-merge for the command line.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages