Skip to content

Instantly share code, notes, and snippets.

@schwartz1375
Last active August 26, 2026 16:42
Show Gist options
  • Select an option

  • Save schwartz1375/c311c2434d9f7d61ab18cbe57a565f9f to your computer and use it in GitHub Desktop.

Select an option

Save schwartz1375/c311c2434d9f7d61ab18cbe57a565f9f to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
# Extracts each ```mermaid fence from a Markdown file and renders it as a
# standalone image file. Companion to md_to_pdf.sh (same toolchain, same
# install steps) for when you want just the figure, not a full-document PDF
# that can split a tall diagram across pages.
#
# Usage: ./extract_mermaid.sh <input.md> [svg|png|pdf|both] [outdir]
# both -> svg AND png (default): svg is the vector render kept beside the
# .md (PDF pipeline, GitHub preview); png (3x) is what a pandoc
# docx build must embed. A pandoc-built .docx with a bare svg and
# no rsvg-convert fallback shows EMPTY FRAMES in Word, so any
# document destined for .docx needs the png twin.
# svg -> vector only; fine for manual Insert > Pictures in Word 365,
# NOT sufficient for pandoc docx builds (see above)
# png -> raster only at 3x scale; for slides or chat
# pdf -> one diagram per file, scaled to fit a single page (--pdfFit)
#
# Output naming: <input-basename>-1.<fmt>, -2, ... (one per mermaid fence,
# in document order). Spaces in the input name become underscores.
#
# v3 changes (August 2026):
# - `both` mode added and made the default. Discovered while building the
# Authorization-Derived Topology docx: pandoc embeds a bare svg with no
# raster fallback when rsvg-convert is absent, and Word renders those as
# blank frames. Rendering svg+png together makes the docx-safe asset
# exist by default instead of being a rescue step.
#
# v2: ${EXTRA[@]+...} guard — macOS ships bash 3.2, where expanding an empty
# array under `set -u` is an "unbound variable" error (fixed upstream in 4.4).
set -euo pipefail
[[ $# -lt 1 ]] && { echo "Usage: $0 <input.md> [svg|png|pdf|both] [outdir]"; exit 1; }
[[ -f "$1" ]] || { echo "Error: $1 not found"; exit 1; }
INPUT="$1"
FMT="${2:-both}"
case "$FMT" in svg|png|pdf) FORMATS="$FMT";; both) FORMATS="svg png";; *) echo "Error: format must be svg, png, pdf, or both"; exit 1;; esac
OUTDIR_RAW="${3:-.}"
mkdir -p "$OUTDIR_RAW"
OUTDIR="$(cd "$OUTDIR_RAW" && pwd)"
CHROME="$(ls -d "$HOME/.cache/puppeteer/chrome-headless-shell"/*/chrome-headless-shell-mac-arm64/chrome-headless-shell 2>/dev/null | sort -V | tail -1)"
command -v mmdc &>/dev/null || { echo "Error: mmdc not found. Run: npm install -g @mermaid-js/mermaid-cli"; exit 1; }
[[ -x "${CHROME:-}" ]] || { echo "Error: chrome-headless-shell not found. Run: npx puppeteer browsers install chrome-headless-shell"; exit 1; }
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
# spaces -> '_' so mmdc's generated filenames stay predictable
BASENAME="$(basename "${INPUT%.md}" | tr ' ' '_')"
PUPPETEER_CFG="$WORK_DIR/puppeteer_config.json"
printf '{"executablePath": "%s", "args": ["--no-sandbox"]}\n' "$CHROME" > "$PUPPETEER_CFG"
shopt -s nullglob
TOTAL=0
for F in $FORMATS; do
EXTRA=()
[[ "$F" == "png" ]] && EXTRA+=(--scale 3)
[[ "$F" == "pdf" ]] && EXTRA+=(--pdfFit)
echo "-> Rendering Mermaid fences from $(basename "$INPUT") as $F..."
mmdc \
-i "$INPUT" \
-o "$WORK_DIR/${BASENAME}.md" \
-e "$F" \
-b white \
-p "$PUPPETEER_CFG" \
${EXTRA[@]+"${EXTRA[@]}"} \
--quiet
FILES=( "$WORK_DIR/${BASENAME}"-*."$F" )
[[ ${#FILES[@]} -eq 0 ]] && { echo "Error: no \`\`\`mermaid fences found in $INPUT"; exit 1; }
for f in "${FILES[@]}"; do
cp "$f" "$OUTDIR/"
echo "OK Saved: $OUTDIR/$(basename "$f") ($(du -h "$f" | cut -f1))"
done
TOTAL=$((TOTAL + ${#FILES[@]}))
done
echo "Done: $TOTAL file(s) written to $OUTDIR"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment