defmodule URP do @moduledoc """ Pure Elixir client for document conversion via LibreOffice's UNO Remote Protocol. Talks directly to a `soffice` process over TCP. No Python, no unoserver, no Gotenberg. ## Setup Add `:urp` to your dependencies — that's it. A default connection pool starts automatically, connecting to `localhost:2002`. # config/runtime.exs (optional — defaults shown) config :urp, :default, host: "soffice", port: 2002, pool_size: 1, backoff_initial: 500, # ms, initial reconnection delay backoff_max: 5_000 # ms, max reconnection delay See `convert/2` for usage examples and options. ## Diagnostics Query soffice state without converting anything: {:ok, "26.2.0.3"} = URP.version() {:ok, services} = URP.services() {:ok, filters} = URP.filters() {:ok, types} = URP.types() {:ok, locale} = URP.locale() ## Named pools For multiple soffice instances, configure named pools: config :urp, :pools, spreadsheets: [host: "soffice-2", port: 2002, pool_size: 1] {:ok, pdf} = URP.convert({:binary, bytes}, filter: "calc_pdf_Export", pool: :spreadsheets) Named pools are started on first use. ## Testing URP.Test.stub(fn _input, _opts -> {:ok, "/tmp/fake.pdf"} end) {:ok, _} = URP.convert({:binary, docx_bytes}, filter: "writer_pdf_Export") See `URP.Test` for details. """ @type setting :: {String.t(), String.t(), boolean() | integer() | String.t()} @type output :: Path.t() | :binary | (binary() -> any()) @type io_mode :: :file | :stream | {:file | :stream, :file | :stream} @type opt :: {:output, output()} | {:pool, atom()} | {:filter, String.t()} | {:filter_data, keyword()} | {:settings, [setting()]} | {:io, io_mode()} | {:timeout, non_neg_integer()} | {:recv_timeout, timeout()} | {:max_frame_size, pos_integer()} @doc """ Query the soffice version string over URP. Returns the raw version string (e.g. `"26.2.0.3"`). Callers can use `Version.parse/1` if needed. ## Examples {:ok, version} = URP.version() # => "26.2.0.3" ## Options * `:pool` — named pool to use (default: the auto-started pool) * `:timeout` — checkout timeout in ms (default `120_000`) """ @spec version(keyword()) :: {:ok, String.t()} | {:error, String.t()} def version(opts \\ []) do {pool, opts} = resolve_pool(opts) URP.Pool.version(pool, opts) end @doc "Like `version/1` but raises on error." @spec version!(keyword()) :: String.t() def version!(opts \\ []) do case version(opts) do {:ok, v} -> v {:error, message} -> raise message end end @doc """ List all service names registered in the UNO service manager. Returns a list of service name strings like `"com.sun.star.frame.Desktop"`. ## Examples {:ok, services} = URP.services() "com.sun.star.frame.Desktop" in services # => true ## Options * `:pool` — named pool to use (default: the auto-started pool) * `:timeout` — checkout timeout in ms (default `120_000`) """ @spec services(keyword()) :: {:ok, [String.t()]} | {:error, String.t()} def services(opts \\ []) do {pool, opts} = resolve_pool(opts) URP.Pool.services(pool, opts) end @doc "Like `services/1` but raises on error." @spec services!(keyword()) :: [String.t()] def services!(opts \\ []) do case services(opts) do {:ok, v} -> v {:error, message} -> raise message end end @doc """ List all export filter names registered in soffice. Returns a list of filter name strings like `"writer_pdf_Export"`. Useful for discovering which filters are available on the connected soffice. ## Examples {:ok, filters} = URP.filters() "writer_pdf_Export" in filters # => true ## Options * `:pool` — named pool to use (default: the auto-started pool) * `:timeout` — checkout timeout in ms (default `120_000`) """ @spec filters(keyword()) :: {:ok, [String.t()]} | {:error, String.t()} def filters(opts \\ []) do {pool, opts} = resolve_pool(opts) URP.Pool.filters(pool, opts) end @doc "Like `filters/1` but raises on error." @spec filters!(keyword()) :: [String.t()] def filters!(opts \\ []) do case filters(opts) do {:ok, v} -> v {:error, message} -> raise message end end @doc """ List all document type names registered in soffice. Returns a list of type name strings like `"writer8"`. These are the internal names soffice uses for file format detection. ## Examples {:ok, types} = URP.types() "writer8" in types # => true ## Options * `:pool` — named pool to use (default: the auto-started pool) * `:timeout` — checkout timeout in ms (default `120_000`) """ @spec types(keyword()) :: {:ok, [String.t()]} | {:error, String.t()} def types(opts \\ []) do {pool, opts} = resolve_pool(opts) URP.Pool.types(pool, opts) end @doc "Like `types/1` but raises on error." @spec types!(keyword()) :: [String.t()] def types!(opts \\ []) do case types(opts) do {:ok, v} -> v {:error, message} -> raise message end end @doc """ Query the soffice locale string. Returns the locale string (e.g. `"en-US"`) or `""` if not configured. ## Examples {:ok, locale} = URP.locale() # => "en-US" ## Options * `:pool` — named pool to use (default: the auto-started pool) * `:timeout` — checkout timeout in ms (default `120_000`) """ @spec locale(keyword()) :: {:ok, String.t()} | {:error, String.t()} def locale(opts \\ []) do {pool, opts} = resolve_pool(opts) URP.Pool.locale(pool, opts) end @doc "Like `locale/1` but raises on error." @spec locale!(keyword()) :: String.t() def locale!(opts \\ []) do case locale(opts) do {:ok, v} -> v {:error, message} -> raise message end end @doc """ Convert a document via LibreOffice. ## Input types * `path` (binary) — local file path. File mode reads it locally before uploading it to soffice; use stream input for bounded local memory. * `{:binary, bytes}` — raw document bytes * enumerable — any `Enumerable` (e.g. `File.stream!/2`), streamed lazily ## Options * `:filter` — export filter name (**required**). See moduledoc for common filters. * `:filter_data` — keyword list of filter-specific export options. Values can be booleans, integers, or strings. For PDF filters, see [PDF export options](https://wiki.documentfoundation.org/API/Tutorials/PDF_export) (e.g. `[UseLosslessCompression: true, ExportFormFields: false]`). * `:settings` — list of `{path, property, value}` triplets to set on soffice before conversion via `ConfigurationUpdateAccess`. Useful for tuning cache limits, graphic memory, etc. Values can be booleans, integers, or strings. These settings modify the soffice process-wide profile and persist after the conversion; do not vary them concurrently on a shared soffice process. See [officecfg schema](https://github.com/LibreOffice/core/tree/master/officecfg/registry/schema/org/openoffice/Office) for all available settings. * `:io` — I/O transfer strategy (default `:file`): * `:file` — transfer complete files via temp files on soffice's filesystem. Fast (~6 URP round-trips), but requires temp disk space on soffice. * `:stream` — stream document bytes over the URP socket via XInputStream/XOutputStream. Stream input is ~40-50% slower (many seek/read round-trips for ZIP formats). Stream output adds <5% overhead — soffice writes in fixed [32 767-byte chunks](https://github.com/LibreOffice/core/blob/libreoffice-26-2-0/sfx2/source/doc/docfile.cxx#L2573), so a 7 MB PDF is ~223 writeBytes calls. No temp files. Path and callback outputs use bounded memory; `:binary` output necessarily accumulates the complete result in memory. * `{:file, :stream}` or `{:stream, :file}` — mix strategies independently for input and output. `{:file, :stream}` is a good defensive choice: fast file-based input with chunked stream output (no large single allocation on the BEAM). * `:output` — where to write converted output: * path string — write to file, returns `{:ok, path}` * `:binary` — return bytes, returns `{:ok, bytes}` * `fun/1` — call with each chunk, returns `:ok` * not set — write to temp file, returns `{:ok, tmp_path}` * `:pool` — named pool to use (default: the auto-started pool) * `:timeout` — checkout timeout in ms (default `120_000`) ## Examples File path input with various output modes: {:ok, pdf_path} = URP.convert("/tmp/report.docx", filter: "writer_pdf_Export") {:ok, "/tmp/out.pdf"} = URP.convert("/tmp/report.docx", filter: "writer_pdf_Export", output: "/tmp/out.pdf") {:ok, pdf_bytes} = URP.convert("/tmp/report.docx", filter: "writer_pdf_Export", output: :binary) Raw bytes: {:ok, pdf_bytes} = URP.convert({:binary, docx_bytes}, filter: "writer_pdf_Export", output: :binary) Enumerable (e.g. streaming a large file): {:ok, pdf_path} = URP.convert(File.stream!("huge.docx", 65_536), filter: "writer_pdf_Export") With soffice settings (e.g. raise graphic memory cache for image-heavy docs): {:ok, pdf} = URP.convert("charts.pptx", filter: "impress_pdf_Export", settings: [ {"org.openoffice.Office.Common/Cache/GraphicManager", "GraphicMemoryLimit", 500_000_000} ] ) The `:filter` option is required: iex> URP.convert({:binary, "bytes"}, output: :binary) ** (ArgumentError) URP.convert/2 requires the :filter option. Common filters: "writer_pdf_Export", "calc_pdf_Export", "impress_pdf_Export", "Markdown" With a test stub (see `URP.Test`): iex> URP.Test.stub(fn _input, _opts -> {:ok, "/tmp/fake.pdf"} end) :ok iex> URP.convert("/tmp/test.docx", filter: "writer_pdf_Export") {:ok, "/tmp/fake.pdf"} """ @spec convert(binary() | {:binary, binary()} | Enumerable.t(), [opt()]) :: {:ok, Path.t()} | {:ok, binary()} | :ok | {:error, String.t()} def convert(input, opts \\ []) def convert(input, opts) when is_binary(input) and is_list(opts) do do_convert(input, opts) end def convert({:binary, bytes} = input, opts) when is_binary(bytes) and is_list(opts) do do_convert(input, opts) end def convert(input, opts) when is_list(opts) do if Enumerable.impl_for(input) do do_convert(input, opts) else raise ArgumentError, "URP.convert/2 expects a file path (binary), {:binary, bytes}, or an Enumerable. " <> "Got: #{inspect(input)}" end end @doc "Like `convert/2` but raises on error." @spec convert!(binary() | {:binary, binary()} | Enumerable.t(), [opt()]) :: Path.t() | binary() | :ok def convert!(input, opts \\ []) do case convert(input, opts) do {:ok, v} -> v :ok -> :ok {:error, message} -> raise message end end defp do_convert(input, opts) do validate_convert_opts!(opts) case URP.Test.__fetch_stub__() do {:ok, fun} -> fun.(input, opts) :error -> {pool, opts} = resolve_pool(opts) {output, opts} = Keyword.pop(opts, :output) pool_opts = case output do nil -> tmp = generate_tmp_path(input, opts[:filter]) Keyword.put(opts, :sink, {:path, tmp}) :binary -> opts path when is_binary(path) -> Keyword.put(opts, :sink, {:path, path}) fun when is_function(fun, 1) -> Keyword.put(opts, :sink, fun) other -> raise ArgumentError, ":output must be a path, :binary, or a one-argument function; got: #{inspect(other)}" end case URP.Pool.convert(pool, input, pool_opts) do :ok when is_nil(output) -> {:ok, elem(pool_opts[:sink], 1)} :ok when is_binary(output) -> {:ok, output} :ok when is_function(output, 1) -> :ok {:ok, _bytes} = ok when output == :binary -> ok {:error, _msg} = err -> err end end end defp validate_convert_opts!(opts) do if not Keyword.keyword?(opts) do raise ArgumentError, "URP.convert/2 options must be a keyword list" end filter = Keyword.get(opts, :filter) if not (is_binary(filter) and filter != "") do raise ArgumentError, "URP.convert/2 requires the :filter option. " <> "Common filters: \"writer_pdf_Export\", \"calc_pdf_Export\", \"impress_pdf_Export\", \"Markdown\"" end validate_io!(Keyword.get(opts, :io, :file)) validate_timeout!(:timeout, Keyword.get(opts, :timeout, 120_000)) validate_timeout!(:recv_timeout, Keyword.get(opts, :recv_timeout, 120_000)) validate_output!(Keyword.get(opts, :output)) validate_filter_data!(Keyword.get(opts, :filter_data, [])) validate_settings!(Keyword.get(opts, :settings, [])) case Keyword.get(opts, :max_frame_size, 1) do value when is_integer(value) and value > 0 -> :ok value -> raise ArgumentError, ":max_frame_size must be a positive integer; got: #{inspect(value)}" end case Keyword.get(opts, :pool) do nil -> :ok name when is_atom(name) -> :ok name -> raise ArgumentError, ":pool must be a configured atom; got: #{inspect(name)}" end end defp validate_io!(:file), do: :ok defp validate_io!(:stream), do: :ok defp validate_io!({input, output}) when input in [:file, :stream] and output in [:file, :stream], do: :ok defp validate_io!(value) do raise ArgumentError, ":io must be :file, :stream, or {:file | :stream, :file | :stream}; got: #{inspect(value)}" end defp validate_timeout!(_name, :infinity), do: :ok defp validate_timeout!(_name, value) when is_integer(value) and value >= 0, do: :ok defp validate_timeout!(name, value) do raise ArgumentError, ":#{name} must be a non-negative integer or :infinity; got: #{inspect(value)}" end defp validate_output!(nil), do: :ok defp validate_output!(:binary), do: :ok defp validate_output!(path) when is_binary(path), do: :ok defp validate_output!(fun) when is_function(fun, 1), do: :ok defp validate_output!(value) do raise ArgumentError, ":output must be a path, :binary, or a one-argument function; got: #{inspect(value)}" end defp validate_filter_data!(filter_data) do valid? = Keyword.keyword?(filter_data) and Enum.all?(filter_data, fn {_key, value} -> is_boolean(value) or is_integer(value) or is_binary(value) end) if not valid? do raise ArgumentError, ":filter_data must be a keyword list with boolean, integer, or string values" end end defp validate_settings!(settings) do valid? = is_list(settings) and Enum.all?(settings, fn {path, property, value} -> is_binary(path) and is_binary(property) and (is_boolean(value) or is_integer(value) or is_binary(value)) _other -> false end) if not valid? do raise ArgumentError, ":settings must contain {path, property, value} triplets with supported values" end end @filter_extensions %{ "writer_pdf_Export" => ".pdf", "calc_pdf_Export" => ".pdf", "impress_pdf_Export" => ".pdf", "draw_pdf_Export" => ".pdf", "Markdown" => ".md", "HTML (StarWriter)" => ".html", "HTML (StarCalc)" => ".html", "Rich Text Format" => ".rtf", "Text" => ".txt", "Text (encoded)" => ".txt", "Office Open XML Text" => ".docx", "writer8" => ".odt" } defp generate_tmp_path(input, filter) do basename = case input do path when is_binary(path) -> Path.basename(path, Path.extname(path)) _ -> "urp" end ext = Map.get(@filter_extensions, filter, ".bin") id = :crypto.strong_rand_bytes(18) |> Base.url_encode64(padding: false) Path.join(System.tmp_dir!(), "#{basename}_#{id}#{ext}") end defp resolve_pool(opts) do {pool_name, opts} = Keyword.pop(opts, :pool) pool = if pool_name, do: ensure_pool!(pool_name), else: URP.Pool.Default {pool, opts} end @doc false def ensure_pool!(name) when not is_atom(name) do raise ArgumentError, "pool name must be a configured atom; got: #{inspect(name)}" end def ensure_pool!(name) do pools = Application.get_env(:urp, :pools, []) config = case Keyword.fetch(pools, name) do {:ok, config} -> config :error -> raise ArgumentError, "pool #{inspect(name)} is not configured. " <> "Add it to config :urp, :pools, #{name}: [host: \"...\", port: 2002]" end pid_name = pool_process_name(name) case GenServer.whereis(pid_name) do pid when is_pid(pid) -> pid_name nil -> opts = [ name: pid_name, host: Keyword.get(config, :host, "localhost"), port: Keyword.get(config, :port, 2002), pool_size: Keyword.get(config, :pool_size, 1) ] ++ Keyword.take(config, [ :backoff_initial, :backoff_max, :connect_timeout, :send_timeout ]) case DynamicSupervisor.start_child(URP.PoolSupervisor, {URP.Pool, opts}) do {:ok, _pid} -> pid_name {:error, {:already_started, _pid}} -> pid_name {:error, reason} -> raise "could not start pool #{inspect(name)}: #{inspect(reason)}" end end end defp pool_process_name(name), do: :"URP.Pool.#{name}" end