Skip to main content

Server Setup: Document Processing (LibreOffice PDF Engine)

This guide covers everything needed to configure a Windows Server to generate PDFs from DOCX templates using LibreOffice. Follow every step in order — missing any one of them is the most common cause of conversion failures in production.


Overview​

BankLingo's DocumentRenderV2Command supports two PDF engines, controlled by the DocumentRendering:PdfEngine config key:

ValueDescription
Spire (default)Uses FreeSpire.Doc — no server setup required, but free tier limits output to 3 pages
LibreOfficeUses LibreOffice soffice.exe — full fidelity, all pages, requires server setup

For production offer letters and multi-page documents, LibreOffice is recommended.


Prerequisites Checklist​

  • Step 1 — LibreOffice installed
  • Step 2 — Java Runtime (JRE 11+) installed
  • Step 3 — Roboto fonts installed
  • Step 4 — IIS App Pool: Load User Profile = True
  • Step 5 — DocumentRendering:PdfEngine set to LibreOffice
  • Step 6 — Smoke test passes

Step 1 — Install LibreOffice​

Run in PowerShell as Administrator:

# Download LibreOffice 24.8 (64-bit Windows)
$msi = "$env:TEMP\LibreOffice.msi"
Invoke-WebRequest `
"https://download.documentfoundation.org/libreoffice/stable/24.8.7/win/x86_64/LibreOffice_24.8.7_Win_x86-64.msi" `
-OutFile $msi -UseBasicParsing

# Silent install — no reboot required
Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /qn REBOOT=ReallySuppress" -Wait

# Verify
Test-Path "C:\Program Files\LibreOffice\program\soffice.exe"
# Expected: True

Note: Always install the 64-bit build on a 64-bit server. The 32-bit build runs out of memory on large documents.


Step 2 — Install Java Runtime (JRE 11+)​

LibreOffice's DOCX → PDF filter requires Java on Windows. Without it, conversions silently fail or exit with code 1.

Option A — winget (Windows Server 2019/2022 with winget):

winget install EclipseAdoptium.Temurin.11.JRE `
--silent --accept-source-agreements --accept-package-agreements

Option B — manual MSI (if winget is unavailable):

$jreUrl = "https://github.com/adoptium/temurin11-binaries/releases/download/jdk-11.0.23%2B9/OpenJDK11U-jre_x64_windows_hotspot_11.0.23_9.msi"
Invoke-WebRequest $jreUrl -OutFile "$env:TEMP\jre11.msi" -UseBasicParsing
Start-Process msiexec.exe -ArgumentList "/i `"$env:TEMP\jre11.msi`" /qn" -Wait

Verify:

java -version
# Expected: openjdk version "11.x.x" ...

Why is this needed? LibreOffice's internal macro engine and DOCX importer use Java for some processing paths. On Windows without JRE present, the Writer filter falls back to a stripped mode that cannot produce a valid PDF from DOCX.


Step 3 — Install Roboto Fonts​

The default BankLingo DOCX templates use the Roboto font family. If Roboto is missing on the server, LibreOffice substitutes a fallback font which changes line heights — this can break the header layout even after other fixes are applied.

# Download Roboto from official Google Fonts GitHub release
$zip = "$env:TEMP\roboto.zip"
Invoke-WebRequest `
"https://github.com/googlefonts/roboto/releases/download/v2.138/roboto-unhinted.zip" `
-OutFile $zip -UseBasicParsing

$extractDir = "$env:TEMP\roboto_extract"
Expand-Archive -Path $zip -DestinationPath $extractDir -Force

# Install each TTF into Windows system fonts
Get-ChildItem $extractDir -Filter "*.ttf" -Recurse | ForEach-Object {
Copy-Item $_.FullName "C:\Windows\Fonts\$($_.Name)" -Force
$fontName = [System.IO.Path]::GetFileNameWithoutExtension($_.Name)
Set-ItemProperty `
"HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" `
-Name "$fontName (TrueType)" `
-Value $_.Name `
-ErrorAction SilentlyContinue
}

Write-Host "Roboto fonts installed."

Verify:

Get-ChildItem "C:\Windows\Fonts" -Filter "Roboto*" | Select-Object Name
# Expected: Roboto-Regular.ttf, Roboto-Bold.ttf, Roboto-Italic.ttf, etc.

Tip: If your templates use other custom fonts (e.g. a branded typeface), install those the same way. LibreOffice reads from C:\Windows\Fonts automatically.


Step 4 — IIS App Pool: Load User Profile = True​

LibreOffice needs access to %TEMP%, %APPDATA%, and a writable user profile directory. IIS Application Pool service accounts run without a loaded user profile by default — this causes LibreOffice to hang indefinitely on first run.

PowerShell (recommended — scripted, repeatable):

Import-Module WebAdministration

# Replace with your actual Application Pool name
$poolName = "BankLingoAdminApi"

Set-ItemProperty "IIS:\AppPools\$poolName" `
-Name "processModel.loadUserProfile" `
-Value $true

# Verify
$result = (Get-ItemProperty "IIS:\AppPools\$poolName" -Name "processModel").loadUserProfile
Write-Host "Load User Profile: $result"
# Expected: True

