Class: LiquidXlsx::Renderer

Inherits:
Object
  • Object
show all
Defined in:
lib/liquid_xlsx/renderer.rb

Overview

Renders a single worksheet by parsing the template AST and rebuilding the sheet data with Liquid-rendered values.

Constant Summary collapse

SINGLE_VAR =

Pattern for a simple variable-only template (no filters, no mixed text).

/\A\{\{\s*([\w.]+)\s*\}\}\z/
TEMPLATE_CACHE_MAX_SIZE =

Maximum size of the per-renderer Liquid::Template parse cache. When exceeded the entire cache is cleared (simple FIFO-like eviction).

512
DATE_1900_EPOCH =

Epoch for Excel 1900 date system (accounts for the leap-year bug). Serial = days since this date. For dates after 1900-02-28 the spurious 1900-02-29 absorbs the one-day offset, producing correct serials for all modern dates. TODO: support 1904 date system if workbookPr/@date1904 is accessible. Currently the Renderer has no access to package/workbook.xml; when date1904 detection is added, switch epoch to Date.new(1904,1,1).

Date.new(1899, 12, 30)
SECONDS_PER_DAY =
86_400.0
DATE_TIME_CLASSES =
[Date, Time].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(worksheet, options = {}) ⇒ Renderer

Returns a new instance of Renderer.



30
31
32
33
34
# File 'lib/liquid_xlsx/renderer.rb', line 30

def initialize(worksheet, options = {})
  @worksheet = worksheet
  @options = options
  @template_cache = {}
end

Instance Attribute Details

#optionsObject (readonly)

rubocop:disable Metrics/ClassLength



9
10
11
# File 'lib/liquid_xlsx/renderer.rb', line 9

def options
  @options
end

#sheet_r_idObject

Returns the value of attribute sheet_r_id.



10
11
12
# File 'lib/liquid_xlsx/renderer.rb', line 10

def sheet_r_id
  @sheet_r_id
end

#worksheetObject (readonly)

rubocop:disable Metrics/ClassLength



9
10
11
# File 'lib/liquid_xlsx/renderer.rb', line 9

def worksheet
  @worksheet
end

Instance Method Details

#render(data, workbook_ops: nil, image_ops: nil, extra_scope: nil) ⇒ String

Render the worksheet with data.

data may be a Hash, a Liquid::Drop, or any object responding to #to_liquid that returns a Hash or a Drop. Plain objects without [] / #key? are not supported because Liquid::Context looks variables up via these methods on the root environment.

extra_scope adds variables that take priority over data (used by dynamic sheets to inject the per-op local variable without merging — which would otherwise require data to be a Hash). It is passed as the second argument of Liquid::Context, the outer scope, which has higher precedence than environments.

Parameters:

  • data (Hash, Liquid::Drop, #to_liquid)

    data to render

  • workbook_ops (Array) (defaults to: nil)

    mutable array to collect sheet % operations

  • image_ops (Array) (defaults to: nil)

    mutable array to collect image_tag % operations

  • extra_scope (Hash{String,Symbol=>Object}) (defaults to: nil)

    extra variables with priority over data (e.g. for dynamic sheets)

Returns:

  • (String)

    XML content of the rendered worksheet



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/liquid_xlsx/renderer.rb', line 55

def render(data, workbook_ops: nil, image_ops: nil, extra_scope: nil)
  rows = worksheet.parse_rows
  return worksheet.to_xml if rows.empty?

  # Quick scan: skip sheet if no Liquid tags at all
  has_tags = rows.any? do |r|
    r[:cells].any? { |c| c[:template] }
  end
  return worksheet.to_xml unless has_tags

  # Build AST
  parser = TemplateParser.new(rows, worksheet.name)
  ast = parser.parse

  strict_vars = @options.fetch(:strict_variables, false)
  strict_filts = @options.fetch(:strict_filters, false)

  # One Liquid::Context per worksheet — shared across all cells.
  # Normalize arbitrary #to_liquid objects (AR models, Struct, …) into a
  # Hash or Drop the context can look variables up in. Hash and Drop pass
  # through unchanged (Drop#to_liquid returns self).
  normalized = normalize_data(data)
  data_hash = stringify_keys(normalized)
  registers = { liquid_xlsx_workbook_ops: workbook_ops,
                liquid_xlsx_image_ops: image_ops || [],
                liquid_xlsx_dynamic_sheets: @options.fetch(:dynamic_sheets, false),
                liquid_xlsx_sheet_name: @worksheet.name,
                liquid_xlsx_sheet_r_id: @sheet_r_id }
  liquid_context = ::Liquid::Context.new(data_hash, {}, registers, true)
  # `extra_scope` is applied AFTER context construction on purpose: passing
  # it as the second (outer_scope) argument would let Liquid's
  # `squash_instance_assigns_with_environments` (invoked at the end of
  # `Context#initialize`) shadow each key with the corresponding value
  # from the environment. For a Drop environment that is fatal, because
  # `Drop#key?` always returns `true`, so unknown methods yield `nil` and
  # overwrite the explicit extra variables. Assigning directly onto the
  # context bypasses squash entirely and still wins lookup priority
  # because scopes are searched before environments.
  if extra_scope
    stringify_keys(extra_scope).each do |k, v|
      liquid_context[k] = v
    end
  end
  liquid_context.strict_variables = strict_vars
  liquid_context.strict_filters = strict_filts
  # Host-application filters, if any. Scoped to this context — nothing is
  # registered on Liquid::Environment, so the host's global config is
  # neither required nor mutated.
  extra_filters = @options[:filters]
  liquid_context.add_filters(extra_filters) if extra_filters
  apply_resource_limits(liquid_context)

  # Get original merge cells
  original_merges = worksheet.merge_cells
  merge_transformer = MergeCellsTransformer.new

  # Render AST to produce new rows + merge info
  formula_translator = FormulaTranslator.new
  state = RenderState.new(merge_transformer, original_merges, formula_translator)

  rendered = render_ast(ast, liquid_context, data_hash, rows, strict_filts, state)

  # Check for merge cells crossing structural block boundaries
  state.check_block_boundaries(worksheet.name)

  # Build merge ranges list
  merge_ranges = state.build_merge_ranges

  # Translate formulas now that all loop expansions are known
  finalize_formulas(rendered, state.formula_translator)

  # Rebuild sheet data (always: an empty result must clear template rows too)
  worksheet.rebuild_sheet_data(rendered, 1)

  # Update dimension
  worksheet.update_dimension(rendered.length)

  # Always update merge cells (clears old ones even if result is empty)
  worksheet.update_merge_cells(merge_ranges)

  # Remove cached formula values if recalculating
  if @options[:recalculate_formulas]
    worksheet.remove_cached_formula_values
  end

  worksheet.to_xml
end