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:
| Value | Description |
|---|---|
Spire (default) | Uses FreeSpire.Doc — no server setup required, but free tier limits output to 3 pages |
LibreOffice | Uses 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:PdfEngineset toLibreOffice - 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\Fontsautomatically.
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:
- Open IIS Manager
- Click Application Pools in the left panel
- Select your pool (e.g.
BankLingoAdminApi) - Click Advanced Settings in the right panel
- Under Process Model → set Load User Profile to True
- 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​
| Cause | Fix |
|---|---|
| Load User Profile = False | Step 4 above |
Stale .lock file in profile | The application clears this automatically; for manual runs delete %TEMP%\banklingo_lo_profile_v1\user\.lock |
Another soffice.exe is running | Stop-Process -Name soffice -Force |
Antivirus blocking soffice.exe | Add LibreOffice install directory to AV exclusions |
Exit code 1, no PDF produced​
| Cause | Fix |
|---|---|
| Java not installed | Step 2 above |
| DOCX file path contains spaces | The application passes paths via ArgumentList (not a shell string), so spaces are safe — check the actual temp path in the logs |
| Corrupted LibreOffice install | Uninstall 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:
DocumentRendering:PdfEngineis set toLibreOffice(notSpire)- The deployed DLL matches commit
705a5264or later onmaster_v2_milestone - 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​
| Cause | Fix |
|---|---|
| Roboto not installed | Step 3 above |
| Custom brand font missing | Install into C:\Windows\Fonts\ the same way as Roboto |
| Font installed per-user, not system-wide | Copy 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.
Related Documentation​
- DocumentRenderV2 Template Guide — template syntax and variable reference
- Creating Word Templates Guide — how to design DOCX templates in Microsoft Word