liquid_xlsx 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 405eceeb12cbc1b76e2bb0fa1ef24f351f544551268799cffd3f1265fa3bda7f
4
- data.tar.gz: 13f0be15c97eaf8f21c8e4b4b1c4a2665032f8a8b7fc0ae885a1c1a2fb2cb126
3
+ metadata.gz: 619408f5440742c3210d5a61c30e44b16165d94ab673cefbb132b310310e0076
4
+ data.tar.gz: 208342e1abeed7c51352a204522030f16a36d8d7a3a08e1f6e07ca4a32f08a2d
5
5
  SHA512:
6
- metadata.gz: 1677f21ae1c65f95cf62fe1728cdeadef70e96a051ed33a1ed7acf57eb2b9067e00f3f4d1901caf20d9d23f37edbec601cef7115704404e75eaaca160d1d6405
7
- data.tar.gz: 9eda27bf7211ffc329af17e9ddf9992dcda7e551ff9526d62afb8df870e56d88381cd369acf120704ede782e10ded1650c410e97d71ebbf5159bff46b311e630
6
+ metadata.gz: 9edb7d9a0473e8220bf4a8f78f492369db9fea4a1b9c07fc4ce92c71f3d999848ce7e64ae53650c84f34541ef091e133f49aad47e96189c7163043c5f902ee13
7
+ data.tar.gz: f9054e1f438f9253f8a03e5e1dd8c806d9ab9e4465ccfbaa95df14d4bc0d8b42935213ad7adf820bd106fc992a5a4ec70d13974e93979128c54cda85bccffd97
data/CHANGELOG.md CHANGED
@@ -1,5 +1,72 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 (2026-10-25)
4
+
5
+ ### Added
6
+
7
+ - Explicit `streaming: true` rendering to files, including `Template#render_to_file`.
8
+ Rows are serialized directly to a Tempfile without output DOM nodes or retained
9
+ row/cell hashes. Worksheet ZIP entries stream from disk with exact byte sizes
10
+ on rubyzip 2 and 3; temporary row files are released on success and failure.
11
+ - Workbook-wide preflight with `StreamingUnsupportedError` coordinates for
12
+ formulas, images, sheet tags and merged ranges at or below the first loop.
13
+ Header merges, nested/consecutive loops, row conditions, shared Liquid context,
14
+ styles, dimensions, column widths and frozen panes are preserved.
15
+ - Single-pass streaming loops over any object implementing `each`, including
16
+ sized/unsized and lazy Enumerators. Length-dependent `forloop` fields are only
17
+ available when `size` returns an Integer; otherwise they are empty or raise
18
+ `MissingVariableError` in strict mode.
19
+ - Compiled simple-variable expressions in streaming cells. Strings avoid
20
+ repeated Liquid rendering; nil/missing values retain Liquid error semantics.
21
+ String-keyed row hashes and collection arrays are not copied.
22
+ - `test_data/bench_stream.rb`: 19-column file-render benchmark for Array/Enumerator
23
+ input with wall time and peak RSS measurements, with or without YJIT.
24
+
25
+ The default renderer is unchanged. Streaming binary-string output is explicitly
26
+ rejected with `ArgumentError`.
27
+
28
+ ## 0.3.0 (2026-08-14)
29
+
30
+ ### Added
31
+
32
+ - `LiquidXlsx.validate(template:)` — check a template without rendering it and
33
+ without any data. Returns a `Validation::Result` listing every problem found
34
+ instead of raising on the first one.
35
+
36
+ This is deliberately not "render it and see what happens". The renderer parses
37
+ only the Liquid it actually executes, so a mistake inside an `{% if %}` branch
38
+ the current document does not take, or inside a loop over an empty collection,
39
+ stays invisible until some other document hits it. Rendering also stops at the
40
+ first failure and runs the template against live records — a heavy price for
41
+ the question "is the file I just uploaded any good?".
42
+
43
+ Issue codes: `:invalid_xlsx`, `:missing_worksheet`, `:template_syntax`,
44
+ `:liquid_syntax`, `:liquid_expression` (warning), `:unknown_filter`,
45
+ `:sheet_tag_disabled`, `:sheet_template_missing`,
46
+ `:merged_cell_crosses_block`, `:liquid_in_formula` (warning),
47
+ `:liquid_in_rich_text` (warning). Each carries the sheet, row and cell it came
48
+ from, plus a stable `code` a host application can map to its own wording.
49
+
50
+ Beyond issues, the result reports what the template *uses*: `roots` (the input
51
+ variables it expects, with loop variables and `{% assign %}` names excluded),
52
+ full `variables` paths, and `filters` — so a host application can check them
53
+ against its own data model without parsing Liquid itself.
54
+
55
+ - Two silent losses are now reported, both of which used to produce a document
56
+ with tags left in it and no error anywhere:
57
+ - Liquid inside a formula is never evaluated (`:liquid_in_formula`).
58
+ - Liquid inside a rich-text inline string — text split into runs because part
59
+ of the cell is formatted differently — is not read (`:liquid_in_rich_text`).
60
+
61
+ - Expressions Liquid silently misreads are reported as warnings
62
+ (`:liquid_expression`): `{{ invoice.total b c }}` and `{{ invoice..total }}`
63
+ print the total and quietly drop the rest. Liquid's default lax parser accepts
64
+ them, so nothing fails — which is exactly why they are worth pointing out.
65
+ Only fragments the renderer would actually reject are errors.
66
+
67
+ - `Tags::SheetTag#template_name` / `#as_name` are now public, so the sheet a
68
+ `{% sheet %}` clones can be checked without rendering the tag.
69
+
3
70
  ## 0.2.2 (2026-08-14)
