defmodule Elixlsx.XMLTemplates do
alias Elixlsx.Util, as: U
alias Elixlsx.Compiler.CellStyleDB
alias Elixlsx.Compiler.StringDB
alias Elixlsx.Compiler.FontDB
alias Elixlsx.Compiler.FillDB
alias Elixlsx.Compiler.SheetCompInfo
alias Elixlsx.Compiler.NumFmtDB
alias Elixlsx.Compiler.BorderStyleDB
alias Elixlsx.Compiler.WorkbookCompInfo
alias Elixlsx.Style.CellStyle
alias Elixlsx.Style.Font
alias Elixlsx.Style.Fill
alias Elixlsx.Style.BorderStyle
alias Elixlsx.Sheet
# TODO: the xml_text_exape functions belong into Elixlsx.Util,
# as they are/will be used by functions in Elixlsx.Style.*
@doc ~S"""
There are 5 characters that should be escaped in XML (<,>,",',&), but only
2 of them *must* be escaped. Saves a couple of CPU cycles, for the environment.
## Examples
iex> Elixlsx.XMLTemplates.minimal_xml_text_escape "Only '&' and '<' are escaped here, '\"' & '>' & \"'\" are not."
"Only '&' and '<' are escaped here, '\"' & '>' & \"'\" are not."
"""
def minimal_xml_text_escape(s) do
U.replace_all(s, [{"&", "&"}, {"<", "<"}])
end
@doc ~S"""
Escape characters for embedding in XML
documents.
## Examples
iex> Elixlsx.XMLTemplates.xml_escape "&\"'<>'"
"&"'<>'"
"""
def xml_escape(s) do
U.replace_all(s, [
{"&", "&"},
{"'", "'"},
{"\"", """},
{"<", "<"},
{">", ">"}
])
end
@docprops_app ~S"""
0
Elixlsx
__APPVERSION__
"""
def docprops_app do
U.replace_all(
@docprops_app,
[{"__APPVERSION__", U.app_version_string()}]
)
end
@docprops_core ~S"""
__TIMESTAMP__
__LANGUAGE__
__TIMESTAMP__
__REVISION__
"""
def docprops_core(timestamp, language \\ "en-US", revision \\ 1) do
U.replace_all(
@docprops_core,
[
{"__TIMESTAMP__", xml_escape(timestamp)},
{"__LANGUAGE__", language},
{"__REVISION__", to_string(revision)}
]
)
end
@spec make_xl_rel_sheet(SheetCompInfo.t()) :: String.t()
def make_xl_rel_sheet(sheet_comp_info) do
# I'd love to use string interpolation here, but unfortunately """< is heredoc notation, so i have to use
# string concatenation or escape all the quotes. Choosing the first.
""
end
@spec make_xl_rel_sheets(nonempty_list(SheetCompInfo.t())) :: String.t()
def make_xl_rel_sheets(sheet_comp_infos) do
Enum.map_join(sheet_comp_infos, &make_xl_rel_sheet/1)
end
### xl/workbook.xml
@spec make_xl_workbook_xml_sheet_entry({Sheet.t(), SheetCompInfo.t()}) :: String.t()
def make_xl_workbook_xml_sheet_entry({sheet_info, sheet_comp_info}) do
if sheet_info.name == "" do
raise %ArgumentError{
message: "The sheet name cannot be blank."
}
end
if String.length(sheet_info.name) > 31 do
raise %ArgumentError{
message:
"The sheet name '#{sheet_info.name}' is too long. Maximum 31 chars allowed for name."
}
end
if String.contains?(sheet_info.name, ~W(: \ / ? * [ ])) do
raise %ArgumentError{
message:
"The sheet name '#{sheet_info.name}' contains following invalid characters: : \ / ? * [ ])"
}
end
"""
"""
end
### [Content_Types].xml
defp contenttypes_sheet_entry(sheet_comp_info) do
"""
"""
end
defp contenttypes_sheet_entries(sheet_comp_infos) do
Enum.map_join(sheet_comp_infos, &contenttypes_sheet_entry/1)
end
def make_contenttypes_xml(wci) do
~S"""
""" <>
contenttypes_sheet_entries(wci.sheet_info) <>
~S"""
"""
end
###
### xl/worksheet/sheet*.xml
###
defp split_into_content_style([h | t], wci) do
cellstyle = CellStyle.from_props(t)
{
h,
CellStyleDB.get_id(wci.cellstyledb, cellstyle),
cellstyle
}
end
defp split_into_content_style(cell, _wci), do: {cell, 0, nil}
defp get_content_type_value(content, wci) do
case content do
{:excelts, num} ->
{"n", to_string(num)}
{:formula, x} ->
{:formula, x}
{:formula, x, opts} when is_list(opts) ->
{:formula, x, opts}
x when is_number(x) ->
{"n", to_string(x)}
x when is_binary(x) ->
id = StringDB.get_id(wci.stringdb, x)
if id == -1 do
{:empty, :empty}
else
{"s", to_string(id)}
end
x when is_boolean(x) ->
{"b",
if x do
"1"
else
"0"
end}
:empty ->
{:empty, :empty}
true ->
:error
end
end
# TODO i know now about string interpolation, i should probably clean this up. ;)
defp xl_sheet_cols(row, rowidx, wci) do
{updated_row, _id} =
row
|> List.foldl({"", 1}, fn cell, {acc, colidx} ->
{content, styleID, cellstyle} = split_into_content_style(cell, wci)
if is_nil(content) do
{acc, colidx + 1}
else
content =
if CellStyle.is_date?(cellstyle) do
U.to_excel_datetime(content)
else
content
end
cv = get_content_type_value(content, wci)
{content_type, content_value, content_opts} =
case cv do
{t, v} ->
{t, v, []}
{t, v, opts} ->
{t, v, opts}
:error ->
raise %ArgumentError{
message:
"Invalid column content at " <>
U.to_excel_coords(rowidx, colidx) <> ": " <> inspect(content)
}
end
cell_xml =
case content_type do
:formula ->
value =
if not is_nil(content_opts[:value]),
do: "#{content_opts[:value]}",
else: ""
"""
#{content_value}
#{value}
"""
:empty ->
"""
"""
type ->
"""
#{content_value}
"""
end
{acc <> cell_xml, colidx + 1}
end
end)
updated_row
end
defp make_data_validations([]) do
""
end
defp make_data_validations(data_validations) do
"""
#{Enum.map(data_validations, &make_data_validation/1)}
"""
end
defp make_data_validation({start_cell, end_cell, values}) do
joined_values =
values
|> Enum.join(",")
|> String.codepoints()
|> Enum.chunk_every(255)
|> Enum.join(""&"")
"""
"#{joined_values}"
"""
end
defp xl_merge_cells([]) do
""
end
defp xl_merge_cells(merge_cells) do
"""
#{
Enum.map(merge_cells, fn {fromCell, toCell} ->
""
end)
}
"""
end
defp xl_sheet_rows(data, row_heights, grouping_info, wci) do
rows =
Enum.zip(data, 1..length(data))
|> Enum.map_join(fn {row, rowidx} ->
"""
#{xl_sheet_cols(row, rowidx, wci)}
"""
end)
if (length(data) + 1) in grouping_info.collapsed_idxs do
rows <>
"""
"""
else
rows
end
end
defp get_row_height_attr(row_heights, rowidx) do
row_height = Map.get(row_heights, rowidx)
if row_height do
"customHeight=\"1\" ht=\"#{row_height}\""
else
""
end
end
defp get_row_grouping_attr(gr_info, rowidx) do
outline_level = Map.get(gr_info.outline_lvs, rowidx)
if(outline_level, do: " outlineLevel=\"#{outline_level}\"", else: "") <>
if(rowidx in gr_info.hidden_idxs, do: " hidden=\"1\"", else: "") <>
if rowidx in gr_info.collapsed_idxs, do: " collapsed=\"1\"", else: ""
end
@typep grouping_info :: %{
outline_lvs: %{optional(idx :: pos_integer) => lv :: pos_integer},
hidden_idxs: MapSet.t(pos_integer),
collapsed_idxs: MapSet.t(pos_integer)
}
@spec get_grouping_info([Sheet.rowcol_group()]) :: grouping_info
defp get_grouping_info(groups) do
ranges =
Enum.map(groups, fn
{%Range{} = range, _opts} -> range
%Range{} = range -> range
end)
collapsed_ranges =
groups
|> Enum.filter(fn
{%Range{} = _range, opts} -> opts[:collapsed]
%Range{} = _range -> false
end)
|> Enum.map(fn {range, _opts} -> range end)
# see ECMA Office Open XML Part1, 18.3.1.73 Row -> attributes -> collapsed for examples
%{
outline_lvs:
ranges
|> Stream.concat()
|> Enum.group_by(& &1)
|> Map.new(fn {k, v} -> {k, length(v)} end),
hidden_idxs: collapsed_ranges |> Stream.concat() |> MapSet.new(),
collapsed_idxs: collapsed_ranges |> Enum.map(&(&1.last + 1)) |> MapSet.new()
}
end
defp make_col({k, width, outline_level, hidden, collapsed}) do
width_attr = if width, do: " width=\"#{width}\" customWidth=\"1\"", else: ""
hidden_attr = if hidden, do: " hidden=\"1\"", else: ""
outline_level_attr = if outline_level, do: " outlineLevel=\"#{outline_level}\"", else: ""
collapsed_attr = if collapsed, do: " collapsed=\"1\"", else: ""
'
'
end
defp make_cols(sheet) do
grouping_info = get_grouping_info(sheet.group_cols)
col_indices =
Stream.concat([
Map.keys(sheet.col_widths),
Map.keys(grouping_info.outline_lvs),
grouping_info.hidden_idxs,
grouping_info.collapsed_idxs
])
|> Enum.sort()
|> Enum.dedup()
unless Enum.empty?(col_indices) do
cols =
col_indices
|> Stream.map(
&{
&1,
Map.get(sheet.col_widths, &1),
Map.get(grouping_info.outline_lvs, &1),
&1 in grouping_info.hidden_idxs,
&1 in grouping_info.collapsed_idxs
}
)
|> Enum.map_join(&make_col/1)
"#{cols}"
else
""
end
end
defp make_max_outline_level_row(row_outline_levels) do
unless row_outline_levels === %{} do
max_outline_level_row =
Map.values(row_outline_levels)
|> Enum.max()
" outlineLevelRow=\"#{max_outline_level_row}\""
else
""
end
end
@spec make_sheet(Sheet.t(), WorkbookCompInfo.t()) :: String.t()
@doc ~S"""
Returns the XML content for single sheet.
"""
def make_sheet(sheet, wci) do
grouping_info = get_grouping_info(sheet.group_rows)
~S"""
make_sheet_show_grid(sheet) <>
"""
>
""" <>
make_sheetview(sheet) <>
"""
make_max_outline_level_row(grouping_info.outline_lvs) <>
"""
/>
""" <>
make_cols(sheet) <>
"""
""" <>
xl_sheet_rows(sheet.rows, sheet.row_heights, grouping_info, wci) <>
~S"""
""" <>
xl_merge_cells(sheet.merge_cells) <>
make_data_validations(sheet.data_validations) <>
"""
"""
end
defp make_sheet_show_grid(sheet) do
show_grid_lines_xml =
case sheet.show_grid_lines do
false -> "showGridLines=\"0\" "
_ -> ""
end
show_grid_lines_xml
end
defp make_sheetview(sheet) do
# according to spec:
# * when only horizontal split is applied we need to use bottomLeft
# * when only vertical split is applied we need to use topRight
# * and when both splits is applied, we can use bottomRight
pane =
case sheet.pane_freeze do
{_row_idx, 0} ->
"bottomLeft"
{0, _col_idx} ->
"topRight"
{col_idx, row_idx} when col_idx > 0 and row_idx > 0 ->
"bottomRight"
_any ->
nil
end
{selection_pane_attr, panel_xml} =
case sheet.pane_freeze do
{row_idx, col_idx} when col_idx > 0 or row_idx > 0 ->
top_left_cell = U.to_excel_coords(row_idx + 1, col_idx + 1)
{"pane=\"#{pane}\"",
""}
_any ->
{"", ""}
end
panel_xml <> " selection_pane_attr <> " activeCell=\"A1\" sqref=\"A1\" />"
end
###
### xl/sharedStrings.xml
###
@spec make_xl_shared_strings(list({non_neg_integer, String.t()})) :: String.t()
def make_xl_shared_strings(stringlist) do
len = length(stringlist)
"""
""" <>
Enum.map_join(stringlist, fn {_, value} ->
# the only two characters that *must* be replaced for safe XML encoding are & and <:
"#{minimal_xml_text_escape(value)}"
end) <> ""
end
###
### xl/styles.xml
###
@spec make_font_list(list(Font.t())) :: String.t()
defp make_font_list(ordered_font_list) do
Enum.map_join(ordered_font_list, "\n", &Font.get_stylexml_entry(&1))
end
@spec make_fill_list(list(Fill.t())) :: String.t()
defp make_fill_list(ordered_fill_list) do
Enum.map_join(ordered_fill_list, "\n", &Fill.get_stylexml_entry(&1))
end
# Turns a CellStyle struct into the styles.xml representation.
# TODO: This could be moved into the CellStyle struct.
@spec style_to_xml_entry(CellStyle.t(), WorkbookCompInfo.t()) :: String.t()
defp style_to_xml_entry(style, wci) do
fontid =
if is_nil(style.font),
do: 0,
else: FontDB.get_id(wci.fontdb, style.font)
fillid =
if is_nil(style.fill),
do: 0,
else: FillDB.get_id(wci.filldb, style.fill)
numfmtid =
if is_nil(style.numfmt),
do: 0,
else: NumFmtDB.get_id(wci.numfmtdb, style.numfmt)
borderid =
if is_nil(style.border),
do: 0,
else: BorderStyleDB.get_id(wci.borderstyledb, style.border)
{apply_alignment, wrap_text_tag} =
case style.font do
nil ->
{"", ""}
font ->
case make_style_alignment(font) do
"" ->
{"", ""}
alignment ->
{"applyAlignment=\"1\"", alignment}
end
end
"""
#{wrap_text_tag}
"""
end
@spec wrap_text(String.t(), Font.t()) :: String.t()
defp wrap_text(attrs, %Font{wrap_text: true}), do: attrs <> "wrapText=\"1\" "
defp wrap_text(attrs, _), do: attrs
@spec horizontal_alignment(String.t(), Font.t()) :: String.t()
defp horizontal_alignment(attrs, %Font{align_horizontal: nil}), do: attrs
defp horizontal_alignment(attrs, %Font{align_horizontal: alignment}) do
if alignment in [:center, :fill, :general, :justify, :left, :right] do
attrs <> "horizontal=\"#{Atom.to_string(alignment)}\" "
else
raise %ArgumentError{
message:
"Given horizontal alignment not supported. Only :center, :fill, :general, :justify, :left, :right are available."
}
end
end
@spec vertical_alignment(String.t(), Font.t()) :: String.t()
defp vertical_alignment(attrs, %Font{align_vertical: nil}), do: attrs
defp vertical_alignment(attrs, %Font{align_vertical: alignment}) do
if alignment in [:center, :top, :bottom] do
attrs <> "vertical=\"#{Atom.to_string(alignment)}\" "
else
raise %ArgumentError{
message:
"Given vertical alignment not supported. Only :center, :top, :bottom are available."
}
end
end
# Creates an alignment xml tag from font style.
@spec make_style_alignment(Font.t()) :: String.t()
defp make_style_alignment(font) do
attrs =
""
|> wrap_text(font)
|> horizontal_alignment(font)
|> vertical_alignment(font)
case attrs do
"" ->
nil
^attrs ->
""
end
end
# Returns the inner content of the block.
@spec make_cellxfs(list(CellStyle.t()), WorkbookCompInfo.t()) :: String.t()
defp make_cellxfs(ordered_style_list, wci) do
Enum.map_join(ordered_style_list, "\n", &style_to_xml_entry(&1, wci))
end
alias Elixlsx.Style.NumFmt
defp make_numfmts_inner(id_numfmt_tuples) do
Enum.map_join(id_numfmt_tuples, "\n", fn {id, numfmt} ->
NumFmt.get_stylexml_entry(numfmt, id)
end)
end
defp make_numfmts(id_numfmt_tuples) do
case length(id_numfmt_tuples) do
0 -> ""
n -> "#{make_numfmts_inner(id_numfmt_tuples)}"
end
end
defp make_borders(borders_list) do
Enum.map_join(borders_list, "\n", &BorderStyle.get_border_style_entry(&1))
end
@spec make_xl_styles(WorkbookCompInfo.t()) :: String.t()
@doc ~S"""
Get the content of the `styles.xml` file.
The WorkbookCompInfo struct must be computed before calling this,
(especially CellStyleDB.register_all)
"""
def make_xl_styles(wci) do
font_list = FontDB.id_sorted_fonts(wci.fontdb)
fill_list = FillDB.id_sorted_fills(wci.filldb)
cell_xfs = CellStyleDB.id_sorted_styles(wci.cellstyledb)
numfmts_list = NumFmtDB.custom_numfmt_id_tuples(wci.numfmtdb)
borders_list = BorderStyleDB.id_sorted_borders(wci.borderstyledb)
"""
#{make_numfmts(numfmts_list)}
#{make_font_list(font_list)}
#{make_fill_list(fill_list)}
#{make_borders(borders_list)}
#{make_cellxfs(cell_xfs, wci)}
"""
end
###
### _rels/.rels
###
@rels_dotrels ~S"""
"""
def rels_dotrels, do: @rels_dotrels
####
#### xl/workbook.xml
####
@spec workbook_sheet_entries(nonempty_list(Sheet.t()), nonempty_list(SheetCompInfo.t())) ::
String.t()
defp workbook_sheet_entries(sheet_infos, sheet_comp_infos) do
Enum.zip(sheet_infos, sheet_comp_infos)
|> Enum.map_join(&make_xl_workbook_xml_sheet_entry/1)
end
@doc ~S"""
Return the data for /xl/workbook.xml
"""
def make_workbook_xml(data, sci) do
~S"""
""" <>
workbook_sheet_entries(data.sheets, sci) <>
~S"""
"""
end
end