calcsheet 0.1.0 → 0.2.0

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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/README.md +124 -9
  4. data/lib/calcsheet/book.rb +25 -9
  5. data/lib/calcsheet/cell_range.rb +54 -0
  6. data/lib/calcsheet/configuration.rb +10 -0
  7. data/lib/calcsheet/display_format.rb +75 -0
  8. data/lib/calcsheet/errors.rb +5 -0
  9. data/lib/calcsheet/formula.rb +40 -0
  10. data/lib/calcsheet/limited_inflater.rb +16 -0
  11. data/lib/calcsheet/native_backends.rb +139 -0
  12. data/lib/calcsheet/option_validation.rb +4 -1
  13. data/lib/calcsheet/raw_row.rb +1 -1
  14. data/lib/calcsheet/readers/csv_reader.rb +43 -20
  15. data/lib/calcsheet/readers/csv_records.rb +193 -0
  16. data/lib/calcsheet/readers/ods/content_parser.rb +49 -69
  17. data/lib/calcsheet/readers/ods/scanner.rb +33 -6
  18. data/lib/calcsheet/readers/ods_reader.rb +16 -1
  19. data/lib/calcsheet/readers/sax_sheet.rb +6 -48
  20. data/lib/calcsheet/readers/xlsx/sheet_parser.rb +31 -64
  21. data/lib/calcsheet/reading.rb +29 -7
  22. data/lib/calcsheet/row.rb +2 -2
  23. data/lib/calcsheet/row_builder.rb +235 -0
  24. data/lib/calcsheet/sheet_list.rb +25 -0
  25. data/lib/calcsheet/sheet_selection.rb +3 -2
  26. data/lib/calcsheet/style.rb +114 -0
  27. data/lib/calcsheet/version.rb +1 -1
  28. data/lib/calcsheet/write_book.rb +49 -17
  29. data/lib/calcsheet/write_sheet.rb +220 -18
  30. data/lib/calcsheet/writers/csv_writer.rb +18 -7
  31. data/lib/calcsheet/writers/ods_writer.rb +249 -16
  32. data/lib/calcsheet/writers/open_formula.rb +67 -0
  33. data/lib/calcsheet/writers/xlsx_writer.rb +226 -37
  34. data/lib/calcsheet/writers/zip_buffered.rb +20 -1
  35. data/lib/calcsheet.rb +31 -8
  36. metadata +11 -2
@@ -7,8 +7,11 @@ require "zip_kit"
7
7
  require "zlib"
8
8
  require_relative "../decimal_text"
9
9
  require_relative "../errors"
10
+ require_relative "../formula"
10
11
  require_relative "../grid"
11
12
  require_relative "../odf"
13
+ require_relative "../style"
14
+ require_relative "open_formula"
12
15
  require_relative "xml_text"
13
16
  require_relative "zip_buffered"
14
17
 
@@ -32,6 +35,12 @@ module Calcsheet
32
35
  # A :decimal cell with a +scale+ shows its value rounded half up to +scale+ decimals in its paragraph, as its number
33
36
  # style does; +office:value+ keeps the full value.
34
37
  #
38
+ # The Styles of the rows are common styles too, "Calcsheet1", "Calcsheet2"... one per Style and data style. A
39
+ # formula is translated to OpenFormula (OpenFormula.from_excel) and written without a value, which LibreOffice
40
+ # computes when it opens the file. A merge spans its first cell (+table:number-columns-spanned+,
41
+ # +table:number-rows-spanned+) and makes the others +table:covered-table-cell+ elements. With +auto_width+ a
42
+ # table's rows go to a temporary file until the sheet ends, since its columns come before them.
43
+ #
35
44
  # Text cells are +office:value-type="string"+, never evaluated, so "+39..." and "=..." stay verbatim.
36
45
  # A paragraph collapses white space (ODF 1.3 part 3, §6.1.2), and the ODS reader too: a run of spaces after a
37
46
  # character is written as one space and a +text:s+ for the rest, other spaces (leading, after a tab or a line
