@sdeverywhere/compile 0.7.30 → 0.7.32
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/package.json +4 -4
- package/src/_shared/helpers.js +7 -18
- package/src/_shared/xlsx.js +517 -0
- package/src/generate/direct-data-helpers.js +2 -3
- package/src/generate/gen-direct-const.js +2 -3
- package/src/generate/gen-equation.js +5 -1
- package/src/generate/gen-expr.js +170 -16
- package/src/generate/gen-lookup-from-direct.js +4 -4
- package/src/model/analyze-cycles.js +208 -0
- package/src/model/model.js +73 -1
- package/src/model/read-equations.js +27 -8
- package/src/model/read-subscripts.js +2 -3
- package/src/model/read-variables.js +19 -14
- package/src/model/toposort.js +83 -1
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdeverywhere/compile",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.32",
|
|
4
4
|
"description": "The core Vensim to C compiler for the SDEverywhere tool suite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@sdeverywhere/parse": "^0.1.
|
|
8
|
+
"@sdeverywhere/parse": "^0.1.5",
|
|
9
9
|
"byline": "^5.0.0",
|
|
10
10
|
"csv-parse": "^5.3.3",
|
|
11
|
+
"fflate": "^0.8.3",
|
|
11
12
|
"ramda": "^0.27.0",
|
|
12
|
-
"strip-bom": "^5.0.0"
|
|
13
|
-
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
|
|
13
|
+
"strip-bom": "^5.0.0"
|
|
14
14
|
},
|
|
15
15
|
"author": "Climate Interactive",
|
|
16
16
|
"license": "MIT",
|
package/src/_shared/helpers.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import * as fs from 'node:fs'
|
|
2
1
|
import util from 'util'
|
|
3
2
|
import { parse as parseCsv } from 'csv-parse/sync'
|
|
4
3
|
import * as R from 'ramda'
|
|
5
|
-
|
|
4
|
+
|
|
6
5
|
import B from './bufx.js'
|
|
6
|
+
import { readXlsx as readXlsxFile, resetXlsxCache } from './xlsx.js'
|
|
7
7
|
|
|
8
8
|
import { canonicalId, canonicalVarId } from '@sdeverywhere/parse'
|
|
9
9
|
|
|
@@ -24,12 +24,6 @@ let nextLevelVarSeq = 1
|
|
|
24
24
|
let nextAuxVarSeq = 1
|
|
25
25
|
// parsed csv data cache
|
|
26
26
|
let csvData = new Map()
|
|
27
|
-
// parsed xlsx data cache
|
|
28
|
-
let xlsxData = new Map()
|
|
29
|
-
|
|
30
|
-
// Newer versions of the xlsx package require manually setting the `fs` instance
|
|
31
|
-
// before using the `XLSX.readFile` function
|
|
32
|
-
XLSX.set_fs(fs)
|
|
33
27
|
|
|
34
28
|
// XXX: This is needed for tests due to sequence numbers being in module-level storage
|
|
35
29
|
export function resetHelperState() {
|
|
@@ -40,7 +34,7 @@ export function resetHelperState() {
|
|
|
40
34
|
nextLevelVarSeq = 1
|
|
41
35
|
nextAuxVarSeq = 1
|
|
42
36
|
csvData.clear()
|
|
43
|
-
|
|
37
|
+
resetXlsxCache()
|
|
44
38
|
}
|
|
45
39
|
|
|
46
40
|
export let canonicalName = name => {
|
|
@@ -203,15 +197,10 @@ export let isIterable = obj => {
|
|
|
203
197
|
}
|
|
204
198
|
// Command helpers
|
|
205
199
|
export let readXlsx = pathname => {
|
|
206
|
-
// Read the XLSX file at the pathname and parse it.
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
if (!xlsx) {
|
|
211
|
-
xlsx = XLSX.readFile(pathname, { cellDates: true })
|
|
212
|
-
xlsxData.set(pathname, xlsx)
|
|
213
|
-
}
|
|
214
|
-
return xlsx
|
|
200
|
+
// Read the XLSX file at the pathname and parse it. Return a workbook
|
|
201
|
+
// object ({ SheetNames, Sheets }) that can be used to access the data.
|
|
202
|
+
// The underlying reader caches parsed files by path.
|
|
203
|
+
return readXlsxFile(pathname)
|
|
215
204
|
}
|
|
216
205
|
export let readCsv = (pathname, delimiter = ',') => {
|
|
217
206
|
// Read the CSV file at the pathname and parse it with the given delimiter.
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
// Copyright (c) 2026 Climate Interactive / New Venture Fund
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
import { strFromU8, unzipSync } from 'fflate'
|
|
6
|
+
|
|
7
|
+
//
|
|
8
|
+
// Minimal xlsx reader and cell-address utilities used by the `GET DIRECT ...`
|
|
9
|
+
// code paths in the compile pipeline. We read numeric cell values only; strings
|
|
10
|
+
// are surfaced where present but are not the focus of this module.
|
|
11
|
+
//
|
|
12
|
+
|
|
13
|
+
const A_UPPER = 65 // 'A'
|
|
14
|
+
const A_LOWER = 97 // 'a'
|
|
15
|
+
|
|
16
|
+
// Workbook cache, mirroring the previous SheetJS readXlsx behavior in
|
|
17
|
+
// helpers.js. Each xlsx file is parsed at most once per process.
|
|
18
|
+
const workbookCache = new Map()
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reset the workbook cache. Intended for tests that load the same path with
|
|
22
|
+
* different contents across runs.
|
|
23
|
+
*/
|
|
24
|
+
export function resetXlsxCache() {
|
|
25
|
+
workbookCache.clear()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read the xlsx file at the given path and return a workbook shaped like the
|
|
30
|
+
* SheetJS `WorkBook`:
|
|
31
|
+
*
|
|
32
|
+
* ```
|
|
33
|
+
* { SheetNames: string[],
|
|
34
|
+
* Sheets: { [name]: { [cellRef]: { v }, '!ref': 'A1:Z99' } } }
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* Sheets are materialized lazily — the first time a sheet name is read from
|
|
38
|
+
* `Sheets`, its XML is parsed; subsequent reads of the same sheet return the
|
|
39
|
+
* cached map. Workbooks are also cached by path.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} pathname The absolute path to the xlsx file.
|
|
42
|
+
* @returns The parsed workbook.
|
|
43
|
+
*/
|
|
44
|
+
export function readXlsx(pathname) {
|
|
45
|
+
// Return the cached workbook if we've already parsed this file
|
|
46
|
+
const cached = workbookCache.get(pathname)
|
|
47
|
+
if (cached) {
|
|
48
|
+
return cached
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Decompress the file, keeping only the entries we actually read from
|
|
52
|
+
const buf = readFileSync(pathname)
|
|
53
|
+
const unzipped = unzipSync(buf, {
|
|
54
|
+
filter: file => {
|
|
55
|
+
const n = file.name
|
|
56
|
+
return (
|
|
57
|
+
n === 'xl/workbook.xml' ||
|
|
58
|
+
n === 'xl/sharedStrings.xml' ||
|
|
59
|
+
n === 'xl/_rels/workbook.xml.rels' ||
|
|
60
|
+
(n.startsWith('xl/worksheets/sheet') && n.endsWith('.xml'))
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Pull out the workbook, rels, and (optional) sharedStrings parts as text
|
|
66
|
+
const wbBytes = unzipped['xl/workbook.xml']
|
|
67
|
+
const relsBytes = unzipped['xl/_rels/workbook.xml.rels']
|
|
68
|
+
if (!wbBytes || !relsBytes) {
|
|
69
|
+
throw new Error(`Failed to read xlsx file (missing workbook or rels): ${pathname}`)
|
|
70
|
+
}
|
|
71
|
+
const wbXml = strFromU8(wbBytes)
|
|
72
|
+
const relsXml = strFromU8(relsBytes)
|
|
73
|
+
const ssBytes = unzipped['xl/sharedStrings.xml']
|
|
74
|
+
|
|
75
|
+
// Build the sheet definitions
|
|
76
|
+
const sheetDefs = parseWorkbookXml(wbXml)
|
|
77
|
+
|
|
78
|
+
// Build the rid-to-target map
|
|
79
|
+
const rels = parseWorkbookRels(relsXml)
|
|
80
|
+
|
|
81
|
+
// Build the shared-string table
|
|
82
|
+
const sharedStrings = ssBytes ? parseSharedStrings(strFromU8(ssBytes)) : []
|
|
83
|
+
|
|
84
|
+
// Resolve each sheet's rid to its worksheet XML bytes in the zip
|
|
85
|
+
const sheetNames = []
|
|
86
|
+
const sheetXmls = Object.create(null)
|
|
87
|
+
for (const { name, rid } of sheetDefs) {
|
|
88
|
+
let target = rels[rid]
|
|
89
|
+
if (!target) {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
// Targets are workbook-relative; normalize to the zip entry path
|
|
93
|
+
target = target.startsWith('/') ? target.slice(1) : 'xl/' + target
|
|
94
|
+
const bytes = unzipped[target]
|
|
95
|
+
if (!bytes) {
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
sheetNames.push(name)
|
|
99
|
+
sheetXmls[name] = bytes
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Lazy materialization: parse a sheet only when first accessed
|
|
103
|
+
const parsedSheets = Object.create(null)
|
|
104
|
+
const sheetsProxy = new Proxy(
|
|
105
|
+
{},
|
|
106
|
+
{
|
|
107
|
+
get(_, name) {
|
|
108
|
+
if (typeof name !== 'string') {
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
if (parsedSheets[name]) {
|
|
112
|
+
return parsedSheets[name]
|
|
113
|
+
}
|
|
114
|
+
const bytes = sheetXmls[name]
|
|
115
|
+
if (!bytes) {
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
const parsed = parseSheetXml(strFromU8(bytes), sharedStrings)
|
|
119
|
+
parsedSheets[name] = parsed
|
|
120
|
+
return parsed
|
|
121
|
+
},
|
|
122
|
+
has(_, name) {
|
|
123
|
+
return typeof name === 'string' && name in sheetXmls
|
|
124
|
+
},
|
|
125
|
+
ownKeys() {
|
|
126
|
+
return sheetNames.slice()
|
|
127
|
+
},
|
|
128
|
+
getOwnPropertyDescriptor(_, name) {
|
|
129
|
+
if (typeof name !== 'string' || !(name in sheetXmls)) {
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
const bytes = sheetXmls[name]
|
|
133
|
+
if (!parsedSheets[name]) {
|
|
134
|
+
parsedSheets[name] = parseSheetXml(strFromU8(bytes), sharedStrings)
|
|
135
|
+
}
|
|
136
|
+
return { enumerable: true, configurable: true, value: parsedSheets[name], writable: false }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
// Cache the workbook by path so subsequent reads are free
|
|
142
|
+
const workbook = { SheetNames: sheetNames, Sheets: sheetsProxy }
|
|
143
|
+
workbookCache.set(pathname, workbook)
|
|
144
|
+
return workbook
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
//
|
|
148
|
+
// Cell address utilities
|
|
149
|
+
//
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Decode an A1-style cell ref to a zero-indexed `{c, r}`.
|
|
153
|
+
*
|
|
154
|
+
* Returns `{c: -1, r: -1}` for invalid input, matching the behavior of
|
|
155
|
+
* `XLSX.utils.decode_cell` from SheetJS.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} ref The cell reference (e.g. 'A1', 'AZ100').
|
|
158
|
+
* @returns The zero-indexed column and row.
|
|
159
|
+
*/
|
|
160
|
+
export function decodeCell(ref) {
|
|
161
|
+
// Walk the leading run of letters, accumulating the 1-based column index
|
|
162
|
+
// in bijective base 26 (A=1, B=2, ..., Z=26, AA=27, AB=28, ...)
|
|
163
|
+
let c = 0
|
|
164
|
+
let i = 0
|
|
165
|
+
const len = ref.length
|
|
166
|
+
while (i < len) {
|
|
167
|
+
const code = ref.charCodeAt(i)
|
|
168
|
+
if (code >= 65 && code <= 90) {
|
|
169
|
+
c = c * 26 + (code - A_UPPER + 1)
|
|
170
|
+
} else if (code >= 97 && code <= 122) {
|
|
171
|
+
c = c * 26 + (code - A_LOWER + 1)
|
|
172
|
+
} else {
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
i++
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Reject input that didn't start with at least one letter
|
|
179
|
+
if (i === 0) {
|
|
180
|
+
return { c: -1, r: -1 }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Parse the trailing row digits; rows are 1-based, so reject 0/non-numeric
|
|
184
|
+
const r = parseInt(ref.slice(i), 10)
|
|
185
|
+
if (!Number.isFinite(r) || r < 1) {
|
|
186
|
+
return { c: -1, r: -1 }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Convert column and row to the zero-indexed form callers expect
|
|
190
|
+
return { c: c - 1, r: r - 1 }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Encode a zero-indexed `{c, r}` to an A1-style cell ref.
|
|
195
|
+
*
|
|
196
|
+
* @param {{c: number, r: number}} addr The zero-indexed column and row.
|
|
197
|
+
* @returns The A1-style cell reference (e.g. 'B7').
|
|
198
|
+
*/
|
|
199
|
+
export function encodeCell({ c, r }) {
|
|
200
|
+
// Convert the 1-based column index to bijective base-26 letters, building
|
|
201
|
+
// the string right-to-left (one letter per iteration, least-significant first)
|
|
202
|
+
let col = ''
|
|
203
|
+
let n = c + 1
|
|
204
|
+
while (n > 0) {
|
|
205
|
+
const rem = (n - 1) % 26
|
|
206
|
+
col = String.fromCharCode(A_UPPER + rem) + col
|
|
207
|
+
n = Math.floor((n - 1) / 26)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Rows in A1 notation are 1-based
|
|
211
|
+
return col + (r + 1)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Decode a column ref (e.g. 'AB') to a zero-indexed column number.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} ref The column reference.
|
|
218
|
+
* @returns The zero-indexed column number, or -1 if the input is invalid.
|
|
219
|
+
*/
|
|
220
|
+
export function decodeCol(ref) {
|
|
221
|
+
// Empty input has no valid column
|
|
222
|
+
if (ref.length === 0) {
|
|
223
|
+
return -1
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Same bijective base-26 walk as decodeCell, but every character must be a
|
|
227
|
+
// letter — anything else (including a row digit) is rejected
|
|
228
|
+
let c = 0
|
|
229
|
+
for (let i = 0; i < ref.length; i++) {
|
|
230
|
+
const code = ref.charCodeAt(i)
|
|
231
|
+
if (code >= 65 && code <= 90) {
|
|
232
|
+
c = c * 26 + (code - A_UPPER + 1)
|
|
233
|
+
} else if (code >= 97 && code <= 122) {
|
|
234
|
+
c = c * 26 + (code - A_LOWER + 1)
|
|
235
|
+
} else {
|
|
236
|
+
return -1
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Convert from 1-based to zero-indexed
|
|
241
|
+
return c - 1
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Decode a row ref (e.g. '13') to a zero-indexed row number.
|
|
246
|
+
*
|
|
247
|
+
* @param {string} ref The row reference.
|
|
248
|
+
* @returns The zero-indexed row number, or -1 if the input is invalid.
|
|
249
|
+
*/
|
|
250
|
+
export function decodeRow(ref) {
|
|
251
|
+
// Parse the 1-based row number and convert to zero-indexed; -1 on invalid input
|
|
252
|
+
const r = parseInt(ref, 10)
|
|
253
|
+
return Number.isFinite(r) && r >= 1 ? r - 1 : -1
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
//
|
|
257
|
+
// XML helpers
|
|
258
|
+
//
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Pull one attribute value out of a tag's attribute list. Cheaper than a full
|
|
262
|
+
* attribute parser when we only need a few specific keys.
|
|
263
|
+
*
|
|
264
|
+
* @param {string} attrs The portion of a start tag containing the attributes.
|
|
265
|
+
* @param {string} name The attribute name to look up.
|
|
266
|
+
* @returns The attribute value, or undefined if the attribute is not present.
|
|
267
|
+
*/
|
|
268
|
+
function getAttr(attrs, name) {
|
|
269
|
+
// Look for `name="` — every attribute we care about uses double quotes
|
|
270
|
+
const i = attrs.indexOf(name + '="')
|
|
271
|
+
if (i < 0) {
|
|
272
|
+
return undefined
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Slice out everything between the opening and closing quote
|
|
276
|
+
const start = i + name.length + 2
|
|
277
|
+
const end = attrs.indexOf('"', start)
|
|
278
|
+
return end < 0 ? undefined : attrs.slice(start, end)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Normalize line endings the way a conformant XML parser (and SheetJS) does:
|
|
283
|
+
* `\r\n` and lone `\r` both become `\n`. Applied after entity decoding so a
|
|
284
|
+
* CR encoded as ` ` is normalized too.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} s The text to normalize.
|
|
287
|
+
* @returns The normalized string.
|
|
288
|
+
*/
|
|
289
|
+
function normalizeEol(s) {
|
|
290
|
+
return s.indexOf('\r') === -1 ? s : s.replace(/\r\n?/g, '\n')
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Decode the standard XML entities (`<`, `>`, `&`, `"`,
|
|
295
|
+
* `'`) along with numeric character references (`&#NN;` and `&#xNN;`)
|
|
296
|
+
* in the given text, and normalize line endings to `\n`.
|
|
297
|
+
*
|
|
298
|
+
* @param {string} s The raw text from an XML element body or attribute.
|
|
299
|
+
* @returns The decoded string.
|
|
300
|
+
*/
|
|
301
|
+
function decodeXmlText(s) {
|
|
302
|
+
// Fast path: most cell text contains no entities, so skip the regex chain
|
|
303
|
+
if (s.indexOf('&') === -1) {
|
|
304
|
+
return normalizeEol(s)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Decode the named entities, then decimal and hex numeric refs, and finally
|
|
308
|
+
// `&` — leaving `&` last avoids accidentally producing `<` etc.
|
|
309
|
+
// from a literal `&lt;` in the source
|
|
310
|
+
return normalizeEol(
|
|
311
|
+
s
|
|
312
|
+
.replace(/</g, '<')
|
|
313
|
+
.replace(/>/g, '>')
|
|
314
|
+
.replace(/"/g, '"')
|
|
315
|
+
.replace(/'/g, "'")
|
|
316
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
|
|
317
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
|
|
318
|
+
.replace(/&/g, '&')
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
//
|
|
323
|
+
// Parsers
|
|
324
|
+
//
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Parse the contents of `xl/sharedStrings.xml` into a positional array of
|
|
328
|
+
* decoded strings. A shared string may contain rich-text runs
|
|
329
|
+
* (`<si><r><t>foo</t></r><r><t>bar</t></r></si>`); concatenate all `<t>`
|
|
330
|
+
* elements within each `<si>` so the result comes back as a single string.
|
|
331
|
+
*
|
|
332
|
+
* @param {string} xml The contents of the `xl/sharedStrings.xml` file.
|
|
333
|
+
* @returns An array indexed by shared-string position.
|
|
334
|
+
*/
|
|
335
|
+
function parseSharedStrings(xml) {
|
|
336
|
+
// Outer regex finds each <si> entry; inner regex finds <t> runs within it.
|
|
337
|
+
// The two are stateful (global) regexes, so reset the inner one per <si>.
|
|
338
|
+
const strings = []
|
|
339
|
+
const siRe = /<si\b[^>]*>([\s\S]*?)<\/si>/g
|
|
340
|
+
const tRe = /<t\b[^>]*>([\s\S]*?)<\/t>/g
|
|
341
|
+
let m
|
|
342
|
+
while ((m = siRe.exec(xml)) !== null) {
|
|
343
|
+
// Concatenate every <t> chunk inside this <si> — rich-text runs collapse
|
|
344
|
+
// into a single plain string
|
|
345
|
+
const inner = m[1]
|
|
346
|
+
let s = ''
|
|
347
|
+
let tm
|
|
348
|
+
tRe.lastIndex = 0
|
|
349
|
+
while ((tm = tRe.exec(inner)) !== null) {
|
|
350
|
+
s += decodeXmlText(tm[1])
|
|
351
|
+
}
|
|
352
|
+
strings.push(s)
|
|
353
|
+
}
|
|
354
|
+
return strings
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Parse the contents of `xl/workbook.xml` into the list of sheet definitions,
|
|
359
|
+
* each with the user-visible sheet name and the relationship id that maps to
|
|
360
|
+
* the sheet's XML part. Attribute spans can contain `/` (in URLs), so the
|
|
361
|
+
* regex matches up to the self-closing `/>` non-greedily rather than excluding
|
|
362
|
+
* `/` from the attribute span.
|
|
363
|
+
*
|
|
364
|
+
* @param {string} xml The contents of the `xl/workbook.xml` file.
|
|
365
|
+
* @returns An array of `{ name, rid }` objects in workbook order.
|
|
366
|
+
*/
|
|
367
|
+
function parseWorkbookXml(xml) {
|
|
368
|
+
// Iterate every <sheet .../> element in workbook order
|
|
369
|
+
const sheets = []
|
|
370
|
+
const re = /<sheet\b([\s\S]*?)\/>/g
|
|
371
|
+
let m
|
|
372
|
+
while ((m = re.exec(xml)) !== null) {
|
|
373
|
+
// Each sheet is identified by its user-visible name and its rid pointer;
|
|
374
|
+
// the rid attribute is conventionally lowercase but accept both forms
|
|
375
|
+
const attrs = m[1]
|
|
376
|
+
const name = getAttr(attrs, 'name')
|
|
377
|
+
const rid = getAttr(attrs, 'r:id') ?? getAttr(attrs, 'r:Id')
|
|
378
|
+
if (name && rid) {
|
|
379
|
+
sheets.push({ name, rid })
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return sheets
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Parse the contents of `xl/_rels/workbook.xml.rels` into a map from
|
|
387
|
+
* relationship id to its target path (the part within the xlsx zip).
|
|
388
|
+
*
|
|
389
|
+
* @param {string} xml The contents of the `xl/_rels/workbook.xml.rels` file.
|
|
390
|
+
* @returns An object mapping relationship id to target path.
|
|
391
|
+
*/
|
|
392
|
+
function parseWorkbookRels(xml) {
|
|
393
|
+
// Iterate every <Relationship .../> element
|
|
394
|
+
const rels = Object.create(null)
|
|
395
|
+
const re = /<Relationship\b([\s\S]*?)\/>/g
|
|
396
|
+
let m
|
|
397
|
+
while ((m = re.exec(xml)) !== null) {
|
|
398
|
+
// Record the Id -> Target mapping; ignore the Type and other attributes
|
|
399
|
+
const attrs = m[1]
|
|
400
|
+
const id = getAttr(attrs, 'Id')
|
|
401
|
+
const target = getAttr(attrs, 'Target')
|
|
402
|
+
if (id && target) {
|
|
403
|
+
rels[id] = target
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return rels
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Extract the text content of the `<v>` element in a cell body, tolerating
|
|
411
|
+
* attributes on the tag (e.g. `<v xml:space="preserve">`). Returns undefined
|
|
412
|
+
* when there is no `<v>` element (e.g. an uncalculated formula cell).
|
|
413
|
+
*
|
|
414
|
+
* @param {string} body The inner XML of a `<c>` element.
|
|
415
|
+
* @returns The raw text between `<v...>` and `</v>`, or undefined.
|
|
416
|
+
*/
|
|
417
|
+
function getVText(body) {
|
|
418
|
+
const m = /<v\b[^>]*>([\s\S]*?)<\/v>/.exec(body)
|
|
419
|
+
return m ? m[1] : undefined
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Scan a worksheet's XML and build a sparse cell map shaped like the SheetJS
|
|
424
|
+
* worksheet object: `{ [cellRef]: { v }, '!ref': 'A1:Z99' }`. Skips empty
|
|
425
|
+
* cells, error cells (`t='e'`), and numeric cells whose cached `<v>` value
|
|
426
|
+
* is missing.
|
|
427
|
+
*
|
|
428
|
+
* @param {string} xml The contents of a `xl/worksheets/sheet*.xml` file.
|
|
429
|
+
* @param {string[]} sharedStrings The shared-string table for resolving `t='s'` cells.
|
|
430
|
+
* @returns The sparse cell map, including a `!ref` key if any cells were read.
|
|
431
|
+
*/
|
|
432
|
+
function parseSheetXml(xml, sharedStrings) {
|
|
433
|
+
const cells = Object.create(null)
|
|
434
|
+
|
|
435
|
+
// Match each <c .../> or <c ...>...</c> block. The attribute span is
|
|
436
|
+
// non-greedy so self-closing cells (e.g. <c r="I4" s="1"/>) don't accidentally
|
|
437
|
+
// swallow following cells.
|
|
438
|
+
const cRe = /<c\b([^>]*?)(\/>|>([\s\S]*?)<\/c>)/g
|
|
439
|
+
let maxRow = -1
|
|
440
|
+
let maxCol = -1
|
|
441
|
+
let m
|
|
442
|
+
while ((m = cRe.exec(xml)) !== null) {
|
|
443
|
+
const attrs = m[1]
|
|
444
|
+
const ref = getAttr(attrs, 'r')
|
|
445
|
+
if (!ref) {
|
|
446
|
+
continue
|
|
447
|
+
}
|
|
448
|
+
if (m[2] === '/>') {
|
|
449
|
+
// empty cell
|
|
450
|
+
continue
|
|
451
|
+
}
|
|
452
|
+
const body = m[3]
|
|
453
|
+
if (!body) {
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
const t = getAttr(attrs, 't')
|
|
457
|
+
|
|
458
|
+
let value
|
|
459
|
+
if (t === 'inlineStr') {
|
|
460
|
+
// Inline string: <is><t>...</t></is>
|
|
461
|
+
const tStart = body.indexOf('<t')
|
|
462
|
+
if (tStart < 0) {
|
|
463
|
+
continue
|
|
464
|
+
}
|
|
465
|
+
const tOpenEnd = body.indexOf('>', tStart)
|
|
466
|
+
const tEnd = body.indexOf('</t>', tOpenEnd)
|
|
467
|
+
value = decodeXmlText(body.slice(tOpenEnd + 1, tEnd))
|
|
468
|
+
} else if (t === 'e') {
|
|
469
|
+
// Error cell, skip
|
|
470
|
+
continue
|
|
471
|
+
} else {
|
|
472
|
+
// The remaining cell types carry their value in a <v> element, which
|
|
473
|
+
// may have attributes (e.g. <v xml:space="preserve">). If <v> is
|
|
474
|
+
// missing (e.g. an uncalculated formula), skip the cell so the
|
|
475
|
+
// caller's missing-cell handling kicks in.
|
|
476
|
+
const vText = getVText(body)
|
|
477
|
+
if (vText === undefined) {
|
|
478
|
+
continue
|
|
479
|
+
}
|
|
480
|
+
if (t === 's') {
|
|
481
|
+
// Shared string: <v>N</v> where N indexes sharedStrings
|
|
482
|
+
value = sharedStrings[parseInt(vText, 10)]
|
|
483
|
+
} else if (t === 'str') {
|
|
484
|
+
// Formula result as string: <v>...</v>
|
|
485
|
+
value = decodeXmlText(vText)
|
|
486
|
+
} else if (t === 'b') {
|
|
487
|
+
// Boolean: <v>0</v> or <v>1</v>
|
|
488
|
+
value = vText.charCodeAt(0) === 49 // '1'
|
|
489
|
+
} else {
|
|
490
|
+
// Numeric (t === 'n' or absent)
|
|
491
|
+
const num = +vText
|
|
492
|
+
if (Number.isNaN(num)) {
|
|
493
|
+
continue
|
|
494
|
+
}
|
|
495
|
+
value = num
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Store the cell under its A1 ref, matching the SheetJS sheet shape
|
|
500
|
+
cells[ref] = { v: value }
|
|
501
|
+
|
|
502
|
+
// Track the bounding row/col so we can synthesize the !ref range below
|
|
503
|
+
const addr = decodeCell(ref)
|
|
504
|
+
if (addr.r > maxRow) {
|
|
505
|
+
maxRow = addr.r
|
|
506
|
+
}
|
|
507
|
+
if (addr.c > maxCol) {
|
|
508
|
+
maxCol = addr.c
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Expose the sheet's bounding range as !ref when any cells were read
|
|
513
|
+
if (maxRow >= 0) {
|
|
514
|
+
cells['!ref'] = `A1:${encodeCell({ c: maxCol, r: maxRow })}`
|
|
515
|
+
}
|
|
516
|
+
return cells
|
|
517
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
|
|
3
|
-
import XLSX from 'xlsx'
|
|
4
|
-
|
|
5
3
|
import { cdbl, readCsv, readXlsx } from '../_shared/helpers.js'
|
|
4
|
+
import { encodeCell } from '../_shared/xlsx.js'
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Return a `getCellValue` function that reads the CSV or XLS[X] content.
|
|
@@ -47,7 +46,7 @@ function handleExcelWorkbook(fileOrTag, workbook, tab, dataKind, dataSource) {
|
|
|
47
46
|
let sheet = workbook.Sheets[tab]
|
|
48
47
|
if (sheet) {
|
|
49
48
|
return (c, r) => {
|
|
50
|
-
let cell = sheet[
|
|
49
|
+
let cell = sheet[encodeCell({ c, r })]
|
|
51
50
|
if (cell == null || cell.v === '') {
|
|
52
51
|
return null
|
|
53
52
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import XLSX from 'xlsx'
|
|
2
|
-
|
|
3
1
|
import { cartesianProductOf } from '../_shared/helpers.js'
|
|
4
2
|
import { indexInSepDim, isDimension, sub } from '../_shared/subscript.js'
|
|
3
|
+
import { decodeCell } from '../_shared/xlsx.js'
|
|
5
4
|
|
|
6
5
|
import { handleExcelOrCsvFile } from './direct-data-helpers.js'
|
|
7
6
|
|
|
@@ -73,7 +72,7 @@ export function generateDirectConstInit(variable, directData, modelDir) {
|
|
|
73
72
|
// Read tabular data into an indexed variable for each cell.
|
|
74
73
|
let numericSubscripts = lhsIndexSubscripts.map(idx => idx.map(s => sub(s).value))
|
|
75
74
|
let lhsSubscripts = numericSubscripts.map(s => s.reduce((a, v) => a.concat(`[${v}]`), ''))
|
|
76
|
-
let dataAddress =
|
|
75
|
+
let dataAddress = decodeCell(startCell.toUpperCase())
|
|
77
76
|
let startCol = dataAddress.c
|
|
78
77
|
let startRow = dataAddress.r
|
|
79
78
|
if (startCol < 0 || startRow < 0) {
|
|
@@ -128,6 +128,9 @@ export function generateEquation(variable, mode, extData, directData, modelDir,
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// Keep a buffer of code that will be included before all subscript loops
|
|
132
|
+
const preLoopLines = []
|
|
133
|
+
|
|
131
134
|
// Keep a buffer of code that will be included before the innermost loop
|
|
132
135
|
const preInnerLoopLines = []
|
|
133
136
|
|
|
@@ -145,6 +148,7 @@ export function generateEquation(variable, mode, extData, directData, modelDir,
|
|
|
145
148
|
cLhs,
|
|
146
149
|
loopIndexVars,
|
|
147
150
|
arrayIndexVars,
|
|
151
|
+
emitPreLoop: s => preLoopLines.push(s),
|
|
148
152
|
emitPreInnerLoop: s => preInnerLoopLines.push(s),
|
|
149
153
|
emitPreFormula: s => preFormulaLines.push(s),
|
|
150
154
|
emitPostFormula: s => postFormulaLines.push(s),
|
|
@@ -161,7 +165,7 @@ export function generateEquation(variable, mode, extData, directData, modelDir,
|
|
|
161
165
|
}
|
|
162
166
|
|
|
163
167
|
// Combine all lines of comments and code into a single array
|
|
164
|
-
return [comment, ...openLoops, ...preFormulaLines, formula, ...postFormulaLines, ...closeLoops]
|
|
168
|
+
return [comment, ...preLoopLines, ...openLoops, ...preFormulaLines, formula, ...postFormulaLines, ...closeLoops]
|
|
165
169
|
}
|
|
166
170
|
|
|
167
171
|
/**
|