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"



325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/liquid_xlsx/package.rb', line 325

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")



366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
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
# File 'lib/liquid_xlsx/package.rb', line 366

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)


180
181
182
# File 'lib/liquid_xlsx/package.rb', line 180

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:



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
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
# File 'lib/liquid_xlsx/package.rb', line 250

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)


418
419
420
# File 'lib/liquid_xlsx/package.rb', line 418

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)


308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/liquid_xlsx/package.rb', line 308

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"



232
233
234
235
236
# File 'lib/liquid_xlsx/package.rb', line 232

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)


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

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)


223
224
225
226
227
228
# File 'lib/liquid_xlsx/package.rb', line 223

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.



185
186
187
188
189
190
191
192
193
194
# File 'lib/liquid_xlsx/package.rb', line 185

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)


171
172
173
174
175
176
# File 'lib/liquid_xlsx/package.rb', line 171

def save_worksheet_xml(r_id, xml)
  path = worksheet_path_for_r_id(r_id)
  return unless path

  @files[path] = xml
end

#set_recalculation_flagsObject

Set workbook recalculation flags.



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/liquid_xlsx/package.rb', line 197

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


435
436
437
# File 'lib/liquid_xlsx/package.rb', line 435

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)


425
426
427
428
429
430
431
# File 'lib/liquid_xlsx/package.rb', line 425

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

  resolve_target("xl", rel["Target"])
end

#worksheet_xml(r_id) ⇒ String?

Get worksheet XML by r_id.

Parameters:

  • r_id (String)

Returns:

  • (String, nil)


163
164
165
166
# File 'lib/liquid_xlsx/package.rb', line 163

def worksheet_xml(r_id)
  path = worksheet_path_for_r_id(r_id)
  path && @files[path]
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