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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +58 -0
- data/LICENSE.txt +21 -0
- data/README.md +862 -0
- data/lib/liquid_xlsx/cell_reference.rb +82 -0
- data/lib/liquid_xlsx/drawing_builder.rb +263 -0
- data/lib/liquid_xlsx/errors.rb +80 -0
- data/lib/liquid_xlsx/filters.rb +9 -0
- data/lib/liquid_xlsx/formula_translator.rb +170 -0
- data/lib/liquid_xlsx/image.rb +209 -0
- data/lib/liquid_xlsx/merge_cells_transformer.rb +76 -0
- data/lib/liquid_xlsx/package.rb +599 -0
- data/lib/liquid_xlsx/renderer.rb +775 -0
- data/lib/liquid_xlsx/shared_strings.rb +47 -0
- data/lib/liquid_xlsx/tags/image_tag.rb +97 -0
- data/lib/liquid_xlsx/tags/sheet_tag.rb +161 -0
- data/lib/liquid_xlsx/template.rb +69 -0
- data/lib/liquid_xlsx/template_nodes.rb +62 -0
- data/lib/liquid_xlsx/template_parser.rb +446 -0
- data/lib/liquid_xlsx/version.rb +5 -0
- data/lib/liquid_xlsx/workbook.rb +308 -0
- data/lib/liquid_xlsx/worksheet.rb +436 -0
- data/lib/liquid_xlsx.rb +89 -0
- metadata +142 -0
|
@@ -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
|