Last active
June 16, 2026 14:27
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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