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,436 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ # Represents and processes a single worksheet.
5
+ class Worksheet
6
+ attr_reader :name, :sheet_xml, :shared_strings
7
+
8
+ # Element order of CT_Worksheet children per the OOXML schema.
9
+ CT_WORKSHEET_ORDER = %w[
10
+ sheetPr dimension sheetViews sheetFormatPr cols sheetData sheetCalcPr
11
+ sheetProtection protectedRanges scenarios autoFilter sortState
12
+ dataConsolidate customSheetViews mergeCells phoneticPr
13
+ conditionalFormatting dataValidations hyperlinks printOptions pageMargins
14
+ pageSetup headerFooter rowBreaks colBreaks customProperties cellWatches
15
+ ignoredErrors smartTags drawing drawingHF picture oleObjects controls
16
+ webPublishItems tableParts extLst
17
+ ].freeze
18
+
19
+ # Excel's hard limit on cell text length.
20
+ EXCEL_MAX_CELL_LENGTH = 32_767
21
+
22
+ # Characters invalid in XML 1.0 (except TAB, LF, CR which are legal).
23
+ ILLEGAL_XML_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F]/
24
+
25
+ # Make a string safe to embed in worksheet XML: valid UTF-8, no illegal
26
+ # control characters, within Excel's cell length limit.
27
+ def self.sanitize_text(value)
28
+ s = value.to_s
29
+ s = if s.encoding == Encoding::UTF_8
30
+ s.valid_encoding? ? s : s.scrub("\u{FFFD}")
31
+ else
32
+ s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\u{FFFD}")
33
+ end
34
+ s = s.gsub(ILLEGAL_XML_CHARS, "")
35
+ s = s[0, EXCEL_MAX_CELL_LENGTH] if s.length > EXCEL_MAX_CELL_LENGTH
36
+ s
37
+ end
38
+
39
+ # Insert a child element into a worksheet root at its schema-mandated
40
+ # position (before the first existing element that must come after it).
41
+ def self.insert_child_in_order(root, node)
42
+ idx = CT_WORKSHEET_ORDER.index(node.name)
43
+ anchor = idx && root.element_children.find do |child|
44
+ ci = CT_WORKSHEET_ORDER.index(child.name)
45
+ ci && ci > idx
46
+ end
47
+ anchor ? anchor.add_previous_sibling(node) : root.add_child(node)
48
+ node
49
+ end
50
+
51
+ def initialize(xml, name, shared_strings)
52
+ @original_xml = xml
53
+ @name = name
54
+ @shared_strings = shared_strings
55
+ @doc = Nokogiri::XML(xml)
56
+ end
57
+
58
+ # Parse rows from the worksheet.
59
+ # @return [Array<Hash>] rows with :row_number, :row_xml, :cells, :row_attrs
60
+ def parse_rows
61
+ rows = []
62
+ ns = @doc.root&.namespace
63
+ return rows unless ns
64
+
65
+ collect_shared_formula_masters(ns)
66
+
67
+ last_row_num = 0
68
+ @doc.xpath("//xmlns:sheetData/xmlns:row", "xmlns" => ns.href).each do |row_elem|
69
+ row_num = row_elem["r"]&.to_i
70
+ row_num = last_row_num + 1 if row_num.nil? || row_num < 1
71
+ last_row_num = row_num
72
+ cells = parse_cells(row_elem, row_num)
73
+ rows << {
74
+ row_number: row_num,
75
+ row_xml: row_elem.to_xml(indent: 0),
76
+ cells: cells,
77
+ row_attrs: row_attributes(row_elem)
78
+ }
79
+ end
80
+ rows
81
+ end
82
+
83
+ # Get merge cells from the worksheet.
84
+ # @return [Array<Hash>] each: {ref: "A3:B3", start_row:, end_row:}
85
+ def merge_cells
86
+ return [] unless merge_elem
87
+
88
+ merge_elem["count"]&.to_i
89
+ merge_elem.xpath("xmlns:mergeCell").map do |mc|
90
+ ref = mc["ref"]
91
+ parts = ref.split(":")
92
+ start = CellReference.new(parts[0])
93
+ end_ref = CellReference.new(parts[1] || parts[0])
94
+ {
95
+ ref: ref,
96
+ start_row: start.row,
97
+ end_row: end_ref.row
98
+ }
99
+ end
100
+ end
101
+
102
+ # Get the mergeCells element.
103
+ # @return [Nokogiri::XML::Element, nil]
104
+ def merge_elem
105
+ ns = @doc.root&.namespace
106
+ return nil unless ns
107
+
108
+ @doc.at_xpath("//xmlns:mergeCells", "xmlns" => ns.href)
109
+ end
110
+
111
+ # Rebuild sheetData with rendered rows.
112
+ # @param rendered_rows [Array<Hash>] array of {cells:, original_row_num:, row_attrs:}
113
+ # @param start_row [Integer] first row number in the output
114
+ def rebuild_sheet_data(rendered_rows, start_row = 1)
115
+ ns = @doc.root&.namespace
116
+ return unless ns
117
+
118
+ sheet_data = @doc.at_xpath("//xmlns:sheetData", "xmlns" => ns.href)
119
+ unless sheet_data
120
+ sheet_data = Nokogiri::XML::Node.new("sheetData", @doc)
121
+ @doc.root.add_child(sheet_data)
122
+ end
123
+
124
+ # Remove all existing rows
125
+ sheet_data.xpath("xmlns:row").each(&:remove)
126
+
127
+ current_row_num = start_row
128
+ sheet_data.namespace
129
+
130
+ rendered_rows.each do |row_info|
131
+ next if row_info.nil?
132
+
133
+ row_elem = Nokogiri::XML::Node.new("row", @doc)
134
+ row_elem["r"] = current_row_num.to_s
135
+
136
+ # Restore original row attributes (height, hidden, etc.)
137
+ apply_row_attributes(row_elem, row_info[:row_attrs])
138
+
139
+ (row_info[:cells] || []).each do |cell_data|
140
+ next unless cell_data
141
+
142
+ row_elem.add_child(build_cell(cell_data, current_row_num))
143
+ end
144
+
145
+ sheet_data.add_child(row_elem)
146
+ current_row_num += 1
147
+ end
148
+ end
149
+
150
+ # Build a single <c> element for a rendered cell.
151
+ def build_cell(cell_data, current_row_num)
152
+ c = Nokogiri::XML::Node.new("c", @doc)
153
+ c["r"] = "#{cell_data[:col]}#{current_row_num}"
154
+ c["s"] = cell_data[:style].to_s if cell_data[:style]
155
+
156
+ if cell_data[:formula]
157
+ # Only override type if original explicitly had t="str" (not for numeric formulas)
158
+ c["t"] = "str" if cell_data[:original_type] == "str"
159
+ f = Nokogiri::XML::Node.new("f", @doc)
160
+ (cell_data[:formula_attrs] || {}).each { |k, val| f[k] = val if val }
161
+ f.content = cell_data[:formula]
162
+ c.add_child(f)
163
+ elsif cell_data[:inline_str]
164
+ add_inline_str(c, cell_data[:rendered_value])
165
+ elsif cell_data[:type] == "s" && cell_data[:text]
166
+ # Shared string reference — keep as is
167
+ c["t"] = "s"
168
+ v_el = Nokogiri::XML::Node.new("v", @doc)
169
+ v_el.content = cell_data[:value].to_s if cell_data[:value]
170
+ c.add_child(v_el)
171
+ elsif cell_data[:rendered_value].is_a?(Numeric)
172
+ v_el = Nokogiri::XML::Node.new("v", @doc)
173
+ v_el.content = cell_data[:rendered_value].to_s
174
+ c.add_child(v_el)
175
+ elsif cell_data[:rendered_value].is_a?(TrueClass) || cell_data[:rendered_value].is_a?(FalseClass)
176
+ c["t"] = "b"
177
+ v_el = Nokogiri::XML::Node.new("v", @doc)
178
+ v_el.content = cell_data[:rendered_value] ? "1" : "0"
179
+ c.add_child(v_el)
180
+ elsif cell_data[:original_value] && !%w[s inlineStr].include?(cell_data[:type])
181
+ # Non-template value cell (numeric, boolean, raw) — preserve as-is
182
+ c["t"] = cell_data[:type] if cell_data[:type]
183
+ v_el = Nokogiri::XML::Node.new("v", @doc)
184
+ v_el.content = cell_data[:original_value].to_s
185
+ c.add_child(v_el)
186
+ else
187
+ # String — use inlineStr
188
+ add_inline_str(c, cell_data[:rendered_value])
189
+ end
190
+
191
+ c
192
+ end
193
+
194
+ # Append <is><t>...</t></is> to a cell element.
195
+ def add_inline_str(c, value)
196
+ c["t"] = "inlineStr"
197
+ is_el = Nokogiri::XML::Node.new("is", @doc)
198
+ c.add_child(is_el)
199
+ is_el.add_child(build_inline_text(value))
200
+ end
201
+
202
+ # Update the dimension element.
203
+ # @param total_rows [Integer]
204
+ def update_dimension(total_rows)
205
+ ns = @doc.root&.namespace
206
+ return unless ns
207
+
208
+ dim = @doc.at_xpath("//xmlns:dimension", "xmlns" => ns.href)
209
+ unless dim
210
+ dim = Nokogiri::XML::Node.new("dimension", @doc)
211
+ @doc.root.children.first&.add_previous_sibling(dim)
212
+ end
213
+
214
+ # Find max column using column index (not string comparison)
215
+ max_col = "A"
216
+ max_col_idx = 0
217
+ @doc.xpath("//xmlns:c").each do |c|
218
+ ref = c["r"]
219
+ col = ref&.gsub(/\d+/, "")
220
+ next unless col
221
+
222
+ col_idx = CellReference.col_to_index(col)
223
+ if col_idx > max_col_idx
224
+ max_col_idx = col_idx
225
+ max_col = col
226
+ end
227
+ end
228
+
229
+ total_rows = 1 if total_rows < 1
230
+ dim["ref"] = "A1:#{max_col}#{total_rows}"
231
+ dim
232
+ end
233
+
234
+ # Build a sanitized <t> element for inline strings, preserving significant
235
+ # whitespace via xml:space when needed.
236
+ def build_inline_text(value)
237
+ t_el = Nokogiri::XML::Node.new("t", @doc)
238
+ text = self.class.sanitize_text(value)
239
+ t_el.content = text
240
+ t_el["xml:space"] = "preserve" if text.match?(/\A\s|\s\z/) || text.include?("\n")
241
+ t_el
242
+ end
243
+
244
+ # Update merged cells with new ranges.
245
+ # @param merge_ranges [Array<String>] e.g. ["A3:B3", "A4:B4"]
246
+ def update_merge_cells(merge_ranges)
247
+ ns = @doc.root&.namespace
248
+ return unless ns
249
+
250
+ # Remove existing mergeCells
251
+ existing = @doc.at_xpath("//xmlns:mergeCells", "xmlns" => ns.href)
252
+ existing&.remove
253
+
254
+ return if merge_ranges.empty?
255
+
256
+ mc_elem = Nokogiri::XML::Node.new("mergeCells", @doc)
257
+ mc_elem["count"] = merge_ranges.length.to_s
258
+
259
+ merge_ranges.each do |ref|
260
+ mc = Nokogiri::XML::Node.new("mergeCell", @doc)
261
+ mc["ref"] = ref
262
+ mc_elem.add_child(mc)
263
+ end
264
+
265
+ # Insert at the schema-mandated position within CT_Worksheet
266
+ self.class.insert_child_in_order(@doc.root, mc_elem)
267
+ end
268
+
269
+ # Remove calc chain cached values from formula cells.
270
+ def remove_cached_formula_values
271
+ @doc.xpath("//xmlns:f").each do |f|
272
+ # Remove sibling <v> elements
273
+ parent = f.parent
274
+ parent.xpath("xmlns:v").each(&:remove)
275
+ end
276
+ end
277
+
278
+ # Serialize back to XML string.
279
+ # @return [String]
280
+ def to_xml
281
+ @doc.to_xml(indent: 0, encoding: "UTF-8")
282
+ end
283
+
284
+ # Extract row-level attributes to preserve.
285
+ def row_attributes(row_elem)
286
+ {
287
+ s: row_elem["s"],
288
+ ht: row_elem["ht"],
289
+ customHeight: row_elem["customHeight"],
290
+ hidden: row_elem["hidden"],
291
+ outlineLevel: row_elem["outlineLevel"],
292
+ customFormat: row_elem["customFormat"],
293
+ spans: row_elem["spans"],
294
+ thickTop: row_elem["thickTop"],
295
+ thickBot: row_elem["thickBot"]
296
+ }
297
+ end
298
+
299
+ # Apply preserved row-level attributes to a rebuilt row element.
300
+ def apply_row_attributes(row_elem, attrs)
301
+ return unless attrs
302
+
303
+ row_elem["s"] = attrs[:s] if attrs[:s]
304
+ row_elem["ht"] = attrs[:ht] if attrs[:ht]
305
+ row_elem["customHeight"] = attrs[:customHeight] if attrs[:customHeight]
306
+ row_elem["hidden"] = attrs[:hidden] if attrs[:hidden]
307
+ row_elem["outlineLevel"] = attrs[:outlineLevel] if attrs[:outlineLevel]
308
+ row_elem["customFormat"] = attrs[:customFormat] if attrs[:customFormat]
309
+ row_elem["spans"] = attrs[:spans] if attrs[:spans]
310
+ row_elem["thickTop"] = attrs[:thickTop] if attrs[:thickTop]
311
+ row_elem["thickBot"] = attrs[:thickBot] if attrs[:thickBot]
312
+ end
313
+
314
+ private
315
+
316
+ # Collect master formulas of shared formula groups (si => formula + origin)
317
+ # so follower cells (<f t="shared" si="N"/> without text) can be
318
+ # materialized into plain formulas.
319
+ def collect_shared_formula_masters(ns)
320
+ @shared_formula_masters = {}
321
+ @doc.xpath("//xmlns:f[@t='shared']", "xmlns" => ns.href).each do |f|
322
+ si = f["si"]
323
+ next unless si
324
+ next if f.text.to_s.empty?
325
+ next if @shared_formula_masters.key?(si)
326
+
327
+ ref = f.parent["r"]
328
+ next unless ref
329
+
330
+ cr = CellReference.new(ref)
331
+ @shared_formula_masters[si] = {
332
+ formula: f.text,
333
+ row: cr.row,
334
+ col_idx: CellReference.col_to_index(cr.col)
335
+ }
336
+ end
337
+ end
338
+
339
+ # Resolve the formula text and extra <f> attributes for a cell.
340
+ # Shared formulas are materialized into plain per-cell formulas; array
341
+ # formulas keep their t/ref attributes.
342
+ def resolve_formula(formula_el, col, row_num)
343
+ return [nil, nil] unless formula_el
344
+
345
+ t = formula_el["t"]
346
+ text = formula_el.text
347
+ text, attrs = case t
348
+ when "shared"
349
+ if text.to_s.empty?
350
+ master = (@shared_formula_masters || {})[formula_el["si"]]
351
+ if master
352
+ row_delta = row_num - master[:row]
353
+ col_delta = CellReference.col_to_index(col) - master[:col_idx]
354
+ text = FormulaTranslator.new.translate(master[:formula], row_delta, col_delta)
355
+ end
356
+ end
357
+ [text, nil]
358
+ when "array"
359
+ [text, { "t" => "array", "ref" => formula_el["ref"] }]
360
+ when nil
361
+ [text, nil]
362
+ else
363
+ [text, { "t" => t }]
364
+ end
365
+ text = nil if text.to_s.empty?
366
+ [text, attrs]
367
+ end
368
+
369
+ def parse_cells(row_elem, row_num = nil)
370
+ cells = []
371
+ row_elem.namespace
372
+ row_num ||= row_elem["r"].to_i
373
+
374
+ next_col_idx = 0
375
+ row_elem.xpath("xmlns:c").each do |c|
376
+ ref = c["r"]
377
+ col = ref&.gsub(/\d+/, "")
378
+ # Cells without an r attribute are legal: derive column from position
379
+ col = CellReference.index_to_col(next_col_idx) if col.nil? || col.empty?
380
+ next_col_idx = CellReference.col_to_index(col) + 1
381
+ type = c["t"]
382
+ style = c["s"]
383
+ formula = c.at_xpath("xmlns:f")
384
+ v = c.at_xpath("xmlns:v")
385
+
386
+ text, template = extract_cell_text(c, type, v, formula)
387
+ formula_text, formula_attrs = resolve_formula(formula, col, row_num)
388
+
389
+ cells << {
390
+ col: col,
391
+ text: text,
392
+ template: template,
393
+ formula: formula_text,
394
+ formula_attrs: formula_attrs,
395
+ style: style,
396
+ type: type,
397
+ value: v&.text,
398
+ raw_cell: c
399
+ }
400
+ end
401
+
402
+ cells
403
+ end
404
+
405
+ # Determine the visible text (and Liquid template, if any) of a cell.
406
+ # @return [Array(String, String)] [text, template] (both may be nil)
407
+ def extract_cell_text(c, type, v, formula)
408
+ text = nil
409
+ template = nil
410
+
411
+ if type == "s" && v
412
+ idx = v.text.to_i
413
+ text = @shared_strings[idx]
414
+ template = text if text && (text.include?("{{") || text.include?("{%"))
415
+ elsif type == "inlineStr"
416
+ is_el = c.at_xpath("xmlns:is")
417
+ t_el = is_el&.at_xpath("xmlns:t")
418
+ text = t_el&.text || ""
419
+ template = text if text.include?("{{") || text.include?("{%")
420
+ elsif formula
421
+ # Formula cell — keep the formula
422
+ text = nil
423
+ elsif type == "b" && v
424
+ # Boolean cell
425
+ text = v.text == "1" ? "TRUE" : "FALSE"
426
+ elsif v
427
+ text = v.text
428
+ end
429
+
430
+ # Shared string reference without sharedStrings table
431
+ text = "" if type == "s" && !v
432
+
433
+ [text, template]
434
+ end
435
+ end
436
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "liquid"
4
+
5
+ require_relative "liquid_xlsx/version"
6
+ require_relative "liquid_xlsx/errors"
7
+ require_relative "liquid_xlsx/cell_reference"
8
+ require_relative "liquid_xlsx/shared_strings"
9
+ require_relative "liquid_xlsx/template_nodes"
10
+ require_relative "liquid_xlsx/template_parser"
11
+ require_relative "liquid_xlsx/formula_translator"
12
+ require_relative "liquid_xlsx/merge_cells_transformer"
13
+ require_relative "liquid_xlsx/filters"
14
+ require_relative "liquid_xlsx/tags/sheet_tag"
15
+ require_relative "liquid_xlsx/tags/image_tag"
16
+ require_relative "liquid_xlsx/image"
17
+ require_relative "liquid_xlsx/drawing_builder"
18
+ require_relative "liquid_xlsx/package"
19
+ require_relative "liquid_xlsx/worksheet"
20
+ require_relative "liquid_xlsx/renderer"
21
+ require_relative "liquid_xlsx/workbook"
22
+ require_relative "liquid_xlsx/template"
23
+
24
+ # LiquidXlsx is a Ruby gem for generating .xlsx files from Excel templates
25
+ # with Liquid syntax. Edit templates in Excel, write Liquid in cells,
26
+ # and render the final file preserving styles, formulas, and merged cells.
27
+ module LiquidXlsx
28
+ class << self
29
+ # Dedicated Liquid environment with the gem's custom tags. Using a scoped
30
+ # environment (instead of registering tags on Liquid::Environment.default)
31
+ # keeps the host application's Liquid configuration untouched.
32
+ def liquid_environment
33
+ @liquid_environment ||= Liquid::Environment.build do |env|
34
+ env.register_tag("sheet", Tags::SheetTag)
35
+ env.register_tag("image_tag", Tags::ImageTag)
36
+ end
37
+ end
38
+
39
+ # High-level API: render a template with data and save to output.
40
+ #
41
+ # @example
42
+ # LiquidXlsx.render(
43
+ # template: "invoice_template.xlsx",
44
+ # output: "invoice.xlsx",
45
+ # data: {
46
+ # invoice: { number: "INV-001", paid: true },
47
+ # customer: { name: "ООО Ромашка" },
48
+ # items: [
49
+ # { title: "Разработка", qty: 10, price: 100 },
50
+ # { title: "Поддержка", qty: 5, price: 50 }
51
+ # ]
52
+ # }
53
+ # )
54
+ #
55
+ # @param template [String] path to .xlsx template file
56
+ # @param output [String] path to output .xlsx file
57
+ # @param data [Hash, Liquid::Drop, #to_liquid] data to render. A Hash, a
58
+ # Liquid::Drop, or any object whose `#to_liquid` returns a Hash/Drop.
59
+ # @param strict_variables [Boolean] raise error on missing variables
60
+ # @param strict_filters [Boolean] raise error on unknown filters
61
+ # @param dynamic_sheets [Boolean] enable {% sheet %} tag for dynamic sheet creation
62
+ # @param hide_control_sheets [Boolean] hide sheets that contained {% sheet %} tags
63
+ # @param hide_template_sheets [Boolean] hide template sheets after cloning
64
+ # @param images [Hash] image rendering options: { loader:, default_dpi: }
65
+ # @param liquid_resource_limits [Hash, nil] optional Liquid resource limits:
66
+ # { render_length_limit:, render_score_limit:, assign_score_limit: }
67
+ # rubocop:disable Metrics/ParameterLists
68
+ def render(template:, output:, data:,
69
+ strict_variables: false, strict_filters: false,
70
+ recalculate_formulas: false, remove_template_comments: false,
71
+ dynamic_sheets: false, hide_control_sheets: true,
72
+ hide_template_sheets: false, images: nil,
73
+ liquid_resource_limits: nil)
74
+ tpl = Template.new(template, {
75
+ strict_variables: strict_variables,
76
+ strict_filters: strict_filters,
77
+ recalculate_formulas: recalculate_formulas,
78
+ remove_template_comments: remove_template_comments,
79
+ dynamic_sheets: dynamic_sheets,
80
+ hide_control_sheets: hide_control_sheets,
81
+ hide_template_sheets: hide_template_sheets,
82
+ images: images || {},
83
+ liquid_resource_limits: liquid_resource_limits
84
+ })
85
+ # rubocop:enable Metrics/ParameterLists
86
+ tpl.render_to_file(data, output)
87
+ end
88
+ end
89
+ end
metadata ADDED
@@ -0,0 +1,142 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: liquid_xlsx
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ivan Khlipitkin
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: liquid
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '5.4'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '6'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '5.4'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '6'
46
+ - !ruby/object:Gem::Dependency
47
+ name: nokogiri
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '1.14'
53
+ - - "<"
54
+ - !ruby/object:Gem::Version
55
+ version: '2'
56
+ type: :runtime
57
+ prerelease: false
58
+ version_requirements: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '1.14'
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: '2'
66
+ - !ruby/object:Gem::Dependency
67
+ name: rubyzip
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '2.3'
73
+ - - "<"
74
+ - !ruby/object:Gem::Version
75
+ version: '4'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '2.3'
83
+ - - "<"
84
+ - !ruby/object:Gem::Version
85
+ version: '4'
86
+ description: A Ruby library for generating .xlsx files from Excel templates using
87
+ Liquid template syntax. Edit your template in Excel, write {{ variables }} and {%
88
+ for %} loops in cells, and render the final file preserving styles, formulas, and
89
+ merged cells.
90
+ email:
91
+ - ivan.khlipitkin@gmail.com
92
+ executables: []
93
+ extensions: []
94
+ extra_rdoc_files: []
95
+ files:
96
+ - CHANGELOG.md
97
+ - LICENSE.txt
98
+ - README.md
99
+ - lib/liquid_xlsx.rb
100
+ - lib/liquid_xlsx/cell_reference.rb
101
+ - lib/liquid_xlsx/drawing_builder.rb
102
+ - lib/liquid_xlsx/errors.rb
103
+ - lib/liquid_xlsx/filters.rb
104
+ - lib/liquid_xlsx/formula_translator.rb
105
+ - lib/liquid_xlsx/image.rb
106
+ - lib/liquid_xlsx/merge_cells_transformer.rb
107
+ - lib/liquid_xlsx/package.rb
108
+ - lib/liquid_xlsx/renderer.rb
109
+ - lib/liquid_xlsx/shared_strings.rb
110
+ - lib/liquid_xlsx/tags/image_tag.rb
111
+ - lib/liquid_xlsx/tags/sheet_tag.rb
112
+ - lib/liquid_xlsx/template.rb
113
+ - lib/liquid_xlsx/template_nodes.rb
114
+ - lib/liquid_xlsx/template_parser.rb
115
+ - lib/liquid_xlsx/version.rb
116
+ - lib/liquid_xlsx/workbook.rb
117
+ - lib/liquid_xlsx/worksheet.rb
118
+ homepage: https://rubygems.org/gems/liquid_xlsx
119
+ licenses:
120
+ - MIT
121
+ metadata:
122
+ homepage_uri: https://rubygems.org/gems/liquid_xlsx
123
+ documentation_uri: https://rubydoc.info/gems/liquid_xlsx
124
+ rubygems_mfa_required: 'true'
125
+ rdoc_options: []
126
+ require_paths:
127
+ - lib
128
+ required_ruby_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '3.1'
133
+ required_rubygems_version: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ requirements: []
139
+ rubygems_version: 3.6.9
140
+ specification_version: 4
141
+ summary: Generate .xlsx files from Excel templates with Liquid syntax
142
+ test_files: []