liquid_xlsx

English summary below · Полная документация — на русском, ниже.

English

Generate .xlsx files from Excel templates written in Liquid syntax.

Design the template in Excel itself, put {{ variables }}, {% for %} and {% if %} into cells, and the gem rewrites the OOXML in place — styles, number formats, formulas, merged cells and workbook structure all survive.

gem "liquid_xlsx"
require "liquid_xlsx"

LiquidXlsx.render(
  template: "invoice_template.xlsx",
  output: "invoice.xlsx",
  data: {
    invoice: { number: "INV-001", paid: false },
    customer: { name: "Acme Ltd" },
    items: [
      { title: "Development", qty: 10, price: 100 },
      { title: "Support",     qty: 5,  price: 50 }
    ]
  }
)

Object API: LiquidXlsx::Template.new(path).render_to_file(data, out), or #render(data) for a binary string.

What it does

  • Liquid variables and standard filters in cells: {{ price | round: 2 }}
  • Numbers and booleans stay numeric in Excel, so formulas keep working
  • Row-level {% for %} and {% if %} blocks (the tag occupies its own row)
  • In-cell {% if %}, {% unless %}, {% case %}
  • One Liquid context per sheet, so {% assign %} / {% capture %} carries over
  • Formulas and merged ranges shift automatically as loops expand rows
  • {% sheet %} — generate whole worksheets from data
  • {% image_tag %} — embed PNG / JPEG / GIF
  • Errors report the sheet, row and cell they came from

Requirements: Ruby >= 3.1. Works with both rubyzip 2.x and 3.x.

Main limitations: .xlsx only; structural tags must occupy a dedicated row; loops are row-wise (no column loops); Liquid loop modifiers (reversed, limit:, offset:, break, continue) and hash iteration are unsupported; merges must not straddle a block boundary; conditional formatting, named ranges, pivot tables and Excel tables are not shifted or rebuilt. The full list is in Ограничения.

Everything else — syntax reference, dynamic sheets, images, render options, error handling, a complete invoice example — is documented in Russian below. The code samples there are language-neutral, and each one is backed by a spec in spec/integration/readme_*_spec.rb.

Документация

Генерация файлов .xlsx из Excel-шаблонов с синтаксисом Liquid.

Откройте шаблон в Excel, напишите в ячейках {{ переменные }}, {% for %}, {% if %}, и библиотека сгенерирует итоговый файл, сохранив стили, формулы, объединённые ячейки и структуру книги. Дополнительно поддерживаются динамические листы ({% sheet %}) и вставка изображений ({% image_tag %}).

Все примеры из этого README автоматически проверяются спеками в spec/integration/readme_core_spec.rb, spec/integration/readme_structural_spec.rb, spec/integration/readme_advanced_spec.rb.

Возможности

  • Переменные Liquid в ячейках: {{ customer.name }}
  • Стандартные фильтры Liquid: {{ price | round: 2 }}
  • Сохранение числовых и булевых типов (число остаётся числом, а не строкой)
  • Структурные теги {% for %} и {% if %} на уровне целых строк
  • {% assign %} / {% capture %} с общим контекстом в пределах листа
  • Внутриячеечные теги Liquid: {% if %}, {% unless %}, {% case %}
  • Автоматическое смещение формул и объединённых ячеек в циклах
  • Динамические листы {% sheet %} — генерация листов по данным
  • Изображения {% image_tag %} — PNG, JPEG, GIF
  • Подробные ошибки с указанием листа, строки и ячейки

Установка

Добавьте в Gemfile:

gem "liquid_xlsx"

Или установите глобально:

gem install liquid_xlsx

Требуется Ruby >= 3.1. Работает как с rubyzip 2.x, так и с 3.x.

Быстрый старт

require "liquid_xlsx"

LiquidXlsx.render(
  template: "invoice_template.xlsx",
  output: "invoice.xlsx",
  data: {
    invoice: { number: "INV-001", paid: false },
    customer: { name: "ООО Ромашка" },
    items: [
      { title: "Разработка", qty: 10, price: 100 },
      { title: "Поддержка", qty: 5, price: 50 }
    ]
  }
)

Объектный API

