Skip to content

Commit a0a263a

Browse files
QuinnYatesclaude
andcommitted
feat: add libreoffice build extension for docx/pptx to pdf (closes #1361)
- Add `packages/build/src/extensions/libreoffice.ts`: installs libreoffice-writer and libreoffice-impress (configurable) via apt with --no-install-recommends so no X11/desktop packages are pulled in. Includes fonts-liberation and fonts-dejavu-core for accurate document rendering. Sets LIBREOFFICE_PATH env var. - Register `@trigger.dev/build/extensions/libreoffice` export in package.json. - Add `references/libreoffice/` reference project demonstrating headless docx/pptx to PDF conversion using execFile + LibreOffice --headless --norestore. - Update docs to reference the dedicated extension instead of the generic aptGet. - Add patch changeset for @trigger.dev/build. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b192b71 commit a0a263a

File tree

8 files changed

+226
-7
lines changed

8 files changed

+226
-7
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/build": patch
3+
---
4+
5+
feat(build): add libreoffice build extension for headless docx/pptx to PDF conversion

docs/guides/examples/libreoffice-pdf-conversion.mdx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,27 +12,36 @@ import LocalDevelopment from "/snippets/local-development-extensions.mdx";
1212
- [LibreOffice](https://www.libreoffice.org/download/libreoffice-fresh/) installed on your machine
1313
- A [Cloudflare R2](https://developers.cloudflare.com) account and bucket
1414

15-
### Using our `aptGet` build extension to add the LibreOffice package
15+
### Using the `libreoffice` build extension
1616

17-
To deploy this task, you'll need to add LibreOffice to your project configuration, like this:
17+
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:
1818

1919
```ts trigger.config.ts
20-
import { aptGet } from "@trigger.dev/build/extensions/core";
20+
import { libreoffice } from "@trigger.dev/build/extensions/libreoffice";
2121
import { defineConfig } from "@trigger.dev/sdk";
2222

2323
export default defineConfig({
2424
project: "<project ref>",
2525
// Your other config settings...
2626
build: {
2727
extensions: [
28-
aptGet({
29-
packages: ["libreoffice"],
30-
}),
28+
libreoffice(),
3129
],
3230
},
3331
});
3432
```
3533

34+
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:
35+
36+
```ts trigger.config.ts
37+
libreoffice({
38+
// Only install the writer component (smaller image)
39+
components: ["writer"],
40+
// Add extra font packages if needed
41+
extraFonts: ["fonts-noto"],
42+
})
43+
```
44+
3645
<Note>
3746
[Build extensions](/config/extensions/overview) allow you to hook into the build system and
3847
customize the build process or the resulting bundle and container image (in the case of

packages/build/package.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"./extensions/typescript": "./src/extensions/typescript.ts",
3232
"./extensions/puppeteer": "./src/extensions/puppeteer.ts",
3333
"./extensions/playwright": "./src/extensions/playwright.ts",
34-
"./extensions/lightpanda": "./src/extensions/lightpanda.ts"
34+
"./extensions/lightpanda": "./src/extensions/lightpanda.ts",
35+
"./extensions/libreoffice": "./src/extensions/libreoffice.ts"
3536
},
3637
"sourceDialects": [
3738
"@triggerdotdev/source"
@@ -65,6 +66,9 @@
6566
],
6667
"extensions/lightpanda": [
6768
"dist/commonjs/extensions/lightpanda.d.ts"
69+
],
70+
"extensions/libreoffice": [
71+
"dist/commonjs/extensions/libreoffice.d.ts"
6872
]
6973
}
7074
},
@@ -207,6 +211,17 @@
207211
"types": "./dist/commonjs/extensions/lightpanda.d.ts",
208212
"default": "./dist/commonjs/extensions/lightpanda.js"
209213
}
214+
},
215+
"./extensions/libreoffice": {
216+
"import": {
217+
"@triggerdotdev/source": "./src/extensions/libreoffice.ts",
218+
"types": "./dist/esm/extensions/libreoffice.d.ts",
219+
"default": "./dist/esm/extensions/libreoffice.js"
220+
},
221+
"require": {
222+
"types": "./dist/commonjs/extensions/libreoffice.d.ts",
223+
"default": "./dist/commonjs/extensions/libreoffice.js"
224+
}
210225
}
211226
},
212227
"main": "./dist/commonjs/index.js",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { BuildManifest } from "@trigger.dev/core/v3";
2+
import { BuildContext, BuildExtension } from "@trigger.dev/core/v3/build";
3+
4+
export type LibreOfficeOptions = {
5+
/**
6+
* Which LibreOffice component packages to install.
7+
* Defaults to ["writer", "impress"] for docx and pptx support.
8+
* - "writer" → libreoffice-writer (handles .doc/.docx)
9+
* - "impress" → libreoffice-impress (handles .ppt/.pptx)
10+
* - "calc" → libreoffice-calc (handles .xls/.xlsx)
11+
* - "draw" → libreoffice-draw (handles .odg)
12+
* - "math" → libreoffice-math (formula editor)
13+
*/
14+
components?: Array<"writer" | "impress" | "calc" | "draw" | "math">;
15+
/**
16+
* Additional font packages to install beyond the built-in defaults.
17+
* Built-in defaults: fonts-liberation, fonts-dejavu-core.
18+
* Example: ["fonts-noto", "fonts-freefont-ttf"]
19+
*/
20+
extraFonts?: string[];
21+
};
22+
23+
export function libreoffice(options: LibreOfficeOptions = {}): BuildExtension {
24+
return new LibreOfficeExtension(options);
25+
}
26+
27+
class LibreOfficeExtension implements BuildExtension {
28+
public readonly name = "LibreOfficeExtension";
29+
30+
constructor(private readonly options: LibreOfficeOptions = {}) {}
31+
32+
async onBuildComplete(context: BuildContext, manifest: BuildManifest) {
33+
if (context.target === "dev") {
34+
return;
35+
}
36+
37+
const components = this.options.components ?? ["writer", "impress"];
38+
const componentPkgs = components.map((c) => `libreoffice-${c}`);
39+
40+
// fonts-liberation: free equivalents of Times New Roman, Arial, Courier New –
41+
// essential for accurate rendering of most Office documents.
42+
// fonts-dejavu-core: broad Unicode coverage for international content.
43+
const fontPkgs = ["fonts-liberation", "fonts-dejavu-core", ...(this.options.extraFonts ?? [])];
44+
45+
const allPkgs = [...componentPkgs, ...fontPkgs].join(" \\\n ");
46+
47+
context.logger.debug(`Adding ${this.name} to the build`, { components });
48+
49+
context.addLayer({
50+
id: "libreoffice",
51+
image: {
52+
// Use --no-install-recommends to avoid pulling in X11 desktop packages.
53+
// LibreOffice's --headless flag handles PDF conversion without a display.
54+
instructions: [
55+
`RUN apt-get update && apt-get install -y --no-install-recommends \\\n ${allPkgs} \\\n && rm -rf /var/lib/apt/lists/*`,
56+
],
57+
},
58+
deploy: {
59+
env: {
60+
LIBREOFFICE_PATH: "/usr/bin/libreoffice",
61+
},
62+
override: true,
63+
},
64+
});
65+
}
66+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "references-libreoffice",
3+
"private": true,
4+
"type": "module",
5+
"devDependencies": {
6+
"trigger.dev": "workspace:*"
7+
},
8+
"dependencies": {
9+
"@trigger.dev/build": "workspace:*",
10+
"@trigger.dev/sdk": "workspace:*"
11+
},
12+
"scripts": {
13+
"dev": "trigger dev",
14+
"deploy": "trigger deploy"
15+
}
16+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { task } from "@trigger.dev/sdk";
2+
import { execFile } from "node:child_process";
3+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { promisify } from "node:util";
7+
8+
const execFileAsync = promisify(execFile);
9+
10+
/**
11+
* Convert a .docx or .pptx file (supplied as a URL) to PDF using LibreOffice
12+
* running in headless mode — no X11 display required.
13+
*
14+
* Requires the `libreoffice()` build extension in trigger.config.ts so that
15+
* LibreOffice is available inside the deployed container.
16+
*/
17+
export const libreofficeConvert = task({
18+
id: "libreoffice-convert",
19+
run: async (payload: {
20+
/** Public URL of the .docx or .pptx file to convert. */
21+
documentUrl: string;
22+
/** Optional output filename (without extension). Defaults to "output". */
23+
outputName?: string;
24+
}) => {
25+
const { documentUrl, outputName = "output" } = payload;
26+
27+
// Use a unique temp directory so concurrent runs don't collide.
28+
const workDir = join(tmpdir(), `lo-${Date.now()}`);
29+
mkdirSync(workDir, { recursive: true });
30+
31+
// Derive a safe input filename from the URL.
32+
const urlPath = new URL(documentUrl).pathname;
33+
const ext = urlPath.split(".").pop() ?? "docx";
34+
const inputPath = join(workDir, `input.${ext}`);
35+
// LibreOffice names the output after the input file stem.
36+
const outputPath = join(workDir, `input.pdf`);
37+
38+
try {
39+
// 1. Download the source document.
40+
const response = await fetch(documentUrl);
41+
if (!response.ok) {
42+
throw new Error(`Failed to fetch document: ${response.status} ${response.statusText}`);
43+
}
44+
const arrayBuffer = await response.arrayBuffer();
45+
writeFileSync(inputPath, Buffer.from(arrayBuffer));
46+
47+
// 2. Convert to PDF using LibreOffice headless.
48+
// --norestore prevents LibreOffice from showing a recovery dialog.
49+
// --outdir directs the output file to our working directory.
50+
const libreofficeBin = process.env.LIBREOFFICE_PATH ?? "libreoffice";
51+
await execFileAsync(libreofficeBin, [
52+
"--headless",
53+
"--norestore",
54+
"--convert-to",
55+
"pdf",
56+
"--outdir",
57+
workDir,
58+
inputPath,
59+
]);
60+
61+
// 3. Read the resulting PDF.
62+
const pdfBuffer = readFileSync(outputPath);
63+
64+
return {
65+
outputName: `${outputName}.pdf`,
66+
sizeBytes: pdfBuffer.byteLength,
67+
// Return base64 so the result is JSON-serialisable.
68+
// In production you would upload pdfBuffer to S3 / R2 instead.
69+
base64: pdfBuffer.toString("base64"),
70+
};
71+
} finally {
72+
// Clean up temp files.
73+
try {
74+
unlinkSync(inputPath);
75+
} catch {}
76+
try {
77+
unlinkSync(outputPath);
78+
} catch {}
79+
}
80+
},
81+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { defineConfig } from "@trigger.dev/sdk/v3";
2+
import { libreoffice } from "@trigger.dev/build/extensions/libreoffice";
3+
4+
export default defineConfig({
5+
project: "proj_libreoffice_example",
6+
build: {
7+
extensions: [
8+
// Installs libreoffice-writer and libreoffice-impress (headless, no X11)
9+
// along with fonts-liberation and fonts-dejavu-core for accurate rendering.
10+
libreoffice(),
11+
],
12+
},
13+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2023",
4+
"module": "Node16",
5+
"moduleResolution": "Node16",
6+
"esModuleInterop": true,
7+
"strict": true,
8+
"skipLibCheck": true,
9+
"customConditions": ["@triggerdotdev/source"],
10+
"lib": ["DOM", "DOM.Iterable"],
11+
"noEmit": true
12+
},
13+
"include": ["./src/**/*.ts", "trigger.config.ts"]
14+
}

0 commit comments

Comments
 (0)