liquid_xlsx 0.2.0 → 0.2.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4a2f29e44a564a50870b6f4c7d066dbb2d648a5ce1031a31115c3087f968710
4
- data.tar.gz: d1c8cbddb4f24c831cd7ada7cf5089814b615a72fa1f788828cddefce67a3dcf
3
+ metadata.gz: 405eceeb12cbc1b76e2bb0fa1ef24f351f544551268799cffd3f1265fa3bda7f
4
+ data.tar.gz: 13f0be15c97eaf8f21c8e4b4b1c4a2665032f8a8b7fc0ae885a1c1a2fb2cb126
5
5
  SHA512:
6
- metadata.gz: 503a440465922dbf924b017ecbd7f7a532ac695a861cffe9cf64fc3ae97603e73a961f645eb20737ac95f93901b62970222d24e20cfc004dc6ec18eeed2ea97e
7
- data.tar.gz: 6f4f7399389a693dcd85af3fe4380e706783db5c4034133a4182a1445de4d3979ac535533e36b422aeeb720168cbe6dc0b85768be54a7557459037471c8c8ab5
6
+ metadata.gz: 1677f21ae1c65f95cf62fe1728cdeadef70e96a051ed33a1ed7acf57eb2b9067e00f3f4d1901caf20d9d23f37edbec601cef7115704404e75eaaca160d1d6405
7
+ data.tar.gz: 9eda27bf7211ffc329af17e9ddf9992dcda7e551ff9526d62afb8df870e56d88381cd369acf120704ede782e10ded1650c410e97d71ebbf5159bff46b311e630
data/CHANGELOG.md CHANGED
@@ -1,6 +1,39 @@
1
1
  # Changelog
2
2
 
3
- ## 0.2.0 (unreleased)
3
+ ## 0.2.2 (2026-08-14)
4
+
5
+ ### Fixed
6
+
7
+ - Templates whose relationship targets are absolute are rendered again. OPC
8
+ allows a `Target` to be relative to the declaring part
9
+ (`worksheets/sheet1.xml`) or absolute from the package root
10
+ (`/xl/worksheets/sheet1.xml`); Excel writes the first form, 1C exports and
11
+ some other generators write the second. The package layer joined every target
12
+ to its base folder, so an absolute one resolved to
13
+ `xl/xl/worksheets/sheet1.xml`, the lookup found nothing, and the sheet was
14
+ skipped — the caller received a copy of the template with `{{ tags }}` still
15
+ in it and no error anywhere. Targets are now resolved with a single helper
16
+ that honours both forms.
17
+ - A sheet listed in `workbook.xml` whose worksheet part cannot be found now
18
+ raises `InvalidXlsxError` instead of being skipped silently. Skipping turned
19
+ a broken package into an unrendered output file, which is far harder to
20
+ diagnose than a failed render.
21
+
22
+ ## 0.2.1 (2026-08-09)
23
+
24
+ ### Fixed
25
+
26
+ - Rendered workbooks are no longer marked ZIP64. Members were streamed without
27
+ a declared size, and rubyzip 3 — where ZIP64 is on by default — then flagged
28
+ every member as ZIP64: version 4.5 in the local header and `0xFFFFFFFF`
29
+ sizes. Such an archive still satisfies rubyzip and `unzip`, but Excel reports
30
+ a corrupt file and LibreOffice 7.4 fails with "source file could not be
31
+ loaded", which also broke conversion to PDF. Newer LibreOffice reads ZIP64,
32
+ so the breakage only surfaced on hosts with an older build. The member size
33
+ is known at write time and is now declared up front, which keeps ordinary
34
+ local headers. rubyzip 2.x was never affected: ZIP64 is off by default there.
35
+
36
+ ## 0.2.0 (2026-08-04)
4
37
 
5
38
  ### Fixed
6
39
 
