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.
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