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