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,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