calcsheet 0.1.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 +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +62 -0
- data/LICENSE.txt +21 -0
- data/README.md +921 -0
- data/lib/calcsheet/book.rb +164 -0
- data/lib/calcsheet/cast.rb +578 -0
- data/lib/calcsheet/column.rb +117 -0
- data/lib/calcsheet/column_letter.rb +34 -0
- data/lib/calcsheet/configuration.rb +186 -0
- data/lib/calcsheet/decimal_text.rb +19 -0
- data/lib/calcsheet/entry_name.rb +28 -0
- data/lib/calcsheet/errors.rb +186 -0
- data/lib/calcsheet/excel_serial.rb +65 -0
- data/lib/calcsheet/format_detector.rb +109 -0
- data/lib/calcsheet/formula_protection.rb +31 -0
- data/lib/calcsheet/grid.rb +20 -0
- data/lib/calcsheet/header_keys.rb +66 -0
- data/lib/calcsheet/limited_inflater.rb +326 -0
- data/lib/calcsheet/merge_check.rb +41 -0
- data/lib/calcsheet/odf.rb +45 -0
- data/lib/calcsheet/option_validation.rb +160 -0
- data/lib/calcsheet/options.rb +10 -0
- data/lib/calcsheet/raw_row.rb +18 -0
- data/lib/calcsheet/raw_types.rb +15 -0
- data/lib/calcsheet/readers/csv_dialect.rb +264 -0
- data/lib/calcsheet/readers/csv_input.rb +128 -0
- data/lib/calcsheet/readers/csv_reader.rb +239 -0
- data/lib/calcsheet/readers/ods/cell_formats.rb +115 -0
- data/lib/calcsheet/readers/ods/content_parser.rb +594 -0
- data/lib/calcsheet/readers/ods/count.rb +30 -0
- data/lib/calcsheet/readers/ods/cursor.rb +121 -0
- data/lib/calcsheet/readers/ods/patterns.rb +70 -0
- data/lib/calcsheet/readers/ods/row_seek.rb +157 -0
- data/lib/calcsheet/readers/ods/scanner.rb +302 -0
- data/lib/calcsheet/readers/ods/style_scan.rb +108 -0
- data/lib/calcsheet/readers/ods/tag_text.rb +76 -0
- data/lib/calcsheet/readers/ods_reader.rb +71 -0
- data/lib/calcsheet/readers/ox_errors.rb +40 -0
- data/lib/calcsheet/readers/references.rb +62 -0
- data/lib/calcsheet/readers/row_batches.rb +45 -0
- data/lib/calcsheet/readers/sax_sheet.rb +108 -0
- data/lib/calcsheet/readers/stream_remainder.rb +66 -0
- data/lib/calcsheet/readers/tag_scan.rb +119 -0
- data/lib/calcsheet/readers/xlsx/escapes.rb +36 -0
- data/lib/calcsheet/readers/xlsx/local_name.rb +54 -0
- data/lib/calcsheet/readers/xlsx/number_format.rb +89 -0
- data/lib/calcsheet/readers/xlsx/row_skipper.rb +162 -0
- data/lib/calcsheet/readers/xlsx/shared_strings.rb +209 -0
- data/lib/calcsheet/readers/xlsx/sheet_parser.rb +435 -0
- data/lib/calcsheet/readers/xlsx/styles.rb +156 -0
- data/lib/calcsheet/readers/xlsx/workbook_meta.rb +228 -0
- data/lib/calcsheet/readers/xlsx_reader.rb +122 -0
- data/lib/calcsheet/reading.rb +281 -0
- data/lib/calcsheet/row.rb +110 -0
- data/lib/calcsheet/sample.rb +10 -0
- data/lib/calcsheet/schema.rb +140 -0
- data/lib/calcsheet/sheet_info.rb +21 -0
- data/lib/calcsheet/sheet_names.rb +114 -0
- data/lib/calcsheet/sheet_selection.rb +58 -0
- data/lib/calcsheet/source.rb +263 -0
- data/lib/calcsheet/stream_body.rb +81 -0
- data/lib/calcsheet/time_zone.rb +96 -0
- data/lib/calcsheet/untrusted.rb +32 -0
- data/lib/calcsheet/version.rb +6 -0
- data/lib/calcsheet/write_book.rb +202 -0
- data/lib/calcsheet/write_sheet.rb +282 -0
- data/lib/calcsheet/writers/csv_writer.rb +138 -0
- data/lib/calcsheet/writers/ods_writer.rb +437 -0
- data/lib/calcsheet/writers/xlsx_writer.rb +350 -0
- data/lib/calcsheet/writers/xml_text.rb +35 -0
- data/lib/calcsheet/writers/zip_buffered.rb +55 -0
- data/lib/calcsheet/zip_container.rb +190 -0
- data/lib/calcsheet/zip_directory.rb +202 -0
- data/lib/calcsheet/zip_end_record.rb +117 -0
- data/lib/calcsheet.rb +295 -0
- metadata +200 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "date"
|
|
5
|
+
require "stringio" # zip_kit 6.3 uses StringIO without requiring it
|
|
6
|
+
require "zip_kit"
|
|
7
|
+
require "zlib"
|
|
8
|
+
require_relative "../decimal_text"
|
|
9
|
+
require_relative "../errors"
|
|
10
|
+
require_relative "../grid"
|
|
11
|
+
require_relative "../odf"
|
|
12
|
+
require_relative "xml_text"
|
|
13
|
+
require_relative "zip_buffered"
|
|
14
|
+
|
|
15
|
+
module Calcsheet
|
|
16
|
+
module Writers
|
|
17
|
+
# ODS writer backend, see the contract in WriteBook. The +mimetype+ entry comes first, stored, without data
|
|
18
|
+
# descriptor (LibreOffice refuses one that has a descriptor) and without extra field (ODF packages §3.3: magic
|
|
19
|
+
# sniffing finds the media type at offset 38). Every sheet is a table of +content.xml+, one
|
|
20
|
+
# deflated zip entry streamed with zip_kit as rows arrive, built as strings in a buffer of about BUFFER_SIZE
|
|
21
|
+
# bytes: memory stays flat and the output may be non-seekable. The parts known only at the end, +styles.xml+
|
|
22
|
+
# (the styles the sheets used), +settings.xml+ (the frozen headers, by sheet name) and the manifest, follow it.
|
|
23
|
+
#
|
|
24
|
+
# Styles: +office:automatic-styles+ must precede the tables in +content.xml+, before the sheets are known, so
|
|
25
|
+
# the cell styles are common styles of +styles.xml+ instead, written at the end with those the sheets used:
|
|
26
|
+
# ODF lets a cell's +table:style-name+ name a common style, and LibreOffice applies it. They are the bold header,
|
|
27
|
+
# a number style per decimal +scale+ (the full value is stored, the style shows +scale+ decimals), dates,
|
|
28
|
+
# datetimes and booleans. LibreOffice ignores a column's common style, though, so the column widths are
|
|
29
|
+
# automatic styles, declared for every whole width from 1 to 255 characters (MAX_WIDTH, the most Excel allows):
|
|
30
|
+
# a width is rounded to whole characters and kept in that range, and any sheet can use any width.
|
|
31
|
+
#
|
|
32
|
+
# A :decimal cell with a +scale+ shows its value rounded half up to +scale+ decimals in its paragraph, as its number
|
|
33
|
+
# style does; +office:value+ keeps the full value.
|
|
34
|
+
#
|
|
35
|
+
# Text cells are +office:value-type="string"+, never evaluated, so "+39..." and "=..." stay verbatim.
|
|
36
|
+
# 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
|
+
# character is written as one space and a +text:s+ for the rest, other spaces (leading, after a tab or a line
|
|
38
|
+
# break) as +text:s+, tabs as +text:tab+ and line feeds as +text:line-break+. A carriage return has no element:
|
|
39
|
+
# text that holds one also carries its exact value in +office:string-value+, which LibreOffice and the reader
|
|
40
|
+
# prefer to the paragraph. So does text with more than Odf::MAX_SPACES spaces, more than the reader takes from the
|
|
41
|
+
# +text:s+ of a cell; a +text:s+ counts at most Odf::MAX_SPACES spaces.
|
|
42
|
+
#
|
|
43
|
+
# @api private
|
|
44
|
+
class OdsWriter
|
|
45
|
+
include ZipBuffered
|
|
46
|
+
|
|
47
|
+
# The private method of zip_kit's ZipWriter that ZipWriter below overrides.
|
|
48
|
+
OVERRIDDEN_ZIP_METHOD = :timestamp_extra_for_local_file_header
|
|
49
|
+
|
|
50
|
+
# The override relies on a private method of zip_kit (the gemspec allows any 6.x from 6.3): fail on the first ODS
|
|
51
|
+
# write, with a clear message, rather than write a mimetype entry LibreOffice refuses. Not when the gem loads:
|
|
52
|
+
# a zip_kit change must not break the CSV and XLSX users.
|
|
53
|
+
# @raise [Calcsheet::Error] when +base+ does not define the method
|
|
54
|
+
def self.check_zip_writer!(base)
|
|
55
|
+
return if base.private_method_defined?(OVERRIDDEN_ZIP_METHOD) || base.method_defined?(OVERRIDDEN_ZIP_METHOD)
|
|
56
|
+
|
|
57
|
+
raise Calcsheet::Error, "zip_kit #{ZipKit::VERSION} has no ZipWriter##{OVERRIDDEN_ZIP_METHOD}, which the ODS writer " \
|
|
58
|
+
"overrides to write the mimetype entry without an extra field; use a zip_kit release that has it"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# zip_kit's writer, except that the local header of the +mimetype+ entry has no extra field: zip_kit adds an
|
|
62
|
+
# extended timestamp to every entry.
|
|
63
|
+
class ZipWriter < ZipKit::ZipWriter
|
|
64
|
+
def write_local_file_header(filename:, **)
|
|
65
|
+
@bare = filename == Odf::MIMETYPE_ENTRY
|
|
66
|
+
super
|
|
67
|
+
ensure
|
|
68
|
+
@bare = false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def timestamp_extra_for_local_file_header(mtime)
|
|
74
|
+
@bare ? "" : super
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The widest column style, in characters (Column::MAX_WIDTH).
|
|
79
|
+
MAX_WIDTH = 255
|
|
80
|
+
# The ODF version the documents declare.
|
|
81
|
+
VERSION = "1.3"
|
|
82
|
+
DECLARATION = %(<?xml version="1.0" encoding="UTF-8"?>\n)
|
|
83
|
+
# 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
|
|
86
|
+
MANIFEST_NS = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"
|
|
87
|
+
# The namespace declarations and version of every root element.
|
|
88
|
+
ROOT_ATTRIBUTES = "#{NAMESPACES.map { |prefix, uri| %(xmlns:#{prefix}="#{uri}") }.join(" ")} office:version=\"#{VERSION}\"".freeze
|
|
89
|
+
# A width of one character, as LibreOffice converts the widths of XLSX columns.
|
|
90
|
+
INCHES_PER_CHARACTER = 0.0875
|
|
91
|
+
# The automatic column styles co1 to co255, one per whole width.
|
|
92
|
+
COLUMN_STYLES = (1..MAX_WIDTH).map do |width|
|
|
93
|
+
%(<style:style style:name="co#{width}" style:family="table-column"><style:table-column-properties ) +
|
|
94
|
+
%(style:column-width="#{format("%.4f", width * INCHES_PER_CHARACTER)}in"/></style:style>)
|
|
95
|
+
end.join.freeze
|
|
96
|
+
|
|
97
|
+
# Common cell styles by key: their name, data style and properties.
|
|
98
|
+
HEADER_STYLE = "Header"
|
|
99
|
+
DATE_STYLE = "Date"
|
|
100
|
+
DATETIME_STYLE = "Datetime"
|
|
101
|
+
BOOLEAN_STYLE = "Boolean"
|
|
102
|
+
# The text properties of the header style.
|
|
103
|
+
BOLD = '<style:text-properties fo:font-weight="bold" style:font-weight-asian="bold" style:font-weight-complex="bold"/>'
|
|
104
|
+
# The fields of the date and datetime data styles: yyyy-mm-dd and hh:mm:ss.
|
|
105
|
+
DATE_PARTS = '<number:year number:style="long"/><number:text>-</number:text><number:month number:style="long"/>' \
|
|
106
|
+
'<number:text>-</number:text><number:day number:style="long"/>'
|
|
107
|
+
TIME_PARTS = '<number:text> </number:text><number:hours number:style="long"/><number:text>:</number:text>' \
|
|
108
|
+
'<number:minutes number:style="long"/><number:text>:</number:text><number:seconds number:style="long"/>'
|
|
109
|
+
|
|
110
|
+
# The view settings of a sheet whose first row is frozen, as LibreOffice writes them.
|
|
111
|
+
FROZEN_HEADER = {
|
|
112
|
+
"HorizontalSplitMode" => [ "short", 0 ], "VerticalSplitMode" => [ "short", 2 ],
|
|
113
|
+
"HorizontalSplitPosition" => [ "int", 0 ], "VerticalSplitPosition" => [ "int", 1 ],
|
|
114
|
+
"ActiveSplitRange" => [ "short", 2 ], "PositionLeft" => [ "int", 0 ], "PositionRight" => [ "int", 0 ],
|
|
115
|
+
"PositionTop" => [ "int", 0 ], "PositionBottom" => [ "int", 1 ]
|
|
116
|
+
}.map { |name, (type, value)| %(<config:config-item config:name="#{name}" config:type="#{type}">#{value}</config:config-item>) }
|
|
117
|
+
.join.freeze
|
|
118
|
+
|
|
119
|
+
# The pieces of the cells: an empty cell, the end of a cell with its paragraph, the end of a value attribute
|
|
120
|
+
# and the start of the paragraph, and the start tags of text cells up to their attributes.
|
|
121
|
+
EMPTY_CELL = "<table:table-cell/>"
|
|
122
|
+
CELL_END = "</text:p></table:table-cell>"
|
|
123
|
+
VALUE_END = '"><text:p>'
|
|
124
|
+
STRING_START = '<table:table-cell office:value-type="string"'
|
|
125
|
+
HEADER_START = %(<table:table-cell table:style-name="#{HEADER_STYLE}" office:value-type="string").freeze
|
|
126
|
+
# The byte of the "T" in "YYYY-MM-DDThh:mm:ss", a space in the text shown.
|
|
127
|
+
TIME_SEPARATOR = 10
|
|
128
|
+
# The byte of a space.
|
|
129
|
+
SPACE = 32
|
|
130
|
+
# Text a paragraph would change: a tab, a line feed, a leading space or two spaces.
|
|
131
|
+
PARAGRAPH_SPACE = /[\t\n]|\A | {2}/
|
|
132
|
+
# A tab, a line feed, a run of spaces after another character, or one at the start or after a tab or line feed.
|
|
133
|
+
SPACE_TOKENS = /(\t)|(\n)|(?<=[^\t\n])( +)|( +)/
|
|
134
|
+
# What an attribute value would turn into spaces, as character references.
|
|
135
|
+
ATTRIBUTE_BREAKS = /[\t\n]/
|
|
136
|
+
ATTRIBUTE_SPACE = { "\t" => "	", "\n" => " " }.freeze
|
|
137
|
+
# A carriage return, alone or before a line feed: one line break in the paragraph.
|
|
138
|
+
LINE_ENDING = /\r\n?/
|
|
139
|
+
|
|
140
|
+
# @return [Integer] rows per sheet, header included
|
|
141
|
+
def self.max_rows_per_sheet
|
|
142
|
+
Grid::MAX_ROWS
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @param io [#<<] the output, never closed here
|
|
146
|
+
# @param _options [Options] as returned by WriteBook.prepare_options; ODS has no format options
|
|
147
|
+
# @raise [Calcsheet::Error] when zip_kit has no longer the method the zip writer overrides
|
|
148
|
+
def initialize(io, _options)
|
|
149
|
+
self.class.check_zip_writer!(ZipKit::ZipWriter)
|
|
150
|
+
@zip = ZipKit::Streamer.new(io, writer: ZipWriter.new)
|
|
151
|
+
@zip.add_stored_entry(filename: Odf::MIMETYPE_ENTRY, size: Odf::MIMETYPE.bytesize, crc32: Zlib.crc32(Odf::MIMETYPE),
|
|
152
|
+
use_data_descriptor: false)
|
|
153
|
+
@zip << Odf::MIMETYPE
|
|
154
|
+
@entry = @zip.write_deflated_file("content.xml")
|
|
155
|
+
@buffer = new_buffer
|
|
156
|
+
@buffer << content_start
|
|
157
|
+
@sheets = []
|
|
158
|
+
@styles = {}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Starts the sheet's table in content.xml, with its columns and header row.
|
|
162
|
+
# @raise [ArgumentError] for more columns than ODS holds
|
|
163
|
+
def start_sheet(name, columns, header:)
|
|
164
|
+
check_columns(name, columns, "ODS")
|
|
165
|
+
@sheets << [ name, header ]
|
|
166
|
+
@name = name
|
|
167
|
+
@columns = columns
|
|
168
|
+
@types = columns.map(&:type)
|
|
169
|
+
@row_number = 0
|
|
170
|
+
prepare_cells(columns)
|
|
171
|
+
@buffer << %(<table:table table:name="#{XmlText.escape(name)}">) << table_columns(columns)
|
|
172
|
+
write_header(columns) if header
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Writes one row of checked values (see the contract in WriteBook), consecutive empty cells as one repeated cell.
|
|
176
|
+
# @raise [WriteError] for a year outside 1..9999; nothing of the row is written
|
|
177
|
+
def row(values)
|
|
178
|
+
mark = @buffer.bytesize
|
|
179
|
+
@buffer << "<table:table-row>"
|
|
180
|
+
empty = 0
|
|
181
|
+
index = 0
|
|
182
|
+
while index < values.size
|
|
183
|
+
value = values[index]
|
|
184
|
+
if value.nil?
|
|
185
|
+
empty += 1
|
|
186
|
+
else
|
|
187
|
+
if empty.positive?
|
|
188
|
+
@buffer << empty_cells(empty)
|
|
189
|
+
empty = 0
|
|
190
|
+
end
|
|
191
|
+
cell(value, index)
|
|
192
|
+
end
|
|
193
|
+
index += 1
|
|
194
|
+
end
|
|
195
|
+
@buffer << EMPTY_CELL if empty == values.size
|
|
196
|
+
@buffer << "</table:table-row>"
|
|
197
|
+
@row_number += 1
|
|
198
|
+
flush if @buffer.bytesize >= BUFFER_SIZE
|
|
199
|
+
rescue StandardError
|
|
200
|
+
# Nothing of a rejected row is written (a WriteError of a date beyond the range).
|
|
201
|
+
rollback(mark)
|
|
202
|
+
raise
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A table needs a row: a sheet without any gets an empty one.
|
|
206
|
+
def end_sheet
|
|
207
|
+
@buffer << "<table:table-row>#{EMPTY_CELL}</table:table-row>" if @row_number.zero?
|
|
208
|
+
@buffer << "</table:table>"
|
|
209
|
+
flush
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Ends content.xml and writes the parts that depend on the sheets, and the zip central directory. A spreadsheet
|
|
213
|
+
# needs a table: a book without any gets an empty one.
|
|
214
|
+
def finish
|
|
215
|
+
empty_book_sheet if @sheets.empty?
|
|
216
|
+
@buffer << "</office:spreadsheet></office:body></office:document-content>"
|
|
217
|
+
flush
|
|
218
|
+
@entry.close
|
|
219
|
+
write_parts("styles.xml" => styles_xml, "settings.xml" => settings_xml, "META-INF/manifest.xml" => manifest_xml)
|
|
220
|
+
@zip.close
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# @api private
|
|
224
|
+
# The first value of a checked row that #row would refuse, without writing anything (see
|
|
225
|
+
# XlsxWriter#row_problem).
|
|
226
|
+
# @return [Array(Integer, String), nil] the column index and the reason
|
|
227
|
+
def row_problem(values)
|
|
228
|
+
values.each_with_index do |value, index|
|
|
229
|
+
next if value.nil? || !%i[date datetime].include?(@types[index]) || (value.year >= 1 && value.year <= 9999)
|
|
230
|
+
|
|
231
|
+
return [ index, year_reason(value) ]
|
|
232
|
+
end
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
private
|
|
237
|
+
|
|
238
|
+
def content_start
|
|
239
|
+
%(#{DECLARATION}<office:document-content #{ROOT_ATTRIBUTES}><office:automatic-styles>#{COLUMN_STYLES}) +
|
|
240
|
+
"</office:automatic-styles><office:body><office:spreadsheet>"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# 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)}") }
|
|
246
|
+
return "<table:table-column/>" if styles.empty?
|
|
247
|
+
|
|
248
|
+
styles.chunk_while { _1 == _2 }.map do |run|
|
|
249
|
+
repeated = run.size > 1 ? %( table:number-columns-repeated="#{run.size}") : ""
|
|
250
|
+
"<table:table-column#{run.first}#{repeated}/>"
|
|
251
|
+
end.join
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# The constant parts of each column's cells: the start up to the value, the part between the value and its text,
|
|
255
|
+
# and the whole cells of booleans.
|
|
256
|
+
def prepare_cells(columns)
|
|
257
|
+
@openings = columns.map do |column|
|
|
258
|
+
style = cell_style(column)
|
|
259
|
+
attributes = style ? %( table:style-name="#{style}") : ""
|
|
260
|
+
case column.type
|
|
261
|
+
when :integer, :decimal then %(<table:table-cell#{attributes} office:value-type="float" office:value=")
|
|
262
|
+
when :date, :datetime then %(<table:table-cell#{attributes} office:value-type="date" office:date-value=")
|
|
263
|
+
when :boolean then %(<table:table-cell#{attributes} office:value-type="boolean" office:boolean-value=")
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
@scales = columns.map { _1.type == :decimal ? _1.scale : nil }
|
|
267
|
+
@trues = @openings.map { %(#{_1}true#{VALUE_END}TRUE#{CELL_END}) if _1 }
|
|
268
|
+
@falses = @openings.map { %(#{_1}false#{VALUE_END}FALSE#{CELL_END}) if _1 }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# The common style of a column's data cells, registered for styles.xml at its first use.
|
|
272
|
+
def cell_style(column)
|
|
273
|
+
case column.type
|
|
274
|
+
when :decimal then column.scale && use_style("Decimal#{column.scale}")
|
|
275
|
+
when :date then use_style(DATE_STYLE)
|
|
276
|
+
when :datetime then use_style(DATETIME_STYLE)
|
|
277
|
+
when :boolean then use_style(BOOLEAN_STYLE)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def use_style(name)
|
|
282
|
+
@styles[name] = true
|
|
283
|
+
name
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def empty_cells(count)
|
|
287
|
+
count == 1 ? EMPTY_CELL : %(<table:table-cell table:number-columns-repeated="#{count}"/>)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def write_header(columns)
|
|
291
|
+
use_style(HEADER_STYLE)
|
|
292
|
+
@buffer << "<table:table-row>"
|
|
293
|
+
columns.each { text_cell(HEADER_START, _1.header) }
|
|
294
|
+
@buffer << "</table:table-row>"
|
|
295
|
+
@row_number = 1
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def cell(value, index)
|
|
299
|
+
case @types[index]
|
|
300
|
+
when :string then text_cell(STRING_START, value)
|
|
301
|
+
when :integer then number(value.to_s, index)
|
|
302
|
+
when :decimal
|
|
303
|
+
text = DecimalText.plain(value)
|
|
304
|
+
scale = @scales[index]
|
|
305
|
+
@buffer << @openings[index] << text << VALUE_END << (scale ? shown(value, text, scale) : text) << CELL_END
|
|
306
|
+
when :date
|
|
307
|
+
check_year(value, index)
|
|
308
|
+
text = value.iso8601
|
|
309
|
+
@buffer << @openings[index] << text << VALUE_END << text << CELL_END
|
|
310
|
+
when :datetime
|
|
311
|
+
check_year(value, index)
|
|
312
|
+
text = value.strftime("%Y-%m-%dT%H:%M:%S")
|
|
313
|
+
@buffer << @openings[index] << text << VALUE_END
|
|
314
|
+
text.setbyte(TIME_SEPARATOR, SPACE)
|
|
315
|
+
@buffer << text << CELL_END
|
|
316
|
+
else @buffer << (value ? @trues[index] : @falses[index]) # :boolean
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def number(text, index)
|
|
321
|
+
@buffer << @openings[index] << text << VALUE_END << text << CELL_END
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# The decimal rounded half up to +scale+ decimals, as its number style shows it; never "-0.00".
|
|
325
|
+
# @param text [String] the value written, digits with at most one "."
|
|
326
|
+
def shown(value, text, scale)
|
|
327
|
+
if value.is_a?(BigDecimal)
|
|
328
|
+
rounded = value.round(scale, BigDecimal::ROUND_HALF_UP)
|
|
329
|
+
rounded = rounded.abs if rounded.zero?
|
|
330
|
+
text = rounded.to_s("F")
|
|
331
|
+
end
|
|
332
|
+
dot = text.index(".")
|
|
333
|
+
return dot ? text[0, dot] : text if scale.zero?
|
|
334
|
+
|
|
335
|
+
missing = dot ? scale - (text.bytesize - dot - 1) : scale
|
|
336
|
+
"#{text}#{"." unless dot}#{"0" * missing}"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Appends a text cell from +start+, its start tag up to the attributes. A carriage return has no element in a
|
|
340
|
+
# paragraph: text that holds one is also written exactly in office:string-value, and with line feeds instead in
|
|
341
|
+
# the paragraph; so is text with more spaces than the reader takes from a paragraph (counted generously).
|
|
342
|
+
def text_cell(start, text)
|
|
343
|
+
@buffer << start
|
|
344
|
+
# Only a text of more than Odf::MAX_SPACES bytes can hold more spaces: the others are not counted.
|
|
345
|
+
if text.include?("\r") || (text.bytesize > Odf::MAX_SPACES && text.count(" ") > Odf::MAX_SPACES)
|
|
346
|
+
@buffer << ' office:string-value="' << XmlText.escape(text).gsub(ATTRIBUTE_BREAKS, ATTRIBUTE_SPACE) << '"'
|
|
347
|
+
text = text.gsub(LINE_ENDING, "\n")
|
|
348
|
+
end
|
|
349
|
+
@buffer << "><text:p>" << paragraph(text) << CELL_END
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Escaped text whose spaces, tabs and line feeds survive the white-space collapsing of a paragraph.
|
|
353
|
+
def paragraph(text)
|
|
354
|
+
text = XmlText.escape(text)
|
|
355
|
+
return text unless PARAGRAPH_SPACE.match?(text)
|
|
356
|
+
|
|
357
|
+
text.gsub(SPACE_TOKENS) do
|
|
358
|
+
if $1 then "<text:tab/>"
|
|
359
|
+
elsif $2 then "<text:line-break/>"
|
|
360
|
+
elsif $3 then $3.size == 1 ? " " : " #{spaces($3.size - 1)}"
|
|
361
|
+
else spaces($4.size)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def spaces(count)
|
|
367
|
+
return "<text:s/>" if count == 1
|
|
368
|
+
return %(<text:s text:c="#{count}"/>) if count <= Odf::MAX_SPACES
|
|
369
|
+
|
|
370
|
+
full, rest = count.divmod(Odf::MAX_SPACES)
|
|
371
|
+
(%(<text:s text:c="#{Odf::MAX_SPACES}"/>) * full) + (rest.zero? ? "" : spaces(rest))
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# @raise [WriteError] the year is before 1 or after 9999: xsd:date has no year 0, the readers read four digits
|
|
375
|
+
def check_year(value, index)
|
|
376
|
+
return if value.year >= 1 && value.year <= 9999
|
|
377
|
+
|
|
378
|
+
raise WriteError.at(year_reason(value), sheet: @name, row: @row_number + 1, column_index: index, header: @columns[index].header)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def year_reason(value)
|
|
382
|
+
"ODS dates are written from 0001-01-01 to 9999-12-31, got #{value.inspect}"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def styles_xml
|
|
386
|
+
data_styles = []
|
|
387
|
+
cell_styles = @styles.keys.map do |name|
|
|
388
|
+
case name
|
|
389
|
+
when HEADER_STYLE then %(<style:style style:name="#{name}" style:family="table-cell">#{BOLD}</style:style>)
|
|
390
|
+
else
|
|
391
|
+
data_styles << data_style(name)
|
|
392
|
+
%(<style:style style:name="#{name}" style:family="table-cell" style:data-style-name="N#{name}"/>)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
%(#{DECLARATION}<office:document-styles #{ROOT_ATTRIBUTES}><office:styles>#{data_styles.join}#{cell_styles.join}) +
|
|
396
|
+
"</office:styles></office:document-styles>"
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def data_style(name)
|
|
400
|
+
case name
|
|
401
|
+
when DATE_STYLE then %(<number:date-style style:name="N#{name}">#{DATE_PARTS}</number:date-style>)
|
|
402
|
+
when DATETIME_STYLE then %(<number:date-style style:name="N#{name}">#{DATE_PARTS}#{TIME_PARTS}</number:date-style>)
|
|
403
|
+
when BOOLEAN_STYLE then %(<number:boolean-style style:name="N#{name}"><number:boolean/></number:boolean-style>)
|
|
404
|
+
else
|
|
405
|
+
scale = name.delete_prefix("Decimal")
|
|
406
|
+
%(<number:number-style style:name="N#{name}"><number:number number:decimal-places="#{scale}" ) +
|
|
407
|
+
%(number:min-decimal-places="#{scale}" number:min-integer-digits="1"/></number:number-style>)
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# The view settings LibreOffice reads: the first sheet active, and the first row frozen in each sheet with a
|
|
412
|
+
# header. A Tables map needs an entry, so it is left out when no sheet has a header.
|
|
413
|
+
def settings_xml
|
|
414
|
+
frozen = @sheets.filter_map do |name, header|
|
|
415
|
+
%(<config:config-item-map-entry config:name="#{XmlText.escape(name)}">#{FROZEN_HEADER}</config:config-item-map-entry>) if header
|
|
416
|
+
end
|
|
417
|
+
tables = frozen.empty? ? "" : %(<config:config-item-map-named config:name="Tables">#{frozen.join}</config:config-item-map-named>)
|
|
418
|
+
%(#{DECLARATION}<office:document-settings #{ROOT_ATTRIBUTES}><office:settings>) +
|
|
419
|
+
'<config:config-item-set config:name="ooo:view-settings"><config:config-item-map-indexed config:name="Views">' \
|
|
420
|
+
'<config:config-item-map-entry><config:config-item config:name="ViewId" config:type="string">view1</config:config-item>' +
|
|
421
|
+
tables +
|
|
422
|
+
%(<config:config-item config:name="ActiveTable" config:type="string">#{XmlText.escape(@sheets.first.first)}</config:config-item>) +
|
|
423
|
+
"</config:config-item-map-entry></config:config-item-map-indexed></config:config-item-set></office:settings>" \
|
|
424
|
+
"</office:document-settings>"
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def manifest_xml
|
|
428
|
+
entries = %w[content.xml styles.xml settings.xml].map do |path|
|
|
429
|
+
%(<manifest:file-entry manifest:full-path="#{path}" manifest:media-type="text/xml"/>)
|
|
430
|
+
end
|
|
431
|
+
%(#{DECLARATION}<manifest:manifest xmlns:manifest="#{MANIFEST_NS}" manifest:version="#{VERSION}">) +
|
|
432
|
+
%(<manifest:file-entry manifest:full-path="/" manifest:version="#{VERSION}" manifest:media-type="#{Odf::MIMETYPE}"/>) +
|
|
433
|
+
"#{entries.join}</manifest:manifest>"
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|