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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +146 -4
- data/lib/liquid_xlsx/errors.rb +2 -0
- data/lib/liquid_xlsx/file_part.rb +32 -0
- data/lib/liquid_xlsx/package.rb +18 -1
- data/lib/liquid_xlsx/renderer.rb +3 -11
- data/lib/liquid_xlsx/streaming_context.rb +34 -0
- data/lib/liquid_xlsx/streaming_renderer.rb +300 -0
- data/lib/liquid_xlsx/tags/sheet_tag.rb +4 -0
- data/lib/liquid_xlsx/template.rb +13 -1
- data/lib/liquid_xlsx/validation/block_boundary.rb +30 -0
- data/lib/liquid_xlsx/validation/issue.rb +36 -0
- data/lib/liquid_xlsx/validation/liquid_usage.rb +149 -0
- data/lib/liquid_xlsx/validation/result.rb +80 -0
- data/lib/liquid_xlsx/validation/usage.rb +61 -0
- data/lib/liquid_xlsx/validator.rb +386 -0
- data/lib/liquid_xlsx/version.rb +1 -1
- data/lib/liquid_xlsx/workbook.rb +27 -0
- data/lib/liquid_xlsx/worksheet.rb +24 -0
- data/lib/liquid_xlsx.rb +46 -2
- metadata +10 -1
|
@@ -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
|
data/lib/liquid_xlsx/version.rb
CHANGED
data/lib/liquid_xlsx/workbook.rb
CHANGED
|
@@ -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.
|
|
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
|