
Pdf Creator
- 733 installs
- 1.3k repo stars
- Updated August 4, 2026
- daymade/claude-code-skills
pdf-creator is a Claude Code skill that generates clean, professional PDF documents from markdown or structured content for developers who need polished exportable documentation.
About
pdf-creator is a Claude Code skill from daymade/claude-code-skills that generates clean, professional PDF documents from markdown or structured content using Claude. Listed on skills.sh with 536 installs, it targets developers who need polished exports without building a custom PDF pipeline. The skill guides the agent through formatting and output so READMEs, specs, and reports become presentation-ready PDFs. Reach for pdf-creator when documentation already exists as markdown and you need a distributable PDF artifact quickly.
- Converts markdown and structured text into polished PDFs
- Supports custom templates and styling options
- Agent skill optimized for Claude Code workflows
- One-command generation of reports, specs, and user guides
- Maintains consistent branding across generated documents
Pdf Creator by the numbers
- 733 all-time installs (skills.sh)
- Ranked #323 of 1,879 Documentation skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/daymade/claude-code-skills --skill pdf-creatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 733 |
|---|---|
| repo stars | ★ 1.3k |
| Last updated | August 4, 2026 |
| Repository | daymade/claude-code-skills ↗ |
How do you convert markdown to a PDF?
Generate clean, professional PDF documents from markdown or structured content using Claude.
Who is it for?
Developers who already have markdown documentation and need a polished PDF export through their coding agent.
Skip if: Teams needing programmatic PDF generation at scale, complex layout engines, or fillable PDF form workflows.
When should I use this skill?
User asks to create, export, or generate a PDF from markdown, docs, or structured text content.
What you get
Formatted PDF files exported from markdown or structured source content.
- PDF documents
By the numbers
- 536 installs on skills.sh
- Ranked 9059 on skills.sh catalog
Files
PDF Creator
Create professional PDF documents from markdown with Chinese font support and theme system.
Quick Start
# Default theme (formal: Songti SC + black/grey, A4 print)
uv run --with weasyprint scripts/md_to_pdf.py input.md output.pdf
# Warm theme (training: PingFang SC + terra cotta)
uv run --with weasyprint scripts/md_to_pdf.py input.md --theme warm-terra
# Mobile theme (narrow page, large font — for phone reading / WeChat sharing)
uv run --with weasyprint scripts/md_to_pdf.py input.md --theme mobile
# Batch convert all markdown files with a specific theme
uv run --with weasyprint scripts/batch_convert.py *.md --theme warm-terra --no-preview
# No weasyprint? Use Chrome backend (auto-detected if weasyprint unavailable)
python scripts/md_to_pdf.py input.md --theme warm-terra --backend chrome
# List available themes
python scripts/md_to_pdf.py --list-themes dummy.mdThemes
Stored in themes/*.css. Each theme is a standalone CSS file.
| Theme | Page Size | Font | Color | Best for |
|---|---|---|---|---|
default | A4 | Songti SC + Heiti SC | Black/grey | Legal docs, contracts, formal reports |
cjk-auto | A4 | Songti SC + Heiti SC | Black/grey | Tables with uneven column content (course schedules, itemized lists) |
warm-terra | A4 | PingFang SC | Terra cotta (#d97756) + warm neutrals | Course outlines, training materials, workshops |
warm-terra-menu | A4 | PingFang SC | Terra cotta (#d97756) + warm neutrals | warm-terra variant hardened for module menus/lists: 2-column long-text tables wrap without first-column overflow + Menlo unicode-range keeps CJK inline-code from rendering blank in Preview/Adobe |
mobile | 148mm × 210mm | PingFang SC | Terra cotta + warm neutrals | Phone reading, WeChat sharing, on-the-go reference |
To create a new theme: copy themes/default.css, modify, save as themes/your-theme.css.
Print vs Mobile: Choose the Right Theme
| Scenario | Recommended Theme | Why |
|---|---|---|
| Print on A4 paper, handouts, contracts | default | Standard page size, formal typography |
| Training materials, course outlines | warm-terra | Warm accent color, readable for workshop contexts |
| Send via WeChat, read on phone | mobile | Narrow page (148mm), 15px font, 1.9 line-height — comfortable on small screens |
| Both print AND mobile needed | Run twice with different themes | The skill is fast; generate both versions |
Decision rule: If the user does not specify, default to warm-terra for training/course content and default for formal documents. Ask "是否需要手机版?" only when the output channel is unclear.
Backends
The script auto-detects the best available backend based on content:
- CJK content detected → auto-selects Chrome (weasyprint subset-embeds PingFang SC as CID Type 0C OpenType, which macOS Preview / Adobe Reader fail to render — appears as garbled text on recipient devices even though it looks fine in Chrome's PDF viewer)
- Non-CJK content → auto-selects weasyprint (faster, no browser startup)
| Backend | Install | Pros | Cons |
|---|---|---|---|
weasyprint | pip install weasyprint | Precise CSS rendering, no browser needed | CJK font embedding bug on some readers |
chrome | Google Chrome installed | Zero Python deps, reliable CJK rendering | Larger binary, slightly less CSS control |
Override with --backend chrome or --backend weasyprint.
Batch Convert
# Default theme, same directory
uv run --with weasyprint scripts/batch_convert.py *.md
# Specific theme, output directory, skip previews for speed
uv run --with weasyprint scripts/batch_convert.py *.md --theme warm-terra --output-dir ./pdfs --no-preview
# Mobile theme for phone reading
uv run --with weasyprint scripts/batch_convert.py *.md --theme mobile --output-dir ./mobile-pdfs --no-previewAnti-Pattern: Do NOT Manually Invoke pandoc + Chrome
Why this skill exists: Manual pandoc input.md -o out.html + chrome --headless --print-to-pdf workflows silently fail in ways that are hard to detect:
| Manual Step | What Goes Wrong | This Skill Fixes |
|---|---|---|
pandoc -o out.html | No CJK-aware CSS → boxes/blanks for Chinese | Injects CJK font stack + typography patch |
Chrome --print-to-pdf | Default header/footer appears (filename, date, URL, page numbers) | Passes --no-pdf-header-footer |
| No post-render check | "Exit code 0" assumed success; rendering bugs hidden | Auto-generates per-page PNG previews + typography lint |
| No theme system | One-size-fits-all; phone reading impossible | Three curated themes (default / warm-terra / mobile) |
batch_convert.py missing | Writing ad-hoc loops, inconsistent flags | Built-in batch mode with --theme support |
Rule: When the user asks for PDF conversion, ALWAYS use this skill. Never bypass it with manual pandoc/Chrome commands.
Troubleshooting
Chinese characters display as boxes: Ensure Chinese fonts are installed (Songti SC, PingFang SC, etc.)
weasyprint import error: Run with uv run --with weasyprint or use --backend chrome instead.
CJK text in code blocks garbled (weasyprint): The script auto-detects code blocks containing Chinese/Japanese/Korean characters and converts them to styled divs with CJK-capable fonts. If you still see issues, use --backend chrome which has native CJK support. Alternatively, convert code blocks to markdown tables before generating the PDF.
Chrome header/footer appearing: The script passes --no-pdf-header-footer. If it still appears, your Chrome version may not support this flag — update Chrome. Note: If you bypassed this skill and used manual Chrome headless, this is the first symptom — see "Anti-Pattern" section above.
Inline code with mixed CJK + ASCII shows blanks in macOS Preview (e.g. ` Terminal/终端 renders only Terminal/ with the CJK part missing): weasyprint subset-embeds PingFang SC as **OpenType (CID Type 0C)**, which strict PDF readers (macOS Preview / Adobe Reader) fail to render. Chrome's PDF viewer falls back automatically and hides the bug. Fix is in the default theme: code font-family chain prioritizes **CID TrueType** CJK fonts (Songti SC / Heiti SC) before OpenType ones (PingFang SC). To verify: pdfplumber + check font['fontname'] of CJK chars — if any references PingFang-SC` (CID Type 0C OT), readers will likely fail. Reorder font chain to put CID TrueType first.
Table column 1 with short label gets mid-broken (e.g. 4/28(周|二)下|午): pandoc auto-emits <colgroup><col style="width:X%"> from dash counts in the markdown separator row. For | ----- | --- | --- | -------- | (uneven dash widths), pandoc allocates col 1 ~17% — too narrow for a 9-char CJK label. Inline style="" beats external CSS at equal specificity, so td:first-child { width:... } is silently shadowed. Fix is in default theme: table colgroup col { width: auto !important } neutralizes pandoc's hint, letting table-layout: fixed distribute equally (25% per column for a 4-col table). To verify: pandoc input.md -t html | grep colgroup — if it shows <col style="width:X%">, the bug applies. Scope: the neutralizer lives only in default.css; warm-terra and mobile themes use different strategies (nowrap on th/td with last-child wrap, and full-flow wrap respectively) and intentionally omit it. The neutralizer is locked in by scripts/tests/test_cjk_tables.py::test_default_theme_neutralizes_pandoc_colgroup_hint.
Visual Self-Check (MANDATORY — Do Not Skip)
This is not optional. After every PDF generation, the script automatically:
1. Converts each page to PNG via pdftoppm (poppler-utils) into a <pdf-name>/ subdirectory under the system temp dir (NOT next to the PDF — previews are a throwaway self-check artifact and must never linger in your working tree / git repo). The exact path is printed after the run as Previews: <path>/page-NN.png 2. Prints a structured self-check checklist reminding the caller to visually inspect each page 3. Runs typography lint to detect CJK line-break anti-patterns
Why mandatory: "PDF generated cleanly" ≠ "rendering matches markdown intent". Common silent failures include:
- Paragraphs collapsing into one (CommonMark soft-break on consecutive non-blank lines)
- Tables overflowing page margins
- Missing CJK / emoji glyphs
- Code block garbling
- Chrome default headers/footers (if bypassed this skill)
Workflow: After running the script, Read each page-NN.png at the printed Previews: path and verify against the markdown source. If anything renders differently from intent, fix the markdown (use - real lists instead of pseudo-lists, insert blank lines, restructure tables) and rerun. The script does NOT silently "fix" non-standard markdown — that would mask the signal that the source is wrong, causing the same markdown to render incorrectly in other processors (Obsidian, GitHub, VS Code preview).
Disable with --no-preview for batch / non-interactive runs:
python scripts/md_to_pdf.py input.md output.pdf --no-previewRequires pdftoppm (brew install poppler on macOS). If not installed, the script logs a hint and skips preview generation but still produces the PDF.
CJK Typography (default behavior)
The script applies two layers of CJK-aware processing automatically — without modifying the user's markdown source or theme CSS files:
Layer 1: CSS patch (auto-injected, fixes ~80% of cases)
_load_theme() appends a CJK typography CSS patch to the loaded theme CSS. The patch:
table { table-layout: fixed; width: 100% }— equal column widths prevent weasyprint auto-layout from squeezing one column to ~10% width when an adjacent column has 5x more contenttd, th { word-break: keep-all; overflow-wrap: normal; line-break: strict }— don't slice CJK characters apart. The deliberate trade-off encoded byoverflow-wrap: normal(notbreak-word) is to let content overflow slightly rather than fall back to mid-token breaks — rationale documented inmd_to_pdf.pyL109-146 inline comments and locked in byscripts/tests/test_cjk_tables.pyth { white-space: nowrap }— short headers stay one line for predictable column widths
This silently fixes the most common anti-pattern (cell content forcibly wrapped between CJK characters producing single-char-only lines), without touching the user's source. The user's theme CSS file on disk is never modified.
Layer 2: Typography lint (post-render detection, catches the rest)
After PDF generation, the script runs pdftotext -layout per page and scans for known CJK anti-patterns per "中文文案排版指北" (Chinese typography style guide):
- Single CJK character alone on a line (cell still too narrow even after Layer 1)
- Line ending with
(followed by content next line (broken bracket pair) - Line starting with
)(broken from previous bracket pair) - Short line ending with mid-thought punctuation
、,;:
Findings are printed to stderr with page+line locations. They are warnings, not errors — PDF still generates. The author sees the finding and decides:
1. Accept (e.g. one orphan char in a long doc may be acceptable) 2. Shorten the offending cell content to fit the column width 3. Restructure (e.g. move long content into a paragraph below the table)
Why not silently auto-fix everything?
Layer 2 deliberately does NOT modify the markdown. Per CLAUDE.md "禁止隐式行为" rule: silently rewriting non-standard markdown (e.g. expanding pseudo-lists into real lists) would mask the signal that the source is wrong, causing the same markdown to render incorrectly in other processors. Layer 1 is acceptable because it patches rendering behavior for already-standard markdown (a standard table that weasyprint happens to render imperfectly for CJK), not the markdown source itself.
Known limitations
When a single cell's content is just slightly longer than the available column width (e.g. 10 CJK chars in a 9-char-wide cell after equal split), weasyprint will fall back to forced break despite keep-all. Layer 1 cannot fix this — Layer 2 will catch it and prompt the author to shorten cell content or restructure.
Security scan passed
Scanned at: 2026-05-10T00:54:48.296005
Tool: gitleaks + pattern-based validation
Content hash: 7804284325ef2700b95f52e849e113b139a7fcd67062856e2b87b1bf2c1ecb6c
#!/usr/bin/env python3
"""
Batch convert multiple markdown files to PDF.
Usage:
python batch_convert.py file1.md file2.md file3.md
python batch_convert.py *.md
python batch_convert.py --output-dir ./pdfs file1.md file2.md
Requirements:
pip install weasyprint markdown
"""
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from md_to_pdf import markdown_to_pdf
def main():
parser = argparse.ArgumentParser(
description='Batch convert markdown files to PDF with Chinese font support'
)
parser.add_argument(
'files',
nargs='+',
help='Markdown files to convert'
)
parser.add_argument(
'--output-dir', '-o',
type=str,
default=None,
help='Output directory for PDFs (default: same as input)'
)
parser.add_argument(
'--theme', '-t',
type=str,
default='default',
help='CSS theme name (default: default). Available themes depend on what is in themes/'
)
parser.add_argument(
'--backend', '-b',
type=str,
default=None,
choices=['weasyprint', 'chrome'],
help='PDF rendering backend (default: auto-detect)'
)
parser.add_argument(
'--no-preview',
action='store_true',
help='Skip per-page PNG preview generation (faster for batch runs)'
)
args = parser.parse_args()
output_dir = Path(args.output_dir) if args.output_dir else None
if output_dir:
output_dir.mkdir(parents=True, exist_ok=True)
success = 0
failed = 0
for md_file in args.files:
md_path = Path(md_file)
if not md_path.exists():
print(f"[SKIP] File not found: {md_file}")
failed += 1
continue
if not md_path.suffix.lower() == '.md':
print(f"[SKIP] Not a markdown file: {md_file}")
failed += 1
continue
# Determine output path
if output_dir:
pdf_file = str(output_dir / md_path.with_suffix('.pdf').name)
else:
pdf_file = str(md_path.with_suffix('.pdf'))
try:
print(f"Converting: {md_file} -> {pdf_file} (theme={args.theme})")
markdown_to_pdf(
str(md_path),
pdf_file,
theme=args.theme,
backend=args.backend,
previews=not args.no_preview,
)
success += 1
except Exception as e:
print(f"[ERROR] Failed to convert {md_file}: {e}")
failed += 1
print(f"\nCompleted: {success} succeeded, {failed} failed")
sys.exit(0 if failed == 0 else 1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Markdown to PDF converter with Chinese font support and theme system.
Converts markdown files to PDF using:
- pandoc (markdown → HTML)
- weasyprint or headless Chrome (HTML → PDF), auto-detected
Usage:
python md_to_pdf.py input.md output.pdf
python md_to_pdf.py input.md --theme warm-terra
python md_to_pdf.py input.md --theme default --backend chrome
python md_to_pdf.py input.md # outputs input.pdf, default theme, auto backend
Themes:
Stored in ../themes/*.css. Built-in themes:
- default: Songti SC + black/grey, formal documents
- warm-terra: PingFang SC + terra cotta, training/workshop materials
Requirements:
pandoc (system install, e.g. brew install pandoc)
weasyprint (pip install weasyprint) OR Google Chrome (for --backend chrome)
"""
from __future__ import annotations
import argparse
import os
import platform
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
THEMES_DIR = SCRIPT_DIR.parent / "themes"
# macOS ARM: auto-configure library path for weasyprint
if platform.system() == "Darwin":
_homebrew_lib = "/opt/homebrew/lib"
if Path(_homebrew_lib).is_dir():
_cur = os.environ.get("DYLD_LIBRARY_PATH", "")
if _homebrew_lib not in _cur:
os.environ["DYLD_LIBRARY_PATH"] = (
f"{_homebrew_lib}:{_cur}" if _cur else _homebrew_lib
)
def _find_chrome() -> str | None:
"""Find Chrome/Chromium binary path."""
candidates = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
shutil.which("google-chrome"),
shutil.which("chromium"),
shutil.which("chrome"),
]
for c in candidates:
if c and Path(c).exists():
return str(c)
return None
def _has_weasyprint() -> bool:
"""Check if weasyprint is importable."""
try:
import weasyprint # noqa: F401
return True
except ImportError:
return False
def _has_cjk_content(md_file: str) -> bool:
"""Check if markdown file contains CJK characters."""
try:
text = Path(md_file).read_text(encoding="utf-8")
cjk_re = re.compile(
r"[一-鿿㐀-䶿豈-"
r" -〿-"
r"-ゟ゠-ヿ"
r"가-]"
)
return bool(cjk_re.search(text))
except Exception:
return False
def _detect_backend(md_file: str | None = None) -> str:
"""Auto-detect best available backend.
CJK content → prefer Chrome (weasyprint subset-embeds PingFang SC as
CID Type 0C OpenType, which macOS Preview / Adobe Reader fail to render).
Non-CJK content → prefer weasyprint (faster, no browser needed).
"""
if md_file and _has_cjk_content(md_file) and _find_chrome():
return "chrome"
if _has_weasyprint():
return "weasyprint"
if _find_chrome():
return "chrome"
print(
"Error: No PDF backend found. Install weasyprint (pip install weasyprint) "
"or Google Chrome.",
file=sys.stderr,
)
sys.exit(1)
# ---------------- CJK typography patch (auto-injected, no source mutation) ----
#
# Design principle: the user's markdown stays untouched, the user's theme CSS
# files stay untouched. We only patch the CSS string at load time (intermediate
# state), so the same .md + the same theme.css render correctly without the
# author having to know about CJK line-break engine quirks.
#
# Why needed: weasyprint's default `word-break: normal` treats every CJK
# character as a break opportunity. In narrow table cells this produces
# single-char-only lines and broken bracket pairs — anti-patterns per the
# well-known "中文文案排版指北" Chinese typography guide.
#
# Strategy (CJK-safe break rules, scoped to table cells only so body text
# behaves normally):
# - `word-break: keep-all` — don't break inside CJK runs
# - `overflow-wrap: break-word` — but allow break at word/punctuation
# boundaries when content exceeds cell width
# - `line-break: strict` — apply strict CJK rules (no break before
# closing 」』), no break after opening 「『(, no break around 、,;:)
_TYPOGRAPHY_CSS_PATCH = """
/* ===== md_to_pdf auto-injected: CJK typography for table cells =====
*
* Three-layer fix for the most common CJK rendering anti-patterns:
*
* 1. table-layout: fixed + equal column widths — prevents weasyprint
* auto-layout from squeezing one column to 10% width when an
* adjacent column has 5x more content (the root cause of "single
* CJK char alone on a line" in narrow cells).
*
* 2. CJK break rules at cell level — don't slice CJK characters apart;
* break only at word/punctuation boundaries.
*
* 3. Header nowrap — short headers stay one line; combined with fixed
* layout, column widths are predictable.
*
* Trade-off: tables now distribute width equally across columns instead
* of content-aware. This may give "wider than needed" columns to short-
* content cells, but eliminates the "single CJK char per line" bug.
*/
table {
table-layout: fixed;
width: 100%;
}
table td, table th {
word-break: keep-all;
/* `overflow-wrap: normal` instead of break-word: when keep-all says
* "don't break inside CJK" and cell is too narrow for the content,
* prefer letting content overflow slightly rather than fallback to
* mid-token breaks (which produce single-CJK-char-per-line). */
overflow-wrap: normal;
line-break: strict;
}
table th {
white-space: nowrap;
}
/* =================================================================== */
"""
def _load_theme(theme_name: str) -> str:
"""Load CSS from themes directory and append CJK typography patch.
The patch is appended AFTER the user theme so it wins the cascade for
table cells. The user's theme CSS file on disk is never modified.
"""
theme_file = THEMES_DIR / f"{theme_name}.css"
if not theme_file.exists():
available = [f.stem for f in THEMES_DIR.glob("*.css")]
print(
f"Error: Theme '{theme_name}' not found. Available: {available}",
file=sys.stderr,
)
sys.exit(1)
return theme_file.read_text(encoding="utf-8") + _TYPOGRAPHY_CSS_PATCH
def _list_themes() -> list[str]:
"""List available theme names."""
if not THEMES_DIR.exists():
return []
return sorted(f.stem for f in THEMES_DIR.glob("*.css"))
def _ensure_list_spacing(text: str) -> str:
"""Ensure blank lines before list items for proper markdown parsing.
Both Python markdown library and pandoc require a blank line before a list
when it follows a paragraph. Without it, list items render as plain text.
"""
lines = text.split("\n")
result = []
list_re = re.compile(r"^(\s*)([-*+]|\d+\.)\s")
for i, line in enumerate(lines):
if i > 0 and list_re.match(line):
prev = lines[i - 1]
if prev.strip() and not list_re.match(prev):
result.append("")
result.append(line)
return "\n".join(result)
_CJK_RANGE = re.compile(
# Chinese: CJK Unified Ideographs + Extension A + Compatibility + Extensions B-F
r"[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff"
r"\U00020000-\U0002a6df\U0002a700-\U0002ebef"
# CJK Symbols and Punctuation + Halfwidth/Fullwidth Forms
r"\u3000-\u303f\uff00-\uffef"
# Japanese: Hiragana + Katakana + Katakana Phonetic Extensions
r"\u3040-\u309f\u30a0-\u30ff\u31f0-\u31ff"
# Korean: Hangul Syllables + Hangul Jamo + Hangul Compatibility Jamo
r"\uac00-\ud7af\u1100-\u11ff\u3130-\u318f]"
)
# Match <pre><code>…</code></pre> allowing attributes on both tags.
# Also handles pandoc's <div class="sourceCode"><pre class="..."><code class="...">
# syntax highlighting wrapper, by matching the inner <pre><code> structure.
_PRE_CODE_RE = re.compile(
r"<pre[^>]*>\s*<code[^>]*>(.+?)</code>\s*</pre>",
flags=re.DOTALL,
)
def _fix_cjk_code_blocks(html: str) -> str:
"""Replace <pre><code> blocks containing CJK with styled divs.
weasyprint renders <pre> blocks using monospace fonts that lack CJK glyphs,
causing garbled output. This converts CJK-heavy code blocks to styled divs
that use the document's CJK font stack instead.
Pure-ASCII code blocks (including pandoc-highlighted ones with language
identifiers) are left untouched so syntax highlighting and monospace
rendering are preserved.
"""
def _replace_if_cjk(match: re.Match) -> str:
content = match.group(1)
if _CJK_RANGE.search(content):
# Strip pandoc's <span> syntax-highlighting wrappers so the
# content renders as plain text in the inherited body font.
cleaned = re.sub(r"<span[^>]*>", "", content)
cleaned = cleaned.replace("</span>", "")
return f'<div class="cjk-code-block">{cleaned}</div>'
return match.group(0)
return _PRE_CODE_RE.sub(_replace_if_cjk, html)
def _md_to_html(md_file: str) -> str:
"""Convert markdown to HTML using pandoc with list spacing preprocessing."""
if not shutil.which("pandoc"):
print(
"Error: pandoc not found. Install with: brew install pandoc",
file=sys.stderr,
)
sys.exit(1)
md_content = Path(md_file).read_text(encoding="utf-8")
md_content = _ensure_list_spacing(md_content)
result = subprocess.run(
["pandoc", "-f", "markdown", "-t", "html"],
input=md_content,
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"Error: pandoc failed: {result.stderr}", file=sys.stderr)
sys.exit(1)
html = result.stdout
html = _fix_cjk_code_blocks(html)
return html
def _build_full_html(html_content: str, css: str, title: str) -> str:
"""Wrap HTML content in a full document with CSS."""
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{title}</title>
<style>{css}</style>
</head>
<body>
{html_content}
</body>
</html>"""
def _render_weasyprint(full_html: str, pdf_file: str, css: str) -> None:
"""Render PDF using weasyprint."""
from weasyprint import CSS, HTML
HTML(string=full_html).write_pdf(pdf_file, stylesheets=[CSS(string=css)])
def _render_chrome(full_html: str, pdf_file: str) -> None:
"""Render PDF using headless Chrome."""
chrome = _find_chrome()
if not chrome:
print("Error: Chrome not found.", file=sys.stderr)
sys.exit(1)
with tempfile.NamedTemporaryFile(
suffix=".html", mode="w", encoding="utf-8", delete=False
) as f:
f.write(full_html)
html_path = f.name
try:
result = subprocess.run(
[
chrome,
"--headless",
"--disable-gpu",
"--no-pdf-header-footer",
f"--print-to-pdf={pdf_file}",
html_path,
],
capture_output=True,
text=True,
)
if not Path(pdf_file).exists():
print(
f"Error: Chrome failed to generate PDF. stderr: {result.stderr}",
file=sys.stderr,
)
sys.exit(1)
finally:
Path(html_path).unlink(missing_ok=True)
# ---------------- Visual self-check (post-render PNG previews) ----------------
#
# Why: "PDF generated successfully" ≠ "PDF renders correctly". Common silent
# failures include paragraph collapsing (pseudo-list), table overflow, missing
# CJK / emoji glyphs, code block garbling. The author/AI must visually inspect
# the rendered output, not assume success from a clean exit code.
#
# Design: after PDF generation, automatically convert each page to PNG next to
# the PDF (via pdftoppm), and print a structured self-check checklist to stdout.
# This makes "Read each PNG and verify" the default contract of every PDF run,
# not an optional step that's easy to skip.
#
# Reference: CLAUDE.md "Self-Verification Protocol" + "Visual Verification" rules.
def _generate_pdf_previews(pdf_file: str, dpi: int = 130) -> list[Path]:
"""Convert each PDF page to PNG for visual inspection.
Returns sorted list of PNG paths. Empty list if pdftoppm not available
or no pages produced. Old previews in target dir are cleaned first.
"""
if not shutil.which("pdftoppm"):
return []
pdf_path = Path(pdf_file).resolve()
# Previews are a throwaway self-check artifact, NOT a deliverable. Write them
# under the system temp dir (NOT next to the PDF) so they never linger in the
# user's working tree / git repo: the self-check happens out-of-process (the
# caller Reads the PNGs), so the script can't know when inspection is done and
# must not drop PNGs into the repo in the first place. Honors $TMPDIR.
preview_dir = Path(tempfile.gettempdir()) / "pdf-creator-previews" / pdf_path.stem
preview_dir.mkdir(parents=True, exist_ok=True)
# Clean stale previews so old/extra pages don't linger after a shorter rerun
for old in preview_dir.glob("page-*.png"):
old.unlink()
subprocess.run(
[
"pdftoppm",
"-png",
"-r",
str(dpi),
str(pdf_path),
str(preview_dir / "page"),
],
capture_output=True,
)
return sorted(preview_dir.glob("page-*.png"))
def _lint_pdf_typography(pdf_file: str) -> list[dict]:
"""Detect inappropriate Chinese line breaks in rendered PDF.
Uses `pdftotext -layout` to preserve visual line structure, then scans
each page for known typography anti-patterns per "中文文案排版指北":
1. Single CJK character alone on a line (cell too narrow → forced break
mid-word/mid-name)
2. Line ending with 全角左括号 「(」 followed by content on next line
(broken parenthesis pair: opener separated from content)
3. Line starting with 全角右括号 「)」 (same as #2 from receiving end)
4. Short line ending with mid-thought punctuation 「、,;:」 right
before next CJK content (suggests forced break in narrow cell)
Returns: list of dicts {page, line, kind, snippet, message}.
Empty list if pdftotext unavailable or PDF clean.
Note: this only catches obvious cases. Subtle typography issues (uneven
line spacing, awkward breaks at safe points) require visual inspection.
"""
if not shutil.which("pdftotext"):
return []
findings: list[dict] = []
# Process each page separately to give accurate page numbers
page_count_result = subprocess.run(
["pdfinfo", str(pdf_file)],
capture_output=True,
text=True,
)
page_count = 0
if page_count_result.returncode == 0:
for line in page_count_result.stdout.splitlines():
if line.startswith("Pages:"):
try:
page_count = int(line.split(":", 1)[1].strip())
except ValueError:
pass
break
if page_count == 0:
return []
cjk_re = re.compile(r"[一-鿿]")
for page_num in range(1, page_count + 1):
result = subprocess.run(
[
"pdftotext",
"-layout",
"-f",
str(page_num),
"-l",
str(page_num),
str(pdf_file),
"-",
],
capture_output=True,
text=True,
)
if result.returncode != 0:
continue
lines = result.stdout.split("\n")
for i, line in enumerate(lines):
stripped = line.strip()
if not stripped:
continue
# Pattern 1: Single CJK character alone on a line
if len(stripped) == 1 and cjk_re.match(stripped):
findings.append({
"page": page_num,
"line": i + 1,
"kind": "single-cjk-char",
"snippet": stripped,
"message": (
f"Single CJK char 「{stripped}」 alone on a line — "
"cell width forced mid-word break"
),
})
continue
# Pattern 2: Line ends with 全角左括号 followed by content next line
if stripped.endswith("(") and i + 1 < len(lines):
next_stripped = lines[i + 1].strip()
if next_stripped and not next_stripped.startswith(")"):
findings.append({
"page": page_num,
"line": i + 1,
"kind": "broken-bracket-open",
"snippet": stripped[-15:],
"message": (
"Line ends with 全角左括号「(」 and content "
"wraps to next line — broken bracket pair"
),
})
continue
# Pattern 3: Line starts with 全角右括号
if stripped.startswith(")"):
findings.append({
"page": page_num,
"line": i + 1,
"kind": "broken-bracket-close",
"snippet": stripped[:15],
"message": (
"Line starts with 全角右括号「)」 — "
"broken from previous line's bracket pair"
),
})
continue
# Pattern 4: Line ends with 中文标点 (suggests forced break)
# This is informational — sometimes legitimate
if stripped.endswith(("、", ",", ";", ":")) and i + 1 < len(lines):
next_stripped = lines[i + 1].strip()
# Only warn if next line continues with CJK content (not a new section)
if next_stripped and cjk_re.match(next_stripped):
# Skip if this looks like end of a list item or paragraph (heuristic)
if len(stripped) < 30: # short cell content suggests forced break
findings.append({
"page": page_num,
"line": i + 1,
"kind": "trailing-punctuation-break",
"snippet": stripped[-15:],
"message": (
f"Short line ends with 「{stripped[-1]}」 mid-thought — "
"may be forced break in narrow cell"
),
})
return findings
def _print_typography_findings(findings: list[dict]) -> None:
"""Print typography lint findings to stderr."""
if not findings:
return
print("", file=sys.stderr)
print(
f"⚠️ Typography lint: {len(findings)} potential Chinese line-break issue(s)",
file=sys.stderr,
)
print(
" Per 中文文案排版指北 — narrow cells / over-wide tables often force "
"inappropriate breaks:",
file=sys.stderr,
)
for f in findings[:20]: # cap output
print(
f" [page {f['page']} L{f['line']}] {f['kind']}: "
f"{f['message']}",
file=sys.stderr,
)
print(f" snippet: 「{f['snippet']}」", file=sys.stderr)
if len(findings) > 20:
print(f" ... ({len(findings) - 20} more)", file=sys.stderr)
print(
" 💡 Fix: reduce table column count, shorten cell content, or "
"restructure as separate paragraph below the table.",
file=sys.stderr,
)
print("", file=sys.stderr)
def _print_self_check_hint(pages: list[Path]) -> None:
"""Print a structured self-check checklist after PDF generation."""
if not pages:
print(
"ℹ️ Visual self-check skipped: pdftoppm not found "
"(install: brew install poppler).",
file=sys.stderr,
)
return
preview_dir = pages[0].parent
print("")
print(f"📋 Visual self-check required ({len(pages)} pages)")
print(f" Previews: {preview_dir}/page-NN.png")
print("")
print(" ⚠️ PDF generated cleanly does NOT mean rendering matches intent.")
print(" Read each page PNG and verify against your markdown source:")
print("")
print(" [ ] Paragraphs render as separate blocks (NOT collapsed into one)")
print(" ↳ Common issue: ≥2 consecutive `**xxx**:text` lines without")
print(" blank lines collapse into one paragraph (CommonMark soft")
print(" break = space). Fix in markdown: use `- ` real list, or")
print(" insert blank lines between.")
print(" [ ] Tables fit within page margins (no right-side text cut off)")
print(" [ ] No inappropriate Chinese line breaks (per 中文文案排版指北)")
print(" ↳ Symptoms: single CJK char alone on a line; broken bracket")
print(" pairs (line ends with `(` while content wraps to next line,")
print(" or line starts with `)`); short cells with mid-thought breaks.")
print(" ↳ Cause: cell width too narrow / table has too many columns.")
print(" ↳ Fix: reduce column count, shorten cell content, or move")
print(" long content into a separate paragraph below the table.")
print(" [ ] Lists keep nested indentation (sub-items visually nested)")
print(" [ ] Emoji + CJK glyphs render correctly (no boxes / placeholders)")
print(" [ ] Code blocks readable (monospace + CJK both visible)")
print(" [ ] No content overflow / unexpected page breaks mid-table")
print(" [ ] Last page ends naturally (no orphan title at top)")
print("")
def markdown_to_pdf(
md_file: str,
pdf_file: str | None = None,
theme: str = "default",
backend: str | None = None,
previews: bool = True,
) -> str:
"""
Convert markdown file to PDF.
Args:
md_file: Path to input markdown file
pdf_file: Path to output PDF (optional, defaults to same name as input)
theme: Theme name (from themes/ directory)
backend: 'weasyprint', 'chrome', or None (auto-detect)
previews: If True (default), auto-generate per-page PNG previews under
the system temp dir (NOT next to the PDF, so they never linger
in the repo) and print a visual self-check checklist with their
path. Disable with --no-preview for batch / non-interactive runs.
Returns:
Path to generated PDF file
"""
md_path = Path(md_file)
if pdf_file is None:
pdf_file = str(md_path.with_suffix(".pdf"))
if backend is None:
backend = _detect_backend(md_file)
css = _load_theme(theme)
html_content = _md_to_html(md_file)
full_html = _build_full_html(html_content, css, md_path.stem)
if backend == "weasyprint":
_render_weasyprint(full_html, pdf_file, css)
elif backend == "chrome":
_render_chrome(full_html, pdf_file)
else:
print(f"Error: Unknown backend '{backend}'", file=sys.stderr)
sys.exit(1)
size_kb = Path(pdf_file).stat().st_size / 1024
print(f"Generated: {pdf_file} ({size_kb:.0f}KB, theme={theme}, backend={backend})")
if previews:
# Auto-run typography lint to catch obvious mid-word breaks in tables.
# Findings go to stderr; do NOT block (warnings, not errors).
typography_findings = _lint_pdf_typography(pdf_file)
_print_typography_findings(typography_findings)
pages = _generate_pdf_previews(pdf_file)
_print_self_check_hint(pages)
return pdf_file
def main():
available_themes = _list_themes()
parser = argparse.ArgumentParser(
description="Markdown to PDF with Chinese font support and themes."
)
parser.add_argument("input", help="Input markdown file")
parser.add_argument("output", nargs="?", help="Output PDF file (optional)")
parser.add_argument(
"--theme",
default="default",
choices=available_themes or ["default"],
help=f"CSS theme (available: {', '.join(available_themes) or 'default'})",
)
parser.add_argument(
"--backend",
choices=["weasyprint", "chrome"],
default=None,
help="PDF rendering backend (default: auto-detect)",
)
parser.add_argument(
"--list-themes",
action="store_true",
help="List available themes and exit",
)
parser.add_argument(
"--no-preview",
action="store_true",
help="Skip per-page PNG preview generation and self-check hint",
)
args = parser.parse_args()
if args.list_themes:
for t in available_themes:
marker = " (default)" if t == "default" else ""
css_file = THEMES_DIR / f"{t}.css"
first_line = ""
for line in css_file.read_text().splitlines():
line = line.strip()
if line.startswith("*") and "—" in line:
first_line = line.lstrip("* ").strip()
break
print(f" {t}{marker}: {first_line}")
sys.exit(0)
if not Path(args.input).exists():
print(f"Error: File not found: {args.input}", file=sys.stderr)
sys.exit(1)
markdown_to_pdf(
args.input,
args.output,
args.theme,
args.backend,
previews=not args.no_preview,
)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Regression test for CJK code block rendering.
weasyprint renders <pre> blocks with monospace fonts that lack CJK glyphs.
md_to_pdf.py detects CJK characters inside <pre><code> and converts those
blocks to <div class="cjk-code-block"> which inherits the body font.
This test verifies:
1. CJK-containing code blocks get the .cjk-code-block class
2. Pure-ASCII code blocks remain as <pre><code> (monospace preserved)
3. Mixed documents handle both cases in a single pass
"""
import subprocess
import sys
import tempfile
from pathlib import Path
TEST_MARKDOWN = """# CJK Code Block 测试
## 场景1:含中文的代码块(应转为 div)
```
03/08 国金:GPT-5.4发布评测 ← 最早的报告
03/10 华创:多Agent | 国海:Token与算力出海
↓ [3/10 CNCERT发布安全预警] ← 重大事件
```
## 场景2:纯 ASCII 代码块(应保持 pre)
```python
def hello():
print("Hello, World")
return 42
```
## 场景3:含日文的代码块(应转为 div)
```
こんにちは
さようなら
```
## 场景4:inline code(`中文` 和 `code` 都应保留)
Use `uv run` to execute or reference `配置` file.
"""
def _extract_html(md_path: str) -> str:
"""Invoke the internal _md_to_html helper to get the preprocessed HTML."""
script_dir = Path(__file__).parent.parent
result = subprocess.run(
[
"uv",
"run",
"--with",
"weasyprint",
"python",
"-c",
f"import sys; sys.path.insert(0, '{script_dir}'); "
f"from md_to_pdf import _md_to_html; "
f"print(_md_to_html('{md_path}'))",
],
capture_output=True,
text=True,
cwd=script_dir.parent,
)
if result.returncode != 0:
raise RuntimeError(f"_md_to_html failed: {result.stderr}")
return result.stdout
def run_test() -> bool:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".md", delete=False, encoding="utf-8"
) as md_file:
md_file.write(TEST_MARKDOWN)
md_path = md_file.name
pdf_path = md_path.replace(".md", ".pdf")
try:
# Step 1: verify HTML preprocessing
print("=== HTML 预处理验证 ===")
html = _extract_html(md_path)
tests_passed = 0
tests_total = 6
# Test 1: CJK code block converted to div
if 'class="cjk-code-block"' in html and "CNCERT发布安全预警" in html:
print("✅ 场景1: 中文 code block 已转为 .cjk-code-block div")
tests_passed += 1
else:
print("❌ 场景1: 中文 code block 未被转换")
# Test 2: Pure ASCII code block stays inside <pre>...</pre>.
# pandoc wraps highlighted blocks as
# <pre class="sourceCode python"><code class="sourceCode python">...
# so we only check for <pre and the presence of the function name.
if "<pre" in html and "hello" in html and "print" in html:
hello_pos = html.find("hello")
# Walk back to the nearest <pre or <div cjk-code-block.
preceding = html[max(0, hello_pos - 500) : hello_pos]
last_pre = preceding.rfind("<pre")
last_cjk = preceding.rfind("cjk-code-block")
if last_pre > last_cjk:
print("✅ 场景2: 纯 ASCII code block 保持 <pre> 结构")
tests_passed += 1
else:
print("❌ 场景2: 纯 ASCII code block 被错误转换为 cjk div")
else:
print("❌ 场景2: 纯 ASCII code block 丢失")
# Test 3: Japanese code block also gets converted
if "こんにちは" in html:
jp_pos = html.find("こんにちは")
preceding = html[max(0, jp_pos - 200) : jp_pos]
if "cjk-code-block" in preceding:
print("✅ 场景3: 日文 code block 已转为 .cjk-code-block div")
tests_passed += 1
else:
print("❌ 场景3: 日文 code block 未被转换")
else:
print("❌ 场景3: 日文 code block 丢失")
# Test 4: Inline code preserved (both CJK and ASCII)
if "<code>中文</code>" in html and "<code>code</code>" in html:
print("✅ 场景4: inline code 保持 <code> 标签")
tests_passed += 1
else:
print("❌ 场景4: inline code 处理异常")
# Step 2: verify PDF can actually be generated end-to-end
print("\n=== PDF 生成验证 ===")
script_dir = Path(__file__).parent.parent
md_to_pdf = script_dir / "md_to_pdf.py"
result = subprocess.run(
["uv", "run", "--with", "weasyprint", str(md_to_pdf), md_path, pdf_path],
capture_output=True,
text=True,
cwd=script_dir.parent,
)
if result.returncode == 0 and Path(pdf_path).exists():
print("✅ 场景5: PDF 生成成功")
tests_passed += 1
else:
print(f"❌ 场景5: PDF 生成失败: {result.stderr}")
# Step 3: verify PDF content contains the CJK text (not garbled)
txt_path = pdf_path.replace(".pdf", ".txt")
result = subprocess.run(
["pdftotext", pdf_path, txt_path], capture_output=True, text=True
)
if result.returncode == 0 and Path(txt_path).exists():
with open(txt_path, "r", encoding="utf-8") as f:
pdf_text = f.read()
# Key test: the CJK text from the code block must be intact
if "CNCERT发布安全预警" in pdf_text and "国金" in pdf_text:
print("✅ 场景6: PDF 中 CJK 文本未乱码")
tests_passed += 1
else:
print("❌ 场景6: PDF 中 CJK 文本乱码或丢失")
print(f" 提取内容(前 500 字符): {pdf_text[:500]}")
else:
print("⚠️ 场景6: pdftotext 不可用,跳过 PDF 内容验证")
tests_total -= 1
print(f"\n=== 测试结果: {tests_passed}/{tests_total} 通过 ===")
if tests_passed == tests_total:
print("\n✅ 所有测试通过!")
return True
print(f"\n❌ {tests_total - tests_passed} 个测试失败")
return False
except Exception as exc: # noqa: BLE001
print(f"❌ 测试异常: {exc}")
import traceback
traceback.print_exc()
return False
finally:
Path(md_path).unlink(missing_ok=True)
Path(pdf_path).unlink(missing_ok=True)
Path(pdf_path.replace(".pdf", ".txt")).unlink(missing_ok=True)
if __name__ == "__main__":
sys.exit(0 if run_test() else 1)
#!/usr/bin/env python3
"""
Regression test for CJK table rendering contract.
Locks in the Layer 1 CSS patch and theme behaviors documented in SKILL.md
under "CJK Typography":
- table-layout: fixed (equal column widths)
- word-break: keep-all (don't break inside CJK runs)
- overflow-wrap: normal (let content overflow rather than break mid-token —
the explicit trade-off in md_to_pdf.py L109-146 inline comments)
- line-break: strict
- th nowrap (predictable header widths)
- colgroup neutralizer in default theme (overrides pandoc dash-count hints)
These are contract-level checks — fast, mostly no weasyprint required.
End-to-end PDF generation is gated by smoke tests at the bottom; they
skip cleanly when weasyprint is unavailable so the unit-level contract
checks still report status.
Why these tests exist: SKILL.md describes the right strategy, but no
test previously guarded the implementation. If a future edit silently
removes `overflow-wrap: normal` from _TYPOGRAPHY_CSS_PATCH, or moves
the colgroup neutralizer out of default.css, these tests fail and
flag the regression before users see broken CJK tables.
"""
from __future__ import annotations
import subprocess
import sys
import tempfile
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.parent
sys.path.insert(0, str(SCRIPT_DIR))
# ---------------- Contract-level checks (no weasyprint required) -------------
def test_layer1_patch_has_table_layout_fixed() -> None:
"""Equal column distribution is the documented strategy.
Removing table-layout: fixed reverts to weasyprint auto-layout, which
can squeeze a column to ~10% width when an adjacent column is content-
heavy. SKILL.md "CJK Typography" Layer 1 lists this as fix #1.
"""
from md_to_pdf import _TYPOGRAPHY_CSS_PATCH
assert "table-layout: fixed" in _TYPOGRAPHY_CSS_PATCH
def test_layer1_patch_has_keep_all() -> None:
"""word-break: keep-all prevents breaking inside a CJK token (run of
consecutive CJK characters). Without it, weasyprint treats every CJK
character as a valid break point — producing the "single CJK char on
a line" anti-pattern.
"""
from md_to_pdf import _TYPOGRAPHY_CSS_PATCH
assert "word-break: keep-all" in _TYPOGRAPHY_CSS_PATCH
def test_layer1_patch_has_overflow_wrap_normal() -> None:
"""The deliberate trade-off: prefer letting content overflow slightly
rather than fall back to mid-token breaks. This is the rationale
documented in md_to_pdf.py L109-146 inline comments. SKILL.md
historically omits this line — separate doc-drift fix tracks that.
"""
from md_to_pdf import _TYPOGRAPHY_CSS_PATCH
assert "overflow-wrap: normal" in _TYPOGRAPHY_CSS_PATCH
def test_layer1_patch_has_line_break_strict() -> None:
"""line-break: strict applies strict CJK punctuation rules:
no break before closing brackets 」』), no break after opening 「『(,
no break around middle dots and small commas 、,;:.
"""
from md_to_pdf import _TYPOGRAPHY_CSS_PATCH
assert "line-break: strict" in _TYPOGRAPHY_CSS_PATCH
def test_layer1_patch_has_th_nowrap() -> None:
"""Short headers stay one line; combined with fixed layout this gives
predictable column widths even when cell content varies.
"""
from md_to_pdf import _TYPOGRAPHY_CSS_PATCH
# The patch sets nowrap specifically on th (header cells), not all cells
assert "white-space: nowrap" in _TYPOGRAPHY_CSS_PATCH
def test_default_theme_neutralizes_pandoc_colgroup_hint() -> None:
"""The colgroup neutralizer must be present in default.css.
Pandoc emits <col style="width:X%"> from markdown separator-row dash
counts. Inline styles beat external stylesheets at equal specificity,
so without !important no td:first-child {width: ...} rule can recover.
`table colgroup col { width: auto !important }` forces fallback to
table-layout: fixed equal-width allocation.
Note: warm-terra and mobile use different strategies (e.g. nowrap on
th/td with last-child wrap) and intentionally don't include the
neutralizer. This test asserts the contract for the default theme only.
"""
from md_to_pdf import _load_theme
css = _load_theme("default")
assert "table colgroup col" in css, "Missing colgroup selector"
assert "width: auto !important" in css, "Missing !important neutralizer"
def test_loaded_theme_appends_patch_after_theme() -> None:
"""_load_theme() must append the typography patch AFTER the theme CSS,
so the patch wins cascade order at equal specificity for table cells.
"""
from md_to_pdf import _load_theme, _TYPOGRAPHY_CSS_PATCH
css = _load_theme("default")
assert _TYPOGRAPHY_CSS_PATCH in css, "Patch not appended to theme"
patch_idx = css.index(_TYPOGRAPHY_CSS_PATCH)
# Theme CSS starts with an @page rule; the patch comes after it
theme_idx = css.index("@page")
assert patch_idx > theme_idx, "Patch must be appended after theme to win cascade"
# ---------------- End-to-end smoke tests (require weasyprint) ----------------
SHORT_CJK_TABLE_MD = """# 短表头四列测试
| 周一 | 周二 | 周三 | 周四 |
|------|------|------|------|
| 上午 | 下午 | 上午 | 下午 |
| 工作 | 休息 | 工作 | 休息 |
"""
LONG_CELL_NARROW_COL_MD = """# 长内容窄列测试
| 标签 | 备注 |
|------|------|
| 类型 | 这是一段比较长的中文备注内容,故意撑爆窄列以触发 Layer 2 排版告警机制 |
| 状态 | 正常 |
"""
def _generate_pdf(md_content: str) -> tuple[bool, list]:
"""Generate PDF from markdown via the public markdown_to_pdf() API.
Returns (ran, typography_findings). `ran` is False when weasyprint isn't
available — caller should skip rather than fail.
"""
try:
from md_to_pdf import (
_has_weasyprint,
_lint_pdf_typography,
markdown_to_pdf,
)
except ImportError:
return False, []
if not _has_weasyprint():
return False, []
with tempfile.TemporaryDirectory() as tmpdir:
md_path = Path(tmpdir) / "input.md"
md_path.write_text(md_content, encoding="utf-8")
pdf_path = Path(tmpdir) / "output.pdf"
markdown_to_pdf(str(md_path), str(pdf_path), previews=False)
findings = _lint_pdf_typography(str(pdf_path))
return True, findings
def test_smoke_short_cjk_table_renders_cleanly() -> None:
"""End-to-end: a typical short 4-col CJK table under the equal-width
strategy should produce no typography lint warnings.
Locks in the contract that the Layer 1 strategy succeeds for the
common case (short content, evenly-sized columns).
"""
ran, findings = _generate_pdf(SHORT_CJK_TABLE_MD)
if not ran:
print(" ⊘ Skipped: weasyprint not available")
return
assert not findings, (
f"Unexpected lint findings on short CJK table: "
f"{[(f['page'], f['kind'], f['snippet']) for f in findings]}"
)
def test_smoke_layer2_lint_pipeline_works() -> None:
"""End-to-end: the Layer 2 lint pipeline must actually execute without
error when given a real PDF. This protects the pipeline plumbing
(pdfinfo + pdftotext + regex scan) against silent breakage.
We do NOT assert a specific finding type here — whether a particular
document triggers a particular anti-pattern depends on weasyprint's
exact line-wrapping behavior, which can shift across versions. The
contract being tested is "the lint runs and returns a list" (it may
be empty for some inputs even when the Layer 1 trade-off bites).
"""
ran, findings = _generate_pdf(LONG_CELL_NARROW_COL_MD)
if not ran:
print(" ⊘ Skipped: weasyprint not available")
return
assert isinstance(findings, list), "Layer 2 lint must return a list"
# Informational: log what was caught (or not). This makes the test
# output a useful artifact for tuning the lint detectors later.
if findings:
kinds = sorted({f["kind"] for f in findings})
print(f" ℹ Layer 2 caught {len(findings)} finding(s): {kinds}")
else:
print(" ℹ Layer 2 returned no findings (Layer 1 sufficient for this input)")
# ---------------- Runner ----------------
def run_all() -> int:
tests = [
("Layer 1 patch: table-layout fixed", test_layer1_patch_has_table_layout_fixed),
("Layer 1 patch: word-break keep-all", test_layer1_patch_has_keep_all),
("Layer 1 patch: overflow-wrap normal", test_layer1_patch_has_overflow_wrap_normal),
("Layer 1 patch: line-break strict", test_layer1_patch_has_line_break_strict),
("Layer 1 patch: th nowrap", test_layer1_patch_has_th_nowrap),
("Default theme: colgroup neutralizer", test_default_theme_neutralizes_pandoc_colgroup_hint),
("Loaded theme: patch appended after CSS", test_loaded_theme_appends_patch_after_theme),
("Smoke: short CJK table renders cleanly", test_smoke_short_cjk_table_renders_cleanly),
("Smoke: Layer 2 lint pipeline executes", test_smoke_layer2_lint_pipeline_works),
]
passed = failed = 0
for name, fn in tests:
try:
fn()
print(f"✅ {name}")
passed += 1
except AssertionError as e:
print(f"❌ {name}: {e}")
failed += 1
except Exception as e: # noqa: BLE001
print(f"❌ {name}: ERROR {type(e).__name__}: {e}")
failed += 1
print(f"\n=== {passed}/{passed + failed} passed ===")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(run_all())
#!/usr/bin/env python3
"""
Test list rendering in PDF generation.
Verifies that markdown lists are correctly rendered in PDFs,
even when they don't have blank lines before them.
The original markdown files are NOT modified - preprocessing
happens in memory during conversion.
"""
import subprocess
import sys
import tempfile
from pathlib import Path
# Test markdown content with various list scenarios
TEST_MARKDOWN = """# 测试列表解析
## 场景1:列表前有空行(正常)
这是一段文字。
- 列表项 1
- 列表项 2
- 列表项 3
## 场景2:列表前没有空行(关键测试)
这是一段文字。
- 列表项 1
- 列表项 2
- 列表项 3
## 场景3:有序列表前没有空行
这是一段文字。
1. 第一项
2. 第二项
3. 第三项
## 场景4:有序列表前有空行(正常)
这是一段文字。
1. 第一项
2. 第二项
3. 第三项
"""
def run_test():
"""Run the list rendering test."""
# Create temporary files
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False, encoding='utf-8') as md_file:
md_file.write(TEST_MARKDOWN)
md_path = md_file.name
pdf_path = md_path.replace('.md', '.pdf')
txt_path = md_path.replace('.md', '.txt')
try:
# Generate PDF
script_dir = Path(__file__).parent.parent
md_to_pdf = script_dir / 'md_to_pdf.py'
print(f"生成 PDF: {md_path} -> {pdf_path}")
result = subprocess.run(
['uv', 'run', '--with', 'weasyprint', str(md_to_pdf), md_path, pdf_path],
capture_output=True, text=True, cwd=script_dir.parent
)
if result.returncode != 0:
print(f"❌ PDF 生成失败: {result.stderr}")
return False
print(f"✅ PDF 已生成")
# Extract text from PDF
result = subprocess.run(
['pdftotext', pdf_path, txt_path],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"❌ 文本提取失败: {result.stderr}")
return False
# Read extracted text
with open(txt_path, 'r', encoding='utf-8') as f:
pdf_text = f.read()
# Verify original file was not modified
with open(md_path, 'r', encoding='utf-8') as f:
original_content = f.read()
if original_content != TEST_MARKDOWN:
print("❌ 原始文件被修改了!")
return False
print("✅ 原始文件未被修改")
# Verify list rendering
print("\n=== 列表渲染验证 ===")
tests_passed = 0
tests_total = 4
# Test 1: List with blank line before it
if '• 列表项 1' in pdf_text:
print("✅ 场景1: 列表前有空行 - 正确渲染")
tests_passed += 1
else:
print("❌ 场景1: 列表前有空行 - 渲染失败")
# Test 2: Critical test - list without blank line before it
scene2_start = pdf_text.find('场景2')
scene2_section = pdf_text[scene2_start:scene2_start+200] if scene2_start != -1 else ""
if '• 列表项 1' in scene2_section and '- 列表项 1' not in scene2_section:
print("✅ 场景2: 列表前没有空行 - 正确渲染(关键测试)")
tests_passed += 1
else:
print("❌ 场景2: 列表前没有空行 - 渲染失败")
print(f" 实际内容: {scene2_section}")
# Test 3: Ordered list without blank line
scene3_start = pdf_text.find('场景3')
scene3_section = pdf_text[scene3_start:scene3_start+200] if scene3_start != -1 else ""
if '1. 第一项' in scene3_section and '2. 第二项' in scene3_section:
print("✅ 场景3: 有序列表前没有空行 - 正确渲染")
tests_passed += 1
else:
print("❌ 场景3: 有序列表前没有空行 - 渲染失败")
# Test 4: Ordered list with blank line
if '1. 第一项' in pdf_text and '2. 第二项' in pdf_text:
print("✅ 场景4: 有序列表前有空行 - 正确渲染")
tests_passed += 1
else:
print("❌ 场景4: 有序列表前有空行 - 渲染失败")
print(f"\n=== 测试结果: {tests_passed}/{tests_total} 通过 ===")
if tests_passed == tests_total:
print("\n✅ 所有测试通过!")
print(f"\n生成的文件:")
print(f" Markdown: {md_path}")
print(f" PDF: {pdf_path}")
print(f" Text: {txt_path}")
return True
else:
print(f"\n❌ {tests_total - tests_passed} 个测试失败")
return False
except Exception as e:
print(f"❌ 测试失败: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == '__main__':
success = run_test()
sys.exit(0 if success else 1)
#!/usr/bin/env python3
"""
Integration test for visual self-check helper.
Verifies the contract:
1. Default PDF run auto-generates per-page PNG previews under the system
temp dir (keyed by PDF stem) — NOT next to the PDF
2. Default run prints a self-check checklist to stdout (so AI/author
is automatically reminded to visually inspect the rendering)
3. --no-preview disables both PNG generation and checklist
4. Stale PNGs from a previous longer run are cleaned on rerun
This test enforces "Visual Verification" rule from CLAUDE.md: PDF generation
silently succeeding does NOT mean the rendering matches the markdown intent.
The checklist makes "Read each page PNG and verify" the default contract,
not an optional step that's easy to skip.
Previews are written under the system temp dir so they never linger in the
user's working tree / git repo. This test points $TMPDIR at its own isolated
TemporaryDirectory, so the previews land there and are cleaned up with it.
"""
import os
import subprocess
import sys
import tempfile
from pathlib import Path
SAMPLE_MD = """# Test Document
A short test for self-check preview generation.
## Section A
- Item 1
- Item 2
- Item 3
## Section B
正文段落(中文测试 CJK rendering)。
"""
def run_md_to_pdf(
args: list[str], scripts_dir: Path, tmpdir: Path
) -> subprocess.CompletedProcess:
"""Run md_to_pdf.py with the given CLI args.
scripts_dir: path to the pdf-creator/scripts/ directory. The script
lives at scripts_dir/md_to_pdf.py; we run the subprocess
with cwd=scripts_dir.parent (the pdf-creator/ root) so
the script's relative themes/ lookup resolves correctly.
tmpdir: pointed at via $TMPDIR so the script's preview dir
(tempfile.gettempdir()/pdf-creator-previews/<stem>) lands
inside the test's isolated tmp and is auto-cleaned.
"""
script = scripts_dir / "md_to_pdf.py"
return subprocess.run(
["uv", "run", "--with", "weasyprint", str(script)] + args,
capture_output=True,
text=True,
cwd=scripts_dir.parent,
env={**os.environ, "TMPDIR": str(tmpdir)},
)
def main() -> int:
passed = 0
total = 0
script_dir = Path(__file__).parent.parent
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
md_path = tmp / "test.md"
md_path.write_text(SAMPLE_MD, encoding="utf-8")
# Previews land here: the script uses tempfile.gettempdir(), which honors
# the $TMPDIR we pass into the subprocess (pointed at this isolated tmp).
preview_root = tmp / "pdf-creator-previews"
# ---- Test 1: Default run prints self-check checklist ----
total += 1
pdf_path = tmp / "test.pdf"
result = run_md_to_pdf([str(md_path), str(pdf_path)], script_dir, tmp)
if result.returncode != 0:
print(f"❌ PDF generation failed: {result.stderr}")
return 1
checklist_markers = [
"Visual self-check required",
"Paragraphs render as separate blocks",
"Tables fit within page margins",
"Emoji + CJK glyphs render",
]
missing = [m for m in checklist_markers if m not in result.stdout]
if not missing:
print("✅ Default run prints self-check checklist with all key items")
passed += 1
else:
print(f"❌ Checklist missing items: {missing}")
print(f" stdout: {result.stdout[:500]}")
# ---- Test 2: Previews in temp dir, and NOT leaked next to the PDF ----
total += 1
preview_dir = preview_root / "test"
leaked = tmp / "test-preview" # the old (buggy) location next to the PDF
if preview_dir.exists() and not leaked.exists():
pages = sorted(preview_dir.glob("page-*.png"))
if pages and all(p.stat().st_size > 0 for p in pages):
print(
f"✅ {len(pages)} non-empty preview PNGs in temp; "
"none leaked next to the PDF"
)
passed += 1
else:
print(f"❌ Preview dir exists but PNGs missing/empty: {pages}")
elif leaked.exists():
print(f"❌ Preview leaked next to the PDF (regression): {leaked}")
else:
print(f"❌ Preview dir not created in temp: {preview_dir}")
# ---- Test 3: --no-preview disables both PNG + checklist ----
total += 1
pdf_path2 = tmp / "test_disabled.pdf"
preview_dir2 = preview_root / "test_disabled"
result = run_md_to_pdf(
[str(md_path), str(pdf_path2), "--no-preview"], script_dir, tmp
)
no_checklist = "Visual self-check" not in result.stdout
no_preview_dir = not preview_dir2.exists()
if no_checklist and no_preview_dir:
print("✅ --no-preview correctly disables PNG + checklist")
passed += 1
else:
print(
f"❌ --no-preview failed: checklist_absent={no_checklist}, "
f"dir_absent={no_preview_dir}"
)
# ---- Test 4: Stale PNGs cleaned on rerun ----
total += 1
# Plant a stale page-99.png (simulating old preview from a longer doc)
preview_dir.mkdir(parents=True, exist_ok=True)
stale = preview_dir / "page-99.png"
stale.write_bytes(b"stale")
result = run_md_to_pdf([str(md_path), str(pdf_path)], script_dir, tmp)
if not stale.exists():
print("✅ Stale preview PNGs cleaned on rerun")
passed += 1
else:
print("❌ Stale page-99.png not cleaned on rerun")
print(f"\n=== {passed}/{total} tests passed ===")
return 0 if passed == total else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
Unit tests for _lint_pdf_typography pattern detection.
The lint function is the Layer 2 safety net for CJK table rendering: when
Layer 1 (equal-width + keep-all + overflow-wrap:normal) can't perfectly
handle a slightly-too-long cell, Layer 2 should detect the resulting
anti-pattern and surface it to stderr so the author can shorten content
or restructure.
These tests exercise the four detection patterns by mocking the subprocess
calls to pdfinfo and pdftotext, so the test doesn't depend on a specific
weasyprint version's exact line-wrapping behavior. Each pattern is
verified in isolation, plus one negative control (clean text → no
findings).
Per "中文文案排版指北" the patterns are:
1. single-cjk-char — single CJK char alone on a line
2. broken-bracket-open — line ends with 全角左括号「(」, content wraps
3. broken-bracket-close — line starts with 全角右括号「)」, split from open
4. trailing-punctuation-break — short line ends with 、,;: before more CJK
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
SCRIPT_DIR = Path(__file__).parent.parent
sys.path.insert(0, str(SCRIPT_DIR))
import md_to_pdf # noqa: E402
def _mock_one_page_pdf(page_text: str) -> list[MagicMock]:
"""Build side_effect list simulating: pdfinfo → "Pages: 1", then
pdftotext returning the given text for page 1.
The lint function calls pdfinfo once + pdftotext once per page, so
for a 1-page PDF we need exactly 2 mock returns in order.
"""
fake_pdfinfo = MagicMock()
fake_pdfinfo.returncode = 0
fake_pdfinfo.stdout = "Pages: 1\nCreator: test\n"
fake_pdftotext = MagicMock()
fake_pdftotext.returncode = 0
fake_pdftotext.stdout = page_text
return [fake_pdfinfo, fake_pdftotext]
def _run_lint(page_text: str) -> list[dict]:
"""Invoke _lint_pdf_typography against a synthetic 1-page PDF whose
pdftotext -layout output is `page_text`. Returns the findings list.
"""
with patch.object(md_to_pdf.shutil, "which", return_value="/usr/local/bin/pdftotext"), \
patch.object(md_to_pdf.subprocess, "run", side_effect=_mock_one_page_pdf(page_text)):
return md_to_pdf._lint_pdf_typography("/fake/path/test.pdf")
# ---------------- Pattern 1: single CJK character ----------------
def test_pattern1_detects_single_cjk_char_alone() -> None:
"""A single CJK character on its own line indicates a forced mid-token
break — the most common anti-pattern in narrow cells.
"""
text = "前面是正常长度的中文行\n字\n下一行也是正常长度的内容"
findings = _run_lint(text)
kinds = {f["kind"] for f in findings}
assert "single-cjk-char" in kinds, (
f"Pattern 1 not detected. Got kinds={kinds}"
)
def test_pattern1_finding_captures_correct_char() -> None:
"""The finding should report the offending character in its snippet."""
text = "正常内容\n你\n继续内容"
findings = _run_lint(text)
single = [f for f in findings if f["kind"] == "single-cjk-char"]
assert single, "Expected one single-cjk-char finding"
assert single[0]["snippet"] == "你"
# ---------------- Pattern 2: broken bracket open ----------------
def test_pattern2_detects_line_ends_with_open_bracket() -> None:
"""Line ending with 全角左括号「(」 followed by content on the next
line indicates the bracket pair got broken across cell wrap.
"""
text = "前面是一段说明(\n续行有补充内容\n"
findings = _run_lint(text)
kinds = {f["kind"] for f in findings}
assert "broken-bracket-open" in kinds, (
f"Pattern 2 not detected. Got kinds={kinds}"
)
# ---------------- Pattern 3: broken bracket close ----------------
def test_pattern3_detects_line_starts_with_close_bracket() -> None:
"""Line starting with 全角右括号「)」 — the receiving end of a broken
bracket pair.
"""
text = "上一行有内容到此结束\n)后续解释跟着括号\n"
findings = _run_lint(text)
kinds = {f["kind"] for f in findings}
assert "broken-bracket-close" in kinds, (
f"Pattern 3 not detected. Got kinds={kinds}"
)
# ---------------- Pattern 4: trailing mid-thought punctuation ----------------
def test_pattern4_detects_short_line_ending_with_dunhao() -> None:
"""A short line (<30 chars) ending with 、 followed by a CJK line
suggests forced break in a narrow cell.
"""
text = "短句结尾有顿号、\n续行有中文内容继续\n"
findings = _run_lint(text)
kinds = {f["kind"] for f in findings}
assert "trailing-punctuation-break" in kinds, (
f"Pattern 4 not detected. Got kinds={kinds}"
)
def test_pattern4_ignores_long_line_ending_with_dunhao() -> None:
"""A long line (>=30 chars) ending with 、 is allowed — likely a real
paragraph break, not a forced cell wrap. This protects against false
positives in body text.
"""
long_line = "这是一段足够长的中文内容,超过三十个字符的话不应当被识别为强制断行、"
text = f"{long_line}\n续行有中文内容\n"
findings = _run_lint(text)
trailing = [f for f in findings if f["kind"] == "trailing-punctuation-break"]
# The detector heuristically allows long lines (>=30 chars) to slip through
# as legitimate paragraph breaks. If this assertion ever changes, the
# heuristic in _lint_pdf_typography must be updated in sync.
assert not trailing, (
f"Expected no trailing-punctuation-break for long line, got: {trailing}"
)
# ---------------- Negative control ----------------
def test_clean_cjk_content_produces_no_findings() -> None:
"""Normal CJK content with complete bracket pairs and no forced breaks
should produce zero findings.
"""
text = (
"这是一段完整的中文内容,包含正常的标点符号。\n"
"括号也是完整的(这种括号没有问题)继续后续内容。\n"
"顿号、逗号,分号;都在长句子里出现没问题。\n"
)
findings = _run_lint(text)
assert findings == [], (
f"Expected no findings on clean content, got: "
f"{[(f['kind'], f['snippet']) for f in findings]}"
)
# ---------------- Runner ----------------
def run_all() -> int:
tests = [
("P1: detect single CJK char alone", test_pattern1_detects_single_cjk_char_alone),
("P1: finding snippet captures char", test_pattern1_finding_captures_correct_char),
("P2: detect line ends with 「(」", test_pattern2_detects_line_ends_with_open_bracket),
("P3: detect line starts with 「)」", test_pattern3_detects_line_starts_with_close_bracket),
("P4: detect short line trailing 「、」", test_pattern4_detects_short_line_ending_with_dunhao),
("P4: ignore long line trailing 「、」", test_pattern4_ignores_long_line_ending_with_dunhao),
("Negative: clean content → no findings", test_clean_cjk_content_produces_no_findings),
]
passed = failed = 0
for name, fn in tests:
try:
fn()
print(f"✅ {name}")
passed += 1
except AssertionError as e:
print(f"❌ {name}: {e}")
failed += 1
except Exception as e: # noqa: BLE001
print(f"❌ {name}: ERROR {type(e).__name__}: {e}")
failed += 1
print(f"\n=== {passed}/{passed + failed} passed ===")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(run_all())
/*
* Default — PDF theme for formal documents
*
* Color palette: black/grey, no accent color
* Font: Songti SC (body) + Heiti SC (headings)
* Best for: legal documents, trademark filings, contracts, formal reports
*
* This is the original built-in theme from md_to_pdf.py, extracted for reference.
*/
/* Restrict 'Menlo' to Latin/ASCII range so CJK characters in inline code
* don't get marked as Menlo (which has no CJK glyphs). Without this, the
* generated PDF references Menlo for CJK chars, and strict PDF readers
* (macOS Preview, some print drivers) show blanks instead of falling back.
* Chrome falls back automatically; Preview does not. The unicode-range
* trick forces weasyprint to skip Menlo for CJK and use the next font in
* the chain (PingFang SC → Heiti SC → Songti SC) which has CJK glyphs. */
@font-face {
font-family: 'Menlo';
src: local('Menlo');
unicode-range: U+0020-007F, U+00A0-00FF, U+2000-206F, U+2070-209F, U+20A0-20CF, U+2100-214F;
}
@font-face {
font-family: 'Menlo';
src: local('Menlo Bold');
font-weight: bold;
unicode-range: U+0020-007F, U+00A0-00FF, U+2000-206F, U+2070-209F, U+20A0-20CF, U+2100-214F;
}
@page {
size: A4;
margin: 2.5cm 2cm 2cm 2cm;
@bottom-center {
content: counter(page) " / " counter(pages);
font-family: 'Songti SC', 'SimSun', 'STSong', 'Noto Serif CJK SC', serif;
font-size: 9pt;
color: #666;
}
}
body {
font-family: 'Songti SC', 'SimSun', 'STSong', 'Noto Serif CJK SC', serif;
font-size: 11.5pt;
line-height: 1.6;
color: #000;
width: 100%;
}
h1 {
font-family: 'Heiti SC', 'SimHei', 'STHeiti', 'Noto Sans CJK SC', sans-serif;
font-size: 17pt;
font-weight: bold;
text-align: center;
margin-top: 0;
margin-bottom: 1.5em;
}
/* Heading scale: each level visibly larger than body (11.5pt) AND visually
* distinct from adjacent levels. Font fallback chain widened to include
* PingFang SC and system-ui in case 'Heiti SC' is not registered with
* fontconfig (common on weasyprint installs).
*
* Size unity rule: all body-adjacent text (inline code, table, pre)
* stays within 0.5-1pt of body so nothing reads as "noticeably smaller".
*/
h2 {
font-family: 'Heiti SC', 'PingFang SC', 'SimHei', 'STHeiti', 'Noto Sans CJK SC', system-ui, sans-serif;
font-size: 15pt;
font-weight: bold;
margin-top: 1.5em;
margin-bottom: 0.8em;
color: #000;
}
h3 {
font-family: 'Heiti SC', 'PingFang SC', 'SimHei', 'STHeiti', 'Noto Sans CJK SC', system-ui, sans-serif;
font-size: 13pt;
font-weight: bold;
margin-top: 1.3em;
margin-bottom: 0.5em;
color: #000;
}
h4 {
font-family: 'Heiti SC', 'PingFang SC', 'SimHei', 'STHeiti', 'Noto Sans CJK SC', system-ui, sans-serif;
font-size: 12.5pt;
font-weight: bold;
margin-top: 1.1em;
margin-bottom: 0.4em;
color: #1a1a1a;
}
/* h5: still distinct from body. Combine size bump (+0.5pt) with left border
* so the heading visually jumps out even when the size delta is subtle.
*/
h5 {
font-family: 'Heiti SC', 'PingFang SC', 'SimHei', 'STHeiti', 'Noto Sans CJK SC', system-ui, sans-serif;
font-size: 12pt;
font-weight: bold;
margin-top: 1em;
margin-bottom: 0.35em;
padding-left: 0.5em;
border-left: 3px solid #888;
color: #1a1a1a;
}
h6 {
font-family: 'Heiti SC', 'PingFang SC', 'SimHei', 'STHeiti', 'Noto Sans CJK SC', system-ui, sans-serif;
font-size: 11.5pt;
font-weight: bold;
margin-top: 0.8em;
margin-bottom: 0.25em;
color: #444;
font-style: italic;
}
p {
margin: 0.8em 0;
text-align: justify;
}
ul, ol {
margin: 0.8em 0;
padding-left: 2em;
}
li {
margin: 0.4em 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 11pt;
table-layout: fixed;
}
/* Keep table rows intact when paginating: prevents a single <tr> from being
* split across page boundaries (cell content cut mid-line, no header repeat).
* Trade-off: short rows that don't fit at page bottom get pushed to next page,
* leaving some white space at the bottom — readability > compactness. */
tr {
page-break-inside: avoid;
break-inside: avoid;
}
/* Repeat <thead> on each page when a table spans multiple pages. */
thead {
display: table-header-group;
}
th, td {
border: 1px solid #666;
padding: 8px 6px;
text-align: left;
overflow-wrap: break-word;
word-break: normal;
}
th {
background-color: #f0f0f0;
font-weight: bold;
}
/* Neutralize pandoc's auto-emitted <col style="width:X%"> based on dash counts
* in the markdown separator row. Pandoc treats `| ----- | --- |` as a column-
* width hint and inlines `style="width: 17%"` etc on each <col>. Inline styles
* beat external stylesheets at equal specificity, so without `!important` no
* `td:first-child { width: ... }` rule can recover. With this neutralizer
* weasyprint falls back to `table-layout: fixed` equal width allocation,
* which for typical 4-col tables gives 25% per column — enough for short
* CJK labels like `4/28(周二)下午` to render on one line.
*
* Authors who really want explicit widths can still write raw HTML
* `<colgroup>` directly in markdown — that overrides this rule when needed. */
table colgroup col {
width: auto !important;
}
hr {
border: none;
border-top: 1px solid #ccc;
margin: 1.5em 0;
}
code {
font-family: 'Menlo', 'Songti SC', 'Heiti SC', 'SimSun', 'PingFang SC', 'Noto Sans CJK SC', monospace;
background: #f5f5f5;
padding: 1px 4px;
border-radius: 3px;
font-size: 11pt;
}
pre {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 12px 16px;
margin: 1em 0;
overflow-wrap: break-word;
white-space: pre-wrap;
word-break: break-all;
}
pre code {
font-family: 'Menlo', 'Songti SC', 'Heiti SC', 'SimSun', 'PingFang SC', 'Noto Sans CJK SC', monospace;
background: none;
padding: 0;
border-radius: 0;
font-size: 10.5pt;
line-height: 1.5;
}
/* CJK code blocks converted to styled divs by preprocessor.
Uses inherit to reuse body's CJK font (weasyprint may not find PingFang SC). */
.cjk-code-block {
font-family: inherit;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 12px 16px;
margin: 1em 0;
font-size: 11pt;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
/* ===== cjk-auto override: content-driven column widths =====
*
* Default theme uses table-layout: fixed for equal-width columns (safer when
* cells have similar content). For multi-column tables with very different
* content lengths (e.g. # / 场次 / 日期 / 金额 where # is 1 char and 场次 is
* 15+ chars), fixed equal-width forces narrow column for long content and
* triggers CJK mid-bracket breaks.
*
* Override: table-layout: auto + every cell nowrap → weasyprint computes
* each column's min-content width from actual cell content, allocates
* proportionally. !important needed because md_to_pdf.py auto-injects a
* fixed-layout patch AFTER theme load.
*/
table {
table-layout: auto !important;
width: 100% !important;
}
table td, table th {
white-space: nowrap !important;
word-break: keep-all !important;
overflow-wrap: normal !important;
}
/*
* Default — PDF theme for formal documents
*
* Color palette: black/grey, no accent color
* Font: Songti SC (body) + Heiti SC (headings)
* Best for: legal documents, trademark filings, contracts, formal reports
*
* This is the original built-in theme from md_to_pdf.py, extracted for reference.
*/
/* Restrict 'Menlo' to Latin/ASCII range so CJK characters in inline code
* don't get marked as Menlo (which has no CJK glyphs). Without this, the
* generated PDF references Menlo for CJK chars, and strict PDF readers
* (macOS Preview, some print drivers) show blanks instead of falling back.
* Chrome falls back automatically; Preview does not. The unicode-range
* trick forces weasyprint to skip Menlo for CJK and use the next font in
* the chain (PingFang SC → Heiti SC → Songti SC) which has CJK glyphs. */
@font-face {
font-family: 'Menlo';
src: local('Menlo');
unicode-range: U+0020-007F, U+00A0-00FF, U+2000-206F, U+2070-209F, U+20A0-20CF, U+2100-214F;
}
@font-face {
font-family: 'Menlo';
src: local('Menlo Bold');
font-weight: bold;
unicode-range: U+0020-007F, U+00A0-00FF, U+2000-206F, U+2070-209F, U+20A0-20CF, U+2100-214F;
}
@page {
size: A4;
margin: 2.5cm 2cm 2cm 2cm;
@bottom-center {
content: counter(page) " / " counter(pages);
font-family: 'Songti SC', 'SimSun', 'STSong', 'Noto Serif CJK SC', serif;
font-size: 9pt;
color: #666;
}
}
body {
font-family: 'Songti SC', 'SimSun', 'STSong', 'Noto Serif CJK SC', serif;
font-size: 11.5pt;
line-height: 1.6;
color: #000;
width: 100%;
}
h1 {
font-family: 'Heiti SC', 'SimHei', 'STHeiti', 'Noto Sans CJK SC', sans-serif;
font-size: 17pt;
font-weight: bold;
text-align: center;
margin-top: 0;
margin-bottom: 1.5em;
}
/* Heading scale: each level visibly larger than body (11.5pt) AND visually
* distinct from adjacent levels. Font fallback chain widened to include
* PingFang SC and system-ui in case 'Heiti SC' is not registered with
* fontconfig (common on weasyprint installs).
*
* Size unity rule: all body-adjacent text (inline code, table, pre)
* stays within 0.5-1pt of body so nothing reads as "noticeably smaller".
*/
h2 {
font-family: 'Heiti SC', 'PingFang SC', 'SimHei', 'STHeiti', 'Noto Sans CJK SC', system-ui, sans-serif;
font-size: 15pt;
font-weight: bold;
margin-top: 1.5em;
margin-bottom: 0.8em;
color: #000;
}
h3 {
font-family: 'Heiti SC', 'PingFang SC', 'SimHei', 'STHeiti', 'Noto Sans CJK SC', system-ui, sans-serif;
font-size: 13pt;
font-weight: bold;
margin-top: 1.3em;
margin-bottom: 0.5em;
color: #000;
}
h4 {
font-family: 'Heiti SC', 'PingFang SC', 'SimHei', 'STHeiti', 'Noto Sans CJK SC', system-ui, sans-serif;
font-size: 12.5pt;
font-weight: bold;
margin-top: 1.1em;
margin-bottom: 0.4em;
color: #1a1a1a;
}
/* h5: still distinct from body. Combine size bump (+0.5pt) with left border
* so the heading visually jumps out even when the size delta is subtle.
*/
h5 {
font-family: 'Heiti SC', 'PingFang SC', 'SimHei', 'STHeiti', 'Noto Sans CJK SC', system-ui, sans-serif;
font-size: 12pt;
font-weight: bold;
margin-top: 1em;
margin-bottom: 0.35em;
padding-left: 0.5em;
border-left: 3px solid #888;
color: #1a1a1a;
}
h6 {
font-family: 'Heiti SC', 'PingFang SC', 'SimHei', 'STHeiti', 'Noto Sans CJK SC', system-ui, sans-serif;
font-size: 11.5pt;
font-weight: bold;
margin-top: 0.8em;
margin-bottom: 0.25em;
color: #444;
font-style: italic;
}
p {
margin: 0.8em 0;
text-align: justify;
}
ul, ol {
margin: 0.8em 0;
padding-left: 2em;
}
li {
margin: 0.4em 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 11pt;
table-layout: fixed;
}
/* Keep table rows intact when paginating: prevents a single <tr> from being
* split across page boundaries (cell content cut mid-line, no header repeat).
* Trade-off: short rows that don't fit at page bottom get pushed to next page,
* leaving some white space at the bottom — readability > compactness. */
tr {
page-break-inside: avoid;
break-inside: avoid;
}
/* Repeat <thead> on each page when a table spans multiple pages. */
thead {
display: table-header-group;
}
th, td {
border: 1px solid #666;
padding: 8px 6px;
text-align: left;
overflow-wrap: break-word;
word-break: normal;
}
th {
background-color: #f0f0f0;
font-weight: bold;
}
/* Neutralize pandoc's auto-emitted <col style="width:X%"> based on dash counts
* in the markdown separator row. Pandoc treats `| ----- | --- |` as a column-
* width hint and inlines `style="width: 17%"` etc on each <col>. Inline styles
* beat external stylesheets at equal specificity, so without `!important` no
* `td:first-child { width: ... }` rule can recover. With this neutralizer
* weasyprint falls back to `table-layout: fixed` equal width allocation,
* which for typical 4-col tables gives 25% per column — enough for short
* CJK labels like `4/28(周二)下午` to render on one line.
*
* Authors who really want explicit widths can still write raw HTML
* `<colgroup>` directly in markdown — that overrides this rule when needed. */
table colgroup col {
width: auto !important;
}
hr {
border: none;
border-top: 1px solid #ccc;
margin: 1.5em 0;
}
code {
font-family: 'Menlo', 'Songti SC', 'Heiti SC', 'SimSun', 'PingFang SC', 'Noto Sans CJK SC', monospace;
background: #f5f5f5;
padding: 1px 4px;
border-radius: 3px;
font-size: 11pt;
}
pre {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 12px 16px;
margin: 1em 0;
overflow-wrap: break-word;
white-space: pre-wrap;
word-break: break-all;
}
pre code {
font-family: 'Menlo', 'Songti SC', 'Heiti SC', 'SimSun', 'PingFang SC', 'Noto Sans CJK SC', monospace;
background: none;
padding: 0;
border-radius: 0;
font-size: 10.5pt;
line-height: 1.5;
}
/* CJK code blocks converted to styled divs by preprocessor.
Uses inherit to reuse body's CJK font (weasyprint may not find PingFang SC). */
.cjk-code-block {
font-family: inherit;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 12px 16px;
margin: 1em 0;
font-size: 11pt;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
/*
* Mobile — PDF theme for phone reading
*
* Narrow page (A5-ish), larger fonts, generous line-height.
* Best for: mobile reading, WeChat sharing, on-the-go reference
*/
@page {
size: 148mm 210mm;
margin: 10mm;
}
body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif;
max-width: 100%;
margin: 0 auto;
padding: 0;
font-size: 15px;
line-height: 1.9;
color: #1f1b17;
}
h1 {
font-size: 26px;
font-weight: 800;
border-bottom: 2px solid #d97756;
padding-bottom: 10px;
margin-top: 0;
margin-bottom: 1em;
line-height: 1.3;
}
h2 {
font-size: 20px;
font-weight: 700;
color: #d97756;
margin-top: 28px;
margin-bottom: 0.7em;
line-height: 1.3;
}
h3 {
font-size: 17px;
font-weight: 700;
margin-top: 22px;
margin-bottom: 0.6em;
line-height: 1.3;
}
p {
margin: 0.8em 0;
}
ul, ol {
padding-left: 24px;
margin: 0.8em 0;
}
li {
margin-bottom: 6px;
word-break: break-word;
}
table {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
font-size: 13px;
}
th, td {
border: 1px solid #e2d6c8;
padding: 6px 8px;
text-align: left;
white-space: normal;
word-break: break-word;
}
th {
background: #faf5f0;
font-weight: 700;
}
blockquote {
border-left: 3px solid #d97756;
padding-left: 14px;
color: #6c6158;
margin: 14px 0;
font-size: 15px;
line-height: 1.8;
}
hr {
border: none;
border-top: 1px solid #e2d6c8;
margin: 20px 0;
}
header, .date {
display: none !important;
}
code {
font-family: 'Menlo', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', monospace;
background: #faf5f0;
padding: 2px 5px;
border-radius: 3px;
font-size: 13px;
}
pre {
background: #faf5f0;
border: 1px solid #e2d6c8;
border-radius: 4px;
padding: 14px 16px;
margin: 12px 0;
overflow-wrap: break-word;
white-space: pre-wrap;
word-break: break-all;
}
pre code {
font-family: 'Menlo', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', monospace;
background: none;
padding: 0;
border-radius: 0;
font-size: 12px;
line-height: 1.7;
}
.cjk-code-block {
font-family: inherit;
background: #faf5f0;
border: 1px solid #e2d6c8;
border-radius: 4px;
padding: 14px 16px;
margin: 12px 0;
font-size: 13px;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-all;
}
strong {
color: #1f1b17;
}
/*
* Warm Terra Menu — warm-terra 视觉 + 双列长文本表格适配
*
* 基于 warm-terra.css(terra cotta #d97756 + PingFang + 浅米表头),仅改两处:
* 1. 表格:首列改换行(warm-terra 原版 th/td nowrap + 仅末列 wrap,导致长标题
* 首列溢出盖住次列)→ 全列 white-space:normal + 固定 44/56 两列宽 + 顶对齐
* 2. inline code 字体链:Songti SC (CID TrueType) 先于 PingFang (CID Type0C OT),
* 防 macOS 预览 / Adobe Reader 把 CJK inline code 显示成空白(warm-terra 原版
* 用 Menlo→PingFang,无 unicode-range 约束,发客户端有乱码风险)
*
* Best for: 模块菜单 / 首列是长问题式标题的双列清单。
*/
/* Menlo 限 Latin,CJK inline code fallback 到 CID-TrueType Songti SC */
@font-face {
font-family: 'Menlo';
src: local('Menlo');
unicode-range: U+0020-007F, U+00A0-00FF, U+2000-206F, U+2070-209F, U+20A0-20CF, U+2100-214F;
}
@page {
size: A4;
margin: 14mm 13mm 14mm;
@bottom-center {
content: counter(page) " / " counter(pages);
font-family: 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif;
font-size: 8.5pt;
color: #8a7460;
}
}
* { box-sizing: border-box; }
body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif;
max-width: 100%;
margin: 0;
font-size: 10.5pt;
line-height: 1.65;
color: #1f1b17;
}
h1 {
font-size: 21pt;
font-weight: 800;
color: #1f1b17;
border-bottom: 2px solid #d97756;
padding-bottom: 8px;
margin: 0 0 0.8em;
}
h2 {
font-size: 15pt;
font-weight: 700;
color: #d97756;
margin: 22px 0 0.3em;
break-after: avoid;
}
h2 + p { color: #6c6158; font-size: 9.5pt; margin: 3px 0 12px; line-height: 1.55; break-after: avoid; }
h3 { font-size: 13pt; font-weight: 700; color: #1f1b17; margin: 14px 0 0.4em; break-after: avoid; }
p { margin: 0.6em 0; }
blockquote {
border-left: 3px solid #d97756;
background: #faf5f0;
padding: 6px 0 6px 13px;
color: #6c6158;
margin: 12px 0 6px;
font-size: 9.4pt;
line-height: 1.7;
}
blockquote strong { color: #1f1b17; }
/* ===== 表格:warm-terra 浅米表头 + 双列长文本换行(修 nowrap 溢出)===== */
table {
border-collapse: collapse;
width: 100% !important;
margin: 8px 0 6px;
font-size: 9.8pt;
table-layout: fixed !important;
}
table colgroup col { width: auto !important; }
tr { break-inside: avoid; page-break-inside: avoid; }
thead { display: table-header-group; }
th {
background: #faf5f0;
color: #1f1b17;
border: 1px solid #e2d6c8;
padding: 7px 9px;
text-align: left;
font-weight: 700;
font-size: 9.5pt;
}
/* 关键修复:全列换行(去掉 warm-terra 的 nowrap),CJK 不切字,顶对齐 */
td {
border: 1px solid #e2d6c8;
padding: 8px 9px;
text-align: left;
white-space: normal !important;
word-break: keep-all !important;
overflow-wrap: break-word !important;
vertical-align: top;
line-height: 1.55;
}
/* 双列宽:模块问题 44% / 一句话 56% */
th:nth-child(1), td:nth-child(1) { width: 44%; }
th:nth-child(2), td:nth-child(2) { width: 56%; }
td:nth-child(1) { font-weight: 700; }
/* inline code = metadata 小标签;字体链 Songti CID 先防预览乱码;本身短,nowrap 不溢出 */
code {
font-family: 'Menlo', 'Songti SC', 'Heiti SC', 'SimSun', 'PingFang SC', monospace;
background: #faf5f0;
color: #8a7460;
padding: 1.5px 7px;
border-radius: 4px;
border: 1px solid #ecdfd2;
font-size: 8.5pt;
white-space: nowrap;
}
strong { color: #1f1b17; }
hr { border: none; border-top: 1px solid #e2d6c8; margin: 18px 0 4px; }
ul, ol { padding-left: 1.8em; margin: 0.6em 0; }
li { margin-bottom: 3px; word-break: break-word; }
header, .date { display: none !important; }
/*
* Warm Terra — PDF theme for workshop/training documents
*
* Color palette: terra cotta (#d97756) + warm neutrals
* Font: PingFang SC (macOS) / Microsoft YaHei (Windows)
* Best for: course outlines, training materials, workshop agendas
*
* Usage with md_to_pdf.py:
* python md_to_pdf.py input.md output.pdf --theme warm-terra
*
* Usage with pandoc + Chrome (fallback):
* pandoc input.md -o /tmp/out.html --standalone -H <(cat this-file.css wrapped in <style>)
* chrome --headless --no-pdf-header-footer --print-to-pdf=out.pdf /tmp/out.html
*/
@page {
size: A4;
margin: 12mm 12mm 16mm 12mm;
@bottom-center {
content: counter(page) " / " counter(pages);
font-family: 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif;
font-size: 9pt;
color: #8a7460;
}
}
body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif;
max-width: 100%;
margin: 0 auto;
padding: 0 10px;
font-size: 13px;
line-height: 1.7;
color: #1f1b17;
}
h1 {
font-size: 22px;
font-weight: 800;
border-bottom: 2px solid #d97756;
padding-bottom: 8px;
margin-top: 0;
margin-bottom: 1em;
}
h2 {
font-size: 17px;
font-weight: 700;
color: #d97756;
margin-top: 24px;
margin-bottom: 0.6em;
}
h3 {
font-size: 14px;
font-weight: 700;
margin-top: 18px;
margin-bottom: 0.5em;
}
/* h4/h5/h6 must remain visually distinct from body text — never smaller than
* body. Use bold + slight color shift instead of size shrinking. */
h4 {
font-size: 13.5px;
font-weight: 700;
margin-top: 14px;
margin-bottom: 0.4em;
color: #1f1b17;
}
h5 {
font-size: 13px;
font-weight: 700;
margin-top: 12px;
margin-bottom: 0.3em;
color: #2a2521;
}
h6 {
font-size: 13px;
font-weight: 700;
margin-top: 10px;
margin-bottom: 0.2em;
color: #4a3f33;
font-style: italic;
}
p {
margin: 0.6em 0;
}
ul, ol {
padding-left: 20px;
margin: 0.6em 0;
}
li {
margin-bottom: 3px;
word-break: break-word;
}
table {
border-collapse: collapse;
width: 100%;
margin: 10px 0;
font-size: 12px;
}
th, td {
border: 1px solid #e2d6c8;
padding: 5px 8px;
text-align: left;
white-space: nowrap;
}
/* Last column wraps (usually the description/content column) */
td:last-child {
white-space: normal;
}
th {
background: #faf5f0;
font-weight: 700;
}
blockquote {
border-left: 3px solid #d97756;
padding-left: 12px;
color: #6c6158;
margin: 10px 0;
font-size: 13px;
}
hr {
border: none;
border-top: 1px solid #e2d6c8;
margin: 16px 0;
}
/* Hide pandoc-generated header/date */
header, .date {
display: none !important;
}
code {
font-family: 'Menlo', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', monospace;
background: #faf5f0;
padding: 1px 4px;
border-radius: 3px;
font-size: 12px;
}
pre {
background: #faf5f0;
border: 1px solid #e2d6c8;
border-radius: 4px;
padding: 12px 16px;
margin: 10px 0;
overflow-wrap: break-word;
white-space: pre-wrap;
word-break: break-all;
}
pre code {
font-family: 'Menlo', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', monospace;
background: none;
padding: 0;
border-radius: 0;
font-size: 11px;
line-height: 1.6;
}
/* CJK code blocks converted to styled divs by preprocessor.
Uses inherit to reuse body's CJK font (weasyprint may not resolve all font names). */
.cjk-code-block {
font-family: inherit;
background: #faf5f0;
border: 1px solid #e2d6c8;
border-radius: 4px;
padding: 12px 16px;
margin: 10px 0;
font-size: 12px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
}
strong {
color: #1f1b17;
}
Related skills
How it compares
Choose pdf-creator for quick agent-driven PDF exports from markdown; use dedicated PDF libraries when you need programmatic batch generation.
FAQ
What input does pdf-creator accept?
pdf-creator accepts markdown or structured content and uses Claude to produce a clean, professional PDF. Developers typically start from READMEs, specs, or reports already written in markdown.
How popular is pdf-creator on skills.sh?
pdf-creator from daymade/claude-code-skills shows 536 installs on skills.sh, indicating steady adoption among Claude Code users who need quick PDF exports from documentation.