template = LiquidXlsx::Template.new("template.xlsx")
template.render_to_file(data, "output.xlsx") # рендер + запись в файл
binary = template.render(data)                # рендер, возвращает бинарную строку

Синтаксис шаблонов

Библиотека разделяет два слоя Liquid:

  1. Внутриячеечный Liquid — всё, что написано внутри одной ячейки ({{ var }}, {{ var | filter }}, {% if %} внутри ячейки и т.д.). Обрабатывается настоящим движком Liquid.
  2. Структурные теги{% for %}, {% endfor %}, {% if %}, {% elsif %}, {% else %}, {% endif %} на уровне целых строк. Такие теги должны занимать отдельную строку и обрабатываются собственным парсером.

Переменные

Любая текстовая ячейка может содержать Liquid-переменные:

Счёт № {{ invoice.number }}
Клиент: {{ customer.name }}
Город: {{ customer.address.city }}

Поддерживается точечная нотация для вложенных хешей и массивов: {{ customer.address.city }}, {{ items.first.title }}.

Несколько выражений и литеральный текст в одной ячейке:

Привет, {{ name }}! Ваш баланс: {{ balance | round: 2 }}

Источники данных: Hash, Liquid::Drop, любой #to_liquid

Параметр data: принимает не только Hash, но и Liquid::Drop, и любой объект, отвечающий на #to_liquid (возвращающий Hash или Drop). Это удобно для ленивого доступа к данным, вычислимых полей и обёрток над AR-моделями:

class InvoiceDrop < Liquid::Drop
  def initialize(invoice)
    @invoice = invoice
  end

  def number
    @invoice.number            # строка
  end

  def total
    @invoice.lines.sum(&:amount)  # вычислимое числовое поле
  end

  def customer
    CustomerDrop.new(@invoice.customer)  # вложенный Drop
  end
end

LiquidXlsx.render(
  template: "invoice.xlsx",
  output:   "out.xlsx",
  data:     InvoiceDrop.new(invoice)    # <- Drop вместо Hash
)

Для объекта, не являющегося Hash или Liquid::Drop, но реализующего #to_liquid (например, AR-модель, Struct, OpenStruct), метод вызывается один раз на входе, и результат используется как корневое окружение Liquid:

class Report < Struct.new(:title, :count)
  def to_liquid
    { "title" => title, "count" => count }
  end
end

LiquidXlsx.render(template: ..., output: ..., data: Report.new("Sales", 42))

Сохранение типов работает и для Drop. Если метод Drop возвращает Integer или Float, ячейка {{ invoice.total }} запишется как число <v> (не как строка), поэтому формулы =SUM(...) и числовые форматы продолжат работать. BigDecimal также сохраняется как число; Rational, Complex, а также Float::NAN/Infinity (не валидны в OOXML) автоматически рендерятся как текст.

