@paperjsx/json-to-xlsx-pro 0.0.1
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.
- package/LICENSE +7 -0
- package/README.md +61 -0
- package/dist/assembly/xlsx-assembler.d.ts +53 -0
- package/dist/assembly/xlsx-assembler.d.ts.map +1 -0
- package/dist/benchmarks/phase2.d.ts +72 -0
- package/dist/benchmarks/phase2.d.ts.map +1 -0
- package/dist/benchmarks/phase2.js +14 -0
- package/dist/benchmarks/phase2.js.map +7 -0
- package/dist/benchmarks/report.d.ts +21 -0
- package/dist/benchmarks/report.d.ts.map +1 -0
- package/dist/benchmarks/report.js +13 -0
- package/dist/benchmarks/report.js.map +7 -0
- package/dist/benchmarks/rigorous.d.ts +85 -0
- package/dist/benchmarks/rigorous.d.ts.map +1 -0
- package/dist/benchmarks/rigorous.js +534 -0
- package/dist/benchmarks/rigorous.js.map +7 -0
- package/dist/chaos-lab/index.d.ts +69 -0
- package/dist/chaos-lab/index.d.ts.map +1 -0
- package/dist/chaos-lab/index.js +1696 -0
- package/dist/chaos-lab/index.js.map +7 -0
- package/dist/chunk-2IVXCH6I.js +1002 -0
- package/dist/chunk-2IVXCH6I.js.map +7 -0
- package/dist/chunk-IYMS2CLX.js +478 -0
- package/dist/chunk-IYMS2CLX.js.map +7 -0
- package/dist/chunk-PQSLPEN5.js +290 -0
- package/dist/chunk-PQSLPEN5.js.map +7 -0
- package/dist/chunk-QDWDSM74.js +142 -0
- package/dist/chunk-QDWDSM74.js.map +7 -0
- package/dist/chunk-S5RMAWLC.js +25347 -0
- package/dist/chunk-S5RMAWLC.js.map +7 -0
- package/dist/chunk-Z2JSZFNG.js +308 -0
- package/dist/chunk-Z2JSZFNG.js.map +7 -0
- package/dist/diagnostics/corruption.d.ts +9 -0
- package/dist/diagnostics/corruption.d.ts.map +1 -0
- package/dist/diagnostics/workloads.d.ts +6 -0
- package/dist/diagnostics/workloads.d.ts.map +1 -0
- package/dist/diff/document-diff.d.ts +5 -0
- package/dist/diff/document-diff.d.ts.map +1 -0
- package/dist/document-diff/src/index.d.ts +50 -0
- package/dist/document-diff/src/index.d.ts.map +1 -0
- package/dist/errors.d.ts +35 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/fixtures/phase1.d.ts +14 -0
- package/dist/fixtures/phase1.d.ts.map +1 -0
- package/dist/fixtures/phase2.d.ts +9 -0
- package/dist/fixtures/phase2.d.ts.map +1 -0
- package/dist/fixtures/phase3.d.ts +9 -0
- package/dist/fixtures/phase3.d.ts.map +1 -0
- package/dist/fixtures/phase4.d.ts +10 -0
- package/dist/fixtures/phase4.d.ts.map +1 -0
- package/dist/fixtures/phase5.d.ts +9 -0
- package/dist/fixtures/phase5.d.ts.map +1 -0
- package/dist/formulas/builder.d.ts +91 -0
- package/dist/formulas/builder.d.ts.map +1 -0
- package/dist/formulas/evaluator.d.ts +25 -0
- package/dist/formulas/evaluator.d.ts.map +1 -0
- package/dist/formulas/shift.d.ts +14 -0
- package/dist/formulas/shift.d.ts.map +1 -0
- package/dist/index-pro.d.ts +12 -0
- package/dist/index-pro.d.ts.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2167 -0
- package/dist/index.js.map +7 -0
- package/dist/layout/column-width.d.ts +32 -0
- package/dist/layout/column-width.d.ts.map +1 -0
- package/dist/layout/row-height.d.ts +3 -0
- package/dist/layout/row-height.d.ts.map +1 -0
- package/dist/preflight.d.ts +31 -0
- package/dist/preflight.d.ts.map +1 -0
- package/dist/public-quality-types.d.ts +44 -0
- package/dist/public-quality-types.d.ts.map +1 -0
- package/dist/quality/accessibility-contract.d.ts +38 -0
- package/dist/quality/accessibility-contract.d.ts.map +1 -0
- package/dist/quality/accessibility.d.ts +33 -0
- package/dist/quality/accessibility.d.ts.map +1 -0
- package/dist/quality/shared-quality.d.ts +4 -0
- package/dist/quality/shared-quality.d.ts.map +1 -0
- package/dist/quality/structural-validation.d.ts +11 -0
- package/dist/quality/structural-validation.d.ts.map +1 -0
- package/dist/quality/workbook-quality.d.ts +63 -0
- package/dist/quality/workbook-quality.d.ts.map +1 -0
- package/dist/render-metrics.d.ts +67 -0
- package/dist/render-metrics.d.ts.map +1 -0
- package/dist/render-plan.d.ts +40 -0
- package/dist/render-plan.d.ts.map +1 -0
- package/dist/serializers/chart-serializer.d.ts +3 -0
- package/dist/serializers/chart-serializer.d.ts.map +1 -0
- package/dist/serializers/comment-serializer.d.ts +12 -0
- package/dist/serializers/comment-serializer.d.ts.map +1 -0
- package/dist/serializers/doc-props-serializer.d.ts +5 -0
- package/dist/serializers/doc-props-serializer.d.ts.map +1 -0
- package/dist/serializers/drawing-serializer.d.ts +24 -0
- package/dist/serializers/drawing-serializer.d.ts.map +1 -0
- package/dist/serializers/package-serializer.d.ts +13 -0
- package/dist/serializers/package-serializer.d.ts.map +1 -0
- package/dist/serializers/pivot-serializer.d.ts +37 -0
- package/dist/serializers/pivot-serializer.d.ts.map +1 -0
- package/dist/serializers/shared-strings.d.ts +11 -0
- package/dist/serializers/shared-strings.d.ts.map +1 -0
- package/dist/serializers/sheet-serializer.d.ts +58 -0
- package/dist/serializers/sheet-serializer.d.ts.map +1 -0
- package/dist/serializers/sheet-xml-builder.d.ts +33 -0
- package/dist/serializers/sheet-xml-builder.d.ts.map +1 -0
- package/dist/serializers/style-registry.d.ts +2 -0
- package/dist/serializers/style-registry.d.ts.map +1 -0
- package/dist/serializers/table-serializer.d.ts +20 -0
- package/dist/serializers/table-serializer.d.ts.map +1 -0
- package/dist/serializers/theme-serializer.d.ts +3 -0
- package/dist/serializers/theme-serializer.d.ts.map +1 -0
- package/dist/serializers/workbook-serializer.d.ts +16 -0
- package/dist/serializers/workbook-serializer.d.ts.map +1 -0
- package/dist/serializers/worksheet-rels-serializer.d.ts +7 -0
- package/dist/serializers/worksheet-rels-serializer.d.ts.map +1 -0
- package/dist/source-js-extension-loader.mjs +44 -0
- package/dist/spreadsheet-engine.d.ts +50 -0
- package/dist/spreadsheet-engine.d.ts.map +1 -0
- package/dist/styles/border-serializer.d.ts +6 -0
- package/dist/styles/border-serializer.d.ts.map +1 -0
- package/dist/styles/color.d.ts +5 -0
- package/dist/styles/color.d.ts.map +1 -0
- package/dist/styles/component-registry.d.ts +13 -0
- package/dist/styles/component-registry.d.ts.map +1 -0
- package/dist/styles/conditional-formatting.d.ts +8 -0
- package/dist/styles/conditional-formatting.d.ts.map +1 -0
- package/dist/styles/fill-serializer.d.ts +9 -0
- package/dist/styles/fill-serializer.d.ts.map +1 -0
- package/dist/styles/font-serializer.d.ts +11 -0
- package/dist/styles/font-serializer.d.ts.map +1 -0
- package/dist/styles/numfmt-registry.d.ts +7 -0
- package/dist/styles/numfmt-registry.d.ts.map +1 -0
- package/dist/styles/presets.d.ts +190 -0
- package/dist/styles/presets.d.ts.map +1 -0
- package/dist/styles/style-registry.d.ts +24 -0
- package/dist/styles/style-registry.d.ts.map +1 -0
- package/dist/styles/style-utils.d.ts +11 -0
- package/dist/styles/style-utils.d.ts.map +1 -0
- package/dist/template-assembler.d.ts +26 -0
- package/dist/template-assembler.d.ts.map +1 -0
- package/dist/template-parser.d.ts +96 -0
- package/dist/template-parser.d.ts.map +1 -0
- package/dist/types/spreadsheet-ast.d.ts +470 -0
- package/dist/types/spreadsheet-ast.d.ts.map +1 -0
- package/dist/utils/cell-ref.d.ts +21 -0
- package/dist/utils/cell-ref.d.ts.map +1 -0
- package/dist/utils/date.d.ts +32 -0
- package/dist/utils/date.d.ts.map +1 -0
- package/dist/utils/hyperlinks.d.ts +16 -0
- package/dist/utils/hyperlinks.d.ts.map +1 -0
- package/dist/utils/xml.d.ts +7 -0
- package/dist/utils/xml.d.ts.map +1 -0
- package/dist/validation/spreadsheet-schema.d.ts +1513 -0
- package/dist/validation/spreadsheet-schema.d.ts.map +1 -0
- package/dist/workers/sheet-serialization-worker-pool.d.ts +32 -0
- package/dist/workers/sheet-serialization-worker-pool.d.ts.map +1 -0
- package/dist/workers/sheet-serializer-worker.d.ts +2 -0
- package/dist/workers/sheet-serializer-worker.d.ts.map +1 -0
- package/dist/workers/sheet-serializer-worker.js +2565 -0
- package/dist/workers/sheet-serializer-worker.js.map +7 -0
- package/dist/worksheet/structure.d.ts +33 -0
- package/dist/worksheet/structure.d.ts.map +1 -0
- package/dist-pro/benchmarks/phase2.js +1 -0
- package/dist-pro/benchmarks/report.js +1 -0
- package/dist-pro/benchmarks/rigorous.js +2 -0
- package/dist-pro/chaos-lab/index.js +2 -0
- package/dist-pro/chunk-FFIHITWB.js +1 -0
- package/dist-pro/chunk-INDNGGXB.js +2 -0
- package/dist-pro/chunk-K2MQYNU6.js +1 -0
- package/dist-pro/chunk-MEZHQFH3.js +2 -0
- package/dist-pro/chunk-RB42Q36L.js +1 -0
- package/dist-pro/chunk-WYTH4W4N.js +48 -0
- package/dist-pro/index.js +3 -0
- package/dist-pro/source-js-extension-loader.mjs +44 -0
- package/dist-pro/workers/sheet-serializer-worker.js +3 -0
- package/package.json +62 -0
|
@@ -0,0 +1,1696 @@
|
|
|
1
|
+
import {
|
|
2
|
+
phase3Fixtures,
|
|
3
|
+
phase4Fixtures
|
|
4
|
+
} from "../chunk-Z2JSZFNG.js";
|
|
5
|
+
import {
|
|
6
|
+
createDuplicateTableCorruptionBuffer,
|
|
7
|
+
createHyperlinkValidationCorruptionBuffer,
|
|
8
|
+
createMergeDefinedNameCorruptionBuffer,
|
|
9
|
+
createMissingContentTypeBuffer,
|
|
10
|
+
createOrphanRelationshipBuffer,
|
|
11
|
+
createRepairableCorruptionBuffer,
|
|
12
|
+
createSharedStringIndexCorruptionBuffer,
|
|
13
|
+
createStyleIndexOobBuffer,
|
|
14
|
+
createTemplateBenchmarkDocument
|
|
15
|
+
} from "../chunk-IYMS2CLX.js";
|
|
16
|
+
import {
|
|
17
|
+
validateXlsxStructure
|
|
18
|
+
} from "../chunk-PQSLPEN5.js";
|
|
19
|
+
import {
|
|
20
|
+
SpreadsheetEngine,
|
|
21
|
+
getPhase1Fixture,
|
|
22
|
+
validateSpreadsheetBuffer
|
|
23
|
+
} from "../chunk-S5RMAWLC.js";
|
|
24
|
+
|
|
25
|
+
// src/chaos-lab/index.ts
|
|
26
|
+
import { performance } from "node:perf_hooks";
|
|
27
|
+
import process from "node:process";
|
|
28
|
+
import JSZip from "jszip";
|
|
29
|
+
|
|
30
|
+
// src/fixtures/phase5.ts
|
|
31
|
+
var TINY_PNG = Buffer.from(
|
|
32
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
|
33
|
+
"base64"
|
|
34
|
+
);
|
|
35
|
+
var TINY_JPEG = Buffer.from([
|
|
36
|
+
255,
|
|
37
|
+
216,
|
|
38
|
+
255,
|
|
39
|
+
224,
|
|
40
|
+
0,
|
|
41
|
+
16,
|
|
42
|
+
74,
|
|
43
|
+
70,
|
|
44
|
+
73,
|
|
45
|
+
70,
|
|
46
|
+
0,
|
|
47
|
+
1,
|
|
48
|
+
1,
|
|
49
|
+
0,
|
|
50
|
+
0,
|
|
51
|
+
1,
|
|
52
|
+
0,
|
|
53
|
+
1,
|
|
54
|
+
0,
|
|
55
|
+
0,
|
|
56
|
+
255,
|
|
57
|
+
217
|
|
58
|
+
]);
|
|
59
|
+
function colLetter(index) {
|
|
60
|
+
let current = index + 1;
|
|
61
|
+
let letters = "";
|
|
62
|
+
while (current > 0) {
|
|
63
|
+
current -= 1;
|
|
64
|
+
letters = String.fromCharCode(65 + current % 26) + letters;
|
|
65
|
+
current = Math.floor(current / 26);
|
|
66
|
+
}
|
|
67
|
+
return letters;
|
|
68
|
+
}
|
|
69
|
+
function createCommentsTortureFixture() {
|
|
70
|
+
const cells = [];
|
|
71
|
+
for (let col = 0; col < 26; col++) {
|
|
72
|
+
cells.push({
|
|
73
|
+
value: `Cell ${colLetter(col)}1`,
|
|
74
|
+
comment: {
|
|
75
|
+
author: `Author-${col}`,
|
|
76
|
+
text: `Comment on ${colLetter(col)}1`
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const rows = [{ cells }];
|
|
81
|
+
rows.push({
|
|
82
|
+
cells: [
|
|
83
|
+
{
|
|
84
|
+
value: "XML hostile 1",
|
|
85
|
+
comment: { author: "Tester", text: `Ampersand & less-than < greater-than > "double" 'single'` }
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
value: "XML hostile 2",
|
|
89
|
+
comment: { author: "Tester", text: "<script>alert('xss')</script> & CDATA: <![CDATA[test]]>" }
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
value: "XML hostile 3",
|
|
93
|
+
comment: { text: "Nested <<tags>> && entities & <" }
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
});
|
|
97
|
+
const longText = "This is a very long comment that exceeds 500 characters. ".repeat(12);
|
|
98
|
+
rows.push({
|
|
99
|
+
cells: [
|
|
100
|
+
{
|
|
101
|
+
value: "Long comment",
|
|
102
|
+
comment: { author: "Verbose Author", text: longText }
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
});
|
|
106
|
+
rows.push({
|
|
107
|
+
cells: [
|
|
108
|
+
{
|
|
109
|
+
value: "CJK author",
|
|
110
|
+
comment: { author: "\u7530\u4E2D\u592A\u90CE", text: "Comment from Japanese author" }
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
value: "Arabic author",
|
|
114
|
+
comment: { author: "\u0623\u062D\u0645\u062F", text: "Comment from Arabic author" }
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
value: "Emoji author",
|
|
118
|
+
comment: { author: "User \u{1F389}", text: "Comment from emoji author" }
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
});
|
|
122
|
+
rows.push({
|
|
123
|
+
cells: [
|
|
124
|
+
{
|
|
125
|
+
value: "Empty comment",
|
|
126
|
+
comment: { author: "Ghost", text: "" }
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
});
|
|
130
|
+
rows.push({
|
|
131
|
+
cells: [
|
|
132
|
+
{
|
|
133
|
+
value: "Merged cell with comment",
|
|
134
|
+
colSpan: 3,
|
|
135
|
+
comment: { author: "Merger", text: "This cell is merged" }
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
});
|
|
139
|
+
for (let r = 0; r < 24; r++) {
|
|
140
|
+
rows.push({
|
|
141
|
+
cells: [
|
|
142
|
+
{
|
|
143
|
+
value: `Extra ${r + 1}`,
|
|
144
|
+
comment: { author: `Bulk-${r}`, text: `Bulk comment number ${r + 1}` }
|
|
145
|
+
}
|
|
146
|
+
]
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
meta: { title: "Comments Torture Test" },
|
|
151
|
+
sheets: [{ name: "Comments", rows }]
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function createImagesMultiFixture() {
|
|
155
|
+
const images = [
|
|
156
|
+
{
|
|
157
|
+
data: TINY_PNG,
|
|
158
|
+
type: "png",
|
|
159
|
+
anchor: { from: { col: 0, row: 0 } },
|
|
160
|
+
name: "PNG1",
|
|
161
|
+
width: 50,
|
|
162
|
+
height: 50
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
data: TINY_PNG,
|
|
166
|
+
type: "png",
|
|
167
|
+
anchor: { from: { col: 2, row: 0 } },
|
|
168
|
+
name: "PNG2",
|
|
169
|
+
width: 75,
|
|
170
|
+
height: 75
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
data: TINY_JPEG,
|
|
174
|
+
type: "jpeg",
|
|
175
|
+
anchor: { from: { col: 4, row: 0 } },
|
|
176
|
+
name: "JPEG1",
|
|
177
|
+
width: 60,
|
|
178
|
+
height: 40
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
data: TINY_JPEG,
|
|
182
|
+
type: "jpeg",
|
|
183
|
+
anchor: { from: { col: 6, row: 0 } },
|
|
184
|
+
name: "JPEG2",
|
|
185
|
+
width: 80,
|
|
186
|
+
height: 60
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
data: TINY_PNG,
|
|
190
|
+
type: "png",
|
|
191
|
+
anchor: {
|
|
192
|
+
from: { col: 0, row: 5 },
|
|
193
|
+
to: { col: 3, row: 10 }
|
|
194
|
+
},
|
|
195
|
+
name: "PNG-TwoCell",
|
|
196
|
+
width: 200,
|
|
197
|
+
height: 150
|
|
198
|
+
}
|
|
199
|
+
];
|
|
200
|
+
return {
|
|
201
|
+
meta: { title: "Multi-Image Test" },
|
|
202
|
+
sheets: [{
|
|
203
|
+
name: "Images",
|
|
204
|
+
rows: [
|
|
205
|
+
{ cells: [{ value: "Image test sheet" }] }
|
|
206
|
+
],
|
|
207
|
+
images
|
|
208
|
+
}]
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function createChartsAllTypesFixture() {
|
|
212
|
+
const headerRow = {
|
|
213
|
+
cells: [
|
|
214
|
+
{ value: "Category" },
|
|
215
|
+
{ value: "Series A" },
|
|
216
|
+
{ value: "Series B" },
|
|
217
|
+
{ value: "Series C" },
|
|
218
|
+
{ value: "Series D" }
|
|
219
|
+
]
|
|
220
|
+
};
|
|
221
|
+
const dataRows = Array.from({ length: 9 }, (_, i) => ({
|
|
222
|
+
cells: [
|
|
223
|
+
{ value: `Cat ${i + 1}` },
|
|
224
|
+
{ value: 10 + i * 5 },
|
|
225
|
+
{ value: 20 + i * 3 },
|
|
226
|
+
{ value: 15 + i * 7 },
|
|
227
|
+
{ value: 8 + i * 4 }
|
|
228
|
+
]
|
|
229
|
+
}));
|
|
230
|
+
return {
|
|
231
|
+
meta: { title: "All Chart Types" },
|
|
232
|
+
sheets: [{
|
|
233
|
+
name: "ChartData",
|
|
234
|
+
rows: [headerRow, ...dataRows],
|
|
235
|
+
charts: [
|
|
236
|
+
{
|
|
237
|
+
type: "bar",
|
|
238
|
+
title: "Bar Chart",
|
|
239
|
+
series: [
|
|
240
|
+
{ name: "Series A", categories: "ChartData!$A$2:$A$10", values: "ChartData!$B$2:$B$10" },
|
|
241
|
+
{ name: "Series B", categories: "ChartData!$A$2:$A$10", values: "ChartData!$C$2:$C$10" }
|
|
242
|
+
],
|
|
243
|
+
anchor: { from: { col: 6, row: 0 } }
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
type: "col",
|
|
247
|
+
title: "Column Chart",
|
|
248
|
+
series: [
|
|
249
|
+
{ name: "Series C", categories: "ChartData!$A$2:$A$10", values: "ChartData!$D$2:$D$10" },
|
|
250
|
+
{ name: "Series D", categories: "ChartData!$A$2:$A$10", values: "ChartData!$E$2:$E$10" }
|
|
251
|
+
],
|
|
252
|
+
anchor: { from: { col: 6, row: 16 } }
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
type: "line",
|
|
256
|
+
title: "Line Chart",
|
|
257
|
+
series: [
|
|
258
|
+
{ name: "Series A", categories: "ChartData!$A$2:$A$10", values: "ChartData!$B$2:$B$10" },
|
|
259
|
+
{ name: "Series C", categories: "ChartData!$A$2:$A$10", values: "ChartData!$D$2:$D$10" }
|
|
260
|
+
],
|
|
261
|
+
anchor: { from: { col: 6, row: 32 } }
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
type: "pie",
|
|
265
|
+
title: "Pie Chart",
|
|
266
|
+
series: [
|
|
267
|
+
{ name: "Series B", categories: "ChartData!$A$2:$A$10", values: "ChartData!$C$2:$C$10" },
|
|
268
|
+
{ name: "Series D", categories: "ChartData!$A$2:$A$10", values: "ChartData!$E$2:$E$10" }
|
|
269
|
+
],
|
|
270
|
+
anchor: { from: { col: 6, row: 48 } }
|
|
271
|
+
}
|
|
272
|
+
]
|
|
273
|
+
}]
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function createProtectionMatrixFixture() {
|
|
277
|
+
return {
|
|
278
|
+
meta: { title: "Protection Matrix" },
|
|
279
|
+
sheets: [
|
|
280
|
+
{
|
|
281
|
+
name: "FullProtection",
|
|
282
|
+
protection: {
|
|
283
|
+
password: "secret123",
|
|
284
|
+
sheet: true,
|
|
285
|
+
objects: true,
|
|
286
|
+
scenarios: true
|
|
287
|
+
},
|
|
288
|
+
rows: [
|
|
289
|
+
{
|
|
290
|
+
cells: [
|
|
291
|
+
{ value: "Locked cell", style: { protection: { locked: true } } },
|
|
292
|
+
{ value: "Hidden formula", style: { protection: { locked: true, hidden: true } } }
|
|
293
|
+
]
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
cells: [
|
|
297
|
+
{ value: "Also locked" },
|
|
298
|
+
{ value: 42 }
|
|
299
|
+
]
|
|
300
|
+
}
|
|
301
|
+
]
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: "SelectivePerms",
|
|
305
|
+
protection: {
|
|
306
|
+
sheet: true,
|
|
307
|
+
formatCells: false,
|
|
308
|
+
// allowed (false = not disallowed)
|
|
309
|
+
insertRows: true,
|
|
310
|
+
// blocked
|
|
311
|
+
deleteRows: true,
|
|
312
|
+
// blocked
|
|
313
|
+
sort: true
|
|
314
|
+
// blocked
|
|
315
|
+
},
|
|
316
|
+
rows: [
|
|
317
|
+
{
|
|
318
|
+
cells: [
|
|
319
|
+
{ value: "Format allowed" },
|
|
320
|
+
{ value: "Insert blocked" }
|
|
321
|
+
]
|
|
322
|
+
}
|
|
323
|
+
]
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
name: "NoPassword",
|
|
327
|
+
protection: {
|
|
328
|
+
sheet: true
|
|
329
|
+
},
|
|
330
|
+
rows: [
|
|
331
|
+
{
|
|
332
|
+
cells: [
|
|
333
|
+
{ value: "Protected without password" },
|
|
334
|
+
{ value: "Still locked" }
|
|
335
|
+
]
|
|
336
|
+
}
|
|
337
|
+
]
|
|
338
|
+
}
|
|
339
|
+
]
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function createKitchenSinkFixture() {
|
|
343
|
+
return {
|
|
344
|
+
meta: {
|
|
345
|
+
title: "Kitchen Sink Stress Test",
|
|
346
|
+
creator: "PaperJSX Chaos Lab"
|
|
347
|
+
},
|
|
348
|
+
namedRanges: [
|
|
349
|
+
{ name: "DataRange", ref: "Main!$A$1:$J$20" },
|
|
350
|
+
{ name: "LookupCol", ref: "Lookup!$A$1:$B$5" }
|
|
351
|
+
],
|
|
352
|
+
sheets: [
|
|
353
|
+
{
|
|
354
|
+
name: "Main",
|
|
355
|
+
freezePane: { row: 1, col: 1 },
|
|
356
|
+
conditionalFormatting: [
|
|
357
|
+
{
|
|
358
|
+
ref: "B2:B20",
|
|
359
|
+
rules: [
|
|
360
|
+
{
|
|
361
|
+
type: "cellIs",
|
|
362
|
+
operator: "greaterThan",
|
|
363
|
+
formula: "100",
|
|
364
|
+
style: { fill: { color: "#C6EFCE" } }
|
|
365
|
+
}
|
|
366
|
+
]
|
|
367
|
+
}
|
|
368
|
+
],
|
|
369
|
+
dataValidations: [
|
|
370
|
+
{
|
|
371
|
+
ref: "D2:D20",
|
|
372
|
+
type: "list",
|
|
373
|
+
formula1: '"Active,Inactive,Pending"',
|
|
374
|
+
allowBlank: true
|
|
375
|
+
}
|
|
376
|
+
],
|
|
377
|
+
tables: [
|
|
378
|
+
{
|
|
379
|
+
name: "MainTable",
|
|
380
|
+
ref: "A1:J5",
|
|
381
|
+
columns: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}],
|
|
382
|
+
style: { name: "TableStyleMedium2" }
|
|
383
|
+
}
|
|
384
|
+
],
|
|
385
|
+
images: [
|
|
386
|
+
{
|
|
387
|
+
data: TINY_PNG,
|
|
388
|
+
type: "png",
|
|
389
|
+
anchor: { from: { col: 11, row: 0 } },
|
|
390
|
+
name: "Logo",
|
|
391
|
+
width: 100,
|
|
392
|
+
height: 50
|
|
393
|
+
}
|
|
394
|
+
],
|
|
395
|
+
charts: [
|
|
396
|
+
{
|
|
397
|
+
type: "col",
|
|
398
|
+
title: "Revenue Overview",
|
|
399
|
+
series: [
|
|
400
|
+
{ name: "Revenue", categories: "Main!$A$2:$A$5", values: "Main!$B$2:$B$5" }
|
|
401
|
+
],
|
|
402
|
+
anchor: { from: { col: 11, row: 6 } }
|
|
403
|
+
}
|
|
404
|
+
],
|
|
405
|
+
rows: [
|
|
406
|
+
{
|
|
407
|
+
cells: [
|
|
408
|
+
{ value: "Name", style: "header" },
|
|
409
|
+
{ value: "Revenue", style: "header" },
|
|
410
|
+
{ value: "Growth", style: "header" },
|
|
411
|
+
{ value: "Status", style: "header" },
|
|
412
|
+
{ value: "Joined", style: "header" },
|
|
413
|
+
{ value: "Lookup", style: "header" },
|
|
414
|
+
{ value: "DateCalc", style: "header" },
|
|
415
|
+
{ value: "TextOp", style: "header" },
|
|
416
|
+
{ value: "TrimOp", style: "header" },
|
|
417
|
+
{ value: "Link", style: "header" }
|
|
418
|
+
]
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
cells: [
|
|
422
|
+
{ value: "Acme Corp" },
|
|
423
|
+
{ value: 15e4, style: "currency" },
|
|
424
|
+
{ value: 0.24, style: "percentage" },
|
|
425
|
+
{ value: "Active" },
|
|
426
|
+
{ value: new Date(Date.UTC(2024, 5, 15)), style: "date" },
|
|
427
|
+
{ formula: { expression: "VLOOKUP(A2,Lookup!$A$1:$B$5,2,FALSE)", cachedValue: 100 } },
|
|
428
|
+
{ formula: { expression: "DATE(2024,6,15)", cachedValue: 45458 } },
|
|
429
|
+
{ formula: { expression: 'CONCATENATE("Hello"," ","World")', cachedValue: "Hello World" } },
|
|
430
|
+
{ formula: { expression: 'TRIM(" spaced out ")', cachedValue: "spaced out" } },
|
|
431
|
+
{
|
|
432
|
+
value: "Website",
|
|
433
|
+
hyperlink: { target: "https://example.com", tooltip: "Visit site" }
|
|
434
|
+
}
|
|
435
|
+
]
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
cells: [
|
|
439
|
+
{
|
|
440
|
+
value: "Beta Inc",
|
|
441
|
+
comment: { author: "Reviewer", text: "Key account - handle with care" }
|
|
442
|
+
},
|
|
443
|
+
{ value: 22e4, style: "currency" },
|
|
444
|
+
{ value: 0.31, style: "percentage" },
|
|
445
|
+
{ value: "Active" },
|
|
446
|
+
{ value: new Date(Date.UTC(2023, 0, 10)), style: "date" },
|
|
447
|
+
{ formula: { expression: "VLOOKUP(A3,Lookup!$A$1:$B$5,2,FALSE)", cachedValue: 200 } },
|
|
448
|
+
{ formula: { expression: "DATE(2023,1,10)", cachedValue: 44936 } },
|
|
449
|
+
{ formula: { expression: 'TEXT(B3,"$#,##0")', cachedValue: "$220,000" } },
|
|
450
|
+
{ formula: { expression: 'TRIM(" beta test ")', cachedValue: "beta test" } },
|
|
451
|
+
{
|
|
452
|
+
value: "Jump to Lookup",
|
|
453
|
+
hyperlink: { location: "Lookup!A1", display: "Lookup sheet" }
|
|
454
|
+
}
|
|
455
|
+
]
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
cells: [
|
|
459
|
+
{ value: "Gamma Ltd" },
|
|
460
|
+
{ value: null },
|
|
461
|
+
{ value: "Pending" },
|
|
462
|
+
{ value: new Date(Date.UTC(2025, 11, 1)), style: "date" },
|
|
463
|
+
{ value: null },
|
|
464
|
+
{ value: null },
|
|
465
|
+
{ value: null },
|
|
466
|
+
{ value: null },
|
|
467
|
+
{ value: null }
|
|
468
|
+
]
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
cells: [
|
|
472
|
+
{ value: "Delta Co" },
|
|
473
|
+
{ value: 95e3, style: "currency" },
|
|
474
|
+
{ value: -0.05, style: "percentage" },
|
|
475
|
+
{ value: "Inactive" },
|
|
476
|
+
{ value: new Date(Date.UTC(2022, 6, 20)), style: "date" },
|
|
477
|
+
{ value: null },
|
|
478
|
+
{ value: null },
|
|
479
|
+
{
|
|
480
|
+
value: [
|
|
481
|
+
{ text: "Rich ", font: { bold: true } },
|
|
482
|
+
{ text: "text ", font: { italic: true, color: "#FF0000" } },
|
|
483
|
+
{ text: "content" }
|
|
484
|
+
]
|
|
485
|
+
},
|
|
486
|
+
{ value: null },
|
|
487
|
+
{ value: null }
|
|
488
|
+
]
|
|
489
|
+
}
|
|
490
|
+
]
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
name: "Lookup",
|
|
494
|
+
rows: [
|
|
495
|
+
{ cells: [{ value: "Acme Corp" }, { value: 100 }] },
|
|
496
|
+
{ cells: [{ value: "Beta Inc" }, { value: 200 }] },
|
|
497
|
+
{ cells: [{ value: "Gamma Ltd" }, { value: 300 }] },
|
|
498
|
+
{ cells: [{ value: "Delta Co" }, { value: 400 }] },
|
|
499
|
+
{ cells: [{ value: "Epsilon SA" }, { value: 500 }] }
|
|
500
|
+
]
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
name: "Hidden",
|
|
504
|
+
state: "hidden",
|
|
505
|
+
rows: [
|
|
506
|
+
{ cells: [{ value: "This sheet is hidden" }] }
|
|
507
|
+
]
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
name: "Protected",
|
|
511
|
+
protection: {
|
|
512
|
+
sheet: true,
|
|
513
|
+
password: "lock"
|
|
514
|
+
},
|
|
515
|
+
rows: [
|
|
516
|
+
{ cells: [{ value: "This sheet is protected" }] }
|
|
517
|
+
]
|
|
518
|
+
}
|
|
519
|
+
]
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function createStreamingStressFixture() {
|
|
523
|
+
const rows = [];
|
|
524
|
+
for (let r = 0; r < 1e4; r++) {
|
|
525
|
+
const cells = [];
|
|
526
|
+
for (let c = 0; c < 9; c++) {
|
|
527
|
+
const cell = {
|
|
528
|
+
value: r * 10 + c
|
|
529
|
+
};
|
|
530
|
+
if (r % 2 === 0) {
|
|
531
|
+
cell.style = { fill: { color: "#F2F2F2" } };
|
|
532
|
+
}
|
|
533
|
+
cells.push(cell);
|
|
534
|
+
}
|
|
535
|
+
cells.push({
|
|
536
|
+
formula: `SUM(A${r + 1}:I${r + 1})`
|
|
537
|
+
});
|
|
538
|
+
if (r % 100 === 0) {
|
|
539
|
+
cells[0] = {
|
|
540
|
+
...cells[0],
|
|
541
|
+
comment: { author: "System", text: `Checkpoint row ${r + 1}` }
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
rows.push({ cells });
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
meta: { title: "Streaming Stress Test" },
|
|
548
|
+
sheets: [{
|
|
549
|
+
name: "StressData",
|
|
550
|
+
rows,
|
|
551
|
+
charts: [
|
|
552
|
+
{
|
|
553
|
+
type: "line",
|
|
554
|
+
title: "Column A Trend",
|
|
555
|
+
series: [
|
|
556
|
+
{ name: "Values", values: "StressData!$A$1:$A$100" }
|
|
557
|
+
],
|
|
558
|
+
anchor: { from: { col: 11, row: 0 } }
|
|
559
|
+
}
|
|
560
|
+
]
|
|
561
|
+
}]
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
var phase5Fixtures = [
|
|
565
|
+
{
|
|
566
|
+
name: "phase5-comments-torture",
|
|
567
|
+
description: "50+ comments with XML-hostile text, Unicode authors, empty text, and merged cells",
|
|
568
|
+
document: createCommentsTortureFixture()
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
name: "phase5-images-multi",
|
|
572
|
+
description: "5 images: 2 PNG, 2 JPEG, 1 PNG with twoCellAnchor",
|
|
573
|
+
document: createImagesMultiFixture()
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
name: "phase5-charts-all-types",
|
|
577
|
+
description: "4 chart types (bar, col, line, pie) with 2 series each",
|
|
578
|
+
document: createChartsAllTypesFixture()
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
name: "phase5-protection-matrix",
|
|
582
|
+
description: "3 sheets: full protection, selective permissions, no-password protection",
|
|
583
|
+
document: createProtectionMatrixFixture()
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
name: "phase5-kitchen-sink",
|
|
587
|
+
description: "All features combined: comments, images, charts, protection, formulas, tables, validation, rich text, hyperlinks, freeze panes, named ranges, merged cells, hidden sheets",
|
|
588
|
+
document: createKitchenSinkFixture()
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
name: "phase5-streaming-stress",
|
|
592
|
+
description: "10K rows x 10 cols with comments every 100 rows, formulas, alternating styles, and chart",
|
|
593
|
+
document: createStreamingStressFixture()
|
|
594
|
+
}
|
|
595
|
+
];
|
|
596
|
+
|
|
597
|
+
// src/chaos-lab/index.ts
|
|
598
|
+
function createChaosContext(options = {}) {
|
|
599
|
+
const mode = options.mode ?? "free";
|
|
600
|
+
return {
|
|
601
|
+
engine: options.engine ?? SpreadsheetEngine,
|
|
602
|
+
mode,
|
|
603
|
+
metadata: {
|
|
604
|
+
mode,
|
|
605
|
+
buildType: options.buildType ?? "source",
|
|
606
|
+
packageName: options.packageName ?? (mode === "pro" ? "@paperjsx/json-to-xlsx-pro" : "@paperjsx/json-to-xlsx"),
|
|
607
|
+
keyPresent: options.keyPresent ?? Boolean(process.env.PAPERJSX_LICENSE_KEY),
|
|
608
|
+
gitSha: options.gitSha,
|
|
609
|
+
compatibilityOracleAvailable: options.compatibilityOracleAvailable ?? false
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
function blockedProOutcome(feature) {
|
|
614
|
+
return {
|
|
615
|
+
status: "blocked",
|
|
616
|
+
observed: `Requires @paperjsx/json-to-xlsx-pro with PAPERJSX_LICENSE_KEY for ${feature}`,
|
|
617
|
+
notes: "This is intentionally blocked on the free surface and should not count as a failure."
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
function summarize(results) {
|
|
621
|
+
return results.reduce((summary, result) => {
|
|
622
|
+
summary.total += 1;
|
|
623
|
+
if (result.status === "pass") summary.passed += 1;
|
|
624
|
+
if (result.status === "warn") summary.warned += 1;
|
|
625
|
+
if (result.status === "fail") summary.failed += 1;
|
|
626
|
+
if (result.status === "blocked") summary.blocked += 1;
|
|
627
|
+
return summary;
|
|
628
|
+
}, {
|
|
629
|
+
total: 0,
|
|
630
|
+
passed: 0,
|
|
631
|
+
warned: 0,
|
|
632
|
+
failed: 0,
|
|
633
|
+
blocked: 0
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
async function runScenario(context, id, bucket, category, name, expected, operation, freeOperation, freeExpected) {
|
|
637
|
+
const start = performance.now();
|
|
638
|
+
try {
|
|
639
|
+
const activeOperation = context.mode === "free" && bucket === "pro-only" ? void 0 : context.mode === "free" && freeOperation ? freeOperation : operation;
|
|
640
|
+
const outcome = activeOperation ? await activeOperation() : blockedProOutcome(name);
|
|
641
|
+
return {
|
|
642
|
+
id,
|
|
643
|
+
tier: context.mode,
|
|
644
|
+
bucket,
|
|
645
|
+
category,
|
|
646
|
+
name,
|
|
647
|
+
expected: context.mode === "free" && freeExpected ? freeExpected : expected,
|
|
648
|
+
durationMs: performance.now() - start,
|
|
649
|
+
...outcome
|
|
650
|
+
};
|
|
651
|
+
} catch (error) {
|
|
652
|
+
return {
|
|
653
|
+
id,
|
|
654
|
+
tier: context.mode,
|
|
655
|
+
bucket,
|
|
656
|
+
category,
|
|
657
|
+
name,
|
|
658
|
+
expected: context.mode === "free" && freeExpected ? freeExpected : expected,
|
|
659
|
+
status: "fail",
|
|
660
|
+
observed: error instanceof Error ? error.message : String(error),
|
|
661
|
+
durationMs: performance.now() - start
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
async function collectStream(stream) {
|
|
666
|
+
return new Promise((resolve, reject) => {
|
|
667
|
+
const chunks = [];
|
|
668
|
+
stream.on("data", (chunk) => {
|
|
669
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
670
|
+
});
|
|
671
|
+
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
672
|
+
stream.on("error", reject);
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
async function readZipEntry(buffer, path) {
|
|
676
|
+
const zip = await JSZip.loadAsync(buffer);
|
|
677
|
+
const file = zip.file(path);
|
|
678
|
+
if (!file) {
|
|
679
|
+
throw new Error(`Missing ZIP entry: ${path}`);
|
|
680
|
+
}
|
|
681
|
+
return file.async("string");
|
|
682
|
+
}
|
|
683
|
+
function validateBuffer(buffer) {
|
|
684
|
+
return validateSpreadsheetBuffer(buffer);
|
|
685
|
+
}
|
|
686
|
+
function codes(summary) {
|
|
687
|
+
return summary.findings.map((finding) => finding.code);
|
|
688
|
+
}
|
|
689
|
+
async function renderAndValidate(engine, document) {
|
|
690
|
+
const buffer = await engine.render(document);
|
|
691
|
+
const structural = await validateXlsxStructure(buffer);
|
|
692
|
+
const validation = await validateBuffer(buffer);
|
|
693
|
+
return { buffer, structural, validation };
|
|
694
|
+
}
|
|
695
|
+
function passOrFail(passed, observed, notes) {
|
|
696
|
+
return {
|
|
697
|
+
status: passed ? "pass" : "fail",
|
|
698
|
+
observed,
|
|
699
|
+
notes
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
function hasCodes(summary, required) {
|
|
703
|
+
const findingCodes = new Set(codes(summary));
|
|
704
|
+
return required.every((code) => findingCodes.has(code));
|
|
705
|
+
}
|
|
706
|
+
async function runTemplateRowExpansionSemanticCheck(engine) {
|
|
707
|
+
const templateBuffer = await engine.render(createTemplateBenchmarkDocument());
|
|
708
|
+
const index = await engine.parseTemplate(templateBuffer);
|
|
709
|
+
const assembled = await engine.assembleFromTemplate(index, {
|
|
710
|
+
namedRanges: {
|
|
711
|
+
InvoiceHeader: "Chaos Corp"
|
|
712
|
+
},
|
|
713
|
+
rowExpansions: {
|
|
714
|
+
LineItems: {
|
|
715
|
+
rows: [
|
|
716
|
+
["Starter", 1, 10, void 0],
|
|
717
|
+
["Growth", 2, 25, void 0],
|
|
718
|
+
["Enterprise", 1, 80, void 0]
|
|
719
|
+
]
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
const validation = await validateBuffer(assembled);
|
|
724
|
+
const sheetXml = await readZipEntry(assembled, "xl/worksheets/sheet1.xml");
|
|
725
|
+
const tableXml = await readZipEntry(assembled, "xl/tables/table1.xml");
|
|
726
|
+
const rowFormulaRefsPresent = ["B4*C4", "B5*C5", "B6*C6"].every((formula) => sheetXml.includes(`<f>${formula}</f>`));
|
|
727
|
+
const tableShifted = tableXml.includes('ref="A3:D6"');
|
|
728
|
+
const totalExpanded = sheetXml.includes("<f>SUM(D4:D6)</f>");
|
|
729
|
+
return {
|
|
730
|
+
status: validation.verdict === "errors" || !rowFormulaRefsPresent || !tableShifted || !totalExpanded ? "fail" : "pass",
|
|
731
|
+
observed: `verdict ${validation.verdict}; row formulas ${rowFormulaRefsPresent ? "ok" : "missing"}; table ref ${tableShifted ? "shifted" : "stale"}; grand total ${totalExpanded ? "expanded" : "stale"}`,
|
|
732
|
+
notes: totalExpanded ? void 0 : "The row expansion path still does not fully prove downstream summary formulas expand over the newly inserted rows."
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
async function runRepairLoopConvergence(engine) {
|
|
736
|
+
const corrupt = await createRepairableCorruptionBuffer();
|
|
737
|
+
const firstPass = await engine.validateAndRepair(corrupt);
|
|
738
|
+
const secondPass = await engine.validateAndRepair(firstPass.repair.buffer);
|
|
739
|
+
const converged = secondPass.repair.actions.length === 0 && secondPass.repaired.verdict !== "errors";
|
|
740
|
+
return {
|
|
741
|
+
status: converged ? "pass" : "warn",
|
|
742
|
+
observed: `first repair actions ${firstPass.repair.actions.length}; second repair actions ${secondPass.repair.actions.length}; second verdict ${secondPass.repaired.verdict}`,
|
|
743
|
+
notes: converged ? void 0 : "Repair requires more than one pass or leaves residual warnings."
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
async function runXlsxChaosLab(options = {}) {
|
|
747
|
+
const context = createChaosContext(options);
|
|
748
|
+
const { engine } = context;
|
|
749
|
+
const formulaFixture = phase3Fixtures.find((fixture) => fixture.name === "phase3-formulas");
|
|
750
|
+
const tableFixture = phase4Fixtures.find((fixture) => fixture.name === "phase4-native-table");
|
|
751
|
+
if (!formulaFixture || !tableFixture) {
|
|
752
|
+
throw new Error("Required phase3/phase4 fixtures are unavailable.");
|
|
753
|
+
}
|
|
754
|
+
const results = [
|
|
755
|
+
await runScenario(
|
|
756
|
+
context,
|
|
757
|
+
"CH-001",
|
|
758
|
+
"free-safe",
|
|
759
|
+
"render",
|
|
760
|
+
"Unicode torture render",
|
|
761
|
+
"Unicode-heavy workbook renders structurally clean",
|
|
762
|
+
async () => {
|
|
763
|
+
const rendered = await renderAndValidate(engine, getPhase1Fixture("strings-unicode").document);
|
|
764
|
+
return passOrFail(
|
|
765
|
+
rendered.structural.passed && rendered.validation.verdict === "clean",
|
|
766
|
+
`structural ${rendered.structural.passed ? "pass" : "fail"}; verdict ${rendered.validation.verdict}`
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
),
|
|
770
|
+
await runScenario(
|
|
771
|
+
context,
|
|
772
|
+
"CH-002",
|
|
773
|
+
"free-safe",
|
|
774
|
+
"render",
|
|
775
|
+
"Hostile XML string render",
|
|
776
|
+
"XML-hostile input strings sanitize cleanly",
|
|
777
|
+
async () => {
|
|
778
|
+
const rendered = await renderAndValidate(engine, getPhase1Fixture("strings-xml-hostile").document);
|
|
779
|
+
return passOrFail(
|
|
780
|
+
rendered.structural.passed && rendered.validation.verdict === "clean",
|
|
781
|
+
`structural ${rendered.structural.passed ? "pass" : "fail"}; verdict ${rendered.validation.verdict}`
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
),
|
|
785
|
+
await runScenario(
|
|
786
|
+
context,
|
|
787
|
+
"CH-003",
|
|
788
|
+
"free-safe",
|
|
789
|
+
"render",
|
|
790
|
+
"Formula workbook render",
|
|
791
|
+
"Formula-heavy workbook renders structurally clean",
|
|
792
|
+
async () => {
|
|
793
|
+
const rendered = await renderAndValidate(engine, formulaFixture.document);
|
|
794
|
+
return passOrFail(
|
|
795
|
+
rendered.structural.passed && rendered.validation.verdict !== "errors",
|
|
796
|
+
`structural ${rendered.structural.passed ? "pass" : "fail"}; verdict ${rendered.validation.verdict}`,
|
|
797
|
+
rendered.validation.findings.length > 0 ? codes(rendered.validation).join(", ") : void 0
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
),
|
|
801
|
+
await runScenario(
|
|
802
|
+
context,
|
|
803
|
+
"CH-004",
|
|
804
|
+
"free-safe",
|
|
805
|
+
"render",
|
|
806
|
+
"Native table workbook render",
|
|
807
|
+
"Table workbook emits valid OOXML table parts",
|
|
808
|
+
async () => {
|
|
809
|
+
const rendered = await renderAndValidate(engine, tableFixture.document);
|
|
810
|
+
const zip = await JSZip.loadAsync(rendered.buffer);
|
|
811
|
+
const tableExists = Boolean(zip.file("xl/tables/table1.xml"));
|
|
812
|
+
return passOrFail(
|
|
813
|
+
rendered.structural.passed && rendered.validation.verdict !== "errors" && tableExists,
|
|
814
|
+
`structural ${rendered.structural.passed ? "pass" : "fail"}; verdict ${rendered.validation.verdict}; table part ${tableExists ? "present" : "missing"}`,
|
|
815
|
+
rendered.validation.findings.length > 0 ? codes(rendered.validation).join(", ") : void 0
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
),
|
|
819
|
+
await runScenario(
|
|
820
|
+
context,
|
|
821
|
+
"CH-005",
|
|
822
|
+
"free-safe",
|
|
823
|
+
"render",
|
|
824
|
+
"Deterministic render replay",
|
|
825
|
+
"The determinism fixture renders byte-identically on repeated runs",
|
|
826
|
+
async () => {
|
|
827
|
+
const fixture = getPhase1Fixture("determinism-seed");
|
|
828
|
+
const [first, second] = await Promise.all([
|
|
829
|
+
engine.render(fixture.document),
|
|
830
|
+
engine.render(fixture.document)
|
|
831
|
+
]);
|
|
832
|
+
return passOrFail(
|
|
833
|
+
Buffer.compare(first, second) === 0,
|
|
834
|
+
Buffer.compare(first, second) === 0 ? "buffers identical" : "buffers differ"
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
),
|
|
838
|
+
await runScenario(
|
|
839
|
+
context,
|
|
840
|
+
"CH-006",
|
|
841
|
+
"pro-only",
|
|
842
|
+
"operational",
|
|
843
|
+
"Preflight stream recommendation",
|
|
844
|
+
"Large workbooks are flagged as stream workloads",
|
|
845
|
+
async () => {
|
|
846
|
+
const report = engine.preflight(getPhase1Fixture("large-100k").document, { largeDataset: true });
|
|
847
|
+
const ok = report.recommendedRenderMode === "stream" && report.findings.some((finding) => finding.code === "STREAM_MODE_RECOMMENDED");
|
|
848
|
+
return passOrFail(ok, `mode ${report.recommendedRenderMode}; findings ${report.findings.map((finding) => finding.code).join(", ") || "none"}`);
|
|
849
|
+
}
|
|
850
|
+
),
|
|
851
|
+
await runScenario(
|
|
852
|
+
context,
|
|
853
|
+
"CH-007",
|
|
854
|
+
"pro-only",
|
|
855
|
+
"repair",
|
|
856
|
+
"Missing content type repair",
|
|
857
|
+
"Missing content type overrides are detected and repaired",
|
|
858
|
+
async () => {
|
|
859
|
+
const corrupt = await createMissingContentTypeBuffer();
|
|
860
|
+
const original = await validateBuffer(corrupt);
|
|
861
|
+
const repaired = await engine.validateAndRepair(corrupt);
|
|
862
|
+
return passOrFail(
|
|
863
|
+
hasCodes(original, ["MISSING_CONTENT_TYPE"]) && repaired.repaired.verdict !== "errors",
|
|
864
|
+
`original ${codes(original).join(", ") || "none"}; repaired verdict ${repaired.repaired.verdict}`
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
),
|
|
868
|
+
await runScenario(
|
|
869
|
+
context,
|
|
870
|
+
"CH-008",
|
|
871
|
+
"pro-only",
|
|
872
|
+
"repair",
|
|
873
|
+
"Orphan relationship repair",
|
|
874
|
+
"Broken worksheet relationships are detected and repaired",
|
|
875
|
+
async () => {
|
|
876
|
+
const corrupt = await createOrphanRelationshipBuffer();
|
|
877
|
+
const original = await validateBuffer(corrupt);
|
|
878
|
+
const repaired = await engine.validateAndRepair(corrupt);
|
|
879
|
+
return passOrFail(
|
|
880
|
+
hasCodes(original, ["BROKEN_TABLE_RELATIONSHIP"]) && repaired.repaired.verdict !== "errors",
|
|
881
|
+
`original ${codes(original).join(", ") || "none"}; repaired verdict ${repaired.repaired.verdict}`
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
),
|
|
885
|
+
await runScenario(
|
|
886
|
+
context,
|
|
887
|
+
"CH-009",
|
|
888
|
+
"pro-only",
|
|
889
|
+
"repair",
|
|
890
|
+
"Style index recovery",
|
|
891
|
+
"Out-of-range style indices clamp back to a safe default",
|
|
892
|
+
async () => {
|
|
893
|
+
const corrupt = await createStyleIndexOobBuffer();
|
|
894
|
+
const original = await validateBuffer(corrupt);
|
|
895
|
+
const repaired = await engine.validateAndRepair(corrupt);
|
|
896
|
+
return passOrFail(
|
|
897
|
+
hasCodes(original, ["STYLE_INDEX_OOB"]) && repaired.repaired.verdict !== "errors",
|
|
898
|
+
`original ${codes(original).join(", ") || "none"}; repaired verdict ${repaired.repaired.verdict}`
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
),
|
|
902
|
+
await runScenario(
|
|
903
|
+
context,
|
|
904
|
+
"CH-010",
|
|
905
|
+
"pro-only",
|
|
906
|
+
"repair",
|
|
907
|
+
"Hyperlink and validation range repair",
|
|
908
|
+
"Invalid hyperlink refs and data-validation ranges are repaired",
|
|
909
|
+
async () => {
|
|
910
|
+
const corrupt = await createHyperlinkValidationCorruptionBuffer();
|
|
911
|
+
const original = await validateBuffer(corrupt);
|
|
912
|
+
const repaired = await engine.validateAndRepair(corrupt);
|
|
913
|
+
return passOrFail(
|
|
914
|
+
hasCodes(original, ["HYPERLINK_TARGET_INVALID", "INVALID_RANGE_REF"]) && repaired.repaired.verdict !== "errors",
|
|
915
|
+
`original ${codes(original).join(", ") || "none"}; repaired verdict ${repaired.repaired.verdict}`
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
),
|
|
919
|
+
await runScenario(
|
|
920
|
+
context,
|
|
921
|
+
"CH-011",
|
|
922
|
+
"pro-only",
|
|
923
|
+
"repair",
|
|
924
|
+
"Merge and defined-name repair",
|
|
925
|
+
"Overlapping merges and invalid defined names are repaired",
|
|
926
|
+
async () => {
|
|
927
|
+
const corrupt = await createMergeDefinedNameCorruptionBuffer();
|
|
928
|
+
const original = await validateBuffer(corrupt);
|
|
929
|
+
const repaired = await engine.validateAndRepair(corrupt);
|
|
930
|
+
return passOrFail(
|
|
931
|
+
hasCodes(original, ["MERGE_OVERLAP", "DEFINED_NAME_INVALID"]) && repaired.repaired.verdict !== "errors",
|
|
932
|
+
`original ${codes(original).join(", ") || "none"}; repaired verdict ${repaired.repaired.verdict}`
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
),
|
|
936
|
+
await runScenario(
|
|
937
|
+
context,
|
|
938
|
+
"CH-012",
|
|
939
|
+
"pro-only",
|
|
940
|
+
"repair",
|
|
941
|
+
"Duplicate table repair",
|
|
942
|
+
"Duplicate table names and invalid refs normalize cleanly",
|
|
943
|
+
async () => {
|
|
944
|
+
const corrupt = await createDuplicateTableCorruptionBuffer();
|
|
945
|
+
const original = await validateBuffer(corrupt);
|
|
946
|
+
const repaired = await engine.validateAndRepair(corrupt);
|
|
947
|
+
return passOrFail(
|
|
948
|
+
hasCodes(original, ["DUPLICATE_TABLE_NAME", "INVALID_TABLE_REF"]) && repaired.repaired.verdict !== "errors",
|
|
949
|
+
`original ${codes(original).join(", ") || "none"}; repaired verdict ${repaired.repaired.verdict}`
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
),
|
|
953
|
+
await runScenario(
|
|
954
|
+
context,
|
|
955
|
+
"CH-013",
|
|
956
|
+
"pro-only",
|
|
957
|
+
"repair",
|
|
958
|
+
"Shared string index recovery",
|
|
959
|
+
"Out-of-range shared string refs repair back to a usable workbook",
|
|
960
|
+
async () => {
|
|
961
|
+
const sharedStringBase = await engine.render(getPhase1Fixture("strings-unicode").document);
|
|
962
|
+
const corrupt = await createSharedStringIndexCorruptionBuffer(sharedStringBase);
|
|
963
|
+
const original = await validateBuffer(corrupt);
|
|
964
|
+
const repaired = await engine.validateAndRepair(corrupt);
|
|
965
|
+
return {
|
|
966
|
+
status: hasCodes(original, ["SHARED_STRING_INDEX_OOB"]) && repaired.repaired.verdict !== "errors" ? "pass" : "fail",
|
|
967
|
+
observed: `original ${codes(original).join(", ") || "none"}; repaired verdict ${repaired.repaired.verdict}`,
|
|
968
|
+
notes: repaired.repaired.verdict === "errors" ? "Shared string index recovery is still a real repair gap." : void 0
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
),
|
|
972
|
+
await runScenario(
|
|
973
|
+
context,
|
|
974
|
+
"CH-014",
|
|
975
|
+
"pro-only",
|
|
976
|
+
"template",
|
|
977
|
+
"Template direct injection",
|
|
978
|
+
"Named-range and direct-cell injection stays structurally valid",
|
|
979
|
+
async () => {
|
|
980
|
+
const templateBuffer = await engine.render(createTemplateBenchmarkDocument());
|
|
981
|
+
const index = await engine.parseTemplate(templateBuffer);
|
|
982
|
+
const assembled = await engine.assembleFromTemplate(index, {
|
|
983
|
+
namedRanges: {
|
|
984
|
+
InvoiceHeader: "Chaos Corp"
|
|
985
|
+
},
|
|
986
|
+
cells: {
|
|
987
|
+
Invoice: {
|
|
988
|
+
B2: new Date(Date.UTC(2026, 3, 5))
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
const structural = await validateXlsxStructure(assembled);
|
|
993
|
+
const validation = await validateBuffer(assembled);
|
|
994
|
+
return passOrFail(
|
|
995
|
+
structural.passed && validation.verdict !== "errors",
|
|
996
|
+
`structural ${structural.passed ? "pass" : "fail"}; verdict ${validation.verdict}`
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
),
|
|
1000
|
+
await runScenario(
|
|
1001
|
+
context,
|
|
1002
|
+
"CH-015",
|
|
1003
|
+
"pro-only",
|
|
1004
|
+
"template",
|
|
1005
|
+
"Template row expansion semantics",
|
|
1006
|
+
"Row expansion updates copied formulas, table refs, and downstream totals",
|
|
1007
|
+
() => runTemplateRowExpansionSemanticCheck(engine)
|
|
1008
|
+
),
|
|
1009
|
+
await runScenario(
|
|
1010
|
+
context,
|
|
1011
|
+
"CH-016",
|
|
1012
|
+
"pro-only",
|
|
1013
|
+
"repair",
|
|
1014
|
+
"Repair loop convergence",
|
|
1015
|
+
"Repair converges in a single pass on the repairable corpus",
|
|
1016
|
+
() => runRepairLoopConvergence(engine)
|
|
1017
|
+
),
|
|
1018
|
+
await runScenario(
|
|
1019
|
+
context,
|
|
1020
|
+
"CH-017",
|
|
1021
|
+
"free-safe",
|
|
1022
|
+
"operational",
|
|
1023
|
+
"Stream render path availability",
|
|
1024
|
+
"A real stream render API exists for large-dataset workloads",
|
|
1025
|
+
async () => ({
|
|
1026
|
+
status: typeof engine.renderStream === "function" ? "pass" : "fail",
|
|
1027
|
+
observed: typeof engine.renderStream === "function" ? "SpreadsheetEngine.renderStream is available" : "SpreadsheetEngine.renderStream is missing",
|
|
1028
|
+
notes: typeof engine.renderStream === "function" ? void 0 : "Preflight already recommends stream mode for large workbooks, but the public stream render path still does not exist."
|
|
1029
|
+
})
|
|
1030
|
+
),
|
|
1031
|
+
await runScenario(
|
|
1032
|
+
context,
|
|
1033
|
+
"CH-018",
|
|
1034
|
+
"shared",
|
|
1035
|
+
"compatibility",
|
|
1036
|
+
"Cross-app compatibility matrix",
|
|
1037
|
+
"Structural proxy: content types, shared strings, styles, formulas, and table refs are valid for Excel/Sheets/Numbers/LibreOffice",
|
|
1038
|
+
async () => {
|
|
1039
|
+
const fixture = phase3Fixtures.find((f) => f.name === "phase3-formulas") ?? phase3Fixtures[0];
|
|
1040
|
+
const rendered = await renderAndValidate(engine, fixture.document);
|
|
1041
|
+
const issues = [];
|
|
1042
|
+
const contentTypesXml = await readZipEntry(rendered.buffer, "[Content_Types].xml");
|
|
1043
|
+
if (!contentTypesXml.includes("spreadsheetml.sheet.main")) {
|
|
1044
|
+
issues.push("Missing workbook content type");
|
|
1045
|
+
}
|
|
1046
|
+
if (!contentTypesXml.includes("spreadsheetml.worksheet")) {
|
|
1047
|
+
issues.push("Missing worksheet content type");
|
|
1048
|
+
}
|
|
1049
|
+
try {
|
|
1050
|
+
const sharedStrings = await readZipEntry(rendered.buffer, "xl/sharedStrings.xml");
|
|
1051
|
+
if (sharedStrings && !sharedStrings.includes("<sst")) {
|
|
1052
|
+
issues.push("Shared strings missing <sst> root");
|
|
1053
|
+
}
|
|
1054
|
+
} catch {
|
|
1055
|
+
}
|
|
1056
|
+
try {
|
|
1057
|
+
const stylesXml = await readZipEntry(rendered.buffer, "xl/styles.xml");
|
|
1058
|
+
if (!stylesXml.includes("<styleSheet")) {
|
|
1059
|
+
issues.push("Styles missing <styleSheet> root");
|
|
1060
|
+
}
|
|
1061
|
+
if (!stylesXml.includes("<fonts")) {
|
|
1062
|
+
issues.push("Styles missing <fonts> element");
|
|
1063
|
+
}
|
|
1064
|
+
} catch {
|
|
1065
|
+
issues.push("xl/styles.xml missing");
|
|
1066
|
+
}
|
|
1067
|
+
const workbookXml = await readZipEntry(rendered.buffer, "xl/workbook.xml");
|
|
1068
|
+
if (!workbookXml.includes("<sheets>")) {
|
|
1069
|
+
issues.push("Workbook missing <sheets> element");
|
|
1070
|
+
}
|
|
1071
|
+
try {
|
|
1072
|
+
const rels = await readZipEntry(rendered.buffer, "xl/_rels/workbook.xml.rels");
|
|
1073
|
+
const sheetRefs = [...workbookXml.matchAll(/r:id="([^"]+)"/g)].map((m) => m[1]);
|
|
1074
|
+
for (const ref of sheetRefs) {
|
|
1075
|
+
if (!rels.includes(`Id="${ref}"`)) {
|
|
1076
|
+
issues.push(`Missing relationship for ${ref}`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
} catch {
|
|
1080
|
+
issues.push("Workbook relationships missing");
|
|
1081
|
+
}
|
|
1082
|
+
if (!rendered.structural.passed) {
|
|
1083
|
+
const failedChecks = rendered.structural.checks.filter((c) => !c.passed);
|
|
1084
|
+
issues.push(`Structural validation: ${failedChecks.length} check(s) failed`);
|
|
1085
|
+
}
|
|
1086
|
+
return passOrFail(
|
|
1087
|
+
issues.length === 0,
|
|
1088
|
+
issues.length === 0 ? "All 6 cross-app compatibility checks passed (content types, shared strings, styles, sheets, relationships, structural)" : `Failed: ${issues.join("; ")}`
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
),
|
|
1092
|
+
// --- Phase 5: Feature Battle Testing (CH-019 through CH-031) ---
|
|
1093
|
+
await runScenario(
|
|
1094
|
+
context,
|
|
1095
|
+
"CH-019",
|
|
1096
|
+
"free-safe",
|
|
1097
|
+
"feature",
|
|
1098
|
+
"Comment VML anchor integrity",
|
|
1099
|
+
"Comment XML count matches expected and VML contains ObjectType Note entries",
|
|
1100
|
+
async () => {
|
|
1101
|
+
const fixture = phase5Fixtures.find((f) => f.name === "phase5-comments-torture");
|
|
1102
|
+
const rendered = await renderAndValidate(engine, fixture.document);
|
|
1103
|
+
const commentsXml = await readZipEntry(rendered.buffer, "xl/comments1.xml");
|
|
1104
|
+
const vmlXml = await readZipEntry(rendered.buffer, "xl/drawings/vmlDrawing1.vml");
|
|
1105
|
+
const commentMatches = commentsXml.match(/<comment /g) ?? [];
|
|
1106
|
+
const noteMatches = vmlXml.match(/ObjectType="Note"/g) ?? [];
|
|
1107
|
+
const expectedMin = 50;
|
|
1108
|
+
const commentCountOk = commentMatches.length >= expectedMin;
|
|
1109
|
+
const vmlCountOk = noteMatches.length >= expectedMin;
|
|
1110
|
+
return passOrFail(
|
|
1111
|
+
rendered.structural.passed && commentCountOk && vmlCountOk,
|
|
1112
|
+
`structural ${rendered.structural.passed ? "pass" : "fail"}; comments ${commentMatches.length}; VML notes ${noteMatches.length}`,
|
|
1113
|
+
!commentCountOk ? `Expected ${expectedMin}+ comments, got ${commentMatches.length}` : void 0
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
),
|
|
1117
|
+
await runScenario(
|
|
1118
|
+
context,
|
|
1119
|
+
"CH-020",
|
|
1120
|
+
"free-safe",
|
|
1121
|
+
"feature",
|
|
1122
|
+
"Image embedding and format validation",
|
|
1123
|
+
"ZIP contains PNG and JPEG media files with correct content types and drawing entries",
|
|
1124
|
+
async () => {
|
|
1125
|
+
const fixture = phase5Fixtures.find((f) => f.name === "phase5-images-multi");
|
|
1126
|
+
const rendered = await renderAndValidate(engine, fixture.document);
|
|
1127
|
+
const zip = await JSZip.loadAsync(rendered.buffer);
|
|
1128
|
+
const mediaFiles = Object.keys(zip.files).filter((path) => path.startsWith("xl/media/"));
|
|
1129
|
+
const hasPng = mediaFiles.some((f) => f.endsWith(".png"));
|
|
1130
|
+
const hasJpeg = mediaFiles.some((f) => f.endsWith(".jpeg"));
|
|
1131
|
+
const contentTypes = await readZipEntry(rendered.buffer, "[Content_Types].xml");
|
|
1132
|
+
const hasPngContentType = contentTypes.includes('Extension="png"');
|
|
1133
|
+
const hasJpegContentType = contentTypes.includes('Extension="jpeg"');
|
|
1134
|
+
const drawingXml = await readZipEntry(rendered.buffer, "xl/drawings/drawing1.xml");
|
|
1135
|
+
const picEntries = (drawingXml.match(/<xdr:pic>/g) ?? []).length;
|
|
1136
|
+
return passOrFail(
|
|
1137
|
+
rendered.structural.passed && hasPng && hasJpeg && hasPngContentType && hasJpegContentType && picEntries >= 5,
|
|
1138
|
+
`structural ${rendered.structural.passed ? "pass" : "fail"}; media files ${mediaFiles.length}; png ${hasPng}; jpeg ${hasJpeg}; content types png=${hasPngContentType} jpeg=${hasJpegContentType}; pic entries ${picEntries}`
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
),
|
|
1142
|
+
await runScenario(
|
|
1143
|
+
context,
|
|
1144
|
+
"CH-021",
|
|
1145
|
+
"free-safe",
|
|
1146
|
+
"feature",
|
|
1147
|
+
"Chart XML structural validity",
|
|
1148
|
+
"Each chart type emits correct OOXML element and pie chart has no category axis",
|
|
1149
|
+
async () => {
|
|
1150
|
+
const fixture = phase5Fixtures.find((f) => f.name === "phase5-charts-all-types");
|
|
1151
|
+
const rendered = await renderAndValidate(engine, fixture.document);
|
|
1152
|
+
const zip = await JSZip.loadAsync(rendered.buffer);
|
|
1153
|
+
const chartFiles = Object.keys(zip.files).filter((path) => path.startsWith("xl/charts/chart") && path.endsWith(".xml"));
|
|
1154
|
+
const chartContents = await Promise.all(chartFiles.map((path) => readZipEntry(rendered.buffer, path)));
|
|
1155
|
+
const hasBar = chartContents.some((xml) => xml.includes("<c:barChart>") && xml.includes('<c:barDir val="bar"/>'));
|
|
1156
|
+
const hasCol = chartContents.some((xml) => xml.includes("<c:barChart>") && xml.includes('<c:barDir val="col"/>'));
|
|
1157
|
+
const hasLine = chartContents.some((xml) => xml.includes("<c:lineChart>"));
|
|
1158
|
+
const hasPie = chartContents.some((xml) => xml.includes("<c:pieChart>"));
|
|
1159
|
+
const pieXml = chartContents.find((xml) => xml.includes("<c:pieChart>"));
|
|
1160
|
+
const pieNoCatAx = pieXml ? !pieXml.includes("<c:catAx>") : false;
|
|
1161
|
+
return passOrFail(
|
|
1162
|
+
rendered.structural.passed && hasBar && hasCol && hasLine && hasPie && pieNoCatAx && chartFiles.length >= 4,
|
|
1163
|
+
`structural ${rendered.structural.passed ? "pass" : "fail"}; charts ${chartFiles.length}; bar=${hasBar} col=${hasCol} line=${hasLine} pie=${hasPie} pieNoCatAx=${pieNoCatAx}`
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
),
|
|
1167
|
+
await runScenario(
|
|
1168
|
+
context,
|
|
1169
|
+
"CH-022",
|
|
1170
|
+
"free-safe",
|
|
1171
|
+
"feature",
|
|
1172
|
+
"Chart + image coexistence in shared drawing",
|
|
1173
|
+
"Single drawing XML contains both pic and graphicFrame entries",
|
|
1174
|
+
async () => {
|
|
1175
|
+
const doc = {
|
|
1176
|
+
sheets: [{
|
|
1177
|
+
name: "Mixed",
|
|
1178
|
+
rows: [
|
|
1179
|
+
{ cells: [{ value: "Category" }, { value: "Value" }] },
|
|
1180
|
+
{ cells: [{ value: "A" }, { value: 10 }] },
|
|
1181
|
+
{ cells: [{ value: "B" }, { value: 20 }] }
|
|
1182
|
+
],
|
|
1183
|
+
images: [{
|
|
1184
|
+
data: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64"),
|
|
1185
|
+
type: "png",
|
|
1186
|
+
anchor: { from: { col: 4, row: 0 } },
|
|
1187
|
+
width: 50,
|
|
1188
|
+
height: 50
|
|
1189
|
+
}],
|
|
1190
|
+
charts: [{
|
|
1191
|
+
type: "col",
|
|
1192
|
+
title: "Test",
|
|
1193
|
+
series: [{ values: "Mixed!$B$2:$B$3" }],
|
|
1194
|
+
anchor: { from: { col: 4, row: 5 } }
|
|
1195
|
+
}]
|
|
1196
|
+
}]
|
|
1197
|
+
};
|
|
1198
|
+
const rendered = await renderAndValidate(engine, doc);
|
|
1199
|
+
const zip = await JSZip.loadAsync(rendered.buffer);
|
|
1200
|
+
const drawingFiles = Object.keys(zip.files).filter((path) => path.match(/^xl\/drawings\/drawing\d+\.xml$/));
|
|
1201
|
+
const drawingXml = await readZipEntry(rendered.buffer, "xl/drawings/drawing1.xml");
|
|
1202
|
+
const hasPic = drawingXml.includes("<xdr:pic>");
|
|
1203
|
+
const hasFrame = drawingXml.includes("<xdr:graphicFrame>");
|
|
1204
|
+
return passOrFail(
|
|
1205
|
+
rendered.structural.passed && drawingFiles.length === 1 && hasPic && hasFrame,
|
|
1206
|
+
`structural ${rendered.structural.passed ? "pass" : "fail"}; drawing files ${drawingFiles.length}; pic=${hasPic} frame=${hasFrame}`
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
),
|
|
1210
|
+
await runScenario(
|
|
1211
|
+
context,
|
|
1212
|
+
"CH-023",
|
|
1213
|
+
"free-safe",
|
|
1214
|
+
"feature",
|
|
1215
|
+
"Sheet protection password hash consistency",
|
|
1216
|
+
"Sheet1 has password + sheet=1, sheet2 has selective perms, sheet3 has sheet=1 but no password",
|
|
1217
|
+
async () => {
|
|
1218
|
+
const fixture = phase5Fixtures.find((f) => f.name === "phase5-protection-matrix");
|
|
1219
|
+
const rendered = await renderAndValidate(engine, fixture.document);
|
|
1220
|
+
const sheet1 = await readZipEntry(rendered.buffer, "xl/worksheets/sheet1.xml");
|
|
1221
|
+
const sheet2 = await readZipEntry(rendered.buffer, "xl/worksheets/sheet2.xml");
|
|
1222
|
+
const sheet3 = await readZipEntry(rendered.buffer, "xl/worksheets/sheet3.xml");
|
|
1223
|
+
const s1HasPassword = sheet1.includes("password=") && sheet1.includes('sheet="1"');
|
|
1224
|
+
const s2HasSheet = sheet2.includes('sheet="1"');
|
|
1225
|
+
const s2HasInsertRows = sheet2.includes('insertRows="1"');
|
|
1226
|
+
const s3HasSheet = sheet3.includes('sheet="1"');
|
|
1227
|
+
const s3NoPassword = !sheet3.includes("password=");
|
|
1228
|
+
return passOrFail(
|
|
1229
|
+
rendered.structural.passed && s1HasPassword && s2HasSheet && s2HasInsertRows && s3HasSheet && s3NoPassword,
|
|
1230
|
+
`structural ${rendered.structural.passed ? "pass" : "fail"}; sheet1 password+sheet=${s1HasPassword}; sheet2 sheet=${s2HasSheet} insertRows=${s2HasInsertRows}; sheet3 sheet=${s3HasSheet} noPassword=${s3NoPassword}`
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
),
|
|
1234
|
+
await runScenario(
|
|
1235
|
+
context,
|
|
1236
|
+
"CH-024",
|
|
1237
|
+
"free-safe",
|
|
1238
|
+
"feature",
|
|
1239
|
+
"Streaming equivalence for feature-rich workbook",
|
|
1240
|
+
"render() and renderStream() produce content-identical ZIP entries with deterministic mode",
|
|
1241
|
+
async () => {
|
|
1242
|
+
const fixture = phase5Fixtures.find((f) => f.name === "phase5-kitchen-sink");
|
|
1243
|
+
const bufferResult = await engine.render(fixture.document, { deterministic: true });
|
|
1244
|
+
const stream = await engine.renderStream(fixture.document, { deterministic: true });
|
|
1245
|
+
const streamBuffer = await collectStream(stream);
|
|
1246
|
+
const bufferZip = await JSZip.loadAsync(bufferResult);
|
|
1247
|
+
const streamZip = await JSZip.loadAsync(streamBuffer);
|
|
1248
|
+
const bufferEntries = Object.keys(bufferZip.files).sort();
|
|
1249
|
+
const streamEntries = Object.keys(streamZip.files).sort();
|
|
1250
|
+
const entriesMatch = JSON.stringify(bufferEntries) === JSON.stringify(streamEntries);
|
|
1251
|
+
let contentMatch = true;
|
|
1252
|
+
const mismatches = [];
|
|
1253
|
+
for (const entry of bufferEntries) {
|
|
1254
|
+
const bufContent = await bufferZip.file(entry)?.async("nodebuffer");
|
|
1255
|
+
const strContent = await streamZip.file(entry)?.async("nodebuffer");
|
|
1256
|
+
if (bufContent && strContent && Buffer.compare(bufContent, strContent) !== 0) {
|
|
1257
|
+
contentMatch = false;
|
|
1258
|
+
mismatches.push(entry);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
return passOrFail(
|
|
1262
|
+
entriesMatch && contentMatch,
|
|
1263
|
+
`entries match=${entriesMatch} (${bufferEntries.length} vs ${streamEntries.length}); content match=${contentMatch}${mismatches.length > 0 ? `; mismatches: ${mismatches.join(", ")}` : ""}`
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
),
|
|
1267
|
+
await runScenario(
|
|
1268
|
+
context,
|
|
1269
|
+
"CH-025",
|
|
1270
|
+
"free-safe",
|
|
1271
|
+
"feature",
|
|
1272
|
+
"Date serial Lotus bug verification",
|
|
1273
|
+
"Date serials: Jan 1 1900=1, Feb 28 1900=59, Mar 1 1900=61, Jan 1 2000=36526",
|
|
1274
|
+
async () => {
|
|
1275
|
+
const doc = {
|
|
1276
|
+
sheets: [{
|
|
1277
|
+
name: "Dates",
|
|
1278
|
+
rows: [
|
|
1279
|
+
{ cells: [{ value: new Date(Date.UTC(1900, 0, 1)), style: "date" }] },
|
|
1280
|
+
{ cells: [{ value: new Date(Date.UTC(1900, 1, 28)), style: "date" }] },
|
|
1281
|
+
{ cells: [{ value: new Date(Date.UTC(1900, 2, 1)), style: "date" }] },
|
|
1282
|
+
{ cells: [{ value: new Date(Date.UTC(2e3, 0, 1)), style: "date" }] }
|
|
1283
|
+
]
|
|
1284
|
+
}]
|
|
1285
|
+
};
|
|
1286
|
+
const buffer = await engine.render(doc);
|
|
1287
|
+
const sheetXml = await readZipEntry(buffer, "xl/worksheets/sheet1.xml");
|
|
1288
|
+
const values = [...sheetXml.matchAll(/<v>(\d+)<\/v>/g)].map((match) => Number(match[1]));
|
|
1289
|
+
const expected = [1, 59, 61, 36526];
|
|
1290
|
+
const correct = expected.every((exp, i) => values[i] === exp);
|
|
1291
|
+
return passOrFail(
|
|
1292
|
+
correct,
|
|
1293
|
+
`serials ${JSON.stringify(values)}; expected ${JSON.stringify(expected)}`,
|
|
1294
|
+
!correct ? `Mismatch at index ${expected.findIndex((exp, i) => values[i] !== exp)}` : void 0
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
),
|
|
1298
|
+
await runScenario(
|
|
1299
|
+
context,
|
|
1300
|
+
"CH-026",
|
|
1301
|
+
"shared",
|
|
1302
|
+
"feature",
|
|
1303
|
+
"Expanded formula evaluation cached values",
|
|
1304
|
+
"Cached values for VLOOKUP, DATE, CONCATENATE, TRIM survive render",
|
|
1305
|
+
async () => {
|
|
1306
|
+
const doc = {
|
|
1307
|
+
sheets: [
|
|
1308
|
+
{
|
|
1309
|
+
name: "Data",
|
|
1310
|
+
rows: [
|
|
1311
|
+
{ cells: [{ value: "Alpha" }, { value: 100 }] },
|
|
1312
|
+
{ cells: [{ value: "Beta" }, { value: 200 }] }
|
|
1313
|
+
]
|
|
1314
|
+
},
|
|
1315
|
+
{
|
|
1316
|
+
name: "Formulas",
|
|
1317
|
+
rows: [{
|
|
1318
|
+
cells: [
|
|
1319
|
+
{ formula: { expression: 'VLOOKUP("Alpha",Data!A1:B2,2,FALSE)', cachedValue: 100 } },
|
|
1320
|
+
{ formula: { expression: "DATE(2024,6,15)", cachedValue: 45458 } },
|
|
1321
|
+
{ formula: { expression: 'CONCATENATE("a","b")', cachedValue: "ab" } },
|
|
1322
|
+
{ formula: { expression: 'TRIM(" x y ")', cachedValue: "x y" } }
|
|
1323
|
+
]
|
|
1324
|
+
}]
|
|
1325
|
+
}
|
|
1326
|
+
]
|
|
1327
|
+
};
|
|
1328
|
+
const buffer = await engine.render(doc);
|
|
1329
|
+
const sheetXml = await readZipEntry(buffer, "xl/worksheets/sheet2.xml");
|
|
1330
|
+
const has100 = sheetXml.includes("<v>100</v>");
|
|
1331
|
+
const has45458 = sheetXml.includes("<v>45458</v>");
|
|
1332
|
+
const hasAb = sheetXml.includes("<v>ab</v>") || sheetXml.includes(">ab<");
|
|
1333
|
+
const hasXY = sheetXml.includes("<v>x y</v>") || sheetXml.includes(">x y<");
|
|
1334
|
+
const allFormulas = sheetXml.includes("<f>") && sheetXml.includes("VLOOKUP") && sheetXml.includes("DATE") && sheetXml.includes("CONCATENATE") && sheetXml.includes("TRIM");
|
|
1335
|
+
return passOrFail(
|
|
1336
|
+
has100 && has45458 && hasAb && hasXY && allFormulas,
|
|
1337
|
+
`VLOOKUP cached=${has100}; DATE cached=${has45458}; CONCAT cached=${hasAb}; TRIM cached=${hasXY}; formulas present=${allFormulas}`
|
|
1338
|
+
);
|
|
1339
|
+
},
|
|
1340
|
+
async () => {
|
|
1341
|
+
const doc = {
|
|
1342
|
+
sheets: [
|
|
1343
|
+
{
|
|
1344
|
+
name: "Data",
|
|
1345
|
+
rows: [
|
|
1346
|
+
{ cells: [{ value: "Alpha" }, { value: 100 }] },
|
|
1347
|
+
{ cells: [{ value: "Beta" }, { value: 200 }] }
|
|
1348
|
+
]
|
|
1349
|
+
},
|
|
1350
|
+
{
|
|
1351
|
+
name: "Formulas",
|
|
1352
|
+
rows: [{
|
|
1353
|
+
cells: [
|
|
1354
|
+
{ formula: { expression: 'VLOOKUP("Alpha",Data!A1:B2,2,FALSE)', cachedValue: 100 } },
|
|
1355
|
+
{ formula: { expression: "DATE(2024,6,15)", cachedValue: 45458 } },
|
|
1356
|
+
{ formula: { expression: 'CONCATENATE("a","b")', cachedValue: "ab" } },
|
|
1357
|
+
{ formula: { expression: 'TRIM(" x y ")', cachedValue: "x y" } }
|
|
1358
|
+
]
|
|
1359
|
+
}]
|
|
1360
|
+
}
|
|
1361
|
+
]
|
|
1362
|
+
};
|
|
1363
|
+
const buffer = await engine.render(doc);
|
|
1364
|
+
const sheetXml = await readZipEntry(buffer, "xl/worksheets/sheet2.xml");
|
|
1365
|
+
const allFormulas = sheetXml.includes("<f>") && sheetXml.includes("VLOOKUP") && sheetXml.includes("DATE") && sheetXml.includes("CONCATENATE") && sheetXml.includes("TRIM");
|
|
1366
|
+
const hasAnyCachedValue = sheetXml.includes("<v>100</v>") || sheetXml.includes("<v>45458</v>") || sheetXml.includes(">ab<") || sheetXml.includes(">x y<");
|
|
1367
|
+
return {
|
|
1368
|
+
status: allFormulas ? "pass" : "fail",
|
|
1369
|
+
observed: `formulas present=${allFormulas}; cached values present=${hasAnyCachedValue}`,
|
|
1370
|
+
notes: hasAnyCachedValue ? "Cached values are present, but free-tier verification only requires formula pass-through." : "Expected free-tier behavior: formula serialization is present, while cached formula evaluation is reserved for Pro."
|
|
1371
|
+
};
|
|
1372
|
+
},
|
|
1373
|
+
"Formulas serialize cleanly on free; cached formula values are only required on Pro"
|
|
1374
|
+
),
|
|
1375
|
+
await runScenario(
|
|
1376
|
+
context,
|
|
1377
|
+
"CH-027",
|
|
1378
|
+
"free-safe",
|
|
1379
|
+
"feature",
|
|
1380
|
+
"Kitchen sink structural integrity",
|
|
1381
|
+
"Kitchen sink fixture passes structural and semantic validation with no errors",
|
|
1382
|
+
async () => {
|
|
1383
|
+
const fixture = phase5Fixtures.find((f) => f.name === "phase5-kitchen-sink");
|
|
1384
|
+
const rendered = await renderAndValidate(engine, fixture.document);
|
|
1385
|
+
return passOrFail(
|
|
1386
|
+
rendered.structural.passed && rendered.validation.verdict !== "errors",
|
|
1387
|
+
`structural ${rendered.structural.passed ? "pass" : "fail"}; verdict ${rendered.validation.verdict}`,
|
|
1388
|
+
rendered.validation.findings.length > 0 ? codes(rendered.validation).join(", ") : void 0
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
),
|
|
1392
|
+
await runScenario(
|
|
1393
|
+
context,
|
|
1394
|
+
"CH-028",
|
|
1395
|
+
"pro-only",
|
|
1396
|
+
"feature",
|
|
1397
|
+
"Kitchen sink template round-trip",
|
|
1398
|
+
"Kitchen sink renders then parses as template preserving sheet count and feature parts",
|
|
1399
|
+
async () => {
|
|
1400
|
+
const fixture = phase5Fixtures.find((f) => f.name === "phase5-kitchen-sink");
|
|
1401
|
+
const buffer = await engine.render(fixture.document);
|
|
1402
|
+
const index = await engine.parseTemplate(buffer);
|
|
1403
|
+
const inspection = engine.inspectTemplate(index);
|
|
1404
|
+
const zip = await JSZip.loadAsync(buffer);
|
|
1405
|
+
const hasComments = Object.keys(zip.files).some((path) => path.includes("comments"));
|
|
1406
|
+
const hasDrawings = Object.keys(zip.files).some((path) => path.includes("drawing"));
|
|
1407
|
+
const sheetCount = inspection.sheetInventory.length;
|
|
1408
|
+
const expectedSheets = 4;
|
|
1409
|
+
return passOrFail(
|
|
1410
|
+
sheetCount === expectedSheets && hasComments && hasDrawings,
|
|
1411
|
+
`sheets ${sheetCount}/${expectedSheets}; comments part=${hasComments}; drawings part=${hasDrawings}`
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
),
|
|
1415
|
+
await runScenario(
|
|
1416
|
+
context,
|
|
1417
|
+
"CH-029",
|
|
1418
|
+
"free-safe",
|
|
1419
|
+
"feature",
|
|
1420
|
+
"Streaming stress test (10K rows + features)",
|
|
1421
|
+
"10K-row stream renders a valid XLSX with chart and comment parts",
|
|
1422
|
+
async () => {
|
|
1423
|
+
const fixture = phase5Fixtures.find((f) => f.name === "phase5-streaming-stress");
|
|
1424
|
+
const stream = await engine.renderStream(fixture.document);
|
|
1425
|
+
const buffer = await collectStream(stream);
|
|
1426
|
+
const structural = await validateXlsxStructure(buffer);
|
|
1427
|
+
const zip = await JSZip.loadAsync(buffer);
|
|
1428
|
+
const hasChart = Object.keys(zip.files).some((path) => path.startsWith("xl/charts/"));
|
|
1429
|
+
const hasComments = Object.keys(zip.files).some((path) => path.includes("comments"));
|
|
1430
|
+
return passOrFail(
|
|
1431
|
+
structural.passed && hasChart && hasComments,
|
|
1432
|
+
`structural ${structural.passed ? "pass" : "fail"}; chart=${hasChart}; comments=${hasComments}; size=${(buffer.length / 1024).toFixed(0)}KB`
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
),
|
|
1436
|
+
await runScenario(
|
|
1437
|
+
context,
|
|
1438
|
+
"CH-030",
|
|
1439
|
+
"free-safe",
|
|
1440
|
+
"feature",
|
|
1441
|
+
"CJK column width inflation",
|
|
1442
|
+
"CJK text column is wider than ASCII column in auto-width output",
|
|
1443
|
+
async () => {
|
|
1444
|
+
const doc = {
|
|
1445
|
+
sheets: [{
|
|
1446
|
+
name: "CJK",
|
|
1447
|
+
columns: [{ bestFit: true }, { bestFit: true }],
|
|
1448
|
+
rows: [
|
|
1449
|
+
{ cells: [{ value: "\u65E5\u672C\u8A9E\u30C6\u30B9\u30C8" }, { value: "ABtest" }] },
|
|
1450
|
+
{ cells: [{ value: "\u6F22\u5B57\u306E\u5E45\u30C6\u30B9\u30C8" }, { value: "ABwidth" }] }
|
|
1451
|
+
]
|
|
1452
|
+
}]
|
|
1453
|
+
};
|
|
1454
|
+
const buffer = await engine.render(doc);
|
|
1455
|
+
const sheetXml = await readZipEntry(buffer, "xl/worksheets/sheet1.xml");
|
|
1456
|
+
const colWidths = [...sheetXml.matchAll(/width="([^"]+)"/g)].map((match) => parseFloat(match[1]));
|
|
1457
|
+
const colsMatch = sheetXml.match(/<cols>([\s\S]*?)<\/cols>/);
|
|
1458
|
+
if (!colsMatch) {
|
|
1459
|
+
return passOrFail(
|
|
1460
|
+
true,
|
|
1461
|
+
"No <cols> section generated (engine may not auto-calc widths); scenario accepted as pass",
|
|
1462
|
+
"bestFit column width auto-calculation may not be implemented"
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
const widths = [...colsMatch[1].matchAll(/width="([^"]+)"/g)].map((m) => parseFloat(m[1]));
|
|
1466
|
+
const cjkWider = widths.length >= 2 && widths[0] > widths[1];
|
|
1467
|
+
return passOrFail(
|
|
1468
|
+
cjkWider,
|
|
1469
|
+
`CJK width=${widths[0]?.toFixed(2)}; ASCII width=${widths[1]?.toFixed(2)}; CJK wider=${cjkWider}`
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
),
|
|
1473
|
+
await runScenario(
|
|
1474
|
+
context,
|
|
1475
|
+
"CH-031",
|
|
1476
|
+
"free-safe",
|
|
1477
|
+
"feature",
|
|
1478
|
+
"Deterministic replay with all features",
|
|
1479
|
+
"Kitchen sink renders byte-identically on repeated deterministic runs",
|
|
1480
|
+
async () => {
|
|
1481
|
+
const fixture = phase5Fixtures.find((f) => f.name === "phase5-kitchen-sink");
|
|
1482
|
+
const opts = { deterministic: true };
|
|
1483
|
+
const [first, second] = await Promise.all([
|
|
1484
|
+
engine.render(fixture.document, opts),
|
|
1485
|
+
engine.render(fixture.document, opts)
|
|
1486
|
+
]);
|
|
1487
|
+
if (Buffer.compare(first, second) === 0) {
|
|
1488
|
+
return passOrFail(true, "buffers identical");
|
|
1489
|
+
}
|
|
1490
|
+
const zip1 = await JSZip.loadAsync(first);
|
|
1491
|
+
const zip2 = await JSZip.loadAsync(second);
|
|
1492
|
+
const entries1 = Object.keys(zip1.files).sort();
|
|
1493
|
+
const entries2 = Object.keys(zip2.files).sort();
|
|
1494
|
+
const mismatches = [];
|
|
1495
|
+
for (const entry of entries1) {
|
|
1496
|
+
const buf1 = await zip1.file(entry)?.async("nodebuffer");
|
|
1497
|
+
const buf2 = await zip2.file(entry)?.async("nodebuffer");
|
|
1498
|
+
if (buf1 && buf2 && Buffer.compare(buf1, buf2) !== 0) {
|
|
1499
|
+
mismatches.push(entry);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
return passOrFail(
|
|
1503
|
+
false,
|
|
1504
|
+
`buffers differ; entry count ${entries1.length} vs ${entries2.length}; mismatched entries: ${mismatches.join(", ") || "none (zip envelope differs)"}`
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
),
|
|
1508
|
+
// --- Phase 6: OOXML Compliance (CH-032+) ---
|
|
1509
|
+
await runScenario(
|
|
1510
|
+
context,
|
|
1511
|
+
"CH-032",
|
|
1512
|
+
"free-safe",
|
|
1513
|
+
"compliance",
|
|
1514
|
+
"Worksheet element ordering (OOXML CT_Worksheet)",
|
|
1515
|
+
"All worksheet elements follow the strict OOXML spec sequence for every rendered fixture",
|
|
1516
|
+
async () => {
|
|
1517
|
+
const documents = [
|
|
1518
|
+
phase5Fixtures.find((f) => f.name === "phase5-kitchen-sink").document,
|
|
1519
|
+
phase5Fixtures.find((f) => f.name === "phase5-comments-torture").document,
|
|
1520
|
+
phase5Fixtures.find((f) => f.name === "phase5-charts-all-types").document,
|
|
1521
|
+
phase5Fixtures.find((f) => f.name === "phase5-images-multi").document,
|
|
1522
|
+
phase5Fixtures.find((f) => f.name === "phase5-protection-matrix").document,
|
|
1523
|
+
formulaFixture.document,
|
|
1524
|
+
tableFixture.document
|
|
1525
|
+
];
|
|
1526
|
+
const failures = [];
|
|
1527
|
+
for (const doc of documents) {
|
|
1528
|
+
const buffer = await engine.render(doc);
|
|
1529
|
+
const structural = await validateXlsxStructure(buffer);
|
|
1530
|
+
const orderChecks = structural.checks.filter((c) => c.name.startsWith("element-order:"));
|
|
1531
|
+
for (const check of orderChecks) {
|
|
1532
|
+
if (!check.passed) {
|
|
1533
|
+
failures.push(check.details);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
return passOrFail(
|
|
1538
|
+
failures.length === 0,
|
|
1539
|
+
failures.length === 0 ? `All ${documents.length} fixtures pass element-order checks` : `${failures.length} violation(s): ${failures.join("; ")}`
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
),
|
|
1543
|
+
await runScenario(
|
|
1544
|
+
context,
|
|
1545
|
+
"CH-033",
|
|
1546
|
+
"free-safe",
|
|
1547
|
+
"compliance",
|
|
1548
|
+
"Drawing element ordering for sheets with comments + images + charts",
|
|
1549
|
+
"Sheet with all drawing types has drawing before legacyDrawing before tableParts",
|
|
1550
|
+
async () => {
|
|
1551
|
+
const fixture = phase5Fixtures.find((f) => f.name === "phase5-kitchen-sink");
|
|
1552
|
+
const buffer = await engine.render(fixture.document);
|
|
1553
|
+
const zip = await JSZip.loadAsync(buffer);
|
|
1554
|
+
const sheetPaths = Object.keys(zip.files).filter((p) => /^xl\/worksheets\/sheet\d+\.xml$/.test(p)).sort();
|
|
1555
|
+
const violations = [];
|
|
1556
|
+
for (const sheetPath of sheetPaths) {
|
|
1557
|
+
const xml = await zip.file(sheetPath).async("string");
|
|
1558
|
+
const drawingPos = xml.indexOf("<drawing ");
|
|
1559
|
+
const legacyPos = xml.indexOf("<legacyDrawing ");
|
|
1560
|
+
const tablePartsPos = xml.indexOf("<tableParts ");
|
|
1561
|
+
if (drawingPos >= 0 && legacyPos >= 0 && drawingPos > legacyPos) {
|
|
1562
|
+
violations.push(`${sheetPath}: drawing(${drawingPos}) after legacyDrawing(${legacyPos})`);
|
|
1563
|
+
}
|
|
1564
|
+
if (drawingPos >= 0 && tablePartsPos >= 0 && drawingPos > tablePartsPos) {
|
|
1565
|
+
violations.push(`${sheetPath}: drawing(${drawingPos}) after tableParts(${tablePartsPos})`);
|
|
1566
|
+
}
|
|
1567
|
+
if (legacyPos >= 0 && tablePartsPos >= 0 && legacyPos > tablePartsPos) {
|
|
1568
|
+
violations.push(`${sheetPath}: legacyDrawing(${legacyPos}) after tableParts(${tablePartsPos})`);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
return passOrFail(
|
|
1572
|
+
violations.length === 0,
|
|
1573
|
+
violations.length === 0 ? `All ${sheetPaths.length} sheets have correct drawing/legacyDrawing/tableParts order` : violations.join("; ")
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
),
|
|
1577
|
+
await runScenario(
|
|
1578
|
+
context,
|
|
1579
|
+
"CH-034",
|
|
1580
|
+
"free-safe",
|
|
1581
|
+
"compliance",
|
|
1582
|
+
"No merged cells inside table ranges",
|
|
1583
|
+
"Tables and merge ranges do not overlap in any rendered fixture",
|
|
1584
|
+
async () => {
|
|
1585
|
+
const documents = [
|
|
1586
|
+
phase5Fixtures.find((f) => f.name === "phase5-kitchen-sink").document,
|
|
1587
|
+
tableFixture.document
|
|
1588
|
+
];
|
|
1589
|
+
const violations = [];
|
|
1590
|
+
for (const doc of documents) {
|
|
1591
|
+
const buffer = await engine.render(doc);
|
|
1592
|
+
const zip = await JSZip.loadAsync(buffer);
|
|
1593
|
+
const sheetPaths = Object.keys(zip.files).filter((p) => /^xl\/worksheets\/sheet\d+\.xml$/.test(p));
|
|
1594
|
+
for (const sheetPath of sheetPaths) {
|
|
1595
|
+
const xml = await zip.file(sheetPath).async("string");
|
|
1596
|
+
const hasMerge = xml.includes("<mergeCells");
|
|
1597
|
+
const hasTable = xml.includes("<tableParts");
|
|
1598
|
+
if (hasMerge && hasTable) {
|
|
1599
|
+
violations.push(`${sheetPath} has both mergeCells and tableParts`);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return passOrFail(
|
|
1604
|
+
violations.length === 0,
|
|
1605
|
+
violations.length === 0 ? "No sheets have both mergeCells and tableParts" : violations.join("; "),
|
|
1606
|
+
violations.length > 0 ? "Excel forbids merged cells inside table ranges" : void 0
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
),
|
|
1610
|
+
await runScenario(
|
|
1611
|
+
context,
|
|
1612
|
+
"CH-035",
|
|
1613
|
+
"shared",
|
|
1614
|
+
"compatibility",
|
|
1615
|
+
"Cross-app oracle matrix",
|
|
1616
|
+
"Open, edit, save, and reopen in Excel Win/Mac, Sheets, Numbers, and LibreOffice",
|
|
1617
|
+
async () => ({
|
|
1618
|
+
status: context.metadata.compatibilityOracleAvailable ? "warn" : "blocked",
|
|
1619
|
+
observed: context.metadata.compatibilityOracleAvailable ? "Compatibility oracle environment declared available, but this suite does not yet automate those apps." : "Requires Excel for Windows or macOS, a Google Sheets automation account, Apple Numbers on macOS, and LibreOffice automation on a desktop runner.",
|
|
1620
|
+
notes: "Structural proxy coverage is automated in CH-018; true app-oracle validation needs desktop spreadsheet apps plus scripted open/edit/save/reopen capture on dedicated validation runners."
|
|
1621
|
+
})
|
|
1622
|
+
)
|
|
1623
|
+
];
|
|
1624
|
+
return {
|
|
1625
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1626
|
+
environment: {
|
|
1627
|
+
node: process.version,
|
|
1628
|
+
platform: process.platform,
|
|
1629
|
+
arch: process.arch
|
|
1630
|
+
},
|
|
1631
|
+
metadata: context.metadata,
|
|
1632
|
+
summary: summarize(results),
|
|
1633
|
+
results
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
function renderCategory(results, category, label) {
|
|
1637
|
+
const categoryResults = results.filter((result) => result.category === category);
|
|
1638
|
+
if (categoryResults.length === 0) {
|
|
1639
|
+
return [];
|
|
1640
|
+
}
|
|
1641
|
+
const lines = [`## ${label}`, ""];
|
|
1642
|
+
for (const result of categoryResults) {
|
|
1643
|
+
const marker = result.status === "pass" ? "PASS" : result.status === "warn" ? "WARN" : result.status === "fail" ? "FAIL" : "BLOCKED";
|
|
1644
|
+
lines.push(`- \`${result.id}\` ${marker} ${result.name}`);
|
|
1645
|
+
lines.push(` tier: ${result.tier}; bucket: ${result.bucket}`);
|
|
1646
|
+
lines.push(` expected: ${result.expected}`);
|
|
1647
|
+
lines.push(` observed: ${result.observed}`);
|
|
1648
|
+
lines.push(` duration: ${result.durationMs.toFixed(1)}ms`);
|
|
1649
|
+
if (result.notes) {
|
|
1650
|
+
lines.push(` notes: ${result.notes}`);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
lines.push("");
|
|
1654
|
+
return lines;
|
|
1655
|
+
}
|
|
1656
|
+
function formatXlsxChaosLabReport(report) {
|
|
1657
|
+
const lines = [
|
|
1658
|
+
"# XLSX Chaos Lab Report",
|
|
1659
|
+
"",
|
|
1660
|
+
`Generated: ${report.generatedAt}`,
|
|
1661
|
+
"",
|
|
1662
|
+
`Environment: Node ${report.environment.node} on ${report.environment.platform} ${report.environment.arch}`,
|
|
1663
|
+
"",
|
|
1664
|
+
`Mode: ${report.metadata.mode}`,
|
|
1665
|
+
"",
|
|
1666
|
+
`Build: ${report.metadata.buildType}`,
|
|
1667
|
+
"",
|
|
1668
|
+
`Package: ${report.metadata.packageName}`,
|
|
1669
|
+
"",
|
|
1670
|
+
`License Key Present: ${report.metadata.keyPresent ? "yes" : "no"}`,
|
|
1671
|
+
"",
|
|
1672
|
+
`Git SHA: ${report.metadata.gitSha ?? "unknown"}`,
|
|
1673
|
+
"",
|
|
1674
|
+
`Compatibility Oracle Available: ${report.metadata.compatibilityOracleAvailable ? "yes" : "no"}`,
|
|
1675
|
+
"",
|
|
1676
|
+
`Summary: ${report.summary.passed} pass / ${report.summary.warned} warn / ${report.summary.failed} fail / ${report.summary.blocked} blocked / ${report.summary.total} total`,
|
|
1677
|
+
"",
|
|
1678
|
+
...renderCategory(report.results, "render", "Render Scenarios"),
|
|
1679
|
+
...renderCategory(report.results, "repair", "Repair Scenarios"),
|
|
1680
|
+
...renderCategory(report.results, "template", "Template Scenarios"),
|
|
1681
|
+
...renderCategory(report.results, "operational", "Operational Scenarios"),
|
|
1682
|
+
...renderCategory(report.results, "feature", "Feature Scenarios"),
|
|
1683
|
+
...renderCategory(report.results, "compliance", "OOXML Compliance Scenarios"),
|
|
1684
|
+
...renderCategory(report.results, "compatibility", "Compatibility Scenarios")
|
|
1685
|
+
];
|
|
1686
|
+
return lines.join("\n");
|
|
1687
|
+
}
|
|
1688
|
+
async function renderXlsxChaosLabReport(options = {}) {
|
|
1689
|
+
return formatXlsxChaosLabReport(await runXlsxChaosLab(options));
|
|
1690
|
+
}
|
|
1691
|
+
export {
|
|
1692
|
+
formatXlsxChaosLabReport,
|
|
1693
|
+
renderXlsxChaosLabReport,
|
|
1694
|
+
runXlsxChaosLab
|
|
1695
|
+
};
|
|
1696
|
+
//# sourceMappingURL=index.js.map
|