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,599 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "zip"
|
|
7
|
+
require "nokogiri"
|
|
8
|
+
|
|
9
|
+
module LiquidXlsx
|
|
10
|
+
# Handles reading and writing .xlsx files as ZIP archives.
|
|
11
|
+
# rubocop:disable Metrics/ClassLength
|
|
12
|
+
class Package
|
|
13
|
+
attr_reader :template_path
|
|
14
|
+
|
|
15
|
+
# Relationship namespace (r: prefix).
|
|
16
|
+
R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
17
|
+
|
|
18
|
+
# rubyzip 3 removed the positional `create` argument of `Zip::File.open`
|
|
19
|
+
# along with the `Zip::File::CREATE` constant; rubyzip 2.4 introduced the
|
|
20
|
+
# `create:` keyword, and 2.3 only understands the positional form. Detect
|
|
21
|
+
# the supported form once at load time instead of rescuing per call.
|
|
22
|
+
ZIP_CREATE_KEYWORD = Zip::File.method(:open).parameters.any? do |type, name|
|
|
23
|
+
name == :create && %i[key keyreq].include?(type)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Guard rails against zip bombs / resource exhaustion when unpacking.
|
|
27
|
+
MAX_ENTRIES = 10_000
|
|
28
|
+
MAX_ENTRY_UNCOMPRESSED = 512 * 1024 * 1024 # 512 MB per entry
|
|
29
|
+
MAX_TOTAL_UNCOMPRESSED = 1024 * 1024 * 1024 # 1 GB per archive
|
|
30
|
+
|
|
31
|
+
def initialize(template_path)
|
|
32
|
+
@template_path = template_path
|
|
33
|
+
@files = {}
|
|
34
|
+
@temp_dir = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Unpack the .xlsx and read all internal files.
|
|
38
|
+
def read
|
|
39
|
+
raise InvalidXlsxError, "Template file not found: #{@template_path}" unless File.exist?(@template_path)
|
|
40
|
+
|
|
41
|
+
@files = {}
|
|
42
|
+
total_size = 0
|
|
43
|
+
Zip::File.open(@template_path) do |zip|
|
|
44
|
+
raise InvalidXlsxError, "Too many entries in archive (max #{MAX_ENTRIES})" if zip.size > MAX_ENTRIES
|
|
45
|
+
|
|
46
|
+
zip.each do |entry|
|
|
47
|
+
# Protection against zip slip
|
|
48
|
+
name = entry.name
|
|
49
|
+
raise InvalidXlsxError, "Zip slip detected: #{name}" if name.start_with?("/") || name.include?("..")
|
|
50
|
+
|
|
51
|
+
if entry.size > MAX_ENTRY_UNCOMPRESSED
|
|
52
|
+
raise InvalidXlsxError, "Archive entry too large: #{name} (#{entry.size} bytes)"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
total_size += entry.size
|
|
56
|
+
if total_size > MAX_TOTAL_UNCOMPRESSED
|
|
57
|
+
raise InvalidXlsxError, "Archive too large when uncompressed (max #{MAX_TOTAL_UNCOMPRESSED} bytes)"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@files[name] = if entry.directory?
|
|
61
|
+
nil
|
|
62
|
+
else
|
|
63
|
+
entry.get_input_stream.read
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
validate_xlsx!
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Get raw content of a file inside the package.
|
|
73
|
+
# @param path [String] e.g. "xl/workbook.xml"
|
|
74
|
+
# @return [String, nil]
|
|
75
|
+
def [](path)
|
|
76
|
+
@files[path]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Set raw content of a file inside the package.
|
|
80
|
+
# @param path [String]
|
|
81
|
+
# @param content [String]
|
|
82
|
+
def []=(path, content)
|
|
83
|
+
@files[path] = content
|
|
84
|
+
@workbook_xml = nil if path == "xl/workbook.xml"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Delete a file from the package.
|
|
88
|
+
# @param path [String]
|
|
89
|
+
def delete(path)
|
|
90
|
+
@workbook_xml = nil if path == "xl/workbook.xml"
|
|
91
|
+
@files.delete(path)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Get the list of file paths in the package.
|
|
95
|
+
# @return [Array<String>]
|
|
96
|
+
def entries
|
|
97
|
+
@files.keys
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Write modified content to a new .xlsx file.
|
|
101
|
+
# An existing file at the path is replaced entirely — otherwise stale
|
|
102
|
+
# entries of the previous archive would survive inside the new one.
|
|
103
|
+
# @param output_path [String]
|
|
104
|
+
def write(output_path)
|
|
105
|
+
FileUtils.rm_f(output_path)
|
|
106
|
+
open_new_zip(output_path) do |zip|
|
|
107
|
+
@files.each do |name, content|
|
|
108
|
+
next if content.nil? # directories
|
|
109
|
+
|
|
110
|
+
zip.get_output_stream(name) { |f| f.write(content) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Write modified content to a binary string.
|
|
116
|
+
# @return [String]
|
|
117
|
+
def to_binary
|
|
118
|
+
Dir.mktmpdir("liquid_xlsx") do |tmp|
|
|
119
|
+
tmp_path = File.join(tmp, "output.xlsx")
|
|
120
|
+
write(tmp_path)
|
|
121
|
+
File.binread(tmp_path)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Get workbook XML parsed.
|
|
126
|
+
# @return [Nokogiri::XML::Document]
|
|
127
|
+
def workbook_xml
|
|
128
|
+
@workbook_xml ||= parse_xml(@files["xl/workbook.xml"])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Get shared strings XML.
|
|
132
|
+
# @return [String, nil]
|
|
133
|
+
def shared_strings_xml
|
|
134
|
+
@files["xl/sharedStrings.xml"]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Get the list of sheet references from workbook.xml.
|
|
138
|
+
# @return [Array<Hash>] each with :name, :sheet_id, :r_id
|
|
139
|
+
def sheets
|
|
140
|
+
workbook_xml.xpath("//xmlns:sheet").map do |sheet|
|
|
141
|
+
{
|
|
142
|
+
name: sheet["name"],
|
|
143
|
+
sheet_id: sheet["sheetId"],
|
|
144
|
+
r_id: sheet["r:id"]
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Get worksheet XML by r_id.
|
|
150
|
+
# @param r_id [String]
|
|
151
|
+
# @return [String, nil]
|
|
152
|
+
def worksheet_xml(r_id)
|
|
153
|
+
# Find relationship target in workbook.xml.rels
|
|
154
|
+
rels = parse_xml(@files["xl/_rels/workbook.xml.rels"])
|
|
155
|
+
rel = rels.at_xpath("//xmlns:Relationship[@Id='#{r_id}']")
|
|
156
|
+
return nil unless rel
|
|
157
|
+
|
|
158
|
+
target = rel["Target"]
|
|
159
|
+
path = "xl/#{target}"
|
|
160
|
+
|
|
161
|
+
# Handle paths like xl/worksheets/sheet1.xml vs xl/../xl/worksheets/sheet1.xml
|
|
162
|
+
# Normalize the path
|
|
163
|
+
normalized = Pathname.new(path).cleanpath.to_s
|
|
164
|
+
normalized = normalized.sub(%r{\A/?}, "")
|
|
165
|
+
@files[normalized]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Save worksheet XML back.
|
|
169
|
+
# @param r_id [String]
|
|
170
|
+
# @param xml [String]
|
|
171
|
+
def save_worksheet_xml(r_id, xml)
|
|
172
|
+
rels = parse_xml(@files["xl/_rels/workbook.xml.rels"])
|
|
173
|
+
rel = rels.at_xpath("//xmlns:Relationship[@Id='#{r_id}']")
|
|
174
|
+
return unless rel
|
|
175
|
+
|
|
176
|
+
target = rel["Target"]
|
|
177
|
+
path = "xl/#{target}"
|
|
178
|
+
normalized = Pathname.new(path).cleanpath.to_s.sub(%r{\A/?}, "")
|
|
179
|
+
@files[normalized] = xml
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Get calc chain XML.
|
|
183
|
+
# @return [String, nil]
|
|
184
|
+
def calc_chain_xml
|
|
185
|
+
@files["xl/calcChain.xml"]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Remove calc chain and its relationship.
|
|
189
|
+
def remove_calc_chain
|
|
190
|
+
@files.delete("xl/calcChain.xml")
|
|
191
|
+
# Remove relationship (Nokogiri does not support XPath ends-with)
|
|
192
|
+
rels = parse_xml(@files["xl/_rels/workbook.xml.rels"])
|
|
193
|
+
rels.xpath("//xmlns:Relationship").each do |rel|
|
|
194
|
+
target = rel["Target"]
|
|
195
|
+
rel.remove if target&.end_with?("calcChain.xml")
|
|
196
|
+
end
|
|
197
|
+
@files["xl/_rels/workbook.xml.rels"] = rels.to_xml(indent: 0, encoding: "UTF-8")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Set workbook recalculation flags.
|
|
201
|
+
def set_recalculation_flags
|
|
202
|
+
wb = workbook_xml
|
|
203
|
+
calc_pr = wb.at_xpath("//xmlns:calcPr")
|
|
204
|
+
if calc_pr
|
|
205
|
+
calc_pr["calcMode"] = "auto"
|
|
206
|
+
calc_pr["fullCalcOnLoad"] = "1"
|
|
207
|
+
calc_pr.remove_attribute("calcId")
|
|
208
|
+
calc_pr.remove_attribute("calcCompleted")
|
|
209
|
+
else
|
|
210
|
+
# Add calcPr element at its schema position (after sheets/definedNames)
|
|
211
|
+
root = wb.at_xpath("/xmlns:workbook")
|
|
212
|
+
if root
|
|
213
|
+
calc_pr_node = Nokogiri::XML::Node.new("calcPr", wb)
|
|
214
|
+
calc_pr_node["calcMode"] = "auto"
|
|
215
|
+
calc_pr_node["fullCalcOnLoad"] = "1"
|
|
216
|
+
anchor = wb.at_xpath("//xmlns:definedNames") || wb.at_xpath("//xmlns:sheets")
|
|
217
|
+
anchor ? anchor.add_next_sibling(calc_pr_node) : root.add_child(calc_pr_node)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
@files["xl/workbook.xml"] = wb.to_xml(indent: 0, encoding: "UTF-8")
|
|
221
|
+
@workbook_xml = nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Find the next available worksheet number.
|
|
225
|
+
# Scans xl/worksheets/ for sheetN.xml and returns N+1.
|
|
226
|
+
# @return [Integer]
|
|
227
|
+
def next_worksheet_number
|
|
228
|
+
nums = entries
|
|
229
|
+
.grep(%r{\Axl/worksheets/sheet\d+\.xml\z})
|
|
230
|
+
.map { |e| e[/\d+/].to_i }
|
|
231
|
+
(nums.max || 0) + 1
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Find the next available rId in workbook.xml.rels.
|
|
235
|
+
# @return [String] e.g. "rId5"
|
|
236
|
+
def next_r_id
|
|
237
|
+
rels = parse_xml(@files["xl/_rels/workbook.xml.rels"])
|
|
238
|
+
existing = rels.xpath("//xmlns:Relationship").map { |r| r["Id"][/\d+/].to_i }
|
|
239
|
+
"rId#{existing.max.to_i + 1}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Find the next available sheetId in workbook.xml.
|
|
243
|
+
# @return [String]
|
|
244
|
+
def next_sheet_id
|
|
245
|
+
existing = workbook_xml.xpath("//xmlns:sheet").map { |s| s["sheetId"].to_i }
|
|
246
|
+
(existing.max || 0).to_i + 1
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Clone a worksheet and register it in the package.
|
|
250
|
+
# Creates new worksheet part and updates workbook.xml, rels, content types.
|
|
251
|
+
# @param source_sheet_name [String] name of existing template sheet
|
|
252
|
+
# @param new_sheet_name [String] name of the new sheet
|
|
253
|
+
# @return [Hash] with :r_id, :sheet_id, :path for the new sheet
|
|
254
|
+
def clone_worksheet(source_sheet_name:, new_sheet_name:)
|
|
255
|
+
source_sheet = find_sheet_by_name(source_sheet_name)
|
|
256
|
+
raise UnsupportedTemplateError, "Template sheet '#{source_sheet_name}' not found" unless source_sheet
|
|
257
|
+
|
|
258
|
+
# Check for worksheet rels (unsupported in MVP).
|
|
259
|
+
# Axlsx always creates empty rels files; only reject non-empty ones.
|
|
260
|
+
source_path = worksheet_path_for_r_id(source_sheet[:r_id])
|
|
261
|
+
source_num = source_path[/\d+/].to_i
|
|
262
|
+
rels_path = "xl/worksheets/_rels/sheet#{source_num}.xml.rels"
|
|
263
|
+
if @files.key?(rels_path) && non_empty_rels?(rels_path)
|
|
264
|
+
raise UnsupportedTemplateError,
|
|
265
|
+
"Template sheet '#{source_sheet_name}' has worksheet relationships and cannot be cloned in MVP."
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
new_num = next_worksheet_number
|
|
269
|
+
new_path = "xl/worksheets/sheet#{new_num}.xml"
|
|
270
|
+
new_r_id = next_r_id
|
|
271
|
+
new_sheet_id = next_sheet_id.to_s
|
|
272
|
+
|
|
273
|
+
# Copy worksheet XML
|
|
274
|
+
source_xml = @files[source_path]
|
|
275
|
+
@files[new_path] = source_xml&.dup
|
|
276
|
+
|
|
277
|
+
# Add sheet entry to workbook.xml
|
|
278
|
+
wb = workbook_xml
|
|
279
|
+
sheets_elem = wb.at_xpath("//xmlns:sheets")
|
|
280
|
+
new_sheet_node = Nokogiri::XML::Node.new("sheet", wb)
|
|
281
|
+
new_sheet_node["name"] = new_sheet_name
|
|
282
|
+
new_sheet_node["sheetId"] = new_sheet_id
|
|
283
|
+
new_sheet_node["r:id"] = new_r_id
|
|
284
|
+
sheets_elem << new_sheet_node
|
|
285
|
+
@files["xl/workbook.xml"] = wb.to_xml(indent: 0, encoding: "UTF-8")
|
|
286
|
+
@workbook_xml = nil
|
|
287
|
+
|
|
288
|
+
# Add relationship in workbook.xml.rels
|
|
289
|
+
rels = parse_xml(@files["xl/_rels/workbook.xml.rels"])
|
|
290
|
+
rels_elem = rels.at_xpath("/xmlns:Relationships") || rels.root
|
|
291
|
+
new_rel = Nokogiri::XML::Node.new("Relationship", rels)
|
|
292
|
+
new_rel["Id"] = new_r_id
|
|
293
|
+
new_rel["Type"] = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
|
|
294
|
+
new_rel["Target"] = "worksheets/sheet#{new_num}.xml"
|
|
295
|
+
rels_elem << new_rel
|
|
296
|
+
@files["xl/_rels/workbook.xml.rels"] = rels.to_xml(indent: 0, encoding: "UTF-8")
|
|
297
|
+
|
|
298
|
+
# Add content type override
|
|
299
|
+
ct = parse_xml(@files["[Content_Types].xml"])
|
|
300
|
+
types_elem = ct.at_xpath("/xmlns:Types")
|
|
301
|
+
new_override = Nokogiri::XML::Node.new("Override", ct)
|
|
302
|
+
new_override["PartName"] = "/xl/worksheets/sheet#{new_num}.xml"
|
|
303
|
+
new_override["ContentType"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"
|
|
304
|
+
types_elem << new_override
|
|
305
|
+
@files["[Content_Types].xml"] = ct.to_xml(indent: 0, encoding: "UTF-8")
|
|
306
|
+
|
|
307
|
+
{ r_id: new_r_id, sheet_id: new_sheet_id.to_i, name: new_sheet_name, path: new_path }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Set a sheet to hidden state in workbook.xml.
|
|
311
|
+
# @param sheet_name [String]
|
|
312
|
+
def hide_sheet(sheet_name)
|
|
313
|
+
sheet = find_sheet_by_name(sheet_name)
|
|
314
|
+
return unless sheet
|
|
315
|
+
|
|
316
|
+
wb = workbook_xml
|
|
317
|
+
# Compare names in Ruby: interpolating them into XPath would allow
|
|
318
|
+
# injection via quotes/apostrophes in user-provided sheet names.
|
|
319
|
+
sheet_node = wb.xpath("//xmlns:sheet").find { |s| s["name"] == sheet_name }
|
|
320
|
+
sheet_node["state"] = "hidden" if sheet_node
|
|
321
|
+
@files["xl/workbook.xml"] = wb.to_xml(indent: 0, encoding: "UTF-8")
|
|
322
|
+
@workbook_xml = nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Add a media file to the package. Deduplicates by SHA-256.
|
|
326
|
+
# @param binary [String] raw bytes
|
|
327
|
+
# @param ext [String] file extension (e.g. "png")
|
|
328
|
+
# @return [String] path e.g. "xl/media/image1.png"
|
|
329
|
+
def add_media(binary, ext)
|
|
330
|
+
sha = Digest::SHA256.hexdigest(binary)
|
|
331
|
+
filename = "image_#{sha[0..11]}.#{ext}"
|
|
332
|
+
path = "xl/media/#{filename}"
|
|
333
|
+
|
|
334
|
+
# Dedup: return existing path if already present
|
|
335
|
+
return path if @files.key?(path)
|
|
336
|
+
|
|
337
|
+
@files[path] = binary
|
|
338
|
+
|
|
339
|
+
# Add/ensure Default content type for this extension
|
|
340
|
+
ct = parse_xml(@files["[Content_Types].xml"])
|
|
341
|
+
ns = { "xmlns" => "http://schemas.openxmlformats.org/package/2006/content-types" }
|
|
342
|
+
ext_node = ct.at_xpath("//xmlns:Default[@Extension='#{ext}']", ns)
|
|
343
|
+
unless ext_node
|
|
344
|
+
default = Nokogiri::XML::Node.new("Default", ct)
|
|
345
|
+
default["Extension"] = ext
|
|
346
|
+
media_ct = case ext
|
|
347
|
+
when "png" then "image/png"
|
|
348
|
+
when "jpeg", "jpg" then "image/jpeg"
|
|
349
|
+
when "gif" then "image/gif"
|
|
350
|
+
else "application/octet-stream"
|
|
351
|
+
end
|
|
352
|
+
default["ContentType"] = media_ct
|
|
353
|
+
# OPC schema requires all Default elements before any Override
|
|
354
|
+
first_override = ct.at_xpath("//xmlns:Override", ns)
|
|
355
|
+
first_override ? first_override.add_previous_sibling(default) : ct.root.add_child(default)
|
|
356
|
+
@files["[Content_Types].xml"] = ct.to_xml(indent: 0, encoding: "UTF-8")
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
path
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Attach a drawing to a worksheet.
|
|
363
|
+
# - Creates xl/drawings/drawingM.xml and _rels/drawingM.xml.rels
|
|
364
|
+
# - Creates/updates worksheet rels and inserts <drawing> element
|
|
365
|
+
# - Adds content type override
|
|
366
|
+
# @param sheet_r_id [String] workbook rels rId for the worksheet
|
|
367
|
+
# @param drawing_xml [String] the <xdr:wsDr> XML
|
|
368
|
+
# @param drawing_rels_xml [String] the drawing relationships XML
|
|
369
|
+
# @return [String] the drawing rId in worksheet rels (e.g. "rId1")
|
|
370
|
+
def attach_drawing(sheet_r_id:, drawing_xml:, drawing_rels_xml:)
|
|
371
|
+
sheet_num = sheet_number_for_r_id(sheet_r_id)
|
|
372
|
+
ws_rels_path = "xl/worksheets/_rels/sheet#{sheet_num}.xml.rels"
|
|
373
|
+
ws_rels = parse_rels(ws_rels_path)
|
|
374
|
+
|
|
375
|
+
# A worksheet may contain at most ONE <drawing> element. If the sheet
|
|
376
|
+
# already has a drawing part, merge the new anchors into it.
|
|
377
|
+
existing_rel = ws_rels.xpath("//xmlns:Relationship").find do |r|
|
|
378
|
+
r["Type"]&.end_with?("/drawing")
|
|
379
|
+
end
|
|
380
|
+
if existing_rel
|
|
381
|
+
merge_into_existing_drawing(existing_rel, drawing_xml, drawing_rels_xml)
|
|
382
|
+
return existing_rel["Id"]
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Determine next drawing number
|
|
386
|
+
drawing_num = next_drawing_number
|
|
387
|
+
drawing_path = "xl/drawings/drawing#{drawing_num}.xml"
|
|
388
|
+
drawing_rels_path = "xl/drawings/_rels/drawing#{drawing_num}.xml.rels"
|
|
389
|
+
|
|
390
|
+
# Write drawing files
|
|
391
|
+
@files[drawing_path] = drawing_xml
|
|
392
|
+
|
|
393
|
+
# Write drawing rels
|
|
394
|
+
if drawing_rels_xml
|
|
395
|
+
@files[drawing_rels_path] = drawing_rels_xml
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Add worksheet rels: worksheet → drawing
|
|
399
|
+
next_wr_id = next_rels_id(ws_rels)
|
|
400
|
+
|
|
401
|
+
new_rel = Nokogiri::XML::Node.new("Relationship", ws_rels)
|
|
402
|
+
new_rel["Id"] = next_wr_id
|
|
403
|
+
new_rel["Type"] = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing"
|
|
404
|
+
new_rel["Target"] = "../drawings/drawing#{drawing_num}.xml"
|
|
405
|
+
ws_rels.root.add_child(new_rel)
|
|
406
|
+
@files[ws_rels_path] = ws_rels.to_xml(indent: 0, encoding: "UTF-8")
|
|
407
|
+
|
|
408
|
+
# Insert <drawing> element into worksheet XML
|
|
409
|
+
ws_path = worksheet_path_for_r_id(sheet_r_id)
|
|
410
|
+
insert_drawing_into_worksheet(ws_path, next_wr_id)
|
|
411
|
+
|
|
412
|
+
# Add content type override
|
|
413
|
+
add_content_type_override(drawing_path,
|
|
414
|
+
"application/vnd.openxmlformats-officedocument.drawing+xml")
|
|
415
|
+
|
|
416
|
+
next_wr_id
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Find a sheet by name in workbook.xml.
|
|
420
|
+
# @param name [String]
|
|
421
|
+
# @return [Hash, nil]
|
|
422
|
+
def find_sheet_by_name(sheet_name)
|
|
423
|
+
sheets.find { |s| s[:name] == sheet_name }
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Get the worksheet file path for a given r_id.
|
|
427
|
+
# @param r_id [String]
|
|
428
|
+
# @return [String, nil]
|
|
429
|
+
def worksheet_path_for_r_id(r_id)
|
|
430
|
+
rels = parse_xml(@files["xl/_rels/workbook.xml.rels"])
|
|
431
|
+
rel = rels.at_xpath("//xmlns:Relationship[@Id='#{r_id}']")
|
|
432
|
+
return nil unless rel
|
|
433
|
+
|
|
434
|
+
target = rel["Target"]
|
|
435
|
+
Pathname.new("xl/#{target}").cleanpath.to_s.sub(%r{\A/?}, "")
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Get all sheet names from workbook.xml.
|
|
439
|
+
# @return [Array<String>]
|
|
440
|
+
def sheet_names
|
|
441
|
+
sheets.map { |s| s[:name] }
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
private
|
|
445
|
+
|
|
446
|
+
# Open a brand-new archive for writing across rubyzip 2.x and 3.x.
|
|
447
|
+
# See ZIP_CREATE_KEYWORD for why the call has to be spelled two ways.
|
|
448
|
+
def open_new_zip(output_path, &)
|
|
449
|
+
if ZIP_CREATE_KEYWORD
|
|
450
|
+
Zip::File.open(output_path, create: true, &)
|
|
451
|
+
else
|
|
452
|
+
Zip::File.open(output_path, Zip::File::CREATE, &)
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Check if a worksheet rels file contains actual relationships (not just empty <Relationships/>).
|
|
457
|
+
def non_empty_rels?(rels_path)
|
|
458
|
+
xml = @files[rels_path]
|
|
459
|
+
return false unless xml
|
|
460
|
+
|
|
461
|
+
doc = parse_xml(xml)
|
|
462
|
+
doc.xpath("//xmlns:Relationship").any?
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def parse_xml(content)
|
|
466
|
+
return nil unless content
|
|
467
|
+
|
|
468
|
+
Nokogiri::XML(content) { |config| config.strict.noblanks }
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def validate_xlsx!
|
|
472
|
+
unless @files.key?("[Content_Types].xml") && @files.key?("xl/workbook.xml")
|
|
473
|
+
raise InvalidXlsxError, "Not a valid .xlsx file: missing required entries"
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# ---------- private drawing helpers ----------
|
|
478
|
+
|
|
479
|
+
def next_drawing_number
|
|
480
|
+
nums = entries
|
|
481
|
+
.grep(%r{\Axl/drawings/drawing\d+\.xml\z})
|
|
482
|
+
.map { |e| e[/\d+/].to_i }
|
|
483
|
+
(nums.max || 0) + 1
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def sheet_number_for_r_id(r_id)
|
|
487
|
+
path = worksheet_path_for_r_id(r_id)
|
|
488
|
+
path[/\d+/].to_i
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def parse_rels(rels_path)
|
|
492
|
+
if @files.key?(rels_path)
|
|
493
|
+
parse_xml(@files[rels_path])
|
|
494
|
+
else
|
|
495
|
+
empty_rels_xml
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def empty_rels_xml
|
|
500
|
+
Nokogiri::XML('<?xml version="1.0" encoding="UTF-8"?>' \
|
|
501
|
+
"<Relationships " \
|
|
502
|
+
'xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>')
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def next_rels_id(rels_doc)
|
|
506
|
+
existing = rels_doc.xpath("//xmlns:Relationship").map do |r|
|
|
507
|
+
r["Id"][/\d+/].to_i
|
|
508
|
+
end
|
|
509
|
+
"rId#{existing.max.to_i + 1}"
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Merge freshly built drawing anchors and their relationships into the
|
|
513
|
+
# drawing part already attached to the worksheet, remapping rIds.
|
|
514
|
+
def merge_into_existing_drawing(ws_drawing_rel, new_drawing_xml, new_rels_xml)
|
|
515
|
+
target = ws_drawing_rel["Target"]
|
|
516
|
+
drawing_path = Pathname.new("xl/worksheets/#{target}").cleanpath.to_s.sub(%r{\A/?}, "")
|
|
517
|
+
drawing_rels_path = "#{drawing_path.sub('xl/drawings/', 'xl/drawings/_rels/')}.rels"
|
|
518
|
+
|
|
519
|
+
existing_doc = parse_xml(@files[drawing_path])
|
|
520
|
+
existing_rels = parse_rels(drawing_rels_path)
|
|
521
|
+
|
|
522
|
+
# Remap new relationship ids to avoid collisions with existing ones
|
|
523
|
+
id_map = {}
|
|
524
|
+
if new_rels_xml
|
|
525
|
+
new_rels = parse_xml(new_rels_xml)
|
|
526
|
+
new_rels.xpath("//xmlns:Relationship").each do |rel|
|
|
527
|
+
old_id = rel["Id"]
|
|
528
|
+
new_id = next_rels_id(existing_rels)
|
|
529
|
+
id_map[old_id] = new_id
|
|
530
|
+
rel["Id"] = new_id
|
|
531
|
+
existing_rels.root.add_child(rel)
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
new_doc = parse_xml(new_drawing_xml)
|
|
536
|
+
new_doc.root.element_children.each do |anchor|
|
|
537
|
+
remap_relationship_ids(anchor, id_map)
|
|
538
|
+
existing_doc.root.add_child(anchor)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
@files[drawing_path] = existing_doc.to_xml(indent: 0, encoding: "UTF-8")
|
|
542
|
+
@files[drawing_rels_path] = existing_rels.to_xml(indent: 0, encoding: "UTF-8")
|
|
543
|
+
add_content_type_override(drawing_path,
|
|
544
|
+
"application/vnd.openxmlformats-officedocument.drawing+xml")
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Rewrite r:embed / r:link style attributes according to an rId map.
|
|
548
|
+
def remap_relationship_ids(node, id_map)
|
|
549
|
+
return if id_map.empty?
|
|
550
|
+
|
|
551
|
+
node.xpath("descendant-or-self::*/@*").each do |attr|
|
|
552
|
+
next unless attr.namespace&.href == R_NS
|
|
553
|
+
next unless id_map.key?(attr.value)
|
|
554
|
+
|
|
555
|
+
attr.value = id_map[attr.value]
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def insert_drawing_into_worksheet(ws_path, drawing_r_id)
|
|
560
|
+
xml = @files[ws_path]
|
|
561
|
+
return unless xml
|
|
562
|
+
|
|
563
|
+
doc = parse_xml(xml)
|
|
564
|
+
root = doc.root
|
|
565
|
+
|
|
566
|
+
# Ensure the r: namespace is declared before writing an r:id attribute
|
|
567
|
+
unless root.namespace_definitions.any? { |ns| ns.prefix == "r" }
|
|
568
|
+
root.add_namespace_definition("r", R_NS)
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
drawing = Nokogiri::XML::Node.new("drawing", doc)
|
|
572
|
+
drawing["r:id"] = drawing_r_id
|
|
573
|
+
|
|
574
|
+
# Insert at the schema-mandated CT_Worksheet position
|
|
575
|
+
Worksheet.insert_child_in_order(root, drawing)
|
|
576
|
+
|
|
577
|
+
@files[ws_path] = doc.to_xml(indent: 0, encoding: "UTF-8")
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def add_content_type_override(part_name, content_type)
|
|
581
|
+
ct = parse_xml(@files["[Content_Types].xml"])
|
|
582
|
+
ns = { "xmlns" => "http://schemas.openxmlformats.org/package/2006/content-types" }
|
|
583
|
+
|
|
584
|
+
# Normalize part name to start with /
|
|
585
|
+
part_name = "/#{part_name}" unless part_name.start_with?("/")
|
|
586
|
+
|
|
587
|
+
# Don't add duplicate
|
|
588
|
+
existing = ct.at_xpath("//xmlns:Override[@PartName='#{part_name}']", ns)
|
|
589
|
+
return if existing
|
|
590
|
+
|
|
591
|
+
override = Nokogiri::XML::Node.new("Override", ct)
|
|
592
|
+
override["PartName"] = part_name
|
|
593
|
+
override["ContentType"] = content_type
|
|
594
|
+
ct.root.add_child(override)
|
|
595
|
+
@files["[Content_Types].xml"] = ct.to_xml(indent: 0, encoding: "UTF-8")
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
# rubocop:enable Metrics/ClassLength
|
|
599
|
+
end
|