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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 77859fcf54ec602752b61cb982570912a6da4acfe35da51bf159a6afa641e552
4
- data.tar.gz: adf22d59468df31118460448d0e6f4c84b3f2a16facc4cb5ff849ef652202ecc
3
+ metadata.gz: 619408f5440742c3210d5a61c30e44b16165d94ab673cefbb132b310310e0076
4
+ data.tar.gz: 208342e1abeed7c51352a204522030f16a36d8d7a3a08e1f6e07ca4a32f08a2d
5
5
  SHA512:
6
- metadata.gz: 7a6de15d7778d554d86582f64e641d9dd328ef743adc4dba868e06c936b39eaed6093fca1ca533ebb5745856304710013a11d61af41b80df3b40ed5a56e08109
7
- data.tar.gz: 76e95b63732e91ef4202efc71d80b1f791d84108d1857c9eac9d74ea3f0695d65ef7591ff7c4dcf28cf92d89c3b96caca81089de997a4fcf4ada199346532f3d
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
- │ картинки, отсутствие template-листа
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 # полный набор тестов (420 примеров)
975
+ bundle exec rspec # полный набор тестов
902
976
  bundle exec rspec spec/integration/ # только интеграционные
903
977
  bundle exec rubocop # линтер
904
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, &)
@@ -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 = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", "\r" => "&#13;" }.freeze
12
+ ATTRIBUTE_ESCAPES = TEXT_ESCAPES.merge('"' => "&quot;", "\n" => "&#10;", "\t" => "&#9;").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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LiquidXlsx
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -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.3.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