
Fill Protocol
- 45 installs
- 236 repo stars
- Updated August 3, 2026
- aperivue/medsci-skills
Fill-protocol is a Claude Code skill that fills institutional Word templates for IRB protocols, ethics applications, and grants in place while preserving the original styles, tables, and fonts.
About
Fill-protocol populates institutional Word form templates for IRB protocols, ethics applications, and grant proposals without breaking the original formatting. A researcher uses it to render drafted content into an institutional template by opening the file and replacing cell or paragraph text in place. It pairs with write-protocol, which drafts the scientific content.
- Fills institutional Word templates (IRB, ethics, grant) while preserving styles and table layout
- Matches cells by left-label text and applies cantSplit so rows never break across pages
- CJK-aware: enforces eastAsia font for Hangul, Kanji, and Hanzi rendering
Fill Protocol by the numbers
- 45 all-time installs (skills.sh)
- Ranked #377 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
fill-protocol capabilities & compatibility
- Capabilities
- fill icmje coi · grant builder · find journal
- Use cases
- documentation
- Pricing
- Free
What fill-protocol says it does
Fill institutional Word form templates (.doc/.docx) for IRB protocols, ethics applications, grant proposals
Recreating institutional forms from scratch with `python-docx` reliably destroys table layouts, page breaks, and font consistency.
Open the existing template — never create from scratch.
npx skills add https://github.com/aperivue/medsci-skills --skill fill-protocolAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 45 |
|---|---|
| repo stars | ★ 236 |
| Last updated | August 3, 2026 |
| Repository | aperivue/medsci-skills ↗ |
What it does
Fill institutional IRB, ethics, or grant Word templates in place while preserving styles, tables, and fonts.
Who is it for?
Researchers laying drafted content into an institutional IRB, ethics, or grant Word template without breaking its formatting.
Skip if: Drafting the scientific content itself, which is write-protocol's job.
When should I use this skill?
Drafted protocol content must be rendered into a required institutional Word form template.
What you get
A filled institutional Word document with preserved layout, matched labels, and CJK fonts intact.
- filled institutional Word document
By the numbers
- three fill modes (table_kv, section_replace, paragraph_replace)
Files
Fill-Protocol Skill
You are helping a researcher populate an institutional Word form (IRB protocol, ethics application, grant proposal, etc.) without breaking the original document formatting. This skill is the formatting counterpart to write-protocol: where write-protocol drafts content, fill-protocol lays that content into the institutional template.
Why This Skill Exists
Recreating institutional forms from scratch with python-docx reliably destroys table layouts, page breaks, and font consistency. The only safe approach is to open the existing template and replace cell/paragraph text in place. This skill enforces that pattern.
Core Principles (Do Not Violate)
1. Open the existing template — never create from scratch. Use Document(template_path), not Document(). 2. Convert .doc → .docx via LibreOffice headless before any editing. pandoc -f doc is not supported; textutil corrupts table structure. 3. Match cells by left-label text, not row/column coordinates. Templates evolve and coordinate matching breaks silently. 4. Apply `cantSplit` to every filled row so a row never breaks across pages. 5. For CJK languages, set the `eastAsia` font attribute, not just run.font.name. Hangul/Kanji/Hanzi will render in fallback fonts otherwise. 6. Validate every fill operation: report unmatched labels, count empty cells, and surface mismatches before saving.
Dependencies
If the template is already .docx, LibreOffice is not required — only the three Python packages below. LibreOffice is needed only when the template is a legacy .doc and must be converted first.
# Python libraries (always required)
pip install --user docxtpl python-docx pyyaml
# LibreOffice (only for legacy .doc input; ~700 MB on macOS)
brew install --cask libreoffice # macOS
sudo apt-get install -y libreoffice # Debian/Ubuntu
sudo dnf install -y libreoffice # Fedora
sudo pacman -S --needed libreoffice-fresh # ArchBundled setup script
The skill ships a setup.sh that detects what is missing and installs only those parts, with a confirmation prompt before each step:
bash setup.sh check # report what's installed (read-only)
bash setup.sh install # install missing pieces (asks before each)Auto-install behavior (for Claude as the caller)
When invoking this skill on behalf of a user:
1. Before calling `doc_to_docx.py`, run bash setup.sh check. If LibreOffice is missing, ask the user before installing — the cask is ~700 MB and proceeding silently is unfriendly. 2. Skip LibreOffice entirely if the template is already .docx. Only surface the install prompt when a .doc is encountered. 3. Never pass --yes to setup.sh install unless the user has explicitly authorized unattended installation in this session. 4. If the user declines installation, fall back to asking them to convert the .doc manually (open in Word/LibreOffice/Pages → Save As → .docx) and then re-run with the converted file.
Workflow
Step 1 — Convert legacy .doc to .docx (if needed)
python scripts/doc_to_docx.py path/to/template.doc path/to/template.docxStep 2 — Inspect the template structure
python scripts/inspect_template.py path/to/template.docxThis lists every table, every cell (with row/column coordinates and content preview), and every top-level paragraph. Use this output to identify the labels you will match against in your YAML content file.
Step 3 — Author a content YAML
The YAML supports three fill modes. All keys are optional.
protections:
korean_font: "맑은 고딕" # CJK font (set to "Noto Sans CJK KR", "SimSun",
# "MS Mincho", etc. for other locales)
cant_split: true # Apply <w:cantSplit/> to every filled row
# Readability options (see "Readability" section below for full semantics)
blank_between_paragraphs: true # default true — Enter between \n\n chunks
blank_around_section_header: true # default true — Enter above/below filled sections
blank_around_all_section_headers: false # default false — opt-in; also touches untouched sections
# Mode 1 — table key/value (left-label cell → right value cell)
table_kv:
"Study Title": "Multi-center prospective validation of ..."
"Principal Investigator": "Last, First (Department)"
"연구 목적": "본 연구는 ..."
# Mode 2 — section replacement (find numbered header, replace until next header)
section_replace:
"1. Background":
"Hepatocellular carcinoma is the third leading cause of ..."
"4. 연구 배경 및 이론적 근거":
"..."
# Mode 3 — single paragraph in-place text replacement
paragraph_replace:
"Title:":
"Title: Multi-center prospective validation of ..."Readability — three blank-line knobs
All blank paragraphs inserted by these options use a forced single-line height (<w:spacing w:line="240" w:before="0" w:after="0"/>) so the gap is exactly one body-text line — never inflates the document's apparent line spacing.
| Option | Default | What it does | When to flip |
|---|---|---|---|
blank_between_paragraphs | true | Inserts a blank line between every \n\n-split chunk inside section_replace | Disable only for forms where every line must be packed tight |
blank_around_section_header | true | Wraps each header that you section_replace with a blank above and a blank below | Disable when the template style already adds visual gaps via space_before/after |
blank_around_all_section_headers | false | After all fills, scans every numbered header (\d+\.\s+) — including ones you didn't replace — and adds blank lines around them | Enable when uniform readability matters more than form fidelity. Default off because IRB / public-document submissions favor template fidelity over visual consistency (page count stability, boilerplate untouched, reviewer-expected layout) |
normalize_page_breaks | true | On save, converts dangling empty paragraphs whose sole content is <w:br w:type="page"/> into a <w:pageBreakBefore/> attribute on the next content paragraph. Prevents visible blank pages when the preceding content (e.g. an abstract table) grows or shrinks and pushes the empty paragraph onto a page of its own, causing the break to land one page later. | Disable only if your template intentionally relies on the empty-paragraph-as-separator pattern for spacing |
The third option exists because section_replace only touches sections you list in the YAML. If a template has 18 numbered sections and you only fill 12, the other 6 stay tight against their content — visually inconsistent. Turn the opt-in on for documents where you'd rather the consistency than the fidelity.
Step 4 — Run the fill
python scripts/fill_form.py \
--template path/to/template.docx \
--content content.yaml \
--output path/to/filled.docxThe CLI prints [OK] / [MISS] for every fill operation and a summary at the end. Investigate any [MISS] before submitting.
Step 5 — Visual verification
soffice --headless --convert-to pdf path/to/filled.docxOpen the PDF and visually confirm: page count is sensible, no table row was split across pages, no font fell back to Times New Roman, all required fields are populated.
Python API
from fill_form import FormFiller
filler = FormFiller("template.docx", korean_font="맑은 고딕")
# Fill table cells
filler.fill_table_kv("Study Title", "...")
filler.fill_table_kv("연구 목적", "...")
# Replace section content (header to next header)
filler.replace_paragraphs_after("4. Background", new_content)
# Replace a single paragraph
filler.replace_paragraph_matching("Title:", "Title: ...")
# Validate and save
warnings = filler.validate()
for w in warnings:
print(w)
filler.save("filled.docx")Anti-Patterns (Do Not Do)
| Anti-pattern | Consequence |
|---|---|
Document() then rebuild table | Loss of header logo, custom margins, footer placeholders, and page numbering |
pandoc -f doc -t docx | "Unknown input format doc" — pandoc does not parse .doc |
textutil -convert docx | Table cell merging is dropped or corrupted |
cell.text = "value" (single assignment) | Run-level styles (bold, color, eastAsia font) are erased |
Coordinate-based matching table.cell(2, 1) | Silent breakage when the template adds or reorders rows |
run.font.name alone for Hangul | Hangul characters render in the default Western font |
Companion Skills
write-protocol— drafts the scientific content (Background, Study Design,
Sample Size, Statistical Plan) that fill-protocol then renders into the form
hwp-pipeline— converts Korean Hangul .hwp / .hwpx files; chain it before
fill-protocol when the institutional form is distributed in HWP format
check-reporting— validates that the filled protocol satisfies CONSORT /
STARD / TRIPOD / CLAIM checklists before submission
calc-sample-size— produces the sample size text thatfill-protocolslots
into the corresponding section
Files
scripts/doc_to_docx.py— LibreOffice headless wrapper for .doc → .docxscripts/inspect_template.py— reports tables, cells, and paragraphsscripts/fill_form.py— theFormFillerlibrary and CLI entry pointexamples/— worked examples for IRB, ethics waiver, and grant templatesreferences/best_practices.md— formatting notes (cantSplit, eastAsia,
multi-line cell text)
Known Limitations
- HWP / HWPX input is not handled directly — chain with
hwp-pipelineto
convert HWP → HWPX → DOCX first.
- Merged cells: filling a label cell that participates in a vertical merge
may overwrite the merged region's content. Test on a copy first.
- Embedded form fields (Word's content controls): not yet supported. Plain
paragraph and table cell content only.
- Right-to-left scripts (Arabic, Hebrew): untested.
Anti-Hallucination
- Never fabricate references. All citations must be verified via
/search-litwith confirmed DOI or PMID. Mark unverified references as[UNVERIFIED - NEEDS MANUAL CHECK]. - Never invent clinical definitions, diagnostic criteria, or guideline recommendations. If uncertain, flag with
[VERIFY]and ask the user. - Never fabricate numerical results — compliance percentages, scores, effect sizes, or sample sizes must come from actual data or analysis output.
- If a reporting guideline item, journal policy, or clinical standard is uncertain, state the uncertainty rather than guessing.
# Example content file for fill-protocol
# Adapt the labels to match your institution's IRB template exactly
# (run `inspect_template.py` first to see the actual cell labels)
protections:
korean_font: "맑은 고딕" # Use "Noto Sans CJK KR" on Linux, "Apple SD Gothic Neo" on macOS Apple-default
cant_split: true
# Mode 1: ABSTRACT-style key/value table (most institutional IRB forms)
table_kv:
"Study Title": >
Multi-center prospective validation of [intervention] for [population]
"Principal Investigator": "Family-Name, Given-Name (Department, Institution)"
"Study Design": >
Prospective, multi-center, single-arm validation cohort with [comparator]
as historical control. Primary endpoint assessed at [timepoint].
"Study Period": "From IRB approval through [YYYY-MM-DD] (~ X years)"
"Target Enrollment": "N = X (this institution: n = Y)"
"Inclusion Criteria": |
All of the following:
1. Age ≥ 19 years
2. [Disease] confirmed by [reference standard] between [date] and [date]
3. [Imaging modality] with [protocol requirement]
"Exclusion Criteria": |
Any of the following:
1. [Image quality criterion fails]
2. [Co-existing disease that confounds]
3. Prior [intervention that alters baseline]
"Sample Size Justification": >
[Insert output of /calc-sample-size here, or describe pilot/feasibility framing]
# Mode 2: Long-form numbered sections in body text
section_replace:
"1. Study Title": >
English: ...
Korean (국문): ...
"4. Background and Rationale":
"..." # Insert output of /write-protocol Background section
"5. Study Objectives":
"..." # Insert output of /write-protocol Objectives section
"12. Statistical Analysis Plan":
"..." # Insert output of /write-protocol + /analyze-stats Statistical Plan
"18. References":
"..." # Insert verified references from /search-lit
# Mode 3: Single-paragraph in-place text replacement (e.g., the title line)
paragraph_replace:
"Title:":
"Title: Multi-center prospective validation of ..."
fill-protocol — Best Practices Reference
CJK Font Setting (mandatory for Korean / Japanese / Chinese)
run.font.name = "맑은 고딕" alone does not apply to Hangul characters in docx output. Word and LibreOffice route CJK glyphs through the eastAsia font slot, which lives in <w:rPr><w:rFonts w:eastAsia="..."/>. The skill sets all four font slots (ascii, hAnsi, cs, eastAsia) to the same font name to guarantee consistent rendering.
Recommended fonts by platform
| Platform | CJK font that always exists |
|---|---|
| Windows | 맑은 고딕 (Malgun Gothic) |
| macOS | Apple SD Gothic Neo |
| Linux | Noto Sans CJK KR |
If the document will be opened on multiple platforms, embed the font in the .docx (Word: File → Options → Save → Embed fonts in the file) or stick to fonts that exist everywhere (Noto family).
Table Row Page-Break Prevention (cantSplit)
Korean institutional IRB tables routinely have multi-line cells (e.g. inclusion/exclusion criteria with 5–10 items). Without cantSplit, a row can break across pages and the label cell ends up orphaned on the previous page.
The XML insertion looks like:
<w:tr>
<w:trPr>
<w:cantSplit/> <!-- this line is added by the skill -->
</w:trPr>
...
</w:tr>The skill applies this automatically to every row that gets filled. You can also pre-set this in Word: select the row → Layout → Properties → Row → uncheck "Allow row to break across pages".
Multi-line Cell Content
YAML | (literal block) and > (folded block) both produce strings with embedded newlines. fill-protocol splits on \n and writes each line as a separate paragraph in the cell, cloning the first paragraph's pPr so indentation, line spacing, and alignment are preserved.
"Inclusion Criteria": |
All of the following:
1. Age ≥ 19 years
2. Confirmed diagnosis ...
3. Imaging within 30 daysIf you want bullets (•) instead of numbers, type them literally in the YAML — Word formatting is preserved at the run level, but list numbering markers are not auto-generated.
Label Matching
The skill normalizes whitespace (including newlines) before comparing cell content to the YAML key. So a cell labeled
연구대상자
정보matches the YAML key "연구대상자 정보" (with a space). Confirm exact labels via inspect_template.py — institutional templates often have trailing spaces, half-width vs. full-width parentheses, or zero-width characters that are invisible in Word but break exact-match.
Section Header Matching
section_replace finds a paragraph whose text equals the YAML key, then replaces every paragraph from there until the next paragraph that starts with \d+\.\s+ (e.g. "1. ", "12. "). This is robust across templates that re-number sections, but assumes numbered headers. For non-numbered templates, pass stop_pattern to replace_paragraphs_after() directly in Python.
Merged Cells
python-docx returns the same _Cell object for cells that participate in a merge (horizontal or vertical). Filling such a cell once propagates the content. The skill detects this via id(cell._tc) and skips duplicates within a row, so vertical-merge label cells won't be filled multiple times.
Validation Before Submission
Always run the visual check:
soffice --headless --convert-to pdf filled.docxLook for:
1. Page count is roughly equal to the original template (±20% is normal, ±50% suggests content overflow or section deletion). 2. No empty cells in mandatory fields. 3. Footer / page number formatting unchanged. 4. CJK characters rendering correctly (not boxes, not Times New Roman substitution). 5. Tables not broken across pages mid-row.
When This Skill Is Not the Right Tool
- HWP / HWPX input: chain with
hwp-pipelinefirst (HWP → HWPX → DOCX) - PDF form filling: use the
pdfskill or a dedicated PDF-form library - Free-form research writing: use
write-paperorwrite-protocol - Slides / presentations: use
generate-pptx - Templates with Word "content controls" (interactive form fields): not
yet supported by this skill
#!/usr/bin/env python3
"""Convert .doc → .docx via LibreOffice headless. Preserves table/font/page layout.
Usage: python3 doc_to_docx.py <input.doc> [output_dir]
python3 doc_to_docx.py <input.doc> <output.docx>
"""
import sys
import shutil
import subprocess
from pathlib import Path
SOFFICE_CANDIDATES = [
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
"/usr/bin/soffice",
"/opt/homebrew/bin/soffice",
"soffice",
]
def _platform_install_hint() -> str:
"""Return platform-specific install instructions for LibreOffice."""
import platform
sys_name = platform.system()
skill_root = Path(__file__).resolve().parent.parent
setup = skill_root / "setup.sh"
lines = ["LibreOffice (soffice) not found.",
"Required only for legacy .doc → .docx conversion.",
"(.docx templates work without it.)",
"",
"Install:"]
if sys_name == "Darwin":
lines.append(" brew install --cask libreoffice")
elif sys_name == "Linux":
lines.append(" sudo apt-get install -y libreoffice # Debian/Ubuntu")
lines.append(" sudo dnf install -y libreoffice # Fedora")
lines.append(" sudo pacman -S --needed libreoffice-fresh # Arch")
else:
lines.append(" See https://www.libreoffice.org/download/")
lines.append("")
lines.append("Or run the bundled setup script:")
lines.append(f" bash {setup} install")
return "\n".join(lines)
def find_soffice() -> str:
for path in SOFFICE_CANDIDATES:
if shutil.which(path) or Path(path).exists():
return path
raise FileNotFoundError(_platform_install_hint())
def convert(input_path: Path, output: Path) -> Path:
soffice = find_soffice()
if output.is_dir() or not output.suffix:
out_dir = output if output.is_dir() else output.parent
out_dir.mkdir(parents=True, exist_ok=True)
cmd = [
soffice,
"--headless",
"--convert-to",
"docx",
"--outdir",
str(out_dir),
str(input_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
raise RuntimeError(f"soffice conversion failed:\n{result.stderr}")
produced = out_dir / (input_path.stem + ".docx")
if not produced.exists():
raise RuntimeError(f"Expected output not found: {produced}")
return produced
out_dir = output.parent
out_dir.mkdir(parents=True, exist_ok=True)
cmd = [
soffice,
"--headless",
"--convert-to",
"docx",
"--outdir",
str(out_dir),
str(input_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
raise RuntimeError(f"soffice conversion failed:\n{result.stderr}")
auto_name = out_dir / (input_path.stem + ".docx")
if auto_name != output and auto_name.exists():
auto_name.replace(output)
return output
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
inp = Path(sys.argv[1]).expanduser().resolve()
if not inp.exists():
print(f"Input not found: {inp}", file=sys.stderr)
sys.exit(2)
if len(sys.argv) >= 3:
out = Path(sys.argv[2]).expanduser().resolve()
else:
out = inp.with_suffix(".docx")
produced = convert(inp, out)
print(f"Converted: {produced}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Fill a Korean Word form template while preserving styles, tables, fonts, and page layout.
Core principles (DO NOT BREAK):
1. Always open existing template via Document(path) — never create from scratch.
2. Modify cell/paragraph TEXT only. Preserve all run-level styles.
3. Apply cantSplit to every row that gets filled (prevents page-break-mid-row).
4. Set Korean font with eastAsia attribute (run.font.name alone fails for Korean).
5. Validate: report empty cells and paragraphs that didn't match.
"""
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable
import yaml
from docx import Document
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Pt
from docx.text.paragraph import Paragraph
from docx.table import _Cell
DEFAULT_KOREAN_FONT = "맑은 고딕"
# ---------- Style preservation helpers ----------
def _set_run_korean_font(run, font_name: str) -> None:
"""Set font for a run including eastAsia attribute (mandatory for Hangul)."""
run.font.name = font_name
rPr = run._element.get_or_add_rPr()
rFonts = rPr.find(qn("w:rFonts"))
if rFonts is None:
rFonts = OxmlElement("w:rFonts")
rPr.append(rFonts)
for attr in ("w:ascii", "w:hAnsi", "w:cs", "w:eastAsia"):
rFonts.set(qn(attr), font_name)
def _apply_cant_split(row) -> None:
"""Mark row to never split across pages."""
trPr = row._tr.get_or_add_trPr()
if trPr.find(qn("w:cantSplit")) is None:
trPr.append(OxmlElement("w:cantSplit"))
def _make_blank_paragraph() -> "OxmlElement":
"""Create an empty paragraph that renders as a single Enter press.
Forces single line height (line=240) and zero spacing-before/after,
so the blank line is exactly one body-text line tall — never inflates
the document's apparent line spacing.
"""
p = OxmlElement("w:p")
pPr = OxmlElement("w:pPr")
spacing = OxmlElement("w:spacing")
spacing.set(qn("w:line"), "240")
spacing.set(qn("w:lineRule"), "auto")
spacing.set(qn("w:before"), "0")
spacing.set(qn("w:after"), "0")
pPr.append(spacing)
p.append(pPr)
return p
def _replace_paragraph_text_keep_style(para: Paragraph, new_text: str,
korean_font: str | None = None) -> None:
"""Replace the entire text content of a paragraph while keeping its style.
Strategy: keep the first run's properties as the template style. Remove all
other runs. Replace the first run's text with new_text. For multi-line
content, split on \n and use w:br between lines (within same run-style block).
"""
# Capture template run (first one) style by copying its rPr
runs = para.runs
template_rPr = None
if runs:
template_run_elem = runs[0]._element
rPr = template_run_elem.find(qn("w:rPr"))
if rPr is not None:
template_rPr = rPr
# Remove all existing runs
for r in list(para._element.findall(qn("w:r"))):
para._element.remove(r)
# Add new run with the captured style
new_run = OxmlElement("w:r")
if template_rPr is not None:
# Deep copy template rPr
from copy import deepcopy
new_run.append(deepcopy(template_rPr))
# Split on \n — insert w:br between lines, w:t for text segments
lines = new_text.split("\n")
for i, line in enumerate(lines):
if i > 0:
br = OxmlElement("w:br")
new_run.append(br)
if line:
t = OxmlElement("w:t")
t.text = line
t.set(qn("xml:space"), "preserve")
new_run.append(t)
para._element.append(new_run)
if korean_font:
# Reapply Korean font to the new run
from docx.text.run import Run
run_obj = Run(new_run, para)
_set_run_korean_font(run_obj, korean_font)
def _replace_cell_text(cell: _Cell, new_text: str,
korean_font: str | None = None) -> None:
"""Replace a cell's text content. Use the first paragraph as template."""
if not cell.paragraphs:
# Cell has no paragraph — add one
cell.add_paragraph(new_text)
if korean_font:
for r in cell.paragraphs[0].runs:
_set_run_korean_font(r, korean_font)
return
# Replace first paragraph, then remove the rest
template_para = cell.paragraphs[0]
# If the new content has multiple lines, we replace first paragraph
# with the first line, and add additional paragraphs for remaining lines.
lines = new_text.split("\n")
_replace_paragraph_text_keep_style(template_para, lines[0],
korean_font=korean_font)
# Remove all paragraphs after the first
for p in list(cell._tc.findall(qn("w:p")))[1:]:
cell._tc.remove(p)
# Add new paragraphs for remaining lines (cloning first paragraph's pPr)
if len(lines) > 1:
from copy import deepcopy
first_p = cell._tc.find(qn("w:p"))
first_pPr = first_p.find(qn("w:pPr")) if first_p is not None else None
first_rPr = None
first_r = first_p.find(qn("w:r")) if first_p is not None else None
if first_r is not None:
first_rPr = first_r.find(qn("w:rPr"))
for line in lines[1:]:
new_p = OxmlElement("w:p")
if first_pPr is not None:
new_p.append(deepcopy(first_pPr))
new_r = OxmlElement("w:r")
if first_rPr is not None:
new_r.append(deepcopy(first_rPr))
t = OxmlElement("w:t")
t.text = line
t.set(qn("xml:space"), "preserve")
new_r.append(t)
new_p.append(new_r)
cell._tc.append(new_p)
if korean_font:
for p in cell.paragraphs:
for r in p.runs:
_set_run_korean_font(r, korean_font)
# ---------- FormFiller class ----------
@dataclass
class FillResult:
matched: list[str] = field(default_factory=list)
unmatched: list[str] = field(default_factory=list)
class FormFiller:
def __init__(self, template_path: str | Path,
korean_font: str = DEFAULT_KOREAN_FONT,
blank_between_paragraphs: bool = True,
blank_around_section_header: bool = True,
blank_around_all_section_headers: bool = False,
normalize_page_breaks: bool = True):
self.path = Path(template_path).expanduser().resolve()
if not self.path.exists():
raise FileNotFoundError(self.path)
self.doc = Document(str(self.path))
self.korean_font = korean_font
self.blank_between_paragraphs = blank_between_paragraphs
self.blank_around_section_header = blank_around_section_header
self.blank_around_all_section_headers = blank_around_all_section_headers
self.normalize_page_breaks_flag = normalize_page_breaks
self._filled_rows: set[int] = set()
self._table_results = FillResult()
self._paragraph_results = FillResult()
# ---- Table cell filling ----
def _cell_text(self, cell: _Cell) -> str:
return "\n".join(p.text for p in cell.paragraphs).strip()
def _label_match(self, cell_text: str, label: str) -> bool:
# Normalize whitespace and newlines
norm_cell = re.sub(r"\s+", "", cell_text)
norm_label = re.sub(r"\s+", "", label)
return norm_cell == norm_label
def fill_table_kv(self, label: str, value: str) -> bool:
"""Find a cell whose text == label, fill the next cell on the right.
Returns True if filled, False otherwise.
Skips merged duplicate cells (same _tc reference).
"""
for table in self.doc.tables:
for row_idx, row in enumerate(table.rows):
# Track unique cells in this row (skip merged duplicates)
seen_tcs: set[int] = set()
cells_in_row: list[_Cell] = []
for c in row.cells:
if id(c._tc) not in seen_tcs:
seen_tcs.add(id(c._tc))
cells_in_row.append(c)
for ci, cell in enumerate(cells_in_row):
if self._label_match(self._cell_text(cell), label):
# Found label cell. Fill the next cell on the right.
if ci + 1 < len(cells_in_row):
target = cells_in_row[ci + 1]
_replace_cell_text(target, value,
korean_font=self.korean_font)
_apply_cant_split(row)
self._table_results.matched.append(label)
return True
self._table_results.unmatched.append(label)
return False
# ---- Paragraph (section) filling ----
def replace_paragraphs_after(self, header_text: str, new_content: str,
stop_pattern: str | None = None) -> bool:
"""Find a paragraph matching header_text, then replace all paragraphs
between this header and the next section header (or stop_pattern) with
new_content.
new_content is split by \n\n into separate paragraphs (preserving the
style of the first replaced paragraph).
"""
body = self.doc.element.body
all_ps = list(self.doc.paragraphs)
# Find header paragraph
header_idx = None
for i, p in enumerate(all_ps):
if self._label_match(p.text, header_text):
header_idx = i
break
if header_idx is None:
self._paragraph_results.unmatched.append(header_text)
return False
# Determine end paragraph (next numbered section header or stop_pattern)
if stop_pattern:
end_re = re.compile(stop_pattern)
else:
# Match patterns like "1. ", "2. ", ... "18. "
end_re = re.compile(r"^\s*\d+\.\s+\S")
end_idx = len(all_ps)
for i in range(header_idx + 1, len(all_ps)):
if end_re.match(all_ps[i].text):
end_idx = i
break
# Paragraphs to replace: header_idx+1 .. end_idx-1
# Strategy: replace first paragraph in range, remove rest, add new paragraphs
if header_idx + 1 >= end_idx:
# No paragraphs between header and next section — just insert
from copy import deepcopy
template_p = all_ps[header_idx]._element
template_pPr = template_p.find(qn("w:pPr"))
template_r = template_p.find(qn("w:r"))
template_rPr = template_r.find(qn("w:rPr")) if template_r is not None else None
insert_after = template_p
# Blank line right after section header
if self.blank_around_section_header:
blank_p = _make_blank_paragraph()
insert_after.addnext(blank_p)
insert_after = blank_p
chunks = new_content.split("\n\n")
for ci, chunk in enumerate(chunks):
if ci > 0 and self.blank_between_paragraphs:
blank_p = _make_blank_paragraph()
insert_after.addnext(blank_p)
insert_after = blank_p
new_p = OxmlElement("w:p")
# New paragraph should NOT have header style — use default (no pPr)
new_r = OxmlElement("w:r")
t = OxmlElement("w:t")
t.text = chunk
t.set(qn("xml:space"), "preserve")
new_r.append(t)
new_p.append(new_r)
insert_after.addnext(new_p)
# Apply Korean font
from docx.text.run import Run
_set_run_korean_font(Run(new_r, None), self.korean_font)
insert_after = new_p
# Blank line right before next section header
if self.blank_around_section_header:
blank_p = _make_blank_paragraph()
insert_after.addnext(blank_p)
self._paragraph_results.matched.append(header_text)
return True
# Replace first paragraph in range
first_target = all_ps[header_idx + 1]
chunks = new_content.split("\n\n")
_replace_paragraph_text_keep_style(first_target, chunks[0],
korean_font=self.korean_font)
# Remove all paragraphs after first_target up to end_idx
for i in range(header_idx + 2, end_idx):
p_elem = all_ps[i]._element
p_elem.getparent().remove(p_elem)
# Insert blank paragraph right after section header (before first body)
first_target_elem = first_target._element
if self.blank_around_section_header:
blank_p = _make_blank_paragraph()
first_target_elem.addprevious(blank_p)
# Add additional chunks as new paragraphs after first_target
from copy import deepcopy
first_pPr = first_target_elem.find(qn("w:pPr"))
first_r = first_target_elem.find(qn("w:r"))
first_rPr = first_r.find(qn("w:rPr")) if first_r is not None else None
insert_after = first_target_elem
for chunk in chunks[1:]:
if self.blank_between_paragraphs:
blank_p = _make_blank_paragraph()
insert_after.addnext(blank_p)
insert_after = blank_p
new_p = OxmlElement("w:p")
if first_pPr is not None:
new_p.append(deepcopy(first_pPr))
new_r = OxmlElement("w:r")
if first_rPr is not None:
new_r.append(deepcopy(first_rPr))
t = OxmlElement("w:t")
t.text = chunk
t.set(qn("xml:space"), "preserve")
new_r.append(t)
new_p.append(new_r)
insert_after.addnext(new_p)
from docx.text.run import Run
_set_run_korean_font(Run(new_r, None), self.korean_font)
insert_after = new_p
# Blank line right before next section header
if self.blank_around_section_header:
blank_p = _make_blank_paragraph()
insert_after.addnext(blank_p)
self._paragraph_results.matched.append(header_text)
return True
# ---- Single-paragraph in-place text replace ----
def replace_paragraph_matching(self, matcher: str, new_text: str,
mode: str = "startswith") -> bool:
"""Replace the entire text of the first paragraph that matches.
mode: 'startswith' | 'contains' | 'exact'
Preserves the paragraph's pPr and the first run's rPr (style).
"""
for p in self.doc.paragraphs:
text = p.text
ok = False
if mode == "startswith":
ok = text.startswith(matcher)
elif mode == "contains":
ok = matcher in text
elif mode == "exact":
ok = text.strip() == matcher.strip()
if ok:
_replace_paragraph_text_keep_style(p, new_text,
korean_font=self.korean_font)
self._paragraph_results.matched.append(f"<para>{matcher}")
return True
self._paragraph_results.unmatched.append(f"<para>{matcher}")
return False
# ---- Document-wide passes ----
def apply_blank_around_all_section_headers(self) -> int:
"""Scan all top-level paragraphs and add blank lines above and below
every numbered section header (e.g. '1. ', '12. ').
OPT-IN ONLY. Use this when the institutional review will tolerate
layout drift (page count change). For strict form-fidelity submissions,
leave disabled (default) and rely on per-section blanks added during
replace_paragraphs_after().
Skips:
- Headers whose previous sibling is already an empty paragraph
(avoids double-blanks when section was filled via section_replace)
- Headers whose next sibling is already an empty paragraph
- Paragraphs inside tables (only top-level body paragraphs scanned)
Returns the number of blank paragraphs inserted.
"""
header_re = re.compile(r"^\s*\d+\.\s+\S")
body = self.doc.element.body
# Collect all top-level <w:p> elements (skip those inside <w:tbl>)
all_top_ps = [el for el in body if el.tag == qn("w:p")]
inserted = 0
def is_blank(p_elem) -> bool:
if p_elem is None or p_elem.tag != qn("w:p"):
return False
# Empty if no <w:t> with text content
for t in p_elem.iter(qn("w:t")):
if t.text and t.text.strip():
return False
return True
def text_of(p_elem) -> str:
return "".join(t.text or "" for t in p_elem.iter(qn("w:t")))
for p_elem in all_top_ps:
text = text_of(p_elem)
if not header_re.match(text):
continue
prev = p_elem.getprevious()
nxt = p_elem.getnext()
if not is_blank(prev):
p_elem.addprevious(_make_blank_paragraph())
inserted += 1
if not is_blank(nxt):
p_elem.addnext(_make_blank_paragraph())
inserted += 1
return inserted
# ---- Validation & save ----
def validate(self) -> list[str]:
warnings: list[str] = []
for label in self._table_results.unmatched:
warnings.append(f"[TABLE-MISS] Label not found: {label!r}")
for header in self._paragraph_results.unmatched:
warnings.append(f"[SECTION-MISS] Header not found: {header!r}")
return warnings
def report(self) -> str:
n_table_ok = len(self._table_results.matched)
n_table_miss = len(self._table_results.unmatched)
n_para_ok = len(self._paragraph_results.matched)
n_para_miss = len(self._paragraph_results.unmatched)
return (
f"Filled {n_table_ok} table cells, {n_para_ok} sections.\n"
f"Missed: {n_table_miss} cells, {n_para_miss} sections."
)
def normalize_page_breaks(self) -> int:
"""Remove dangling empty paragraphs whose sole content is a page break,
and transfer the break to the next content paragraph via pageBreakBefore.
Why: templates often place `<w:p><w:r><w:br w:type="page"/></w:r></w:p>`
after a table or section header to force the next block onto a new page.
When the preceding content's height varies (e.g. an abstract table grows
with content), the empty paragraph can spill onto a page by itself and
the page break then forces the next block one more page forward —
producing a visibly blank page.
Replacing this pattern with `<w:pageBreakBefore/>` on the next content
paragraph's `pPr` preserves the "start on a new page" intent regardless
of where the preceding content ends, eliminating the blank page.
Returns the number of paragraphs normalized.
"""
from copy import deepcopy # noqa: F401 (kept for parity with other helpers)
body = self.doc.element.body
children = list(body)
fixed = 0
for i, el in enumerate(children):
if not el.tag.endswith("}p"):
continue
# Only collapse paragraphs with NO real text, containing a page break
text = "".join((t.text or "") for t in el.iter(qn("w:t")))
if text.strip():
continue
page_brs = [b for b in el.iter(qn("w:br"))
if b.get(qn("w:type")) == "page"]
if not page_brs:
continue
# Find the next sibling content paragraph (non-empty p or table)
target = None
for j in range(i + 1, len(children)):
sib = children[j]
if sib.tag.endswith("}p"):
sib_text = "".join((t.text or "") for t in sib.iter(qn("w:t")))
if sib_text.strip():
target = sib
break
elif sib.tag.endswith("}tbl"):
# A table has no pPr; leave the break alone.
target = None
break
if target is None:
continue
# Attach pageBreakBefore to target's pPr (idempotent)
pPr = target.find(qn("w:pPr"))
if pPr is None:
pPr = OxmlElement("w:pPr")
target.insert(0, pPr)
if pPr.find(qn("w:pageBreakBefore")) is None:
pbb = OxmlElement("w:pageBreakBefore")
pPr.insert(0, pbb)
# Remove the dangling empty paragraph
el.getparent().remove(el)
fixed += 1
return fixed
def save(self, output_path: str | Path) -> Path:
if self.normalize_page_breaks_flag:
self.normalize_page_breaks()
out = Path(output_path).expanduser().resolve()
out.parent.mkdir(parents=True, exist_ok=True)
self.doc.save(str(out))
return out
# ---------- CLI ----------
def fill_from_yaml(template: Path, content_yaml: Path, output: Path) -> None:
with open(content_yaml, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f)
protections = cfg.get("protections", {}) or {}
korean_font = protections.get("korean_font", DEFAULT_KOREAN_FONT)
blank_between = protections.get("blank_between_paragraphs", True)
blank_around = protections.get("blank_around_section_header", True)
blank_around_all = protections.get("blank_around_all_section_headers", False)
normalize_pb = protections.get("normalize_page_breaks", True)
filler = FormFiller(template, korean_font=korean_font,
blank_between_paragraphs=blank_between,
blank_around_section_header=blank_around,
blank_around_all_section_headers=blank_around_all,
normalize_page_breaks=normalize_pb)
# Fill table key-value pairs
for label, value in (cfg.get("table_kv") or {}).items():
ok = filler.fill_table_kv(str(label), str(value))
status = "OK " if ok else "MISS"
print(f" [{status}] table_kv: {label!r}")
# Replace section content (between headers)
for header, content in (cfg.get("section_replace") or {}).items():
ok = filler.replace_paragraphs_after(str(header), str(content))
status = "OK " if ok else "MISS"
print(f" [{status}] section: {header!r}")
# Replace single paragraph in-place (e.g., title line)
for matcher, content in (cfg.get("paragraph_replace") or {}).items():
ok = filler.replace_paragraph_matching(str(matcher), str(content),
mode="startswith")
status = "OK " if ok else "MISS"
print(f" [{status}] paragraph: {matcher!r}")
# Document-wide pass: blank lines around ALL numbered section headers
if blank_around_all:
n = filler.apply_blank_around_all_section_headers()
print(f" [OK ] blank lines around all numbered headers: {n} inserted")
print()
print(filler.report())
print()
warnings = filler.validate()
for w in warnings:
print(f" WARN: {w}")
saved = filler.save(output)
print(f"\nSaved: {saved}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--template", required=True, help="Path to template .docx")
parser.add_argument("--content", required=True, help="Path to content YAML")
parser.add_argument("--output", required=True, help="Output .docx path")
args = parser.parse_args()
fill_from_yaml(Path(args.template), Path(args.content), Path(args.output))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Inspect a Word template — list all tables, cells, and paragraphs.
Output identifies fillable cells (likely empty after a label cell).
Usage: python3 inspect_template.py <template.docx>
"""
import sys
from pathlib import Path
from docx import Document
def cell_text(cell) -> str:
return "\n".join(p.text for p in cell.paragraphs).strip()
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
path = Path(sys.argv[1]).expanduser().resolve()
doc = Document(str(path))
print(f"=== Template: {path.name} ===\n")
print(f"Sections: {len(doc.sections)}")
sec = doc.sections[0]
print(
f" Page: {sec.page_width.cm:.1f} × {sec.page_height.cm:.1f} cm, "
f"margins L/R/T/B: {sec.left_margin.cm:.1f}/{sec.right_margin.cm:.1f}/"
f"{sec.top_margin.cm:.1f}/{sec.bottom_margin.cm:.1f}"
)
print()
print(f"Tables: {len(doc.tables)}")
for ti, table in enumerate(doc.tables):
n_rows = len(table.rows)
n_cols = len(table.columns)
print(f"\n[Table {ti}] rows={n_rows}, cols={n_cols}")
for ri, row in enumerate(table.rows):
for ci, cell in enumerate(row.cells):
text = cell_text(cell)
preview = text.replace("\n", " ⏎ ")
if len(preview) > 70:
preview = preview[:67] + "..."
marker = " [empty]" if not text else ""
print(f" ({ri},{ci}): {preview!r}{marker}")
print(f"\nParagraphs (top-level, not in tables): {len(doc.paragraphs)}")
for pi, p in enumerate(doc.paragraphs):
text = p.text.strip()
if not text:
continue
preview = text[:80] + ("..." if len(text) > 80 else "")
print(f" P{pi}: {preview!r}")
if __name__ == "__main__":
main()
#!/usr/bin/env bash
# fill-protocol — environment setup
#
# Verifies and (optionally) installs the dependencies required by fill-protocol:
# - LibreOffice (only required for .doc → .docx conversion of legacy templates)
# - Python packages: docxtpl, python-docx, pyyaml
#
# Usage:
# bash setup.sh check # report what is/isn't installed, do nothing
# bash setup.sh install # install everything that's missing (asks before each step)
# bash setup.sh install --yes # install without prompting (for CI / Claude auto-install)
# bash setup.sh # equivalent to `check`
ACTION="${1:-check}"
AUTO_YES=false
[[ "${2:-}" == "--yes" ]] && AUTO_YES=true
# ---------- helpers ----------
prompt_yn() {
if $AUTO_YES; then return 0; fi
read -r -p "$1 [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]]
}
detect_os() {
case "$(uname -s)" in
Darwin) echo "macos" ;;
Linux)
if command -v apt-get >/dev/null 2>&1; then echo "debian"
elif command -v dnf >/dev/null 2>&1; then echo "fedora"
elif command -v pacman >/dev/null 2>&1; then echo "arch"
else echo "linux-unknown"; fi
;;
*) echo "unsupported" ;;
esac
}
find_soffice() {
for path in \
"/Applications/LibreOffice.app/Contents/MacOS/soffice" \
"/usr/bin/soffice" \
"/opt/homebrew/bin/soffice"; do
if [[ -x "$path" ]]; then echo "$path"; return 0; fi
done
if command -v soffice >/dev/null 2>&1; then
command -v soffice; return 0
fi
return 1
}
# ---------- check ----------
OS=$(detect_os)
echo "Detected OS: $OS"
echo
# 1. LibreOffice
SOFFICE=$(find_soffice || true)
if [[ -n "$SOFFICE" ]]; then
VER=$("$SOFFICE" --version 2>&1 | head -1 || echo "?")
echo "✅ LibreOffice: $SOFFICE"
echo " $VER"
SOFFICE_OK=true
else
echo "❌ LibreOffice: not installed"
echo " (Only required for .doc → .docx conversion. .docx templates work without it.)"
SOFFICE_OK=false
fi
# 2. Python packages
PYBIN="${PYTHON:-python3}"
echo
echo "Python: $($PYBIN --version 2>&1)"
PY_MISSING=()
for pkg in docx docxtpl yaml; do
if $PYBIN -c "import $pkg" 2>/dev/null; then
echo "✅ $pkg"
else
echo "❌ $pkg"
PY_MISSING+=("$pkg")
fi
done
# Map import-name → pip-name (function form for bash 3.2 compatibility — macOS default)
pipname_for() {
case "$1" in
docx) echo "python-docx" ;;
docxtpl) echo "docxtpl" ;;
yaml) echo "pyyaml" ;;
*) echo "$1" ;;
esac
}
if [[ "$ACTION" == "check" ]]; then
echo
if $SOFFICE_OK && [[ ${#PY_MISSING[@]} -eq 0 ]]; then
echo "All dependencies present."
exit 0
else
echo "Run \`bash setup.sh install\` to install missing dependencies."
exit 1
fi
fi
# ---------- install ----------
if [[ "$ACTION" != "install" ]]; then
echo "Unknown action: $ACTION (use 'check' or 'install')"
exit 2
fi
# Install LibreOffice if missing
if ! $SOFFICE_OK; then
case "$OS" in
macos)
CMD="brew install --cask libreoffice"
;;
debian)
CMD="sudo apt-get install -y libreoffice"
;;
fedora)
CMD="sudo dnf install -y libreoffice"
;;
arch)
CMD="sudo pacman -S --needed libreoffice-fresh"
;;
*)
echo "❌ Cannot auto-install LibreOffice on $OS — install manually."
exit 3
;;
esac
echo
echo "About to install LibreOffice (~700 MB):"
echo " $CMD"
if prompt_yn "Proceed?"; then
eval "$CMD"
else
echo "Skipped LibreOffice install."
fi
fi
# Install Python packages if missing
if [[ ${#PY_MISSING[@]} -gt 0 ]]; then
PIP_PKGS=""
for m in "${PY_MISSING[@]}"; do PIP_PKGS="$PIP_PKGS $(pipname_for "$m")"; done
PIP_CMD="$PYBIN -m pip install --user --break-system-packages$PIP_PKGS"
echo
echo "About to install Python packages:"
echo " $PIP_CMD"
if prompt_yn "Proceed?"; then
eval "$PIP_CMD"
else
echo "Skipped Python package install."
fi
fi
echo
echo "Re-running check…"
echo
exec bash "$0" check
schema_version: 2
name: fill-protocol
layer: A
owner_domain: form_filling
maturity: official
when_to_use: "Fill an institutional Word (.doc/.docx) template (IRB protocol, ethics application, grant form) while preserving styles, tables, fonts, and page geometry."
when_NOT_to_use: "Drafting the scientific content (use write-protocol); ICMJE COI forms (use fill-icmje-coi)."
inputs:
- "institutional Word template"
- "content mapping (fill_*.yaml)"
outputs:
- "filled .docx preserving the institutional template"
deterministic_scripts:
- scripts/fill_form.py
- scripts/inspect_template.py
- scripts/doc_to_docx.py
side_effects:
- writes_docx_forms
downstream_consumers:
- render-pdf-doc
forbidden_actions:
- rebuild_template_from_blank_document
- drop_template_styles_or_logos
# v2.1 quality card
purpose: "Render approved content into an institutional Word template without losing its layout, styles, or page geometry."
safety_boundaries:
- "Operates on the original template (never rebuilds from a blank Document, which strips logos/headers/styles)."
- "CJK eastAsia fonts and table cantSplit are enforced for Korean templates."
known_limitations:
- "Requires the institutional template file; cannot invent a missing one."
- "Content-controlled (SDT) fields may need manual handling in Word."
validation_commands:
- "confirm [MISS] count is 0 after fill"
- "soffice --headless --convert-to pdf for visual check"
evidence_surface: bundled_script
#!/usr/bin/env bash
# Regression test for fill-protocol/scripts/fill_form.py.
# Builds a synthetic template .docx at runtime (python-docx) with a 2-column
# key/value table, two numbered section headers, and a title paragraph; writes a
# content YAML exercising table_kv / section_replace / paragraph_replace plus one
# deliberately-missing label; runs fill_form.py; then re-opens the output and
# asserts the values landed and the bogus label reported MISS. No committed
# binary fixture. Needs python-docx + pyyaml (already in CI deps). Network-free,
# Hangul-free (template uses English labels; eastAsia font path is exercised by
# real usage, not asserted here).
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="$HERE/../scripts/fill_form.py"
TMP="$(mktemp -d -t fillform_XXXX)"
trap 'rm -rf "$TMP"' EXIT
fail=0
check() { local label="$1"; shift
if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label"
else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi
}
[[ -f "$SCRIPT" ]] || { echo "ENV-ERR: fill_form.py missing" >&2; exit 2; }
python3 -c "import docx, yaml" 2>/dev/null || { echo "SKIP: python-docx/pyyaml unavailable"; exit 0; }
TEMPLATE="$TMP/template.docx"
CONTENT="$TMP/content.yaml"
OUTPUT="$TMP/filled.docx"
# --- Build synthetic template ---
python3 - "$TEMPLATE" <<'PY'
import sys
from docx import Document
doc = Document()
doc.add_paragraph("Study Title: PLACEHOLDER TITLE")
t = doc.add_table(rows=2, cols=2)
t.cell(0, 0).text = "Principal Investigator"
t.cell(0, 1).text = ""
t.cell(1, 0).text = "IRB Number"
t.cell(1, 1).text = ""
doc.add_paragraph("1. Background")
doc.add_paragraph("TODO: background placeholder")
doc.add_paragraph("2. Methods")
doc.add_paragraph("TODO: methods placeholder")
doc.save(sys.argv[1])
PY
check "synthetic template built" test -s "$TEMPLATE"
# --- Content YAML (one label intentionally absent: 'Funding Source') ---
# (no korean_font override -> fill_form.py uses its built-in default; keeps this
# test file Hangul-free.)
cat > "$CONTENT" <<'YAML'
table_kv:
Principal Investigator: "Alice Kim"
IRB Number: "IRB-2026-001"
Funding Source: "This label is absent in the template"
section_replace:
"1. Background": "Synthetic background content for the regression test."
paragraph_replace:
"Study Title:": "Study Title: Synthetic Protocol"
YAML
# --- Run the filler, capture stdout for MISS detection ---
LOG="$TMP/run.log"
python3 "$SCRIPT" --template "$TEMPLATE" --content "$CONTENT" --output "$OUTPUT" >"$LOG" 2>&1
check "fill_form exit 0" test "$?" -eq 0
check "output docx written" test -s "$OUTPUT"
# Absent label reported as MISS; present labels reported OK.
check "absent label reported MISS" grep -qE "\[MISS\].*Funding Source" "$LOG"
check "present label reported OK" grep -qE "\[OK \].*Principal Investigator" "$LOG"
# --- Re-open output and assert substitutions landed ---
check "values substituted in output" python3 - "$OUTPUT" <<'PY'
import sys
from docx import Document
doc = Document(sys.argv[1])
# all text across paragraphs + table cells
texts = [p.text for p in doc.paragraphs]
for tbl in doc.tables:
for row in tbl.rows:
for c in row.cells:
texts.append(c.text)
blob = "\n".join(texts)
assert "Alice Kim" in blob, "PI value missing"
assert "IRB-2026-001" in blob, "IRB value missing"
assert "Synthetic background content" in blob, "section_replace missing"
assert "Study Title: Synthetic Protocol" in blob, "paragraph_replace missing"
assert "PLACEHOLDER TITLE" not in blob, "title placeholder not replaced"
PY
echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail"
exit "$fail"
Related skills
FAQ
How does it avoid breaking the template?
It opens the existing template with Document(template_path) and replaces cell or paragraph text in place rather than creating the document from scratch.
Does it handle Korean and other CJK templates?
Yes; it sets the eastAsia font attribute so Hangul, Kanji, and Hanzi render correctly instead of falling back.