Public/New-KritBrandedDocument.ps1
|
function New-KritBrandedDocument { <# .SYNOPSIS Renders a Markdown (or HTML) source file to a Kritical-branded artefact in one or more output formats: PDF, DOCX, HTML. .DESCRIPTION End-to-end pipeline for customer proposals, internal reports, training decks etc. Pulls brand spec from Get-KritBrandSpec (single source of truth). Applies: - Primary colour (#13365C Kritical dark blue) on headings + tables - Secondary colour (#15AFD1 Kritical cyan) on accents - Roboto headings + Assistant sub-headings (via @font-face when fonts available in Kritical-Branding/public/fonts/) - Horizontal_Logo.png in header - Canonical footer (ABN, ACN, address, tagline, phone, email, web) - Optional Outlook email signature appended Render engines (auto-detected; first match wins): - PDF: Pandoc + wkhtmltopdf (preferred) Pandoc + Chrome / Edge headless (fallback) Markdown-it + Chrome headless (last-ditch) - DOCX: Pandoc --reference-doc=Kritical-BaseTemplate-CURRENT.docx - HTML: Pandoc + brand-aligned CSS, single-file with base64 images Output filename: <CustomerName>-<DocumentTitle>-<utc-stamp>.<ext> .PARAMETER Source Path to source .md or .html file. .PARAMETER OutDir Output directory. Created if missing. .PARAMETER Format One or more of: PDF, DOCX, HTML. Default: PDF + DOCX + HTML. .PARAMETER CustomerName Customer name (e.g. 'Kitchenworx', 'EES'). Used in output filename + Word --metadata for use in templates. .PARAMETER DocumentTitle Short document title (e.g. 'Proposal-Cover', 'Rate-Card'). .PARAMETER EmbedSignature Append the Outlook signature (email-signature.htm) at the end of the rendered HTML / PDF (not DOCX). .PARAMETER NoFooter Skip the canonical footer (rare; only for unbranded internal previews). .PARAMETER NoBanner Skip the Kritical banner Write-Host on console. .EXAMPLE New-KritBrandedDocument -Source .\Kitchenworx-Proposal.md ` -OutDir .\out -CustomerName Kitchenworx -DocumentTitle Proposal-Cover .EXAMPLE # PDF only New-KritBrandedDocument -Source .\report.md -OutDir .\out -Format PDF ` -CustomerName Internal -DocumentTitle Q2-Report .EXAMPLE # Bulk a folder of .md files Get-ChildItem .\drafts\*.md | ForEach-Object { New-KritBrandedDocument -Source $_.FullName -OutDir .\out ` -CustomerName EES -DocumentTitle ($_.BaseName) } .NOTES Author: Joshua Finley - Kritical Pty Ltd Inventory: Github/KRTPax8ToShopifyConnector/reference/KRITICAL-BRAND-ASSET-INVENTORY-1507.md Architecture: KRITICAL-BRAND-ASSET-INVENTORY-1507 section 8 #> [CmdletBinding(SupportsShouldProcess)] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $Source, [Parameter(Mandatory)] [string] $OutDir, [ValidateSet('PDF','DOCX','HTML')] [string[]] $Format = @('PDF','DOCX','HTML'), [Parameter(Mandatory)] [string] $CustomerName, [Parameter(Mandatory)] [string] $DocumentTitle, [switch] $EmbedSignature, [switch] $NoFooter, [switch] $NoBanner ) if (-not (Test-Path -LiteralPath $Source)) { throw "Source file not found: $Source" } New-Item -ItemType Directory -Path $OutDir -Force | Out-Null if (-not $NoBanner.IsPresent) { try { Write-KritBanner -Title "BrandedDocument: $CustomerName - $DocumentTitle" -Compact } catch { } } $spec = Get-KritBrandSpec $brandRoot = $null if ($env:USERPROFILE) { $candidate = Join-Path $env:USERPROFILE 'OneDrive - Kritical Pty Ltd\Kritical-Branding\public' if (Test-Path -LiteralPath $candidate) { $brandRoot = $candidate } } $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmssZ') $safeCustomer = ($CustomerName -replace '[^\w\-]','_') $safeTitle = ($DocumentTitle -replace '[^\w\-]','_') $baseName = "{0}-{1}-{2}" -f $safeCustomer, $safeTitle, $stamp # Build the brand-aligned CSS once; reused for HTML + PDF-via-headless paths. $primary = $spec.colours.primary.kriticalDarkBlue $secondary = $spec.colours.secondary.kriticalCyan $lightGrey = $spec.colours.secondary.lightGrey $textColor = $spec.colours.tertiary.black $entity = $spec.entity $contact = $spec.contact $messaging = $spec.messaging $logoPath = $null if ($brandRoot) { $logoCandidate = Join-Path $brandRoot 'logos\Horizontal_Logo.png' if (Test-Path -LiteralPath $logoCandidate) { $logoPath = $logoCandidate } } $logoDataUri = '' if ($logoPath) { $bytes = [IO.File]::ReadAllBytes($logoPath) $logoDataUri = "data:image/png;base64,$([Convert]::ToBase64String($bytes))" } $sigDataHtml = '' if ($EmbedSignature.IsPresent -and $brandRoot) { $sigPath = Join-Path $brandRoot 'email-signature.htm' if (Test-Path -LiteralPath $sigPath) { $sigDataHtml = Get-Content -LiteralPath $sigPath -Raw -Encoding UTF8 } } $footerHtml = if ($NoFooter.IsPresent) { '' } else { @" <footer class="kr-footer"> <div class="kr-tagline"><em>$($messaging.tagline)</em></div> <div class="kr-corp">$($entity.legalName) · ABN $($entity.abn) · ACN $($entity.acn)</div> <div class="kr-corp">$($entity.registeredAddress)</div> <div class="kr-contact">$($contact.phoneMain) · <a href="mailto:$($contact.emailSales)">$($contact.emailSales)</a> · <a href="$($contact.webPrimary)">$($contact.webPrimary)</a></div> </footer> "@ } $headerHtml = if ($logoDataUri) { "<header class='kr-header'><img src='$logoDataUri' alt='Kritical' class='kr-logo'/><div class='kr-positioning'>$($messaging.positioning)</div></header>" } else { "<header class='kr-header'><div class='kr-wordmark'>Kritical™</div><div class='kr-positioning'>$($messaging.positioning)</div></header>" } $css = @" @font-face { font-family:'Roboto'; src: local('Roboto'); font-weight: normal; } @font-face { font-family:'Assistant'; src: local('Assistant'); font-weight: 500; } * { box-sizing: border-box; } html,body { margin:0; padding:0; } body { font-family: 'Assistant', 'Segoe UI', Calibri, Arial, sans-serif; color:$textColor; line-height:1.55; font-size: 11pt; max-width: 1100px; margin: 0 auto; padding: 1.5em 1em 3em 1em; background:#fff; } h1,h2,h3,h4 { font-family: 'Roboto', 'Segoe UI', Calibri, Arial, sans-serif; color:$primary; font-weight: normal; line-height:1.25; margin: 1.2em 0 0.5em 0; } h1 { font-size: 28pt; border-bottom: 3px solid $primary; padding-bottom: 0.2em; } h2 { font-size: 18pt; border-bottom: 1px solid $secondary; padding-bottom: 0.15em; margin-top: 1.8em; } h3 { font-size: 14pt; color: $primary; } h4 { font-size: 12pt; color: $secondary; } p { margin: 0.5em 0; } strong { color: $primary; } a { color: $secondary; text-decoration: none; } a:hover { text-decoration: underline; } table { border-collapse: collapse; margin: 1em 0; width: 100%; } th, td { border: 1px solid $lightGrey; padding: 0.5em 0.75em; text-align: left; vertical-align: top; font-size: 10pt; } th { background: $primary; color: #fff; font-family: 'Roboto', Calibri, sans-serif; font-weight: normal; } tr:nth-child(even) td { background: #f7f9fb; } blockquote { border-left: 4px solid $primary; margin: 1em 0; padding: 0.5em 1em; background: $lightGrey; color: $textColor; } code { background: $lightGrey; padding: 0.1em 0.4em; font-family: Consolas, 'Courier New', monospace; font-size: 10pt; } pre { background: $lightGrey; padding: 0.8em; overflow-x: auto; font-family: Consolas, monospace; font-size: 9.5pt; border-left: 4px solid $secondary; } hr { border: 0; border-top: 1px solid $lightGrey; margin: 1.5em 0; } ul, ol { margin: 0.5em 0 0.5em 1.5em; padding: 0; } li { margin: 0.2em 0; } .kr-header { display: flex; align-items: center; justify-content: space-between; border-bottom: 2px solid $primary; padding: 0 0 0.6em 0; margin-bottom: 1.5em; } .kr-logo { max-height: 56px; } .kr-wordmark { font-family: 'Roboto', sans-serif; font-size: 22pt; color: $primary; } .kr-positioning { font-family: 'Assistant', sans-serif; font-size: 9pt; color: $primary; text-align: right; } .kr-footer { margin-top: 3em; padding-top: 0.8em; border-top: 1px solid $primary; font-size: 8.5pt; color: $textColor; text-align: center; } .kr-tagline { color: $primary; font-size: 10pt; margin-bottom: 0.3em; } .kr-corp { color: $textColor; opacity: 0.85; } .kr-contact { color: $secondary; margin-top: 0.2em; } .kr-signature { margin-top: 2em; padding-top: 1em; border-top: 1px dashed $lightGrey; font-size: 10pt; } @page { size: A4; margin: 18mm; } @media print { body { max-width: none; padding: 0; } .kr-header { page-break-after: avoid; } h1, h2, h3 { page-break-after: avoid; } table, tr, td, th { page-break-inside: avoid; } } "@ # --- Render Markdown source -> HTML via Pandoc (preferred) ----------------------- $haveP = $null -ne (Get-Command pandoc -ErrorAction SilentlyContinue) $haveW = $null -ne (Get-Command wkhtmltopdf -ErrorAction SilentlyContinue) $chromePath = "${env:ProgramFiles}\Google\Chrome\Application\chrome.exe" if (-not (Test-Path -LiteralPath $chromePath)) { $chromePath = "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe" } $haveChrome = Test-Path -LiteralPath $chromePath $edgePath = "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe" $haveEdge = Test-Path -LiteralPath $edgePath $headlessBrowser = if ($haveChrome) { $chromePath } elseif ($haveEdge) { $edgePath } else { $null } if (-not $haveP) { throw "Pandoc is required for New-KritBrandedDocument. Install: winget install --id JohnMacFarlane.Pandoc" } $intermediateHtml = Join-Path $OutDir ("{0}-intermediate.html" -f $baseName) $isHtml = ([IO.Path]::GetExtension($Source)).ToLowerInvariant() -eq '.html' # Generate the styled HTML body wrapper $bodyHtmlPath = Join-Path $OutDir ("{0}-body.html" -f $baseName) if ($isHtml) { Copy-Item -LiteralPath $Source -Destination $bodyHtmlPath -Force } else { & pandoc $Source -f 'gfm+raw_html' -t html5 -o $bodyHtmlPath 2>&1 | Write-Verbose } $bodyHtml = Get-Content -LiteralPath $bodyHtmlPath -Raw -Encoding UTF8 $fullHtml = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>$($entity.tradeName) - $CustomerName - $DocumentTitle</title> <meta name="author" content="$($messaging.directorName)"> <meta name="generator" content="Krit.OmniFramework / New-KritBrandedDocument"> <style>$css</style> </head> <body> $headerHtml $bodyHtml $footerHtml $(if ($sigDataHtml) { "<div class='kr-signature'>$sigDataHtml</div>" }) </body> </html> "@ Set-Content -LiteralPath $intermediateHtml -Value $fullHtml -Encoding UTF8 Remove-Item -LiteralPath $bodyHtmlPath -ErrorAction SilentlyContinue $results = [System.Collections.Generic.List[pscustomobject]]::new() # --- HTML output ------------------------------------------------------------- if ($Format -contains 'HTML') { $htmlOut = Join-Path $OutDir ("{0}.html" -f $baseName) Copy-Item -LiteralPath $intermediateHtml -Destination $htmlOut -Force $results.Add([pscustomobject]@{ Format='HTML'; Path=$htmlOut; Engine='pandoc+css'; Size=(Get-Item $htmlOut).Length }) } # --- PDF output -------------------------------------------------------------- if ($Format -contains 'PDF') { $pdfOut = Join-Path $OutDir ("{0}.pdf" -f $baseName) if ($haveW) { & wkhtmltopdf --enable-local-file-access --quiet $intermediateHtml $pdfOut 2>&1 | Write-Verbose $engine = 'wkhtmltopdf' } elseif ($headlessBrowser) { $absUri = ([Uri]$intermediateHtml).AbsoluteUri & $headlessBrowser --headless --disable-gpu --no-pdf-header-footer --print-to-pdf="$pdfOut" $absUri 2>&1 | Write-Verbose $engine = if ($chromePath -eq $headlessBrowser) { 'chrome-headless' } else { 'edge-headless' } } else { Write-Warning "No PDF engine available (wkhtmltopdf / Chrome / Edge). Skipping PDF for $baseName." $engine = $null } if ($engine -and (Test-Path -LiteralPath $pdfOut)) { $results.Add([pscustomobject]@{ Format='PDF'; Path=$pdfOut; Engine=$engine; Size=(Get-Item $pdfOut).Length }) } } # --- DOCX output (via Pandoc --reference-doc) -------------------------------- if ($Format -contains 'DOCX') { $docxOut = Join-Path $OutDir ("{0}.docx" -f $baseName) $referenceDoc = $null if ($brandRoot) { $candidate = Join-Path $brandRoot 'Kritical-BaseTemplate-CURRENT.docx' if (Test-Path -LiteralPath $candidate) { $referenceDoc = $candidate } } $pargs = @( $Source '-f', 'gfm+raw_html' '-t', 'docx' '--metadata', "title=$CustomerName - $DocumentTitle" '--metadata', "author=$($messaging.directorName)" '-o', $docxOut ) if ($referenceDoc) { $pargs += '--reference-doc'; $pargs += $referenceDoc } & pandoc @pargs 2>&1 | Write-Verbose if (Test-Path -LiteralPath $docxOut) { $engine = if ($referenceDoc) { "pandoc+reference-doc" } else { "pandoc (no template)" } $results.Add([pscustomobject]@{ Format='DOCX'; Path=$docxOut; Engine=$engine; Size=(Get-Item $docxOut).Length }) } } # --- Cleanup intermediate ---------------------------------------------------- Remove-Item -LiteralPath $intermediateHtml -ErrorAction SilentlyContinue [pscustomobject]@{ Source = (Resolve-Path -LiteralPath $Source).Path OutDir = (Resolve-Path -LiteralPath $OutDir).Path CustomerName = $CustomerName DocumentTitle = $DocumentTitle BaseName = $baseName BrandSpecSource = if ($spec.PSObject.Properties['_sourcePath']) { $spec._sourcePath } else { 'fallback' } Outputs = @($results) RenderedAtUtc = (Get-Date).ToUniversalTime() } } |