Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement basic cell styling #53

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
/pkg/
/spec/reports/
/tmp/
.byebug_history
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/xlsxtream.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ module Xlsxtream
require "xlsxtream/worksheet"
require "xlsxtream/columns"
require "xlsxtream/row"
require "xlsxtream/cell"
16 changes: 16 additions & 0 deletions lib/xlsxtream/cell.rb
Original file line number Diff line number Diff line change
@@ -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
102 changes: 68 additions & 34 deletions lib/xlsxtream/row.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# frozen_string_literal: true

require "date"
require "xlsxtream/xml"

Expand All @@ -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) { "<v>#{text}</v>" }
IST_WRAPPER = ->(text) { "<is><t>#{text}</t></is>" }

def initialize(row, rownum, workbook, options = {})
@row = row
@rownum = rownum
@stylesheet = workbook.stylesheet
@sst = options[:sst]
@auto_format = options[:auto_format]
end
Expand All @@ -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 << %{<c r="#{cid}"}
xml << %{ t="#{type}"} if type
xml << %{ s="#{style}"} if style
xml << '>'
xml << (type == INLINE_STRING_TYPE ? IST_WRAPPER : V_WRAPPER)[content]
xml << '</c>'
end

case value
when Numeric
xml << %Q{<c r="#{cid}" t="n"><v>#{value}</v></c>}
when TrueClass, FalseClass
xml << %Q{<c r="#{cid}" t="b"><v>#{value ? 1 : 0}</v></c>}
when Time
xml << %Q{<c r="#{cid}" s="#{TIME_STYLE}"><v>#{time_to_oa_date(value)}</v></c>}
when DateTime
xml << %Q{<c r="#{cid}" s="#{TIME_STYLE}"><v>#{datetime_to_oa_date(value)}</v></c>}
when Date
xml << %Q{<c r="#{cid}" s="#{DATE_STYLE}"><v>#{date_to_oa_date(value)}</v></c>}
else
value = value.to_s
xml << '</row>'
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{<c r="#{cid}" t="s"><v>#{@sst[value]}</v></c>}
else
xml << %Q{<c r="#{cid}" t="inlineStr"><is><t>#{XML.escape_value(value)}</t></is></c>}
end
end
if @sst
[@sst[value], SHARED_STRING_TYPE]
else
[XML.escape_value(value), INLINE_STRING_TYPE]
end
end

xml << '</row>'
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)
Expand Down
24 changes: 24 additions & 0 deletions lib/xlsxtream/styles/border.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module Xlsxtream
module Styles
class Border
def to_xml
'<border/>'
end

def ==(other)
self.class == other.class && state == other.state
end
alias eql? ==

def hash
state.hash
end

def state
[]
end
end
end
end
67 changes: 67 additions & 0 deletions lib/xlsxtream/styles/fill.rb
Original file line number Diff line number Diff line change
@@ -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
"<fill>#{pattern_tag}</fill>"
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
%{<fgColor rgb="#{@color}"/>}
end

def pattern_tag
return unless color_tag
%{<patternFill patternType="#{@pattern}">#{color_tag}</patternFill>}
end
end
end
end
86 changes: 86 additions & 0 deletions lib/xlsxtream/styles/font.rb
Original file line number Diff line number Diff line change
@@ -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
"<font>#{tags.join}</font>"
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
[
%{<sz val="#{@size}"/>},
%{<name val="#{@name}"/>},
%{<family val="#{@family_id}"/>}
].tap do |arr|
arr << %{<b val="true"/>} if @bold
arr << %{<i val="true"/>} if @italic
arr << %{<u val="#{@underline}"/>} if @underline
arr << %{<strike val="true"/>} if @strike
arr << %{<color rgb="#{@color}"/>} 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
Loading