4
71
 
5
72
  ### Fixed
data/README.md CHANGED
@@ -44,7 +44,10 @@ Object API: `LiquidXlsx::Template.new(path).render_to_file(data, out)`, or
44
44
  - Formulas and merged ranges shift automatically as loops expand rows
45
45
  - `{% sheet %}` — generate whole worksheets from data
46
46
  - `{% image_tag %}` — embed PNG / JPEG / GIF
47
+ - Explicit `streaming: true` file rendering for large, restricted templates
47
48
  - Errors report the sheet, row and cell they came from
49
+ - `LiquidXlsx.validate` — check a template without data: every problem at once,
50
+ including the branches a trial render would never reach
48
51
 
49
52
  **Requirements:** Ruby >= 3.1. Works with both rubyzip 2.x and 3.x.
50
53
 
@@ -131,6 +134,75 @@ template.render_to_file(data, "output.xlsx") # рендер + запись в ф
131
134
  binary = template.render(data) # рендер, возвращает бинарную строку
132
135
  ```
133
136
 
137
+ ## Потоковый режим
138
+
139
+ Для выгрузок на сотни тысяч строк включите `streaming: true`. Каждая готовая
140
+ строка сразу записывается во временный файл; строки результата и DOM листа
141
+ в памяти не накапливаются. Затем лист записывается в ZIP с точным байтовым
142
+ размером, без ZIP64. Временные файлы удаляются и при успехе, и при исключении.
143
+ Без этой опции используется прежний рендер со всеми возможностями.
144
+
145
+ ```ruby
146
+ rows = Enumerator.new do |output|
147
+ 400_000.times do |index|
148
+ output << { "name" => "Product #{index}", "price" => index + 0.5 }
149
+ end
150
+ end
151
+
152
+ LiquidXlsx.render(template: "prices.xlsx", output: "prices_out.xlsx",
153
+ data: { "rows" => rows }, streaming: true)
154
+
155
+ # Объектный API: опция в конструкторе или для отдельного вызова.
156
+ template = LiquidXlsx::Template.new("prices.xlsx", streaming: true)
157
+ template.render_to_file(data, "prices_out.xlsx")
158
+ LiquidXlsx::Template.new("prices.xlsx").render_to_file(data, "prices_out.xlsx", streaming: true)
159
+ ```
160
+
161
+ `Template#render`, возвращающий бинарную строку, при `streaming: true`
162
+ выбрасывает `ArgumentError`: потоковый результат нужно писать в файл.
163
+
164
+ Поддерживаются вложенные и последовательные циклы, строки `{% if/elsif/else %}`,
165
+ внутриячеечный Liquid, общий контекст `assign/capture`, стили ячеек и строк,
166
+ высоты, ширины колонок, закрепление и объединения **выше первого цикла**.
167
+ Пустые ячейки без стиля не записываются; `dimension` учитывает записанные
168
+ ячейки и число выведенных строк.
169
+
170
+ До исполнения данных проверяются **все листы и ветки шаблона**. Любая формула
171
+ на листе, объединение в строке первого цикла или ниже (в том числе пересекающее
172
+ его начало), `{% image_tag %}` и `{% sheet %}` вызывают
173
+ `LiquidXlsx::StreamingUnsupportedError` с листом, строкой и ячейкой.
174
+ Объединения через границу условного блока по-прежнему запрещены.
175
+
176
+ Коллекцией цикла может быть любой объект с `each`: `Array`, `Enumerator`,
177
+ `Enumerator::Lazy`, объект приложения или результат метода Drop. Гем вызывает
178
+ `each` один раз на исполнение цикла; `for ... else` определяется по наличию
179
+ выданных элементов. `size`, если он есть, должен возвращать число элементов
180
+ без обхода коллекции. Только целый `Integer` считается известным размером.
181
+ При неизвестном размере доступны `forloop.index`, `index0`, `first`;
182
+ `length`, `last`, `rindex`, `rindex0` дают пустое значение в lenient-режиме и
183
+ `MissingVariableError` с координатами при `strict_variables: true`.
184
+
185
+ Для минимального расхода памяти передавайте Enumerator и Hash со строковыми
186
+ ключами на всех уровнях. Такой Hash элемента передаётся в контекст без копии;
187
+ Hash с другими ключами нормализуется при получении элемента. Массивы коллекций
188
+ заранее не обходятся и не копируются. Уже созданный Array остаётся в памяти
189
+ приложения; потоковый рендер устраняет дополнительное накопление результата.
190
+ Шаблон, shared strings и остальные исходные части книги по-прежнему читаются
191
+ в память; расход памяти зависит от их размера и от одной текущей строки.
192
+ В потоковом режиме выполняется minor GC раз в 16 384 выведенные строки, чтобы
193
+ ограничить временные аллокации при большом входном Array; настройки GC процесса
194
+ не меняются. Для XML строк требуется свободное место во временном каталоге (rubyzip также
195
+ использует временные файлы при сборке архива).
196
+
197
+ Измерение полного рендера до готового файла, с пиковым приростом RSS из
198
+ `/proc/self/status` (`VmHWM`), в отдельном процессе для каждого запуска:
199
+
200
+ ```bash
201
+ bundle exec ruby test_data/bench_stream.rb normal array 20000
202
+ bundle exec ruby test_data/bench_stream.rb streaming array 100000
203
+ bundle exec ruby --yjit test_data/bench_stream.rb streaming enumerator 400000
204
+ ```
205
+
134
206
  ## Синтаксис шаблонов
