furud 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/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +101 -0
- data/lib/furud/engine.rb +756 -0
- data/lib/furud/format.rb +244 -0
- data/lib/furud/formula.rb +538 -0
- data/lib/furud/functions.rb +794 -0
- data/lib/furud/types.rb +70 -0
- data/lib/furud/version.rb +5 -0
- data/lib/furud.rb +15 -0
- data/sig/furud.rbs +128 -0
- metadata +57 -0
data/lib/furud/format.rb
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Furud
|
|
6
|
+
module Format
|
|
7
|
+
Section = Data.define(:pattern, :condition, :color)
|
|
8
|
+
Spec = Data.define(:sections, :locale)
|
|
9
|
+
DATE_TOKENS = %w[yyyy yy mmmm mmm mm m dddd ddd dd d hh h ss s].sort_by { |token| -token.length }.freeze
|
|
10
|
+
LOCALE_MARKS = {
|
|
11
|
+
en: [".", ","], ja: [".", ","], de: [",", "."], es: [",", "."],
|
|
12
|
+
it: [",", "."], fr: [",", "\u202f"], pt: [",", "."]
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def parse(pattern, locale: :en)
|
|
18
|
+
sections = split_sections(pattern.to_s).first(4).map do |section|
|
|
19
|
+
condition_match = section.match(/\[(<=|>=|<>|=|<|>)(-?\d+(?:\.\d+)?)\]/)
|
|
20
|
+
color = section.match(/\[(black|blue|cyan|green|magenta|red|white|yellow|color\d+)\]/i)&.captures&.first&.downcase
|
|
21
|
+
cleaned = section.gsub(/\[(?:<=|>=|<>|=|<|>)-?\d+(?:\.\d+)?\]/, "")
|
|
22
|
+
.gsub(/\[(?:black|blue|cyan|green|magenta|red|white|yellow|color\d+)\]/i, "")
|
|
23
|
+
Section.new(pattern: cleaned, condition: condition_match && [condition_match[1], condition_match[2].to_f], color: color&.to_sym)
|
|
24
|
+
end
|
|
25
|
+
Spec.new(sections: sections.freeze, locale: locale.to_sym)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def apply(value, spec)
|
|
29
|
+
return [value.to_s, {}] if value.is_a?(ErrorValue)
|
|
30
|
+
return ["", {}] if value.nil?
|
|
31
|
+
|
|
32
|
+
section, index = select_section(value, spec.sections)
|
|
33
|
+
style = { section: index }
|
|
34
|
+
style[:color] = section.color if section&.color
|
|
35
|
+
return [text_format(value, section&.pattern), style] if value.is_a?(String)
|
|
36
|
+
|
|
37
|
+
pattern = section&.pattern.to_s
|
|
38
|
+
if date_pattern?(pattern)
|
|
39
|
+
date = if value.is_a?(Time)
|
|
40
|
+
value
|
|
41
|
+
elsif value.is_a?(Date)
|
|
42
|
+
value
|
|
43
|
+
else
|
|
44
|
+
serial = numeric(value)
|
|
45
|
+
base = Date.new(1899, 12, 30) + serial.floor
|
|
46
|
+
seconds = ((serial % 1) * 86_400).round
|
|
47
|
+
Time.utc(base.year, base.month, base.day) + seconds
|
|
48
|
+
end
|
|
49
|
+
return [format_date(date, pattern), style]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
[format_number(value, pattern, spec.locale, index), style]
|
|
53
|
+
rescue ArgumentError, TypeError, RangeError
|
|
54
|
+
[value.to_s, style || {}]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Returns [inferred_value, Excel format pattern].
|
|
58
|
+
def infer(text, locale: :en)
|
|
59
|
+
value = text.to_s.strip
|
|
60
|
+
return [value, "@"] if value.empty?
|
|
61
|
+
return [value.casecmp?("true"), "General"] if %w[true false].include?(value.downcase)
|
|
62
|
+
return [Date.iso8601(value), "yyyy-mm-dd"] if value.match?(/\A\d{4}-\d{2}-\d{2}\z/) && Date.valid_date?(*value.split("-").map(&:to_i))
|
|
63
|
+
|
|
64
|
+
decimal, group = LOCALE_MARKS.fetch(locale.to_sym, LOCALE_MARKS[:en])
|
|
65
|
+
normalized = value.delete(group).sub(decimal, ".")
|
|
66
|
+
if normalized.match?(/\A[+-]?(?:\d+|\d*\.\d+)%?\z/)
|
|
67
|
+
percent = normalized.delete_suffix("%").to_f
|
|
68
|
+
return [value.end_with?("%") ? percent / 100 : (normalized.include?(".") ? normalized.to_f : normalized.to_i), value.end_with?("%") ? "0.00%" : normalized.include?(".") ? "0.00" : "#,##0"]
|
|
69
|
+
end
|
|
70
|
+
[text, "@"]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def split_sections(pattern)
|
|
74
|
+
sections = []
|
|
75
|
+
current = +""
|
|
76
|
+
quoted = false
|
|
77
|
+
bracket = false
|
|
78
|
+
escaped = false
|
|
79
|
+
pattern.each_char do |char|
|
|
80
|
+
if escaped
|
|
81
|
+
current << char
|
|
82
|
+
escaped = false
|
|
83
|
+
elsif char == "\\"
|
|
84
|
+
current << char
|
|
85
|
+
escaped = true
|
|
86
|
+
elsif char == '"' && !bracket
|
|
87
|
+
quoted = !quoted
|
|
88
|
+
current << char
|
|
89
|
+
elsif char == "[" && !quoted
|
|
90
|
+
bracket = true
|
|
91
|
+
current << char
|
|
92
|
+
elsif char == "]" && bracket
|
|
93
|
+
bracket = false
|
|
94
|
+
current << char
|
|
95
|
+
elsif char == ";" && !quoted && !bracket
|
|
96
|
+
sections << current
|
|
97
|
+
current = +""
|
|
98
|
+
else
|
|
99
|
+
current << char
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
sections << current
|
|
103
|
+
sections
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def select_section(value, sections)
|
|
107
|
+
return [nil, 0] if sections.empty?
|
|
108
|
+
|
|
109
|
+
conditional = sections.each_with_index.find do |section, _index|
|
|
110
|
+
section.condition && condition_match?(value, *section.condition)
|
|
111
|
+
end
|
|
112
|
+
return conditional if conditional
|
|
113
|
+
if sections.any?(&:condition)
|
|
114
|
+
fallback = sections.each_with_index.find { |section, _index| !section.condition }
|
|
115
|
+
return fallback if fallback
|
|
116
|
+
return [nil, 0]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
index = if value.is_a?(String)
|
|
120
|
+
3
|
|
121
|
+
elsif value.is_a?(Numeric) && value.negative?
|
|
122
|
+
[1, sections.length - 1].min
|
|
123
|
+
elsif value == 0
|
|
124
|
+
[2, sections.length - 1].min
|
|
125
|
+
else
|
|
126
|
+
0
|
|
127
|
+
end
|
|
128
|
+
[sections[index], index]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def condition_match?(value, operator, target)
|
|
132
|
+
value = numeric(value)
|
|
133
|
+
{ "<" => value < target, "<=" => value <= target, ">" => value > target,
|
|
134
|
+
">=" => value >= target, "=" => value == target, "<>" => value != target }.fetch(operator)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def format_number(value, pattern, locale, section_index)
|
|
138
|
+
value = numeric(value)
|
|
139
|
+
pattern = "General" if pattern.empty?
|
|
140
|
+
return general_number(value) if pattern.casecmp?("General")
|
|
141
|
+
|
|
142
|
+
raw = unquote(pattern.gsub(/\[(?:h+|m+|s+)\]/i, ""))
|
|
143
|
+
if raw.include?("%")
|
|
144
|
+
value *= 100
|
|
145
|
+
elsif raw.include?("‰")
|
|
146
|
+
value *= 1000
|
|
147
|
+
end
|
|
148
|
+
placeholders = raw.match(/[0#?][0#?,]*(?:\.[0#?]+)?(?:[Ee][+-]?[0#?]+)?/)
|
|
149
|
+
return raw.gsub("@", value.to_s) unless placeholders
|
|
150
|
+
|
|
151
|
+
number_pattern = placeholders[0]
|
|
152
|
+
integer_pattern, fractional_pattern = number_pattern.split(".", 2)
|
|
153
|
+
fractional_pattern = fractional_pattern&.sub(/[Ee].*/, "")
|
|
154
|
+
decimals = fractional_pattern&.length.to_i
|
|
155
|
+
rendered = format("%.#{decimals}f", value.abs)
|
|
156
|
+
integer, fraction = rendered.split(".", 2)
|
|
157
|
+
grouped = integer_pattern.include?(",") ? group_integer(integer, locale) : integer
|
|
158
|
+
optional_decimals = fractional_pattern.to_s[/#+\z/]&.length.to_i
|
|
159
|
+
if optional_decimals.positive?
|
|
160
|
+
fraction = fraction.sub(/0{1,#{optional_decimals}}\z/, "")
|
|
161
|
+
end
|
|
162
|
+
decimal_mark, = LOCALE_MARKS.fetch(locale.to_sym, LOCALE_MARKS[:en])
|
|
163
|
+
number = fraction.to_s.empty? ? grouped : "#{grouped}#{decimal_mark}#{fraction}"
|
|
164
|
+
prefix = raw[0...placeholders.begin(0)]
|
|
165
|
+
suffix = raw[placeholders.end(0)..]
|
|
166
|
+
number = "-#{number}" if value.negative? && section_index.zero? && !unquote(prefix).match?(/[-(]/)
|
|
167
|
+
(unquote(prefix) + number + unquote(suffix)).gsub("\\", "")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def group_integer(integer, locale)
|
|
171
|
+
group = LOCALE_MARKS.fetch(locale.to_sym, LOCALE_MARKS[:en])[1]
|
|
172
|
+
integer.reverse.scan(/.{1,3}/).join(group).reverse
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def date_pattern?(pattern)
|
|
176
|
+
unquote(pattern).match?(/(?:y{2,4}|d{1,4}|m{1,4}|h{1,2}|s{1,2}|AM\/PM)/i)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def format_date(value, pattern)
|
|
180
|
+
date = value.is_a?(Time) ? value.to_date : value
|
|
181
|
+
pattern = unquote(pattern)
|
|
182
|
+
hour = value.respond_to?(:hour) ? value.hour : 0
|
|
183
|
+
minute = value.respond_to?(:min) ? value.min : 0
|
|
184
|
+
second = value.respond_to?(:sec) ? value.sec : 0
|
|
185
|
+
tokens = DATE_TOKENS.to_h do |token|
|
|
186
|
+
value = case token
|
|
187
|
+
when "yyyy" then date.strftime("%Y")
|
|
188
|
+
when "yy" then date.strftime("%y")
|
|
189
|
+
when "mmmm" then date.strftime("%B")
|
|
190
|
+
when "mmm" then date.strftime("%b")
|
|
191
|
+
when "mm", "m" then date.strftime("%m")
|
|
192
|
+
when "dddd" then date.strftime("%A")
|
|
193
|
+
when "ddd" then date.strftime("%a")
|
|
194
|
+
when "dd" then date.strftime("%d")
|
|
195
|
+
when "d" then date.strftime("%-d")
|
|
196
|
+
when "hh" then format("%02d", hour)
|
|
197
|
+
when "h" then hour.to_s
|
|
198
|
+
when "ss" then format("%02d", second)
|
|
199
|
+
when "s" then second.to_s
|
|
200
|
+
else token
|
|
201
|
+
end
|
|
202
|
+
[token, value]
|
|
203
|
+
end
|
|
204
|
+
pattern.gsub(/AM\/PM|yyyy|yy|mmmm|mmm|mm|m|dddd|ddd|dd|d|hh|h|ss|s/i) do |token|
|
|
205
|
+
next (hour < 12 ? "AM" : "PM") if token.casecmp?("AM/PM")
|
|
206
|
+
|
|
207
|
+
if %w[m mm].include?(token.downcase)
|
|
208
|
+
before = Regexp.last_match.pre_match[-1]
|
|
209
|
+
after = Regexp.last_match.post_match[0]
|
|
210
|
+
if before == ":" || after == ":"
|
|
211
|
+
format(token.length == 2 ? "%02d" : "%d", minute)
|
|
212
|
+
else
|
|
213
|
+
tokens.fetch(token.downcase, token)
|
|
214
|
+
end
|
|
215
|
+
else
|
|
216
|
+
tokens.fetch(token.downcase, token)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def text_format(value, pattern)
|
|
222
|
+
return value.to_s if pattern.nil? || pattern.empty? || pattern.casecmp?("General")
|
|
223
|
+
|
|
224
|
+
unquote(pattern).gsub("@", value.to_s)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def unquote(pattern)
|
|
228
|
+
pattern.gsub(/"([^"]*)"/) { Regexp.last_match(1) }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def numeric(value)
|
|
232
|
+
return value.to_f if value.is_a?(Integer)
|
|
233
|
+
return value if value.is_a?(Numeric)
|
|
234
|
+
return value.to_time.to_f if value.is_a?(Time)
|
|
235
|
+
return (value - Date.new(1899, 12, 30)).to_i if value.is_a?(Date)
|
|
236
|
+
|
|
237
|
+
Float(value)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def general_number(value)
|
|
241
|
+
value.to_i == value ? value.to_i.to_s : value.to_s
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|