Skip to content

Commit

Permalink
Images
Browse files Browse the repository at this point in the history
The previous work in xou#124 by @ngscheurich and @joseustra
was using a twoCellAnchor but that's quite tricky
to get right.

This is now using a oneCellAnchor to make the
calculations much simpler.

The api is almost the same, but you don't need to add
any emu based values for offsets. You can also align
the image from the right of a cell. That's something
I needed but you also have to take the max character
width into account.

Also added a few extra tests for a few basic files.

I also fixed the formula because it didn't work
in the example file.
  • Loading branch information
krishandley committed Aug 26, 2023
1 parent a70d878 commit d601c15
Show file tree
Hide file tree
Showing 17 changed files with 1,052 additions and 91 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ elixlsx-*.tar

# Misc.
*.swp

# Elixir LS cache
/.elixir_ls/
80 changes: 74 additions & 6 deletions example.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -145,7 +150,70 @@ 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)
# Images can be aligned from the right, but you
# might need to adjust the char (max character width)
# which is different per font and size.
|> 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
)
# Pass in the binary instead of loading from a file path
|> 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")
Binary file added ladybug-3475779_640.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 37 additions & 1 deletion lib/elixlsx/compiler.ex
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) ->
Expand Down Expand Up @@ -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 ->
Expand All @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions lib/elixlsx/compiler/drawing_comp_info.ex
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions lib/elixlsx/compiler/drawing_db.ex
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion lib/elixlsx/compiler/workbook_comp_info.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
79 changes: 79 additions & 0 deletions lib/elixlsx/image.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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", <<binary>>}
When aligning the image to the right you might
need to adjust the char attribute. 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
Loading

0 comments on commit d601c15

Please sign in to comment.