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,775 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module LiquidXlsx
|
|
6
|
+
# Renders a single worksheet by parsing the template AST and
|
|
7
|
+
# rebuilding the sheet data with Liquid-rendered values.
|
|
8
|
+
class Renderer # rubocop:disable Metrics/ClassLength
|
|
9
|
+
attr_reader :worksheet, :options
|
|
10
|
+
attr_accessor :sheet_r_id
|
|
11
|
+
|
|
12
|
+
# Pattern for a simple variable-only template (no filters, no mixed text).
|
|
13
|
+
SINGLE_VAR = /\A\{\{\s*([\w.]+)\s*\}\}\z/
|
|
14
|
+
|
|
15
|
+
# Maximum size of the per-renderer Liquid::Template parse cache.
|
|
16
|
+
# When exceeded the entire cache is cleared (simple FIFO-like eviction).
|
|
17
|
+
TEMPLATE_CACHE_MAX_SIZE = 512
|
|
18
|
+
|
|
19
|
+
# Epoch for Excel 1900 date system (accounts for the leap-year bug).
|
|
20
|
+
# Serial = days since this date. For dates after 1900-02-28 the spurious
|
|
21
|
+
# 1900-02-29 absorbs the one-day offset, producing correct serials for
|
|
22
|
+
# all modern dates.
|
|
23
|
+
# TODO: support 1904 date system if workbookPr/@date1904 is accessible.
|
|
24
|
+
# Currently the Renderer has no access to package/workbook.xml;
|
|
25
|
+
# when date1904 detection is added, switch epoch to Date.new(1904,1,1).
|
|
26
|
+
DATE_1900_EPOCH = Date.new(1899, 12, 30)
|
|
27
|
+
SECONDS_PER_DAY = 86_400.0
|
|
28
|
+
DATE_TIME_CLASSES = [Date, Time].freeze
|
|
29
|
+
|
|
30
|
+
def initialize(worksheet, options = {})
|
|
31
|
+
@worksheet = worksheet
|
|
32
|
+
@options = options
|
|
33
|
+
@template_cache = {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Render the worksheet with data.
|
|
37
|
+
#
|
|
38
|
+
# `data` may be a Hash, a Liquid::Drop, or any object responding to
|
|
39
|
+
# `#to_liquid` that returns a Hash or a Drop. Plain objects without `[]` /
|
|
40
|
+
# `#key?` are not supported because Liquid::Context looks variables up via
|
|
41
|
+
# these methods on the root environment.
|
|
42
|
+
#
|
|
43
|
+
# `extra_scope` adds variables that take priority over `data` (used by
|
|
44
|
+
# dynamic sheets to inject the per-op local variable without merging —
|
|
45
|
+
# which would otherwise require `data` to be a Hash). It is passed as the
|
|
46
|
+
# second argument of Liquid::Context, the outer scope, which has higher
|
|
47
|
+
# precedence than environments.
|
|
48
|
+
#
|
|
49
|
+
# @param data [Hash, Liquid::Drop, #to_liquid] data to render
|
|
50
|
+
# @param workbook_ops [Array] mutable array to collect {% sheet %} operations
|
|
51
|
+
# @param image_ops [Array] mutable array to collect {% image_tag %} operations
|
|
52
|
+
# @param extra_scope [Hash{String,Symbol=>Object}] extra variables with
|
|
53
|
+
# priority over `data` (e.g. for dynamic sheets)
|
|
54
|
+
# @return [String] XML content of the rendered worksheet
|
|
55
|
+
def render(data, workbook_ops: nil, image_ops: nil, extra_scope: nil)
|
|
56
|
+
rows = worksheet.parse_rows
|
|
57
|
+
return worksheet.to_xml if rows.empty?
|
|
58
|
+
|
|
59
|
+
# Quick scan: skip sheet if no Liquid tags at all
|
|
60
|
+
has_tags = rows.any? do |r|
|
|
61
|
+
r[:cells].any? { |c| c[:template] }
|
|
62
|
+
end
|
|
63
|
+
return worksheet.to_xml unless has_tags
|
|
64
|
+
|
|
65
|
+
# Build AST
|
|
66
|
+
parser = TemplateParser.new(rows, worksheet.name)
|
|
67
|
+
ast = parser.parse
|
|
68
|
+
|
|
69
|
+
strict_vars = @options.fetch(:strict_variables, false)
|
|
70
|
+
strict_filts = @options.fetch(:strict_filters, false)
|
|
71
|
+
|
|
72
|
+
# One Liquid::Context per worksheet — shared across all cells.
|
|
73
|
+
# Normalize arbitrary #to_liquid objects (AR models, Struct, …) into a
|
|
74
|
+
# Hash or Drop the context can look variables up in. Hash and Drop pass
|
|
75
|
+
# through unchanged (Drop#to_liquid returns self).
|
|
76
|
+
normalized = normalize_data(data)
|
|
77
|
+
data_hash = stringify_keys(normalized)
|
|
78
|
+
registers = { liquid_xlsx_workbook_ops: workbook_ops,
|
|
79
|
+
liquid_xlsx_image_ops: image_ops || [],
|
|
80
|
+
liquid_xlsx_dynamic_sheets: @options.fetch(:dynamic_sheets, false),
|
|
81
|
+
liquid_xlsx_sheet_name: @worksheet.name,
|
|
82
|
+
liquid_xlsx_sheet_r_id: @sheet_r_id }
|
|
83
|
+
liquid_context = ::Liquid::Context.new(data_hash, {}, registers, true)
|
|
84
|
+
# `extra_scope` is applied AFTER context construction on purpose: passing
|
|
85
|
+
# it as the second (outer_scope) argument would let Liquid's
|
|
86
|
+
# `squash_instance_assigns_with_environments` (invoked at the end of
|
|
87
|
+
# `Context#initialize`) shadow each key with the corresponding value
|
|
88
|
+
# from the environment. For a Drop environment that is fatal, because
|
|
89
|
+
# `Drop#key?` always returns `true`, so unknown methods yield `nil` and
|
|
90
|
+
# overwrite the explicit extra variables. Assigning directly onto the
|
|
91
|
+
# context bypasses squash entirely and still wins lookup priority
|
|
92
|
+
# because scopes are searched before environments.
|
|
93
|
+
if extra_scope
|
|
94
|
+
stringify_keys(extra_scope).each do |k, v|
|
|
95
|
+
liquid_context[k] = v
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
liquid_context.strict_variables = strict_vars
|
|
99
|
+
liquid_context.strict_filters = strict_filts
|
|
100
|
+
apply_resource_limits(liquid_context)
|
|
101
|
+
|
|
102
|
+
# Get original merge cells
|
|
103
|
+
original_merges = worksheet.merge_cells
|
|
104
|
+
merge_transformer = MergeCellsTransformer.new
|
|
105
|
+
|
|
106
|
+
# Render AST to produce new rows + merge info
|
|
107
|
+
formula_translator = FormulaTranslator.new
|
|
108
|
+
state = RenderState.new(merge_transformer, original_merges, formula_translator)
|
|
109
|
+
|
|
110
|
+
rendered = render_ast(ast, liquid_context, data_hash, rows, strict_filts, state)
|
|
111
|
+
|
|
112
|
+
# Check for merge cells crossing structural block boundaries
|
|
113
|
+
state.check_block_boundaries(worksheet.name)
|
|
114
|
+
|
|
115
|
+
# Build merge ranges list
|
|
116
|
+
merge_ranges = state.build_merge_ranges
|
|
117
|
+
|
|
118
|
+
# Translate formulas now that all loop expansions are known
|
|
119
|
+
finalize_formulas(rendered, state.formula_translator)
|
|
120
|
+
|
|
121
|
+
# Rebuild sheet data (always: an empty result must clear template rows too)
|
|
122
|
+
worksheet.rebuild_sheet_data(rendered, 1)
|
|
123
|
+
|
|
124
|
+
# Update dimension
|
|
125
|
+
worksheet.update_dimension(rendered.length)
|
|
126
|
+
|
|
127
|
+
# Always update merge cells (clears old ones even if result is empty)
|
|
128
|
+
worksheet.update_merge_cells(merge_ranges)
|
|
129
|
+
|
|
130
|
+
# Remove cached formula values if recalculating
|
|
131
|
+
if @options[:recalculate_formulas]
|
|
132
|
+
worksheet.remove_cached_formula_values
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
worksheet.to_xml
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def stringify_keys(hash)
|
|
141
|
+
return hash unless hash.is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_value(v) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def stringify_value(value)
|
|
147
|
+
case value
|
|
148
|
+
when Hash then stringify_keys(value)
|
|
149
|
+
when Array then value.map { |v| stringify_value(v) }
|
|
150
|
+
else value
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Normalize the root `data` argument into something Liquid::Context can
|
|
155
|
+
# look variables up in. Liquid::Context treats its first argument as an
|
|
156
|
+
# "environment" and calls `[]` / `key?` on it during lookup, so a plain
|
|
157
|
+
# object with only `#to_liquid` (but no `[]`) would raise NoMethodError.
|
|
158
|
+
# Hash and Liquid::Drop already satisfy the lookup contract and pass
|
|
159
|
+
# through unchanged; everything else that responds to `#to_liquid` is
|
|
160
|
+
# converted once at the boundary (AR models, Struct, OpenStruct, …).
|
|
161
|
+
def normalize_data(data)
|
|
162
|
+
return data if data.is_a?(Hash) || data.is_a?(::Liquid::Drop)
|
|
163
|
+
return data unless data.respond_to?(:to_liquid)
|
|
164
|
+
|
|
165
|
+
result = data.to_liquid
|
|
166
|
+
return result if result.is_a?(Hash) || result.is_a?(::Liquid::Drop)
|
|
167
|
+
|
|
168
|
+
raise RenderError,
|
|
169
|
+
"#to_liquid returned #{result.class}; " \
|
|
170
|
+
"expected a Hash or Liquid::Drop"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Whether a numeric value can be written to OOXML `<v>` directly.
|
|
174
|
+
# Rational and Complex produce invalid numeric lexemes ("1/2", "1+2i"),
|
|
175
|
+
# and Float NaN/Infinity are not valid in OOXML either. Anything failing
|
|
176
|
+
# this check falls through to normal string rendering.
|
|
177
|
+
def numeric_for_excel?(value)
|
|
178
|
+
return false unless value.is_a?(Numeric)
|
|
179
|
+
return false if value.is_a?(Rational) || value.is_a?(Complex)
|
|
180
|
+
return false if value.respond_to?(:finite?) && !value.finite?
|
|
181
|
+
|
|
182
|
+
true
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def render_ast(ast, liquid_context, data_hash, original_rows, strict_filters, state)
|
|
186
|
+
rendered_rows = []
|
|
187
|
+
|
|
188
|
+
ast.each do |node|
|
|
189
|
+
process_node(node, liquid_context, data_hash, rendered_rows, original_rows,
|
|
190
|
+
strict_filters, state)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
rendered_rows
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def process_node(node, liquid_context, data_hash, rendered_rows, original_rows,
|
|
197
|
+
strict_filters, state)
|
|
198
|
+
case node
|
|
199
|
+
when Nodes::RowNode
|
|
200
|
+
template_row_num = node.row_data[:row_number]
|
|
201
|
+
new_row_num = rendered_rows.length + 1
|
|
202
|
+
|
|
203
|
+
cells = render_row_cells(node, liquid_context, data_hash, state,
|
|
204
|
+
new_row_num)
|
|
205
|
+
|
|
206
|
+
# Build row info hash preserving original attributes
|
|
207
|
+
row_info = {
|
|
208
|
+
cells: cells,
|
|
209
|
+
original_row_num: template_row_num,
|
|
210
|
+
row_attrs: node.row_data[:row_attrs]
|
|
211
|
+
}
|
|
212
|
+
rendered_rows << row_info
|
|
213
|
+
|
|
214
|
+
# Record merge cells for this row
|
|
215
|
+
state.record_row(template_row_num, new_row_num)
|
|
216
|
+
when Nodes::ForNode
|
|
217
|
+
collection = resolve_for_collection(liquid_context, data_hash, node)
|
|
218
|
+
|
|
219
|
+
if collection.empty? && node.else_body
|
|
220
|
+
state.enter_block(:for_else, node.for_row, node.endfor_row)
|
|
221
|
+
node.else_body.each do |child|
|
|
222
|
+
process_node(child, liquid_context, data_hash, rendered_rows, original_rows,
|
|
223
|
+
strict_filters, state)
|
|
224
|
+
end
|
|
225
|
+
state.leave_block
|
|
226
|
+
return
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Record block boundary using TEMPLATE row numbers
|
|
230
|
+
state.enter_block(:for, node.for_row, node.endfor_row)
|
|
231
|
+
state.start_loop
|
|
232
|
+
|
|
233
|
+
collection.each_with_index do |item, index|
|
|
234
|
+
state.start_iteration(index)
|
|
235
|
+
|
|
236
|
+
liquid_context.stack do
|
|
237
|
+
liquid_context[node.variable_name] = stringify_value(item)
|
|
238
|
+
liquid_context["forloop"] = {
|
|
239
|
+
"index" => index + 1,
|
|
240
|
+
"index0" => index,
|
|
241
|
+
"first" => index == 0,
|
|
242
|
+
"last" => index == collection.length - 1,
|
|
243
|
+
"length" => collection.length
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
node.body.each do |child|
|
|
247
|
+
process_node(child, liquid_context, data_hash, rendered_rows, original_rows,
|
|
248
|
+
strict_filters, state)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
state.end_iteration
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
execution_rows = state.end_loop
|
|
256
|
+
expansion_path = state.current_path
|
|
257
|
+
|
|
258
|
+
# Recursively collect all RowNode template rows, including those
|
|
259
|
+
# inside nested IfNode / ForNode bodies.
|
|
260
|
+
body_template_rows = collect_body_rows(node.body)
|
|
261
|
+
if body_template_rows.any?
|
|
262
|
+
rows_map = body_template_rows.to_h { |t| [t, execution_rows[t] || []] }
|
|
263
|
+
state.formula_translator.register_expansion(
|
|
264
|
+
rows: rows_map,
|
|
265
|
+
block_first: node.for_row,
|
|
266
|
+
block_last: node.endfor_row,
|
|
267
|
+
path: expansion_path
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
state.leave_block
|
|
272
|
+
when Nodes::IfNode
|
|
273
|
+
state.enter_block(:if, node.if_row, node.endif_row)
|
|
274
|
+
|
|
275
|
+
node.branches.each do |branch|
|
|
276
|
+
if branch[:condition].nil?
|
|
277
|
+
# else branch
|
|
278
|
+
branch[:body].each do |child|
|
|
279
|
+
process_node(child, liquid_context, data_hash, rendered_rows, original_rows,
|
|
280
|
+
strict_filters, state)
|
|
281
|
+
end
|
|
282
|
+
state.leave_block
|
|
283
|
+
return
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
condition_result = evaluate_condition(branch[:condition], liquid_context, node.if_row)
|
|
287
|
+
next unless condition_result
|
|
288
|
+
|
|
289
|
+
branch[:body].each do |child|
|
|
290
|
+
process_node(child, liquid_context, data_hash, rendered_rows, original_rows,
|
|
291
|
+
strict_filters, state)
|
|
292
|
+
end
|
|
293
|
+
state.leave_block
|
|
294
|
+
return
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
state.leave_block
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Recursively collect template row numbers of all RowNode descendants
|
|
302
|
+
# in a list of AST nodes, including those inside nested IfNode branches
|
|
303
|
+
# and ForNode bodies.
|
|
304
|
+
def collect_body_rows(nodes)
|
|
305
|
+
nodes.flat_map do |child|
|
|
306
|
+
case child
|
|
307
|
+
when Nodes::RowNode
|
|
308
|
+
[child.row_data[:row_number]]
|
|
309
|
+
when Nodes::ForNode
|
|
310
|
+
rows = collect_body_rows(child.body)
|
|
311
|
+
rows += collect_body_rows(child.else_body) if child.else_body
|
|
312
|
+
rows
|
|
313
|
+
when Nodes::IfNode
|
|
314
|
+
child.branches.flat_map { |branch| collect_body_rows(branch[:body]) }
|
|
315
|
+
else
|
|
316
|
+
[]
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def resolve_collection(liquid_context, _data_hash, path)
|
|
322
|
+
safe_context_lookup(liquid_context, path)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Resolve and validate a for-loop collection. nil becomes an empty
|
|
326
|
+
# collection; non-Enumerable values raise a clear error instead of a raw
|
|
327
|
+
# NoMethodError on #each.
|
|
328
|
+
#
|
|
329
|
+
# In strict-variables mode the collection name is looked up through a
|
|
330
|
+
# raw Liquid::Context#[] call so that UndefinedVariable /
|
|
331
|
+
# UndefinedDropMethod errors propagate (and are converted to
|
|
332
|
+
# MissingVariableError). A defined variable whose value is nil produces
|
|
333
|
+
# an empty loop — it is NOT treated as a missing variable.
|
|
334
|
+
# In lenient mode the safe lookup path is used (errors are swallowed
|
|
335
|
+
# and nil is returned, yielding an empty loop).
|
|
336
|
+
def resolve_for_collection(liquid_context, data_hash, node)
|
|
337
|
+
collection = if liquid_context.strict_variables
|
|
338
|
+
strict_collection_lookup(liquid_context, node)
|
|
339
|
+
else
|
|
340
|
+
resolve_collection(liquid_context, data_hash, node.collection_name)
|
|
341
|
+
end
|
|
342
|
+
return [] if collection.nil?
|
|
343
|
+
return collection if collection.is_a?(Array)
|
|
344
|
+
|
|
345
|
+
raise RenderError.new(
|
|
346
|
+
"Cannot iterate over #{collection.class} " \
|
|
347
|
+
"in '{% for #{node.variable_name} in #{node.collection_name} %}': " \
|
|
348
|
+
"expected an Array (list).",
|
|
349
|
+
sheet: @worksheet.name,
|
|
350
|
+
row: node.for_row
|
|
351
|
+
)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Look up a for-loop collection name in strict mode. Liquid::Context#[]
|
|
355
|
+
# resolves Drops, loop variables and {% assign %} in a single pass and
|
|
356
|
+
# raises UndefinedVariable / UndefinedDropMethod when the variable is
|
|
357
|
+
# genuinely absent under strict_variables. We catch those and convert
|
|
358
|
+
# them to a MissingVariableError with sheet/row/template context.
|
|
359
|
+
# Defined-variable-with-nil-value returns nil (no exception) — nil
|
|
360
|
+
# becomes an empty loop just like in lenient mode.
|
|
361
|
+
def strict_collection_lookup(liquid_context, node)
|
|
362
|
+
liquid_context[node.collection_name]
|
|
363
|
+
rescue Liquid::UndefinedVariable, Liquid::UndefinedDropMethod => e
|
|
364
|
+
raise MissingVariableError.new(
|
|
365
|
+
"Missing variable in for-loop: #{e.message}",
|
|
366
|
+
sheet: @worksheet.name,
|
|
367
|
+
row: node.for_row,
|
|
368
|
+
template: "{% for #{node.variable_name} in #{node.collection_name} %}"
|
|
369
|
+
)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def evaluate_condition(condition, liquid_context, row_num = nil)
|
|
373
|
+
template_text = "{% if #{condition} %}true{% endif %}"
|
|
374
|
+
template = cached_parse(template_text)
|
|
375
|
+
result = template.render(liquid_context)
|
|
376
|
+
result.strip == "true"
|
|
377
|
+
rescue Liquid::UndefinedVariable, Liquid::UndefinedDropMethod => e
|
|
378
|
+
raise MissingVariableError.new(
|
|
379
|
+
"Missing variable in condition: #{e.message}",
|
|
380
|
+
sheet: @worksheet.name,
|
|
381
|
+
row: row_num,
|
|
382
|
+
template: "{% if #{condition} %}"
|
|
383
|
+
)
|
|
384
|
+
rescue Liquid::Error => e
|
|
385
|
+
raise RenderError.new(
|
|
386
|
+
"Liquid error in condition: #{e.message}",
|
|
387
|
+
sheet: @worksheet.name,
|
|
388
|
+
row: row_num,
|
|
389
|
+
template: "{% if #{condition} %}"
|
|
390
|
+
)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Apply optional Liquid resource limits from options[:liquid_resource_limits].
|
|
394
|
+
def apply_resource_limits(liquid_context)
|
|
395
|
+
limits = @options[:liquid_resource_limits]
|
|
396
|
+
return unless limits.is_a?(Hash)
|
|
397
|
+
|
|
398
|
+
rl = liquid_context.resource_limits
|
|
399
|
+
rl.render_length_limit = limits[:render_length_limit] if limits[:render_length_limit]
|
|
400
|
+
rl.render_score_limit = limits[:render_score_limit] if limits[:render_score_limit]
|
|
401
|
+
rl.assign_score_limit = limits[:assign_score_limit] if limits[:assign_score_limit]
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Translate all pending formulas once every loop expansion is registered.
|
|
405
|
+
# This must run after the full AST walk: formulas above a loop would
|
|
406
|
+
# otherwise be translated before the loop's expansion is known.
|
|
407
|
+
def finalize_formulas(rendered_rows, translator)
|
|
408
|
+
rendered_rows.each do |row_info|
|
|
409
|
+
next unless row_info
|
|
410
|
+
|
|
411
|
+
(row_info[:cells] || []).each do |cell|
|
|
412
|
+
next unless cell && cell[:formula] && cell[:formula_row_delta]
|
|
413
|
+
|
|
414
|
+
template_row = cell[:formula_template_row]
|
|
415
|
+
copied = translator.copied_row?(template_row)
|
|
416
|
+
cell[:formula] = translator.translate(
|
|
417
|
+
cell[:formula], cell[:formula_row_delta], 0,
|
|
418
|
+
template_row: template_row, copied: copied,
|
|
419
|
+
formula_path: cell[:formula_path] || []
|
|
420
|
+
)
|
|
421
|
+
next unless (attrs = cell[:formula_attrs]) && attrs["ref"]
|
|
422
|
+
|
|
423
|
+
attrs = cell[:formula_attrs] = attrs.dup
|
|
424
|
+
attrs["ref"] = translator.translate(
|
|
425
|
+
attrs["ref"], cell[:formula_row_delta], 0,
|
|
426
|
+
template_row: template_row, copied: copied,
|
|
427
|
+
formula_path: cell[:formula_path] || []
|
|
428
|
+
)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def render_row_cells(node, liquid_context, data_hash, state, new_row_num)
|
|
434
|
+
cells = node.row_data[:cells]
|
|
435
|
+
return [] unless cells
|
|
436
|
+
|
|
437
|
+
template_row_num = node.row_data[:row_number]
|
|
438
|
+
|
|
439
|
+
cells.map do |cell_data|
|
|
440
|
+
result = cell_data.dup
|
|
441
|
+
|
|
442
|
+
# Set anchor coordinates for {% image_tag %} (render row/col)
|
|
443
|
+
# These are set for EVERY cell so image_tag picks them up
|
|
444
|
+
liquid_context.registers[:liquid_xlsx_anchor_row] = new_row_num
|
|
445
|
+
liquid_context.registers[:liquid_xlsx_anchor_col] = cell_data[:col]
|
|
446
|
+
liquid_context.registers[:liquid_xlsx_template_anchor_row] = template_row_num
|
|
447
|
+
|
|
448
|
+
if cell_data[:formula]
|
|
449
|
+
# Defer translation until all loop expansions are registered
|
|
450
|
+
# (see #finalize_formulas).
|
|
451
|
+
result[:formula_row_delta] = new_row_num - template_row_num
|
|
452
|
+
result[:formula_template_row] = template_row_num
|
|
453
|
+
result[:formula_path] = state.current_path
|
|
454
|
+
result[:original_type] = cell_data[:type]
|
|
455
|
+
result[:inline_str] = false
|
|
456
|
+
result[:template] = nil
|
|
457
|
+
elsif cell_data[:template]
|
|
458
|
+
rendered = render_cell_template(cell_data, liquid_context, data_hash, template_row_num)
|
|
459
|
+
result[:rendered_value] = rendered
|
|
460
|
+
# Preserve numeric/boolean types: don't set inlineStr for them.
|
|
461
|
+
# Non-finite floats (NaN/Infinity) cannot be written as numbers.
|
|
462
|
+
# Date/Time values are converted to numeric serials in the fast path,
|
|
463
|
+
# so they also flow through as Numeric here.
|
|
464
|
+
numeric = rendered.is_a?(Numeric) &&
|
|
465
|
+
!(rendered.is_a?(Float) && !rendered.finite?)
|
|
466
|
+
result[:inline_str] = !(numeric ||
|
|
467
|
+
rendered.is_a?(TrueClass) ||
|
|
468
|
+
rendered.is_a?(FalseClass))
|
|
469
|
+
result[:type] = nil
|
|
470
|
+
result[:template] = nil
|
|
471
|
+
else
|
|
472
|
+
# Non-template cell: preserve original content with type intact
|
|
473
|
+
if cell_data[:type] == "inlineStr"
|
|
474
|
+
result[:rendered_value] = cell_data[:text]
|
|
475
|
+
result[:inline_str] = true
|
|
476
|
+
elsif cell_data[:type] == "s"
|
|
477
|
+
# Keep as shared string reference (no change needed)
|
|
478
|
+
result[:inline_str] = false
|
|
479
|
+
elsif cell_data[:value]
|
|
480
|
+
# Plain value cell (numeric, boolean, raw) — preserve value and type
|
|
481
|
+
result[:original_value] = cell_data[:value]
|
|
482
|
+
result[:original_type] = cell_data[:type]
|
|
483
|
+
result[:inline_str] = false
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
result
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Render a cell template through the shared Liquid::Context.
|
|
492
|
+
# Tries to preserve numeric/boolean/date types when possible.
|
|
493
|
+
def render_cell_template(cell_data, liquid_context, _data_hash, row_num)
|
|
494
|
+
template_text = cell_data[:template]
|
|
495
|
+
cell_ref = "#{cell_data[:col]}#{row_num}"
|
|
496
|
+
|
|
497
|
+
# For simple {{ var }} templates without filters, try direct extraction
|
|
498
|
+
# to preserve numeric/boolean/date types for Excel. `safe_context_lookup`
|
|
499
|
+
# resolves Drops, `to_liquid`, loop variables and `{% assign %}` in a
|
|
500
|
+
# single pass through Liquid::Context (which respects scope priority),
|
|
501
|
+
# so a hand-rolled Hash fallback is neither needed nor correct for
|
|
502
|
+
# non-Hash data. See `numeric_for_excel?` for why some Numeric subtypes
|
|
503
|
+
# are deliberately routed through normal rendering.
|
|
504
|
+
var_match = template_text.strip.match(SINGLE_VAR)
|
|
505
|
+
if var_match
|
|
506
|
+
ctx_val = safe_context_lookup(liquid_context, var_match[1])
|
|
507
|
+
return ctx_val if numeric_for_excel?(ctx_val)
|
|
508
|
+
return ctx_val if ctx_val.is_a?(TrueClass) || ctx_val.is_a?(FalseClass)
|
|
509
|
+
return date_time_to_serial(ctx_val) if date_time?(ctx_val)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Render through shared Liquid context
|
|
513
|
+
begin
|
|
514
|
+
# Set source coordinates for {% sheet %} tag error reporting in registers
|
|
515
|
+
liquid_context.registers[:liquid_xlsx_source_row] = row_num
|
|
516
|
+
liquid_context.registers[:liquid_xlsx_source_cell] = cell_ref
|
|
517
|
+
|
|
518
|
+
template = cached_parse(template_text)
|
|
519
|
+
rendered = template.render(liquid_context)
|
|
520
|
+
rescue Liquid::UndefinedVariable, Liquid::UndefinedDropMethod => e
|
|
521
|
+
raise MissingVariableError.new(
|
|
522
|
+
"Missing variable: #{e.message}",
|
|
523
|
+
sheet: @worksheet.name,
|
|
524
|
+
row: row_num,
|
|
525
|
+
cell: cell_ref,
|
|
526
|
+
template: template_text
|
|
527
|
+
)
|
|
528
|
+
rescue Liquid::Error => e
|
|
529
|
+
raise RenderError.new(
|
|
530
|
+
"Liquid render error: #{e.message}",
|
|
531
|
+
sheet: @worksheet.name,
|
|
532
|
+
row: row_num,
|
|
533
|
+
cell: cell_ref,
|
|
534
|
+
template: template_text
|
|
535
|
+
)
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
coerce_value(rendered)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Look up a variable in Liquid context, suppressing strict-variable errors.
|
|
542
|
+
# Returns nil if the variable is undefined (used for type-preserving fast
|
|
543
|
+
# path). Catches both `UndefinedVariable` (missing Hash key) and
|
|
544
|
+
# `UndefinedDropMethod` (missing Drop method under `strict_variables`) —
|
|
545
|
+
# these are sibling subclasses of Liquid::Error, not a parent/child pair,
|
|
546
|
+
# so both must be listed explicitly.
|
|
547
|
+
def safe_context_lookup(liquid_context, path)
|
|
548
|
+
liquid_context[path]
|
|
549
|
+
rescue Liquid::UndefinedVariable, Liquid::UndefinedDropMethod
|
|
550
|
+
nil
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def coerce_value(value)
|
|
554
|
+
return value.to_s if value.nil?
|
|
555
|
+
return value if value.is_a?(Integer) || value.is_a?(Float)
|
|
556
|
+
return value if value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
557
|
+
return value if value.is_a?(Date) || value.is_a?(Time)
|
|
558
|
+
|
|
559
|
+
value.to_s
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# -------------------------------------------------------------------
|
|
563
|
+
# Template cache — avoids re-parsing identical Liquid templates.
|
|
564
|
+
# Liquid::Template instances are stateless after compilation; the
|
|
565
|
+
# same instance can be safely rendered against different contexts.
|
|
566
|
+
# `strict_variables` / `strict_filters` are context-level settings
|
|
567
|
+
# (set on @liquid_context, lines 80-81), not template-level.
|
|
568
|
+
# `environment` (LiquidXlsx.liquid_environment) carries only tag
|
|
569
|
+
# registries and is identical for every cell/condition parse.
|
|
570
|
+
# -------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
# Parse or fetch from cache a Liquid::Template for the given text.
|
|
573
|
+
# When the cache exceeds TEMPLATE_CACHE_MAX_SIZE, the entire cache is
|
|
574
|
+
# cleared on the next miss (simple eviction — fast path templates are
|
|
575
|
+
# few and conditions even fewer for typical workbooks).
|
|
576
|
+
def cached_parse(template_text)
|
|
577
|
+
if @template_cache.size >= TEMPLATE_CACHE_MAX_SIZE && !@template_cache.key?(template_text)
|
|
578
|
+
@template_cache.clear
|
|
579
|
+
end
|
|
580
|
+
@template_cache[template_text] ||= ::Liquid::Template.parse(template_text,
|
|
581
|
+
environment: LiquidXlsx.liquid_environment)
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# -------------------------------------------------------------------
|
|
585
|
+
# Date / Time → Excel serial number conversion (1900 date system).
|
|
586
|
+
# Uses local time as-is via #to_time (no timezone adjustment).
|
|
587
|
+
# Serial = integer days + fractional day (seconds / 86_400).
|
|
588
|
+
# The style (s=) from the template cell is preserved, so if the
|
|
589
|
+
# template has a date format applied, Excel displays the serial
|
|
590
|
+
# correctly as a date.
|
|
591
|
+
# -------------------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
def date_time?(value)
|
|
594
|
+
DATE_TIME_CLASSES.any? { |klass| value.is_a?(klass) }
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def date_time_to_serial(value)
|
|
598
|
+
time = value.to_time
|
|
599
|
+
days = (time.to_date - DATE_1900_EPOCH).to_i
|
|
600
|
+
fraction = ((time.hour * 3600) + (time.min * 60) + time.sec +
|
|
601
|
+
(time.nsec / 1_000_000_000.0)) / SECONDS_PER_DAY
|
|
602
|
+
serial = days + fraction
|
|
603
|
+
serial == serial.to_i ? serial.to_i : serial
|
|
604
|
+
rescue NoMethodError
|
|
605
|
+
value
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Tracks merge cells, formula expansions, and block boundaries.
|
|
610
|
+
class RenderState
|
|
611
|
+
attr_reader :formula_translator
|
|
612
|
+
|
|
613
|
+
def initialize(merge_transformer, original_merges, formula_translator)
|
|
614
|
+
@merge_transformer = merge_transformer
|
|
615
|
+
@original_merges = original_merges
|
|
616
|
+
@formula_translator = formula_translator
|
|
617
|
+
|
|
618
|
+
# Map: template_row_num => [new_row_num, ...] (multiple for loop iterations)
|
|
619
|
+
@row_map = {}
|
|
620
|
+
# Structural block boundaries (template row numbers) for merge crossing detection
|
|
621
|
+
@blocks = []
|
|
622
|
+
# Current loop state
|
|
623
|
+
# Each frame: { iterations: [...], current_iteration: {row_map: {}, index:} or nil }
|
|
624
|
+
@loop_stack = []
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def record_row(template_row_num, new_row_num)
|
|
628
|
+
@row_map[template_row_num] ||= []
|
|
629
|
+
@row_map[template_row_num] << new_row_num
|
|
630
|
+
|
|
631
|
+
# Also write to all active per-iteration row maps for later aggregation
|
|
632
|
+
@loop_stack.each do |frame|
|
|
633
|
+
iter = frame[:current_iteration]
|
|
634
|
+
next unless iter
|
|
635
|
+
|
|
636
|
+
iter[:row_map][template_row_num] ||= []
|
|
637
|
+
iter[:row_map][template_row_num] << new_row_num
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def enter_block(type, for_row, endfor_row)
|
|
642
|
+
@blocks << { type: type, start: for_row, end: endfor_row } if for_row && endfor_row
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def leave_block
|
|
646
|
+
# Block already recorded in enter_block
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def start_loop
|
|
650
|
+
@loop_stack.push(iterations: [], current_iteration: nil)
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def start_iteration(index)
|
|
654
|
+
@loop_stack.last[:current_iteration] = { row_map: {}, index: index }
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def end_iteration
|
|
658
|
+
@loop_stack.last[:iterations] << @loop_stack.last[:current_iteration]
|
|
659
|
+
@loop_stack.last[:current_iteration] = nil
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# Aggregate per-iteration row maps of the current loop execution into
|
|
663
|
+
# a single {template_row => [rendered_row, ...]} hash and pop the frame.
|
|
664
|
+
def end_loop
|
|
665
|
+
frame = @loop_stack.pop
|
|
666
|
+
aggregate = {}
|
|
667
|
+
frame[:iterations].each do |iter|
|
|
668
|
+
iter[:row_map].each do |t_row, r_rows|
|
|
669
|
+
aggregate[t_row] ||= []
|
|
670
|
+
aggregate[t_row].concat(r_rows)
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
aggregate
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Current iteration path: indices of all active loop iterations.
|
|
677
|
+
# Used for formula-path and expansion-path visibility filtering.
|
|
678
|
+
def current_path
|
|
679
|
+
@loop_stack.filter_map { |f| f[:current_iteration]&.dig(:index) }
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# Check if any merge cell crosses a structural block boundary.
|
|
683
|
+
# Both block bounds and merge bounds use template row numbers.
|
|
684
|
+
def check_block_boundaries(sheet_name)
|
|
685
|
+
@original_merges.each do |merge_info|
|
|
686
|
+
merge_start = merge_info[:start_row]
|
|
687
|
+
merge_end = merge_info[:end_row]
|
|
688
|
+
|
|
689
|
+
@blocks.each do |block|
|
|
690
|
+
next unless crosses_boundary?(merge_start, merge_end, block[:start], block[:end])
|
|
691
|
+
|
|
692
|
+
raise UnsupportedTemplateError.new(
|
|
693
|
+
"Merged cell '#{merge_info[:ref]}' crosses a #{block[:type]} block boundary " \
|
|
694
|
+
"(template rows #{block[:start]}-#{block[:end]})",
|
|
695
|
+
sheet: sheet_name,
|
|
696
|
+
block_rows: "rows #{block[:start]}-#{block[:end]}"
|
|
697
|
+
)
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def build_merge_ranges
|
|
703
|
+
result = []
|
|
704
|
+
|
|
705
|
+
@original_merges.each do |merge_info|
|
|
706
|
+
ref = merge_info[:ref]
|
|
707
|
+
start_template_row = merge_info[:start_row]
|
|
708
|
+
end_template_row = merge_info[:end_row]
|
|
709
|
+
|
|
710
|
+
# Skip merges on structural rows (consumed by parser)
|
|
711
|
+
unless @row_map.key?(start_template_row) || @row_map.key?(end_template_row)
|
|
712
|
+
next
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
if @row_map.key?(start_template_row) && @row_map.key?(end_template_row)
|
|
716
|
+
start_rows = @row_map[start_template_row]
|
|
717
|
+
end_rows = @row_map[end_template_row]
|
|
718
|
+
|
|
719
|
+
start_rows.each_with_index do |sr, i|
|
|
720
|
+
er = end_rows[i] || sr
|
|
721
|
+
cloned = clone_merge(ref, sr - start_template_row, er - end_template_row)
|
|
722
|
+
result << cloned if cloned
|
|
723
|
+
end
|
|
724
|
+
elsif @row_map.key?(start_template_row)
|
|
725
|
+
@row_map[start_template_row].each do |nr|
|
|
726
|
+
cloned = clone_merge_row(ref, nr - start_template_row)
|
|
727
|
+
result << cloned if cloned
|
|
728
|
+
end
|
|
729
|
+
else
|
|
730
|
+
result << ref
|
|
731
|
+
end
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
result
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
private
|
|
738
|
+
|
|
739
|
+
def crosses_boundary?(merge_start, merge_end, block_start, block_end)
|
|
740
|
+
# Merge entirely above or below the block
|
|
741
|
+
return false if merge_end < block_start || merge_start > block_end
|
|
742
|
+
|
|
743
|
+
# Merge strictly inside the block body (between the tag rows)
|
|
744
|
+
return false if merge_start > block_start && merge_end < block_end
|
|
745
|
+
|
|
746
|
+
# Merge entirely on a single structural tag row (it is consumed with it)
|
|
747
|
+
return false if merge_start == merge_end && (merge_start == block_start || merge_start == block_end)
|
|
748
|
+
|
|
749
|
+
# Any other overlap = crossing (including merges that swallow a tag row)
|
|
750
|
+
true
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def clone_merge(ref, start_offset, end_offset)
|
|
754
|
+
parts = ref.split(":")
|
|
755
|
+
return nil if parts.length < 2
|
|
756
|
+
|
|
757
|
+
start_ref = CellReference.new(parts[0])
|
|
758
|
+
end_ref = CellReference.new(parts[1])
|
|
759
|
+
|
|
760
|
+
new_start = start_ref.shift(start_offset)
|
|
761
|
+
new_end = end_ref.shift(end_offset)
|
|
762
|
+
"#{new_start}:#{new_end}"
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
def clone_merge_row(ref, offset)
|
|
766
|
+
parts = ref.split(":")
|
|
767
|
+
start_ref = CellReference.new(parts[0])
|
|
768
|
+
end_ref = CellReference.new(parts[1] || parts[0])
|
|
769
|
+
|
|
770
|
+
new_start = start_ref.shift(offset)
|
|
771
|
+
new_end = end_ref.shift(offset)
|
|
772
|
+
"#{new_start}:#{new_end}"
|
|
773
|
+
end
|
|
774
|
+
end
|
|
775
|
+
end
|