Ограничения:

  • Корневой объект должен реализовывать []/key? (как Hash и Liquid::Drop) либо #to_liquid, возвращающий Hash/Drop. Произвольный объект только с методами-атрибутами, но без to_liquid, работать не будет — Liquid ищет переменные через [].
  • Drop-методы, используемые в {% for %}, должны возвращать именно Array. Enumerable/ActiveRecord::Relation напрямую не поддерживаются — материализуйте их через .to_a в самом методе.
  • Если Drop-метод возвращает вложенный Hash, используйте строковые ключи: stringify_keys рекурсивно нормализует корневой Hash (и результат #to_liquid→Hash), но не применяется к значениям, возвращаемым Drop-методами во время рендера.
  • Один и тот же Drop-метод для одной ячейки {{ var }} может вызываться дважды (для fast-path и для основного рендера). Если у метода есть побочные эффекты или SQL-запросы, мемоизируйте результат.

Сохранение типов

Если вся ячейка целиком состоит из одной переменной {{ var }} (без фильтров, без окружающего текста), значение подставляется напрямую из данных. Числа остаются числами, булевы значения — булевыми:

Шаблон Данные Результат в Excel
{{ qty }} 42 Числовая ячейка <v>42</v>
{{ price }} 19.99 Числовая ячейка <v>19.99</v>
{{ active }} true Булевая ячейка t="b" <v>1</v>
{{ name }} "Иван" Строка inlineStr

Как только добавляется фильтр, окружающий текст или ещё одна переменная — ячейка всегда становится строковой (inlineStr):

{{ price | round: 2 }}      → строка "19.99"
Цена: {{ price }}           → строка "Цена: 19.99"

Фильтры Liquid

Поддерживаются все стандартные фильтры Liquid:

{{ name | upcase }}                  # IVAN PETROV
{{ name | downcase }}                # ivan petrov
{{ name | capitalize }}              # Ivan petrov
{{ price | round: 2 }}               # 19.99
{{ total | plus: 100 }}              # 1100
{{ total | minus: 50 }}              # 950
{{ price | times: 1.2 }}             # 120.0
{{ items | size }}                   # 3
{{ items | join: ", " }}             # яблоко, груша, слива
{{ items | first }}                  # яблоко
{{ items | last }}                   # слива
{{ name | append: "!" }}             # hello!
{{ name | prepend: "Mr. " }}         # Mr. hello
{{ name | replace: "old", "new" }}   # hello new
{{ desc | truncate: 10 }}            # this is...
{{ created_at | date: "%d.%m.%Y" }}  # 15.03.2024
{{ phone | default: "—" }}           # +7... или "—" если nil
{{ text | strip }}                   # обрезка пробелов

Примечание: арифметические фильтры над Float сохраняют дробный формат (100.0 | plus: 10"110.0"). Если нужно целое — добавьте round.

assign и capture — общий контекст на листе

В пределах одного листа создаётся один Liquid::Context, общий для всех ячеек в порядке документа (сверху вниз, слева направо). Поэтому переменная, созданная в одной ячейке, видна в последующих:

A1: {% assign total = 100 %}
B1: {{ total }}                       → 100

A2: {% assign total = total | plus: 50 %}
B2: {{ total }}                       → 150

{% capture %} собирает блок текста в переменную:

A1: {% capture greeting %}Привет, {{ name }}!{% endcapture %}
B1: {{ greeting }}                    → Привет, Мир!

{% assign %} работает и внутри циклов — накопленные значения доступны после {% endfor %} (см. пример ниже).

Внутриячеечные теги Liquid

Внутри одной ячейки можно использовать условные теги Liquid (это НЕ структурные теги уровня строк, обрабатываются самим Liquid):

{% if paid %}Оплачено{% else %}Не оплачено{% endif %}

{% if total > 1000 %}Крупный{% elsif total > 100 %}Средний{% else %}Малый{% endif %}

{% unless cancelled %}Активен{% endunless %}

{% case status %}{% when "active" %}Активен{% when "pending" %}Ожидает{% endcase %}

Поддерживаемые операторы в условиях: ==, !=, >, <, >=, <=, contains; логические and, or; ключевые слова true, false, nil, empty, blank.

Ограничение: внутриячеечный {% case %} с веткой {% else %} в текущей версии не работает — структурный парсер ошибочно воспринимает {% else %} как тег уровня строки. Используйте {% case %}/{% when %} без {% else %}, либо эквивалентный {% if %}/{% elsif %}/{% else %}.

По правилам Liquid пустой массив правдив. {% unless items %} сработает только при items = nil/false, но не на items = []. Для проверки пустоты используйте {% if items == empty %}.

Структурные теги

Правило отдельной строки

{% for %}, {% endfor %}, {% if %}, {% elsif %}, {% else %}, {% endif %} должны занимать отдельную строку: тег один в первой непустой ячейке строки, остальные ячейки строки пусты.

Допустимо Недопустимо
A1: {% for x in xs %} A1: Сумма: {% for x in xs %}
A1: {% for x in xs %} B1: текст

Нарушение правила выбрасывает LiquidXlsx::TemplateSyntaxError с указанием листа, строки и ячейки.

Циклы {% for %}

{% for item in items %}
{{ forloop.index }}
{{ item.title }}
{{ item.qty }}
{{ item.price }}
{% endfor %}

Строки между {% for %} и {% endfor %} повторяются для каждого элемента. Внутри тела доступны переменные цикла:

Переменная Значение
{{ forloop.index }} 1-based индекс (1, 2, 3, ...)
{{ forloop.index0 }} 0-based индекс (0, 1, 2, ...)
{{ forloop.first }} true на первой итерации (булево)
{{ forloop.last }} true на последней итерации
{{ forloop.length }} Общее количество итераций

Стили ячеек, формулы и объединённые ячейки в теле цикла сохраняются и корректно смещаются.

Пустые циклы с {% else %}

{% for item in items %}
{{ item.title }}
{% else %}
Список пуст
{% endfor %}

Если коллекция пуста или равна nil, отрисовывается блок {% else %}. Цикл над nil (ключ отсутствует в данных) тоже безопасен — эквивалентен пустому массиву.

Вложенные циклы

{% for order in orders %}
Заказ {{ order.number }}
{% for line in order.lines %}
  {{ line.title }}: {{ line.qty }}
{% endfor %}
{% endfor %}

Внутренний цикл разрешает коллекцию через Liquid-контекст, в котором уже установлена переменная внешнего цикла (order).

Условия на уровне строк {% if %}

Вся строка включается или исключается целиком:

{% if invoice.paid %}
Оплачено
{% elsif invoice.pending %}
В обработке
{% else %}
Не оплачено
{% endif %}

Условия поддерживают сравнения, and/or, contains, проверку пустоты:

{% if age >= 18 and age <= 65 %}     возрастной диапазон
{% if tags contains "vip" %}         наличие элемента
{% if notes == empty %}              пустая коллекция

Вложенные конструкции

{% if %} внутри {% for %} и наоборот — поддерживаются в любой комбинации:

{% for item in items %}
{% if item.urgent %}
СРОЧНО: {{ item.name }}
{% endif %}
{% endfor %}
{% if show_items %}
{% for item in items %}
{{ item.name }}
{% endfor %}
{% endif %}

Накопление суммы в цикле

{% assign %} внутри тела цикла сохраняется после {% endfor %}:

{% assign sum = 0 %}
{% for item in items %}
{% assign sum = sum | plus: item.qty %}
{% endfor %}
Итого: {{ sum }}                       → Итого: 18 (5 + 10 + 3)

Формулы

Формулы в теле цикла автоматически смещаются по строкам:

Шаблон После 3 итераций
=A2*2 =A2*2, =A3*2, =A4*2

Формулы-суммы под циклом, ссылающиеся на строку-шаблон, раскрываются в диапазон:

Шаблон После 3 итераций
=SUM(A2:A2) =SUM(A2:A4)

Опция recalculate_formulas: true удаляет xl/calcChain.xml и сбрасывает кэшированные значения, заставляя Excel пересчитать всё при открытии.

Объединённые ячейки

Объединённые диапазоны в теле цикла клонируются для каждой итерии:

Шаблон После 3 итераций
A2:B2 A2:B2, A3:B3, A4:B4

Диапазоны ниже блока автоматически смещаются. Объединённая ячейка, переходящая через границу {% for %}/{% if %}-блока, вызывает LiquidXlsx::UnsupportedTemplateError.

Динамические листы {% sheet %}

Тег {% sheet %} создаёт копии указанного листа — по одной на элемент коллекции. Например, по списку счетов можно сгенерировать отдельный лист под каждый счёт.

Требуется опция dynamic_sheets: true.

Синтаксис

{% sheet name: <имя> template: "<шаблон>" data: <данные> as: "<переменная>" %}
Аргумент Обязательный Описание
name: да Имя нового листа (Liquid-выражение или строка)
template: да Имя листа-шаблона (только строковый литерал в ")
data: да Данные для этого листа (Liquid-выражение)
as: нет Имя переменной для data (по умолчанию "item")

Важно: значения template: и строковые литералы в name:/as: должны быть в двойных кавычках. Одинарные кавычки не поддерживаются.

Типичный сценарий

Контрольный лист Index обходит коллекцию и создаёт листы:

{% for inv in invoices %}
{% sheet name: inv.number template: "Invoice" data: inv as: "inv" %}
{% endfor %}

Лист-шаблон Invoice ссылается на локальную переменную:

A1: Счёт № {{ inv.number }}
A2: Клиент: {{ inv.client }}

Вызов:

LiquidXlsx.render(
  template: "tpl.xlsx",
  output: "out.xlsx",
  dynamic_sheets: true,
  data: {
    invoices: [
      { number: "INV-001", client: "Альфа" },
      { number: "INV-002", client: "Бета" }
    ]
  }
)

Результат: в книге появятся листы INV-001 и INV-002, заполненные из соответствующих счетов. Контрольный лист Index будет скрыт.

Liquid-фильтры в name:

{% sheet name: inv.number | append: " - " | append: inv.client template: "Invoice" data: inv as: "inv" %}

→ имя листа INV-001 - Alpha.

Значение по умолчанию as: "item"

Если as: опущено, данные доступны как item:

{% sheet name: inv.number template: "Invoice" data: inv %}

В шаблоне: {{ item.number }}.

Нормализация имён листов

Excel запрещает в именах листов символы : \ / ? * [ ] и ограничивает длину 31 символом. Библиотека автоматически:

  • удаляет запрещённые символы ("Отчёт/За: 2024""ОтчётЗа 2024");
  • обрезает имя до 31 символа;
  • при совпадении имён добавляет суффиксы: DUP, DUP (2), DUP (3);
  • пустое имя заменяет на Sheet.

Поведение по умолчанию

  • hide_control_sheets: true — контрольный лист (с тегом {% sheet %}) скрывается.
  • hide_template_sheets: false — лист-шаблон остаётся видимым. Установите true, чтобы скрыть его.
  • Вызов {% sheet %} без dynamic_sheets: true выбрасывает LiquidXlsx::RenderError.
  • Отсутствующий template: выбрасывает LiquidXlsx::UnsupportedTemplateError.

Изображения {% image_tag %}

Вставка изображений (PNG, JPEG, GIF) прямо из данных. Формат распознаётся по сигнатуре файла (magic bytes).

Синтаксис

{% image_tag <источник>, <опции> %}

Источник — первый позиционный аргумент: Liquid-выражение, возвращающее бинарную строку с данными картинки.

Опция Тип Описание
colspan: целое > 0 Сколько колонок вправо занимает картинка
rowspan: целое > 0 Сколько строк вниз занимает картинка
to: "F10" Правый нижний угол (инклюзивно)
width: целое > 0 px Фиксированная ширина в пикселях
height: целое > 0 px Фиксированная высота в пикселях

Выбор якоря

Якорь определяется в порядке приоритета:

  1. to: — абсолютный правый нижний угол;
  2. colspan:/rowspan: — относительный охват от ячейки-якоря;
  3. ячейка-якорь — левый верх объединённого диапазона → заполнение merge;
  4. width:/height: — фиксированный размер (oneCellAnchor);
  5. по умолчанию — одна ячейка (colspan=1, rowspan=1).

При width: без height: (или наоборот) вторая размерность выводится из натуральных пропорций картинки; для вырожденных размеров (ноль в заголовке) используется квадрат.

Примеры

{% image_tag logo, colspan: 2, rowspan: 3 %}      # заливает 2 колонки и 3 строки
{% image_tag photo, width: 120, height: 60 %}     # фиксированный размер
{% image_tag cover, width: 100 %}                 # высота из пропорций
{% image_tag logo, to: "F10" %}                   # правый нижний угол — F10

Вызов:

LiquidXlsx.render(
  template: "tpl.xlsx",
  output: "out.xlsx",
  data: { "logo" => File.binread("logo.png") }
)

Загрузка по пути/URL

Если источник — строка-путь (например, "logos/x.png"), подключите images.loader:

LiquidXlsx.render(
  template: "tpl.xlsx",
  output: "out.xlsx",
  images: { loader: ->(ref) { File.binread(ref) } },
  data: { "logo_path" => "logos/company.png" }
)

Без loader строка-путь выбрасывает LiquidXlsx::RenderError.

В циклах

Якорь автоматически смещается на каждой итерации:

{% for item in items %}
{% image_tag item.photo, colspan: 1, rowspan: 1 %}
{% endfor %}

Идентичные картинки (одинаковый SHA-256) дедуплицируются — в архиве хранится одна медиа-запись.

Валидация

  • colspan:, rowspan:, width:, height: должны быть положительными целыми числами. Иначе — LiquidXlsx::RenderError.
  • Неподдерживаемый формат — LiquidXlsx::UnsupportedTemplateError.

Опции рендеринга

LiquidXlsx.render(
  template:,
  output:,
  data:,
  strict_variables: false,        # true — raise на отсутствующей переменной
  strict_filters: false,          # true — raise на неизвестном фильтре
  recalculate_formulas: false,    # true — удалить calcChain, форсировать пересчёт
  remove_template_comments: false,# зарезервировано
  dynamic_sheets: false,          # true — включить {% sheet %}
  hide_control_sheets: true,      # скрывать контрольные листы
  hide_template_sheets: false,    # скрывать листы-шаблоны после клонирования
  images: nil,                    # { loader:, default_dpi: } для {% image_tag %}
  liquid_resource_limits: nil,    # { render_length_limit:, render_score_limit:, ... }
  filters: nil                    # модуль (или массив модулей) со своими Liquid-фильтрами
)
Опция По умолчанию Описание
strict_variables false При true отсутствующая переменная → MissingVariableError
strict_filters false При true неизвестный фильтр → исключение Liquid
recalculate_formulas false Удалить calcChain.xml, форсировать пересчёт в Excel
dynamic_sheets false Включить обработку {% sheet %}
hide_control_sheets true Скрывать листы с тегами {% sheet %}
hide_template_sheets false Скрывать листы-шаблоны
images nil { loader:, default_dpi: } — загрузчик путей и DPI для width/height
liquid_resource_limits nil Лимиты Liquid (render_length_limit, render_score_limit, assign_score_limit)
filters nil Модуль или массив модулей со своими фильтрами — см. Свои фильтры

Свои фильтры

Фильтры приложения подключаются опцией filters: и доступны в любой ячейке книги:

module MyFilters
  def money2(value) = format("%.2f", value.to_f)
end

LiquidXlsx.render(template: "invoice.xlsx", output: "out.xlsx",
                  data: data, filters: MyFilters)   # или [MyFilters, DateFilters]
{{ invoice.sum | money2 }}

Модули добавляются только в контекст рендера (Liquid::Context#add_filters), на Liquid::Environment ничего не регистрируется — глобальная конфигурация Liquid в приложении остаётся нетронутой.

Обработка ошибок

Все ошибки наследуются от LiquidXlsx::Error < StandardError. Там, где применимо, объект ошибки содержит атрибуты sheet, row, cell, template.

LiquidXlsx::Error
├── TemplateSyntaxError          нарушение правила отдельной строки,
│                                непарные структурные теги
├── RenderError                  ошибка рендера (включая {% sheet %}/{% image_tag %}),
│   └── MissingVariableError     при strict_variables: true
├── UnsupportedTemplateError     merge через границу блока, неизвестный формат
│                                картинки, отсутствие template-листа
├── InvalidXlsxError             битый .xlsx на входе
└── OutputWriteError             ошибка записи выходного файла

Пример сообщения:

LiquidXlsx::TemplateSyntaxError:
Structural tag must be placed on a dedicated row.
Sheet: Invoice
Row: 12
Cell: A12
Tag: {% for item in items %}

Полный пример: счёт-фактура

Шаблон invoice.xlsx:

A B C D E
Счёт № invoice.number }
Клиент customer.name }

| | | | | | | № | Услуга | Кол-во | Цена | Сумма | | {% for item in items %} | | | | | | forloop.index } | item.title } | item.qty } | item.price } | =C6*D6 | | {% endfor %} | | | | | | | | | Итого | =SUM(E6:E6) | | {% if invoice.paid %} | | | | | | Оплачено | | | | | | {% else %} | | | | | | Не оплачено | | | | | | {% endif %} | | | | |

