diff --git a/.changeset/libreoffice-build-extension.md b/.changeset/libreoffice-build-extension.md new file mode 100644 index 00000000000..13594b768ad --- /dev/null +++ b/.changeset/libreoffice-build-extension.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/build": patch +--- + +feat(build): add libreoffice build extension for headless docx/pptx to PDF conversion diff --git a/docs/guides/examples/libreoffice-pdf-conversion.mdx b/docs/guides/examples/libreoffice-pdf-conversion.mdx index bec9f1e0645..584003767ea 100644 --- a/docs/guides/examples/libreoffice-pdf-conversion.mdx +++ b/docs/guides/examples/libreoffice-pdf-conversion.mdx @@ -12,12 +12,12 @@ 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({ @@ -25,14 +25,23 @@ export default defineConfig({ // 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"], +}) +``` + [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 diff --git a/packages/build/package.json b/packages/build/package.json index 92cc4331520..8b3b343b044 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -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" @@ -65,6 +66,9 @@ ], "extensions/lightpanda": [ "dist/commonjs/extensions/lightpanda.d.ts" + ], + "extensions/libreoffice": [ + "dist/commonjs/extensions/libreoffice.d.ts" ] } }, @@ -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", diff --git a/packages/build/src/extensions/libreoffice.ts b/packages/build/src/extensions/libreoffice.ts new file mode 100644 index 00000000000..7b1e86372f3 --- /dev/null +++ b/packages/build/src/extensions/libreoffice.ts @@ -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, + }, + }); + } +} diff --git a/references/libreoffice/package.json b/references/libreoffice/package.json new file mode 100644 index 00000000000..54c6815b153 --- /dev/null +++ b/references/libreoffice/package.json @@ -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" + } +} diff --git a/references/libreoffice/src/trigger/libreoffice-convert.ts b/references/libreoffice/src/trigger/libreoffice-convert.ts new file mode 100644 index 00000000000..f20030d3d2e --- /dev/null +++ b/references/libreoffice/src/trigger/libreoffice-convert.ts @@ -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"; + 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 {} + } + }, +}); diff --git a/references/libreoffice/trigger.config.ts b/references/libreoffice/trigger.config.ts new file mode 100644 index 00000000000..28ac7300b05 --- /dev/null +++ b/references/libreoffice/trigger.config.ts @@ -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(), + ], + }, +}); diff --git a/references/libreoffice/tsconfig.json b/references/libreoffice/tsconfig.json new file mode 100644 index 00000000000..3bb455e5d40 --- /dev/null +++ b/references/libreoffice/tsconfig.json @@ -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"] +}