From 157336d9a82b155b255ef56b0aadb61c6f1e7167 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Thu, 27 Aug 2026 12:49:44 -0400 Subject: [PATCH] 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) --- bin/md2pdf | 152 ++++++++++++ bin/md2pdf-install-quickaction | 65 +++++ bin/md2pdf.css | 120 ++++++++++ macos/md2pdf.workflow/Contents/Info.plist | 32 +++ macos/md2pdf.workflow/Contents/document.wflow | 222 ++++++++++++++++++ 5 files changed, 591 insertions(+) create mode 100755 bin/md2pdf create mode 100755 bin/md2pdf-install-quickaction create mode 100644 bin/md2pdf.css create mode 100644 macos/md2pdf.workflow/Contents/Info.plist create mode 100644 macos/md2pdf.workflow/Contents/document.wflow diff --git a/bin/md2pdf b/bin/md2pdf new file mode 100755 index 0000000..6fb42d9 --- /dev/null +++ b/bin/md2pdf @@ -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 < 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 diff --git a/bin/md2pdf-install-quickaction b/bin/md2pdf-install-quickaction new file mode 100755 index 0000000..9d71c39 --- /dev/null +++ b/bin/md2pdf-install-quickaction @@ -0,0 +1,65 @@ +#!/bin/zsh +# +# md2pdf-install-quickaction — install (or refresh) the "Convert to PDF" +# Finder Quick Action. +# +# Symlinks the version-controlled workflow bundle from the dotfiles repo into +# ~/Library/Services so edits in the repo take effect without reinstalling, +# then flushes the Services cache so the menu item appears immediately. +# +# Usage: md2pdf-install-quickaction [--copy] +# --copy Install a copy instead of a symlink (use if you prefer the +# installed action to be independent of the repo). + +emulate -L zsh +set -o pipefail + +prog=${0:t} +die() { print -u2 "$prog: $*"; exit 1; } + +local mode=symlink +[[ $1 == --copy ]] && mode=copy +[[ -n $1 && $1 != --copy ]] && die "unknown argument: $1 (only --copy is supported)" + +# Source bundle lives next to this script: ../macos/md2pdf.workflow +local src="${0:A:h:h}/macos/md2pdf.workflow" +[[ -d $src ]] || die "workflow bundle not found at $src" + +local services="$HOME/Library/Services" +local dest="$services/md2pdf.workflow" + +mkdir -p "$services" || die "could not create $services" + +# Remove any prior install (symlink or directory) so we start clean. +if [[ -L $dest || -e $dest ]]; then + rm -rf -- "$dest" || die "could not remove existing $dest" +fi + +if [[ $mode == symlink ]]; then + ln -s "$src" "$dest" || die "could not symlink workflow into $services" + print -- "✓ linked $dest -> $src" +else + cp -R "$src" "$dest" || die "could not copy workflow into $services" + print -- "✓ copied workflow to $dest" +fi + +# Refresh the Services/Quick Actions registry so the item shows up now. +local pbs="/System/Library/CoreServices/pbs" +if [[ -x $pbs ]]; then + "$pbs" -flush 2>/dev/null + print -- "✓ flushed Services cache" +fi +# Nudge the Extensions/Quick Actions registration too (harmless if absent). +/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \ + -f "$dest" 2>/dev/null + +cat < ul, li > ol { margin-top: 0.25em; margin-bottom: 0; } + +/* --- Code --- */ +code, pre, kbd, samp { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; + font-size: 0.88em; +} + +code { + background-color: rgba(129, 139, 152, 0.15); + padding: 0.2em 0.4em; + border-radius: 6px; +} + +pre { + background-color: #f6f8fa; + border: 1px solid #d1d9e0; + border-radius: 6px; + padding: 1em; + overflow: auto; + line-height: 1.45; + break-inside: avoid; +} +pre code { + background: transparent; + padding: 0; + border-radius: 0; + font-size: 100%; + white-space: pre; +} + +/* --- Blockquotes --- */ +blockquote { + margin-left: 0; + padding: 0 1em; + color: #59636e; + border-left: 0.25em solid #d1d9e0; +} +blockquote > :last-child { margin-bottom: 0; } + +/* --- Tables --- */ +table { + border-collapse: collapse; + width: 100%; + display: block; + overflow: auto; + break-inside: avoid; +} +th, td { + border: 1px solid #d1d9e0; + padding: 0.5em 0.85em; + text-align: left; +} +th { background-color: #f6f8fa; font-weight: 600; } +tr:nth-child(2n) td { background-color: #f6f8fa; } + +/* --- Rules & images --- */ +hr { + height: 1px; + border: 0; + background-color: #d1d9e0; + margin: 1.8em 0; +} + +img { max-width: 100%; box-sizing: border-box; } + +/* pandoc wraps the --metadata title in a header; keep it unobtrusive. */ +header#title-block-header h1.title { margin-bottom: 0.8em; } diff --git a/macos/md2pdf.workflow/Contents/Info.plist b/macos/md2pdf.workflow/Contents/Info.plist new file mode 100644 index 0000000..a0b1f83 --- /dev/null +++ b/macos/md2pdf.workflow/Contents/Info.plist @@ -0,0 +1,32 @@ + + + + + NSServices + + + NSMenuItem + + default + Convert to PDF (md2pdf) + + NSMessage + runWorkflowAsService + NSRequiredContext + + NSApplicationIdentifier + com.apple.finder + + NSSendFileTypes + + net.daringfireball.markdown + public.plain-text + + NSSendTypes + + NSFilenamesPboardType + + + + + diff --git a/macos/md2pdf.workflow/Contents/document.wflow b/macos/md2pdf.workflow/Contents/document.wflow new file mode 100644 index 0000000..45fd263 --- /dev/null +++ b/macos/md2pdf.workflow/Contents/document.wflow @@ -0,0 +1,222 @@ + + + + + AMApplicationBuild + 521 + AMApplicationVersion + 2.10 + AMDocumentVersion + 2 + actions + + + action + + AMAccepts + + Container + List + Optional + + Types + + com.apple.cocoa.string + + + AMActionVersion + 2.0.3 + AMApplication + + Automator + + AMParameterProperties + + COMMAND_STRING + + CheckedForUserDefaultShell + + inputMethod + + shell + + source + + + AMProvides + + Container + List + Types + + com.apple.cocoa.string + + + ActionBundlePath + /System/Library/Automator/Run Shell Script.action + ActionName + Run Shell Script + ActionParameters + + COMMAND_STRING + # md2pdf Quick Action — convert each selected Markdown file to PDF. +# Files arrive as arguments ("$@") because inputMethod is set to 1. +tool="$HOME/bin/md2pdf" +[ -x "$tool" ] || tool="$HOME/dotfiles/bin/md2pdf" + +ok=0 +fail=0 +for f in "$@"; do + case "$f" in + *.md|*.markdown|*.mdown|*.mkd) + if "$tool" "$f"; then + ok=$((ok + 1)) + else + fail=$((fail + 1)) + fi + ;; + *) + # Skip anything that isn't Markdown. + ;; + esac +done + +if [ "$fail" -gt 0 ]; then + osascript -e "display notification \"$ok converted, $fail failed\" with title \"md2pdf\" sound name \"Basso\"" +elif [ "$ok" -gt 0 ]; then + noun="files" + if [ "$ok" -eq 1 ]; then noun="file"; fi + osascript -e "display notification \"$ok $noun converted to PDF\" with title \"md2pdf\"" +fi + + CheckedForUserDefaultShell + + inputMethod + 1 + shell + /bin/zsh + source + + + BundleIdentifier + com.apple.RunShellScript + CFBundleVersion + 2.0.3 + CanShowSelectedItemsWhenRun + + CanShowWhenRun + + Category + + AMCategoryUtilities + + Class Name + RunShellScriptAction + InputUUID + 2A1B3C4D-5E6F-4A7B-8C9D-0E1F2A3B4C5D + Keywords + + Shell + Script + Command + Run + Unix + + OutputUUID + 3B2C4D5E-6F7A-4B8C-9D0E-1F2A3B4C5D6E + UUID + 1A2B3C4D-5E6F-4A7B-8C9D-0E1F2A3B4C5E + arguments + + 0 + + default value + 0 + name + inputMethod + required + 0 + type + 0 + uuid + 0 + + 1 + + default value + + name + CheckedForUserDefaultShell + required + 0 + type + 0 + uuid + 1 + + 2 + + default value + + name + source + required + 0 + type + 0 + uuid + 2 + + 3 + + default value + /bin/sh + name + shell + required + 0 + type + 0 + uuid + 3 + + 4 + + default value + + name + COMMAND_STRING + required + 0 + type + 0 + uuid + 4 + + + isViewVisible + 1 + location + 309.000000:253.000000 + nibPath + /System/Library/Automator/Run Shell Script.action/Contents/Resources/Base.lproj/main.nib + + isViewVisible + 1 + + + connectors + + workflowMetaData + + serviceInputTypeIdentifier + com.apple.Automator.fileSystemObject + serviceOutputTypeIdentifier + com.apple.Automator.nothing + serviceProcessesInput + 0 + workflowTypeIdentifier + com.apple.Automator.servicesMenu + + +