IIS Manager UI alternative:

  1. Open IIS Manager
  2. Click Application Pools in the left panel
  3. Select your pool (e.g. BankLingoAdminApi)
  4. Click Advanced Settings in the right panel
  5. Under Process Model → set Load User Profile to True
  6. Click OK → recycle the pool

Why this matters: Without a loaded profile, %TEMP% resolves to a system path where the service account has no write permission. LibreOffice fails to create its lock file and hangs waiting for a profile that never loads.


Step 5 — Configure the PDF Engine​

Add or update the following in appsettings.json on the server:

{
"DocumentRendering": {
"PdfEngine": "LibreOffice",
"PdfTimeoutSeconds": 120
}
}

Alternative — set as an IIS environment variable (no file edit, survives deployments):

Import-Module WebAdministration

$siteName = "BankLingoAdminSite" # replace with your IIS site name

# PdfEngine
Add-WebConfigurationProperty `
-PSPath "IIS:\Sites\$siteName" `
-Filter "system.webServer/aspNetCore/environmentVariables" `
-Name "." `
-Value @{ name = "DocumentRendering__PdfEngine"; value = "LibreOffice" }

# PdfTimeoutSeconds (optional — default is 60; 120 recommended for large documents)
Add-WebConfigurationProperty `
-PSPath "IIS:\Sites\$siteName" `
-Filter "system.webServer/aspNetCore/environmentVariables" `
-Name "." `
-Value @{ name = "DocumentRendering__PdfTimeoutSeconds"; value = "120" }

Note: Double underscore __ is the ASP.NET Core convention for nested config keys in environment variables (DocumentRendering:PdfEngine → DocumentRendering__PdfEngine).


Step 6 — Smoke Test​

Run this on the server to confirm LibreOffice converts a real DOCX without hanging:

$soffice = "C:\Program Files\LibreOffice\program\soffice.exe"
$tmp = "$env:TEMP\lo_smoketest_$(Get-Random)"
New-Item $tmp -ItemType Directory | Out-Null

# Replace with any small .docx on the server
$testDoc = "C:\inetpub\wwwroot\BankLingoAdmin\templates\SAMPLE_OFFER_LETTER_TEMPLATE.docx"

& $soffice `
"-env:UserInstallation=file:///$($tmp.Replace('\','/'))/profile" `
--headless --norestore --nologo `
--convert-to pdf `
--outdir $tmp `
$testDoc

Write-Host "Exit code: $LASTEXITCODE"
Get-ChildItem $tmp -Filter "*.pdf"

# Cleanup
Remove-Item $tmp -Recurse -Force

Expected output:

Exit code: 0

Directory: C:\...\lo_smoketest_...

Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 08/08/2026 162009 SAMPLE_OFFER_LETTER_TEMPLATE.pdf

If exit code is non-zero or no PDF appears, check the troubleshooting section below.


Troubleshooting​

LibreOffice hangs / times out​

CauseFix
Load User Profile = FalseStep 4 above
Stale .lock file in profileThe application clears this automatically; for manual runs delete %TEMP%\banklingo_lo_profile_v1\user\.lock
Another soffice.exe is runningStop-Process -Name soffice -Force
Antivirus blocking soffice.exeAdd LibreOffice install directory to AV exclusions

Exit code 1, no PDF produced​

CauseFix
Java not installedStep 2 above
DOCX file path contains spacesThe application passes paths via ArgumentList (not a shell string), so spaces are safe — check the actual temp path in the logs
Corrupted LibreOffice installUninstall and reinstall via the MSI

Header renders incorrectly (text compressed / clipped)​

This is handled automatically by the StripAlternateContentChoices preprocessor built into TemplateEngine2. If you see a regression, check:

  1. DocumentRendering:PdfEngine is set to LibreOffice (not Spire)
  2. The deployed DLL matches commit 705a5264 or later on master_v2_milestone
  3. The application logs for [LibreOffice] StripAlternateContentChoices failed — if present, the preprocessor caught an error and fell back to the raw bytes; investigate the warning message

Wrong fonts / garbled text​

CauseFix
Roboto not installedStep 3 above
Custom brand font missingInstall into C:\Windows\Fonts\ the same way as Roboto
Font installed per-user, not system-wideCopy to C:\Windows\Fonts\ (system-wide) not %LOCALAPPDATA%\Microsoft\Windows\Fonts\

How the PDF Engine Works (Architecture Summary)​

API Request
│
â–¼
DocumentRenderV2Command
│
├─ Render DOCX template (variable substitution, table loops)
│
├─ StripAlternateContentChoices (DOCX preprocessor)
│ ├─ Fix 1: w:lineRule="exact" on wps:wsp spacing → correct auto-fit heights
│ └─ Fix 2: wpg coordinate transform normalisation → correct header layout
│
├─ Write preprocessed DOCX to %TEMP%\docx2pdf_{guid}\input.docx
│
├─ soffice.exe --headless --convert-to pdf
│ └─ Runs with persistent profile (banklingo_lo_profile_v1)
│ Font cache built ONCE; subsequent calls start in < 5 seconds
│
└─ Return PDF bytes → store in MinIO / return to caller

The first conversion on a fresh server profile takes 20-60 seconds (LibreOffice builds its font cache). Every subsequent call takes 3-8 seconds.