Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/libreoffice-build-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/build": patch
---

feat(build): add libreoffice build extension for headless docx/pptx to PDF conversion
21 changes: 15 additions & 6 deletions docs/guides/examples/libreoffice-pdf-conversion.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,36 @@ import LocalDevelopment from "/snippets/local-development-extensions.mdx";
- [LibreOffice](https://www.libreoffice.org/download/libreoffice-fresh/) installed on your machine
- A [Cloudflare R2](https://developers.cloudflare.com) account and bucket

### Using our `aptGet` build extension to add the LibreOffice package
### Using the `libreoffice` build extension

To deploy this task, you'll need to add LibreOffice to your project configuration, like this:
To deploy this task, add the dedicated `libreoffice` build extension to your project configuration. It installs LibreOffice in headless mode (no X11 required) along with the fonts needed for accurate document rendering:

```ts trigger.config.ts
import { aptGet } from "@trigger.dev/build/extensions/core";
import { libreoffice } from "@trigger.dev/build/extensions/libreoffice";
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
project: "<project ref>",
// Your other config settings...
build: {
extensions: [
aptGet({
packages: ["libreoffice"],
}),
libreoffice(),
],
},
});
```

By default this installs `libreoffice-writer` (for `.docx`) and `libreoffice-impress` (for `.pptx`) together with `fonts-liberation` and `fonts-dejavu-core`. You can customise which components are installed:

```ts trigger.config.ts
libreoffice({
// Only install the writer component (smaller image)
components: ["writer"],
// Add extra font packages if needed
extraFonts: ["fonts-noto"],
})
```

<Note>
[Build extensions](/config/extensions/overview) allow you to hook into the build system and
customize the build process or the resulting bundle and container image (in the case of
Expand Down
17 changes: 16 additions & 1 deletion packages/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"./extensions/typescript": "./src/extensions/typescript.ts",
"./extensions/puppeteer": "./src/extensions/puppeteer.ts",
"./extensions/playwright": "./src/extensions/playwright.ts",
"./extensions/lightpanda": "./src/extensions/lightpanda.ts"
"./extensions/lightpanda": "./src/extensions/lightpanda.ts",
"./extensions/libreoffice": "./src/extensions/libreoffice.ts"
},
"sourceDialects": [
"@triggerdotdev/source"
Expand Down Expand Up @@ -65,6 +66,9 @@
],
"extensions/lightpanda": [
"dist/commonjs/extensions/lightpanda.d.ts"
],
"extensions/libreoffice": [
"dist/commonjs/extensions/libreoffice.d.ts"
]
}
},
Expand Down Expand Up @@ -207,6 +211,17 @@
"types": "./dist/commonjs/extensions/lightpanda.d.ts",
"default": "./dist/commonjs/extensions/lightpanda.js"
}
},
"./extensions/libreoffice": {
"import": {
"@triggerdotdev/source": "./src/extensions/libreoffice.ts",
"types": "./dist/esm/extensions/libreoffice.d.ts",
"default": "./dist/esm/extensions/libreoffice.js"
},
"require": {
"types": "./dist/commonjs/extensions/libreoffice.d.ts",
"default": "./dist/commonjs/extensions/libreoffice.js"
}
}
},
"main": "./dist/commonjs/index.js",
Expand Down
66 changes: 66 additions & 0 deletions packages/build/src/extensions/libreoffice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { BuildManifest } from "@trigger.dev/core/v3";
import { BuildContext, BuildExtension } from "@trigger.dev/core/v3/build";

export type LibreOfficeOptions = {
/**
* Which LibreOffice component packages to install.
* Defaults to ["writer", "impress"] for docx and pptx support.
* - "writer" → libreoffice-writer (handles .doc/.docx)
* - "impress" → libreoffice-impress (handles .ppt/.pptx)
* - "calc" → libreoffice-calc (handles .xls/.xlsx)
* - "draw" → libreoffice-draw (handles .odg)
* - "math" → libreoffice-math (formula editor)
*/
components?: Array<"writer" | "impress" | "calc" | "draw" | "math">;
/**
* Additional font packages to install beyond the built-in defaults.
* Built-in defaults: fonts-liberation, fonts-dejavu-core.
* Example: ["fonts-noto", "fonts-freefont-ttf"]
*/
extraFonts?: string[];
};

export function libreoffice(options: LibreOfficeOptions = {}): BuildExtension {
return new LibreOfficeExtension(options);
}

class LibreOfficeExtension implements BuildExtension {
public readonly name = "LibreOfficeExtension";

constructor(private readonly options: LibreOfficeOptions = {}) {}

async onBuildComplete(context: BuildContext, manifest: BuildManifest) {
if (context.target === "dev") {
return;
}

const components = this.options.components ?? ["writer", "impress"];
const componentPkgs = components.map((c) => `libreoffice-${c}`);

// fonts-liberation: free equivalents of Times New Roman, Arial, Courier New –
// essential for accurate rendering of most Office documents.
// fonts-dejavu-core: broad Unicode coverage for international content.
const fontPkgs = ["fonts-liberation", "fonts-dejavu-core", ...(this.options.extraFonts ?? [])];

const allPkgs = [...componentPkgs, ...fontPkgs].join(" \\\n ");

context.logger.debug(`Adding ${this.name} to the build`, { components });

context.addLayer({
id: "libreoffice",
image: {
// Use --no-install-recommends to avoid pulling in X11 desktop packages.
// LibreOffice's --headless flag handles PDF conversion without a display.
instructions: [
`RUN apt-get update && apt-get install -y --no-install-recommends \\\n ${allPkgs} \\\n && rm -rf /var/lib/apt/lists/*`,
],
},
deploy: {
env: {
LIBREOFFICE_PATH: "/usr/bin/libreoffice",
},
override: true,
},
});
}
}
16 changes: 16 additions & 0 deletions references/libreoffice/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "references-libreoffice",
"private": true,
"type": "module",
"devDependencies": {
"trigger.dev": "workspace:*"
},
"dependencies": {
"@trigger.dev/build": "workspace:*",
"@trigger.dev/sdk": "workspace:*"
},
"scripts": {
"dev": "trigger dev",
"deploy": "trigger deploy"
}
}
81 changes: 81 additions & 0 deletions references/libreoffice/src/trigger/libreoffice-convert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { task } from "@trigger.dev/sdk";
import { execFile } from "node:child_process";
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

