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,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "date"
5
+ require_relative "cast"
6
+ require_relative "column"
7
+ require_relative "errors"
8
+ require_relative "time_zone"
9
+
10
+ module Calcsheet
11
+ # A sheet being written, yielded by WriteBook#sheet. Each row is checked against the column types
12
+ # before the backend sees it, so a rejected row (WriteError) writes nothing and the sheet stays usable. When the
13
+ # sheet reaches the row limit (header included) it continues in a sheet named "Base (2)", "Base (3)"..., repeating
14
+ # the header.
15
+ class WriteSheet
16
+ # What each column type accepts, for the message of a refused value.
17
+ EXPECTED = {
18
+ string: "a String", integer: "an Integer", decimal: "a finite BigDecimal or an Integer", date: "a Date",
19
+ datetime: "a Time", boolean: "true or false"
20
+ }.freeze
21
+ # Integers of fewer bits, and decimals with fewer digits and a smaller exponent, are written in less than
22
+ # Cast::MAX_NUMBER_BYTES characters (2**3000 has 904 digits), so only longer ones are measured.
23
+ SHORT_INTEGER_BITS = 3_000
24
+ # A decimal with fewer significant digits and a smaller exponent writes fewer than 1,024 characters.
25
+ SHORT_DECIMAL_DIGITS = 500
26
+ # Integers of more bits are measured from their bit length, not their text.
27
+ EXACT_INTEGER_BITS = 1_000_000
28
+ # The bound of #cell's quick text check without max_cell_bytes: no String is that long.
29
+ NO_TEXT_LIMIT = 1 << 62
30
+ # The unknown keys of a row a message lists.
31
+ SHOWN_KEYS = 10
32
+
33
+ # @return [String] the name of the sheet rows are written to, a continuation after an overflow
34
+ attr_reader :name
35
+
36
+ # @api private
37
+ # @param writer [Object] the writer backend
38
+ # @param names [SheetNames] the names of the workbook
39
+ # @param columns [Array<Hash, Column>]
40
+ # @param limit [Integer, nil] rows per sheet, header included; nil for no limit
41
+ # @param zone [#local, nil] the time_zone that converts written times; nil keeps their own wall clock
42
+ # @param max_cell_bytes [Integer, nil] the longest text a cell may hold, as the readers bound it; nil for none
43
+ # @raise [ArgumentError] on invalid columns, name or limit
44
+ # @raise [WriteError] for a written header longer than +max_cell_bytes+, or one the backend refuses
45
+ def initialize(writer, names, name, columns, header:, limit:, zone:, max_cell_bytes: nil)
46
+ raise ArgumentError, "columns: must be a non-empty Array" unless columns.is_a?(Array) && !columns.empty?
47
+
48
+ @columns = columns.map { Column.from(_1) }.freeze
49
+ @types = @columns.map(&:type).freeze
50
+ raise ArgumentError, "header: must be true or false, got #{header.inspect}" unless header == true || header == false
51
+
52
+ @header = header
53
+ if @header && limit && limit < 2
54
+ raise ArgumentError, "max_rows_per_sheet: #{limit} leaves no room for data rows after the header"
55
+ end
56
+
57
+ @writer = writer
58
+ @names = names
59
+ @limit = limit
60
+ @zone = zone
61
+ @max_cell_bytes = max_cell_bytes
62
+ # The texts of fewer bytes fit whatever prefix the backend adds (one byte at most); one comparison per text.
63
+ @short_text = max_cell_bytes || NO_TEXT_LIMIT
64
+ @base = names.claim(name)
65
+ @part = 1
66
+ @closed = false
67
+ begin
68
+ check_headers(@base)
69
+ start(@base)
70
+ rescue StandardError
71
+ # The sheet was refused (a header too long, too many columns...): nothing was written, so the name is free
72
+ # again.
73
+ names.release(@base)
74
+ raise
75
+ end
76
+ end
77
+
78
+ # @param row [Array, Hash] values in column order, or keyed by each column's +key:+ (else its header); missing
79
+ # values are empty cells
80
+ # @return [self]
81
+ # @raise [WriteError] a value does not fit its column type or the format (a number longer than
82
+ # Cast::MAX_NUMBER_BYTES, a text longer than +max_cell_bytes+ or an XLSX cell, a date the format cannot hold), or
83
+ # the Array has more values than columns; nothing of the row is written and the sheet stays usable
84
+ # @raise [ArgumentError] the row is neither an Array nor a Hash, has unknown or ambiguous keys, or the sheet's
85
+ # block has ended
86
+ def <<(row)
87
+ raise ArgumentError, "sheet #{@name.inspect} is closed" if @closed
88
+
89
+ values = check(values_of(row))
90
+ if full?
91
+ problem = @writer.respond_to?(:row_problem) && @writer.row_problem(values)
92
+ raise failure(problem[1], problem[0]) if problem
93
+
94
+ continue
95
+ end
96
+ @writer.row(values)
97
+ @rows += 1
98
+ self
99
+ end
100
+
101
+ private
102
+
103
+ # Ends the sheet in the backend. Private, like #abandon: only WriteBook#sheet calls them (through +send+), once,
104
+ # after the block, so the block cannot end the sheet twice and corrupt the file.
105
+ def close
106
+ @closed = true
107
+ @writer.end_sheet
108
+ end
109
+
110
+ # Marks the sheet closed without ending it in the backend, when its block did not complete.
111
+ def abandon
112
+ @closed = true
113
+ end
114
+
115
+ def start(name)
116
+ @writer.start_sheet(name, @columns, header: @header)
117
+ @name = name
118
+ @rows = @header ? 1 : 0
119
+ end
120
+
121
+ def full?
122
+ !@limit.nil? && @rows >= @limit
123
+ end
124
+
125
+ def continue
126
+ @writer.end_sheet
127
+ name, @part = @names.claim_continuation(@base, @part + 1)
128
+ start(name)
129
+ end
130
+
131
+ def values_of(row)
132
+ case row
133
+ when Array
134
+ raise failure("#{row.size} values for #{@columns.size} columns") if row.size > @columns.size
135
+
136
+ row
137
+ when Hash then from_hash(row)
138
+ else raise ArgumentError, "row must be an Array or a Hash, got #{Untrusted.show(row)}"
139
+ end
140
+ end
141
+
142
+ def from_hash(row)
143
+ values = Array.new(@columns.size)
144
+ unknown = nil
145
+ row.each do |key, value|
146
+ index = lookup[key]
147
+ index.nil? ? (unknown ||= []) << key : values[index] = value
148
+ end
149
+ raise ArgumentError, "unknown column key(s) #{shown_keys(unknown)}" if unknown
150
+
151
+ values
152
+ end
153
+
154
+ # The first SHOWN_KEYS keys, each cut (Untrusted.show), and how many more there are.
155
+ def shown_keys(keys)
156
+ shown = keys.first(SHOWN_KEYS).map { Untrusted.show(_1) }.join(", ")
157
+ keys.size > SHOWN_KEYS ? "#{shown} and #{keys.size - SHOWN_KEYS} more" : shown
158
+ end
159
+
160
+ def lookup
161
+ @lookup ||= @columns.each_with_index.with_object({}) do |(column, index), keys|
162
+ key = column.lookup_key
163
+ raise ArgumentError, "ambiguous column key #{Untrusted.show(key)}: give the columns distinct key: values" if keys.key?(key)
164
+
165
+ keys[key] = index
166
+ end
167
+ end
168
+
169
+ def check(values)
170
+ Array.new(@columns.size) do |index|
171
+ value = values[index]
172
+ value.nil? ? nil : cell(value, index)
173
+ end
174
+ end
175
+
176
+ def cell(value, index)
177
+ type = @types[index]
178
+ case type
179
+ when :string
180
+ if value.is_a?(String)
181
+ text = Column.utf8(value)
182
+ raise failure("text cannot be converted to UTF-8, got #{Untrusted.show(value)}", index) if text.nil?
183
+
184
+ return text.bytesize < @short_text ? text : long_text(text, index)
185
+ end
186
+ when :integer
187
+ # The bit length of a common Integer is checked here, without a method call per cell.
188
+ return value.bit_length < SHORT_INTEGER_BITS ? value : number(value, index) if value.is_a?(Integer)
189
+ when :decimal
190
+ return value.bit_length < SHORT_INTEGER_BITS ? value : number(value, index) if value.is_a?(Integer)
191
+ return number(value, index) if value.is_a?(BigDecimal) && value.finite?
192
+ when :date then return value if value.is_a?(Date) && !value.is_a?(DateTime)
193
+ when :datetime then return wall_clock(value) if value.is_a?(Time)
194
+ when :boolean then return value if true.equal?(value) || false.equal?(value)
195
+ end
196
+ raise failure("expected #{EXPECTED.fetch(type)} (:#{type} column), got #{Untrusted.show(value)}", index)
197
+ end
198
+
199
+ # A number whose written text (the full digits of an Integer, the plain text of a BigDecimal) is longer than
200
+ # Cast::MAX_NUMBER_BYTES would make the file unreadable: the readers refuse longer numbers. The length is
201
+ # computed without the text, which for BigDecimal("1e500000000") would take gigabytes. An Integer comes here only
202
+ # with SHORT_INTEGER_BITS bits or more: #cell returns the common short ones itself.
203
+ def number(value, index)
204
+ if value.is_a?(Integer)
205
+ length = integer_length(value)
206
+ return value if length.is_a?(Integer) && length <= Cast::MAX_NUMBER_BYTES
207
+ else
208
+ return value if value.exponent.abs < SHORT_DECIMAL_DIGITS && value.n_significant_digits < SHORT_DECIMAL_DIGITS
209
+
210
+ length = self.class.plain_length(value)
211
+ return value if length <= Cast::MAX_NUMBER_BYTES
212
+ end
213
+
214
+ raise failure("the number is #{length} characters long, more than the #{Cast::MAX_NUMBER_BYTES} that a reader reads", index)
215
+ end
216
+
217
+ # A text the readers would refuse: longer than max_cell_bytes as the backend writes it (CSV puts a "'" before a
218
+ # text that starts like a formula, see #text_bytesize in the WriteBook contract).
219
+ def long_text(text, index)
220
+ length = @writer.respond_to?(:text_bytesize) ? @writer.text_bytesize(text, index) : text.bytesize
221
+ return text if length <= @max_cell_bytes
222
+
223
+ raise failure("the text is #{length} bytes long, more than the #{@max_cell_bytes} of max_cell_bytes that a reader reads", index)
224
+ end
225
+
226
+ # A written header the readers would refuse: longer than max_cell_bytes as the backend writes it (CSV puts a "'"
227
+ # before a header that starts like a formula, see #header_bytesize in the WriteBook contract). Checked before the
228
+ # backend starts the sheet, so that nothing of it is written.
229
+ def check_headers(name)
230
+ return unless @header && @max_cell_bytes
231
+
232
+ @columns.each_with_index do |column, index|
233
+ length = @writer.respond_to?(:header_bytesize) ? @writer.header_bytesize(column) : column.header.bytesize
234
+ next if length <= @max_cell_bytes
235
+
236
+ raise WriteError.at("the header is #{length} bytes long, more than the #{@max_cell_bytes} of max_cell_bytes that a " \
237
+ "reader reads", sheet: name, row: 1, column_index: index, header: column.header)
238
+ end
239
+ end
240
+
241
+ # The digits (and sign) of an Integer; beyond EXACT_INTEGER_BITS, where its text would cost too much to build,
242
+ # "about N" from its bit length (exact for a power of two).
243
+ def integer_length(value)
244
+ return value.to_s.bytesize if value.bit_length <= EXACT_INTEGER_BITS
245
+
246
+ "about #{((value.bit_length - 1) * Math.log10(2)).floor + 1 + (value.negative? ? 1 : 0)}"
247
+ end
248
+
249
+ # @api private
250
+ # The bytes of DecimalText.plain of a finite BigDecimal, from its sign, exponent and significant
251
+ # digits: "123.45", "0.00123" or "12300". The value is 0.ddd x 10**exponent with +digits+ significant digits: an
252
+ # exponent of at least +digits+ is an integer of +exponent+ digits; a positive one below it puts the point inside
253
+ # the digits (one byte more); zero or a negative one writes "0.", -exponent zeros, then the digits.
254
+ # @return [Integer]
255
+ def self.plain_length(value)
256
+ sign = value.sign.negative? ? 1 : 0
257
+ return sign + 1 if value.zero?
258
+
259
+ digits = value.n_significant_digits
260
+ exponent = value.exponent
261
+ length =
262
+ if exponent >= digits then exponent
263
+ elsif exponent.positive? then digits + 1
264
+ else 2 - exponent + digits
265
+ end
266
+ sign + length
267
+ end
268
+
269
+ # The wall clock to write, in whole seconds, as a UTC Time (the writer backend contract).
270
+ def wall_clock(time)
271
+ time = time.round
272
+ fields = @zone ? TimeZone.wall_clock(@zone, time) : [ time.year, time.month, time.day, time.hour, time.min, time.sec ]
273
+ Time.utc(*fields)
274
+ end
275
+
276
+ # A WriteError located where the row would be written: the next row, or a continuation's first data row.
277
+ def failure(reason, index = nil)
278
+ sheet, row = full? ? [ @names.free_continuation(@base, @part + 1).first, @header ? 2 : 1 ] : [ @name, @rows + 1 ]
279
+ WriteError.at(reason, sheet: sheet, row: row, column_index: index, header: index && @columns[index].header)
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "csv"
5
+ require "date"
6
+ require "stringio"
7
+ require_relative "../decimal_text"
8
+ require_relative "../errors"
9
+ require_relative "../formula_protection"
10
+
11
+ module Calcsheet
12
+ # The writer backends registered in Calcsheet.writers (CsvWriter, XlsxWriter, OdsWriter), which implement the
13
+ # contract of WriteBook, and what they share: XmlText and ZipBuffered.
14
+ #
15
+ # @api private
16
+ module Writers
17
+ # CSV writer backend, see the contract in WriteBook. A CSV file holds one sheet and has no row limit.
18
+ # Text cells and headers starting with a formula character get a leading "'" unless their column disables
19
+ # +formula_protection+ (headers of every type, cells of :string columns). Output is UTF-8.
20
+ #
21
+ # @api private
22
+ class CsvWriter
23
+ # The UTF-8 byte order mark of +bom: true+, written as an escape: the character is invisible.
24
+ BOM = "\uFEFF"
25
+ # excel_it: true presets these values for the options left nil, and "," for a decimal_separator not given per
26
+ # call.
27
+ EXCEL_IT = { col_sep: ";", bom: true, date_format: "%d/%m/%Y", datetime_format: "%d/%m/%Y %H:%M:%S" }.freeze
28
+ # The values of the same options without the preset.
29
+ PLAIN = { col_sep: ",", bom: false, date_format: "%Y-%m-%d", datetime_format: "%Y-%m-%d %H:%M:%S" }.freeze
30
+
31
+ class << self
32
+ # @return [nil] CSV has no row limit
33
+ def max_rows_per_sheet
34
+ nil
35
+ end
36
+
37
+ # Resolves the excel_it preset into the options, explicit values winning (see the contract in WriteBook).
38
+ # The values are checked already (OptionValidation), so this raises nothing.
39
+ # @param explicit [Array<Symbol>] the option keys given per call
40
+ # @return [Options]
41
+ def prepare_options(options, explicit:)
42
+ options.with(**dialect(options, explicit: explicit))
43
+ end
44
+
45
+ # @param explicit [Array<Symbol>] the option keys given per call; decimal_separator wins over the preset
46
+ # only when listed
47
+ # @return [Hash] col_sep, row_sep, bom, decimal_separator, date_format, datetime_format
48
+ def dialect(options, explicit:)
49
+ excel_it = options.excel_it
50
+ preset = excel_it ? EXCEL_IT : PLAIN
51
+ dialect = preset.to_h { |key, default| [ key, options.public_send(key).nil? ? default : options.public_send(key) ] }
52
+ dialect[:row_sep] = options.row_sep
53
+ dialect[:decimal_separator] = excel_it && !explicit.include?(:decimal_separator) ? "," : options.decimal_separator
54
+ dialect.freeze
55
+ end
56
+ end
57
+
58
+ # @param io [#write] the output, never closed here
59
+ # @param options [Options] as returned by prepare_options (their decimal_separator is taken as given)
60
+ def initialize(io, options)
61
+ @io = io
62
+ @dialect = self.class.dialect(options, explicit: [ :decimal_separator ])
63
+ @line = StringIO.new(+"")
64
+ @csv = CSV.new(@line, col_sep: @dialect[:col_sep], row_sep: @dialect[:row_sep])
65
+ @started = false
66
+ end
67
+
68
+ # Writes the BOM, if any, and the header row.
69
+ # @raise [WriteError] for a second sheet
70
+ def start_sheet(name, columns, header:)
71
+ raise WriteError.new("a CSV file holds a single sheet: cannot start sheet '#{name}'", sheet: name) if @started
72
+
73
+ @started = true
74
+ @protected = columns.map { |column| column.type == :string && column.formula_protection }
75
+ @io.write(BOM) if @dialect[:bom]
76
+ emit(columns.map { header_text(_1) }) if header
77
+ end
78
+
79
+ # Writes one record: each value as its text (see #text), quoted by the stdlib CSV where needed.
80
+ def row(values)
81
+ emit(values.each_with_index.map { |value, index| text(value, index) })
82
+ end
83
+
84
+ # @param text [String] a text of the column at +index+ (WriteBook contract)
85
+ # @return [Integer] the bytes written for it, the "'" of formula protection included
86
+ def text_bytesize(text, index)
87
+ @protected[index] ? FormulaProtection.protect(text).bytesize : text.bytesize
88
+ end
89
+
90
+ # @param column [Column] a column of a sheet about to start (WriteBook contract)
91
+ # @return [Integer] the bytes written for its header, the "'" of formula protection included
92
+ def header_bytesize(column)
93
+ header_text(column).bytesize
94
+ end
95
+
96
+ # Nothing ends a CSV sheet.
97
+ def end_sheet
98
+ end
99
+
100
+ # Nothing completes a CSV file.
101
+ def finish
102
+ end
103
+
104
+ private
105
+
106
+ def emit(fields)
107
+ @csv << fields
108
+ @io.write(@line.string)
109
+ # A new String for every line: the target may keep the one it was given.
110
+ @line.string = +""
111
+ end
112
+
113
+ # The header of a column as written: protected unless the column disables formula_protection, whatever its type.
114
+ def header_text(column)
115
+ column.formula_protection ? FormulaProtection.protect(column.header) : column.header
116
+ end
117
+
118
+ # The text of a value: protected text, digits, the plain decimal, the formatted date or time, "true"/"false".
119
+ def text(value, index)
120
+ case value
121
+ when nil then nil
122
+ when String then @protected[index] ? FormulaProtection.protect(value) : value
123
+ when Integer then value.to_s
124
+ when BigDecimal then decimal(value)
125
+ when Time then value.strftime(@dialect[:datetime_format])
126
+ when Date then value.strftime(@dialect[:date_format])
127
+ else value.to_s # true or false
128
+ end
129
+ end
130
+
131
+ # The exact value without exponent, its integral ".0" dropped.
132
+ def decimal(value)
133
+ text = DecimalText.plain(value)
134
+ @dialect[:decimal_separator] == "." ? text : text.tr(".", @dialect[:decimal_separator])
135
+ end
136
+ end
137
+ end
138
+ end