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



31
32
33
34
35
# File 'lib/liquid_xlsx/package.rb', line 31

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)


75
76
77
# File 'lib/liquid_xlsx/package.rb', line 75

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

#[]=(path, content) ⇒ Object

Set raw content of a file inside the package.

Parameters:

  • path (String)
  • content (String)


82
83
84
85
# File 'lib/liquid_xlsx/package.rb', line 82

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"



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
357
358
359
360
# File 'lib/liquid_xlsx/package.rb', line 329

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



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
414
415
416
417
# File 'lib/liquid_xlsx/package.rb', line 370

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)


184
185
186
# File 'lib/liquid_xlsx/package.rb', line 184

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:



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
305
306
307
308
# File 'lib/liquid_xlsx/package.rb', line 254

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)


89
90
91
92
# File 'lib/liquid_xlsx/package.rb', line 89

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


96
97
98
# File 'lib/liquid_xlsx/package.rb', line 96

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)


422
423
424
# File 'lib/liquid_xlsx/package.rb', line 422

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)


312
313
314
315
316
317
318
319
320
321
322
323
# File 'lib/liquid_xlsx/package.rb', line 312

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"



236
237
238
239
240
# File 'lib/liquid_xlsx/package.rb', line 236

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)


244
245
246
247
# File 'lib/liquid_xlsx/package.rb', line 244

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)


227
228
229
230
231
232
# File 'lib/liquid_xlsx/package.rb', line 227

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:



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/liquid_xlsx/package.rb', line 38

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.



189
190
191
192
193
194
195
196
197
198
# File 'lib/liquid_xlsx/package.rb', line 189

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
177
178
179
180
# File 'lib/liquid_xlsx/package.rb', line 171

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.



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/liquid_xlsx/package.rb', line 201

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)


133
134
135
# File 'lib/liquid_xlsx/package.rb', line 133

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

#sheet_namesArray<String>

Get all sheet names from workbook.xml.

Returns:

  • (Array<String>)


440
441
442
# File 'lib/liquid_xlsx/package.rb', line 440

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



139
140
141
142
143
144
145
146
147
# File 'lib/liquid_xlsx/package.rb', line 139

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)


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

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)


127
128
129
# File 'lib/liquid_xlsx/package.rb', line 127

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)


429
430
431
432
433
434
435
436
# File 'lib/liquid_xlsx/package.rb', line 429

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)


152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/liquid_xlsx/package.rb', line 152

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)


104
105
106
107
108
109
110
111
112
113
# File 'lib/liquid_xlsx/package.rb', line 104

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

      zip.get_output_stream(name) { |f| f.write(content) }
    end
  end
end