param(
[Parameter(Mandatory = $true)]
[string]$PlanMarkdown,
[Parameter(Mandatory = $true)]
[string]$SourceMarkdown,
[Parameter(Mandatory = $true)]
[string]$OutputDocx
)
$ErrorActionPreference = 'Stop'
function Escape-XmlText {
param([AllowNull()][string]$Text)
if ($null -eq $Text) { return '' }
return [System.Security.SecurityElement]::Escape($Text)
}
function Add-Paragraph {
param(
[System.Collections.Generic.List[string]]$Parts,
[string]$Text,
[string]$Style = $null
)
$styleXml = ''
if ($Style) {
$styleXml = ""
}
$Parts.Add("$styleXml$(Escape-XmlText $Text)")
}
function Add-Table {
param(
[System.Collections.Generic.List[string]]$Parts,
[string[]]$Headers,
[object[]]$Rows
)
$tbl = New-Object System.Text.StringBuilder
[void]$tbl.Append('')
[void]$tbl.Append('')
[void]$tbl.Append('')
foreach ($h in $Headers) {
[void]$tbl.Append('')
[void]$tbl.Append((Escape-XmlText $h))
[void]$tbl.Append('')
}
[void]$tbl.Append('')
foreach ($row in $Rows) {
[void]$tbl.Append('')
foreach ($cell in $row) {
[void]$tbl.Append('')
[void]$tbl.Append((Escape-XmlText ([string]$cell)))
[void]$tbl.Append('')
}
[void]$tbl.Append('')
}
[void]$tbl.Append('')
$Parts.Add($tbl.ToString())
}
function Flush-TableBuffer {
param(
[System.Collections.Generic.List[string]]$Parts,
[System.Collections.Generic.List[string]]$Buffer
)
if ($Buffer.Count -lt 2) {
foreach ($line in $Buffer) {
Add-Paragraph -Parts $Parts -Text $line
}
$Buffer.Clear()
return
}
$header = $Buffer[0].Trim('|').Split('|') | ForEach-Object { $_.Trim() }
$rows = @()
for ($i = 2; $i -lt $Buffer.Count; $i++) {
$rows += ,($Buffer[$i].Trim('|').Split('|') | ForEach-Object { $_.Trim() })
}
Add-Table -Parts $Parts -Headers $header -Rows $rows
$Buffer.Clear()
}
function Add-MarkdownContent {
param(
[System.Collections.Generic.List[string]]$Parts,
[string]$MarkdownPath
)
$lines = [System.IO.File]::ReadAllLines($MarkdownPath, [System.Text.Encoding]::UTF8)
$tableBuffer = New-Object System.Collections.Generic.List[string]
$inCode = $false
foreach ($line in $lines) {
if ($line.TrimStart().StartsWith('```')) {
if ($tableBuffer.Count -gt 0) { Flush-TableBuffer -Parts $Parts -Buffer $tableBuffer }
$inCode = -not $inCode
Add-Paragraph -Parts $Parts -Text $line -Style 'Code'
continue
}
if ($inCode) {
Add-Paragraph -Parts $Parts -Text $line -Style 'Code'
continue
}
if ($line.TrimStart().StartsWith('|') -and $line.TrimEnd().EndsWith('|')) {
$tableBuffer.Add($line)
continue
}
if ($tableBuffer.Count -gt 0) { Flush-TableBuffer -Parts $Parts -Buffer $tableBuffer }
if ($line.StartsWith('### ')) {
Add-Paragraph -Parts $Parts -Text $line.Substring(4) -Style 'Heading3'
} elseif ($line.StartsWith('## ')) {
Add-Paragraph -Parts $Parts -Text $line.Substring(3) -Style 'Heading2'
} elseif ($line.StartsWith('# ')) {
Add-Paragraph -Parts $Parts -Text $line.Substring(2) -Style 'Heading1'
} elseif ($line.Trim().StartsWith('- ')) {
Add-Paragraph -Parts $Parts -Text ('ยท ' + $line.Trim().Substring(2))
} elseif ([string]::IsNullOrWhiteSpace($line)) {
Add-Paragraph -Parts $Parts -Text ''
} else {
Add-Paragraph -Parts $Parts -Text $line
}
}
if ($tableBuffer.Count -gt 0) { Flush-TableBuffer -Parts $Parts -Buffer $tableBuffer }
}
if (-not (Test-Path -LiteralPath $PlanMarkdown)) { throw "Plan markdown not found: $PlanMarkdown" }
if (-not (Test-Path -LiteralPath $SourceMarkdown)) { throw "Source markdown not found: $SourceMarkdown" }
$outputDir = Split-Path -Path $OutputDocx -Parent
if (-not (Test-Path -LiteralPath $outputDir)) {
New-Item -ItemType Directory -Path $outputDir | Out-Null
}
$tempRoot = Join-Path $env:TEMP ("docx_" + [Guid]::NewGuid().ToString('N'))
$wordDir = Join-Path $tempRoot 'word'
$relsDir = Join-Path $tempRoot '_rels'
$wordRelsDir = Join-Path $wordDir '_rels'
New-Item -ItemType Directory -Path $wordDir, $relsDir, $wordRelsDir | Out-Null
$parts = New-Object System.Collections.Generic.List[string]
Add-MarkdownContent -Parts $parts -MarkdownPath $PlanMarkdown
Add-MarkdownContent -Parts $parts -MarkdownPath $SourceMarkdown
$documentXml = @"
$($parts -join "`n")
"@
$stylesXml = @'
'@
$contentTypesXml = @'
'@
$relsXml = @'
'@
$docRelsXml = @'
'@
[System.IO.File]::WriteAllText((Join-Path $tempRoot '[Content_Types].xml'), $contentTypesXml, [System.Text.Encoding]::UTF8)
[System.IO.File]::WriteAllText((Join-Path $relsDir '.rels'), $relsXml, [System.Text.Encoding]::UTF8)
[System.IO.File]::WriteAllText((Join-Path $wordDir 'document.xml'), $documentXml, [System.Text.Encoding]::UTF8)
[System.IO.File]::WriteAllText((Join-Path $wordDir 'styles.xml'), $stylesXml, [System.Text.Encoding]::UTF8)
[System.IO.File]::WriteAllText((Join-Path $wordRelsDir 'document.xml.rels'), $docRelsXml, [System.Text.Encoding]::UTF8)
if (Test-Path -LiteralPath $OutputDocx) {
Remove-Item -LiteralPath $OutputDocx -Force
}
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::CreateFromDirectory($tempRoot, $OutputDocx)
Remove-Item -LiteralPath $tempRoot -Recurse -Force
Write-Output $OutputDocx