diff --git a/.gitignore b/.gitignore
index 0cb6eeb..d4e690f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@
/pkg/
/spec/reports/
/tmp/
+.byebug_history
diff --git a/README.md b/README.md
index 5475554..93827d4 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,8 @@ deduplication via a shared string table (SST). Its purpose is to replace CSV for
CSV in Excel is very buggy and error prone. It's very efficient and can quickly write millions of rows with
low memory usage.
-Xlsxtream does not support formatting, charts, comments and a myriad of
-other [OOXML](https://en.wikipedia.org/wiki/Office_Open_XML) features. If you are looking for a
+Xlsxtream supports some basic cell styling, fonts and bordering. However if you fancy charts, comments and a myriad of
+other [OOXML](https://en.wikipedia.org/wiki/Office_Open_XML) features it might be worth looking for a
fully featured solution take a look at [caxslx](https://github.com/caxlsx/caxlsx).
Xlsxtream supports writing to files or IO-like objects, data is flushed as the ZIP compressor sees fit.
@@ -57,6 +57,13 @@ xlsx.write_worksheet 'AppendixSheet' do |sheet|
sheet.add_row [Time.now, 'Time-machine']
end
+# Individual cells can be styled
+# It does not impact performance or file size
+xlsx.write_worksheet 'Styled sheet' do |sheet|
+ sheet << [Cell.new('in red fill', fill: { color: 'FF0000' }), 'no style here']
+ sheet << [Cell.new('font style', font: { bold: true, italic: true })]
+end
+
# If you have highly repetitive data, you can enable Shared String Tables (SST)
# for the workbook or a single worksheet. The SST has to be kept in memory,
# so do not use it if you have a huge amount of rows or a little duplication
diff --git a/lib/xlsxtream.rb b/lib/xlsxtream.rb
index 7cdb9d0..164452b 100644
--- a/lib/xlsxtream.rb
+++ b/lib/xlsxtream.rb
@@ -8,3 +8,4 @@ module Xlsxtream
require "xlsxtream/worksheet"
require "xlsxtream/columns"
require "xlsxtream/row"
+require "xlsxtream/cell"
diff --git a/lib/xlsxtream/cell.rb b/lib/xlsxtream/cell.rb
new file mode 100644
index 0000000..afcb574
--- /dev/null
+++ b/lib/xlsxtream/cell.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Xlsxtream
+ class Cell
+ attr_reader :content, :style
+
+ def initialize(content = nil, style = {})
+ @content = content
+ @style = style
+ end
+
+ def styled?
+ !@style.empty?
+ end
+ end
+end
diff --git a/lib/xlsxtream/row.rb b/lib/xlsxtream/row.rb
index afba661..0994624 100644
--- a/lib/xlsxtream/row.rb
+++ b/lib/xlsxtream/row.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+
require "date"
require "xlsxtream/xml"
@@ -13,15 +14,20 @@ class Row
# ISO 8601 yyyy-mm-ddThh:mm:ss(.s)(Z|+hh:mm|-hh:mm)
TIME_PATTERN = /\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(?::[0-9]{2}(?:\.[0-9]{1,9})?)?(?:Z|[+-][0-9]{2}:[0-9]{2})?\z/.freeze
- TRUE_STRING = 'true'.freeze
- FALSE_STRING = 'false'.freeze
-
- DATE_STYLE = 1
- TIME_STYLE = 2
-
- def initialize(row, rownum, options = {})
+ TRUE_STRING = 'true'
+ FALSE_STRING = 'false'
+ GENERIC_STRING_TYPE = 'str' # can be used for cells containing formulas
+ SHARED_STRING_TYPE = 's' # used only for shared strings
+ INLINE_STRING_TYPE = 'inlineStr' # without formulas, can be of rich text format
+ NUMERIC_TYPE = 'n'
+ BOOLEAN_TYPE = 'b'
+ V_WRAPPER = ->(text) { "#{text}" }
+ IST_WRAPPER = ->(text) { "#{text}" }
+
+ def initialize(row, rownum, workbook, options = {})
@row = row
@rownum = rownum
+ @stylesheet = workbook.stylesheet
@sst = options[:sst]
@auto_format = options[:auto_format]
end
@@ -34,40 +40,68 @@ def to_xml
cid = "#{column}#{@rownum}"
column.next!
- if @auto_format && value.is_a?(String)
- value = auto_format(value)
- end
+ value = auto_format(value) if @auto_format && value.is_a?(String)
+ content, type = prepare_cell_content_and_resolve_type(value)
+
+ # no xml output for empty non-styled strings
+ next if content.nil? && !(value.respond_to?(:styled?) && value.styled?)
+
+ style = resolve_cell_style(value)
+
+ # The code below renders a single XML cell.
+ #
+ # As Xlsxtream library is optimized for performance and memory, it was decided to keep
+ # the cell rendering logic here and not in the `Cell` class despite OOP standards encourage
+ # otherwise. This is to avoid unnecessary memory allocations in case of low share of
+ # non-styled cell content (with no need to use `Cell` wrapper).
+ xml << %{'
+ xml << (type == INLINE_STRING_TYPE ? IST_WRAPPER : V_WRAPPER)[content]
+ xml << ''
+ end
- case value
- when Numeric
- xml << %Q{#{value}}
- when TrueClass, FalseClass
- xml << %Q{#{value ? 1 : 0}}
- when Time
- xml << %Q{#{time_to_oa_date(value)}}
- when DateTime
- xml << %Q{#{datetime_to_oa_date(value)}}
- when Date
- xml << %Q{#{date_to_oa_date(value)}}
- else
- value = value.to_s
+ xml << ''
+ end
+
+ private
- unless value.empty? # no xml output for for empty strings
- value = value.encode(ENCODING) if value.encoding != ENCODING
+ def prepare_cell_content_and_resolve_type(value)
+ case value
+ when Numeric
+ [value, NUMERIC_TYPE]
+ when TrueClass, FalseClass
+ [(value ? 1 : 0), BOOLEAN_TYPE]
+ when Time
+ [time_to_oa_date(value), nil]
+ when DateTime
+ [datetime_to_oa_date(value), nil]
+ when Date
+ [date_to_oa_date(value), nil]
+ when Cell
+ prepare_cell_content_and_resolve_type(value.content)
+ else
+ value = value.to_s
+ return [nil, nil] if value.empty?
+
+ value = value.encode(ENCODING) if value.encoding != ENCODING
- if @sst
- xml << %Q{#{@sst[value]}}
- else
- xml << %Q{#{XML.escape_value(value)}}
- end
- end
+ if @sst
+ [@sst[value], SHARED_STRING_TYPE]
+ else
+ [XML.escape_value(value), INLINE_STRING_TYPE]
end
end
-
- xml << ''
end
- private
+ def resolve_cell_style(value)
+ case value
+ when Time, DateTime then @stylesheet.datetime_style_id
+ when Date then @stylesheet.date_style_id
+ when Cell then @stylesheet.style_id(value)
+ end
+ end
# Detects and casts numbers, date, time in text
def auto_format(value)
diff --git a/lib/xlsxtream/styles/border.rb b/lib/xlsxtream/styles/border.rb
new file mode 100644
index 0000000..4fe1594
--- /dev/null
+++ b/lib/xlsxtream/styles/border.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Xlsxtream
+ module Styles
+ class Border
+ def to_xml
+ ''
+ end
+
+ def ==(other)
+ self.class == other.class && state == other.state
+ end
+ alias eql? ==
+
+ def hash
+ state.hash
+ end
+
+ def state
+ []
+ end
+ end
+ end
+end
diff --git a/lib/xlsxtream/styles/fill.rb b/lib/xlsxtream/styles/fill.rb
new file mode 100644
index 0000000..33fac50
--- /dev/null
+++ b/lib/xlsxtream/styles/fill.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Xlsxtream
+ module Styles
+ class Fill
+ NONE = 'none'
+ SOLID = 'solid'
+
+ # https://docs.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.patternvalues?view=openxml-2.8.1
+ MS_SUPPORTED = Set[
+ NONE,
+ SOLID,
+ 'darkDown',
+ 'darkGray',
+ 'darkGrid',
+ 'darkHorizontal',
+ 'darkTrellis',
+ 'darkUp',
+ 'darkVertical',
+ 'gray0625',
+ 'gray125',
+ 'lightDown',
+ 'lightGray',
+ 'lightGrid',
+ 'lightHorizontal',
+ 'lightTrellis',
+ 'lightUp',
+ 'lightVertical',
+ 'mediumGray'
+ ]
+
+ def initialize(pattern: nil, color: nil)
+ @pattern = pattern || (color ? SOLID : NONE)
+ @color = color
+ end
+
+ def to_xml
+ "#{pattern_tag}"
+ end
+
+ def ==(other)
+ self.class == other.class && state == other.state
+ end
+ alias eql? ==
+
+ def hash
+ state.hash
+ end
+
+ def state
+ [@pattern, @color]
+ end
+
+ private
+
+ def color_tag
+ return unless @color
+ %{}
+ end
+
+ def pattern_tag
+ return unless color_tag
+ %{#{color_tag}}
+ end
+ end
+ end
+end
diff --git a/lib/xlsxtream/styles/font.rb b/lib/xlsxtream/styles/font.rb
new file mode 100644
index 0000000..59fe887
--- /dev/null
+++ b/lib/xlsxtream/styles/font.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+module Xlsxtream
+ module Styles
+ class Font
+ FAMILY_IDS = {
+ '' => 0,
+ 'roman' => 1,
+ 'swiss' => 2,
+ 'modern' => 3,
+ 'script' => 4,
+ 'decorative' => 5
+ }.freeze
+
+ DEFAULT_UNDERLINE = 'single'
+ SUPPORTED_UNDERLINES = [
+ DEFAULT_UNDERLINE,
+ 'singleAccounting',
+ 'double',
+ 'doubleAccounting'
+ ]
+
+ def initialize(bold: nil,
+ italic: nil,
+ strike: nil,
+ underline: nil,
+ color: nil,
+ size: 12,
+ name: 'Calibri',
+ family: 'Swiss')
+ @bold = bold
+ @italic = italic
+ @strike = strike
+ @underline = resolve_underline(underline)
+ @color = color
+ @size = size
+ @name = name
+ @family_id = resolve_family_id(family)
+ end
+
+ def to_xml
+ "#{tags.join}"
+ end
+
+ def ==(other)
+ self.class == other.class && state == other.state
+ end
+ alias eql? ==
+
+ def hash
+ state.hash
+ end
+
+ def state
+ [@bold, @italic, @strike, @underline, @color, @size, @name, @family_id]
+ end
+
+ private
+
+ def tags
+ [
+ %{},
+ %{},
+ %{}
+ ].tap do |arr|
+ arr << %{} if @bold
+ arr << %{} if @italic
+ arr << %{} if @underline
+ arr << %{} if @strike
+ arr << %{} if @color
+ end
+ end
+
+ def resolve_family_id(value)
+ FAMILY_IDS[value.to_s.downcase] or fail Error,
+ "Invalid font family #{value}, must be one of "\
+ + FAMILY_IDS.keys.map(&:inspect).join(', ')
+ end
+
+ def resolve_underline(value)
+ return value if SUPPORTED_UNDERLINES.include?(value)
+ return DEFAULT_UNDERLINE if value == true
+ end
+ end
+ end
+end
diff --git a/lib/xlsxtream/styles/stylesheet.rb b/lib/xlsxtream/styles/stylesheet.rb
new file mode 100644
index 0000000..83fd817
--- /dev/null
+++ b/lib/xlsxtream/styles/stylesheet.rb
@@ -0,0 +1,139 @@
+# frozen_string_literal: true
+
+require 'set'
+
+require "xlsxtream/styles/border"
+require "xlsxtream/styles/fill"
+require "xlsxtream/styles/font"
+require "xlsxtream/styles/xf"
+
+module Xlsxtream
+ module Styles
+ class Stylesheet
+ # TODO: create a class for NumFormat to replace the constants below
+ DEFAULT_NUM_FORMAT_ID = 0
+ DATE_NUM_FORMAT_ID = 164
+ TIME_NUM_FORMAT_ID = 165
+ SUPPORTED_GLOBAL_OPTIONS = Set[:font, :fill]
+
+ # Parameter `global_options` - represents a hash of default options to be
+ # applied to the whole book, for example:
+ # `global_options = { font: { size: 10 }, fill: { color: 'FFFF00' } }`
+ def initialize(global_options = {})
+ @global_options = extract_styles_from global_options
+
+ @borders = Hash.new { |h, k| h[k] = h.size }
+ @fills = Hash.new { |h, k| h[k] = h.size }
+ @fonts = Hash.new { |h, k| h[k] = h.size }
+ @xfs = Hash.new { |h, k| h[k] = h.size }
+
+ populate_with_basic_xfs!
+ end
+
+ def style_id(cell)
+ xf_args = {}
+
+ xf_args[:numFmtId] = case cell.content
+ when Date then DATE_NUM_FORMAT_ID
+ when Time, DateTime then TIME_NUM_FORMAT_ID
+ else DEFAULT_NUM_FORMAT_ID
+ end
+
+ xf_args[:borderId] = @borders[Border.new]
+ xf_args[:fontId] = @fonts[Font.new(cell.style[:font] || {})]
+ xf_args[:fillId] = @fills[Fill.new(cell.style[:fill] || {})]
+ xf_args[:applyFill] = 1 unless xf_args[:fillId] == @fills[default_fill]
+
+ @xfs[Xf.new(xf_args)]
+ end
+
+ def to_xml
+ XML.header + XML.strip(<<-XML)
+
+
+
+
+
+ #{@fonts.keys.map(&:to_xml).join}
+ #{@fills.keys.map(&:to_xml).join}
+ #{@borders.keys.map(&:to_xml).join}
+ #{@xfs.keys.first.to_xml}
+ #{@xfs.keys.map(&:to_xml).join}
+
+
+
+
+
+
+ XML
+ end
+
+ def default_style_id
+ @default_style_id ||= @xfs[Xf.new(
+ numFmtId: DEFAULT_NUM_FORMAT_ID,
+ fontId: @fonts[default_font],
+ fillId: @fills[default_fill],
+ borderId: @borders[default_border]
+ )]
+ end
+
+ def datetime_style_id
+ @datetime_style_id ||= @xfs[Xf.new(
+ numFmtId: TIME_NUM_FORMAT_ID,
+ fontId: @fonts[default_font],
+ fillId: @fills[default_fill],
+ borderId: @borders[default_border],
+ applyNumberFormat: 1
+ )]
+ end
+
+ def date_style_id
+ @date_style_id ||= @xfs[Xf.new(
+ numFmtId: DATE_NUM_FORMAT_ID,
+ fontId: @fonts[default_font],
+ fillId: @fills[default_fill],
+ borderId: @borders[default_border],
+ applyNumberFormat: 1
+ )]
+ end
+
+ private
+
+ def default_border
+ # no options for `Border` are supported currently
+ @default_border ||= Border.new
+ end
+
+ def default_fill
+ @default_fill ||= begin
+ @global_options.key?(:fill) ? Fill.new(@global_options[:fill]) : Fill.new
+ end
+ end
+
+ def default_font
+ @default_font ||= begin
+ @global_options.key?(:font) ? Font.new(@global_options[:font]) : Font.new
+ end
+ end
+
+ def populate_with_basic_xfs!
+ # add default entities to the collections
+ @borders[default_border]
+ @fills[default_fill]
+ @fonts[default_font]
+
+ # populate xfs
+ default_style_id
+ date_style_id
+ datetime_style_id
+ end
+
+ def extract_styles_from(options)
+ SUPPORTED_GLOBAL_OPTIONS.reduce({}) do |result, option_key|
+ result[option_key] = options[option_key] if options[option_key].is_a?(Hash)
+ result
+ end
+ end
+ end
+ end
+end
diff --git a/lib/xlsxtream/styles/xf.rb b/lib/xlsxtream/styles/xf.rb
new file mode 100644
index 0000000..4c7ed91
--- /dev/null
+++ b/lib/xlsxtream/styles/xf.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Xlsxtream
+ module Styles
+ class Xf
+ REQUIRED_FIELDS = %i[
+ numFmtId
+ fontId
+ fillId
+ borderId
+ xfId
+ ].freeze
+
+ OPTIONAL_FIELDS = %i[
+ applyFill
+ applyNumberFormat
+ ].freeze
+
+ SUPPORTED_FIELDS = REQUIRED_FIELDS + OPTIONAL_FIELDS
+
+ def initialize(attrs)
+ @attrs = filter attrs
+ end
+
+ def to_xml
+ tag_attrs = @attrs.map { |k, v| %{#{k}="#{v}"} }.join(' ')
+
+ %{}
+ end
+
+ def ==(other)
+ self.class == other.class && state == other.state
+ end
+ alias eql? ==
+
+ def hash
+ state.hash
+ end
+
+ def state
+ @attrs
+ end
+
+ private
+
+ def filter(attrs)
+ attrs.select { |key| SUPPORTED_FIELDS.include?(key.to_sym) }
+ end
+ end
+ end
+end
diff --git a/lib/xlsxtream/workbook.rb b/lib/xlsxtream/workbook.rb
index 1fdb04b..aa7c4c5 100644
--- a/lib/xlsxtream/workbook.rb
+++ b/lib/xlsxtream/workbook.rb
@@ -4,21 +4,13 @@
require "xlsxtream/shared_string_table"
require "xlsxtream/worksheet"
require "xlsxtream/io/zip_tricks"
+require "xlsxtream/styles/stylesheet"
module Xlsxtream
class Workbook
-
- FONT_FAMILY_IDS = {
- '' => 0,
- 'roman' => 1,
- 'swiss' => 2,
- 'modern' => 3,
- 'script' => 4,
- 'decorative' => 5
- }.freeze
+ attr_reader :stylesheet, :io
class << self
-
def open(output, options = {})
workbook = new(output, options)
if block_given?
@@ -31,7 +23,6 @@ def open(output, options = {})
workbook
end
end
-
end
def initialize(output, options = {})
@@ -44,6 +35,7 @@ def initialize(output, options = {})
"The Xlsxtream::Workbook.new :io_wrapper option is deprecated. "\
"Please pass an IO wrapper instance as the first argument instead."
end
+
if output.is_a?(String) || !output.respond_to?(:<<)
@file = File.open(output, 'wb')
@io = IO::ZipTricks.new(@file)
@@ -54,8 +46,11 @@ def initialize(output, options = {})
@file = nil
@io = IO::ZipTricks.new(output)
end
+
@sst = SharedStringTable.new
@worksheets = []
+
+ @stylesheet = Styles::Stylesheet.new(options)
end
def add_worksheet(*args, &block)
@@ -111,7 +106,7 @@ def build_worksheet(name = nil, options = {})
@io.add_file "xl/worksheets/sheet#{sheet_id}.xml"
- worksheet = Worksheet.new(@io, :id => sheet_id, :name => name, :sst => sst, :auto_format => auto_format, :columns => columns)
+ worksheet = Worksheet.new(self, id: sheet_id, name: name, sst: sst, auto_format: auto_format, columns: columns)
@worksheets << worksheet
worksheet
@@ -146,55 +141,8 @@ def write_workbook
end
def write_styles
- font_options = @options.fetch(:font, {})
- font_size = font_options.fetch(:size, 12).to_s
- font_name = font_options.fetch(:name, 'Calibri').to_s
- font_family = font_options.fetch(:family, 'Swiss').to_s.downcase
- font_family_id = FONT_FAMILY_IDS[font_family] or fail Error,
- "Invalid font family #{font_family}, must be one of "\
- + FONT_FAMILY_IDS.keys.map(&:inspect).join(', ')
-
@io.add_file "xl/styles.xml"
- @io << XML.header
- @io << XML.strip(<<-XML)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- XML
+ @io << @stylesheet.to_xml
end
def write_sst
diff --git a/lib/xlsxtream/worksheet.rb b/lib/xlsxtream/worksheet.rb
index c4e8b41..3665761 100644
--- a/lib/xlsxtream/worksheet.rb
+++ b/lib/xlsxtream/worksheet.rb
@@ -4,8 +4,9 @@
module Xlsxtream
class Worksheet
- def initialize(io, options = {})
- @io = io
+ def initialize(workbook, options = {})
+ @workbook = workbook
+ @io = workbook.io
@rownum = 1
@closed = false
@options = options
@@ -14,7 +15,7 @@ def initialize(io, options = {})
end
def <<(row)
- @io << Row.new(row, @rownum, @options).to_xml
+ @io << Row.new(row, @rownum, @workbook, @options).to_xml
@rownum += 1
end
alias_method :add_row, :<<
diff --git a/test/xlsxtream/row_test.rb b/test/xlsxtream/row_test.rb
index db051a2..2d16c57 100644
--- a/test/xlsxtream/row_test.rb
+++ b/test/xlsxtream/row_test.rb
@@ -4,100 +4,103 @@
module Xlsxtream
class RowTest < Minitest::Test
+ DUMMY_IO = StringIO.new
+ DUMMY_WORKBOOK = Workbook.new(DUMMY_IO)
+
def test_empty_column
- row = Row.new([nil], 1)
+ row = Row.new([nil], 1, DUMMY_WORKBOOK)
expected = '
'
actual = row.to_xml
assert_equal expected, actual
end
def test_string_column
- row = Row.new(['hello'], 1)
+ row = Row.new(['hello'], 1, DUMMY_WORKBOOK)
expected = 'hello
'
actual = row.to_xml
assert_equal expected, actual
end
def test_symbol_column
- row = Row.new([:hello], 1)
+ row = Row.new([:hello], 1, DUMMY_WORKBOOK)
expected = 'hello
'
actual = row.to_xml
assert_equal expected, actual
end
def test_boolean_column
- row = Row.new([true], 1)
+ row = Row.new([true], 1, DUMMY_WORKBOOK)
actual = row.to_xml
expected = '1
'
assert_equal expected, actual
- row = Row.new([false], 1)
+ row = Row.new([false], 1, DUMMY_WORKBOOK)
actual = row.to_xml
expected = '0
'
assert_equal expected, actual
end
def test_text_boolean_column
- row = Row.new(['true'], 1, :auto_format => true)
+ row = Row.new(['true'], 1, DUMMY_WORKBOOK, :auto_format => true)
actual = row.to_xml
expected = '1
'
assert_equal expected, actual
- row = Row.new(['false'], 1, :auto_format => true)
+ row = Row.new(['false'], 1, DUMMY_WORKBOOK, :auto_format => true)
actual = row.to_xml
expected = '0
'
assert_equal expected, actual
end
def test_integer_column
- row = Row.new([1], 1)
+ row = Row.new([1], 1, DUMMY_WORKBOOK)
actual = row.to_xml
expected = '1
'
assert_equal expected, actual
end
def test_text_integer_column
- row = Row.new(['1'], 1, :auto_format => true)
+ row = Row.new(['1'], 1, DUMMY_WORKBOOK, :auto_format => true)
actual = row.to_xml
expected = '1
'
assert_equal expected, actual
end
def test_float_column
- row = Row.new([1.5], 1)
+ row = Row.new([1.5], 1, DUMMY_WORKBOOK)
actual = row.to_xml
expected = '1.5
'
assert_equal expected, actual
end
def test_text_float_column
- row = Row.new(['1.5'], 1, :auto_format => true)
+ row = Row.new(['1.5'], 1, DUMMY_WORKBOOK, :auto_format => true)
actual = row.to_xml
expected = '1.5
'
assert_equal expected, actual
end
def test_date_column
- row = Row.new([Date.new(1900, 1, 1)], 1)
+ row = Row.new([Date.new(1900, 1, 1)], 1, DUMMY_WORKBOOK)
actual = row.to_xml
expected = '2.0
'
assert_equal expected, actual
end
def test_text_date_column
- row = Row.new(['1900-01-01'], 1, :auto_format => true)
+ row = Row.new(['1900-01-01'], 1, DUMMY_WORKBOOK, :auto_format => true)
actual = row.to_xml
expected = '2.0
'
assert_equal expected, actual
end
def test_invalid_text_date_column
- row = Row.new(['1900-02-29'], 1, :auto_format => true)
+ row = Row.new(['1900-02-29'], 1, DUMMY_WORKBOOK, :auto_format => true)
actual = row.to_xml
expected = '1900-02-29
'
assert_equal expected, actual
end
def test_date_time_column
- row = Row.new([DateTime.new(1900, 1, 1, 12, 0, 0, '+00:00')], 1)
+ row = Row.new([DateTime.new(1900, 1, 1, 12, 0, 0, '+00:00')], 1, DUMMY_WORKBOOK)
actual = row.to_xml
expected = '2.5
'
assert_equal expected, actual
@@ -113,26 +116,26 @@ def test_text_date_time_column
'1900-01-01T12:00:00.000000000Z'
]
candidates.each do |timestamp|
- row = Row.new([timestamp], 1, :auto_format => true)
+ row = Row.new([timestamp], 1, DUMMY_WORKBOOK, :auto_format => true)
actual = row.to_xml
expected = '2.5
'
assert_equal expected, actual
end
- row = Row.new(['1900-01-01T12'], 1, :auto_format => true)
+ row = Row.new(['1900-01-01T12'], 1, DUMMY_WORKBOOK, :auto_format => true)
actual = row.to_xml
expected = '2.5
'
refute_equal expected, actual
end
def test_invalid_text_date_time_column
- row = Row.new(['1900-02-29T12:00'], 1, :auto_format => true)
+ row = Row.new(['1900-02-29T12:00'], 1, DUMMY_WORKBOOK, :auto_format => true)
actual = row.to_xml
expected = '1900-02-29T12:00
'
assert_equal expected, actual
end
def test_time_column
- row = Row.new([Time.new(1900, 1, 1, 12, 0, 0, '+00:00')], 1)
+ row = Row.new([Time.new(1900, 1, 1, 12, 0, 0, '+00:00')], 1, DUMMY_WORKBOOK)
actual = row.to_xml
expected = '2.5
'
assert_equal expected, actual
@@ -140,17 +143,52 @@ def test_time_column
def test_string_column_with_shared_string_table
mock_sst = { 'hello' => 0 }
- row = Row.new(['hello'], 1, :sst => mock_sst)
+ row = Row.new(['hello'], 1, DUMMY_WORKBOOK, :sst => mock_sst)
expected = '0
'
actual = row.to_xml
assert_equal expected, actual
end
def test_multiple_columns
- row = Row.new(['foo', nil, 23], 1)
+ row = Row.new(['foo', nil, 23], 1, DUMMY_WORKBOOK)
expected = 'foo23
'
actual = row.to_xml
assert_equal expected, actual
end
+
+ def test_styled_column_with_really_no_style
+ row = Row.new([Cell.new('foo')], 1, DUMMY_WORKBOOK)
+ expected_style_id = DUMMY_WORKBOOK.stylesheet.default_style_id
+
+ expected = XML.strip(<<-HTML)
+
+
+
+ foo
+
+
+
+ HTML
+
+ assert_equal expected, row.to_xml
+ end
+
+ def test_styled_columns
+ cells = [
+ Cell.new('Red fill', fill: { color: 'FF0000' }),
+ nil,
+ Cell.new(23, font: { bold: true, italic: true })
+ ]
+ row = Row.new(cells, 1, DUMMY_WORKBOOK)
+
+ expected = XML.strip(<<-HTML)
+
+ Red fill
+ 23
+
+ HTML
+
+ assert_equal expected, row.to_xml
+ end
end
end
diff --git a/test/xlsxtream/styles/stylesheet_test.rb b/test/xlsxtream/styles/stylesheet_test.rb
new file mode 100644
index 0000000..68fce12
--- /dev/null
+++ b/test/xlsxtream/styles/stylesheet_test.rb
@@ -0,0 +1,121 @@
+require 'test_helper'
+
+module Xlsxtream; module Styles
+ class StylesheetTest < Minitest::Test
+ DEFAULT_NUM_FORMAT_ID = 0
+ DATE_NUM_FORMAT_ID = 164
+ TIME_NUM_FORMAT_ID = 165
+
+ SUPPORTED_OPTIONS = {
+ fill: { color: 'FF0000' }, font: { bold: true, italic: true }
+ }
+
+ UNSUPPORTED_OPTIONS = {
+ foo: { color: 'FF0000' }, bar: { bold: true, italic: true }
+ }
+
+ def test_constants
+ assert_equal DEFAULT_NUM_FORMAT_ID, Stylesheet::DEFAULT_NUM_FORMAT_ID
+ assert_equal DATE_NUM_FORMAT_ID, Stylesheet::DATE_NUM_FORMAT_ID
+ assert_equal TIME_NUM_FORMAT_ID, Stylesheet::TIME_NUM_FORMAT_ID
+ end
+
+ def test_initialize_populates_basic_xfs
+ workbook = Workbook.new(StringIO.new)
+ assert_equal 3, peek_num_of_styles(workbook.stylesheet)
+ end
+
+ def test_initialize_respects_global_options
+ workbook = Workbook.new(StringIO.new, SUPPORTED_OPTIONS)
+
+ # does not create a new default
+ assert_equal 3, peek_num_of_styles(workbook.stylesheet)
+
+ # the default style is set using global options
+ actual_style_id = workbook.stylesheet.style_id(Cell.new('foo', SUPPORTED_OPTIONS))
+ assert_equal workbook.stylesheet.default_style_id, actual_style_id
+ end
+
+ def test_style_id
+ workbook = Workbook.new(StringIO.new)
+ initial_num_styles = peek_num_of_styles(workbook.stylesheet)
+
+ # creates a new style for a cell with the new style
+ cell = Cell.new('foo', SUPPORTED_OPTIONS)
+ assert_equal initial_num_styles, workbook.stylesheet.style_id(cell)
+
+ # does not create a new style for the cell with the same style
+ cell = Cell.new('bar', SUPPORTED_OPTIONS.dup)
+ assert_equal initial_num_styles, workbook.stylesheet.style_id(cell)
+
+ # does not respect unsupported options
+ cell = Cell.new('foo', UNSUPPORTED_OPTIONS)
+ assert_equal 0, workbook.stylesheet.style_id(cell)
+ end
+
+ def test_to_xml
+ workbook = Workbook.new(StringIO.new)
+
+ cell = Cell.new('foo', SUPPORTED_OPTIONS)
+ workbook.stylesheet.style_id(cell)
+
+ expected = \
+ ''"\r\n" \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ '' \
+ ''
+
+ actual = workbook.stylesheet.to_xml
+ assert_equal expected, actual
+ end
+
+ private
+
+ def peek_num_of_styles(stylesheet)
+ stylesheet.instance_variable_get("@xfs").size
+ end
+ end
+end; end
diff --git a/test/xlsxtream/workbook_test.rb b/test/xlsxtream/workbook_test.rb
index a589cae..890f145 100644
--- a/test/xlsxtream/workbook_test.rb
+++ b/test/xlsxtream/workbook_test.rb
@@ -384,24 +384,20 @@ def test_styles_content
'' \
'' \
'' \
- '' \
+ '' \
'' \
- '' \
- '' \
- '' \
- '' \
'' \
'' \
'' \
'' \
'' \
'' \
- '' \
+ '' \
'' \
'' \
- '' \
- '' \
- '' \
+ '' \
+ '' \
+ '' \
'' \
'' \
'' \
@@ -415,8 +411,8 @@ def test_styles_content
def test_custom_font_size
iow_spy = io_wrapper_spy
- font_options = { :size => 23 }
- Workbook.open(iow_spy, :font => font_options) {}
+ font_options = { size: 23 }
+ Workbook.open(iow_spy, font: font_options) {}
expected = ''
actual = iow_spy['xl/styles.xml'][/]+>/]
assert_equal expected, actual
diff --git a/test/xlsxtream/worksheet_test.rb b/test/xlsxtream/worksheet_test.rb
index 9f2aa3b..363f291 100644
--- a/test/xlsxtream/worksheet_test.rb
+++ b/test/xlsxtream/worksheet_test.rb
@@ -1,13 +1,14 @@
# frozen_string_literal: true
require 'test_helper'
require 'stringio'
+require 'xlsxtream/workbook'
require 'xlsxtream/worksheet'
module Xlsxtream
class WorksheetTest < Minitest::Test
def test_empty_worksheet
io = StringIO.new
- ws = Worksheet.new(io)
+ ws = Worksheet.new(mock_workbook(io))
ws.close
expected = \
''"\r\n" \
@@ -17,7 +18,7 @@ def test_empty_worksheet
def test_add_row
io = StringIO.new
- ws = Worksheet.new(io)
+ ws = Worksheet.new(mock_workbook(io))
ws << ['foo']
ws.add_row ['bar']
ws.close
@@ -33,7 +34,7 @@ def test_add_row
def test_add_row_with_sst_option
io = StringIO.new
mock_sst = { 'foo' => 0 }
- ws = Worksheet.new(io, :sst => mock_sst)
+ ws = Worksheet.new(mock_workbook(io), :sst => mock_sst)
ws << ['foo']
ws.close
expected = \
@@ -46,7 +47,7 @@ def test_add_row_with_sst_option
def test_add_row_with_auto_format_option
io = StringIO.new
- ws = Worksheet.new(io, :auto_format => true)
+ ws = Worksheet.new(mock_workbook(io), :auto_format => true)
ws << ['1.5']
ws.close
expected = \
@@ -59,7 +60,7 @@ def test_add_row_with_auto_format_option
def test_add_columns_via_worksheet_options
io = StringIO.new
- ws = Worksheet.new(io, { :columns => [ {}, {}, { :width_pixels => 42 } ] } )
+ ws = Worksheet.new(mock_workbook(io), { :columns => [ {}, {}, { :width_pixels => 42 } ] } )
ws.close
expected = \
''"\r\n" \
@@ -74,7 +75,7 @@ def test_add_columns_via_worksheet_options
def test_add_columns_via_worksheet_options_and_add_rows
io = StringIO.new
- ws = Worksheet.new(io, { :columns => [ {}, {}, { :width_pixels => 42 } ] } )
+ ws = Worksheet.new(mock_workbook(io), { :columns => [ {}, {}, { :width_pixels => 42 } ] } )
ws << ['foo']
ws.add_row ['bar']
ws.close
@@ -93,13 +94,24 @@ def test_add_columns_via_worksheet_options_and_add_rows
end
def test_respond_to_id
- ws = Worksheet.new(StringIO.new, id: 1)
+ ws = Worksheet.new(mock_workbook, id: 1)
assert_equal 1, ws.id
end
def test_respond_to_name
- ws = Worksheet.new(StringIO.new, name: 'test')
+ ws = Worksheet.new(mock_workbook, name: 'test')
assert_equal 'test', ws.name
end
+
+ private
+
+ def mock_workbook(io = StringIO.new)
+ ss = Styles::Stylesheet.new
+
+ Minitest::Mock.new
+ .expect(:io, io)
+ .expect(:stylesheet, ss)
+ .expect(:stylesheet, ss)
+ end
end
end