
Brewdoc:Md To Pdf
- 19 installs
- 29 repo stars
- Updated August 2, 2026
- kochetkov-ma/claude-brewcode
Helps with ai & agent building tasks.
About
brewdoc:md-to-pdf is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- brewdoc:md-to-pdf
- AI & Agent Building
- AI-coding skill
Brewdoc:Md To Pdf by the numbers
- 19 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #10,587 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kochetkov-ma/claude-brewcode --skill brewdocmd-to-pdfAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 19 |
|---|---|
| repo stars | ★ 29 |
| Last updated | August 2, 2026 |
| Repository | kochetkov-ma/claude-brewcode ↗ |
What it does
Helps with ai & agent building tasks.
Files
MD to PDF
Converts Markdown files to professional PDF using one of two rendering engines.
Step 0: Parse Arguments
Parse $ARGUMENTS to determine mode and components.
| Component | Required | Description |
|---|---|---|
md_file | per mode | Path to .md file |
--engine | No | reportlab or weasyprint (overrides saved config) |
custom_prompt | No | Last argument in double quotes = LLM preprocessing instructions |
Mode detection rules:
| Condition | Mode |
|---|---|
Empty or help | HELP |
styles or config | STYLES |
test | TEST |
Path to .md file + quoted string at end | CONVERT+PROMPT |
Path to .md file (no quoted string) | CONVERT |
Extract --engine <name> from anywhere in arguments if present. Remove it before further parsing.
Step 1: Dependency Check
Determine the target engine (from --engine flag, saved config, or default reportlab).
EXECUTE using Bash tool:
bash "${CLAUDE_SKILL_DIR}/scripts/check_deps.sh" check ENGINE_NAME 2>&1; echo "EXIT_CODE=$?"Replace ENGINE_NAME with the target engine.
If output contains `MISSING_PIP` or `MISSING_SYSTEM`:
Use AskUserQuestion presenting the engine comparison table:
| Feature | reportlab | weasyprint |
|---|---|---|
| Install | pip only | pip + brew |
| Quality | Good | Excellent |
| Speed | Fast | Moderate |
| Images | Basic | Full |
| CSS Styling | No | Yes |
| Code highlight | No | Yes (Pygments) |
Options:
- "Install ENGINE_NAME dependencies"
- "Switch to OTHER_ENGINE" (if the other engine is available)
- "Cancel"
If user chooses install, EXECUTE using Bash tool:
bash "${CLAUDE_SKILL_DIR}/scripts/check_deps.sh" install ENGINE_NAME 2>&1 && echo "---INSTALL_OK---" || echo "---INSTALL_FAILED---"STOP if INSTALL_FAILED -- report error and exit.
If user cancels -- STOP.
Step 2: Engine Selection (first run only)
Check for saved config in order: 1. Project: .claude/md-to-pdf.config.json 2. Global: ~/.claude/md-to-pdf.config.json
If --engine flag was provided -- use it (skip config lookup).
If no saved preference and no --engine flag -- use AskUserQuestion with the engine comparison table from Step 1. Save the choice:
{
"engine": "reportlab",
"pygments_theme": "github"
}Write to project config .claude/md-to-pdf.config.json (create .claude/ dir if needed).
Step 3: Mode Execution
HELP Mode
Print formatted usage:
MD to PDF Converter
Usage:
/brewdoc:md-to-pdf <file.md> Convert with saved engine/style
/brewdoc:md-to-pdf <file.md> --engine weasyprint Convert with specific engine
/brewdoc:md-to-pdf <file.md> "remove section X" Preprocess MD then convert
/brewdoc:md-to-pdf styles Configure page/color/font
/brewdoc:md-to-pdf test Convert bundled test file
/brewdoc:md-to-pdf help Show this help
Engines:
reportlab -- Pure Python, fast, no system deps (pip install reportlab)
weasyprint -- HTML/CSS pipeline, best quality (pip + brew deps)EXIT after printing.
CONVERT Mode
1. Read the input MD file with Read tool. If not found -- STOP with error. 2. Determine output path: same directory, same name, .pdf extension. 3. Build the config path argument (if project or global config exists, add --config CONFIG_PATH).
EXECUTE using Bash tool:
python3 "${CLAUDE_SKILL_DIR}/scripts/md_to_pdf.py" "INPUT_PATH" "OUTPUT_PATH" --engine ENGINE --quiet 2>&1 && echo "---CONVERT_OK---" || echo "---CONVERT_FAILED---"Replace INPUT_PATH, OUTPUT_PATH, ENGINE with actual values. Add --config CONFIG_PATH if a style config JSON exists. Add --pygments-theme THEME for weasyprint if configured.
STOP if CONVERT_FAILED -- read error output, attempt fix, retry once. If still failing -- report error.
4. Parse structured output lines: STATUS, OUTPUT, PAGES, SIZE, ENGINE.
CONVERT+PROMPT Mode
1. Read the input MD file with Read tool. 2. Apply LLM transformations per the custom_prompt instructions (delete sections, rewrite headings, restructure, etc.). 3. Write modified content to temp file: {original_dir}/.tmp_{original_name}.md 4. Run the converter on the temp file (same command as CONVERT mode, using temp file as input, original name for output). 5. Delete the temp file.
EXECUTE using Bash tool:
rm -f "TEMP_FILE_PATH"6. Proceed to Step 4 with preprocessing: true.
STYLES Mode
Run interactive configuration via AskUserQuestion dialogs:
Question 1 -- Page size: Options: A4 (default), Letter, Legal
Question 2 -- Color scheme: Options: Default blue (primary #1a3a5c), Dark (primary #2d3748), Custom (ask for hex values)
Question 3 -- Code theme (weasyprint only): Options: github (default), monokai, friendly, solarized-dark, solarized-light
Question 4 -- Footer format: Options: Page {page} of {total} (default), {page}/{total}, Disabled
Build JSON config matching styles/default.json structure, overriding changed values. Write to .claude/md-to-pdf.config.json.
Report saved settings table and EXIT.
TEST Mode
1. Use bundled test file at ${CLAUDE_SKILL_DIR}/test/test-all-elements.md as INPUT_PATH. 2. Determine output path: /tmp/md-to-pdf-test-ENGINE.pdf 3. Run converter (same command as CONVERT mode, using test file as input, /tmp/ output). 4. Proceed to Step 4.
Step 4: Report Results
| Parameter | Value |
|---|---|
| Source | absolute path to input MD |
| Output | absolute path to output PDF |
| Pages | from PAGES= in script output |
| Size | from SIZE= in script output |
| Engine | reportlab or weasyprint |
| Preprocessing | custom_prompt summary (if used) or none |
MIT License
Copyright (c) 2025-2026 Maxim Kochetkov (kochetkov-ma)
https://github.com/kochetkov-ma/claude-brewcode
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
MD to PDF
Converts Markdown files to professional PDF documents. Supports two rendering engines: reportlab (lightweight, pure Python) and weasyprint (full HTML/CSS pipeline with syntax highlighting).
Quick Start
/brewdoc:md-to-pdf docs/report.mdConverts docs/report.md to docs/report.pdf using your saved engine preference (or prompts you to choose one on first run).
Modes
| Mode | Trigger | What it does |
|---|---|---|
| Convert | <file.md> | Converts the Markdown file to PDF |
| Convert+Prompt | <file.md> "instructions" | Applies LLM preprocessing to the Markdown content, then converts |
| Styles | styles | Interactive configuration for page size, colors, code theme, footer |
| Test | test | Converts a bundled test file to /tmp/ to verify the setup works |
| Help | no args or help | Prints usage reference |
Engines
| Feature | reportlab | weasyprint |
|---|---|---|
| Install | pip install reportlab | pip + brew system deps |
| Quality | Good | Excellent |
| Speed | Fast | Moderate |
| Images | Basic | Full |
| CSS Styling | No | Yes |
| Code Highlighting | No | Yes (Pygments) |
Default: reportlab. Override per-invocation with --engine, or save a preference via the first-run prompt (stored in .claude/md-to-pdf.config.json).
Examples
Good Usage
Simple conversion:
/brewdoc:md-to-pdf README.mdSpecify engine explicitly:
/brewdoc:md-to-pdf README.md --engine weasyprintPreprocess before converting -- remove a section, rewrite headings, restructure:
/brewdoc:md-to-pdf docs/api.md "Remove the Changelog section and make all headings one level smaller"Configure page layout and colors interactively:
/brewdoc:md-to-pdf stylesVerify installation with the bundled test document:
/brewdoc:md-to-pdf testCommon Mistakes
Non-existent file:
/brewdoc:md-to-pdf missing-file.mdThe skill reads the file first and stops with an error if it does not exist. Verify the path before invoking.
Using weasyprint features with reportlab:
/brewdoc:md-to-pdf doc.md --engine reportlabIf you need CSS styling, syntax highlighting, or full image support, use --engine weasyprint instead. Reportlab does not support these features.
Forgetting quotes around the preprocessing prompt:
/brewdoc:md-to-pdf doc.md remove the changelogThe preprocessing prompt must be the last argument wrapped in double quotes: "remove the changelog".
Output
The PDF is written to the same directory as the input file, with the same name and a .pdf extension. In test mode, output goes to /tmp/md-to-pdf-test-<engine>.pdf.
After conversion, a result report is printed:
| Field | Description |
|---|---|
| Source | Absolute path to the input Markdown file |
| Output | Absolute path to the generated PDF |
| Pages | Total page count |
| Size | File size of the PDF |
| Engine | Which engine was used (reportlab or weasyprint) |
| Preprocessing | Summary of the prompt applied, or none |
Tips
- Choosing an engine: Start with
reportlabfor speed and zero system dependencies. Switch toweasyprintwhen you need syntax-highlighted code blocks, CSS-based styling, or high-fidelity image rendering. - Style customization: Run
/brewdoc:md-to-pdf stylesto configure page size (A4, Letter, Legal), color scheme, code theme, and footer format. Settings are saved to.claude/md-to-pdf.config.jsonand reused automatically. - Preprocessing use cases: The
"prompt"argument is useful for preparing documents before PDF generation -- strip draft sections, translate headings, flatten structure, or redact sensitive content without modifying the original file. - Test before sharing: Run
/brewdoc:md-to-pdf testafter installing a new engine or changing styles to confirm everything renders correctly.
Documentation
Full docs: md-to-pdf
#!/usr/bin/env bash
set -euo pipefail
# check_deps.sh — Dependency checker for md-to-pdf skill
# Usage: check_deps.sh <command> [engine]
#
# Commands:
# check <engine> Check if engine dependencies are satisfied
# install <engine> Install missing dependencies for engine
# status Show dependency status for all engines
#
# Engines: reportlab, weasyprint
#
# Exit codes: 0 = OK, 1 = MISSING_*, 2 = bad usage
SCRIPT_NAME="$(basename "$0")"
MIN_PYTHON_MAJOR=3
MIN_PYTHON_MINOR=8
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
usage() {
cat <<EOF
Usage: $SCRIPT_NAME <command> [engine]
Commands:
check <engine> Check dependencies (reportlab | weasyprint)
install <engine> Install missing dependencies (reportlab | weasyprint)
status Show all engines' dependency status
Engines:
reportlab Pure-Python PDF (pip: reportlab)
weasyprint HTML-based PDF (pip: weasyprint markdown pygments; brew: pango cairo gdk-pixbuf libffi)
Exit codes:
0 OK — all dependencies present
1 MISSING_PYTHON | MISSING_SYSTEM | MISSING_PIP
2 Bad usage
EOF
}
die_usage() {
echo "$1" >&2
echo "Run '$SCRIPT_NAME help' for usage." >&2
exit 2
}
is_macos() { [[ "$(uname -s)" == "Darwin" ]]; }
# ---------------------------------------------------------------------------
# Python version check (common to both engines)
# ---------------------------------------------------------------------------
check_python() {
if ! command -v python3 &>/dev/null; then
echo "MISSING_PYTHON"
return 1
fi
local ver
ver="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
local major minor
major="${ver%%.*}"
minor="${ver##*.}"
if (( major < MIN_PYTHON_MAJOR )) || { (( major == MIN_PYTHON_MAJOR )) && (( minor < MIN_PYTHON_MINOR )); }; then
echo "MISSING_PYTHON"
return 1
fi
return 0
}
# ---------------------------------------------------------------------------
# Pip package checks
# ---------------------------------------------------------------------------
# check_pip_packages <pkg1> <pkg2> ...
# Prints comma-separated list of missing packages (empty if all present).
check_pip_packages() {
local missing=()
for pkg in "$@"; do
if ! python3 -c "import $pkg" &>/dev/null; then
missing+=("$pkg")
fi
done
if (( ${#missing[@]} == 0 )); then
echo ""
else
local IFS=','
echo "${missing[*]}"
fi
}
# ---------------------------------------------------------------------------
# Brew / system package checks (weasyprint only)
# ---------------------------------------------------------------------------
WEASYPRINT_BREW_PKGS=(pango cairo gdk-pixbuf libffi)
# On Linux, the equivalent apt packages differ:
# libpango1.0-dev libcairo2-dev libgdk-pixbuf2.0-dev libffi-dev
# This script checks brew on macOS; on Linux it falls back to pkg-config.
check_system_packages() {
local missing=()
if is_macos; then
for pkg in "${WEASYPRINT_BREW_PKGS[@]}"; do
if ! brew list "$pkg" &>/dev/null; then
missing+=("$pkg")
fi
done
else
# Linux: use pkg-config as a heuristic
local -A linux_pc_names=(
[pango]="pango"
[cairo]="cairo"
[gdk-pixbuf]="gdk-pixbuf-2.0"
[libffi]="libffi"
)
for pkg in "${WEASYPRINT_BREW_PKGS[@]}"; do
local pc="${linux_pc_names[$pkg]}"
if ! pkg-config --exists "$pc" &>/dev/null 2>&1; then
missing+=("$pkg")
fi
done
fi
if (( ${#missing[@]} == 0 )); then
echo ""
else
local IFS=','
echo "${missing[*]}"
fi
}
# ---------------------------------------------------------------------------
# check command
# ---------------------------------------------------------------------------
cmd_check() {
local engine="${1:-}"
[[ -z "$engine" ]] && die_usage "Engine required. Use: reportlab | weasyprint"
# Common: python3 >= 3.8
local py_result
py_result="$(check_python)" || { echo "$py_result"; exit 1; }
case "$engine" in
reportlab)
local pip_missing
pip_missing="$(check_pip_packages reportlab)"
if [[ -n "$pip_missing" ]]; then
echo "MISSING_PIP|$pip_missing"
exit 1
fi
echo "OK"
;;
weasyprint)
local sys_missing pip_missing
sys_missing="$(check_system_packages)"
pip_missing="$(check_pip_packages weasyprint markdown pygments)"
if [[ -n "$sys_missing" ]]; then
echo "MISSING_SYSTEM|$sys_missing"
exit 1
fi
if [[ -n "$pip_missing" ]]; then
echo "MISSING_PIP|$pip_missing"
exit 1
fi
echo "OK"
;;
*)
die_usage "Unknown engine: $engine. Use: reportlab | weasyprint"
;;
esac
}
# ---------------------------------------------------------------------------
# install command
# ---------------------------------------------------------------------------
cmd_install() {
local engine="${1:-}"
[[ -z "$engine" ]] && die_usage "Engine required. Use: reportlab | weasyprint"
# Ensure python3 exists first
if ! command -v python3 &>/dev/null; then
echo "python3 not found. Install Python >= $MIN_PYTHON_MAJOR.$MIN_PYTHON_MINOR first." >&2
exit 1
fi
case "$engine" in
reportlab)
local pip_missing
pip_missing="$(check_pip_packages reportlab)"
if [[ -z "$pip_missing" ]]; then
echo "OK"
return
fi
echo "Installing pip packages: $pip_missing" >&2
IFS=',' read -ra pkgs <<< "$pip_missing"
pip3 install "${pkgs[@]}"
echo "OK"
;;
weasyprint)
local sys_missing pip_missing
sys_missing="$(check_system_packages)"
if [[ -n "$sys_missing" ]]; then
if is_macos; then
echo "Installing brew packages: $sys_missing" >&2
IFS=',' read -ra pkgs <<< "$sys_missing"
brew install "${pkgs[@]}"
else
echo "Missing system packages: $sys_missing" >&2
echo "On Debian/Ubuntu: sudo apt-get install libpango1.0-dev libcairo2-dev libgdk-pixbuf2.0-dev libffi-dev" >&2
exit 1
fi
fi
pip_missing="$(check_pip_packages weasyprint markdown pygments)"
if [[ -n "$pip_missing" ]]; then
echo "Installing pip packages: $pip_missing" >&2
IFS=',' read -ra pkgs <<< "$pip_missing"
pip3 install "${pkgs[@]}"
fi
echo "OK"
;;
*)
die_usage "Unknown engine: $engine. Use: reportlab | weasyprint"
;;
esac
}
# ---------------------------------------------------------------------------
# status command
# ---------------------------------------------------------------------------
label_status() {
# $1 = check result (OK or MISSING_*)
if [[ "$1" == "OK" ]]; then
echo "installed"
else
echo "missing"
fi
}
cmd_status() {
echo "| Component | Status | Detail |"
echo "|-----------------|-----------|----------------------------|"
# Python
local py_result py_status py_detail
set +e
py_result="$(check_python)"
local py_rc=$?
set -e
if (( py_rc == 0 )); then
py_status="installed"
py_detail="$(python3 --version 2>&1)"
else
py_status="missing"
py_detail="Require >= $MIN_PYTHON_MAJOR.$MIN_PYTHON_MINOR"
fi
printf "| %-15s | %-9s | %-26s |\n" "python3" "$py_status" "$py_detail"
# reportlab pip
local rl_missing rl_status rl_detail
if (( py_rc == 0 )); then
rl_missing="$(check_pip_packages reportlab)"
if [[ -z "$rl_missing" ]]; then
rl_status="installed"
rl_detail="reportlab"
else
rl_status="missing"
rl_detail="pip: $rl_missing"
fi
else
rl_status="unknown"
rl_detail="python3 required"
fi
printf "| %-15s | %-9s | %-26s |\n" "reportlab" "$rl_status" "$rl_detail"
# weasyprint system deps
local ws_sys_missing ws_sys_status ws_sys_detail
ws_sys_missing="$(check_system_packages)"
if [[ -z "$ws_sys_missing" ]]; then
ws_sys_status="installed"
ws_sys_detail="pango cairo gdk-pixbuf libffi"
else
ws_sys_status="missing"
ws_sys_detail="brew: $ws_sys_missing"
fi
printf "| %-15s | %-9s | %-26s |\n" "weasy-system" "$ws_sys_status" "$ws_sys_detail"
# weasyprint pip deps
local wp_missing wp_status wp_detail
if (( py_rc == 0 )); then
wp_missing="$(check_pip_packages weasyprint markdown pygments)"
if [[ -z "$wp_missing" ]]; then
wp_status="installed"
wp_detail="weasyprint markdown pygments"
else
wp_status="missing"
wp_detail="pip: $wp_missing"
fi
else
wp_status="unknown"
wp_detail="python3 required"
fi
printf "| %-15s | %-9s | %-26s |\n" "weasy-pip" "$wp_status" "$wp_detail"
}
# ---------------------------------------------------------------------------
# Main dispatch
# ---------------------------------------------------------------------------
CMD="${1:-help}"
shift 2>/dev/null || true
case "$CMD" in
check) cmd_check "$@" ;;
install) cmd_install "$@" ;;
status) cmd_status ;;
help|-h|--help) usage ;;
*) die_usage "Unknown command: $CMD" ;;
esac
#!/usr/bin/env python3
"""
Universal Markdown to PDF converter with dual engine support.
Engines:
- reportlab (default) -- pure Python, no system deps
- weasyprint -- HTML/CSS pipeline, better fidelity
Usage:
python3 md_to_pdf.py <input.md> [output.pdf] [options]
python3 md_to_pdf.py input.md --engine weasyprint --style custom.css
python3 md_to_pdf.py input.md output.pdf --config my_config.json --quiet
Dependencies:
reportlab engine: pip install reportlab
weasyprint engine: pip install weasyprint markdown pygments
"""
import json
import os
import re
import sys
import argparse
import platform
from copy import deepcopy
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_CONFIG_PATH = SCRIPT_DIR / ".." / "styles" / "default.json"
DEFAULT_CSS_PATH = SCRIPT_DIR / ".." / "styles" / "default.css"
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv=None):
p = argparse.ArgumentParser(
description="Convert Markdown to PDF (reportlab or weasyprint).",
)
p.add_argument("input", help="Path to the Markdown file")
p.add_argument("output", nargs="?", default=None, help="Output PDF path (default: <input>.pdf)")
p.add_argument("--engine", choices=["reportlab", "weasyprint"], default="reportlab",
help="Rendering engine (default: reportlab)")
p.add_argument("--config", default=None, help="JSON style config overrides")
p.add_argument("--style", default=None, help="CSS file (weasyprint only)")
p.add_argument("--pygments-theme", default="github", help="Code theme (weasyprint only, default: github)")
p.add_argument("--quiet", action="store_true", help="Suppress progress output")
args = p.parse_args(argv)
if args.output is None:
args.output = str(Path(args.input).with_suffix(".pdf"))
return args
# ---------------------------------------------------------------------------
# Config loading (deep merge)
# ---------------------------------------------------------------------------
def _deep_merge(base: dict, override: dict) -> dict:
result = deepcopy(base)
for key, val in override.items():
if key in result and isinstance(result[key], dict) and isinstance(val, dict):
result[key] = _deep_merge(result[key], val)
else:
result[key] = deepcopy(val)
return result
def load_config(config_path=None) -> dict:
"""Load config from JSON, falling back to defaults."""
defaults = {}
if DEFAULT_CONFIG_PATH.exists():
defaults = json.loads(DEFAULT_CONFIG_PATH.read_text(encoding="utf-8"))
if config_path:
user_cfg = json.loads(Path(config_path).read_text(encoding="utf-8"))
return _deep_merge(defaults, user_cfg)
return defaults
# ---------------------------------------------------------------------------
# Cross-platform font detection (reportlab)
# ---------------------------------------------------------------------------
_FONT_SEARCH = {
"Darwin": [
("/System/Library/Fonts/Supplemental/PTSans.ttc", {
"body": ("PTSans", 0), "bold": ("PTSans-Bold", 7),
"italic": ("PTSans-Italic", 1), "boldItalic": ("PTSans-BoldItalic", 6),
}),
("/System/Library/Fonts/Supplemental/Arial.ttf", {
"body": ("Arial", None), "bold": ("Arial-Bold", None),
"italic": ("Arial-Italic", None), "boldItalic": ("Arial-BoldItalic", None),
}),
],
"Linux": [
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", {
"body": ("DejaVuSans", None), "bold": ("DejaVuSans-Bold", None),
"italic": ("DejaVuSans-Oblique", None), "boldItalic": ("DejaVuSans-BoldOblique", None),
}),
],
}
# Linux bold/italic companion files
_LINUX_COMPANIONS = {
"bold": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"italic": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf",
"boldItalic": "/usr/share/fonts/truetype/dejavu/DejaVuSans-BoldOblique.ttf",
}
def detect_fonts() -> dict:
"""Detect available fonts, return name mapping dict.
Returns:
{"body": "FontName", "bold": "FontName-Bold",
"italic": "FontName-Italic", "boldItalic": "FontName-BoldItalic",
"family": "FontName", "_source": "path_or_builtin",
"_entries": [(name, path, subfontIndex|None), ...]}
"""
system = platform.system()
candidates = _FONT_SEARCH.get(system, []) + _FONT_SEARCH.get("Linux", [])
for font_path, mapping in candidates:
if not os.path.exists(font_path):
continue
entries = []
names = {}
for role, (name, idx) in mapping.items():
if system == "Linux" and role != "body":
companion = _LINUX_COMPANIONS.get(role)
if companion and os.path.exists(companion):
entries.append((name, companion, None))
else:
entries.append((name, font_path, idx))
else:
entries.append((name, font_path, idx))
names[role] = name
names["family"] = names["body"]
names["_source"] = font_path
names["_entries"] = entries
return names
return {
"body": "Helvetica", "bold": "Helvetica-Bold",
"italic": "Helvetica-Oblique", "boldItalic": "Helvetica-BoldOblique",
"family": "Helvetica", "_source": "builtin", "_entries": [],
}
def register_detected_fonts(font_info: dict):
"""Register detected fonts with reportlab (lazy import)."""
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
for name, path, idx in font_info.get("_entries", []):
kwargs = {"subfontIndex": idx} if idx is not None else {}
pdfmetrics.registerFont(TTFont(name, path, **kwargs))
if font_info["_entries"]:
pdfmetrics.registerFontFamily(
font_info["family"],
normal=font_info["body"],
bold=font_info["bold"],
italic=font_info["italic"],
boldItalic=font_info["boldItalic"],
)
# ---------------------------------------------------------------------------
# Structured output
# ---------------------------------------------------------------------------
def print_status(output_path: str, page_count: int, engine: str, quiet: bool):
size_bytes = Path(output_path).stat().st_size
size_kb = f"{size_bytes / 1024:.0f}KB"
lines = [
f"STATUS=OK",
f"OUTPUT={output_path}",
f"PAGES={page_count}",
f"SIZE={size_kb}",
f"ENGINE={engine}",
]
if not quiet:
for ln in lines:
print(ln)
def print_failure(message: str):
print("STATUS=FAILED", file=sys.stdout)
print(message, file=sys.stderr)
# ---------------------------------------------------------------------------
# WeasyPrint engine
# ---------------------------------------------------------------------------
def convert_weasyprint(input_path: str, output_path: str, config: dict,
css_path=None, pygments_theme="github", quiet=False):
"""Convert MD -> HTML -> CSS -> PDF via weasyprint."""
try:
import markdown
import weasyprint
from pygments.formatters import HtmlFormatter
except ImportError as exc:
print_failure(f"Missing dependency for weasyprint engine: {exc}\n"
f"Install with: pip install weasyprint markdown pygments")
sys.exit(1)
md_text = Path(input_path).read_text(encoding="utf-8")
extensions = [
"tables", "fenced_code", "codehilite", "footnotes",
"toc", "attr_list", "def_list", "admonition", "sane_lists", "smarty",
]
extension_configs = {
"codehilite": {"css_class": "highlight", "guess_lang": True},
}
html_body = markdown.markdown(md_text, extensions=extensions,
extension_configs=extension_configs)
# Resolve CSS
css_file = Path(css_path) if css_path else DEFAULT_CSS_PATH
css_link = ""
if css_file.exists():
css_link = f'<link rel="stylesheet" href="file://{css_file.resolve()}">'
# Pygments inline CSS
try:
pygments_css = HtmlFormatter(style=pygments_theme).get_style_defs(".highlight")
except Exception:
pygments_css = HtmlFormatter(style="default").get_style_defs(".highlight")
html_doc = f"""<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
{css_link}
<style>{pygments_css}</style>
</head><body>
{html_body}
</body></html>"""
doc = weasyprint.HTML(string=html_doc, base_url=str(Path(input_path).parent)).render()
doc.write_pdf(output_path)
page_count = len(doc.pages)
print_status(output_path, page_count, "weasyprint", quiet)
# ---------------------------------------------------------------------------
# Reportlab engine -- helpers
# ---------------------------------------------------------------------------
def _rl_colors(config: dict):
"""Build reportlab color objects from config."""
from reportlab.lib import colors as rlc
c = config.get("colors", {})
return {
"primary": rlc.HexColor(c.get("primary", "#1a3a5c")),
"secondary": rlc.HexColor(c.get("secondary", "#2c5282")),
"text": rlc.HexColor(c.get("text", "#1a1a1a")),
"code_bg": rlc.HexColor(c.get("code_bg", "#f0f0f0")),
"header_bg": rlc.HexColor(c.get("header_bg", "#1a3a5c")),
"header_fg": rlc.HexColor(c.get("header_fg", "#ffffff")),
"quote_bg": rlc.HexColor(c.get("quote_bg", "#f5f5f5")),
"border": rlc.HexColor(c.get("border", "#3182ce")),
"light_bg": rlc.HexColor(c.get("light_bg", "#f0f4f8")),
"white": rlc.white,
}
def safe_xml(text: str, code_font: str = "Courier-Bold") -> str:
"""Escape XML-unsafe chars, apply inline Markdown markup.
Handles: **bold**, *italic*, ***bold italic***, `inline code`, [links](url).
Args:
code_font: font face for inline `code` spans (default: Courier-Bold).
"""
text = text.replace("&", "&")
text = text.replace("<", "<").replace(">", ">")
# Links [text](url) -- must come before bold/italic to avoid mangling
# Internal anchors (#...) become plain bold text; external URLs become <a> tags
def _link_repl(m):
link_text, url = m.group(1), m.group(2)
if url.startswith("#"):
return f"<b>{link_text}</b>"
return f'<a href="{url}" color="blue"><u>{link_text}</u></a>'
text = re.sub(r"\[([^\]]+?)\]\(([^)]+?)\)", _link_repl, text)
# Bold+italic (***), then bold (**), then italic (*)
text = re.sub(r"\*\*\*(.+?)\*\*\*", r"<b><i>\1</i></b>", text)
text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
text = re.sub(r"\*(.+?)\*", r"<i>\1</i>", text)
# Inline code -- monospace bold in red
text = re.sub(r"`(.+?)`", rf'<font face="{code_font}" color="#c53030">\1</font>', text)
return text
def parse_md_table(lines: list) -> list:
"""Parse markdown table lines into a list of rows (list of cell strings)."""
rows = []
for line in lines:
line = line.strip()
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.split("|")]
if cells and cells[0] == "":
cells = cells[1:]
if cells and cells[-1] == "":
cells = cells[:-1]
if all(re.match(r"^[-:]+$", c) for c in cells):
continue
rows.append(cells)
return rows
# ---------------------------------------------------------------------------
# Reportlab engine -- styles
# ---------------------------------------------------------------------------
def build_styles(font_info: dict, config: dict):
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
clr = _rl_colors(config)
typo = config.get("typography", {})
body_sz = typo.get("body_size", 9)
f = font_info
ss = StyleSheet1()
ss.add(ParagraphStyle(
name="Normal", fontName=f["body"], fontSize=body_sz,
leading=body_sz * 1.35, textColor=clr["text"], spaceAfter=4,
))
ss.add(ParagraphStyle(
name="H1", fontName=f["bold"], fontSize=typo.get("h1_size", 18),
leading=22, textColor=clr["primary"], alignment=TA_CENTER,
spaceAfter=6, spaceBefore=0,
))
ss.add(ParagraphStyle(
name="H2", fontName=f["bold"], fontSize=typo.get("h2_size", 14),
leading=18, textColor=clr["primary"], alignment=TA_LEFT,
spaceAfter=6, spaceBefore=14,
))
ss.add(ParagraphStyle(
name="H3", fontName=f["bold"], fontSize=typo.get("h3_size", 12),
leading=15, textColor=clr["secondary"], alignment=TA_LEFT,
spaceAfter=4, spaceBefore=10,
))
ss.add(ParagraphStyle(
name="H4", fontName=f["bold"], fontSize=typo.get("h4_size", 10),
leading=13, textColor=clr["primary"], alignment=TA_LEFT,
spaceAfter=4, spaceBefore=8,
))
ss.add(ParagraphStyle(
name="Blockquote", fontName=f["italic"], fontSize=body_sz - 0.5,
leading=11, textColor=clr["text"], leftIndent=14,
spaceAfter=4, spaceBefore=2, backColor=clr["quote_bg"],
borderPadding=(4, 6, 4, 6),
))
ss.add(ParagraphStyle(
name="TableCell", fontName=f["body"], fontSize=8,
leading=10, textColor=clr["text"],
))
ss.add(ParagraphStyle(
name="TableCellBold", fontName=f["bold"], fontSize=8,
leading=10, textColor=clr["text"],
))
ss.add(ParagraphStyle(
name="TableHeaderCell", fontName=f["bold"], fontSize=8,
leading=10, textColor=clr["header_fg"],
))
ss.add(ParagraphStyle(
name="Subtitle", fontName=f["italic"], fontSize=10,
leading=13, textColor=clr["text"], alignment=TA_CENTER, spaceAfter=10,
))
ss.add(ParagraphStyle(
name="BulletItem", fontName=f["body"], fontSize=body_sz,
leading=body_sz * 1.35, textColor=clr["text"],
leftIndent=16, bulletIndent=6, spaceAfter=3,
))
ss.add(ParagraphStyle(
name="NumberedItem", fontName=f["body"], fontSize=body_sz,
leading=body_sz * 1.35, textColor=clr["text"],
leftIndent=20, firstLineIndent=-14, spaceAfter=3,
))
ss.add(ParagraphStyle(
name="CodeBlock", fontName="Courier", fontSize=config.get("code", {}).get("font_size", 7.5),
leading=10, textColor=clr["text"], leftIndent=6,
spaceAfter=2, spaceBefore=2,
))
return ss
# ---------------------------------------------------------------------------
# Reportlab engine -- table builder
# ---------------------------------------------------------------------------
def build_table(rows: list, styles, font_info: dict, clr: dict, available_width: float):
"""Build a reportlab Table from parsed MD rows with auto column widths."""
from reportlab.lib import colors as rlc
from reportlab.platypus import Table, TableStyle, Paragraph
if not rows:
return None
num_cols = max(len(r) for r in rows)
for row in rows:
while len(row) < num_cols:
row.append("")
header_style = styles["TableHeaderCell"]
cell_style = styles["TableCell"]
data = []
for ri, row in enumerate(rows):
prow = []
for cell in row:
cell_text = safe_xml(cell)
st = header_style if ri == 0 else cell_style
prow.append(Paragraph(cell_text, st))
data.append(prow)
# Auto column widths based on content length
col_weights = [0.0] * num_cols
for row in rows:
for ci, cell in enumerate(row):
col_weights[ci] = max(col_weights[ci], len(cell))
total_weight = sum(col_weights) or 1
col_ratios = [max(min(w / total_weight, 0.60), 0.05) for w in col_weights]
ratio_sum = sum(col_ratios)
col_widths = [(r / ratio_sum) * available_width for r in col_ratios]
table = Table(data, colWidths=col_widths, repeatRows=1)
table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), clr["header_bg"]),
("TEXTCOLOR", (0, 0), (-1, 0), clr["header_fg"]),
("FONTNAME", (0, 0), (-1, 0), font_info["bold"]),
("FONTSIZE", (0, 0), (-1, 0), 8),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("GRID", (0, 0), (-1, -1), 0.5, rlc.HexColor("#c0c0c0")),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [rlc.white, clr["light_bg"]]),
]))
return table
# ---------------------------------------------------------------------------
# Reportlab engine -- blockquote builder
# ---------------------------------------------------------------------------
def build_blockquote(text: str, styles, font_info: dict, clr: dict, available_width: float):
"""Build a blockquote as a table with a left blue border."""
from reportlab.platypus import Table, TableStyle, Paragraph
cell_text = safe_xml(text)
para = Paragraph(cell_text, styles["Blockquote"])
data = [[" ", para]]
col_widths = [3, available_width - 10]
t = Table(data, colWidths=col_widths)
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (0, 0), clr["border"]),
("BACKGROUND", (1, 0), (1, 0), clr["quote_bg"]),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (0, 0), 0),
("RIGHTPADDING", (0, 0), (0, 0), 0),
("LEFTPADDING", (1, 0), (1, 0), 6),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
return t
# ---------------------------------------------------------------------------
# Reportlab engine -- code block builder
# ---------------------------------------------------------------------------
def build_code_block(text: str, styles, clr: dict, available_width: float):
"""Build a code block with gray background."""
from reportlab.lib import colors as rlc
from reportlab.platypus import Table, TableStyle, Paragraph
text = text.replace("&", "&")
text = text.replace("<", "<").replace(">", ">")
text = text.replace("\n", "<br/>")
para = Paragraph(text, styles["CodeBlock"])
data = [[para]]
t = Table(data, colWidths=[available_width])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (0, 0), clr["code_bg"]),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("BOX", (0, 0), (-1, -1), 0.5, rlc.HexColor("#d0d0d0")),
]))
return t
# ---------------------------------------------------------------------------
# Reportlab engine -- image support
# ---------------------------------------------------------------------------
def _try_build_image(alt: str, src: str, available_width: float):
"""Attempt to build a reportlab Image flowable. Returns None if not possible."""
from reportlab.platypus import Image
if src.startswith("http://") or src.startswith("https://"):
return None
img_path = Path(src)
if not img_path.exists():
return None
try:
img = Image(str(img_path))
iw, ih = img.drawWidth, img.drawHeight
if iw > available_width:
ratio = available_width / iw
img.drawWidth = available_width
img.drawHeight = ih * ratio
return img
except Exception:
return None
# ---------------------------------------------------------------------------
# Reportlab engine -- numbered canvas
# ---------------------------------------------------------------------------
def _make_numbered_canvas_class(font_name: str, footer_fmt: str):
"""Create a NumberedCanvas class bound to the given font and format string."""
from reportlab.lib import colors as rlc
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen.canvas import Canvas
class NumberedCanvas(Canvas):
def __init__(self, *args, **kwargs):
Canvas.__init__(self, *args, **kwargs)
self._saved_page_states = []
def showPage(self):
self._saved_page_states.append(dict(self.__dict__))
self._startPage()
def save(self):
num_pages = len(self._saved_page_states)
for state in self._saved_page_states:
self.__dict__.update(state)
self._draw_footer(num_pages)
Canvas.showPage(self)
Canvas.save(self)
def _draw_footer(self, page_count):
self.saveState()
fn = font_name if font_name != "Helvetica" else "Helvetica"
self.setFont(fn, 8)
self.setFillColor(rlc.HexColor("#888888"))
text = footer_fmt.format(page=self._pageNumber, total=page_count)
self.drawCentredString(A4[0] / 2, 25, text)
self.restoreState()
return NumberedCanvas
# ---------------------------------------------------------------------------
# Reportlab engine -- markdown to story (flowables)
# ---------------------------------------------------------------------------
def md_to_story(md_text: str, styles, font_info: dict, clr: dict,
available_width: float) -> list:
"""Parse markdown text and return a list of reportlab flowables."""
from reportlab.lib import colors as rlc
from reportlab.platypus import Paragraph, Spacer
from reportlab.platypus.flowables import HRFlowable
lines = md_text.split("\n")
story = []
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped:
i += 1
continue
# Fenced code block
if stripped.startswith("```"):
code_lines = []
i += 1
while i < len(lines):
if lines[i].strip().startswith("```"):
i += 1
break
code_lines.append(lines[i].rstrip())
i += 1
story.append(build_code_block("\n".join(code_lines), styles, clr, available_width))
story.append(Spacer(1, 4))
continue
# Image 
img_match = re.match(r"^!\[([^\]]*)\]\(([^)]+)\)$", stripped)
if img_match:
alt, src = img_match.group(1), img_match.group(2)
img = _try_build_image(alt, src, available_width)
if img:
story.append(Spacer(1, 4))
story.append(img)
story.append(Spacer(1, 4))
i += 1
continue
# Horizontal rule
if stripped in ("---", "***", "___"):
story.append(Spacer(1, 4))
story.append(HRFlowable(
width="100%", thickness=0.5,
color=rlc.HexColor("#cccccc"), spaceAfter=4, spaceBefore=4,
))
i += 1
continue
# H1
if stripped.startswith("# ") and not stripped.startswith("## "):
story.append(Spacer(1, 20))
story.append(Paragraph(safe_xml(stripped[2:].strip()), styles["H1"]))
i += 1
continue
# H2
if stripped.startswith("## ") and not stripped.startswith("### "):
story.append(Paragraph(safe_xml(stripped[3:].strip()), styles["H2"]))
story.append(HRFlowable(
width="100%", thickness=0.8,
color=clr["primary"], spaceAfter=6, spaceBefore=1,
))
i += 1
continue
# H3
if stripped.startswith("### ") and not stripped.startswith("#### "):
story.append(Paragraph(safe_xml(stripped[4:].strip()), styles["H3"]))
i += 1
continue
# H4
if stripped.startswith("#### "):
story.append(Paragraph(safe_xml(stripped[5:].strip()), styles["H4"]))
i += 1
continue
# Blockquote
if stripped.startswith("> ") or stripped == ">":
quote_lines = []
while i < len(lines):
s = lines[i].strip()
if s.startswith("> "):
quote_lines.append(s[2:])
elif s == ">":
quote_lines.append("")
else:
break
i += 1
story.append(build_blockquote(" ".join(quote_lines), styles, font_info, clr, available_width))
story.append(Spacer(1, 4))
continue
# Table
if stripped.startswith("|"):
table_lines = []
while i < len(lines):
s = lines[i].strip()
if s.startswith("|"):
table_lines.append(s)
i += 1
else:
break
rows = parse_md_table(table_lines)
if rows:
t = build_table(rows, styles, font_info, clr, available_width)
if t:
story.append(t)
story.append(Spacer(1, 6))
continue
# Checkbox items
if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
text = stripped[6:].strip()
marker = "\u2610 " if stripped.startswith("- [ ]") else "\u2611 "
story.append(Paragraph(marker + safe_xml(text), styles["BulletItem"]))
i += 1
continue
# Numbered list
m_num = re.match(r"^(\d+)\.\s+(.+)$", stripped)
if m_num:
story.append(Paragraph(
f"<b>{m_num.group(1)}.</b> " + safe_xml(m_num.group(2)),
styles["NumberedItem"],
))
i += 1
continue
# Bullet list
if stripped.startswith("- ") or stripped.startswith("* "):
story.append(Paragraph(
"\u2022 " + safe_xml(stripped[2:].strip()),
styles["BulletItem"],
))
i += 1
continue
# Bold-colon lines (**Label:** value) -- render with bold styling
if stripped.startswith("**") and ":" in stripped:
text = safe_xml(stripped)
story.append(Paragraph(text, styles["Normal"]))
i += 1
continue
# Plain text
story.append(Paragraph(safe_xml(stripped), styles["Normal"]))
i += 1
return story
# ---------------------------------------------------------------------------
# Reportlab engine -- document builder
# ---------------------------------------------------------------------------
def build_document(output_path: str, config: dict):
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.platypus import BaseDocTemplate, Frame, PageTemplate
page_width, page_height = A4
margins = config.get("page", {}).get("margins", {})
left = margins.get("left", 25) * mm
right = margins.get("right", 20) * mm
top = margins.get("top", 25) * mm
bottom = margins.get("bottom", 25) * mm
doc = BaseDocTemplate(
output_path, pagesize=A4,
leftMargin=left, rightMargin=right,
topMargin=top, bottomMargin=bottom,
)
frame = Frame(
left, bottom,
page_width - left - right,
page_height - top - bottom,
id="main_frame",
)
page_template = PageTemplate(
id="main", frames=[frame],
onPage=lambda canvas, doc: None,
)
doc.addPageTemplates([page_template])
available_width = page_width - left - right
return doc, available_width
# ---------------------------------------------------------------------------
# Reportlab engine -- main conversion
# ---------------------------------------------------------------------------
def convert_reportlab(input_path: str, output_path: str, config: dict, quiet=False):
"""Convert MD -> PDF via reportlab."""
try:
from reportlab.platypus import Paragraph # noqa: verify import
except ImportError:
print_failure("reportlab is not installed.\nInstall with: pip install reportlab")
sys.exit(1)
font_info = detect_fonts()
register_detected_fonts(font_info)
clr = _rl_colors(config)
styles = build_styles(font_info, config)
doc, available_width = build_document(output_path, config)
md_text = Path(input_path).read_text(encoding="utf-8")
story = md_to_story(md_text, styles, font_info, clr, available_width)
footer_cfg = config.get("footer", {})
footer_fmt = footer_cfg.get("format", "Page {page} of {total}")
canvas_cls = _make_numbered_canvas_class(font_info["body"], footer_fmt)
if footer_cfg.get("enabled", True):
doc.build(story, canvasmaker=canvas_cls)
else:
doc.build(story)
page_count = canvas_cls.__dict__.get("_page_count", 0)
# Fallback: read page count from built doc
if page_count == 0:
try:
page_count = doc.page
except Exception:
page_count = 0
print_status(output_path, page_count, "reportlab", quiet)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
args = parse_args()
if not os.path.isfile(args.input):
print_failure(f"File not found: {args.input}")
sys.exit(1)
config = load_config(args.config)
try:
if args.engine == "weasyprint":
convert_weasyprint(args.input, args.output, config,
css_path=args.style,
pygments_theme=args.pygments_theme,
quiet=args.quiet)
else:
convert_reportlab(args.input, args.output, config, quiet=args.quiet)
except Exception as exc:
print_failure(str(exc))
sys.exit(1)
if __name__ == "__main__":
main()
/* === Page Setup === */
@page {
size: A4;
margin: 25mm 20mm 25mm 25mm;
@bottom-center {
content: "Page " counter(page) " of " counter(pages);
font-size: 7pt;
color: #888888;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", sans-serif;
}
}
/* === Base Typography === */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", "Noto Sans CJK SC", sans-serif;
font-size: 9pt;
line-height: 1.4;
color: #1a1a1a;
}
/* === Headings === */
h1, h2, h3, h4, h5, h6 { margin-top: 12pt; margin-bottom: 4pt; page-break-after: avoid; }
h1 { font-size: 20pt; color: #1a3a5c; text-align: center; margin-top: 18pt; margin-bottom: 10pt; }
h2 { font-size: 15pt; color: #1a3a5c; border-bottom: 1px solid #c0c0c0; padding-bottom: 3pt; }
h3 { font-size: 12pt; color: #2c5282; }
h4 { font-size: 10pt; color: #2c5282; }
h5 { font-size: 9pt; color: #4a6fa5; }
h6 { font-size: 9pt; color: #6b8cba; font-style: italic; }
/* === Paragraphs & Links === */
p { margin: 4pt 0; }
a { color: #2b6cb0; text-decoration: underline; }
@media print { a { text-decoration: none; } }
/* === Tables === */
table {
width: 100%;
border-collapse: collapse;
margin: 8pt 0;
font-size: 8.5pt;
page-break-inside: avoid;
}
th, td { border: 0.5px solid #c0c0c0; padding: 3px 4px; text-align: left; }
th { background-color: #1a3a5c; color: #ffffff; font-weight: bold; }
tr:nth-child(even) { background-color: #f0f4f8; }
tr:nth-child(odd) { background-color: #ffffff; }
/* === Code Blocks === */
pre { page-break-inside: avoid; margin: 6pt 0; }
pre code {
display: block;
background: #f0f0f0;
padding: 6px 8px;
border: 0.5px solid #d0d0d0;
border-radius: 2px;
font-family: "SFMono-Regular", "Cascadia Code", "Fira Code", Menlo, Consolas, monospace;
font-size: 8pt;
line-height: 1.35;
white-space: pre-wrap;
word-wrap: break-word;
color: inherit;
font-weight: normal;
}
/* === Inline Code === */
code {
background: #f0f0f0;
padding: 1px 3px;
border-radius: 2px;
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 8.5pt;
color: #c53030;
font-weight: bold;
}
/* === Blockquotes === */
blockquote {
border-left: 3px solid #3182ce;
background: #f5f5f5;
padding: 6px 10px;
margin: 6pt 0;
font-style: italic;
color: #4a4a4a;
page-break-inside: avoid;
}
blockquote p { margin: 2pt 0; }
/* === Lists === */
ul, ol { margin: 4pt 0; padding-left: 18pt; }
ul ul, ol ol, ul ol, ol ul { padding-left: 14pt; }
ul ul ul, ol ol ol, ul ul ol, ol ol ul,
ul ol ul, ol ul ol, ul ol ol, ol ul ul { padding-left: 12pt; }
li { margin-bottom: 2pt; }
/* === Task Lists === */
li input[type="checkbox"] { margin-right: 4px; vertical-align: middle; }
li.task-list-item { list-style-type: none; margin-left: -18pt; }
/* === Horizontal Rules === */
hr { border: none; border-top: 0.5px solid #cccccc; margin: 10pt 0; }
/* === Images === */
img { max-width: 100%; height: auto; }
/* === Pygments Syntax Highlighting === */
.highlight .k, .highlight .kn, .highlight .kd,
.highlight .kc, .highlight .kr, .highlight .kt { color: #d73a49; font-weight: bold; }
.highlight .s, .highlight .s1, .highlight .s2, .highlight .sb,
.highlight .sc, .highlight .sd, .highlight .se, .highlight .sh,
.highlight .sx, .highlight .si, .highlight .sr { color: #032f62; }
.highlight .n, .highlight .na, .highlight .nc, .highlight .nd,
.highlight .ne, .highlight .ni, .highlight .nl, .highlight .nn,
.highlight .nt, .highlight .nv, .highlight .p { color: #24292e; }
.highlight .c, .highlight .c1, .highlight .cm, .highlight .ch,
.highlight .cs, .highlight .cp, .highlight .cpf { color: #6a737d; font-style: italic; }
.highlight .o, .highlight .ow { color: #d73a49; }
.highlight .nb, .highlight .bp { color: #005cc5; }
.highlight .nf, .highlight .fm { color: #6f42c1; }
.highlight .mi, .highlight .mf, .highlight .mh,
.highlight .mo, .highlight .mb, .highlight .il { color: #005cc5; }
.highlight .w { color: transparent; }
{
"page": {
"size": "A4",
"margins": { "top": 25, "right": 20, "bottom": 25, "left": 25 }
},
"fonts": {
"body": "auto",
"heading": "auto",
"code": "monospace"
},
"colors": {
"primary": "#1a3a5c",
"secondary": "#2c5282",
"text": "#1a1a1a",
"code_bg": "#f0f0f0",
"header_bg": "#1a3a5c",
"header_fg": "#ffffff",
"quote_bg": "#f5f5f5",
"border": "#3182ce",
"light_bg": "#f0f4f8"
},
"footer": {
"enabled": true,
"format": "Page {page} of {total}"
},
"code": {
"theme": "github",
"font_size": 8
},
"typography": {
"body_size": 9,
"h1_size": 18,
"h2_size": 14,
"h3_size": 12,
"h4_size": 10,
"line_height": 1.4
}
}
Markdown-to-PDF Converter Test File
This document exercises every Markdown element to verify correct PDF rendering.
---
1. Headers
Header Level 1 — Primary Title
Header Level 2 — Section Title
Header Level 3 — Subsection
Header Level 4 — Sub-subsection
Header Level 5 — Minor Heading
Header Level 6 — Smallest Heading
---
2. Text Formatting
This paragraph contains bold text, italic text, *bold italic text*, ~~strikethrough text~~, and inline code.
Normal text resumes here to verify that formatting terminates correctly.
---
3. Links and Images
- External link: OpenAI
- Internal anchor: Jump to Headers
- Auto-linked URL: https://example.com
---
4. Lists
Unordered List
- First unordered item
- Second unordered item
- Third unordered item
Ordered List
1. First ordered item 2. Second ordered item 3. Third ordered item
Nested List (3 Levels)
- Level 1 — Item A
- Level 2 — Item A.1
- Level 3 — Item A.1.a
- Level 3 — Item A.1.b
- Level 2 — Item A.2
- Level 1 — Item B
1. Level 2 — Ordered B.1 2. Level 2 — Ordered B.2
- Level 3 — Mixed B.2.a
Task Lists
- [x] Completed task
- [x] Another completed task
- [ ] Pending task
- [ ] Another pending task
---
5. Tables
Simple Table
| Name | Role | Status |
|---|---|---|
| Alice | Developer | Active |
| Bob | Designer | On Leave |
| Charlie | Project Lead | Active |
| Diana | QA Engineer | Active |
Wide Table (6+ Columns)
| ID | Name | Department | Location | Start Date | Salary | Rating |
|---|---|---|---|---|---|---|
| 001 | Alice | Engineering | New York | 2022-01-15 | $120,000 | A |
| 002 | Bob | Design | San Francisco | 2021-06-01 | $110,000 | B+ |
| 003 | Charlie | Management | London | 2020-03-20 | $140,000 | A+ |
| 004 | Diana | QA | Berlin | 2023-09-10 | $95,000 | A |
Table with Formatting
| Feature | Syntax | Supported |
|---|---|---|
| Bold | **text** | Yes |
| Italic | *text* | Yes |
Inline Code | ` code ` | Yes |
| ~~Strikethrough~~ | ~~text~~ | Yes |
---
6. Code Blocks
Python
from pathlib import Path
from typing import Optional
def read_config(path: str) -> Optional[dict]:
config_file = Path(path)
if not config_file.exists():
return None
with config_file.open("r", encoding="utf-8") as f:
return json.load(f)JavaScript
async function fetchUsers(apiUrl) {
const response = await fetch(apiUrl, {
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
}Bash
#!/usr/bin/env bash
set -euo pipefail
INPUT_DIR="${1:-.}"
OUTPUT_DIR="${2:-./output}"
mkdir -p "$OUTPUT_DIR"
for file in "$INPUT_DIR"/*.md; do
echo "Converting: $(basename "$file")"
pandoc "$file" -o "$OUTPUT_DIR/$(basename "${file%.md}.pdf")"
doneSQL
SELECT
u.id,
u.username,
COUNT(o.id) AS order_count,
COALESCE(SUM(o.total), 0) AS total_spent
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at >= '2025-01-01'
GROUP BY u.id, u.username
HAVING COUNT(o.id) > 0
ORDER BY total_spent DESCJSON
{
"name": "markdown-converter",
"version": "2.0.0",
"dependencies": {
"puppeteer": "^22.0.0",
"marked": "^12.0.0",
"highlight.js": "^11.9.0"
},
"scripts": {
"convert": "node src/convert.js"
}
}Go
package main
import (
"fmt"
"os"
"path/filepath"
)
func listMarkdownFiles(dir string) ([]string, error) {
matches, err := filepath.Glob(filepath.Join(dir, "*.md"))
if err != nil {
return nil, fmt.Errorf("glob failed: %w", err)
}
return matches, nil
}YAML
converter:
input_format: markdown
output_format: pdf
options:
page_size: A4
margin:
top: 20mm
bottom: 20mm
left: 15mm
right: 15mm
font_family: "Noto Sans"
syntax_highlighting: true---
7. Blockquotes
Simple Blockquote
This is a simple blockquote. It should render with a left border and subtle background.
Nested Blockquote
This is the outer blockquote.
>
> This is a nested inner blockquote. It should be visually indented further.
Blockquote with Formatting
Important: This blockquote contains bold text, inline code, and a link to example.com.>
It also spans multiple lines to test paragraph handling within quotes.
---
***
___
8. Horizontal Rules
The three horizontal rules above use ---, ***, and ___ respectively. They should all render identically.
---
9. Footnotes
Markdown-to-PDF conversion requires careful handling of layout[^1] and typography[^2].
[^1]: Layout includes margins, page breaks, headers, and footers. [^2]: Typography covers font selection, line height, letter spacing, and ligatures.
---
10. Definition Lists
Term 1 — Markdown : A lightweight markup language for creating formatted text using a plain-text editor.
Term 2 — PDF : Portable Document Format, a file format developed by Adobe for presenting documents independent of software or hardware.
---
11. Multilingual Text
English
The Markdown-to-PDF converter must handle a variety of text encodings and scripts. Proper font fallback is essential for rendering characters outside the Latin alphabet. This section verifies that each language displays correctly in the generated PDF. Missing glyphs indicate a font configuration issue.
Russian
Кириллический текст для проверки отображения шрифтов. Поддержка различных языков является важной функцией конвертера. Каждый символ должен корректно отображаться в итоговом PDF-документе. Проверка переносов строк и интервалов также необходима.
Chinese
中文测试文本。这是一个用于测试PDF转换器多语言支持的段落。正确的字体回退机制对于渲染非拉丁字母字符至关重要。每个字符都应在生成的PDF中正确显示。
Japanese
日本語テスト。PDFコンバーターのテストです。フォントフォールバックが正しく機能することを確認します。各文字が正しくレンダリングされる必要があります。
---
12. Long Paragraph for Line Wrapping
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. This sentence follows the Latin filler to verify that mixed content and long continuous paragraphs wrap correctly at page boundaries without clipping or overlapping adjacent elements.
---
13. Special Characters
Arrows
Left arrow: <- Right arrow: -> Bidirectional: <-> Double right: =>
Math Symbols
Less than or equal: <= Greater than or equal: >= Not equal: != Plus-minus: +/- Multiplication: x
Unicode Symbols and Arrows
Arrows: → ← ↔ ↑ ↓ ⇒ ⇐ ⇔
Math: ≤ ≥ ≠ ± × ÷ ∞ ∑ ∏ √ ∂ ∫
Check/Cross: ✓ ✗ ✔ ✘
Stars: ★ ☆ ✦ ✧
Miscellaneous: ◆ ◇ ● ○ ■ □ ▲ △
Emoji
Documents: 📄 📝 📋 📂
Tools: 🔧 🔨 ⚙️ 🛠️
Status: ✅ ❌ ⚠️ ℹ️
Other: 🚀 💡 🎯 🔍
---
End of test file.