xlsxrb 0.1.0
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 +7 -0
- data/.devcontainer/Dockerfile +64 -0
- data/.devcontainer/devcontainer.json +17 -0
- data/CHANGELOG.md +12 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/Rakefile +290 -0
- data/benchmark.rb +390 -0
- data/docs/ARCHITECTURE.md +488 -0
- data/docs/SPEC_SOURCES.md +42 -0
- data/docs/visual/VisualGallery.md +4684 -0
- data/docs/wasm/wasm_doc_helper.css +189 -0
- data/docs/wasm/wasm_doc_helper.js +320 -0
- data/lib/xlsxrb/elements/cell.rb +77 -0
- data/lib/xlsxrb/elements/column.rb +28 -0
- data/lib/xlsxrb/elements/row.rb +47 -0
- data/lib/xlsxrb/elements/types.rb +36 -0
- data/lib/xlsxrb/elements/workbook.rb +47 -0
- data/lib/xlsxrb/elements/worksheet.rb +50 -0
- data/lib/xlsxrb/elements.rb +15 -0
- data/lib/xlsxrb/ooxml/reader.rb +8048 -0
- data/lib/xlsxrb/ooxml/shared_strings_parser.rb +75 -0
- data/lib/xlsxrb/ooxml/styles_parser.rb +194 -0
- data/lib/xlsxrb/ooxml/utils.rb +122 -0
- data/lib/xlsxrb/ooxml/workbook_parser.rb +78 -0
- data/lib/xlsxrb/ooxml/workbook_writer.rb +1000 -0
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +455 -0
- data/lib/xlsxrb/ooxml/worksheet_writer.rb +1008 -0
- data/lib/xlsxrb/ooxml/writer.rb +5450 -0
- data/lib/xlsxrb/ooxml/xml_builder.rb +113 -0
- data/lib/xlsxrb/ooxml/xml_parser.rb +68 -0
- data/lib/xlsxrb/ooxml/zip_generator.rb +162 -0
- data/lib/xlsxrb/ooxml/zip_reader.rb +158 -0
- data/lib/xlsxrb/ooxml/zip_writer.rb +213 -0
- data/lib/xlsxrb/ooxml.rb +22 -0
- data/lib/xlsxrb/style_builder.rb +241 -0
- data/lib/xlsxrb/version.rb +5 -0
- data/lib/xlsxrb.rb +1427 -0
- data/measure_memory.rb +42 -0
- data/sig/xlsxrb.rbs +23 -0
- data/vendor/sdk_runner/Program.cs +91 -0
- data/vendor/sdk_runner/sdk_runner.csproj +13 -0
- metadata +144 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "xml_parser"
|
|
4
|
+
|
|
5
|
+
module Xlsxrb
|
|
6
|
+
module Ooxml
|
|
7
|
+
# SAX-based parser for xl/sharedStrings.xml.
|
|
8
|
+
# Returns an Array of strings (index = SST index).
|
|
9
|
+
class SharedStringsParser
|
|
10
|
+
# Parses all shared strings and returns an Array of strings.
|
|
11
|
+
def self.parse(xml_string, part_name: "xl/sharedStrings.xml")
|
|
12
|
+
return [] if xml_string.nil? || xml_string.empty?
|
|
13
|
+
|
|
14
|
+
strings = []
|
|
15
|
+
each_event(xml_string, part_name: part_name) do |event|
|
|
16
|
+
strings << event.args[0] if event.type == :sst_item
|
|
17
|
+
end
|
|
18
|
+
strings
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Yields Event objects for each shared string.
|
|
22
|
+
def self.each_event(xml_string, part_name: "xl/sharedStrings.xml", &block)
|
|
23
|
+
return enum_for(:each_event, xml_string, part_name: part_name) unless block
|
|
24
|
+
return if xml_string.nil? || xml_string.empty?
|
|
25
|
+
|
|
26
|
+
listener = EventListener.new(part_name, &block)
|
|
27
|
+
XmlParser.parse(xml_string, listener)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# SAX listener for generating events from shared string table.
|
|
31
|
+
class EventListener
|
|
32
|
+
include REXML::SAX2Listener
|
|
33
|
+
|
|
34
|
+
def initialize(part_name, &block)
|
|
35
|
+
@part_name = part_name
|
|
36
|
+
@block = block
|
|
37
|
+
@in_si = false
|
|
38
|
+
@in_t = false
|
|
39
|
+
@current_text = +""
|
|
40
|
+
@index = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def start_element(_uri, localname, _qname, _attrs)
|
|
44
|
+
case localname
|
|
45
|
+
when "si"
|
|
46
|
+
@in_si = true
|
|
47
|
+
@current_text = +""
|
|
48
|
+
when "t"
|
|
49
|
+
@in_t = true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def end_element(_uri, localname, _qname)
|
|
54
|
+
case localname
|
|
55
|
+
when "si"
|
|
56
|
+
@in_si = false
|
|
57
|
+
frozen_str = @current_text.freeze
|
|
58
|
+
@block.call(Event.new(
|
|
59
|
+
type: :sst_item,
|
|
60
|
+
args: [frozen_str],
|
|
61
|
+
source: { part: @part_name, index: @index }
|
|
62
|
+
))
|
|
63
|
+
@index += 1
|
|
64
|
+
when "t"
|
|
65
|
+
@in_t = false
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def characters(text)
|
|
70
|
+
@current_text << text if @in_si && @in_t
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "xml_parser"
|
|
4
|
+
|
|
5
|
+
module Xlsxrb
|
|
6
|
+
module Ooxml
|
|
7
|
+
# SAX-based parser for xl/styles.xml.
|
|
8
|
+
# Returns a Hash with :num_fmts, :fonts, :fills, :borders, :cell_xfs, :cell_style_xfs.
|
|
9
|
+
class StylesParser
|
|
10
|
+
def self.parse(xml_string)
|
|
11
|
+
return {} if xml_string.nil? || xml_string.empty?
|
|
12
|
+
|
|
13
|
+
listener = Listener.new
|
|
14
|
+
XmlParser.parse(xml_string, listener)
|
|
15
|
+
listener.result
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# SAX listener for parsing styles.xml content.
|
|
19
|
+
class Listener
|
|
20
|
+
include REXML::SAX2Listener
|
|
21
|
+
|
|
22
|
+
attr_reader :result
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@result = {
|
|
26
|
+
num_fmts: {},
|
|
27
|
+
fonts: [],
|
|
28
|
+
fills: [],
|
|
29
|
+
borders: [],
|
|
30
|
+
cell_xfs: [],
|
|
31
|
+
cell_style_xfs: []
|
|
32
|
+
}
|
|
33
|
+
@context = []
|
|
34
|
+
@current_font = nil
|
|
35
|
+
@current_fill = nil
|
|
36
|
+
@current_fill_pattern = nil
|
|
37
|
+
@current_border = nil
|
|
38
|
+
@current_xf = nil
|
|
39
|
+
@in_cell_xfs = false
|
|
40
|
+
@in_cell_style_xfs = false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def start_element(_uri, localname, _qname, attrs)
|
|
44
|
+
@context.push(localname)
|
|
45
|
+
case localname
|
|
46
|
+
when "numFmt"
|
|
47
|
+
id = attrs["numFmtId"]&.to_i
|
|
48
|
+
code = attrs["formatCode"]
|
|
49
|
+
@result[:num_fmts][id] = code if id && code
|
|
50
|
+
when "font"
|
|
51
|
+
@current_font = {} if parent_context?("fonts")
|
|
52
|
+
when "b"
|
|
53
|
+
@current_font[:bold] = true if @current_font
|
|
54
|
+
when "i"
|
|
55
|
+
@current_font[:italic] = true if @current_font
|
|
56
|
+
when "u"
|
|
57
|
+
@current_font[:underline] = attrs["val"] || "single" if @current_font
|
|
58
|
+
when "sz"
|
|
59
|
+
@current_font[:sz] = attrs["val"]&.to_f if @current_font
|
|
60
|
+
when "color"
|
|
61
|
+
handle_color(attrs)
|
|
62
|
+
when "name"
|
|
63
|
+
@current_font[:name] = attrs["val"] if @current_font
|
|
64
|
+
when "fill"
|
|
65
|
+
@current_fill = {} if parent_context?("fills")
|
|
66
|
+
when "patternFill"
|
|
67
|
+
@current_fill_pattern = attrs["patternType"] if @current_fill
|
|
68
|
+
when "fgColor", "bgColor"
|
|
69
|
+
handle_fill_color(localname, attrs) if @current_fill
|
|
70
|
+
when "border"
|
|
71
|
+
@current_border = {} if parent_context?("borders")
|
|
72
|
+
when "left", "right", "top", "bottom", "diagonal"
|
|
73
|
+
handle_border_side(localname, attrs)
|
|
74
|
+
when "cellXfs"
|
|
75
|
+
@in_cell_xfs = true
|
|
76
|
+
when "cellStyleXfs"
|
|
77
|
+
@in_cell_style_xfs = true
|
|
78
|
+
when "xf"
|
|
79
|
+
handle_xf(attrs)
|
|
80
|
+
when "alignment"
|
|
81
|
+
handle_alignment(attrs)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def end_element(_uri, localname, _qname)
|
|
86
|
+
case localname
|
|
87
|
+
when "font"
|
|
88
|
+
if @current_font
|
|
89
|
+
@result[:fonts] << @current_font
|
|
90
|
+
@current_font = nil
|
|
91
|
+
end
|
|
92
|
+
when "fill"
|
|
93
|
+
if @current_fill
|
|
94
|
+
@current_fill[:pattern] = @current_fill_pattern if @current_fill_pattern
|
|
95
|
+
@result[:fills] << @current_fill
|
|
96
|
+
@current_fill = nil
|
|
97
|
+
@current_fill_pattern = nil
|
|
98
|
+
end
|
|
99
|
+
when "border"
|
|
100
|
+
if @current_border
|
|
101
|
+
@result[:borders] << @current_border
|
|
102
|
+
@current_border = nil
|
|
103
|
+
end
|
|
104
|
+
when "cellXfs"
|
|
105
|
+
@in_cell_xfs = false
|
|
106
|
+
when "cellStyleXfs"
|
|
107
|
+
@in_cell_style_xfs = false
|
|
108
|
+
when "xf"
|
|
109
|
+
finalize_xf
|
|
110
|
+
end
|
|
111
|
+
@context.pop
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def characters(_text)
|
|
115
|
+
# No text content needed for styles
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def parent_context?(tag)
|
|
121
|
+
@context.length >= 2 && @context[-2] == tag
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def handle_color(attrs)
|
|
125
|
+
return unless @current_font
|
|
126
|
+
|
|
127
|
+
color = extract_color(attrs)
|
|
128
|
+
@current_font[:color] = color unless color.empty?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def handle_fill_color(localname, attrs)
|
|
132
|
+
color = extract_color(attrs)
|
|
133
|
+
return if color.empty?
|
|
134
|
+
|
|
135
|
+
key = localname == "fgColor" ? :fg_color : :bg_color
|
|
136
|
+
@current_fill[key] = color
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def handle_border_side(side, attrs)
|
|
140
|
+
return unless @current_border
|
|
141
|
+
|
|
142
|
+
style = attrs["style"]
|
|
143
|
+
@current_border[side.to_sym] = { style: style } if style
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def handle_xf(attrs)
|
|
147
|
+
@current_xf = {
|
|
148
|
+
num_fmt_id: attrs["numFmtId"]&.to_i,
|
|
149
|
+
font_id: attrs["fontId"]&.to_i,
|
|
150
|
+
fill_id: attrs["fillId"]&.to_i,
|
|
151
|
+
border_id: attrs["borderId"]&.to_i,
|
|
152
|
+
xf_id: attrs["xfId"]&.to_i,
|
|
153
|
+
apply_number_format: attrs["applyNumberFormat"] == "1",
|
|
154
|
+
apply_font: attrs["applyFont"] == "1",
|
|
155
|
+
apply_fill: attrs["applyFill"] == "1",
|
|
156
|
+
apply_border: attrs["applyBorder"] == "1",
|
|
157
|
+
apply_alignment: attrs["applyAlignment"] == "1"
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def handle_alignment(attrs)
|
|
162
|
+
return unless @current_xf
|
|
163
|
+
|
|
164
|
+
alignment = {}
|
|
165
|
+
alignment[:horizontal] = attrs["horizontal"] if attrs["horizontal"]
|
|
166
|
+
alignment[:vertical] = attrs["vertical"] if attrs["vertical"]
|
|
167
|
+
alignment[:wrap_text] = true if attrs["wrapText"] == "1"
|
|
168
|
+
alignment[:text_rotation] = attrs["textRotation"]&.to_i if attrs["textRotation"]
|
|
169
|
+
@current_xf[:alignment] = alignment unless alignment.empty?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def finalize_xf
|
|
173
|
+
return unless @current_xf
|
|
174
|
+
|
|
175
|
+
if @in_cell_xfs
|
|
176
|
+
@result[:cell_xfs] << @current_xf
|
|
177
|
+
elsif @in_cell_style_xfs
|
|
178
|
+
@result[:cell_style_xfs] << @current_xf
|
|
179
|
+
end
|
|
180
|
+
@current_xf = nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def extract_color(attrs)
|
|
184
|
+
color = {}
|
|
185
|
+
color[:rgb] = attrs["rgb"] if attrs["rgb"]
|
|
186
|
+
color[:theme] = attrs["theme"]&.to_i if attrs["theme"]
|
|
187
|
+
color[:indexed] = attrs["indexed"]&.to_i if attrs["indexed"]
|
|
188
|
+
color[:tint] = attrs["tint"]&.to_f if attrs["tint"]
|
|
189
|
+
color
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "openssl"
|
|
6
|
+
|
|
7
|
+
module Xlsxrb
|
|
8
|
+
module Ooxml
|
|
9
|
+
# OOXML-specific utility methods and constants.
|
|
10
|
+
module Utils
|
|
11
|
+
# Excel 1900 date system epoch.
|
|
12
|
+
EPOCH_1900 = Date.new(1899, 12, 31) # serial 1 = Jan 1, 1900
|
|
13
|
+
|
|
14
|
+
# Built-in number format codes defined by SpreadsheetML.
|
|
15
|
+
BUILTIN_NUM_FMT_CODES = {
|
|
16
|
+
0 => "General",
|
|
17
|
+
1 => "0",
|
|
18
|
+
2 => "0.00",
|
|
19
|
+
3 => "#,##0",
|
|
20
|
+
4 => "#,##0.00",
|
|
21
|
+
9 => "0%",
|
|
22
|
+
10 => "0.00%",
|
|
23
|
+
11 => "0.00E+00",
|
|
24
|
+
12 => "# ?/?",
|
|
25
|
+
13 => "# ??/??",
|
|
26
|
+
14 => "mm-dd-yy",
|
|
27
|
+
15 => "d-mmm-yy",
|
|
28
|
+
16 => "d-mmm",
|
|
29
|
+
17 => "mmm-yy",
|
|
30
|
+
18 => "h:mm AM/PM",
|
|
31
|
+
19 => "h:mm:ss AM/PM",
|
|
32
|
+
20 => "h:mm",
|
|
33
|
+
21 => "h:mm:ss",
|
|
34
|
+
22 => "m/d/yy h:mm",
|
|
35
|
+
37 => "#,##0 ;(#,##0)",
|
|
36
|
+
38 => "#,##0 ;[Red](#,##0)",
|
|
37
|
+
39 => "#,##0.00;(#,##0.00)",
|
|
38
|
+
40 => "#,##0.00;[Red](#,##0.00)",
|
|
39
|
+
45 => "mm:ss",
|
|
40
|
+
46 => "[h]:mm:ss",
|
|
41
|
+
47 => "mmss.0",
|
|
42
|
+
48 => "##0.0E+0",
|
|
43
|
+
49 => "@"
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# Built-in numFmtIds that represent date/time formats.
|
|
47
|
+
BUILTIN_DATE_FMT_IDS = [14, 15, 16, 17, 18, 19, 20, 21, 22].freeze
|
|
48
|
+
|
|
49
|
+
# Default date format code used by Writer for Date cells.
|
|
50
|
+
DEFAULT_DATE_FORMAT = "yyyy\\-mm\\-dd"
|
|
51
|
+
|
|
52
|
+
# Default date-time format code used by Writer for Time cells.
|
|
53
|
+
DEFAULT_DATETIME_FORMAT = "yyyy\\-mm\\-dd\\ hh:mm:ss"
|
|
54
|
+
|
|
55
|
+
class << self
|
|
56
|
+
# Converts a Date to an Excel serial number (1900 system).
|
|
57
|
+
def date_to_serial(date)
|
|
58
|
+
serial = (date - EPOCH_1900).to_i
|
|
59
|
+
# Lotus 1-2-3 bug: serial 60 = Feb 29, 1900 (doesn't exist).
|
|
60
|
+
# Dates on or after Mar 1, 1900 (raw serial >= 60) need +1.
|
|
61
|
+
serial += 1 if serial >= 60
|
|
62
|
+
serial
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Converts an Excel serial number (1900 system) to a Date.
|
|
66
|
+
def serial_to_date(serial)
|
|
67
|
+
# Adjust for Lotus 1-2-3 bug.
|
|
68
|
+
serial -= 1 if serial > 60
|
|
69
|
+
EPOCH_1900 + serial
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Converts a Time to a fractional Excel serial number (1900 system).
|
|
73
|
+
def datetime_to_serial(time)
|
|
74
|
+
date = time.to_date
|
|
75
|
+
day_serial = date_to_serial(date)
|
|
76
|
+
# Fractional part: seconds since midnight / seconds per day
|
|
77
|
+
seconds_since_midnight = (time.hour * 3600) + (time.min * 60) + time.sec
|
|
78
|
+
day_serial + (seconds_since_midnight.to_f / 86_400)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Converts a fractional Excel serial number to a Time (1900 system, UTC).
|
|
82
|
+
def serial_to_datetime(serial)
|
|
83
|
+
int_part = serial.to_i
|
|
84
|
+
frac = serial - int_part
|
|
85
|
+
date = serial_to_date(int_part)
|
|
86
|
+
total_seconds = (frac * 86_400).round
|
|
87
|
+
hours = total_seconds / 3600
|
|
88
|
+
minutes = (total_seconds % 3600) / 60
|
|
89
|
+
seconds = total_seconds % 60
|
|
90
|
+
Time.utc(date.year, date.month, date.day, hours, minutes, seconds)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Hashes a plain-text password for use with sheet/workbook protection.
|
|
94
|
+
# Returns { algorithm_name:, hash_value:, salt_value:, spin_count: }.
|
|
95
|
+
# Algorithm per ECMA-376 Part 4 §2.4.2.24.
|
|
96
|
+
def hash_password(password, algorithm: "SHA-512", salt: nil, spin_count: 100_000)
|
|
97
|
+
raise ArgumentError, "password must be a String" unless password.is_a?(String)
|
|
98
|
+
raise ArgumentError, "spin_count must be a positive Integer" unless spin_count.is_a?(Integer) && spin_count.positive?
|
|
99
|
+
|
|
100
|
+
salt_bytes = (salt || SecureRandom.random_bytes(16)).b
|
|
101
|
+
# OOXML sheet/workbook protection hashing uses UTF-16LE password bytes.
|
|
102
|
+
password_bytes = password.encode("UTF-16LE").b
|
|
103
|
+
|
|
104
|
+
digest_name = algorithm.tr("-", "")
|
|
105
|
+
hash = OpenSSL::Digest.digest(digest_name, salt_bytes + password_bytes)
|
|
106
|
+
|
|
107
|
+
spin_count.times do |i|
|
|
108
|
+
iteration_bytes = [i].pack("V") # little-endian uint32
|
|
109
|
+
hash = OpenSSL::Digest.digest(digest_name, hash + iteration_bytes)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
algorithm_name: algorithm,
|
|
114
|
+
hash_value: [hash].pack("m0"),
|
|
115
|
+
salt_value: [salt_bytes].pack("m0"),
|
|
116
|
+
spin_count: spin_count
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "xml_parser"
|
|
4
|
+
|
|
5
|
+
module Xlsxrb
|
|
6
|
+
module Ooxml
|
|
7
|
+
# SAX-based parser for xl/workbook.xml.
|
|
8
|
+
# Returns sheet list: [{ name:, sheet_id:, r_id: }, ...].
|
|
9
|
+
class WorkbookParser
|
|
10
|
+
def self.parse(xml_string)
|
|
11
|
+
return [] if xml_string.nil? || xml_string.empty?
|
|
12
|
+
|
|
13
|
+
listener = Listener.new
|
|
14
|
+
XmlParser.parse(xml_string, listener)
|
|
15
|
+
listener.sheets
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# SAX listener for workbook.xml sheets.
|
|
19
|
+
class Listener
|
|
20
|
+
include REXML::SAX2Listener
|
|
21
|
+
|
|
22
|
+
attr_reader :sheets
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@sheets = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def start_element(_uri, localname, _qname, attrs)
|
|
29
|
+
return unless localname == "sheet"
|
|
30
|
+
|
|
31
|
+
@sheets << {
|
|
32
|
+
name: attrs["name"],
|
|
33
|
+
sheet_id: attrs["sheetId"]&.to_i,
|
|
34
|
+
r_id: attrs["r:id"] || attrs["id"] || attrs.find { |k, _| k.end_with?(":id") }&.last
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def end_element(_uri, _localname, _qname); end
|
|
39
|
+
|
|
40
|
+
def characters(_text); end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Parses .rels files to build rId -> target mapping.
|
|
45
|
+
class RelationshipsParser
|
|
46
|
+
def self.parse(xml_string)
|
|
47
|
+
return {} if xml_string.nil? || xml_string.empty?
|
|
48
|
+
|
|
49
|
+
listener = Listener.new
|
|
50
|
+
XmlParser.parse(xml_string, listener)
|
|
51
|
+
listener.relationships
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# SAX listener for .rels relationship files.
|
|
55
|
+
class Listener
|
|
56
|
+
include REXML::SAX2Listener
|
|
57
|
+
|
|
58
|
+
attr_reader :relationships
|
|
59
|
+
|
|
60
|
+
def initialize
|
|
61
|
+
@relationships = {}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def start_element(_uri, localname, _qname, attrs)
|
|
65
|
+
return unless localname == "Relationship"
|
|
66
|
+
|
|
67
|
+
rid = attrs["Id"]
|
|
68
|
+
target = attrs["Target"]
|
|
69
|
+
@relationships[rid] = target if rid && target
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def end_element(_uri, _localname, _qname); end
|
|
73
|
+
|
|
74
|
+
def characters(_text); end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|