Go library and CLI providing CGO bindings to LibreOfficeKit for document-to-PDF conversion. It loads LibreOffice as an in-process shared library via dlopen, eliminating the need for Python or UNO sockets.
Note
lok powers the LibreOffice conversions of Gotenberg 9. Its API is pre-1.0 and may change between releases.
Build and runtime dependencies (Debian/Ubuntu):
apt-get install -y \
gcc \
libc6-dev \
libreoffice-core \
libreoffice-writer \
libreoffice-calc \
libreoffice-impress \
libreofficekit-dev \
fontconfig \
fonts-liberation- Go 1.27.3+ with
CGO_ENABLED=1 - gcc, libc6-dev: C compiler and headers for CGO
- libreoffice-core, -writer, -calc, -impress: LibreOffice runtime
- libreofficekit-dev: C headers for the LibreOfficeKit API
- fontconfig, fonts-liberation: fonts for document rendering
Prebuilt lok binaries for Linux (amd64, 386, arm64, armv7, ppc64le), the platforms Gotenberg ships, come with each GitHub release. They need glibc 2.34+ and LibreOffice at runtime. The install script downloads one, verifies its checksum and installs it in /usr/local/bin:
curl -fsSL https://raw.githubusercontent.com/gotenberg/lok/main/install.sh | shSet LOK_VERSION=v1.2.3 to pin a release and LOK_INSTALL_DIR to install elsewhere.
The cmd/lok binary converts a single document to PDF:
go build -o lok ./cmd/lok
lok \
--input-path input.docx \
--output-path output.pdf \
--libreoffice-program-path /usr/lib/libreoffice/program \
--landscape \
--quality 50 \
--page-ranges "1-3"Run lok --help for the full list of flags (page geometry, PDF export filter, viewer preferences).
By default, each run uses a private temporary LibreOffice user profile. --user-profile-path points LibreOffice at a directory you control instead, and lok leaves it in place on exit. Seed <dir>/user/registrymodifications.xcu beforehand to apply LibreOffice settings, for example a proxy or BlockUntrustedRefererLinks.
In long-running mode, the binary keeps a single LibreOffice instance alive and reads JSON requests from stdin:
lok --long-running --libreoffice-program-path /usr/lib/libreoffice/program{ "inputPath": "/tmp/in.docx", "outputPath": "/tmp/out.pdf", "landscape": true }{ "success": true }A failed request also carries a code, so you can branch on it without parsing error: invalid_request, invalid_password (LibreOffice rejected the password given for an encrypted document), load_failed, save_failed, office_destroyed, panic or unknown.
{
"success": false,
"code": "invalid_password",
"error": "failed to load document: wrong document password"
}This avoids the ~1-2s lok_init overhead per conversion.
Prefer the CLI binary over embedding the library directly in your Go process.
LibreOffice loads as a shared library (dlopen) and has characteristics that make in-process embedding difficult:
- Memory leaks. LibreOffice leaks ~0.5 MiB per conversion.
destroy()+lok_init()does not fully reclaim memory because the shared library stays loaded withoutdlclose. The only reliable way to reclaim memory is process death. - No cancellation. LibreOfficeKit calls are blocking C functions. Go contexts, timeouts, and cancellation do not interrupt them. A hung conversion will block the goroutine (and the underlying OS thread) indefinitely.
- Signal handler conflicts. LibreOffice installs signal handlers without
SA_ONSTACK, which can crash the Go runtime. This requiresGODEBUG=asyncpreemptoff=1as a workaround. - Not thread-safe. A single LibreOfficeKit instance cannot be used from multiple goroutines concurrently, even for operations on different documents.
The cmd/lok binary sidesteps all of these: the caller spawns it as a subprocess, can kill it on timeout, and gets full memory reclamation on process exit. For batch workloads, the --long-running mode avoids re-initialization overhead while still allowing the caller to kill and restart the process when needed.
When embedding directly is acceptable (short-lived or controlled processes; otherwise prefer the CLI):
import "github.com/gotenberg/lok/pkg/lok"
office, err := lok.Init("/usr/lib/libreoffice/program")
if err != nil {
log.Fatal(err)
}
opts := lok.DefaultOptions()
opts.Landscape = true
opts.Quality = 50
if err := lok.Convert(office, "input.docx", "output.pdf", opts); err != nil {
log.Fatal(err)
}Options configures the export: page orientation and paper size, page ranges, PDF/A conformance, image quality, watermark, and viewer preferences. DefaultOptions matches LibreOffice's own defaults.
Warning
Calling Close (or Lifecycle.Close) is optional and usually best skipped: LibreOffice's destroy() can crash the Go runtime on shutdown (it installs signal handlers without SA_ONSTACK). Prefer letting process exit reclaim resources, or run with GODEBUG=asyncpreemptoff=1.
Note
Page geometry (Landscape, PaperFormat, PaperWidth/PaperHeight) is applied by a Basic macro, which LibreOfficeKit cannot do through a UNO dispatch. Init creates a private user profile, establishes it with a one-time soffice run, installs the macro, and removes the profile on Close. Documents load with macro execution on at LibreOffice's "very high" security level and no trusted location, so their own macros don't run unless EnableMacros is set: since LibreOffice 26.8.2, a load with macros off blocks every macro, this one included.
Lifecycle reuses one Office across conversions and trims LibreOffice's leaked memory on a schedule:
lc, err := lok.NewLifecycle(lok.LifecycleConfig{
ProgramPath: "/usr/lib/libreoffice/program",
TrimInterval: 10,
})
if err != nil {
log.Fatal(err)
}
err = lc.Convert("input.docx", "output.pdf", lok.DefaultOptions())- Contributing guide: CONTRIBUTING.md
- Bug reports: GitHub Issues
- Security vulnerabilities: SECURITY.md