Рендер:

LiquidXlsx.render(
  template: "invoice.xlsx",
  output: "invoice_001.xlsx",
  data: {
    invoice: { number: "INV-001", paid: false },
    customer: { name: "ООО Ромашка" },
    items: [
      { title: "Разработка", qty: 10, price: 100 },
      { title: "Поддержка", qty: 5, price: 50 }
    ]
  }
)

Результат:

A B C D E
Счёт № INV-001
Клиент ООО Ромашка

| | | | | | | № | Услуга | Кол-во | Цена | Сумма | | 1 | Разработка | 10 | 100 | =C6*D6 | | 2 | Поддержка | 5 | 50 | =C7*D7 | | | | | Итого | =SUM(E6:E7) | | Не оплачено | | | | |

Ограничения

  1. Только формат .xlsx (не .xls, .xlsm, .xlsb).
  2. Структурные теги {% for %} / {% if %} обязаны занимать отдельную строку.
  3. Колонковые циклы не поддерживаются (только по строкам).
  4. Не поддерживаются Liquid-модификаторы цикла: reversed, limit:, offset:, break, continue. Итерация по хешу (for k, v in hash) тоже не работает.
  5. Таблица общих строк (sharedStrings.xml) читается, но не перезаписывается — весь новый текст пишется как inlineStr.
  6. Объединённая ячейка через границу {% for %}/{% if %}-блока вызывает UnsupportedTemplateError.
  7. Формулы переводятся по строкам (сдвиг колонок не поддерживается).
  8. Условное форматирование и именованные диапазоны не смещаются при вставке строк.
  9. Сводные таблицы (Pivot) и таблицы Excel (ListObject) не перестраиваются.
  10. Rich text (<r> внутри <is>) в шаблонных ячейках может теряться.
  11. Внутриячеечный {% case %} с веткой {% else %} не работает (см. выше).
  12. Клонирование листа-шаблона с собственными рисунками/rels не поддерживается.
  13. Rational, Complex, Float::NAN/Infinity не могут быть записаны как числовое значение <v> (OOXML не допускает таких лексем) — они рендерятся как текст. Integer, Float и BigDecimal сохраняются как числа.

