liquid_xlsx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ # Represents the workbook and orchestrates rendering across all sheets.
5
+ class Workbook
6
+ attr_reader :package, :shared_strings, :options
7
+
8
+ def initialize(package, options = {})
9
+ @package = package
10
+ @options = options
11
+ @shared_strings = SharedStrings.new(package.shared_strings_xml)
12
+ end
13
+
14
+ # Render all worksheets with the given data.
15
+ # @param data [Hash, Liquid::Drop, #to_liquid] data to render (Hash/Drop,
16
+ # or any object whose `#to_liquid` returns one)
17
+ # @return [Package] modified package
18
+ def render(data)
19
+ all_image_ops = []
20
+
21
+ if @options[:dynamic_sheets]
22
+ render_with_dynamic_sheets(data, all_image_ops)
23
+ else
24
+ render_existing_sheets(data, all_image_ops)
25
+ end
26
+
27
+ # Phase: build drawings from collected image ops
28
+ build_drawings(all_image_ops)
29
+
30
+ # Handle recalculation
31
+ if @options[:recalculate_formulas]
32
+ package.remove_calc_chain if package.calc_chain_xml
33
+ package.set_recalculation_flags
34
+ end
35
+
36
+ package
37
+ end
38
+
39
+ # Maximum sheet name length in Excel.
40
+ MAX_SHEET_NAME_LENGTH = 31
41
+
42
+ # Characters forbidden in Excel sheet names.
43
+ FORBIDDEN_SHEET_NAME_CHARS = %r{[:/\\\?\*\[\]]}
44
+
45
+ # Maximum deduplication attempts before giving up.
46
+ SHEET_NAME_DEDUP_MAX = 9999
47
+
48
+ private
49
+
50
+ def render_existing_sheets(data, all_image_ops = [])
51
+ package.sheets.each do |sheet_info|
52
+ xml = package.worksheet_xml(sheet_info[:r_id])
53
+ next unless xml
54
+
55
+ image_ops = []
56
+
57
+ worksheet = Worksheet.new(xml, sheet_info[:name], @shared_strings)
58
+ renderer = Renderer.new(worksheet, @options)
59
+ renderer.sheet_r_id = sheet_info[:r_id]
60
+ sheet_xml = renderer.render(data, image_ops: image_ops)
61
+ package.save_worksheet_xml(sheet_info[:r_id], sheet_xml)
62
+
63
+ all_image_ops.concat(image_ops) if image_ops.any?
64
+ end
65
+ end
66
+
67
+ def render_with_dynamic_sheets(data, all_image_ops = [])
68
+ # Phase 0: Save original sheet XMLs before rendering mutates them
69
+ original_xmls = {}
70
+ package.sheets.each do |sheet_info|
71
+ xml = package.worksheet_xml(sheet_info[:r_id])
72
+ original_xmls[sheet_info[:name]] = xml if xml
73
+ end
74
+
75
+ # Phase 0.5: Identify control and template sheets
76
+ control_names, template_names_from_tags = scan_sheet_tags
77
+
78
+ # Phase 1: Render all sheets EXCEPT template sheets, collecting
79
+ # {% sheet %} operations from control sheets along the way. Template
80
+ # sheets are skipped — they use local variables unavailable in global
81
+ # context and will be rendered in Phase 3 per-op. Ordinary sheets
82
+ # (neither control nor template) must still be rendered normally.
83
+ workbook_ops = []
84
+
85
+ package.sheets.each do |sheet_info|
86
+ next if template_names_from_tags.include?(sheet_info[:name])
87
+
88
+ xml = original_xmls[sheet_info[:name]]
89
+ next unless xml
90
+
91
+ image_ops = []
92
+ worksheet = Worksheet.new(xml, sheet_info[:name], @shared_strings)
93
+ renderer = Renderer.new(worksheet, @options)
94
+ renderer.sheet_r_id = sheet_info[:r_id]
95
+ sheet_xml = renderer.render(data, workbook_ops: workbook_ops, image_ops: image_ops)
96
+ package.save_worksheet_xml(sheet_info[:r_id], sheet_xml)
97
+ all_image_ops.concat(image_ops) if image_ops.any?
98
+ end
99
+
100
+ # Phase 2: Process collected operations
101
+ ops = workbook_ops
102
+ template_sheet_names = ops.map { |op| op[:template_sheet] }.uniq
103
+ # Merge template names from tag scanning with ops
104
+ template_sheet_names |= template_names_from_tags
105
+
106
+ # Ops source_sheet also identifies control sheets (for hide_control_sheets)
107
+ control_sheet_names = ops.map { |op| op[:source_sheet] }.compact.uniq
108
+ control_sheet_names |= control_names
109
+
110
+ # Phase 3: Create and render new sheets (using original template XML)
111
+ ops.each do |op|
112
+ create_and_render_sheet(op, data, original_xmls, all_image_ops)
113
+ end
114
+
115
+ # Phase 4: Cleanup — hide control sheets and template sheets
116
+ cleanup_sheets(control_sheet_names, template_sheet_names)
117
+ end
118
+
119
+ # Scan all sheets to find control sheets (containing {% sheet %} tags)
120
+ # and template sheet names referenced in those tags.
121
+ # Checks both inlineStr and shared string references.
122
+ # @return [Array<Array<String>>] [control_names, template_names]
123
+ def scan_sheet_tags
124
+ control_names = []
125
+ template_names = []
126
+
127
+ package.sheets.each do |sheet_info|
128
+ xml = package.worksheet_xml(sheet_info[:r_id])
129
+ next unless xml
130
+
131
+ texts = sheet_texts(xml)
132
+ combined = texts.join("\n")
133
+ next unless combined.include?("{% sheet")
134
+
135
+ control_names << sheet_info[:name]
136
+ combined.scan(/template:\s*"([^"]+)"/).each do |match|
137
+ template_names << match[0]
138
+ end
139
+ end
140
+
141
+ [control_names, template_names]
142
+ end
143
+
144
+ # Extract all cell text content from a worksheet XML.
145
+ # Covers both inlineStr and shared string references.
146
+ def sheet_texts(sheet_xml)
147
+ texts = []
148
+ doc = Nokogiri::XML(sheet_xml)
149
+ ns = doc.root&.namespace
150
+ return texts unless ns
151
+
152
+ doc.xpath("//xmlns:c", "xmlns" => ns.href).each do |c|
153
+ if c["t"] == "inlineStr"
154
+ t_el = c.at_xpath("xmlns:is/xmlns:t", "xmlns" => ns.href)
155
+ texts << (t_el&.text || "")
156
+ elsif c["t"] == "s"
157
+ v_el = c.at_xpath("xmlns:v", "xmlns" => ns.href)
158
+ next unless v_el
159
+
160
+ idx = v_el.text.to_i
161
+ texts << (@shared_strings[idx] || "")
162
+ end
163
+ end
164
+
165
+ texts
166
+ end
167
+
168
+ def create_and_render_sheet(op, global_data, original_xmls, all_image_ops)
169
+ source_sheet = package.find_sheet_by_name(op[:template_sheet])
170
+ unless source_sheet
171
+ raise UnsupportedTemplateError.new(
172
+ "Template sheet '#{op[:template_sheet]}' not found.",
173
+ sheet: op[:source_sheet],
174
+ row: op[:source_row],
175
+ cell: op[:source_cell]
176
+ )
177
+ end
178
+
179
+ source_xml = original_xmls[op[:template_sheet]]
180
+ raw_sheet_name = op[:name].to_s
181
+
182
+ safe_name = normalize_sheet_name(raw_sheet_name)
183
+ safe_name = deduplicate_sheet_name(safe_name)
184
+
185
+ new_sheet = package.clone_worksheet(
186
+ source_sheet_name: op[:template_sheet],
187
+ new_sheet_name: safe_name
188
+ )
189
+
190
+ image_ops = []
191
+
192
+ worksheet = Worksheet.new(source_xml, safe_name, @shared_strings)
193
+ renderer = Renderer.new(worksheet, @options)
194
+ renderer.sheet_r_id = new_sheet[:r_id]
195
+ # Inject the per-op local variable via `extra_scope` rather than
196
+ # `global_data.merge`, so `global_data` is free to be a Drop or any
197
+ # `#to_liquid` object. `extra_scope` keys are assigned directly onto
198
+ # the Liquid context as scope variables, which take priority during
199
+ # lookup (scopes are searched before environments).
200
+ rendered_xml = renderer.render(
201
+ global_data,
202
+ image_ops: image_ops,
203
+ extra_scope: { op[:as] => op[:data] }
204
+ )
205
+ package[new_sheet[:path]] = rendered_xml
206
+
207
+ # Associate image ops with the new sheet's r_id
208
+ image_ops.each { |io| io[:sheet_r_id] = new_sheet[:r_id] }
209
+ all_image_ops.concat(image_ops) if image_ops.any?
210
+ end
211
+
212
+ def cleanup_sheets(control_sheet_names, template_sheet_names)
213
+ if @options[:hide_control_sheets]
214
+ control_sheet_names.each { |name| package.hide_sheet(name) }
215
+ end
216
+
217
+ if @options[:hide_template_sheets]
218
+ template_sheet_names.each { |name| package.hide_sheet(name) }
219
+ end
220
+ end
221
+
222
+ # Build all drawing parts from collected image operations.
223
+ # Groups ops by sheet_r_id, builds drawing XML/rels for each sheet,
224
+ # adds media files and attaches drawings to worksheets.
225
+ def build_drawings(all_image_ops)
226
+ return if all_image_ops.empty?
227
+
228
+ images_options = @options[:images] || {}
229
+
230
+ # Group operations by sheet_r_id
231
+ by_sheet = all_image_ops.group_by { |op| op[:sheet_r_id] }
232
+
233
+ by_sheet.each do |sheet_r_id, sheet_ops|
234
+ # Parse merge ranges from the rendered worksheet XML
235
+ merge_refs = sheet_merge_refs(sheet_r_id)
236
+
237
+ builder = DrawingBuilder.new(sheet_ops, images_options, merge_refs)
238
+ result = builder.build
239
+ next if result[:drawing_xml].nil?
240
+
241
+ # Add all media files (dedup by SHA inside package.add_media)
242
+ result[:media_items].each do |mi|
243
+ package.add_media(mi[:binary], mi[:ext])
244
+ end
245
+
246
+ # Attach drawing to worksheet
247
+ package.attach_drawing(
248
+ sheet_r_id: sheet_r_id,
249
+ drawing_xml: result[:drawing_xml],
250
+ drawing_rels_xml: result[:drawing_rels_xml]
251
+ )
252
+ end
253
+ end
254
+
255
+ # Normalize a sheet name to be valid for Excel.
256
+ # - Removes forbidden characters
257
+ # - Truncates to 31 characters
258
+ # - Ensures non-empty (falls back to "Sheet" if empty after normalization)
259
+ def normalize_sheet_name(name)
260
+ name = name.to_s.dup.force_encoding("UTF-8")
261
+ name = name.scrub("_") unless name.valid_encoding?
262
+ name.gsub!(FORBIDDEN_SHEET_NAME_CHARS, "")
263
+ name = name[0, MAX_SHEET_NAME_LENGTH] if name.length > MAX_SHEET_NAME_LENGTH
264
+ name.strip!
265
+
266
+ name = "Sheet" if name.empty?
267
+ name
268
+ end
269
+
270
+ # Make sheet name unique among existing sheets.
271
+ # If the name already exists, appends " (2)", " (3)", etc.
272
+ def deduplicate_sheet_name(name, max: SHEET_NAME_DEDUP_MAX)
273
+ existing = package.sheet_names
274
+ return name unless existing.include?(name)
275
+
276
+ counter = 2
277
+ loop do
278
+ suffix = " (#{counter})"
279
+ # Keep the deduplicated name within Excel's 31-character limit
280
+ base = name[0, MAX_SHEET_NAME_LENGTH - suffix.length]
281
+ candidate = "#{base}#{suffix}"
282
+ return candidate unless existing.include?(candidate)
283
+
284
+ counter += 1
285
+ if counter > max
286
+ raise LiquidXlsx::Error, "Could not find a unique name for sheet " \
287
+ "'#{name}' after #{max} attempts."
288
+ end
289
+ end
290
+ end
291
+
292
+ # Parse merge cell refs from the rendered worksheet XML for a given sheet_r_id.
293
+ # @return [Array<String>] e.g. ["A1:C2", "D5:D6"]
294
+ def sheet_merge_refs(sheet_r_id)
295
+ xml = package.worksheet_xml(sheet_r_id)
296
+ return [] unless xml
297
+
298
+ doc = Nokogiri::XML(xml)
299
+ ns = doc.root&.namespace
300
+ return [] unless ns
301
+
302
+ mc = doc.at_xpath("//xmlns:mergeCells", "xmlns" => ns.href)
303
+ return [] unless mc
304
+
305
+ mc.xpath("xmlns:mergeCell", "xmlns" => ns.href).map { |c| c["ref"] }
306
+ end
307
+ end
308
+ end