├── .local └── .gitkeep ├── test ├── test_helper.exs ├── exceed │ ├── doc_props │ │ ├── app_test.exs │ │ └── core_test.exs │ ├── relationships │ │ ├── default_test.exs │ │ └── workbook_test.exs │ ├── workbook_test.exs │ ├── util_test.exs │ ├── content_type_test.exs │ ├── worksheet │ │ └── cell_test.exs │ └── worksheet_test.exs ├── support │ └── simple_case.ex └── exceed_test.exs ├── .tool-versions ├── bin └── dev │ ├── audit │ ├── test │ ├── doctor │ ├── shipit │ └── update ├── lib ├── exceed │ ├── error.ex │ ├── relationships.ex │ ├── shared_strings.ex │ ├── doc_props │ │ ├── app.ex │ │ └── core.ex │ ├── relationships │ │ ├── default.ex │ │ └── workbook.ex │ ├── file.ex │ ├── namespace.ex │ ├── stylesheet.ex │ ├── content_type.ex │ ├── xml.ex │ ├── util.ex │ ├── workbook.ex │ ├── worksheet │ │ └── cell.ex │ └── worksheet.ex └── exceed.ex ├── .envrc ├── .formatter.exs ├── .gitignore ├── Brewfile ├── LICENSE.md ├── benchmark ├── exceed.exs └── elixlsx.exs ├── guides └── phoenix.md ├── CHANGELOG.md ├── .config └── medic.toml ├── mix.exs ├── README.md ├── mix.lock ├── .github └── workflows │ └── tests.yml └── .credo.exs /.local/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.18.4-otp-28 2 | erlang 28.1 3 | -------------------------------------------------------------------------------- /bin/dev/audit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | medic audit 4 | -------------------------------------------------------------------------------- /bin/dev/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | medic test 4 | -------------------------------------------------------------------------------- /bin/dev/doctor: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | medic doctor 4 | -------------------------------------------------------------------------------- /bin/dev/shipit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | medic shipit 4 | -------------------------------------------------------------------------------- /bin/dev/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | medic update 4 | -------------------------------------------------------------------------------- /lib/exceed/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed.Error do 2 | defexception [:message] 3 | end 4 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source_env_if_exists $HOME/.envrc 4 | 5 | source_env_if_exists .local/envrc 6 | 7 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{benchmark,config,lib,test}/**/*.{ex,exs}"], 3 | line_length: 120 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.ez 2 | /.fetch 3 | /.local/ 4 | /_build/ 5 | /cover/ 6 | /deps/ 7 | /doc/ 8 | /tmp/ 9 | Brewfile.lock.json 10 | erl_crash.dump 11 | exceed-*.tar 12 | -------------------------------------------------------------------------------- /Brewfile: -------------------------------------------------------------------------------- 1 | tap 'synchronal/tap' 2 | 3 | brew 'synchronal/tap/medic' 4 | brew 'synchronal/tap/medic-ext-elixir' 5 | brew 'synchronal/tap/medic-ext-homebrew' 6 | brew 'synchronal/tap/medic-ext-tool-versions' 7 | -------------------------------------------------------------------------------- /lib/exceed/relationships.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed.Relationships do 2 | @moduledoc false 3 | 4 | def type(type), 5 | do: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/" <> type 6 | end 7 | -------------------------------------------------------------------------------- /lib/exceed/shared_strings.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed.SharedStrings do 2 | @moduledoc false 3 | 4 | def to_xml do 5 | [ 6 | XmlStream.declaration(version: "1.0", encoding: "UTF-8"), 7 | XmlStream.element("sst", %{"xmlns" => Exceed.Namespace.main()}, []) 8 | ] 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/exceed/doc_props/app.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed.DocProps.App do 2 | @moduledoc false 3 | alias XmlStream, as: Xs 4 | 5 | def to_xml do 6 | [ 7 | Xs.declaration(version: "1.0", encoding: "UTF-8", standalone: "yes"), 8 | Xs.element( 9 | "Properties", 10 | [ 11 | {"xmlns", Exceed.Namespace.extended_properties()}, 12 | {"xmlns:vt", Exceed.Namespace.doc_props_vt()} 13 | ], 14 | [] 15 | ) 16 | ] 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/exceed/doc_props/app_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Exceed.DocProps.AppTest do 2 | # @related [subject](lib/exceed/doc_props/app.ex) 3 | use Test.SimpleCase, async: true 4 | alias Exceed.DocProps.App 5 | alias XmlQuery, as: Xq 6 | 7 | describe "to_xml" do 8 | test "includes an empty Properties tag" do 9 | xml = App.to_xml() |> stream_to_xml() 10 | assert tag = Xq.find!(xml, "/Properties") 11 | 12 | assert Xq.all(tag, "/*/node()") == [] 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/exceed/relationships/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed.Relationships.Default do 2 | @moduledoc false 3 | 4 | def to_xml do 5 | [ 6 | XmlStream.declaration(version: "1.0", encoding: "UTF-8"), 7 | XmlStream.element("Relationships", %{"xmlns" => Exceed.Namespace.relationships()}, [ 8 | XmlStream.empty_element("Relationship", %{ 9 | "Target" => "xl/workbook.xml", 10 | "Type" => Exceed.Relationships.type("officeDocument"), 11 | "Id" => "rId1" 12 | }), 13 | XmlStream.empty_element("Relationship", %{ 14 | "Target" => "docProps/core.xml", 15 | "Type" => Exceed.Relationships.type("metadata/core-properties"), 16 | "Id" => "rId2" 17 | }), 18 | XmlStream.empty_element("Relationship", %{ 19 | "Target" => "docProps/app.xml", 20 | "Type" => Exceed.Relationships.type("extended-properties"), 21 | "Id" => "rId3" 22 | }) 23 | ]) 24 | ] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/exceed/file.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed.File do 2 | @moduledoc false 3 | 4 | @buffer_size_bytes 128 * 1024 5 | @accumulator {<<>>, 0} 6 | 7 | def file(content, filename, opts) do 8 | stream = XmlStream.stream!(content, printer: Exceed.Xml) 9 | stream = if Keyword.get(opts, :buffer, true), do: buffer(stream), else: stream 10 | Zstream.entry(filename, stream) 11 | end 12 | 13 | defp buffer(stream) do 14 | stream 15 | |> Stream.chunk_while( 16 | @accumulator, 17 | fn chunk, {acc, length} -> 18 | binary_chunk = IO.iodata_to_binary(chunk) 19 | acc = acc <> binary_chunk 20 | length = length + byte_size(binary_chunk) 21 | 22 | if length >= @buffer_size_bytes do 23 | {:cont, acc, @accumulator} 24 | else 25 | {:cont, {acc, length}} 26 | end 27 | end, 28 | fn 29 | {[], _} -> {:cont, [], @accumulator} 30 | {acc, _} -> {:cont, acc, @accumulator} 31 | end 32 | ) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/exceed/namespace.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed.Namespace do 2 | @moduledoc false 3 | 4 | def content_types, do: "http://schemas.openxmlformats.org/package/2006/content-types" 5 | def core_props, do: "http://schemas.openxmlformats.org/package/2006/metadata/core-properties" 6 | def doc_props_vt, do: "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes" 7 | def doc_relationships, do: "http://schemas.openxmlformats.org/officeDocument/2006/relationships" 8 | def dublin_core, do: "http://purl.org/dc/elements/1.1/" 9 | def dublin_core_terms, do: "http://purl.org/dc/terms/" 10 | def dublin_core_type, do: "http://purl.org/dc/cdmitype/" 11 | def extended_properties, do: "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" 12 | def main, do: "http://schemas.openxmlformats.org/spreadsheetml/2006/main" 13 | def relationships, do: "http://schemas.openxmlformats.org/package/2006/relationships" 14 | def schema_instance, do: "http://www.w3.org/2001/XMLSchema-instance" 15 | end 16 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2024 synchronal.dev 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 4 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 5 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 6 | persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 9 | Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 12 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 13 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 14 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | -------------------------------------------------------------------------------- /lib/exceed/relationships/workbook.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed.Relationships.Workbook do 2 | @moduledoc false 3 | 4 | alias XmlStream, as: Xs 5 | 6 | def to_xml(%Exceed.Workbook{worksheets: sheets}) do 7 | [ 8 | Xs.declaration(version: "1.0", encoding: "UTF-8"), 9 | Xs.element("Relationships", %{"xmlns" => Exceed.Namespace.relationships()}, [ 10 | Xs.empty_element("Relationship", %{ 11 | "Target" => "styles.xml", 12 | "Type" => Exceed.Relationships.type("styles"), 13 | "Id" => "rId1" 14 | }), 15 | Xs.empty_element("Relationship", %{ 16 | "Target" => "sharedStrings.xml", 17 | "Type" => Exceed.Relationships.type("sharedStrings"), 18 | "Id" => "rId2" 19 | }) 20 | | for {_sheet, idx} <- Enum.with_index(sheets, 1) do 21 | Xs.empty_element("Relationship", %{ 22 | "Target" => "worksheets/sheet#{idx}.xml", 23 | "Type" => Exceed.Relationships.type("worksheet"), 24 | "Id" => "rId#{sheet_index(idx)}" 25 | }) 26 | end 27 | ]) 28 | ] 29 | end 30 | 31 | def sheet_index(i), do: i + 2 32 | end 33 | -------------------------------------------------------------------------------- /benchmark/exceed.exs: -------------------------------------------------------------------------------- 1 | defmodule Benchmark do 2 | def run(opts \\ [], column_count \\ 10, row_count \\ 100_000) do 3 | headers = headers(column_count) 4 | stream = stream(column_count, row_count) 5 | 6 | benchmark(column_count, row_count, fn -> 7 | Exceed.Worksheet.new("Sheet Name", headers, stream) 8 | |> Exceed.Worksheet.to_xml() 9 | |> Exceed.File.file("xl/worksheets/sheet1.xml", opts) 10 | |> List.wrap() 11 | |> Zstream.zip() 12 | |> Stream.run() 13 | end) 14 | end 15 | 16 | defp headers(column_count) do 17 | Stream.iterate(1, &(&1 + 1)) 18 | |> Stream.map(&"Header #{&1}") 19 | |> Enum.take(column_count) 20 | end 21 | 22 | defp benchmark(column_count, batch_size, fun) do 23 | {duration, _} = :timer.tc(fun, :millisecond) 24 | 25 | rate_per_row = Float.round(batch_size / (duration / 1_000), 2) 26 | IO.puts("Batch size #{column_count}*#{batch_size} completed in #{duration}ms, rate: #{rate_per_row} rows/sec") 27 | end 28 | 29 | def stream(column_count, row_count) do 30 | Stream.iterate(1, &(&1 + 1)) 31 | |> Stream.chunk_every(column_count) 32 | |> Stream.take(row_count) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /benchmark/elixlsx.exs: -------------------------------------------------------------------------------- 1 | defmodule Benchmark do 2 | require Logger 3 | 4 | def run(_opts \\ [], column_count \\ 10, row_count \\ 100_000) do 5 | headers = headers(column_count) 6 | rows = stream(column_count, row_count) 7 | 8 | benchmark(column_count, row_count, fn -> 9 | sheet = %Elixir.Elixlsx.Sheet{name: "Sheet 1", rows: [headers | Enum.to_list(rows)]} 10 | workbook = %Elixir.Elixlsx.Workbook{sheets: [sheet]} 11 | workbook |> Elixir.Elixlsx.write_to("/tmp/hello.xlsx") 12 | end) 13 | end 14 | 15 | defp benchmark(column_count, batch_size, fun) do 16 | {duration, _} = :timer.tc(fun, :millisecond) 17 | 18 | rate_per_row = Float.round(batch_size / (duration / 1_000), 2) 19 | IO.puts("Batch size #{column_count}*#{batch_size} completed in #{duration}ms, rate: #{rate_per_row} rows/sec") 20 | end 21 | 22 | defp headers(column_count) do 23 | Stream.iterate(1, &(&1 + 1)) 24 | |> Stream.map(&"Header #{&1}") 25 | |> Enum.take(column_count) 26 | end 27 | 28 | def stream(column_count, row_count) do 29 | Stream.iterate(1, &(&1 + 1)) 30 | |> Stream.chunk_every(column_count) 31 | |> Stream.take(row_count) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /guides/phoenix.md: -------------------------------------------------------------------------------- 1 | # Phoenix Integration 2 | 3 | Given a Phoenix application, Excel files streamed via Exceed may be downloaded from controllers. 4 | This requires that the `conn` be configured as a chunked, and then each chunk of the stream be 5 | reduced into it. 6 | 7 | ## Examples 8 | 9 | ``` elixir 10 | defmodule Web.ExcelController do 11 | use Web, :controller 12 | 13 | def download(conn, _params) do 14 | conn = 15 | conn 16 | |> put_resp_content_type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") 17 | |> put_resp_header("content-disposition", "attachment; filename=file.xlsx") 18 | |> send_chunked(:ok) 19 | 20 | excel_stream() 21 | |> Enum.reduce_while(conn, fn excel_chunk, conn -> 22 | case chunk(conn, excel_chunk) do 23 | {:ok, conn} -> {:cont, conn} 24 | {:error, :closed} -> {:halt, conn} 25 | end 26 | end) 27 | end 28 | 29 | # # # 30 | 31 | defp excel_stream do 32 | Exceed.Workbook.new("Creator Name") 33 | |> Exceed.Workbook.add_worksheet( 34 | Exceed.Worksheet.new("Sheet Name", ["Heading 1", "Heading 2"], 35 | [["Row 1 Cell 1", "Row 1 Cell 2"], ["Row 2 Cell 1", "Row 2 Cell 2"]]) 36 | ) 37 | |> Exceed.stream!() 38 | end 39 | end 40 | ``` 41 | -------------------------------------------------------------------------------- /test/exceed/doc_props/core_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Exceed.DocProps.CoreTest do 2 | # @related [subject](lib/exceed/doc_props/core.ex) 3 | use Test.SimpleCase, async: true 4 | alias Exceed.DocProps.Core 5 | alias XmlQuery, as: Xq 6 | 7 | describe "to_xml" do 8 | test "includes a creator tag" do 9 | xml = Core.to_xml("creator name") |> stream_to_xml() 10 | assert tag = Xq.find!(xml, "/cp:coreProperties/dc:creator") 11 | 12 | assert Xq.text(tag) == "creator name" 13 | end 14 | 15 | test "includes a created at tag" do 16 | xml = Core.to_xml("creator name") |> stream_to_xml() 17 | assert tag = Xq.find!(xml, "/cp:coreProperties/dcterms:created") 18 | 19 | assert Xq.attr(tag, "xsi:type") == "dcterms:W3CDTF" 20 | assert {:ok, created_at, 0} = Xq.text(tag) |> DateTime.from_iso8601() 21 | assert_recent(created_at) 22 | end 23 | 24 | test "includes a modified at tag" do 25 | xml = Core.to_xml("creator name") |> stream_to_xml() 26 | assert tag = Xq.find!(xml, "/cp:coreProperties/dcterms:modified") 27 | 28 | assert Xq.attr(tag, "xsi:type") == "dcterms:W3CDTF" 29 | assert {:ok, created_at, 0} = Xq.text(tag) |> DateTime.from_iso8601() 30 | assert_recent(created_at) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/exceed/doc_props/core.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed.DocProps.Core do 2 | @moduledoc false 3 | alias XmlStream, as: Xs 4 | 5 | def to_xml(creator) do 6 | now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() 7 | 8 | [ 9 | Xs.declaration(version: "1.0", encoding: "UTF-8", standalone: "yes"), 10 | Xs.element( 11 | "cp:coreProperties", 12 | [ 13 | {"xmlns:cp", Exceed.Namespace.core_props()}, 14 | {"xmlns:dc", Exceed.Namespace.dublin_core()}, 15 | {"xmlns:dcterms", Exceed.Namespace.dublin_core_terms()}, 16 | {"xmlns:dcmitype", Exceed.Namespace.dublin_core_type()}, 17 | {"xmlns:xsi", Exceed.Namespace.schema_instance()} 18 | ], 19 | [ 20 | # Xs.element("dc:title", Xs.content(title)), 21 | Xs.empty_element("dc:subject"), 22 | Xs.element("dc:creator", Xs.content(creator)), 23 | Xs.empty_element("cp:keywords"), 24 | Xs.empty_element("dc:description"), 25 | Xs.element("cp:lastModifiedBy", Xs.content(creator)), 26 | Xs.element("dcterms:created", %{"xsi:type" => "dcterms:W3CDTF"}, Xs.content(now)), 27 | Xs.element("dcterms:modified", %{"xsi:type" => "dcterms:W3CDTF"}, Xs.content(now)), 28 | Xs.element("cp:category", []) 29 | ] 30 | ) 31 | ] 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## Unreleased 4 | 5 | ## 0.7.2 6 | 7 | - Slight performance improvement when buffering writing worksheets. 8 | 9 | ## 0.7.1 10 | 11 | - Slight performance improvement when buffering file writes. 12 | 13 | ## 0.7.0 14 | 15 | - Update file buffering to 128kb chunks (prior to zipping) 16 | - Improve performance of buffered file creation; track buffer size in accumulator, rather than continually 17 | walking binaries to compute size. 18 | 19 | ## 0.6.2 20 | 21 | - Fix phoenix integration docs. 22 | 23 | ## 0.6.1 24 | 25 | - Add documentation for streaming Excel files from Phoenix controllers. 26 | 27 | ## 0.6.0 28 | 29 | - `Exceed.stream!` accepts a `buffer` option, which defaults to `true` and disables buffering when set to `false`, 30 | which may be more performant in certain situations. 31 | 32 | ## 0.5.0 33 | 34 | - Handle booleans with `t="b"`. 35 | 36 | ## 0.4.0 37 | 38 | - Handle nils and atoms when writing cells. 39 | - Drop support for Elixir 1.15. 40 | 41 | ## 0.3.1 42 | 43 | - Update and organize documentation. 44 | 45 | ## 0.3.0 46 | 47 | - Introduce the `Exceed.Worksheet.Cell` protocol for converting data 48 | to XmlStream fragments that can be written to a spreadsheet's XML. 49 | - Date and DateTime cells used default formatting rules that may be 50 | parsed back into Dates and DateTimes. 51 | 52 | ## 0.2.0 53 | 54 | - Dates and DateTimes have default formatting rules applied. 55 | - Dates and DateTimes are converted to floats in Excel epoch time. 56 | 57 | ## 0.1.0 58 | 59 | - Initial release 60 | -------------------------------------------------------------------------------- /test/exceed/relationships/default_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Exceed.Relationships.DefaultTest do 2 | # @related [subject](lib/exceed/relationships/default.ex) 3 | use Test.SimpleCase, async: true 4 | alias Exceed.Relationships.Default 5 | alias XmlQuery, as: Xq 6 | 7 | describe "to_xml" do 8 | test "includes a relationship for the workbook" do 9 | xml = Default.to_xml() |> stream_to_xml() 10 | assert tag = Xq.find!(xml, "/Relationships/Relationship[@Target='xl/workbook.xml']") 11 | 12 | assert Xq.attr(tag, "Type") == 13 | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" 14 | 15 | assert Xq.attr(tag, "Id") == "rId1" 16 | end 17 | 18 | test "includes a relationship for the core doc properties" do 19 | xml = Default.to_xml() |> stream_to_xml() 20 | assert tag = Xq.find!(xml, "/Relationships/Relationship[@Target='docProps/core.xml']") 21 | 22 | assert Xq.attr(tag, "Type") == 23 | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/metadata/core-properties" 24 | 25 | assert Xq.attr(tag, "Id") == "rId2" 26 | end 27 | 28 | test "includes a relationship for the app doc properties" do 29 | xml = Default.to_xml() |> stream_to_xml() 30 | assert tag = Xq.find!(xml, "/Relationships/Relationship[@Target='docProps/app.xml']") 31 | 32 | assert Xq.attr(tag, "Type") == 33 | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" 34 | 35 | assert Xq.attr(tag, "Id") == "rId3" 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/support/simple_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Test.SimpleCase do 2 | @moduledoc """ 3 | The simplest test case template. 4 | """ 5 | 6 | use ExUnit.CaseTemplate 7 | 8 | using do 9 | quote do 10 | import Moar.Assertions 11 | import Moar.Sugar 12 | import Test.SimpleCase 13 | end 14 | end 15 | 16 | setup [ 17 | :setup_worksheets, 18 | :setup_workbook 19 | ] 20 | 21 | def stream_to_file(workbook, tmpdir) do 22 | filename = Path.join(tmpdir, "workbook.xlsx") 23 | 24 | workbook 25 | |> Exceed.stream!() 26 | |> Stream.into(File.stream!(filename)) 27 | |> Stream.run() 28 | 29 | String.to_charlist(filename) 30 | end 31 | 32 | def extract_file(filename, part) do 33 | {:ok, handle} = :zip.zip_open(filename, [:memory]) 34 | {:ok, {_zip_name, xml}} = :zip.zip_get(~c"#{part}", handle) 35 | {:ok, xml} 36 | end 37 | 38 | def stream_to_xml(wb), 39 | do: 40 | wb 41 | |> XmlStream.stream!() 42 | |> Enum.to_list() 43 | |> IO.iodata_to_binary() 44 | 45 | def setup_workbook(%{worksheets: worksheets} = ctx) do 46 | case Map.get(ctx, :workbook) do 47 | nil -> 48 | :ok 49 | 50 | true -> 51 | wb = 52 | worksheets 53 | |> Enum.reduce(Exceed.Workbook.new("me"), &Exceed.Workbook.add_worksheet(&2, &1)) 54 | |> Exceed.Workbook.finalize() 55 | 56 | [wb: wb] 57 | end 58 | end 59 | 60 | def setup_worksheets(ctx) do 61 | worksheets = 62 | for name <- List.wrap(Map.get(ctx, :sheet, [])) do 63 | Exceed.Worksheet.new(name, ["Header 1", "Header 2"], [["Value 1", "Value 2"], ["Value 3", "Value 4"]]) 64 | end 65 | 66 | [worksheets: worksheets] 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/exceed/workbook_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Exceed.WorkbookTest do 2 | # @related [subject](lib/exceed/workbook.ex) 3 | use Test.SimpleCase, async: true 4 | 5 | alias Exceed.Workbook 6 | alias XmlQuery, as: Xq 7 | 8 | doctest Workbook 9 | 10 | describe "new" do 11 | test "produces a new workbook with no sheets" do 12 | assert %Workbook{creator: "person", worksheets: []} == Workbook.new("person") 13 | end 14 | end 15 | 16 | describe "to_xml" do 17 | @describetag :workbook 18 | 19 | @tag sheet: ["Uno", "Dos", "Tres"] 20 | test "includes a sheet tag per worksheet, with r:id starting at 3", %{wb: wb} do 21 | xml = Workbook.to_xml(wb) |> stream_to_xml() 22 | 23 | assert sheet1 = Xq.find!(xml, "/workbook/sheets/sheet[1]") 24 | assert Xq.attr(sheet1, "name") == "Uno" 25 | assert Xq.attr(sheet1, "sheetId") == "1" 26 | assert Xq.attr(sheet1, "r:id") == "rId3" 27 | 28 | assert sheet2 = Xq.find!(xml, "/workbook/sheets/sheet[2]") 29 | assert Xq.attr(sheet2, "name") == "Dos" 30 | assert Xq.attr(sheet2, "sheetId") == "2" 31 | assert Xq.attr(sheet2, "r:id") == "rId4" 32 | 33 | assert sheet3 = Xq.find!(xml, "/workbook/sheets/sheet[3]") 34 | assert Xq.attr(sheet3, "name") == "Tres" 35 | assert Xq.attr(sheet3, "sheetId") == "3" 36 | assert Xq.attr(sheet3, "r:id") == "rId5" 37 | end 38 | end 39 | 40 | describe "inspect" do 41 | @describetag :workbook 42 | 43 | test "shows empty sheets", %{wb: wb} do 44 | assert inspect(wb) == "#Exceed.Workbook" 45 | end 46 | 47 | @tag sheet: ["Uno", "Dos", "Tres"] 48 | test "shows sheet names when present", %{wb: wb} do 49 | assert inspect(wb) == "#Exceed.Workbook" 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /.config/medic.toml: -------------------------------------------------------------------------------- 1 | [doctor] 2 | checks = [ 3 | { check = "homebrew" }, 4 | { check = "tool-versions", command = "plugin-installed", args = { plugin = "erlang" } }, 5 | { check = "tool-versions", command = "plugin-installed", args = { plugin = "elixir" } }, 6 | { check = "tool-versions", command = "package-installed", args = { plugin = "erlang" } }, 7 | { check = "tool-versions", command = "package-installed", args = { plugin = "elixir" } }, 8 | { check = "elixir", command = "local-hex" }, 9 | { check = "elixir", command = "local-rebar" }, 10 | { check = "elixir", command = "packages-installed" }, 11 | ] 12 | 13 | [test] 14 | checks = [ 15 | { name = "Check for warnings", shell = "mix compile --force --warnings-as-errors" }, 16 | { name = "Elixir tests", shell = "mix test --color --warnings-as-errors", verbose = true }, 17 | ] 18 | 19 | [audit] 20 | checks = [ 21 | { name = "Check formatting", shell = "mix format --check-formatted", remedy = "mix format" }, 22 | { step = "elixir", command = "audit-deps" }, 23 | { step = "elixir", command = "credo" }, 24 | { step = "elixir", command = "dialyzer" }, 25 | { check = "elixir", command = "unused-deps" }, 26 | ] 27 | 28 | [outdated] 29 | checks = [ 30 | { check = "elixir" }, 31 | ] 32 | 33 | [update] 34 | steps = [ 35 | { step = "git", command = "pull" }, 36 | { step = "elixir", command = "get-deps" }, 37 | { step = "elixir", command = "compile-deps", args = { mix-env = "dev" } }, 38 | { step = "elixir", command = "compile-deps", args = { mix-env = "test" } }, 39 | { doctor = {} }, 40 | { name = "Build docs", shell = "mix docs" }, 41 | ] 42 | 43 | [shipit] 44 | steps = [ 45 | { audit = {} }, 46 | { update = {} }, 47 | { test = {} }, 48 | { step = "git", command = "push" }, 49 | { step = "github", command = "link-to-actions", verbose = true }, 50 | ] 51 | 52 | -------------------------------------------------------------------------------- /lib/exceed/stylesheet.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed.Stylesheet do 2 | @moduledoc false 3 | 4 | alias XmlStream, as: Xs 5 | 6 | def to_xml do 7 | [ 8 | Xs.declaration(version: "1.0", encoding: "UTF-8", standalone: "yes"), 9 | Xs.element("styleSheet", %{"xmlns" => Exceed.Namespace.main()}, [ 10 | Xs.element("numFmts", %{"count" => "2"}, [ 11 | Xs.empty_element("numFmt", %{"numFmtId" => "164", "formatCode" => "yyyy-mm-dd"}), 12 | Xs.empty_element("numFmt", %{"numFmtId" => "165", "formatCode" => "yyyy-mm-dd hh:mm:ss"}) 13 | ]), 14 | Xs.element("fonts", %{"count" => "1"}, [ 15 | Xs.empty_element("font") 16 | ]), 17 | Xs.element("fills", %{"count" => "1"}, [ 18 | Xs.element("fill", [Xs.empty_element("patternFill", %{"patternType" => "none"})]) 19 | ]), 20 | Xs.element("borders", %{"count" => "1"}, [ 21 | # Important: borders must be in order start/end/top/bottom/diagonal 22 | Xs.element("border", [ 23 | Xs.empty_element("start"), 24 | Xs.empty_element("end"), 25 | Xs.empty_element("top"), 26 | Xs.empty_element("bottom"), 27 | Xs.empty_element("diagonal") 28 | ]) 29 | ]), 30 | Xs.element("cellXfs", %{"count" => "3"}, [ 31 | Xs.empty_element("xf", %{"borderId" => "0", "fontId" => "0", "numFmtId" => "0", "xfId" => "0"}), 32 | Xs.empty_element("xf", %{ 33 | "numFmtId" => "164", 34 | "borderId" => "0", 35 | "fontId" => "0", 36 | "xfId" => "0", 37 | "applyNumberFormat" => "1" 38 | }), 39 | Xs.empty_element("xf", %{ 40 | "numFmtId" => "165", 41 | "borderId" => "0", 42 | "fontId" => "0", 43 | "xfId" => "0", 44 | "applyNumberFormat" => "1" 45 | }) 46 | ]), 47 | Xs.element( 48 | "tableStyles", 49 | %{"count" => "0", "defaultPivotStyle" => "PivotStyleLight16", "defaultTableStyle" => "TableStyleMedium9"}, 50 | [] 51 | ) 52 | ]) 53 | ] 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/exceed/relationships/workbook_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Exceed.Relationships.WorkbookTest do 2 | # @related [subject](lib/exceed/relationships/workbook.ex) 3 | use Test.SimpleCase, async: true 4 | alias Exceed.Relationships.Workbook 5 | alias XmlQuery, as: Xq 6 | 7 | describe "to_xml" do 8 | @describetag :workbook 9 | 10 | test "includes a relationship for the styles", %{wb: wb} do 11 | xml = Workbook.to_xml(wb) |> stream_to_xml() 12 | assert tag = Xq.find!(xml, "/Relationships/Relationship[@Target='styles.xml']") 13 | 14 | assert Xq.attr(tag, "Type") == 15 | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" 16 | 17 | assert Xq.attr(tag, "Id") == "rId1" 18 | end 19 | 20 | test "includes a relationship for the shared strings", %{wb: wb} do 21 | xml = Workbook.to_xml(wb) |> stream_to_xml() 22 | assert tag = Xq.find!(xml, "/Relationships/Relationship[@Target='sharedStrings.xml']") 23 | 24 | assert Xq.attr(tag, "Type") == 25 | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" 26 | 27 | assert Xq.attr(tag, "Id") == "rId2" 28 | end 29 | 30 | test "does not include relationships to sheets when none exit", %{wb: wb} do 31 | xml = Workbook.to_xml(wb) |> stream_to_xml() 32 | assert Xq.find(xml, "/Relationships/Relationship[@Target='worksheets/sheet1.xml']") == nil 33 | end 34 | 35 | @tag sheet: ["First sheet", "Second sheet"] 36 | test "includes a relationship for each worksheet", %{wb: wb} do 37 | xml = Workbook.to_xml(wb) |> stream_to_xml() 38 | assert tag = Xq.find!(xml, "/Relationships/Relationship[@Target='worksheets/sheet1.xml']") 39 | 40 | assert Xq.attr(tag, "Type") == 41 | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" 42 | 43 | assert Xq.attr(tag, "Id") == "rId3" 44 | 45 | assert tag = Xq.find!(xml, "/Relationships/Relationship[@Target='worksheets/sheet2.xml']") 46 | 47 | assert Xq.attr(tag, "Type") == 48 | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" 49 | 50 | assert Xq.attr(tag, "Id") == "rId4" 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Exceed.MixProject do 2 | use Mix.Project 3 | 4 | @scm_url "https://github.com/synchronal/exceed" 5 | @version "0.7.2" 6 | 7 | def application, 8 | do: [ 9 | extra_applications: [:logger] 10 | ] 11 | 12 | def cli, 13 | do: [ 14 | preferred_envs: [credo: :test, docs: :docs, dialyzer: :test] 15 | ] 16 | 17 | def project, 18 | do: [ 19 | app: :exceed, 20 | deps: deps(), 21 | description: "A high-level stream-oriented MS Excel OpenXML (`.xlsx`) generator", 22 | dialyzer: dialyzer(), 23 | docs: docs(), 24 | elixir: "~> 1.16", 25 | elixirc_paths: elixirc_paths(Mix.env()), 26 | homepage_url: @scm_url, 27 | name: "Exceed", 28 | package: package(), 29 | source_url: @scm_url, 30 | start_permanent: Mix.env() == :prod, 31 | version: @version 32 | ] 33 | 34 | # # # 35 | 36 | defp deps, 37 | do: [ 38 | {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, 39 | {:decimal, "~> 2.1", optional: true}, 40 | {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false}, 41 | {:elixlsx, "> 0.0.0", only: :benchmark}, 42 | {:ex_doc, "~> 0.31", only: [:docs, :dev], runtime: false}, 43 | {:mix_audit, "~> 2.0", only: :dev, runtime: false}, 44 | {:moar, "~> 3.0", only: :test}, 45 | {:xlsx_reader, "~> 0.8", only: :test, github: "xavier/xlsx_reader"}, 46 | {:xml_query, "> 0.0.0", only: :test}, 47 | {:xml_stream, "~> 0.3"}, 48 | {:zstream, "~> 0.6.4"} 49 | ] 50 | 51 | defp dialyzer, 52 | do: [ 53 | plt_add_apps: [:ex_unit, :mix], 54 | plt_add_deps: :app_tree 55 | ] 56 | 57 | defp docs, 58 | do: [ 59 | main: "readme", 60 | extras: ["guides/phoenix.md", "README.md", "LICENSE.md"], 61 | groups_for_modules: [ 62 | Protocols: [Exceed.Worksheet.Cell], 63 | Utilities: [Exceed.Util] 64 | ] 65 | ] 66 | 67 | defp elixirc_paths(:test), do: ["lib", "test/support"] 68 | defp elixirc_paths(_), do: ["lib"] 69 | 70 | defp package, 71 | do: [ 72 | licenses: ["MIT"], 73 | maintainers: ["synchronal.dev", "Erik Hanson", "Eric Saxby"], 74 | links: %{"GitHub" => @scm_url, "Sponsor" => "https://github.com/sponsors/reflective-dev"} 75 | ] 76 | end 77 | -------------------------------------------------------------------------------- /lib/exceed/content_type.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed.ContentType do 2 | @moduledoc false 3 | 4 | def to_xml(%Exceed.Workbook{worksheets: worksheets}) do 5 | [ 6 | XmlStream.declaration(version: "1.0", encoding: "UTF-8"), 7 | XmlStream.element("Types", %{"xmlns" => Exceed.Namespace.content_types()}, [ 8 | XmlStream.empty_element("Default", %{ 9 | "ContentType" => "application/vnd.openxmlformats-package.relationships+xml", 10 | "Extension" => "rels" 11 | }), 12 | XmlStream.empty_element("Default", %{ 13 | "ContentType" => "application/xml", 14 | "Extension" => "xml" 15 | }), 16 | XmlStream.empty_element("Override", %{ 17 | "ContentType" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", 18 | "PartName" => "/xl/workbook.xml" 19 | }), 20 | XmlStream.empty_element("Override", %{ 21 | "ContentType" => "application/vnd.openxmlformats-officedocument.extended-properties+xml", 22 | "PartName" => "/docProps/app.xml" 23 | }), 24 | XmlStream.empty_element("Override", %{ 25 | "ContentType" => "application/vnd.openxmlformats-package.core-properties+xml", 26 | "PartName" => "/docProps/core.xml" 27 | }), 28 | XmlStream.empty_element("Override", %{ 29 | "ContentType" => "application/vnd.openxmlformats-package.relationships+xml", 30 | "PartName" => "/xl/_rels/workbook.xml.rels" 31 | }), 32 | XmlStream.empty_element("Override", %{ 33 | "ContentType" => "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml", 34 | "PartName" => "/xl/styles.xml" 35 | }), 36 | XmlStream.empty_element("Override", %{ 37 | "ContentType" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml", 38 | "PartName" => "/xl/sharedStrings.xml" 39 | }) 40 | | for {_worksheet, i} <- Enum.with_index(worksheets, 1) do 41 | XmlStream.empty_element("Override", %{ 42 | "ContentType" => "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml", 43 | "PartName" => "/xl/worksheets/sheet#{i}.xml" 44 | }) 45 | end 46 | ]) 47 | ] 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/exceed.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed do 2 | # @related [tests](test/exceed_test.exs) 3 | @moduledoc """ 4 | Exceed is a high-level stream-oriented library for generating Excel files, 5 | useful when generating spreadsheets from data sets that exceed available 6 | memory (or the memory that one wishes to devote to generating Excel files). 7 | 8 | ## Examples 9 | 10 | ``` elixir 11 | iex> rows = Stream.repeatedly(fn -> [:rand.uniform(), :rand.uniform()] end) 12 | iex> stream = Exceed.Workbook.new("creator name") 13 | ...> |> Exceed.Workbook.add_worksheet( 14 | ...> Exceed.Worksheet.new("Sheet", ["header a", "header b"], Enum.take(rows, 10))) 15 | ...> |> Exceed.stream!() 16 | ...> 17 | iex> zip = stream |> Enum.to_list() |> IO.iodata_to_binary() 18 | iex> {:ok, package} = XlsxReader.open(zip, [source: :binary]) 19 | iex> XlsxReader.sheet_names(package) 20 | ["Sheet"] 21 | ``` 22 | """ 23 | 24 | @doc """ 25 | Convert an `Exceed.Workbook` to a stream. See `Exceed.Workbook.new/1`, 26 | `Exceed.Worksheet.new/4`, and `Exceed.Workbook.add_worksheet/2`. 27 | 28 | The only option at the moment is `buffer` which can be set to `true` (the default) 29 | or to `false` (which may be more performant in some situations). 30 | """ 31 | @spec stream!(Exceed.Workbook.t(), keyword()) :: Enum.t() 32 | def stream!(%Exceed.Workbook{} = wb, opts \\ []) do 33 | wb = Exceed.Workbook.finalize(wb) 34 | 35 | [ 36 | {Exceed.ContentType.to_xml(wb), "[Content_Types].xml"}, 37 | {Exceed.Relationships.Default.to_xml(), "_rels/.rels"}, 38 | {Exceed.DocProps.App.to_xml(), "docProps/app.xml"}, 39 | {Exceed.DocProps.Core.to_xml(wb.creator), "docProps/core.xml"}, 40 | {Exceed.Relationships.Workbook.to_xml(wb), "xl/_rels/workbook.xml.rels"}, 41 | {Exceed.Workbook.to_xml(wb), "xl/workbook.xml"}, 42 | {Exceed.Stylesheet.to_xml(), "xl/styles.xml"}, 43 | {Exceed.SharedStrings.to_xml(), "xl/sharedStrings.xml"} 44 | | worksheets_to_files(wb.worksheets) 45 | ] 46 | |> Enum.map(&to_file(&1, opts)) 47 | |> Zstream.zip() 48 | end 49 | 50 | # # # 51 | 52 | defp to_file({xml, filename}, opts), 53 | do: Exceed.File.file(xml, filename, opts) 54 | 55 | defp worksheets_to_files(worksheets) do 56 | for {worksheet, i} <- Enum.with_index(worksheets, 1) do 57 | {Exceed.Worksheet.to_xml(worksheet), "xl/worksheets/sheet#{i}.xml"} 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/exceed/xml.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed.Xml do 2 | @moduledoc false 3 | 4 | # A printer for XmlStream that produces fewer nested iodata by using string 5 | # interpolation. 6 | 7 | alias XmlStream.Printer, as: P 8 | @behaviour XmlStream.Printer 9 | 10 | def init(_), do: nil 11 | 12 | def print({:open, name, attrs}, _) when attrs == %{} or attrs == [] do 13 | {["<#{P.encode_name(name)}>"], nil} 14 | end 15 | 16 | def print({:open, name, attrs}, _) do 17 | {["<#{P.encode_name(name)}#{attrs_to_string(attrs)}>"], nil} 18 | end 19 | 20 | def print({:close, name}, _) do 21 | {[""], nil} 22 | end 23 | 24 | def print({:decl, attrs}, _) do 25 | {[""], nil} 26 | end 27 | 28 | def print({:pi, target, attrs}, _) when attrs == %{} do 29 | {[""], nil} 30 | end 31 | 32 | def print({:pi, target, attrs}, _) do 33 | {[""], nil} 34 | end 35 | 36 | def print({:comment, text}, _) do 37 | {[""], nil} 38 | end 39 | 40 | def print({:cdata, data}, _) do 41 | {[""], nil} 42 | end 43 | 44 | def print({:doctype, root_name, declaration}, _) do 45 | {[""], nil} 46 | end 47 | 48 | def print({:empty_elem, name, attrs}, _) when attrs == %{} or attrs == [] do 49 | {["<#{P.encode_name(name)}/>"], nil} 50 | end 51 | 52 | def print({:empty_elem, name, attrs}, _) do 53 | {["<#{P.encode_name(name)}#{P.attrs_to_string(attrs)}/>"], nil} 54 | end 55 | 56 | def print({:const, value}, _) do 57 | {[escape_binary(to_string(value))], nil} 58 | end 59 | 60 | # # # 61 | 62 | defp attrs_to_string(attrs) do 63 | Enum.reduce(attrs, <<>>, fn {key, value}, acc -> 64 | acc <> " " <> P.encode_name(key) <> ~s(=") <> escape_binary(to_string(value)) <> ~s(") 65 | end) 66 | end 67 | 68 | defp escape_binary(""), do: "" 69 | defp escape_binary("&" <> rest), do: "&" <> escape_binary(rest) 70 | defp escape_binary("\"" <> rest), do: """ <> escape_binary(rest) 71 | defp escape_binary("'" <> rest), do: "'" <> escape_binary(rest) 72 | defp escape_binary("<" <> rest), do: "<" <> escape_binary(rest) 73 | defp escape_binary(">" <> rest), do: ">" <> escape_binary(rest) 74 | defp escape_binary(<> <> rest), do: <> <> escape_binary(rest) 75 | end 76 | -------------------------------------------------------------------------------- /lib/exceed/util.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed.Util.Guards do 2 | @moduledoc false 3 | 4 | defguard is_valid_year?(year) when year >= 1900 5 | end 6 | 7 | defmodule Exceed.Util do 8 | # @related [tests](test/exceed/util_test.exs) 9 | 10 | @moduledoc "Helpers for converting Elixir data formats to Excel." 11 | import Exceed.Util.Guards 12 | 13 | @type erl_datetime_t() :: { 14 | {pos_integer(), pos_integer(), pos_integer()}, 15 | {non_neg_integer(), non_neg_integer(), non_neg_integer()} 16 | } 17 | 18 | @excel_epoch {{1899, 12, 30}, {0, 0, 0}} 19 | @secs_per_day 86_400 20 | 21 | @doc """ 22 | Converts a `Date`, `DateTime`, or `NaiveDateTime` to a float representing days 23 | since 1900, correcting for the Lotus 123 bug (Excel treats 1900 as a leap 24 | year). 25 | 26 | ## Examples 27 | 28 | ``` elixir 29 | ``` 30 | """ 31 | @spec to_excel_datetime(erl_datetime_t() | Date.t() | DateTime.t() | NaiveDateTime.t()) :: float() 32 | def to_excel_datetime({{1900, mm, dd}, {h, m, s}}) 33 | when mm in [1, 2] do 34 | in_seconds = :calendar.datetime_to_gregorian_seconds({{1900, mm, dd}, {h, m, s}}) 35 | excel_epoch = :calendar.datetime_to_gregorian_seconds(@excel_epoch) 36 | 37 | timestamp = (in_seconds - excel_epoch) / @secs_per_day 38 | timestamp - 1 39 | end 40 | 41 | def to_excel_datetime({{yy, mm, dd}, {h, m, s}}) when is_valid_year?(yy) do 42 | in_seconds = :calendar.datetime_to_gregorian_seconds({{yy, mm, dd}, {h, m, s}}) 43 | excel_epoch = :calendar.datetime_to_gregorian_seconds(@excel_epoch) 44 | 45 | (in_seconds - excel_epoch) / @secs_per_day 46 | end 47 | 48 | def to_excel_datetime(%DateTime{year: yy, month: mm, day: dd, hour: h, minute: m, second: s, time_zone: "Etc/UTC"}) 49 | when is_valid_year?(yy), 50 | do: to_excel_datetime({{yy, mm, dd}, {h, m, s}}) 51 | 52 | def to_excel_datetime(%DateTime{time_zone: "Etc/UTC"} = datetime), 53 | do: DateTime.to_iso8601(datetime) 54 | 55 | def to_excel_datetime(%NaiveDateTime{year: yy, month: mm, day: dd, hour: h, minute: m, second: s}) 56 | when is_valid_year?(yy), 57 | do: to_excel_datetime({{yy, mm, dd}, {h, m, s}}) 58 | 59 | def to_excel_datetime(%NaiveDateTime{} = datetime), 60 | do: NaiveDateTime.to_iso8601(datetime) 61 | 62 | def to_excel_datetime(%Date{year: yy, month: mm, day: dd}) when is_valid_year?(yy), 63 | do: to_excel_datetime({{yy, mm, dd}, {0, 0, 0}}) 64 | 65 | def to_excel_datetime(%Date{} = datetime), 66 | do: Date.to_iso8601(datetime) 67 | end 68 | -------------------------------------------------------------------------------- /lib/exceed/workbook.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed.Workbook do 2 | # @related [tests](test/exceed/workbook_test.exs) 3 | 4 | @moduledoc """ 5 | The top-level data structure that collects worksheets and metadata for 6 | generating an Excel file. 7 | 8 | ## Examples 9 | 10 | ``` elixir 11 | iex> Exceed.Workbook.new("creator name") 12 | #Exceed.Workbook 13 | ``` 14 | 15 | ``` elixir 16 | iex> headers = ["header 1"] 17 | iex> rows = Stream.repeatedly(fn -> [:rand.uniform(), :rand.uniform()] end) 18 | iex> ws = Exceed.Worksheet.new("Sheet Name", headers, rows) 19 | ...> 20 | iex> Exceed.Workbook.new("creator name") 21 | ...> |> Exceed.Workbook.add_worksheet(ws) 22 | #Exceed.Workbook 23 | ``` 24 | """ 25 | 26 | alias Exceed.Worksheet 27 | alias XmlStream, as: Xs 28 | 29 | @type t() :: %__MODULE__{ 30 | creator: String.t(), 31 | worksheets: [Worksheet.t()] 32 | } 33 | 34 | defstruct [ 35 | :creator, 36 | worksheets: [] 37 | ] 38 | 39 | @doc """ 40 | Initialize a new workbook with a creator name. 41 | """ 42 | @spec new(String.t()) :: t() 43 | def new(creator), do: __struct__(creator: creator) 44 | 45 | @doc """ 46 | Adds an `Exceed.Worksheet` to the workbook. 47 | """ 48 | @spec add_worksheet(t(), Exceed.Worksheet.t()) :: t() 49 | def add_worksheet(%__MODULE__{} = wb, %Worksheet{} = ws), 50 | do: %{wb | worksheets: [ws | wb.worksheets]} 51 | 52 | @doc false 53 | def finalize(%__MODULE__{worksheets: worksheets} = wb), 54 | do: %{wb | worksheets: Enum.reverse(worksheets)} 55 | 56 | @doc false 57 | def to_xml(%__MODULE__{worksheets: worksheets}) do 58 | [ 59 | Xs.declaration(version: "1.0", encoding: "UTF-8"), 60 | Xs.element( 61 | "workbook", 62 | %{"xmlns" => Exceed.Namespace.main(), "xmlns:r" => Exceed.Namespace.doc_relationships()}, 63 | [ 64 | Xs.element( 65 | "sheets", 66 | for {ws, i} <- Enum.with_index(worksheets, 1) do 67 | rel_idx = Exceed.Relationships.Workbook.sheet_index(i) 68 | Xs.empty_element("sheet", %{"name" => ws.name, "sheetId" => "#{i}", "r:id" => "rId#{rel_idx}"}) 69 | end 70 | ) 71 | ] 72 | ) 73 | ] 74 | end 75 | 76 | defimpl Inspect do 77 | import Inspect.Algebra 78 | 79 | def inspect(wb, opts) do 80 | worksheet_names = Enum.map(wb.worksheets, & &1.name) 81 | concat(["#Exceed.Workbook"]) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /test/exceed/util_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Exceed.UtilTest do 2 | # @related [subject](lib/exceed/util.ex) 3 | use Test.SimpleCase, async: true 4 | 5 | alias Exceed.Util 6 | 7 | describe "to_excel_datetime" do 8 | test "converts UTC datetimes to days since 1899, with seconds as fractional day" do 9 | assert ~U[1900-01-01 00:00:00Z] |> Util.to_excel_datetime() == 1.0 10 | assert ~U[1900-01-01 01:17:52Z] |> Util.to_excel_datetime() == 1.054074074074074 11 | assert (1 * 3600 + 17 * 60 + 52) / 86_400 == 0.05407407407407407 12 | 13 | assert ~U[1900-01-31 00:00:00Z] |> Util.to_excel_datetime() == 31.0 14 | assert ~U[1900-01-31 01:17:52Z] |> Util.to_excel_datetime() == 31.0540740740740742 15 | end 16 | 17 | test "corrects UTC datetimes for a bug in excel treating 1900 as a leap year" do 18 | assert ~U[1900-02-28 00:00:00Z] |> Util.to_excel_datetime() == 31 + 28 19 | assert ~U[1900-03-01 00:00:00Z] |> Util.to_excel_datetime() == 31 + 28 + 2 20 | assert ~U[2024-01-01 00:00:00Z] |> Util.to_excel_datetime() == 45_292.0 21 | assert (2024 - 1900) * 365 + 25 + 6 + 1 == 45_292 22 | end 23 | 24 | test "Converts UTC datetimes prior to 1900 to iso8601" do 25 | assert ~U[1899-12-31 23:59:59Z] |> Util.to_excel_datetime() == "1899-12-31T23:59:59Z" 26 | end 27 | 28 | test "converts naive datetimes to days since 1899, with seconds as fractional day" do 29 | assert ~N[1900-01-01 00:00:00] |> Util.to_excel_datetime() == 1.0 30 | assert ~N[1900-01-01 01:17:52] |> Util.to_excel_datetime() == 1.054074074074074 31 | assert (1 * 3600 + 17 * 60 + 52) / 86_400 == 0.05407407407407407 32 | 33 | assert ~N[1900-01-31 00:00:00] |> Util.to_excel_datetime() == 31.0 34 | assert ~N[1900-01-31 01:17:52] |> Util.to_excel_datetime() == 31.0540740740740742 35 | end 36 | 37 | test "corrects naive datetimes for a bug in excel treating 1900 as a leap year" do 38 | assert ~N[1900-02-28 00:00:00] |> Util.to_excel_datetime() == 31 + 28 39 | assert ~N[1900-03-01 00:00:00] |> Util.to_excel_datetime() == 31 + 28 + 2 40 | assert ~N[2024-01-01 00:00:00] |> Util.to_excel_datetime() == 45_292.0 41 | assert (2024 - 1900) * 365 + 25 + 6 + 1 == 45_292 42 | end 43 | 44 | test "Converts naive datetimes prior to 1900 to iso8601" do 45 | assert ~N[1899-12-31 23:59:59] |> Util.to_excel_datetime() == "1899-12-31T23:59:59" 46 | end 47 | 48 | test "converts a Date to days since 1899, with seconds as fractional day" do 49 | assert ~D[1900-01-01] |> Util.to_excel_datetime() == 1.0 50 | assert ~D[1900-01-31] |> Util.to_excel_datetime() == 31.0 51 | end 52 | 53 | test "corrects dates for a bug in excel treating 1900 as a leap year" do 54 | assert ~D[1900-02-28] |> Util.to_excel_datetime() == 31 + 28 55 | assert ~D[1900-03-01] |> Util.to_excel_datetime() == 31 + 28 + 2 56 | assert ~D[2024-01-01] |> Util.to_excel_datetime() == 45_292.0 57 | end 58 | 59 | test "Converts dates prior to 1900 to iso8601" do 60 | assert ~D[1899-12-31] |> Util.to_excel_datetime() == "1899-12-31" 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/exceed/content_type_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Exceed.ContentTypeTest do 2 | # @related [subject](lib/exceed/content_type.ex) 3 | use Test.SimpleCase, async: true 4 | alias Exceed.ContentType 5 | alias XmlQuery, as: Xq 6 | 7 | describe "to_xml" do 8 | @describetag :workbook 9 | 10 | test "includes a default for extension: rels", %{wb: wb} do 11 | xml = ContentType.to_xml(wb) |> stream_to_xml() 12 | assert tag = Xq.find!(xml, "/Types/Default[@Extension='rels']") 13 | 14 | assert to_string(tag) == 15 | "" 16 | end 17 | 18 | test "includes a default for extension: xml", %{wb: wb} do 19 | xml = ContentType.to_xml(wb) |> stream_to_xml() 20 | assert tag = Xq.find!(xml, "/Types/Default[@Extension='xml']") 21 | 22 | assert to_string(tag) == 23 | "" 24 | end 25 | 26 | test "includes an override for /xl/workbook.xml", %{wb: wb} do 27 | xml = ContentType.to_xml(wb) |> stream_to_xml() 28 | assert tag = Xq.find!(xml, "/Types/Override[@PartName='/xl/workbook.xml']") 29 | assert Xq.attr(tag, "ContentType") == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" 30 | end 31 | 32 | test "includes an override for /docProps/app.xml", %{wb: wb} do 33 | xml = ContentType.to_xml(wb) |> stream_to_xml() 34 | assert tag = Xq.find!(xml, "/Types/Override[@PartName='/docProps/app.xml']") 35 | assert Xq.attr(tag, "ContentType") == "application/vnd.openxmlformats-officedocument.extended-properties+xml" 36 | end 37 | 38 | test "includes an override for /docProps/core.xml", %{wb: wb} do 39 | xml = ContentType.to_xml(wb) |> stream_to_xml() 40 | assert tag = Xq.find!(xml, "/Types/Override[@PartName='/docProps/core.xml']") 41 | assert Xq.attr(tag, "ContentType") == "application/vnd.openxmlformats-package.core-properties+xml" 42 | end 43 | 44 | test "includes an override for /xl/_rels/workbook.xml.rels", %{wb: wb} do 45 | xml = ContentType.to_xml(wb) |> stream_to_xml() 46 | assert tag = Xq.find!(xml, "/Types/Override[@PartName='/xl/_rels/workbook.xml.rels']") 47 | assert Xq.attr(tag, "ContentType") == "application/vnd.openxmlformats-package.relationships+xml" 48 | end 49 | 50 | test "includes an override for /xl/styles.xml", %{wb: wb} do 51 | xml = ContentType.to_xml(wb) |> stream_to_xml() 52 | assert tag = Xq.find!(xml, "/Types/Override[@PartName='/xl/styles.xml']") 53 | assert Xq.attr(tag, "ContentType") == "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" 54 | end 55 | 56 | test "includes an override for /xl/sharedStrings.xml", %{wb: wb} do 57 | xml = ContentType.to_xml(wb) |> stream_to_xml() 58 | assert tag = Xq.find!(xml, "/Types/Override[@PartName='/xl/sharedStrings.xml']") 59 | 60 | assert Xq.attr(tag, "ContentType") == 61 | "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml" 62 | end 63 | 64 | test "does not include overrides for sheets when none exist", %{wb: wb} do 65 | xml = ContentType.to_xml(wb) |> stream_to_xml() 66 | 67 | assert Xq.find(xml, "/Types/Override[@PartName='/xl/worksheets/sheet1.xml']") == nil 68 | end 69 | 70 | @tag sheet: ["First", "Second"] 71 | test "includes an override for each worksheet", %{wb: wb} do 72 | xml = ContentType.to_xml(wb) |> stream_to_xml() 73 | 74 | assert tag = Xq.find!(xml, "/Types/Override[@PartName='/xl/worksheets/sheet1.xml']") 75 | assert Xq.attr(tag, "ContentType") == "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" 76 | 77 | assert tag = Xq.find!(xml, "/Types/Override[@PartName='/xl/worksheets/sheet2.xml']") 78 | assert Xq.attr(tag, "ContentType") == "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/exceed/worksheet/cell.ex: -------------------------------------------------------------------------------- 1 | defprotocol Exceed.Worksheet.Cell do 2 | # @related [tests](test/exceed/worksheet/cell_test.exs) 3 | 4 | @moduledoc """ 5 | A protocol for transforming source data into data structures that can be streamed 6 | to appropriate SpreadsheetML tags, using the `XmlStream` library. 7 | 8 | This protocol is implemented for floats, integers, strings, and binaries, in addition 9 | to `Date`, `DateTime`, and `NaiveDateTime`. If the [decimal](https://hex.pm/packages/decimal) 10 | library is present, this protocoal is automatically implemented for `Decimal`. 11 | 12 | ## Examples 13 | 14 | ``` elixir 15 | defimpl Exceed.Worksheet.Cell, for: MyStruct do 16 | alias XmlStream, as: Xs 17 | 18 | def to_attrs(%MyStruct{value: value}) when is_binary(value), 19 | do: %{"t" => "inlineStr"} 20 | 21 | def to_content(%MyStruct{value: value}) when is_binary(value), 22 | do: Xs.element("is", [Xs.element("t", [Xs.content(value)])]) 23 | end 24 | ``` 25 | """ 26 | 27 | @doc """ 28 | For a given data type, these attributes will be merged onto the `c` tag wrapping 29 | this cell's content. Note that the `r` attribute (designating the cell's identifier 30 | in `A1` format) will be calculated when streaming a worksheet to XLSX, and should 31 | _not_ be included in this output. 32 | """ 33 | @spec to_attrs(t) :: XmlStream.attrs() 34 | def to_attrs(value) 35 | 36 | @doc """ 37 | For a given data type, convert the value to a list of tags. Functions from 38 | `XmlStream` including `XmlStream.element/3`, `XmlStream.empty_element/2`, and 39 | `XmlStream.content/1` may be used to facilitate the generation of tags. 40 | """ 41 | @spec to_content(t) :: XmlStream.fragment() 42 | def to_content(value) 43 | end 44 | 45 | defimpl Exceed.Worksheet.Cell, for: Atom do 46 | alias XmlStream, as: Xs 47 | 48 | def to_attrs(v) when is_boolean(v), do: %{"t" => "b"} 49 | def to_attrs(_), do: %{"t" => "inlineStr"} 50 | 51 | def to_content(true), do: Xs.element("v", [Xs.content("1")]) 52 | def to_content(false), do: Xs.element("v", [Xs.content("0")]) 53 | 54 | def to_content(value), 55 | do: Xs.element("is", [Xs.element("t", [Xs.content("#{value}")])]) 56 | end 57 | 58 | defimpl Exceed.Worksheet.Cell, for: Float do 59 | alias XmlStream, as: Xs 60 | 61 | def to_attrs(_), do: %{"t" => "n"} 62 | 63 | def to_content(value), 64 | do: Xs.element("v", [Xs.content(value)]) 65 | end 66 | 67 | defimpl Exceed.Worksheet.Cell, for: Integer do 68 | alias XmlStream, as: Xs 69 | 70 | def to_attrs(_), do: %{"t" => "n"} 71 | 72 | def to_content(value), 73 | do: Xs.element("v", [Xs.content(value)]) 74 | end 75 | 76 | defimpl Exceed.Worksheet.Cell, for: String do 77 | alias XmlStream, as: Xs 78 | 79 | def to_attrs(_), do: %{"t" => "inlineStr"} 80 | 81 | def to_content(value), 82 | do: Xs.element("is", [Xs.element("t", [Xs.content(value)])]) 83 | end 84 | 85 | defimpl Exceed.Worksheet.Cell, for: BitString do 86 | alias XmlStream, as: Xs 87 | 88 | def to_attrs(_), do: %{"t" => "inlineStr"} 89 | 90 | def to_content(value), 91 | do: Xs.element("is", [Xs.element("t", [Xs.content(value)])]) 92 | end 93 | 94 | defimpl Exceed.Worksheet.Cell, for: Date do 95 | import Exceed.Util.Guards, only: [is_valid_year?: 1] 96 | alias Exceed.Util 97 | 98 | def to_attrs(%Date{year: year}) when is_valid_year?(year), do: %{"s" => "1"} 99 | def to_attrs(%Date{}), do: %{"t" => "inlineStr"} 100 | 101 | def to_content(value), 102 | do: Util.to_excel_datetime(value) |> Exceed.Worksheet.Cell.to_content() 103 | end 104 | 105 | defimpl Exceed.Worksheet.Cell, for: DateTime do 106 | import Exceed.Util.Guards, only: [is_valid_year?: 1] 107 | alias Exceed.Util 108 | 109 | def to_attrs(%DateTime{year: year}) when is_valid_year?(year), do: %{"s" => "2"} 110 | def to_attrs(%DateTime{}), do: %{"t" => "inlineStr"} 111 | 112 | def to_content(value), 113 | do: Util.to_excel_datetime(value) |> Exceed.Worksheet.Cell.to_content() 114 | end 115 | 116 | defimpl Exceed.Worksheet.Cell, for: NaiveDateTime do 117 | import Exceed.Util.Guards, only: [is_valid_year?: 1] 118 | alias Exceed.Util 119 | 120 | def to_attrs(%NaiveDateTime{year: year}) when is_valid_year?(year), do: %{"s" => "2"} 121 | def to_attrs(%NaiveDateTime{}), do: %{"t" => "inlineStr"} 122 | 123 | def to_content(value), 124 | do: Util.to_excel_datetime(value) |> Exceed.Worksheet.Cell.to_content() 125 | end 126 | 127 | if Code.ensure_loaded?(Decimal) do 128 | defimpl Exceed.Worksheet.Cell, for: Decimal do 129 | alias XmlStream, as: Xs 130 | 131 | def to_attrs(_), do: %{"t" => "n"} 132 | 133 | def to_content(value), 134 | do: Xs.element("v", [Xs.content(to_string(value))]) 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /test/exceed/worksheet/cell_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Exceed.Worksheet.CellTest do 2 | # @related [subject](lib/exceed/worksheet/cell.ex) 3 | use Test.SimpleCase, async: true 4 | alias Exceed.Worksheet.Cell 5 | 6 | describe "nil" do 7 | test "attrs: assigns type of `inlineStr`" do 8 | assert Cell.to_attrs(nil) == %{"t" => "inlineStr"} 9 | end 10 | 11 | test "content: wraps the content in `is`>`t`" do 12 | assert Cell.to_content(nil) |> stream_to_xml() == "" 13 | end 14 | end 15 | 16 | describe "atoms" do 17 | test "attrs: assigns type of `inlineStr`" do 18 | assert Cell.to_attrs(:something) == %{"t" => "inlineStr"} 19 | end 20 | 21 | test "content: wraps the content in `is`>`t`" do 22 | assert Cell.to_content(:something) |> stream_to_xml() == "something" 23 | end 24 | end 25 | 26 | describe "booleans" do 27 | test "attrs: assigns type of `b`" do 28 | assert Cell.to_attrs(true) == %{"t" => "b"} 29 | assert Cell.to_attrs(false) == %{"t" => "b"} 30 | end 31 | 32 | test "content: wraps the content in `v`" do 33 | assert Cell.to_content(true) |> stream_to_xml() == "1" 34 | assert Cell.to_content(false) |> stream_to_xml() == "0" 35 | end 36 | end 37 | 38 | describe "floats" do 39 | test "attrs: assigns type of `n`" do 40 | assert Cell.to_attrs(5.78) == %{"t" => "n"} 41 | end 42 | 43 | test "content: wraps the content in `v`" do 44 | assert Cell.to_content(5.78) |> stream_to_xml() == "5.78" 45 | end 46 | end 47 | 48 | describe "integers" do 49 | test "attrs: assigns type of `n`" do 50 | assert Cell.to_attrs(12) == %{"t" => "n"} 51 | end 52 | 53 | test "content: wraps the content in `v`" do 54 | assert Cell.to_content(12) |> stream_to_xml() == "12" 55 | end 56 | end 57 | 58 | describe "strings" do 59 | test "attrs: assigns type of `inlineStr`" do 60 | assert Cell.to_attrs("Cell Content") == %{"t" => "inlineStr"} 61 | end 62 | 63 | test "content: wraps the content in `is`>`t`" do 64 | assert Cell.to_content("Cell Content") |> stream_to_xml() == "Cell Content" 65 | end 66 | end 67 | 68 | describe "dates" do 69 | test "attrs: assigns style of `1`" do 70 | assert Cell.to_attrs(~D[2024-01-01]) == %{"s" => "1"} 71 | end 72 | 73 | test "attrs: assigns type of `inlineStr` when before 1900" do 74 | assert Cell.to_attrs(~D[1899-01-01]) == %{"t" => "inlineStr"} 75 | end 76 | 77 | test "content: converts the date to epoch and wraps the content in `v`" do 78 | assert ~D[2024-01-01] |> Exceed.Util.to_excel_datetime() == 45_292.0 79 | assert Cell.to_content(~D[2024-01-01]) |> stream_to_xml() == "45292.0" 80 | end 81 | 82 | test "content: wraps the content in `is`>`t` when before 1900" do 83 | assert Cell.to_content(~D[1899-01-01]) |> stream_to_xml() == "1899-01-01" 84 | end 85 | end 86 | 87 | describe "utc datetimes" do 88 | test "attrs: assigns style of `2`" do 89 | assert Cell.to_attrs(~U[2024-01-01 15:01:02Z]) == %{"s" => "2"} 90 | end 91 | 92 | test "attrs: assigns type of `inlineStr` when before 1900" do 93 | assert Cell.to_attrs(~U[1899-01-01 23:59:59Z]) == %{"t" => "inlineStr"} 94 | end 95 | 96 | test "content: converts the date to epoch and wraps the content in `v`" do 97 | assert ~U[2024-01-01 15:01:02Z] |> Exceed.Util.to_excel_datetime() == 45_292.62571759259 98 | assert Cell.to_content(~U[2024-01-01 15:01:02Z]) |> stream_to_xml() == "45292.62571759259" 99 | end 100 | 101 | test "content: wraps the content in `is`>`t` tags when before 1900" do 102 | assert Cell.to_content(~U[1899-01-01 15:01:02Z]) |> stream_to_xml() == "1899-01-01T15:01:02Z" 103 | end 104 | end 105 | 106 | describe "naive datetimes" do 107 | test "attrs: assigns style of `2`" do 108 | assert Cell.to_attrs(~N[2024-01-01 15:01:02]) == %{"s" => "2"} 109 | end 110 | 111 | test "attrs: assigns type of `inlineStr` when before 1900" do 112 | assert Cell.to_attrs(~N[1899-01-01 23:59:59]) == %{"t" => "inlineStr"} 113 | end 114 | 115 | test "content: converts the date to epoch and wraps the content in `v`" do 116 | assert ~N[2024-01-01 15:01:02Z] |> Exceed.Util.to_excel_datetime() == 45_292.62571759259 117 | assert Cell.to_content(~N[2024-01-01 15:01:02]) |> stream_to_xml() == "45292.62571759259" 118 | end 119 | 120 | test "content: wraps the content in `is`>`t` tags when before 1900" do 121 | assert Cell.to_content(~N[1899-01-01 15:01:02]) |> stream_to_xml() == "1899-01-01T15:01:02" 122 | end 123 | end 124 | 125 | describe "decimals" do 126 | test "attrs: assigns type of `n`" do 127 | assert Cell.to_attrs(Decimal.new("5.78")) == %{"t" => "n"} 128 | end 129 | 130 | test "content: wraps the content in `v`" do 131 | assert Cell.to_content(Decimal.new("5.78")) |> stream_to_xml() == "5.78" 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Exceed 2 | 3 | `Exceed` is a high-level stream-oriented library for generating Excel files, 4 | useful when generating spreadsheets from data sets large enough that they may 5 | exceed available memory—or the available memory that one wants to dedicate to 6 | building spreadsheets. 7 | 8 | ## Sponsorship 💕 9 | 10 | This library is part of the [Synchronal suite of libraries and tools](https://github.com/synchronal) 11 | which includes more than 15 open source Elixir libraries as well as some Rust libraries and tools. 12 | 13 | You can support our open source work by [sponsoring us](https://github.com/sponsors/reflective-dev). 14 | If you have specific features in mind, bugs you'd like fixed, or new libraries you'd like to see, 15 | file an issue or contact us at [contact@reflective.dev](mailto:contact@reflective.dev). 16 | 17 | ## Installation 18 | 19 | ``` elixir 20 | def deps do 21 | [ 22 | {:exceed, "~> 0.6"} 23 | ] 24 | end 25 | ``` 26 | 27 | ## Why a duck? 28 | 29 | XLSX files are zip files containing numerous XML files following 30 | [`SpreadsheetML`](https://learn.microsoft.com/en-us/office/open-xml/spreadsheet/structure-of-a-spreadsheetml-document?tabs=cs) 31 | OpenXML schemas. [`High-level libraries`](https://hex.pm/packages/elixlsx) already 32 | exist to generate XLSX files from Elixir, but do not handle streams—all 33 | content to be written to a spreadsheet must be held in memory until the 34 | spreadsheet is fully written. 35 | 36 | [`Low-level libraries`](https://hex.pm/packages/xlsx_stream) already exist to 37 | generate XLSX files from streams, but are so low level that one must know all 38 | the ins and outs of SpreasheetML, including positional ordering of files, tags, 39 | and attributes. 40 | 41 | We wanted a library that hides the complexity of streaming to XML to zlib, 42 | and hides the complexity of OOXML. 43 | 44 | 45 | ## Usage 46 | 47 | XLSX streams are generated by initializing a workbook (with a creator name), 48 | adding worksheets, and then converting that to a stream. 49 | 50 | ``` elixir 51 | stream = 52 | Exceed.Workbook.new("Creator Name") 53 | |> Exceed.Workbook.add_worksheet( 54 | Exceed.Worksheet.new("Sheet Name", ["Heading 1", "Heading 2"], 55 | [["Row 1 Cell 1", "Row 1 Cell 2"], ["Row 2 Cell 1", "Row 2 Cell 2"]]) 56 | ) 57 | |> Exceed.stream!() 58 | 59 | stream 60 | |> Stream.into(File.stream!("/tmp/workbook.xslx")) 61 | |> Stream.run() 62 | ``` 63 | 64 | Worksheets may be initialized with lists of lists, or they may be initialized 65 | with a stream of data that maps to a list of cells. 66 | 67 | ``` elixir 68 | rows = 69 | Stream.unfold(1, fn 70 | 10_001 -> nil 71 | row_count -> {["Row #{row_count} Cell 1", "Row #{row_count} Cell 2"], row_count + 1} 72 | end) 73 | 74 | Exceed.Worksheet.new("Sheet Name", ["Heading 1", "Heading 2"], rows) 75 | ``` 76 | 77 | ## Alternatives & References 78 | 79 | This library is inspired by and learns from other great libraries. One might 80 | choose to use one of those in place of `Exceed`: 81 | 82 | - [`elixlsx`](https://hex.pm/packages/elixlsx) - Provides fine-grained control 83 | over cells, but is not stream-oriented and thus requires that all source data 84 | and rows be retained in memory until the entire workbook is written. 85 | - [`xlsx_stream`](https://hex.pm/packages/xlsx_stream) - Provides low-level 86 | constructs that may be combined to make an Excel file. Works nicely with 87 | streams. Requires that one know all the ins and outs of 88 | [`SpreadsheetML`](https://learn.microsoft.com/en-us/office/open-xml/spreadsheet/structure-of-a-spreadsheetml-document?tabs=cs) 89 | in order to make a valid file that Excel can parse. 90 | 91 | ## Contributing 92 | 93 | This library uses [`medic`](https://github.com/synchronal/medic-rs) for its 94 | development workflow. 95 | 96 | ``` shell 97 | brew bundle 98 | 99 | bin/dev/doctor 100 | bin/dev/test 101 | bin/dev/audit 102 | bin/dev/update 103 | bin/dev/shipit 104 | ``` 105 | 106 | If one does not want to install extra tooling but wishes to contribute code 107 | fixes, new features, or documentation, please verify that code is formatted 108 | with the versions of Elixir and Erlang specified in `.tool-versions`, passes 109 | all tests, and passes strict credo and dialyzer. 110 | 111 | ## Benchmarks 112 | 113 | At time of writing, benchmarks indicate that Exceed performs at 30% to 40% the 114 | speed of non-streaming libraries such as Elixlsx. Ideas and PRs to improve the 115 | performance of file generation are very welcome! 116 | 117 | ``` shell 118 | # 10 columns, 100_000 rows 119 | MIX_ENV=benchmark mix run -r benchmark/exceed.exs -e "Benchmark.run()" 120 | # 20 columns, 1_000_000 rows 121 | MIX_ENV=benchmark mix run -r benchmark/exceed.exs -e "Benchmark.run([], 20, 1_000_000)" 122 | # pass options to `Exceed.File.file/3` 123 | MIX_ENV=benchmark mix run -r benchmark/exceed.exs -e "Benchmark.run([buffer: false], 20, 1_000_000)" 124 | 125 | # 10 columns, 100_000 rows 126 | MIX_ENV=benchmark mix run -r benchmark/elixlsx.exs -e "Benchmark.run()" 127 | # 20 columns, 1_000_000 rows 128 | MIX_ENV=benchmark mix run -r benchmark/elixlsx.exs -e "Benchmark.run([], 20, 1_000_000)" 129 | ``` 130 | 131 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 4 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 5 | "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 7 | "elixlsx": {:hex, :elixlsx, "0.6.0", "858c2c821ab52f4ca0988adce188d19f3b239a4fff8b36b26cd81ec8af9b2ab3", [:mix], [], "hexpm", "c4766f47afea075a85950a5c6fe981e98b8b8a30cc076382aaacf2bb8dbcd25d"}, 8 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 9 | "ex_doc": {:hex, :ex_doc, "0.38.4", "ab48dff7a8af84226bf23baddcdda329f467255d924380a0cf0cee97bb9a9ede", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "f7b62346408a83911c2580154e35613eb314e0278aeea72ed7fedef9c1f165b2"}, 10 | "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 11 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 12 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 14 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 15 | "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, 16 | "moar": {:hex, :moar, "3.2.0", "2de699ae924ef93f5c0055d7e7b5de6fb2da9baef0d9b8901ec3d8af39fff3d4", [:mix], [], "hexpm", "1c8180ff6def24dc093bb1eb9337c260754b85be400fa6f2f5acc9bf0d3de6d7"}, 17 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 18 | "saxy": {:hex, :saxy, "1.6.0", "02cb4e9bd045f25ac0c70fae8164754878327ee393c338a090288210b02317ee", [:mix], [], "hexpm", "ef42eb4ac983ca77d650fbdb68368b26570f6cc5895f0faa04d34a6f384abad3"}, 19 | "xlsx_reader": {:git, "https://github.com/xavier/xlsx_reader.git", "3d8413875a25a4c3b8a0af3b46d6d9a8932d107d", []}, 20 | "xml_query": {:hex, :xml_query, "2.0.0", "906719ee8a4238899523a69e2d5061df674edf96fe8b9fca8eb2b4e622e86ffd", [:mix], [], "hexpm", "8572efa58689ac773bba367cfbdb9d998971610cc59c5810901e42873a1f0ea2"}, 21 | "xml_stream": {:hex, :xml_stream, "0.4.0", "74ba968fac79df82cc643664b8fb209562f3e06ffecc847c2b57cc9b074c136f", [:mix], [], "hexpm", "38cdb3dbb0f7fca005f240f0628839f81cfd21bf5dd58fe29048321fbc26a3db"}, 22 | "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, 23 | "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, 24 | "zstream": {:hex, :zstream, "0.6.7", "ae74c7544f4e0563ffbe71324bf1c9bbc0ab33bb580a0ae718da511fb8a5c9d6", [:mix], [], "hexpm", "48c43ae0f00cfcda1ccb69c1d044755663d43b2ee8a0a65763648bf2078d634d"}, 25 | } 26 | -------------------------------------------------------------------------------- /test/exceed_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExceedTest do 2 | # @related [subject](lib/exceed.ex) 3 | use Test.SimpleCase, async: true 4 | alias XmlQuery, as: Xq 5 | 6 | doctest Exceed 7 | 8 | describe "stream! without worksheets" do 9 | @describetag :tmp_dir 10 | 11 | test "converts a workbook to a zlib stream that can be streamed to a file", %{tmp_dir: tmpdir} do 12 | filename = Path.join(tmpdir, "empty_workbook.xlsx") 13 | 14 | assert_that(Exceed.Workbook.new("me") |> Exceed.stream!() |> Stream.into(File.stream!(filename)) |> Stream.run(), 15 | changes: File.exists?(filename), 16 | from: false, 17 | to: true 18 | ) 19 | 20 | assert {:ok, [{:zip_comment, _} | files]} = :zip.list_dir(String.to_charlist(filename)) 21 | 22 | files 23 | |> Enum.map(fn {:zip_file, name, _info, _, _, _} -> to_string(name) end) 24 | |> assert_eq([ 25 | "[Content_Types].xml", 26 | "_rels/.rels", 27 | "docProps/app.xml", 28 | "docProps/core.xml", 29 | "xl/_rels/workbook.xml.rels", 30 | "xl/workbook.xml", 31 | "xl/styles.xml", 32 | "xl/sharedStrings.xml" 33 | ]) 34 | 35 | assert {:ok, wb} = XlsxReader.open(filename) 36 | assert XlsxReader.sheet_names(wb) == [] 37 | end 38 | 39 | test "includes an xl/workbook.xml", %{tmp_dir: tmpdir} do 40 | filename = Exceed.Workbook.new("me") |> stream_to_file(tmpdir) 41 | {:ok, wb} = extract_file(filename, "xl/workbook.xml") 42 | 43 | assert [sheets] = 44 | Xq.find!(wb, "/workbook") 45 | |> Xq.all("sheets") 46 | 47 | assert sheets |> to_string() == "" 48 | end 49 | end 50 | 51 | describe "stream! with worksheets" do 52 | @describetag :tmp_dir 53 | 54 | setup %{tmp_dir: tmpdir} do 55 | filename = 56 | Exceed.Workbook.new("me") 57 | |> Exceed.Workbook.add_worksheet( 58 | Exceed.Worksheet.new("First Worksheet", ["Header A", "Header B", "Header C"], [ 59 | ["Content A1", "Content B1", "Content C1"], 60 | ["Content A2", "Content B2", "Content C2"] 61 | ]) 62 | ) 63 | |> Exceed.Workbook.add_worksheet( 64 | Exceed.Worksheet.new("Second Worksheet", ["Header AA", "Header BB"], [ 65 | ["Content AA1", "Content BB1"], 66 | ["Content AA2", "Content BB2"] 67 | ]) 68 | ) 69 | |> stream_to_file(tmpdir) 70 | 71 | [filename: filename] 72 | end 73 | 74 | test "includes a part for each sheet", %{filename: filename} do 75 | assert {:ok, [{:zip_comment, _} | files]} = :zip.list_dir(filename) 76 | 77 | parts = files |> Enum.map(fn {:zip_file, name, _info, _, _, _} -> to_string(name) end) 78 | 79 | assert "xl/worksheets/sheet1.xml" in parts 80 | assert "xl/worksheets/sheet2.xml" in parts 81 | end 82 | 83 | test "can be parsed", %{filename: filename} do 84 | assert {:ok, wb} = XlsxReader.open(to_string(filename)) 85 | assert XlsxReader.sheet_names(wb) == ["First Worksheet", "Second Worksheet"] 86 | end 87 | end 88 | 89 | describe "dates" do 90 | @describetag :tmp_dir 91 | test "can be parsed", %{tmp_dir: tmpdir} do 92 | today = Date.utc_today() 93 | 94 | stream = 95 | Stream.unfold(0, fn i -> {[Date.add(today, -i)], i + 1} end) 96 | |> Stream.take(100) 97 | 98 | filename = 99 | Exceed.Workbook.new("me") 100 | |> Exceed.Workbook.add_worksheet(Exceed.Worksheet.new("Sheet", nil, stream)) 101 | |> stream_to_file(tmpdir) 102 | 103 | assert {:ok, wb} = XlsxReader.open(to_string(filename)) 104 | assert XlsxReader.sheet_names(wb) == ["Sheet"] 105 | assert {:ok, rows} = XlsxReader.sheet(wb, "Sheet") 106 | 107 | assert Enum.take(rows, 5) == [ 108 | [today], 109 | [Date.add(today, -1)], 110 | [Date.add(today, -2)], 111 | [Date.add(today, -3)], 112 | [Date.add(today, -4)] 113 | ] 114 | end 115 | end 116 | 117 | describe "datetimes" do 118 | @describetag :tmp_dir 119 | test "can be parsed", %{tmp_dir: tmpdir} do 120 | now = DateTime.utc_now() 121 | 122 | stream = 123 | Stream.unfold(0, fn i -> {[DateTime.add(now, -i, :day)], i + 1} end) 124 | |> Stream.take(100) 125 | 126 | filename = 127 | Exceed.Workbook.new("me") 128 | |> Exceed.Workbook.add_worksheet(Exceed.Worksheet.new("Sheet", nil, stream)) 129 | |> stream_to_file(tmpdir) 130 | 131 | assert {:ok, wb} = XlsxReader.open(to_string(filename)) 132 | assert XlsxReader.sheet_names(wb) == ["Sheet"] 133 | assert {:ok, rows} = XlsxReader.sheet(wb, "Sheet") 134 | 135 | parsed_now = now |> DateTime.truncate(:second) |> DateTime.to_naive() 136 | 137 | assert Enum.take(rows, 5) == [ 138 | [parsed_now], 139 | [NaiveDateTime.add(parsed_now, -1, :day)], 140 | [NaiveDateTime.add(parsed_now, -2, :day)], 141 | [NaiveDateTime.add(parsed_now, -3, :day)], 142 | [NaiveDateTime.add(parsed_now, -4, :day)] 143 | ] 144 | end 145 | end 146 | 147 | describe "strings" do 148 | @describetag :tmp_dir 149 | test "can be parsed", %{tmp_dir: tmpdir} do 150 | stream = 151 | Stream.unfold(65, fn char -> {[to_string([char])], char + 1} end) 152 | |> Stream.take(10_000) 153 | 154 | filename = 155 | Exceed.Workbook.new("me") 156 | |> Exceed.Workbook.add_worksheet(Exceed.Worksheet.new("Sheet", nil, stream)) 157 | |> stream_to_file(tmpdir) 158 | 159 | assert {:ok, wb} = XlsxReader.open(to_string(filename)) 160 | assert XlsxReader.sheet_names(wb) == ["Sheet"] 161 | assert {:ok, rows} = XlsxReader.sheet(wb, "Sheet") 162 | 163 | assert Enum.take(rows, 6) == [["A"], ["B"], ["C"], ["D"], ["E"], ["F"]] 164 | assert Enum.take(Enum.drop(rows, 490), 6) == [["ȫ"], ["Ȭ"], ["ȭ"], ["Ȯ"], ["ȯ"], ["Ȱ"]] 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /test/exceed/worksheet_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Exceed.WorksheetTest do 2 | # @related [subject](lib/exceed/worksheet.ex) 3 | use Test.SimpleCase, async: true 4 | alias Exceed.Worksheet 5 | alias XmlQuery, as: Xq 6 | 7 | doctest Exceed.Worksheet 8 | 9 | setup do 10 | stream = 11 | Stream.unfold(1, fn 12 | 100 -> nil 13 | row -> {["row #{row} cell 1", row, row * 1.5], row + 1} 14 | end) 15 | 16 | headers = ["inline strings", "integers", "floats"] 17 | 18 | [headers: headers, stream: stream] 19 | end 20 | 21 | describe "new" do 22 | test "captures inputs", %{headers: headers, stream: stream} do 23 | assert %Worksheet{name: "sheet", headers: ^headers, content: ^stream, opts: []} = 24 | Worksheet.new("sheet", headers, stream) 25 | end 26 | 27 | test "can set headers to nil", %{stream: stream} do 28 | assert %Worksheet{name: "sheet", headers: nil, content: ^stream, opts: []} = 29 | Worksheet.new("sheet", nil, stream) 30 | end 31 | 32 | test "raises when headers are set to an empty list", %{stream: stream} do 33 | assert_raise Exceed.Error, "Worksheet headers must be a list of items or nil", fn -> 34 | Worksheet.new("sheet", [], stream) 35 | end 36 | end 37 | end 38 | 39 | describe "to_xml" do 40 | test "generates rows for the headers and each member of the stream", %{headers: headers, stream: stream} do 41 | ws = Worksheet.new("sheet", headers, Enum.take(stream, 2)) 42 | xml = Worksheet.to_xml(ws) |> stream_to_xml() 43 | 44 | assert [header_row, row_1, row_2] = Xq.all(xml, "/worksheet/sheetData/row") 45 | 46 | assert header_row |> Xq.attr("r") == "1" 47 | 48 | header_row 49 | |> extract_cells() 50 | |> assert_eq([ 51 | %{type: "inlineStr", text: "inline strings", children: "is/t", cell: "A1"}, 52 | %{type: "inlineStr", text: "integers", children: "is/t", cell: "B1"}, 53 | %{type: "inlineStr", text: "floats", children: "is/t", cell: "C1"} 54 | ]) 55 | 56 | row_1 57 | |> extract_cells() 58 | |> assert_eq([ 59 | %{type: "inlineStr", text: "row 1 cell 1", children: "is/t", cell: "A2"}, 60 | %{type: "n", text: "1", children: "v", cell: "B2"}, 61 | %{type: "n", text: "1.5", children: "v", cell: "C2"} 62 | ]) 63 | 64 | row_2 65 | |> extract_cells() 66 | |> assert_eq([ 67 | %{type: "inlineStr", text: "row 2 cell 1", children: "is/t", cell: "A3"}, 68 | %{type: "n", text: "2", children: "v", cell: "B3"}, 69 | %{type: "n", text: "3.0", children: "v", cell: "C3"} 70 | ]) 71 | end 72 | 73 | test "can generate rows when no headers are given", %{stream: stream} do 74 | ws = Worksheet.new("sheet", nil, Enum.take(stream, 1)) 75 | xml = Worksheet.to_xml(ws) |> stream_to_xml() 76 | 77 | assert [row_1] = Xq.all(xml, "/worksheet/sheetData/row") 78 | 79 | row_1 80 | |> extract_cells() 81 | |> assert_eq([ 82 | %{type: "inlineStr", text: "row 1 cell 1", children: "is/t", cell: "A1"}, 83 | %{type: "n", text: "1", children: "v", cell: "B1"}, 84 | %{type: "n", text: "1.5", children: "v", cell: "C1"} 85 | ]) 86 | end 87 | 88 | test "configures each column as wide as the header plus some default padding", 89 | %{headers: headers, stream: stream} do 90 | ws = Worksheet.new("sheet", headers, Enum.take(stream, 0)) 91 | xml = Worksheet.to_xml(ws) |> stream_to_xml() 92 | 93 | [header1, header2, header3] = headers 94 | 95 | assert [col1, col2, col3] = Xq.all(xml, "/worksheet/cols/col") 96 | 97 | assert String.length(header1) == 14 98 | assert Xq.attr(col1, "min") == "1" 99 | assert Xq.attr(col1, "max") == "1" 100 | assert Xq.attr(col1, "width") == "18.25" 101 | 102 | assert String.length(header2) == 8 103 | assert Xq.attr(col2, "min") == "2" 104 | assert Xq.attr(col2, "max") == "2" 105 | assert Xq.attr(col2, "width") == "12.25" 106 | 107 | assert String.length(header3) == 6 108 | assert Xq.attr(col3, "min") == "3" 109 | assert Xq.attr(col3, "max") == "3" 110 | assert Xq.attr(col3, "width") == "10.25" 111 | end 112 | 113 | test "can set the column width padding", 114 | %{headers: headers, stream: stream} do 115 | ws = Worksheet.new("sheet", headers, Enum.take(stream, 0), cols: [padding: 2.34]) 116 | xml = Worksheet.to_xml(ws) |> stream_to_xml() 117 | 118 | [header1, header2, header3] = headers 119 | 120 | assert [col1, col2, col3] = Xq.all(xml, "/worksheet/cols/col") 121 | 122 | assert String.length(header1) == 14 123 | assert Xq.attr(col1, "width") == "16.34" 124 | 125 | assert String.length(header2) == 8 126 | assert Xq.attr(col2, "width") == "10.34" 127 | 128 | assert String.length(header3) == 6 129 | assert Xq.attr(col3, "width") == "8.34" 130 | end 131 | 132 | test "uses the first row of the stream to set column width when headers are not given", 133 | %{stream: stream} do 134 | ws = Worksheet.new("sheet", nil, Enum.take(stream, 2)) 135 | xml = Worksheet.to_xml(ws) |> stream_to_xml() 136 | 137 | assert [col1, col2, col3] = Xq.all(xml, "/worksheet/cols/col") 138 | assert Xq.attr(col1, "width") == "16.25" 139 | assert Xq.attr(col2, "width") == "5.25" 140 | assert Xq.attr(col3, "width") == "7.25" 141 | end 142 | 143 | test "can specify the width of specific columns", 144 | %{headers: headers, stream: stream} do 145 | ws = Worksheet.new("sheet", headers, Enum.take(stream, 2), cols: [widths: %{1 => 7.325, 3 => 1.1}]) 146 | xml = Worksheet.to_xml(ws) |> stream_to_xml() 147 | 148 | assert [col1, col2, col3] = Xq.all(xml, "/worksheet/cols/col") 149 | assert Xq.attr(col1, "width") == "7.325" 150 | assert Xq.attr(col2, "width") == "12.25" 151 | assert Xq.attr(col3, "width") == "1.1" 152 | end 153 | 154 | test "converts datetimes to excel timestamp floats for times after 1900" do 155 | ws = %Worksheet{ 156 | name: "sheet", 157 | headers: nil, 158 | content: [[~U(2024-01-01 00:00:00Z)], [~U(1899-12-31 00:00:00Z)]], 159 | opts: [] 160 | } 161 | 162 | xml = Worksheet.to_xml(ws) |> stream_to_xml() 163 | assert [row_1, row_2] = Xq.all(xml, "/worksheet/sheetData/row") 164 | 165 | row_1 166 | |> extract_cells() 167 | |> assert_eq([ 168 | %{type: "n", text: "45292.0", children: "v", cell: "A1"} 169 | ]) 170 | 171 | row_2 172 | |> extract_cells() 173 | |> assert_eq([ 174 | %{type: "inlineStr", text: "1899-12-31T00:00:00Z", children: "is/t", cell: "A2"} 175 | ]) 176 | end 177 | 178 | test "converts dates to excel timestamp floats for times after 1900" do 179 | ws = %Worksheet{ 180 | name: "sheet", 181 | headers: nil, 182 | content: [[~D(2024-01-01)], [~D(1899-12-31)]], 183 | opts: [] 184 | } 185 | 186 | xml = Worksheet.to_xml(ws) |> stream_to_xml() 187 | assert [row_1, row_2] = Xq.all(xml, "/worksheet/sheetData/row") 188 | 189 | row_1 190 | |> extract_cells() 191 | |> assert_eq([ 192 | %{type: "n", text: "45292.0", children: "v", cell: "A1"} 193 | ]) 194 | 195 | row_2 196 | |> extract_cells() 197 | |> assert_eq([ 198 | %{type: "inlineStr", text: "1899-12-31", children: "is/t", cell: "A2"} 199 | ]) 200 | end 201 | end 202 | 203 | # # # 204 | 205 | defp extract_cells(row) do 206 | row 207 | |> Xq.all("//c") 208 | |> Enum.map(fn cell -> 209 | case {Xq.attr(cell, "s"), Xq.attr(cell, "t")} do 210 | {_, "inlineStr"} -> 211 | %{ 212 | cell: Xq.attr(cell, "r"), 213 | children: "is/t", 214 | text: Xq.find!(cell, "//is/t") |> Xq.text(), 215 | type: "inlineStr" 216 | } 217 | 218 | {nil, "n"} -> 219 | %{ 220 | cell: Xq.attr(cell, "r"), 221 | children: "v", 222 | text: Xq.find!(cell, "//v") |> Xq.text(), 223 | type: "n" 224 | } 225 | 226 | {"" <> _, nil} -> 227 | %{ 228 | cell: Xq.attr(cell, "r"), 229 | children: "v", 230 | text: Xq.find!(cell, "//v") |> Xq.text(), 231 | type: "n" 232 | } 233 | end 234 | end) 235 | end 236 | end 237 | -------------------------------------------------------------------------------- /lib/exceed/worksheet.ex: -------------------------------------------------------------------------------- 1 | defmodule Exceed.Worksheet do 2 | # @related [tests](test/exceed/worksheet_test.exs) 3 | 4 | @moduledoc """ 5 | Worksheets represent the tabular data to be included in an Excel sheet, in 6 | addition to metadata about the sheet and how it should be rendered. 7 | 8 | ## Examples 9 | 10 | ``` elixir 11 | iex> headers = ["header 1"] 12 | iex> rows = [["row 1"], ["row 2"]] 13 | iex> ws = Exceed.Worksheet.new("Sheet Name", headers, rows) 14 | #Exceed.Worksheet 15 | iex> 16 | iex> Exceed.Workbook.new("creator name") 17 | ...> |> Exceed.Workbook.add_worksheet(ws) 18 | #Exceed.Workbook 19 | ``` 20 | 21 | ## Sheet content 22 | 23 | Rows are represented by an enumerable where each row will be resolved to a 24 | list of cells. In the above example, a list of lists is provided. 25 | Alternatively, a stream may be provided. 26 | 27 | ``` elixir 28 | iex> stream = Stream.repeatedly(fn -> [:rand.uniform(), :rand.uniform()] end) 29 | ...> 30 | iex> Exceed.Worksheet.new("Sheet Name", ["Random 1", "Random 2"], stream) 31 | #Exceed.Worksheet 32 | ``` 33 | 34 | Values in each row execute the `Exceed.Worksheet.Cell` protocol to convert 35 | Elixir data structures to XML using appropriate SpreadsheetML tags and 36 | determine the appropriate XML attributes to merge onto the cell. 37 | 38 | ## Sheet options 39 | 40 | When initializing a worksheet, default options may be overridded by providing 41 | an options via the optional fourth argument to `Exceed.Worksheet.new/4`. 42 | 43 | ``` elixir 44 | iex> headers = ["header 1"] 45 | iex> rows = [["row 1"], ["row 2"]] 46 | iex> opts = [cols: [padding: 6.325, widths: %{2 => 10.75}]] 47 | iex> 48 | iex> Exceed.Worksheet.new("Sheet Name", headers, rows, opts) 49 | #Exceed.Worksheet 50 | ``` 51 | 52 | - Column padding - `cols: [padding: value]` - default: `4.25` - extra space 53 | given to each column, in addition to whatever is determined from the 54 | headers or the specified widths. Not used when an exact width is specified 55 | for a column. 56 | - Column width - `cols: [widths: %{1 => 15.75}]` - specify the exact width 57 | of specific columns as a map of 1-indexed column indexes to floats. When not 58 | provided, this is automatically determined from the character length of the 59 | relevant header cell, or from the first row when no headers are provided. 60 | 61 | ### Column widths 62 | 63 | 1. Note that the actual rendered width of a cell depends on the character 64 | width of the applied font, on the computer used to open the spreadsheet (see 65 | the [OpenXML.Spreadsheet Column docs](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.column?view=openxml-3.0.1) 66 | for more info). 67 | 68 | """ 69 | alias Exceed.Worksheet.Cell 70 | alias XmlStream, as: Xs 71 | 72 | @type headers() :: [String.t()] | nil 73 | @type spreadsheet_options() :: [spreadsheet_option()] 74 | @type spreadsheet_option() :: {:cols, [columns_option()]} 75 | @type columns_option() :: 76 | {:padding, float()} 77 | | {:widths, %{integer() => float()}} 78 | 79 | @type t() :: %__MODULE__{ 80 | content: Enum.t(), 81 | headers: headers(), 82 | name: String.t(), 83 | opts: spreadsheet_options() 84 | } 85 | 86 | @derive {Inspect, only: [:name]} 87 | defstruct ~w( 88 | content 89 | headers 90 | name 91 | opts 92 | )a 93 | 94 | @doc """ 95 | Initialize a new worksheet to be added to a workbook. See `Exceed.Workbook.add_worksheet/2`. 96 | For worksheet options, see the module docs for `m:Exceed.Worksheet#module-sheet-options`. 97 | 98 | ## Examples 99 | 100 | ``` elixir 101 | iex> headers = ["header 1"] 102 | iex> rows = [["row 1"], ["row 2"]] 103 | iex> opts = [cols: [padding: 6.325]] 104 | iex> %Worksheet{} = Exceed.Worksheet.new("Sheet Name", headers, rows, opts) 105 | 106 | ``` 107 | """ 108 | @spec new(String.t(), headers(), Enum.t(), keyword()) :: t() 109 | def new(name, headers, content, opts \\ []) 110 | 111 | def new(_name, [], _content, _opts), 112 | do: raise(Exceed.Error, "Worksheet headers must be a list of items or nil") 113 | 114 | def new(name, headers, content, opts), 115 | do: __struct__(name: name, headers: headers, content: content, opts: opts) 116 | 117 | @doc false 118 | def to_xml(%__MODULE__{headers: headers, content: content, opts: opts}) do 119 | %{ 120 | col_padding: col_padding, 121 | col_widths: col_widths 122 | } = normalize_opts(opts) 123 | 124 | [ 125 | Xs.declaration(version: "1.0", encoding: "UTF-8"), 126 | {:open, "worksheet", 127 | %{"xmlns" => Exceed.Namespace.main(), "xmlns:r" => Exceed.Namespace.relationships(), "xml:space" => "preserve"}}, 128 | Xs.element("sheetPr", [Xs.empty_element("pageSetUpPr", %{"fitToPage" => "0"})]), 129 | Xs.element("sheetViews", [ 130 | Xs.empty_element("sheetView", %{ 131 | "defaultGridColor" => "1", 132 | "rightToLeft" => "0", 133 | "showFormulas" => "0", 134 | "showGridLines" => "1", 135 | "showOutlineSymbols" => "0", 136 | "showRowColHeaders" => "1", 137 | "showRuler" => "1", 138 | "showWhiteSpace" => "0", 139 | "showZeros" => "1", 140 | "tabSelected" => "0", 141 | "windowProtection" => "0", 142 | "workbookViewId" => "0", 143 | "zoomScale" => "100", 144 | "zoomScaleNormal" => "0", 145 | "zoomScalePageLayoutView" => "0", 146 | "zoomScaleSheetLayoutView" => "0" 147 | }) 148 | ]), 149 | Xs.empty_element("sheetFormatPr", %{"baseColWidth" => "8", "defaultRowHeight" => "18"}), 150 | cols(headers, content, col_padding, col_widths), 151 | {:open, "sheetData", []}, 152 | sheet_data(content, headers), 153 | {:close, "sheetData"}, 154 | Xs.empty_element("sheetCalcPr", %{"fullCalcOnLoad" => "1"}), 155 | Xs.empty_element("printOptions", %{ 156 | "gridLines" => "0", 157 | "headings" => "0", 158 | "horizontalCentered" => "0", 159 | "verticalCentered" => "0" 160 | }), 161 | Xs.empty_element("pageMargins", %{ 162 | "bottom" => "1.0", 163 | "footer" => "0.5", 164 | "header" => "0.5", 165 | "left" => "0.75", 166 | "right" => "0.75", 167 | "top" => "1.0" 168 | }), 169 | Xs.empty_element("pageSetup"), 170 | Xs.empty_element("headerFooter"), 171 | {:close, "worksheet"} 172 | ] 173 | end 174 | 175 | # # # 176 | 177 | defp cell_idx_to_letter(x), do: IO.chardata_to_string(:lists.reverse(x)) 178 | 179 | defp cols(nil, content, padding, widths) do 180 | case content |> Stream.take(1) |> Enum.to_list() do 181 | [headers] -> cols(headers, content, padding, widths) 182 | end 183 | end 184 | 185 | defp cols(headers, _content, padding, widths) do 186 | Xs.element( 187 | "cols", 188 | for {header, i} <- Enum.with_index(headers, 1) do 189 | width = Map.get(widths, i, String.length(to_string(header)) + padding) 190 | Xs.empty_element("col", %{"min" => i, "max" => i, "width" => width}) 191 | end 192 | ) 193 | end 194 | 195 | defp normalize_opts(opts) do 196 | %{ 197 | col_padding: get_in(opts, [:cols, :padding]) || 4.25, 198 | col_widths: get_in(opts, [:cols, :widths]) || %{} 199 | } 200 | end 201 | 202 | defp next_alphabet([x | rest]) when x >= ?A and x < ?Z, do: [x + 1 | rest] 203 | defp next_alphabet([]), do: [?A] 204 | defp next_alphabet([x | rest]) when x == ?Z, do: [?A | next_alphabet(rest)] 205 | 206 | defp prepend_headers(stream, nil), do: stream 207 | defp prepend_headers(stream, headers), do: Stream.concat([headers], stream) 208 | 209 | defp sheet_data(stream, headers) do 210 | stream 211 | |> prepend_headers(headers) 212 | |> Stream.transform(1, fn row, row_idx -> 213 | to_row(row, row_idx) 214 | end) 215 | end 216 | 217 | defp to_cells(row, row_idx) do 218 | Enum.reduce(row, {[], [?A]}, fn cell, {cells, count} -> 219 | attrs = Cell.to_attrs(cell) 220 | body = Cell.to_content(cell) 221 | cell_letter = cell_idx_to_letter(count) 222 | 223 | {:lists.append(cells, Xs.element("c", Map.put(attrs, "r", cell_letter <> row_idx), body)), next_alphabet(count)} 224 | end) 225 | |> elem(0) 226 | end 227 | 228 | defp to_row(items, row_idx) do 229 | identifier = to_string(row_idx) 230 | {[Xs.element("row", %{"r" => identifier}, to_cells(items, identifier))], row_idx + 1} 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test & Audit 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - ci/* 7 | pull_request: 8 | branches: 9 | - main 10 | jobs: 11 | build_test: 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - pair: 17 | elixir: 1.16 18 | otp: 26 19 | - pair: 20 | elixir: 1.18 21 | otp: 28 22 | name: Build Test 23 | runs-on: ubuntu-24.04 24 | env: 25 | MIX_ENV: test 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Set up Elixir 29 | id: beam 30 | uses: erlef/setup-beam@v1 31 | with: 32 | elixir-version: ${{ matrix.pair.elixir }} 33 | otp-version: ${{ matrix.pair.otp }} 34 | - name: Cache deps 35 | uses: actions/cache@v3 36 | with: 37 | path: deps 38 | key: ${{ runner.os }}-test-deps-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 39 | - name: Cache _build 40 | uses: actions/cache@v3 41 | with: 42 | path: _build 43 | key: ${{ runner.os }}-test-build-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 44 | - name: Install dependencies 45 | run: mix deps.get 46 | working-directory: . 47 | - name: Compile for test 48 | run: mix compile --force --warnings-as-errors 49 | working-directory: . 50 | build_dev: 51 | strategy: 52 | fail-fast: false 53 | matrix: 54 | include: 55 | - pair: 56 | elixir: 1.16 57 | otp: 26 58 | - pair: 59 | elixir: 1.18 60 | otp: 28 61 | name: Build Dev 62 | runs-on: ubuntu-24.04 63 | env: 64 | MIX_ENV: dev 65 | steps: 66 | - uses: actions/checkout@v4 67 | - name: Set up Elixir 68 | id: beam 69 | uses: erlef/setup-beam@v1 70 | with: 71 | elixir-version: ${{ matrix.pair.elixir }} 72 | otp-version: ${{ matrix.pair.otp }} 73 | - name: Cache deps 74 | uses: actions/cache@v3 75 | with: 76 | path: deps 77 | key: ${{ runner.os }}-dev-deps-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 78 | - name: Cache _build 79 | uses: actions/cache@v3 80 | with: 81 | path: _build 82 | key: ${{ runner.os }}-dev-build-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 83 | - name: Install dependencies 84 | run: mix deps.get 85 | working-directory: . 86 | - name: Compile for dev 87 | run: mix compile --force --warnings-as-errors 88 | working-directory: . 89 | test: 90 | strategy: 91 | fail-fast: false 92 | matrix: 93 | include: 94 | - pair: 95 | elixir: 1.16 96 | otp: 26 97 | - pair: 98 | elixir: 1.18 99 | otp: 28 100 | name: Test 101 | needs: build_test 102 | runs-on: ubuntu-24.04 103 | env: 104 | MIX_ENV: test 105 | steps: 106 | - uses: actions/checkout@v4 107 | - name: Set up Elixir 108 | id: beam 109 | uses: erlef/setup-beam@v1 110 | with: 111 | elixir-version: ${{ matrix.pair.elixir }} 112 | otp-version: ${{ matrix.pair.otp }} 113 | - name: Cache deps 114 | uses: actions/cache@v3 115 | with: 116 | path: deps 117 | key: ${{ runner.os }}-test-deps-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 118 | - name: Cache _build 119 | uses: actions/cache@v3 120 | with: 121 | path: _build 122 | key: ${{ runner.os }}-test-build-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 123 | - name: Run tests 124 | run: mix test --color --warnings-as-errors 125 | working-directory: . 126 | credo_and_dialyxir: 127 | strategy: 128 | fail-fast: false 129 | matrix: 130 | include: 131 | - pair: 132 | elixir: 1.16 133 | otp: 26 134 | - pair: 135 | elixir: 1.18 136 | otp: 28 137 | name: Credo + Dialyxir 138 | needs: build_test 139 | runs-on: ubuntu-24.04 140 | env: 141 | MIX_ENV: test 142 | steps: 143 | - uses: actions/checkout@v4 144 | - name: Set up Elixir 145 | id: beam 146 | uses: erlef/setup-beam@v1 147 | with: 148 | elixir-version: ${{ matrix.pair.elixir }} 149 | otp-version: ${{ matrix.pair.otp }} 150 | - name: Cache deps 151 | uses: actions/cache@v3 152 | with: 153 | path: deps 154 | key: ${{ runner.os }}-test-deps-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 155 | - name: Cache _build 156 | uses: actions/cache@v3 157 | with: 158 | path: _build 159 | key: ${{ runner.os }}-test-build-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 160 | - name: Cache PLTs 161 | uses: actions/cache@v3 162 | with: 163 | path: priv/plts 164 | key: ${{ runner.os }}-test-dialyzer-v2-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 165 | - name: Credo 166 | run: mix credo --strict 167 | working-directory: . 168 | - name: Run dialyzer 169 | run: mix dialyzer 170 | working-directory: . 171 | audit: 172 | strategy: 173 | fail-fast: false 174 | matrix: 175 | include: 176 | - pair: 177 | elixir: 1.18 178 | otp: 28 179 | name: Audit 180 | needs: build_dev 181 | runs-on: ubuntu-24.04 182 | env: 183 | MIX_ENV: dev 184 | steps: 185 | - uses: actions/checkout@v4 186 | - name: Set up Elixir 187 | id: beam 188 | uses: erlef/setup-beam@v1 189 | with: 190 | elixir-version: ${{ matrix.pair.elixir }} 191 | otp-version: ${{ matrix.pair.otp }} 192 | - name: Cache deps 193 | uses: actions/cache@v3 194 | with: 195 | path: deps 196 | key: ${{ runner.os }}-dev-deps-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 197 | - name: Cache _build 198 | uses: actions/cache@v3 199 | with: 200 | path: _build 201 | key: ${{ runner.os }}-dev-build-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 202 | - name: Check Elixir formatting 203 | run: mix format --check-formatted 204 | working-directory: . 205 | - name: Check for unused dependencies 206 | run: mix deps.unlock --check-unused 207 | working-directory: . 208 | - name: Audit deps 209 | run: mix deps.audit 210 | working-directory: . 211 | publish: 212 | strategy: 213 | fail-fast: false 214 | matrix: 215 | include: 216 | - pair: 217 | elixir: 1.18 218 | otp: 28 219 | name: Publish to Hex 220 | if: github.ref == 'refs/heads/main' 221 | needs: 222 | - test 223 | - credo_and_dialyxir 224 | - audit 225 | runs-on: ubuntu-24.04 226 | steps: 227 | - uses: actions/checkout@v4 228 | - name: Set up Elixir 229 | id: beam 230 | uses: erlef/setup-beam@v1 231 | with: 232 | elixir-version: ${{ matrix.pair.elixir }} 233 | otp-version: ${{ matrix.pair.otp }} 234 | - name: Cache deps 235 | uses: actions/cache@v3 236 | with: 237 | path: deps 238 | key: ${{ runner.os }}-dev-deps-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 239 | - name: Cache _build 240 | uses: actions/cache@v3 241 | with: 242 | path: _build 243 | key: ${{ runner.os }}-dev-build-v1-${{ hashFiles('**/mix.lock') }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }} 244 | - name: Publish to Hex 245 | uses: synchronal/hex-publish-action@v3 246 | with: 247 | name: exceed 248 | key: ${{ secrets.HEX_PM_KEY }} 249 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: %{ 68 | enabled: [ 69 | # 70 | ## Consistency Checks 71 | # 72 | {Credo.Check.Consistency.ExceptionNames, []}, 73 | {Credo.Check.Consistency.LineEndings, []}, 74 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 75 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 76 | {Credo.Check.Consistency.SpaceInParentheses, []}, 77 | {Credo.Check.Consistency.TabsOrSpaces, []}, 78 | 79 | # 80 | ## Design Checks 81 | # 82 | # You can customize the priority of any check 83 | # Priority values are: `low, normal, high, higher` 84 | # 85 | {Credo.Check.Design.AliasUsage, [priority: :low, if_nested_deeper_than: 3, if_called_more_often_than: 3]}, 86 | # You can also customize the exit_status of each check. 87 | # If you don't want TODO comments to cause `mix credo` to fail, just 88 | # set this value to 0 (zero). 89 | # 90 | # {Credo.Check.Design.TagTODO, [exit_status: 2]}, 91 | {Credo.Check.Design.TagFIXME, []}, 92 | 93 | # 94 | ## Readability Checks 95 | # 96 | {Credo.Check.Readability.AliasOrder, []}, 97 | {Credo.Check.Readability.FunctionNames, []}, 98 | {Credo.Check.Readability.LargeNumbers, []}, 99 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 100 | {Credo.Check.Readability.ModuleAttributeNames, []}, 101 | {Credo.Check.Readability.ModuleDoc, false}, 102 | {Credo.Check.Readability.ModuleNames, []}, 103 | {Credo.Check.Readability.ParenthesesInCondition, []}, 104 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 105 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 106 | {Credo.Check.Readability.PredicateFunctionNames, []}, 107 | {Credo.Check.Readability.PreferImplicitTry, []}, 108 | {Credo.Check.Readability.RedundantBlankLines, []}, 109 | {Credo.Check.Readability.Semicolons, []}, 110 | {Credo.Check.Readability.SpaceAfterCommas, []}, 111 | {Credo.Check.Readability.StringSigils, []}, 112 | {Credo.Check.Readability.TrailingBlankLine, []}, 113 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 114 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 115 | {Credo.Check.Readability.VariableNames, []}, 116 | {Credo.Check.Readability.WithSingleClause, []}, 117 | 118 | # 119 | ## Refactoring Opportunities 120 | # 121 | {Credo.Check.Refactor.Apply, []}, 122 | {Credo.Check.Refactor.CondStatements, []}, 123 | {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 20]}, 124 | {Credo.Check.Refactor.FunctionArity, []}, 125 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 126 | {Credo.Check.Refactor.MatchInCondition, []}, 127 | {Credo.Check.Refactor.MapJoin, []}, 128 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 129 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 130 | {Credo.Check.Refactor.Nesting, [max_nesting: 4]}, 131 | {Credo.Check.Refactor.UnlessWithElse, []}, 132 | {Credo.Check.Refactor.WithClauses, []}, 133 | {Credo.Check.Refactor.FilterFilter, []}, 134 | {Credo.Check.Refactor.RejectReject, []}, 135 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 136 | 137 | # 138 | ## Warnings 139 | # 140 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 141 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 142 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 143 | {Credo.Check.Warning.IExPry, []}, 144 | {Credo.Check.Warning.IoInspect, []}, 145 | {Credo.Check.Warning.OperationOnSameValues, []}, 146 | {Credo.Check.Warning.OperationWithConstantResult, []}, 147 | {Credo.Check.Warning.RaiseInsideRescue, []}, 148 | {Credo.Check.Warning.SpecWithStruct, []}, 149 | {Credo.Check.Warning.WrongTestFileExtension, []}, 150 | {Credo.Check.Warning.UnusedEnumOperation, []}, 151 | {Credo.Check.Warning.UnusedFileOperation, []}, 152 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 153 | {Credo.Check.Warning.UnusedListOperation, []}, 154 | {Credo.Check.Warning.UnusedPathOperation, []}, 155 | {Credo.Check.Warning.UnusedRegexOperation, []}, 156 | {Credo.Check.Warning.UnusedStringOperation, []}, 157 | {Credo.Check.Warning.UnusedTupleOperation, []}, 158 | {Credo.Check.Warning.UnsafeExec, []} 159 | ], 160 | disabled: [ 161 | # 162 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 163 | 164 | # 165 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 166 | # and be sure to use `mix credo --strict` to see low priority checks) 167 | # 168 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 169 | {Credo.Check.Consistency.UnusedVariableNames, []}, 170 | {Credo.Check.Design.DuplicatedCode, []}, 171 | {Credo.Check.Design.SkipTestWithoutComment, []}, 172 | {Credo.Check.Readability.AliasAs, []}, 173 | {Credo.Check.Readability.BlockPipe, []}, 174 | {Credo.Check.Readability.ImplTrue, []}, 175 | {Credo.Check.Readability.MultiAlias, []}, 176 | {Credo.Check.Readability.NestedFunctionCalls, []}, 177 | {Credo.Check.Readability.SeparateAliasRequire, []}, 178 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 179 | {Credo.Check.Readability.SinglePipe, []}, 180 | {Credo.Check.Readability.Specs, []}, 181 | {Credo.Check.Readability.StrictModuleLayout, []}, 182 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 183 | {Credo.Check.Refactor.ABCSize, []}, 184 | {Credo.Check.Refactor.AppendSingleItem, []}, 185 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 186 | {Credo.Check.Refactor.FilterReject, []}, 187 | {Credo.Check.Refactor.IoPuts, []}, 188 | {Credo.Check.Refactor.MapMap, []}, 189 | {Credo.Check.Refactor.ModuleDependencies, []}, 190 | {Credo.Check.Refactor.NegatedIsNil, []}, 191 | {Credo.Check.Refactor.PipeChainStart, []}, 192 | {Credo.Check.Refactor.RejectFilter, []}, 193 | {Credo.Check.Refactor.VariableRebinding, []}, 194 | {Credo.Check.Warning.LazyLogging, []}, 195 | {Credo.Check.Warning.LeakyEnvironment, []}, 196 | {Credo.Check.Warning.MapGetUnsafePass, []}, 197 | {Credo.Check.Warning.MixEnv, []}, 198 | {Credo.Check.Warning.UnsafeToAtom, []} 199 | 200 | # {Credo.Check.Refactor.MapInto, []}, 201 | 202 | # 203 | # Custom checks can be created using `mix credo.gen.check`. 204 | # 205 | ] 206 | } 207 | } 208 | ] 209 | } 210 | --------------------------------------------------------------------------------