@@ -23,6 +23,17 @@ module LiquidXlsx
23
23
  name == :create && %i[key keyreq].include?(type)
24
24
  end
25
25
 
26
+ # rubyzip 3 turned ZIP64 on by default. Streaming a member gives it no size
27
+ # up front, so it marks EVERY member as ZIP64 — version 4.5 in the local
28
+ # header and 0xFFFFFFFF sizes. Such an .xlsx is rejected by Excel and by
29
+ # LibreOffice builds without ZIP64 support (7.4 fails, 26.2 reads it).
30
+ # Here the member content is fully known, so the size can be declared up
31
+ # front and Entry#prep_local_zip64_extra leaves an ordinary header alone.
32
+ # rubyzip 2.x has no `size:` keyword, but there ZIP64 is off by default.
33
+ ZIP_SIZE_KEYWORD = Zip::File.instance_method(:get_output_stream).parameters.any? do |type, name|
34
+ name == :size && %i[key keyreq].include?(type)
35
+ end
36
+
26
37
  # Guard rails against zip bombs / resource exhaustion when unpacking.
27
38
  MAX_ENTRIES = 10_000
28
39
  MAX_ENTRY_UNCOMPRESSED = 512 * 1024 * 1024 # 512 MB per entry
@@ -107,7 +118,7 @@ module LiquidXlsx
107
118
  @files.each do |name, content|
108
119
  next if content.nil? # directories
109
120
 
110
- zip.get_output_stream(name) { |f| f.write(content) }
121
+ write_entry(zip, name, content)
111
122
  end
112
123
  end
113
124
  end
@@ -150,33 +161,18 @@ module LiquidXlsx
150
161
  # @param r_id [String]
151
162
  # @return [String, nil]
152
163
  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]
164
+ path = worksheet_path_for_r_id(r_id)
165
+ path && @files[path]
166
166
  end
167
167
 
168
168
  # Save worksheet XML back.
169
169
  # @param r_id [String]
170
170
  # @param xml [String]
171
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
172
+ path = worksheet_path_for_r_id(r_id)
173
+ return unless path
175
174
 
176
- target = rel["Target"]
177
- path = "xl/#{target}"
178
- normalized = Pathname.new(path).cleanpath.to_s.sub(%r{\A/?}, "")
179
- @files[normalized] = xml
175
+ @files[path] = xml
180
176
  end
181
177
 
182
178
  # Get calc chain XML.
@@ -431,8 +427,7 @@ module LiquidXlsx
431
427
  rel = rels.at_xpath("//xmlns:Relationship[@Id='#{r_id}']")
432
428
  return nil unless rel
433
429
 
434
- target = rel["Target"]
435
- Pathname.new("xl/#{target}").cleanpath.to_s.sub(%r{\A/?}, "")
430
+ resolve_target("xl", rel["Target"])
436
431
  end
437
432
 
438
433
  # Get all sheet names from workbook.xml.
@@ -443,6 +438,37 @@ module LiquidXlsx
443
438
 
444
439
  private
445
440
 
441
+ # Turn a relationship Target into a package path.
442
+ #
443
+ # OPC allows a Target to be either relative to the folder of the part that
444
+ # declares it ("worksheets/sheet1.xml", "../drawings/drawing1.xml") or
445
+ # absolute from the package root ("/xl/worksheets/sheet1.xml"). Excel writes
446
+ # the relative form, but 1C and some other generators write the absolute one.
447
+ # Joining an absolute target to the base gave "xl/xl/worksheets/sheet1.xml":
448
+ # the lookup returned nil, `Workbook#render_existing_sheets` skipped the
449
+ # sheet, and the template came back copied instead of rendered.
450
+ #
451
+ # @param base_dir [String] folder of the declaring part, e.g. "xl"
452
+ # @param target [String, nil] the Target attribute value
453
+ # @return [String, nil] normalized package path
454
+ def resolve_target(base_dir, target)
455
+ return nil if target.nil? || target.empty?
456
+
457
+ path = target.start_with?("/") ? target : "#{base_dir}/#{target}"
458
+ Pathname.new(path).cleanpath.to_s.sub(%r{\A/+}, "")
459
+ end
460
+
461
+ # The size is declared in bytes: String#size counts characters, so on UTF-8
462
+ # content it would disagree with what actually gets written.
463
+ # See ZIP_SIZE_KEYWORD — rubyzip 2.x has no such keyword and needs none.
464
+ def write_entry(zip, name, content)
465
+ if ZIP_SIZE_KEYWORD
466
+ zip.get_output_stream(name, size: content.bytesize) { |f| f.write(content) }
467
+ else
468
+ zip.get_output_stream(name) { |f| f.write(content) }
469
+ end
470
+ end
471
+
446
472
  # Open a brand-new archive for writing across rubyzip 2.x and 3.x.
