Current section
Files
Jump to
Current section
Files
lib/xlsx.ex
defmodule CsvParser.Xlsx do
@moduledoc false
alias __MODULE__
require Record
defstruct [:sheet, :strings, :opts]
Record.defrecord(:state, acc: nil, row: {nil, 0}, col: nil, value: false, headers: nil, strings: nil, map: nil, fun: nil, col_last: 0, col_count: nil)
def new(path, opts) do
with {:ok, xlsx} <- :zip.unzip(String.to_charlist(path), [:memory]),
{:ok, sheet} <- extract_sheet(xlsx, opts)
do
shared_string_data = Enum.find_value(xlsx, fn
{'xl/sharedStrings.xml', data} -> data
_ -> nil
end)
strings = parse_strings(shared_string_data)
{:ok, %Xlsx{sheet: sheet, strings: strings, opts: opts}}
else
nil -> {:error, :unknown_sheet}
_ -> {:error, :invalid_format}
end
end
defp extract_sheet(xlsx, opts) do
suffix = String.to_charlist("#{opts[:sheet_index] || 1}.xml")
Enum.find_value(xlsx, fn
{[?x, ?l, ?/, ?w, ?o, ?r, ?k, ?s, ?h, ?e, ?e, ?t, ?s, ?/, ?s, ?h, ?e, ?e, ?t | rest], data} ->
case suffix == rest do
true -> {:ok, data}
false -> nil
end
_ -> nil
end)
end
# not sure if this can happen, but no harm to handle it
defp parse_strings(nil), do: {}
defp parse_strings(data) do
{:ok, {strings, _, _}, _} =
:erlsom.parse_sax(data, {[], false, false}, fn event, {acc, item?, text?} ->
case simplify(event) do
{:startElement, 'si', _} -> {acc, true, false}
{:startElement, 't', _} -> {acc, item?, true}
{:characters, value} ->
case item? && text? do
true -> {[String.Chars.to_string(value) | acc], true, true}
false -> {acc, item?, text?}
end
{:endElement, 't'} -> {acc, item?, false}
{:endElement, 'si'} -> {acc, false, false}
_ -> {acc, item?, text?}
end
end)
strings
|> Enum.reverse()
|> List.to_tuple()
end
def reduce(xlsx, acc, fun) do
s = state(acc: acc, fun: fun, strings: xlsx.strings, map: xlsx.opts[:map] || false)
{:ok, s, _} = :erlsom.parse_sax(xlsx.sheet, s, fn event, s ->
handle(simplify(event), s)
end)
state(s, :acc)
end
defp handle({:startElement, 'row', _}, s), do: state(s, row: {[], 0})
defp handle({:startElement, 'c', attr}, s) do
col = Enum.reduce(attr, {nil, nil}, fn
{:attribute, 't', [], [], type}, {_type, index} -> {type, index}
{:attribute, 'r', [], [], index}, {type, _index} -> {type, extract_index(index)}
_, acc -> acc
end)
state(s, col: col, value: false)
end
defp handle({:startElement, 'v', _}, s), do: state(s, value: true)
defp handle({:characters, value}, s) do
{row, count} = state(s, :row)
{type, index} = state(s, :col)
case row == nil || type == nil|| state(s, :value) == false do
true -> s # a value that doesn't seem to belong to a row -> col -> value
false ->
value = case type do
's' -> elem(state(s, :strings), elem(:string.to_integer(value), 0))
_ -> String.Chars.to_string(value)
end
last = state(s, :col_last)
row = case index - last do
1 -> row
n -> append_missing(n, row)
end
state(s, row: {[value | row], index}, col_last: index)
end
end
defp handle({:endElement, 'v'}, s), do: state(s, value: false)
defp handle({:endElement, 'c'}, s), do: state(s, col: {nil, nil}, value: false)
defp handle({:endElement, 'row'}, s) do
{row, count} = state(s, :row)
# right pad our row if count < col_count, or store col_count if this is the first row
{s, row} = case state(s, :col_count) do
nil -> {state(s, col_count: count), row}
col_count ->
row = case count < col_count do
true -> Enum.reduce(count .. col_count - 1, row, fn _, row -> [nil | row] end)
false -> row # we have a full column
end
{s, row}
end
row = Enum.reverse(row)
case state(s, :map) do
false ->
acc = state(s, :fun).({:ok, row}, state(s, :acc))
state(s, acc: acc, row: {nil, 0}, col: {nil, nil}, col_last: 0, value: false)
transform ->
case state(s, :headers) do
nil -> state(s, headers: build_headers(transform, row), col: {nil, nil}, col_last: 0)
headers ->
row = Map.new(Enum.zip(headers, row))
acc = state(s, :fun).({:ok, row}, state(s, :acc))
state(s, acc: acc, row: {nil, 0}, col: {nil, nil}, col_last: 0, value: false)
end
end
end
defp handle(_other, s), do: s
defp build_headers(true, row), do: row
defp build_headers(:upper, row), do: Enum.map(row, &String.upcase/1)
defp build_headers(:lower, row), do: Enum.map(row, &String.downcase/1)
defp build_headers(fun, row) when is_function(fun), do: fun.(row)
defp simplify({:startElement, _url, name, _prefix, attributes}), do: {:startElement, name, attributes}
defp simplify({:endElement, _url, name, _prefix}), do: {:endElement, name}
defp simplify(event), do: event
defp extract_index([n1, r | _]) when r >= ?0 and r <= ?9 do
n1 - ?A + 1
end
defp extract_index([n1, n2 | _]) do
((n1 - ?A + 1) * 26) + n2 - ?A + 1
end
defp append_missing(2, row), do: [nil | row]
defp append_missing(3, row), do: [nil | [nil | row]]
defp append_missing(4, row), do: [nil | [nil | [nil | row]]]
defp append_missing(5, row), do: [nil | [nil | [nil | [nil | row]]]]
defp append_missing(n, row) do
Enum.reduce(2..n, row, fn _, row -> [nil | row] end)
end
end