liquid_xlsx 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +58 -0
- data/LICENSE.txt +21 -0
- data/README.md +862 -0
- data/lib/liquid_xlsx/cell_reference.rb +82 -0
- data/lib/liquid_xlsx/drawing_builder.rb +263 -0
- data/lib/liquid_xlsx/errors.rb +80 -0
- data/lib/liquid_xlsx/filters.rb +9 -0
- data/lib/liquid_xlsx/formula_translator.rb +170 -0
- data/lib/liquid_xlsx/image.rb +209 -0
- data/lib/liquid_xlsx/merge_cells_transformer.rb +76 -0
- data/lib/liquid_xlsx/package.rb +599 -0
- data/lib/liquid_xlsx/renderer.rb +775 -0
- data/lib/liquid_xlsx/shared_strings.rb +47 -0
- data/lib/liquid_xlsx/tags/image_tag.rb +97 -0
- data/lib/liquid_xlsx/tags/sheet_tag.rb +161 -0
- data/lib/liquid_xlsx/template.rb +69 -0
- data/lib/liquid_xlsx/template_nodes.rb +62 -0
- data/lib/liquid_xlsx/template_parser.rb +446 -0
- data/lib/liquid_xlsx/version.rb +5 -0
- data/lib/liquid_xlsx/workbook.rb +308 -0
- data/lib/liquid_xlsx/worksheet.rb +436 -0
- data/lib/liquid_xlsx.rb +89 -0
- metadata +142 -0
data/README.md
ADDED
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
# liquid_xlsx
|
|
2
|
+
|
|
3
|
+
**English summary below · Полная документация — на русском, [ниже](#документация).**
|
|
4
|
+
|
|
5
|
+
## English
|
|
6
|
+
|
|
7
|
+
Generate `.xlsx` files from Excel templates written in Liquid syntax.
|
|
8
|
+
|
|
9
|
+
Design the template in Excel itself, put `{{ variables }}`, `{% for %}` and
|
|
10
|
+
`{% if %}` into cells, and the gem rewrites the OOXML in place — styles,
|
|
11
|
+
number formats, formulas, merged cells and workbook structure all survive.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "liquid_xlsx"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "liquid_xlsx"
|
|
19
|
+
|
|
20
|
+
LiquidXlsx.render(
|
|
21
|
+
template: "invoice_template.xlsx",
|
|
22
|
+
output: "invoice.xlsx",
|
|
23
|
+
data: {
|
|
24
|
+
invoice: { number: "INV-001", paid: false },
|
|
25
|
+
customer: { name: "Acme Ltd" },
|
|
26
|
+
items: [
|
|
27
|
+
{ title: "Development", qty: 10, price: 100 },
|
|
28
|
+
{ title: "Support", qty: 5, price: 50 }
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Object API: `LiquidXlsx::Template.new(path).render_to_file(data, out)`, or
|
|
35
|
+
`#render(data)` for a binary string.
|
|
36
|
+
|
|
37
|
+
**What it does**
|
|
38
|
+
|
|
39
|
+
- Liquid variables and standard filters in cells: `{{ price | round: 2 }}`
|
|
40
|
+
- Numbers and booleans stay numeric in Excel, so formulas keep working
|
|
41
|
+
- Row-level `{% for %}` and `{% if %}` blocks (the tag occupies its own row)
|
|
42
|
+
- In-cell `{% if %}`, `{% unless %}`, `{% case %}`
|
|
43
|
+
- One Liquid context per sheet, so `{% assign %}` / `{% capture %}` carries over
|
|
44
|
+
- Formulas and merged ranges shift automatically as loops expand rows
|
|
45
|
+
- `{% sheet %}` — generate whole worksheets from data
|
|
46
|
+
- `{% image_tag %}` — embed PNG / JPEG / GIF
|
|
47
|
+
- Errors report the sheet, row and cell they came from
|
|
48
|
+
|
|
49
|
+
**Requirements:** Ruby >= 3.1. Works with both rubyzip 2.x and 3.x.
|
|
50
|
+
|
|
51
|
+
**Main limitations:** `.xlsx` only; structural tags must occupy a dedicated
|
|
52
|
+
row; loops are row-wise (no column loops); Liquid loop modifiers (`reversed`,
|
|
53
|
+
`limit:`, `offset:`, `break`, `continue`) and hash iteration are unsupported;
|
|
54
|
+
merges must not straddle a block boundary; conditional formatting, named
|
|
55
|
+
ranges, pivot tables and Excel tables are not shifted or rebuilt. The full
|
|
56
|
+
list is in [Ограничения](#ограничения).
|
|
57
|
+
|
|
58
|
+
Everything else — syntax reference, dynamic sheets, images, render options,
|
|
59
|
+
error handling, a complete invoice example — is documented in Russian below.
|
|
60
|
+
The code samples there are language-neutral, and each one is backed by a spec
|
|
61
|
+
in `spec/integration/readme_*_spec.rb`.
|
|
62
|
+
|
|
63
|
+
## Документация
|
|
64
|
+
|
|
65
|
+
Генерация файлов `.xlsx` из Excel-шаблонов с синтаксисом Liquid.
|
|
66
|
+
|
|
67
|
+
Откройте шаблон в Excel, напишите в ячейках `{{ переменные }}`, `{% for %}`,
|
|
68
|
+
`{% if %}`, и библиотека сгенерирует итоговый файл, сохранив стили, формулы,
|
|
69
|
+
объединённые ячейки и структуру книги. Дополнительно поддерживаются
|
|
70
|
+
[динамические листы](#динамические-листы-sheet) (`{% sheet %}`) и
|
|
71
|
+
[вставка изображений](#изображения-image_tag) (`{% image_tag %}`).
|
|
72
|
+
|
|
73
|
+
> Все примеры из этого README автоматически проверяются спеками в
|
|
74
|
+
> `spec/integration/readme_core_spec.rb`,
|
|
75
|
+
> `spec/integration/readme_structural_spec.rb`,
|
|
76
|
+
> `spec/integration/readme_advanced_spec.rb`.
|
|
77
|
+
|
|
78
|
+
## Возможности
|
|
79
|
+
|
|
80
|
+
- Переменные Liquid в ячейках: `{{ customer.name }}`
|
|
81
|
+
- Стандартные фильтры Liquid: `{{ price | round: 2 }}`
|
|
82
|
+
- Сохранение числовых и булевых типов (число остаётся числом, а не строкой)
|
|
83
|
+
- Структурные теги `{% for %}` и `{% if %}` на уровне целых строк
|
|
84
|
+
- `{% assign %}` / `{% capture %}` с общим контекстом в пределах листа
|
|
85
|
+
- Внутриячеечные теги Liquid: `{% if %}`, `{% unless %}`, `{% case %}`
|
|
86
|
+
- Автоматическое смещение формул и объединённых ячеек в циклах
|
|
87
|
+
- Динамические листы `{% sheet %}` — генерация листов по данным
|
|
88
|
+
- Изображения `{% image_tag %}` — PNG, JPEG, GIF
|
|
89
|
+
- Подробные ошибки с указанием листа, строки и ячейки
|
|
90
|
+
|
|
91
|
+
## Установка
|
|
92
|
+
|
|
93
|
+
Добавьте в Gemfile:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
gem "liquid_xlsx"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Или установите глобально:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
gem install liquid_xlsx
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Требуется Ruby >= 3.1. Работает как с `rubyzip` 2.x, так и с 3.x.
|
|
106
|
+
|
|
107
|
+
## Быстрый старт
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
require "liquid_xlsx"
|
|
111
|
+
|
|
112
|
+
LiquidXlsx.render(
|
|
113
|
+
template: "invoice_template.xlsx",
|
|
114
|
+
output: "invoice.xlsx",
|
|
115
|
+
data: {
|
|
116
|
+
invoice: { number: "INV-001", paid: false },
|
|
117
|
+
customer: { name: "ООО Ромашка" },
|
|
118
|
+
items: [
|
|
119
|
+
{ title: "Разработка", qty: 10, price: 100 },
|
|
120
|
+
{ title: "Поддержка", qty: 5, price: 50 }
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Объектный API
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
template = LiquidXlsx::Template.new("template.xlsx")
|
|
130
|
+
template.render_to_file(data, "output.xlsx") # рендер + запись в файл
|
|
131
|
+
binary = template.render(data) # рендер, возвращает бинарную строку
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Синтаксис шаблонов
|
|
135
|
+
|
|
136
|
+
Библиотека разделяет два слоя Liquid:
|
|
137
|
+
|
|
138
|
+
1. **Внутриячеечный Liquid** — всё, что написано внутри одной ячейки
|
|
139
|
+
(`{{ var }}`, `{{ var | filter }}`, `{% if %}` внутри ячейки и т.д.).
|
|
140
|
+
Обрабатывается настоящим движком Liquid.
|
|
141
|
+
2. **Структурные теги** — `{% for %}`, `{% endfor %}`, `{% if %}`,
|
|
142
|
+
`{% elsif %}`, `{% else %}`, `{% endif %}` на уровне целых строк.
|
|
143
|
+
Такие теги должны занимать [отдельную строку](#правило-отдельной-строки)
|
|
144
|
+
и обрабатываются собственным парсером.
|
|
145
|
+
|
|
146
|
+
### Переменные
|
|
147
|
+
|
|
148
|
+
Любая текстовая ячейка может содержать Liquid-переменные:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
Счёт № {{ invoice.number }}
|
|
152
|
+
Клиент: {{ customer.name }}
|
|
153
|
+
Город: {{ customer.address.city }}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Поддерживается точечная нотация для вложенных хешей и массивов:
|
|
157
|
+
`{{ customer.address.city }}`, `{{ items.first.title }}`.
|
|
158
|
+
|
|
159
|
+
Несколько выражений и литеральный текст в одной ячейке:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
Привет, {{ name }}! Ваш баланс: {{ balance | round: 2 }}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Источники данных: `Hash`, `Liquid::Drop`, любой `#to_liquid`
|
|
166
|
+
|
|
167
|
+
Параметр `data:` принимает не только `Hash`, но и `Liquid::Drop`, и любой
|
|
168
|
+
объект, отвечающий на `#to_liquid` (возвращающий Hash или Drop). Это удобно
|
|
169
|
+
для ленивого доступа к данным, вычислимых полей и обёрток над AR-моделями:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
class InvoiceDrop < Liquid::Drop
|
|
173
|
+
def initialize(invoice)
|
|
174
|
+
@invoice = invoice
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def number
|
|
178
|
+
@invoice.number # строка
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def total
|
|
182
|
+
@invoice.lines.sum(&:amount) # вычислимое числовое поле
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def customer
|
|
186
|
+
CustomerDrop.new(@invoice.customer) # вложенный Drop
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
LiquidXlsx.render(
|
|
191
|
+
template: "invoice.xlsx",
|
|
192
|
+
output: "out.xlsx",
|
|
193
|
+
data: InvoiceDrop.new(invoice) # <- Drop вместо Hash
|
|
194
|
+
)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Для объекта, не являющегося `Hash` или `Liquid::Drop`, но реализующего
|
|
198
|
+
`#to_liquid` (например, AR-модель, `Struct`, `OpenStruct`), метод вызывается
|
|
199
|
+
один раз на входе, и результат используется как корневое окружение Liquid:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
class Report < Struct.new(:title, :count)
|
|
203
|
+
def to_liquid
|
|
204
|
+
{ "title" => title, "count" => count }
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
LiquidXlsx.render(template: ..., output: ..., data: Report.new("Sales", 42))
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**Сохранение типов работает и для Drop.** Если метод Drop возвращает `Integer`
|
|
212
|
+
или `Float`, ячейка `{{ invoice.total }}` запишется как число `<v>` (не как
|
|
213
|
+
строка), поэтому формулы `=SUM(...)` и числовые форматы продолжат работать.
|
|
214
|
+
`BigDecimal` также сохраняется как число; `Rational`, `Complex`, а также
|
|
215
|
+
`Float::NAN`/`Infinity` (не валидны в OOXML) автоматически рендерятся как
|
|
216
|
+
текст.
|
|
217
|
+
|
|
218
|
+
**Ограничения:**
|
|
219
|
+
|
|
220
|
+
- Корневой объект должен реализовывать `[]`/`key?` (как `Hash` и `Liquid::Drop`)
|
|
221
|
+
либо `#to_liquid`, возвращающий Hash/Drop. Произвольный объект только с
|
|
222
|
+
методами-атрибутами, но без `to_liquid`, работать не будет — Liquid ищет
|
|
223
|
+
переменные через `[]`.
|
|
224
|
+
- Drop-методы, используемые в `{% for %}`, должны возвращать именно `Array`.
|
|
225
|
+
`Enumerable`/`ActiveRecord::Relation` напрямую не поддерживаются —
|
|
226
|
+
материализуйте их через `.to_a` в самом методе.
|
|
227
|
+
- Если Drop-метод возвращает вложенный `Hash`, используйте **строковые** ключи:
|
|
228
|
+
`stringify_keys` рекурсивно нормализует корневой `Hash` (и результат
|
|
229
|
+
`#to_liquid`→Hash), но не применяется к значениям, возвращаемым
|
|
230
|
+
Drop-методами во время рендера.
|
|
231
|
+
- Один и тот же Drop-метод для одной ячейки `{{ var }}` может вызываться
|
|
232
|
+
дважды (для fast-path и для основного рендера). Если у метода есть побочные
|
|
233
|
+
эффекты или SQL-запросы, мемоизируйте результат.
|
|
234
|
+
|
|
235
|
+
### Сохранение типов
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
Если вся ячейка целиком состоит из одной переменной `{{ var }}` (без фильтров,
|
|
239
|
+
без окружающего текста), значение подставляется напрямую из данных.
|
|
240
|
+
Числа остаются числами, булевы значения — булевыми:
|
|
241
|
+
|
|
242
|
+
| Шаблон | Данные | Результат в Excel |
|
|
243
|
+
|------------------|-------------------|--------------------------|
|
|
244
|
+
| `{{ qty }}` | `42` | Числовая ячейка `<v>42</v>` |
|
|
245
|
+
| `{{ price }}` | `19.99` | Числовая ячейка `<v>19.99</v>` |
|
|
246
|
+
| `{{ active }}` | `true` | Булевая ячейка `t="b"` `<v>1</v>` |
|
|
247
|
+
| `{{ name }}` | `"Иван"` | Строка `inlineStr` |
|
|
248
|
+
|
|
249
|
+
Как только добавляется фильтр, окружающий текст или ещё одна переменная —
|
|
250
|
+
ячейка всегда становится строковой (`inlineStr`):
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
{{ price | round: 2 }} → строка "19.99"
|
|
254
|
+
Цена: {{ price }} → строка "Цена: 19.99"
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Фильтры Liquid
|
|
258
|
+
|
|
259
|
+
Поддерживаются все стандартные фильтры Liquid:
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
{{ name | upcase }} # IVAN PETROV
|
|
263
|
+
{{ name | downcase }} # ivan petrov
|
|
264
|
+
{{ name | capitalize }} # Ivan petrov
|
|
265
|
+
{{ price | round: 2 }} # 19.99
|
|
266
|
+
{{ total | plus: 100 }} # 1100
|
|
267
|
+
{{ total | minus: 50 }} # 950
|
|
268
|
+
{{ price | times: 1.2 }} # 120.0
|
|
269
|
+
{{ items | size }} # 3
|
|
270
|
+
{{ items | join: ", " }} # яблоко, груша, слива
|
|
271
|
+
{{ items | first }} # яблоко
|
|
272
|
+
{{ items | last }} # слива
|
|
273
|
+
{{ name | append: "!" }} # hello!
|
|
274
|
+
{{ name | prepend: "Mr. " }} # Mr. hello
|
|
275
|
+
{{ name | replace: "old", "new" }} # hello new
|
|
276
|
+
{{ desc | truncate: 10 }} # this is...
|
|
277
|
+
{{ created_at | date: "%d.%m.%Y" }} # 15.03.2024
|
|
278
|
+
{{ phone | default: "—" }} # +7... или "—" если nil
|
|
279
|
+
{{ text | strip }} # обрезка пробелов
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
> Примечание: арифметические фильтры над Float сохраняют дробный формат
|
|
283
|
+
> (`100.0 | plus: 10` → `"110.0"`). Если нужно целое — добавьте `round`.
|
|
284
|
+
|
|
285
|
+
### assign и capture — общий контекст на листе
|
|
286
|
+
|
|
287
|
+
В пределах одного листа создаётся **один** `Liquid::Context`, общий для всех
|
|
288
|
+
ячеек в порядке документа (сверху вниз, слева направо). Поэтому переменная,
|
|
289
|
+
созданная в одной ячейке, видна в последующих:
|
|
290
|
+
|
|
291
|
+
```
|
|
292
|
+
A1: {% assign total = 100 %}
|
|
293
|
+
B1: {{ total }} → 100
|
|
294
|
+
|
|
295
|
+
A2: {% assign total = total | plus: 50 %}
|
|
296
|
+
B2: {{ total }} → 150
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
`{% capture %}` собирает блок текста в переменную:
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
A1: {% capture greeting %}Привет, {{ name }}!{% endcapture %}
|
|
303
|
+
B1: {{ greeting }} → Привет, Мир!
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
`{% assign %}` работает и внутри циклов — накопленные значения доступны
|
|
307
|
+
после `{% endfor %}` (см. [пример ниже](#накопление-суммы-в-цикле)).
|
|
308
|
+
|
|
309
|
+
### Внутриячеечные теги Liquid
|
|
310
|
+
|
|
311
|
+
Внутри одной ячейки можно использовать условные теги Liquid (это НЕ структурные
|
|
312
|
+
теги уровня строк, обрабатываются самим Liquid):
|
|
313
|
+
|
|
314
|
+
```
|
|
315
|
+
{% if paid %}Оплачено{% else %}Не оплачено{% endif %}
|
|
316
|
+
|
|
317
|
+
{% if total > 1000 %}Крупный{% elsif total > 100 %}Средний{% else %}Малый{% endif %}
|
|
318
|
+
|
|
319
|
+
{% unless cancelled %}Активен{% endunless %}
|
|
320
|
+
|
|
321
|
+
{% case status %}{% when "active" %}Активен{% when "pending" %}Ожидает{% endcase %}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Поддерживаемые операторы в условиях: `==`, `!=`, `>`, `<`, `>=`, `<=`,
|
|
325
|
+
`contains`; логические `and`, `or`; ключевые слова `true`, `false`, `nil`,
|
|
326
|
+
`empty`, `blank`.
|
|
327
|
+
|
|
328
|
+
> Ограничение: внутриячеечный `{% case %}` с веткой `{% else %}` в текущей
|
|
329
|
+
> версии не работает — структурный парсер ошибочно воспринимает `{% else %}`
|
|
330
|
+
> как тег уровня строки. Используйте `{% case %}/{% when %}` без `{% else %}`,
|
|
331
|
+
> либо эквивалентный `{% if %}/{% elsif %}/{% else %}`.
|
|
332
|
+
|
|
333
|
+
> По правилам Liquid пустой массив **правдив**. `{% unless items %}` сработает
|
|
334
|
+
> только при `items = nil`/`false`, но не на `items = []`. Для проверки
|
|
335
|
+
> пустоты используйте `{% if items == empty %}`.
|
|
336
|
+
|
|
337
|
+
### Структурные теги
|
|
338
|
+
|
|
339
|
+
#### Правило отдельной строки
|
|
340
|
+
|
|
341
|
+
`{% for %}`, `{% endfor %}`, `{% if %}`, `{% elsif %}`, `{% else %}`,
|
|
342
|
+
`{% endif %}` должны занимать **отдельную строку**: тег один в первой
|
|
343
|
+
непустой ячейке строки, остальные ячейки строки пусты.
|
|
344
|
+
|
|
345
|
+
| Допустимо | Недопустимо |
|
|
346
|
+
|--------------------------|----------------------------------------|
|
|
347
|
+
| `A1: {% for x in xs %}` | `A1: Сумма: {% for x in xs %}` |
|
|
348
|
+
| | `A1: {% for x in xs %}` `B1: текст` |
|
|
349
|
+
|
|
350
|
+
Нарушение правила выбрасывает `LiquidXlsx::TemplateSyntaxError` с указанием
|
|
351
|
+
листа, строки и ячейки.
|
|
352
|
+
|
|
353
|
+
#### Циклы `{% for %}`
|
|
354
|
+
|
|
355
|
+
```
|
|
356
|
+
{% for item in items %}
|
|
357
|
+
{{ forloop.index }}
|
|
358
|
+
{{ item.title }}
|
|
359
|
+
{{ item.qty }}
|
|
360
|
+
{{ item.price }}
|
|
361
|
+
{% endfor %}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Строки между `{% for %}` и `{% endfor %}` повторяются для каждого элемента.
|
|
365
|
+
Внутри тела доступны переменные цикла:
|
|
366
|
+
|
|
367
|
+
| Переменная | Значение |
|
|
368
|
+
|-----------------------|-----------------------------------|
|
|
369
|
+
| `{{ forloop.index }}` | 1-based индекс (1, 2, 3, ...) |
|
|
370
|
+
| `{{ forloop.index0 }}`| 0-based индекс (0, 1, 2, ...) |
|
|
371
|
+
| `{{ forloop.first }}` | `true` на первой итерации (булево)|
|
|
372
|
+
| `{{ forloop.last }}` | `true` на последней итерации |
|
|
373
|
+
| `{{ forloop.length }}`| Общее количество итераций |
|
|
374
|
+
|
|
375
|
+
Стили ячеек, формулы и объединённые ячейки в теле цикла сохраняются и
|
|
376
|
+
корректно смещаются.
|
|
377
|
+
|
|
378
|
+
#### Пустые циклы с `{% else %}`
|
|
379
|
+
|
|
380
|
+
```
|
|
381
|
+
{% for item in items %}
|
|
382
|
+
{{ item.title }}
|
|
383
|
+
{% else %}
|
|
384
|
+
Список пуст
|
|
385
|
+
{% endfor %}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Если коллекция пуста или равна `nil`, отрисовывается блок `{% else %}`.
|
|
389
|
+
Цикл над `nil` (ключ отсутствует в данных) тоже безопасен — эквивалентен
|
|
390
|
+
пустому массиву.
|
|
391
|
+
|
|
392
|
+
#### Вложенные циклы
|
|
393
|
+
|
|
394
|
+
```
|
|
395
|
+
{% for order in orders %}
|
|
396
|
+
Заказ {{ order.number }}
|
|
397
|
+
{% for line in order.lines %}
|
|
398
|
+
{{ line.title }}: {{ line.qty }}
|
|
399
|
+
{% endfor %}
|
|
400
|
+
{% endfor %}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Внутренний цикл разрешает коллекцию через Liquid-контекст, в котором уже
|
|
404
|
+
установлена переменная внешнего цикла (`order`).
|
|
405
|
+
|
|
406
|
+
#### Условия на уровне строк `{% if %}`
|
|
407
|
+
|
|
408
|
+
Вся строка включается или исключается целиком:
|
|
409
|
+
|
|
410
|
+
```
|
|
411
|
+
{% if invoice.paid %}
|
|
412
|
+
Оплачено
|
|
413
|
+
{% elsif invoice.pending %}
|
|
414
|
+
В обработке
|
|
415
|
+
{% else %}
|
|
416
|
+
Не оплачено
|
|
417
|
+
{% endif %}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Условия поддерживают сравнения, `and`/`or`, `contains`, проверку пустоты:
|
|
421
|
+
|
|
422
|
+
```
|
|
423
|
+
{% if age >= 18 and age <= 65 %} возрастной диапазон
|
|
424
|
+
{% if tags contains "vip" %} наличие элемента
|
|
425
|
+
{% if notes == empty %} пустая коллекция
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
#### Вложенные конструкции
|
|
429
|
+
|
|
430
|
+
`{% if %}` внутри `{% for %}` и наоборот — поддерживаются в любой комбинации:
|
|
431
|
+
|
|
432
|
+
```
|
|
433
|
+
{% for item in items %}
|
|
434
|
+
{% if item.urgent %}
|
|
435
|
+
СРОЧНО: {{ item.name }}
|
|
436
|
+
{% endif %}
|
|
437
|
+
{% endfor %}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
```
|
|
441
|
+
{% if show_items %}
|
|
442
|
+
{% for item in items %}
|
|
443
|
+
{{ item.name }}
|
|
444
|
+
{% endfor %}
|
|
445
|
+
{% endif %}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
#### Накопление суммы в цикле
|
|
449
|
+
|
|
450
|
+
`{% assign %}` внутри тела цикла сохраняется после `{% endfor %}`:
|
|
451
|
+
|
|
452
|
+
```
|
|
453
|
+
{% assign sum = 0 %}
|
|
454
|
+
{% for item in items %}
|
|
455
|
+
{% assign sum = sum | plus: item.qty %}
|
|
456
|
+
{% endfor %}
|
|
457
|
+
Итого: {{ sum }} → Итого: 18 (5 + 10 + 3)
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### Формулы
|
|
461
|
+
|
|
462
|
+
Формулы в теле цикла автоматически смещаются по строкам:
|
|
463
|
+
|
|
464
|
+
| Шаблон | После 3 итераций |
|
|
465
|
+
|------------|------------------------------------------|
|
|
466
|
+
| `=A2*2` | `=A2*2`, `=A3*2`, `=A4*2` |
|
|
467
|
+
|
|
468
|
+
Формулы-суммы под циклом, ссылающиеся на строку-шаблон, раскрываются в диапазон:
|
|
469
|
+
|
|
470
|
+
| Шаблон | После 3 итераций |
|
|
471
|
+
|-----------------|--------------------|
|
|
472
|
+
| `=SUM(A2:A2)` | `=SUM(A2:A4)` |
|
|
473
|
+
|
|
474
|
+
Опция `recalculate_formulas: true` удаляет `xl/calcChain.xml` и сбрасывает
|
|
475
|
+
кэшированные значения, заставляя Excel пересчитать всё при открытии.
|
|
476
|
+
|
|
477
|
+
### Объединённые ячейки
|
|
478
|
+
|
|
479
|
+
Объединённые диапазоны в теле цикла клонируются для каждой итерии:
|
|
480
|
+
|
|
481
|
+
| Шаблон | После 3 итераций |
|
|
482
|
+
|--------------|-------------------------------------|
|
|
483
|
+
| `A2:B2` | `A2:B2`, `A3:B3`, `A4:B4` |
|
|
484
|
+
|
|
485
|
+
Диапазоны ниже блока автоматически смещаются. Объединённая ячейка, переходящая
|
|
486
|
+
через границу `{% for %}`/`{% if %}`-блока, вызывает
|
|
487
|
+
`LiquidXlsx::UnsupportedTemplateError`.
|
|
488
|
+
|
|
489
|
+
## Динамические листы `{% sheet %}`
|
|
490
|
+
|
|
491
|
+
Тег `{% sheet %}` создаёт копии указанного листа — по одной на элемент
|
|
492
|
+
коллекции. Например, по списку счетов можно сгенерировать отдельный лист
|
|
493
|
+
под каждый счёт.
|
|
494
|
+
|
|
495
|
+
Требуется опция `dynamic_sheets: true`.
|
|
496
|
+
|
|
497
|
+
### Синтаксис
|
|
498
|
+
|
|
499
|
+
```
|
|
500
|
+
{% sheet name: <имя> template: "<шаблон>" data: <данные> as: "<переменная>" %}
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
| Аргумент | Обязательный | Описание |
|
|
504
|
+
|--------------|--------------|---------------------------------------------------|
|
|
505
|
+
| `name:` | да | Имя нового листа (Liquid-выражение или строка) |
|
|
506
|
+
| `template:` | да | Имя листа-шаблона (только строковый литерал в `"`)|
|
|
507
|
+
| `data:` | да | Данные для этого листа (Liquid-выражение) |
|
|
508
|
+
| `as:` | нет | Имя переменной для `data` (по умолчанию `"item"`) |
|
|
509
|
+
|
|
510
|
+
> **Важно:** значения `template:` и строковые литералы в `name:`/`as:` должны
|
|
511
|
+
> быть в **двойных** кавычках. Одинарные кавычки не поддерживаются.
|
|
512
|
+
|
|
513
|
+
### Типичный сценарий
|
|
514
|
+
|
|
515
|
+
Контрольный лист `Index` обходит коллекцию и создаёт листы:
|
|
516
|
+
|
|
517
|
+
```
|
|
518
|
+
{% for inv in invoices %}
|
|
519
|
+
{% sheet name: inv.number template: "Invoice" data: inv as: "inv" %}
|
|
520
|
+
{% endfor %}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
Лист-шаблон `Invoice` ссылается на локальную переменную:
|
|
524
|
+
|
|
525
|
+
```
|
|
526
|
+
A1: Счёт № {{ inv.number }}
|
|
527
|
+
A2: Клиент: {{ inv.client }}
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
Вызов:
|
|
531
|
+
|
|
532
|
+
```ruby
|
|
533
|
+
LiquidXlsx.render(
|
|
534
|
+
template: "tpl.xlsx",
|
|
535
|
+
output: "out.xlsx",
|
|
536
|
+
dynamic_sheets: true,
|
|
537
|
+
data: {
|
|
538
|
+
invoices: [
|
|
539
|
+
{ number: "INV-001", client: "Альфа" },
|
|
540
|
+
{ number: "INV-002", client: "Бета" }
|
|
541
|
+
]
|
|
542
|
+
}
|
|
543
|
+
)
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
Результат: в книге появятся листы `INV-001` и `INV-002`, заполненные из
|
|
547
|
+
соответствующих счетов. Контрольный лист `Index` будет скрыт.
|
|
548
|
+
|
|
549
|
+
### Liquid-фильтры в `name:`
|
|
550
|
+
|
|
551
|
+
```
|
|
552
|
+
{% sheet name: inv.number | append: " - " | append: inv.client template: "Invoice" data: inv as: "inv" %}
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
→ имя листа `INV-001 - Alpha`.
|
|
556
|
+
|
|
557
|
+
### Значение по умолчанию `as: "item"`
|
|
558
|
+
|
|
559
|
+
Если `as:` опущено, данные доступны как `item`:
|
|
560
|
+
|
|
561
|
+
```
|
|
562
|
+
{% sheet name: inv.number template: "Invoice" data: inv %}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
В шаблоне: `{{ item.number }}`.
|
|
566
|
+
|
|
567
|
+
### Нормализация имён листов
|
|
568
|
+
|
|
569
|
+
Excel запрещает в именах листов символы `: \ / ? * [ ]` и ограничивает длину
|
|
570
|
+
31 символом. Библиотека автоматически:
|
|
571
|
+
|
|
572
|
+
- удаляет запрещённые символы (`"Отчёт/За: 2024"` → `"ОтчётЗа 2024"`);
|
|
573
|
+
- обрезает имя до 31 символа;
|
|
574
|
+
- при совпадении имён добавляет суффиксы: `DUP`, `DUP (2)`, `DUP (3)`;
|
|
575
|
+
- пустое имя заменяет на `Sheet`.
|
|
576
|
+
|
|
577
|
+
### Поведение по умолчанию
|
|
578
|
+
|
|
579
|
+
- `hide_control_sheets: true` — контрольный лист (с тегом `{% sheet %}`)
|
|
580
|
+
скрывается.
|
|
581
|
+
- `hide_template_sheets: false` — лист-шаблон остаётся видимым. Установите
|
|
582
|
+
`true`, чтобы скрыть его.
|
|
583
|
+
- Вызов `{% sheet %}` без `dynamic_sheets: true` выбрасывает
|
|
584
|
+
`LiquidXlsx::RenderError`.
|
|
585
|
+
- Отсутствующий `template:` выбрасывает `LiquidXlsx::UnsupportedTemplateError`.
|
|
586
|
+
|
|
587
|
+
## Изображения `{% image_tag %}`
|
|
588
|
+
|
|
589
|
+
Вставка изображений (PNG, JPEG, GIF) прямо из данных. Формат распознаётся по
|
|
590
|
+
сигнатуре файла (magic bytes).
|
|
591
|
+
|
|
592
|
+
### Синтаксис
|
|
593
|
+
|
|
594
|
+
```
|
|
595
|
+
{% image_tag <источник>, <опции> %}
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
Источник — первый позиционный аргумент: Liquid-выражение, возвращающее
|
|
599
|
+
бинарную строку с данными картинки.
|
|
600
|
+
|
|
601
|
+
| Опция | Тип | Описание |
|
|
602
|
+
|------------|--------------|------------------------------------------------|
|
|
603
|
+
| `colspan:` | целое > 0 | Сколько колонок вправо занимает картинка |
|
|
604
|
+
| `rowspan:` | целое > 0 | Сколько строк вниз занимает картинка |
|
|
605
|
+
| `to:` | `"F10"` | Правый нижний угол (инклюзивно) |
|
|
606
|
+
| `width:` | целое > 0 px | Фиксированная ширина в пикселях |
|
|
607
|
+
| `height:` | целое > 0 px | Фиксированная высота в пикселях |
|
|
608
|
+
|
|
609
|
+
### Выбор якоря
|
|
610
|
+
|
|
611
|
+
Якорь определяется в порядке приоритета:
|
|
612
|
+
|
|
613
|
+
1. `to:` — абсолютный правый нижний угол;
|
|
614
|
+
2. `colspan:`/`rowspan:` — относительный охват от ячейки-якоря;
|
|
615
|
+
3. ячейка-якорь — левый верх объединённого диапазона → заполнение merge;
|
|
616
|
+
4. `width:`/`height:` — фиксированный размер (`oneCellAnchor`);
|
|
617
|
+
5. по умолчанию — одна ячейка (`colspan=1, rowspan=1`).
|
|
618
|
+
|
|
619
|
+
При `width:` без `height:` (или наоборот) вторая размерность выводится из
|
|
620
|
+
натуральных пропорций картинки; для вырожденных размеров (ноль в заголовке)
|
|
621
|
+
используется квадрат.
|
|
622
|
+
|
|
623
|
+
### Примеры
|
|
624
|
+
|
|
625
|
+
```
|
|
626
|
+
{% image_tag logo, colspan: 2, rowspan: 3 %} # заливает 2 колонки и 3 строки
|
|
627
|
+
{% image_tag photo, width: 120, height: 60 %} # фиксированный размер
|
|
628
|
+
{% image_tag cover, width: 100 %} # высота из пропорций
|
|
629
|
+
{% image_tag logo, to: "F10" %} # правый нижний угол — F10
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
Вызов:
|
|
633
|
+
|
|
634
|
+
```ruby
|
|
635
|
+
LiquidXlsx.render(
|
|
636
|
+
template: "tpl.xlsx",
|
|
637
|
+
output: "out.xlsx",
|
|
638
|
+
data: { "logo" => File.binread("logo.png") }
|
|
639
|
+
)
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
### Загрузка по пути/URL
|
|
643
|
+
|
|
644
|
+
Если источник — строка-путь (например, `"logos/x.png"`), подключите
|
|
645
|
+
`images.loader`:
|
|
646
|
+
|
|
647
|
+
```ruby
|
|
648
|
+
LiquidXlsx.render(
|
|
649
|
+
template: "tpl.xlsx",
|
|
650
|
+
output: "out.xlsx",
|
|
651
|
+
images: { loader: ->(ref) { File.binread(ref) } },
|
|
652
|
+
data: { "logo_path" => "logos/company.png" }
|
|
653
|
+
)
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
Без `loader` строка-путь выбрасывает `LiquidXlsx::RenderError`.
|
|
657
|
+
|
|
658
|
+
### В циклах
|
|
659
|
+
|
|
660
|
+
Якорь автоматически смещается на каждой итерации:
|
|
661
|
+
|
|
662
|
+
```
|
|
663
|
+
{% for item in items %}
|
|
664
|
+
{% image_tag item.photo, colspan: 1, rowspan: 1 %}
|
|
665
|
+
{% endfor %}
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
Идентичные картинки (одинаковый SHA-256) дедуплицируются — в архиве хранится
|
|
669
|
+
одна медиа-запись.
|
|
670
|
+
|
|
671
|
+
### Валидация
|
|
672
|
+
|
|
673
|
+
- `colspan:`, `rowspan:`, `width:`, `height:` должны быть положительными
|
|
674
|
+
целыми числами. Иначе — `LiquidXlsx::RenderError`.
|
|
675
|
+
- Неподдерживаемый формат — `LiquidXlsx::UnsupportedTemplateError`.
|
|
676
|
+
|
|
677
|
+
## Опции рендеринга
|
|
678
|
+
|
|
679
|
+
```ruby
|
|
680
|
+
LiquidXlsx.render(
|
|
681
|
+
template:,
|
|
682
|
+
output:,
|
|
683
|
+
data:,
|
|
684
|
+
strict_variables: false, # true — raise на отсутствующей переменной
|
|
685
|
+
strict_filters: false, # true — raise на неизвестном фильтре
|
|
686
|
+
recalculate_formulas: false, # true — удалить calcChain, форсировать пересчёт
|
|
687
|
+
remove_template_comments: false,# зарезервировано
|
|
688
|
+
dynamic_sheets: false, # true — включить {% sheet %}
|
|
689
|
+
hide_control_sheets: true, # скрывать контрольные листы
|
|
690
|
+
hide_template_sheets: false, # скрывать листы-шаблоны после клонирования
|
|
691
|
+
images: nil, # { loader:, default_dpi: } для {% image_tag %}
|
|
692
|
+
liquid_resource_limits: nil # { render_length_limit:, render_score_limit:, ... }
|
|
693
|
+
)
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
| Опция | По умолчанию | Описание |
|
|
697
|
+
|------------------------|--------------|---------------------------------------------------------|
|
|
698
|
+
| `strict_variables` | `false` | При `true` отсутствующая переменная → `MissingVariableError` |
|
|
699
|
+
| `strict_filters` | `false` | При `true` неизвестный фильтр → исключение Liquid |
|
|
700
|
+
| `recalculate_formulas` | `false` | Удалить `calcChain.xml`, форсировать пересчёт в Excel |
|
|
701
|
+
| `dynamic_sheets` | `false` | Включить обработку `{% sheet %}` |
|
|
702
|
+
| `hide_control_sheets` | `true` | Скрывать листы с тегами `{% sheet %}` |
|
|
703
|
+
| `hide_template_sheets` | `false` | Скрывать листы-шаблоны |
|
|
704
|
+
| `images` | `nil` | `{ loader:, default_dpi: }` — загрузчик путей и DPI для `width/height` |
|
|
705
|
+
| `liquid_resource_limits` | `nil` | Лимиты Liquid (`render_length_limit`, `render_score_limit`, `assign_score_limit`) |
|
|
706
|
+
|
|
707
|
+
## Обработка ошибок
|
|
708
|
+
|
|
709
|
+
Все ошибки наследуются от `LiquidXlsx::Error < StandardError`. Там, где
|
|
710
|
+
применимо, объект ошибки содержит атрибуты `sheet`, `row`, `cell`, `template`.
|
|
711
|
+
|
|
712
|
+
```
|
|
713
|
+
LiquidXlsx::Error
|
|
714
|
+
├── TemplateSyntaxError нарушение правила отдельной строки,
|
|
715
|
+
│ непарные структурные теги
|
|
716
|
+
├── RenderError ошибка рендера (включая {% sheet %}/{% image_tag %}),
|
|
717
|
+
│ └── MissingVariableError при strict_variables: true
|
|
718
|
+
├── UnsupportedTemplateError merge через границу блока, неизвестный формат
|
|
719
|
+
│ картинки, отсутствие template-листа
|
|
720
|
+
├── InvalidXlsxError битый .xlsx на входе
|
|
721
|
+
└── OutputWriteError ошибка записи выходного файла
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
Пример сообщения:
|
|
725
|
+
|
|
726
|
+
```
|
|
727
|
+
LiquidXlsx::TemplateSyntaxError:
|
|
728
|
+
Structural tag must be placed on a dedicated row.
|
|
729
|
+
Sheet: Invoice
|
|
730
|
+
Row: 12
|
|
731
|
+
Cell: A12
|
|
732
|
+
Tag: {% for item in items %}
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
## Полный пример: счёт-фактура
|
|
736
|
+
|
|
737
|
+
Шаблон `invoice.xlsx`:
|
|
738
|
+
|
|
739
|
+
| A | B | C | D | E |
|
|
740
|
+
|------------------------------------|--------------------|---------|--------|---------------|
|
|
741
|
+
| Счёт № {{ invoice.number }} | | | | |
|
|
742
|
+
| Клиент | {{ customer.name }}| | | |
|
|
743
|
+
| | | | | |
|
|
744
|
+
| № | Услуга | Кол-во | Цена | Сумма |
|
|
745
|
+
| `{% for item in items %}` | | | | |
|
|
746
|
+
| {{ forloop.index }} | {{ item.title }} | {{ item.qty }} | {{ item.price }} | `=C6*D6` |
|
|
747
|
+
| `{% endfor %}` | | | | |
|
|
748
|
+
| | | | Итого | `=SUM(E6:E6)` |
|
|
749
|
+
| `{% if invoice.paid %}` | | | | |
|
|
750
|
+
| Оплачено | | | | |
|
|
751
|
+
| `{% else %}` | | | | |
|
|
752
|
+
| Не оплачено | | | | |
|
|
753
|
+
| `{% endif %}` | | | | |
|
|
754
|
+
|
|
755
|
+
Рендер:
|
|
756
|
+
|
|
757
|
+
```ruby
|
|
758
|
+
LiquidXlsx.render(
|
|
759
|
+
template: "invoice.xlsx",
|
|
760
|
+
output: "invoice_001.xlsx",
|
|
761
|
+
data: {
|
|
762
|
+
invoice: { number: "INV-001", paid: false },
|
|
763
|
+
customer: { name: "ООО Ромашка" },
|
|
764
|
+
items: [
|
|
765
|
+
{ title: "Разработка", qty: 10, price: 100 },
|
|
766
|
+
{ title: "Поддержка", qty: 5, price: 50 }
|
|
767
|
+
]
|
|
768
|
+
}
|
|
769
|
+
)
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
Результат:
|
|
773
|
+
|
|
774
|
+
| A | B | C | D | E |
|
|
775
|
+
|------------------|--------------|---------|--------|---------------|
|
|
776
|
+
| Счёт № INV-001 | | | | |
|
|
777
|
+
| Клиент | ООО Ромашка | | | |
|
|
778
|
+
| | | | | |
|
|
779
|
+
| № | Услуга | Кол-во | Цена | Сумма |
|
|
780
|
+
| 1 | Разработка | 10 | 100 | `=C6*D6` |
|
|
781
|
+
| 2 | Поддержка | 5 | 50 | `=C7*D7` |
|
|
782
|
+
| | | | Итого | `=SUM(E6:E7)` |
|
|
783
|
+
| Не оплачено | | | | |
|
|
784
|
+
|
|
785
|
+
## Ограничения
|
|
786
|
+
|
|
787
|
+
1. Только формат `.xlsx` (не `.xls`, `.xlsm`, `.xlsb`).
|
|
788
|
+
2. Структурные теги `{% for %}` / `{% if %}` обязаны занимать отдельную строку.
|
|
789
|
+
3. Колонковые циклы не поддерживаются (только по строкам).
|
|
790
|
+
4. Не поддерживаются Liquid-модификаторы цикла: `reversed`, `limit:`, `offset:`,
|
|
791
|
+
`break`, `continue`. Итерация по хешу (`for k, v in hash`) тоже не работает.
|
|
792
|
+
5. Таблица общих строк (`sharedStrings.xml`) читается, но не перезаписывается —
|
|
793
|
+
весь новый текст пишется как `inlineStr`.
|
|
794
|
+
6. Объединённая ячейка через границу `{% for %}`/`{% if %}`-блока вызывает
|
|
795
|
+
`UnsupportedTemplateError`.
|
|
796
|
+
7. Формулы переводятся по строкам (сдвиг колонок не поддерживается).
|
|
797
|
+
8. Условное форматирование и именованные диапазоны не смещаются при вставке строк.
|
|
798
|
+
9. Сводные таблицы (Pivot) и таблицы Excel (ListObject) не перестраиваются.
|
|
799
|
+
10. Rich text (`<r>` внутри `<is>`) в шаблонных ячейках может теряться.
|
|
800
|
+
11. Внутриячеечный `{% case %}` с веткой `{% else %}` не работает (см. выше).
|
|
801
|
+
12. Клонирование листа-шаблона с собственными рисунками/rels не поддерживается.
|
|
802
|
+
13. `Rational`, `Complex`, `Float::NAN`/`Infinity` не могут быть записаны как
|
|
803
|
+
числовое значение `<v>` (OOXML не допускает таких лексем) — они рендерятся
|
|
804
|
+
как текст. `Integer`, `Float` и `BigDecimal` сохраняются как числа.
|
|
805
|
+
|
|
806
|
+
## Разработка
|
|
807
|
+
|
|
808
|
+
```bash
|
|
809
|
+
bundle install # установка зависимостей
|
|
810
|
+
bundle exec rspec # полный набор тестов (420 примеров)
|
|
811
|
+
bundle exec rspec spec/integration/ # только интеграционные
|
|
812
|
+
bundle exec rubocop # линтер
|
|
813
|
+
bundle exec rubocop -a # автоисправление
|
|
814
|
+
bundle exec rake # rspec + rubocop
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
Основной `Gemfile.lock` резолвит `rubyzip` 3.x. Вторая ветка совместимости
|
|
818
|
+
(`rubyzip` 2.x) проверяется отдельным бандлом:
|
|
819
|
+
|
|
820
|
+
```bash
|
|
821
|
+
BUNDLE_GEMFILE=gemfiles/rubyzip2.gemfile bundle install
|
|
822
|
+
BUNDLE_GEMFILE=gemfiles/rubyzip2.gemfile bundle exec rspec
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
Примеры из этого README покрыты тестами:
|
|
826
|
+
|
|
827
|
+
```bash
|
|
828
|
+
bundle exec rspec spec/integration/readme_core_spec.rb
|
|
829
|
+
bundle exec rspec spec/integration/readme_structural_spec.rb
|
|
830
|
+
bundle exec rspec spec/integration/readme_advanced_spec.rb
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
### Архитектура
|
|
834
|
+
|
|
835
|
+
Конвейер рендеринга: `LiquidXlsx.render` → `Template` → `Package` (ZIP I/O) →
|
|
836
|
+
`Workbook` (оркестрация листов) → для каждого листа `Worksheet` (XML в/из) +
|
|
837
|
+
`Renderer` (управляет AST из `TemplateParser`) → `Package#write`.
|
|
838
|
+
|
|
839
|
+
Ключевые файлы:
|
|
840
|
+
|
|
841
|
+
- `lib/liquid_xlsx.rb` — точка входа, публичный API, регистрация тегов
|
|
842
|
+
- `lib/liquid_xlsx/template.rb` — класс шаблона
|
|
843
|
+
- `lib/liquid_xlsx/package.rb` — чтение/запись ZIP-архива
|
|
844
|
+
- `lib/liquid_xlsx/workbook.rb` — оркестрация нескольких листов
|
|
845
|
+
- `lib/liquid_xlsx/worksheet.rb` — манипуляция XML одного листа
|
|
846
|
+
- `lib/liquid_xlsx/renderer.rb` — применение AST к строкам листа
|
|
847
|
+
- `lib/liquid_xlsx/template_parser.rb` — построение AST структурных тегов
|
|
848
|
+
- `lib/liquid_xlsx/template_nodes.rb` — типы узлов AST
|
|
849
|
+
- `lib/liquid_xlsx/formula_translator.rb` — смещение формул
|
|
850
|
+
- `lib/liquid_xlsx/merge_cells_transformer.rb` — смещение merge-диапазонов
|
|
851
|
+
- `lib/liquid_xlsx/cell_reference.rb` — парсер A1-ссылок
|
|
852
|
+
- `lib/liquid_xlsx/shared_strings.rb` — чтение общих строк
|
|
853
|
+
- `lib/liquid_xlsx/tags/sheet_tag.rb` — тег `{% sheet %}`
|
|
854
|
+
- `lib/liquid_xlsx/tags/image_tag.rb` — тег `{% image_tag %}`
|
|
855
|
+
- `lib/liquid_xlsx/image.rb` — определение размеров PNG/JPEG/GIF
|
|
856
|
+
- `lib/liquid_xlsx/drawing_builder.rb` — построение XML рисунков
|
|
857
|
+
- `lib/liquid_xlsx/errors.rb` — иерархия ошибок
|
|
858
|
+
- `lib/liquid_xlsx/filters.rb` — пользовательские Liquid-фильтры
|
|
859
|
+
|
|
860
|
+
## Лицензия
|
|
861
|
+
|
|
862
|
+
MIT
|