@@ -81,8 +90,9 @@ module Calcsheet
81
90
  VERSION = "1.3"
82
91
  DECLARATION = %(<?xml version="1.0" encoding="UTF-8"?>\n)
83
92
  # The namespaces the root elements declare. LibreOffice resolves the prefix of the "ooo:view-settings" name:
84
- # without ooo, it ignores the view settings.
85
- NAMESPACES = Odf::NAMESPACES.slice(:office, :style, :text, :table, :fo, :number, :config, :ooo).freeze
93
+ # without ooo, it ignores the view settings. "of" is the prefix of the formulas.
94
+ NAMESPACES = Odf::NAMESPACES.slice(:office, :style, :text, :table, :fo, :number, :config, :ooo)
95
+ .merge(of: "urn:oasis:names:tc:opendocument:xmlns:of:1.2").freeze
86
96
  MANIFEST_NS = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"
87
97
  # The namespace declarations and version of every root element.
88
98
  ROOT_ATTRIBUTES = "#{NAMESPACES.map { |prefix, uri| %(xmlns:#{prefix}="#{uri}") }.join(" ")} office:version=\"#{VERSION}\"".freeze
@@ -156,26 +166,45 @@ module Calcsheet
156
166
  @buffer << content_start
157
167
  @sheets = []
158
168
  @styles = {}
169
+ @custom_styles = {}
170
+ @data_styles = {}
171
+ @deferred = nil
159
172
  end
160
173
 
161
- # Starts the sheet's table in content.xml, with its columns and header row.
174
+ # Starts the sheet's table in content.xml, with its columns and header row; with +auto_width+, the rows go to a
175
+ # temporary file and the table is written when the sheet ends, its column widths known.
162
176
  # @raise [ArgumentError] for more columns than ODS holds
163
- def start_sheet(name, columns, header:)
164
- check_columns(name, columns, "ODS")
177
+ def start_sheet(name, columns, header:, widths: nil, auto_width: false)
178
+ check_columns(name, columns, "ODS") if columns
165
179
  @sheets << [ name, header ]
166
180
  @name = name
167
181
  @columns = columns
168
- @types = columns.map(&:type)
182
+ @types = columns&.map(&:type)
169
183
  @row_number = 0
170
- prepare_cells(columns)
171
- @buffer << %(<table:table table:name="#{XmlText.escape(name)}">) << table_columns(columns)
184
+ @merges = []
185
+ prepare_cells(columns) if columns
186
+ if auto_width
187
+ flush
188
+ defer_rows
189
+ else
190
+ @buffer << table_start(columns ? columns.map(&:width) : widths || [])
191
+ end
172
192
  write_header(columns) if header
173
193
  end
174
194
 
175
- # Writes one row of checked values (see the contract in WriteBook), consecutive empty cells as one repeated cell.
195
+ # @param range [CellRange] spanned by its first cell when its first row is written
196
+ def merge(range)
197
+ @merges << range
198
+ end
199
+
200
+ # Writes one row of checked values (see the contract in WriteBook), consecutive empty cells as one repeated cell:
201
+ # a typed row without styles, formulas or merges from the constant parts of #prepare_cells, any other cell by
202
+ # cell.
176
203
  # @raise [WriteError] for a year outside 1..9999; nothing of the row is written
177
- def row(values)
204
+ def row(values, styles = nil)
178
205
  mark = @buffer.bytesize
206
+ return any_row(values, styles) if styles || @types.nil? || !@merges.empty? || values.any?(Formula)
207
+
179
208
  @buffer << "<table:table-row>"
180
209
  empty = 0
181
210
  index = 0
@@ -202,9 +231,19 @@ module Calcsheet
202
231
  raise
203
232
  end
204
233
 
205
- # A table needs a row: a sheet without any gets an empty one.
206
- def end_sheet
234
+ # A table needs a row: a sheet without any gets an empty one. With +auto_width+, writes the table's start and
235
+ # columns of +widths+, then its rows.
236
+ def end_sheet(widths = nil)
207
237
  @buffer << "<table:table-row>#{EMPTY_CELL}</table:table-row>" if @row_number.zero?
