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