Class: LiquidXlsx::Package

Inherits:
Object
  • Object
show all
Defined in:
lib/liquid_xlsx/package.rb

Overview

Handles reading and writing .xlsx files as ZIP archives. rubocop:disable Metrics/ClassLength

Constant Summary collapse

R_NS =

Relationship namespace (r: prefix).

"http://schemas.openxmlformats.org/officeDocument/2006/relationships"
ZIP_CREATE_KEYWORD =

rubyzip 3 removed the positional create argument of Zip::File.open along with the Zip::File::CREATE constant; rubyzip 2.4 introduced the create: keyword, and 2.3 only understands the positional form. Detect the supported form once at load time instead of rescuing per call.

Zip::File.method(:open).parameters.any? do |type, name|
  name == :create && %i[key keyreq].include?(type)
end
ZIP_SIZE_KEYWORD =

rubyzip 3 turned ZIP64 on by default. Streaming a member gives it no size up front, so it marks EVERY member as ZIP64 — version 4.5 in the local header and 0xFFFFFFFF sizes. Such an .xlsx is rejected by Excel and by LibreOffice builds without ZIP64 support (7.4 fails, 26.2 reads it). Here the member content is fully known, so the size can be declared up front and Entry#prep_local_zip64_extra leaves an ordinary header alone. rubyzip 2.x has no size: keyword, but there ZIP64 is off by default.

Zip::File.instance_method(:get_output_stream).parameters.any? do |type, name|
  name == :size && %i[key keyreq].include?(type)
end
MAX_ENTRIES =

Guard rails against zip bombs / resource exhaustion when unpacking.

10_000
MAX_ENTRY_UNCOMPRESSED =

512 MB per entry

512 * 1024 * 1024
MAX_TOTAL_UNCOMPRESSED =

1 GB per archive

1024 * 1024 * 1024

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(template_path) ⇒ Package

Returns a new instance of Package.



42
43
44
45
46
# File 'lib/liquid_xlsx/package.rb', line 42

def initialize(template_path)
  @template_path = template_path
  @files = {}
  @temp_dir = nil
end

Instance Attribute Details

#template_pathObject (readonly)

Returns the value of attribute template_path.



13
14
15
# File 'lib/liquid_xlsx/package.rb', line 13

def template_path
  @template_path
end

Instance Method Details

#[](path) ⇒ String?

Get raw content of a file inside the package.

Parameters:

  • path (String)

    e.g. "xl/workbook.xml"

Returns:

  • (String, nil)


86
87
88
# File 'lib/liquid_xlsx/package.rb', line 86

def [](path)
  @files[path]
end

#[]=(path, content) ⇒ Object

Set raw content of a file inside the package.

Parameters:

  • path (String)
  • content (String)


93
94
95
96
# File 'lib/liquid_xlsx/package.rb', line 93

def []=(path, content)
  @files[path] = content
  @workbook_xml = nil if path == "xl/workbook.xml"
end

#add_media(binary, ext) ⇒ String

Add a media file to the package. Deduplicates by SHA-256.

Parameters:

  • binary (String)

    raw bytes

  • ext (String)

    file extension (e.g. "png")

Returns:

  • (String)

    path e.g. "xl/media/image1.png"



340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
# File 'lib/liquid_xlsx/package.rb', line 340

def add_media(binary, ext)
  sha = Digest::SHA256.hexdigest(binary)
  filename = "image_#{sha[0..11]}.#{ext}"
  path = "xl/media/#{filename}"

  # Dedup: return existing path if already present
  return path if @files.key?(path)

  @files[path] = binary

  # Add/ensure Default content type for this extension
  ct = parse_xml(@files["[Content_Types].xml"])
  ns = { "xmlns" => "http://schemas.openxmlformats.org/package/2006/content-types" }
  ext_node = ct.at_xpath("//xmlns:Default[@Extension='#{ext}']", ns)
  unless ext_node
    default = Nokogiri::XML::Node.new("Default", ct)
    default["Extension"] = ext
    media_ct = case ext
               when "png" then "image/png"
               when "jpeg", "jpg" then "image/jpeg"
               when "gif" then "image/gif"
               else "application/octet-stream"
               end
    default["ContentType"] = media_ct
    # OPC schema requires all Default elements before any Override
    first_override = ct.at_xpath("//xmlns:Override", ns)
    first_override ? first_override.add_previous_sibling(default) : ct.root.add_child(default)
    @files["[Content_Types].xml"] = ct.to_xml(indent: 0, encoding: "UTF-8")
  end

  path
