#!/usr/bin/env bash set -euo pipefail # pdfify — Convert Markdown to beautiful PDF via Docker # Supports: images, mermaid diagrams, tables, code blocks, Obsidian callouts # Usage: ./pdfify [file2.md ...] [options] VERSION="1.2.0" IMAGE_NAME="pdfify" GIST_ID="23f4514a1f0da1347d3f89926c23b68f" GIST_RAW="https://gist.githubusercontent.com/jclement/${GIST_ID}/raw/pdfify.sh" SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" # --- Colors --- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' MAGENTA='\033[0;35m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' # --- Portable SHA-256 (macOS has shasum, Linux often has sha256sum) --- _sha256() { shasum -a 256 "$@" 2>/dev/null || sha256sum "$@"; } info() { echo -e "${BLUE}::${RESET} ${BOLD}$*${RESET}"; } success() { echo -e "${GREEN}✓${RESET} $*"; } warn() { echo -e "${YELLOW}⚠${RESET} $*"; } detail() { echo -e " ${DIM}→${RESET} $*"; } header() { echo -e "\n${MAGENTA}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"; echo -e "${MAGENTA} ${BOLD}$*${RESET}"; echo -e "${MAGENTA}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n"; } # --- Self-update --- do_update() { info "Updating pdfify from gist..." local raw_url tmp raw_url=$(curl -fsSL "https://api.github.com/gists/${GIST_ID}" 2>/dev/null | grep '"raw_url"' | head -1 | sed 's/.*"raw_url": *"//;s/".*//') [[ -z "$raw_url" ]] && raw_url="$GIST_RAW" tmp=$(mktemp) if curl -fsSL "$raw_url" -o "$tmp" 2>/dev/null; then if [[ -s "$tmp" ]] && head -1 "$tmp" | grep -q '^#!/'; then chmod +x "$tmp" mv "$tmp" "$SELF" success "Updated to latest version" detail "${CYAN}${SELF}${RESET}" else rm -f "$tmp" echo -e "${RED}Error:${RESET} Downloaded file doesn't look like a script" exit 1 fi else rm -f "$tmp" echo -e "${RED}Error:${RESET} Failed to download update" exit 1 fi exit 0 } check_for_update() { local remote_hash local_hash raw_url raw_url=$(curl -fsSL --connect-timeout 2 --max-time 3 "https://api.github.com/gists/${GIST_ID}" 2>/dev/null | grep '"raw_url"' | head -1 | sed 's/.*"raw_url": *"//;s/".*//') || return 0 [[ -z "$raw_url" ]] && return 0 remote_hash=$(curl -fsSL --connect-timeout 2 --max-time 5 "$raw_url" 2>/dev/null | _sha256 | cut -d' ' -f1) || return 0 local_hash=$(_sha256 < "$SELF" | cut -d' ' -f1) if [[ -n "$remote_hash" && "$remote_hash" != "$local_hash" ]]; then echo -e "${YELLOW}⚠${RESET} ${DIM}A newer version of pdfify is available. Run ${CYAN}pdfify --update${DIM} to upgrade.${RESET}" fi } # --- Args (CLI overrides frontmatter; "" means "use frontmatter default") --- REBUILD=0 WATCH=0 OPEN=0 PREVIEW=0 OUT_FILE="" NEXT_KEY="" POSITIONAL=() # CLI overrides — empty string means "not set, defer to frontmatter" CLI_TOC_LEVEL="" CLI_NUMBERS="" CLI_NUMBER_FROM="" CLI_TITLE="" CLI_SUBTITLE="" CLI_AUTHOR="" CLI_HEADER="" CLI_FOOTER="" CLI_DATE="" CLI_WATERMARK="" for arg in "$@"; do if [[ -n "$NEXT_KEY" ]]; then case "$NEXT_KEY" in toc-level) CLI_TOC_LEVEL="$arg" ;; number-from) CLI_NUMBER_FROM="$arg" ;; out) OUT_FILE="$arg" ;; title) CLI_TITLE="$arg" ;; subtitle) CLI_SUBTITLE="$arg" ;; author) CLI_AUTHOR="$arg" ;; header) CLI_HEADER="$arg" ;; footer) CLI_FOOTER="$arg" ;; date) CLI_DATE="$arg" ;; watermark) CLI_WATERMARK="$arg" ;; esac NEXT_KEY="" continue fi case "$arg" in --rebuild) REBUILD=1 ;; --update) do_update ;; --watch) WATCH=1 ;; --open) OPEN=1 ;; --preview) PREVIEW=1; OPEN=1 ;; --no-numbers) CLI_NUMBERS="false" ;; --numbers) CLI_NUMBERS="true" ;; --clean) echo -e "${BLUE}::${RESET} ${BOLD}Removing Docker image ${CYAN}${IMAGE_NAME}${RESET}..." docker rmi "$IMAGE_NAME" >/dev/null 2>&1 && echo -e "${GREEN}✓${RESET} Image removed" || echo -e "${DIM}Image not found${RESET}" exit 0 ;; --toc-level) NEXT_KEY="toc-level" ;; --toc-level=*) CLI_TOC_LEVEL="${arg#*=}" ;; --number-from) NEXT_KEY="number-from" ;; --number-from=*) CLI_NUMBER_FROM="${arg#*=}" ;; --out) NEXT_KEY="out" ;; --out=*) OUT_FILE="${arg#*=}" ;; --title) NEXT_KEY="title" ;; --title=*) CLI_TITLE="${arg#*=}" ;; --subtitle) NEXT_KEY="subtitle" ;; --subtitle=*) CLI_SUBTITLE="${arg#*=}" ;; --author) NEXT_KEY="author" ;; --author=*) CLI_AUTHOR="${arg#*=}" ;; --header) NEXT_KEY="header" ;; --header=*) CLI_HEADER="${arg#*=}" ;; --footer) NEXT_KEY="footer" ;; --footer=*) CLI_FOOTER="${arg#*=}" ;; --date) NEXT_KEY="date" ;; --date=*) CLI_DATE="${arg#*=}" ;; --watermark) NEXT_KEY="watermark" ;; --watermark=*) CLI_WATERMARK="${arg#*=}" ;; --version) echo "pdfify v${VERSION}"; exit 0 ;; --help|-h) echo -e "${BOLD}pdfify${RESET} v${VERSION} — Markdown to PDF" echo "" echo -e "${BOLD}Usage:${RESET} pdfify ${CYAN} [file2.md ...]${RESET} [options]" echo "" echo -e "${BOLD}Options:${RESET}" echo -e " ${DIM}--out FILE${RESET} Output file (single input only)" echo -e " ${DIM}--toc-level N${RESET} TOC depth: 0=none, 1=H1, 2=H2, 3=H3 (default: 3)" echo -e " ${DIM}--numbers${RESET} Enable numbered headings (default)" echo -e " ${DIM}--no-numbers${RESET} Disable numbered headings" echo -e " ${DIM}--number-from N${RESET} Start numbering at heading level N (default: 2)" echo -e " ${DIM}--open${RESET} Open PDF after generation" echo -e " ${DIM}--preview${RESET} Render to /tmp and open (no permanent file)" echo -e " ${DIM}--watch${RESET} Watch for changes and regenerate" echo -e " ${DIM}--rebuild${RESET} Force rebuild the Docker image" echo -e " ${DIM}--clean${RESET} Remove the Docker image" echo -e " ${DIM}--update${RESET} Update pdfify to latest version from gist" echo -e " ${DIM}--version${RESET} Show version" echo "" echo -e "${BOLD}Overrides${RESET} (CLI trumps frontmatter):" echo -e " ${DIM}--title TEXT${RESET} ${DIM}--subtitle TEXT${RESET}" echo -e " ${DIM}--author TEXT${RESET} ${DIM}--header TEXT${RESET}" echo -e " ${DIM}--footer TEXT${RESET} ${DIM}--date TEXT${RESET}" echo -e " ${DIM}--watermark TEXT${RESET}" echo "" echo -e "${BOLD}Frontmatter:${RESET}" echo -e " title, subtitle, author, header, footer, toc-level, date," echo -e " numbersections (true/false), numberfrom (1-4), watermark" exit 0 ;; *) POSITIONAL+=("$arg") ;; esac done if [[ ${#POSITIONAL[@]} -lt 1 ]]; then echo -e "${BOLD}Usage:${RESET} pdfify ${CYAN} [file2.md ...]${RESET} [options]" echo -e " Run ${CYAN}pdfify --help${RESET} for all options" exit 1 fi if [[ -n "$OUT_FILE" && ${#POSITIONAL[@]} -gt 1 ]]; then echo -e "${RED}Error:${RESET} --out cannot be used with multiple input files" exit 1 fi # --- Open helper --- open_pdf() { local pdf="$1" if command -v open >/dev/null 2>&1; then open "$pdf" elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$pdf" fi } header "pdfify v${VERSION}" # --- Embedded Dockerfile --- DOCKERFILE=$(cat <<'DOCKERFILE_END' FROM node:20-slim ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update -qq && \ apt-get install -y --no-install-recommends \ pandoc \ texlive-latex-recommended \ texlive-latex-extra \ texlive-fonts-recommended \ texlive-fonts-extra \ texlive-xetex \ lmodern \ librsvg2-bin \ chromium \ ca-certificates \ fonts-liberation \ fonts-roboto \ fonts-roboto-unhinted \ fonts-noto-color-emoji \ wget \ fontconfig \ && rm -rf /var/lib/apt/lists/* RUN mkdir -p /usr/share/fonts/truetype/roboto-mono && \ for style in Regular Bold Italic BoldItalic Medium MediumItalic Light LightItalic; do \ wget -q "https://github.com/googlefonts/RobotoMono/raw/main/fonts/ttf/RobotoMono-${style}.ttf" \ -O "/usr/share/fonts/truetype/roboto-mono/RobotoMono-${style}.ttf" 2>/dev/null || true; \ done && \ fc-cache -f RUN npm install -g @mermaid-js/mermaid-cli ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV CHROME_PATH=/usr/bin/chromium RUN echo '{"maxTextSize": 90000, "flowchart": {"useMaxWidth": true}, "theme": "default"}' > /opt/mermaid-config.json RUN echo '{"args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]}' > /opt/puppeteer-config.json WORKDIR /work ENTRYPOINT ["/bin/bash"] DOCKERFILE_END ) # --- Build Docker image --- echo "" if [[ $REBUILD -eq 1 ]]; then info "Removing existing Docker image ${CYAN}${IMAGE_NAME}${RESET}..." docker rmi "$IMAGE_NAME" >/dev/null 2>&1 || true success "Image removed" fi if docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then info "Docker image ${GREEN}${IMAGE_NAME}${RESET} found ${DIM}(cached)${RESET}" success "Reusing existing image" else info "Building Docker image ${CYAN}${IMAGE_NAME}${RESET}..." detail "Installing: pandoc, XeLaTeX, mermaid-cli, Chromium, fonts" detail "This takes 2-3 minutes on first run (cached after)" echo "" BUILD_CTX=$(mktemp -d) echo "$DOCKERFILE" | DOCKER_BUILDKIT=0 docker build -t "$IMAGE_NAME" -f - "$BUILD_CTX" 2>&1 | while IFS= read -r line; do if [[ "$line" =~ ^Step\ ([0-9]+)/([0-9]+) ]]; then echo -e " ${CYAN}[${BASH_REMATCH[1]}/${BASH_REMATCH[2]}]${RESET} ${DIM}${line#*: }${RESET}" elif [[ "$line" == *"Successfully tagged"* ]]; then echo -e " ${GREEN}${line}${RESET}" elif [[ "$line" == *"ERROR"* || "$line" == *"error"* ]]; then echo -e " ${RED}${line}${RESET}" fi done rm -rf "$BUILD_CTX" if ! docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then echo -e "\n${RED}Docker build failed. Re-running with full output:${RESET}\n" BUILD_CTX=$(mktemp -d) echo "$DOCKERFILE" | DOCKER_BUILDKIT=0 docker build -t "$IMAGE_NAME" -f - "$BUILD_CTX" rm -rf "$BUILD_CTX" exit 1 fi success "Docker image built" fi # === Per-file conversion === convert_file() { local INPUT_PATH="$1" local OUTPUT_OVERRIDE="$2" # --- Resolve paths --- local INPUT INPUT_DIR INPUT_FILE OUTPUT OUTPUT_DIR OUTPUT_FILE INPUT="$(cd "$(dirname "$INPUT_PATH")" && pwd)/$(basename "$INPUT_PATH")" if [[ ! -f "$INPUT" ]]; then echo -e "${RED}Error:${RESET} $INPUT_PATH not found" return 1 fi INPUT_DIR="$(dirname "$INPUT")" INPUT_FILE="$(basename "$INPUT")" OUTPUT="${OUTPUT_OVERRIDE:-${INPUT%.md}.pdf}" OUTPUT_DIR="$(cd "$(dirname "$OUTPUT")" 2>/dev/null && pwd || (mkdir -p "$(dirname "$OUTPUT")" && cd "$(dirname "$OUTPUT")" && pwd))" OUTPUT="${OUTPUT_DIR}/$(basename "$OUTPUT")" OUTPUT_FILE="$(basename "$OUTPUT")" # Preview mode: write temp file in input dir (Docker-mountable), move to /tmp after local PREVIEW_FINAL="" if [[ $PREVIEW -eq 1 ]]; then local base="${INPUT_FILE%.md}" PREVIEW_FINAL="/tmp/pdfify-preview-${base}.pdf" OUTPUT_FILE=".pdfify-preview-${base}.pdf" OUTPUT="${OUTPUT_DIR}/${OUTPUT_FILE}" fi info "Input: ${CYAN}${INPUT}${RESET}" if [[ -n "$PREVIEW_FINAL" ]]; then info "Output: ${CYAN}${PREVIEW_FINAL}${RESET} ${DIM}(preview)${RESET}" else info "Output: ${CYAN}${OUTPUT}${RESET}" fi # --- Parse YAML frontmatter --- local FM_TITLE="" FM_SUBTITLE="" FM_AUTHOR="" FM_FOOTER="" FM_HEADER="" local FM_TOC_LEVEL="" FM_DATE="" FM_NUMBERSECTIONS="" FM_NUMBERFROM="" FM_WATERMARK="" local FM_DATE_HASH="" FM_DATE_DIRTY="" FM_DATE_LABEL="" if head -1 "$INPUT" | grep -q '^---'; then local FM_BLOCK FM_BLOCK=$(awk 'NR==1 && /^---/{found=1; next} found && /^---/{exit} found{print}' "$INPUT") extract_fm() { echo "$FM_BLOCK" | sed -n "s/^$1:[[:space:]]*//p" | sed 's/^["'"'"']\(.*\)["'"'"']$/\1/'; } FM_TITLE=$(extract_fm "title") FM_AUTHOR=$(extract_fm "author") FM_SUBTITLE=$(extract_fm "subtitle") FM_FOOTER=$(extract_fm "footer") FM_HEADER=$(extract_fm "header") FM_TOC_LEVEL=$(extract_fm "toc-level") FM_DATE=$(extract_fm "date") FM_NUMBERSECTIONS=$(extract_fm "numbersections") FM_NUMBERFROM=$(extract_fm "numberfrom") FM_WATERMARK=$(extract_fm "watermark") fi # --- CLI overrides frontmatter --- [[ -n "$CLI_TITLE" ]] && FM_TITLE="$CLI_TITLE" [[ -n "$CLI_SUBTITLE" ]] && FM_SUBTITLE="$CLI_SUBTITLE" [[ -n "$CLI_AUTHOR" ]] && FM_AUTHOR="$CLI_AUTHOR" [[ -n "$CLI_FOOTER" ]] && FM_FOOTER="$CLI_FOOTER" [[ -n "$CLI_HEADER" ]] && FM_HEADER="$CLI_HEADER" [[ -n "$CLI_DATE" ]] && FM_DATE="$CLI_DATE" [[ -n "$CLI_WATERMARK" ]] && FM_WATERMARK="$CLI_WATERMARK" [[ -n "$CLI_TOC_LEVEL" ]] && FM_TOC_LEVEL="$CLI_TOC_LEVEL" [[ -n "$CLI_NUMBER_FROM" ]] && FM_NUMBERFROM="$CLI_NUMBER_FROM" [[ -n "$CLI_NUMBERS" ]] && FM_NUMBERSECTIONS="$CLI_NUMBERS" # --- Auto-detect document structure --- # Count H1 headings (outside code blocks) local H1_COUNT=0 IN_CODE_SCAN=0 FIRST_H1_TEXT="" while IFS= read -r scanline || [[ -n "$scanline" ]]; do [[ "$scanline" =~ ^\`\`\` ]] && { if [[ $IN_CODE_SCAN -eq 0 ]]; then IN_CODE_SCAN=1; else IN_CODE_SCAN=0; fi; continue; } if [[ $IN_CODE_SCAN -eq 0 && "$scanline" =~ ^#\ ]]; then H1_COUNT=$((H1_COUNT + 1)) [[ $H1_COUNT -eq 1 ]] && FIRST_H1_TEXT="${scanline#\# }" fi done < "$INPUT" local FILE_TOC_LEVEL="${FM_TOC_LEVEL:-3}" local FILE_NUMBERS=1 [[ "$FM_NUMBERSECTIONS" == "false" ]] && FILE_NUMBERS=0 # Auto-determine numberfrom based on structure (if not explicitly set) local FILE_NUMBER_FROM="${FM_NUMBERFROM:-}" local HIDE_FIRST_H1=0 if [[ -z "$FILE_NUMBER_FROM" ]]; then if [[ $H1_COUNT -eq 1 ]]; then # Single H1 = document title; number from H2, hide H1 in body FILE_NUMBER_FROM=2 HIDE_FIRST_H1=1 # Use H1 text as title if no title set [[ -z "$FM_TITLE" ]] && FM_TITLE="$FIRST_H1_TEXT" detail "Auto: ${DIM}single H1 detected → using as title, numbering from H2${RESET}" else # Multiple H1s = sections; number from H1 FILE_NUMBER_FROM=1 detail "Auto: ${DIM}${H1_COUNT} H1s detected → numbering from H1${RESET}" fi fi # Default date: current date/time # Set to "none" in frontmatter or --date to suppress FM_DATE_HASH="${FM_DATE_HASH:-}" FM_DATE_DIRTY="${FM_DATE_DIRTY:-}" if [[ "$FM_DATE" == "none" || "$FM_DATE" == "false" ]]; then FM_DATE="" elif [[ -z "$FM_DATE" && -z "$CLI_DATE" ]]; then FM_DATE="$(date +"%Y-%m-%d %H:%M")" fi echo "" [[ -n "$FM_TITLE" ]] && detail "Title: ${CYAN}${FM_TITLE}${RESET}" [[ -n "$FM_SUBTITLE" ]] && detail "Subtitle: ${CYAN}${FM_SUBTITLE}${RESET}" [[ -n "$FM_AUTHOR" ]] && detail "Author: ${CYAN}${FM_AUTHOR}${RESET}" [[ -n "$FM_HEADER" ]] && detail "Header: ${CYAN}${FM_HEADER}${RESET}" [[ -n "$FM_FOOTER" ]] && detail "Footer: ${CYAN}${FM_FOOTER}${RESET}" detail "Date: ${CYAN}${FM_DATE}${RESET}" detail "TOC: ${CYAN}level ${FILE_TOC_LEVEL}${RESET}" detail "Numbered: ${CYAN}$([ $FILE_NUMBERS -eq 1 ] && echo "yes (from H${FILE_NUMBER_FROM})" || echo no)${RESET}" [[ -n "$FM_WATERMARK" ]] && detail "Watermark: ${CYAN}${FM_WATERMARK}${RESET}" # --- Git hash for source file --- local GIT_STAMP="" # --- Discover images referenced in the markdown --- echo "" info "Scanning ${CYAN}${INPUT_FILE}${RESET} for assets..." IMAGES=() while IFS= read -r img; do [[ -z "$img" ]] && continue [[ "$img" =~ ^https?:// ]] && continue if [[ -f "$INPUT_DIR/$img" ]]; then IMAGES+=("$img") success "Image: ${CYAN}${img}${RESET} ${DIM}($(du -h "$INPUT_DIR/$img" | cut -f1 | tr -d ' '))${RESET}" else warn "Image: ${YELLOW}${img}${RESET} ${RED}(not found)${RESET}" fi done < <(sed -n 's/.*!\[[^]]*\](\([^)]*\)).*/\1/p' "$INPUT"; sed -n 's/.*src="\([^"]*\)".*/\1/p' "$INPUT") MERMAID_COUNT=$(grep -c '```mermaid' "$INPUT" || true) if [[ $MERMAID_COUNT -gt 0 ]]; then success "Mermaid diagrams: ${CYAN}${MERMAID_COUNT}${RESET}" fi CALLOUT_COUNT=$(grep -c '> \[!' "$INPUT" || true) if [[ $CALLOUT_COUNT -gt 0 ]]; then success "Callouts: ${CYAN}${CALLOUT_COUNT}${RESET}" fi TABLE_COUNT=$(grep -c '^|' "$INPUT" || true) CODE_COUNT=$(grep -c '```' "$INPUT" || true) CODE_COUNT=$(( (CODE_COUNT - MERMAID_COUNT * 2) / 2 )) [[ $TABLE_COUNT -gt 0 ]] && detail "Tables: ${TABLE_COUNT} rows" [[ $CODE_COUNT -gt 0 ]] && detail "Code blocks: ~${CODE_COUNT}" echo "" info "Found ${GREEN}${#IMAGES[@]}${RESET} image(s), ${GREEN}${MERMAID_COUNT}${RESET} mermaid diagram(s), ${GREEN}${CALLOUT_COUNT}${RESET} callout(s)" # --- Write the conversion script to a temp file (mounted into Docker) --- CONVERT_SCRIPT="${INPUT_DIR}/.pdfify-convert-$$.sh" trap 'rm -f "$CONVERT_SCRIPT"' EXIT cat > "$CONVERT_SCRIPT" <<'INNER_SCRIPT' #!/bin/bash set -euo pipefail RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' info() { echo -e "${BLUE}::${RESET} ${BOLD}$*${RESET}"; } success() { echo -e "${GREEN}✓${RESET} $*"; } detail() { echo -e " ${DIM}→${RESET} $*"; } INPUT_FILE="$1" OUTPUT_FILE="$2" WORKDIR="/work" cd "$WORKDIR" # --- Step 0: Strip first H1 if it's being used as document title --- HIDE_FIRST_H1="${HIDE_FIRST_H1:-0}" EFFECTIVE_INPUT="$INPUT_FILE" if [[ "$HIDE_FIRST_H1" == "1" ]]; then STRIPPED=$(mktemp /tmp/pdfify-stripped-XXXXXX.md) FOUND_H1=0 IN_CODE_BLK=0 IN_FMATTER=0 while IFS= read -r line || [[ -n "$line" ]]; do [[ "$line" =~ ^\`\`\` ]] && { if [[ $IN_CODE_BLK -eq 0 ]]; then IN_CODE_BLK=1; else IN_CODE_BLK=0; fi; } if [[ "$line" == "---" && $IN_CODE_BLK -eq 0 ]]; then if [[ $IN_FMATTER -eq 0 && $FOUND_H1 -eq 0 ]]; then IN_FMATTER=1; else IN_FMATTER=0; fi fi # Skip the first H1 (and any blank line immediately after) if [[ $FOUND_H1 -eq 0 && $IN_CODE_BLK -eq 0 && $IN_FMATTER -eq 0 && "$line" =~ ^#\ ]]; then FOUND_H1=1 continue fi # Skip blank line right after removed H1 if [[ $FOUND_H1 -eq 1 && -z "$line" ]]; then FOUND_H1=2 continue fi [[ $FOUND_H1 -eq 1 ]] && FOUND_H1=2 echo "$line" >> "$STRIPPED" done < "$INPUT_FILE" EFFECTIVE_INPUT="$(basename "$STRIPPED")" detail "Stripped first H1 (promoted to title)" fi # --- Step 1: Pre-process Obsidian callouts --- info "Pre-processing callouts..." CALLOUT_MD=$(mktemp /tmp/pdfify-callout-XXXXXX.md) IN_CALLOUT=0 CALLOUT_TYPE="" CALLOUT_TITLE="" CALLOUT_BUF="" CALLOUT_COUNT=0 flush_callout() { if [[ $IN_CALLOUT -eq 1 && -n "$CALLOUT_TYPE" ]]; then CALLOUT_COUNT=$((CALLOUT_COUNT + 1)) local latex_type case "${CALLOUT_TYPE,,}" in info|note) latex_type="calloutinfo" ;; tip|hint) latex_type="callouttip" ;; warning|caution) latex_type="calloutwarning" ;; danger|error|bug) latex_type="calloutdanger" ;; example) latex_type="calloutexample" ;; quote|cite) latex_type="calloutquote" ;; *) latex_type="calloutinfo" ;; esac echo "" >> "$CALLOUT_MD" echo '```{=latex}' >> "$CALLOUT_MD" echo "\\begin{${latex_type}}{${CALLOUT_TITLE}}" >> "$CALLOUT_MD" echo '```' >> "$CALLOUT_MD" echo "" >> "$CALLOUT_MD" echo "$CALLOUT_BUF" >> "$CALLOUT_MD" echo "" >> "$CALLOUT_MD" echo '```{=latex}' >> "$CALLOUT_MD" echo "\\end{${latex_type}}" >> "$CALLOUT_MD" echo '```' >> "$CALLOUT_MD" echo "" >> "$CALLOUT_MD" fi IN_CALLOUT=0 CALLOUT_TYPE="" CALLOUT_TITLE="" CALLOUT_BUF="" } while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" =~ ^\>\ *\[!([a-zA-Z]+)\]\ *(.*) ]]; then flush_callout IN_CALLOUT=1 CALLOUT_TYPE="${BASH_REMATCH[1]}" CALLOUT_TITLE="${BASH_REMATCH[2]:-${BASH_REMATCH[1]^}}" continue fi if [[ $IN_CALLOUT -eq 1 ]]; then if [[ "$line" =~ ^\>\ ?(.*) ]]; then CALLOUT_BUF="${CALLOUT_BUF}${BASH_REMATCH[1]} " continue else flush_callout fi fi echo "$line" >> "$CALLOUT_MD" done < "${STRIPPED:-$INPUT_FILE}" flush_callout if [[ $CALLOUT_COUNT -gt 0 ]]; then success "Converted $CALLOUT_COUNT callout(s)" fi # --- Step 1b+1c: Inject page breaks (after TOC, before each H1) --- BREAK_INJECTED=$(mktemp /tmp/pdfify-breaks-XXXXXX.md) H1_COUNT=0 IN_FM=0 IN_CODE=0 DONE_TOC_BREAK=0 while IFS= read -r line || [[ -n "$line" ]]; do # Track code blocks (``` opens/closes) if [[ "$line" =~ ^\`\`\` ]]; then if [[ $IN_CODE -eq 0 ]]; then IN_CODE=1; else IN_CODE=0; fi echo "$line" >> "$BREAK_INJECTED" continue fi # Track frontmatter (only at start of file) if [[ "$line" == "---" && $IN_CODE -eq 0 ]]; then if [[ $IN_FM -eq 0 && $H1_COUNT -eq 0 ]]; then IN_FM=1; else IN_FM=0; fi echo "$line" >> "$BREAK_INJECTED" continue fi if [[ $IN_CODE -eq 0 && $IN_FM -eq 0 ]]; then # Before first content after frontmatter: inject TOC page break if [[ $DONE_TOC_BREAK -eq 0 && "$TOC_LEVEL" -gt 0 && -n "$line" ]]; then echo "" >> "$BREAK_INJECTED" echo '```{=latex}' >> "$BREAK_INJECTED" echo '\newpage' >> "$BREAK_INJECTED" echo '```' >> "$BREAK_INJECTED" echo "" >> "$BREAK_INJECTED" DONE_TOC_BREAK=1 fi # Page break before each top-level section (except first) # Build the marker: numberfrom=1 → "# ", numberfrom=2 → "## " BREAK_HASHES=$(printf '#%.0s' $(seq 1 "$FILE_NUMBER_FROM")) if [[ "$line" == "${BREAK_HASHES} "* ]]; then # Make sure it's exactly that level, not deeper NEXT_CHAR="${line:${#BREAK_HASHES}:1}" if [[ "$NEXT_CHAR" != "#" ]]; then H1_COUNT=$((H1_COUNT + 1)) if [[ $H1_COUNT -gt 1 ]]; then echo "" >> "$BREAK_INJECTED" echo '```{=latex}' >> "$BREAK_INJECTED" echo '\newpage' >> "$BREAK_INJECTED" echo '```' >> "$BREAK_INJECTED" echo "" >> "$BREAK_INJECTED" fi fi fi fi echo "$line" >> "$BREAK_INJECTED" done < "$CALLOUT_MD" rm -f "$CALLOUT_MD" CALLOUT_MD="$BREAK_INJECTED" # --- Step 2: Pre-render Mermaid blocks to PNG --- info "Pre-rendering Mermaid diagrams..." TEMP_MD=$(mktemp /tmp/pdfify-XXXXXX.md) MERMAID_COUNT=0 IN_MERMAID=0 MERMAID_BUF="" while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" =~ ^\`\`\`mermaid ]]; then IN_MERMAID=1 MERMAID_BUF="" continue fi if [[ $IN_MERMAID -eq 1 ]]; then if [[ "$line" =~ ^\`\`\` ]]; then IN_MERMAID=0 MERMAID_COUNT=$((MERMAID_COUNT + 1)) MERMAID_FILE="/tmp/mermaid-${MERMAID_COUNT}.mmd" MERMAID_PNG="/tmp/mermaid-${MERMAID_COUNT}.png" echo "$MERMAID_BUF" > "$MERMAID_FILE" detail "Rendering diagram ${CYAN}#${MERMAID_COUNT}${RESET}..." mmdc -i "$MERMAID_FILE" \ -o "$MERMAID_PNG" \ -w 1600 \ -b transparent \ -c /opt/mermaid-config.json \ -p /opt/puppeteer-config.json \ 2>/dev/null || { echo -e " ${YELLOW}⚠${RESET} Diagram $MERMAID_COUNT failed — inserting as code block" echo '```' >> "$TEMP_MD" echo "$MERMAID_BUF" >> "$TEMP_MD" echo '```' >> "$TEMP_MD" continue } SIZE=$(du -h "$MERMAID_PNG" 2>/dev/null | cut -f1 | tr -d ' ') success "Diagram #${MERMAID_COUNT} rendered ${DIM}(${SIZE})${RESET}" echo "" >> "$TEMP_MD" echo "![Diagram ${MERMAID_COUNT}](${MERMAID_PNG})\\" >> "$TEMP_MD" echo "" >> "$TEMP_MD" else MERMAID_BUF="${MERMAID_BUF}${line} " fi else echo "$line" >> "$TEMP_MD" fi done < "$CALLOUT_MD" # --- Lua filter: protect brackets in headings for titlesec --- # Square brackets in headings break titlesec (\SQSPL@scan error) because LaTeX # interprets [ as the start of an optional argument. BRACKET_FILTER=$(mktemp /tmp/pdfify-bracket-filter-XXXXXX.lua) cat > "$BRACKET_FILTER" <<'LUAFILTER' -- Protect square brackets in headings to prevent titlesec \SQSPL@scan errors. -- Brackets in headings make titlesec think they are optional arguments. -- We replace [ and ] with \lbrack/\rbrack in all inline types. function Header(el) if FORMAT ~= "latex" and FORMAT ~= "pdf" then return nil end el = el:walk { Str = function(s) if s.text:find("[%[%]]") then local t = s.text:gsub("%[", "\\lbrack{}"):gsub("%]", "\\rbrack{}") return pandoc.RawInline("latex", t) end end, Code = function(c) -- All code in headings must use \oldtexttt to bypass seqsplit -- (seqsplit in titlesec moving arguments causes \SQSPL@scan errors) local t = c.text t = t:gsub("\\", "\\textbackslash ") t = t:gsub("%%", "\\%%") t = t:gsub("%#", "\\#") t = t:gsub("%$", "\\$") t = t:gsub("%&", "\\&") t = t:gsub("_", "\\_") t = t:gsub("%{", "\\{") t = t:gsub("%}", "\\}") t = t:gsub("~", "\\textasciitilde{}") t = t:gsub("%^", "\\textasciicircum{}") t = t:gsub("%[", "\\lbrack{}"):gsub("%]", "\\rbrack{}") return pandoc.RawInline("latex", "\\oldtexttt{" .. t .. "}") end } return el end LUAFILTER echo "" info "Generating PDF with Pandoc + XeLaTeX..." detail "Engine: xelatex" detail "Font: Roboto / Roboto Mono" detail "Margins: 0.5in, Font size: 10pt" echo "" # Write LaTeX preamble for modern styling PREAMBLE=$(mktemp /tmp/pdfify-preamble-XXXXXX.tex) cat > "$PREAMBLE" <<'LATEX' % --- Modern color scheme --- \usepackage{xcolor} \definecolor{accent}{HTML}{374151} \definecolor{accentdark}{HTML}{111827} \definecolor{codebg}{HTML}{F8F9FA} \definecolor{codeborder}{HTML}{E2E8F0} \definecolor{headrulecolor}{HTML}{E2E8F0} % --- Callout colors --- \definecolor{infobg}{HTML}{EFF6FF} \definecolor{infobar}{HTML}{3B82F6} \definecolor{infofg}{HTML}{1E40AF} \definecolor{tipbg}{HTML}{F0FDF4} \definecolor{tipbar}{HTML}{22C55E} \definecolor{tipfg}{HTML}{166534} \definecolor{warningbg}{HTML}{FFFBEB} \definecolor{warningbar}{HTML}{F59E0B} \definecolor{warningfg}{HTML}{92400E} \definecolor{dangerbg}{HTML}{FEF2F2} \definecolor{dangerbar}{HTML}{EF4444} \definecolor{dangerfg}{HTML}{991B1B} \definecolor{examplebg}{HTML}{F5F3FF} \definecolor{examplebar}{HTML}{8B5CF6} \definecolor{examplefg}{HTML}{5B21B6} \definecolor{quotecallbg}{HTML}{F8F9FA} \definecolor{quotecallbar}{HTML}{6B7280} \definecolor{quotecallfg}{HTML}{374151} % --- Code block wrapping and styling --- \usepackage{fvextra} \DefineVerbatimEnvironment{Highlighting}{Verbatim}{ breaklines, breakanywhere, commandchars=\\\{\}, fontsize=\small } % Background on code blocks via mdframed \usepackage[framemethod=tikz]{mdframed} % Override pandoc's Shaded environment \renewenvironment{Shaded}{% \begin{mdframed}[ backgroundcolor=codebg, hidealllines=true, roundcorner=4pt, innertopmargin=8pt, innerbottommargin=8pt, innerleftmargin=10pt, innerrightmargin=10pt, skipabove=10pt, skipbelow=10pt ] }{% \end{mdframed} } % --- Callout environments --- \newenvironment{calloutbase}[3]{% \begin{mdframed}[ backgroundcolor=#1, linecolor=#2, linewidth=3pt, topline=false, bottomline=false, rightline=false, innertopmargin=12pt, innerbottommargin=12pt, innerleftmargin=12pt, innerrightmargin=12pt, skipabove=12pt, skipbelow=12pt, roundcorner=0pt ] \textbf{\color{#2}#3}\par\smallskip\setlength{\parindent}{0pt} }{% \end{mdframed} } \newenvironment{calloutinfo}[1]{\begin{calloutbase}{infobg}{infobar}{#1}}{\end{calloutbase}} \newenvironment{callouttip}[1]{\begin{calloutbase}{tipbg}{tipbar}{#1}}{\end{calloutbase}} \newenvironment{calloutwarning}[1]{\begin{calloutbase}{warningbg}{warningbar}{#1}}{\end{calloutbase}} \newenvironment{calloutdanger}[1]{\begin{calloutbase}{dangerbg}{dangerbar}{#1}}{\end{calloutbase}} \newenvironment{calloutexample}[1]{\begin{calloutbase}{examplebg}{examplebar}{#1}}{\end{calloutbase}} \newenvironment{calloutquote}[1]{\begin{calloutbase}{quotecallbg}{quotecallbar}{#1}}{\end{calloutbase}} % --- PDF bookmarks (sidebar navigation in PDF viewers) --- \usepackage{bookmark} \bookmarksetup{ numbered=false, open, openlevel=2 } % --- Title banner --- \definecolor{titlebg}{HTML}{E5E7EB} % --- Page break after TOC --- \let\oldtableofcontents\tableofcontents \renewcommand{\tableofcontents}{\oldtableofcontents\clearpage} % --- TOC styling --- \usepackage{tocloft} \setlength{\cftbeforetoctitleskip}{0.5em} \renewcommand{\cfttoctitlefont}{\LARGE\bfseries\color{accentdark}\scshape} \renewcommand{\cftaftertoctitle}{\par\vspace{2pt}{\color{headrulecolor}\hrule height 1pt}\vspace{10pt}} \renewcommand{\cftsecfont}{\bfseries\color{accentdark}} \renewcommand{\cftsecpagefont}{\bfseries\color{accentdark}} \renewcommand{\cftsubsecfont}{\color{accent}} \renewcommand{\cftsubsecpagefont}{\color{accent}} \renewcommand{\cftsubsubsecfont}{\small\color{accent}} \renewcommand{\cftsubsubsecpagefont}{\small\color{accent}} \renewcommand{\cftsecleader}{\cftdotfill{\cftsecdotsep}} \renewcommand{\cftsecdotsep}{\cftdotsep} \setlength{\cftbeforesecskip}{6pt} \setlength{\cftbeforesubsecskip}{2pt} % --- Heading font --- \newfontfamily\headingfont{Roboto}[BoldFont={Roboto Bold}] % --- Symbol fallback (arrows, etc.) --- \usepackage{newunicodechar} \newfontfamily\fallbackfont{Liberation Sans}[Scale=MatchLowercase] \newunicodechar{→}{{\fallbackfont →}} \newunicodechar{←}{{\fallbackfont ←}} \newunicodechar{↔}{{\fallbackfont ↔}} \newunicodechar{⇒}{{\fallbackfont ⇒}} \newunicodechar{⇐}{{\fallbackfont ⇐}} \newunicodechar{✓}{{\fallbackfont ✓}} \newunicodechar{✗}{{\fallbackfont ✗}} % --- Modern section headings (tight, bold, dark) --- \usepackage{titlesec} % H1: # headings — large, small caps, dark, with rule \titleformat{\section} {\LARGE\headingfont\bfseries\color{accentdark}\addfontfeatures{LetterSpace=5}\scshape} {\thesection}{0.5em}{}[\vspace{2pt}{\color{headrulecolor}\titlerule[1pt]}] \titlespacing*{\section}{0pt}{20pt}{10pt} % H2: ## headings \titleformat{\subsection} {\Large\headingfont\bfseries\color{accentdark}\addfontfeatures{LetterSpace=-1}} {\thesubsection}{0.5em}{} \titlespacing*{\subsection}{0pt}{16pt}{8pt} % H3: ### headings \titleformat{\subsubsection} {\large\bfseries\color{accent}} {\thesubsubsection}{0.5em}{} \titlespacing*{\subsubsection}{0pt}{12pt}{6pt} % H4: #### headings \titleformat{\paragraph}[hang] {\normalsize\bfseries\color{accent}} {\theparagraph}{0.5em}{} \titlespacing*{\paragraph}{0pt}{10pt}{4pt} %%SECNUMDEPTH_PLACEHOLDER%% % --- Page style (header/footer injected by pdfify) --- \usepackage{fancyhdr} \pagestyle{fancy} \fancyhf{} \renewcommand{\headrulewidth}{0pt} \renewcommand{\footrulewidth}{0pt} \setlength{\headheight}{14pt} %%HEADER_PLACEHOLDER%% %%FOOTER_PLACEHOLDER%% % Make plain style identical to fancy (so title/TOC pages get the same footer) \fancypagestyle{plain}{\fancyhf{}\renewcommand{\headrulewidth}{0pt}\renewcommand{\footrulewidth}{0pt}%%FOOTER_PLAIN%%} % --- Blockquote styling (plain > quotes, not callouts) --- \usepackage{etoolbox} \renewenvironment{quote}{% \begin{mdframed}[ backgroundcolor=infobg, linecolor=infobar, linewidth=3pt, topline=false, bottomline=false, rightline=false, innertopmargin=12pt, innerbottommargin=12pt, innerleftmargin=12pt, innerrightmargin=12pt, skipabove=10pt, skipbelow=10pt, roundcorner=0pt ]% }{% \end{mdframed}% } % --- Table styling --- \usepackage{booktabs} \usepackage{colortbl} \usepackage{longtable} \usepackage{tabularx} \arrayrulecolor{codeborder} % Allow line breaks in table cells and shrink monospace to fit \usepackage{array} \renewcommand{\arraystretch}{1.4} \let\oldtexttt\texttt \renewcommand{\texttt}[1]{{\small\oldtexttt{\seqsplit{#1}}}} \usepackage{seqsplit} \setlength{\tabcolsep}{4pt} % --- Images constrained to page --- \usepackage{grffile} \usepackage[export]{adjustbox} \let\oldincludegraphics\includegraphics \renewcommand{\includegraphics}[2][]{% \oldincludegraphics[max width=\textwidth,max height=0.45\textheight,keepaspectratio,#1]{#2}% } % --- Figures don't float --- \usepackage{float} \floatplacement{figure}{H} % --- Caption styling --- \usepackage{caption} \captionsetup{labelformat=empty,font={small,color=gray},skip=4pt} % --- Tighter lists --- \usepackage{enumitem} \setlist{nosep,leftmargin=1.5em} % --- Links --- \usepackage{hyperref} \hypersetup{ colorlinks=true, linkcolor=accent, urlcolor=accent, citecolor=accent } % --- Horizontal rules --- \renewcommand{\rule}[2]{\textcolor{headrulecolor}{\vrule width \textwidth height 0.5pt}} LATEX TOC_LEVEL="${TOC_LEVEL:-3}" FM_FOOTER="${FM_FOOTER:-}" FM_HEADER="${FM_HEADER:-}" FM_AUTHOR="${FM_AUTHOR:-}" FM_DATE="${FM_DATE:-}" FM_DATE_LABEL="${FM_DATE_LABEL:-}" FM_DATE_HASH="${FM_DATE_HASH:-}" FM_DATE_DIRTY="${FM_DATE_DIRTY:-}" FILE_NUMBERS="${FILE_NUMBERS:-1}" FILE_NUMBER_FROM="${FILE_NUMBER_FROM:-2}" # Escape LaTeX special characters in text fields (uses sed to avoid # bash parameter substitution brace-parsing issues with } in replacements) latex_escape() { printf '%s' "$1" | sed \ -e 's/\\/@@BSLASH@@/g' \ -e 's/&/\\&/g' \ -e 's/%/\\%/g' \ -e 's/\$/\\$/g' \ -e 's/#/\\#/g' \ -e 's/_/\\_/g' \ -e 's/{/\\{/g' \ -e 's/}/\\}/g' \ -e 's/~/\\textasciitilde{}/g' \ -e 's/\^/\\textasciicircum{}/g' \ -e 's/@@BSLASH@@/\\textbackslash{}/g' } # Inject title banner into preamble FM_TITLE="${FM_TITLE:-}" FM_TITLE_TEX="$(latex_escape "$FM_TITLE")" FM_SUBTITLE_TEX="$(latex_escape "${FM_SUBTITLE:-}")" FM_AUTHOR_TEX="$(latex_escape "${FM_AUTHOR:-}")" { if [[ -n "$FM_TITLE" ]]; then cat <<'TITLE_STATIC' \makeatletter \renewcommand{\maketitle}{% \thispagestyle{fancy}% \vspace*{-\topskip}% \vspace*{-\headsep}% \vspace*{-\headheight}% \vspace*{-0.55in}% \noindent\hspace*{-0.5in}% \fcolorbox{titlebg}{titlebg}{% \parbox{\dimexpr\paperwidth-2\fboxsep-2\fboxrule}{% \hspace*{0.3in}\begin{minipage}{\dimexpr\textwidth}% \vspace{20pt}% TITLE_STATIC echo " {\\fontsize{28}{34}\\selectfont\\bfseries\\color{black}${FM_TITLE_TEX}}\\\\[6pt]%" FM_SUBTITLE="${FM_SUBTITLE:-}" if [[ -n "$FM_SUBTITLE" ]]; then echo " {\\fontsize{14}{18}\\selectfont\\color{black}${FM_SUBTITLE_TEX}}\\\\[8pt]%" fi if [[ -n "$FM_AUTHOR" ]]; then echo " {\\fontsize{11}{14}\\selectfont\\color{black}${FM_AUTHOR_TEX}}\\\\[6pt]%" fi if [[ -n "$FM_DATE" ]]; then DATE_VAL="" if [[ -n "$FM_DATE_HASH" ]]; then DATE_VAL="${FM_DATE% · *} · {\\texttt{${FM_DATE_HASH}}}" else DATE_VAL="${FM_DATE}" fi DIRTY_PART="" if [[ -n "${FM_DATE_DIRTY:-}" ]]; then DIRTY_PART=" {\\color{gray}\\itshape (dirty)}" fi if [[ -n "$FM_DATE_LABEL" ]]; then echo " {\\fontsize{10}{12}\\selectfont\\color{black}${DATE_VAL} {\\color{gray}--- ${FM_DATE_LABEL}}${DIRTY_PART}}\\\\[4pt]%" else echo " {\\fontsize{10}{12}\\selectfont\\color{black}${DATE_VAL}${DIRTY_PART}}\\\\[4pt]%" fi fi cat <<'TITLE_END' \vspace{6pt}% \end{minipage}% }% }% \par\vspace{20pt}% } \makeatother TITLE_END echo '\AtBeginDocument{\maketitle}' else echo '\renewcommand{\maketitle}{}' fi } >> "$PREAMBLE" # Inject header/footer into preamble GIT_STAMP="${GIT_STAMP:-}" FOOTER_L="" FOOTER_C="" FOOTER_R="\\\\fancyfoot[R]{\\\\color{gray}\\\\small Page \\\\thepage\\\\ of \\\\pageref*{LastPage}}" [[ -n "$FM_FOOTER" ]] && FOOTER_L="\\\\fancyfoot[L]{\\\\color{gray}\\\\small ${FM_FOOTER}}" sed -i "s|%%FOOTER_PLACEHOLDER%%|\\\\usepackage{lastpage}${FOOTER_L}${FOOTER_C}${FOOTER_R}|" "$PREAMBLE" sed -i "s|%%FOOTER_PLAIN%%|${FOOTER_L}${FOOTER_C}${FOOTER_R}|" "$PREAMBLE" if [[ -n "$FM_HEADER" ]]; then sed -i "s|%%HEADER_PLACEHOLDER%%|\\\\fancyhead[C]{\\\\color{gray}\\\\small ${FM_HEADER}}|" "$PREAMBLE" else sed -i "s|%%HEADER_PLACEHOLDER%%||" "$PREAMBLE" fi # Inject watermark if set FM_WATERMARK="${FM_WATERMARK:-}" if [[ -n "$FM_WATERMARK" ]]; then cat >> "$PREAMBLE" <> "$PREAMBLE" <> "$PREAMBLE" <<'SECNUM2' \makeatletter \renewcommand{\thesection}{} \renewcommand{\thesubsection}{\arabic{subsection}} \renewcommand{\thesubsubsection}{\thesubsection.\arabic{subsubsection}} % Remove section number from titleformat without changing style \titleformat{\section} {\LARGE\headingfont\bfseries\color{accentdark}\addfontfeatures{LetterSpace=5}\scshape} {}{0em}{}[\vspace{2pt}{\color{headrulecolor}\titlerule[1pt]}] \makeatother SECNUM2 fi if [[ "$FILE_NUMBER_FROM" -ge 3 ]]; then cat >> "$PREAMBLE" <<'SECNUM3' \renewcommand{\thesubsection}{} \renewcommand{\thesubsubsection}{\arabic{subsubsection}} \titleformat{\subsection} {\Large\headingfont\bfseries\color{accentdark}\addfontfeatures{LetterSpace=-1}} {}{0em}{} SECNUM3 fi fi # Remove placeholder sed -i 's|%%SECNUMDEPTH_PLACEHOLDER%%||' "$PREAMBLE" pandoc "$TEMP_MD" \ -o "$OUTPUT_FILE" \ --pdf-engine=xelatex \ --lua-filter="$BRACKET_FILTER" \ --resource-path=".:$WORKDIR" \ --columns=72 \ -V geometry:"margin=0.5in,includehead,includefoot" \ -V fontsize=10pt \ -V mainfont="Roboto" \ -V monofont="Roboto Mono" \ "${TOC_FLAGS[@]}" \ "${AUTHOR_FLAGS[@]}" \ "${NUMBER_FLAGS[@]}" \ --highlight-style=tango \ -H "$PREAMBLE" \ --standalone rm -f "$TEMP_MD" "$CALLOUT_MD" "$PREAMBLE" "${BRACKET_FILTER:-}" "${STRIPPED:-}" /tmp/mermaid-*.mmd /tmp/mermaid-*.png PAGES=$(strings "$OUTPUT_FILE" 2>/dev/null | grep -c '/Type /Page' || echo "?") SIZE=$(du -h "$OUTPUT_FILE" | cut -f1 | tr -d ' ') success "PDF generated: ${CYAN}${SIZE}${RESET}, ~${CYAN}${PAGES}${RESET} pages" INNER_SCRIPT chmod +x "$CONVERT_SCRIPT" # --- Run Docker --- echo "" info "Launching Docker container..." detail "Mounting: ${CYAN}${INPUT_DIR}${RESET} → /work ${DIM}(read-only)${RESET}" detail "Output: ${CYAN}${OUTPUT_DIR}${RESET} → /output" echo "" CONVERT_BASENAME="$(basename "$CONVERT_SCRIPT")" docker run --rm \ -v "$INPUT_DIR:/work:ro" \ -v "$OUTPUT_DIR:/output" \ -e "TOC_LEVEL=$FILE_TOC_LEVEL" \ -e "FM_FOOTER=$FM_FOOTER" \ -e "FM_HEADER=$FM_HEADER" \ -e "FM_AUTHOR=$FM_AUTHOR" \ -e "FM_TITLE=$FM_TITLE" \ -e "FM_SUBTITLE=$FM_SUBTITLE" \ -e "FM_DATE=$FM_DATE" \ -e "FM_DATE_LABEL=${FM_DATE_LABEL:-}" \ -e "FM_DATE_HASH=${FM_DATE_HASH:-}" \ -e "FM_DATE_DIRTY=${FM_DATE_DIRTY:-}" \ -e "GIT_STAMP=${GIT_STAMP:-}" \ -e "FILE_NUMBERS=$FILE_NUMBERS" \ -e "FILE_NUMBER_FROM=$FILE_NUMBER_FROM" \ -e "HIDE_FIRST_H1=$HIDE_FIRST_H1" \ -e "FM_WATERMARK=$FM_WATERMARK" \ --tmpfs /tmp:exec \ "$IMAGE_NAME" "/work/$CONVERT_BASENAME" "$INPUT_FILE" "/output/$OUTPUT_FILE" \ || { echo "" echo -e " ${RED}${BOLD}Error producing PDF.${RESET} Docker/pandoc exited with a non-zero status." echo "" return 1 } # Move preview file to /tmp and clean up if [[ -n "$PREVIEW_FINAL" ]]; then mv "$OUTPUT" "$PREVIEW_FINAL" OUTPUT="$PREVIEW_FINAL" fi echo "" echo -e " ${GREEN}${BOLD}PDF created:${RESET} ${CYAN}${OUTPUT}${RESET}" echo "" # Open if requested if [[ $OPEN -eq 1 ]]; then open_pdf "$OUTPUT" fi } # --- Process each input file --- run_all() { local FAILED=0 for input_file in "${POSITIONAL[@]}"; do convert_file "$input_file" "$OUT_FILE" || FAILED=$((FAILED + 1)) done if [[ $FAILED -eq 0 ]]; then header "Complete! (${#POSITIONAL[@]} file(s))" else header "${FAILED} of ${#POSITIONAL[@]} file(s) failed" fi } run_all # --- Watch mode --- if [[ $WATCH -eq 1 ]]; then info "Watching for changes... ${DIM}(Ctrl+C to stop)${RESET}" echo "" # Get initial checksums (using a temp file instead of associative array for bash 3 compat) CHECKSUM_FILE=$(mktemp) trap 'rm -f "$CHECKSUM_FILE"' EXIT for f in "${POSITIONAL[@]}"; do fpath="$(cd "$(dirname "$f")" && pwd)/$(basename "$f")" echo "$(_sha256 < "$fpath" | cut -d' ' -f1) $fpath" >> "$CHECKSUM_FILE" done while true; do sleep 2 CHANGED=0 for f in "${POSITIONAL[@]}"; do fpath="$(cd "$(dirname "$f")" && pwd)/$(basename "$f")" NEW_HASH=$(_sha256 < "$fpath" | cut -d' ' -f1) OLD_HASH=$(grep " $fpath\$" "$CHECKSUM_FILE" | cut -d' ' -f1) if [[ "$NEW_HASH" != "$OLD_HASH" ]]; then CHANGED=1 # Update stored checksum grep -v " $fpath\$" "$CHECKSUM_FILE" > "${CHECKSUM_FILE}.tmp" || true echo "$NEW_HASH $fpath" >> "${CHECKSUM_FILE}.tmp" mv "${CHECKSUM_FILE}.tmp" "$CHECKSUM_FILE" fi done if [[ $CHANGED -eq 1 ]]; then echo "" info "Change detected — rebuilding..." echo "" run_all fi done fi # Check for updates (runs after success, fast timeout) check_for_update