#!/bin/zsh # # md2pdf — convert Markdown files to PDF. # # Pipeline: pandoc (md -> self-contained styled HTML) -> Chrome headless # (HTML -> PDF). Uses only pandoc + Google Chrome, both assumed installed. # # Usage: # md2pdf FILE.md [FILE2.md ...] convert each; PDF written next to source # md2pdf FILE.md -o OUT.pdf explicit output (single input only) # md2pdf FILE.md --css STYLE.css use a custom stylesheet # md2pdf -h | --help # # Output defaults to the same directory and basename as the input # (report.md -> report.pdf). An existing PDF is overwritten. emulate -L zsh set -o pipefail prog=${0:t} usage() { cat < notes.pdf) and overwrites any existing PDF. - Requires pandoc and Google Chrome. EOF } die() { print -u2 "$prog: $*"; exit 1; } # --- locate Google Chrome --------------------------------------------------- find_chrome() { local candidates=( "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" "$HOME/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" "/Applications/Chromium.app/Contents/MacOS/Chromium" ) local c for c in $candidates; do [[ -x $c ]] && { print -r -- "$c"; return 0; } done # Fall back to Spotlight lookup by bundle id. local app app=$(mdfind "kMDItemCFBundleIdentifier == 'com.google.Chrome'" 2>/dev/null | head -1) if [[ -n $app && -x "$app/Contents/MacOS/Google Chrome" ]]; then print -r -- "$app/Contents/MacOS/Google Chrome" return 0 fi return 1 } # --- parse arguments -------------------------------------------------------- typeset -a inputs local out="" css="" while (( $# )); do case $1 in -h|--help) usage; exit 0 ;; -o|--output) [[ -n $2 ]] || die "option $1 requires a path" out=$2; shift 2 ;; --output=*) out=${1#*=}; shift ;; --css) [[ -n $2 ]] || die "option $1 requires a path" css=$2; shift 2 ;; --css=*) css=${1#*=}; shift ;; --) shift; inputs+=("$@"); break ;; -*) die "unknown option: $1 (try --help)" ;; *) inputs+=("$1"); shift ;; esac done (( ${#inputs} )) || { usage; exit 1; } [[ -n $out && ${#inputs} -gt 1 ]] && die "-o/--output cannot be used with multiple input files" # --- resolve dependencies once ---------------------------------------------- command -v pandoc >/dev/null 2>&1 || die "pandoc not found (install with: brew install pandoc)" local chrome chrome=$(find_chrome) || die "Google Chrome not found — install it or use a Chromium build" # Default stylesheet lives next to the real script (resolve through symlinks). local default_css="${0:A:h}/md2pdf.css" if [[ -n $css ]]; then [[ -f $css ]] || die "stylesheet not found: $css" elif [[ -f $default_css ]]; then css=$default_css fi # --- per-run temp workspace, cleaned on exit -------------------------------- local workdir workdir=$(mktemp -d "${TMPDIR:-/tmp}/md2pdf.XXXXXX") || die "could not create temp dir" trap 'rm -rf -- "$workdir"' EXIT INT TERM # --- convert one file ------------------------------------------------------- convert_one() { local in=$1 dest=$2 [[ -f $in ]] || { print -u2 "✗ $in: no such file"; return 1; } local html="$workdir/${in:t:r}.html" # --standalone gives a full HTML doc (head + linked CSS); an empty title # metadata suppresses pandoc's "please specify a title" warning without # emitting a visible title block above the document's own H1. local -a pandoc_args=( "$in" -f markdown -t html5 --standalone --embed-resources --metadata "title=" -o "$html" ) [[ -n $css ]] && pandoc_args+=(--css "$css") if ! pandoc $pandoc_args 2>"$workdir/pandoc.err"; then print -u2 "✗ $in: pandoc failed" [[ -s "$workdir/pandoc.err" ]] && print -u2 -- "$(<"$workdir/pandoc.err")" return 1 fi # Chrome's headless PDF mode runs an independent instance and does not # disturb a running Chrome profile, so no --user-data-dir is needed. (An # isolated --user-data-dir actually makes headless Chrome hang on exit.) if ! "$chrome" --headless=new --disable-gpu --no-pdf-header-footer \ --print-to-pdf="$dest" "file://$html" >/dev/null 2>"$workdir/chrome.err"; then print -u2 "✗ $in: Chrome failed to render PDF" [[ -s "$workdir/chrome.err" ]] && print -u2 -- "$(<"$workdir/chrome.err")" return 1 fi [[ -s $dest ]] || { print -u2 "✗ $in: no PDF produced"; return 1; } print -- "✓ wrote ${dest}" return 0 } # --- drive all inputs ------------------------------------------------------- local failures=0 f dest for f in $inputs; do if [[ -n $out ]]; then dest=$out else dest="${f:h}/${f:t:r}.pdf" fi convert_one "$f" "$dest" || (( failures++ )) done (( failures == 0 )) || exit 1