Generate recurring documents (invoices, contracts, reports, letters) from a template where only a few fields change each cycle (invoice number, date, amount, recipient name, etc.).
User has:
Triggers: "generate invoice", "monthly invoice", "from template", "same format different date/number", "recurring document", "template + new fields".
.html file — plain text, version-control friendlyuv pip install playwright
python3 -m playwright install chromium
Template pattern:
<!-- invoice-template.html -->
<style>/ all styling here /</style>
<div class="invoice-no">{{INVOICE_NO}}</div>
<div class="date">{{DATE}}</div>
Generation script:
from playwright.sync_api import sync_playwright
html = open('template.html').read()
html = html.replace('{{INVOICE_NO}}', 'gts2627-005')
html = html.replace('{{DATE}}', '2026-08-31')
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.set_content(html, wait_until='networkidle')
page.pdf(path='output.pdf', format='A4', print_background=True,
margin={'top':'0mm','right':'0mm','bottom':'0mm','left':'0mm'})
browser.close()
soffice --headless --convert-to pdfpdf skill's form-filling)Convert to image for visual inspection:
import pymupdf
doc = pymupdf.open('reference.pdf')
page = doc[0]
pix = page.get_pixmap(dpi=200)
pix.save('/tmp/reference.png')
Use vision_analyze to describe layout: sections, colors, fonts, borders, table structure.
Build invoice-template.html with:
@page { size: A4; margin: 0; } for full-page control{{PLACEHOLDER}} syntax for changeable fieldsCreate generate-invoice.py:
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
TEMPLATE_DIR = Path(__file__).parent
TEMPLATE_FILE = TEMPLATE_DIR / 'invoice-template.html'
def generate(invoice_no: str, date: str) -> Path:
html = TEMPLATE_FILE.read_text()
html = html.replace('{{INVOICE_NO}}', invoice_no)
html = html.replace('{{DATE}}', date)
output = TEMPLATE_DIR / f'{date}-[client].pdf'
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.set_content(html, wait_until='networkidle')
page.pdf(path=str(output), format='A4', print_background=True,
margin={'top':'0mm','right':'0mm','bottom':'0mm','left':'0mm'})
browser.close()
return output
if __name__ == '__main__':
output = generate(sys.argv[1], sys.argv[2])
print(f'Generated: {output}')
Convert generated PDF to image and compare with original:
import pymupdf
doc = pymupdf.open('generated.pdf')
doc[0].get_pixmap(dpi=200).save('/tmp/generated.png')
Use vision_analyze to compare layout fidelity.
from docx import Document
doc = Document('template.docx')
See table structure
for i, t in enumerate(doc.tables):
print(f'Table {i}: {len(t.rows)} rows x {len(t.columns)} cols')
for ri, row in enumerate(t.rows):
cells = [c.text[:50] for c in row.cells]
print(f' Row {ri}: {cells}')
See paragraphs
for i, p in enumerate(doc.paragraphs):
if p.text.strip():
print(f'P{i}: {repr(p.text[:80])}')
Map out which cells/paragraphs contain values that change per cycle. Common patterns:
from docx import Document
doc = Document('template.docx')
Replace in tables
replacements = {
'gts-2627-004': 'gts-2627-005',
'2026-07-31': '2026-08-31',
}
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for old, new in replacements.items():
if old in cell.text:
for paragraph in cell.paragraphs:
for run in paragraph.runs:
if old in run.text:
run.text = run.text.replace(old, new)
Also check top-level paragraphs
for p in doc.paragraphs:
for old, new in replacements.items():
if old in p.text:
for run in p.runs:
if old in run.text:
run.text = run.text.replace(old, new)
doc.save('output.docx')
Pitfall: Word often splits a visible string across multiple <w:r> runs (revision IDs, spell-check markers). The text "gts-2627-004" might be three separate runs. If simple run.text replacement misses it, use the XML-level approach from the docx skill's editing section (merge_runs.py first, then replace).
soffice --headless --convert-to pdf output.docx
or from Python:
import subprocess
subprocess.run(['soffice', '--headless', '--convert-to', 'pdf', 'output.docx'], cwd='/output/dir')
# Convert PDF to images for visual inspection
python3 -c "
import fitz # pymupdf
doc = fitz.open('output.pdf')
for i, page in enumerate(doc):
page.get_pixmap(dpi=150).save(f'/tmp/verify_{i}.png')
"
Then inspect with vision_analyze
For invoices: {YYYY-MM-DD}-{client-slug}.pdf (e.g., 2026-08-31-[client].pdf)
Keep the template as {client-slug}.docx (e.g., [client].docx) in the same folder.
uv pip install playwright installs the Python package, but you also need python3 -m playwright install chromium to download the browser binary. Both steps required.print_background=True: Without this flag, background colors (like orange section headers) won't render in the PDF. Always set it.wait_until='networkidle': Ensures all CSS is loaded before PDF capture. Without this, you may get unstyled content.@page { size: A4; margin: 0; } CSS plus margin={'top':'0mm',...} in Playwright ensures the HTML controls all spacing. If you omit one, you get double margins.width: 210mm on the body to match. If content overflows, it creates a second page — use min-height: 297mm to enforce single-page invoices.#f0b27a (warm) or #e8a87c (peach). Fine-tune visually.run.text replacement fails, merge runs first with docx skill's merge_runs.py, then replace.cell.text may appear in multiple rows. Iterate carefully and track which cells were already modified.run.text replacement preserves the run's formatting (bold, font, color). Direct paragraph.text = ... does NOT — it strips all formatting. Always replace at the run level.soffice --headless uses LibreOffice's font substitution. If the template uses a rare font, the PDF may look slightly different. Verify with vision_analyze.soffice --headless fails if another LibreOffice instance is running. Use --nofirststartwizard and ensure no desktop LibreOffice is open, or use a dedicated user profile: soffice -env:UserInstallation=file:///tmp/soffice_profile --headless --convert-to pdf file.docx.invoices/
├── invoice-template.html ← fully templatized (ALL variable fields use {{PLACEHOLDERS}})
├── generate-invoice.py ← loads YAML configs, replaces placeholders, renders PDF
├── clients/ ← Bill To configs (one YAML per client)
│ ├── [client].yaml
│ └── [client].yaml
└── pay-to/ ← Pay To configs (one YAML per payee entity)
├── gts.yaml
└── joseph.yaml
clients/<slug>.yaml):
name: "Company Name Pvt Ltd"
address_1: "Street Address"
address_2: "Area, City"
city_state: "City - PIN"
gstn: "GSTN NUMBER"
state_code: "04"
Payee config (pay-to/<slug>.yaml):
name: "Entity Name"
address_1: "Street Address"
address_2: "City State - PIN"
gstn: "GSTN NUMBER"
account: "1234567890"
bank_line: "Bank Name, Branch, IFSC: XXXXX"
Use {{CATEGORY_FIELD}} syntax. Categories:
{{INVOICE_NO}}, {{DATE}} — per-run values (provided at generation time){{BILL_TO_*}} — from client config{{PAY_TO_*}} — from payee configimport yaml
from pathlib import Path
from playwright.sync_api import sync_playwright
TEMPLATE_DIR = Path(__file__).parent
def load_yaml(path: Path) -> dict:
with open(path) as f:
return yaml.safe_load(f)
def generate_invoice(client: str, payee: str, invoice_no: str, date: str) -> Path:
html = (TEMPLATE_DIR / "invoice-template.html").read_text()
client_cfg = load_yaml(TEMPLATE_DIR / "clients" / f"{client}.yaml")
payee_cfg = load_yaml(TEMPLATE_DIR / "pay-to" / f"{payee}.yaml")
replacements = {
"{{INVOICE_NO}}": invoice_no,
"{{DATE}}": date,
"{{BILL_TO_NAME}}": client_cfg["name"],
"{{BILL_TO_ADDRESS_1}}": client_cfg["address_1"],
# ... all BILL_TO and PAY_TO fields
}
for k, v in replacements.items():
html = html.replace(k, str(v))
output = TEMPLATE_DIR / f"{date}-{client}-{payee}.pdf"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.set_content(html, wait_until="networkidle")
page.pdf(path=str(output), format="A4", print_background=True,
margin={"top":"0mm","right":"0mm","bottom":"0mm","left":"0mm"})
browser.close()
return output
Usage:
python3 generate-invoice.py --client [client] --pay-to gts --invoice NSI/2627/005 --date 2026-08-31
When adding a new client or payee:
1. Create the YAML config file
2. Done — no template or script changes needed
When the layout itself changes (new columns, different sections):
1. Edit invoice-template.html once
2. All entity combinations inherit the change
Do NOT create per-combination configs (e.g.,ns-gts.yaml, ns-jju.yaml). That duplicates the Bill To data across files and creates maintenance drag. Keep the two axes (client × payee) separate.
address_2 may be empty string: Use .get("address_2", "") when loading payee configs — some entities don't have a second address line.{{...}} literal renders in the PDF. Do a quick grep '{{' invoice-template.html after any template edit to catch orphaned placeholders.docx — deep Word document creation/editing (bundled, read-only reference)pdf — PDF operations when template approach isn't viableocr-and-documents — extracting content from scanned PDFs to create templatesreferences/invoice-html-template-example.md — complete HTML invoice template with CSS patterns, color values, layout diagram, and placeholder conventionsreferences/multi-entity-invoice-setup.md — real-world example: [client] / Joseph Judes invoices for [client], directory structure, YAML configs, generation script