diff --git a/.gitignore b/.gitignore index 2897c48..f9233c4 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ elixlsx-*.tar # Misc. *.swp + +# Elixir LS cache +/.elixir_ls/ \ No newline at end of file diff --git a/example.exs b/example.exs index f96313f..e13e694 100755 --- a/example.exs +++ b/example.exs @@ -4,7 +4,8 @@ require Elixlsx alias Elixlsx.{Sheet, Workbook} -sheet1 = Sheet.with_name("First") +sheet1 = + Sheet.with_name("First") # Set cell B2 to the string "Hi". :) |> Sheet.set_cell("B2", "Hi") # Optionally, set font properties: @@ -48,11 +49,15 @@ sheet1 = Sheet.with_name("First") |> Sheet.set_cell("A3", "cow") |> Sheet.add_data_validations("A1", "A10", ["dog", "cat", "cow"]) # within same sheet - |> Sheet.add_data_validations("A1", "A10", "=$A$2:$A$16") + |> Sheet.add_data_validations("B1", "B10", "=$B$2:$B$16") # reference to other sheet "=#{sheet.name}!$A$2:$A$16" - |> Sheet.add_data_validations("A1", "A10", "=sheet2!$A$2:$A$16") + |> Sheet.add_data_validations("C1", "C10", "=Third!$A$2:$A$16") -workbook = %Workbook{sheets: [sheet1]} +workbook = %Workbook{ + sheets: [sheet1], + font: "Arial", + font_size: 12 +} # it is also possible to add a custom "created" date to workbook, otherwise, # the current date is used. @@ -65,7 +70,7 @@ sheet2 = name: "Third", rows: [[1, 2, 3, 4, 5], [1, 2], ["increased row height"], ["hello", "world"]] } - |> Sheet.set_row_height(3, 40) + |> Sheet.set_row_height(2, 75) workbook = Workbook.append_sheet(workbook, sheet2) @@ -136,7 +141,7 @@ sheet5 = sheet6 = %Sheet{ name: "Row and Column Groups", - rows: 1..100 |> Enum.chunk(10), + rows: 1..100 |> Enum.chunk_every(10), # collapse and hide rows 2 to 3 group_rows: [{2..3, collapsed: true}, 6..7], # nest @@ -145,7 +150,55 @@ sheet6 = # nest further |> Sheet.group_cols("C", "D") +# Images +sheet7 = %Sheet{ + name: "Images", + rows: List.duplicate(["A", "B", "C", "D", "E"], 5) +} + +sheet7 = + sheet7 + |> Sheet.set_col_width("A", 10) + |> Sheet.set_col_width("B", 10) + |> Sheet.set_col_width("C", 10) + |> Sheet.set_col_width("D", 10) + |> Sheet.set_col_width("E", 10) + |> Sheet.set_row_height(1, 75) + |> Sheet.set_row_height(2, 75) + |> Sheet.set_row_height(3, 75) + |> Sheet.set_row_height(4, 75) + |> Sheet.set_row_height(5, 75) + |> Sheet.insert_image(1, 1, "ladybug-3475779_640.jpg", width: 50, height: 50, align_x: :right, char: 9) + |> Sheet.insert_image(2, 2, "ladybug-3475779_640.jpg", width: 100, height: 100) + |> Sheet.insert_image(3, 0, {"ladybug-3475779_640.jpg", File.read!("ladybug-3475779_640.jpg")}, width: 150, height: 150) + +sheet8 = %Sheet{ + name: "Images 2", + rows: List.duplicate(["A", "B", "C", "D", "E"], 5) +} + +sheet8 = + sheet8 + |> Sheet.set_col_width("A", 10) + |> Sheet.set_col_width("B", 10) + |> Sheet.set_col_width("C", 10) + |> Sheet.set_col_width("D", 10) + |> Sheet.set_col_width("E", 10) + |> Sheet.set_row_height(1, 75) + |> Sheet.set_row_height(2, 75) + |> Sheet.set_row_height(3, 75) + |> Sheet.set_row_height(4, 75) + |> Sheet.set_row_height(5, 75) + |> Sheet.insert_image(0, 0, "ladybug-3475779_640.jpg", + width: 100, + height: 100, + x_offset: 50, + y_offset: 50 + ) + Workbook.append_sheet(workbook, sheet4) |> Workbook.append_sheet(sheet5) |> Workbook.append_sheet(sheet6) +|> Workbook.append_sheet(sheet7) +|> Workbook.append_sheet(sheet8) |> Elixlsx.write_to("example.xlsx") diff --git a/ladybug-3475779_640.jpg b/ladybug-3475779_640.jpg new file mode 100644 index 0000000..bcbacc4 Binary files /dev/null and b/ladybug-3475779_640.jpg differ diff --git a/lib/elixlsx/compiler.ex b/lib/elixlsx/compiler.ex index b88f2ad..d4b699b 100644 --- a/lib/elixlsx/compiler.ex +++ b/lib/elixlsx/compiler.ex @@ -1,7 +1,9 @@ defmodule Elixlsx.Compiler do alias Elixlsx.Compiler.WorkbookCompInfo alias Elixlsx.Compiler.SheetCompInfo + alias Elixlsx.Compiler.DrawingCompInfo alias Elixlsx.Compiler.CellStyleDB + alias Elixlsx.Compiler.DrawingDB alias Elixlsx.Compiler.StringDB alias Elixlsx.XML alias Elixlsx.Sheet @@ -25,6 +27,26 @@ defmodule Elixlsx.Compiler do {Enum.reverse(sheetCompInfos), nextrID} end + @doc """ + Turn a list of %Sheet{} into a list of %DrawingCompInfo{} + and return the next rId after. + """ + @spec make_drawing_info(nonempty_list(Sheet.t()), non_neg_integer) :: + {list(DrawingCompInfo.t()), non_neg_integer} + def make_drawing_info(sheets, init_rId) do + {dcis, _, next_id} = + Enum.reduce(sheets, {[], 1, init_rId}, fn acc, {dci, idx, rId} -> + if acc.images == [] do + {dci, idx, rId} + else + new_dci = DrawingCompInfo.make(idx, rId) + {dci ++ [new_dci], idx + 1, rId + 1} + end + end) + + {dcis, next_id} + end + def compinfo_cell_pass_value(wci, value) do cond do is_binary(value) && XML.valid?(value) -> @@ -59,6 +81,16 @@ defmodule Elixlsx.Compiler do end end + def compinfo_image_pass(wci, image) do + update_in(wci.drawingdb, &DrawingDB.register_image(&1, image)) + end + + def compinfo_from_images(wci, images) do + List.foldl(images, wci, fn image, wci -> + compinfo_image_pass(wci, image) + end) + end + @spec compinfo_from_rows(WorkbookCompInfo.t(), list(list(any()))) :: WorkbookCompInfo.t() def compinfo_from_rows(wci, rows) do List.foldl(rows, wci, fn cols, wci -> @@ -71,16 +103,20 @@ defmodule Elixlsx.Compiler do @spec compinfo_from_sheets(WorkbookCompInfo.t(), list(Sheet.t())) :: WorkbookCompInfo.t() def compinfo_from_sheets(wci, sheets) do List.foldl(sheets, wci, fn sheet, wci -> - compinfo_from_rows(wci, sheet.rows) + wci + |> compinfo_from_rows(sheet.rows) + |> compinfo_from_images(sheet.images) end) end @first_free_rid 2 def make_workbook_comp_info(workbook) do {sci, next_rId} = make_sheet_info(workbook.sheets, @first_free_rid) + {dci, next_rId} = make_drawing_info(workbook.sheets, next_rId) %WorkbookCompInfo{ sheet_info: sci, + drawing_info: dci, next_free_xl_rid: next_rId } |> compinfo_from_sheets(workbook.sheets) diff --git a/lib/elixlsx/compiler/drawing_comp_info.ex b/lib/elixlsx/compiler/drawing_comp_info.ex new file mode 100644 index 0000000..01db3fa --- /dev/null +++ b/lib/elixlsx/compiler/drawing_comp_info.ex @@ -0,0 +1,27 @@ +defmodule Elixlsx.Compiler.DrawingCompInfo do + alias __MODULE__ + + @moduledoc ~S""" + Compilation info for a Drawing, to be filled + during the actual write process. + """ + + defstruct rId: "", + filename: "drawing1.xml", + drawingId: 0 + + @type t :: %DrawingCompInfo{ + rId: String.t(), + filename: String.t(), + drawingId: non_neg_integer + } + + @spec make(non_neg_integer, non_neg_integer) :: DrawingCompInfo.t() + def make(drawingidx, rId) do + %DrawingCompInfo{ + rId: "rId" <> to_string(rId), + filename: "drawing" <> to_string(drawingidx) <> ".xml", + drawingId: drawingidx + } + end +end diff --git a/lib/elixlsx/compiler/drawing_db.ex b/lib/elixlsx/compiler/drawing_db.ex new file mode 100644 index 0000000..cd5cb0a --- /dev/null +++ b/lib/elixlsx/compiler/drawing_db.ex @@ -0,0 +1,49 @@ +defmodule Elixlsx.Compiler.DrawingDB do + alias __MODULE__ + alias Elixlsx.Image + + @moduledoc ~S""" + Database of drawing elements in the whole document. + + Drawing id values must be unique across the document + regardless of what kind of drawing they are. + """ + + defstruct images: %{}, element_count: 0 + + @type t :: %DrawingDB{ + images: %{Image.t() => pos_integer}, + element_count: non_neg_integer + } + + def register_image(drawingdb, image) do + case Map.fetch(drawingdb.images, image) do + :error -> + %DrawingDB{ + images: Map.put(drawingdb.images, image, drawingdb.element_count + 1), + element_count: drawingdb.element_count + 1 + } + + {:ok, _} -> + drawingdb + end + end + + def get_id(drawingdb, image) do + case Map.fetch(drawingdb.images, image) do + :error -> + raise %ArgumentError{ + message: "Invalid key provided for DrawingDB.get_id: " <> inspect(image) + } + + {:ok, id} -> + id + end + end + + def image_types(db) do + db.images + |> Enum.map(fn {i, _} -> {i.extension, i.type} end) + |> Enum.uniq() + end +end diff --git a/lib/elixlsx/compiler/workbook_comp_info.ex b/lib/elixlsx/compiler/workbook_comp_info.ex index a883a96..4b8bf2d 100644 --- a/lib/elixlsx/compiler/workbook_comp_info.ex +++ b/lib/elixlsx/compiler/workbook_comp_info.ex @@ -6,25 +6,29 @@ defmodule Elixlsx.Compiler.WorkbookCompInfo do required to generate the XML file. It is used as the aggregator when folding over the individual - cells. + cells and images. """ defstruct sheet_info: nil, + drawing_info: nil, stringdb: %Compiler.StringDB{}, fontdb: %Compiler.FontDB{}, filldb: %Compiler.FillDB{}, cellstyledb: %Compiler.CellStyleDB{}, numfmtdb: %Compiler.NumFmtDB{}, borderstyledb: %Compiler.BorderStyleDB{}, + drawingdb: %Compiler.DrawingDB{}, next_free_xl_rid: nil @type t :: %Compiler.WorkbookCompInfo{ sheet_info: [Compiler.SheetCompInfo.t()], + drawing_info: [Compiler.DrawingCompInfo.t()], stringdb: Compiler.StringDB.t(), fontdb: Compiler.FontDB.t(), filldb: Compiler.FillDB.t(), cellstyledb: Compiler.CellStyleDB.t(), numfmtdb: Compiler.NumFmtDB.t(), borderstyledb: Compiler.BorderStyleDB.t(), + drawingdb: Compiler.DrawingDB.t(), next_free_xl_rid: non_neg_integer } end diff --git a/lib/elixlsx/image.ex b/lib/elixlsx/image.ex new file mode 100644 index 0000000..9cb6aa9 --- /dev/null +++ b/lib/elixlsx/image.ex @@ -0,0 +1,77 @@ +defmodule Elixlsx.Image do + alias Elixlsx.Image + + @moduledoc ~S""" + An Image can either by a path to an image, or + a binary {"path_or_unique_id", <>} + + char is the max character width of a font, this is + used when calculating how many pixels are in a column. + You might need to experiment with this value + depending on what font and size you are using. + """ + + defstruct file_path: "", + type: "image/png", + extension: "png", + x: 0, + y: 0, + x_offset: 0, + y_offset: 0, + width: 1, + height: 1, + binary: nil, + align_x: :left, + char: 7 + + @type t :: %Image{ + file_path: String.t() | {String.t(), binary}, + type: String.t(), + extension: String.t(), + x: integer, + y: integer, + x_offset: integer, + y_offset: integer, + width: integer, + height: integer, + binary: binary | nil, + align_x: :left | :right, + char: integer + } + + @doc """ + Create an image struct based on opts + """ + def new(_, _, _, opts \\ []) + + def new(file_path, x, y, opts) when is_binary(file_path) do + new({file_path, nil}, x, y, opts) + end + + def new({file_path, binary}, x, y, opts) do + {ext, type} = image_type(file_path) + + %Image{ + file_path: file_path, + binary: binary, + type: type, + extension: ext, + x: x, + y: y, + x_offset: Keyword.get(opts, :x_offset, 0), + y_offset: Keyword.get(opts, :y_offset, 0), + width: Keyword.get(opts, :width, 1), + height: Keyword.get(opts, :height, 1), + align_x: Keyword.get(opts, :align_x, :left), + char: Keyword.get(opts, :char, 7) + } + end + + defp image_type(file_path) do + case Path.extname(file_path) do + ".jpg" -> {"jpg", "image/jpeg"} + ".jpeg" -> {"jpeg", "image/jpeg"} + ".png" -> {"png", "image/png"} + end + end +end diff --git a/lib/elixlsx/sheet.ex b/lib/elixlsx/sheet.ex index 40cddd2..627bd1d 100644 --- a/lib/elixlsx/sheet.ex +++ b/lib/elixlsx/sheet.ex @@ -1,6 +1,7 @@ defmodule Elixlsx.Sheet do alias __MODULE__ alias Elixlsx.Sheet + alias Elixlsx.Image alias Elixlsx.Util @moduledoc ~S""" @@ -22,6 +23,7 @@ defmodule Elixlsx.Sheet do """ defstruct name: "", rows: [], + images: [], col_widths: %{}, row_heights: %{}, group_cols: [], @@ -34,6 +36,7 @@ defmodule Elixlsx.Sheet do @type t :: %Sheet{ name: String.t(), rows: list(list(any())), + images: list(Image.t()), col_widths: %{pos_integer => number}, row_heights: %{pos_integer => number}, group_cols: list(rowcol_group), @@ -41,7 +44,7 @@ defmodule Elixlsx.Sheet do merge_cells: [{String.t(), String.t()}], pane_freeze: {number, number} | nil, show_grid_lines: boolean(), - data_validations: list({String.t(), String.t(), list(String.t()) | String.t()}) + data_validations: list({String.t(), String.t(), list(String.t())}) } @type rowcol_group :: Range.t() | {Range.t(), opts :: keyword} @@ -119,21 +122,16 @@ defmodule Elixlsx.Sheet do """ def set_at(sheet, rowidx, colidx, content, opts \\ []) when is_number(rowidx) and is_number(colidx) do + sheet = maybe_extend(sheet, rowidx, colidx) + cond do length(sheet.rows) <= rowidx -> # append new rows, call self again with new sheet - n_new_rows = rowidx - length(sheet.rows) - new_rows = 0..n_new_rows |> Enum.map(fn _ -> [] end) - - update_in(sheet.rows, &(&1 ++ new_rows)) + append_rows(sheet, rowidx) |> set_at(rowidx, colidx, content, opts) length(Enum.at(sheet.rows, rowidx)) <= colidx -> - n_new_cols = colidx - length(Enum.at(sheet.rows, rowidx)) - new_cols = 0..n_new_cols |> Enum.map(fn _ -> nil end) - new_row = Enum.at(sheet.rows, rowidx) ++ new_cols - - update_in(sheet.rows, &List.replace_at(&1, rowidx, new_row)) + replace_rows(sheet, rowidx, colidx) |> set_at(rowidx, colidx, content, opts) true -> @@ -145,12 +143,46 @@ defmodule Elixlsx.Sheet do end end - @spec set_col_width(Sheet.t(), String.t(), number) :: Sheet.t() + @spec maybe_extend(Sheet.t(), non_neg_integer, non_neg_integer) :: Sheet.t() + defp maybe_extend(sheet, rowidx, colidx) do + cond do + length(sheet.rows) <= rowidx -> + # append new rows, call self again with new sheet + append_rows(sheet, rowidx) + |> maybe_extend(rowidx, colidx) + + length(Enum.at(sheet.rows, rowidx)) <= colidx -> + replace_rows(sheet, rowidx, colidx) + |> maybe_extend(rowidx, colidx) + + true -> + sheet + end + end + + @spec append_rows(Sheet.t(), non_neg_integer) :: Sheet.t() + defp append_rows(sheet, rowidx) do + n_new_rows = rowidx - length(sheet.rows) + new_rows = 0..n_new_rows |> Enum.map(fn _ -> [] end) + + update_in(sheet.rows, &(&1 ++ new_rows)) + end + + @spec replace_rows(Sheet.t(), non_neg_integer, non_neg_integer) :: Sheet.t() + defp replace_rows(sheet, rowidx, colidx) do + n_new_cols = colidx - length(Enum.at(sheet.rows, rowidx)) + new_cols = 0..n_new_cols |> Enum.map(fn _ -> nil end) + new_row = Enum.at(sheet.rows, rowidx) ++ new_cols + + update_in(sheet.rows, &List.replace_at(&1, rowidx, new_row)) + end + @doc ~S""" Set the column width for a given column. Column is indexed by name ("A", ...) """ + @spec set_col_width(Sheet.t(), String.t(), number) :: Sheet.t() def set_col_width(sheet, column, width) do update_in( sheet.col_widths, @@ -158,12 +190,12 @@ defmodule Elixlsx.Sheet do ) end - @spec set_row_height(Sheet.t(), number, number) :: Sheet.t() @doc ~S""" Set the row height for a given row. Row is indexed starting from 1 """ + @spec set_row_height(Sheet.t(), number, number) :: Sheet.t() def set_row_height(sheet, row_idx, height) do update_in( sheet.row_heights, @@ -222,6 +254,27 @@ defmodule Elixlsx.Sheet do %{sheet | pane_freeze: nil} end + @doc """ + Insert an image at the given positions. + """ + @spec insert_image( + Sheet.t(), + non_neg_integer, + non_neg_integer, + String.t() | {String.t(), binary}, + key: any + ) :: + Sheet.t() + def insert_image(sheet, x, y, imagepath, opts \\ []) do + image = Image.new(imagepath, x, y, opts) + + # Ensure there are enough rows and columns to accomodate the image position + sheet = maybe_extend(sheet, y, x) + + # Add the image to the list of images in this sheet + update_in(sheet.images, &[image | &1]) + end + @spec add_data_validations(Sheet.t(), String.t(), String.t(), list(String.t())) :: Sheet.t() def add_data_validations(sheet, start_cell, end_cell, values) do %{sheet | data_validations: [{start_cell, end_cell, values} | sheet.data_validations]} diff --git a/lib/elixlsx/util.ex b/lib/elixlsx/util.ex index a2a73be..1629b13 100644 --- a/lib/elixlsx/util.ex +++ b/lib/elixlsx/util.ex @@ -1,5 +1,7 @@ defmodule Elixlsx.Util do alias Elixlsx.XML + alias Elixlsx.Image + @col_alphabet Enum.to_list(?A..?Z) @doc ~S""" @@ -257,4 +259,14 @@ defmodule Elixlsx.Util do def app_version_string do String.replace(@version, ~r/(\d+)\.(\d+)\.(\d+)/, "\\1.\\2\\3") end + + @doc """ + Convert width to pixels + """ + @spec width_to_px(number, Image.t()) :: number + def width_to_px(0, _), do: 0 + + def width_to_px(w, image) do + w * image.char + 5 + end end diff --git a/lib/elixlsx/workbook.ex b/lib/elixlsx/workbook.ex index f09d557..b555ea8 100644 --- a/lib/elixlsx/workbook.ex +++ b/lib/elixlsx/workbook.ex @@ -10,11 +10,16 @@ defmodule Elixlsx.Workbook do alias Elixlsx.Sheet alias Elixlsx.Workbook - defstruct sheets: [], datetime: nil + defstruct sheets: [], + datetime: nil, + font: nil, + font_size: nil @type t :: %Workbook{ sheets: nonempty_list(Sheet.t()), - datetime: String.t() | integer | nil + datetime: String.t() | integer | nil, + font: String.t(), + font_size: number } @doc "Append a sheet at the end." diff --git a/lib/elixlsx/writer.ex b/lib/elixlsx/writer.ex index 9b9f16b..a488853 100644 --- a/lib/elixlsx/writer.ex +++ b/lib/elixlsx/writer.ex @@ -2,6 +2,7 @@ defmodule Elixlsx.Writer do alias Elixlsx.Util, as: U alias Elixlsx.XMLTemplates alias Elixlsx.Compiler.StringDB + alias Elixlsx.Compiler.DrawingDB alias Elixlsx.Compiler.WorkbookCompInfo alias Elixlsx.Compiler.SheetCompInfo alias Elixlsx.Workbook @@ -81,9 +82,9 @@ defmodule Elixlsx.Writer do ] end - @spec get_xl_styles_xml(WorkbookCompInfo.t()) :: zip_tuple - def get_xl_styles_xml(wci) do - {'xl/styles.xml', XMLTemplates.make_xl_styles(wci)} + @spec get_xl_styles_xml(Workbook.t(), WorkbookCompInfo.t()) :: zip_tuple + def get_xl_styles_xml(workbook, wci) do + {'xl/styles.xml', XMLTemplates.make_xl_styles(workbook, wci)} end @spec get_xl_workbook_xml(Workbook.t(), [SheetCompInfo.t()]) :: zip_tuple @@ -102,14 +103,102 @@ defmodule Elixlsx.Writer do String.to_charlist("xl/worksheets/#{sci.filename}") end + @spec sheet_full__rels_path(SheetCompInfo.t()) :: list(char) + defp sheet_full__rels_path(sci) do + String.to_charlist("xl/worksheets/_rels/#{sci.filename}.rels") + end + + @spec get_xl_worksheets__rel_dir( + Workbook.t(), + Sheet.t(), + SheetCompInfo.t(), + WorkbookCompInfo.t() + ) :: + list(zip_tuple) + def get_xl_worksheets__rel_dir(w, s, sci, wci) do + if s.images == [] do + [] + else + filename = sheet_full__rels_path(sci) + xml = XMLTemplates.make_xl_worksheet_rel_sheet(w, s, wci) + [{filename, xml}] + end + end + @spec get_xl_worksheets_dir(Workbook.t(), WorkbookCompInfo.t()) :: list(zip_tuple) def get_xl_worksheets_dir(data, wci) do sheets = data.sheets Enum.zip(sheets, wci.sheet_info) - |> Enum.map(fn {s, sci} -> - {sheet_full_path(sci), XMLTemplates.make_sheet(s, wci)} + |> Enum.flat_map(fn {s, sci} -> + [{sheet_full_path(sci), XMLTemplates.make_sheet(s, wci)}] ++ + get_xl_worksheets__rel_dir(data, s, sci, wci) + end) + end + + @spec drawing_full_path(DrawingCompInfo.t()) :: list(char) + defp drawing_full_path(dci) do + String.to_charlist("xl/drawings/#{dci.filename}") + end + + @spec drawing_full__rels_path(DrawingCompInfo.t()) :: list(char) + defp drawing_full__rels_path(dci) do + String.to_charlist("xl/drawings/_rels/#{dci.filename}.rels") + end + + @spec image_full_path(Image.t(), WorkbookCompInfo.t()) :: list(char) + def image_full_path(image, wci) do + id = DrawingDB.get_id(wci.drawingdb, image) + + String.to_charlist("xl/media/image#{id}.#{image.extension}") + end + + @spec read_image(String.t()) :: binary + def read_image(file_path) do + File.read!(file_path) + end + + @spec get_xl_drawings__rel_dir(list(Image.t()), DrawingCompInfo.t(), WorkbookCompInfo.t()) :: + list(zip_tuple) + def get_xl_drawings__rel_dir(images, dci, wci) do + if images == [] do + [] + else + [{drawing_full__rels_path(dci), XMLTemplates.make_xl_drawing_rel_sheet(images, wci)}] + end + end + + @spec get_xl_drawings_dir(Workbook.t(), WorkbookCompInfo.t()) :: list(zip_tuple) + def get_xl_drawings_dir(data, wci) do + ## We have one wci.drawing_info per sheet that has any images + has_images? = fn s -> s.images != [] end + sheets = for sheet <- data.sheets, has_images?.(sheet), do: sheet + + Enum.zip(sheets, wci.drawing_info) + |> Enum.flat_map(fn {s, dci} -> + [{drawing_full_path(dci), XMLTemplates.make_drawing(s, wci)}] ++ + get_xl_drawings__rel_dir(s.images, dci, wci) + end) + end + + @spec get_xl_media_dir(Workbook.t(), WorkbookCompInfo.t()) :: list(zip_tuple) + def get_xl_media_dir(data, wci) do + has_images? = fn s -> s.images != [] end + sheets = for sheet <- data.sheets, has_images?.(sheet), do: sheet + + sheets + |> Enum.flat_map(fn s -> + Enum.map(s.images, fn image -> + case image do + %{binary: nil} -> + {image_full_path(image, wci), read_image(image.file_path)} + + %{binary: binary} -> + {image_full_path(image, wci), binary} + end + end) end) + |> Enum.uniq() end def get_contentTypes_xml(_, wci) do @@ -121,11 +210,12 @@ defmodule Elixlsx.Writer do next_free_xl_rid = wci.next_free_xl_rid [ - get_xl_styles_xml(wci), + get_xl_styles_xml(data, wci), get_xl_sharedStrings_xml(data, wci), get_xl_workbook_xml(data, sheet_comp_infos) ] ++ get_xl_rels_dir(data, sheet_comp_infos, next_free_xl_rid) ++ - get_xl_worksheets_dir(data, wci) + get_xl_worksheets_dir(data, wci) ++ + get_xl_drawings_dir(data, wci) ++ get_xl_media_dir(data, wci) end end diff --git a/lib/elixlsx/xml_templates.ex b/lib/elixlsx/xml_templates.ex index 2d79c7a..edd330b 100644 --- a/lib/elixlsx/xml_templates.ex +++ b/lib/elixlsx/xml_templates.ex @@ -1,4 +1,5 @@ defmodule Elixlsx.XMLTemplates do + alias Elixlsx.Workbook alias Elixlsx.Util, as: U alias Elixlsx.Compiler.CellStyleDB alias Elixlsx.Compiler.StringDB @@ -7,6 +8,7 @@ defmodule Elixlsx.XMLTemplates do alias Elixlsx.Compiler.SheetCompInfo alias Elixlsx.Compiler.NumFmtDB alias Elixlsx.Compiler.BorderStyleDB + alias Elixlsx.Compiler.DrawingDB alias Elixlsx.Compiler.WorkbookCompInfo alias Elixlsx.Style.CellStyle alias Elixlsx.Style.Font @@ -88,11 +90,33 @@ defmodule Elixlsx.XMLTemplates do @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. - "" + """ + + """ + |> clean_xml() + end + + @spec make_xl_worksheet_rel_sheet(Workbook.t(), Sheet.t(), WorkbookCompInfo.t()) :: String.t() + def make_xl_worksheet_rel_sheet(w, sheet, wci) do + has_images? = fn s -> s.images != [] end + sheets = for sheet <- w.sheets, has_images?.(sheet), do: sheet + {_, di} = Enum.zip(sheets, wci.drawing_info) |> Enum.find(fn {s, _} -> s == sheet end) + + """ + + + + + """ + |> clean_xml() end @spec make_xl_rel_sheets(nonempty_list(SheetCompInfo.t())) :: String.t() @@ -124,9 +148,7 @@ defmodule Elixlsx.XMLTemplates do end """ - + """ end @@ -141,22 +163,52 @@ defmodule Elixlsx.XMLTemplates do Enum.map_join(sheet_comp_infos, &contenttypes_sheet_entry/1) end + defp contenttypes_drawing_entry(drawing_comp_info) do + """ + + """ + |> clean_xml() + end + + defp contenttypes_drawing_entries(drawing_comp_infos) do + Enum.map_join(drawing_comp_infos, &contenttypes_drawing_entry/1) + end + + defp contenttypes_drawing_type({extension, type}) do + """ + + """ + |> clean_xml() + end + + defp contenttypes_drawing_types(drawing_db) do + drawing_types = DrawingDB.image_types(drawing_db) + Enum.map_join(drawing_types, &contenttypes_drawing_type/1) + end + def make_contenttypes_xml(wci) do - ~S""" + """ - - - - - - - """ <> - contenttypes_sheet_entries(wci.sheet_info) <> - ~S""" - - - """ + + + + + + + + #{contenttypes_sheet_entries(wci.sheet_info)} + #{contenttypes_drawing_entries(wci.drawing_info)} + #{contenttypes_drawing_types(wci.drawingdb)} + + + """ end ### @@ -300,31 +352,29 @@ defmodule Elixlsx.XMLTemplates do """ end - defp make_data_validation({start_cell, end_cell, values}) when is_bitstring(values) do - """ - - #{values} - - """ - end - defp make_data_validation({start_cell, end_cell, values}) do - joined_values = - values - |> Enum.join(",") - |> String.codepoints() - |> Enum.chunk_every(255) - |> Enum.join(""&"") + case values do + v when is_list(v) -> + joined_values = + values + |> Enum.join(",") + |> String.codepoints() + |> Enum.chunk_every(255) + |> Enum.join(""&"") - """ - - "#{joined_values}" - - """ + """ + + "#{joined_values}" + + """ + + v when is_binary(v) -> + """ + + #{v} + + """ + end end defp xl_merge_cells([]) do @@ -334,11 +384,7 @@ defmodule Elixlsx.XMLTemplates do defp xl_merge_cells(merge_cells) do """ - #{ - Enum.map(merge_cells, fn {fromCell, toCell} -> - "" - end) - } + #{Enum.map(merge_cells, fn {fromCell, toCell} -> "" end)} """ end @@ -348,9 +394,7 @@ defmodule Elixlsx.XMLTemplates do Enum.zip(data, 1..length(data)) |> Enum.map_join(fn {row, rowidx} -> """ - + #{xl_sheet_cols(row, rowidx, wci)} """ @@ -471,6 +515,109 @@ defmodule Elixlsx.XMLTemplates do end end + ### + ### xl/drawings/drawing*.xml + ### + @spec make_drawing(Sheet.t(), WorkbookCompInfo.t()) :: String.t() + def make_drawing(%{images: []}, _wci), do: "" + + def make_drawing(s, wci) do + """ + + + #{Enum.map_join(s.images, "\n", fn i -> make_xl_drawings_one_cell(i, wci, s) end)} + + """ + |> clean_xml() + end + + defp make_xl_drawings_one_cell(image, wci, s) do + drawing_id = to_string(DrawingDB.get_id(wci.drawingdb, image)) + + w = s.col_widths[image.x + 1] || 8.43 + w_px = U.width_to_px(w, image) + + col_off = + case image.align_x do + :left -> + image.x_offset * 9525 + + :right -> + (w_px - image.width - image.x_offset) * 9525 + end + + """ + + + #{image.x} + #{col_off} + #{image.y} + #{image.y_offset * 9525} + + + + + + + + + + + + + + + + + + + + + + + """ + end + + def xl_drawing_rel_sheet_rows(images, wci) do + Enum.map_join(images, "\n", fn image -> + id = DrawingDB.get_id(wci.drawingdb, image) + + """ + + """ + end) + |> clean_xml() + end + + @spec make_xl_drawing_rel_sheet(list(Image.t()), WorkbookCompInfo.t()) :: String.t() + def make_xl_drawing_rel_sheet(images, wci) do + """ + + + #{xl_drawing_rel_sheet_rows(images, wci)} + + """ + |> clean_xml() + end + + @spec make_drawing_ref(List.t()) :: String.t() + defp make_drawing_ref([]), do: "" + defp make_drawing_ref(_drawings), do: "" + @spec make_sheet(Sheet.t(), WorkbookCompInfo.t()) :: String.t() @doc ~S""" Returns the XML content for single sheet. @@ -514,6 +661,9 @@ defmodule Elixlsx.XMLTemplates do make_data_validations(sheet.data_validations) <> """ + """ <> + make_drawing_ref(sheet.images) <> + """ """ end @@ -554,9 +704,7 @@ defmodule Elixlsx.XMLTemplates do top_left_cell = U.to_excel_coords(row_idx + 1, col_idx + 1) {"pane=\"#{pane}\"", - ""} + ""} _any -> {"", ""} @@ -575,9 +723,7 @@ defmodule Elixlsx.XMLTemplates do """ - + """ <> Enum.map_join(stringlist, fn {_, value} -> # the only two characters that *must* be replaced for safe XML encoding are & and <: @@ -724,14 +870,14 @@ defmodule Elixlsx.XMLTemplates do Enum.map_join(borders_list, "\n", &BorderStyle.get_border_style_entry(&1)) end - @spec make_xl_styles(WorkbookCompInfo.t()) :: String.t() + @spec make_xl_styles(Workbook.t(), 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 + def make_xl_styles(workbook, 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) @@ -743,7 +889,7 @@ defmodule Elixlsx.XMLTemplates do #{make_numfmts(numfmts_list)} - + #{workbook_font(workbook)} #{make_font_list(font_list)} @@ -813,4 +959,29 @@ defmodule Elixlsx.XMLTemplates do """ end + + @spec clean_xml(String.t()) :: String.t() + defp clean_xml(str) do + str + |> String.split("\n") + |> Enum.map_join(" ", &String.trim/1) + |> String.replace("\" />", "\"/>", global: true) + |> String.replace("> <", "><", global: true) + |> String.replace("> <", "><", global: true) + end + + defp workbook_font(workbook) do + case workbook do + %{font: font, font_size: size} when is_binary(font) and is_integer(size) -> + """ + + + + + """ + + _ -> + "" + end + end end diff --git a/mix.exs b/mix.exs index 1ec6181..f5725fe 100644 --- a/mix.exs +++ b/mix.exs @@ -24,6 +24,7 @@ defmodule Elixlsx.Mixfile do defp deps do [ + {:floki, "~> 0.34.3", only: [:dev, :test]}, {:excheck, "~> 0.5", only: :test}, {:triq, "~> 1.0", only: :test}, {:credo, "~> 0.5", only: [:dev, :test]}, diff --git a/mix.lock b/mix.lock index 34c8123..1d92283 100644 --- a/mix.lock +++ b/mix.lock @@ -6,6 +6,7 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, "ex_doc": {:hex, :ex_doc, "0.23.0", "a069bc9b0bf8efe323ecde8c0d62afc13d308b1fa3d228b65bca5cf8703a529d", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f5e2c4702468b2fd11b10d39416ddadd2fcdd173ba2a0285ebd92c39827a5a16"}, "excheck": {:hex, :excheck, "0.5.3", "7326a29cc5fdb6900e66dac205a6a70cc994e2fe037d39136817d7dab13cdabf", [:mix], [], "hexpm", "2a27ffeff9d3b2ef45c454efb13990f08bc2578f93fd6d054025da74775ca869"}, + "floki": {:hex, :floki, "0.34.3", "5e2dcaec5d7c228ce5b1d3501502e308b2d79eb655e4191751a1fe491c37feac", [:mix], [], "hexpm", "9577440eea5b97924b4bf3c7ea55f7b8b6dce589f9b28b096cc294a8dc342341"}, "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, diff --git a/test/elixlsx_test.exs b/test/elixlsx_test.exs index 871593d..cadb606 100644 --- a/test/elixlsx_test.exs +++ b/test/elixlsx_test.exs @@ -1,4 +1,5 @@ defmodule ElixlsxTest do + alias Elixlsx.Sheet require Record Record.defrecord( @@ -12,7 +13,6 @@ defmodule ElixlsxTest do use ExUnit.Case doctest Elixlsx doctest Elixlsx.Sheet - doctest Elixlsx.Util, import: true doctest Elixlsx.XMLTemplates doctest Elixlsx.Color doctest Elixlsx.Style.Border @@ -126,4 +126,355 @@ defmodule ElixlsxTest do end end) end + + test "docProps/app" do + workbook = %Workbook{sheets: [Sheet.with_name("foo")]} + wci = Elixlsx.Compiler.make_workbook_comp_info(workbook) + res = Elixlsx.Writer.create_files(workbook, wci) + doc = get_doc(res, 'docProps/app.xml') + + expected = """ + + + 0 + Elixlsx + 0.52 + + """ + + assert doc == Floki.parse_document!(expected) + end + + test "docProps/core" do + workbook = %Workbook{sheets: [Sheet.with_name("foo")]} + wci = Elixlsx.Compiler.make_workbook_comp_info(workbook) + res = Elixlsx.Writer.create_files(workbook, wci) + doc = get_doc(res, 'docProps/core.xml') + + assert [ + {:pi, "xml", [{"version", "1.0"}, {"encoding", "UTF-8"}, {"standalone", "yes"}]}, + { + "cp:coreproperties", + [ + {"xmlns:cp", + "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"}, + {"xmlns:dc", "http://purl.org/dc/elements/1.1/"}, + {"xmlns:dcterms", "http://purl.org/dc/terms/"}, + {"xmlns:dcmitype", "http://purl.org/dc/dcmitype/"}, + {"xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"} + ], + [ + {"dcterms:created", [{"xsi:type", "dcterms:W3CDTF"}], [_]}, + {"dc:language", [], ["en-US"]}, + {"dcterms:modified", [{"xsi:type", "dcterms:W3CDTF"}], [_]}, + {"cp:revision", [], ["1"]} + ] + } + ] = doc + end + + test "_rels/.rels" do + workbook = %Workbook{sheets: [Sheet.with_name("foo")]} + wci = Elixlsx.Compiler.make_workbook_comp_info(workbook) + res = Elixlsx.Writer.create_files(workbook, wci) + doc = get_doc(res, '_rels/.rels') + + expected = """ + + + + + + + """ + + assert doc == Floki.parse_document!(expected) + end + + test "xl/styles.xml" do + workbook = %Workbook{ + sheets: [Sheet.with_name("foo")], + font: "Calibri Light", + font_size: 16 + } + + wci = Elixlsx.Compiler.make_workbook_comp_info(workbook) + res = Elixlsx.Writer.create_files(workbook, wci) + doc = get_doc(res, 'xl/styles.xml') + + expected = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + + assert doc == Floki.parse_document!(expected) + end + + test "xl/sharedStrings.xml" do + workbook = %Workbook{sheets: [Sheet.with_name("foo")]} + wci = Elixlsx.Compiler.make_workbook_comp_info(workbook) + res = Elixlsx.Writer.create_files(workbook, wci) + doc = get_doc(res, 'xl/sharedStrings.xml') + + expected = """ + + + + """ + + assert doc == Floki.parse_document!(expected) + end + + test "xl/workbook.xml" do + workbook = %Workbook{sheets: [Sheet.with_name("foo")]} + wci = Elixlsx.Compiler.make_workbook_comp_info(workbook) + res = Elixlsx.Writer.create_files(workbook, wci) + doc = get_doc(res, 'xl/workbook.xml') + + expected = """ + + + + + + + + + + + + """ + + assert doc == Floki.parse_document!(expected) + end + + test "xl/_rels/workbook.xml.rels" do + workbook = %Workbook{sheets: [Sheet.with_name("foo")]} + wci = Elixlsx.Compiler.make_workbook_comp_info(workbook) + res = Elixlsx.Writer.create_files(workbook, wci) + doc = get_doc(res, 'xl/_rels/workbook.xml.rels') + + expected = """ + + + + + + + """ + + assert doc == Floki.parse_document!(expected) + end + + test "xl/worksheets/sheet1.xml" do + workbook = %Workbook{sheets: [Sheet.with_name("foo")]} + wci = Elixlsx.Compiler.make_workbook_comp_info(workbook) + res = Elixlsx.Writer.create_files(workbook, wci) + doc = get_doc(res, 'xl/worksheets/sheet1.xml') + + expected = """ + + + + + + + + + + + + + + + + """ + + assert doc == Floki.parse_document!(expected) + end + + test "[Content_Types].xml" do + workbook = %Workbook{sheets: [Sheet.with_name("foo")]} + wci = Elixlsx.Compiler.make_workbook_comp_info(workbook) + res = Elixlsx.Writer.create_files(workbook, wci) + doc = get_doc(res, '[Content_Types].xml') + + expected = """ + + + + + + + + + + + + + """ + + assert doc == Floki.parse_document!(expected) + end + + test "sheet rels" do + workbook = %Workbook{ + sheets: [ + Sheet.with_name("foo") + |> Sheet.insert_image(0, 0, "ladybug-3475779_640.jpg"), + Sheet.with_name("bar") + |> Sheet.insert_image(0, 0, "ladybug-3475779_640.jpg") + ] + } + + wci = Elixlsx.Compiler.make_workbook_comp_info(workbook) + res = Elixlsx.Writer.create_files(workbook, wci) + + doc = get_doc(res, 'xl/worksheets/sheet1.xml') + assert Floki.find(doc, "drawing") == [{"drawing", [{"r:id", "rId1"}], []}] + + doc = get_doc(res, 'xl/worksheets/sheet2.xml') + assert Floki.find(doc, "drawing") == [{"drawing", [{"r:id", "rId1"}], []}] + + doc = get_doc(res, 'xl/worksheets/_rels/sheet1.xml.rels') + rel = Floki.find(doc, "relationship") + assert Floki.attribute(rel, "id") == ["rId1"] + assert Floki.attribute(rel, "target") == ["../drawings/drawing1.xml"] + + # target should be drawing 2, but the id + # should be the same as sheet 1 + doc = get_doc(res, 'xl/worksheets/_rels/sheet2.xml.rels') + rel = Floki.find(doc, "relationship") + assert Floki.attribute(rel, "id") == ["rId1"] + assert Floki.attribute(rel, "target") == ["../drawings/drawing2.xml"] + end + + test "drawing single cell" do + workbook = %Workbook{ + sheets: [ + %Sheet{name: "single"} + |> Sheet.set_col_width("A", 12) + |> Sheet.set_row_height(1, 75) + |> Sheet.insert_image(0, 0, "ladybug-3475779_640.jpg", + width: 100, + height: 100, + char: 10, + emu: 10 + ) + ] + } + + wci = Elixlsx.Compiler.make_workbook_comp_info(workbook) + res = Elixlsx.Writer.create_files(workbook, wci) + + doc = get_doc(res, 'xl/drawings/drawing1.xml') + + xml = """ + + + + + 0 + 0 + 0 + 0 + + + + + + + + + + + + + + + + + + + + + + + + """ + + assert doc == Floki.parse_document!(xml) + end + + defp get_doc(res, name) do + {_, sheet} = Enum.find(res, fn {a, _} -> a == name end) + Floki.parse_fragment!(sheet) + end end diff --git a/test/util_test.exs b/test/util_test.exs index e4f3e56..aef1dcc 100644 --- a/test/util_test.exs +++ b/test/util_test.exs @@ -1,9 +1,12 @@ defmodule ExCheck.UtilTest do - use ExUnit.Case, async: false + use ExUnit.Case use ExCheck + alias Elixlsx.Image alias Elixlsx.Util + doctest Util, import: true + property :enc_dec do for_all x in such_that(x in int() when x >= 0) do implies x >= 0 do @@ -11,4 +14,9 @@ defmodule ExCheck.UtilTest do end end end + + test "width_to_px" do + assert Util.width_to_px(1, %Image{}) == 12 + assert Util.width_to_px(1, %Image{char: 10}) == 15 + end end