Packages

File parser for popular excel formats: xls (Excel 2003), csv, xlsx (Excel 2007). Stores data in ets (except for csv, which uses stream).

Current section

Files

Jump to
suexcxine_exoffice lib exoffice parser excel_2003 loader.ex
Raw

lib/exoffice/parser/excel_2003/loader.ex

defmodule Exoffice.Parser.Excel2003.Loader do
alias Exoffice.Parser.Excel2003
alias Exoffice.Parser.Excel2003.OLE
alias Exoffice.Parser.Excel2003.Cell
alias Exoffice.Parser.Excel2003.String, as: ExofficeString
alias Exoffice.Parser.Excel2003.Index
alias Xlsxir.StateManager
use Bitwise, only_operators: true
# ParseXL definitions
@xls_biff8 0x0600
@xls_biff7 0x0500
@xls_workbook_globals 0x0005
@xls_worksheet 0x0010
# Calendar
# Base date of 1st Jan 1900 = 1.0
@calendar_windows_1900 1900
# Base date of 2nd Jan 1904 = 1.0
@calendar_mac_1904 1904
# record identifiers
@xls_type_sheet 0x0085
@xls_type_bof 0x0809
@xls_type_codepage 0x0042
@xls_type_datemode 0x0022
@xls_type_sst 0x00FC
@xls_type_continue 0x003C
@xls_type_labelsst 0x00FD
@xls_type_number 0x0203
@xls_type_blank 0x0201
@xls_type_eof 0x000A
# sheet state
@sheetstate_visible "visible"
@sheetstate_hidden "hidden"
@sheetstate_veryhidden "veryHidden"
defstruct data: nil,
summary_information: nil,
document_summary_information: nil,
sst_tid: nil
def load(path, sheet \\ nil) do
with {:ok, file} <- File.open(path, [:read, :binary]),
{:ok, ole} <- :file.read(file, 8),
true <- ole == OLE.identifier_ole(),
{:ok, binary} <- File.read(path),
{:ok, ole} <- OLE.parse_blocks(binary),
loader <- get_stream(ole),
{stream, _pos, excel} <- parse(loader, 0, %Excel2003{data_size: byte_size(loader.data)}),
pids = parse_sheets(loader, excel, sheet) do
Enum.map(pids, fn {status, pid, _} ->
sort_ets_table_by_row(pid)
{status, pid}
end)
else
{:error, reason} -> {:error, reason}
end
end
defp get_stream(ole) do
[data, summary_information, document_summary_information] =
Enum.map(
[
ole.workbook,
ole.summary_information,
ole.document_summary_information
],
fn
nil ->
nil
prop_name ->
prop = Enum.at(ole.props, prop_name)
OLE.get_stream(ole, prop, prop.start_block, "")
end
)
Index.new()
%__MODULE__{
data: data,
summary_information: summary_information,
document_summary_information: document_summary_information,
sst_tid: GenServer.call(StateManager, :new_table)
}
end
defp parse_sheets(%__MODULE__{sst_tid: sst_tid} = loader, excel, sheet) do
sheets = if is_nil(sheet), do: excel.sheets, else: [Enum.at(excel.sheets, sheet)]
pids =
sheets
|> Enum.filter(fn %{sheet_type: type} ->
type == <<0>>
end)
|> Enum.map(fn sheet ->
table_id = GenServer.call(StateManager, :new_table)
parse_sheet_part(loader, sheet.offset, excel, table_id)
{:ok, table_id, excel}
end)
Index.del()
:ets.delete(sst_tid)
pids
end
def parse_sheet_part(loader, pos, excel), do: {loader, pos, excel}
def parse_sheet_part(%__MODULE__{data: stream} = loader, pos, excel, table_id) when byte_size(stream) > pos do
code = OLE.get_int_2d(stream, pos)
case code do
@xls_type_bof -> read_bof(loader, pos, excel, :parse_sheet_part, table_id)
@xls_type_labelsst -> read_label_sst(loader, pos, excel, table_id)
@xls_type_number -> read_number(loader, pos, excel, table_id)
@xls_type_blank -> read_blank(loader, pos, excel, table_id)
@xls_type_eof -> read_default(loader, pos, excel, :parse_sheet_part, nil)
_ -> read_default(loader, pos, excel, :parse_sheet_part, table_id)
end
end
def parse_sheet_part(%__MODULE__{} = loader, pos, excel, _table_id) do
{loader, pos, excel}
end
def parse(%__MODULE__{data: stream} = loader, pos, excel) when byte_size(stream) - 4 > pos do
code = OLE.get_int_2d(stream, pos)
case code do
@xls_type_bof -> read_bof(loader, pos, excel)
@xls_type_sheet -> read_sheet(loader, pos, excel)
@xls_type_codepage -> read_codepage(loader, pos, excel)
@xls_type_datemode -> read_datemode(loader, pos, excel)
@xls_type_sst -> read_sst(loader, pos, excel)
_ -> read_default(loader, pos, excel)
end
end
def parse(%__MODULE__{} = loader, pos, excel) do
{loader, pos, excel}
end
defp read_bof(%__MODULE__{data: stream} = loader, pos, excel, fun \\ :parse, pid \\ nil) do
length = OLE.get_int_2d(stream, pos + 2)
record_data = binary_part(stream, pos + 4, length)
new_pos = pos + length + 4
# offset: 2; size: 2; type of the following data
substream_type = OLE.get_int_2d(record_data, 2)
case substream_type do
@xls_workbook_globals ->
version = OLE.get_int_2d(record_data, 0)
if version != @xls_biff8 && version != @xls_biff7 do
{:error, "Cannot read this Excel file. Version is too old."}
else
parse(loader, new_pos, %{excel | version: version})
end
@xls_worksheet ->
# do not use this version information for anything
# it is unreliable (OpenOffice doc, 5.8), use only version information from the global stream
apply(__MODULE__, fun, if(pid, do: [loader, new_pos, excel, pid], else: [loader, new_pos, excel]))
_ ->
# substream, e.g. chart
# just skip the entire substream
read_bof_default(loader, new_pos, excel)
end
end
defp read_label_sst(%__MODULE__{data: stream, sst_tid: sst_tid} = loader, pos, excel, pid) do
length = OLE.get_int_2d(stream, pos + 2)
record_data = read_record_data(stream, pos + 4, length)
# offset: 0; size: 2; index to row
row = OLE.get_int_2d(record_data, 0) + 1
# offset: 2; size: 2; index to column
column = OLE.get_int_2d(record_data, 2)
column_string = Cell.string_from_column_index(column)
# offset: 6; size: 4; index to SST record
index = OLE.get_int_4d(record_data, 6)
value =
:ets.lookup(sst_tid, index)
|> List.first()
|> Tuple.to_list()
|> Enum.at(1)
# add cell
case :ets.match(pid, {row, :"$1"}) do
[[cells]] ->
:ets.insert(pid, {row, [[column_string <> to_string(row), value]] ++ cells})
_ ->
:ets.insert(pid, {row, [[column_string <> to_string(row), value]]})
end
parse_sheet_part(loader, pos + 4 + length, excel, pid)
end
defp read_number(%__MODULE__{data: stream} = loader, pos, excel, pid) do
length = OLE.get_int_2d(stream, pos + 2)
record_data = read_record_data(stream, pos + 4, length)
# offset: 0; size: 2; index to row
row = OLE.get_int_2d(record_data, 0) + 1
# offset: 2; size 2; index to column
column = OLE.get_int_2d(record_data, 2)
column_string = Cell.string_from_column_index(column)
value = extract_number(binary_part(record_data, 6, 8))
# add cell
case :ets.match(pid, {row, :"$1"}) do
[[cells]] ->
:ets.insert(pid, {row, [[column_string <> to_string(row), value]] ++ cells})
_ ->
:ets.insert(pid, {row, [[column_string <> to_string(row), value]]})
end
parse_sheet_part(loader, pos + 4 + length, excel, pid)
end
defp read_blank(%__MODULE__{data: stream} = loader, pos, excel, pid) do
length = OLE.get_int_2d(stream, pos + 2)
record_data = read_record_data(stream, pos + 4, length)
# offset: 0; size: 2; row index
row = OLE.get_int_2d(record_data, 0) + 1
# offset: 2; size: 2; col index
column = OLE.get_int_2d(record_data, 2)
column_string = Cell.string_from_column_index(column)
# add cell
case :ets.match(pid, {row, :"$1"}) do
[[cells]] ->
:ets.insert(pid, {row, [[column_string <> to_string(row), nil]] ++ cells})
_ ->
:ets.insert(pid, {row, [[column_string <> to_string(row), nil]]})
end
parse_sheet_part(loader, pos + 4 + length, excel, pid)
end
defp extract_number(data) do
rknumhigh = OLE.get_int_4d(data, 4)
rknumlow = OLE.get_int_4d(data, 0)
sign = (rknumhigh &&& 0x80000000) >>> 31
exp = ((rknumhigh &&& 0x7FF00000) >>> 20) - 1023
mantissa = 0x100000 ||| (rknumhigh &&& 0x000FFFFF)
mantissa_low1 = (rknumlow &&& 0x80000000) >>> 31
mantissa_low2 = rknumlow &&& 0x7FFFFFFF
value =
if 20 - exp > 1023 do
0
else
mantissa / :math.pow(2, 20 - exp)
end
value
|> (fn v ->
if mantissa_low1 != 0 && 21 - exp <= 1023 do
v + 1 / :math.pow(2, 21 - exp)
else
v
end
end).()
|> (fn v ->
if 52 - exp > 1023 do
v
else
v + mantissa_low2 / :math.pow(2, 52 - exp)
end
end).()
|> (fn v -> if sign != 0, do: v * -1, else: v end).()
end
defp read_codepage(%__MODULE__{data: stream} = loader, pos, excel) do
length = OLE.get_int_2d(stream, pos + 2)
record_data = read_record_data(stream, pos + 4, length)
# offset: 0; size: 2; code page identifier
case OLE.get_int_2d(record_data, 0) |> codepage_to_name do
{:ok, codepage} -> parse(loader, pos + length + 4, %{excel | codepage: codepage})
{:error, reason} -> {:error, reason}
end
end
defp codepage_to_name(codepage) do
case codepage do
# ASCII
367 ->
{:ok, "ASCII"}
# OEM US
437 ->
{:ok, "CP437"}
# OEM Arabic
720 ->
{:error, "Code page 720 not supported."}
# OEM Greek
737 ->
{:ok, "CP737"}
# OEM Baltic
775 ->
{:ok, "CP775"}
# OEM Latin I
850 ->
{:ok, "CP850"}
# OEM Latin II (Central European)
852 ->
{:ok, "CP852"}
# OEM Cyrillic
855 ->
{:ok, "CP855"}
# OEM Turkish
857 ->
{:ok, "CP857"}
# OEM Multilingual Latin I with Euro
858 ->
{:ok, "CP858"}
# OEM Portugese
860 ->
{:ok, "CP860"}
# OEM Icelandic
861 ->
{:ok, "CP861"}
# OEM Hebrew
862 ->
{:ok, "CP862"}
# OEM Canadian (French)
863 ->
{:ok, "CP863"}
# OEM Arabic
864 ->
{:ok, "CP864"}
# OEM Nordic
865 ->
{:ok, "CP865"}
# OEM Cyrillic (Russian)
866 ->
{:ok, "CP866"}
# OEM Greek (Modern)
869 ->
{:ok, "CP869"}
# ANSI Thai
874 ->
{:ok, "CP874"}
# ANSI Japanese Shift-JIS
932 ->
{:ok, "CP932"}
# ANSI Chinese Simplified GBK
936 ->
{:ok, "CP936"}
# ANSI Korean (Wansung)
949 ->
{:ok, "CP949"}
# ANSI Chinese Traditional BIG5
950 ->
{:ok, "CP950"}
# UTF-16 (BIFF8)
1200 ->
{:ok, "UTF-16LE"}
# ANSI Latin II (Central European)
1250 ->
{:ok, "CP1250"}
# ANSI Cyrillic
1251 ->
{:ok, "CP1251"}
# CodePage is not always correctly set when the xls file was saved by Apple's Numbers program
0 ->
{:ok, "CP1252"}
# ANSI Latin I (BIFF4-BIFF7)
1252 ->
{:ok, "CP1252"}
# ANSI Greek
1253 ->
{:ok, "CP1253"}
# ANSI Turkish
1254 ->
{:ok, "CP1254"}
# ANSI Hebrew
1255 ->
{:ok, "CP1255"}
# ANSI Arabic
1256 ->
{:ok, "CP1256"}
# ANSI Baltic
1257 ->
{:ok, "CP1257"}
# ANSI Vietnamese
1258 ->
{:ok, "CP1258"}
# ANSI Korean (Johab)
1361 ->
{:ok, "CP1361"}
# Apple Roman
10000 ->
{:ok, "MAC"}
# Macintosh Japanese
10001 ->
{:ok, "CP932"}
# Macintosh Chinese Traditional
10002 ->
{:ok, "CP950"}
# Macintosh Korean
10003 ->
{:ok, "CP1361"}
# Macintosh Greek
10006 ->
{:ok, "MACGREEK"}
# Macintosh Cyrillic
10007 ->
{:ok, "MACCYRILLIC"}
# Macintosh - Simplified Chinese (GB 2312)
10008 ->
{:ok, "CP936"}
# Macintosh Central Europe
10029 ->
{:ok, "MACCENTRALEUROPE"}
# Macintosh Icelandic
10079 ->
{:ok, "MACICELAND"}
# Macintosh Turkish
10081 ->
{:ok, "MACTURKISH"}
# UTF-16 (BIFF8) This isn't correct, but some Excel writer libraries erroneously use Codepage 21010 for UTF-16LE
21010 ->
{:ok, "UTF-16LE"}
# Apple Roman
32768 ->
{:ok, "MAC"}
# ANSI Latin I (BIFF2-BIFF3)
32769 ->
{:error, "Code page 32769 not supported."}
# Unicode (UTF-7)
65000 ->
{:ok, "UTF-7"}
# Unicode (UTF-8)
65001 ->
{:ok, "UTF-8"}
_ ->
{:error, "Unknown codepage: " <> codepage}
end
end
defp read_bof_default(%__MODULE__{data: stream} = loader, pos, excel) do
code = OLE.get_int_2d(stream, pos)
length = OLE.get_int_2d(stream, pos + 2)
case code != @xls_type_eof && pos < excel.data_size do
true -> read_bof_default(loader, pos + length + 4, excel)
false -> excel
end
end
defp read_datemode(%__MODULE__{data: stream} = loader, pos, excel) do
length = OLE.get_int_2d(stream, pos + 2)
record_data = read_record_data(stream, pos + 4, length)
# offset: 0; size: 2; 0 = base 1900, 1 = base 1904
excel = if binary_part(record_data, 0, 1) == <<1>>, do: %{excel | base_date: @calendar_mac_1904}, else: excel
parse(loader, pos + length + 4, excel)
end
defp read_sheet(%__MODULE__{data: stream} = loader, pos, excel) do
length = OLE.get_int_2d(stream, pos + 2)
record_data = read_record_data(stream, pos + 4, length)
# offset: 0; size: 4; absolute stream position of the BOF record of the sheet
# NOTE: not encrypted
rec_offset = OLE.get_int_4d(stream, pos + 4)
# offset: 4; size: 1; sheet state
sheet_state =
case binary_part(record_data, 4, 0) do
<<1>> -> @sheetstate_hidden
<<2>> -> @sheetstate_veryhidden
_ -> @sheetstate_visible
end
# offset: 5; size: 1; sheet type
sheet_type = binary_part(record_data, 5, 1)
# offset: 6; size: var; sheet name
rec_name =
case excel.version do
@xls_biff8 ->
binary_part(record_data, 6, byte_size(record_data) - 6)
|> ExofficeString.read_unicode_string_short()
@xls_biff7 ->
binary_part(record_data, 6, byte_size(record_data) - 6)
|> ExofficeString.read_byte_string_short(excel.codepage)
end
sheet = %{name: rec_name.value, offset: rec_offset, sheet_state: sheet_state, sheet_type: sheet_type}
parse(loader, pos + length + 4, %{excel | sheets: excel.sheets ++ [sheet]})
end
defp get_spliced_record_data(stream, pos, splice_offsets, data, i, @xls_type_continue) do
# offset: 2; size: 2; length
length = OLE.get_int_2d(stream, pos + 2)
data = data <> read_record_data(stream, pos + 4, length)
splice_offsets = splice_offsets ++ [Enum.at(splice_offsets, i - 1) + length]
new_pos = pos + length + 4
get_spliced_record_data(stream, new_pos, splice_offsets, data, i + 1, OLE.get_int_2d(stream, new_pos))
end
defp get_spliced_record_data(_, pos, splice_offsets, data, _, _) do
{data, splice_offsets, pos}
end
defp read_sst(%__MODULE__{data: stream, sst_tid: sst_tid} = loader, pos, excel) do
Index.new()
# get spliced record data
{record_data, splice_offsets, pos} = get_spliced_record_data(stream, pos, [0], <<>>, 1, @xls_type_continue)
nm = OLE.get_int_4d(record_data, 4)
0..(nm - 1)
|> Stream.scan(8, fn _, pos ->
{num_chars, pos} = {OLE.get_int_2d(record_data, pos), pos + 2}
{option_flags, pos} = {OLE.decoded_binary_at(record_data, pos), pos + 1}
# bit: 0; mask: 0x01; 0 = compressed; 1 = uncompressed
is_compressed = (option_flags &&& 0x01) == 0
# bit: 2; mask: 0x02; 0 = ordinary; 1 = Asian phonetic
has_asian = (option_flags &&& 0x04) != 0
# bit: 3; mask: 0x03; 0 = ordinary; 1 = Rich-Text
has_rich_text = (option_flags &&& 0x08) != 0
# number of Rich-Text formatting runs
{formatting_runs, pos} =
case has_rich_text do
true -> {OLE.get_int_2d(record_data, pos), pos + 2}
false -> {nil, pos}
end
# size of Asian phonetic setting
{extended_run_length, pos} =
case has_asian do
true -> {OLE.get_int_2d(record_data, pos), pos + 4}
false -> {nil, pos}
end
len = if is_compressed, do: num_chars, else: num_chars * 2
limit_pos = Enum.drop_while(splice_offsets, &(pos > &1)) |> List.first()
{ret_str, is_compressed, pos} =
case pos + len <= limit_pos do
true ->
{binary_part(record_data, pos, len), is_compressed, pos + len}
false ->
# character array is split between records
# first part of character array
ret_str = binary_part(record_data, pos, limit_pos - pos)
bytes_read = limit_pos - pos
# remaining characters in Unicode string
chars_left = num_chars - if is_compressed, do: bytes_read, else: bytes_read / 2
pos = limit_pos
get_ret_str(record_data, splice_offsets, ret_str, chars_left, pos, is_compressed)
end
# convert to UTF-8
ret_str = ExofficeString.encode_utf_16(ret_str, is_compressed)
# read additional Rich-Text information, if any
{_fmt_runs, pos} =
case has_rich_text do
true ->
# list of formatting runs
fmt_runs =
Enum.reduce(0..(formatting_runs - 1), [], fn i, acc ->
# first formatted character; zero-based
char_pos = OLE.get_int_2d(record_data, pos + i * 4)
# index to font record
font_index = OLE.get_int_2d(record_data, pos + 2 + i * 4)
acc ++ [[char_pos, font_index]]
end)
{fmt_runs, pos + 4 * formatting_runs}
false ->
{[], pos}
end
pos = if has_asian, do: pos + extended_run_length, else: pos
:ets.insert(sst_tid, {Index.get(), ret_str})
Index.inc()
pos
end)
|> Enum.into([])
parse(loader, pos, excel)
end
defp get_ret_str(record_data, splice_offsets, ret_str, chars_left, pos, is_compressed) when chars_left > 0 do
# look up next limit position, in case the string span more than one continue record
limit_pos = Enum.drop_while(splice_offsets, &(pos >= &1)) |> List.first()
# repeated option flags
# OpenOffice.org documentation 5.21
{option, pos} = {OLE.decoded_binary_at(record_data, pos), pos + 1}
{ret_str, chars_left, is_compressed, len} =
cond do
is_compressed && option == 0 ->
# 1st fragment compressed
# this fragment compressed
len = min(chars_left, limit_pos - pos) |> round
{ret_str <> binary_part(record_data, pos, len), chars_left - len, true, len}
!is_compressed && option != 0 ->
# 1st fragment uncompressed
# this fragment uncompressed
len = min(chars_left * 2, limit_pos - pos) |> round
{ret_str <> binary_part(record_data, pos, len), round(chars_left - len / 2), false, len}
!is_compressed && option == 0 ->
# 1st fragment uncompressed
# this fragment compressed
len = min(chars_left, limit_pos - pos) |> round
ret_str =
Enum.reduce(0..(len - 1), ret_str, fn i, acc ->
acc <> binary_part(record_data, pos + i, 1) <> <<0>>
end)
{ret_str, chars_left - len, false, len}
true ->
# 1st fragment compressed
# this fragment uncompressed
ret_str =
Enum.reduce(0..(byte_size(ret_str) - 1), "", fn i, acc ->
acc <> binary_part(ret_str, i, 1) <> <<0>>
end)
len = min(chars_left * 2, limit_pos - pos) |> round
ret_str = ret_str <> binary_part(record_data, pos, len)
{ret_str, round(chars_left - len / 2), false, len}
end
get_ret_str(record_data, splice_offsets, ret_str, chars_left, pos + len, is_compressed)
end
defp get_ret_str(_record_data, _splice_offsets, ret_str, _, pos, is_compressed) do
{ret_str, is_compressed, pos}
end
defp read_record_data(binary, pos, length) do
# Encryption is not supported
binary_part(binary, pos, length)
end
defp read_default(%__MODULE__{data: stream} = loader, pos, excel, fun \\ :parse, pid \\ nil) do
length = OLE.get_int_2d(stream, pos + 2)
# move stream pointer to next record
new_pos = pos + length + 4
apply(__MODULE__, fun, if(pid, do: [loader, new_pos, excel, pid], else: [loader, new_pos, excel]))
end
def sort_ets_table_by_row(tid) do
rows = :ets.tab2list(tid) |> Enum.map(fn {row, cells} -> {row, Enum.sort(cells)} end)
:ets.insert(tid, rows)
end
end