defmodule XlsxReader do @moduledoc """ Opens XLSX workbook and reads its worksheets. ## Example ```elixir {:ok, package} = XlsxReader.open("test.xlsx") XlsxReader.sheet_names(package) # ["Sheet 1", "Sheet 2", "Sheet 3"] {:ok, rows} = XlsxReader.sheet(package, "Sheet 1") # [ # ["Date", "Temperature"], # [~D[2019-11-01], 8.4], # [~D[2019-11-02], 7.5], # ... # ] ``` ## Sheet contents Sheets are loaded on-demand by `sheet/3` and `sheets/2`. The sheet contents is returned as a list of lists: ```elixir [ ["A1", "B1", "C1" | _], ["A2", "B2", "C2" | _], ["A3", "B3", "C3" | _], | _ ] ``` The behavior of the sheet parser can be customized for each individual sheet, see `sheet/3`. """ alias XlsxReader.{PackageLoader, ZipArchive} @typedoc """ Source for the XLSX file: file system (`:path`) or in-memory (`:binary`) """ @type source :: :path | :binary @typedoc """ Option to specify the XLSX file source """ @type source_option :: {:source, source()} @typedoc """ List of cell values """ @type row :: list(any()) @typedoc """ List of rows """ @type rows :: list(row()) @typedoc """ Sheet name """ @type sheet_name :: String.t() @typedoc """ Error tuple with message describing the cause of the error """ @type error :: {:error, String.t()} @doc """ Opens an XLSX file located on the file system (default) or from memory. ## Examples ### Opening XLSX file on the file system ```elixir {:ok, package} = XlsxReader.open("test.xlsx") ``` ### Opening XLSX file from memory ```elixir blob = File.read!("test.xlsx") {:ok, package} = XlsxReader.open(blob, source: :binary) ``` ## Options * `source`: `:path` (on the file system, default) or `:binary` (in memory) """ @spec open(String.t() | binary(), [source_option]) :: {:ok, XlsxReader.Package.t()} | error() def open(file, options \\ []) do file |> ZipArchive.handle(Keyword.get(options, :source, :path)) |> PackageLoader.open() end @doc """ Lists the names of the sheets in the package's workbook """ @spec sheet_names(XlsxReader.Package.t()) :: [sheet_name()] def sheet_names(package) do for %{name: name} <- package.workbook.sheets, do: name end @doc """ Loads the sheet with the given name (see `sheet_names/1`) ## Options * `type_conversion` - boolean (default: `true`) * `blank_value` - placeholder value for empty cells (default: `""`) * `empty_rows` - include empty rows (default: `true`) * `number_type` - type used for numeric conversion :`Integer`, 'Decimal' or `Float` (default: `Float`) The `Decimal` type requires the [decimal](https://github.com/ericmj/decimal) library. """ @spec sheet(XlsxReader.Package.t(), sheet_name(), Keyword.t()) :: {:ok, rows()} def sheet(package, sheet_name, options \\ []) do PackageLoader.load_sheet_by_name(package, sheet_name, options) end @doc """ Loads all the sheets in the workbook. On success, returns `{:ok, [{sheet_name, rows}, ...]}`. ## Options See `sheet/2`. """ @spec sheets(XlsxReader.Package.t(), Keyword.t()) :: {:ok, list({sheet_name(), rows()})} | error() def sheets(package, options \\ []) do load_all_sheets(package, options, package.workbook.sheets, []) end @doc """ Loads all the sheets in the workbook concurrently. On success, returns `{:ok, [{sheet_name, rows}, ...]}`. When processing files with multiple sheets, `async_sheets/3` is ~3x faster than `sheets/2` but it comes with a caveat. `async_sheets/3` uses `Task.async_stream/3` under the hood and thus runs each concurrent task with a timeout. If you expect your dataset to be of a significant size, you may want to increase it from the default 10000ms (see "Concurrency options" below). If the order in which the sheets are returned is not relevant for your application, you can pass `ordered: false` (see "Concurrency options" below) for a modest speed gain. ## Sheet options See `sheet/2`. ## Concurrency options * `max_concurrency` - maximum number of tasks to run at the same time (default: `System.schedulers_online/0`) * `ordered` - maintain order consistent with `sheet_names/1` (default: `true`) * `timeout` - maximum duration in milliseconds to process a sheet (default: `10_000`) """ def async_sheets(package, sheet_options \\ [], task_options \\ []) do max_concurrency = Keyword.get(task_options, :max_concurrency, System.schedulers_online()) ordered = Keyword.get(task_options, :ordered, true) timeout = Keyword.get(task_options, :timeout, 10_000) package.workbook.sheets |> Task.async_stream( fn sheet -> case PackageLoader.load_sheet_by_rid(package, sheet.rid, sheet_options) do {:ok, rows} -> {:ok, {sheet.name, rows}} error -> error end end, max_concurrency: max_concurrency, ordered: ordered, timeout: timeout, on_timeout: :kill_task ) |> Enum.reduce_while({:ok, []}, fn {:ok, {:ok, entry}}, {:ok, acc} -> {:cont, {:ok, [entry | acc]}} {:ok, error}, _acc -> {:halt, {:error, error}} {:exit, :timeout}, _acc -> {:halt, {:error, "timeout exceeded"}} {:exit, reason}, _acc -> {:halt, {:error, reason}} end) |> case do {:ok, list} -> if ordered, do: {:ok, Enum.reverse(list)}, else: {:ok, list} error -> error end end ## @spec load_all_sheets( XlsxReader.Package.t(), Keyword.t(), list(XlsxReader.Sheet.t()), list({sheet_name(), rows()}) ) :: {:ok, list({sheet_name(), rows()})} | error() defp load_all_sheets(_package, _options, [], acc), do: {:ok, Enum.reverse(acc)} defp load_all_sheets(package, options, [sheet | sheets], acc) do case PackageLoader.load_sheet_by_rid(package, sheet.rid, options) do {:ok, rows} -> load_all_sheets(package, options, sheets, [{sheet.name, rows} | acc]) error -> error end end end