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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.devcontainer/Dockerfile +64 -0
  3. data/.devcontainer/devcontainer.json +17 -0
  4. data/CHANGELOG.md +12 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +384 -0
  8. data/Rakefile +290 -0
  9. data/benchmark.rb +390 -0
  10. data/docs/ARCHITECTURE.md +488 -0
  11. data/docs/SPEC_SOURCES.md +42 -0
  12. data/docs/visual/VisualGallery.md +4684 -0
  13. data/docs/wasm/wasm_doc_helper.css +189 -0
  14. data/docs/wasm/wasm_doc_helper.js +320 -0
  15. data/lib/xlsxrb/elements/cell.rb +77 -0
  16. data/lib/xlsxrb/elements/column.rb +28 -0
  17. data/lib/xlsxrb/elements/row.rb +47 -0
  18. data/lib/xlsxrb/elements/types.rb +36 -0
  19. data/lib/xlsxrb/elements/workbook.rb +47 -0
  20. data/lib/xlsxrb/elements/worksheet.rb +50 -0
  21. data/lib/xlsxrb/elements.rb +15 -0
  22. data/lib/xlsxrb/ooxml/reader.rb +8048 -0
  23. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +75 -0
  24. data/lib/xlsxrb/ooxml/styles_parser.rb +194 -0
  25. data/lib/xlsxrb/ooxml/utils.rb +122 -0
  26. data/lib/xlsxrb/ooxml/workbook_parser.rb +78 -0
  27. data/lib/xlsxrb/ooxml/workbook_writer.rb +1000 -0
  28. data/lib/xlsxrb/ooxml/worksheet_parser.rb +455 -0
  29. data/lib/xlsxrb/ooxml/worksheet_writer.rb +1008 -0
  30. data/lib/xlsxrb/ooxml/writer.rb +5450 -0
  31. data/lib/xlsxrb/ooxml/xml_builder.rb +113 -0
  32. data/lib/xlsxrb/ooxml/xml_parser.rb +68 -0
  33. data/lib/xlsxrb/ooxml/zip_generator.rb +162 -0
  34. data/lib/xlsxrb/ooxml/zip_reader.rb +158 -0
  35. data/lib/xlsxrb/ooxml/zip_writer.rb +213 -0
  36. data/lib/xlsxrb/ooxml.rb +22 -0
  37. data/lib/xlsxrb/style_builder.rb +241 -0
  38. data/lib/xlsxrb/version.rb +5 -0
  39. data/lib/xlsxrb.rb +1427 -0
  40. data/measure_memory.rb +42 -0
  41. data/sig/xlsxrb.rbs +23 -0
  42. data/vendor/sdk_runner/Program.cs +91 -0
  43. data/vendor/sdk_runner/sdk_runner.csproj +13 -0
  44. 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