diff --git a/example.exs b/example.exs index fcac7ce..1c4d4a4 100755 --- a/example.exs +++ b/example.exs @@ -98,6 +98,18 @@ sheet4 sheet5 = %Sheet{name: "No gridlines shown", show_grid_lines: false} |> Sheet.set_at(0, 0, "Just this cell") +sheet6 = %Sheet{ + name: "Images", + rows: List.duplicate(["A", "B", "C", "D", "E"], 5) +} + +sheet6 = + sheet6 + |> Sheet.insert_image(0, 5, "ladybug-3475779_640.jpg") + |> Sheet.set_row_height(1, 40) + |> Sheet.insert_image(6, 6, "ladybug-3475779_640.jpg") + Workbook.append_sheet(workbook, sheet4) |> Workbook.append_sheet(sheet5) +|> Workbook.append_sheet(sheet6) |> 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 397059b..d030b0b 100644 --- a/lib/elixlsx/compiler.ex +++ b/lib/elixlsx/compiler.ex @@ -1,8 +1,10 @@ defmodule Elixlsx.Compiler do alias Elixlsx.Compiler.WorkbookCompInfo alias Elixlsx.Compiler.SheetCompInfo + alias Elixlsx.Compiler.DrawingCompInfo alias Elixlsx.Compiler.CellStyleDB alias Elixlsx.Compiler.StringDB + alias Elixlsx.Compiler.DrawingDB alias Elixlsx.Sheet @doc ~S""" @@ -23,7 +25,29 @@ defmodule Elixlsx.Compiler do {Enum.reverse(sheetCompInfos), nextrID} end - def compinfo_cell_pass_value wci, value do + @doc ~S""" + Accepts a list of Sheets and the next free relationship ID. + Returns a tuple containing a list of DrawingCompInfo's based on the images + within the sheets and the next free relationship ID. + """ + @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 + # fold helper. aggregator holds {list(DrawingCompInfo), drawingidx, rId}. + add_sheet = fn sheet, {dci, idx, rId} -> + if sheet.images == [] do + {dci, idx, rId} + else + {[DrawingCompInfo.make(idx, rId) | dci], idx + 1, rId + 1} + end + end + + # TODO probably better to use a zip [1..] |> map instead of fold[l|r]/reverse + {sheetCompInfos, _, nextrID} = List.foldl(sheets, {[], 1, init_rId}, add_sheet) + {Enum.reverse(sheetCompInfos), nextrID} + end + + def compinfo_cell_pass_value wci, value do cond do is_binary(value) && String.valid?(value) -> update_in wci.stringdb, &StringDB.register_string(&1, value) @@ -62,20 +86,37 @@ defmodule Elixlsx.Compiler do end end - @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 - 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_sheets(WorkbookCompInfo.t(), list(Sheet.t())) :: WorkbookCompInfo.t() + def compinfo_from_sheets(wci, sheets) do + List.foldl(sheets, wci, fn sheet, wci -> + 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, - next_free_xl_rid: next_rId, + drawing_info: dci, + next_free_xl_rid: next_rId } |> compinfo_from_sheets(workbook.sheets) |> CellStyleDB.register_all diff --git a/lib/elixlsx/compiler/drawing_comp_info.ex b/lib/elixlsx/compiler/drawing_comp_info.ex new file mode 100644 index 0000000..78082e8 --- /dev/null +++ b/lib/elixlsx/compiler/drawing_comp_info.ex @@ -0,0 +1,24 @@ +defmodule Elixlsx.Compiler.DrawingCompInfo do + alias Elixlsx.Compiler.DrawingCompInfo + + @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..390201a --- /dev/null +++ b/lib/elixlsx/compiler/drawing_db.ex @@ -0,0 +1,58 @@ +defmodule Elixlsx.Compiler.DrawingDB do + alias __MODULE__ + alias Elixlsx.Compiler.DBUtil + alias Elixlsx.Image + + @doc """ + 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. + + So far this only supports images, but could be extended to include other + kinds of drawing. + + An alternative would be to add a Drawing module and have "subclasses" for + different drawing types + """ + + 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 id_sorted_drawings(db), do: DBUtil.id_sorted_values(db.images) + + def image_types(db) do + db.images + |> Enum.reduce(%MapSet{}, fn {i, _}, acc -> + MapSet.put(acc, {i.extension, i.type}) + end) + |> Enum.to_list() + end +end diff --git a/lib/elixlsx/compiler/workbook_comp_info.ex b/lib/elixlsx/compiler/workbook_comp_info.ex index 3e7af82..b05ed4b 100644 --- a/lib/elixlsx/compiler/workbook_comp_info.ex +++ b/lib/elixlsx/compiler/workbook_comp_info.ex @@ -5,25 +5,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..e87b179 --- /dev/null +++ b/lib/elixlsx/image.ex @@ -0,0 +1,65 @@ +defmodule Elixlsx.Image do + alias Elixlsx.Image + + @moduledoc ~S""" + Structure for excel drawing files. + + - x_offset: integer + - y_offset: integer + - x_scale: float + - y_scale: float + - positioning: atom (:absolute, :oneCell, :twoCell) + """ + + defstruct file_path: "", + type: "image/png", + extension: "png", + rowidx: 0, + colidx: 0, + x_offset: 0, + y_offset: 0, + x_scale: 1, + y_scale: 1, + positioning: :twoCell + + @type t :: %Image{ + file_path: String.t(), + type: String.t(), + extension: String.t(), + rowidx: integer, + colidx: integer, + x_offset: integer, + y_offset: integer, + x_scale: float, + y_scale: float, + positioning: atom + } + + @doc """ + Create an image struct based on opts + """ + def new(file_path, rowidx, colidx, opts \\ []) do + {ext, type} = image_type(file_path) + + %Image{ + file_path: file_path, + type: type, + extension: ext, + rowidx: rowidx, + colidx: colidx, + x_offset: Keyword.get(opts, :x_offset, 0), + y_offset: Keyword.get(opts, :y_offset, 0), + x_scale: Keyword.get(opts, :x_scale, 1), + y_scale: Keyword.get(opts, :y_scale, 1), + positioning: Keyword.get(opts, :positioning, :twoCell) + } + 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 f0b265b..31fcd5f 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""" Describes a single sheet with a given name. The name can be up to 31 characters long. @@ -16,10 +17,11 @@ defmodule Elixlsx.Sheet do The property list describes formatting options for that cell. See Font.from_props/1 for a list of options. """ - defstruct name: "", rows: [], col_widths: %{}, row_heights: %{}, merge_cells: [], pane_freeze: nil, show_grid_lines: true + defstruct name: "", rows: [], images: [], col_widths: %{}, row_heights: %{}, merge_cells: [], pane_freeze: nil, show_grid_lines: true @type t :: %Sheet { name: String.t, rows: list(list(any())), + images: list(Image.t()), col_widths: %{pos_integer => number}, row_heights: %{pos_integer => number}, merge_cells: [], @@ -96,28 +98,36 @@ 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) + + update_in(sheet.rows, fn rows -> + List.update_at(rows, rowidx, fn cols -> + List.replace_at(cols, colidx, [content | opts]) + end) + end) + end + + @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 n_new_rows = rowidx - length(sheet.rows) new_rows = 0..n_new_rows |> Enum.map(fn _ -> [] end) - update_in(sheet.rows, &(&1 ++ new_rows)) |> - set_at(rowidx, colidx, content, opts) + update_in(sheet.rows, &(&1 ++ new_rows)) + |> maybe_extend(rowidx, colidx) 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)) |> - set_at(rowidx, colidx, content, opts) + update_in(sheet.rows, &List.replace_at(&1, rowidx, new_row)) + |> maybe_extend(rowidx, colidx) + true -> - update_in sheet.rows, fn rows -> - List.update_at rows, rowidx, fn cols -> - List.replace_at cols, colidx, [content | opts] - end - end + sheet end end @@ -156,4 +166,19 @@ defmodule Elixlsx.Sheet do def remove_pane_freeze(sheet) do %{sheet | pane_freeze: nil} end + + @doc """ + Insert an image at a given position. + """ + @spec insert_image(Sheet.t(), non_neg_integer, non_neg_integer, String.t(), key: any) :: + Sheet.t() + def insert_image(sheet, rowidx, colidx, imagepath, opts \\ []) + when is_number(rowidx) and is_number(colidx) do + image = Image.new(imagepath, rowidx, colidx, opts) + # Ensure there are enough rows and columns to accomodate the image position + sheet = maybe_extend(sheet, rowidx, colidx) + + # Add the image to the list of images in this sheet + update_in(sheet.images, &[image | &1]) + end end diff --git a/lib/elixlsx/writer.ex b/lib/elixlsx/writer.ex index 5431b71..6840d18 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 @@ -112,14 +113,87 @@ 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(Sheet.t(), SheetCompInfo.t()) :: list(zip_tuple) + def get_xl_worksheets__rel_dir(s, sci) do + if s.images == [] do + [] + else + [{sheet_full__rels_path(sci), XMLTemplates.make_xl_worksheet_rel_sheet()}] + 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)} - end) + |> Enum.flat_map(fn {s, sci} -> + [{sheet_full_path(sci), XMLTemplates.make_sheet(s, wci)}] ++ + get_xl_worksheets__rel_dir(s, sci) + 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()) :: String.t() + 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.images, 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 -> + {image_full_path(image, wci), read_image(image.file_path)} + end) + end) end @@ -134,8 +208,10 @@ defmodule Elixlsx.Writer do [ get_xl_styles_xml(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_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_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 79c59eb..f51f4cf 100644 --- a/lib/elixlsx/xml_templates.ex +++ b/lib/elixlsx/xml_templates.ex @@ -7,6 +7,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 @@ -91,6 +92,18 @@ defmodule Elixlsx.XMLTemplates do "" end + @spec make_xl_worksheet_rel_sheet() :: String.t() + def make_xl_worksheet_rel_sheet() do + # We should probably care about a future with multiple rels here + # but for now just hard code the drawing one + """ + + + + + """ + end @spec make_xl_rel_sheets(nonempty_list(SheetCompInfo.t)) :: String.t def make_xl_rel_sheets sheet_comp_infos do @@ -120,22 +133,46 @@ defmodule Elixlsx.XMLTemplates do Enum.map_join sheet_comp_infos, &contenttypes_sheet_entry/1 end + defp contenttypes_drawing_entry(drawing_comp_info) do + """ + + """ + 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 + """ + + """ + 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) <> + ~S""" + + + """ end ### @@ -288,6 +325,99 @@ defmodule Elixlsx.XMLTemplates do end end + ### + ### xl/drawings/drawing*.xml + ### + @spec make_drawing(list(Image.t()), WorkbookCompInfo.t()) :: String.t() + def make_drawing([], _wci), do: "" + + def make_drawing(images, wci) do + """ + + + """ <> + Enum.map_join(images, fn i -> make_xl_drawings_twoCell(i, wci) end) <> + """ + + """ + end + + defp make_xl_drawings_twoCell(image, wci) do + drawing_id = to_string(DrawingDB.get_id(wci.drawingdb, image)) + + """ + + + #{image.colidx} + #{image.x_offset} + #{image.rowidx} + #{image.x_offset} + + + #{image.colidx + 1} + #{image.x_offset} + #{image.rowidx + 1} + #{image.x_offset} + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + end + + def xl_drawing_rel_sheet_rows(images, wci) do + Enum.map_join(images, fn image -> + id = DrawingDB.get_id(wci.drawingdb, image) + + ~s""" + + """ + end) + end + + @spec make_xl_drawing_rel_sheet(list(Image.t()), WorkbookCompInfo.t()) :: String.t() + def make_xl_drawing_rel_sheet(images, wci) do + ~S""" + + + """ <> + xl_drawing_rel_sheet_rows(images, wci) <> + ~S""" + + """ + 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. @@ -302,32 +432,33 @@ defmodule Elixlsx.XMLTemplates do make_sheet_show_grid(sheet) <> - """ - > - """ - <> make_sheetview(sheet) <> - """ - - - - """ - <> make_col_widths(sheet) <> - """ - - """ - <> - xl_sheet_rows(sheet.rows, sheet.row_heights, wci) - <> - ~S""" - - """ - <> xl_merge_cells(sheet.merge_cells) <> - """ - - - """ + """ <> + make_sheet_show_grid(sheet) <> + """ + > + """ <> + make_sheetview(sheet) <> + """ + + + + """ <> + make_col_widths(sheet) <> + """ + + """ <> + xl_sheet_rows(sheet.rows, sheet.row_heights, wci) <> + ~S""" + + """ <> + xl_merge_cells(sheet.merge_cells) <> + """ + + """ <> + make_drawing_ref(sheet.images) <> + """ + + """ end defp make_sheet_show_grid(sheet) do