spreadsheet_builder 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +43 -0
- data/LICENSE +20 -0
- data/fail_width.xls +0 -0
- data/lib/spreadsheet_builder.rb +24 -0
- data/lib/spreadsheet_builder/border.rb +163 -0
- data/lib/spreadsheet_builder/builder.rb +194 -0
- data/lib/spreadsheet_builder/css_parser.rb +163 -0
- data/lib/spreadsheet_builder/css_rule.rb +42 -0
- data/lib/spreadsheet_builder/data.rb +31 -0
- data/lib/spreadsheet_builder/html_parser.rb +96 -0
- data/lib/spreadsheet_builder/palette.rb +32 -0
- data/lib/spreadsheet_builder/translations.rb +37 -0
- data/pass_height.xls +0 -0
- data/pass_size.xls +0 -0
- data/pass_size2.xls +0 -0
- data/pass_size3.xls +0 -0
- data/pass_size_percent.xls +0 -0
- data/pass_width.xls +0 -0
- data/spreadsheet_builder.gemspec +24 -0
- data/test.css +12 -0
- data/test.html +6 -0
- data/test.rb +51 -0
- data/test.xls +0 -0
- data/test2.css +20 -0
- data/test2.html +46 -0
- data/test2.xls +0 -0
- data/test_color.xls +0 -0
- data/test_width_and_height.xls +0 -0
- data/test_width_and_height2.xls +0 -0
- data/vertical_align.xls +0 -0
- metadata +130 -0
@@ -0,0 +1,163 @@
|
|
1
|
+
module SpreadsheetBuilder
|
2
|
+
class CssParser
|
3
|
+
|
4
|
+
def self.px_from_input(val, base = :width)
|
5
|
+
case val
|
6
|
+
when /%$/
|
7
|
+
val.to_f / 100 * { width: 1024, height: 16 }[base]
|
8
|
+
when /px$/
|
9
|
+
val.to_f
|
10
|
+
when /cm$/
|
11
|
+
val.to_f * 37.795275591
|
12
|
+
when /em$/
|
13
|
+
val.to_f * 16
|
14
|
+
when /pt$/
|
15
|
+
val.to_f * 16 / 12
|
16
|
+
else
|
17
|
+
nil
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.pt_from_input(val, base = :width)
|
22
|
+
px_from_input(val, base) * 12 / 16
|
23
|
+
end
|
24
|
+
|
25
|
+
attr_reader :cache, :rules
|
26
|
+
|
27
|
+
def initialize
|
28
|
+
reset
|
29
|
+
end
|
30
|
+
|
31
|
+
def reset(level = :parser)
|
32
|
+
method = "reset_#{level}"
|
33
|
+
|
34
|
+
__send__(method)
|
35
|
+
end
|
36
|
+
|
37
|
+
def format_from_node(node)
|
38
|
+
format_from_klass_tree(klass_tree_from_node(node), node)
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
def accepted_keys
|
43
|
+
# TODO Keep these in a config
|
44
|
+
keys = %w{ color background-color font-size font-weight vertical-align text-align border border-width border-style border-color height width }
|
45
|
+
dirs = %w{ top bottom left right }
|
46
|
+
types = %w{ width style color }
|
47
|
+
dirs.each do |dir|
|
48
|
+
keys << "border-#{dir}"
|
49
|
+
types.each { |type| keys << "border-#{dir}-#{type}" }
|
50
|
+
end
|
51
|
+
keys
|
52
|
+
end
|
53
|
+
|
54
|
+
def klass_tree_from_node(base)
|
55
|
+
tree = (base.ancestors.reverse.drop(1) << base)
|
56
|
+
tree.map { |n, t| klass_node_from_node(n) }
|
57
|
+
end
|
58
|
+
|
59
|
+
def format_from_klass_tree(klass, node)
|
60
|
+
# klass is uniq to each node (because of first-child, nth-child, etc)
|
61
|
+
# so caching with the class is useless
|
62
|
+
# TODO find a better way to cache that works
|
63
|
+
if @cache[klass]
|
64
|
+
format = @cache[klass]
|
65
|
+
else
|
66
|
+
declarations = declarations_from_klass_tree(klass)
|
67
|
+
format = format_from_declarations(declarations, node)
|
68
|
+
@cache[klass] = format
|
69
|
+
end
|
70
|
+
|
71
|
+
format || {}
|
72
|
+
end
|
73
|
+
|
74
|
+
def klass_node_from_node(node)
|
75
|
+
name = node.name
|
76
|
+
root = node.ancestors.last
|
77
|
+
siblings = node.parent.element_children
|
78
|
+
|
79
|
+
index = siblings.index(node) + 1
|
80
|
+
first_of_kind = root.css(name).first == node
|
81
|
+
last_of_kind = root.css(name).last == node
|
82
|
+
|
83
|
+
klasses = node.attributes["class"] && node.attributes["class"].value
|
84
|
+
klasses = klasses.to_s.split(/ /).map { |k| "." + k }
|
85
|
+
klasses << name
|
86
|
+
klasses << "#{name}:nth-child(#{index})"
|
87
|
+
klasses << "#{name}:nth-child(odd)" if index.odd?
|
88
|
+
klasses << "#{name}:nth-child(even)" if index.even?
|
89
|
+
klasses << "#{name}:first-child)" if index == 1
|
90
|
+
klasses << "#{name}:last-child)" if index == siblings.length
|
91
|
+
klasses << "#{name}:first-of-kind)" if first_of_kind
|
92
|
+
klasses << "#{name}:last-of-kind)" if last_of_kind
|
93
|
+
klasses
|
94
|
+
end
|
95
|
+
|
96
|
+
def find_rules(tree)
|
97
|
+
@rules.each_with_object(Hash.new {|h,k| h[k] = []}) do |rule,rules_found|
|
98
|
+
found = rule.find_selector(tree)
|
99
|
+
rules_found[found.length] << rule if found
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def reset_none
|
104
|
+
end
|
105
|
+
|
106
|
+
def reset_rules(parser = @parser)
|
107
|
+
@rules = []
|
108
|
+
parser.each_rule_set(:all) { |r|
|
109
|
+
@rules << SpreadsheetBuilder::CssRule.new(r)
|
110
|
+
}
|
111
|
+
@rules.delete_if { |rule|
|
112
|
+
rule.each_declaration do |key,_|
|
113
|
+
rule.remove_declaration!(key) unless accepted_keys.include?(key)
|
114
|
+
end
|
115
|
+
rule.declarations.empty?
|
116
|
+
}
|
117
|
+
end
|
118
|
+
|
119
|
+
def translate_declaration(key, val)
|
120
|
+
f = TRANSLATIONS[key].call(val) if key && val
|
121
|
+
f || {}
|
122
|
+
end
|
123
|
+
|
124
|
+
def format_from_declarations(declarations, node)
|
125
|
+
denied = Hash.new { |h,k| h[k] = [] }.merge(
|
126
|
+
"table" => %w{ width height },
|
127
|
+
"tr" => %w{ width }
|
128
|
+
)
|
129
|
+
declarations.delete_if { |k,v|
|
130
|
+
v.nil? || v.empty? || denied[node.name].include?(k)
|
131
|
+
}
|
132
|
+
|
133
|
+
declarations.each_with_object({}) { |(k,v), format|
|
134
|
+
format.merge!(translate_declaration(k,v[:value]))
|
135
|
+
}
|
136
|
+
end
|
137
|
+
|
138
|
+
def declarations_from_klass_tree(klass)
|
139
|
+
rules = find_rules(klass).sort_by { |specificity,_| specificity }
|
140
|
+
declarations = rules.map { |_,rules|
|
141
|
+
rules.inject({}) { |dec, r| dec.merge(r.declarations) }
|
142
|
+
}.inject({},&:merge)
|
143
|
+
end
|
144
|
+
|
145
|
+
def reset_cache
|
146
|
+
@cache = {}
|
147
|
+
end
|
148
|
+
|
149
|
+
def reset_parser
|
150
|
+
parser = CssParser::Parser.new
|
151
|
+
# TODO load these files from a config
|
152
|
+
parser.load_uri!("file://#{Dir.pwd}/test2.css")
|
153
|
+
# TODO or even better parse the html doc for spreadsheet links
|
154
|
+
# and load those
|
155
|
+
#parser.load_uri!("file://#{Dir.pwd}/test2.css")
|
156
|
+
|
157
|
+
# Explicity reset rules to avoid infinite loop or bad data
|
158
|
+
reset_rules(parser)
|
159
|
+
reset_cache
|
160
|
+
@parser = parser
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module SpreadsheetBuilder
|
2
|
+
class CssRule
|
3
|
+
def initialize(rule)
|
4
|
+
@rule = rule
|
5
|
+
end
|
6
|
+
|
7
|
+
def declarations
|
8
|
+
@rule.instance_variable_get(:@declarations)
|
9
|
+
end
|
10
|
+
|
11
|
+
def selectors
|
12
|
+
@rule.selectors.map { |s|
|
13
|
+
s = s.split(/[\s>]/).map { |node| node.split('.') }
|
14
|
+
s.each do |node|
|
15
|
+
n = node.length
|
16
|
+
node[1...n] = node[1...n].map { |k| '.' + k }
|
17
|
+
node.delete_if { |k| k.empty? || k == "." }
|
18
|
+
end
|
19
|
+
}.sort_by(&:length)
|
20
|
+
end
|
21
|
+
|
22
|
+
def find_selector(tree)
|
23
|
+
selectors.find { |s|
|
24
|
+
next unless tree.last & s.last == s.last
|
25
|
+
|
26
|
+
s[0..-2].reverse.inject(tree.length - 2) do |i, s_node|
|
27
|
+
break false unless i
|
28
|
+
tree[0..i].index { |kt_node| kt_node & s_node == s_node }
|
29
|
+
end
|
30
|
+
}
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
def method_missing(method, *attrs, &block)
|
35
|
+
if @rule.respond_to?(method)
|
36
|
+
@rule.__send__(method, *attrs, &block)
|
37
|
+
else
|
38
|
+
super
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module SpreadsheetBuilder
|
2
|
+
def self.from_data(data)
|
3
|
+
builder = Builder.new
|
4
|
+
[:merges, :row_heights, :col_widths].each do |d|
|
5
|
+
if data[d]
|
6
|
+
builder.instance_variable_get("@#{d}".to_sym).merge!(data[d])
|
7
|
+
end
|
8
|
+
end
|
9
|
+
if data[:sheets]
|
10
|
+
builder.instance_variable_set(:@sheets, data[:sheets])
|
11
|
+
end
|
12
|
+
if data[:cells]
|
13
|
+
data[:cells].group_by { |c| c[:sheet] }.each do |sheet, cells|
|
14
|
+
sheet ||= 0
|
15
|
+
if sheet.respond_to?(:to_str)
|
16
|
+
builder.set_sheet_by_name(sheet)
|
17
|
+
else
|
18
|
+
builder.set_sheet(sheet)
|
19
|
+
end
|
20
|
+
cells.each do |cell|
|
21
|
+
# row and col required
|
22
|
+
builder.set_cell_value(cell[:row], cell[:col], cell[:value])
|
23
|
+
if format = cell[:format]
|
24
|
+
builder.add_format_to_cell(cell[:row], cell[:col], format)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
builder
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
module SpreadsheetBuilder
|
2
|
+
class HtmlParser
|
3
|
+
def self.from_slim(file, options = {}, context = self, &block)
|
4
|
+
html = Slim::Template.new(file, options).render(context, &block)
|
5
|
+
new(html)
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.from_erb(file)
|
9
|
+
html = File.read(file)
|
10
|
+
template = ERB.new(html)
|
11
|
+
html = template.result
|
12
|
+
|
13
|
+
new(html)
|
14
|
+
end
|
15
|
+
|
16
|
+
def initialize(html)
|
17
|
+
@html = html
|
18
|
+
@css = SpreadsheetBuilder::CssParser.new
|
19
|
+
end
|
20
|
+
|
21
|
+
def build(force_level = :none)
|
22
|
+
SpreadsheetBuilder.from_data(to_data(force_level))
|
23
|
+
end
|
24
|
+
|
25
|
+
# TODO clean this up
|
26
|
+
def to_data(force_level = :none)
|
27
|
+
@css.reset(force_level)
|
28
|
+
|
29
|
+
# need to check for attributes colspan and row span
|
30
|
+
cells = []
|
31
|
+
merges = []
|
32
|
+
col_widths = {}
|
33
|
+
row_heights = {}
|
34
|
+
|
35
|
+
doc = Nokogiri::HTML(@html)
|
36
|
+
tb = doc.css('table').first
|
37
|
+
|
38
|
+
# ignoring specified formats for anything other than table tr td/th
|
39
|
+
tb_format = @css.format_from_node(tb)
|
40
|
+
|
41
|
+
doc.css('tr').each_with_index do |tr, row|
|
42
|
+
tr_format = tb_format.merge(@css.format_from_node(tr))
|
43
|
+
|
44
|
+
tr.css('td, th').each_with_index do |td, col|
|
45
|
+
|
46
|
+
rowheight = td.attributes["rowheight"]
|
47
|
+
colwidth = td.attributes["colwidth"]
|
48
|
+
rowspan = td.attributes["rowspan"]
|
49
|
+
colspan = td.attributes["colspan"]
|
50
|
+
|
51
|
+
rowheight &&= rowheight.value.to_i
|
52
|
+
colwidth &&= colwidth.value.to_i
|
53
|
+
rowspan &&= rowspan.value.to_i
|
54
|
+
colspan &&= colspan.value.to_i
|
55
|
+
|
56
|
+
add_td_to_cells(row, col, td, tr_format, cells)
|
57
|
+
if colspan
|
58
|
+
(1..colspan-1).each {|t|
|
59
|
+
add_td_to_cells(row, col+t, td, tr_format, cells)
|
60
|
+
}
|
61
|
+
end
|
62
|
+
if rowspan
|
63
|
+
(1..rowspan-1).each {|t|
|
64
|
+
add_td_to_cells(row+t, col, td, tr_format, cells)
|
65
|
+
}
|
66
|
+
end
|
67
|
+
if colspan || rowspan
|
68
|
+
merges << [
|
69
|
+
row, col, row + (rowspan || 1)-1, col + (colspan || 1)-1
|
70
|
+
]
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
{ cells: cells, merges: { 0 => merges } }
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
# TODO Document
|
80
|
+
def add_td_to_cells(row, col, td, tr_format, cells)
|
81
|
+
found = cells.find { |cell| cell[:row] == row && cell[:col] == col}
|
82
|
+
unless found
|
83
|
+
td_format = tr_format.merge(@css.format_from_node(td))
|
84
|
+
cells << {
|
85
|
+
row: row,
|
86
|
+
col: col,
|
87
|
+
value: td.text.strip,
|
88
|
+
format: td_format,
|
89
|
+
path: td.css_path
|
90
|
+
}
|
91
|
+
else
|
92
|
+
add_td_to_cells(row, col + 1, td, tr_format, cells)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module SpreadsheetBuilder
|
2
|
+
Rgb = Spreadsheet::Excel::Rgb
|
3
|
+
|
4
|
+
CUSTOM_PALETTE = {
|
5
|
+
:xls_color_0 => Rgb.new(0,0,0),
|
6
|
+
:xls_color_1 => Rgb.new(255,255,255),
|
7
|
+
:xls_color_2 => Rgb.new(204,204,204),
|
8
|
+
:xls_color_3 => Rgb.new(249,249,249)
|
9
|
+
}
|
10
|
+
|
11
|
+
PALETTE = Shade::Palette.new do |p|
|
12
|
+
Rgb.class_variable_get(:@@RGB_MAP).merge(CUSTOM_PALETTE).each do
|
13
|
+
|name, value|
|
14
|
+
p.add("##{value.to_i.to_s(16).ljust(6, "0")}", name.to_s)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
module Palette
|
19
|
+
def self._color_from_input(input)
|
20
|
+
input = input.to_s
|
21
|
+
if input =~ /^rgb/i
|
22
|
+
_, r, g, b = input.match(/^rgba*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)[^\)]*/)
|
23
|
+
r, g, b = [r, g, b].map(&:to_i)
|
24
|
+
input = "##{Spreadsheet::Excel::Rgb.new(r, g, b).as_hex.ljust(6, "0")}"
|
25
|
+
end
|
26
|
+
|
27
|
+
# Assume a color is always found
|
28
|
+
color = PALETTE.nearest_value(input).name.to_sym
|
29
|
+
color
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module SpreadsheetBuilder
|
2
|
+
TRANSLATIONS = {}
|
3
|
+
TRANSLATIONS["text-align"] = Proc.new { |val|
|
4
|
+
allowed = %w{ left center right justify }
|
5
|
+
{ horizontal_align: val.to_sym } if allowed.include?(val)
|
6
|
+
}
|
7
|
+
TRANSLATIONS["vertical-align"] = Proc.new { |val|
|
8
|
+
allowed = %{ top middle bottom }
|
9
|
+
{ vertical_align: val.to_sym } if allowed.include?(val)
|
10
|
+
}
|
11
|
+
TRANSLATIONS["color"] = Proc.new { |val|
|
12
|
+
{ color: Palette._color_from_input(val) }
|
13
|
+
}
|
14
|
+
TRANSLATIONS["background-color"] = Proc.new { |val|
|
15
|
+
{ pattern_fg_color: Palette._color_from_input(val), pattern: 1 }
|
16
|
+
}
|
17
|
+
TRANSLATIONS["font-size"] = Proc.new { |val|
|
18
|
+
{ size: SpreadsheetBuilder::CssParser.pt_from_input(val, :height) }
|
19
|
+
}
|
20
|
+
TRANSLATIONS["font-weight"] = Proc.new { |val|
|
21
|
+
accepted = %{ bold normal }
|
22
|
+
if accepted.inlcude?(val)
|
23
|
+
{ weight: val }
|
24
|
+
end
|
25
|
+
}
|
26
|
+
%w{ border border-top border-bottom border-left border-right border-width border-top-width border-bottom-width border-left-width border-right-width }.each do
|
27
|
+
|key|
|
28
|
+
TRANSLATIONS[key] = Proc.new { |val| Border.new(key, val).format }
|
29
|
+
end
|
30
|
+
# TODO Prove these ratios
|
31
|
+
TRANSLATIONS["height"] = Proc.new { |val|
|
32
|
+
{ height: SpreadsheetBuilder::CssParser.pt_from_input(val) }
|
33
|
+
}
|
34
|
+
TRANSLATIONS["width"] = Proc.new { |val|
|
35
|
+
{ width: SpreadsheetBuilder::CssParser.px_from_input(val) / 7.5 }
|
36
|
+
}
|
37
|
+
end
|
data/pass_height.xls
ADDED
Binary file
|
data/pass_size.xls
ADDED
Binary file
|
data/pass_size2.xls
ADDED
Binary file
|
data/pass_size3.xls
ADDED
Binary file
|
Binary file
|
data/pass_width.xls
ADDED
Binary file
|
@@ -0,0 +1,24 @@
|
|
1
|
+
current_dir = File.expand_path('..', __FILE__)
|
2
|
+
#extensions = %w{ rb yml haml erb slim html js json jbuilder }
|
3
|
+
#files = Dir.glob(current_dir + "/**/*.{#{extensions.join(',')}}")
|
4
|
+
files = Dir.glob(current_dir + '/**/*')
|
5
|
+
files.collect! {|file| file.sub(current_dir + '/', '')}
|
6
|
+
files.push('LICENSE')
|
7
|
+
|
8
|
+
Gem::Specification.new do |s|
|
9
|
+
s.name = 'spreadsheet_builder'
|
10
|
+
s.version = '0.0.1'
|
11
|
+
s.date = "#{Time.now.strftime("%Y-%m-%d")}"
|
12
|
+
s.homepage = 'https://github.com/jphager2/spreadsheet_builder'
|
13
|
+
s.summary = 'build xls spreadsheets'
|
14
|
+
s.description = 'A nice extension for building xls spreadsheets'
|
15
|
+
s.authors = ['jphager2']
|
16
|
+
s.email = 'jphager2@gmail.com'
|
17
|
+
s.files = files
|
18
|
+
s.license = 'MIT'
|
19
|
+
|
20
|
+
s.add_runtime_dependency 'nokogiri', '~> 1.6'
|
21
|
+
s.add_runtime_dependency 'spreadsheet', '~> 1.0'
|
22
|
+
s.add_runtime_dependency 'css_parser', '~> 1.3 '
|
23
|
+
s.add_runtime_dependency 'shade', '~> 0.0'
|
24
|
+
end
|