studio-engine 0.98.0 → 0.99.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.
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "date"
5
+
6
+ module Studio
7
+ module KnowledgePreview
8
+ # Renders a spreadsheet cell's stored number the way its number format
9
+ # says to, which is what separates a readable financial statement from a
10
+ # grid of raw floats: 45382 is a date, 0.0825 is 8.25%, -1234.5 is
11
+ # (1,234.50).
12
+ #
13
+ # It reads the format CODE (the string Excel shows under Format Cells >
14
+ # Custom), so a custom format works as well as a built-in one. Covered:
15
+ # positive;negative;zero sections, thousands grouping, fixed and optional
16
+ # decimals, percent, currency and other literal prefixes and suffixes,
17
+ # accounting padding, trailing-comma scaling, scientific notation, and
18
+ # date and time tokens in both date systems. Not covered, and rendered as
19
+ # a plain number instead: fractions ("# ?/?") and conditional sections
20
+ # ("[>100]"). Colours are dropped.
21
+ module NumberFormat
22
+ BUILTIN = {
23
+ 0 => "General", 1 => "0", 2 => "0.00", 3 => "#,##0", 4 => "#,##0.00",
24
+ 9 => "0%", 10 => "0.00%", 11 => "0.00E+00", 12 => "# ?/?", 13 => "# ??/??",
25
+ 14 => "m/d/yyyy", 15 => "d-mmm-yy", 16 => "d-mmm", 17 => "mmm-yy",
26
+ 18 => "h:mm AM/PM", 19 => "h:mm:ss AM/PM", 20 => "h:mm", 21 => "h:mm:ss",
27
+ 22 => "m/d/yyyy h:mm",
28
+ 37 => "#,##0 ;(#,##0)", 38 => "#,##0 ;[Red](#,##0)",
29
+ 39 => "#,##0.00;(#,##0.00)", 40 => "#,##0.00;[Red](#,##0.00)",
30
+ 41 => '_(* #,##0_);_(* \(#,##0\);_(* "-"_);_(@_)',
31
+ 42 => '_("$"* #,##0_);_("$"* \(#,##0\);_("$"* "-"_);_(@_)',
32
+ 43 => '_(* #,##0.00_);_(* \(#,##0.00\);_(* "-"??_);_(@_)',
33
+ 44 => '_("$"* #,##0.00_);_("$"* \(#,##0.00\);_("$"* "-"??_);_(@_)',
34
+ 45 => "mm:ss", 46 => "[h]:mm:ss", 47 => "mm:ss.0", 48 => "##0.0E+0", 49 => "@"
35
+ }.freeze
36
+
37
+ MONTHS = %w[January February March April May June July August September October November December].freeze
38
+ DAYS = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
39
+
40
+ # Excel refuses a format code longer than this, and so does this module:
41
+ # a longer one renders as General. The cap is what bounds the cost of
42
+ # tokenizing a code and of every number rendered through it.
43
+ MAX_CODE_LENGTH = 255
44
+ # Excel shows at most thirty decimal places.
45
+ MAX_DECIMALS = 30
46
+ # A number with more integer digits than this is not a figure anyone
47
+ # reads digit by digit; it renders in General's compact form instead of
48
+ # as three hundred digits.
49
+ MAX_FIXED_MAGNITUDE = 1e30
50
+ GENERAL = [[[:general, "General"]]].freeze
51
+
52
+ # Excel keeps 15 significant digits; rounding from that decimal reading
53
+ # (not from the binary float) is what makes 1.005 show as 1.01.
54
+ SIGNIFICANT_DIGITS = 15
55
+ # Past the last second of 9999-12-31 a serial is not a date in either
56
+ # system; such a cell renders as a plain number.
57
+ MAX_DATE_SERIAL = 2_958_465
58
+
59
+ module_function
60
+
61
+ # The format code for a style's numFmtId: the workbook's own custom
62
+ # formats first, then the built-in table, then General.
63
+ def code_for(id, custom = {})
64
+ custom[id] || BUILTIN[id] || "General"
65
+ end
66
+
67
+ def format(value, code, date1904: false)
68
+ render(value, tokenize(code.to_s), date1904: date1904)
69
+ end
70
+
71
+ # The same, from sections already tokenized: a sheet's cells share a
72
+ # handful of formats, so the reader tokenizes each one once.
73
+ def render(value, sections, date1904: false)
74
+ section, magnitude, signed = pick_section(sections, value)
75
+ text =
76
+ if section.any? { |kind, _| kind == :date || kind == :elapsed }
77
+ format_date(value, section, date1904) || general(value)
78
+ else
79
+ format_number(magnitude, section, signed)
80
+ end
81
+ text.strip
82
+ rescue ArgumentError, FloatDomainError, RangeError
83
+ general(value)
84
+ end
85
+
86
+ def date_format?(code)
87
+ tokenize(code.to_s).first.to_a.any? { |kind, _| kind == :date || kind == :elapsed }
88
+ end
89
+
90
+ # A number with no format: an integer without a decimal point, anything
91
+ # else to ten significant digits with trailing zeros dropped.
92
+ def general(value)
93
+ return value.to_i.to_s if value.finite? && value == value.to_i && value.abs < 1e15
94
+
95
+ Kernel.format("%.10g", value)
96
+ end
97
+
98
+ # --- sections -----------------------------------------------------------
99
+
100
+ # => [section, value to render, whether to prepend a minus sign]
101
+ def pick_section(sections, value)
102
+ negative = value.negative?
103
+ if sections.size >= 3 && value.zero?
104
+ [sections[2], value, false]
105
+ elsif sections.size >= 2 && negative
106
+ [sections[1], value.abs, false]
107
+ else
108
+ [sections[0], value.abs, negative]
109
+ end
110
+ end
111
+
112
+ def tokenize(code)
113
+ return GENERAL if code.length > MAX_CODE_LENGTH
114
+
115
+ sections = [[]]
116
+ index = 0
117
+ while index < code.length
118
+ char = code[index]
119
+ rest = code[index..]
120
+ current = sections.last
121
+
122
+ if char == ";"
123
+ sections << []
124
+ index += 1
125
+ elsif char == '"'
126
+ close = code.index('"', index + 1) || code.length
127
+ current << [:literal, code[(index + 1)...close]]
128
+ index = close + 1
129
+ elsif char == "\\"
130
+ current << [:literal, code[index + 1].to_s]
131
+ index += 2
132
+ elsif char == "_"
133
+ current << [:literal, " "]
134
+ index += 2
135
+ elsif char == "*"
136
+ index += 2
137
+ elsif char == "["
138
+ close = code.index("]", index) || code.length
139
+ bracket(code[(index + 1)...close], current)
140
+ index = close + 1
141
+ elsif (word = rest[/\Ageneral/i])
142
+ current << [:general, word]
143
+ index += word.length
144
+ elsif (marker = rest[/\A(?:AM\/PM|A\/P)/i])
145
+ current << [:ampm, marker]
146
+ index += marker.length
147
+ elsif rest.match?(/\AE[+-]/i)
148
+ current << [:exponent, rest[1]]
149
+ index += 2
150
+ elsif (run = rest[/\A(?:y+|m+|d+|h+|s+)/i])
151
+ current << [:date, run.downcase]
152
+ index += run.length
153
+ elsif "0#?".include?(char)
154
+ current << [:digit, char]
155
+ index += 1
156
+ elsif char == "."
157
+ current << [:point, char]
158
+ index += 1
159
+ elsif char == ","
160
+ current << [:comma, char]
161
+ index += 1
162
+ elsif char == "%"
163
+ current << [:percent, char]
164
+ index += 1
165
+ elsif char == "@"
166
+ current << [:text, char]
167
+ index += 1
168
+ elsif char == "/"
169
+ current << [:slash, char]
170
+ index += 1
171
+ else
172
+ current << [:literal, char]
173
+ index += 1
174
+ end
175
+ end
176
+ sections
177
+ end
178
+
179
+ # [$€-407] is a currency symbol with a locale; [h] is elapsed time; the
180
+ # rest (colours, conditions) carries nothing a plain table can show.
181
+ def bracket(body, section)
182
+ if body.start_with?("$")
183
+ symbol = body[1..].to_s.split("-", 2).first.to_s
184
+ section << [:literal, symbol] unless symbol.empty?
185
+ elsif body.match?(/\A(?:h+|m+|s+)\z/i)
186
+ section << [:elapsed, body.downcase]
187
+ end
188
+ end
189
+
190
+ # --- numbers ------------------------------------------------------------
191
+
192
+ def format_number(value, section, signed)
193
+ kinds = section.map(&:first)
194
+ sign = signed ? "-" : ""
195
+ return sign + literals(section) + general(value) if kinds.include?(:general)
196
+
197
+ first = kinds.index { |kind| %i[digit point].include?(kind) }
198
+ # A section with no digit placeholders is fixed text: accounting
199
+ # formats render zero as "-" this way.
200
+ return literals(section) if first.nil? && !kinds.include?(:text)
201
+ return sign + general(value) if first.nil? || kinds.include?(:slash)
202
+
203
+ last = kinds.rindex { |kind| %i[digit point].include?(kind) }
204
+ prefix = literals(section[0...first])
205
+ suffix = literals(section[(last + 1)..])
206
+ body = section[first..last]
207
+ # The exponent's own digits ("E+00") are not decimal places.
208
+ exponent = body.index { |kind, _| kind == :exponent }
209
+ body = body[0...exponent] if exponent
210
+
211
+ value *= 100 if kinds.include?(:percent)
212
+ point = body.index { |kind, _| kind == :point }
213
+ integer_tokens = point ? body[0...point] : body
214
+ decimal_tokens = point ? body[(point + 1)..] : []
215
+
216
+ # Commas after the last digit placeholder scale by a thousand each;
217
+ # a comma between placeholders asks for grouping.
218
+ trailing = section[(last + 1)..].take_while { |kind, _| kind == :comma }.size
219
+ trailing += integer_tokens.reverse.take_while { |kind, _| kind == :comma }.size
220
+ value /= (1000.0**trailing) if trailing.positive?
221
+ grouped = integer_tokens.each_cons(3).any? { |a, b, c| a[0] == :digit && b[0] == :comma && c[0] == :digit }
222
+
223
+ return sign + prefix + general(value) + suffix if value.abs >= MAX_FIXED_MAGNITUDE
224
+
225
+ decimal_digits = [decimal_tokens.count { |kind, _| kind == :digit }, MAX_DECIMALS].min
226
+ if kinds.include?(:exponent)
227
+ return sign + prefix + Kernel.format("%.#{decimal_digits}E", value) + suffix
228
+ end
229
+
230
+ minimum_decimals = [decimal_tokens.count { |kind, mark| kind == :digit && mark == "0" }, MAX_DECIMALS].min
231
+ minimum_integers = integer_tokens.count { |kind, mark| kind == :digit && mark == "0" }
232
+
233
+ rounded = BigDecimal(value, SIGNIFICANT_DIGITS).round(decimal_digits, BigDecimal::ROUND_HALF_UP)
234
+ whole, fraction = rounded.to_s("F").split(".")
235
+ fraction = fraction.to_s.ljust(decimal_digits, "0")[0, decimal_digits]
236
+ fraction = fraction.sub(/0+\z/, "").ljust(minimum_decimals, "0")
237
+ whole = "" if whole == "0" && minimum_integers.zero?
238
+ whole = whole.rjust(minimum_integers, "0")
239
+ whole = whole.reverse.scan(/\d{1,3}/).join(",").reverse if grouped
240
+ # A value that rounds to zero carries no minus sign: -0.001 at two
241
+ # decimals is 0.00, not -0.00.
242
+ sign = "" if rounded.zero?
243
+
244
+ number = fraction.empty? ? whole : "#{whole}.#{fraction}"
245
+ sign + prefix + number + suffix
246
+ end
247
+
248
+ def literals(tokens)
249
+ tokens.to_a.filter_map { |kind, mark| mark if %i[literal percent].include?(kind) }.join
250
+ end
251
+
252
+ # --- dates --------------------------------------------------------------
253
+
254
+ def format_date(serial, section, date1904)
255
+ return nil if serial.negative? || serial > MAX_DATE_SERIAL
256
+
257
+ days = serial.floor
258
+ seconds = ((serial - days) * 86_400).round
259
+ if seconds >= 86_400
260
+ days += 1
261
+ seconds = 0
262
+ end
263
+ date = date_for(days, date1904)
264
+ hour, remainder = seconds.divmod(3600)
265
+ minute, second = remainder.divmod(60)
266
+ twelve_hour = section.any? { |kind, _| kind == :ampm }
267
+ date_tokens = section.each_index.select { |i| %i[date elapsed].include?(section[i][0]) }
268
+
269
+ section.each_with_index.map { |(kind, mark), index|
270
+ case kind
271
+ when :date
272
+ date_part(mark, index, date_tokens, section, date, hour, minute, second, twelve_hour)
273
+ when :elapsed
274
+ elapsed_part(mark, serial)
275
+ when :ampm
276
+ meridiem = hour < 12 ? "AM" : "PM"
277
+ mark.length == 3 ? meridiem[0] : meridiem
278
+ when :digit then "0"
279
+ when :general, :text, :exponent then ""
280
+ else mark
281
+ end
282
+ }.join
283
+ end
284
+
285
+ # Serial 60 is 1900-02-29, a day Lotus 1-2-3 invented and Excel kept;
286
+ # every later serial is one day ahead of the real calendar for it.
287
+ def date_for(days, date1904)
288
+ return Date.new(1904, 1, 1) + days if date1904
289
+
290
+ days < 60 ? Date.new(1899, 12, 31) + days : Date.new(1899, 12, 30) + days
291
+ end
292
+
293
+ def date_part(mark, index, date_tokens, section, date, hour, minute, second, twelve_hour)
294
+ case mark[0]
295
+ when "y" then mark.length <= 2 ? Kernel.format("%02d", date.year % 100) : date.year.to_s
296
+ when "d"
297
+ case mark.length
298
+ when 1 then date.day.to_s
299
+ when 2 then Kernel.format("%02d", date.day)
300
+ when 3 then DAYS[date.wday][0, 3]
301
+ else DAYS[date.wday]
302
+ end
303
+ when "h"
304
+ shown = twelve_hour ? ((hour % 12).zero? ? 12 : hour % 12) : hour
305
+ mark.length == 1 ? shown.to_s : Kernel.format("%02d", shown)
306
+ when "s" then mark.length == 1 ? second.to_s : Kernel.format("%02d", second)
307
+ when "m"
308
+ if minutes?(index, date_tokens, section)
309
+ mark.length == 1 ? minute.to_s : Kernel.format("%02d", minute)
310
+ else
311
+ case mark.length
312
+ when 1 then date.month.to_s
313
+ when 2 then Kernel.format("%02d", date.month)
314
+ when 3 then MONTHS[date.month - 1][0, 3]
315
+ when 4 then MONTHS[date.month - 1]
316
+ else MONTHS[date.month - 1][0]
317
+ end
318
+ end
319
+ end
320
+ end
321
+
322
+ # "m" is minutes when the date token before it is hours or the one after
323
+ # it is seconds, and months everywhere else.
324
+ def minutes?(index, date_tokens, section)
325
+ position = date_tokens.index(index)
326
+ before = position.positive? ? section[date_tokens[position - 1]][1] : nil
327
+ after = date_tokens[position + 1] ? section[date_tokens[position + 1]][1] : nil
328
+ before.to_s.start_with?("h") || after.to_s.start_with?("s")
329
+ end
330
+
331
+ def elapsed_part(mark, serial)
332
+ total =
333
+ case mark[0]
334
+ when "h" then (serial * 24).floor
335
+ when "m" then (serial * 1440).floor
336
+ else (serial * 86_400).round
337
+ end
338
+ total.to_s.rjust(mark.length, "0")
339
+ end
340
+ end
341
+ end
342
+ end