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:
Executable
+152
@@ -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
|
||||
Executable
+65
@@ -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 <<EOF
|
||||
|
||||
Installed. To use it:
|
||||
• In Finder, right-click one or more .md files
|
||||
• Choose Quick Actions ▸ Convert to PDF (md2pdf) (may be under a "..." submenu)
|
||||
|
||||
If it doesn't appear immediately, toggle it on in:
|
||||
System Settings ▸ Keyboard ▸ Keyboard Shortcuts… ▸ Services ▸ Files and Folders
|
||||
(or log out and back in to fully refresh the Services menu).
|
||||
EOF
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/* md2pdf default stylesheet — clean, GitHub-flavored, print-friendly. */
|
||||
|
||||
@page {
|
||||
margin: 2cm 1.9cm;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
|
||||
sans-serif;
|
||||
line-height: 1.55;
|
||||
color: #1f2328;
|
||||
max-width: 46em;
|
||||
margin: 0 auto;
|
||||
padding: 0 0.5em;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* --- Headings --- */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 1.4em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
h1 { font-size: 1.9em; border-bottom: 1px solid #d1d9e0; padding-bottom: 0.3em; }
|
||||
h2 { font-size: 1.45em; border-bottom: 1px solid #d1d9e0; padding-bottom: 0.3em; }
|
||||
h3 { font-size: 1.2em; }
|
||||
h4 { font-size: 1em; }
|
||||
h5 { font-size: 0.9em; }
|
||||
h6 { font-size: 0.85em; color: #59636e; }
|
||||
h1:first-child, h2:first-child, h3:first-child { margin-top: 0; }
|
||||
|
||||
/* Keep a heading with the content that follows it. */
|
||||
h1, h2, h3, h4, h5, h6 { break-after: avoid; }
|
||||
|
||||
/* --- Body elements --- */
|
||||
p, ul, ol, dl, table, pre, blockquote { margin-top: 0; margin-bottom: 1em; }
|
||||
|
||||
a { color: #0969da; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
strong { font-weight: 600; }
|
||||
|
||||
ul, ol { padding-left: 2em; }
|
||||
li + li { margin-top: 0.25em; }
|
||||
li > 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; }
|
||||
Reference in New Issue
Block a user