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,455 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "xml_parser"
4
+
5
+ module Xlsxrb
6
+ module Ooxml
7
+ # Streaming parser for xl/worksheets/sheetN.xml.
8
+ # Uses a fast string-scanning approach for row/cell extraction,
9
+ # falling back to REXML SAX only for column definitions and unmapped data.
10
+ class WorksheetParser
11
+ EMPTY_ARRAY = [].freeze
12
+ EMPTY_HASH = {}.freeze
13
+
14
+ # Parses all rows from a worksheet XML string.
15
+ # Returns an Array of raw row hashes:
16
+ # { index:, cells: [{ ref:, type:, style_index:, value:, formula: }], attrs:, unmapped: }
17
+ def self.parse(xml_string, shared_strings: [])
18
+ return [] if xml_string.nil? || xml_string.empty?
19
+
20
+ rows = []
21
+ each_row(xml_string, shared_strings: shared_strings) { |row| rows << row }
22
+ rows
23
+ end
24
+
25
+ # Streaming parse: yields one raw row hash at a time.
26
+ def self.each_row(xml_string, shared_strings: [], part_name: "xl/worksheets/sheet1.xml", &block)
27
+ return enum_for(:each_row, xml_string, shared_strings: shared_strings, part_name: part_name) unless block
28
+
29
+ current_row = nil
30
+ each_event(xml_string, shared_strings: shared_strings, part_name: part_name) do |event|
31
+ case event.type
32
+ when :row_start
33
+ index, attrs = event.args
34
+ current_row = { index: index, cells: [], attrs: attrs, unmapped: EMPTY_ARRAY, source: event.source }
35
+ when :cell
36
+ ref, type, style_index, value, formula = event.args
37
+ if current_row
38
+ cell = { ref: ref, type: type, style_index: style_index, value: value, source: event.source }
39
+ cell[:formula] = formula if formula
40
+ current_row[:cells] << cell
41
+ end
42
+ when :row_end
43
+ if current_row
44
+ block.call(current_row)
45
+ current_row = nil
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ # Parses column definitions (<cols>) from a worksheet.
52
+ def self.parse_columns(xml_string, part_name: "xl/worksheets/sheet1.xml")
53
+ return [] if xml_string.nil? || xml_string.empty?
54
+
55
+ columns = []
56
+ each_event(xml_string, part_name: part_name) do |event|
57
+ next unless event.type == :column
58
+
59
+ min, max, width, hidden, custom_width, outline_level = event.args
60
+ columns << {
61
+ min: min,
62
+ max: max,
63
+ width: width,
64
+ hidden: hidden,
65
+ custom_width: custom_width,
66
+ outline_level: outline_level
67
+ }
68
+ end
69
+ columns
70
+ end
71
+
72
+ # Yields Event objects for columns, rows, cells, and hyperlinks.
73
+ def self.each_event(xml_string, shared_strings: [], part_name: "xl/worksheets/sheet1.xml", &block)
74
+ return enum_for(:each_event, xml_string, shared_strings: shared_strings, part_name: part_name) unless block
75
+ return if xml_string.nil? || xml_string.empty?
76
+
77
+ # 1. Parse and yield column events
78
+ if xml_string.include?("<cols")
79
+ listener = ColumnsListener.new
80
+ XmlParser.parse(xml_string, listener)
81
+ listener.columns.each do |col|
82
+ block.call(Event.new(
83
+ type: :column,
84
+ args: [col[:min], col[:max], col[:width], col[:hidden], col[:custom_width], col[:outline_level]],
85
+ source: { part: part_name }
86
+ ))
87
+ end
88
+ end
89
+
90
+ # 2. Parse and yield row/cell events
91
+ fast_scan_events(xml_string, shared_strings, part_name, &block)
92
+
93
+ # 3. Parse and yield hyperlink events
94
+ return unless xml_string.include?("hyperlink")
95
+
96
+ xml = xml_string.b
97
+ hpos_term = xml.index("hyperlinks")
98
+ return unless hpos_term
99
+
100
+ hpos = xml.rindex("<", hpos_term)
101
+ return unless hpos
102
+
103
+ h_end_term = xml.index("/hyperlinks", hpos)
104
+ h_end = h_end_term ? xml.index(">", h_end_term) : xml.size
105
+ h_end ||= xml.size
106
+
107
+ pos = hpos
108
+ while pos < h_end
109
+ hl_start_term = xml.index("hyperlink", pos)
110
+ break unless hl_start_term && hl_start_term < h_end
111
+
112
+ hl_start = xml.rindex("<", hl_start_term)
113
+ break unless hl_start && hl_start < h_end
114
+
115
+ hl_tag_end = xml.index(">", hl_start + 1)
116
+ break unless hl_tag_end
117
+
118
+ hl_tag = xml.byteslice(hl_start, hl_tag_end - hl_start)
119
+ ref = tag_attr(hl_tag, ' ref="')
120
+ rid = tag_attr(hl_tag, ' r:id="') || tag_attr(hl_tag, ' id="')
121
+ display = tag_attr(hl_tag, ' display="')
122
+ tooltip = tag_attr(hl_tag, ' tooltip="')
123
+ location = tag_attr(hl_tag, ' location="')
124
+
125
+ if ref
126
+ block.call(Event.new(
127
+ type: :hyperlink,
128
+ args: [ref, rid, display, tooltip, location],
129
+ source: { part: part_name, cell: ref }
130
+ ))
131
+ end
132
+ pos = hl_tag_end + 1
133
+ end
134
+ end
135
+
136
+ # ---- Fast string-scanning event parser (byte-level) ----
137
+
138
+ def self.fast_scan_events(xml_src, shared_strings, part_name, &block)
139
+ xml = xml_src.b # force ASCII-8BIT for O(1) byte indexing
140
+
141
+ sd_start = xml.index("<sheetData")
142
+ return unless sd_start
143
+
144
+ sd_open_end = xml.index(">", sd_start)
145
+ return unless sd_open_end
146
+
147
+ return if xml.getbyte(sd_open_end - 1) == 47 # self-closing <sheetData/>
148
+
149
+ sd_end = xml.index("</sheetData>", sd_open_end)
150
+ return unless sd_end
151
+
152
+ pos = sd_open_end + 1
153
+
154
+ while pos < sd_end
155
+ row_start = xml.index("<row", pos)
156
+ break unless row_start && row_start < sd_end
157
+
158
+ nb = xml.getbyte(row_start + 4)
159
+ unless [32, 62, 9, 10, 13, 47].include?(nb)
160
+ pos = row_start + 4
161
+ next
162
+ end
163
+
164
+ tag_end = xml.index(">", row_start + 4)
165
+ break unless tag_end
166
+
167
+ if xml.getbyte(tag_end - 1) == 47
168
+ pos = tag_end + 1
169
+ next
170
+ end
171
+
172
+ # Row index and attrs from tag substring (bounded search)
173
+ row_tag = xml.byteslice(row_start, tag_end - row_start)
174
+ row_index = 0
175
+ r_val = tag_attr(row_tag, ' r="')
176
+ row_index = r_val.to_i - 1 if r_val
177
+
178
+ attrs = extract_row_attrs(row_tag)
179
+
180
+ row_end = xml.index("</row>", tag_end + 1)
181
+ break unless row_end
182
+
183
+ row_source = { part: part_name, row: row_index }
184
+ block.call(Event.new(
185
+ type: :row_start,
186
+ args: [row_index, attrs],
187
+ source: row_source
188
+ ))
189
+
190
+ fast_scan_cells_events(xml, tag_end + 1, row_end, shared_strings, part_name, row_index, &block)
191
+
192
+ block.call(Event.new(
193
+ type: :row_end,
194
+ args: [],
195
+ source: row_source
196
+ ))
197
+
198
+ pos = row_end + 6
199
+ end
200
+ end
201
+
202
+ private_class_method :fast_scan_events
203
+
204
+ def self.extract_row_attrs(row_tag)
205
+ attrs = EMPTY_HASH
206
+
207
+ ht_val = tag_attr(row_tag, ' ht="')
208
+ if ht_val
209
+ attrs = {}
210
+ attrs[:height] = ht_val.to_f
211
+ end
212
+
213
+ if row_tag.include?('hidden="1"')
214
+ attrs = {} if attrs.equal?(EMPTY_HASH)
215
+ attrs[:hidden] = true
216
+ end
217
+
218
+ if row_tag.include?('customHeight="1"')
219
+ attrs = {} if attrs.equal?(EMPTY_HASH)
220
+ attrs[:custom_height] = true
221
+ end
222
+
223
+ ol_val = tag_attr(row_tag, ' outlineLevel="')
224
+ if ol_val
225
+ attrs = {} if attrs.equal?(EMPTY_HASH)
226
+ attrs[:outline_level] = ol_val.to_i
227
+ end
228
+
229
+ attrs
230
+ end
231
+
232
+ private_class_method :extract_row_attrs
233
+
234
+ # Extract an attribute value from a small tag substring (bounded search).
235
+ def self.tag_attr(tag, prefix)
236
+ a_pos = tag.index(prefix)
237
+ return nil unless a_pos
238
+
239
+ val_start = a_pos + prefix.bytesize
240
+ val_end = tag.index('"', val_start)
241
+ return nil unless val_end
242
+
243
+ tag.byteslice(val_start, val_end - val_start).force_encoding("UTF-8")
244
+ end
245
+
246
+ private_class_method :tag_attr
247
+
248
+ def self.fast_scan_cells_events(xml, from, to, shared_strings, part_name, row_index, &block)
249
+ pos = from
250
+
251
+ while pos < to
252
+ c_start = xml.index("<c", pos)
253
+ break unless c_start && c_start < to
254
+
255
+ nb = xml.getbyte(c_start + 2)
256
+ unless [32, 62, 9, 10, 13, 47].include?(nb)
257
+ pos = c_start + 2
258
+ next
259
+ end
260
+
261
+ c_tag_end = xml.index(">", c_start + 2)
262
+ break unless c_tag_end
263
+
264
+ # Extract tag substring for bounded attribute search
265
+ c_tag = xml.byteslice(c_start, c_tag_end - c_start)
266
+ ref = tag_attr(c_tag, ' r="')
267
+ type = tag_attr(c_tag, ' t="')
268
+ style_str = tag_attr(c_tag, ' s="')
269
+ style_index = style_str&.to_i
270
+
271
+ # Self-closing <c ... />
272
+ if xml.getbyte(c_tag_end - 1) == 47
273
+ block.call(Event.new(
274
+ type: :cell,
275
+ args: [ref, type, style_index, nil, nil],
276
+ source: { part: part_name, row: row_index, cell: ref }
277
+ ))
278
+ pos = c_tag_end + 1
279
+ next
280
+ end
281
+
282
+ c_end = xml.index("</c>", c_tag_end + 1)
283
+ break unless c_end
284
+
285
+ # Parse cell content sequentially (bounded to c_end - avoid unbounded scans)
286
+ value = nil
287
+ formula = nil
288
+ inline_str = nil
289
+ cpos = c_tag_end + 1
290
+ while cpos < c_end
291
+ tag_pos = xml.index("<", cpos)
292
+ break unless tag_pos && tag_pos < c_end
293
+
294
+ tag_char = xml.getbyte(tag_pos + 1)
295
+ case tag_char
296
+ when 118 # 'v'
297
+ if xml.getbyte(tag_pos + 2) == 62 # <v>
298
+ v_val_start = tag_pos + 3
299
+ v_end = xml.index("</v>", v_val_start)
300
+ if v_end
301
+ raw_value = xml.byteslice(v_val_start, v_end - v_val_start)
302
+ value = resolve_fast_value(raw_value, type, shared_strings)
303
+ cpos = v_end + 4
304
+ else
305
+ cpos = tag_pos + 3
306
+ end
307
+ elsif xml.getbyte(tag_pos + 2) == 47 && xml.getbyte(tag_pos + 3) == 62 # <v/>
308
+ cpos = tag_pos + 4
309
+ else
310
+ cpos = tag_pos + 2
311
+ end
312
+ when 102 # 'f'
313
+ f_tag_end = xml.index(">", tag_pos + 2)
314
+ if f_tag_end && f_tag_end < c_end
315
+ if xml.getbyte(f_tag_end - 1) == 47 # self-closing <f ... />
316
+ cpos = f_tag_end + 1
317
+ else
318
+ f_end = xml.index("</f>", f_tag_end + 1)
319
+ if f_end && f_end <= c_end
320
+ formula = xml.byteslice(f_tag_end + 1, f_end - f_tag_end - 1).force_encoding("UTF-8")
321
+ formula = decode_xml_entities(formula) if formula.include?("&")
322
+ cpos = f_end + 4
323
+ else
324
+ cpos = f_tag_end + 1
325
+ end
326
+ end
327
+ else
328
+ cpos = tag_pos + 2
329
+ end
330
+ when 105 # 'i' - <is>
331
+ if xml.byteslice(tag_pos, 4) == "<is>"
332
+ is_end = xml.index("</is>", tag_pos + 4)
333
+ if is_end && is_end <= c_end
334
+ inline_str = extract_inline_text(xml, tag_pos + 4, is_end)
335
+ cpos = is_end + 5
336
+ else
337
+ cpos = tag_pos + 4
338
+ end
339
+ else
340
+ cpos = tag_pos + 2
341
+ end
342
+ else
343
+ # Skip unknown tag
344
+ close = xml.index(">", tag_pos + 1)
345
+ cpos = close ? close + 1 : c_end
346
+ end
347
+ end
348
+
349
+ val_to_use = inline_str || value
350
+ block.call(Event.new(
351
+ type: :cell,
352
+ args: [ref, type, style_index, val_to_use, formula],
353
+ source: { part: part_name, row: row_index, cell: ref }
354
+ ))
355
+
356
+ pos = c_end + 4
357
+ end
358
+ end
359
+
360
+ private_class_method :fast_scan_cells_events
361
+
362
+ def self.extract_inline_text(xml, from, to)
363
+ result = +""
364
+ pos = from
365
+ while pos < to
366
+ t_start = xml.index("<t", pos)
367
+ break unless t_start && t_start < to
368
+
369
+ t_tag_end = xml.index(">", t_start)
370
+ break unless t_tag_end
371
+ next (pos = t_start + 2) if xml.getbyte(t_tag_end - 1) == 47
372
+
373
+ t_end = xml.index("</t>", t_tag_end + 1)
374
+ break unless t_end && t_end <= to
375
+
376
+ result << xml.byteslice(t_tag_end + 1, t_end - t_tag_end - 1)
377
+ pos = t_end + 4
378
+ end
379
+ result.force_encoding("UTF-8")
380
+ result = decode_xml_entities(result) if result.include?("&")
381
+ result
382
+ end
383
+
384
+ private_class_method :extract_inline_text
385
+
386
+ def self.resolve_fast_value(raw, type, shared_strings)
387
+ case type
388
+ when "s"
389
+ shared_strings[raw.to_i] || ""
390
+ when "b"
391
+ raw == "1"
392
+ when "e", "str", "inlineStr"
393
+ val = raw.force_encoding("UTF-8")
394
+ val.include?("&") ? decode_xml_entities(val) : val
395
+ else
396
+ return nil if raw.empty?
397
+
398
+ if raw.include?(".")
399
+ raw.to_f
400
+ else
401
+ int_val = raw.to_i
402
+ int_val.to_s == raw ? int_val : raw.to_f
403
+ end
404
+ end
405
+ end
406
+
407
+ private_class_method :resolve_fast_value
408
+
409
+ XML_ENTITIES = { "&amp;" => "&", "&lt;" => "<", "&gt;" => ">", "&quot;" => '"', "&apos;" => "'" }.freeze
410
+
411
+ def self.decode_xml_entities(str)
412
+ str.gsub(/&(?:amp|lt|gt|quot|apos);/, XML_ENTITIES)
413
+ end
414
+
415
+ private_class_method :decode_xml_entities
416
+
417
+ # Parses <cols> section for column definitions.
418
+ class ColumnsListener
419
+ include REXML::SAX2Listener
420
+
421
+ attr_reader :columns
422
+
423
+ def initialize
424
+ @columns = []
425
+ @in_cols = false
426
+ end
427
+
428
+ def start_element(_uri, localname, _qname, attrs)
429
+ case localname
430
+ when "cols"
431
+ @in_cols = true
432
+ when "col"
433
+ return unless @in_cols
434
+
435
+ col = {
436
+ min: attrs["min"]&.to_i,
437
+ max: attrs["max"]&.to_i,
438
+ width: attrs["width"]&.to_f,
439
+ hidden: attrs["hidden"] == "1",
440
+ custom_width: attrs["customWidth"] == "1",
441
+ outline_level: attrs["outlineLevel"]&.to_i
442
+ }
443
+ @columns << col
444
+ end
445
+ end
446
+
447
+ def end_element(_uri, localname, _qname)
448
+ @in_cols = false if localname == "cols"
449
+ end
450
+
451
+ def characters(_text); end
452
+ end
453
+ end
454
+ end
455
+ end