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,189 @@
1
+ /* wasm_doc_helper.css */
2
+ .wasm-playground-wrapper {
3
+ margin: 1.5em 0;
4
+ position: relative;
5
+ }
6
+
7
+ /* 'Try in Browser' Button */
8
+ .wasm-try-btn {
9
+ display: inline-flex;
10
+ align-items: center;
11
+ justify-content: center;
12
+ background: linear-gradient(135deg, #4f46e5 0%, #3b82f6 100%);
13
+ color: white !important;
14
+ border: none;
15
+ padding: 8px 16px;
16
+ font-size: 13px;
17
+ font-weight: 600;
18
+ border-radius: 6px;
19
+ cursor: pointer;
20
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25);
21
+ transition: all 0.2s ease;
22
+ margin-bottom: 8px;
23
+ outline: none;
24
+ }
25
+
26
+ .wasm-try-btn:hover {
27
+ background: linear-gradient(135deg, #4338ca 0%, #2563eb 100%);
28
+ transform: translateY(-1px);
29
+ box-shadow: 0 6px 16px rgba(59, 130, 246, 0.35);
30
+ }
31
+
32
+ .wasm-try-btn:active {
33
+ transform: translateY(1px);
34
+ box-shadow: 0 2px 6px rgba(59, 130, 246, 0.2);
35
+ }
36
+
37
+ /* Loader indicator */
38
+ .wasm-loader {
39
+ display: flex;
40
+ align-items: center;
41
+ gap: 12px;
42
+ padding: 16px;
43
+ background-color: #f3f4f6;
44
+ border: 1px solid #e5e7eb;
45
+ border-radius: 8px;
46
+ font-size: 14px;
47
+ color: #374151;
48
+ }
49
+
50
+ .wasm-loader-text {
51
+ font-weight: 500;
52
+ }
53
+
54
+ .wasm-spinner {
55
+ width: 20px;
56
+ height: 20px;
57
+ border: 3px solid rgba(59, 130, 246, 0.2);
58
+ border-top-color: #3b82f6;
59
+ border-radius: 50%;
60
+ animation: wasm-spin 0.8s linear infinite;
61
+ }
62
+
63
+ @keyframes wasm-spin {
64
+ to { transform: rotate(360deg); }
65
+ }
66
+
67
+ /* Playground main container */
68
+ .wasm-playground-container {
69
+ display: flex;
70
+ flex-direction: column;
71
+ border: 1px solid #e5e7eb;
72
+ border-radius: 8px;
73
+ overflow: hidden;
74
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.05);
75
+ background: #ffffff;
76
+ transition: border-color 0.3s ease;
77
+ }
78
+
79
+ /* Editor (textarea) */
80
+ .wasm-editor {
81
+ width: 100%;
82
+ box-sizing: border-box;
83
+ padding: 16px;
84
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
85
+ font-size: 13.5px;
86
+ line-height: 1.6;
87
+ color: #1f2937;
88
+ background-color: #fafafa;
89
+ border: none;
90
+ border-bottom: 1px solid #e5e7eb;
91
+ resize: vertical;
92
+ outline: none;
93
+ tab-size: 2;
94
+ }
95
+
96
+ .wasm-editor:focus {
97
+ background-color: #ffffff;
98
+ }
99
+
100
+ /* Action bar container */
101
+ .wasm-action-bar {
102
+ display: flex;
103
+ gap: 8px;
104
+ padding: 10px 16px;
105
+ background-color: #f9fafb;
106
+ border-bottom: 1px solid #e5e7eb;
107
+ }
108
+
109
+ /* Common action buttons style */
110
+ .wasm-action-bar button {
111
+ padding: 6px 12px;
112
+ font-size: 12.5px;
113
+ font-weight: 600;
114
+ border-radius: 4px;
115
+ cursor: pointer;
116
+ transition: all 0.15s ease;
117
+ outline: none;
118
+ }
119
+
120
+ .wasm-run-btn {
121
+ background-color: #10b981;
122
+ color: white;
123
+ border: 1px solid #059669;
124
+ }
125
+
126
+ .wasm-run-btn:hover:not(:disabled) {
127
+ background-color: #059669;
128
+ }
129
+
130
+ .wasm-run-btn:disabled {
131
+ background-color: #a7f3d0;
132
+ border-color: #a7f3d0;
133
+ cursor: not-allowed;
134
+ }
135
+
136
+ .wasm-reset-btn {
137
+ background-color: white;
138
+ color: #4b5563;
139
+ border: 1px solid #d1d5db;
140
+ }
141
+
142
+ .wasm-reset-btn:hover {
143
+ background-color: #f3f4f6;
144
+ color: #1f2937;
145
+ border-color: #9ca3af;
146
+ }
147
+
148
+ .wasm-close-btn {
149
+ background-color: white;
150
+ color: #ef4444;
151
+ border: 1px solid #fca5a5;
152
+ margin-left: auto; /* Align to the right edge */
153
+ }
154
+
155
+ .wasm-close-btn:hover {
156
+ background-color: #fef2f2;
157
+ border-color: #ef4444;
158
+ }
159
+
160
+ /* Console output logs area */
161
+ .wasm-console {
162
+ margin: 0;
163
+ padding: 14px 16px;
164
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
165
+ font-size: 12.5px;
166
+ line-height: 1.5;
167
+ background-color: #1e293b;
168
+ color: #e2e8f0;
169
+ overflow-x: auto;
170
+ max-height: 200px;
171
+ border-top: none;
172
+ }
173
+
174
+ .wasm-console.running {
175
+ color: #93c5fd;
176
+ }
177
+
178
+ .wasm-console.success {
179
+ color: #a7f3d0;
180
+ }
181
+
182
+ .wasm-console.error {
183
+ color: #fca5a5;
184
+ }
185
+
186
+ .wasm-error {
187
+ color: #ef4444;
188
+ font-weight: 600;
189
+ }
@@ -0,0 +1,320 @@
1
+ // wasm_doc_helper.js
2
+ (function () {
3
+ // Detect all pre.ruby blocks to setup interactive playgrounds
4
+ document.addEventListener("DOMContentLoaded", () => {
5
+ const codeBlocks = document.querySelectorAll("pre.ruby");
6
+ codeBlocks.forEach((block, index) => {
7
+ setupPlayground(block, index);
8
+ });
9
+ });
10
+
11
+ let rubyVM = null;
12
+ let isInitializing = false;
13
+ const initListeners = [];
14
+ let consoleBuffer = ""; // Temporary buffer to capture stdout/stderr
15
+
16
+ // Helper to initialize and retrieve the Ruby Wasm VM
17
+ async function ensureRubyWasm() {
18
+ if (rubyVM) return rubyVM;
19
+ if (isInitializing) {
20
+ return new Promise((resolve) => initListeners.push(resolve));
21
+ }
22
+ isInitializing = true;
23
+
24
+ // Load browser.umd.js dynamically from CDN
25
+ await loadScript("https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.9.3-2.9.4/dist/browser.umd.js");
26
+
27
+ // Fetch and compile the custom prepackaged ruby.wasm module
28
+ const prefix = window.rdoc_rel_prefix || "./";
29
+ const response = await fetch(`${prefix}wasm/ruby.wasm?cb=${Date.now()}`);
30
+ const buffer = await response.arrayBuffer();
31
+ const module = await WebAssembly.compile(buffer);
32
+
33
+ // Capture stdout/stderr streams to buffer during execution
34
+ const rubyWasm = window.rubyWasm || window["ruby-wasm-wasi"];
35
+ const DefaultRubyVM = rubyWasm.DefaultRubyVM;
36
+ let vmInit;
37
+
38
+ const options = {
39
+ stdout: (str) => { consoleBuffer += str; },
40
+ stderr: (str) => { consoleBuffer += str; }
41
+ };
42
+
43
+ // Handle initialization polymorphism across various ruby-wasm versions
44
+ if (typeof DefaultRubyVM.initialize === "function") {
45
+ vmInit = DefaultRubyVM.initialize(module, options);
46
+ } else if (typeof DefaultRubyVM.initializeVM === "function") {
47
+ vmInit = DefaultRubyVM.initializeVM(module, options);
48
+ } else if (typeof DefaultRubyVM === "function") {
49
+ try {
50
+ vmInit = DefaultRubyVM(module, options);
51
+ } catch (e) {
52
+ const instance = new DefaultRubyVM();
53
+ const initFn = instance.initialize || instance.initializeVM;
54
+ if (typeof initFn === "function") {
55
+ vmInit = initFn.call(instance, module, options);
56
+ } else {
57
+ throw new Error("No initialization method found on DefaultRubyVM instance");
58
+ }
59
+ }
60
+ } else {
61
+ throw new Error("DefaultRubyVM is not available");
62
+ }
63
+ const { vm } = await vmInit;
64
+
65
+ rubyVM = vm;
66
+
67
+ isInitializing = false;
68
+ const listeners = [...initListeners];
69
+ initListeners.length = 0;
70
+ listeners.forEach((resolve) => resolve(rubyVM));
71
+ return rubyVM;
72
+ }
73
+
74
+ // Inject script tags dynamically
75
+ function loadScript(src) {
76
+ return new Promise((resolve, reject) => {
77
+ if (document.querySelector(`script[src="${src}"]`)) {
78
+ resolve();
79
+ return;
80
+ }
81
+ const script = document.createElement("script");
82
+ script.src = src;
83
+ script.onload = resolve;
84
+ script.onerror = reject;
85
+ document.head.appendChild(script);
86
+ });
87
+ }
88
+
89
+ // Setup playground UI elements around a code block
90
+ function setupPlayground(block, index) {
91
+ const originalCode = block.textContent;
92
+
93
+ const btn = document.createElement("button");
94
+ btn.className = "wasm-try-btn";
95
+ btn.innerHTML = `
96
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 6px; vertical-align: middle;">
97
+ <polygon points="5 3 19 12 5 21 5 3"></polygon>
98
+ </svg>Try it in Browser
99
+ `;
100
+
101
+ const wrapper = document.createElement("div");
102
+ wrapper.className = "wasm-playground-wrapper";
103
+
104
+ block.parentNode.insertBefore(wrapper, block);
105
+ wrapper.appendChild(block);
106
+ wrapper.insertBefore(btn, block);
107
+
108
+ btn.addEventListener("click", async () => {
109
+ btn.style.display = "none";
110
+
111
+ const loader = document.createElement("div");
112
+ loader.className = "wasm-loader";
113
+ loader.innerHTML = `
114
+ <span class="wasm-loader-text">Loading Ruby Wasm Engine...</span>
115
+ <div class="wasm-spinner"></div>
116
+ `;
117
+ wrapper.insertBefore(loader, block);
118
+
119
+ try {
120
+ const vm = await ensureRubyWasm();
121
+ loader.remove();
122
+ activatePlayground(wrapper, block, originalCode, vm);
123
+ } catch (e) {
124
+ loader.innerHTML = `<span class="wasm-error">Error loading Wasm: ${e.message}</span>`;
125
+ console.error(e);
126
+ }
127
+ });
128
+
129
+ // Auto-trigger load under E2E testing mode
130
+ if (window.location.hash === "#test-mode" && index === 0) {
131
+ setTimeout(() => {
132
+ console.log("[Script Mock] Test mode detected. Triggering playground load...");
133
+ btn.click();
134
+
135
+ const interval = setInterval(() => {
136
+ const runBtn = wrapper.querySelector(".wasm-run-btn");
137
+ if (runBtn && !runBtn.disabled) {
138
+ clearInterval(interval);
139
+ console.log("[Script Mock] Playground loaded. Triggering run...");
140
+ setTimeout(() => {
141
+ runBtn.click();
142
+ }, 1000);
143
+ }
144
+ }, 500);
145
+ }, 1000);
146
+ }
147
+ }
148
+
149
+ // Replace block with the interactive editor and console
150
+ function activatePlayground(wrapper, originalBlock, code, vm) {
151
+ originalBlock.style.display = "none";
152
+
153
+ const playgroundContainer = document.createElement("div");
154
+ playgroundContainer.className = "wasm-playground-container";
155
+
156
+ const textarea = document.createElement("textarea");
157
+ textarea.className = "wasm-editor";
158
+ textarea.value = code;
159
+ textarea.rows = Math.max(8, code.split("\n").length + 2);
160
+
161
+ const actionBar = document.createElement("div");
162
+ actionBar.className = "wasm-action-bar";
163
+
164
+ const runBtn = document.createElement("button");
165
+ runBtn.className = "wasm-run-btn";
166
+ runBtn.textContent = "Run & Download";
167
+
168
+ const resetBtn = document.createElement("button");
169
+ resetBtn.className = "wasm-reset-btn";
170
+ resetBtn.textContent = "Reset";
171
+
172
+ const closeBtn = document.createElement("button");
173
+ closeBtn.className = "wasm-close-btn";
174
+ closeBtn.textContent = "Close";
175
+
176
+ actionBar.appendChild(runBtn);
177
+ actionBar.appendChild(resetBtn);
178
+ actionBar.appendChild(closeBtn);
179
+
180
+ const consoleArea = document.createElement("pre");
181
+ consoleArea.className = "wasm-console";
182
+ consoleArea.textContent = "Ready to run. Output and status will be shown here.";
183
+
184
+ playgroundContainer.appendChild(textarea);
185
+ playgroundContainer.appendChild(actionBar);
186
+ playgroundContainer.appendChild(consoleArea);
187
+ wrapper.appendChild(playgroundContainer);
188
+
189
+ resetBtn.addEventListener("click", () => {
190
+ textarea.value = code;
191
+ consoleArea.textContent = "Reset to original code.";
192
+ consoleArea.className = "wasm-console";
193
+ });
194
+
195
+ closeBtn.addEventListener("click", () => {
196
+ playgroundContainer.remove();
197
+ originalBlock.style.display = "block";
198
+ wrapper.querySelector(".wasm-try-btn").style.display = "inline-flex";
199
+ });
200
+
201
+ runBtn.addEventListener("click", async () => {
202
+ runBtn.disabled = true;
203
+ runBtn.textContent = "Running...";
204
+ consoleArea.textContent = "Executing Ruby code...";
205
+ consoleArea.className = "wasm-console running";
206
+ consoleBuffer = "";
207
+
208
+ // Clear any preexisting XLSX files in the virtual FS root to avoid collision/leak
209
+ clearXlsxFiles(vm, "/");
210
+
211
+ try {
212
+ let userCode = textarea.value;
213
+ if (!userCode.includes('require "xlsxrb"') && !userCode.includes("require 'xlsxrb'")) {
214
+ userCode = 'require "xlsxrb"\n' + userCode;
215
+ }
216
+ // Force include the current directory in load path
217
+ userCode = '$LOAD_PATH << Dir.pwd unless $LOAD_PATH.include?(Dir.pwd)\n' + userCode;
218
+
219
+ // Evaluate the user code in Wasm VM
220
+ vm.eval(userCode);
221
+
222
+ // Scan virtual FS for new XLSX output files
223
+ const generatedFiles = scanXlsxFiles(vm, "/");
224
+
225
+ consoleArea.className = "wasm-console success";
226
+ let output = consoleBuffer;
227
+
228
+ if (generatedFiles.length > 0) {
229
+ const mainFile = generatedFiles[0];
230
+ output += `\n[Success] Generated file: ${mainFile}\nTriggering download...`;
231
+ downloadFileFromVfs(vm, mainFile);
232
+ } else {
233
+ output += `\n[Warning] Execution succeeded, but no .xlsx file was generated in the root directory.`;
234
+ }
235
+
236
+ consoleArea.textContent = output || "Execution completed successfully (no stdout).";
237
+
238
+ } catch (err) {
239
+ consoleArea.className = "wasm-console error";
240
+ consoleArea.textContent = `Error during execution:\n${err.message}\n\nConsole output:\n${consoleBuffer}`;
241
+ console.error(err);
242
+
243
+ // Send runtime errors back to test server under E2E testing
244
+ if (window.location.hash === "#test-mode") {
245
+ fetch("http://localhost:9001/error", {
246
+ method: "POST",
247
+ body: `Error during execution:\n${err.message}\n\nConsole output:\n${consoleBuffer}`
248
+ }).catch(e => console.error("Failed to post back error:", e));
249
+ }
250
+ } finally {
251
+ runBtn.disabled = false;
252
+ runBtn.textContent = "Run & Download";
253
+ }
254
+ });
255
+ }
256
+
257
+ // Delete all .xlsx files recursively under the virtual directory
258
+ function clearXlsxFiles(vm, dir) {
259
+ try {
260
+ vm.eval(`
261
+ Dir.glob("${dir}/**/*.xlsx").each do |file|
262
+ File.delete(file) rescue nil
263
+ end
264
+ `);
265
+ } catch (e) {
266
+ console.error("Error clearing XLSX files:", e);
267
+ }
268
+ }
269
+
270
+ // Recursively scan virtual directory for generated .xlsx files
271
+ function scanXlsxFiles(vm, dir) {
272
+ try {
273
+ const filesStr = vm.eval(`
274
+ Dir.glob("${dir}/**/*.xlsx").join(",")
275
+ `).toString();
276
+ return filesStr ? filesStr.split(",") : [];
277
+ } catch (e) {
278
+ console.error("Error scanning XLSX files:", e);
279
+ return [];
280
+ }
281
+ }
282
+
283
+ // Fetch binary contents of a VFS file and trigger browser download
284
+ function downloadFileFromVfs(vm, filepath) {
285
+ try {
286
+ // Hex bridge unpacking to transfer binary data safely to JS
287
+ const hexData = vm.eval(`
288
+ File.binread("${filepath}").unpack1("H*")
289
+ `).toString();
290
+
291
+ const len = hexData.length;
292
+ const bytes = new Uint8Array(len / 2);
293
+ for (let i = 0; i < len; i += 2) {
294
+ bytes[i / 2] = parseInt(hexData.substr(i, 2), 16);
295
+ }
296
+
297
+ const filename = filepath.split("/").pop();
298
+ const blob = new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
299
+ const url = URL.createObjectURL(blob);
300
+ const a = document.createElement("a");
301
+ a.href = url;
302
+ a.download = filename;
303
+ document.body.appendChild(a);
304
+ a.click();
305
+ document.body.removeChild(a);
306
+ setTimeout(() => URL.revokeObjectURL(url), 10000);
307
+
308
+ // Post binary data back to test server under E2E testing
309
+ if (window.location.hash === "#test-mode") {
310
+ console.log("[Script Mock] Test mode detected. Sending generated file back to test server...");
311
+ fetch("http://localhost:9001/upload", {
312
+ method: "POST",
313
+ body: bytes
314
+ }).catch(err => console.error("Failed to post back test file:", err));
315
+ }
316
+ } catch (e) {
317
+ console.error("Error downloading file from Vfs:", e);
318
+ }
319
+ }
320
+ })();
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xlsxrb
4
+ module Elements
5
+ # Represents a single cell in a worksheet.
6
+ # All indices are 0-based.
7
+ Cell = Data.define(:row_index, :column_index, :value, :formula, :style_index, :unmapped_data, :errors) do
8
+ def initialize(row_index:, column_index:, value: nil, formula: nil, style_index: nil, unmapped_data: {}, errors: nil)
9
+ computed_errors = errors || self.class.validate(row_index, column_index, value)
10
+ computed_errors = computed_errors.freeze unless computed_errors.frozen?
11
+ super(row_index: row_index, column_index: column_index, value: value, formula: formula,
12
+ style_index: style_index, unmapped_data: unmapped_data, errors: computed_errors)
13
+ end
14
+
15
+ def valid?
16
+ errors.empty?
17
+ end
18
+
19
+ # Excel-style reference (e.g. "A1").
20
+ def ref
21
+ "#{self.class.column_letter(column_index)}#{row_index + 1}"
22
+ end
23
+
24
+ # Cache column letters up to Excel's limit (16,384)
25
+ @column_letters = (0...16_384).map do |index|
26
+ result = +""
27
+ i = index
28
+ loop do
29
+ result.prepend(("A".ord + (i % 26)).chr)
30
+ i = (i / 26) - 1
31
+ break if i.negative?
32
+ end
33
+ result.freeze
34
+ end.freeze
35
+
36
+ # Converts a 0-based column index to a letter (0 -> "A", 25 -> "Z", 26 -> "AA").
37
+ def self.column_letter(index)
38
+ @column_letters[index] || begin
39
+ result = +""
40
+ i = index
41
+ loop do
42
+ result.prepend(("A".ord + (i % 26)).chr)
43
+ i = (i / 26) - 1
44
+ break if i.negative?
45
+ end
46
+ result
47
+ end
48
+ end
49
+
50
+ # Parses an Excel-style reference to [row_index, col_index] (both 0-based).
51
+ def self.parse_ref(ref)
52
+ match = ref.match(/\A([A-Z]+)(\d+)\z/)
53
+ return nil unless match
54
+
55
+ col = match[1].chars.reduce(0) { |acc, c| (acc * 26) + (c.ord - "A".ord + 1) } - 1
56
+ row = match[2].to_i - 1
57
+ [row, col]
58
+ end
59
+
60
+ def self.validate(row_index, column_index, value)
61
+ if row_index.is_a?(Integer) && row_index >= 0 && row_index < 1_048_576 &&
62
+ column_index.is_a?(Integer) && column_index >= 0 && column_index < 16_384 &&
63
+ (value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.is_a?(Date) || value.is_a?(Time))
64
+ return [].freeze
65
+ end
66
+
67
+ errs = []
68
+ errs << "row_index must be a non-negative Integer (got #{row_index.inspect})" if !row_index.is_a?(Integer) || row_index.negative?
69
+ errs << "column_index must be a non-negative Integer (got #{column_index.inspect})" if !column_index.is_a?(Integer) || column_index.negative?
70
+ errs << "row_index must be < 1048576 (got #{row_index}, max row is 1048575)" if row_index.is_a?(Integer) && row_index >= 1_048_576
71
+ errs << "column_index must be < 16384 (got #{column_index}, max column is XFD=16383)" if column_index.is_a?(Integer) && column_index >= 16_384
72
+ errs << "unsupported value type: #{value.class} (#{value.inspect}) — supported types: String, Numeric, true/false, Date, Time, or nil" unless value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.is_a?(Date) || value.is_a?(Time)
73
+ errs
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xlsxrb
4
+ module Elements
5
+ # Represents column formatting in a worksheet.
6
+ # index is 0-based.
7
+ Column = Data.define(:index, :width, :hidden, :custom_width, :outline_level, :unmapped_data, :errors) do
8
+ def initialize(index:, width: nil, hidden: false, custom_width: false, outline_level: nil,
9
+ unmapped_data: {}, errors: nil)
10
+ computed_errors = errors || self.class.validate(index)
11
+ super(index: index, width: width, hidden: hidden, custom_width: custom_width,
12
+ outline_level: outline_level, unmapped_data: unmapped_data,
13
+ errors: computed_errors.freeze)
14
+ end
15
+
16
+ def valid?
17
+ errors.empty?
18
+ end
19
+
20
+ def self.validate(index)
21
+ errs = []
22
+ errs << "index must be a non-negative Integer (got #{index.inspect})" if !index.is_a?(Integer) || index.negative?
23
+ errs << "index must be < 16384 (got #{index}, max column is XFD=16383)" if index.is_a?(Integer) && index >= 16_384
24
+ errs
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xlsxrb
4
+ module Elements
5
+ # Represents a single row in a worksheet.
6
+ # index is 0-based.
7
+ Row = Data.define(:index, :cells, :height, :hidden, :custom_height, :outline_level, :unmapped_data, :errors) do
8
+ def initialize(index:, cells: [], height: nil, hidden: false, custom_height: false, outline_level: nil,
9
+ unmapped_data: {}, errors: nil)
10
+ computed_errors = errors || self.class.validate(index, cells)
11
+ computed_errors = computed_errors.freeze unless computed_errors.frozen?
12
+ cells = cells.freeze unless cells.frozen?
13
+ super(index: index, cells: cells, height: height, hidden: hidden,
14
+ custom_height: custom_height, outline_level: outline_level,
15
+ unmapped_data: unmapped_data, errors: computed_errors)
16
+ end
17
+
18
+ def valid?
19
+ errors.empty?
20
+ end
21
+
22
+ # Returns the cell at the given 0-based column index, or nil.
23
+ def cell_at(column_index)
24
+ cells.find { |c| c.column_index == column_index }
25
+ end
26
+
27
+ # Returns cell values as an Array (sparse columns get nil).
28
+ def values
29
+ return [] if cells.empty?
30
+
31
+ max_col = cells.max_by(&:column_index).column_index
32
+ result = Array.new(max_col + 1)
33
+ cells.each { |c| result[c.column_index] = c.value }
34
+ result
35
+ end
36
+
37
+ def self.validate(index, cells)
38
+ return [].freeze if index.is_a?(Integer) && index >= 0 && cells.is_a?(Array)
39
+
40
+ errs = []
41
+ errs << "index must be a non-negative Integer (got #{index.inspect})" if !index.is_a?(Integer) || index.negative?
42
+ errs << "cells must be an Array (got #{cells.class})" unless cells.is_a?(Array)
43
+ errs
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xlsxrb
4
+ module Elements
5
+ # Represents a formula with an optional cached value.
6
+ # Optional: type (:shared, :array), ref (range), shared_index (si for shared formulas)
7
+ Formula = Data.define(:expression, :cached_value, :type, :ref, :shared_index, :calculate_always, :aca, :bx, :dt2d, :dtr, :r1, :r2) do
8
+ def initialize(expression:, cached_value: nil, type: nil, ref: nil, shared_index: nil, calculate_always: nil, aca: nil, bx: nil, dt2d: nil, dtr: nil, r1: nil, r2: nil) # rubocop:disable Naming/MethodParameterName
9
+ super
10
+ end
11
+ end
12
+
13
+ # Represents a cell error value (e.g. #N/A, #REF!, #DIV/0!).
14
+ VALID_ERROR_CODES = %w[#NULL! #DIV/0! #VALUE! #REF! #NAME? #NUM! #N/A #GETTING_DATA].freeze
15
+ CellError = Data.define(:code) do
16
+ def initialize(code:)
17
+ raise ArgumentError, "invalid error code: #{code.inspect} (must be one of #{VALID_ERROR_CODES.join(", ")})" unless VALID_ERROR_CODES.include?(code)
18
+
19
+ super
20
+ end
21
+
22
+ def to_s
23
+ code
24
+ end
25
+ end
26
+
27
+ # Represents a rich text string with formatting runs.
28
+ # runs: array of hashes, each with :text and optional :font (hash of font properties).
29
+ # Font properties: :bold, :italic, :underline, :sz, :color, :name
30
+ RichText = Data.define(:runs) do
31
+ def to_s
32
+ runs.map { |r| r[:text] }.join
33
+ end
34
+ end
35
+ end
36
+ end