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.
@@ -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