Claude Skill

fill-protocol

Fill institutional Word form templates (.doc/.docx) for IRB protocols, ethics applications, grant proposals, and other structured research documents while preserving the original styles, table layouts, fonts, and page geometry. Pairs with write-protocol — write-protocol drafts th

LLM Mart · 0 points · 3 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download Aperivue-medsci-skills-skills_fill-protocol-55a3f75.zip · 25 KB
Part of aperivue/medsci-skills — 47 skills

Install

skills CLI npx skills add https://github.com/Aperivue/medsci-skills/tree/main/skills/fill-protocol
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install aperivue-medsci-skills@llmmart
Git git clone https://github.com/Aperivue/medsci-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole aperivue/medsci-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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     # Arch

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

Step 2 — Inspect the template structure

python scripts/inspect_template.py path/to/template.docx

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

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

Open 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 that fill-protocol slots into the corresponding section

Files

  • scripts/doc_to_docx.py — LibreOffice headless wrapper for .doc → .docx
  • scripts/inspect_template.py — reports tables, cells, and paragraphs
  • scripts/fill_form.py — the FormFiller library and CLI entry point
  • examples/ — worked examples for IRB, ethics waiver, and grant templates
  • references/best_practices.md — formatting notes (cantSplit, eastAsia, multi-line cell text)

Known Limitations

  • HWP / HWPX input is not handled directly — chain with hwp-pipeline to 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-lit with 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.