Разработка

bundle install                        # установка зависимостей
bundle exec rspec                     # полный набор тестов (420 примеров)
bundle exec rspec spec/integration/   # только интеграционные
bundle exec rubocop                   # линтер
bundle exec rubocop -a                # автоисправление
bundle exec rake                      # rspec + rubocop

Основной Gemfile.lock резолвит rubyzip 3.x. Вторая ветка совместимости (rubyzip 2.x) проверяется отдельным бандлом:

BUNDLE_GEMFILE=gemfiles/rubyzip2.gemfile bundle install
BUNDLE_GEMFILE=gemfiles/rubyzip2.gemfile bundle exec rspec

Примеры из этого README покрыты тестами:

bundle exec rspec spec/integration/readme_core_spec.rb
bundle exec rspec spec/integration/readme_structural_spec.rb
bundle exec rspec spec/integration/readme_advanced_spec.rb

Архитектура

Конвейер рендеринга: LiquidXlsx.renderTemplatePackage (ZIP I/O) → Workbook (оркестрация листов) → для каждого листа Worksheet (XML в/из) + Renderer (управляет AST из TemplateParser) → Package#write.

Ключевые файлы:

  • lib/liquid_xlsx.rb — точка входа, публичный API, регистрация тегов
  • lib/liquid_xlsx/template.rb — класс шаблона
  • lib/liquid_xlsx/package.rb — чтение/запись ZIP-архива
  • lib/liquid_xlsx/workbook.rb — оркестрация нескольких листов
  • lib/liquid_xlsx/worksheet.rb — манипуляция XML одного листа
  • lib/liquid_xlsx/renderer.rb — применение AST к строкам листа
  • lib/liquid_xlsx/template_parser.rb — построение AST структурных тегов
  • lib/liquid_xlsx/template_nodes.rb — типы узлов AST
  • lib/liquid_xlsx/formula_translator.rb — смещение формул
  • lib/liquid_xlsx/merge_cells_transformer.rb — смещение merge-диапазонов
  • lib/liquid_xlsx/cell_reference.rb — парсер A1-ссылок
  • lib/liquid_xlsx/shared_strings.rb — чтение общих строк
  • lib/liquid_xlsx/tags/sheet_tag.rb — тег {% sheet %}
  • lib/liquid_xlsx/tags/image_tag.rb — тег {% image_tag %}
  • lib/liquid_xlsx/image.rb — определение размеров PNG/JPEG/GIF
  • lib/liquid_xlsx/drawing_builder.rb — построение XML рисунков
  • lib/liquid_xlsx/errors.rb — иерархия ошибок
  • lib/liquid_xlsx/filters.rb — пользовательские Liquid-фильтры

Лицензия

MIT