Public/Convert-MD2PDF.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. <# .SYNOPSIS Converts Markdown files to PDF using pandoc. .DESCRIPTION Accepts Markdown file paths as strings, FileInfo objects, or via pipeline. Output PDF has the same base name with a .pdf extension, written to the same directory as the source file (or to -OutputDirectory if specified). .EXAMPLE Convert-MD2PDF ./debate.md .EXAMPLE Get-ChildItem *.md | Convert-MD2PDF .EXAMPLE 'report.md', 'notes.md' | Convert-MD2PDF -OutputDirectory ./pdfs .EXAMPLE Convert-MD2PDF -Path ./docs/*.md -Margin 1in #> function Convert-MD2PDF { [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)] [Alias('FullName', 'PSPath')] [string[]]$Path, [Parameter()] [string]$OutputDirectory, [Parameter()] [string]$PandocPath, [Parameter()] [ValidateSet('letter', 'a4')] [string]$PaperSize = 'letter', [Parameter()] [string]$Margin = '0.75in', [Parameter()] [switch]$TableOfContents, [Parameter()] [switch]$Show ) begin { Set-StrictMode -Version Latest # Resolve pandoc if ($PandocPath) { if (-not (Test-Path $PandocPath)) { throw "Pandoc not found at: $PandocPath" } } else { $PandocPath = Get-Command pandoc -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source if (-not $PandocPath) { throw 'pandoc is not installed. Install with: brew install pandoc' } } # Verify PDF engine is available # Priority: typst (lightweight, fast) > LaTeX engines > weasyprint > HTML fallback $PdfEngine = $null $UseFallback = $false foreach ($engine in @('typst', 'xelatex', 'lualatex', 'pdflatex', 'weasyprint', 'wkhtmltopdf')) { if (Get-Command $engine -ErrorAction SilentlyContinue) { $PdfEngine = $engine break } } if (-not $PdfEngine) { # macOS fallback: pandoc → HTML → PDF via cupsfilter or textutil if ($IsMacOS -and (Test-Path '/usr/sbin/cupsfilter')) { $UseFallback = $true Write-Verbose 'No dedicated PDF engine found — using pandoc → HTML → cupsfilter fallback' } else { throw 'No PDF engine found. Install one of: typst (brew install typst), MacTeX (brew install --cask mactex-no-gui), or weasyprint (pip install weasyprint).' } } if ($OutputDirectory -and -not (Test-Path $OutputDirectory)) { try { $null = New-Item -Path $OutputDirectory -ItemType Directory -Force -ErrorAction Stop } catch { throw "Failed to create output directory '$OutputDirectory': $_`nCheck that the parent directory exists and you have write permissions." } } $Converted = [System.Collections.Generic.List[PSObject]]::new() } process { foreach ($Item in $Path) { # Resolve the actual file path (handles wildcards, relative paths, FileInfo objects) $ResolvedPaths = @(Resolve-Path -Path $Item -ErrorAction SilentlyContinue) if ($ResolvedPaths.Count -eq 0) { Write-Error "File not found: $Item" continue } foreach ($Resolved in $ResolvedPaths) { $SourcePath = $Resolved.Path $SourceItem = Get-Item $SourcePath if ($SourceItem.Extension -notin @('.md', '.markdown', '.mdown', '.mkd')) { Write-Warning "Skipping non-Markdown file: $SourcePath" continue } # Determine output path $BaseName = [System.IO.Path]::GetFileNameWithoutExtension($SourceItem.Name) if ($OutputDirectory) { $OutDir = Resolve-Path $OutputDirectory } else { $OutDir = $SourceItem.DirectoryName } $PdfPath = Join-Path $OutDir "$BaseName.pdf" try { if ($UseFallback) { # Fallback: pandoc → standalone HTML → cupsfilter → PDF $TempHtml = [System.IO.Path]::GetTempFileName() + '.html' $HtmlArgs = @( $SourcePath '-o', $TempHtml '--standalone' '--self-contained' '--highlight-style', 'tango' '--metadata', "title=$BaseName" '-c', 'data:text/css,body{font-family:Helvetica Neue,sans-serif;max-width:48em;margin:auto;padding:2em;font-size:11pt}pre{background:%23f5f5f5;padding:1em;overflow-x:auto}code{font-family:Menlo,monospace;font-size:0.9em}' ) if ($TableOfContents) { $HtmlArgs += '--toc' } Write-Verbose "pandoc → HTML: $($HtmlArgs -join ' ')" & $PandocPath @HtmlArgs 2>&1 | ForEach-Object { if ($_ -is [System.Management.Automation.ErrorRecord]) { Write-Warning "pandoc: $_" } } if (Test-Path $TempHtml) { Write-Verbose "cupsfilter → PDF" & /bin/bash -c "/usr/sbin/cupsfilter '$TempHtml' > '$PdfPath' 2>/dev/null" Remove-Item $TempHtml -ErrorAction SilentlyContinue } } else { # Direct pandoc → PDF via engine $TexHeader = $null $PandocArgs = @( $SourcePath '-o', $PdfPath '--pdf-engine', $PdfEngine '--highlight-style', 'tango' ) # Engine-specific options if ($PdfEngine -eq 'typst') { $PandocArgs += @('-V', "margin-x=$Margin", '-V', "margin-y=$Margin") } elseif ($PdfEngine -in @('xelatex', 'lualatex', 'pdflatex')) { # Create a temp LaTeX header with xcolor package for colored text $TexHeader = Join-Path ([System.IO.Path]::GetTempPath()) "pandoc-header-$([guid]::NewGuid().ToString('N').Substring(0,8)).tex" Set-Content -Path $TexHeader -Value '\usepackage[dvipsnames]{xcolor}' -Encoding UTF8 $PandocArgs += @( '-V', "geometry:margin=$Margin" '-V', "papersize:$PaperSize" '-V', 'colorlinks=true' '-V', 'linkcolor=blue' '-H', $TexHeader ) if ($PdfEngine -in @('xelatex', 'lualatex')) { $PandocArgs += @('-V', 'mainfont:Helvetica Neue', '-V', 'monofont:Menlo') } } elseif ($PdfEngine -eq 'weasyprint') { $PandocArgs += @('--css', 'data:text/css,@page{size:' + $PaperSize + ';margin:' + $Margin + '}') } if ($TableOfContents) { $PandocArgs += '--toc' } Write-Verbose "pandoc $($PandocArgs -join ' ')" & $PandocPath @PandocArgs 2>&1 | ForEach-Object { if ($_ -is [System.Management.Automation.ErrorRecord]) { Write-Warning "pandoc: $_" } } # Clean up temp header file if ($null -ne $TexHeader -and (Test-Path $TexHeader)) { Remove-Item $TexHeader -ErrorAction SilentlyContinue } } if ((Test-Path $PdfPath) -and (Get-Item $PdfPath).Length -gt 0) { $PdfItem = Get-Item $PdfPath $Result = [PSCustomObject]@{ Source = $SourcePath PDF = $PdfPath Size = $PdfItem.Length } $Converted.Add($Result) Write-Verbose "Created: $PdfPath ($([math]::Round($PdfItem.Length / 1024))KB)" if ($Show) { if ($IsMacOS) { & open $PdfPath } elseif ($IsWindows) { & start $PdfPath } else { & xdg-open $PdfPath } } $Result } else { if ($UseFallback) { $EngineInfo = 'cupsfilter (HTML fallback)' } else { $EngineInfo = $PdfEngine } Write-Error @" PDF conversion failed for: $SourcePath Engine: $EngineInfo Expected output: $PdfPath Input size: $((Get-Item $SourcePath).Length) bytes Troubleshooting: 1. Verify the Markdown is valid: pandoc '$SourcePath' -t plain | Select-Object -First 5 2. Try a different engine: Convert-MD2PDF '$SourcePath' (ensure typst/xelatex is installed) 3. Check pandoc version: pandoc --version "@ } } catch { Write-Error "Failed to convert $SourcePath : $_" } } } } end { if ($Converted.Count -gt 1) { Write-Verbose "Converted $($Converted.Count) files" } } } |