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
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 URL extension extraction fallback ?? "docx" never triggers for extensionless URLs

When documentUrl has no file extension in its pathname (e.g., https://api.example.com/files/123), urlPath.split(".").pop() returns the entire pathname string (e.g., /files/123), not null/undefined. The ?? "docx" fallback therefore never fires. This results in ext being set to the full pathname, producing an invalid inputPath like /tmp/lo-1234/input./files/123 and an outputPath (input.pdf) that won't match LibreOffice's actual output filename.

Reproduction and fix
// With URL: https://example.com/api/v1/files/123
const urlPath = new URL(url).pathname; // "/api/v1/files/123"
const ext = urlPath.split(".").pop();  // "/api/v1/files/123" (not undefined!)

A safer approach would be to extract the basename first and check for a dot:

const basename = urlPath.split("/").pop() ?? "";
const ext = basename.includes(".") ? basename.split(".").pop() : "docx";
Suggested change
const ext = urlPath.split(".").pop() ?? "docx";
const basename = urlPath.split("/").pop() ?? "";
const ext = basename.includes(".") ? basename.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";
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"]
}