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 +4 -4
- data/CHANGELOG.md +34 -1
- data/lib/liquid_xlsx/package.rb +50 -25
- data/lib/liquid_xlsx/version.rb +1 -1
- data/lib/liquid_xlsx/workbook.rb +18 -5
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 405eceeb12cbc1b76e2bb0fa1ef24f351f544551268799cffd3f1265fa3bda7f
|
|
4
|
+
data.tar.gz: 13f0be15c97eaf8f21c8e4b4b1c4a2665032f8a8b7fc0ae885a1c1a2fb2cb126
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
data/lib/liquid_xlsx/package.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
return unless rel
|
|
172
|
+
path = worksheet_path_for_r_id(r_id)
|
|
173
|
+
return unless path
|
|
175
174
|
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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])
|
data/lib/liquid_xlsx/version.rb
CHANGED
data/lib/liquid_xlsx/workbook.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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)
|