studio-engine 0.98.0 → 0.99.0.rc1
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 +77 -0
- data/README.md +10 -0
- data/app/controllers/studio/knowledge_docs_controller.rb +24 -1
- data/app/javascript/studio/controllers/modal_host_controller.js +6 -16
- data/app/javascript/studio/modal_host.js +25 -5
- data/app/models/studio/knowledge_doc.rb +37 -2
- data/app/views/studio/knowledge_docs/_preview.html.erb +66 -0
- data/app/views/studio/knowledge_docs/_preview_fallback.html.erb +9 -0
- data/app/views/studio/knowledge_docs/_preview_sheet.html.erb +43 -0
- data/app/views/studio/knowledge_docs/_preview_styles.html.erb +24 -0
- data/app/views/studio/knowledge_docs/preview.html.erb +12 -0
- data/app/views/studio/knowledge_docs/show.html.erb +26 -2
- data/lib/studio/knowledge_preview/number_format.rb +342 -0
- data/lib/studio/knowledge_preview/xlsx.rb +646 -0
- data/lib/studio/knowledge_preview/xml_guard.rb +344 -0
- data/lib/studio/knowledge_preview/zip.rb +242 -0
- data/lib/studio/knowledge_preview.rb +322 -0
- data/lib/studio/s3.rb +39 -4
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +5 -2
- data/studio-engine.gemspec +4 -0
- metadata +26 -2
|
@@ -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
|