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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -1
- data/README.md +124 -9
- data/lib/calcsheet/book.rb +25 -9
- data/lib/calcsheet/cell_range.rb +54 -0
- data/lib/calcsheet/configuration.rb +10 -0
- data/lib/calcsheet/display_format.rb +75 -0
- data/lib/calcsheet/errors.rb +5 -0
- data/lib/calcsheet/formula.rb +40 -0
- data/lib/calcsheet/limited_inflater.rb +16 -0
- data/lib/calcsheet/native_backends.rb +139 -0
- data/lib/calcsheet/option_validation.rb +4 -1
- data/lib/calcsheet/raw_row.rb +1 -1
- data/lib/calcsheet/readers/csv_reader.rb +43 -20
- data/lib/calcsheet/readers/csv_records.rb +193 -0
- data/lib/calcsheet/readers/ods/content_parser.rb +49 -69
- data/lib/calcsheet/readers/ods/scanner.rb +33 -6
- data/lib/calcsheet/readers/ods_reader.rb +16 -1
- data/lib/calcsheet/readers/sax_sheet.rb +6 -48
- data/lib/calcsheet/readers/xlsx/sheet_parser.rb +31 -64
- data/lib/calcsheet/reading.rb +29 -7
- data/lib/calcsheet/row.rb +2 -2
- data/lib/calcsheet/row_builder.rb +235 -0
- data/lib/calcsheet/sheet_list.rb +25 -0
- data/lib/calcsheet/sheet_selection.rb +3 -2
- data/lib/calcsheet/style.rb +114 -0
- data/lib/calcsheet/version.rb +1 -1
- data/lib/calcsheet/write_book.rb +49 -17
- data/lib/calcsheet/write_sheet.rb +220 -18
- data/lib/calcsheet/writers/csv_writer.rb +18 -7
- data/lib/calcsheet/writers/ods_writer.rb +249 -16
- data/lib/calcsheet/writers/open_formula.rb +67 -0
- data/lib/calcsheet/writers/xlsx_writer.rb +226 -37
- data/lib/calcsheet/writers/zip_buffered.rb +20 -1
- data/lib/calcsheet.rb +31 -8
- 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)
|
|
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
|
|
182
|
+
@types = columns&.map(&:type)
|
|
169
183
|
@row_number = 0
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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?(
|
|
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
|
-
|
|
245
|
-
|
|
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,
|
|
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
|