liquid_xlsx 0.2.2 → 0.3.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 +42 -0
- data/README.md +68 -0
- data/lib/liquid_xlsx/renderer.rb +3 -11
- data/lib/liquid_xlsx/tags/sheet_tag.rb +4 -0
- 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.rb +39 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 77859fcf54ec602752b61cb982570912a6da4acfe35da51bf159a6afa641e552
|
|
4
|
+
data.tar.gz: adf22d59468df31118460448d0e6f4c84b3f2a16facc4cb5ff849ef652202ecc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7a6de15d7778d554d86582f64e641d9dd328ef743adc4dba868e06c936b39eaed6093fca1ca533ebb5745856304710013a11d61af41b80df3b40ed5a56e08109
|
|
7
|
+
data.tar.gz: 76e95b63732e91ef4202efc71d80b1f791d84108d1857c9eac9d74ea3f0695d65ef7591ff7c4dcf28cf92d89c3b96caca81089de997a4fcf4ada199346532f3d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-08-14)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `LiquidXlsx.validate(template:)` — check a template without rendering it and
|
|
8
|
+
without any data. Returns a `Validation::Result` listing every problem found
|
|
9
|
+
instead of raising on the first one.
|
|
10
|
+
|
|
11
|
+
This is deliberately not "render it and see what happens". The renderer parses
|
|
12
|
+
only the Liquid it actually executes, so a mistake inside an `{% if %}` branch
|
|
13
|
+
the current document does not take, or inside a loop over an empty collection,
|
|
14
|
+
stays invisible until some other document hits it. Rendering also stops at the
|
|
15
|
+
first failure and runs the template against live records — a heavy price for
|
|
16
|
+
the question "is the file I just uploaded any good?".
|
|
17
|
+
|
|
18
|
+
Issue codes: `:invalid_xlsx`, `:missing_worksheet`, `:template_syntax`,
|
|
19
|
+
`:liquid_syntax`, `:liquid_expression` (warning), `:unknown_filter`,
|
|
20
|
+
`:sheet_tag_disabled`, `:sheet_template_missing`,
|
|
21
|
+
`:merged_cell_crosses_block`, `:liquid_in_formula` (warning),
|
|
22
|
+
`:liquid_in_rich_text` (warning). Each carries the sheet, row and cell it came
|
|
23
|
+
from, plus a stable `code` a host application can map to its own wording.
|
|
24
|
+
|
|
25
|
+
Beyond issues, the result reports what the template *uses*: `roots` (the input
|
|
26
|
+
variables it expects, with loop variables and `{% assign %}` names excluded),
|
|
27
|
+
full `variables` paths, and `filters` — so a host application can check them
|
|
28
|
+
against its own data model without parsing Liquid itself.
|
|
29
|
+
|
|
30
|
+
- Two silent losses are now reported, both of which used to produce a document
|
|
31
|
+
with tags left in it and no error anywhere:
|
|
32
|
+
- Liquid inside a formula is never evaluated (`:liquid_in_formula`).
|
|
33
|
+
- Liquid inside a rich-text inline string — text split into runs because part
|
|
34
|
+
of the cell is formatted differently — is not read (`:liquid_in_rich_text`).
|
|
35
|
+
|
|
36
|
+
- Expressions Liquid silently misreads are reported as warnings
|
|
37
|
+
(`:liquid_expression`): `{{ invoice.total b c }}` and `{{ invoice..total }}`
|
|
38
|
+
print the total and quietly drop the rest. Liquid's default lax parser accepts
|
|
39
|
+
them, so nothing fails — which is exactly why they are worth pointing out.
|
|
40
|
+
Only fragments the renderer would actually reject are errors.
|
|
41
|
+
|
|
42
|
+
- `Tags::SheetTag#template_name` / `#as_name` are now public, so the sheet a
|
|
43
|
+
`{% sheet %}` clones can be checked without rendering the tag.
|
|
44
|
+
|
|
3
45
|
## 0.2.2 (2026-08-14)
|
|
4
46
|
|
|
5
47
|
### Fixed
|
data/README.md
CHANGED
|
@@ -45,6 +45,8 @@ Object API: `LiquidXlsx::Template.new(path).render_to_file(data, out)`, or
|
|
|
45
45
|
- `{% sheet %}` — generate whole worksheets from data
|
|
46
46
|
- `{% image_tag %}` — embed PNG / JPEG / GIF
|
|
47
47
|
- Errors report the sheet, row and cell they came from
|
|
48
|
+
- `LiquidXlsx.validate` — check a template without data: every problem at once,
|
|
49
|
+
including the branches a trial render would never reach
|
|
48
50
|
|
|
49
51
|
**Requirements:** Ruby >= 3.1. Works with both rubyzip 2.x and 3.x.
|
|
50
52
|
|
|
@@ -727,6 +729,72 @@ LiquidXlsx.render(template: "invoice.xlsx", output: "out.xlsx",
|
|
|
727
729
|
на `Liquid::Environment` ничего не регистрируется — глобальная конфигурация
|
|
728
730
|
Liquid в приложении остаётся нетронутой.
|
|
729
731
|
|
|
732
|
+
## Проверка шаблона без рендера
|
|
733
|
+
|
|
734
|
+
`LiquidXlsx.validate` разбирает шаблон и возвращает список всех найденных
|
|
735
|
+
проблем. Данные не нужны, ничего не исполняется, на первой ошибке разбор не
|
|
736
|
+
останавливается.
|
|
737
|
+
|
|
738
|
+
```ruby
|
|
739
|
+
result = LiquidXlsx.validate(
|
|
740
|
+
template: "invoice.xlsx",
|
|
741
|
+
known_filters: MyApp::Filters.public_instance_methods(false), # необязательно
|
|
742
|
+
dynamic_sheets: false # как будете рендерить
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
result.valid? # => false
|
|
746
|
+
result.errors # ошибки: документ не напечатается
|
|
747
|
+
result.warnings # предупреждения: напечатается, но не так, как задумано
|
|
748
|
+
|
|
749
|
+
result.errors.first.code # => :liquid_syntax
|
|
750
|
+
result.errors.first.location # => "Invoice!B12"
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
Почему это не «отрендерить и посмотреть»: рендер разбирает только тот Liquid,
|
|
754
|
+
который реально исполняет. Опечатка в ветке `{% if %}`, которую этот документ не
|
|
755
|
+
выбрал, или в теле цикла по пустой коллекции при пробном рендере не всплывёт — и
|
|
756
|
+
дождётся другого документа. Плюс рендер выполняет шаблон на живых записях, что
|
|
757
|
+
для проверки только что загруженного файла — лишнее.
|
|
758
|
+
|
|
759
|
+
### Что находит
|
|
760
|
+
|
|
761
|
+
| Код | Уровень | Что это |
|
|
762
|
+
|---|---|---|
|
|
763
|
+
| `:invalid_xlsx` | ошибка | файл не читается как книга `.xlsx` |
|
|
764
|
+
| `:missing_worksheet` | ошибка | лист объявлен в `workbook.xml`, а его части в пакете нет |
|
|
765
|
+
| `:template_syntax` | ошибка | непарные структурные теги, тег не на отдельной строке |
|
|
766
|
+
| `:liquid_syntax` | ошибка | Liquid не разберёт фрагмент — рендер упадёт |
|
|
767
|
+
| `:liquid_expression` | предупреждение | Liquid прочтёт выражение иначе, чем написано |
|
|
768
|
+
| `:unknown_filter` | ошибка | фильтра не будет при рендере |
|
|
769
|
+
| `:sheet_tag_disabled` | ошибка | `{% sheet %}` при `dynamic_sheets: false` |
|
|
770
|
+
| `:sheet_template_missing` | ошибка | `{% sheet template: "X" %}`, а листа `X` нет |
|
|
771
|
+
| `:merged_cell_crosses_block` | ошибка | объединение пересекает границу блока |
|
|
772
|
+
| `:liquid_in_formula` | предупреждение | Liquid внутри формулы не вычисляется |
|
|
773
|
+
| `:liquid_in_rich_text` | предупреждение | Liquid в ячейке с разным форматированием не читается |
|
|
774
|
+
|
|
775
|
+
Разница между `:liquid_syntax` и `:liquid_expression` существенна.
|
|
776
|
+
`{{ invoice.total b c }}` и `{{ invoice..total }}` Liquid принимает: печатает
|
|
777
|
+
сумму, а лишнее молча выбрасывает. Документ выйдет, поэтому это предупреждение,
|
|
778
|
+
а не ошибка — но в шаблоне написано не то, что имелось в виду.
|
|
779
|
+
|
|
780
|
+
### Что шаблон использует
|
|
781
|
+
|
|
782
|
+
```ruby
|
|
783
|
+
result.roots # => ["invoice", "customer"] — что шаблон ждёт на входе
|
|
784
|
+
result.variables # => [#<VariableUse root: "invoice", segments: ["items", "title"]>, ...]
|
|
785
|
+
result.filters # => ["money2", "date_words"]
|
|
786
|
+
result.sheets # => ["Invoice", "Приложение"]
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
`roots` — именно входные переменные: имена, которые шаблон заводит сам
|
|
790
|
+
(`{% for item in ... %}`, `{% assign %}`, `{% capture %}`), из списка исключены.
|
|
791
|
+
Так приложение может сверить шаблон со своей моделью данных, не разбирая Liquid
|
|
792
|
+
самостоятельно.
|
|
793
|
+
|
|
794
|
+
У пути есть признак `truncated?`: в `{{ items[i].title }}` за динамическим
|
|
795
|
+
индексом проверять нечего, и об этом сказано явно — чтобы вызывающий код не
|
|
796
|
+
объявил «нет такого поля» там, где поле определяется в рантайме.
|
|
797
|
+
|
|
730
798
|
## Обработка ошибок
|
|
731
799
|
|
|
732
800
|
Все ошибки наследуются от `LiquidXlsx::Error < StandardError`. Там, где
|
data/lib/liquid_xlsx/renderer.rb
CHANGED
|
@@ -741,18 +741,10 @@ module LiquidXlsx
|
|
|
741
741
|
|
|
742
742
|
private
|
|
743
743
|
|
|
744
|
+
# Shared with Validator: the renderer only sees blocks it executes, the
|
|
745
|
+
# validator sees all of them, and the two must judge a crossing the same way.
|
|
744
746
|
def crosses_boundary?(merge_start, merge_end, block_start, block_end)
|
|
745
|
-
|
|
746
|
-
return false if merge_end < block_start || merge_start > block_end
|
|
747
|
-
|
|
748
|
-
# Merge strictly inside the block body (between the tag rows)
|
|
749
|
-
return false if merge_start > block_start && merge_end < block_end
|
|
750
|
-
|
|
751
|
-
# Merge entirely on a single structural tag row (it is consumed with it)
|
|
752
|
-
return false if merge_start == merge_end && (merge_start == block_start || merge_start == block_end)
|
|
753
|
-
|
|
754
|
-
# Any other overlap = crossing (including merges that swallow a tag row)
|
|
755
|
-
true
|
|
747
|
+
Validation::BlockBoundary.crosses?(merge_start, merge_end, block_start, block_end)
|
|
756
748
|
end
|
|
757
749
|
|
|
758
750
|
def clone_merge(ref, start_offset, end_offset)
|
|
@@ -12,6 +12,10 @@ module LiquidXlsx
|
|
|
12
12
|
# - +data+: Liquid expression — object to pass as local variable
|
|
13
13
|
# - +as+: (optional) quoted string — local variable name, defaults to "item"
|
|
14
14
|
class SheetTag < Liquid::Tag
|
|
15
|
+
# Exposed for Validator: it has to know which sheet the tag clones, to
|
|
16
|
+
# check that the sheet exists, without rendering the tag.
|
|
17
|
+
attr_reader :template_name, :as_name
|
|
18
|
+
|
|
15
19
|
SYNTAX = /\A\s*(.+)\s*\z/
|
|
16
20
|
# Known argument keys. Values are split on the positions of these keys
|
|
17
21
|
# (not on whitespace) so that a name/data value may itself contain
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiquidXlsx
|
|
4
|
+
module Validation
|
|
5
|
+
# Whether a merged range crosses the boundary of a structural block.
|
|
6
|
+
#
|
|
7
|
+
# A merge that starts outside a `{% for %}` and ends inside it cannot be
|
|
8
|
+
# cloned per iteration, so the renderer refuses such templates. The rule
|
|
9
|
+
# lives here because two callers need it and they must agree: the renderer
|
|
10
|
+
# (which only sees blocks it actually executes) and the validator (which
|
|
11
|
+
# sees every block in the AST, executed or not).
|
|
12
|
+
module BlockBoundary
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def crosses?(merge_start, merge_end, block_start, block_end)
|
|
16
|
+
# Merge entirely above or below the block
|
|
17
|
+
return false if merge_end < block_start || merge_start > block_end
|
|
18
|
+
|
|
19
|
+
# Merge strictly inside the block body (between the tag rows)
|
|
20
|
+
return false if merge_start > block_start && merge_end < block_end
|
|
21
|
+
|
|
22
|
+
# Merge entirely on a single structural tag row (it is consumed with it)
|
|
23
|
+
return false if merge_start == merge_end && (merge_start == block_start || merge_start == block_end)
|
|
24
|
+
|
|
25
|
+
# Any other overlap = crossing (including merges that swallow a tag row)
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiquidXlsx
|
|
4
|
+
module Validation
|
|
5
|
+
# A single problem found in a template.
|
|
6
|
+
#
|
|
7
|
+
# `code` is a stable machine-readable symbol: host applications map it to
|
|
8
|
+
# their own localized text, so the English `message` here is a fallback for
|
|
9
|
+
# logs and specs, never the only thing a user can be shown.
|
|
10
|
+
Issue = Struct.new(:code, :severity, :sheet, :row, :cell, :message, :source,
|
|
11
|
+
keyword_init: true) do
|
|
12
|
+
def error?
|
|
13
|
+
severity == :error
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def warning?
|
|
17
|
+
severity == :warning
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Human-readable location: "Sheet1!B12", "Sheet1 row 12" or "Sheet1".
|
|
21
|
+
def location
|
|
22
|
+
return nil unless sheet
|
|
23
|
+
|
|
24
|
+
return "#{sheet}!#{cell}" if cell
|
|
25
|
+
return "#{sheet} row #{row}" if row
|
|
26
|
+
|
|
27
|
+
sheet
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
{ code: code, severity: severity, sheet: sheet, row: row, cell: cell,
|
|
32
|
+
message: message, source: source }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiquidXlsx
|
|
4
|
+
module Validation
|
|
5
|
+
# Walks a parsed Liquid template and collects what it refers to: variable
|
|
6
|
+
# paths, filter names, and the names it binds itself.
|
|
7
|
+
#
|
|
8
|
+
# The point of doing this here rather than in the host application is that
|
|
9
|
+
# the host would have to parse every fragment a second time — and parse it
|
|
10
|
+
# with this gem's Liquid environment, or `{% sheet %}` and `{% image_tag %}`
|
|
11
|
+
# would come back as unknown tags. Handing back extracted usages keeps
|
|
12
|
+
# Liquid entirely on this side of the boundary.
|
|
13
|
+
class LiquidUsage
|
|
14
|
+
# Tags whose bodies Liquid exposes through an attachment rather than a
|
|
15
|
+
# nodelist ({% case %} branches).
|
|
16
|
+
def self.call(root)
|
|
17
|
+
collector = new
|
|
18
|
+
collector.walk(root)
|
|
19
|
+
collector
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :variables, :filters, :assigns, :locals
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@variables = []
|
|
26
|
+
@filters = []
|
|
27
|
+
@assigns = []
|
|
28
|
+
@locals = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def walk(node)
|
|
32
|
+
visit(node)
|
|
33
|
+
each_child(node) { |child| walk(child) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def visit(node)
|
|
39
|
+
case node
|
|
40
|
+
when ::Liquid::Variable then visit_variable(node)
|
|
41
|
+
when ::Liquid::Assign then visit_assign(node)
|
|
42
|
+
when ::Liquid::For then visit_for(node)
|
|
43
|
+
when ::Liquid::TableRow then visit_for(node)
|
|
44
|
+
when ::Liquid::If then visit_conditions(node.blocks)
|
|
45
|
+
when ::Liquid::Case then visit_case(node)
|
|
46
|
+
when ::Liquid::Capture then visit_capture(node)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def visit_variable(node)
|
|
51
|
+
collect_expression(node.name)
|
|
52
|
+
node.filters.each do |(name, *args)|
|
|
53
|
+
@filters << name.to_s
|
|
54
|
+
args.flatten.each { |arg| collect_expression(arg) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def visit_assign(node)
|
|
59
|
+
@assigns << node.to.to_s
|
|
60
|
+
visit_variable(node.from) if node.from.is_a?(::Liquid::Variable)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# {% for item in list %} binds `item` (and `forloop`) for its body; the
|
|
64
|
+
# collection itself is an ordinary reference. `limit:`/`offset:` may be
|
|
65
|
+
# variables too.
|
|
66
|
+
def visit_for(node)
|
|
67
|
+
@locals << node.variable_name.to_s
|
|
68
|
+
@locals << "forloop"
|
|
69
|
+
collect_expression(node.collection_name)
|
|
70
|
+
collect_expression(node.limit)
|
|
71
|
+
collect_expression(node.from)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def visit_case(node)
|
|
75
|
+
collect_expression(node.left)
|
|
76
|
+
visit_conditions(node.blocks)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Liquid does not expose the capture target, but a name captured and then
|
|
80
|
+
# used must not be reported as missing — read the ivar rather than let the
|
|
81
|
+
# host produce a false "unknown variable".
|
|
82
|
+
def visit_capture(node)
|
|
83
|
+
target = node.instance_variable_get(:@to)
|
|
84
|
+
@assigns << target.to_s if target
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def visit_conditions(blocks)
|
|
88
|
+
Array(blocks).each do |condition|
|
|
89
|
+
next unless condition.respond_to?(:left)
|
|
90
|
+
|
|
91
|
+
collect_expression(condition.left)
|
|
92
|
+
collect_expression(condition.right)
|
|
93
|
+
visit_conditions([condition.child_condition]) if condition.child_condition
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Liquid's own children: BlockBody, nested tags, and the bodies {% case %}
|
|
98
|
+
# hangs off its conditions as attachments.
|
|
99
|
+
def each_child(node)
|
|
100
|
+
if node.respond_to?(:nodelist) && node.nodelist
|
|
101
|
+
node.nodelist.each { |child| yield child unless child.is_a?(::String) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
return unless node.is_a?(::Liquid::Case)
|
|
105
|
+
|
|
106
|
+
node.blocks.each do |condition|
|
|
107
|
+
attachment = condition.attachment
|
|
108
|
+
yield attachment if attachment
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A variable reference is a VariableLookup; literals and numbers are not
|
|
113
|
+
# references at all. Lookups that are themselves expressions (`items[i]`)
|
|
114
|
+
# end the path: what follows depends on a runtime value, so the caller is
|
|
115
|
+
# told the path is truncated instead of being handed a name to check.
|
|
116
|
+
def collect_expression(expression)
|
|
117
|
+
case expression
|
|
118
|
+
when ::Liquid::VariableLookup then collect_lookup(expression)
|
|
119
|
+
when ::Liquid::Variable then visit_variable(expression)
|
|
120
|
+
when ::Liquid::RangeLookup
|
|
121
|
+
collect_expression(expression.instance_variable_get(:@start_obj))
|
|
122
|
+
collect_expression(expression.instance_variable_get(:@end_obj))
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def collect_lookup(lookup)
|
|
127
|
+
root = lookup.name
|
|
128
|
+
# A lookup whose own name is an expression (`{{ [dynamic] }}`) has no
|
|
129
|
+
# static root to report.
|
|
130
|
+
return collect_expression(root) unless root.is_a?(::String)
|
|
131
|
+
|
|
132
|
+
segments = []
|
|
133
|
+
truncated = false
|
|
134
|
+
|
|
135
|
+
Array(lookup.lookups).each do |segment|
|
|
136
|
+
unless segment.is_a?(::String)
|
|
137
|
+
collect_expression(segment)
|
|
138
|
+
truncated = true
|
|
139
|
+
break
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
segments << segment
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
@variables << VariableUse.new(root: root, segments: segments, truncated: truncated)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiquidXlsx
|
|
4
|
+
module Validation
|
|
5
|
+
# Outcome of validating a template: what is wrong with it, and what it uses.
|
|
6
|
+
#
|
|
7
|
+
# Validation never raises for a broken template — a broken template is the
|
|
8
|
+
# expected input here, and a caller wants the whole list, not the first
|
|
9
|
+
# failure. Only programming errors (a missing file handed to the validator,
|
|
10
|
+
# for instance) still raise.
|
|
11
|
+
class Result
|
|
12
|
+
attr_reader :issues, :usages, :sheets
|
|
13
|
+
|
|
14
|
+
def initialize(issues: [], usages: [], sheets: [])
|
|
15
|
+
@issues = issues
|
|
16
|
+
@usages = usages
|
|
17
|
+
@sheets = sheets
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def valid?
|
|
21
|
+
errors.empty?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def errors
|
|
25
|
+
issues.select(&:error?)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def warnings
|
|
29
|
+
issues.select(&:warning?)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def add(code:, severity: :error, sheet: nil, row: nil, cell: nil, message: nil, source: nil)
|
|
33
|
+
issues << Issue.new(code: code, severity: severity, sheet: sheet, row: row,
|
|
34
|
+
cell: cell, message: message, source: source)
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def add_usage(usage)
|
|
39
|
+
usages << usage
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Every variable reference in the workbook, deduplicated by path.
|
|
44
|
+
# Root names alone are rarely enough for a caller: `invoice.customer.name`
|
|
45
|
+
# and `invoice.customer.inn` are different questions to ask of a model.
|
|
46
|
+
def variables
|
|
47
|
+
usages.flat_map(&:variables).uniq(&:path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Root variable names the template expects to find in the data.
|
|
51
|
+
# Names bound by the template itself ({% for %}, {% assign %}) are not
|
|
52
|
+
# roots — they are excluded, otherwise every loop variable would look
|
|
53
|
+
# like a missing input.
|
|
54
|
+
def roots
|
|
55
|
+
bound = bound_names
|
|
56
|
+
|
|
57
|
+
variables.map(&:root).uniq.reject { |name| bound.include?(name) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def filters
|
|
61
|
+
usages.flat_map(&:filters).uniq
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Names the template binds itself, in any sheet: loop variables, assigns,
|
|
65
|
+
# captures. Scope is deliberately ignored — a name bound anywhere counts
|
|
66
|
+
# as known everywhere. Tracking real scopes would let the validator report
|
|
67
|
+
# "unknown variable" for a name that is bound two rows above, and a false
|
|
68
|
+
# error costs more than a missed one here.
|
|
69
|
+
def bound_names
|
|
70
|
+
@bound_names ||= usages.flat_map { |usage| usage.assigns + usage.locals }.uniq
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_h
|
|
74
|
+
{ valid: valid?, issues: issues.map(&:to_h), sheets: sheets,
|
|
75
|
+
roots: roots, filters: filters,
|
|
76
|
+
variables: variables.map(&:path) }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -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.rb
CHANGED
|
@@ -20,6 +20,12 @@ require_relative "liquid_xlsx/worksheet"
|
|
|
20
20
|
require_relative "liquid_xlsx/renderer"
|
|
21
21
|
require_relative "liquid_xlsx/workbook"
|
|
22
22
|
require_relative "liquid_xlsx/template"
|
|
23
|
+
require_relative "liquid_xlsx/validation/issue"
|
|
24
|
+
require_relative "liquid_xlsx/validation/usage"
|
|
25
|
+
require_relative "liquid_xlsx/validation/result"
|
|
26
|
+
require_relative "liquid_xlsx/validation/block_boundary"
|
|
27
|
+
require_relative "liquid_xlsx/validation/liquid_usage"
|
|
28
|
+
require_relative "liquid_xlsx/validator"
|
|
23
29
|
|
|
24
30
|
# LiquidXlsx is a Ruby gem for generating .xlsx files from Excel templates
|
|
25
31
|
# with Liquid syntax. Edit templates in Excel, write Liquid in cells,
|
|
@@ -89,5 +95,38 @@ module LiquidXlsx
|
|
|
89
95
|
# rubocop:enable Metrics/ParameterLists
|
|
90
96
|
tpl.render_to_file(data, output)
|
|
91
97
|
end
|
|
98
|
+
|
|
99
|
+
# Check a template without rendering it and without any data.
|
|
100
|
+
#
|
|
101
|
+
# Unlike a trial render, this parses every cell and every branch — including
|
|
102
|
+
# the ones a particular document would not take — collects all problems
|
|
103
|
+
# instead of stopping at the first, and executes nothing, so validating a
|
|
104
|
+
# template has no side effects on the application's records.
|
|
105
|
+
#
|
|
106
|
+
# result = LiquidXlsx.validate(template: "invoice.xlsx",
|
|
107
|
+
# known_filters: MyFilters.public_instance_methods(false))
|
|
108
|
+
# result.valid? # => false
|
|
109
|
+
# result.issues # => [#<Issue code: :liquid_syntax, sheet: "Sheet1", cell: "B12", ...>]
|
|
110
|
+
# result.roots # => ["invoice", "customer"] — what the template expects as input
|
|
111
|
+
#
|
|
112
|
+
# @param template [String] path to the .xlsx template
|
|
113
|
+
# @param dynamic_sheets [Boolean] pass true if you render with dynamic_sheets:
|
|
114
|
+
# true; otherwise {% sheet %} in the template is reported as an error,
|
|
115
|
+
# because that is what rendering it would do
|
|
116
|
+
# @param known_filters [Array<String, Symbol>, nil] filter names available at
|
|
117
|
+
# render time (standard Liquid filters are included automatically); nil
|
|
118
|
+
# disables the unknown-filter check
|
|
119
|
+
# @return [Validation::Result]
|
|
120
|
+
def validate(template:, dynamic_sheets: false, known_filters: nil)
|
|
121
|
+
Validator.new(template,
|
|
122
|
+
dynamic_sheets: dynamic_sheets,
|
|
123
|
+
known_filters: known_filters && (known_filters.map(&:to_s) + standard_filters))
|
|
124
|
+
.call
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Filters every Liquid template can use without the host registering anything.
|
|
128
|
+
def standard_filters
|
|
129
|
+
@standard_filters ||= ::Liquid::StandardFilters.public_instance_methods(false).map(&:to_s)
|
|
130
|
+
end
|
|
92
131
|
end
|
|
93
132
|
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ivan Khlipitkin
|
|
@@ -112,6 +112,12 @@ files:
|
|
|
112
112
|
- lib/liquid_xlsx/template.rb
|
|
113
113
|
- lib/liquid_xlsx/template_nodes.rb
|
|
114
114
|
- lib/liquid_xlsx/template_parser.rb
|
|
115
|
+
- lib/liquid_xlsx/validation/block_boundary.rb
|
|
116
|
+
- lib/liquid_xlsx/validation/issue.rb
|
|
117
|
+
- lib/liquid_xlsx/validation/liquid_usage.rb
|
|
118
|
+
- lib/liquid_xlsx/validation/result.rb
|
|
119
|
+
- lib/liquid_xlsx/validation/usage.rb
|
|
120
|
+
- lib/liquid_xlsx/validator.rb
|
|
115
121
|
- lib/liquid_xlsx/version.rb
|
|
116
122
|
- lib/liquid_xlsx/workbook.rb
|
|
117
123
|
- lib/liquid_xlsx/worksheet.rb
|