135
207
 
136
208
  Библиотека разделяет два слоя Liquid:
@@ -221,8 +293,8 @@ LiquidXlsx.render(template: ..., output: ..., data: Report.new("Sales", 42))
221
293
  либо `#to_liquid`, возвращающий Hash/Drop. Произвольный объект только с
222
294
  методами-атрибутами, но без `to_liquid`, работать не будет — Liquid ищет
223
295
  переменные через `[]`.
224
- - Drop-методы, используемые в `{% for %}`, должны возвращать именно `Array`.
225
- `Enumerable`/`ActiveRecord::Relation` напрямую не поддерживаются —
296
+ - Drop-методы, используемые в `{% for %}`, в обычном режиме должны возвращать именно `Array`.
297
+ `Enumerable`/`ActiveRecord::Relation` в обычном режиме напрямую не поддерживаются —
226
298
  материализуйте их через `.to_a` в самом методе.
227
299
  - Если Drop-метод возвращает вложенный `Hash`, используйте **строковые** ключи:
228
300
  `stringify_keys` рекурсивно нормализует корневой `Hash` (и результат
@@ -681,6 +753,7 @@ LiquidXlsx.render(
681
753
  template:,
682
754
  output:,
683
755
  data:,
756
+ streaming: false, # true — строки результата через временный файл
684
757
  strict_variables: false, # true — raise на отсутствующей переменной
685
758
  strict_filters: false, # true — raise на неизвестном фильтре
686
759
  recalculate_formulas: false, # true — удалить calcChain, форсировать пересчёт
@@ -696,6 +769,7 @@ LiquidXlsx.render(
696
769
 
697
770
  | Опция | По умолчанию | Описание |
698
771
  |------------------------|--------------|---------------------------------------------------------|
772
+ | `streaming` | `false` | Потоковый рендер в файл с ограничениями шаблона |
699
773
  | `strict_variables` | `false` | При `true` отсутствующая переменная → `MissingVariableError` |
700
774
  | `strict_filters` | `false` | При `true` неизвестный фильтр → исключение Liquid |
701
775
  | `recalculate_formulas` | `false` | Удалить `calcChain.xml`, форсировать пересчёт в Excel |
@@ -727,6 +801,72 @@ LiquidXlsx.render(template: "invoice.xlsx", output: "out.xlsx",
727
801
  на `Liquid::Environment` ничего не регистрируется — глобальная конфигурация
728
802
  Liquid в приложении остаётся нетронутой.
729
803
 
804
+ ## Проверка шаблона без рендера
805
+
806
+ `LiquidXlsx.validate` разбирает шаблон и возвращает список всех найденных
807
+ проблем. Данные не нужны, ничего не исполняется, на первой ошибке разбор не
808
+ останавливается.
809
+
810
+ ```ruby
811
+ result = LiquidXlsx.validate(
812
+ template: "invoice.xlsx",
813
+ known_filters: MyApp::Filters.public_instance_methods(false), # необязательно
814
+ dynamic_sheets: false # как будете рендерить
815
+ )
816
+
817
+ result.valid? # => false
818
+ result.errors # ошибки: документ не напечатается
819
+ result.warnings # предупреждения: напечатается, но не так, как задумано
820
+
821
+ result.errors.first.code # => :liquid_syntax
822
+ result.errors.first.location # => "Invoice!B12"
823
+ ```
824
+
825
+ Почему это не «отрендерить и посмотреть»: рендер разбирает только тот Liquid,
826
+ который реально исполняет. Опечатка в ветке `{% if %}`, которую этот документ не
827
+ выбрал, или в теле цикла по пустой коллекции при пробном рендере не всплывёт — и
828
+ дождётся другого документа. Плюс рендер выполняет шаблон на живых записях, что
829
+ для проверки только что загруженного файла — лишнее.
830
+
831
+ ### Что находит
832
+
833
+ | Код | Уровень | Что это |
834
+ |---|---|---|
835
+ | `:invalid_xlsx` | ошибка | файл не читается как книга `.xlsx` |
836
+ | `:missing_worksheet` | ошибка | лист объявлен в `workbook.xml`, а его части в пакете нет |
837
+ | `:template_syntax` | ошибка | непарные структурные теги, тег не на отдельной строке |
838
+ | `:liquid_syntax` | ошибка | Liquid не разберёт фрагмент — рендер упадёт |
839
+ | `:liquid_expression` | предупреждение | Liquid прочтёт выражение иначе, чем написано |
840
+ | `:unknown_filter` | ошибка | фильтра не будет при рендере |
841
+ | `:sheet_tag_disabled` | ошибка | `{% sheet %}` при `dynamic_sheets: false` |
842
+ | `:sheet_template_missing` | ошибка | `{% sheet template: "X" %}`, а листа `X` нет |
843
+ | `:merged_cell_crosses_block` | ошибка | объединение пересекает границу блока |
844
+ | `:liquid_in_formula` | предупреждение | Liquid внутри формулы не вычисляется |
845
+ | `:liquid_in_rich_text` | предупреждение | Liquid в ячейке с разным форматированием не читается |
846
+
847
+ Разница между `:liquid_syntax` и `:liquid_expression` существенна.
848
+ `{{ invoice.total b c }}` и `{{ invoice..total }}` Liquid принимает: печатает
849
+ сумму, а лишнее молча выбрасывает. Документ выйдет, поэтому это предупреждение,
850
+ а не ошибка — но в шаблоне написано не то, что имелось в виду.
851
+
852
+ ### Что шаблон использует
853
+
854
+ ```ruby
855
+ result.roots # => ["invoice", "customer"] — что шаблон ждёт на входе
856
+ result.variables # => [#<VariableUse root: "invoice", segments: ["items", "title"]>, ...]
857
+ result.filters # => ["money2", "date_words"]
858
+ result.sheets # => ["Invoice", "Приложение"]
859
+ ```
860
+
861
+ `roots` — именно входные переменные: имена, которые шаблон заводит сам
862
+ (`{% for item in ... %}`, `{% assign %}`, `{% capture %}`), из списка исключены.
863
+ Так приложение может сверить шаблон со своей моделью данных, не разбирая Liquid
864
+ самостоятельно.
865
+
866
+ У пути есть признак `truncated?`: в `{{ items[i].title }}` за динамическим
867
+ индексом проверять нечего, и об этом сказано явно — чтобы вызывающий код не
868
+ объявил «нет такого поля» там, где поле определяется в рантайме.
869
+
730
870
  ## Обработка ошибок
731
871
 
732
872
  Все ошибки наследуются от `LiquidXlsx::Error < StandardError`. Там, где
@@ -739,7 +879,9 @@ LiquidXlsx::Error
739
879
  ├── RenderError ошибка рендера (включая {% sheet %}/{% image_tag %}),
740
880
  │ └── MissingVariableError при strict_variables: true
741
881
  ├── UnsupportedTemplateError merge через границу блока, неизвестный формат
742
- │ картинки, отсутствие template-листа
882
+ │ │ картинки, отсутствие template-листа
883
+ │ └── StreamingUnsupportedError формулы, images, sheet или merges ниже цикла
884
+ │ в потоковом режиме
743
885
  ├── InvalidXlsxError битый .xlsx на входе
744
886
  └── OutputWriteError ошибка записи выходного файла
745
887
  ```
@@ -830,7 +972,7 @@ LiquidXlsx.render(
830
972
 
831
973
  ```bash
832
974
  bundle install # установка зависимостей
833
- bundle exec rspec # полный набор тестов (420 примеров)
975
+ bundle exec rspec # полный набор тестов
834
976
  bundle exec rspec spec/integration/ # только интеграционные
835
977
  bundle exec rubocop # линтер
836
978
  bundle exec rubocop -a # автоисправление
@@ -74,6 +74,8 @@ module LiquidXlsx
74
74
  end
75
75
  end
76
76
 
77
+ class StreamingUnsupportedError < UnsupportedTemplateError; end
78
+
77
79
  class InvalidXlsxError < Error; end
78
80
 
79
81
  class OutputWriteError < Error; end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module LiquidXlsx
6
+ # A worksheet envelope around rows on disk. Ownership passes to Package;
7
+ # neither the complete XML nor its ZIP input is accumulated in memory.
8
+ class FilePart
9
+ attr_reader :file
10
+
11
+ def initialize(file, prefix, suffix)
12
+ @file = file
13
+ @prefix = prefix
14
+ @suffix = suffix
15
+ end
16
+
17
+ def bytesize
18
+ @prefix.bytesize + @file.size + @suffix.bytesize
19
+ end
20
+
21
+ def write_to(output)
22
+ output.write(@prefix)
23
+ @file.rewind
24
+ IO.copy_stream(@file, output)
25
+ output.write(@suffix)
26
+ end
27
+
28
+ def close
29
+ @file.close!
30
+ end
31
+ end
32
+ end
@@ -123,6 +123,11 @@ module LiquidXlsx
123
123
  end
124
124
  end
125
125
 
126
+ # Release owned worksheet files on success and on any render/write failure.
127
+ def close_file_parts
128
+ @files.each_value { |content| content.close if content.is_a?(FilePart) }
129
+ end
130
+
126
131
  # Write modified content to a binary string.
127
132
  # @return [String]
128
133
  def to_binary
@@ -462,13 +467,25 @@ module LiquidXlsx
462
467
  # content it would disagree with what actually gets written.
463
468
  # See ZIP_SIZE_KEYWORD — rubyzip 2.x has no such keyword and needs none.
464
469
  def write_entry(zip, name, content)
465
- if ZIP_SIZE_KEYWORD
470
+ if content.is_a?(FilePart)
471
+ write_file_entry(zip, name, content)
472
+ elsif ZIP_SIZE_KEYWORD
466
473
  zip.get_output_stream(name, size: content.bytesize) { |f| f.write(content) }
467
474
  else
468
475
  zip.get_output_stream(name) { |f| f.write(content) }
469
476
  end
470
477
  end
471
478
 
479
+ def write_file_entry(zip, name, content)
480
+ if ZIP_SIZE_KEYWORD
481
+ zip.get_output_stream(name, size: content.bytesize) { |f| content.write_to(f) }
482
+ else
483
+ entry = Zip::Entry.new("", name)
484
+ entry.size = content.bytesize
485
+ zip.get_output_stream(entry) { |f| content.write_to(f) }
486
+ end
487
+ end
488
+
472
489
  # Open a brand-new archive for writing across rubyzip 2.x and 3.x.
473
490
  # See ZIP_CREATE_KEYWORD for why the call has to be spelled two ways.
474
491
  def open_new_zip(output_path, &)
@@ -741,18 +741,10 @@ module LiquidXlsx
741
741
 
742
742
  private
743
743
 
744
+ # Shared with Validator: the renderer only sees blocks it executes, the
745
+ # validator sees all of them, and the two must judge a crossing the same way.
744
746
  def crosses_boundary?(merge_start, merge_end, block_start, block_end)
745
- # Merge entirely above or below the block
746
- return false if merge_end < block_start || merge_start > block_end
747
-
748
- # Merge strictly inside the block body (between the tag rows)
749
- return false if merge_start > block_start && merge_end < block_end
750
-
751
- # Merge entirely on a single structural tag row (it is consumed with it)
752
- return false if merge_start == merge_end && (merge_start == block_start || merge_start == block_end)
753
-
754
- # Any other overlap = crossing (including merges that swallow a tag row)
755
- true
747
+ Validation::BlockBoundary.crosses?(merge_start, merge_end, block_start, block_end)
756
748
  end
757
749
 
758
750
  def clone_merge(ref, start_offset, end_offset)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ # Liquid does not define Object#to_liquid for arbitrary enumerables. Adapt
5
+ # only values read from the context, without mutating caller-owned hashes.
6
+ class StreamingContext < Liquid::Context
7
+ class Collection
8
+ def initialize(source)
9
+ @source = source
10
+ end
11
+
12
+ def to_liquid
13
+ self
14
+ end
15
+
16
+ def each(&)
17
+ @source.each(&)
18
+ end
19
+
20
+ def size
21
+ @source.size if @source.respond_to?(:size)
22
+ end
23
+ end
24
+
25
+ def lookup_and_evaluate(obj, key, **options)
26
+ value = super
27
+ if !value.respond_to?(:to_liquid) && value.respond_to?(:each)
28
+ Collection.new(value)
29
+ else
30
+ value
31
+ end
32
+ end
33
+ end
34
+ end