end

#attach_drawing(sheet_r_id:, drawing_xml:, drawing_rels_xml:) ⇒ String

Attach a drawing to a worksheet.

  • Creates xl/drawings/drawingM.xml and _rels/drawingM.xml.rels
  • Creates/updates worksheet rels and inserts element
  • Adds content type override

Parameters:

  • sheet_r_id (String)

    workbook rels rId for the worksheet

  • drawing_xml (String)

    the xdr:wsDr XML

  • drawing_rels_xml (String)

    the drawing relationships XML

Returns:

  • (String)

    the drawing rId in worksheet rels (e.g. "rId1")



381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
# File 'lib/liquid_xlsx/package.rb', line 381

def attach_drawing(sheet_r_id:, drawing_xml:, drawing_rels_xml:)
  sheet_num = sheet_number_for_r_id(sheet_r_id)
  ws_rels_path = "xl/worksheets/_rels/sheet#{sheet_num}.xml.rels"
  ws_rels = parse_rels(ws_rels_path)

  # A worksheet may contain at most ONE <drawing> element. If the sheet
  # already has a drawing part, merge the new anchors into it.
  existing_rel = ws_rels.xpath("//xmlns:Relationship").find do |r|
    r["Type"]&.end_with?("/drawing")
  end
  if existing_rel
    merge_into_existing_drawing(existing_rel, drawing_xml, drawing_rels_xml)
    return existing_rel["Id"]
  end

  # Determine next drawing number
  drawing_num = next_drawing_number
  drawing_path = "xl/drawings/drawing#{drawing_num}.xml"
  drawing_rels_path = "xl/drawings/_rels/drawing#{drawing_num}.xml.rels"

  # Write drawing files
  @files[drawing_path] = drawing_xml

  # Write drawing rels
  if drawing_rels_xml
    @files[drawing_rels_path] = drawing_rels_xml
  end

  # Add worksheet rels: worksheet → drawing
  next_wr_id = next_rels_id(ws_rels)

  new_rel = Nokogiri::XML::Node.new("Relationship", ws_rels)
  new_rel["Id"] = next_wr_id
  new_rel["Type"] = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing"
  new_rel["Target"] = "../drawings/drawing#{drawing_num}.xml"
  ws_rels.root.add_child(new_rel)
  @files[ws_rels_path] = ws_rels.to_xml(indent: 0, encoding: "UTF-8")

  # Insert <drawing> element into worksheet XML
  ws_path = worksheet_path_for_r_id(sheet_r_id)
  insert_drawing_into_worksheet(ws_path, next_wr_id)

  # Add content type override
  add_content_type_override(drawing_path,
                            "application/vnd.openxmlformats-officedocument.drawing+xml")

  next_wr_id
end

#calc_chain_xmlString?

Get calc chain XML.

Returns:

  • (String, nil)


195
196
197
# File 'lib/liquid_xlsx/package.rb', line 195

def calc_chain_xml
  @files["xl/calcChain.xml"]
end

#clone_worksheet(source_sheet_name:, new_sheet_name:) ⇒ Hash

Clone a worksheet and register it in the package. Creates new worksheet part and updates workbook.xml, rels, content types.

Parameters:

  • source_sheet_name (String)

    name of existing template sheet

  • new_sheet_name (String)

    name of the new sheet

Returns:

  • (Hash)

    with :r_id, :sheet_id, :path for the new sheet

Raises:



265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/liquid_xlsx/package.rb', line 265