Files (medsci-skills)
  • examples
    • example_irb_template.yaml 2 KB
      # 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 ..."
      
  • references
    • best_practices.md 4.1 KB
      # 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:
      
      ```xml
      <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.
      
      ```yaml
      "Inclusion Criteria": |
        All of the following:
        1. Age ≥ 19 years
        2. Confirmed diagnosis ...
        3. Imaging within 30 days
      ```
      
      If 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:
      
      ```bash
      soffice --headless --convert-to pdf filled.docx
      ```
      
      Look 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-pipeline` first (HWP → HWPX → DOCX)
      - **PDF form filling**: use the `pdf` skill or a dedicated PDF-form library
      - **Free-form research writing**: use `write-paper` or `write-protocol`
      - **Slides / presentations**: use `generate-pptx`
      - **Templates with Word "content controls"** (interactive form fields): not
        yet supported by this skill
      
  • scripts
    • doc_to_docx.py 3.5 KB
      #!/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()
      
    • fill_form.py 25.4 KB
      #!/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 _append_text_with_breaks(run_elem, text: str) -> None:
          """Fill a `w:r` with `text`, turning every `\\n` into a real `w:br`.
      
          Word does not read a newline inside `w:t` as a line break — it collapses it to a space. So
          `t.text = chunk` on multi-line content produces a document that opens cleanly, validates, and
          has silently run the author's list together. Three call sites built runs by hand and only one
          of them split on `\\n`, which meant `section_replace` content kept its line breaks in the FIRST
          paragraph and lost them in every paragraph after it — an IRB form's inclusion criteria arriving
          as a single flowed sentence, under a `[OK]` line and a clean `validate()`. Nothing but opening
          the rendered PDF could see it.
      
          Every run-building path goes through here now, so the class cannot come back one call site at
          a time.
          """
          for i, line in enumerate(text.split("\n")):
              if i > 0:
                  run_elem.append(OxmlElement("w:br"))
              if line:
                  t = OxmlElement("w:t")
                  t.text = line
                  t.set(qn("xml:space"), "preserve")
                  run_elem.append(t)
      
      
      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))
      
          _append_text_with_breaks(new_run, new_text)
      
          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))
                  _append_text_with_breaks(new_r, line)
                  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")
                      _append_text_with_breaks(new_r, chunk)
                      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))
                  _append_text_with_breaks(new_r, chunk)
                  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}")
              warnings.extend(self._raw_newline_warnings())
              return warnings
      
          def _raw_newline_warnings(self) -> list[str]:
              """A newline that survived into `w:t` is a line break the reader will never see.
      
              This is the only failure in this tool that both fills and validates cleanly: every label
              matches, every section is found, the file opens in Word — and the list the author wrote is
              one flowed sentence, because Word treats a newline inside `w:t` as a space. It went to an
              institutional review board that way once. Checking the produced XML is what makes the
              `[OK]` line mean something; checking the fill results only ever proved the fill ran.
              """
              out: list[str] = []
              for t in self.doc.element.body.iter(qn("w:t")):
                  if t.text and "\n" in t.text:
                      shown = t.text.strip().replace("\n", "\\n")[:60]
                      out.append(
                          f"[RAW-NEWLINE] A line break will render as a space: {shown!r} "
                          "— the text needs a w:br or a new paragraph, not a newline in w:t"
                      )
              return out
      
          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()
      
    • inspect_template.py 1.8 KB
      #!/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()
      
  • tests
    • test_fill_form.sh 3.6 KB
      #!/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"
      
    • test_fill_form_newlines.sh 5.8 KB
      #!/usr/bin/env bash
      # Regression test: a newline in section content must become a line break the reader can see.
      #
      # `section_replace` splits its content on `\n\n` into paragraphs. Only the FIRST of those went
      # through the run builder that turns `\n` into `w:br`; every later one did `t.text = chunk`, which
      # leaves the newline inside `w:t` — and Word renders a newline in `w:t` as a space. An IRB form's
      # inclusion criteria, written as "1) ...\n2) ...\n3) ...", therefore arrived as one flowed
      # sentence. The run printed `[OK]` for every label and `validate()` returned clean, because both
      # were reporting on the fill, not on the document. Only opening the rendered PDF showed it.
      #
      # Both defective paths are covered here: the branch that REPLACES existing body paragraphs, and
      # the branch that INSERTS when a header is immediately followed by the next header. The assertions
      # are made against the produced XML — the artifact — not against the filler's own report.
      #
      # Builds its template at runtime; no committed binary fixture. Network-free.
      set -u
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SCRIPT="$HERE/../scripts/fill_form.py"
      TMP="$(mktemp -d -t fillnl_XXXX)"
      trap 'rm -rf "$TMP"' EXIT
      
      pass=0
      fail=0
      ck() {
        local label="$1" expected="$2" actual="$3"
        if [ "$expected" = "$actual" ]; then
          printf '  PASS  %-52s %s\n' "$label" "$actual"
          pass=$((pass + 1))
        else
          printf '  FAIL  %-52s expected=%s actual=%s\n' "$label" "$expected" "$actual"
          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"
      
      python3 - "$TEMPLATE" <<'PY'
      import sys
      from docx import Document
      doc = Document()
      doc.add_paragraph("1. Background")
      doc.add_paragraph("TODO: background placeholder")     # replace-path: a body paragraph exists
      doc.add_paragraph("2. Eligibility")
      doc.add_paragraph("3. References")                    # insert-path: header follows header
      doc.add_paragraph("TODO: references placeholder")
      doc.save(sys.argv[1])
      PY
      
      # Chunk 0 of Background carries one newline; chunk 1 carries two. Eligibility has no body
      # paragraph after it, so it takes the insert branch, and its single chunk carries two newlines.
      # The single-line section is the negative control: it must produce no break at all.
      cat > "$CONTENT" <<'YAML'
      section_replace:
        "1. Background": "Opening line one\nOpening line two\n\nInclusion criteria:\n1) age 19 or over\n2) index CT available\n3) written consent"
        "2. Eligibility": "Exclusion criteria:\n1) prior surgery\n2) no follow-up"
        "3. References": "A single line with no breaks at all."
      YAML
      
      LOG="$TMP/run.log"
      python3 "$SCRIPT" --template "$TEMPLATE" --content "$CONTENT" --output "$OUTPUT" >"$LOG" 2>&1
      rc=$?
      ck "fill_form exit code" "0" "$rc"
      
      out="$(python3 - "$OUTPUT" <<'PY'
      import json, sys, zipfile, re
      xml = zipfile.ZipFile(sys.argv[1]).read("word/document.xml").decode("utf-8")
      from docx import Document
      doc = Document(sys.argv[1])
      res = {}
      # w:br elements are the line breaks a reader actually sees.
      res["breaks"] = str(len(re.findall(r"<w:br\s*/>", xml)))
      # A newline surviving inside w:t is the defect itself, in the artifact.
      res["raw_newlines_in_wt"] = str(len(re.findall(r"<w:t[^>]*>[^<]*\n[^<]*</w:t>", xml)))
      # Every criterion must still be present as text — a fix that dropped content would be worse.
      blob = "\n".join(p.text for p in doc.paragraphs)
      for token in ("age 19 or over", "index CT available", "written consent",
                    "prior surgery", "no follow-up", "Opening line two"):
          res["has_" + token.split()[0] + token.split()[-1]] = str(token in blob).lower()
      res["single_line_intact"] = str("A single line with no breaks at all." in blob).lower()
      print(json.dumps(res))
      PY
      )"
      get() { python3 -c "import json,sys; print(json.loads(sys.argv[1])[sys.argv[2]])" "$out" "$1"; }
      
      echo "==== every newline became a break the reader can see ===="
      # Background: 1 (chunk 0) + 3 (chunk 1) = 4.  Eligibility, insert path: 2.  References: 0.
      ck "w:br elements in the produced document"   "6"     "$(get breaks)"
      
      echo "==== NEGATIVE CONTROLS ===="
      ck "newlines left inside w:t"                 "0"     "$(get raw_newlines_in_wt)"
      ck "single-line section unchanged"            "true"  "$(get single_line_intact)"
      
      echo "==== no criterion was dropped in the process ===="
      ck "chunk 0, second line"                     "true"  "$(get has_Openingtwo)"
      ck "replace path, first criterion"            "true"  "$(get has_ageover)"
      ck "replace path, last criterion"             "true"  "$(get has_writtenconsent)"
      ck "insert path, first criterion"             "true"  "$(get has_priorsurgery)"
      ck "insert path, last criterion"              "true"  "$(get has_nofollow-up)"
      
      echo "==== validate() can see the defect if it ever returns ===="
      warned="$(python3 - "$SCRIPT" "$TMP" <<'PY'
      import importlib.util, sys
      from pathlib import Path
      from docx import Document
      from docx.oxml.ns import qn
      
      spec = importlib.util.spec_from_file_location("ff", sys.argv[1])
      m = importlib.util.module_from_spec(spec)
      sys.modules["ff"] = m
      spec.loader.exec_module(m)
      
      # A document poisoned the way the old code poisoned it: a newline sitting inside w:t.
      p = Path(sys.argv[2]) / "poisoned.docx"
      doc = Document()
      para = doc.add_paragraph()
      run = para.add_run()
      t = run._element.find(qn("w:t"))
      if t is None:
          from docx.oxml import OxmlElement
          t = OxmlElement("w:t")
          run._element.append(t)
      t.text = "1) first\n2) second"
      doc.save(p)
      
      filler = m.FormFiller(str(p))
      hits = [w for w in filler.validate() if "RAW-NEWLINE" in w]
      print(len(hits))
      PY
      )"
      ck "warning raised on a poisoned document"    "1"     "$warned"
      
      echo
      echo "  passed=$pass failed=$fail"
      [ "$fail" -eq 0 ] || exit 1
      echo "OK: line breaks reach the page, no newline hides in w:t, and validate() would say so."
      
  • setup.sh 4.2 KB
    #!/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
    
  • SKILL.md 11 KB
    ---
    name: fill-protocol
    description: >
      Fill institutional Word form templates (.doc/.docx) for IRB protocols, ethics
      applications, grant proposals, and other structured research documents while
      preserving the original styles, table layouts, fonts, and page geometry. Pairs
      with write-protocol — write-protocol drafts the scientific content, fill-protocol
      renders it into the institutional template. Korean-aware (CJK eastAsia font
      enforcement, table cantSplit) but works for any language template.
    triggers: fill protocol, fill template, fill IRB form, IRB template, ethics template, grant template, 양식 채우기, 연구계획서 작성, 신청서 작성, 정부 양식, 병원 양식, 워드 템플릿
    tools: Read, Write, Edit, Bash, Grep, Glob
    model: inherit
    ---
    
    # 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.
    
    ```bash
    # 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     # Arch
    ```
    
    ### Bundled 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
    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)
    
    ```bash
    python scripts/doc_to_docx.py path/to/template.doc path/to/template.docx
    ```
    
    ### Step 2 — Inspect the template structure
    
    ```bash
    python scripts/inspect_template.py path/to/template.docx
    ```
    
    This 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.
    
    ```yaml
    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
    
    ```bash
    python scripts/fill_form.py \
      --template path/to/template.docx \
      --content  content.yaml \
      --output   path/to/filled.docx
    ```
    
    The CLI prints `[OK]` / `[MISS]` for every fill operation and a summary at the
    end. Investigate any `[MISS]` before submitting.
    
    ### Step 5 — Visual verification
    
    ```bash
    soffice --headless --convert-to pdf path/to/filled.docx
    ```
    
    Open 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
    
    ```python
    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 that `fill-protocol` slots
      into the corresponding section
    
    ## Files
    
    - `scripts/doc_to_docx.py` — LibreOffice headless wrapper for .doc → .docx
    - `scripts/inspect_template.py` — reports tables, cells, and paragraphs
    - `scripts/fill_form.py` — the `FormFiller` library and CLI entry point
    - `examples/` — worked examples for IRB, ethics waiver, and grant templates
    - `references/best_practices.md` — formatting notes (cantSplit, eastAsia,
      multi-line cell text)
    
    ## Known Limitations
    
    - **HWP / HWPX input is not handled directly** — chain with `hwp-pipeline` to
      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-lit` with 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.
    
  • skill.yml 1.4 KB
    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
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related