
Render Pdf Doc
- 45 installs
- 236 repo stars
- Updated August 3, 2026
- aperivue/medsci-skills
Render-pdf-doc is a Claude Code skill that renders non-bibliography academic Markdown (English or Korean) to publication-quality PDF via pandoc and xelatex.
About
Render-pdf-doc is a Claude skill that renders academic Markdown documents to publication-quality PDF via pandoc and xelatex. It targets non-bibliography artifacts such as research proposals, IRB cover letters, briefings, and anchor docs. It auto-infers pipe-table column widths, applies CJK-aware font fallback for Korean, and scans for glyphs the font would silently drop. It is not for manuscripts with a bibliography.
- Renders academic Markdown (English or Korean) to PDF via pandoc + xelatex
- Auto-infers pipe-table column widths from content
- CJK-aware font fallback and a glyph-coverage scan to catch silent drops
Render Pdf Doc 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)
render-pdf-doc capabilities & compatibility
- Capabilities
- manage refs · present paper · make figures
- Use cases
- documentation · pdf parsing
- Platforms
- macOS · Linux
What render-pdf-doc says it does
Render academic Markdown documents (English or Korean) to publication-quality PDF via pandoc + xelatex.
xelatex **silently drops** any character the chosen font does not cover
npx skills add https://github.com/aperivue/medsci-skills --skill render-pdf-docAdd 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
A researcher uses it to render a Korean or English proposal, IRB cover letter, or briefing handout to a clean PDF.
Who is it for?
Rendering proposals, IRB cover letters, briefings, and anchor docs to PDF, including Korean documents.
Skip if: Manuscripts with a bibliography (use manage-refs render_pandoc.sh), Word form filling, or figures.
When should I use this skill?
You need a non-bibliography academic Markdown document rendered to a clean English or Korean PDF.
What you get
A publication-quality PDF with correctly sized tables, proper CJK fonts, and no silently dropped glyphs.
- Publication-quality PDF
- Column-width-inferred Markdown
- Glyph-coverage scan report
By the numbers
- 4 core principles
- auto-inferred pipe-table column widths
Files
Render-PDF-Doc Skill
Markdown + frontmatter → publication-quality academic PDF (English or Korean).
Why This Skill Exists
In real circulation cycles for academic PDFs, two recurring failure patterns appear: 1. v1 drafts: change-history, version numbers, and PI attribution leak into the attached PDF, confusing the first recipient. 2. v2 drafts: pandoc pipe-table dash ratios are misjudged, narrowing the first column and forcing label wrapping that hurts readability.
Manual fixes work but the same pattern recurs across proposals, briefings, IRB covers, exemption applications. This skill focuses on layout (CJK fonts + table column widths). Bibliography and CSL are handled by /manage-refs.
Boundary (separation from other skills)
| Task | Skill |
|---|---|
| Manuscript + bibliography → DOCX/PDF | /manage-refs scripts/render_pandoc.sh (CSL + .bib) |
| Filling an institutional .docx form | /fill-protocol |
| ICMJE COI form | /fill-icmje-coi |
| Figure / PPTX | /make-figures, /present-paper |
| This skill: non-bib academic markdown → PDF (proposal, briefing, anchor doc, IRB cover) | /render-pdf-doc |
Core Principles
1. Pipe table column widths must be inferred from content. No equal splitting. Size the first column (label) to the longest label, and distribute the remaining width content-proportionally across the data columns. 2. Set the CJK font explicitly — mainfont + CJKmainfont. The default fallback is OS-detected. 3. For circulation PDFs, remove change history / version numbers / PI attribution (or split them into a supplementary). Use the frontmatter redact_internal: true option. 4. No Quarto dependency — raw pandoc + xelatex. Quarto's tbl-colwidths has reported PDF regressions (issues 6089/9200).
Dependencies
# Required
brew install pandoc # macOS
brew install --cask mactex-no-gui # xelatex + xeCJK (~5 GB)
# Linux
sudo apt-get install pandoc texlive-xetex texlive-lang-cjk fonts-noto-cjkDetection:
bash scripts/check_deps.shWorkflow
Step 1 — Author markdown with frontmatter
---
title: "Paper 2 Calibration Anchor — Q&A Grid"
author: "<Author Group>"
date: "2026-05-01"
mainfont: "Apple SD Gothic Neo" # macOS default
CJKmainfont: "Apple SD Gothic Neo"
geometry: "margin=0.85in"
fontsize: 11pt
linestretch: 1.25
colorlinks: true
---For Linux/CI, use Noto Sans CJK KR instead. The render script auto-detects.
Step 2 — Infer column widths
python scripts/infer_colwidths.py input.md > input.colwidths.mdThe script: 1. Finds every pipe table block. 2. For each column, computes display width = max(len(header), max(len(cell))) (CJK = 2 cells, ASCII = 1). 3. Generates dash-row separator with proportional dash counts. 4. Writes a new file with separator rows replaced.
Override per-table via attribute: {tbl-colwidths="[20,40,40]"} after caption — passes through unchanged.
Step 3 — Render
bash scripts/render_pdf.sh -i input.colwidths.md -o output.pdfOr one-shot:
bash scripts/render_pdf.sh -i input.md -o output.pdf --infer-colwidthsStep 3.5 — Scientific-symbol + CJK glyph scan (before render)
xelatex silently drops any character the chosen font does not cover — the PDF renders with the glyph simply missing, no error or warning. Academic markdown routinely carries glyphs a default Latin font misses: transition arrows (→ ↑ ↓), math operators (− ≤ ≥ ± √ ∪ × ≈ ≠), stats Greek (κ μ σ β), bullets/marks (• ★ ✓), and CJK. Scan the source first so a silent drop is caught before it ships:
python3 scripts/scan_glyph_coverage.py input.md --strict
# real cmap check when you have the font file + fonttools:
python3 scripts/scan_glyph_coverage.py input.md --font "/path/to/body.otf" --strictIt groups the risky glyphs by class (advisory), or — with --font + fonttools — reports which are genuinely absent from the font's cmap. If risky glyphs are present, ensure mainfont/CJKmainfont cover them (a CJK-capable font such as Apple SD Gothic Neo / Noto Sans CJK usually covers arrows + Hangul but can still miss the true-minus − U+2212 and ★). The DOCX is authoritative; the PDF is a convenience copy — never let a PDF render drop a glyph the document needs.
Step 4 — Visual verify
Open the PDF. Check:
- The first-column labels do not wrap and stay on a single line
- Data columns have sufficient width
- No broken Korean glyphs (a Times New Roman fallback means CJKmainfont was not applied)
- No missing scientific symbols (arrows, −, ≤, ±, √) — the Step 3.5 scan flags candidates
- No change history / internal version numbers exposed
Templates
Starter markdown in templates/ (English default; a Korean variant *_ko.md ships alongside each):
anchor-doc.md— Q&A gridproposal-cover.md— research-proposal cover pagebriefing-handout.md— meeting brief (1-page)reference-table.md— comparison-table format
Each template marks slots with a <!-- TODO: --> marker.
Anti-Patterns
| Anti-pattern | Consequence |
|---|---|
| Equal dash split (`\ | ---\ |
CJKmainfont not set | Hangul falls back to Times New Roman (broken Latin glyphs or blanks) |
| Change history / version (e.g. v3.2.2) / PI attribution exposed in a circulation PDF | Confuses the first recipient; leaks internal information |
Quarto tbl-colwidths for PDF | PDF regression in Quarto 1.4+ — trust HTML only |
Files
scripts/render_pdf.sh— pandoc + xelatex wrapper, OS font detectionscripts/infer_colwidths.py— auto-generates pipe-table separator dash ratiosscripts/check_deps.sh— checks for pandoc / xelatex / CJK fonttemplates/— 4 starters (English) + their*_ko.mdKorean variantsreferences/pandoc_korean_cheatsheet.md— collection of frontmatter patterns (Korean-PDF reference)references/known_pitfalls.md— em-dash line breaks, smart quotes, etc. (Korean-PDF reference)
Anti-Hallucination
- Numerical content in tables: apply
~/.claude/rules/numerical-safety.md. Read from CSV. - References: use
/manage-refsseparately — this skill does not handle bib. - When producing a circulation PDF, apply
~/.claude/rules/senior-mentor-circulation.md(preserve the primary source) +~/.claude/rules/ai-drafted-document-policy.md.
Known Pitfalls — Korean Academic PDF via pandoc + xelatex
Locale: Korean. This reference intentionally contains Korean examples — it demonstrates Korean-PDF rendering failure modes (a locale feature of/render-pdf-doc). Seedocs/locale_inventory.md.
1. 균등 분할 dash separator (|---|---|---|)
증상: 첫 열에 짧은 라벨만 있어도 데이터 열과 같은 폭 → 데이터 wrap, 가독성 저하.
해결: scripts/infer_colwidths.py 사용. Header + cell content 최대 display-width(CJK=2)에 비례한 dash count로 separator 행을 교체.
수동 override: {tbl-colwidths="[20,40,40]"} 캡션 뒤에 부착 (Quarto 1.4+ HTML만; PDF는 위 스크립트 권장).
2. CJKmainfont 미설정 → Hangul fallback
증상: Times New Roman 등 라틴 폰트로 fallback. 한글 글자가 빈 □ 또는 깨진 글리프로 출력.
해결: frontmatter에 mainfont + CJKmainfont 모두 명시. macOS는 Apple SD Gothic Neo, Linux는 Noto Sans CJK KR. xeCJK는 xelatex와 함께 자동 로드.
3. CJKmainfont 설정 시 smart quotes 깨짐
증상: pandoc issue 7509 — CJKmainfont 활성화 시 "hello"의 곧은 따옴표가 CJK 폰트로 렌더되어 ”hello” 처럼 보임.
해결: 본문에 영문 quote 많을 때만 영향. 필요 시 --smart=off 또는 본문 quote를 \enquote{...}로 명시.
4. Em-dash 줄바꿈 깨짐
증상: — 양쪽 공백 없이 사용 시 xelatex가 줄바꿈 안 함 → 우측 마진 침범.
해결: 본문에서는 em-dash 25개 미만 권장 (~/.claude/rules/manuscript-style-classical.md §8). 필요 시 양쪽 공백 추가 또는 \,—\,.
5. 회람 PDF에 변경이력 / 버전번호 / PI attribution 노출
증상: 첫 수신자가 "v3.2.2" 같은 내부 버전·작성자 코멘트를 보고 혼란.
해결: 회람용 markdown은 별도 파일로 (anchor_circulation.md 등). 변경이력 섹션은 supplementary로 분리. frontmatter redact_internal: true 옵션은 future work.
6. xelatex 미설치 (Linux CI)
sudo apt-get install -y texlive-xetex texlive-lang-cjk fonts-noto-cjkmacOS는 brew install --cask mactex-no-gui (~5 GB) 또는 basictex + tlmgr install xecjk (경량).
7. Pipe table 안에 | 문자
증상: 셀 본문에 |가 있으면 컬럼이 잘못 분리됨.
해결: \| escape, 또는 grid table (+---+---+) 사용. infer_colwidths.py는 pipe table만 처리.
8. Quarto tbl-colwidths PDF regression
Quarto 1.4+에서 PDF에서는 무시되는 케이스 보고 (issues 6089, 9200). HTML은 OK. PDF 신뢰 불가 → raw pandoc + infer_colwidths 사용.
Pandoc Korean PDF Cheatsheet
Locale: Korean. This reference intentionally contains Korean examples — it documents how to render Korean academic PDFs (a locale feature of/render-pdf-doc). Seedocs/locale_inventory.md.
검증된 frontmatter 패턴
---
title: "문서 제목"
author: "작성자"
date: "2026-05-01"
mainfont: "Apple SD Gothic Neo" # macOS / Linux: "Noto Serif CJK KR"
CJKmainfont: "Apple SD Gothic Neo" # macOS / Linux: "Noto Sans CJK KR"
geometry: "margin=0.85in" # cover/proposal: 1in / briefing: 0.75in
fontsize: 11pt # briefing: 10pt
linestretch: 1.25 # cover: 1.5 / briefing: 1.2
colorlinks: true
---명령어 (raw pandoc)
pandoc input.md \
--pdf-engine=xelatex \
-o output.pdfOS-detect 자동화는 scripts/render_pdf.sh 참고.
표 폭 자동 추론
# 1) 분리행 dash 비율을 content-proportional로 교체
python3 scripts/infer_colwidths.py input.md --out input.cw.md
# 2) 렌더
pandoc input.cw.md --pdf-engine=xelatex -o output.pdf또는 wrapper 한 번에:
bash scripts/render_pdf.sh -i input.md -o output.pdf --infer-colwidthsPer-document 폰트 커스터마이즈
frontmatter > --font CLI > OS default (낮은 우선순위).
bash scripts/render_pdf.sh -i input.md --font "Nanum Myeongjo" --cjk-font "Nanum Myeongjo"폰트 후보 (한글 학술용)
| 폰트 | 비고 |
|---|---|
| Apple SD Gothic Neo | macOS 기본, sans, 가장 깔끔 |
| Noto Sans CJK KR | Linux 기본 |
| Noto Serif CJK KR | 본문 serif |
| Nanum Myeongjo | 명조계 (전통적) |
| Nanum Gothic | 고딕계 sans |
| Pretendard | 모던 sans, 별도 설치 |
추가 옵션
--toc— 목차 생성 (긴 proposal에 유용)--number-sections— 자동 섹션 번호-V documentclass=article(default) /report/book-V papersize=a4— A4 (default: letter)
참고 링크
- Pandoc User Guide
- Pandoc with Chinese (CJK applies)
- Quarto tbl-colwidths (HTML 신뢰; PDF regression 주의)
- Issue 7509 — CJKmainfont smart quotes
#!/usr/bin/env bash
# check_deps.sh — verify pandoc + xelatex + CJK font availability.
set -u
ok=0
fail=0
check() {
local name="$1"; shift
if "$@" >/dev/null 2>&1; then
echo "[OK] $name"
ok=$((ok + 1))
else
echo "[MISS] $name"
fail=$((fail + 1))
fi
}
check "pandoc" command -v pandoc
check "xelatex" command -v xelatex
if [[ "$(uname)" == "Darwin" ]]; then
if /usr/bin/fc-list 2>/dev/null | grep -qi "Apple SD Gothic Neo" \
|| system_profiler SPFontsDataType 2>/dev/null | grep -qi "Apple SD Gothic Neo"; then
echo "[OK] Apple SD Gothic Neo (macOS)"
ok=$((ok + 1))
else
echo "[WARN] Apple SD Gothic Neo not detected — falling back to default fontconfig"
fi
else
if fc-list 2>/dev/null | grep -qi "Noto.*CJK.*KR"; then
echo "[OK] Noto Sans/Serif CJK KR"
ok=$((ok + 1))
else
echo "[MISS] Noto CJK KR — apt install fonts-noto-cjk"
fail=$((fail + 1))
fi
fi
echo
echo "Summary: $ok ok, $fail fail"
exit $fail
#!/usr/bin/env python3
"""
infer_colwidths.py — Replace pandoc pipe-table separator rows with dash-ratios
proportional to per-column content width.
Pandoc pipe tables interpret the dash count in the separator row as relative
column widths (when the total exceeds the line width). This script replaces:
| Label | Long data column | Other |
|-------|-----------------------------|-------|
with widths derived from the maximum display-width of header + cells per column.
CJK glyphs count as 2 cells (East-Asian wide), ASCII as 1.
Usage:
python infer_colwidths.py input.md [--out output.md] [--min 5] [--total 80]
If --out is omitted, writes to stdout.
"""
from __future__ import annotations
import argparse
import re
import sys
import unicodedata
from pathlib import Path
def display_width(s: str) -> int:
"""East-Asian-aware char width. Wide/Fullwidth = 2, else = 1."""
w = 0
for ch in s:
if unicodedata.east_asian_width(ch) in ("W", "F"):
w += 2
else:
w += 1
return w
SEPARATOR_RE = re.compile(r"^\s*\|?[\s:\-|]+\|[\s:\-|]+\s*$")
PIPE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$")
def split_row(line: str) -> list[str]:
s = line.strip()
if s.startswith("|"):
s = s[1:]
if s.endswith("|"):
s = s[:-1]
return [c.strip() for c in s.split("|")]
def is_separator(line: str) -> bool:
if not SEPARATOR_RE.match(line):
return False
cells = split_row(line)
if len(cells) < 2:
return False
for c in cells:
if not re.match(r"^:?-{3,}:?$", c.strip()):
return False
return True
def parse_alignment(sep_cell: str) -> str:
s = sep_cell.strip()
left = s.startswith(":")
right = s.endswith(":")
if left and right:
return "center"
if right:
return "right"
if left:
return "left"
return "default"
def make_separator(widths: list[int], aligns: list[str]) -> str:
parts = []
for w, a in zip(widths, aligns):
body = "-" * max(3, w)
if a == "center":
cell = ":" + body[1:-1] + ":" if len(body) >= 3 else ":-:"
elif a == "left":
cell = ":" + body[1:] if len(body) >= 2 else ":--"
elif a == "right":
cell = body[:-1] + ":" if len(body) >= 2 else "--:"
else:
cell = body
parts.append(cell)
return "| " + " | ".join(parts) + " |"
def find_table_blocks(lines: list[str]) -> list[tuple[int, int, int]]:
"""Return list of (header_idx, sep_idx, end_idx_exclusive) for pipe tables."""
blocks = []
i = 0
while i < len(lines) - 1:
if PIPE_ROW_RE.match(lines[i]) and is_separator(lines[i + 1]):
header = i
sep = i + 1
j = sep + 1
while j < len(lines) and PIPE_ROW_RE.match(lines[j]):
j += 1
blocks.append((header, sep, j))
i = j
else:
i += 1
return blocks
def infer_widths_for_block(
lines: list[str], header: int, sep: int, end: int, min_dashes: int
) -> tuple[list[int], list[str]]:
header_cells = split_row(lines[header])
sep_cells = split_row(lines[sep])
n = len(header_cells)
aligns = [parse_alignment(c) for c in sep_cells]
if len(aligns) < n:
aligns += ["default"] * (n - len(aligns))
aligns = aligns[:n]
widths = [display_width(h) for h in header_cells]
for r in range(sep + 1, end):
cells = split_row(lines[r])
for k in range(min(n, len(cells))):
widths[k] = max(widths[k], display_width(cells[k]))
widths = [max(min_dashes, w) for w in widths]
return widths, aligns
def process(text: str, min_dashes: int, scale: float) -> str:
lines = text.splitlines()
blocks = find_table_blocks(lines)
for header, sep, end in blocks:
widths, aligns = infer_widths_for_block(lines, header, sep, end, min_dashes)
if scale != 1.0:
widths = [max(min_dashes, int(round(w * scale))) for w in widths]
lines[sep] = make_separator(widths, aligns)
return "\n".join(lines) + ("\n" if text.endswith("\n") else "")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("input", type=Path, help="Input markdown file")
ap.add_argument("--out", type=Path, default=None, help="Output path (default: stdout)")
ap.add_argument("--min", type=int, default=5, help="Minimum dashes per column (default: 5)")
ap.add_argument("--scale", type=float, default=1.0, help="Multiply all widths (default: 1.0)")
args = ap.parse_args()
text = args.input.read_text(encoding="utf-8")
out = process(text, min_dashes=args.min, scale=args.scale)
if args.out:
args.out.write_text(out, encoding="utf-8")
print(f"[infer_colwidths] {args.input} → {args.out}", file=sys.stderr)
else:
sys.stdout.write(out)
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env bash
# render_pdf.sh — pandoc + xelatex wrapper for Korean academic markdown.
#
# Usage:
# render_pdf.sh -i input.md [-o output.pdf] [--infer-colwidths]
# [--font "Apple SD Gothic Neo"] [--cjk-font "Apple SD Gothic Neo"]
# [-- <extra pandoc args>]
#
# Defaults:
# - macOS: mainfont/CJKmainfont = "Apple SD Gothic Neo"
# - Linux: mainfont = "Noto Serif CJK KR", CJKmainfont = "Noto Sans CJK KR"
# - Output path = <input>.pdf
# - geometry = margin=0.85in, fontsize = 11pt (override via frontmatter)
#
# The frontmatter in input.md takes precedence over CLI/auto-detected defaults.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INPUT=""
OUTPUT=""
INFER_COLWIDTHS=0
MAINFONT=""
CJKFONT=""
EXTRA=()
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") -i <input.md> [-o <output.pdf>] [options] [-- <pandoc args>]
Options:
-i Input markdown
-o Output PDF (default: <input>.pdf)
--infer-colwidths Run scripts/infer_colwidths.py on a temp copy first
--font NAME mainfont (default: OS-detected)
--cjk-font NAME CJKmainfont (default: OS-detected)
-h | --help Help
Pass-through: any args after '--' go directly to pandoc.
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
-i) INPUT="$2"; shift 2 ;;
-o) OUTPUT="$2"; shift 2 ;;
--infer-colwidths) INFER_COLWIDTHS=1; shift ;;
--font) MAINFONT="$2"; shift 2 ;;
--cjk-font) CJKFONT="$2"; shift 2 ;;
-h|--help) usage ;;
--) shift; EXTRA=("$@"); break ;;
*) EXTRA+=("$1"); shift ;;
esac
done
[[ -z "$INPUT" ]] && usage
[[ -f "$INPUT" ]] || { echo "ERROR: input not found: $INPUT" >&2; exit 2; }
[[ -z "$OUTPUT" ]] && OUTPUT="${INPUT%.md}.pdf"
# OS-based font defaults
if [[ -z "$MAINFONT" || -z "$CJKFONT" ]]; then
if [[ "$(uname)" == "Darwin" ]]; then
: "${MAINFONT:=Apple SD Gothic Neo}"
: "${CJKFONT:=Apple SD Gothic Neo}"
else
: "${MAINFONT:=Noto Serif CJK KR}"
: "${CJKFONT:=Noto Sans CJK KR}"
fi
fi
command -v pandoc >/dev/null || { echo "ERROR: pandoc not installed" >&2; exit 3; }
command -v xelatex >/dev/null || { echo "ERROR: xelatex not installed (install mactex / texlive-xetex)" >&2; exit 3; }
WORK="$INPUT"
TMPDIR=""
if [[ "$INFER_COLWIDTHS" == "1" ]]; then
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
WORK="$TMPDIR/$(basename "$INPUT")"
python3 "$SCRIPT_DIR/infer_colwidths.py" "$INPUT" --out "$WORK"
fi
ARGS=(
--pdf-engine=xelatex
-V "mainfont=${MAINFONT}"
-V "CJKmainfont=${CJKFONT}"
-V "geometry:margin=0.85in"
-V "fontsize=11pt"
-V "linestretch=1.25"
-V "colorlinks=true"
-o "$OUTPUT"
)
echo "[render_pdf] in=$INPUT out=$OUTPUT mainfont='$MAINFONT' CJK='$CJKFONT' infer=$INFER_COLWIDTHS" >&2
pandoc "${ARGS[@]}" ${EXTRA[@]+"${EXTRA[@]}"} "$WORK"
echo "[render_pdf] ok → $OUTPUT" >&2
#!/usr/bin/env python3
"""Scientific-symbol + CJK glyph-coverage scanner for xelatex PDF builds.
xelatex **silently drops** a character the chosen font does not cover — the PDF
renders with the glyph simply missing, no error. Academic markdown routinely
carries glyphs a default Latin font misses: transition arrows (→ ↑ ↓ ↔), math
operators (− ≤ ≥ ± √ ∪ × ≈ ≠), stats Greek (κ μ σ β χ), bullets/marks (• ★ ✓),
and CJK. This scans the SOURCE markdown, groups the risky non-ASCII glyphs it
finds by class, and (when a font file + fonttools are available) reports which are
genuinely absent from the font's cmap. The DOCX is authoritative; the PDF is a
convenience copy, so the goal is to surface a likely silent drop before it ships.
NOT an integrity detector — named `scan_glyph_coverage.py` (not check_/detect_/
derive_) so the catalog glob does not count it; it is a render-time QA helper.
INPUT
markdown one or more .md files (positional).
--font optional path to the .ttf/.otf that will render the body; with
`fonttools` installed, glyphs absent from its cmap are reported as
MISSING (the real coverage check). Without it, the scan is advisory
(presence by class — verify your mainfont/CJKmainfont covers them).
OUTPUT
stdout report and, with --json, an artifact:
{files, classes{name:[chars]}, missing_in_font[], summary}
Exit 1 (with --strict) when risky glyphs are present AND no font verified them,
or when --font is given and any glyph is genuinely missing from it.
Stdlib-only core (re/json/argparse/unicodedata); fonttools optional. Exit codes:
0 clean/advisory-ok, 1 risky-uncovered (with --strict), 2 input/usage error.
"""
from __future__ import annotations
import argparse
import json
import sys
import unicodedata
from pathlib import Path
# Risky glyph classes (codepoint ranges / explicit points) that a default Latin
# font (Helvetica/Times) commonly misses and xelatex drops silently.
CLASSES = {
"arrows": [(0x2190, 0x21FF)],
"math_operators": [(0x2212, 0x2212), (0x2264, 0x2265), (0x00B1, 0x00B1),
(0x221A, 0x221A), (0x222A, 0x222A), (0x00D7, 0x00D7),
(0x2248, 0x2248), (0x2260, 0x2260), (0x2211, 0x2211),
(0x220F, 0x220F), (0x2265, 0x2265), (0x2243, 0x2243)],
"greek_stats": [(0x0370, 0x03FF)],
"marks_bullets": [(0x2605, 0x2606), (0x2713, 0x2714), (0x2022, 0x2022),
(0x2020, 0x2021), (0x00A7, 0x00A7)],
"cjk": [(0x3040, 0x30FF), (0x3400, 0x4DBF), (0x4E00, 0x9FFF),
(0xAC00, 0xD7A3), (0xF900, 0xFAFF)],
}
def _classify(ch: str) -> str | None:
cp = ord(ch)
if cp < 0x80:
return None
for name, ranges in CLASSES.items():
for lo, hi in ranges:
if lo <= cp <= hi:
return name
return None
def scan(paths: list[Path]) -> dict:
classes: dict[str, dict[str, int]] = {}
all_chars: set[str] = set()
for p in paths:
if not p.is_file():
sys.stderr.write(f"ERROR: file not found: {p}\n")
sys.exit(2)
for ch in p.read_text(encoding="utf-8"):
cls = _classify(ch)
if cls:
classes.setdefault(cls, {}).setdefault(ch, 0)
classes[cls][ch] += 1
all_chars.add(ch)
return {"classes": classes, "chars": sorted(all_chars)}
def font_missing(chars: list[str], font_path: Path) -> tuple[list[str], bool]:
"""Return (missing_chars, checked). checked=False if fonttools/font unavailable."""
try:
from fontTools.ttLib import TTFont # type: ignore
except Exception:
return [], False
if not font_path.is_file():
sys.stderr.write(f"WARN: --font not found: {font_path}\n")
return [], False
try:
font = TTFont(str(font_path))
cmap = set()
for t in font["cmap"].tables:
cmap.update(t.cmap.keys())
except Exception as e:
sys.stderr.write(f"WARN: could not read font cmap: {e}\n")
return [], False
return [c for c in chars if ord(c) not in cmap], True
def main() -> int:
ap = argparse.ArgumentParser(description="Scientific-symbol + CJK glyph-coverage scanner.")
ap.add_argument("markdown", nargs="+", help="markdown file(s) to scan")
ap.add_argument("--font", help="path to body font (.ttf/.otf) — checks real cmap if fonttools present")
ap.add_argument("--json", help="write JSON artifact")
ap.add_argument("--strict", action="store_true",
help="exit 1 if risky glyphs are present and unverified, or genuinely missing from --font")
ap.add_argument("--quiet", action="store_true")
args = ap.parse_args()
res = scan([Path(m) for m in args.markdown])
classes = res["classes"]
missing, checked = ([], False)
if args.font:
missing, checked = font_missing(res["chars"], Path(args.font))
n_risky = sum(sum(d.values()) for d in classes.values())
out = {
"files": args.markdown,
"classes": {k: sorted(v) for k, v in classes.items()},
"font_checked": checked,
"missing_in_font": sorted(missing),
"summary": {"n_risky_glyphs": n_risky, "n_classes": len(classes),
"n_missing_in_font": len(missing)},
}
if not args.quiet:
print("=" * 41)
print(" Glyph Coverage (xelatex silent-drop scan)")
print("=" * 41)
for cls, chars in out["classes"].items():
names = ", ".join(f"{c} (U+{ord(c):04X} {unicodedata.name(c, '?')[:24]})" for c in chars[:8])
print(f" {cls}: {names}{' …' if len(chars) > 8 else ''}")
if not classes:
print(" (no risky non-ASCII glyphs found)")
if checked:
print(f"\nfont cmap checked: {len(missing)} glyph(s) MISSING from the font"
+ (f": {' '.join(missing)}" if missing else ""))
elif classes:
print("\nADVISORY: risky glyphs present; verify mainfont/CJKmainfont cover them "
"(pass --font with fonttools for a real cmap check). DOCX is authoritative.")
if args.json:
Path(args.json).parent.mkdir(parents=True, exist_ok=True)
Path(args.json).write_text(json.dumps(out, indent=2), encoding="utf-8")
if not args.quiet:
print(f"wrote {args.json}")
if args.strict:
if checked:
return 1 if missing else 0
return 1 if n_risky else 0
return 0
if __name__ == "__main__":
sys.exit(main())
schema_version: 2
name: render-pdf-doc
layer: A
owner_domain: document_layout
maturity: official
when_to_use:
- Render a non-bibliography Korean/English academic markdown to publication-quality PDF
- Research proposals, IRB cover letters, briefing handouts, anchor docs, reference tables
- Auto-infer pipe-table column widths for content-proportional layout
- CJK-font-aware PDF on macOS (Apple SD Gothic Neo) or Linux (Noto Sans CJK KR)
when_NOT_to_use:
- Manuscripts with bibliography (use /manage-refs scripts/render_pandoc.sh — owns CSL pipeline)
- Filling institutional Word form templates (use /fill-protocol)
- Figures, plots, or PPTX (use /make-figures or /present-paper)
- Hand-typing CSV data into tables (forbidden — see numerical-safety rule)
inputs:
- markdown_file (non-bibliography academic doc — proposal, briefing, anchor doc, IRB cover, reference table)
- frontmatter (optional; mainfont, CJKmainfont, geometry, fontsize, linestretch)
outputs:
- pdf_file (default: same dir, same stem, .pdf)
- intermediate.colwidths.md (optional, when --infer-colwidths is run as separate step)
deterministic_scripts:
- scripts/render_pdf.sh
- scripts/infer_colwidths.py
- scripts/check_deps.sh
side_effects:
- writes_pdf_artifacts
- reads_os_font_table # for CJK font detection (Apple SD Gothic Neo / Noto Sans CJK KR)
downstream_consumers:
- intake-project
- manage-project
- write-protocol # when no institutional .docx form exists; see fill-protocol for the form-fill alternative
forbidden_actions:
- bibliography_rendering # delegate /manage-refs scripts/render_pandoc.sh
- citation_key_modification
- institutional_word_form_filling # delegate /fill-protocol
- figure_or_pptx_generation # delegate /make-figures or /present-paper
- hand_typing_csv_data_into_tables # ~/.claude/rules/numerical-safety.md
provenance:
origin: "P3 spinoff from /write-paper Phase 7.6 split (2026-05-01)"
precedent: "an education-research calibration anchor PDF — manual fix x2 → skill"
quality_gates:
- check_deps.sh: hard exit if pandoc + xelatex + CJK-font dependencies are not installed
- infer_colwidths.py: enforce content-proportional dashes unless the equal-split (--equal) option is specified
- circulation_redaction: for circulation PDFs, enforce frontmatter `redact_internal: true` or keep change history / version numbers / PI attribution out of the body
# v2.1 quality card
purpose: "Render a Markdown manuscript to a styled DOCX/PDF (tables, column widths, dependencies checked)."
safety_boundaries:
- "Delegates bibliography rendering to manage-refs and figures/PPTX to make-figures/present-paper; does not modify citation keys."
- "Does not hand-type CSV data into tables (numerical-safety)."
known_limitations:
- "Output fidelity depends on installed render dependencies (checked by check_deps.sh)."
- "Institutional Word forms are out of scope (use fill-protocol)."
validation_commands:
- "bash scripts/check_deps.sh"
- "bash scripts/render_pdf.sh <manuscript.md>"
- "bash tests/test_glyph_coverage.sh"
evidence_surface: bundled_script
<!-- TODO: Section 1 -->
<!-- TODO: 한 단락 요약 (배경·목적). 회람 PDF에서는 변경이력·내부 버전 노출 금지. -->
Q&A 정렬표
| Axis | 질문 | 합의 답변 |
|---|---|---|
| 정의 | <!-- TODO: 질문 텍스트 --> | <!-- TODO: 답변 --> |
| 범위 | <!-- TODO --> | <!-- TODO --> |
| 측정 | <!-- TODO --> | <!-- TODO --> |
Pipe table 분리 행은 scripts/infer_colwidths.py가 content-proportional 대시 비율로 자동 교체.<!-- English-default template. For a Korean starter, see anchor-doc_ko.md. -->
<!-- TODO: Section 1 -->
<!-- TODO: one-paragraph summary (background / purpose). In a circulation PDF, do not expose change history or internal versions. -->
Q&A alignment grid
| Axis | Question | Agreed answer |
|---|---|---|
| Definition | <!-- TODO: question text --> | <!-- TODO: answer --> |
| Scope | <!-- TODO --> | <!-- TODO --> |
| Measurement | <!-- TODO --> | <!-- TODO --> |
scripts/infer_colwidths.py auto-replaces the pipe-table separator row with content-proportional dash ratios.<!-- TODO: 미팅 주제 -->
일시: <!-- TODO --> · 장소: <!-- TODO --> · 참석: <!-- TODO -->
핵심 결정 (1-line each)
1. <!-- TODO --> 2. <!-- TODO --> 3. <!-- TODO -->
논의 / 액션 아이템
| # | 항목 | 담당 | 기한 |
|---|---|---|---|
| 1 | <!-- TODO --> | <!-- TODO --> | <!-- TODO --> |
| 2 | <!-- TODO --> | <!-- TODO --> | <!-- TODO --> |
다음 미팅
<!-- TODO: 일시 + 어젠다 1줄 -->
<!-- English-default template. For a Korean starter, see briefing-handout_ko.md. -->
<!-- TODO: meeting topic -->
Date/time: <!-- TODO --> · Location: <!-- TODO --> · Attendees: <!-- TODO -->
Key decisions (1 line each)
1. <!-- TODO --> 2. <!-- TODO --> 3. <!-- TODO -->
Discussion / action items
| # | Item | Owner | Due |
|---|---|---|---|
| 1 | <!-- TODO --> | <!-- TODO --> | <!-- TODO --> |
| 2 | <!-- TODO --> | <!-- TODO --> | <!-- TODO --> |
Next meeting
<!-- TODO: date/time + one-line agenda -->
연구계획서 표지
| 항목 | 내용 |
|---|---|
| 연구 제목 | <!-- TODO --> |
| 책임연구자 | <!-- TODO --> |
| 공동연구자 | <!-- TODO --> |
| 소속기관 | <!-- TODO --> |
| 연구 기간 | <!-- TODO: YYYY-MM-DD ~ YYYY-MM-DD --> |
| IRB 승인번호 | <!-- TODO --> |
| 연구비 출처 | <!-- TODO --> |
연구 요약
<!-- TODO: 한 단락 (200-300 자) — 배경·목적·방법·기대효과 -->
핵심 키워드
<!-- TODO: 키워드 1, 키워드 2, 키워드 3 -->
<!-- English-default template. For a Korean starter, see proposal-cover_ko.md. -->
Research Proposal Cover
| Item | Content |
|---|---|
| Research title | <!-- TODO --> |
| Principal investigator | <!-- TODO --> |
| Co-investigators | <!-- TODO --> |
| Institution | <!-- TODO --> |
| Study period | <!-- TODO: YYYY-MM-DD ~ YYYY-MM-DD --> |
| IRB approval no. | <!-- TODO --> |
| Funding source | <!-- TODO --> |
Summary
<!-- TODO: one paragraph (200-300 words) — background / purpose / methods / expected impact -->
Keywords
<!-- TODO: keyword 1, keyword 2, keyword 3 -->
<!-- TODO: 표 제목 -->
| 차원 | 옵션 A | 옵션 B | 옵션 C |
|---|---|---|---|
| <!-- TODO --> | <!-- TODO --> | <!-- TODO --> | <!-- TODO --> |
| <!-- TODO --> | <!-- TODO --> | <!-- TODO --> | <!-- TODO --> |
| <!-- TODO --> | <!-- TODO --> | <!-- TODO --> | <!-- TODO --> |
Pipe table 폭은 --infer-colwidths로 content-proportional 자동 분배.<!-- English-default template. For a Korean starter, see reference-table_ko.md. -->
<!-- TODO: table title -->
| Dimension | Option A | Option B | Option C |
|---|---|---|---|
| <!-- TODO --> | <!-- TODO --> | <!-- TODO --> | <!-- TODO --> |
| <!-- TODO --> | <!-- TODO --> | <!-- TODO --> | <!-- TODO --> |
| <!-- TODO --> | <!-- TODO --> | <!-- TODO --> | <!-- TODO --> |
Pipe-table widths are distributed content-proportionally via --infer-colwidths.Doc
A plain ASCII document with no risky glyphs at all. Hazard ratio 1.05 (95% CI 1.01 to 1.10).
Doc
The state moved from no steatosis → steatosis (resolution 0.39, 95% CI ±0.01); kappa kappa-symbol below: κ ≤ 0.8. CJK case label 病例. ★ flagged.
#!/usr/bin/env bash
# Regression test for the scientific-symbol + CJK glyph-coverage scanner (G43).
# Synthetic, PII-free fixtures: a doc with risky glyphs (arrow →, ±, ≤, κ, CJK,
# ★) that xelatex would silently drop under a default Latin font, and a plain
# ASCII doc. Stdlib-only (python3); fonttools optional (not required here).
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="$HERE/../scripts/scan_glyph_coverage.py"
RISKY="$HERE/fixtures/glyph_risky.md"
PLAIN="$HERE/fixtures/glyph_plain.md"
OUT="$(mktemp -t glyph_XXXX).json"
trap 'rm -f "$OUT"' 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
}
has_class() { python3 -c "
import json; d=json.load(open('$OUT')); assert '$1' in d['classes'], '$1 missing'"; }
[[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; }
# (1) risky doc -> exit 1 under --strict (risky glyphs present, unverified font)
python3 "$SCRIPT" "$RISKY" --json "$OUT" --strict --quiet >/dev/null 2>&1
check "exit 1 (risky glyphs, no font)" test "$?" -eq 1
check "arrows class detected" has_class arrows
check "math_operators class detected" has_class math_operators
check "cjk class detected" has_class cjk
# (2) plain ASCII doc -> exit 0, no classes
python3 "$SCRIPT" "$PLAIN" --json "$OUT" --strict --quiet >/dev/null 2>&1
check "exit 0 on plain ASCII" test "$?" -eq 0
check "no risky classes on plain doc" python3 -c "
import json; d=json.load(open('$OUT')); assert d['classes']=={}, d['classes']"
echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail"
exit "$fail"
Related skills
FAQ
Can it render Korean?
Yes. It sets mainfont and CJKmainfont explicitly with OS-detected fallback (Apple SD Gothic Neo on macOS, Noto Sans CJK KR on Linux).
What about manuscripts with references?
It is not for those; use manage-refs render_pandoc.sh for bibliography and CSL rendering.