spreet 0.0.2 → 0.0.3

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.
@@ -1,3 +1,49 @@
1
1
  = Spreet
2
2
 
3
- Universal handler for spr[eadsh]eets.
3
+ Universal handler for spr[eadsh]eets.
4
+
5
+ == Why ?
6
+ This gems is a handler for spreadsheets. With its independent API, it is possible to create, update files in some formats. Today the list is not very long:
7
+
8
+ * CSV: UTF-8 with commas (Read & Write)
9
+ * CSV for Excel: CP1252 with semicolons (Read & Write)
10
+ * ODS: Open Document Format (Read & Write with restrictions)
11
+
12
+ == Installation
13
+
14
+ gem install spreet
15
+
16
+ == How to use it
17
+
18
+ # Create a new document
19
+ doc = Spreet::Document.new
20
+ sheet = doc.sheets.add "My Sheet"
21
+
22
+ # Coordinates can be called with spreadsheet style...
23
+ sheet["A1"] = "Last name"
24
+ # ...or more classic style...
25
+ sheet[1,0] = "First name"
26
+ # ...or if necessary as a Hash
27
+ sheet[:x=>2, :y=>0] = "Born on"
28
+
29
+ sheet.next_row
30
+ for person in People.all
31
+ sheet.row person.last_name, person.first_name, person.born_on
32
+ end
33
+
34
+ # Write it as a classic CSV
35
+ sheet.write("people-1.csv")
36
+ # Write it as a CSV for Excel
37
+ sheet.write("people-2.csv", :format=>:xcsv) # CSV for Excel
38
+ # or write it as an Open Document Spreadsheet
39
+ sheet.write("people-3.ods")
40
+
41
+ == To do soon
42
+
43
+ * Add style management for cells
44
+ * Add Header/Footer
45
+ * HTML Writer
46
+ * PDF Writer like OpenOffice/LibreOffice would make it
47
+
48
+ == Travis
49
+ {<img src="https://secure.travis-ci.org/burisu/spreet.png"/>}[http://travis-ci.org/burisu/spreet]
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.2
1
+ 0.0.3
@@ -0,0 +1,100 @@
1
+ # encoding: utf-8
2
+
3
+ # Class to manage big hash with lot of pairs
4
+ class BigArray
5
+
6
+ def initialize(klass_name=nil, partition=8, levels=4)
7
+ @partition = partition.to_i
8
+ raise ArgumentError.new("Partition must be an integer > 0") unless @partition > 0
9
+ @levels = levels.to_i
10
+ raise ArgumentError.new("Levels must be an integer > 0") unless @levels > 0
11
+ klass_name ||= "#{self.class.name}#{@partition}_#{@levels}"
12
+ @base_class = "Hash"
13
+ code = ""
14
+ code << "class #{klass_name}\n"
15
+ code << " def initialize()\n"
16
+ code << " @root = #{@base_class}.new\n"
17
+ code << " end\n\n"
18
+
19
+ code << " def [](index)\n"
20
+ code << dive do |pointer|
21
+ "return nil"
22
+ end.strip.gsub(/^/, ' ')+"\n"
23
+ code << " return cursor[#{index_at_level(@levels)}]\n"
24
+ code << " end\n\n"
25
+
26
+ code << " def []=(index, value)\n"
27
+ # code << " index, value = args[0], args[1]\n"
28
+ code << dive do |pointer|
29
+ "#{pointer} = #{@base_class}.new"
30
+ end.strip.gsub(/^/, ' ')+"\n"
31
+ code << " return cursor[#{index_at_level(@levels)}] = value\n"
32
+ code << " end\n\n"
33
+
34
+ code << " def delete(index)\n"
35
+ # code << " index, value = args[0], args[1]\n"
36
+ code << dive do |pointer|
37
+ "return nil"
38
+ end.strip.gsub(/^/, ' ')+"\n"
39
+ code << " return cursor.delete(#{index_at_level(@levels)})\n"
40
+ code << " end\n\n"
41
+
42
+ code << " def each(&block)\n"
43
+ code << browse do
44
+ "yield(index, value)"
45
+ end.strip.gsub(/^/, ' ')+"\n"
46
+ code << " end\n\n"
47
+
48
+ code << " def to_hash\n"
49
+ code << " hash = {}\n"
50
+ code << browse do
51
+ "hash[index] = value"
52
+ end.strip.gsub(/^/, ' ')+"\n"
53
+ code << " return hash\n"
54
+ code << " end\n\n"
55
+
56
+ code << "end\n"
57
+ # raise code
58
+ eval(code)
59
+ return self.class.const_get(klass_name)
60
+ end
61
+
62
+ private
63
+
64
+ def dive(&block)
65
+ code = ""
66
+ for level in 1..(@levels-1)
67
+ pointer = "#{level == 1 ? '@root' : 'cursor'}[#{index_at_level(level)}]"
68
+ code << "unless #{pointer}.is_a?(#{@base_class})\n"
69
+ code << yield(pointer).to_s.strip.gsub(/^/, ' ')+"\n"
70
+ code << "end\n"
71
+ code << "cursor = #{pointer}\n"
72
+ end
73
+ return code
74
+ end
75
+
76
+ def browse(level = 1, &block)
77
+ code = ""
78
+ value = (level == @levels ? 'value' : "h#{level}")
79
+ code << "for l#{level}, #{value} in #{level == 1 ? '@root' : 'h'+(level-1).to_s}\n"
80
+ if level > 1
81
+ i = (level == @levels ? "index" : "i#{level}")
82
+ code << " #{i} = ("+(level>2 ? "i" : "l")+"#{level-1} << #{@partition})|l#{level}\n"
83
+ end
84
+ if level == @levels
85
+ code << yield.to_s.strip.gsub(/^/, ' ')+"\n"
86
+ else
87
+ code << browse(level + 1, &block).to_s.strip.gsub(/^/, ' ')+"\n"
88
+ end
89
+ code << "end\n"
90
+ return code
91
+ end
92
+
93
+ def index_at_level(level, variable="index")
94
+ v = variable
95
+ v = "(#{v} >> #{(@levels-level)*@partition})" if level < @levels
96
+ return "#{v}&#{2**@partition-1}"
97
+ end
98
+
99
+
100
+ end
@@ -0,0 +1,145 @@
1
+ # encoding: utf-8
2
+
3
+ # Class to manage durations (or intervals in SQL vocabulary)
4
+ class Duration
5
+ # 365.25 * 86_400 == 31_557_600
6
+ SECONDS_IN_YEAR = 31_557_600 # 31_556_926 => 31_556_928
7
+ SECONDS_IN_MONTH = SECONDS_IN_YEAR / 12
8
+
9
+ FIELDS = [:years, :months, :days, :hours, :minutes, :seconds]
10
+ attr_accessor *FIELDS
11
+ attr_reader :sign
12
+
13
+ def initialize(*args)
14
+ @years, @months, @days, @hours, @minutes, @seconds = 0, 0, 0, 0, 0, 0
15
+ @sign = 1
16
+ if args.size == 1 and args[0].is_a? String
17
+ self.parse(args[0])
18
+ else
19
+ @years = (args.shift || 0).to_i
20
+ @months = (args.shift || 0).to_i
21
+ @days = (args.shift || 0).to_i
22
+ @hours = (args.shift || 0).to_i
23
+ @minutes = (args.shift || 0).to_i
24
+ @seconds = (args.shift || 0).to_i
25
+ end
26
+ end
27
+
28
+ def parse(string)
29
+ unless string.match(/^\-?P(\d+Y)?(\d+M)?(\d+D)?(T(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/)
30
+ raise ArgumentError.new("Malformed string")
31
+ end
32
+ strings = string.split('T')
33
+ strings[0].gsub(/\d+[YMD]/) do |token|
34
+ code, count = token.to_s[-1..-1], token.to_s[0..-2].to_i
35
+ if code == "Y"
36
+ @years = count
37
+ elsif code == "M"
38
+ @months = count
39
+ elsif code == "D"
40
+ @days = count
41
+ end
42
+ token
43
+ end
44
+ strings[1].to_s.gsub(/(\d+[HM]|\d+(\.\d+)?S)/) do |token|
45
+ code, count = token.to_s[-1..-1], token.to_s[0..-2]
46
+ if code == "H"
47
+ @hours = count.to_i
48
+ elsif code == "M"
49
+ @minutes = count.to_i
50
+ elsif code == "S"
51
+ @seconds = count.to_f
52
+ end
53
+ token
54
+ end
55
+ self.sign = (string.match(/^\-/) ? -1 : 1)
56
+ return self
57
+ end
58
+
59
+ def sign=(val)
60
+ @sign = (val >= 0 ? 1 : -1)
61
+ end
62
+
63
+ def to_s(compression = :normal)
64
+ if compression == :maximum
65
+ return (@sign > 0 ? "" : "-")+"P#{@years.to_s+'Y' if @years > 0}#{@months.to_s+'M' if @months > 0}#{@days.to_s+'D' if @days > 0}"+((@hours.zero? and @minutes.zero? and @seconds.zero?) ? '' : "T#{(@hours.to_s+'H') if @hours > 0}#{(@minutes.to_s+'M') if @minutes > 0}#{((@seconds.floor != @seconds ? @seconds.to_s : @seconds.to_i.to_s)+'S') if @seconds > 0}")
66
+ elsif compression == :minimum
67
+ return (@sign > 0 ? "" : "-")+"P"+@years.to_s+"Y"+@months.to_s+"M"+@days.to_s+"DT"+@hours.to_s+"H"+@minutes.to_s+"M"+@seconds.to_s+"S"
68
+ else
69
+ return (@sign > 0 ? "" : "-")+"P"+((@years.zero? and @months.zero? and @days.zero?) ? '' : @years.to_s+"Y"+@months.to_s+"M"+@days.to_s+"D")+((@hours.zero? and @minutes.zero? and @seconds.zero?) ? '' : "T"+@hours.to_s+"H"+@minutes.to_s+"M"+(@seconds.round != @seconds ? @seconds.to_s : @seconds.to_i.to_s)+"S")
70
+ end
71
+ end
72
+
73
+ # Export all values to hash
74
+ def to_hash
75
+ {:years=>@years, :months=>@months, :days=>@days, :hours=>@hours, :minutes=>@minutes, :seconds=>@seconds, :sign=>@sign}
76
+ end
77
+
78
+ # Computes a duration in seconds based on theoric and statistic values
79
+ # Because the relation between some of date parts isn't fixed (such as the number of days in a month),
80
+ # the order relationship between durations is only partial, and the result of a comparison
81
+ # between two durations may be undetermined.
82
+ def to_f
83
+ count = @seconds
84
+ count += 60 * @minutes
85
+ # 60 * 60 = 3_600
86
+ count += 3_600 * @hours
87
+ # 60 * 60 * 24 = 86_400
88
+ count += 86_400 * @days
89
+ # 365.25/12 * 86_400 == 31_557_600/12 == 2_629_800
90
+ count += SECONDS_IN_MONTH * @months
91
+ # 365.25 * 86_400 == 31_557_600
92
+ count += SECONDS_IN_YEAR * @years
93
+ return @sign * count
94
+ end
95
+
96
+ def to_i
97
+ self.to_f.to_i
98
+ end
99
+
100
+ # Normalize seconds, minutes, hours and month with their fixed relations
101
+ def normalize!(normalize_method = :right)
102
+ if normalize_method == :seconds
103
+ count = self.to_f
104
+ @years = (count / SECONDS_IN_YEAR).floor
105
+ count -= @years * SECONDS_IN_YEAR
106
+ @months = (count / SECONDS_IN_MONTH).floor
107
+ count -= @months * SECONDS_IN_MONTH
108
+ @days = (count / 86_400).floor
109
+ count -= @days * 86_400
110
+ @hours = (count / 3_600).floor
111
+ count -= @hours * 3_600
112
+ @minutes = (count / 60).floor
113
+ count -= @minutes * 60
114
+ @seconds = count
115
+ else
116
+ if @seconds >= 60
117
+ minutes = (@seconds / 60).floor
118
+ @seconds -= minutes * 60
119
+ @minutes += minutes
120
+ end
121
+ if @minutes >= 60
122
+ hours = (@minutes / 60).floor
123
+ @minutes -= hours * 60
124
+ @hours += hours
125
+ end
126
+ if @hours >= 24
127
+ days = (@hours / 24).floor
128
+ @hours -= days * 24
129
+ @days += days
130
+ end
131
+ # No way to convert correctly days in month
132
+ if @months >= 12
133
+ years = (@months / 12).floor
134
+ @months -= years * 12
135
+ @years += years
136
+ end
137
+ end
138
+ return self
139
+ end
140
+
141
+ def normalize(normalize_method = :right)
142
+ self.dup.normalize!(normalize_method)
143
+ end
144
+
145
+ end
@@ -1,11 +1,19 @@
1
1
  # encoding: utf-8
2
2
  require 'pathname'
3
+ require 'duration'
4
+ require 'money'
5
+ require 'time'
6
+ require 'big_array'
7
+ require 'spreet/coordinates'
8
+
9
+ # Create class for arrays
10
+ BigArray.new("Cells", 10, 3)
3
11
 
4
12
  module Spreet
5
13
 
6
14
  module VERSION
7
15
  version = nil
8
- File.open("VERSION") {|f| version = f.read.split('.')}
16
+ File.open(File.join(File.dirname(__FILE__), "..", "VERSION")) {|f| version = f.read.split('.')}
9
17
  MAJOR = version[0].to_i.freeze
10
18
  MINOR = version[1].to_i.freeze
11
19
  TINY = version[2].to_i.freeze
@@ -15,83 +23,42 @@ module Spreet
15
23
  end
16
24
 
17
25
 
18
- class Coordinates
19
- # Limit coordinates x and y in 0..65535 but coordinates are in one integer of 32 bits
20
- CPU_SEMI_WIDTH = 16 # ((RUBY_PLATFORM.match(/^[^\-]*[^\-0-9]64/) ? 64 : 32) / 2).freeze
21
- Y_FILTER = ((1 << CPU_SEMI_WIDTH) - 1).freeze
22
-
23
- BASE_26_BEF = "0123456789abcdefghijklmnop"
24
- BASE_26_AFT = "abcdefghijklmnopqrstuvwxyz"
25
-
26
- attr_accessor :x, :y
27
- def initialize(*args)
28
- value = (args.size == 1 ? args[0] : args)
29
- @x, @y = 0, 0
30
- if value.is_a? String
31
- if value.downcase.match(/^[a-z]+[0-9]+$/)
32
- value = value.downcase.split(/([A-Z]+|[0-9]+)/).delete_if{|x| x.size.zero?}
33
- @x, @y = value[0].tr(BASE_26_AFT, BASE_26_BEF).to_i(26), value[1].to_i(10)-1
34
- elsif value.downcase.match(/^[0-9]+[^0-9]+[0-9]+$/)
35
- value = value.downcase.split(/[^0-9]+/)
36
- @x, @y = value[0].to_i(10), value[1].to_i(10)
37
- end
38
- elsif value.is_a? Integer
39
- @x, @y = (value >> CPU_SEMI_WIDTH), value & Y_FILTER
40
- elsif value.is_a? Coordinates
41
- @x, @y = value.x, value.y
42
- elsif value.is_a? Array
43
- @x, @y = value[0].to_i, value[1].to_i
44
- elsif value.is_a? Hash
45
- @x, @y = value[:x] || value[:column] || 0, value[:y] || value[:row] || 0
46
- end
47
- end
48
-
49
- def to_s
50
- @x.to_s(26).tr(BASE_26_BEF, BASE_26_AFT).upcase+(@y+1).to_s(10)
51
- end
52
-
53
- def to_a
54
- [@x, @y]
55
- end
56
-
57
- def to_hash
58
- {:x=>@x, :y=>@y}
59
- end
60
-
61
- def to_i
62
- (@x << CPU_SEMI_WIDTH) + @y
63
- end
64
-
65
- def ==(other_coordinate)
66
- other_coordinate.x == self.x and other_coordinate.y == self.y
67
- end
68
-
69
- def <=>(other_coordinate)
70
- self.to_i <=> other_coordinate.to_i
71
- end
72
- end
73
-
74
26
  # Represents a cell in a sheet
75
27
  class Cell
76
28
  attr_reader :text, :value, :type, :sheet, :coordinates
29
+ attr_accessor :annotation
77
30
 
78
31
  def initialize(sheet, *args)
79
32
  @sheet = sheet
80
33
  @coordinates = Coordinates.new(*args)
81
34
  self.value = nil
82
35
  @empty = true
36
+ @covered = false # determine_covered
37
+ @annotation = nil
83
38
  end
84
39
 
85
40
  def value=(val)
86
- @value = val
87
- @type = determine_type
88
- @text = val.to_s
89
- @empty = false
41
+ if val.is_a?(Cell)
42
+ @value = val.value
43
+ @type = val.type
44
+ self.text = val.text
45
+ @empty = val.empty?
46
+ @annotation = val.annotation
47
+ else
48
+ @value = val
49
+ @type = determine_type
50
+ self.text = val
51
+ @empty = false
52
+ end
90
53
  end
91
54
 
92
55
  def empty?
93
56
  @empty
94
57
  end
58
+
59
+ def covered?
60
+ @covered
61
+ end
95
62
 
96
63
  def clear!
97
64
  self.value = nil
@@ -106,29 +73,35 @@ module Spreet
106
73
  self.coordinates <=> other_cell.coordinates
107
74
  end
108
75
 
76
+ def text=(val)
77
+ @text = val.to_s
78
+ end
79
+
80
+ def inspect
81
+ "<#{self.coordinates}: #{self.text.inspect}#{'('+self.value.inspect+')' if self.text != self.value}>"
82
+ end
83
+
109
84
  private
85
+
110
86
 
111
87
  def determine_type
112
- if value.is_a? Date
88
+ if value.is_a? Date or value.is_a? DateTime
113
89
  :date
114
- elsif value.is_a? Integer
115
- :integer
116
- elsif value.is_a? Numeric
117
- :decimal
118
- elsif value.is_a? DateTime
119
- :datetime
90
+ elsif value.is_a? Numeric # or percentage
91
+ :float
92
+ elsif value.is_a? Money
93
+ :currency
94
+ elsif value.is_a? Duration
95
+ :time
120
96
  elsif value.is_a?(TrueClass) or value.is_a?(FalseClass)
121
97
  :boolean
122
- elsif value.nil?
123
- :null
124
- else
98
+ else # if value.is_a?(String)
125
99
  :string
126
100
  end
127
101
  end
128
102
 
129
103
  end
130
104
 
131
-
132
105
  class Sheet
133
106
  attr_reader :document, :name, :columns
134
107
  attr_accessor :current_row
@@ -138,7 +111,8 @@ module Spreet
138
111
  self.name = name
139
112
  raise ArgumentError.new("Must be a Document") unless document.is_a? Document
140
113
  @current_row = 0
141
- @cells = {}
114
+ @cells = {} # BigArray::Cells.new
115
+ @bound = compute_bound
142
116
  end
143
117
 
144
118
  def name=(value)
@@ -152,11 +126,6 @@ module Spreet
152
126
  @name = value
153
127
  end
154
128
 
155
- def cells
156
- @cells.delete_if{|k,v| v.empty?}
157
- @cells.values
158
- end
159
-
160
129
  def next_row(increment = 1)
161
130
  @current_row += increment
162
131
  end
@@ -175,7 +144,7 @@ module Spreet
175
144
  value = args.delete_at(-1)
176
145
  cell = self[*args]
177
146
  cell.value = value
178
- @bound = compute_bound
147
+ @updated = true
179
148
  end
180
149
 
181
150
  def row(*args)
@@ -188,13 +157,17 @@ module Spreet
188
157
  next_row
189
158
  end
190
159
 
160
+ def rows(index)
161
+ row = []
162
+ for i in 0..bound.x
163
+ row[i] = self[i, index]
164
+ end
165
+ return row
166
+ end
167
+
191
168
  def each_row(&block)
192
169
  for j in 0..bound.y
193
- row = []
194
- for i in 0..bound.x
195
- row[i] = self[i, j]
196
- end
197
- yield row
170
+ yield rows(j)
198
171
  end
199
172
  end
200
173
 
@@ -204,13 +177,17 @@ module Spreet
204
177
  end
205
178
 
206
179
  def bound
207
- @bound
180
+ if @updated
181
+ compute_bound
182
+ else
183
+ @bound
184
+ end
208
185
  end
209
186
 
210
187
  def remove!(coordinates)
211
188
  raise ArgumentError.new("Must be a Coordinates") unless document.is_a?(Coordinates)
212
189
  @cells.delete(coordinates.to_i)
213
- @bound = compute_bound
190
+ @updated = true
214
191
  end
215
192
 
216
193
  # Moves the sheet to an other position in the list of sheets
@@ -231,14 +208,17 @@ module Spreet
231
208
  private
232
209
 
233
210
  def compute_bound
234
- bound = Coordinates.new
235
- for id, cell in @cells
211
+ bound = Coordinates.new(0,0)
212
+ for index, cell in @cells
213
+ # for cell in @cells.compact
236
214
  unless cell.empty?
237
215
  bound.x = cell.coordinates.x if cell.coordinates.x > bound.x
238
- bound.y = cell.coordinates.y if cell.coordinates.x > bound.y
216
+ bound.y = cell.coordinates.y if cell.coordinates.y > bound.y
239
217
  end
240
218
  end
241
- return bound
219
+ @updated = false
220
+ @bound = bound
221
+ return @bound
242
222
  end
243
223
 
244
224
  end
@@ -280,11 +260,14 @@ module Spreet
280
260
  end
281
261
 
282
262
  def remove(sheet)
283
- @array.delete(sheet)
263
+ @array.delete_at(index(sheet))
284
264
  end
285
265
 
286
266
  def move(sheet, shift=0)
287
- move_at(sheet, index(sheet) + shift)
267
+ position = index(sheet) + shift
268
+ position = 0 if position < 0
269
+ position = self.count-1 if position >= self.count
270
+ move_at(sheet, position)
288
271
  end
289
272
 
290
273
  def move_at(sheet, position=-1)
@@ -310,17 +293,6 @@ module Spreet
310
293
  def initialize(option={})
311
294
  @sheets = Sheets.new(self)
312
295
  end
313
-
314
- def to_term
315
- text = "Spreet (#{@sheets.count}):\n"
316
- for sheet in @sheets
317
- text << " - #{sheet.name}:\n"
318
- for cell in sheet.cells.sort
319
- text << " - #{cell.coordinates.to_s}: #{cell.text.inspect}\n"
320
- end
321
- end
322
- return text
323
- end
324
296
 
325
297
  def write(file, options={})
326
298
  handler = self.class.extract_handler(file, options.delete(:format))