liquid_xlsx 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 +58 -0
- data/LICENSE.txt +21 -0
- data/README.md +862 -0
- data/lib/liquid_xlsx/cell_reference.rb +82 -0
- data/lib/liquid_xlsx/drawing_builder.rb +263 -0
- data/lib/liquid_xlsx/errors.rb +80 -0
- data/lib/liquid_xlsx/filters.rb +9 -0
- data/lib/liquid_xlsx/formula_translator.rb +170 -0
- data/lib/liquid_xlsx/image.rb +209 -0
- data/lib/liquid_xlsx/merge_cells_transformer.rb +76 -0
- data/lib/liquid_xlsx/package.rb +599 -0
- data/lib/liquid_xlsx/renderer.rb +775 -0
- data/lib/liquid_xlsx/shared_strings.rb +47 -0
- data/lib/liquid_xlsx/tags/image_tag.rb +97 -0
- data/lib/liquid_xlsx/tags/sheet_tag.rb +161 -0
- data/lib/liquid_xlsx/template.rb +69 -0
- data/lib/liquid_xlsx/template_nodes.rb +62 -0
- data/lib/liquid_xlsx/template_parser.rb +446 -0
- data/lib/liquid_xlsx/version.rb +5 -0
- data/lib/liquid_xlsx/workbook.rb +308 -0
- data/lib/liquid_xlsx/worksheet.rb +436 -0
- data/lib/liquid_xlsx.rb +89 -0
- metadata +142 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiquidXlsx
|
|
4
|
+
# Parses worksheet rows into an AST of template nodes.
|
|
5
|
+
class TemplateParser # rubocop:disable Metrics/ClassLength
|
|
6
|
+
FOR_START = /\A\{%-?\s*for\s+(\w+)\s+in\s+([\w.]+)\s*-?%\}\z/
|
|
7
|
+
FOR_END = /\A\{%-?\s*endfor\s*-?%\}\z/
|
|
8
|
+
IF_START = /\A\{%-?\s*if\s+((?:(?!%\}).)+)\s*-?%\}\z/
|
|
9
|
+
ELSIF = /\A\{%-?\s*elsif\s+((?:(?!%\}).)+)\s*-?%\}\z/
|
|
10
|
+
ELSE_TAG = /\A\{%-?\s*else\s*-?%\}\z/
|
|
11
|
+
ENDIF = /\A\{%-?\s*endif\s*-?%\}\z/
|
|
12
|
+
STRUCTURAL_KEYWORDS = /\A\{%-?\s*(for|endfor|if|elsif|else|endif)\b/
|
|
13
|
+
MIXED_STRUCTURAL = /\{%-?\s*(?:for|endfor|if|elsif|else|endif)\b/
|
|
14
|
+
PURE_STRUCTURAL = [FOR_START, FOR_END, IF_START, ELSIF, ELSE_TAG, ENDIF].freeze
|
|
15
|
+
|
|
16
|
+
# Matches balanced pairs of non-structural Liquid blocks so they can be
|
|
17
|
+
# masked before MIXED_STRUCTURAL checks.
|
|
18
|
+
NON_STRUCTURAL_PAIR_RE = /
|
|
19
|
+
\{%-?\s*
|
|
20
|
+
(case|unless|capture|comment|raw|tablerow)
|
|
21
|
+
\b
|
|
22
|
+
.*?%\}
|
|
23
|
+
.*?
|
|
24
|
+
\{%-?\s*end\1\s*-?%\}
|
|
25
|
+
/mx
|
|
26
|
+
|
|
27
|
+
attr_reader :sheet_name
|
|
28
|
+
|
|
29
|
+
def initialize(rows, sheet_name = "Sheet1")
|
|
30
|
+
@rows = rows
|
|
31
|
+
@sheet_name = sheet_name
|
|
32
|
+
@pos = 0
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parse
|
|
36
|
+
nodes = []
|
|
37
|
+
nodes << parse_element while @pos < @rows.length
|
|
38
|
+
nodes
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def current_row
|
|
44
|
+
@rows[@pos]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def advance
|
|
48
|
+
@pos += 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Iteratively remove balanced non-structural Liquid blocks (case/endcase,
|
|
52
|
+
# unless/endunless, capture/endcapture, comment/endcomment, raw/endraw,
|
|
53
|
+
# tablerow/endtablerow) so that inner {% else %}, {% if %}, etc. do not
|
|
54
|
+
# trigger false-positive MIXED_STRUCTURAL matches.
|
|
55
|
+
def mask_non_structural_blocks(text)
|
|
56
|
+
result = text.dup
|
|
57
|
+
prev = nil
|
|
58
|
+
while result != prev
|
|
59
|
+
prev = result
|
|
60
|
+
result = result.gsub(NON_STRUCTURAL_PAIR_RE, "")
|
|
61
|
+
end
|
|
62
|
+
result
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check if a row is a pure structural tag row.
|
|
66
|
+
# This means the first cell contains ONLY a structural tag (nothing before/after),
|
|
67
|
+
# and no other cells have content.
|
|
68
|
+
def structural_row?(row)
|
|
69
|
+
first = first_cell_value(row)
|
|
70
|
+
return false unless first
|
|
71
|
+
|
|
72
|
+
stripped = first.strip
|
|
73
|
+
masked = mask_non_structural_blocks(stripped)
|
|
74
|
+
if masked.match?(MIXED_STRUCTURAL) && !stripped.match?(STRUCTURAL_KEYWORDS)
|
|
75
|
+
raise TemplateSyntaxError.new(
|
|
76
|
+
"Structural tag must be placed on a dedicated row. Found mixed content: #{stripped.inspect}",
|
|
77
|
+
sheet: @sheet_name,
|
|
78
|
+
row: row[:row_number],
|
|
79
|
+
cell: cell_reference(row),
|
|
80
|
+
tag: stripped
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
return false unless stripped.match?(STRUCTURAL_KEYWORDS)
|
|
85
|
+
|
|
86
|
+
# Must match exactly one of the structural patterns
|
|
87
|
+
matches_any = [FOR_START, FOR_END, IF_START, ELSIF, ELSE_TAG, ENDIF].any? do |pat|
|
|
88
|
+
first.strip.match?(pat)
|
|
89
|
+
end
|
|
90
|
+
return false unless matches_any
|
|
91
|
+
|
|
92
|
+
# Other cells must be empty
|
|
93
|
+
cells = row[:cells]
|
|
94
|
+
return true if cells.nil? || cells.length <= 1
|
|
95
|
+
|
|
96
|
+
sorted = cells.sort_by { |c| CellReference.col_to_index(c[:col]) }
|
|
97
|
+
first_content = sorted.find { |c| cell_content(c) }
|
|
98
|
+
return false unless first_content
|
|
99
|
+
|
|
100
|
+
# All cells AFTER the first content cell must be empty
|
|
101
|
+
first_idx = sorted.index(first_content)
|
|
102
|
+
sorted[(first_idx + 1)..].all? { |c| cell_content(c).nil? }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Raise TemplateSyntaxError if any cell mixes structural tag with other content,
|
|
106
|
+
# or if a row has a structural tag in one cell and other content cells.
|
|
107
|
+
def validate_no_mixed_structural_content(row)
|
|
108
|
+
check_mixed_content_in_cells(row)
|
|
109
|
+
check_tag_cell_with_other_content(row)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def check_mixed_content_in_cells(row)
|
|
113
|
+
row[:cells].each do |c|
|
|
114
|
+
cell_val = c[:text] || c[:template]
|
|
115
|
+
next unless cell_val
|
|
116
|
+
|
|
117
|
+
stripped = cell_val.strip
|
|
118
|
+
next unless stripped.match?(MIXED_STRUCTURAL)
|
|
119
|
+
|
|
120
|
+
masked = mask_non_structural_blocks(stripped)
|
|
121
|
+
next unless masked.match?(MIXED_STRUCTURAL)
|
|
122
|
+
next if stripped.match?(STRUCTURAL_KEYWORDS)
|
|
123
|
+
|
|
124
|
+
raise TemplateSyntaxError.new(
|
|
125
|
+
"Structural tag must be placed on a dedicated row. " \
|
|
126
|
+
"Found mixed content: #{stripped.inspect}",
|
|
127
|
+
sheet: @sheet_name,
|
|
128
|
+
row: row[:row_number],
|
|
129
|
+
cell: "#{c[:col]}#{row[:row_number]}",
|
|
130
|
+
tag: stripped
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def check_tag_cell_with_other_content(row)
|
|
136
|
+
cells_with_tag = row[:cells].select do |c|
|
|
137
|
+
val = c[:text] || c[:template]
|
|
138
|
+
next unless val
|
|
139
|
+
|
|
140
|
+
PURE_STRUCTURAL.any? { |pat| val.strip.match?(pat) }
|
|
141
|
+
end
|
|
142
|
+
cells_with_content = row[:cells].select do |c|
|
|
143
|
+
val = cell_content(c)
|
|
144
|
+
next unless val
|
|
145
|
+
next if PURE_STRUCTURAL.any? { |pat| val.strip.match?(pat) }
|
|
146
|
+
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
return unless cells_with_tag.any? && cells_with_content.any?
|
|
150
|
+
|
|
151
|
+
tag_cell = cells_with_tag.first
|
|
152
|
+
tag_text = tag_cell[:text] || tag_cell[:template]
|
|
153
|
+
raise TemplateSyntaxError.new(
|
|
154
|
+
"Structural tag must be on a dedicated row. " \
|
|
155
|
+
"Found tag in #{tag_cell[:col]}#{row[:row_number]} with other cells having content.",
|
|
156
|
+
sheet: @sheet_name,
|
|
157
|
+
row: row[:row_number],
|
|
158
|
+
cell: "#{tag_cell[:col]}#{row[:row_number]}",
|
|
159
|
+
tag: tag_text.strip
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def parse_element
|
|
164
|
+
row = current_row
|
|
165
|
+
|
|
166
|
+
validate_no_mixed_structural_content(row)
|
|
167
|
+
|
|
168
|
+
first = first_cell_value(row)
|
|
169
|
+
|
|
170
|
+
# Check if first cell is a structural tag but row has other content
|
|
171
|
+
if first&.strip&.match?(STRUCTURAL_KEYWORDS)
|
|
172
|
+
pure_tag = [FOR_START, FOR_END, IF_START, ELSIF, ELSE_TAG, ENDIF].any? do |pat|
|
|
173
|
+
first.strip.match?(pat)
|
|
174
|
+
end
|
|
175
|
+
if pure_tag
|
|
176
|
+
# First cell is a structural tag. Check that other cells are empty.
|
|
177
|
+
cells = row[:cells]
|
|
178
|
+
if cells && cells.length > 1
|
|
179
|
+
sorted = cells.sort_by { |c| CellReference.col_to_index(c[:col]) }
|
|
180
|
+
first_content = sorted.find { |c| cell_content(c) }
|
|
181
|
+
other_have_content = if first_content
|
|
182
|
+
first_idx = sorted.index(first_content)
|
|
183
|
+
sorted[(first_idx + 1)..].any? { |c| cell_content(c) }
|
|
184
|
+
else
|
|
185
|
+
false
|
|
186
|
+
end
|
|
187
|
+
if other_have_content
|
|
188
|
+
raise TemplateSyntaxError.new(
|
|
189
|
+
"Structural tag must be placed on a dedicated row.",
|
|
190
|
+
sheet: @sheet_name,
|
|
191
|
+
row: row[:row_number],
|
|
192
|
+
cell: cell_reference(row),
|
|
193
|
+
tag: first.strip
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
return advance && Nodes::RowNode.new(row) unless structural_row?(row)
|
|
201
|
+
|
|
202
|
+
first_cell_text = first_cell_value(row).strip
|
|
203
|
+
|
|
204
|
+
case first_cell_text
|
|
205
|
+
when FOR_START
|
|
206
|
+
parse_for(::Regexp.last_match(1), ::Regexp.last_match(2))
|
|
207
|
+
when IF_START
|
|
208
|
+
parse_if(::Regexp.last_match(1))
|
|
209
|
+
when FOR_END
|
|
210
|
+
raise TemplateSyntaxError.new(
|
|
211
|
+
"Unexpected {% endfor %}",
|
|
212
|
+
sheet: @sheet_name,
|
|
213
|
+
row: row[:row_number],
|
|
214
|
+
cell: cell_reference(row),
|
|
215
|
+
tag: first_cell_text
|
|
216
|
+
)
|
|
217
|
+
when ENDIF
|
|
218
|
+
raise TemplateSyntaxError.new(
|
|
219
|
+
"Unexpected {% endif %}",
|
|
220
|
+
sheet: @sheet_name,
|
|
221
|
+
row: row[:row_number],
|
|
222
|
+
cell: cell_reference(row),
|
|
223
|
+
tag: first_cell_text
|
|
224
|
+
)
|
|
225
|
+
when ELSIF
|
|
226
|
+
raise TemplateSyntaxError.new(
|
|
227
|
+
"Unexpected {% elsif %} outside if block",
|
|
228
|
+
sheet: @sheet_name,
|
|
229
|
+
row: row[:row_number],
|
|
230
|
+
cell: cell_reference(row),
|
|
231
|
+
tag: first_cell_text
|
|
232
|
+
)
|
|
233
|
+
when ELSE_TAG
|
|
234
|
+
raise TemplateSyntaxError.new(
|
|
235
|
+
"Unexpected {% else %} outside if/for block",
|
|
236
|
+
sheet: @sheet_name,
|
|
237
|
+
row: row[:row_number],
|
|
238
|
+
cell: cell_reference(row),
|
|
239
|
+
tag: first_cell_text
|
|
240
|
+
)
|
|
241
|
+
else
|
|
242
|
+
advance
|
|
243
|
+
Nodes::RowNode.new(row)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def parse_for(var_name, collection_name)
|
|
248
|
+
for_tag_row = current_row[:row_number]
|
|
249
|
+
advance # skip for tag
|
|
250
|
+
body = []
|
|
251
|
+
else_body = nil
|
|
252
|
+
|
|
253
|
+
while @pos < @rows.length
|
|
254
|
+
row = current_row
|
|
255
|
+
first_cell_value(row)
|
|
256
|
+
|
|
257
|
+
if match_row?(row, FOR_END)
|
|
258
|
+
validate_no_mixed_structural_content(row)
|
|
259
|
+
endfor_tag_row = row[:row_number]
|
|
260
|
+
advance
|
|
261
|
+
return Nodes::ForNode.new(var_name, collection_name, body, else_body,
|
|
262
|
+
for_row: for_tag_row, endfor_row: endfor_tag_row)
|
|
263
|
+
elsif match_row?(row, ELSE_TAG)
|
|
264
|
+
validate_no_mixed_structural_content(row)
|
|
265
|
+
advance
|
|
266
|
+
else_body = []
|
|
267
|
+
while @pos < @rows.length
|
|
268
|
+
else_row = current_row
|
|
269
|
+
if match_row?(else_row, FOR_END)
|
|
270
|
+
validate_no_mixed_structural_content(else_row)
|
|
271
|
+
endfor_tag_row = else_row[:row_number]
|
|
272
|
+
advance
|
|
273
|
+
return Nodes::ForNode.new(var_name, collection_name, body, else_body,
|
|
274
|
+
for_row: for_tag_row, endfor_row: endfor_tag_row)
|
|
275
|
+
end
|
|
276
|
+
if match_row?(else_row, ENDIF)
|
|
277
|
+
validate_no_mixed_structural_content(else_row)
|
|
278
|
+
raise TemplateSyntaxError.new(
|
|
279
|
+
"Unexpected {% endif %} inside a {% for %} block — check that " \
|
|
280
|
+
"{% for %} is closed with {% endfor %}",
|
|
281
|
+
sheet: @sheet_name,
|
|
282
|
+
row: else_row[:row_number],
|
|
283
|
+
cell: cell_reference(else_row),
|
|
284
|
+
tag: first_cell_value(else_row).strip
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
else_body << parse_element
|
|
288
|
+
end
|
|
289
|
+
raise TemplateSyntaxError.new(
|
|
290
|
+
"Unclosed {% for %} block (missing {% endfor %})",
|
|
291
|
+
sheet: @sheet_name,
|
|
292
|
+
row: @rows[@pos > 0 ? @pos - 1 : 0][:row_number],
|
|
293
|
+
tag: "{% for #{var_name} in #{collection_name} %}"
|
|
294
|
+
)
|
|
295
|
+
elsif match_row?(row, ENDIF)
|
|
296
|
+
validate_no_mixed_structural_content(row)
|
|
297
|
+
raise TemplateSyntaxError.new(
|
|
298
|
+
"Unexpected {% endif %} inside a {% for %} block — check that " \
|
|
299
|
+
"{% for %} is closed with {% endfor %}",
|
|
300
|
+
sheet: @sheet_name,
|
|
301
|
+
row: row[:row_number],
|
|
302
|
+
cell: cell_reference(row),
|
|
303
|
+
tag: first_cell_value(row).strip
|
|
304
|
+
)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
body << parse_element
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
raise TemplateSyntaxError.new(
|
|
311
|
+
"Unclosed {% for %} block (missing {% endfor %})",
|
|
312
|
+
sheet: @sheet_name,
|
|
313
|
+
row: @rows.last[:row_number],
|
|
314
|
+
tag: "{% for #{var_name} in #{collection_name} %}"
|
|
315
|
+
)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def parse_if(condition)
|
|
319
|
+
condition = condition.strip
|
|
320
|
+
if_tag_row = current_row[:row_number]
|
|
321
|
+
advance # skip if tag
|
|
322
|
+
branches = [{ condition: condition, body: [] }]
|
|
323
|
+
|
|
324
|
+
while @pos < @rows.length
|
|
325
|
+
row = current_row
|
|
326
|
+
first_cell_value(row)
|
|
327
|
+
|
|
328
|
+
if match_row?(row, ENDIF)
|
|
329
|
+
validate_no_mixed_structural_content(row)
|
|
330
|
+
endif_tag_row = row[:row_number]
|
|
331
|
+
advance
|
|
332
|
+
return Nodes::IfNode.new(branches, if_row: if_tag_row, endif_row: endif_tag_row)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
elsif_match = match_row_with_capture(row, ELSIF)
|
|
336
|
+
if elsif_match
|
|
337
|
+
validate_no_mixed_structural_content(row)
|
|
338
|
+
advance
|
|
339
|
+
branches << { condition: elsif_match[1].strip, body: [] }
|
|
340
|
+
next
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
if match_row?(row, ELSE_TAG)
|
|
344
|
+
validate_no_mixed_structural_content(row)
|
|
345
|
+
advance
|
|
346
|
+
branches << { condition: nil, body: [] }
|
|
347
|
+
while @pos < @rows.length
|
|
348
|
+
else_row = current_row
|
|
349
|
+
if match_row?(else_row, ENDIF)
|
|
350
|
+
validate_no_mixed_structural_content(else_row)
|
|
351
|
+
endif_tag_row = else_row[:row_number]
|
|
352
|
+
advance
|
|
353
|
+
return Nodes::IfNode.new(branches, if_row: if_tag_row, endif_row: endif_tag_row)
|
|
354
|
+
end
|
|
355
|
+
if match_row?(else_row, FOR_END)
|
|
356
|
+
validate_no_mixed_structural_content(else_row)
|
|
357
|
+
raise TemplateSyntaxError.new(
|
|
358
|
+
"Unexpected {% endfor %} inside an {% if %} block — check that " \
|
|
359
|
+
"{% if %} is closed with {% endif %}",
|
|
360
|
+
sheet: @sheet_name,
|
|
361
|
+
row: else_row[:row_number],
|
|
362
|
+
cell: cell_reference(else_row),
|
|
363
|
+
tag: first_cell_value(else_row).strip
|
|
364
|
+
)
|
|
365
|
+
end
|
|
366
|
+
branches.last[:body] << parse_element
|
|
367
|
+
end
|
|
368
|
+
raise TemplateSyntaxError.new(
|
|
369
|
+
"Unclosed {% if %} block (missing {% endif %})",
|
|
370
|
+
sheet: @sheet_name,
|
|
371
|
+
row: @rows[@pos > 0 ? @pos - 1 : 0][:row_number],
|
|
372
|
+
tag: "{% if #{condition} %}"
|
|
373
|
+
)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
if match_row?(row, FOR_END)
|
|
377
|
+
validate_no_mixed_structural_content(row)
|
|
378
|
+
raise TemplateSyntaxError.new(
|
|
379
|
+
"Unexpected {% endfor %} inside an {% if %} block — check that " \
|
|
380
|
+
"{% if %} is closed with {% endif %}",
|
|
381
|
+
sheet: @sheet_name,
|
|
382
|
+
row: row[:row_number],
|
|
383
|
+
cell: cell_reference(row),
|
|
384
|
+
tag: first_cell_value(row).strip
|
|
385
|
+
)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
branches.last[:body] << parse_element
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
raise TemplateSyntaxError.new(
|
|
392
|
+
"Unclosed {% if %} block (missing {% endif %})",
|
|
393
|
+
sheet: @sheet_name,
|
|
394
|
+
row: @rows.last[:row_number],
|
|
395
|
+
tag: "{% if #{condition} %}"
|
|
396
|
+
)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Match a row against a pattern (returns MatchData or nil)
|
|
400
|
+
def match_row_with_capture(row, pattern)
|
|
401
|
+
first = first_cell_value(row)
|
|
402
|
+
return nil unless first
|
|
403
|
+
|
|
404
|
+
first.strip.match(pattern)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Check if a row matches a pattern (returns boolean)
|
|
408
|
+
def match_row?(row, pattern)
|
|
409
|
+
first = first_cell_value(row)
|
|
410
|
+
return false unless first
|
|
411
|
+
|
|
412
|
+
first.strip.match?(pattern)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Effective content of a cell for "dedicated row" validation.
|
|
416
|
+
# Formula cells count as content (represented as "=FORMULA") so they are
|
|
417
|
+
# not silently swallowed by structural tag rows.
|
|
418
|
+
def cell_content(cell)
|
|
419
|
+
val = cell[:text] || cell[:template]
|
|
420
|
+
return val if val && !val.strip.empty?
|
|
421
|
+
return "=#{cell[:formula]}" if cell[:formula]
|
|
422
|
+
|
|
423
|
+
nil
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def first_cell_value(row)
|
|
427
|
+
cells = row[:cells]
|
|
428
|
+
return nil if cells.nil? || cells.empty?
|
|
429
|
+
|
|
430
|
+
# Find the first NON-EMPTY cell by column order (A, B, C...)
|
|
431
|
+
sorted = cells.sort_by { |c| CellReference.col_to_index(c[:col]) }
|
|
432
|
+
first = sorted.find { |c| cell_content(c) }
|
|
433
|
+
return nil unless first
|
|
434
|
+
|
|
435
|
+
cell_content(first)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def cell_reference(row)
|
|
439
|
+
cells = row[:cells]
|
|
440
|
+
return nil if cells.nil? || cells.empty?
|
|
441
|
+
|
|
442
|
+
sorted = cells.sort_by { |c| CellReference.col_to_index(c[:col]) }
|
|
443
|
+
"#{sorted.first[:col]}#{row[:row_number]}"
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
end
|