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.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +62 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +921 -0
  6. data/lib/calcsheet/book.rb +164 -0
  7. data/lib/calcsheet/cast.rb +578 -0
  8. data/lib/calcsheet/column.rb +117 -0
  9. data/lib/calcsheet/column_letter.rb +34 -0
  10. data/lib/calcsheet/configuration.rb +186 -0
  11. data/lib/calcsheet/decimal_text.rb +19 -0
  12. data/lib/calcsheet/entry_name.rb +28 -0
  13. data/lib/calcsheet/errors.rb +186 -0
  14. data/lib/calcsheet/excel_serial.rb +65 -0
  15. data/lib/calcsheet/format_detector.rb +109 -0
  16. data/lib/calcsheet/formula_protection.rb +31 -0
  17. data/lib/calcsheet/grid.rb +20 -0
  18. data/lib/calcsheet/header_keys.rb +66 -0
  19. data/lib/calcsheet/limited_inflater.rb +326 -0
  20. data/lib/calcsheet/merge_check.rb +41 -0
  21. data/lib/calcsheet/odf.rb +45 -0
  22. data/lib/calcsheet/option_validation.rb +160 -0
  23. data/lib/calcsheet/options.rb +10 -0
  24. data/lib/calcsheet/raw_row.rb +18 -0
  25. data/lib/calcsheet/raw_types.rb +15 -0
  26. data/lib/calcsheet/readers/csv_dialect.rb +264 -0
  27. data/lib/calcsheet/readers/csv_input.rb +128 -0
  28. data/lib/calcsheet/readers/csv_reader.rb +239 -0
  29. data/lib/calcsheet/readers/ods/cell_formats.rb +115 -0
  30. data/lib/calcsheet/readers/ods/content_parser.rb +594 -0
  31. data/lib/calcsheet/readers/ods/count.rb +30 -0
  32. data/lib/calcsheet/readers/ods/cursor.rb +121 -0
  33. data/lib/calcsheet/readers/ods/patterns.rb +70 -0
  34. data/lib/calcsheet/readers/ods/row_seek.rb +157 -0
  35. data/lib/calcsheet/readers/ods/scanner.rb +302 -0
  36. data/lib/calcsheet/readers/ods/style_scan.rb +108 -0
  37. data/lib/calcsheet/readers/ods/tag_text.rb +76 -0
  38. data/lib/calcsheet/readers/ods_reader.rb +71 -0
  39. data/lib/calcsheet/readers/ox_errors.rb +40 -0
  40. data/lib/calcsheet/readers/references.rb +62 -0
  41. data/lib/calcsheet/readers/row_batches.rb +45 -0
  42. data/lib/calcsheet/readers/sax_sheet.rb +108 -0
  43. data/lib/calcsheet/readers/stream_remainder.rb +66 -0
  44. data/lib/calcsheet/readers/tag_scan.rb +119 -0
  45. data/lib/calcsheet/readers/xlsx/escapes.rb +36 -0
  46. data/lib/calcsheet/readers/xlsx/local_name.rb +54 -0
  47. data/lib/calcsheet/readers/xlsx/number_format.rb +89 -0
  48. data/lib/calcsheet/readers/xlsx/row_skipper.rb +162 -0
  49. data/lib/calcsheet/readers/xlsx/shared_strings.rb +209 -0
  50. data/lib/calcsheet/readers/xlsx/sheet_parser.rb +435 -0
  51. data/lib/calcsheet/readers/xlsx/styles.rb +156 -0
  52. data/lib/calcsheet/readers/xlsx/workbook_meta.rb +228 -0
  53. data/lib/calcsheet/readers/xlsx_reader.rb +122 -0
  54. data/lib/calcsheet/reading.rb +281 -0
  55. data/lib/calcsheet/row.rb +110 -0
  56. data/lib/calcsheet/sample.rb +10 -0
  57. data/lib/calcsheet/schema.rb +140 -0
  58. data/lib/calcsheet/sheet_info.rb +21 -0
  59. data/lib/calcsheet/sheet_names.rb +114 -0
  60. data/lib/calcsheet/sheet_selection.rb +58 -0
  61. data/lib/calcsheet/source.rb +263 -0
  62. data/lib/calcsheet/stream_body.rb +81 -0
  63. data/lib/calcsheet/time_zone.rb +96 -0
  64. data/lib/calcsheet/untrusted.rb +32 -0
  65. data/lib/calcsheet/version.rb +6 -0
  66. data/lib/calcsheet/write_book.rb +202 -0
  67. data/lib/calcsheet/write_sheet.rb +282 -0
  68. data/lib/calcsheet/writers/csv_writer.rb +138 -0
  69. data/lib/calcsheet/writers/ods_writer.rb +437 -0
  70. data/lib/calcsheet/writers/xlsx_writer.rb +350 -0
  71. data/lib/calcsheet/writers/xml_text.rb +35 -0
  72. data/lib/calcsheet/writers/zip_buffered.rb +55 -0
  73. data/lib/calcsheet/zip_container.rb +190 -0
  74. data/lib/calcsheet/zip_directory.rb +202 -0
  75. data/lib/calcsheet/zip_end_record.rb +117 -0
  76. data/lib/calcsheet.rb +295 -0
  77. 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" => "&#9;", "\n" => "&#10;" }.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