def clone_worksheet(source_sheet_name:, new_sheet_name:)
  source_sheet = find_sheet_by_name(source_sheet_name)
  raise UnsupportedTemplateError, "Template sheet '#{source_sheet_name}' not found" unless source_sheet

  # Check for worksheet rels (unsupported in MVP).
  # Axlsx always creates empty rels files; only reject non-empty ones.
  source_path = worksheet_path_for_r_id(source_sheet[:r_id])
  source_num = source_path[/\d+/].to_i
  rels_path = "xl/worksheets/_rels/sheet#{source_num}.xml.rels"
  if @files.key?(rels_path) && non_empty_rels?(rels_path)
    raise UnsupportedTemplateError,
          "Template sheet '#{source_sheet_name}' has worksheet relationships and cannot be cloned in MVP."
  end

  new_num = next_worksheet_number
  new_path = "xl/worksheets/sheet#{new_num}.xml"
  new_r_id = next_r_id
  new_sheet_id = next_sheet_id.to_s

  # Copy worksheet XML
  source_xml = @files[source_path]
  @files[new_path] = source_xml&.dup

  # Add sheet entry to workbook.xml
  wb = workbook_xml
  sheets_elem = wb.at_xpath("//xmlns:sheets")
  new_sheet_node = Nokogiri::XML::Node.new("sheet", wb)
  new_sheet_node["name"] = new_sheet_name
  new_sheet_node["sheetId"] = new_sheet_id
  new_sheet_node["r:id"] = new_r_id
  sheets_elem << new_sheet_node
  @files["xl/workbook.xml"] = wb.to_xml(indent: 0, encoding: "UTF-8")
  @workbook_xml = nil

  # Add relationship in workbook.xml.rels
  rels = parse_xml(@files["xl/_rels/workbook.xml.rels"])
  rels_elem = rels.at_xpath("/xmlns:Relationships") || rels.root
  new_rel = Nokogiri::XML::Node.new("Relationship", rels)
  new_rel["Id"] = new_r_id
  new_rel["Type"] = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
  new_rel["Target"] = "worksheets/sheet#{new_num}.xml"
  rels_elem << new_rel
  @files["xl/_rels/workbook.xml.rels"] = rels.to_xml(indent: 0, encoding: "UTF-8")

  # Add content type override
  ct = parse_xml(@files["[Content_Types].xml"])
  types_elem = ct.at_xpath("/xmlns:Types")
  new_override = Nokogiri::XML::Node.new("Override", ct)
  new_override["PartName"] = "/xl/worksheets/sheet#{new_num}.xml"
  new_override["ContentType"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"
  types_elem << new_override
  @files["[Content_Types].xml"] = ct.to_xml(indent: 0, encoding: "UTF-8")

  { r_id: new_r_id, sheet_id: new_sheet_id.to_i, name: new_sheet_name, path: new_path }
end

#delete(path) ⇒ Object

Delete a file from the package.

Parameters:

  • path (String)


100
101
102
103
# File 'lib/liquid_xlsx/package.rb', line 100

def delete(path)
  @workbook_xml = nil if path == "xl/workbook.xml"
  @files.delete(path)
end

#entriesArray<String>

Get the list of file paths in the package.

Returns:

  • (Array<String>)


107
108
109
# File 'lib/liquid_xlsx/package.rb', line 107

def entries
  @files.keys
end

#find_sheet_by_name(sheet_name) ⇒ Hash?

Find a sheet by name in workbook.xml.

Parameters:

  • name (String)

Returns:

  • (Hash, nil)


433
434
435
# File 'lib/liquid_xlsx/package.rb', line 433

def find_sheet_by_name(sheet_name)
  sheets.find { |s| s[:name] == sheet_name }
end

#hide_sheet(sheet_name) ⇒ Object

Set a sheet to hidden state in workbook.xml.

Parameters:

  • sheet_name (String)


323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/liquid_xlsx/package.rb', line 323

def hide_sheet(sheet_name)
  sheet = find_sheet_by_name(sheet_name)
  return unless sheet

  wb = workbook_xml
  # Compare names in Ruby: interpolating them into XPath would allow
  # injection via quotes/apostrophes in user-provided sheet names.
  sheet_node = wb.xpath("//xmlns:sheet").find { |s| s["name"] == sheet_name }
  sheet_node["state"] = "hidden" if sheet_node
  @files["xl/workbook.xml"] = wb.to_xml(indent: 0, encoding: "UTF-8")
  @workbook_xml = nil
end

#next_r_idString

Find the next available rId in workbook.xml.rels.

Returns:

  • (String)

    e.g. "rId5"



247
248
249
250
251
# File 'lib/liquid_xlsx/package.rb', line 247

def next_r_id
  rels = parse_xml(@files["xl/_rels/workbook.xml.rels"])
  existing = rels.xpath("//xmlns:Relationship").map { |r| r["Id"][/\d+/].to_i }
  "rId#{existing.max.to_i + 1}"
end

#next_sheet_idString

Find the next available sheetId in workbook.xml.

Returns:

  • (String)


255
256
257
258
# File 'lib/liquid_xlsx/package.rb', line 255

def next_sheet_id
  existing = workbook_xml.xpath("//xmlns:sheet").map { |s| s["sheetId"].to_i }
  (existing.max || 0).to_i + 1
end

#next_worksheet_numberInteger

Find the next available worksheet number. Scans xl/worksheets/ for sheetN.xml and returns N+1.

Returns:

  • (Integer)


238
239
240
241
242
243
# File 'lib/liquid_xlsx/package.rb', line 238

def next_worksheet_number
  nums = entries
         .grep(%r{\Axl/worksheets/sheet\d+\.xml\z})
         .map { |e| e[/\d+/].to_i }
  (nums.max || 0) + 1
end

#readObject

Unpack the .xlsx and read all internal files.

Raises:



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/liquid_xlsx/package.rb', line 49

def read
  raise InvalidXlsxError, "Template file not found: #{@template_path}" unless File.exist?(@template_path)

  @files = {}
  total_size = 0
  Zip::File.open(@template_path) do |zip|
    raise InvalidXlsxError, "Too many entries in archive (max #{MAX_ENTRIES})" if zip.size > MAX_ENTRIES

    zip.each do |entry|
      # Protection against zip slip
      name = entry.name
      raise InvalidXlsxError, "Zip slip detected: #{name}" if name.start_with?("/") || name.include?("..")

      if entry.size > MAX_ENTRY_UNCOMPRESSED
        raise InvalidXlsxError, "Archive entry too large: #{name} (#{entry.size} bytes)"
      end

      total_size += entry.size
      if total_size > MAX_TOTAL_UNCOMPRESSED
        raise InvalidXlsxError, "Archive too large when uncompressed (max #{MAX_TOTAL_UNCOMPRESSED} bytes)"
      end

      @files[name] = if entry.directory?
                       nil
                     else
                       entry.get_input_stream.read
                     end
    end
  end

  validate_xlsx!
  self
end

#remove_calc_chainObject

Remove calc chain and its relationship.



200
201
202
203
204
205
206
207
208
209
# File 'lib/liquid_xlsx/package.rb', line 200

def remove_calc_chain
  @files.delete("xl/calcChain.xml")
  # Remove relationship (Nokogiri does not support XPath ends-with)
  rels = parse_xml(@files["xl/_rels/workbook.xml.rels"])
  rels.xpath("//xmlns:Relationship").each do |rel|
    target = rel["Target"]
    rel.remove if target&.end_with?("calcChain.xml")
  end
  @files["xl/_rels/workbook.xml.rels"] = rels.to_xml(indent: 0, encoding: "UTF-8")
end

#save_worksheet_xml(r_id, xml) ⇒ Object

Save worksheet XML back.

Parameters:

  • r_id (String)
  • xml (String)


182
183
184
185
186
187
188
189
190
191
# File 'lib/liquid_xlsx/package.rb', line 182

def save_worksheet_xml(r_id, xml)
  rels = parse_xml(@files["xl/_rels/workbook.xml.rels"])
  rel = rels.at_xpath("//xmlns:Relationship[@Id='#{r_id}']")
  return unless rel

  target = rel["Target"]
  path = "xl/#{target}"
  normalized = Pathname.new(path).cleanpath.to_s.sub(%r{\A/?}, "")
  @files[normalized] = xml
end

#set_recalculation_flagsObject

Set workbook recalculation flags.



212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/liquid_xlsx/package.rb', line 212

def set_recalculation_flags
  wb = workbook_xml
  calc_pr = wb.at_xpath("//xmlns:calcPr")
  if calc_pr
    calc_pr["calcMode"] = "auto"
    calc_pr["fullCalcOnLoad"] = "1"
    calc_pr.remove_attribute("calcId")
    calc_pr.remove_attribute("calcCompleted")
  else
    # Add calcPr element at its schema position (after sheets/definedNames)
    root = wb.at_xpath("/xmlns:workbook")
    if root
      calc_pr_node = Nokogiri::XML::Node.new("calcPr", wb)
      calc_pr_node["calcMode"] = "auto"
      calc_pr_node["fullCalcOnLoad"] = "1"
      anchor = wb.at_xpath("//xmlns:definedNames") || wb.at_xpath("//xmlns:sheets")
      anchor ? anchor.add_next_sibling(calc_pr_node) : root.add_child(calc_pr_node)
    end
  end
  @files["xl/workbook.xml"] = wb.to_xml(indent: 0, encoding: "UTF-8")
  @workbook_xml = nil
end

#shared_strings_xmlString?

Get shared strings XML.

Returns:

  • (String, nil)


144
145
146
# File 'lib/liquid_xlsx/package.rb', line 144

def shared_strings_xml
  @files["xl/sharedStrings.xml"]
end

#sheet_namesArray<String>

Get all sheet names from workbook.xml.

Returns:

  • (Array<String>)


451
452
453
# File 'lib/liquid_xlsx/package.rb', line 451

def sheet_names
  sheets.map { |s| s[:name] }
end

#sheetsArray<Hash>

Get the list of sheet references from workbook.xml.

Returns:

  • (Array<Hash>)

    each with :name, :sheet_id, :r_id



150
151
152
153
154
155
156
157
158
# File 'lib/liquid_xlsx/package.rb', line 150

def sheets
  workbook_xml.xpath("//xmlns:sheet").map do |sheet|
    {
      name: sheet["name"],
      sheet_id: sheet["sheetId"],
      r_id: sheet["r:id"]
    }
  end
end

#to_binaryString

Write modified content to a binary string.

Returns:

  • (String)


128
129
130
131
132
133
134
# File 'lib/liquid_xlsx/package.rb', line 128

def to_binary
  Dir.mktmpdir("liquid_xlsx") do |tmp|
    tmp_path = File.join(tmp, "output.xlsx")
    write(tmp_path)
    File.binread(tmp_path)
  end
end

#workbook_xmlNokogiri::XML::Document

Get workbook XML parsed.

Returns:

  • (Nokogiri::XML::Document)


138
139
140
# File 'lib/liquid_xlsx/package.rb', line 138

def workbook_xml
  @workbook_xml ||= parse_xml(@files["xl/workbook.xml"])
end

#worksheet_path_for_r_id(r_id) ⇒ String?

Get the worksheet file path for a given r_id.

Parameters:

  • r_id (String)

Returns:

  • (String, nil)


440
441
442
443
444
445
446
447
# File 'lib/liquid_xlsx/package.rb', line 440

def worksheet_path_for_r_id(r_id)
  rels = parse_xml(@files["xl/_rels/workbook.xml.rels"])
  rel = rels.at_xpath("//xmlns:Relationship[@Id='#{r_id}']")
  return nil unless rel

  target = rel["Target"]
  Pathname.new("xl/#{target}").cleanpath.to_s.sub(%r{\A/?}, "")
end

#worksheet_xml(r_id) ⇒ String?

Get worksheet XML by r_id.

Parameters:

  • r_id (String)

Returns:

  • (String, nil)


163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/liquid_xlsx/package.rb', line 163

def worksheet_xml(r_id)
  # Find relationship target in workbook.xml.rels
  rels = parse_xml(@files["xl/_rels/workbook.xml.rels"])
  rel = rels.at_xpath("//xmlns:Relationship[@Id='#{r_id}']")
  return nil unless rel

  target = rel["Target"]
  path = "xl/#{target}"

  # Handle paths like xl/worksheets/sheet1.xml vs xl/../xl/worksheets/sheet1.xml
  # Normalize the path
  normalized = Pathname.new(path).cleanpath.to_s
  normalized = normalized.sub(%r{\A/?}, "")
  @files[normalized]
end

#write(output_path) ⇒ Object

Write modified content to a new .xlsx file. An existing file at the path is replaced entirely — otherwise stale entries of the previous archive would survive inside the new one.

Parameters:

  • output_path (String)


115
116
117
118
119
120
121
122
123
124
# File 'lib/liquid_xlsx/package.rb', line 115

def write(output_path)
  FileUtils.rm_f(output_path)
  open_new_zip(output_path) do |zip|
    @files.each do |name, content|
      next if content.nil? # directories

      write_entry(zip, name, content)
    end
  end
end