Skip to content

Instantly share code, notes, and snippets.

@0xR
Last active June 16, 2026 14:27
Show Gist options
  • Select an option

  • Save 0xR/518b53d72c800071d26229ff308931a5 to your computer and use it in GitHub Desktop.

Select an option

Save 0xR/518b53d72c800071d26229ff308931a5 to your computer and use it in GitHub Desktop.
pbpaste-md — paste clipboard as clean Markdown on macOS. Converts RTF/HTML (Word, Pages, Confluence, Teams, browsers) via pandoc, stripping non-semantic spans, div wrappers, and Confluence prosemirror artifacts. Falls back to plain text.
#!/usr/bin/env bash
# pbpaste-md — paste the clipboard as Markdown, preserving formatting.
#
# Strategy:
# 1. If the clipboard has RTF (Word, Pages, TextEdit, Apple Mail, …) and
# `textutil` is available, convert RTF → HTML via textutil and then to
# Markdown via pandoc. RTF preserves list semantics that Word's HTML
# flavor mangles (it fakes bullets with <span style="font-family:Symbol">).
# 2. Else if the clipboard has HTML (Teams, Confluence, browsers, …),
# convert directly with pandoc.
# 3. Else, fall back to plain-text pbpaste.
#
# Both HTML inputs go through clean_html() before pandoc to remove non-
# semantic wrappers that GFM would otherwise pass through as raw HTML.
#
# Usage:
# pbpaste-md # markdown (or plain text fallback)
# pbpaste-md -r # raw HTML (RTF is converted to HTML if needed)
# pbpaste-md -t # force plain text (same as pbpaste)
# pbpaste-md -h # help
set -euo pipefail
usage() {
sed -n '2,21p' "$0" | sed 's/^# \{0,1\}//'
}
mode=md
while [ $# -gt 0 ]; do
case "$1" in
-r|--raw|--html) mode=html ;;
-t|--text) mode=text ;;
-h|--help) usage; exit 0 ;;
*) echo "pbpaste-md: unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
shift
done
clipboard_info=$(osascript -e 'clipboard info' 2>/dev/null || true)
has_html() { [[ "$clipboard_info" == *"class HTML"* ]]; }
has_rtf() { [[ "$clipboard_info" == *"class RTF"* ]]; }
dump() {
# dump <four-char-code> <out-file>
# Writes the requested clipboard flavor to $2 via AppleScript.
osascript \
-e "set f to (open for access (POSIX file \"$2\") with write permission)" \
-e "write (the clipboard as «class $1») to f" \
-e 'close access f' >/dev/null
}
# clean_html
# Reads HTML on stdin, writes cleaned HTML on stdout. Removes non-semantic
# wrappers that confuse pandoc's GFM output:
# 1. textutil heading paragraphs (large font, e.g. 16px) → <h2>.
# 2. Confluence/prosemirror inline code: <span class="code" …>X</span> →
# <code>X</code>.
# 3. Confluence emoji placeholders: <span data-emoji-text="X" …></span> → X.
# 3b. Bold spans (style="font-weight:600|700|bold") → <strong>, so emphasis
# survives the blanket span strip below.
# 4. Strip ALL remaining <span> open AND close tags independently (color
# marks, mention pills, prosemirror marks, Apple-converted-space, sN, and
# Microsoft Loop/Scriptor's nested attribution wrappers, etc.) — keeping
# content. Stripping open/close separately (rather than as matched pairs)
# is essential: Loop nests spans many levels deep, and a paired
# non-greedy match leaves stray outer open tags behind.
# 5. Strip <div …> and </div> wrapper tags (preserving inner content) —
# Confluence's fabric-editor layout divs and textutil's loose-text divs
# both belong here. Pandoc otherwise passes them through verbatim.
# 6. Drop the "code-block" class on <pre>/<code> so pandoc doesn't emit it
# as a code-fence info string.
# 7. Trim whitespace inside <p> after the rewrites.
# 8. Group runs of "<p>• text</p>" faux-bullet paragraphs into <ul>.
clean_html() {
perl -CSD -0777 -pe '
# Drop HTML comments (e.g. Scriptor'\''s <!--ScriptorStartFragment-->).
s{<!--.*?-->}{}gs;
my %heading;
if (/<style[^>]*>(.*?)<\/style>/s) {
my $css = $1;
while ($css =~ /p\.([A-Za-z0-9_-]+)\s*\{[^}]*font:\s*([0-9.]+)px/g) {
$heading{$1} = 1 if $2 >= 14;
}
}
for my $cls (keys %heading) {
s{<p class="\Q$cls\E">(.*?)</p>}{<h2>$1</h2>}gs;
}
# Inline code spans (Confluence prosemirror): match a <span> whose class
# attribute is exactly "code" or starts with "code ".
s{<span\b([^>]*\bclass="(?:code|code\s[^"]*)"[^>]*)>(.*?)</span>}{<code>$2</code>}gs;
# Strip the "code-block" class on <pre>/<code> so it isn'\''t turned into
# a code-fence language by pandoc.
s{(<(?:pre|code)\b[^>]*\bclass=")([^"]*)(\b)code-block\b}{$1$2$3}gs;
# Emoji placeholder spans (Confluence): extract the emoji character.
# Must run before the general span strip so the text value is recovered.
s{<span\b[^>]*\bdata-emoji-text="([^"]*)"[^>]*>.*?</span>}{$1}gs;
# Bold spans → <strong> so emphasis survives the blanket strip below.
# Matches font-weight: bold | 600 | 700 | 800 | 900.
s{<span\b[^>]*\bstyle="[^"]*font-weight:\s*(?:bold|[6-9]00)[^"]*"[^>]*>(.*?)</span>}{<strong>$1</strong>}gs;
# Strip ALL remaining <span> open AND close tags independently, keeping
# inner content. Done separately (not as matched pairs) so deeply nested
# wrappers — e.g. Microsoft Loop/Scriptor attribution spans — are removed
# cleanly instead of leaving orphaned outer open tags behind.
s{</?span\b[^>]*>}{}gs;
# Reduce <a> tags to just their href. Pandoc passes anchors through as raw
# HTML when they carry attributes it does not recognise (e.g. Microsoft
# Loop'\''s unfurl / propertyattribution); with href alone it emits a clean
# [text](url) link. Anchors with no href (bookmarks) lose their tag.
s{<a\b[^>]*?\b(href="[^"]*")[^>]*>}{<a $1>}gs;
s{<a\b(?![^>]*\bhref=)[^>]*>(.*?)</a>}{$1}gs;
# Drop <div …> and </div> wrappers entirely; keep their content. Pandoc
# otherwise re-emits them as raw HTML in GFM output.
s{</?div\b[^>]*>}{}gs;
# Trim whitespace that the rewrites left at the start/end of <p>.
s{(<p[^>]*>)\s+}{$1}gs;
s{\s+(</p>)}{$1}gs;
# Group consecutive faux-bullet "<p>• text</p>" paragraphs into a <ul>.
s{((?:<p[^>]*>\s*[\x{2022}\x{00B7}\x{25E6}\x{25AA}\x{25CF}]\s*.*?</p>\s*)+)}{
my $block = $1;
my @items;
while ($block =~ m{<p[^>]*>\s*[\x{2022}\x{00B7}\x{25E6}\x{25AA}\x{25CF}]\s*(.*?)</p>}gs) {
my $item = $1;
$item =~ s/^\s+|\s+$//g;
push @items, " <li>$item</li>";
}
"<ul>\n" . join("\n", @items) . "\n</ul>\n"
}gse;
'
}
# html_to_md <html-path>
# clean_html → pandoc → minor whitespace cleanup.
html_to_md() {
clean_html < "$1" \
| pandoc -f html -t gfm --wrap=none \
| awk 'BEGIN{blank=0} /^[[:space:]]*$/{blank++; if(blank==1) print ""; next} {blank=0; print}'
}
case "$mode" in
text)
exec pbpaste
;;
html)
tmp=$(mktemp -d -t pbpaste-md.XXXXXX)
trap 'rm -rf "$tmp"' EXIT
if has_html; then
dump 'HTML' "$tmp/c.html"
clean_html < "$tmp/c.html"
elif has_rtf && command -v textutil >/dev/null 2>&1; then
dump 'RTF ' "$tmp/c.rtf"
textutil -convert html "$tmp/c.rtf" -stdout 2>/dev/null | clean_html
else
echo "pbpaste-md: no HTML or RTF on clipboard" >&2
exit 1
fi
;;
md)
if ! command -v pandoc >/dev/null 2>&1; then
echo "pbpaste-md: pandoc not on PATH; install with 'brew install pandoc'. Falling back to plain text." >&2
exec pbpaste
fi
tmp=$(mktemp -d -t pbpaste-md.XXXXXX)
trap 'rm -rf "$tmp"' EXIT
# Prefer RTF when present — Word's HTML mangles list structure into
# styled spans, but its RTF carries real list metadata that textutil
# turns into proper <ul><li>.
if has_rtf && command -v textutil >/dev/null 2>&1; then
dump 'RTF ' "$tmp/c.rtf"
textutil -convert html "$tmp/c.rtf" -stdout 2>/dev/null > "$tmp/c.html"
html_to_md "$tmp/c.html"
elif has_html; then
dump 'HTML' "$tmp/c.html"
html_to_md "$tmp/c.html"
else
exec pbpaste
fi
;;
esac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment