README.md

# URP

Pure Elixir client for the [UNO Remote Protocol](https://wiki.openoffice.org/wiki/Uno/Binary/Spec/Protocol).
Converts documents to PDF by talking directly to a LibreOffice `soffice`
process over a TCP socket.

No Python. No unoserver. No Gotenberg.

## Installation

Add `urp` to your dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:urp, "~> 0.1.0"}
  ]
end
```

For test stubbing support (optional):

```elixir
def deps do
  [
    {:urp, "~> 0.1.0"},
    {:nimble_ownership, "~> 1.0", only: :test}
  ]
end
```

## Prerequisites

A running `soffice` process with a URP socket listener:

```sh
soffice --headless --invisible --nologo \
  --accept="socket,host=0.0.0.0,port=2002,tcpNoDelay=1;urp;" \
  --norestore
```

Or via Docker (volume mount only needed for file-based conversion):

```sh
docker run -d --name soffice \
  -p 2002:2002 \
  libreofficedocker/alpine:3.23 \
  soffice --headless --invisible --nologo \
    --accept="socket,host=0.0.0.0,port=2002,tcpNoDelay=1;urp;" \
    --norestore
```

## Configuration

Define a converter module and configure it via application config:

```elixir
# lib/my_app/converter.ex
defmodule MyApp.Converter do
  use URP, otp_app: :my_app
end
```

```elixir
# config/runtime.exs
config :my_app, MyApp.Converter,
  host: "soffice",
  port: 2002

# URP.Connection and URP.Pool read their own keys:
config :my_app, URP.Connection,
  host: "soffice",
  port: 2002

config :my_app, URP.Pool,
  host: "soffice",
  port: 2002
```

Then call it anywhere in your app:

```elixir
{:ok, pdf_bytes} = MyApp.Converter.convert_stream(docx_bytes)
{:ok, pdf_bytes} = MyApp.Converter.convert_file_stream("/path/to/input.docx")
{:ok, output}    = MyApp.Converter.convert("/shared/input.docx", "/shared/output.pdf")
```

Config is resolved in this order (last wins):

1. Compile-time defaults from `use URP, ...`
2. Runtime config from `Application.get_env/3`
3. Per-call opts

## Usage

### Direct (scripts, IEx)

No wrapper module or supervision tree needed:

```elixir
{:ok, pdf_bytes} = URP.convert_stream(docx_bytes, host: "localhost", port: 2002)
{:ok, pdf_bytes} = URP.convert_file_stream("/path/to/input.docx")
{:ok, output_path} = URP.convert("/shared/input.docx", "/shared/output.pdf")
```

### Supervised (production)

For serialized access (one conversion at a time), add `URP.Connection`:

```elixir
# application.ex
children = [
  {URP.Connection, otp_app: :my_app}
]

# anywhere in your app
{:ok, pdf} = URP.Connection.convert_stream(docx_bytes)
```

For concurrent conversions, add `URP.Pool`:

```elixir
# application.ex
children = [
  {URP.Pool, otp_app: :my_app, pool_size: 4}
]

# anywhere in your app
{:ok, pdf} = URP.Pool.convert_stream(docx_bytes)
```

### Sink (streaming output)

By default, converted bytes accumulate in memory. Use `:sink` to stream
output as it arrives:

```elixir
# Write to file
:ok = URP.convert_stream(docx_bytes, sink: {:path, "/tmp/output.pdf"})

# Stream to an HTTP response, S3 upload, etc.
:ok = URP.convert_stream(docx_bytes, sink: fn chunk -> send_chunk(chunk) end)
```

## Testing

Define a converter module with `use URP` and stub it in tests — no
running soffice needed:

```elixir
# test/test_helper.exs
URP.Test.start()
ExUnit.start()

# test/my_app/invoice_test.exs
test "generates invoice PDF" do
  URP.Test.stub(MyApp.Converter, fn _input, _opts ->
    {:ok, "%PDF-fake"}
  end)

  assert {:ok, _pdf} = MyApp.generate_invoice(order)
end
```

Stubs are per-process and propagate through `$callers` (Tasks, GenServers).
See `URP.Test` for details.

Integration tests in this repo require soffice on `localhost:2002` and are
skipped automatically when it's not reachable:

```sh
mix test
```

## Architecture

| Module | Role |
|---|---|
| `URP` | Public API + `use URP` macro for wrapper modules |
| `URP.Connection` | Supervised GenServer — serialization, backpressure, timeouts |
| `URP.Pool` | NimblePool — concurrent conversions with connection pooling |
| `URP.Test` | Test helpers — per-process stubs via NimbleOwnership |
| `URP.Bridge` | Mid-level — UNO operations (handshake, load, store, close, streaming) |
| `URP.Stream` | Bidirectional URP dispatch for XInputStream/XOutputStream |
| `URP.Protocol` | Low-level — binary wire format (framing, encoding, reply parsing) |

## References

- [UNO Binary Protocol Spec](https://wiki.openoffice.org/wiki/Uno/Binary/Spec/Protocol)
- [binaryurp source](https://git.libreoffice.org/core/+/refs/heads/master/binaryurp/) — reader.cxx, writer.cxx, marshal.cxx
- [specialfunctionids.hxx](https://git.libreoffice.org/core/+/refs/heads/master/binaryurp/source/specialfunctionids.hxx)
- [typeclass.h](https://git.libreoffice.org/core/+/refs/heads/master/include/typelib/typeclass.h)
- [XInputStream](https://api.libreoffice.org/docs/idl/ref/interfacecom_1_1sun_1_1star_1_1io_1_1XInputStream.html), [XOutputStream](https://api.libreoffice.org/docs/idl/ref/interfacecom_1_1sun_1_1star_1_1io_1_1XOutputStream.html)
- [MediaDescriptor](https://api.libreoffice.org/docs/idl/ref/servicecom_1_1sun_1_1star_1_1document_1_1MediaDescriptor.html)

## License

MIT — see [LICENSE](LICENSE).

This is an independent implementation based on the public UNO protocol spec.
LibreOffice source was consulted as documentation for protocol details not
covered by the spec. No code was copied.