Add md2pdf: Markdown-to-PDF CLI with Finder Quick Action

- bin/md2pdf: converts .md to PDF via pandoc + headless Chrome
  (no LaTeX needed); writes alongside source, supports -o and --css
- bin/md2pdf.css: clean GitHub-style default stylesheet
- bin/md2pdf-install-quickaction: installs the Finder right-click action
- macos/md2pdf.workflow: version-controlled Quick Action bundle
  (right-click .md files to Convert to PDF)
This commit is contained in:
Eric Wagoner
2026-08-27 12:49:44 -04:00
parent cc7b21b1f3
commit 157336d9a8
5 changed files with 591 additions and 0 deletions
Executable
+152
View File
@@ -0,0 +1,152 @@
#!/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 <<EOF
$prog — convert Markdown files to PDF
Usage:
$prog FILE.md [FILE2.md ...] Convert each file; PDF written beside the source
$prog FILE.md -o OUT.pdf Write to an explicit path (single input only)
$prog FILE.md --css STYLE.css Render with a custom stylesheet
$prog -h | --help Show this help
Notes:
- Output defaults to the source directory with a .pdf extension
(e.g. notes.md -> 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