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 # 1.1.2 — load logo + JoshCreations branded banner + footer images as data-URIs $detectImageMime = { param([byte[]] $bytes, [string] $path) if ($bytes.Length -ge 12) { if ($bytes[0] -eq 0x89 -and $bytes[1] -eq 0x50 -and $bytes[2] -eq 0x4E -and $bytes[3] -eq 0x47 -and $bytes[4] -eq 0x0D -and $bytes[5] -eq 0x0A -and $bytes[6] -eq 0x1A -and $bytes[7] -eq 0x0A) { return 'image/png' } if ($bytes[0] -eq 0xFF -and $bytes[1] -eq 0xD8 -and $bytes[2] -eq 0xFF) { return 'image/jpeg' } $riff = [Text.Encoding]::ASCII.GetString($bytes, 0, 4) $webp = [Text.Encoding]::ASCII.GetString($bytes, 8, 4) if ($riff -eq 'RIFF' -and $webp -eq 'WEBP') { return 'image/webp' } } if ($bytes.Length -ge 6) { $prefix = [Text.Encoding]::ASCII.GetString($bytes, 0, 6) if ($prefix -eq 'GIF87a' -or $prefix -eq 'GIF89a') { return 'image/gif' } } switch ([IO.Path]::GetExtension($path).ToLowerInvariant()) { '.svg' { 'image/svg+xml' } '.png' { 'image/png' } '.jpg' { 'image/jpeg' } '.jpeg' { 'image/jpeg' } '.webp' { 'image/webp' } '.gif' { 'image/gif' } default { 'application/octet-stream' } } } $imgToDataUri = { param([string] $path, [string] $mime) if (Test-Path -LiteralPath $path) { $bytes = [IO.File]::ReadAllBytes($path) $mime = & $detectImageMime $bytes $path return ("data:{0};base64,{1}" -f $mime, [Convert]::ToBase64String($bytes)) } return '' } $logoDataUri = '' $partnerBadgeUris = @{} # v1.1.5 — partner badges + logo all sourced from kritical.au harvest only if ($brandRoot) { # v1.1.5 — JoshCreations imagery removed end-to-end per operator directive 2026-06-25. # Cover uses kritical.au Horizontal_Logo (which already bakes in the tagline). # Footer uses kritical.au partner badges on navy band only. $logoCandidate = Join-Path $brandRoot 'banners\from-kritical-au-20260625\Horizontal_Logo.png' $logoFallback = Join-Path $brandRoot 'logos\Horizontal_Logo.png' $logoFallback2 = Join-Path $brandRoot 'logos\Original_Logo.png' $partnerSrc = Join-Path $brandRoot 'banners\from-kritical-au-20260625' foreach ($cand in @($logoCandidate,$logoFallback,$logoFallback2)) { if (Test-Path -LiteralPath $cand) { $logoDataUri = & $imgToDataUri $cand 'image/png'; break } } if (Test-Path -LiteralPath $partnerSrc) { foreach ($pBadge in @('Microsoft_White.png','Pax8.png','Apple_2.png','Veeam.png','Lenovo.png','Crowdstrike-1-384x230.webp')) { $pPath = Join-Path $partnerSrc $pBadge if (Test-Path -LiteralPath $pPath) { $partnerBadgeUris[$pBadge] = & $imgToDataUri $pPath 'image/png' } } } } $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 } } # v1.1.4 — Single clean footer band: partner badges on navy + one row of contact + tagline. No duplicate image. $footerHtml = if ($NoFooter.IsPresent) { '' } else { @" <footer class="kr-footer"> __PARTNER_BADGE_ROW__ <div class="kr-footer-line"> <span class="kr-tagline"><em>$($messaging.tagline)</em></span> </div> <div class="kr-footer-line kr-corp"> $($entity.legalName) · ABN $($entity.abn) · ACN $($entity.acn) · $($entity.registeredAddress) </div> <div class="kr-footer-line kr-contact"> $($contact.phoneMain) · <a href="mailto:$($contact.emailSales)">$($contact.emailSales)</a> · <a href="$($contact.webPrimary)">$($contact.webPrimary)</a> </div> </footer> "@ } # v1.1.5 — Clean cover: kritical.au Horizontal_Logo (tagline baked in) + positioning text + clean contact line. $coverContactHtml = "<div class='kr-cover-contact'>$($contact.phoneMain) · <a href='mailto:$($contact.emailSales)'>$($contact.emailSales)</a> · <a href='$($contact.webPrimary)'>$($contact.webPrimary)</a></div>" $headerHtml = if ($logoDataUri) { @" <div class="kr-cover"> <img src="$logoDataUri" alt="Kritical" class="kr-cover-logo"/> <div class="kr-cover-positioning">$($messaging.positioning)</div> $coverContactHtml </div> "@ } else { @" <div class="kr-cover"> <div class="kr-cover-wordmark">Kritical™</div> <div class="kr-cover-tagline">$($messaging.tagline)</div> <div class="kr-cover-positioning">$($messaging.positioning)</div> $coverContactHtml </div> "@ } # 1.1.3 — Partner badges row appended below the footer banner $partnerBadgeRowHtml = '' if ($partnerBadgeUris.Count -gt 0) { $badgesHtml = ($partnerBadgeUris.GetEnumerator() | Sort-Object Name | ForEach-Object { "<img src='$($_.Value)' alt='$([IO.Path]::GetFileNameWithoutExtension($_.Key))' class='kr-partner-badge'/>" }) -join "`n " $partnerBadgeRowHtml = @" <div class="kr-partner-row"> <div class="kr-partner-label">Authorised partner of</div> <div class="kr-partner-badges"> $badgesHtml </div> </div> "@ } # Substitute partner-row placeholder into footer (after both defined) $footerHtml = $footerHtml.Replace('__PARTNER_BADGE_ROW__', $partnerBadgeRowHtml) $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; } /* v1.1.9.2 — kill browser default body top-spacing for HTML standalone view; @page handles PDF print */ body { font-family: 'Assistant', 'Segoe UI', Calibri, Arial, sans-serif; color:$textColor; line-height:1.65; font-size: 11pt; max-width: 920px; margin: 0 auto; padding: 0.6em 1.2em 3em 1.2em; background:#fff; } @media screen { body { padding-top: 1.2em; } } h1,h2,h3,h4 { font-family: 'Roboto', 'Segoe UI', Calibri, Arial, sans-serif; color:$primary; font-weight: 500; line-height:1.25; margin: 1.4em 0 0.6em 0; } h1 { font-size: 26pt; border-bottom: 4px solid $primary; padding-bottom: 0.25em; margin: 0.2em 0 0.6em 0; } .kr-cover + h1, .kr-cover + h2 { margin-top: 0; } h2 { font-size: 16pt; border-bottom: 2px solid $secondary; padding-bottom: 0.2em; margin-top: 2em; } h3 { font-size: 13pt; color: $primary; margin-top: 1.5em; } h4 { font-size: 11.5pt; color: $secondary; } p { margin: 0.6em 0; } strong { color: $primary; font-weight: 600; } a { color: $secondary; text-decoration: underline; text-decoration-color: $lightGrey; text-underline-offset: 2px; } a:hover { text-decoration-color: $secondary; } .doc-meta { margin: 0.4em 0 1.2em 0; padding: 0.75em 1em; border-left: 4px solid $secondary; background: #f7f9fb; font-size: 10pt; line-height: 1.35; page-break-inside: avoid; break-inside: avoid; } .doc-meta div { margin: 0.16em 0; } .doc-meta strong { display: inline-block; min-width: 6.8em; color: $primary; } table { border-collapse: collapse; margin: 1em 0; width: 100%; font-size: 10pt; } th, td { border: 1px solid $lightGrey; padding: 0.55em 0.8em; text-align: left; vertical-align: top; } th { background: $primary; color: #fff; font-family: 'Roboto', Calibri, sans-serif; font-weight: 500; } tr:nth-child(even) td { background: #f7f9fb; } table.kr-gantt { border-collapse: separate; border-spacing: 0; table-layout: fixed; width: 100%; margin: 1em 0 1.4em 0; font-size: 8.5pt; page-break-inside: avoid; break-inside: avoid; } table.kr-gantt th, table.kr-gantt td { border: 1px solid #cfd8e3; padding: 0.42em 0.45em; text-align: center; vertical-align: middle; line-height: 1.25; } table.kr-gantt thead th { background: $primary; color: #fff; font-weight: 600; } table.kr-gantt th:first-child, table.kr-gantt td:first-child { width: 20%; text-align: left; background: #eef3f7; color: $primary; font-weight: 600; } table.kr-gantt tr:nth-child(even) td { background: #fff; } table.kr-gantt .g-empty { background: #fff !important; color: transparent; } table.kr-gantt .g-bar { color: #fff; font-family: 'Roboto', Calibri, sans-serif; font-weight: 600; text-align: center; letter-spacing: 0; } table.kr-gantt .g-navy { background: #13365C !important; } table.kr-gantt .g-cyan { background: #0B83A5 !important; } table.kr-gantt .g-green { background: #27754F !important; } table.kr-gantt .g-amber { background: #A76209 !important; } table.kr-gantt .g-plum { background: #6E3A78 !important; } table.kr-gantt .g-slate { background: #4F6072 !important; } table.kr-gantt .g-red { background: #A23B32 !important; } .kr-gantt-note { margin: -0.4em 0 1.2em 0; font-size: 9pt; color: #4d5965; font-style: italic; } .kr-architecture { margin: 1em 0 1.35em 0; page-break-inside: avoid; break-inside: avoid; } .kr-arch-row { display: flex; align-items: stretch; gap: 0.7em; margin: 0.7em 0; } .kr-arch-card, .kr-layer { border: 1px solid #cfd8e3; border-left: 5px solid $primary; background: #f7f9fb; padding: 0.75em 0.9em; box-sizing: border-box; page-break-inside: avoid; break-inside: avoid; } .kr-arch-card { flex: 1 1 0; min-width: 0; } .kr-arch-arrow { flex: 0 0 1.2em; align-self: center; text-align: center; color: $secondary; font-family: 'Roboto', sans-serif; font-size: 16pt; font-weight: 700; } .kr-arch-title, .kr-layer-title { font-family: 'Roboto', sans-serif; color: $primary; font-weight: 600; margin: 0 0 0.35em 0; line-height: 1.2; } .kr-arch-body, .kr-layer-body { font-size: 9.2pt; line-height: 1.35; } .kr-layer { margin: 0.45em 0; } .kr-layer ul { margin: 0.35em 0 0.15em 1.1em; } .kr-layer li { margin: 0.12em 0; } .kr-layer-foundation { border-left-color: #13365C; } .kr-layer-data { border-left-color: #0B83A5; } .kr-layer-process { border-left-color: #27754F; } .kr-layer-app { border-left-color: #A76209; } .kr-layer-human { border-left-color: #6E3A78; } .kr-layer-security { border-left-color: #A23B32; } .kr-arch-note { margin: 0.6em 0 1.1em 0; font-size: 9pt; color: #4d5965; font-style: italic; } blockquote { border-left: 5px solid $primary; margin: 1.2em 0; padding: 0.7em 1.2em; background: #f4f7fb; color: $textColor; font-style: normal; } code { background: #eef2f7; color: #13365C; padding: 0.1em 0.4em; font-family: Consolas, 'Courier New', monospace; font-size: 9.5pt; border-radius: 2px; } td[class*='g-'] code, .g-bar code { color: #13365C; background: rgba(255,255,255,0.92); } pre { background: #f4f7fb; padding: 0.9em 1.1em; overflow-x: auto; font-family: Consolas, monospace; font-size: 9pt; border-left: 4px solid $secondary; margin: 1em 0; } pre { white-space: pre-wrap; word-wrap: break-word; } hr { border: 0; border-top: 1px solid $lightGrey; margin: 2em 0; } ul, ol { margin: 0.5em 0 0.8em 1.6em; padding: 0; } li { margin: 0.25em 0; } figure, img, svg { max-width: 100%; box-sizing: border-box; } img { height: auto; display: block; margin: 1em auto; } figure img { max-width: 82%; max-height: 42vh; } .page-break { page-break-after: always; break-after: page; } /* Customer-facing sales-document primitives, lifted from the Kritical playbook style but kept print-safe. */ .callout { border-left: 4px solid $primary; padding: 1em 1.2em; margin: 1em 0; background: #f7f9fb; } .callout-title { font-family: 'Roboto', sans-serif; font-weight: 600; color: $primary; margin-bottom: 0.25em; } .callout-red { border-left-color: #c0392b; background: #fdf6f6; } .callout-red .callout-title { color: #c0392b; } .callout-green { border-left-color: #278f5f; background: #f4fbf7; } .callout-green .callout-title { color: #278f5f; } .callout-blue { border-left-color: $secondary; background: #f3fbfd; } .callout-blue .callout-title { color: $primary; } .stat-grid { display: flex; flex-wrap: wrap; gap: 0.7em; margin: 1em 0; } .stat-item { flex: 1 1 28%; min-width: 160px; padding: 0.8em 1em; background: #f7f9fb; border-left: 3px solid $primary; } .stat-number { display: block; font-family: 'Roboto', sans-serif; font-size: 18pt; font-weight: 600; color: $primary; line-height: 1.1; } .stat-label { display: block; font-size: 9pt; color: #4d5965; margin-top: 0.3em; } .feature-list { list-style: none; margin-left: 0; padding-left: 0; } .feature-list li { position: relative; padding-left: 1.4em; } .feature-list li::before { content: "\2713"; position: absolute; left: 0; color: #278f5f; font-weight: 700; } .script-box, .email-template { background: #f7f9fb; border: 1px solid $lightGrey; padding: 1em 1.2em; margin: 1em 0; font-size: 10pt; } .script-label, .email-header { font-family: 'Roboto', sans-serif; font-weight: 600; color: $primary; margin-bottom: 0.4em; text-transform: uppercase; letter-spacing: 0.08em; font-size: 8.5pt; } /* v1.1.6.1 — Cover-page header: tight at top, tight to body */ .kr-cover { text-align: center; padding: 0; margin: 0 0 0.4em 0; } .kr-cover-logo { max-height: 180px; max-width: 50%; display: block; margin: 0 auto 0.4em auto; } .kr-cover-wordmark { font-family: 'Roboto', sans-serif; font-size: 56pt; color: $primary; margin: 0 0 0.2em 0; letter-spacing: 0.02em; font-weight: 500; } .kr-cover-tagline { font-family: 'Assistant', sans-serif; font-size: 18pt; color: $primary; font-style: italic; margin: 0.4em 0 0.2em 0; font-weight: 500; } .kr-cover-positioning { font-family: 'Assistant', sans-serif; font-size: 11pt; color: $textColor; opacity: 0.78; margin: 0 0 0.3em 0; text-align: center; } .kr-cover-contact { font-family: 'Assistant', sans-serif; font-size: 10.5pt; color: $primary; text-align: center; margin: 0.2em 0 0.6em 0; } .kr-cover-contact a { color: $secondary; text-decoration: none; padding: 0 0.3em; } .kr-banner-strip { display: block; width: 100%; max-width: 100%; height: auto; margin: 1em auto 0 auto; border-top: 3px solid $primary; border-bottom: 3px solid $primary; } /* v1.1.7 — Partner-badges band sits as the primary footer, kept inside print-safe page bounds */ .kr-partner-row { margin: 1.2em auto 0.7em auto; text-align: center; background: $primary; padding: 0.9em 0.8em 1em 0.8em; width: 92%; max-width: 92%; box-sizing: border-box; overflow: hidden; page-break-inside: avoid; } .kr-partner-label { font-family: 'Roboto', sans-serif; font-size: 8pt; color: #fff; text-transform: uppercase; letter-spacing: 0.14em; margin-bottom: 0.55em; opacity: 0.88; } .kr-partner-badges { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); align-items: center; justify-content: center; gap: 0.55em; width: 100%; max-width: 100%; margin: 0 auto; } .kr-partner-badge { display: block; max-height: 26px; max-width: 82px; width: auto; height: auto; margin: 0 auto; vertical-align: middle; object-fit: contain; filter: brightness(1.05); } /* Footer text band underneath the partner row */ .kr-footer { margin-top: 1.5em; text-align: center; line-height: 1.55; page-break-inside: avoid; break-inside: avoid; } .kr-footer-line { display: block; margin: 0.18em 0; font-size: 8.6pt; color: $textColor; } .kr-footer-line.kr-corp { opacity: 0.78; } .kr-footer-line.kr-contact { margin-top: 0.4em; } .kr-footer-line.kr-contact a { color: $secondary; text-decoration: none; padding: 0 0.3em; } .kr-tagline { color: $primary; font-size: 10pt; font-weight: 500; } .kr-corp { color: $textColor; opacity: 0.78; } .kr-contact { color: $textColor; margin-top: 0.4em; } .kr-contact a { color: $secondary; text-decoration: none; padding: 0 0.3em; } .kr-signature { margin-top: 2em; padding: 1.5em 1em 0 1em; border-top: 1px dashed $lightGrey; font-size: 10pt; } @page { size: A4; margin: 12mm 16mm 18mm 16mm; } @media print { body { max-width: none; padding: 0 4mm; } .kr-cover { page-break-after: avoid; } h1, h2, h3 { page-break-after: avoid; } tr, td, th { page-break-inside: avoid; } table.kr-gantt { font-size: 7.6pt; } table.kr-gantt th, table.kr-gantt td { padding: 0.32em 0.28em; } .doc-meta { font-size: 9.2pt; padding: 0.55em 0.75em; } .kr-arch-row { gap: 0.35em; } .kr-arch-card, .kr-layer { padding: 0.5em 0.65em; } .kr-arch-body, .kr-layer-body { font-size: 8.2pt; } .kr-arch-arrow { font-size: 12pt; } .kr-partner-row { width: 88%; max-width: 88%; padding: 0.45in 0.25in 0.5in 0.25in; } .kr-partner-badges { gap: 0.25in; } .kr-partner-badge { max-height: 0.27in; max-width: 0.85in; } } "@ # --- Render Markdown source -> HTML via Pandoc (preferred) ----------------------- $pandocCmd = $null $pandocCommand = Get-Command pandoc -ErrorAction SilentlyContinue if ($pandocCommand) { $pandocCmd = $pandocCommand.Source } else { foreach ($pandocCandidate in @( (Join-Path $env:LOCALAPPDATA 'Pandoc\pandoc.exe'), (Join-Path $env:ProgramFiles 'Pandoc\pandoc.exe'), (Join-Path ${env:ProgramFiles(x86)} 'Pandoc\pandoc.exe') )) { if ($pandocCandidate -and (Test-Path -LiteralPath $pandocCandidate)) { $pandocCmd = $pandocCandidate break } } } $haveP = $null -ne $pandocCmd $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' # 1.1.10 — Mermaid pre-render: substitute ```mermaid``` blocks with brand-themed PNG images before Pandoc $sourceForRender = $Source if (-not $isHtml) { $mmdcCandidates = @( (Join-Path $env:TEMP 'lens-npm-prefix\mmdc.cmd'), (Join-Path $env:APPDATA 'npm\mmdc.cmd'), 'mmdc' ) $mmdcCmd = $mmdcCandidates | Where-Object { $_ -eq 'mmdc' -or (Test-Path -LiteralPath $_) } | Select-Object -First 1 if ($mmdcCmd) { $md = Get-Content -LiteralPath $Source -Raw -Encoding UTF8 if ($md -match '(?ms)```mermaid\r?\n.*?\r?\n```') { $diagramDir = Join-Path $OutDir ("{0}-diagrams" -f $baseName) New-Item -ItemType Directory -Force -Path $diagramDir | Out-Null $themeJson = Join-Path $diagramDir 'krit-mermaid-theme.json' @" { "theme": "base", "themeVariables": { "primaryColor": "$primary", "primaryTextColor": "#FFFFFF", "primaryBorderColor": "$primary", "lineColor": "$secondary", "secondaryColor": "$secondary", "tertiaryColor": "$lightGrey", "fontFamily": "Roboto, Arial, sans-serif" } } "@ | Set-Content -LiteralPath $themeJson -Encoding UTF8 $script:diagIndex = 0 $pattern = '(?ms)```mermaid\r?\n(.*?)\r?\n```' $newMd = [regex]::Replace($md, $pattern, { param($m) $script:diagIndex++ $mmdSrc = Join-Path $diagramDir "diagram-$($script:diagIndex).mmd" $pngOut = Join-Path $diagramDir "diagram-$($script:diagIndex).png" $svgOut = Join-Path $diagramDir "diagram-$($script:diagIndex).svg" $m.Groups[1].Value | Set-Content -LiteralPath $mmdSrc -Encoding UTF8 try { & $mmdcCmd -i $mmdSrc -o $pngOut -c $themeJson -b white --quiet 2>&1 | Write-Verbose } catch { Write-Warning "mmdc failed on diagram $($script:diagIndex): $_" } if (-not (Test-Path -LiteralPath $pngOut)) { try { & $mmdcCmd -i $mmdSrc -o $svgOut -c $themeJson -b white --quiet 2>&1 | Write-Verbose } catch { Write-Warning "mmdc SVG fallback failed on diagram $($script:diagIndex): $_" } } $diagramOut = if (Test-Path -LiteralPath $pngOut) { $pngOut } elseif (Test-Path -LiteralPath $svgOut) { $svgOut } else { $null } if ($diagramOut) { # URL-encode spaces (and other unsafe chars) so Pandoc + Chrome both follow it $urlPath = ($diagramOut -replace '\\','/') $urlPath = [System.Uri]::EscapeUriString($urlPath) "" } else { $m.Value # leave original block on failure } }) $preRenderedMd = Join-Path $OutDir ("{0}-prerendered.md" -f $baseName) Set-Content -LiteralPath $preRenderedMd -Value $newMd -Encoding UTF8 $sourceForRender = $preRenderedMd } } else { Write-Verbose "mmdc not found; mermaid blocks will render as code" } } # 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 { & $pandocCmd $sourceForRender -f 'gfm+raw_html' -t html5 -o $bodyHtmlPath 2>&1 | Write-Verbose } $bodyHtml = Get-Content -LiteralPath $bodyHtmlPath -Raw -Encoding UTF8 # v1.1.10 — inline local images as data-URIs so standalone HTML/PDF paths cannot break when shared. # Pandoc may emit a line break between <img and src=, so this uses dotall matching. $localImgPat = '(?is)<img\b([^>]*?)\bsrc=(["''])([^"'']+\.(?:svg|png|jpe?g|webp|gif))\2([^>]*)>' $bodyHtml = [regex]::Replace($bodyHtml, $localImgPat, { param($m) $rawSrc = [System.Net.WebUtility]::HtmlDecode($m.Groups[3].Value) if ($rawSrc -match '^(?i:data:|https?://)') { return $m.Value } if ($rawSrc -match '^file:') { try { $local = ([Uri]$rawSrc).LocalPath } catch { $local = $rawSrc -replace '^file:///','' } } else { $local = [System.Uri]::UnescapeDataString($rawSrc) } # If relative, resolve against OutDir if (-not [IO.Path]::IsPathRooted($local)) { $local = Join-Path $OutDir $local } if (Test-Path -LiteralPath $local) { try { $bytes = [IO.File]::ReadAllBytes($local) $mime = & $detectImageMime $bytes $local $b64 = [Convert]::ToBase64String($bytes) $before = $m.Groups[1].Value $after = $m.Groups[4].Value return "<img$before src=`"data:$mime;base64,$b64`"$after>" } catch { Write-Warning "Could not inline image: $local" } } return $m.Value }) $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 = @( $sourceForRender '-f', 'gfm+raw_html' '-t', 'docx' '--metadata', "title=$CustomerName - $DocumentTitle" '--metadata', "author=$($messaging.directorName)" '-o', $docxOut ) if ($referenceDoc) { $pargs += '--reference-doc'; $pargs += $referenceDoc } & $pandocCmd @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() } } |