liquid_xlsx 0.2.2 → 0.4.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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ module Validation
5
+ # One variable reference found in a template fragment.
6
+ #
7
+ # `segments` is the lookup chain after the root: `invoice.items.first.name`
8
+ # becomes root "invoice" with segments ["items", "first", "name"].
9
+ #
10
+ # `truncated` marks a path the host application must not check past its last
11
+ # segment: a dynamic lookup (`items[idx].name`) hides what comes next, and
12
+ # reporting "no such field" for it would be a false alarm.
13
+ VariableUse = Struct.new(:root, :segments, :truncated, keyword_init: true) do
14
+ def path
15
+ ([root] + Array(segments)).join(".")
16
+ end
17
+
18
+ def truncated?
19
+ truncated ? true : false
20
+ end
21
+
22
+ def to_h
23
+ { root: root, segments: Array(segments), truncated: truncated?, path: path }
24
+ end
25
+ end
26
+
27
+ # Everything a single template fragment refers to.
28
+ #
29
+ # A fragment is one unit of Liquid the renderer will actually parse: a cell
30
+ # template, a structural `{% for %}` collection or an `{% if %}` condition.
31
+ # `kind` says which, so a host application can phrase its diagnostics in the
32
+ # user's terms ("cell B12" vs "the {% if %} in row 12").
33
+ # `kind` is one of :cell, :condition, :loop.
34
+ Usage = Struct.new(:sheet, :row, :cell, :kind, :source, :variables, :filters,
35
+ :assigns, :locals, keyword_init: true) do
36
+ def variables
37
+ self[:variables] || []
38
+ end
39
+
40
+ def filters
41
+ self[:filters] || []
42
+ end
43
+
44
+ # Names bound by the fragment itself ({% assign %}, {% capture %}).
45
+ def assigns
46
+ self[:assigns] || []
47
+ end
48
+
49
+ # Names bound for the fragment's body ({% for item in ... %}, sheet `as:`).
50
+ def locals
51
+ self[:locals] || []
52
+ end
53
+
54
+ def to_h
55
+ { sheet: sheet, row: row, cell: cell, kind: kind, source: source,
56
+ variables: variables.map(&:to_h), filters: filters,
57
+ assigns: assigns, locals: locals }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ # Checks a template without rendering it and without any data.
5
+ #
6
+ # Why this is not "render and see what happens": the renderer parses only the
7
+ # Liquid it actually executes (Renderer#cached_parse), so a typo inside an
8
+ # {% if %} branch that this document does not take, or inside a loop over an
9
+ # empty collection, stays invisible until the day some other document takes
10
+ # that branch. It also stops at the first failure, and rendering executes the
11
+ # template against live records — printing a document is a side effect nobody
12
+ # asked for when all they did was upload a file.
13
+ #
14
+ # So: parse everything, execute nothing, and report every problem found.
15
+ class Validator
16
+ LIQUID_MARKER = /\{\{|\{%/
17
+
18
+ # @param template_path [String] path to the .xlsx template
19
+ # @param dynamic_sheets [Boolean] whether the caller renders with
20
+ # dynamic_sheets: true — {% sheet %} is an error when it does not
21
+ # @param known_filters [Array<String>, nil] filter names available at render
22
+ # time; nil disables the unknown-filter check
23
+ def initialize(template_path, dynamic_sheets: false, known_filters: nil)
24
+ @template_path = template_path
25
+ @dynamic_sheets = dynamic_sheets
26
+ @known_filters = known_filters&.map(&:to_s)
27
+ @result = Validation::Result.new
28
+ end
29
+
30
+ # @return [Validation::Result]
31
+ def call
32
+ package = open_package
33
+ return @result if package.nil?
34
+
35
+ sheet_names = safe_sheet_names(package)
36
+ @result.sheets.concat(sheet_names)
37
+
38
+ package.sheets.each { |sheet_info| validate_sheet(package, sheet_info) }
39
+
40
+ check_sheet_tags(sheet_names)
41
+ check_filters
42
+
43
+ @result
44
+ rescue Nokogiri::XML::SyntaxError => e
45
+ invalid_xlsx("Corrupt XML inside the workbook: #{e.message}")
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :result
51
+
52
+ def open_package
53
+ package = Package.new(@template_path)
54
+ package.read
55
+ package
56
+ rescue InvalidXlsxError => e
57
+ invalid_xlsx(e.message)
58
+ nil
59
+ rescue Zip::Error, Errno::ENOENT, Errno::EACCES, Errno::EISDIR => e
60
+ invalid_xlsx("#{e.class}: #{e.message}")
61
+ nil
62
+ end
63
+
64
+ def invalid_xlsx(message)
65
+ @result.add(code: :invalid_xlsx, message: "Not a readable .xlsx workbook. #{message}")
66
+ @result
67
+ end
68
+
69
+ # Sheet names are needed for {% sheet template: "X" %}; a workbook broken
70
+ # enough to fail here still gets its parts validated below.
71
+ def safe_sheet_names(package)
72
+ package.sheet_names
73
+ rescue StandardError
74
+ []
75
+ end
76
+
77
+ def validate_sheet(package, sheet_info)
78
+ name = sheet_info[:name]
79
+ xml = worksheet_xml(package, sheet_info)
80
+ return if xml.nil?
81
+
82
+ worksheet = Worksheet.new(xml, name, SharedStrings.new(package.shared_strings_xml))
83
+ rows = worksheet.parse_rows
84
+
85
+ check_formulas(name, rows)
86
+ check_rich_text(name, xml)
87
+
88
+ ast = parse_structure(name, rows)
89
+ # Structure did not parse: the cells are still worth checking, and a flat
90
+ # pass over them is exactly what the caller wants to see next to the
91
+ # structural error.
92
+ return validate_cells_flat(name, rows) if ast.nil?
93
+
94
+ validate_nodes(name, ast)
95
+ check_merges(name, worksheet, ast)
96
+ rescue Nokogiri::XML::SyntaxError => e
97
+ @result.add(code: :invalid_xlsx, sheet: name,
98
+ message: "Corrupt XML in sheet '#{name}': #{e.message}")
99
+ end
100
+
101
+ # A workbook with no readable relationships part makes the lookup itself
102
+ # fail; for the caller that is the same problem as a missing sheet file, and
103
+ # the validator must not propagate it as a crash.
104
+ def worksheet_xml(package, sheet_info)
105
+ xml = begin
106
+ package.worksheet_xml(sheet_info[:r_id])
107
+ rescue StandardError
108
+ nil
109
+ end
110
+ return xml if xml
111
+
112
+ @result.add(code: :missing_worksheet, sheet: sheet_info[:name],
113
+ message: "The workbook lists sheet '#{sheet_info[:name]}' " \
114
+ "(#{sheet_info[:r_id]}) but the file for it is missing from the package.")
115
+ nil
116
+ end
117
+
118
+ # @return [Array<Nodes::Base>, nil] nil when the structure is broken
119
+ def parse_structure(name, rows)
120
+ TemplateParser.new(rows, name).parse
121
+ rescue TemplateSyntaxError => e
122
+ @result.add(code: :template_syntax, sheet: e.sheet || name, row: e.row, cell: e.cell,
123
+ message: e.message.lines.first.to_s.strip, source: e.tag)
124
+ nil
125
+ end
126
+
127
+ # --- fragments ---------------------------------------------------------
128
+
129
+ def validate_nodes(sheet, nodes)
130
+ Array(nodes).each do |node|
131
+ case node
132
+ when Nodes::RowNode then validate_row(sheet, node.row_data)
133
+ when Nodes::ForNode then validate_for(sheet, node)
134
+ when Nodes::IfNode then validate_if(sheet, node)
135
+ end
136
+ end
137
+ end
138
+
139
+ def validate_row(sheet, row)
140
+ row[:cells].each do |cell|
141
+ next unless cell[:template]
142
+
143
+ parse_fragment(sheet: sheet, row: row[:row_number], cell: "#{cell[:col]}#{row[:row_number]}",
144
+ kind: :cell, source: cell[:template])
145
+ end
146
+ end
147
+
148
+ # The renderer looks the collection up by name (Renderer#resolve_for_collection),
149
+ # so validating `{{ name }}` sees exactly what it will see.
150
+ def validate_for(sheet, node)
151
+ usage = parse_fragment(sheet: sheet, row: node.for_row, kind: :loop,
152
+ source: "{% for #{node.variable_name} in #{node.collection_name} %}",
153
+ liquid: "{{ #{node.collection_name} }}")
154
+ if usage
155
+ usage.locals.push(node.variable_name.to_s, "forloop")
156
+ usage.locals.uniq!
157
+ end
158
+
159
+ validate_nodes(sheet, node.body)
160
+ validate_nodes(sheet, node.else_body)
161
+ end
162
+
163
+ # Conditions are wrapped exactly as Renderer#evaluate_condition wraps them:
164
+ # validating a different string than the one that gets executed would check
165
+ # the wrong thing.
166
+ def validate_if(sheet, node)
167
+ node.branches.each do |branch|
168
+ condition = branch[:condition]
169
+ if condition
170
+ parse_fragment(sheet: sheet, row: node.if_row, kind: :condition,
171
+ source: "{% if #{condition} %}",
172
+ liquid: "{% if #{condition} %}true{% endif %}")
173
+ end
174
+
175
+ validate_nodes(sheet, branch[:body])
176
+ end
177
+ end
178
+
179
+ # Cells of a sheet whose structure did not parse.
180
+ def validate_cells_flat(sheet, rows)
181
+ rows.each { |row| validate_row(sheet, row) }
182
+ end
183
+
184
+ # Fragments are parsed twice, in strict mode and in Liquid's default lax
185
+ # mode, because the two modes answer different questions.
186
+ #
187
+ # Lax mode is what the renderer uses, so a fragment it rejects will not
188
+ # print — that is an error. What lax mode accepts, it may still not read the
189
+ # way the author meant: `{{ invoice.total b c }}` and `{{ invoice..total }}`
190
+ # both render the total and quietly drop the rest. Strict mode is the only
191
+ # thing that notices, and the right word for it is a warning, not an error:
192
+ # the document does print, and calling it broken would be false.
193
+ #
194
+ # @return [Validation::Usage, nil] nil when the fragment does not parse
195
+ def parse_fragment(sheet:, kind:, source:, row: nil, cell: nil, liquid: nil)
196
+ text = liquid || source
197
+ template = parse_liquid(text, :strict)
198
+ usage = build_usage(template, sheet: sheet, row: row, cell: cell, kind: kind, source: source)
199
+ @result.add_usage(usage)
200
+ usage
201
+ rescue ::Liquid::SyntaxError => e
202
+ report_syntax_error(e, text: text, sheet: sheet, row: row, cell: cell, source: source)
203
+ nil
204
+ rescue TemplateSyntaxError, LiquidXlsx::Error => e
205
+ # Tag constructors of this gem validate their own arguments at parse time
206
+ # ({% sheet %} without `template:`, for instance).
207
+ @result.add(code: :liquid_syntax, sheet: sheet, row: row, cell: cell,
208
+ message: e.message.lines.first.to_s.strip, source: source)
209
+ nil
210
+ end
211
+
212
+ def parse_liquid(text, error_mode)
213
+ ::Liquid::Template.parse(text, environment: LiquidXlsx.liquid_environment, error_mode: error_mode)
214
+ end
215
+
216
+ # Two different problems wear the same exception, and a user needs to tell
217
+ # them apart: a fragment the renderer will refuse outright (error), and a
218
+ # fragment it will accept while silently ignoring part of what was written
219
+ # (warning).
220
+ def report_syntax_error(error, text:, sheet:, row:, cell:, source:)
221
+ if parses_leniently?(text)
222
+ @result.add(code: :liquid_expression, severity: :warning, sheet: sheet, row: row, cell: cell,
223
+ message: "Liquid does not read this expression the way it is written and will " \
224
+ "ignore part of it: #{error.message}",
225
+ source: source)
226
+ else
227
+ @result.add(code: :liquid_syntax, sheet: sheet, row: row, cell: cell,
228
+ message: error.message, source: source)
229
+ end
230
+ end
231
+
232
+ def parses_leniently?(text)
233
+ parse_liquid(text, :lax)
234
+ true
235
+ rescue ::Liquid::SyntaxError, LiquidXlsx::Error
236
+ false
237
+ end
238
+
239
+ def build_usage(template, sheet:, row:, cell:, kind:, source:)
240
+ collected = Validation::LiquidUsage.call(template.root)
241
+ @sheet_tags = (@sheet_tags || []).concat(collect_sheet_tags(template.root, sheet, row, cell))
242
+
243
+ Validation::Usage.new(
244
+ sheet: sheet, row: row, cell: cell, kind: kind, source: source,
245
+ variables: collected.variables.uniq(&:path),
246
+ filters: collected.filters.uniq,
247
+ assigns: collected.assigns.uniq,
248
+ locals: collected.locals.uniq
249
+ )
250
+ end
251
+
252
+ # --- whole-workbook checks --------------------------------------------
253
+
254
+ def collect_sheet_tags(node, sheet, row, cell)
255
+ found = []
256
+ walk_liquid(node) do |child|
257
+ next unless child.is_a?(Tags::SheetTag)
258
+
259
+ found << { tag: child, sheet: sheet, row: row, cell: cell }
260
+ end
261
+ found
262
+ end
263
+
264
+ def walk_liquid(node, &block)
265
+ yield node
266
+ return unless node.respond_to?(:nodelist) && node.nodelist
267
+
268
+ node.nodelist.each do |child|
269
+ next if child.is_a?(::String)
270
+
271
+ walk_liquid(child, &block)
272
+ end
273
+ end
274
+
275
+ # {% sheet %} needs two things the fragment itself cannot know: the caller
276
+ # must render with dynamic_sheets, and the sheet named in `template:` must
277
+ # exist. Both fail at render time today, long after the template was saved.
278
+ def check_sheet_tags(sheet_names)
279
+ Array(@sheet_tags).each do |entry|
280
+ tag = entry[:tag]
281
+
282
+ unless @dynamic_sheets
283
+ @result.add(code: :sheet_tag_disabled, sheet: entry[:sheet], row: entry[:row], cell: entry[:cell],
284
+ message: "{% sheet %} requires rendering with dynamic_sheets: true.")
285
+ end
286
+
287
+ template_name = tag.template_name
288
+ next if template_name.nil? || sheet_names.empty? || sheet_names.include?(template_name)
289
+
290
+ @result.add(code: :sheet_template_missing, sheet: entry[:sheet], row: entry[:row], cell: entry[:cell],
291
+ message: "{% sheet %} refers to template sheet '#{template_name}', " \
292
+ "which does not exist in this workbook.")
293
+ end
294
+ end
295
+
296
+ def check_filters
297
+ return if @known_filters.nil?
298
+
299
+ @result.usages.each do |usage|
300
+ usage.filters.each do |filter|
301
+ next if @known_filters.include?(filter)
302
+
303
+ @result.add(code: :unknown_filter, sheet: usage.sheet, row: usage.row, cell: usage.cell,
304
+ message: "Unknown filter '#{filter}'.", source: usage.source)
305
+ end
306
+ end
307
+ end
308
+
309
+ # Liquid inside a formula is never evaluated: Worksheet#extract_cell_text
310
+ # keeps formula cells as formulas, so the tag travels into the output file
311
+ # untouched. Silent, and hard to spot in a large sheet.
312
+ def check_formulas(sheet, rows)
313
+ rows.each do |row|
314
+ row[:cells].each do |cell|
315
+ formula = cell[:formula]
316
+ next unless formula&.match?(LIQUID_MARKER)
317
+
318
+ @result.add(code: :liquid_in_formula, severity: :warning, sheet: sheet,
319
+ row: row[:row_number], cell: "#{cell[:col]}#{row[:row_number]}",
320
+ message: "Liquid inside a formula is not evaluated.",
321
+ source: "=#{formula}")
322
+ end
323
+ end
324
+ end
325
+
326
+ # Rich-text inline strings: text split into runs (<is><r><t>) is not read by
327
+ # the renderer, so Liquid written there disappears from the output without
328
+ # any error. Editors produce runs as soon as part of a cell is formatted
329
+ # differently — a bold word inside the cell is enough.
330
+ def check_rich_text(sheet, xml)
331
+ doc = Nokogiri::XML(xml)
332
+ ns = doc.root&.namespace
333
+ return unless ns
334
+
335
+ doc.xpath("//xmlns:c[@t='inlineStr']", "xmlns" => ns.href).each do |cell|
336
+ is_element = cell.at_xpath("xmlns:is")
337
+ next if is_element.nil? || is_element.at_xpath("xmlns:t")
338
+
339
+ text = is_element.xpath(".//xmlns:t").map(&:text).join
340
+ next unless text.match?(LIQUID_MARKER)
341
+
342
+ @result.add(code: :liquid_in_rich_text, severity: :warning, sheet: sheet,
343
+ cell: cell["r"],
344
+ message: "Liquid in a rich-text cell is not read. Retype the cell " \
345
+ "so all of its text has the same formatting.",
346
+ source: text)
347
+ end
348
+ end
349
+
350
+ def check_merges(sheet, worksheet, ast)
351
+ merges = worksheet.merge_cells
352
+ return if merges.empty?
353
+
354
+ blocks = collect_blocks(ast)
355
+ return if blocks.empty?
356
+
357
+ merges.each do |merge|
358
+ blocks.each do |block|
359
+ next unless Validation::BlockBoundary.crosses?(merge[:start_row], merge[:end_row],
360
+ block[:start], block[:end])
361
+
362
+ @result.add(code: :merged_cell_crosses_block, sheet: sheet, row: block[:start],
363
+ message: "Merged cell '#{merge[:ref]}' crosses the boundary of a " \
364
+ "#{block[:type]} block (rows #{block[:start]}-#{block[:end]}).",
365
+ source: merge[:ref])
366
+ end
367
+ end
368
+ end
369
+
370
+ def collect_blocks(nodes, blocks = [])
371
+ Array(nodes).each do |node|
372
+ case node
373
+ when Nodes::ForNode
374
+ blocks << { type: :for, start: node.for_row, end: node.endfor_row } if node.for_row && node.endfor_row
375
+ collect_blocks(node.body, blocks)
376
+ collect_blocks(node.else_body, blocks)
377
+ when Nodes::IfNode
378
+ blocks << { type: :if, start: node.if_row, end: node.endif_row } if node.if_row && node.endif_row
379
+ node.branches.each { |branch| collect_blocks(branch[:body], blocks) }
380
+ end
381
+ end
382
+
383
+ blocks
384
+ end
385
+ end
386
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LiquidXlsx
4
- VERSION = "0.2.2"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -16,6 +16,8 @@ module LiquidXlsx
16
16
  # or any object whose `#to_liquid` returns one)
17
17
  # @return [Package] modified package
18
18
  def render(data)
19
+ return render_streaming(data) if @options[:streaming]
20
+
19
21
  all_image_ops = []
20
22
 
21
23
  if @options[:dynamic_sheets]
@@ -47,6 +49,31 @@ module LiquidXlsx
47
49
 
48
50
  private
49
51
 
52
+ # Preflight every worksheet before executing user data or creating row files.
53
+ def render_streaming(data)
54
+ renderers = package.sheets.map do |sheet_info|
55
+ worksheet = Worksheet.new(worksheet_xml!(sheet_info), sheet_info[:name], @shared_strings)
56
+ renderer = StreamingRenderer.new(worksheet, @options)
57
+ renderer.prepare
58
+ [sheet_info[:r_id], renderer]
59
+ end
60
+ looping = renderers.map(&:last).select(&:loops?)
61
+ if looping.size > 1
62
+ raise StreamingUnsupportedError.new(
63
+ "Only one worksheet may contain loops in streaming mode",
64
+ sheet: looping[1].worksheet.name
65
+ )
66
+ end
67
+ renderers.each do |r_id, renderer|
68
+ package.save_worksheet_xml(r_id, renderer.render(data))
69
+ end
70
+ if @options[:recalculate_formulas]
71
+ package.remove_calc_chain if package.calc_chain_xml
72
+ package.set_recalculation_flags
73
+ end
74
+ package
75
+ end
76
+
50
77
  # Worksheet part behind a <sheet> entry of workbook.xml.
51
78
  #
52
79
  # A workbook that lists a sheet whose part cannot be resolved is broken, and
@@ -303,6 +303,30 @@ module LiquidXlsx
303
303
  @doc.to_xml(indent: 0, encoding: "UTF-8")
304
304
  end
305
305
 
306
+ # Serialize only the small worksheet envelope; rows are supplied from disk.
307
+ def streaming_envelope(total_rows, max_col)
308
+ root = @doc.root
309
+ ns = { "xmlns" => root.namespace.href }
310
+ sheet_data = root.at_xpath("xmlns:sheetData", ns)
311
+ unless sheet_data
312
+ sheet_data = Nokogiri::XML::Node.new("sheetData", @doc)
313
+ self.class.insert_child_in_order(root, sheet_data)
314
+ end
315
+ sheet_data.children.remove
316
+ token = +"liquid_xlsx_rows"
317
+ comments = @doc.xpath("//comment()").map(&:content)
318
+ token << "_" while comments.include?(token)
319
+ marker = Nokogiri::XML::Comment.new(@doc, token)
320
+ sheet_data.add_child(marker)
321
+ dim = root.at_xpath("xmlns:dimension", ns)
322
+ unless dim
323
+ dim = Nokogiri::XML::Node.new("dimension", @doc)
324
+ self.class.insert_child_in_order(root, dim)
325
+ end
326
+ dim["ref"] = "A1:#{max_col}#{[total_rows, 1].max}"
327
+ to_xml.split("<!--#{token}-->", 2)
328
+ end
329
+
306
330
  # Extract row-level attributes to preserve.
307
331
  def row_attributes(row_elem)
308
332
  {
data/lib/liquid_xlsx.rb CHANGED
@@ -15,11 +15,20 @@ require_relative "liquid_xlsx/tags/sheet_tag"
15
15
  require_relative "liquid_xlsx/tags/image_tag"
16
16
  require_relative "liquid_xlsx/image"
17
17
  require_relative "liquid_xlsx/drawing_builder"
18
+ require_relative "liquid_xlsx/file_part"
18
19
  require_relative "liquid_xlsx/package"
19
20
  require_relative "liquid_xlsx/worksheet"
20
21
  require_relative "liquid_xlsx/renderer"
22
+ require_relative "liquid_xlsx/streaming_context"
23
+ require_relative "liquid_xlsx/streaming_renderer"
21
24
  require_relative "liquid_xlsx/workbook"
22
25
  require_relative "liquid_xlsx/template"
26
+ require_relative "liquid_xlsx/validation/issue"
27
+ require_relative "liquid_xlsx/validation/usage"
28
+ require_relative "liquid_xlsx/validation/result"
29
+ require_relative "liquid_xlsx/validation/block_boundary"
30
+ require_relative "liquid_xlsx/validation/liquid_usage"
31
+ require_relative "liquid_xlsx/validator"
23
32
 
24
33
  # LiquidXlsx is a Ruby gem for generating .xlsx files from Excel templates
25
34
  # with Liquid syntax. Edit templates in Excel, write Liquid in cells,
@@ -58,6 +67,7 @@ module LiquidXlsx
58
67
  # Liquid::Drop, or any object whose `#to_liquid` returns a Hash/Drop.
59
68
  # @param strict_variables [Boolean] raise error on missing variables
60
69
  # @param strict_filters [Boolean] raise error on unknown filters
70
+ # @param streaming [Boolean] stream worksheet rows to disk (restricted templates)
61
71
  # @param dynamic_sheets [Boolean] enable {% sheet %} tag for dynamic sheet creation
62
72
  # @param hide_control_sheets [Boolean] hide sheets that contained {% sheet %} tags
63
73
  # @param hide_template_sheets [Boolean] hide template sheets after cloning
@@ -73,7 +83,7 @@ module LiquidXlsx
73
83
  recalculate_formulas: false, remove_template_comments: false,
74
84
  dynamic_sheets: false, hide_control_sheets: true,
75
85
  hide_template_sheets: false, images: nil,
76
- liquid_resource_limits: nil, filters: nil)
86
+ liquid_resource_limits: nil, filters: nil, streaming: false)
77
87
  tpl = Template.new(template, {
78
88
  strict_variables: strict_variables,
79
89
  strict_filters: strict_filters,
@@ -84,10 +94,44 @@ module LiquidXlsx
84
94
  hide_template_sheets: hide_template_sheets,
85
95
  images: images || {},
86
96
  liquid_resource_limits: liquid_resource_limits,
87
- filters: filters
97
+ filters: filters,
98
+ streaming: streaming
88
99
  })
89
100
  # rubocop:enable Metrics/ParameterLists
90
101
  tpl.render_to_file(data, output)
91
102
  end
103
+
104
+ # Check a template without rendering it and without any data.
105
+ #
106
+ # Unlike a trial render, this parses every cell and every branch — including
107
+ # the ones a particular document would not take — collects all problems
108
+ # instead of stopping at the first, and executes nothing, so validating a
109
+ # template has no side effects on the application's records.
110
+ #
111
+ # result = LiquidXlsx.validate(template: "invoice.xlsx",
112
+ # known_filters: MyFilters.public_instance_methods(false))
113
+ # result.valid? # => false
114
+ # result.issues # => [#<Issue code: :liquid_syntax, sheet: "Sheet1", cell: "B12", ...>]
115
+ # result.roots # => ["invoice", "customer"] — what the template expects as input
116
+ #
117
+ # @param template [String] path to the .xlsx template
118
+ # @param dynamic_sheets [Boolean] pass true if you render with dynamic_sheets:
119
+ # true; otherwise {% sheet %} in the template is reported as an error,
120
+ # because that is what rendering it would do
121
+ # @param known_filters [Array<String, Symbol>, nil] filter names available at
122
+ # render time (standard Liquid filters are included automatically); nil
123
+ # disables the unknown-filter check
124
+ # @return [Validation::Result]
125
+ def validate(template:, dynamic_sheets: false, known_filters: nil)
126
+ Validator.new(template,
127
+ dynamic_sheets: dynamic_sheets,
128
+ known_filters: known_filters && (known_filters.map(&:to_s) + standard_filters))
129
+ .call
130
+ end
131
+
132
+ # Filters every Liquid template can use without the host registering anything.
133
+ def standard_filters
134
+ @standard_filters ||= ::Liquid::StandardFilters.public_instance_methods(false).map(&:to_s)
135
+ end
92
136
  end
93
137
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: liquid_xlsx
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ivan Khlipitkin
@@ -100,6 +100,7 @@ files:
100
100
  - lib/liquid_xlsx/cell_reference.rb
101
101
  - lib/liquid_xlsx/drawing_builder.rb
102
102
  - lib/liquid_xlsx/errors.rb
103
+ - lib/liquid_xlsx/file_part.rb
103
104
  - lib/liquid_xlsx/filters.rb
104
105
  - lib/liquid_xlsx/formula_translator.rb
105
106
  - lib/liquid_xlsx/image.rb
@@ -107,11 +108,19 @@ files:
107
108
  - lib/liquid_xlsx/package.rb
108
109
  - lib/liquid_xlsx/renderer.rb
109
110
  - lib/liquid_xlsx/shared_strings.rb
111
+ - lib/liquid_xlsx/streaming_context.rb
112
+ - lib/liquid_xlsx/streaming_renderer.rb
110
113
  - lib/liquid_xlsx/tags/image_tag.rb
111
114
  - lib/liquid_xlsx/tags/sheet_tag.rb
112
115
  - lib/liquid_xlsx/template.rb
113
116
  - lib/liquid_xlsx/template_nodes.rb
114
117
  - lib/liquid_xlsx/template_parser.rb
118
+ - lib/liquid_xlsx/validation/block_boundary.rb
119
+ - lib/liquid_xlsx/validation/issue.rb
120
+ - lib/liquid_xlsx/validation/liquid_usage.rb
121
+ - lib/liquid_xlsx/validation/result.rb
122
+ - lib/liquid_xlsx/validation/usage.rb
123
+ - lib/liquid_xlsx/validator.rb
115
124
  - lib/liquid_xlsx/version.rb
116
125
  - lib/liquid_xlsx/workbook.rb
117
126
  - lib/liquid_xlsx/worksheet.rb