238
+ if @deferred
239
+ flush
240
+ file = @deferred
241
+ @deferred = nil
242
+ @buffer << table_start(widths || [])
243
+ flush
244
+ @deferred = file
245
+ replay_deferred
246
+ end
208
247
  @buffer << "</table:table>"
209
248
  flush
210
249
  end
@@ -226,7 +265,7 @@ module Calcsheet
226
265
  # @return [Array(Integer, String), nil] the column index and the reason
227
266
  def row_problem(values)
228
267
  values.each_with_index do |value, index|
229
- next if value.nil? || !%i[date datetime].include?(@types[index]) || (value.year >= 1 && value.year <= 9999)
268
+ next if value.nil? || !%i[date datetime].include?(type_of(value, index)) || (value.year >= 1 && value.year <= 9999)
230
269
 
231
270
  return [ index, year_reason(value) ]
232
271
  end
@@ -240,9 +279,14 @@ module Calcsheet
240
279
  "</office:automatic-styles><office:body><office:spreadsheet>"
241
280
  end
242
281
 
282
+ def table_start(widths)
283
+ %(<table:table table:name="#{XmlText.escape(@name)}">#{table_columns(widths)})
284
+ end
285
+
243
286
  # One table-column per column, consecutive ones of the same width merged; a table needs one.
244
- def table_columns(columns)
245
- styles = columns.map { |column| column.width && %( table:style-name="co#{column.width.round.clamp(1, MAX_WIDTH)}") }
287
+ # @param widths [Array<Numeric, nil>] by column index
288
+ def table_columns(widths)
289
+ styles = widths.map { |width| width && %( table:style-name="co#{width.round.clamp(1, MAX_WIDTH)}") }
246
290
  return "<table:table-column/>" if styles.empty?
247
291
 
248
292
  styles.chunk_while { _1 == _2 }.map do |run|
@@ -317,6 +361,135 @@ module Calcsheet
317
361
  end
318
362
  end
319
363
 
364
+ # A row of any sheet, cell by cell: the type of each value is its column's, or its class's in a free sheet, a
365
+ # formula's whatever the column; a styled nil is a styled empty cell; a merge spans its first cell and covers
366
+ # the others.
367
+ def any_row(values, styles)
368
+ mark = @buffer.bytesize
369
+ row = @row_number + 1
370
+ merges = @merges.select { _1.first_row <= row && row <= _1.last_row }
371
+ count = [ values.size, styles&.size || 0, *merges.map { _1.last_column + 1 } ].max
372
+ @buffer << "<table:table-row>"
373
+ empty = 0
374
+ index = 0
375
+ while index < count
376
+ value = values[index]
377
+ style = styles&.[](index)
378
+ merge = merges.find { _1.cover?(row, index) }
379
+ first = merge && merge.first_row == row && merge.first_column == index
380
+ if value.nil? && style.nil? && merge.nil?
381
+ empty += 1
382
+ else
383
+ if empty.positive?
384
+ @buffer << empty_cells(empty)
385
+ empty = 0
386
+ end
387
+ if merge && !first
388
+ @buffer << "<table:covered-table-cell#{style_attribute(style, nil)}/>"
389
+ else
390
+ any_cell(value, index, style, first ? spans(merge) : "")
391
+ end
392
+ end
393
+ index += 1
394
+ end
395
+ @buffer << EMPTY_CELL if count.zero? || empty == count
396
+ @buffer << "</table:table-row>"
397
+ @row_number += 1
398
+ flush if @buffer.bytesize >= BUFFER_SIZE
399
+ rescue StandardError
400
+ rollback(mark)
401
+ raise
402
+ end
403
+
404
+ def spans(merge)
405
+ attributes = +""
406
+ columns = merge.last_column - merge.first_column + 1
407
+ rows = merge.last_row - merge.first_row + 1
408
+ attributes << %( table:number-columns-spanned="#{columns}") if columns > 1
409
+ attributes << %( table:number-rows-spanned="#{rows}") if rows > 1
410
+ attributes
411
+ end
412
+
413
+ def any_cell(value, index, style, spans)
414
+ type = value.nil? ? nil : type_of(value, index)
415
+ attributes = style_attribute(style, type && value_style(type, index)) + spans
416
+ case type
417
+ when nil then @buffer << "<table:table-cell#{attributes}/>"
418
+ when :formula
419
+ formula = XmlText.escape(OpenFormula.from_excel(value.text)).gsub(ATTRIBUTE_BREAKS, ATTRIBUTE_SPACE)
420
+ @buffer << "<table:table-cell#{attributes} table:formula=\"#{formula}\"/>"
421
+ when :string then text_cell(%(<table:table-cell#{attributes} office:value-type="string"), value)
422
+ when :integer, :decimal
423
+ text = type == :integer ? value.to_s : DecimalText.plain(value)
424
+ value_type = style&.format&.kind == :percent ? "percentage" : "float"
425
+ @buffer << %(<table:table-cell#{attributes} office:value-type="#{value_type}" office:value="#{text}">) <<
426
+ "<text:p>" << text << CELL_END
427
+ when :date
428
+ check_year(value, index)
429
+ text = value.iso8601
430
+ @buffer << %(<table:table-cell#{attributes} office:value-type="date" office:date-value="#{text}"><text:p>) <<
431
+ text << CELL_END
432
+ when :datetime
433
+ check_year(value, index)
434
+ text = value.strftime("%Y-%m-%dT%H:%M:%S")
435
+ @buffer << %(<table:table-cell#{attributes} office:value-type="date" office:date-value="#{text}"><text:p>) <<
436
+ text.tr("T", " ") << CELL_END
437
+ else
438
+ text = value ? "true" : "false"
439
+ @buffer << %(<table:table-cell#{attributes} office:value-type="boolean" office:boolean-value="#{text}"><text:p>) <<
440
+ text.upcase << CELL_END
441
+ end
442
+ end
443
+
444
+ # The type a value is written as: a formula, its column's type, or in a free sheet its class's.
445
+ def type_of(value, index)
446
+ return :formula if value.is_a?(Formula)
447
+ return @types[index] if @types
448
+
449
+ case value
450
+ when String then :string
451
+ when Integer then :integer
452
+ when BigDecimal then :decimal
453
+ when Time then :datetime
454
+ when Date then :date
455
+ else :boolean
456
+ end
457
+ end
458
+
459
+ # The name of the data style a cell of +type+ takes without a style that gives one: its column's, or a date's,
460
+ # time's or boolean's.
461
+ def value_style(type, index)
462
+ if @columns && type != :formula
463
+ name = cell_style(@columns[index])
464
+ return name && "N#{name}"
465
+ end
466
+
467
+ case type
468
+ when :date then use_style(DATE_STYLE) && "N#{DATE_STYLE}"
469
+ when :datetime then use_style(DATETIME_STYLE) && "N#{DATETIME_STYLE}"
470
+ when :boolean then use_style(BOOLEAN_STYLE) && "N#{BOOLEAN_STYLE}"
471
+ end
472
+ end
473
+
474
+ # The table:style-name attribute of a cell: a common style for its Style with its data style (the Style's
475
+ # format, else +value_style+), the plain data style's cell style without a Style, nothing without either.
476
+ def style_attribute(style, data_style)
477
+ if style.nil?
478
+ return "" if data_style.nil?
479
+
480
+ return %( table:style-name="#{data_style.delete_prefix("N")}")
481
+ end
482
+
483
+ data_style = data_format(style.format) if style.format
484
+ name = @custom_styles[[ style, data_style ]] ||= "Calcsheet#{@custom_styles.size + 1}"
485
+ %( table:style-name="#{name}")
486
+ end
487
+
488
+ # The name of the data style of a DisplayFormat, registered at its first use.
489
+ def data_format(format)
490
+ @data_styles[format] ||= "NCalcsheet#{@data_styles.size + 1}"
491
+ end
492
+
320
493
  def number(text, index)
321
494
  @buffer << @openings[index] << text << VALUE_END << text << CELL_END
322
495
  end
@@ -375,7 +548,8 @@ module Calcsheet
375
548
  def check_year(value, index)
376
549
  return if value.year >= 1 && value.year <= 9999
377
550
 
378
- raise WriteError.at(year_reason(value), sheet: @name, row: @row_number + 1, column_index: index, header: @columns[index].header)
551
+ raise WriteError.at(year_reason(value), sheet: @name, row: @row_number + 1, column_index: index,
552
+ header: @columns&.[](index)&.header)
379
553
  end
380
554
 
381
555
  def year_reason(value)
@@ -392,10 +566,69 @@ module Calcsheet
392
566
  %(<style:style style:name="#{name}" style:family="table-cell" style:data-style-name="N#{name}"/>)
393
567
  end
394
568
  end
569
+ data_styles.concat(@data_styles.map { |format, name| display_style(format, name) })
570
+ cell_styles.concat(@custom_styles.map { |(style, data_style), name| custom_style(style, data_style, name) })
395
571
  %(#{DECLARATION}<office:document-styles #{ROOT_ATTRIBUTES}><office:styles>#{data_styles.join}#{cell_styles.join}) +
396
572
  "</office:styles></office:document-styles>"
397
573
  end
398
574
 
575
+ # The ODF line of each border style, by its XLSX name.
576
+ BORDER_LINES = { thin: "0.75pt solid", medium: "1.5pt solid", thick: "2.5pt solid", dashed: "0.75pt dashed",
577
+ dotted: "0.75pt dotted", double: "2.5pt double" }.freeze
578
+ # The ODF values of the alignments.
579
+ TEXT_ALIGN = { left: "start", center: "center", right: "end" }.freeze
580
+ VERTICAL_ALIGN = { top: "top", center: "middle", bottom: "bottom" }.freeze
581
+
582
+ def custom_style(style, data_style, name)
583
+ data = data_style ? %( style:data-style-name="#{data_style}") : ""
584
+ xml = +%(<style:style style:name="#{name}" style:family="table-cell" style:parent-style-name="Default"#{data}>)
585
+ cell = []
586
+ cell << %(fo:background-color="##{style.fill}") if style.fill
587
+ style.border&.edges&.each { cell << %(fo:border-#{_1}="#{BORDER_LINES.fetch(style.border.style)} ##{style.border.color}") }
588
+ cell << 'fo:wrap-option="wrap"' if style.wrap
589
+ cell << %(style:vertical-align="#{VERTICAL_ALIGN.fetch(style.valign)}") if style.valign
590
+ xml << "<style:table-cell-properties #{cell.join(" ")}/>" unless cell.empty?
591
+ xml << %(<style:paragraph-properties fo:text-align="#{TEXT_ALIGN.fetch(style.align)}"/>) if style.align
592
+ text = []
593
+ text << 'fo:font-weight="bold" style:font-weight-asian="bold" style:font-weight-complex="bold"' if style.bold
594
+ text << 'fo:font-style="italic" style:font-style-asian="italic" style:font-style-complex="italic"' if style.italic
595
+ if style.size
596
+ size = "#{style.size}pt"
597
+ text << %(fo:font-size="#{size}" style:font-size-asian="#{size}" style:font-size-complex="#{size}")
598
+ end
599
+ text << %(fo:color="##{style.color}") if style.color
600
+ xml << "<style:text-properties #{text.join(" ")}/>" unless text.empty?
601
+ xml << "</style:style>"
602
+ end
603
+
604
+ # The data style of a DisplayFormat.
605
+ def display_style(format, name)
606
+ case format.kind
607
+ when :number then %(<number:number-style style:name="#{name}">#{number_element(format)}</number:number-style>)
608
+ when :percent
609
+ sign = format.code.end_with?(" %") ? " %" : "%"
610
+ %(<number:percentage-style style:name="#{name}">#{number_element(format)}<number:text>#{sign}</number:text></number:percentage-style>)
611
+ when :text then %(<number:text-style style:name="#{name}"><number:text-content/></number:text-style>)
612
+ else
613
+ element = format.kind == :time ? "number:time-style" : "number:date-style"
614
+ %(<#{element} style:name="#{name}">#{format.parts.map { date_part(*_1) }.join}</#{element}>)
615
+ end
616
+ end
617
+
618
+ def number_element(format)
619
+ grouping = format.grouping ? ' number:grouping="true"' : ""
620
+ %(<number:number number:decimal-places="#{format.decimals}" number:min-decimal-places="#{format.decimals}" ) +
621
+ %(number:min-integer-digits="1"#{grouping}/>)
622
+ end
623
+
624
+ def date_part(field, text)
625
+ case field
626
+ when :text then "<number:text>#{XmlText.escape(text)}</number:text>"
627
+ when :year then '<number:year number:style="long"/>'
628
+ else %(<number:#{field} number:style="long"/>)
629
+ end
630
+ end
631
+
399
632
  def data_style(name)
400
633
  case name
401
634
  when DATE_STYLE then %(<number:date-style style:name="N#{name}">#{DATE_PARTS}</number:date-style>)
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Calcsheet
6
+ module Writers
7
+ # A formula in the syntax of Excel turned into OpenFormula, as ODS stores it: +SUM(B2:B5)+ becomes
8
+ # +of:=SUM([.B2:.B5])+. Cell references and ranges, on this sheet or another (+Data!A1+, +'My data'!A1:B2+),
9
+ # are bracketed, and the argument separator "," becomes ";"; strings, function names, numbers and operators are
10
+ # kept. Names and anything else stay as written.
11
+ #
12
+ # @api private
13
+ module OpenFormula
14
+ # A cell reference, with its optional "$" signs.
15
+ CELL = /\$?[A-Za-z]{1,3}\$?[1-9][0-9]{0,6}/
16
+ # A reference that is not the start of a longer name or a function call.
17
+ REFERENCE = /(#{CELL})(?::(#{CELL}))?(?![\w(.$:])/
18
+ # The sheet of a reference: a quoted name ('My data') or a plain one, before "!".
19
+ SHEET = /('(?:[^']|'')+'|[A-Za-z_][\w.]*)!/
20
+ # A string literal, its quotes doubled inside.
21
+ STRING = /"(?:[^"]|"")*"/
22
+ # A name or a function name.
23
+ NAME = /[A-Za-z_][\w.]*/
24
+
25
+ module_function
26
+
27
+ # @param text [String] the formula without its "=" (Formula#text)
28
+ # @return [String] the value of a cell's table:formula
29
+ def from_excel(text)
30
+ scanner = StringScanner.new(text)
31
+ result = +"of:="
32
+ until scanner.eos?
33
+ if (string = scanner.scan(STRING))
34
+ result << string
35
+ elsif scanner.check(SHEET) && (reference = sheet_reference(scanner))
36
+ result << reference
37
+ elsif scanner.scan(REFERENCE)
38
+ result << bracket(nil, scanner[1], scanner[2])
39
+ elsif (name = scanner.scan(NAME))
40
+ result << name
41
+ else
42
+ char = scanner.getch
43
+ result << (char == "," ? ";" : char)
44
+ end
45
+ end
46
+ result
47
+ end
48
+
49
+ # A reference with its sheet ("Data!A1"), or nil (the scanner unmoved) when no reference follows the sheet.
50
+ def sheet_reference(scanner)
51
+ start = scanner.pos
52
+ scanner.scan(SHEET)
53
+ sheet = scanner[1]
54
+ return bracket(sheet, scanner[1], scanner[2]) if scanner.scan(REFERENCE)
55
+
56
+ scanner.pos = start
57
+ nil
58
+ end
59
+
60
+ # "[.A1]", "[.A1:.B2]", "[$Data.A1]" or "[$'My data'.A1:.B2]".
61
+ def bracket(sheet, first, last)
62
+ prefix = sheet ? "$#{sheet}" : ""
63
+ last ? "[#{prefix}.#{first}:.#{last}]" : "[#{prefix}.#{first}]"
64
+ end
65
+ end
66
+ end
67
+ end