liquid_xlsx 0.3.0 → 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 +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +78 -4
- data/lib/liquid_xlsx/errors.rb +2 -0
- data/lib/liquid_xlsx/file_part.rb +32 -0
- data/lib/liquid_xlsx/package.rb +18 -1
- data/lib/liquid_xlsx/streaming_context.rb +34 -0
- data/lib/liquid_xlsx/streaming_renderer.rb +300 -0
- data/lib/liquid_xlsx/template.rb +13 -1
- data/lib/liquid_xlsx/version.rb +1 -1
- data/lib/liquid_xlsx/workbook.rb +27 -0
- data/lib/liquid_xlsx/worksheet.rb +24 -0
- data/lib/liquid_xlsx.rb +7 -2
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 619408f5440742c3210d5a61c30e44b16165d94ab673cefbb132b310310e0076
|
|
4
|
+
data.tar.gz: 208342e1abeed7c51352a204522030f16a36d8d7a3a08e1f6e07ca4a32f08a2d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9edb7d9a0473e8220bf4a8f78f492369db9fea4a1b9c07fc4ce92c71f3d999848ce7e64ae53650c84f34541ef091e133f49aad47e96189c7163043c5f902ee13
|
|
7
|
+
data.tar.gz: f9054e1f438f9253f8a03e5e1dd8c806d9ab9e4465ccfbaa95df14d4bc0d8b42935213ad7adf820bd106fc992a5a4ec70d13974e93979128c54cda85bccffd97
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
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
|
+
|
|
3
28
|
## 0.3.0 (2026-08-14)
|
|
4
29
|
|
|
5
30
|
### Added
|
data/README.md
CHANGED
|
@@ -44,6 +44,7 @@ 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
|
|
48
49
|
- `LiquidXlsx.validate` — check a template without data: every problem at once,
|
|
49
50
|
including the branches a trial render would never reach
|
|
@@ -133,6 +134,75 @@ template.render_to_file(data, "output.xlsx") # рендер + запись в ф
|
|
|
133
134
|
binary = template.render(data) # рендер, возвращает бинарную строку
|
|
134
135
|
```
|
|
135
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
|
+
|
|
136
206
|
## Синтаксис шаблонов
|
|
137
207
|
|
|
138
208
|
Библиотека разделяет два слоя Liquid:
|
|
@@ -223,8 +293,8 @@ LiquidXlsx.render(template: ..., output: ..., data: Report.new("Sales", 42))
|
|
|
223
293
|
либо `#to_liquid`, возвращающий Hash/Drop. Произвольный объект только с
|
|
224
294
|
методами-атрибутами, но без `to_liquid`, работать не будет — Liquid ищет
|
|
225
295
|
переменные через `[]`.
|
|
226
|
-
- Drop-методы, используемые в `{% for %}`, должны возвращать именно `Array`.
|
|
227
|
-
`Enumerable`/`ActiveRecord::Relation` напрямую не поддерживаются —
|
|
296
|
+
- Drop-методы, используемые в `{% for %}`, в обычном режиме должны возвращать именно `Array`.
|
|
297
|
+
`Enumerable`/`ActiveRecord::Relation` в обычном режиме напрямую не поддерживаются —
|
|
228
298
|
материализуйте их через `.to_a` в самом методе.
|
|
229
299
|
- Если Drop-метод возвращает вложенный `Hash`, используйте **строковые** ключи:
|
|
230
300
|
`stringify_keys` рекурсивно нормализует корневой `Hash` (и результат
|
|
@@ -683,6 +753,7 @@ LiquidXlsx.render(
|
|
|
683
753
|
template:,
|
|
684
754
|
output:,
|
|
685
755
|
data:,
|
|
756
|
+
streaming: false, # true — строки результата через временный файл
|
|
686
757
|
strict_variables: false, # true — raise на отсутствующей переменной
|
|
687
758
|
strict_filters: false, # true — raise на неизвестном фильтре
|
|
688
759
|
recalculate_formulas: false, # true — удалить calcChain, форсировать пересчёт
|
|
@@ -698,6 +769,7 @@ LiquidXlsx.render(
|
|
|
698
769
|
|
|
699
770
|
| Опция | По умолчанию | Описание |
|
|
700
771
|
|------------------------|--------------|---------------------------------------------------------|
|
|
772
|
+
| `streaming` | `false` | Потоковый рендер в файл с ограничениями шаблона |
|
|
701
773
|
| `strict_variables` | `false` | При `true` отсутствующая переменная → `MissingVariableError` |
|
|
702
774
|
| `strict_filters` | `false` | При `true` неизвестный фильтр → исключение Liquid |
|
|
703
775
|
| `recalculate_formulas` | `false` | Удалить `calcChain.xml`, форсировать пересчёт в Excel |
|
|
@@ -807,7 +879,9 @@ LiquidXlsx::Error
|
|
|
807
879
|
├── RenderError ошибка рендера (включая {% sheet %}/{% image_tag %}),
|
|
808
880
|
│ └── MissingVariableError при strict_variables: true
|
|
809
881
|
├── UnsupportedTemplateError merge через границу блока, неизвестный формат
|
|
810
|
-
│
|
|
882
|
+
│ │ картинки, отсутствие template-листа
|
|
883
|
+
│ └── StreamingUnsupportedError формулы, images, sheet или merges ниже цикла
|
|
884
|
+
│ в потоковом режиме
|
|
811
885
|
├── InvalidXlsxError битый .xlsx на входе
|
|
812
886
|
└── OutputWriteError ошибка записи выходного файла
|
|
813
887
|
```
|
|
@@ -898,7 +972,7 @@ LiquidXlsx.render(
|
|
|
898
972
|
|
|
899
973
|
```bash
|
|
900
974
|
bundle install # установка зависимостей
|
|
901
|
-
bundle exec rspec # полный набор тестов
|
|
975
|
+
bundle exec rspec # полный набор тестов
|
|
902
976
|
bundle exec rspec spec/integration/ # только интеграционные
|
|
903
977
|
bundle exec rubocop # линтер
|
|
904
978
|
bundle exec rubocop -a # автоисправление
|
data/lib/liquid_xlsx/errors.rb
CHANGED
|
@@ -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
|
data/lib/liquid_xlsx/package.rb
CHANGED
|
@@ -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
|
|
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, &)
|
|
@@ -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
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiquidXlsx
|
|
4
|
+
# An independent AST walker: output rows and loop iterations are never retained.
|
|
5
|
+
class StreamingRenderer < Renderer
|
|
6
|
+
# MRI sizes its young heap relative to live input, including large Arrays.
|
|
7
|
+
# Occasional minor collections bound transient XML garbage without changing
|
|
8
|
+
# process-wide GC configuration or retaining output metadata.
|
|
9
|
+
GC_ROW_INTERVAL = 16_384
|
|
10
|
+
|
|
11
|
+
TEXT_ESCAPES = { "&" => "&", "<" => "<", ">" => ">", "\r" => " " }.freeze
|
|
12
|
+
ATTRIBUTE_ESCAPES = TEXT_ESCAPES.merge('"' => """, "\n" => " ", "\t" => "	").freeze
|
|
13
|
+
|
|
14
|
+
def prepare
|
|
15
|
+
@rows = worksheet.parse_rows
|
|
16
|
+
@merges = worksheet.merge_cells
|
|
17
|
+
validate_cells
|
|
18
|
+
@ast = TemplateParser.new(@rows, worksheet.name).parse
|
|
19
|
+
@merge_state = RenderState.new(MergeCellsTransformer.new, @merges, FormulaTranslator.new)
|
|
20
|
+
prepare_nodes(@ast)
|
|
21
|
+
@merge_state.check_block_boundaries(worksheet.name)
|
|
22
|
+
# Only endpoints of header merges need mapping, bounded by template size.
|
|
23
|
+
@merge_rows = @merges.flat_map { |m| [m[:start_row], m[:end_row]] }.uniq.to_h { |r| [r, true] }
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# True when the sheet iterates a collection; a one-shot source must feed only one sheet.
|
|
28
|
+
def loops?
|
|
29
|
+
@loops == true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def render(data, **_unused)
|
|
33
|
+
prepare unless @ast
|
|
34
|
+
file = Tempfile.new(["liquid_xlsx_rows", ".xml"])
|
|
35
|
+
file.binmode
|
|
36
|
+
unless @rows.any? { |row| row[:cells].any? { |cell| cell[:template] } }
|
|
37
|
+
part = FilePart.new(file, worksheet.to_xml, "")
|
|
38
|
+
return part
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
context = streaming_context(data)
|
|
42
|
+
@output = file
|
|
43
|
+
@row_buffer = +""
|
|
44
|
+
@row_number = 0
|
|
45
|
+
@max_col_index = 0
|
|
46
|
+
@max_col = "A"
|
|
47
|
+
walk(@ast, context)
|
|
48
|
+
file.flush
|
|
49
|
+
worksheet.update_merge_cells(@merge_state.build_merge_ranges)
|
|
50
|
+
prefix, suffix = worksheet.streaming_envelope(@row_number, @max_col)
|
|
51
|
+
part = FilePart.new(file, prefix, suffix)
|
|
52
|
+
part
|
|
53
|
+
ensure
|
|
54
|
+
file&.close! unless part
|
|
55
|
+
@output = nil
|
|
56
|
+
@row_buffer = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def validate_cells
|
|
62
|
+
first_loop = @rows.find do |row|
|
|
63
|
+
row[:cells].any? { |c| c[:template]&.strip&.match?(TemplateParser::FOR_START) }
|
|
64
|
+
end
|
|
65
|
+
@rows.each do |row|
|
|
66
|
+
row[:cells].each do |cell|
|
|
67
|
+
if cell[:raw_cell].at_xpath("xmlns:f")
|
|
68
|
+
unsupported("Formulas are not supported in streaming mode", row[:row_number], cell[:col])
|
|
69
|
+
end
|
|
70
|
+
# Inspect all visible runs as well, including tags in unused branches.
|
|
71
|
+
text = cell[:text].to_s + cell[:raw_cell].xpath("xmlns:is//xmlns:t").map(&:text).join
|
|
72
|
+
next unless text.match?(/\{%-?\s*(?:image_tag|sheet)\b/)
|
|
73
|
+
|
|
74
|
+
unsupported("image_tag and sheet tags are not supported in streaming mode", row[:row_number], cell[:col])
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
return unless first_loop
|
|
78
|
+
|
|
79
|
+
@merges.each do |merge|
|
|
80
|
+
next if merge[:end_row] < first_loop[:row_number]
|
|
81
|
+
|
|
82
|
+
ref = CellReference.new(merge[:ref].split(":").first)
|
|
83
|
+
unsupported("Merges at or below the first loop are not supported in streaming mode", ref.row, ref.col)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def unsupported(message, row, col)
|
|
88
|
+
raise StreamingUnsupportedError.new(message, sheet: worksheet.name, row: row, cell: "#{col}#{row}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def prepare_nodes(nodes)
|
|
92
|
+
nodes.each do |node|
|
|
93
|
+
case node
|
|
94
|
+
when Nodes::RowNode
|
|
95
|
+
row = node.row_data
|
|
96
|
+
row[:stream_attrs] = (row[:row_attrs] || {}).filter_map do |key, value|
|
|
97
|
+
%( #{key}="#{escape_attribute(value)}") if value
|
|
98
|
+
end.join
|
|
99
|
+
row[:cells].each do |cell|
|
|
100
|
+
cell[:stream_index] = CellReference.col_to_index(cell[:col])
|
|
101
|
+
cell[:stream_style] = cell[:style] ? %( s="#{escape_attribute(cell[:style])}") : ""
|
|
102
|
+
text = cell[:template]
|
|
103
|
+
match = text&.strip&.match(SINGLE_VAR)
|
|
104
|
+
cell[:stream_lookup] = Liquid::Expression.parse(match[1]) if match
|
|
105
|
+
cell[:stream_string_fast] = text&.match?(SINGLE_VAR)
|
|
106
|
+
cell[:stream_static] = static_cell(cell) unless text
|
|
107
|
+
end
|
|
108
|
+
when Nodes::ForNode
|
|
109
|
+
@loops = true
|
|
110
|
+
prepare_nodes(node.body)
|
|
111
|
+
prepare_nodes(node.else_body || [])
|
|
112
|
+
when Nodes::IfNode
|
|
113
|
+
@merge_state.enter_block(:if, node.if_row, node.endif_row)
|
|
114
|
+
prepare_nodes(node.branches.flat_map { |b| b[:body] })
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def streaming_context(data)
|
|
120
|
+
normalized = normalize_data(data)
|
|
121
|
+
# Normalize the root without walking or duplicating collection arrays.
|
|
122
|
+
normalized = normalized.to_h { |key, value| [key.to_s, streaming_value(value)] } if normalized.is_a?(Hash)
|
|
123
|
+
context = StreamingContext.new(normalized, {}, {}, true)
|
|
124
|
+
context.strict_variables = @options.fetch(:strict_variables, false)
|
|
125
|
+
context.strict_filters = @options.fetch(:strict_filters, false)
|
|
126
|
+
context.add_filters(@options[:filters]) if @options[:filters]
|
|
127
|
+
apply_resource_limits(context)
|
|
128
|
+
context
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def streaming_value(value)
|
|
132
|
+
return value unless value.is_a?(Hash)
|
|
133
|
+
return value if value.each_key.all?(String)
|
|
134
|
+
|
|
135
|
+
value.to_h { |key, item| [key.to_s, streaming_value(item)] }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def streaming_item(item)
|
|
139
|
+
return item if item.is_a?(Hash) && item.each_key.all?(String)
|
|
140
|
+
|
|
141
|
+
stringify_value(item)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def walk(nodes, context)
|
|
145
|
+
nodes.each do |node|
|
|
146
|
+
case node
|
|
147
|
+
when Nodes::RowNode then write_row(node.row_data, context)
|
|
148
|
+
when Nodes::ForNode then walk_for(node, context)
|
|
149
|
+
when Nodes::IfNode
|
|
150
|
+
branch = node.branches.find do |candidate|
|
|
151
|
+
candidate[:condition].nil? || evaluate_condition(candidate[:condition], context, node.if_row)
|
|
152
|
+
end
|
|
153
|
+
walk(branch[:body], context) if branch
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def walk_for(node, context)
|
|
159
|
+
collection = if context.strict_variables
|
|
160
|
+
strict_collection_lookup(context, node)
|
|
161
|
+
else
|
|
162
|
+
safe_context_lookup(context, node.collection_name)
|
|
163
|
+
end
|
|
164
|
+
collection = [] if collection.nil?
|
|
165
|
+
unless collection.respond_to?(:each)
|
|
166
|
+
raise RenderError.new("Cannot iterate over #{collection.class}: expected an object with #each",
|
|
167
|
+
sheet: worksheet.name, row: node.for_row)
|
|
168
|
+
end
|
|
169
|
+
size = collection.size if collection.respond_to?(:size)
|
|
170
|
+
size = nil unless size.is_a?(Integer)
|
|
171
|
+
loop_info = {}
|
|
172
|
+
loop_info["length"] = size if size
|
|
173
|
+
index = 0
|
|
174
|
+
collection.each do |item|
|
|
175
|
+
loop_info["index"] = index + 1
|
|
176
|
+
loop_info["index0"] = index
|
|
177
|
+
loop_info["first"] = index.zero?
|
|
178
|
+
if size
|
|
179
|
+
loop_info["last"] = index == size - 1
|
|
180
|
+
loop_info["rindex"] = size - index
|
|
181
|
+
loop_info["rindex0"] = size - index - 1
|
|
182
|
+
end
|
|
183
|
+
context.stack do
|
|
184
|
+
context[node.variable_name] = streaming_item(item)
|
|
185
|
+
context["forloop"] = loop_info
|
|
186
|
+
walk(node.body, context)
|
|
187
|
+
end
|
|
188
|
+
index += 1
|
|
189
|
+
end
|
|
190
|
+
walk(node.else_body, context) if index.zero? && node.else_body
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def write_row(row, context)
|
|
194
|
+
@row_number += 1
|
|
195
|
+
number = @row_number.to_s
|
|
196
|
+
xml = @row_buffer.clear
|
|
197
|
+
xml << '<row r="' << number << '"' << row[:stream_attrs] << ">"
|
|
198
|
+
row[:cells].each do |cell|
|
|
199
|
+
if cell[:template]
|
|
200
|
+
value = streaming_cell_value(cell, context, row[:row_number])
|
|
201
|
+
next if (value.nil? || value == "") && !cell[:style]
|
|
202
|
+
|
|
203
|
+
xml << '<c r="' << cell[:col] << number << '"' << cell[:stream_style]
|
|
204
|
+
append_value(xml, value)
|
|
205
|
+
else
|
|
206
|
+
content = cell[:stream_static]
|
|
207
|
+
next unless content
|
|
208
|
+
|
|
209
|
+
xml << '<c r="' << cell[:col] << number << '"' << content
|
|
210
|
+
end
|
|
211
|
+
xml << "</c>"
|
|
212
|
+
if cell[:stream_index] > @max_col_index
|
|
213
|
+
@max_col_index = cell[:stream_index]
|
|
214
|
+
@max_col = cell[:col]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
xml << "</row>\n"
|
|
218
|
+
@output.write(xml)
|
|
219
|
+
GC.start(full_mark: false) if (@row_number % GC_ROW_INTERVAL).zero?
|
|
220
|
+
@merge_state.record_row(row[:row_number], @row_number) if @merge_rows[row[:row_number]]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def streaming_cell_value(cell, context, row)
|
|
224
|
+
if (expression = cell[:stream_lookup])
|
|
225
|
+
value = begin
|
|
226
|
+
context.evaluate(expression)
|
|
227
|
+
rescue Liquid::UndefinedVariable, Liquid::UndefinedDropMethod
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
return value if value.is_a?(String) && cell[:stream_string_fast] && !@options[:liquid_resource_limits]
|
|
231
|
+
return value if numeric_for_excel?(value) || value == true || value == false
|
|
232
|
+
return date_time_to_serial(value) if date_time?(value)
|
|
233
|
+
|
|
234
|
+
return render_liquid_cell(cell, context, row)
|
|
235
|
+
end
|
|
236
|
+
render_cell_template(cell, context, nil, row)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The compiled lookup already performed the original type-preserving probe.
|
|
240
|
+
# Go straight to Liquid on fallback so nil/Drop methods are read twice, as
|
|
241
|
+
# before, rather than probing a third time through render_cell_template.
|
|
242
|
+
def render_liquid_cell(cell, context, row)
|
|
243
|
+
ref = "#{cell[:col]}#{row}"
|
|
244
|
+
context.registers[:liquid_xlsx_source_row] = row
|
|
245
|
+
context.registers[:liquid_xlsx_source_cell] = ref
|
|
246
|
+
coerce_value(cached_parse(cell[:template]).render(context))
|
|
247
|
+
rescue Liquid::UndefinedVariable, Liquid::UndefinedDropMethod => e
|
|
248
|
+
raise MissingVariableError.new("Missing variable: #{e.message}", sheet: worksheet.name,
|
|
249
|
+
row: row, cell: ref, template: cell[:template])
|
|
250
|
+
rescue Liquid::Error => e
|
|
251
|
+
raise RenderError.new("Liquid render error: #{e.message}", sheet: worksheet.name,
|
|
252
|
+
row: row, cell: ref, template: cell[:template])
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def static_cell(cell)
|
|
256
|
+
style = cell[:stream_style]
|
|
257
|
+
if cell[:type] == "s" && cell[:text]
|
|
258
|
+
%(#{style} t="s"><v>#{escape_text(cell[:value].to_s)}</v>)
|
|
259
|
+
elsif cell[:type] != "inlineStr" && cell[:value]
|
|
260
|
+
type = cell[:type] ? %( t="#{escape_attribute(cell[:type])}") : ""
|
|
261
|
+
%(#{style}#{type}><v>#{escape_text(cell[:value])}</v>)
|
|
262
|
+
elsif cell[:text].nil? || cell[:text].empty?
|
|
263
|
+
serialize_value("", style) if cell[:style]
|
|
264
|
+
else
|
|
265
|
+
serialize_value(cell[:text], style)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Static cell content is compiled once; dynamic cells append directly into
|
|
270
|
+
# the reused row buffer, avoiding two XML-sized temporary strings per cell.
|
|
271
|
+
def serialize_value(value, style)
|
|
272
|
+
xml = style.dup
|
|
273
|
+
append_value(xml, value)
|
|
274
|
+
xml
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def append_value(xml, value)
|
|
278
|
+
case value
|
|
279
|
+
when String
|
|
280
|
+
text = Worksheet.sanitize_text(value)
|
|
281
|
+
xml << ' t="inlineStr"><is><t'
|
|
282
|
+
xml << ' xml:space="preserve"' if text.match?(/\A\s|\s\z/) || text.include?("\n")
|
|
283
|
+
xml << ">" << escape_text(text) << "</t></is>"
|
|
284
|
+
when Numeric then xml << "><v>" << value.to_s << "</v>"
|
|
285
|
+
when true, false then xml << ' t="b"><v>' << (value ? "1" : "0") << "</v>"
|
|
286
|
+
else append_value(xml, value.to_s)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def escape_text(text)
|
|
291
|
+
return text unless text.match?(/[&<>\r]/)
|
|
292
|
+
|
|
293
|
+
text.gsub(/[&<>\r]/, TEXT_ESCAPES)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def escape_attribute(text)
|
|
297
|
+
text.to_s.gsub(/[&<>"\r\n\t]/, ATTRIBUTE_ESCAPES)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
data/lib/liquid_xlsx/template.rb
CHANGED
|
@@ -14,7 +14,12 @@ module LiquidXlsx
|
|
|
14
14
|
# @param data [Hash, Liquid::Drop, #to_liquid] data to render (Hash/Drop,
|
|
15
15
|
# or any object whose `#to_liquid` returns one)
|
|
16
16
|
# @param output_path [String]
|
|
17
|
-
def render_to_file(data, output_path)
|
|
17
|
+
def render_to_file(data, output_path, **options)
|
|
18
|
+
unless options.empty?
|
|
19
|
+
configured = self.class.new(@template_path, @options.merge(options))
|
|
20
|
+
return configured.render_to_file(data, output_path)
|
|
21
|
+
end
|
|
22
|
+
|
|
18
23
|
package = render_package(data)
|
|
19
24
|
package.write(output_path)
|
|
20
25
|
rescue LiquidXlsx::Error
|
|
@@ -22,6 +27,8 @@ module LiquidXlsx
|
|
|
22
27
|
rescue Zip::Error, Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR, Errno::EISDIR => e
|
|
23
28
|
raise OutputWriteError,
|
|
24
29
|
"Failed to write output '#{output_path}': #{e.class}: #{e.message}"
|
|
30
|
+
ensure
|
|
31
|
+
package&.close_file_parts
|
|
25
32
|
end
|
|
26
33
|
|
|
27
34
|
# Render template and return binary content.
|
|
@@ -29,6 +36,8 @@ module LiquidXlsx
|
|
|
29
36
|
# or any object whose `#to_liquid` returns one)
|
|
30
37
|
# @return [String] binary .xlsx content
|
|
31
38
|
def render(data)
|
|
39
|
+
raise ArgumentError, "Streaming requires render_to_file; binary output is not supported" if @options[:streaming]
|
|
40
|
+
|
|
32
41
|
package = render_package(data)
|
|
33
42
|
package.to_binary
|
|
34
43
|
rescue LiquidXlsx::Error
|
|
@@ -63,7 +72,10 @@ module LiquidXlsx
|
|
|
63
72
|
"Failed to read template '#{@template_path}': #{e.class}: #{e.message}"
|
|
64
73
|
end
|
|
65
74
|
|
|
75
|
+
workbook_rendered = true
|
|
66
76
|
package
|
|
77
|
+
ensure
|
|
78
|
+
package&.close_file_parts unless workbook_rendered
|
|
67
79
|
end
|
|
68
80
|
end
|
|
69
81
|
end
|
data/lib/liquid_xlsx/version.rb
CHANGED
data/lib/liquid_xlsx/workbook.rb
CHANGED
|
@@ -16,6 +16,8 @@ module LiquidXlsx
|
|
|
16
16
|
# or any object whose `#to_liquid` returns one)
|
|
17
17
|
# @return [Package] modified package
|
|
18
18
|
def render(data)
|
|
19
|
+
return render_streaming(data) if @options[:streaming]
|
|
20
|
+
|
|
19
21
|
all_image_ops = []
|
|
20
22
|
|
|
21
23
|
if @options[:dynamic_sheets]
|
|
@@ -47,6 +49,31 @@ module LiquidXlsx
|
|
|
47
49
|
|
|
48
50
|
private
|
|
49
51
|
|
|
52
|
+
# Preflight every worksheet before executing user data or creating row files.
|
|
53
|
+
def render_streaming(data)
|
|
54
|
+
renderers = package.sheets.map do |sheet_info|
|
|
55
|
+
worksheet = Worksheet.new(worksheet_xml!(sheet_info), sheet_info[:name], @shared_strings)
|
|
56
|
+
renderer = StreamingRenderer.new(worksheet, @options)
|
|
57
|
+
renderer.prepare
|
|
58
|
+
[sheet_info[:r_id], renderer]
|
|
59
|
+
end
|
|
60
|
+
looping = renderers.map(&:last).select(&:loops?)
|
|
61
|
+
if looping.size > 1
|
|
62
|
+
raise StreamingUnsupportedError.new(
|
|
63
|
+
"Only one worksheet may contain loops in streaming mode",
|
|
64
|
+
sheet: looping[1].worksheet.name
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
renderers.each do |r_id, renderer|
|
|
68
|
+
package.save_worksheet_xml(r_id, renderer.render(data))
|
|
69
|
+
end
|
|
70
|
+
if @options[:recalculate_formulas]
|
|
71
|
+
package.remove_calc_chain if package.calc_chain_xml
|
|
72
|
+
package.set_recalculation_flags
|
|
73
|
+
end
|
|
74
|
+
package
|
|
75
|
+
end
|
|
76
|
+
|
|
50
77
|
# Worksheet part behind a <sheet> entry of workbook.xml.
|
|
51
78
|
#
|
|
52
79
|
# A workbook that lists a sheet whose part cannot be resolved is broken, and
|
|
@@ -303,6 +303,30 @@ module LiquidXlsx
|
|
|
303
303
|
@doc.to_xml(indent: 0, encoding: "UTF-8")
|
|
304
304
|
end
|
|
305
305
|
|
|
306
|
+
# Serialize only the small worksheet envelope; rows are supplied from disk.
|
|
307
|
+
def streaming_envelope(total_rows, max_col)
|
|
308
|
+
root = @doc.root
|
|
309
|
+
ns = { "xmlns" => root.namespace.href }
|
|
310
|
+
sheet_data = root.at_xpath("xmlns:sheetData", ns)
|
|
311
|
+
unless sheet_data
|
|
312
|
+
sheet_data = Nokogiri::XML::Node.new("sheetData", @doc)
|
|
313
|
+
self.class.insert_child_in_order(root, sheet_data)
|
|
314
|
+
end
|
|
315
|
+
sheet_data.children.remove
|
|
316
|
+
token = +"liquid_xlsx_rows"
|
|
317
|
+
comments = @doc.xpath("//comment()").map(&:content)
|
|
318
|
+
token << "_" while comments.include?(token)
|
|
319
|
+
marker = Nokogiri::XML::Comment.new(@doc, token)
|
|
320
|
+
sheet_data.add_child(marker)
|
|
321
|
+
dim = root.at_xpath("xmlns:dimension", ns)
|
|
322
|
+
unless dim
|
|
323
|
+
dim = Nokogiri::XML::Node.new("dimension", @doc)
|
|
324
|
+
self.class.insert_child_in_order(root, dim)
|
|
325
|
+
end
|
|
326
|
+
dim["ref"] = "A1:#{max_col}#{[total_rows, 1].max}"
|
|
327
|
+
to_xml.split("<!--#{token}-->", 2)
|
|
328
|
+
end
|
|
329
|
+
|
|
306
330
|
# Extract row-level attributes to preserve.
|
|
307
331
|
def row_attributes(row_elem)
|
|
308
332
|
{
|
data/lib/liquid_xlsx.rb
CHANGED
|
@@ -15,9 +15,12 @@ require_relative "liquid_xlsx/tags/sheet_tag"
|
|
|
15
15
|
require_relative "liquid_xlsx/tags/image_tag"
|
|
16
16
|
require_relative "liquid_xlsx/image"
|
|
17
17
|
require_relative "liquid_xlsx/drawing_builder"
|
|
18
|
+
require_relative "liquid_xlsx/file_part"
|
|
18
19
|
require_relative "liquid_xlsx/package"
|
|
19
20
|
require_relative "liquid_xlsx/worksheet"
|
|
20
21
|
require_relative "liquid_xlsx/renderer"
|
|
22
|
+
require_relative "liquid_xlsx/streaming_context"
|
|
23
|
+
require_relative "liquid_xlsx/streaming_renderer"
|
|
21
24
|
require_relative "liquid_xlsx/workbook"
|
|
22
25
|
require_relative "liquid_xlsx/template"
|
|
23
26
|
require_relative "liquid_xlsx/validation/issue"
|
|
@@ -64,6 +67,7 @@ module LiquidXlsx
|
|
|
64
67
|
# Liquid::Drop, or any object whose `#to_liquid` returns a Hash/Drop.
|
|
65
68
|
# @param strict_variables [Boolean] raise error on missing variables
|
|
66
69
|
# @param strict_filters [Boolean] raise error on unknown filters
|
|
70
|
+
# @param streaming [Boolean] stream worksheet rows to disk (restricted templates)
|
|
67
71
|
# @param dynamic_sheets [Boolean] enable {% sheet %} tag for dynamic sheet creation
|
|
68
72
|
# @param hide_control_sheets [Boolean] hide sheets that contained {% sheet %} tags
|
|
69
73
|
# @param hide_template_sheets [Boolean] hide template sheets after cloning
|
|
@@ -79,7 +83,7 @@ module LiquidXlsx
|
|
|
79
83
|
recalculate_formulas: false, remove_template_comments: false,
|
|
80
84
|
dynamic_sheets: false, hide_control_sheets: true,
|
|
81
85
|
hide_template_sheets: false, images: nil,
|
|
82
|
-
liquid_resource_limits: nil, filters: nil)
|
|
86
|
+
liquid_resource_limits: nil, filters: nil, streaming: false)
|
|
83
87
|
tpl = Template.new(template, {
|
|
84
88
|
strict_variables: strict_variables,
|
|
85
89
|
strict_filters: strict_filters,
|
|
@@ -90,7 +94,8 @@ module LiquidXlsx
|
|
|
90
94
|
hide_template_sheets: hide_template_sheets,
|
|
91
95
|
images: images || {},
|
|
92
96
|
liquid_resource_limits: liquid_resource_limits,
|
|
93
|
-
filters: filters
|
|
97
|
+
filters: filters,
|
|
98
|
+
streaming: streaming
|
|
94
99
|
})
|
|
95
100
|
# rubocop:enable Metrics/ParameterLists
|
|
96
101
|
tpl.render_to_file(data, output)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: liquid_xlsx
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ivan Khlipitkin
|
|
@@ -100,6 +100,7 @@ files:
|
|
|
100
100
|
- lib/liquid_xlsx/cell_reference.rb
|
|
101
101
|
- lib/liquid_xlsx/drawing_builder.rb
|
|
102
102
|
- lib/liquid_xlsx/errors.rb
|
|
103
|
+
- lib/liquid_xlsx/file_part.rb
|
|
103
104
|
- lib/liquid_xlsx/filters.rb
|
|
104
105
|
- lib/liquid_xlsx/formula_translator.rb
|
|
105
106
|
- lib/liquid_xlsx/image.rb
|
|
@@ -107,6 +108,8 @@ files:
|
|
|
107
108
|
- lib/liquid_xlsx/package.rb
|
|
108
109
|
- lib/liquid_xlsx/renderer.rb
|
|
109
110
|
- lib/liquid_xlsx/shared_strings.rb
|
|
111
|
+
- lib/liquid_xlsx/streaming_context.rb
|
|
112
|
+
- lib/liquid_xlsx/streaming_renderer.rb
|
|
110
113
|
- lib/liquid_xlsx/tags/image_tag.rb
|
|
111
114
|
- lib/liquid_xlsx/tags/sheet_tag.rb
|
|
112
115
|
- lib/liquid_xlsx/template.rb
|