447
473
  # See ZIP_CREATE_KEYWORD for why the call has to be spelled two ways.
448
474
  def open_new_zip(output_path, &)
@@ -512,8 +538,7 @@ module LiquidXlsx
512
538
  # Merge freshly built drawing anchors and their relationships into the
513
539
  # drawing part already attached to the worksheet, remapping rIds.
514
540
  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/?}, "")
541
+ drawing_path = resolve_target("xl/worksheets", ws_drawing_rel["Target"])
517
542
  drawing_rels_path = "#{drawing_path.sub('xl/drawings/', 'xl/drawings/_rels/')}.rels"
518
543
 
519
544
  existing_doc = parse_xml(@files[drawing_path])
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LiquidXlsx
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
@@ -47,10 +47,25 @@ module LiquidXlsx
47
47
 
48
48
  private
49
49
 
50
+ # Worksheet part behind a <sheet> entry of workbook.xml.
51
+ #
52
+ # A workbook that lists a sheet whose part cannot be resolved is broken, and
53
+ # skipping it silently used to hand the caller an untouched copy of the
54
+ # template: no error anywhere, just `{{ tags }}` left in the "rendered"
55
+ # file. Fail loudly instead — the usual cause is a rels Target the package
56
+ # layer could not resolve.
57
+ def worksheet_xml!(sheet_info)
58
+ xml = package.worksheet_xml(sheet_info[:r_id])
59
+ return xml if xml
60
+
61
+ raise InvalidXlsxError,
62
+ "Worksheet part for sheet '#{sheet_info[:name]}' (#{sheet_info[:r_id]}) " \
63
+ "is missing from the package"
64
+ end
65
+
50
66
  def render_existing_sheets(data, all_image_ops = [])
51
67
  package.sheets.each do |sheet_info|
52
- xml = package.worksheet_xml(sheet_info[:r_id])
53
- next unless xml
68
+ xml = worksheet_xml!(sheet_info)
54
69
 
55
70
  image_ops = []
56
71
 
@@ -68,8 +83,7 @@ module LiquidXlsx
68
83
  # Phase 0: Save original sheet XMLs before rendering mutates them
69
84
  original_xmls = {}
70
85
  package.sheets.each do |sheet_info|
71
- xml = package.worksheet_xml(sheet_info[:r_id])
72
- original_xmls[sheet_info[:name]] = xml if xml
86
+ original_xmls[sheet_info[:name]] = worksheet_xml!(sheet_info)
73
87
  end
74
88
 
75
89
  # Phase 0.5: Identify control and template sheets
@@ -86,7 +100,6 @@ module LiquidXlsx
86
100
  next if template_names_from_tags.include?(sheet_info[:name])
87
101
 
88
102
  xml = original_xmls[sheet_info[:name]]
89
- next unless xml
90
103
 
91
104
  image_ops = []
92
105
  worksheet = Worksheet.new(xml, sheet_info[:name], @shared_strings)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: liquid_xlsx
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ivan Khlipitkin