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,436 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiquidXlsx
|
|
4
|
+
# Represents and processes a single worksheet.
|
|
5
|
+
class Worksheet
|
|
6
|
+
attr_reader :name, :sheet_xml, :shared_strings
|
|
7
|
+
|
|
8
|
+
# Element order of CT_Worksheet children per the OOXML schema.
|
|
9
|
+
CT_WORKSHEET_ORDER = %w[
|
|
10
|
+
sheetPr dimension sheetViews sheetFormatPr cols sheetData sheetCalcPr
|
|
11
|
+
sheetProtection protectedRanges scenarios autoFilter sortState
|
|
12
|
+
dataConsolidate customSheetViews mergeCells phoneticPr
|
|
13
|
+
conditionalFormatting dataValidations hyperlinks printOptions pageMargins
|
|
14
|
+
pageSetup headerFooter rowBreaks colBreaks customProperties cellWatches
|
|
15
|
+
ignoredErrors smartTags drawing drawingHF picture oleObjects controls
|
|
16
|
+
webPublishItems tableParts extLst
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
# Excel's hard limit on cell text length.
|
|
20
|
+
EXCEL_MAX_CELL_LENGTH = 32_767
|
|
21
|
+
|
|
22
|
+
# Characters invalid in XML 1.0 (except TAB, LF, CR which are legal).
|
|
23
|
+
ILLEGAL_XML_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F]/
|
|
24
|
+
|
|
25
|
+
# Make a string safe to embed in worksheet XML: valid UTF-8, no illegal
|
|
26
|
+
# control characters, within Excel's cell length limit.
|
|
27
|
+
def self.sanitize_text(value)
|
|
28
|
+
s = value.to_s
|
|
29
|
+
s = if s.encoding == Encoding::UTF_8
|
|
30
|
+
s.valid_encoding? ? s : s.scrub("\u{FFFD}")
|
|
31
|
+
else
|
|
32
|
+
s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\u{FFFD}")
|
|
33
|
+
end
|
|
34
|
+
s = s.gsub(ILLEGAL_XML_CHARS, "")
|
|
35
|
+
s = s[0, EXCEL_MAX_CELL_LENGTH] if s.length > EXCEL_MAX_CELL_LENGTH
|
|
36
|
+
s
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Insert a child element into a worksheet root at its schema-mandated
|
|
40
|
+
# position (before the first existing element that must come after it).
|
|
41
|
+
def self.insert_child_in_order(root, node)
|
|
42
|
+
idx = CT_WORKSHEET_ORDER.index(node.name)
|
|
43
|
+
anchor = idx && root.element_children.find do |child|
|
|
44
|
+
ci = CT_WORKSHEET_ORDER.index(child.name)
|
|
45
|
+
ci && ci > idx
|
|
46
|
+
end
|
|
47
|
+
anchor ? anchor.add_previous_sibling(node) : root.add_child(node)
|
|
48
|
+
node
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(xml, name, shared_strings)
|
|
52
|
+
@original_xml = xml
|
|
53
|
+
@name = name
|
|
54
|
+
@shared_strings = shared_strings
|
|
55
|
+
@doc = Nokogiri::XML(xml)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Parse rows from the worksheet.
|
|
59
|
+
# @return [Array<Hash>] rows with :row_number, :row_xml, :cells, :row_attrs
|
|
60
|
+
def parse_rows
|
|
61
|
+
rows = []
|
|
62
|
+
ns = @doc.root&.namespace
|
|
63
|
+
return rows unless ns
|
|
64
|
+
|
|
65
|
+
collect_shared_formula_masters(ns)
|
|
66
|
+
|
|
67
|
+
last_row_num = 0
|
|
68
|
+
@doc.xpath("//xmlns:sheetData/xmlns:row", "xmlns" => ns.href).each do |row_elem|
|
|
69
|
+
row_num = row_elem["r"]&.to_i
|
|
70
|
+
row_num = last_row_num + 1 if row_num.nil? || row_num < 1
|
|
71
|
+
last_row_num = row_num
|
|
72
|
+
cells = parse_cells(row_elem, row_num)
|
|
73
|
+
rows << {
|
|
74
|
+
row_number: row_num,
|
|
75
|
+
row_xml: row_elem.to_xml(indent: 0),
|
|
76
|
+
cells: cells,
|
|
77
|
+
row_attrs: row_attributes(row_elem)
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
rows
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Get merge cells from the worksheet.
|
|
84
|
+
# @return [Array<Hash>] each: {ref: "A3:B3", start_row:, end_row:}
|
|
85
|
+
def merge_cells
|
|
86
|
+
return [] unless merge_elem
|
|
87
|
+
|
|
88
|
+
merge_elem["count"]&.to_i
|
|
89
|
+
merge_elem.xpath("xmlns:mergeCell").map do |mc|
|
|
90
|
+
ref = mc["ref"]
|
|
91
|
+
parts = ref.split(":")
|
|
92
|
+
start = CellReference.new(parts[0])
|
|
93
|
+
end_ref = CellReference.new(parts[1] || parts[0])
|
|
94
|
+
{
|
|
95
|
+
ref: ref,
|
|
96
|
+
start_row: start.row,
|
|
97
|
+
end_row: end_ref.row
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Get the mergeCells element.
|
|
103
|
+
# @return [Nokogiri::XML::Element, nil]
|
|
104
|
+
def merge_elem
|
|
105
|
+
ns = @doc.root&.namespace
|
|
106
|
+
return nil unless ns
|
|
107
|
+
|
|
108
|
+
@doc.at_xpath("//xmlns:mergeCells", "xmlns" => ns.href)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Rebuild sheetData with rendered rows.
|
|
112
|
+
# @param rendered_rows [Array<Hash>] array of {cells:, original_row_num:, row_attrs:}
|
|
113
|
+
# @param start_row [Integer] first row number in the output
|
|
114
|
+
def rebuild_sheet_data(rendered_rows, start_row = 1)
|
|
115
|
+
ns = @doc.root&.namespace
|
|
116
|
+
return unless ns
|
|
117
|
+
|
|
118
|
+
sheet_data = @doc.at_xpath("//xmlns:sheetData", "xmlns" => ns.href)
|
|
119
|
+
unless sheet_data
|
|
120
|
+
sheet_data = Nokogiri::XML::Node.new("sheetData", @doc)
|
|
121
|
+
@doc.root.add_child(sheet_data)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Remove all existing rows
|
|
125
|
+
sheet_data.xpath("xmlns:row").each(&:remove)
|
|
126
|
+
|
|
127
|
+
current_row_num = start_row
|
|
128
|
+
sheet_data.namespace
|
|
129
|
+
|
|
130
|
+
rendered_rows.each do |row_info|
|
|
131
|
+
next if row_info.nil?
|
|
132
|
+
|
|
133
|
+
row_elem = Nokogiri::XML::Node.new("row", @doc)
|
|
134
|
+
row_elem["r"] = current_row_num.to_s
|
|
135
|
+
|
|
136
|
+
# Restore original row attributes (height, hidden, etc.)
|
|
137
|
+
apply_row_attributes(row_elem, row_info[:row_attrs])
|
|
138
|
+
|
|
139
|
+
(row_info[:cells] || []).each do |cell_data|
|
|
140
|
+
next unless cell_data
|
|
141
|
+
|
|
142
|
+
row_elem.add_child(build_cell(cell_data, current_row_num))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
sheet_data.add_child(row_elem)
|
|
146
|
+
current_row_num += 1
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Build a single <c> element for a rendered cell.
|
|
151
|
+
def build_cell(cell_data, current_row_num)
|
|
152
|
+
c = Nokogiri::XML::Node.new("c", @doc)
|
|
153
|
+
c["r"] = "#{cell_data[:col]}#{current_row_num}"
|
|
154
|
+
c["s"] = cell_data[:style].to_s if cell_data[:style]
|
|
155
|
+
|
|
156
|
+
if cell_data[:formula]
|
|
157
|
+
# Only override type if original explicitly had t="str" (not for numeric formulas)
|
|
158
|
+
c["t"] = "str" if cell_data[:original_type] == "str"
|
|
159
|
+
f = Nokogiri::XML::Node.new("f", @doc)
|
|
160
|
+
(cell_data[:formula_attrs] || {}).each { |k, val| f[k] = val if val }
|
|
161
|
+
f.content = cell_data[:formula]
|
|
162
|
+
c.add_child(f)
|
|
163
|
+
elsif cell_data[:inline_str]
|
|
164
|
+
add_inline_str(c, cell_data[:rendered_value])
|
|
165
|
+
elsif cell_data[:type] == "s" && cell_data[:text]
|
|
166
|
+
# Shared string reference — keep as is
|
|
167
|
+
c["t"] = "s"
|
|
168
|
+
v_el = Nokogiri::XML::Node.new("v", @doc)
|
|
169
|
+
v_el.content = cell_data[:value].to_s if cell_data[:value]
|
|
170
|
+
c.add_child(v_el)
|
|
171
|
+
elsif cell_data[:rendered_value].is_a?(Numeric)
|
|
172
|
+
v_el = Nokogiri::XML::Node.new("v", @doc)
|
|
173
|
+
v_el.content = cell_data[:rendered_value].to_s
|
|
174
|
+
c.add_child(v_el)
|
|
175
|
+
elsif cell_data[:rendered_value].is_a?(TrueClass) || cell_data[:rendered_value].is_a?(FalseClass)
|
|
176
|
+
c["t"] = "b"
|
|
177
|
+
v_el = Nokogiri::XML::Node.new("v", @doc)
|
|
178
|
+
v_el.content = cell_data[:rendered_value] ? "1" : "0"
|
|
179
|
+
c.add_child(v_el)
|
|
180
|
+
elsif cell_data[:original_value] && !%w[s inlineStr].include?(cell_data[:type])
|
|
181
|
+
# Non-template value cell (numeric, boolean, raw) — preserve as-is
|
|
182
|
+
c["t"] = cell_data[:type] if cell_data[:type]
|
|
183
|
+
v_el = Nokogiri::XML::Node.new("v", @doc)
|
|
184
|
+
v_el.content = cell_data[:original_value].to_s
|
|
185
|
+
c.add_child(v_el)
|
|
186
|
+
else
|
|
187
|
+
# String — use inlineStr
|
|
188
|
+
add_inline_str(c, cell_data[:rendered_value])
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
c
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Append <is><t>...</t></is> to a cell element.
|
|
195
|
+
def add_inline_str(c, value)
|
|
196
|
+
c["t"] = "inlineStr"
|
|
197
|
+
is_el = Nokogiri::XML::Node.new("is", @doc)
|
|
198
|
+
c.add_child(is_el)
|
|
199
|
+
is_el.add_child(build_inline_text(value))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Update the dimension element.
|
|
203
|
+
# @param total_rows [Integer]
|
|
204
|
+
def update_dimension(total_rows)
|
|
205
|
+
ns = @doc.root&.namespace
|
|
206
|
+
return unless ns
|
|
207
|
+
|
|
208
|
+
dim = @doc.at_xpath("//xmlns:dimension", "xmlns" => ns.href)
|
|
209
|
+
unless dim
|
|
210
|
+
dim = Nokogiri::XML::Node.new("dimension", @doc)
|
|
211
|
+
@doc.root.children.first&.add_previous_sibling(dim)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Find max column using column index (not string comparison)
|
|
215
|
+
max_col = "A"
|
|
216
|
+
max_col_idx = 0
|
|
217
|
+
@doc.xpath("//xmlns:c").each do |c|
|
|
218
|
+
ref = c["r"]
|
|
219
|
+
col = ref&.gsub(/\d+/, "")
|
|
220
|
+
next unless col
|
|
221
|
+
|
|
222
|
+
col_idx = CellReference.col_to_index(col)
|
|
223
|
+
if col_idx > max_col_idx
|
|
224
|
+
max_col_idx = col_idx
|
|
225
|
+
max_col = col
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
total_rows = 1 if total_rows < 1
|
|
230
|
+
dim["ref"] = "A1:#{max_col}#{total_rows}"
|
|
231
|
+
dim
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Build a sanitized <t> element for inline strings, preserving significant
|
|
235
|
+
# whitespace via xml:space when needed.
|
|
236
|
+
def build_inline_text(value)
|
|
237
|
+
t_el = Nokogiri::XML::Node.new("t", @doc)
|
|
238
|
+
text = self.class.sanitize_text(value)
|
|
239
|
+
t_el.content = text
|
|
240
|
+
t_el["xml:space"] = "preserve" if text.match?(/\A\s|\s\z/) || text.include?("\n")
|
|
241
|
+
t_el
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Update merged cells with new ranges.
|
|
245
|
+
# @param merge_ranges [Array<String>] e.g. ["A3:B3", "A4:B4"]
|
|
246
|
+
def update_merge_cells(merge_ranges)
|
|
247
|
+
ns = @doc.root&.namespace
|
|
248
|
+
return unless ns
|
|
249
|
+
|
|
250
|
+
# Remove existing mergeCells
|
|
251
|
+
existing = @doc.at_xpath("//xmlns:mergeCells", "xmlns" => ns.href)
|
|
252
|
+
existing&.remove
|
|
253
|
+
|
|
254
|
+
return if merge_ranges.empty?
|
|
255
|
+
|
|
256
|
+
mc_elem = Nokogiri::XML::Node.new("mergeCells", @doc)
|
|
257
|
+
mc_elem["count"] = merge_ranges.length.to_s
|
|
258
|
+
|
|
259
|
+
merge_ranges.each do |ref|
|
|
260
|
+
mc = Nokogiri::XML::Node.new("mergeCell", @doc)
|
|
261
|
+
mc["ref"] = ref
|
|
262
|
+
mc_elem.add_child(mc)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Insert at the schema-mandated position within CT_Worksheet
|
|
266
|
+
self.class.insert_child_in_order(@doc.root, mc_elem)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Remove calc chain cached values from formula cells.
|
|
270
|
+
def remove_cached_formula_values
|
|
271
|
+
@doc.xpath("//xmlns:f").each do |f|
|
|
272
|
+
# Remove sibling <v> elements
|
|
273
|
+
parent = f.parent
|
|
274
|
+
parent.xpath("xmlns:v").each(&:remove)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Serialize back to XML string.
|
|
279
|
+
# @return [String]
|
|
280
|
+
def to_xml
|
|
281
|
+
@doc.to_xml(indent: 0, encoding: "UTF-8")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Extract row-level attributes to preserve.
|
|
285
|
+
def row_attributes(row_elem)
|
|
286
|
+
{
|
|
287
|
+
s: row_elem["s"],
|
|
288
|
+
ht: row_elem["ht"],
|
|
289
|
+
customHeight: row_elem["customHeight"],
|
|
290
|
+
hidden: row_elem["hidden"],
|
|
291
|
+
outlineLevel: row_elem["outlineLevel"],
|
|
292
|
+
customFormat: row_elem["customFormat"],
|
|
293
|
+
spans: row_elem["spans"],
|
|
294
|
+
thickTop: row_elem["thickTop"],
|
|
295
|
+
thickBot: row_elem["thickBot"]
|
|
296
|
+
}
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Apply preserved row-level attributes to a rebuilt row element.
|
|
300
|
+
def apply_row_attributes(row_elem, attrs)
|
|
301
|
+
return unless attrs
|
|
302
|
+
|
|
303
|
+
row_elem["s"] = attrs[:s] if attrs[:s]
|
|
304
|
+
row_elem["ht"] = attrs[:ht] if attrs[:ht]
|
|
305
|
+
row_elem["customHeight"] = attrs[:customHeight] if attrs[:customHeight]
|
|
306
|
+
row_elem["hidden"] = attrs[:hidden] if attrs[:hidden]
|
|
307
|
+
row_elem["outlineLevel"] = attrs[:outlineLevel] if attrs[:outlineLevel]
|
|
308
|
+
row_elem["customFormat"] = attrs[:customFormat] if attrs[:customFormat]
|
|
309
|
+
row_elem["spans"] = attrs[:spans] if attrs[:spans]
|
|
310
|
+
row_elem["thickTop"] = attrs[:thickTop] if attrs[:thickTop]
|
|
311
|
+
row_elem["thickBot"] = attrs[:thickBot] if attrs[:thickBot]
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
private
|
|
315
|
+
|
|
316
|
+
# Collect master formulas of shared formula groups (si => formula + origin)
|
|
317
|
+
# so follower cells (<f t="shared" si="N"/> without text) can be
|
|
318
|
+
# materialized into plain formulas.
|
|
319
|
+
def collect_shared_formula_masters(ns)
|
|
320
|
+
@shared_formula_masters = {}
|
|
321
|
+
@doc.xpath("//xmlns:f[@t='shared']", "xmlns" => ns.href).each do |f|
|
|
322
|
+
si = f["si"]
|
|
323
|
+
next unless si
|
|
324
|
+
next if f.text.to_s.empty?
|
|
325
|
+
next if @shared_formula_masters.key?(si)
|
|
326
|
+
|
|
327
|
+
ref = f.parent["r"]
|
|
328
|
+
next unless ref
|
|
329
|
+
|
|
330
|
+
cr = CellReference.new(ref)
|
|
331
|
+
@shared_formula_masters[si] = {
|
|
332
|
+
formula: f.text,
|
|
333
|
+
row: cr.row,
|
|
334
|
+
col_idx: CellReference.col_to_index(cr.col)
|
|
335
|
+
}
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Resolve the formula text and extra <f> attributes for a cell.
|
|
340
|
+
# Shared formulas are materialized into plain per-cell formulas; array
|
|
341
|
+
# formulas keep their t/ref attributes.
|
|
342
|
+
def resolve_formula(formula_el, col, row_num)
|
|
343
|
+
return [nil, nil] unless formula_el
|
|
344
|
+
|
|
345
|
+
t = formula_el["t"]
|
|
346
|
+
text = formula_el.text
|
|
347
|
+
text, attrs = case t
|
|
348
|
+
when "shared"
|
|
349
|
+
if text.to_s.empty?
|
|
350
|
+
master = (@shared_formula_masters || {})[formula_el["si"]]
|
|
351
|
+
if master
|
|
352
|
+
row_delta = row_num - master[:row]
|
|
353
|
+
col_delta = CellReference.col_to_index(col) - master[:col_idx]
|
|
354
|
+
text = FormulaTranslator.new.translate(master[:formula], row_delta, col_delta)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
[text, nil]
|
|
358
|
+
when "array"
|
|
359
|
+
[text, { "t" => "array", "ref" => formula_el["ref"] }]
|
|
360
|
+
when nil
|
|
361
|
+
[text, nil]
|
|
362
|
+
else
|
|
363
|
+
[text, { "t" => t }]
|
|
364
|
+
end
|
|
365
|
+
text = nil if text.to_s.empty?
|
|
366
|
+
[text, attrs]
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def parse_cells(row_elem, row_num = nil)
|
|
370
|
+
cells = []
|
|
371
|
+
row_elem.namespace
|
|
372
|
+
row_num ||= row_elem["r"].to_i
|
|
373
|
+
|
|
374
|
+
next_col_idx = 0
|
|
375
|
+
row_elem.xpath("xmlns:c").each do |c|
|
|
376
|
+
ref = c["r"]
|
|
377
|
+
col = ref&.gsub(/\d+/, "")
|
|
378
|
+
# Cells without an r attribute are legal: derive column from position
|
|
379
|
+
col = CellReference.index_to_col(next_col_idx) if col.nil? || col.empty?
|
|
380
|
+
next_col_idx = CellReference.col_to_index(col) + 1
|
|
381
|
+
type = c["t"]
|
|
382
|
+
style = c["s"]
|
|
383
|
+
formula = c.at_xpath("xmlns:f")
|
|
384
|
+
v = c.at_xpath("xmlns:v")
|
|
385
|
+
|
|
386
|
+
text, template = extract_cell_text(c, type, v, formula)
|
|
387
|
+
formula_text, formula_attrs = resolve_formula(formula, col, row_num)
|
|
388
|
+
|
|
389
|
+
cells << {
|
|
390
|
+
col: col,
|
|
391
|
+
text: text,
|
|
392
|
+
template: template,
|
|
393
|
+
formula: formula_text,
|
|
394
|
+
formula_attrs: formula_attrs,
|
|
395
|
+
style: style,
|
|
396
|
+
type: type,
|
|
397
|
+
value: v&.text,
|
|
398
|
+
raw_cell: c
|
|
399
|
+
}
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
cells
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Determine the visible text (and Liquid template, if any) of a cell.
|
|
406
|
+
# @return [Array(String, String)] [text, template] (both may be nil)
|
|
407
|
+
def extract_cell_text(c, type, v, formula)
|
|
408
|
+
text = nil
|
|
409
|
+
template = nil
|
|
410
|
+
|
|
411
|
+
if type == "s" && v
|
|
412
|
+
idx = v.text.to_i
|
|
413
|
+
text = @shared_strings[idx]
|
|
414
|
+
template = text if text && (text.include?("{{") || text.include?("{%"))
|
|
415
|
+
elsif type == "inlineStr"
|
|
416
|
+
is_el = c.at_xpath("xmlns:is")
|
|
417
|
+
t_el = is_el&.at_xpath("xmlns:t")
|
|
418
|
+
text = t_el&.text || ""
|
|
419
|
+
template = text if text.include?("{{") || text.include?("{%")
|
|
420
|
+
elsif formula
|
|
421
|
+
# Formula cell — keep the formula
|
|
422
|
+
text = nil
|
|
423
|
+
elsif type == "b" && v
|
|
424
|
+
# Boolean cell
|
|
425
|
+
text = v.text == "1" ? "TRUE" : "FALSE"
|
|
426
|
+
elsif v
|
|
427
|
+
text = v.text
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Shared string reference without sharedStrings table
|
|
431
|
+
text = "" if type == "s" && !v
|
|
432
|
+
|
|
433
|
+
[text, template]
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
data/lib/liquid_xlsx.rb
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "liquid"
|
|
4
|
+
|
|
5
|
+
require_relative "liquid_xlsx/version"
|
|
6
|
+
require_relative "liquid_xlsx/errors"
|
|
7
|
+
require_relative "liquid_xlsx/cell_reference"
|
|
8
|
+
require_relative "liquid_xlsx/shared_strings"
|
|
9
|
+
require_relative "liquid_xlsx/template_nodes"
|
|
10
|
+
require_relative "liquid_xlsx/template_parser"
|
|
11
|
+
require_relative "liquid_xlsx/formula_translator"
|
|
12
|
+
require_relative "liquid_xlsx/merge_cells_transformer"
|
|
13
|
+
require_relative "liquid_xlsx/filters"
|
|
14
|
+
require_relative "liquid_xlsx/tags/sheet_tag"
|
|
15
|
+
require_relative "liquid_xlsx/tags/image_tag"
|
|
16
|
+
require_relative "liquid_xlsx/image"
|
|
17
|
+
require_relative "liquid_xlsx/drawing_builder"
|
|
18
|
+
require_relative "liquid_xlsx/package"
|
|
19
|
+
require_relative "liquid_xlsx/worksheet"
|
|
20
|
+
require_relative "liquid_xlsx/renderer"
|
|
21
|
+
require_relative "liquid_xlsx/workbook"
|
|
22
|
+
require_relative "liquid_xlsx/template"
|
|
23
|
+
|
|
24
|
+
# LiquidXlsx is a Ruby gem for generating .xlsx files from Excel templates
|
|
25
|
+
# with Liquid syntax. Edit templates in Excel, write Liquid in cells,
|
|
26
|
+
# and render the final file preserving styles, formulas, and merged cells.
|
|
27
|
+
module LiquidXlsx
|
|
28
|
+
class << self
|
|
29
|
+
# Dedicated Liquid environment with the gem's custom tags. Using a scoped
|
|
30
|
+
# environment (instead of registering tags on Liquid::Environment.default)
|
|
31
|
+
# keeps the host application's Liquid configuration untouched.
|
|
32
|
+
def liquid_environment
|
|
33
|
+
@liquid_environment ||= Liquid::Environment.build do |env|
|
|
34
|
+
env.register_tag("sheet", Tags::SheetTag)
|
|
35
|
+
env.register_tag("image_tag", Tags::ImageTag)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# High-level API: render a template with data and save to output.
|
|
40
|
+
#
|
|
41
|
+
# @example
|
|
42
|
+
# LiquidXlsx.render(
|
|
43
|
+
# template: "invoice_template.xlsx",
|
|
44
|
+
# output: "invoice.xlsx",
|
|
45
|
+
# data: {
|
|
46
|
+
# invoice: { number: "INV-001", paid: true },
|
|
47
|
+
# customer: { name: "ООО Ромашка" },
|
|
48
|
+
# items: [
|
|
49
|
+
# { title: "Разработка", qty: 10, price: 100 },
|
|
50
|
+
# { title: "Поддержка", qty: 5, price: 50 }
|
|
51
|
+
# ]
|
|
52
|
+
# }
|
|
53
|
+
# )
|
|
54
|
+
#
|
|
55
|
+
# @param template [String] path to .xlsx template file
|
|
56
|
+
# @param output [String] path to output .xlsx file
|
|
57
|
+
# @param data [Hash, Liquid::Drop, #to_liquid] data to render. A Hash, a
|
|
58
|
+
# Liquid::Drop, or any object whose `#to_liquid` returns a Hash/Drop.
|
|
59
|
+
# @param strict_variables [Boolean] raise error on missing variables
|
|
60
|
+
# @param strict_filters [Boolean] raise error on unknown filters
|
|
61
|
+
# @param dynamic_sheets [Boolean] enable {% sheet %} tag for dynamic sheet creation
|
|
62
|
+
# @param hide_control_sheets [Boolean] hide sheets that contained {% sheet %} tags
|
|
63
|
+
# @param hide_template_sheets [Boolean] hide template sheets after cloning
|
|
64
|
+
# @param images [Hash] image rendering options: { loader:, default_dpi: }
|
|
65
|
+
# @param liquid_resource_limits [Hash, nil] optional Liquid resource limits:
|
|
66
|
+
# { render_length_limit:, render_score_limit:, assign_score_limit: }
|
|
67
|
+
# rubocop:disable Metrics/ParameterLists
|
|
68
|
+
def render(template:, output:, data:,
|
|
69
|
+
strict_variables: false, strict_filters: false,
|
|
70
|
+
recalculate_formulas: false, remove_template_comments: false,
|
|
71
|
+
dynamic_sheets: false, hide_control_sheets: true,
|
|
72
|
+
hide_template_sheets: false, images: nil,
|
|
73
|
+
liquid_resource_limits: nil)
|
|
74
|
+
tpl = Template.new(template, {
|
|
75
|
+
strict_variables: strict_variables,
|
|
76
|
+
strict_filters: strict_filters,
|
|
77
|
+
recalculate_formulas: recalculate_formulas,
|
|
78
|
+
remove_template_comments: remove_template_comments,
|
|
79
|
+
dynamic_sheets: dynamic_sheets,
|
|
80
|
+
hide_control_sheets: hide_control_sheets,
|
|
81
|
+
hide_template_sheets: hide_template_sheets,
|
|
82
|
+
images: images || {},
|
|
83
|
+
liquid_resource_limits: liquid_resource_limits
|
|
84
|
+
})
|
|
85
|
+
# rubocop:enable Metrics/ParameterLists
|
|
86
|
+
tpl.render_to_file(data, output)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: liquid_xlsx
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ivan Khlipitkin
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: liquid
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.4'
|
|
33
|
+
- - "<"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '6'
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '5.4'
|
|
43
|
+
- - "<"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '6'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: nokogiri
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '1.14'
|
|
53
|
+
- - "<"
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '2'
|
|
56
|
+
type: :runtime
|
|
57
|
+
prerelease: false
|
|
58
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '1.14'
|
|
63
|
+
- - "<"
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '2'
|
|
66
|
+
- !ruby/object:Gem::Dependency
|
|
67
|
+
name: rubyzip
|
|
68
|
+
requirement: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '2.3'
|
|
73
|
+
- - "<"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '4'
|
|
76
|
+
type: :runtime
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '2.3'
|
|
83
|
+
- - "<"
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '4'
|
|
86
|
+
description: A Ruby library for generating .xlsx files from Excel templates using
|
|
87
|
+
Liquid template syntax. Edit your template in Excel, write {{ variables }} and {%
|
|
88
|
+
for %} loops in cells, and render the final file preserving styles, formulas, and
|
|
89
|
+
merged cells.
|
|
90
|
+
email:
|
|
91
|
+
- ivan.khlipitkin@gmail.com
|
|
92
|
+
executables: []
|
|
93
|
+
extensions: []
|
|
94
|
+
extra_rdoc_files: []
|
|
95
|
+
files:
|
|
96
|
+
- CHANGELOG.md
|
|
97
|
+
- LICENSE.txt
|
|
98
|
+
- README.md
|
|
99
|
+
- lib/liquid_xlsx.rb
|
|
100
|
+
- lib/liquid_xlsx/cell_reference.rb
|
|
101
|
+
- lib/liquid_xlsx/drawing_builder.rb
|
|
102
|
+
- lib/liquid_xlsx/errors.rb
|
|
103
|
+
- lib/liquid_xlsx/filters.rb
|
|
104
|
+
- lib/liquid_xlsx/formula_translator.rb
|
|
105
|
+
- lib/liquid_xlsx/image.rb
|
|
106
|
+
- lib/liquid_xlsx/merge_cells_transformer.rb
|
|
107
|
+
- lib/liquid_xlsx/package.rb
|
|
108
|
+
- lib/liquid_xlsx/renderer.rb
|
|
109
|
+
- lib/liquid_xlsx/shared_strings.rb
|
|
110
|
+
- lib/liquid_xlsx/tags/image_tag.rb
|
|
111
|
+
- lib/liquid_xlsx/tags/sheet_tag.rb
|
|
112
|
+
- lib/liquid_xlsx/template.rb
|
|
113
|
+
- lib/liquid_xlsx/template_nodes.rb
|
|
114
|
+
- lib/liquid_xlsx/template_parser.rb
|
|
115
|
+
- lib/liquid_xlsx/version.rb
|
|
116
|
+
- lib/liquid_xlsx/workbook.rb
|
|
117
|
+
- lib/liquid_xlsx/worksheet.rb
|
|
118
|
+
homepage: https://rubygems.org/gems/liquid_xlsx
|
|
119
|
+
licenses:
|
|
120
|
+
- MIT
|
|
121
|
+
metadata:
|
|
122
|
+
homepage_uri: https://rubygems.org/gems/liquid_xlsx
|
|
123
|
+
documentation_uri: https://rubydoc.info/gems/liquid_xlsx
|
|
124
|
+
rubygems_mfa_required: 'true'
|
|
125
|
+
rdoc_options: []
|
|
126
|
+
require_paths:
|
|
127
|
+
- lib
|
|
128
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
129
|
+
requirements:
|
|
130
|
+
- - ">="
|
|
131
|
+
- !ruby/object:Gem::Version
|
|
132
|
+
version: '3.1'
|
|
133
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '0'
|
|
138
|
+
requirements: []
|
|
139
|
+
rubygems_version: 3.6.9
|
|
140
|
+
specification_version: 4
|
|
141
|
+
summary: Generate .xlsx files from Excel templates with Liquid syntax
|
|
142
|
+
test_files: []
|