effect-libreoffice 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Install pnpm
17
+ uses: pnpm/action-setup@v4
18
+
19
+ - name: Setup Node.js
20
+ uses: actions/setup-node@v6
21
+ with:
22
+ node-version: 24
23
+ cache: "pnpm"
24
+
25
+ - name: Install dependencies
26
+ run: pnpm install
27
+
28
+ - name: Install LibreOffice
29
+ run: |
30
+ sudo apt-get update
31
+ sudo apt-get install -y --no-install-recommends libreoffice-writer
32
+
33
+ - name: Lint
34
+ run: pnpm biome ci .
35
+
36
+ - name: Type check
37
+ run: pnpm type-check
38
+
39
+ - name: Test
40
+ run: pnpm test
@@ -0,0 +1,3 @@
1
+ {
2
+ "typescript.tsdk": "node_modules/typescript/lib"
3
+ }
package/Dockerfile ADDED
@@ -0,0 +1,12 @@
1
+ FROM alpine:latest
2
+
3
+ # install fonts https://wiki.alpinelinux.org/wiki/Fonts
4
+ RUN apk add --no-cache font-terminus font-inconsolata font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra
5
+
6
+ # install libreoffice + dependencies
7
+ RUN apk add --no-cache libreoffice-writer python3 py3-pip openjdk11-jre-headless
8
+
9
+ # install unoserver via pip
10
+ RUN pip install unoserver --break-system-packages
11
+
12
+ CMD ["unoserver", "--interface", "0.0.0.0"]
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # effect-libreoffice
2
+
3
+ A Effect-based wrapper for LibreOffice, providing multiple strategies for document conversion.
4
+
5
+ ## Implementations
6
+
7
+ This library offers two distinct implementations for interacting with LibreOffice:
8
+
9
+ 1. **LibreOfficeCmd (Default)**: Uses the `soffice` command-line tool directly.
10
+ 2. **UnoClient (Uno)**: Connects to a running `unoserver` instance.
11
+
12
+ ### Comparison
13
+
14
+ | Feature | LibreOfficeCmd (Default) | UnoClient (Uno) |
15
+ | :-------------- | :--------------------------------------- | :----------------------------------------- |
16
+ | **Method** | Spawns a new process for each conversion | Connects to a long-running server |
17
+ | **Performance** | Slower (~440ms/file) | Fast (~60ms/file) |
18
+ | **Setup** | Requires LibreOffice installed locally | Requires `unoserver` |
19
+ | **Best For** | CLI tools, low volume, simple setup | Servers, high volume, performance critical |
20
+
21
+ ## Usage
22
+
23
+ ### Default Implementation (CLI)
24
+
25
+ Best for quick scripts or when you don't want to manage a separate server.
26
+
27
+ ```typescript
28
+ import { LibreOffice } from "effect-libreoffice";
29
+ import { NodeContext } from "@effect/platform-node";
30
+ import { Effect } from "effect";
31
+
32
+ const program = Effect.gen(function* () {
33
+ const libre = yield* LibreOffice;
34
+ yield* libre.convertLocalFile("input.docx", "output.pdf");
35
+ });
36
+
37
+ // Provide the Default layer (which uses LibreOfficeCmd) and NodeContext
38
+ program.pipe(
39
+ Effect.provide(LibreOffice.Default),
40
+ Effect.provide(NodeContext.layer),
41
+ Effect.runPromise
42
+ );
43
+ ```
44
+
45
+ ### Uno Implementation (Remote)
46
+
47
+ Best for server environments. You need a running `unoserver`.
48
+
49
+ ```yaml
50
+ # compose.yml
51
+ services:
52
+ unoserver:
53
+ image: libreofficedocker/libreoffice-unoserver:3.22
54
+ ports:
55
+ - "2003:2003"
56
+ volumes:
57
+ - /tmp:/tmp # some shared directory where files can be written and read
58
+ ```
59
+
60
+ ```typescript
61
+ import { LibreOffice, UnoClient, UnoServer } from "effect-libreoffice";
62
+ import { NodeContext, NodeHttpClient } from "@effect/platform-node";
63
+ import { Effect, Layer } from "effect";
64
+
65
+ const program = Effect.gen(function* () {
66
+ const libre = yield* LibreOffice;
67
+ yield* libre.convertLocalFile("input.docx", "output.pdf");
68
+ });
69
+
70
+ const UnoLayer = LibreOffice.Uno.pipe(
71
+ Layer.provide(UnoClient.Default),
72
+ Layer.provide(UnoServer.Remote) // Defaults to localhost:2003
73
+ // Layer.provide(UnoServer.remoteWithURL("http://unoserver:2003/RPC2"))
74
+ );
75
+
76
+ program.pipe(
77
+ Effect.provide(UnoLayer),
78
+ Effect.provide(NodeHttpClient.layer),
79
+ Effect.provide(NodeContext.layer),
80
+ Effect.runPromise
81
+ );
82
+ ```
package/biome.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "files": {
9
+ "ignoreUnknown": true
10
+ },
11
+ "formatter": {
12
+ "enabled": true,
13
+ "indentStyle": "space"
14
+ },
15
+ "linter": {
16
+ "enabled": true,
17
+ "rules": {
18
+ "recommended": true,
19
+ "suspicious": {
20
+ "noShadowRestrictedNames": "off"
21
+ },
22
+ "correctness": {
23
+ "noUnusedVariables": {
24
+ "level": "warn",
25
+ "fix": "none"
26
+ }
27
+ }
28
+ }
29
+ },
30
+ "javascript": {
31
+ "formatter": {
32
+ "quoteStyle": "double"
33
+ }
34
+ },
35
+ "assist": {
36
+ "enabled": true,
37
+ "actions": {
38
+ "source": {
39
+ "organizeImports": "on"
40
+ }
41
+ }
42
+ }
43
+ }
package/compose.yml ADDED
@@ -0,0 +1,19 @@
1
+ services:
2
+ # unoserver:
3
+ # image: libreofficedocker/libreoffice-unoserver:3.22
4
+ # ports:
5
+ # - "2004:2004" # uno rest api
6
+ # - "2003:2003" # uno api
7
+ unoserver:
8
+ build: .
9
+ ports:
10
+ - "2003:2003" # uno api
11
+ volumes:
12
+ - /tmp/test-convert:/tmp/test-convert
13
+ user: "1000:1000"
14
+ environment:
15
+ - HOME=/tmp
16
+ develop:
17
+ watch:
18
+ - action: rebuild
19
+ path: ./Dockerfile
@@ -0,0 +1,3 @@
1
+ FROM ubuntu:24.04
2
+
3
+ RUN apt-get update && apt-get install -y libreoffice-writer --no-install-recommends
@@ -0,0 +1,5 @@
1
+ services:
2
+ unoserver:
3
+ build: ./uno.Dockerfile
4
+ api:
5
+ build: ./api.Dockerfile
@@ -0,0 +1,9 @@
1
+ FROM alpine:latest
2
+
3
+ # install libreoffice + dependencies
4
+ RUN apk add --no-cache libreoffice-writer python3 py3-pip openjdk11-jre-headless
5
+
6
+ # install unoserver via pip
7
+ RUN pip install unoserver --break-system-packages
8
+
9
+ CMD ["unoserver"]
@@ -0,0 +1,7 @@
1
+ Hi there
2
+
3
+ This is a test file.
4
+
5
+ This should be converted to PDF.
6
+
7
+ Have fun!
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "effect-libreoffice",
3
+ "type": "module",
4
+ "version": "1.0.0",
5
+ "description": "A effect based LibreOffice converter library",
6
+ "main": "./dist/index.mjs",
7
+ "module": "./dist/index.mjs",
8
+ "author": "Filip Weiss <me@fiws.net>",
9
+ "license": "ISC",
10
+ "homepage": "https://github.com/fiws/effect-libreoffice",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/fiws/effect-libreoffice.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/fiws/effect-libreoffice/issues"
17
+ },
18
+ "exports": {
19
+ ".": "./dist/index.mjs",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "keywords": [],
23
+ "dependencies": {
24
+ "fast-xml-parser": "^5.3.3"
25
+ },
26
+ "peerDependencies": {
27
+ "@effect/platform": "^0.93.3",
28
+ "effect": "^3.19.6"
29
+ },
30
+ "devDependencies": {
31
+ "@biomejs/biome": "2.3.10",
32
+ "@effect/language-service": "^0.62.4",
33
+ "@effect/platform": "^0.94.0",
34
+ "@effect/platform-node": "^0.104.0",
35
+ "@effect/vitest": "^0.27.0",
36
+ "effect": "^3.19.13",
37
+ "testcontainers": "^11.10.0",
38
+ "tinybench": "^6.0.0",
39
+ "tsdown": "^0.18.2",
40
+ "tsx": "^4.21.0",
41
+ "typescript": "^5.9.3",
42
+ "vitest": "^4.0.16"
43
+ },
44
+ "types": "./dist/index.d.mts",
45
+ "scripts": {
46
+ "build": "tsdown",
47
+ "test": "vitest run",
48
+ "type-check": "tsc --noEmit"
49
+ }
50
+ }
@@ -0,0 +1,126 @@
1
+ import { FileSystem, Path } from "@effect/platform";
2
+ import { NodeContext } from "@effect/platform-node";
3
+ import { assert, expect, it } from "@effect/vitest";
4
+ import { Effect, Layer, Predicate } from "effect";
5
+ import { LibreOffice } from "./index";
6
+
7
+ const TestLive = Layer.provideMerge(LibreOffice.Default, NodeContext.layer);
8
+
9
+ it.layer(TestLive)("Libreoffice (Default)", (it) => {
10
+ it.scoped(
11
+ "should convert a file",
12
+ Effect.fn(function* () {
13
+ const fs = yield* FileSystem.FileSystem;
14
+ const path = yield* Path.Path;
15
+ const libre = yield* LibreOffice;
16
+
17
+ const tempDir = yield* fs.makeTempDirectory();
18
+ const sourceFile = path.join(tempDir, "test.txt");
19
+ const targetFile = path.join(tempDir, "test.out.pdf");
20
+
21
+ yield* fs.writeFileString(sourceFile, "Hello PDF");
22
+ yield* libre.convertLocalFile(sourceFile, targetFile);
23
+
24
+ const targetContent = yield* fs.readFile(targetFile);
25
+
26
+ const header = new TextDecoder().decode(targetContent.slice(0, 4));
27
+ expect(header).toBe("%PDF");
28
+ }),
29
+ );
30
+
31
+ it.effect(
32
+ "should fails with source file not found",
33
+ Effect.fn(function* () {
34
+ const libre = yield* LibreOffice;
35
+ const result = yield* libre
36
+ .convertLocalFile("./fixtures/test-not-found.txt", "test.out.pdf")
37
+ .pipe(Effect.flip);
38
+
39
+ expect(result._tag).toBe("LibreOfficeError");
40
+
41
+ // assertion for type narrowing
42
+ assert(
43
+ Predicate.isTagged(result, "LibreOfficeError"),
44
+ "result is not LibreOfficeError",
45
+ );
46
+ expect(result.reason).toBe("InputFileNotFound");
47
+ }),
48
+ );
49
+
50
+ it.effect(
51
+ "Should work with 2 conversions in parallel",
52
+ Effect.fn(function* () {
53
+ const libre = yield* LibreOffice;
54
+ const fs = yield* FileSystem.FileSystem;
55
+ const path = yield* Path.Path;
56
+
57
+ const tempDir = yield* fs.makeTempDirectory();
58
+ const sourceFile = path.join(tempDir, "test.txt");
59
+ const targetFile = path.join(tempDir, "test.out.pdf");
60
+
61
+ yield* fs.writeFileString(sourceFile, "Hello PDF");
62
+
63
+ // will internaly use a semaphore to limit parallel conversions to 1
64
+ yield* Effect.all(
65
+ [
66
+ libre.convertLocalFile(sourceFile, targetFile),
67
+ libre.convertLocalFile(sourceFile, targetFile),
68
+ ],
69
+ { concurrency: "unbounded" },
70
+ );
71
+
72
+ const targetContent = yield* fs.readFile(targetFile);
73
+
74
+ const header = new TextDecoder().decode(targetContent.slice(0, 4));
75
+ expect(header).toBe("%PDF");
76
+ }),
77
+ );
78
+
79
+ it.effect(
80
+ "should fail with invalid output extension",
81
+ Effect.fn(function* () {
82
+ const libre = yield* LibreOffice;
83
+ const fs = yield* FileSystem.FileSystem;
84
+ const path = yield* Path.Path;
85
+ const tempDir = yield* fs.makeTempDirectory();
86
+ const sourceFile = path.join(tempDir, "test.txt");
87
+ const targetFile = path.join(tempDir, "test.invalidext");
88
+
89
+ yield* fs.writeFileString(sourceFile, "Hello PDF");
90
+
91
+ const result = yield* libre
92
+ .convertLocalFile(sourceFile, targetFile)
93
+ .pipe(Effect.flip);
94
+
95
+ assert(
96
+ Predicate.isTagged(result, "LibreOfficeError"),
97
+ "result is not LibreOfficeError",
98
+ );
99
+ expect(result.reason).toBe("BadOutputExtension");
100
+ }),
101
+ );
102
+
103
+ it.effect(
104
+ "should fail with output as directory",
105
+ Effect.fn(function* () {
106
+ const libre = yield* LibreOffice;
107
+ const fs = yield* FileSystem.FileSystem;
108
+ const path = yield* Path.Path;
109
+ const tempDir = yield* fs.makeTempDirectory();
110
+ const sourceFile = path.join(tempDir, "test.txt");
111
+ const targetFile = tempDir;
112
+
113
+ yield* fs.writeFileString(sourceFile, "Hello PDF");
114
+
115
+ const result = yield* libre
116
+ .convertLocalFile(sourceFile, targetFile)
117
+ .pipe(Effect.flip);
118
+
119
+ assert(
120
+ Predicate.isTagged(result, "LibreOfficeError"),
121
+ "result is not LibreOfficeError",
122
+ );
123
+ expect(result.reason).toBe("BadOutputExtension");
124
+ }),
125
+ );
126
+ });
package/src/index.ts ADDED
@@ -0,0 +1,180 @@
1
+ import { Command, FileSystem, Path } from "@effect/platform";
2
+ import { Context, Effect, flow, Layer, Match, Stream, String } from "effect";
3
+ import { LibreOfficeError, type OutputPath } from "./shared";
4
+ import { UnoClient, UnoError } from "./uno/uno";
5
+
6
+ const runString = <E, R>(
7
+ stream: Stream.Stream<Uint8Array, E, R>,
8
+ ): Effect.Effect<string, E, R> =>
9
+ stream.pipe(Stream.decodeText(), Stream.runFold(String.empty, String.concat));
10
+
11
+ export class LibreOfficeCmd extends Context.Reference<LibreOfficeCmd>()(
12
+ "libre-convert-effect/index/LibreOfficeCmd",
13
+ { defaultValue: () => ["soffice", "--headless"] },
14
+ ) {}
15
+
16
+ export class LibreOffice extends Effect.Service<LibreOffice>()(
17
+ "libre-convert-effect/index/LibreOffice",
18
+ {
19
+ // #region Default
20
+ scoped: Effect.gen(function* () {
21
+ const fs = yield* FileSystem.FileSystem;
22
+ const path = yield* Path.Path;
23
+ const sem = yield* Effect.makeSemaphore(1);
24
+
25
+ return {
26
+ /**
27
+ * Converts a file to a different format.
28
+ *
29
+ * ### Example
30
+ *
31
+ * ```ts
32
+ * const program = Effect.gen(function* () {
33
+ * const libre = yield* LibreOffice;
34
+ * yield* libre.convertLocalFile("/path/to/input.docx", "/path/to/output.pdf");
35
+ * });
36
+ * ```
37
+ */
38
+ convertLocalFile: Effect.fn(function* (
39
+ input: string,
40
+ output: OutputPath,
41
+ ) {
42
+ const [cmd, ...args] = yield* LibreOfficeCmd;
43
+
44
+ const parsedInput = path.parse(input);
45
+ const parsedOutput = path.parse(output);
46
+
47
+ // to preserve compatiblity with unoserver we have to check if the output is a directory
48
+ if (
49
+ yield* fs.stat(output).pipe(
50
+ Effect.map((stat) => stat.type === "Directory"),
51
+ Effect.catchAll(() => Effect.succeed(false)),
52
+ )
53
+ ) {
54
+ return yield* Effect.fail(
55
+ new LibreOfficeError({
56
+ reason: "BadOutputExtension",
57
+ message: "Output path is a directory",
58
+ }),
59
+ );
60
+ }
61
+
62
+ // we need a temporary directory to ensure conversions do not conflict
63
+ const tempDir = yield* fs.makeTempDirectoryScoped({
64
+ prefix: "effect-libreoffice-",
65
+ });
66
+
67
+ // libreoffice does not do well with parallel conversions. It works if we provide
68
+ // a new "UserInstallation" for each conversion but this slows down execution by about 8x
69
+ // so we use a semaphore to limit parallel conversions to 1
70
+ yield* sem.withPermits(1)(
71
+ Effect.gen(function* () {
72
+ const process = yield* Command.make(
73
+ cmd,
74
+ ...args,
75
+ "--convert-to",
76
+ parsedOutput.ext.slice(1),
77
+ "--outdir",
78
+ tempDir,
79
+ input,
80
+ ).pipe(Command.start);
81
+ // We need to wait for the process to exit to get the exit code
82
+ // and capture stderr in parallel to avoid missing output
83
+ const [exitCode, result] = yield* Effect.all(
84
+ [process.exitCode, runString(process.stderr)],
85
+ { concurrency: "unbounded" },
86
+ );
87
+
88
+ // Check for specific errors in stderr first, regardless of exit code
89
+ yield* Match.value(String.trim(result)).pipe(
90
+ Match.when(
91
+ String.includes("Error: source file could not be loaded"),
92
+ () =>
93
+ new LibreOfficeError({
94
+ reason: "InputFileNotFound",
95
+ message: result,
96
+ }),
97
+ ),
98
+ Match.when(
99
+ String.includes("Error: no export filter"),
100
+ () =>
101
+ new LibreOfficeError({
102
+ reason: "BadOutputExtension",
103
+ message: result,
104
+ }),
105
+ ),
106
+ Match.when(
107
+ String.includes("Permission denied"),
108
+ () =>
109
+ new LibreOfficeError({
110
+ reason: "PermissionDenied",
111
+ message: result,
112
+ }),
113
+ ),
114
+ Match.when(
115
+ String.includes("Error: "),
116
+ () =>
117
+ new LibreOfficeError({
118
+ reason: "Unknown",
119
+ message: result,
120
+ }),
121
+ ),
122
+ Match.orElse(() => Effect.void),
123
+ );
124
+
125
+ if (exitCode !== 0) {
126
+ return yield* new LibreOfficeError({
127
+ reason: "Unknown",
128
+ message:
129
+ result || `Process failed with exit code ${exitCode}`,
130
+ });
131
+ }
132
+
133
+ // using the libreoffice cli we can not specify the output file name
134
+ // it will be the input file name with the extension changed to the output format
135
+ const libreOutputPath = path.join(
136
+ tempDir,
137
+ String.concat(parsedInput.name, parsedOutput.ext),
138
+ );
139
+
140
+ // so we rename the file to the expected output path
141
+ yield* fs.copyFile(libreOutputPath, output);
142
+
143
+ // (temp directory is cleaned up by finalizer from makeTempDirectoryScoped)
144
+ }),
145
+ );
146
+ }, Effect.scoped),
147
+ };
148
+ }),
149
+ // #endregion
150
+ },
151
+ ) {
152
+ // #region Uno
153
+ /**
154
+ * The Uno layer uses a unoserver to convert files. It is much more
155
+ * performant than the cli but requires a unoserver to be running.
156
+ */
157
+ static Uno = Layer.scoped(
158
+ LibreOffice,
159
+ Effect.gen(function* () {
160
+ const client = yield* UnoClient;
161
+
162
+ return LibreOffice.make({
163
+ convertLocalFile: flow(
164
+ client.convert,
165
+ Effect.as(undefined),
166
+ Effect.mapError((err) =>
167
+ err instanceof UnoError
168
+ ? new LibreOfficeError(err)
169
+ : new LibreOfficeError({
170
+ reason: "Unknown",
171
+ message: `Failed to convert file: ${err}`,
172
+ cause: err,
173
+ }),
174
+ ),
175
+ ),
176
+ });
177
+ }),
178
+ );
179
+ // #endregion
180
+ }