
Lovstudio:Fill Form
- 2 installs
- Updated May 7, 2026
- lovstudio/fill-form-skill
Fills Word (.docx) form templates with user data by detecting label-to-value table cells and outputting a completed document.
About
Reads a .docx template with label-value table cells, detects fillable fields, and outputs a completed document with proper CJK/Latin font switching. A user uses it to auto-fill application, registration, or Word table forms.
- Detects label-to-value cell pairs in Word table forms
- Handles CJK/Latin mixed text with font switching
Lovstudio:Fill Form by the numbers
- 2 all-time installs (skills.sh)
- Ranked #548 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Jul 25, 2026 (Skillselion catalog sync)
npx skills add https://github.com/lovstudio/fill-form-skill --skill lovstudiofill-formAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| Last updated | May 7, 2026 |
| Repository | lovstudio/fill-form-skill ↗ |
What it does
Fills Word (.docx) form templates with user data by detecting label-to-value table cells and outputting a completed document.
Files
fill-form — Fill Word Form Templates
This skill fills in Word document form templates (.docx) with user-provided data. It detects table-based form fields (label in one cell, value in the adjacent cell) and populates them automatically.
When to Use
- User has a
.docxform template with blank fields to fill - User wants to fill in an application form, registration form, etc.
- Document uses Word tables for form layout (label | value cell pairs)
- User mentions 填表, 申请表, 登记表, or wants to automate form filling
Workflow (MANDATORY)
You MUST follow these steps in order:
Step 1: Scan the template
Discover all fillable fields:
python lovstudio-fill-form/scripts/fill_form.py --template <path> --scanStep 2: Pre-fill from known context
Before asking the user, try to fill as many fields as possible from: 1. User memory — name, title, organization, etc. 2. Context files — if the user provides reference documents (e.g. STARTER-PROMPT.md, project docs), extract relevant info to fill content-heavy fields 3. Conversation context — anything already mentioned
For content-heavy fields (e.g. "主要内容/简介/摘要"), actively compose the content by synthesizing from context files, user's known expertise, and the topic/title.
Step 3: Ask only what you don't know
Use `AskUserQuestion` to collect ONLY the fields you cannot fill from context.
- Group fields into a single question
- If ALL fields are unknown, list them all
- If the user says some fields can be left blank (e.g. "其他朋友会帮我填"),
respect that and leave those empty
- Do NOT force the user to provide every field
Step 4: Fill and save
Write a JSON data file (avoids shell escaping issues with long text), then run:
python lovstudio-fill-form/scripts/fill_form.py \
--template <path> \
--data-file /tmp/form_data.jsonOutput path rules:
- Default:
<template_dir>/<name>_filled.docx(same directory as the template) - If the template is in a temp directory or system path, save to user's document
directory or ask the user where to save
- Use
--outputto override explicitly
CLI Reference
| Argument | Default | Description |
|---|---|---|
--template | (required) | Path to template .doc/.docx file |
--output | <template_dir>/<name>_filled.docx | Output .docx path |
--scan | false | List all detected form fields |
--data | "" | JSON string with field→value mapping |
--data-file | "" | Path to JSON file with field→value mapping |
--font | Platform CJK serif | Font name for filled text |
--font-size | 11 | Font size in points |
How Field Detection Works
1. Table-based (primary): Scans all tables for rows with label→value cell pairs. A label cell contains short text (CJK or Latin); the adjacent cell is the value field. 2. Merged rows: Detects full-width merged cells with "Label:" pattern as large text areas. 3. Paragraph fallback: If no tables found, detects "Label:value" patterns in paragraphs.
Limitations
.docfiles are auto-converted to.docxvia macOStextutil, which loses table structure.
For best results, use .docx templates directly. If you only have .doc, convert with LibreOffice first: libreoffice --headless --convert-to docx file.doc
- Fields are matched by normalized label text (whitespace removed). If a label contains
unusual formatting, the match may fail — use --scan to verify detection.
Dependencies
pip install python-docx --break-system-packageslovstudio:fill-form
Fill Word document form templates (.docx) with structured data. Auto-detects table-based form fields (label → value cell pairs), supports CJK/Latin mixed text with proper font switching, merged cells, and paragraph-based forms.
Part of lovstudio/skills — by lovstudio.ai
Install
npx skills add lovstudio/skills --skill lovstudio:fill-formRequires: Python 3.8+ and pip install python-docx
How It Works
┌─────────────────────────────────────────────────┐
│ Template (.docx) │
│ ┌──────────┬───────────┬──────────┬──────────┐ │
│ │ 主讲人 │ │ 职称 │ │ │
│ ├──────────┼───────────┴──────────┴──────────┤ │
│ │ 单位 │ │ │
│ ├──────────┼───────────┬──────────┬──────────┤ │
│ │ 讲座题目 │ │ 时间 │ │ │
│ └──────────┴───────────┴──────────┴──────────┘ │
└────────────────────┬────────────────────────────┘
│ --scan → detect fields
│ --data → fill values
▼
┌─────────────────────────────────────────────────┐
│ Filled (.docx) │
│ ┌──────────┬───────────┬──────────┬──────────┐ │
│ │ 主讲人 │ 张三 │ 职称 │ 教授 │ │
│ ├──────────┼───────────┴──────────┴──────────┤ │
│ │ 单位 │ 北京大学 │ │
│ ├──────────┼───────────┬──────────┬──────────┤ │
│ │ 讲座题目 │ AI与未来 │ 时间 │ 4月10日 │ │
│ └──────────┴───────────┴──────────┴──────────┘ │
└─────────────────────────────────────────────────┘Usage
Step 1 — Scan the template to see all detected fields:
python fill_form.py --template form.docx --scanOutput:
Detected 6 form fields:
1. 主讲人
2. 职称
3. 单位
4. 讲座题目
5. 时间
6. 地点
--- JSON ---
{
"主讲人": "",
"职称": "",
...
}Step 2 — Fill with a JSON data file or inline JSON:
# From JSON file
python fill_form.py --template form.docx --data-file data.json
# Inline JSON
python fill_form.py --template form.docx \
--data '{"主讲人": "张三", "职称": "教授", "单位": "北京大学"}'Output saves to the same directory as the template (form_filled.docx).
Options
| Option | Default | Description |
|---|---|---|
--template | (required) | Path to template .doc/.docx |
--output | <name>_filled.docx | Output path (defaults to template directory) |
--scan | List all detected form fields | |
--data | JSON string with field → value mapping | |
--data-file | Path to JSON file with field → value mapping | |
--font | Platform CJK serif | Font for filled text |
--font-size | 11 | Font size in points |
Field Detection
The script detects fillable fields in three ways:
| Method | How it works | Example |
|---|---|---|
| Table cells | Label in one cell, blank value in adjacent cell | │ 姓名 │ ___ │ |
| Merged rows | Full-width cell with Label: pattern | │ 备注:________________ │ |
| Paragraphs | Fallback for docs without tables | 姓名: followed by blank line |
Fields are matched by normalized label (whitespace-insensitive), so 主 讲 人 matches 主讲人.
Supported Formats
| Format | Support |
|---|---|
.docx | Full support (recommended) |
.doc | Auto-converts via textutil (macOS) or LibreOffice. Table structure may be lost — convert to .docx first for best results. |
License
MIT
#!/usr/bin/env python3
"""
fill_form — Fill in Word document form templates.
Reads a .docx template containing tables with label→value cell pairs,
fills specified fields with provided data, and saves the result.
Supports:
- .doc input (auto-converts via textutil on macOS)
- Table-based forms (label in one cell, value in adjacent cell)
- Multi-row merged cells (e.g., large text areas)
- CJK/Latin mixed text with font switching
- Scan mode to list all detected fields
Usage:
# Scan template to list fields
python fill_form.py --template form.docx --scan
# Fill form with JSON data
python fill_form.py --template form.docx --output filled.docx \\
--data '{"主讲人": "张三", "讲座题目": "AI与未来"}'
# Fill from JSON file
python fill_form.py --template form.docx --output filled.docx --data-file data.json
Dependencies:
pip install python-docx --break-system-packages
"""
import re, os, sys, json, argparse, shutil, subprocess, tempfile
from copy import deepcopy
from docx import Document
from docx.shared import Pt, RGBColor
from docx.oxml.ns import qn
import platform as _platform
_PLAT = _platform.system()
# ─── Fonts ───────────────────────────────────────────────────────────
def _get_fonts():
if _PLAT == "Darwin":
return ("Songti SC", "PingFang SC", "Menlo")
elif _PLAT == "Windows":
return ("SimSun", "Microsoft YaHei", "Consolas")
else:
return ("Noto Serif CJK SC", "Noto Sans CJK SC", "DejaVu Sans Mono")
CJK_SERIF, CJK_SANS, MONO = _get_fonts()
_CJK_RANGES = [
(0x4E00, 0x9FFF), (0x3400, 0x4DBF), (0xF900, 0xFAFF), (0x3000, 0x303F),
(0xFF00, 0xFFEF), (0x2E80, 0x2EFF), (0x2F00, 0x2FDF), (0xFE30, 0xFE4F),
(0x20000, 0x2A6DF),
]
def _is_cjk(ch):
cp = ord(ch)
return any(lo <= cp <= hi for lo, hi in _CJK_RANGES)
# ─── .doc → .docx conversion ────────────────────────────────────────
def convert_doc_to_docx(doc_path):
"""Convert .doc to .docx, return path to temp .docx file."""
tmp_dir = tempfile.mkdtemp(prefix="fillform_")
base = os.path.splitext(os.path.basename(doc_path))[0]
out_path = os.path.join(tmp_dir, base + ".docx")
if _PLAT == "Darwin":
# Try textutil first (preserves basic structure)
r = subprocess.run(
["textutil", "-convert", "docx", "-output", out_path, doc_path],
capture_output=True, text=True,
)
if r.returncode == 0 and os.path.exists(out_path):
return out_path
# Try LibreOffice as fallback
for soffice in ["soffice", "libreoffice",
"/Applications/LibreOffice.app/Contents/MacOS/soffice"]:
if shutil.which(soffice) or os.path.exists(soffice):
r = subprocess.run(
[soffice, "--headless", "--convert-to", "docx",
"--outdir", tmp_dir, doc_path],
capture_output=True, text=True,
)
candidate = os.path.join(tmp_dir, base + ".docx")
if r.returncode == 0 and os.path.exists(candidate):
return candidate
print(f"ERROR: Cannot convert .doc to .docx. Install LibreOffice or use a .docx file.",
file=sys.stderr)
sys.exit(1)
# ─── Field detection ────────────────────────────────────────────────
def _normalize_label(text):
"""Normalize a label: strip whitespace and common padding characters."""
return re.sub(r'[\s\u3000\xa0]+', '', text).strip()
def _cell_text(cell):
"""Get clean text from a cell."""
return cell.text.strip()
def _is_label_cell(text):
"""Heuristic: a cell is a label if it has text, is short, and looks like a field name."""
clean = _normalize_label(text)
if not clean or len(clean) > 50:
return False
# Must contain at least one CJK char or look like a known pattern
if any(_is_cjk(c) for c in clean):
return True
if re.match(r'^[A-Za-z\s/]+$', clean) and len(clean) < 30:
return True
return False
def scan_fields(doc):
"""Scan document tables and return list of detected field labels with locations."""
fields = []
for ti, table in enumerate(doc.tables):
for ri, row in enumerate(table.rows):
cells = row.cells
ci = 0
while ci < len(cells):
cell = cells[ci]
text = _cell_text(cell)
if _is_label_cell(text):
norm = _normalize_label(text)
# The value cell is the next non-label cell
value_ci = ci + 1
# Skip duplicate merged cells (python-docx repeats merged cells)
while value_ci < len(cells) and cells[value_ci]._tc is cell._tc:
value_ci += 1
if value_ci < len(cells):
value_cell = cells[value_ci]
existing = _cell_text(value_cell)
fields.append({
"label": norm,
"raw_label": text,
"table": ti,
"row": ri,
"label_col": ci,
"value_col": value_ci,
"current_value": existing if existing else "",
})
ci = value_ci + 1
continue
ci += 1
# Also detect large text areas (merged cells spanning full row with label in text)
for ti, table in enumerate(doc.tables):
for ri, row in enumerate(table.rows):
cells = row.cells
if len(set(id(c._tc) for c in cells)) == 1:
# All cells are the same (fully merged row)
text = _cell_text(cells[0])
# Check if it starts with a label-like pattern
m = re.match(r'^(.+?)[::]\s*$', text) or re.match(r'^(.+?)[::]', text)
if m:
label = _normalize_label(m.group(1))
# Avoid duplicates
if not any(f["label"] == label for f in fields):
fields.append({
"label": label,
"raw_label": m.group(1),
"table": ti,
"row": ri,
"label_col": 0,
"value_col": -1, # -1 means inline (same cell, after colon)
"current_value": text[m.end():].strip(),
})
# Fallback: if no table fields found, try paragraph-based detection
if not fields:
fields = _scan_paragraph_fields(doc)
return fields
def _scan_paragraph_fields(doc):
"""Detect fields in paragraph-based forms (no tables).
Pattern: a paragraph with label text followed by an empty/short paragraph as value.
Also detects 'label:value' patterns on a single line.
"""
fields = []
paras = doc.paragraphs
for i, p in enumerate(paras):
text = p.text.strip()
if not text:
continue
# Pattern 1: "Label:" at end of line, value on next line(s) or empty
m = re.match(r'^(.+?)[::]\s*$', text)
if m and _is_label_cell(m.group(1)):
label = _normalize_label(m.group(1))
# Value is in following non-empty paragraph(s) until next label
value_parts = []
j = i + 1
while j < len(paras):
next_text = paras[j].text.strip()
if next_text and not re.match(r'^.+?[::]\s*$', next_text) and not _is_label_cell(next_text):
value_parts.append(next_text)
else:
break
j += 1
fields.append({
"label": label,
"raw_label": m.group(1),
"table": -1,
"row": -1,
"label_col": -1,
"value_col": -1,
"para_index": i,
"current_value": " ".join(value_parts),
"type": "paragraph",
})
continue
# Pattern 2: "Label:value" on same line
m = re.match(r'^(.+?)[::]\s*(.+)$', text)
if m and _is_label_cell(m.group(1)):
label = _normalize_label(m.group(1))
if not any(f["label"] == label for f in fields):
fields.append({
"label": label,
"raw_label": m.group(1),
"table": -1,
"row": -1,
"label_col": -1,
"value_col": -1,
"para_index": i,
"current_value": m.group(2).strip(),
"type": "paragraph_inline",
})
return fields
def _set_cell_text(cell, text, font_name=CJK_SERIF, font_size=Pt(11)):
"""Set cell text, preserving basic formatting and applying CJK font."""
# Clear existing content
for p in cell.paragraphs:
for r in p.runs:
r.text = ""
if cell.paragraphs:
p = cell.paragraphs[0]
else:
p = cell.add_paragraph()
# Preserve alignment if set
run = p.add_run(text)
run.font.size = font_size
# Set both Latin and CJK font
run.font.name = font_name
rPr = run._element.get_or_add_rPr()
rFonts = rPr.find(qn('w:rFonts'))
if rFonts is None:
rFonts = parse_xml(f'<w:rFonts {nsdecls("w")} w:eastAsia="{font_name}"/>')
rPr.insert(0, rFonts)
else:
rFonts.set(qn('w:eastAsia'), font_name)
def fill_fields(doc, data, fields=None, font_name=CJK_SERIF, font_size=Pt(11)):
"""Fill detected fields with provided data. Returns list of filled field names."""
if fields is None:
fields = scan_fields(doc)
filled = []
args_font = font_name
args_font_size = font_size
data_norm = {_normalize_label(k): v for k, v in data.items()}
for field in fields:
label = field["label"]
if label not in data_norm:
continue
value = str(data_norm[label])
ti = field["table"]
ri = field["row"]
if ti >= 0:
table = doc.tables[ti]
row = table.rows[ri]
else:
row = None
field_type = field.get("type", "table")
if field_type == "paragraph":
# Paragraph-based: set the paragraph after the label
pi = field["para_index"]
p = doc.paragraphs[pi]
raw = field["raw_label"]
# Clear following value paragraphs and set first one
if pi + 1 < len(doc.paragraphs):
next_p = doc.paragraphs[pi + 1]
next_p.clear()
run = next_p.add_run(value)
run.font.name = args_font
run.font.size = args_font_size
else:
p.clear()
run = p.add_run(f"{raw}:{value}")
run.font.name = args_font
run.font.size = args_font_size
elif field_type == "paragraph_inline":
pi = field["para_index"]
p = doc.paragraphs[pi]
raw = field["raw_label"]
p.clear()
run = p.add_run(f"{raw}:{value}")
run.font.name = args_font
run.font.size = args_font_size
elif row is not None and field["value_col"] == -1:
# Inline field in table cell (label: value in same cell)
cell = row.cells[field["label_col"]]
raw = field["raw_label"]
_set_cell_text(cell, f"{raw}:{value}")
elif row is not None:
cell = row.cells[field["value_col"]]
_set_cell_text(cell, value)
filled.append(label)
return filled
# We need nsdecls import for _set_cell_text
from docx.oxml.ns import nsdecls
from docx.oxml import parse_xml
# ─── Main ───────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description="Fill in Word form templates")
ap.add_argument("--template", required=True, help="Path to template .doc/.docx file")
ap.add_argument("--output", default="", help="Output .docx path (default: <template>_filled.docx)")
ap.add_argument("--scan", action="store_true", help="Scan and list all detected form fields")
ap.add_argument("--data", default="", help="JSON string with field→value mapping")
ap.add_argument("--data-file", default="", help="Path to JSON file with field→value mapping")
ap.add_argument("--font", default=CJK_SERIF, help=f"Font name for filled text (default: {CJK_SERIF})")
ap.add_argument("--font-size", type=float, default=11, help="Font size in points (default: 11)")
args = ap.parse_args()
template_path = args.template
# Handle .doc files
tmp_docx = None
if template_path.lower().endswith(".doc") and not template_path.lower().endswith(".docx"):
print(f"Converting .doc → .docx ...", file=sys.stderr)
tmp_docx = convert_doc_to_docx(template_path)
template_path = tmp_docx
doc = Document(template_path)
if args.scan:
fields = scan_fields(doc)
if not fields:
print("No form fields detected in the document.")
return
print(f"Detected {len(fields)} form fields:\n")
for i, f in enumerate(fields, 1):
current = f" (current: \"{f['current_value']}\")" if f["current_value"] else ""
print(f" {i}. {f['label']}{current}")
# Also output as JSON for programmatic use
print(f"\n--- JSON ---")
template = {f["label"]: f["current_value"] or "" for f in fields}
print(json.dumps(template, ensure_ascii=False, indent=2))
return
# Load data
if args.data:
data = json.loads(args.data)
elif args.data_file:
with open(args.data_file, encoding="utf-8") as f:
data = json.load(f)
else:
print("ERROR: Provide --data or --data-file to fill the form.", file=sys.stderr)
sys.exit(1)
fields = scan_fields(doc)
filled = fill_fields(doc, data, fields, font_name=args.font, font_size=Pt(args.font_size))
# Determine output path
output = args.output
if not output:
base = os.path.splitext(os.path.basename(args.template))[0]
template_dir = os.path.dirname(os.path.abspath(args.template))
output = os.path.join(template_dir, base + "_filled.docx")
doc.save(output)
print(f"Filled {len(filled)}/{len(fields)} fields → {output}")
if filled:
print(f" Filled: {', '.join(filled)}")
unfilled = [f["label"] for f in fields if f["label"] not in filled]
if unfilled:
print(f" Unfilled: {', '.join(unfilled)}")
if __name__ == "__main__":
main()