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,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
|