/**
* Convert a .docx or .pptx file (supplied as a URL) to PDF using LibreOffice
* running in headless mode — no X11 display required.
*
* Requires the `libreoffice()` build extension in trigger.config.ts so that
* LibreOffice is available inside the deployed container.
*/
export const libreofficeConvert = task({
id: "libreoffice-convert",
run: async (payload: {
/** Public URL of the .docx or .pptx file to convert. */
documentUrl: string;
/** Optional output filename (without extension). Defaults to "output". */
outputName?: string;
}) => {
const { documentUrl, outputName = "output" } = payload;

// Use a unique temp directory so concurrent runs don't collide.
const workDir = join(tmpdir(), `lo-${Date.now()}`);
mkdirSync(workDir, { recursive: true });

// Derive a safe input filename from the URL.
const urlPath = new URL(documentUrl).pathname;
const ext = urlPath.split(".").pop() ?? "docx";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 URL extension extraction produces invalid file path when URL has no file extension

When the documentUrl has no dot-separated file extension in its pathname (e.g., https://api.example.com/documents/report or a signed URL like https://storage.example.com/abc123), the extension extraction at line 33 produces a broken path that causes a runtime crash.

Root Cause

The expression urlPath.split(".").pop() always returns a non-empty string (the last element of the split array), so the ?? "docx" fallback never triggers for URLs without a file extension.

For example, given documentUrl = "https://api.example.com/documents/report":

  • urlPath = "/documents/report"
  • urlPath.split(".") = ["/documents/report"]
  • .pop() = "/documents/report" — a truthy string, not null/undefined
  • ext = "/documents/report"
  • inputPath = join(workDir, "input./documents/report") → e.g. /tmp/lo-123/input./documents/report

The resulting inputPath contains intermediate directory components (/documents/) that don't exist, so writeFileSync(inputPath, ...) at line 45 throws ENOENT. Even URLs with dots in non-extension positions (e.g. https://api.v2.example.com/file) would extract garbage like "com/file" containing a /.

Impact: The task crashes for any URL whose pathname doesn't end with a recognizable .ext suffix. Since many document-serving APIs and signed URLs lack file extensions, this is a common real-world scenario. Users are likely to copy this reference project as a template.

Suggested change
const ext = urlPath.split(".").pop() ?? "docx";
const lastSegment = urlPath.split("/").pop() ?? "";
const ext = lastSegment.includes(".") ? lastSegment.split(".").pop()! : "docx";
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const inputPath = join(workDir, `input.${ext}`);
// LibreOffice names the output after the input file stem.
const outputPath = join(workDir, `input.pdf`);

try {
// 1. Download the source document.
const response = await fetch(documentUrl);
if (!response.ok) {
throw new Error(`Failed to fetch document: ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
writeFileSync(inputPath, Buffer.from(arrayBuffer));

// 2. Convert to PDF using LibreOffice headless.
// --norestore prevents LibreOffice from showing a recovery dialog.
// --outdir directs the output file to our working directory.
const libreofficeBin = process.env.LIBREOFFICE_PATH ?? "libreoffice";
await execFileAsync(libreofficeBin, [
"--headless",
"--norestore",
"--convert-to",
"pdf",
"--outdir",
workDir,
inputPath,
]);

// 3. Read the resulting PDF.
const pdfBuffer = readFileSync(outputPath);

return {
outputName: `${outputName}.pdf`,
sizeBytes: pdfBuffer.byteLength,
// Return base64 so the result is JSON-serialisable.
// In production you would upload pdfBuffer to S3 / R2 instead.
base64: pdfBuffer.toString("base64"),
};
} finally {
// Clean up temp files.
try {
unlinkSync(inputPath);
} catch {}
try {
unlinkSync(outputPath);
} catch {}
}
},
});
13 changes: 13 additions & 0 deletions references/libreoffice/trigger.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineConfig } from "@trigger.dev/sdk/v3";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Reference config uses deprecated @trigger.dev/sdk/v3 import path

At references/libreoffice/trigger.config.ts:1, the import uses @trigger.dev/sdk/v3 which is noted as a deprecated path alias in CLAUDE.md and packages/trigger-sdk/CLAUDE.md ("Never use @trigger.dev/sdk/v3"). While this still works as an alias, other reference projects and the docs example (docs/guides/examples/libreoffice-pdf-conversion.mdx:21) correctly use @trigger.dev/sdk. This inconsistency could confuse users who use this reference as a template.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

import { libreoffice } from "@trigger.dev/build/extensions/libreoffice";

export default defineConfig({
project: "proj_libreoffice_example",
build: {
extensions: [
// Installs libreoffice-writer and libreoffice-impress (headless, no X11)
// along with fonts-liberation and fonts-dejavu-core for accurate rendering.
libreoffice(),
],
},
});
14 changes: 14 additions & 0 deletions references/libreoffice/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "Node16",
"moduleResolution": "Node16",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"customConditions": ["@triggerdotdev/source"],
"lib": ["DOM", "DOM.Iterable"],
"noEmit": true
},
"include": ["./src/**/*.ts", "trigger.config.ts"]
}