From c2c2fd551b96c7ae2c410d2743b672a26fd594a9 Mon Sep 17 00:00:00 2001 From: Kris Handley Date: Fri, 25 Aug 2023 22:34:21 +0900 Subject: [PATCH] Images The previous work in #124 by x and x was using a twoCellAnchor but that's quite tricky to get right. This is now using a oneCellAnchor to make the calculations much simpler. I have added a few extra tests. I also fixed the formula because I couldn't run the example file without doing that first. --- .gitignore | 3 + example.exs | 65 +++- ladybug-3475779_640.jpg | Bin 0 -> 35369 bytes lib/elixlsx/compiler.ex | 38 ++- lib/elixlsx/compiler/drawing_comp_info.ex | 27 ++ lib/elixlsx/compiler/drawing_db.ex | 49 +++ lib/elixlsx/compiler/workbook_comp_info.ex | 6 +- lib/elixlsx/image.ex | 77 +++++ lib/elixlsx/sheet.ex | 77 ++++- lib/elixlsx/util.ex | 12 + lib/elixlsx/workbook.ex | 9 +- lib/elixlsx/writer.ex | 104 +++++- lib/elixlsx/xml_templates.ex | 293 +++++++++++++---- mix.exs | 1 + mix.lock | 1 + test/elixlsx_test.exs | 353 ++++++++++++++++++++- test/util_test.exs | 10 +- 17 files changed, 1033 insertions(+), 92 deletions(-) create mode 100644 ladybug-3475779_640.jpg create mode 100644 lib/elixlsx/compiler/drawing_comp_info.ex create mode 100644 lib/elixlsx/compiler/drawing_db.ex create mode 100644 lib/elixlsx/image.ex 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 0000000000000000000000000000000000000000..bcbacc446a002d8cec95067cbbf9d232baf93aa4 GIT binary patch literal 35369 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 literal 0 HcmV?d00001 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