datareader-spss 0.1.2
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 +21 -0
- package/README.md +311 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +597 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 everydaydesign
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# datareader-spss
|
|
2
|
+
|
|
3
|
+
**Correct, zero-dependency SPSS `.sav`/`.zsav` reader for the browser and Node — validated against R [`haven`](https://haven.tidyverse.org/).**
|
|
4
|
+
|
|
5
|
+
`datareader-spss` parses IBM SPSS system files (`.sav`) and their compressed variant (`.zsav`) into plain
|
|
6
|
+
JavaScript values: numbers, strings, `Date`s, variable metadata, value labels, and declared missing
|
|
7
|
+
values. It is written in pure TypeScript against Web platform APIs (`ArrayBuffer`, `DataView`,
|
|
8
|
+
`TextDecoder`, `DecompressionStream`) — **no runtime dependencies**, and the same build runs in the
|
|
9
|
+
browser and in Node.
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
SPSS `.sav` files are everywhere in the social sciences, market research, and public data, but the
|
|
14
|
+
JavaScript ecosystem lacked a reader that decodes *real* files correctly — most tools stumble on RLE
|
|
15
|
+
compression, ZSAV (zlib) blocks, very-long strings, encodings, or the exact bit-level meaning of
|
|
16
|
+
system- vs. user-missing values. Getting any of those wrong silently corrupts data.
|
|
17
|
+
|
|
18
|
+
`datareader-spss` was built to be correct first: every construct is validated value-for-value against R's
|
|
19
|
+
`haven`/`ReadStat`, the de-facto reference implementation. It uses only Web APIs, so it ships as a
|
|
20
|
+
single ESM module with zero dependencies and no native addons — drop it into a browser upload flow
|
|
21
|
+
or a Node script alike.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm i datareader-spss
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bun add datareader-spss
|
|
31
|
+
pnpm add datareader-spss
|
|
32
|
+
yarn add datareader-spss
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
### Browser — a picked/dropped `File`
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { readSav, applyUserMissing } from "datareader-spss";
|
|
41
|
+
|
|
42
|
+
const input = document.querySelector<HTMLInputElement>("#file");
|
|
43
|
+
input.addEventListener("change", async () => {
|
|
44
|
+
const file = input.files?.[0];
|
|
45
|
+
if (!file) return;
|
|
46
|
+
|
|
47
|
+
const parsed = await readSav(await file.arrayBuffer());
|
|
48
|
+
const sheet = parsed.sheets[0];
|
|
49
|
+
|
|
50
|
+
console.log(sheet.variables.map((v) => v.name)); // column names
|
|
51
|
+
console.log(sheet.rows.length); // row count
|
|
52
|
+
|
|
53
|
+
// Replace every declared user-missing cell with null before analysis:
|
|
54
|
+
const clean = applyUserMissing(sheet);
|
|
55
|
+
console.log(clean.rows[0]);
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### React — a file input
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import { useState } from "react";
|
|
63
|
+
import { readSav, applyUserMissing, type Sheet } from "datareader-spss";
|
|
64
|
+
|
|
65
|
+
export function SavImporter() {
|
|
66
|
+
const [sheet, setSheet] = useState<Sheet | null>(null);
|
|
67
|
+
|
|
68
|
+
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
|
69
|
+
const file = e.target.files?.[0];
|
|
70
|
+
if (!file) return;
|
|
71
|
+
const { sheets } = await readSav(await file.arrayBuffer());
|
|
72
|
+
setSheet(applyUserMissing(sheets[0]));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
<input type="file" accept=".sav" onChange={onFile} />
|
|
78
|
+
{sheet && (
|
|
79
|
+
<p>
|
|
80
|
+
{sheet.rows.length} rows × {sheet.variables.length} variables
|
|
81
|
+
</p>
|
|
82
|
+
)}
|
|
83
|
+
</>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Node — a file on disk
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { readFile } from "node:fs/promises";
|
|
92
|
+
import { readSav } from "datareader-spss";
|
|
93
|
+
|
|
94
|
+
const nodeBuf = await readFile("survey.sav");
|
|
95
|
+
|
|
96
|
+
// Slice an exact ArrayBuffer (Node pools Buffers behind a shared store):
|
|
97
|
+
const bytes = nodeBuf.buffer.slice(
|
|
98
|
+
nodeBuf.byteOffset,
|
|
99
|
+
nodeBuf.byteOffset + nodeBuf.byteLength,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const parsed = await readSav(bytes);
|
|
103
|
+
for (const v of parsed.sheets[0].variables) {
|
|
104
|
+
console.log(v.name, v.type, v.label ?? "");
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Reading variables and rows
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { readSav, type CellValue } from "datareader-spss";
|
|
112
|
+
|
|
113
|
+
const { sheets, encoding } = await readSav(buffer);
|
|
114
|
+
const { variables, rows } = sheets[0];
|
|
115
|
+
|
|
116
|
+
// `rows` is CellValue[][], column-aligned to `variables` by index.
|
|
117
|
+
const genderCol = variables.findIndex((v) => v.name === "gender");
|
|
118
|
+
const genderValues: CellValue[] = rows.map((row) => row[genderCol]);
|
|
119
|
+
|
|
120
|
+
// A variable's value labels (e.g. 1 -> "Male", 2 -> "Female"):
|
|
121
|
+
const gender = variables[genderCol];
|
|
122
|
+
console.log(gender.valueLabels); // [{ value: 1, label: "Male" }, ...]
|
|
123
|
+
console.log(encoding); // e.g. "utf-8" or "windows-1252"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## API
|
|
127
|
+
|
|
128
|
+
### `readSav(buf, opts?)`
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
function readSav(
|
|
132
|
+
buf: ArrayBuffer,
|
|
133
|
+
opts?: Partial<SavLimits>,
|
|
134
|
+
): Promise<ParsedFile>;
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Reads a `.sav`/`.zsav` file end-to-end (header → dictionary → extensions → variables → data cases,
|
|
138
|
+
inflating ZSAV blocks as needed) and resolves to a `ParsedFile`. `opts` tightens the resource
|
|
139
|
+
ceilings (see [Security & limits](#security--limits)). Rejects with a `SavError` on malformed or
|
|
140
|
+
hostile input.
|
|
141
|
+
|
|
142
|
+
### `applyUserMissing(sheet)`
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
function applyUserMissing(sheet: Sheet): Sheet;
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Returns a **new** `Sheet` in which every cell matching its variable's declared `MissingSpec` is
|
|
149
|
+
replaced by `null` (user-missing → system-missing). Non-mutating — the input sheet is untouched. Use
|
|
150
|
+
it when you want declared missing codes treated as missing; skip it when you need the literal codes.
|
|
151
|
+
|
|
152
|
+
### `SavError`
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
class SavError extends Error {}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Thrown when a resource bound rejects a malformed or hostile file (a bad file, not a reader bug).
|
|
159
|
+
Because it extends `Error`, a plain `catch` still catches it; check `err instanceof SavError` to
|
|
160
|
+
distinguish a rejected/hostile file from a genuinely unsupported-but-valid construct (which throws a
|
|
161
|
+
plain `Error`).
|
|
162
|
+
|
|
163
|
+
### `DEFAULT_LIMITS`
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
const DEFAULT_LIMITS: SavLimits = {
|
|
167
|
+
// ncases × nvars ceiling (~500k rows × 100 vars)
|
|
168
|
+
maxCells: 50_000_000,
|
|
169
|
+
// ZSAV inflate output ceiling (512 MiB)
|
|
170
|
+
maxInflatedBytes: 512 * 1024 * 1024,
|
|
171
|
+
};
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The generous defaults that `readSav` merges your `opts` over. No well-formed file is affected.
|
|
175
|
+
|
|
176
|
+
### Types
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
// null = system-missing
|
|
180
|
+
type CellValue = string | number | Date | null;
|
|
181
|
+
|
|
182
|
+
type Measure = "nominal" | "ordinal" | "scale" | "unknown";
|
|
183
|
+
|
|
184
|
+
type MissingSpec =
|
|
185
|
+
| { kind: "none" }
|
|
186
|
+
| { kind: "discrete"; values: number[] }
|
|
187
|
+
| { kind: "range"; lo: number; hi: number }
|
|
188
|
+
| { kind: "range+discrete"; lo: number; hi: number; value: number }
|
|
189
|
+
| { kind: "strings"; values: string[] };
|
|
190
|
+
|
|
191
|
+
type SpssFormat = {
|
|
192
|
+
type: number;
|
|
193
|
+
width: number;
|
|
194
|
+
decimals: number;
|
|
195
|
+
isDate: boolean;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
type Variable = {
|
|
199
|
+
name: string;
|
|
200
|
+
label?: string;
|
|
201
|
+
type: "numeric" | "string";
|
|
202
|
+
// string byte width, or the merged width for very-long strings
|
|
203
|
+
width?: number;
|
|
204
|
+
missing: MissingSpec;
|
|
205
|
+
valueLabels?: Array<{ value: CellValue; label: string }>;
|
|
206
|
+
format: SpssFormat;
|
|
207
|
+
measure: Measure;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
type Sheet = {
|
|
211
|
+
name: string;
|
|
212
|
+
variables: Variable[];
|
|
213
|
+
rows: CellValue[][];
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
type ParsedFile = {
|
|
217
|
+
format: "sav";
|
|
218
|
+
encoding?: string;
|
|
219
|
+
sheets: Sheet[];
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
type SavLimits = {
|
|
223
|
+
maxCells: number;
|
|
224
|
+
maxInflatedBytes: number;
|
|
225
|
+
};
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
For a `.sav` file, `sheets` always contains exactly one sheet (named `"data"`); `rows` is row-major
|
|
229
|
+
and column-aligned to `variables`.
|
|
230
|
+
|
|
231
|
+
**Missing values.** `null` in a cell is always **system-missing**. Declared **user-missing** values
|
|
232
|
+
(a survey's `99 = "no answer"`, or a range) are kept as their literal number/string so you can see
|
|
233
|
+
them — call `applyUserMissing(sheet)` to fold them to `null`. A variable's declaration is on
|
|
234
|
+
`variable.missing`:
|
|
235
|
+
|
|
236
|
+
- `{ kind: "none" }` — no declared missing values.
|
|
237
|
+
- `{ kind: "discrete", values }` — up to three discrete codes.
|
|
238
|
+
- `{ kind: "range", lo, hi }` — a missing range `[lo, hi]`.
|
|
239
|
+
- `{ kind: "range+discrete", lo, hi, value }` — a range plus one discrete code.
|
|
240
|
+
- `{ kind: "strings", values }` — discrete codes for a string variable.
|
|
241
|
+
|
|
242
|
+
## Format coverage
|
|
243
|
+
|
|
244
|
+
| Area | Supported |
|
|
245
|
+
| -------------- | ------------------------------------------------------------------- |
|
|
246
|
+
| Magic | `$FL2` (uncompressed / RLE) and `$FL3` (ZSAV) |
|
|
247
|
+
| Compression | uncompressed, RLE (SPSS bytecode), ZSAV (zlib `deflate` blocks) |
|
|
248
|
+
| Variables | numeric, string, and very-long strings (> 255 bytes, segment-merged) |
|
|
249
|
+
| Dates | SPSS date/time formats decoded to JavaScript `Date` |
|
|
250
|
+
| Value labels | numeric and string value-label sets, resolved per variable |
|
|
251
|
+
| Missing values | discrete, range, range + discrete, and string missing specs |
|
|
252
|
+
| Encodings | file-declared encoding via `TextDecoder` (UTF-8, windows-125x, …) |
|
|
253
|
+
| Endianness | little-endian |
|
|
254
|
+
|
|
255
|
+
## Correctness
|
|
256
|
+
|
|
257
|
+
Every supported construct is validated **value-for-value against R `haven`** (which wraps the
|
|
258
|
+
`ReadStat` C library) across a fixture matrix covering compression modes, numeric/string/very-long
|
|
259
|
+
variables, dates, value labels, each missing-value kind, and encodings — plus a real-world 2.78 MB
|
|
260
|
+
survey file. The oracle fixtures live alongside the source and are asserted on every test run.
|
|
261
|
+
|
|
262
|
+
## Security & limits
|
|
263
|
+
|
|
264
|
+
`datareader-spss` is designed to parse **untrusted** files safely. A hostile `.sav` cannot make it OOM or
|
|
265
|
+
hang: every attacker-controlled allocation and loop is bounded, and any bound violation throws a
|
|
266
|
+
catchable `SavError` rather than exhausting memory or spinning.
|
|
267
|
+
|
|
268
|
+
- Reads never allocate beyond the remaining file bytes.
|
|
269
|
+
- The data-cell budget (`maxCells`) and the ZSAV inflate budget (`maxInflatedBytes`) are enforced
|
|
270
|
+
while streaming, so a decompression bomb aborts before it materializes.
|
|
271
|
+
- Malformed dictionaries, out-of-spec record counts, and truncated headers are rejected up front.
|
|
272
|
+
|
|
273
|
+
Tune the ceilings for memory-constrained environments:
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
await readSav(buf, {
|
|
277
|
+
maxCells: 5_000_000,
|
|
278
|
+
maxInflatedBytes: 64 * 1024 * 1024,
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Recommendations for consumers:
|
|
283
|
+
|
|
284
|
+
- Still bound the **input** size before you hand a buffer to `readSav` (reject files larger than you
|
|
285
|
+
expect for your use case).
|
|
286
|
+
- Treat `Variable.name` (and other file-derived strings) as **untrusted** — don't use a variable
|
|
287
|
+
name as a plain-object key without care; prefer a `Map` or a `null`-prototype object to avoid
|
|
288
|
+
prototype-pollution surprises from adversarial names.
|
|
289
|
+
|
|
290
|
+
## Roadmap
|
|
291
|
+
|
|
292
|
+
`datareader-spss` reads modern little-endian `.sav`/`.zsav` files correctly today, and is intentionally
|
|
293
|
+
read-only. On the map for future releases:
|
|
294
|
+
|
|
295
|
+
- **Big-endian system files** — the header already detects byte order; big-endian *data* reading and
|
|
296
|
+
`haven` validation are not yet implemented.
|
|
297
|
+
- **Portable `.por` format** — the older text-based SPSS portable format.
|
|
298
|
+
- **Writing `.sav` files** — the reader is read-only; a writer is a possible future addition.
|
|
299
|
+
|
|
300
|
+
Issues and contributions are welcome.
|
|
301
|
+
|
|
302
|
+
## License
|
|
303
|
+
|
|
304
|
+
MIT © 2026 everydaydesign
|
|
305
|
+
|
|
306
|
+
## Credits
|
|
307
|
+
|
|
308
|
+
- The [PSPP System File Format](https://www.gnu.org/software/pspp/pspp-dev/html_node/System-File-Format.html)
|
|
309
|
+
documentation — the specification this reader implements.
|
|
310
|
+
- R [`haven`](https://haven.tidyverse.org/) and [`ReadStat`](https://github.com/WizardMac/ReadStat) —
|
|
311
|
+
the validation oracle every fixture is checked against.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
type CellValue = string | number | Date | null;
|
|
2
|
+
type Measure = "nominal" | "ordinal" | "scale" | "unknown";
|
|
3
|
+
type MissingSpec = {
|
|
4
|
+
kind: "none";
|
|
5
|
+
} | {
|
|
6
|
+
kind: "discrete";
|
|
7
|
+
values: number[];
|
|
8
|
+
} | {
|
|
9
|
+
kind: "range";
|
|
10
|
+
lo: number;
|
|
11
|
+
hi: number;
|
|
12
|
+
} | {
|
|
13
|
+
kind: "range+discrete";
|
|
14
|
+
lo: number;
|
|
15
|
+
hi: number;
|
|
16
|
+
value: number;
|
|
17
|
+
} | {
|
|
18
|
+
kind: "strings";
|
|
19
|
+
values: string[];
|
|
20
|
+
};
|
|
21
|
+
type SpssFormat = {
|
|
22
|
+
type: number;
|
|
23
|
+
width: number;
|
|
24
|
+
decimals: number;
|
|
25
|
+
isDate: boolean;
|
|
26
|
+
};
|
|
27
|
+
type Variable = {
|
|
28
|
+
name: string;
|
|
29
|
+
label?: string;
|
|
30
|
+
type: "numeric" | "string";
|
|
31
|
+
width?: number;
|
|
32
|
+
missing: MissingSpec;
|
|
33
|
+
valueLabels?: Array<{
|
|
34
|
+
value: CellValue;
|
|
35
|
+
label: string;
|
|
36
|
+
}>;
|
|
37
|
+
format: SpssFormat;
|
|
38
|
+
measure: Measure;
|
|
39
|
+
};
|
|
40
|
+
type Format = "csv" | "tsv" | "txt" | "xlsx" | "sav";
|
|
41
|
+
type Sheet = {
|
|
42
|
+
name: string;
|
|
43
|
+
variables: Variable[];
|
|
44
|
+
rows: CellValue[][];
|
|
45
|
+
};
|
|
46
|
+
type ParsedFile = {
|
|
47
|
+
format: Format;
|
|
48
|
+
encoding?: string;
|
|
49
|
+
sheets: Sheet[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Thrown for any malformed or hostile `.sav` input that a resource bound rejects — a bad file, not
|
|
53
|
+
* a reader bug. Distinct from the plain `Error`s raised for unsupported-but-valid constructs so a
|
|
54
|
+
* caller can `catch` an attack (OOM/hang avoidance) separately if it wants to. */
|
|
55
|
+
declare class SavError extends Error {
|
|
56
|
+
}
|
|
57
|
+
/** Resource ceilings that keep a hostile `.sav` from exhausting memory or looping unboundedly. The
|
|
58
|
+
* defaults are generous, so no well-formed file is affected; pass a stricter `readSav(buf, opts)` in
|
|
59
|
+
* memory-constrained environments. */
|
|
60
|
+
type SavLimits = {
|
|
61
|
+
/** Maximum `ncases × nvars` data cells the reader will materialize (~500k rows × 100 vars). */
|
|
62
|
+
maxCells: number;
|
|
63
|
+
/** Cumulative inflate output ceiling: total bytes a ZSAV may inflate to before the reader aborts (512 MiB). */
|
|
64
|
+
maxInflatedBytes: number;
|
|
65
|
+
};
|
|
66
|
+
declare const DEFAULT_LIMITS: SavLimits;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Return a new {@link Sheet} in which every cell matching its variable's
|
|
70
|
+
* declared {@link MissingSpec} is replaced by `null` (user-missing → system-missing).
|
|
71
|
+
* Non-mutating: fresh `rows` and fresh inner arrays; the input is left untouched.
|
|
72
|
+
*/
|
|
73
|
+
declare function applyUserMissing(sheet: Sheet): Sheet;
|
|
74
|
+
|
|
75
|
+
/** Read an SPSS `.sav`/`.zsav` system file end-to-end: header → dictionary → extensions → variables
|
|
76
|
+
* → (inflate if ZSAV) → data cases. Matches R `haven` on the committed golden fixtures. `opts`
|
|
77
|
+
* tightens the resource ceilings (see `SavLimits`); a hostile file is rejected with a `SavError`. */
|
|
78
|
+
declare function readSav(buf: ArrayBuffer, opts?: Partial<SavLimits>): Promise<ParsedFile>;
|
|
79
|
+
|
|
80
|
+
export { type CellValue, DEFAULT_LIMITS, type Format, type Measure, type MissingSpec, type ParsedFile, SavError, type SavLimits, type Sheet, type SpssFormat, type Variable, applyUserMissing, readSav };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
// src/limits.ts
|
|
2
|
+
var SavError = class extends Error {
|
|
3
|
+
};
|
|
4
|
+
var DEFAULT_LIMITS = {
|
|
5
|
+
maxCells: 5e7,
|
|
6
|
+
maxInflatedBytes: 512 * 1024 * 1024
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// src/missing.ts
|
|
10
|
+
function numberMatches(cell, spec) {
|
|
11
|
+
switch (spec.kind) {
|
|
12
|
+
case "discrete":
|
|
13
|
+
return spec.values.includes(cell);
|
|
14
|
+
case "range":
|
|
15
|
+
return cell >= spec.lo && cell <= spec.hi;
|
|
16
|
+
case "range+discrete":
|
|
17
|
+
return cell >= spec.lo && cell <= spec.hi || cell === spec.value;
|
|
18
|
+
default:
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function isMissing(cell, spec) {
|
|
23
|
+
if (typeof cell === "number") return numberMatches(cell, spec);
|
|
24
|
+
if (typeof cell === "string") return spec.kind === "strings" && spec.values.includes(cell);
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
function applyUserMissing(sheet) {
|
|
28
|
+
const specs = sheet.variables.map((v) => v.missing);
|
|
29
|
+
const rows = sheet.rows.map(
|
|
30
|
+
(row) => row.map((cell, col) => isMissing(cell, specs[col]) ? null : cell)
|
|
31
|
+
);
|
|
32
|
+
return { ...sheet, rows };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/sav/binary.ts
|
|
36
|
+
var Cursor = class {
|
|
37
|
+
view;
|
|
38
|
+
pos = 0;
|
|
39
|
+
little = true;
|
|
40
|
+
constructor(buf) {
|
|
41
|
+
this.view = new DataView(buf);
|
|
42
|
+
}
|
|
43
|
+
get length() {
|
|
44
|
+
return this.view.byteLength;
|
|
45
|
+
}
|
|
46
|
+
seek(p) {
|
|
47
|
+
this.pos = p;
|
|
48
|
+
}
|
|
49
|
+
skip(n) {
|
|
50
|
+
this.pos += n;
|
|
51
|
+
}
|
|
52
|
+
readI32() {
|
|
53
|
+
const v = this.view.getInt32(this.pos, this.little);
|
|
54
|
+
this.pos += 4;
|
|
55
|
+
return v;
|
|
56
|
+
}
|
|
57
|
+
readF64() {
|
|
58
|
+
const v = this.view.getFloat64(this.pos, this.little);
|
|
59
|
+
this.pos += 8;
|
|
60
|
+
return v;
|
|
61
|
+
}
|
|
62
|
+
readBytes(n) {
|
|
63
|
+
if (n < 0 || this.pos + n > this.length) {
|
|
64
|
+
throw new SavError(`read of ${n} bytes exceeds the file`);
|
|
65
|
+
}
|
|
66
|
+
const out = new Uint8Array(n);
|
|
67
|
+
out.set(new Uint8Array(this.view.buffer, this.view.byteOffset + this.pos, n));
|
|
68
|
+
this.pos += n;
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
readStr(n, dec) {
|
|
72
|
+
return dec.decode(this.readBytes(n));
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// src/sav/dates.ts
|
|
77
|
+
var DATE_FORMAT_TYPES = /* @__PURE__ */ new Set([20, 22, 23, 24, 28, 29, 30, 38, 39]);
|
|
78
|
+
var SPSS_EPOCH_OFFSET_SECONDS = 12219379200;
|
|
79
|
+
function isDateFormat(type) {
|
|
80
|
+
return DATE_FORMAT_TYPES.has(type);
|
|
81
|
+
}
|
|
82
|
+
function spssToDate(seconds) {
|
|
83
|
+
return new Date((seconds - SPSS_EPOCH_OFFSET_SECONDS) * 1e3);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/sav/cases.ts
|
|
87
|
+
function toBunEncoding(enc) {
|
|
88
|
+
const e = enc.toLowerCase();
|
|
89
|
+
if (e.includes("1252") || e.includes("ansi") || e.includes("8859") || e.includes("latin")) {
|
|
90
|
+
return "windows-1252";
|
|
91
|
+
}
|
|
92
|
+
if (e.includes("16")) return "utf-16";
|
|
93
|
+
return "utf-8";
|
|
94
|
+
}
|
|
95
|
+
function readSegment(alloc, source) {
|
|
96
|
+
const chunks = Math.max(1, Math.ceil(alloc / 8));
|
|
97
|
+
const raw = new Uint8Array(chunks * 8);
|
|
98
|
+
for (let c = 0; c < chunks; c++) raw.set(source.nextString8(), c * 8);
|
|
99
|
+
return raw.subarray(0, alloc);
|
|
100
|
+
}
|
|
101
|
+
function readStringValue(segments, source, dec) {
|
|
102
|
+
const parts = [];
|
|
103
|
+
for (const alloc of segments) parts.push(readSegment(alloc, source));
|
|
104
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
105
|
+
const all = new Uint8Array(total);
|
|
106
|
+
let ofs = 0;
|
|
107
|
+
for (const p of parts) {
|
|
108
|
+
all.set(p, ofs);
|
|
109
|
+
ofs += p.length;
|
|
110
|
+
}
|
|
111
|
+
return dec.decode(all).replace(/ +$/, "");
|
|
112
|
+
}
|
|
113
|
+
function readNumericCell(variable, source) {
|
|
114
|
+
const v = source.nextNumeric();
|
|
115
|
+
if (v !== null && variable.format.isDate) return spssToDate(v);
|
|
116
|
+
return v;
|
|
117
|
+
}
|
|
118
|
+
function readCell(plan, source, dec) {
|
|
119
|
+
if (plan.segments.length > 0) return readStringValue(plan.segments, source, dec);
|
|
120
|
+
return readNumericCell(plan.variable, source);
|
|
121
|
+
}
|
|
122
|
+
function readCases(header, plans, dictInfo, source, limits) {
|
|
123
|
+
const dec = new TextDecoder(toBunEncoding(dictInfo.encoding));
|
|
124
|
+
const rows = [];
|
|
125
|
+
if (header.ncases >= 0) {
|
|
126
|
+
for (let i = 0; i < header.ncases; i++) {
|
|
127
|
+
rows.push(plans.map((plan) => readCell(plan, source, dec)));
|
|
128
|
+
}
|
|
129
|
+
return rows;
|
|
130
|
+
}
|
|
131
|
+
while (!source.atEnd()) {
|
|
132
|
+
rows.push(plans.map((plan) => readCell(plan, source, dec)));
|
|
133
|
+
if (rows.length * plans.length > limits.maxCells) {
|
|
134
|
+
throw new SavError("case count exceeds cell limit");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return rows;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/sav/variable.ts
|
|
141
|
+
var VALID_NMISSING = /* @__PURE__ */ new Set([-3, -2, 0, 1, 2, 3]);
|
|
142
|
+
function decodeFormat(packed, isDate = () => false) {
|
|
143
|
+
const decimals = packed & 255;
|
|
144
|
+
const width = packed >> 8 & 255;
|
|
145
|
+
const type = packed >> 16 & 255;
|
|
146
|
+
return { type, width, decimals, isDate: isDate(type) };
|
|
147
|
+
}
|
|
148
|
+
function decodeMissing(n, values) {
|
|
149
|
+
if (n === 0) return { kind: "none" };
|
|
150
|
+
if (n > 0) return { kind: "discrete", values };
|
|
151
|
+
if (n === -2) return { kind: "range", lo: values[0], hi: values[1] };
|
|
152
|
+
return { kind: "range+discrete", lo: values[0], hi: values[1], value: values[2] };
|
|
153
|
+
}
|
|
154
|
+
function readMissing(cur, nMissing, type, dec) {
|
|
155
|
+
const count = Math.abs(nMissing);
|
|
156
|
+
if (type > 0) {
|
|
157
|
+
const values = [];
|
|
158
|
+
for (let i = 0; i < count; i++) values.push(cur.readStr(8, dec).replace(/ +$/, ""));
|
|
159
|
+
return count > 0 ? { kind: "strings", values } : { kind: "none" };
|
|
160
|
+
}
|
|
161
|
+
const nums = [];
|
|
162
|
+
for (let i = 0; i < count; i++) nums.push(cur.readF64());
|
|
163
|
+
return decodeMissing(nMissing, nums);
|
|
164
|
+
}
|
|
165
|
+
function readVariableRecord(cur, dec) {
|
|
166
|
+
const type = cur.readI32();
|
|
167
|
+
if (type === -1) {
|
|
168
|
+
cur.skip(24);
|
|
169
|
+
return "continuation";
|
|
170
|
+
}
|
|
171
|
+
const hasLabel = cur.readI32();
|
|
172
|
+
const nMissing = cur.readI32();
|
|
173
|
+
if (!VALID_NMISSING.has(nMissing)) throw new SavError("invalid missing-value count");
|
|
174
|
+
const print = decodeFormat(cur.readI32());
|
|
175
|
+
const write = decodeFormat(cur.readI32());
|
|
176
|
+
const name = cur.readStr(8, dec).replace(/[\s\0]+$/, "");
|
|
177
|
+
let label;
|
|
178
|
+
if (hasLabel) {
|
|
179
|
+
const labelLen = cur.readI32();
|
|
180
|
+
label = cur.readStr(labelLen, dec);
|
|
181
|
+
cur.skip((4 - labelLen % 4) % 4);
|
|
182
|
+
}
|
|
183
|
+
const missing = readMissing(cur, nMissing, type, dec);
|
|
184
|
+
return { name, type, label, missing, print, write };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/sav/dictionary.ts
|
|
188
|
+
var MAX_DICT_RECORDS = 1e7;
|
|
189
|
+
var MAX_DOC_LINES = 1e5;
|
|
190
|
+
var PROVISIONAL = new TextDecoder("utf-8");
|
|
191
|
+
function readValueLabelSet(cur) {
|
|
192
|
+
const count = cur.readI32();
|
|
193
|
+
const labels = [];
|
|
194
|
+
for (let i = 0; i < count; i++) {
|
|
195
|
+
const raw = cur.readBytes(8);
|
|
196
|
+
const labelLen = cur.readBytes(1)[0];
|
|
197
|
+
const labelRaw = cur.readBytes(labelLen);
|
|
198
|
+
cur.skip((8 - (1 + labelLen) % 8) % 8);
|
|
199
|
+
labels.push({ raw, labelRaw });
|
|
200
|
+
}
|
|
201
|
+
return { labels, varIndexes: [] };
|
|
202
|
+
}
|
|
203
|
+
function readExtension(cur) {
|
|
204
|
+
const subtype = cur.readI32();
|
|
205
|
+
const size = cur.readI32();
|
|
206
|
+
const count = cur.readI32();
|
|
207
|
+
return { subtype, size, count, bytes: cur.readBytes(size * count) };
|
|
208
|
+
}
|
|
209
|
+
function handleVariable(cur, state) {
|
|
210
|
+
const physIdx = state.physicalPos;
|
|
211
|
+
state.physicalPos += 1;
|
|
212
|
+
const v = readVariableRecord(cur, PROVISIONAL);
|
|
213
|
+
if (v !== "continuation") {
|
|
214
|
+
state.variables.push(v);
|
|
215
|
+
state.physicalIndexes.push(physIdx);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function handleValueLabels(cur, state) {
|
|
219
|
+
state.pending = readValueLabelSet(cur);
|
|
220
|
+
state.valueLabelSets.push(state.pending);
|
|
221
|
+
}
|
|
222
|
+
function handleVarIndexes(cur, state) {
|
|
223
|
+
const count = cur.readI32();
|
|
224
|
+
const idx = [];
|
|
225
|
+
for (let i = 0; i < count; i++) idx.push(cur.readI32());
|
|
226
|
+
if (state.pending) state.pending.varIndexes = idx;
|
|
227
|
+
}
|
|
228
|
+
function handleDocument(cur) {
|
|
229
|
+
const nLines = cur.readI32();
|
|
230
|
+
if (nLines < 0 || nLines > MAX_DOC_LINES) throw new SavError("invalid document record");
|
|
231
|
+
cur.skip(nLines * 80);
|
|
232
|
+
}
|
|
233
|
+
function handleExtension(cur, state) {
|
|
234
|
+
state.extensions.push(readExtension(cur));
|
|
235
|
+
}
|
|
236
|
+
var HANDLERS = {
|
|
237
|
+
2: handleVariable,
|
|
238
|
+
3: handleValueLabels,
|
|
239
|
+
4: handleVarIndexes,
|
|
240
|
+
6: handleDocument,
|
|
241
|
+
7: handleExtension
|
|
242
|
+
};
|
|
243
|
+
function readDictionary(cur) {
|
|
244
|
+
const state = {
|
|
245
|
+
variables: [],
|
|
246
|
+
physicalIndexes: [],
|
|
247
|
+
physicalPos: 1,
|
|
248
|
+
valueLabelSets: [],
|
|
249
|
+
extensions: []
|
|
250
|
+
};
|
|
251
|
+
for (let records = 0; ; records++) {
|
|
252
|
+
if (records > MAX_DICT_RECORDS) throw new SavError("too many dictionary records");
|
|
253
|
+
const prev = cur.pos;
|
|
254
|
+
const recType = cur.readI32();
|
|
255
|
+
if (recType === 999) {
|
|
256
|
+
cur.readI32();
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
const handler = HANDLERS[recType];
|
|
260
|
+
if (!handler) throw new Error(`Unknown SPSS dictionary record type: ${recType}`);
|
|
261
|
+
handler(cur, state);
|
|
262
|
+
if (cur.pos <= prev) throw new SavError("dictionary made no progress");
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
variables: state.variables,
|
|
266
|
+
physicalIndexes: state.physicalIndexes,
|
|
267
|
+
valueLabelSets: state.valueLabelSets,
|
|
268
|
+
extensions: state.extensions
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/sav/extensions.ts
|
|
273
|
+
var UTF8 = new TextDecoder("utf-8");
|
|
274
|
+
function viewOf(bytes) {
|
|
275
|
+
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
276
|
+
}
|
|
277
|
+
function eachPair(tokens, apply) {
|
|
278
|
+
for (const token of tokens) {
|
|
279
|
+
const eq = token.indexOf("=");
|
|
280
|
+
if (eq > 0) apply(token.slice(0, eq), token.slice(eq + 1));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function parseLongNames(bytes, out) {
|
|
284
|
+
eachPair(UTF8.decode(bytes).split(" "), (key, value) => out.set(key, value));
|
|
285
|
+
}
|
|
286
|
+
function parseVeryLong(bytes, out) {
|
|
287
|
+
const tokens = UTF8.decode(bytes).split("\0").flatMap((chunk) => chunk.split(" "));
|
|
288
|
+
eachPair(tokens, (key, value) => out.set(key, Number(value)));
|
|
289
|
+
}
|
|
290
|
+
function measureName(code) {
|
|
291
|
+
if (code === 1) return "nominal";
|
|
292
|
+
if (code === 2) return "ordinal";
|
|
293
|
+
if (code === 3) return "scale";
|
|
294
|
+
return "unknown";
|
|
295
|
+
}
|
|
296
|
+
function parseMeasures(record, little) {
|
|
297
|
+
const view = viewOf(record.bytes);
|
|
298
|
+
const measures = [];
|
|
299
|
+
const triples = Math.floor(record.count / 3);
|
|
300
|
+
for (let t = 0; t < triples; t++) {
|
|
301
|
+
if (t * 12 + 12 > record.bytes.length) break;
|
|
302
|
+
measures.push(measureName(view.getInt32(t * 12, little)));
|
|
303
|
+
}
|
|
304
|
+
return measures;
|
|
305
|
+
}
|
|
306
|
+
function applyRecord(record, little, info) {
|
|
307
|
+
switch (record.subtype) {
|
|
308
|
+
case 20:
|
|
309
|
+
info.encoding = UTF8.decode(record.bytes).trim();
|
|
310
|
+
break;
|
|
311
|
+
case 4:
|
|
312
|
+
info.sysmis = viewOf(record.bytes).getFloat64(0, little);
|
|
313
|
+
break;
|
|
314
|
+
case 13:
|
|
315
|
+
parseLongNames(record.bytes, info.longNames);
|
|
316
|
+
break;
|
|
317
|
+
case 14:
|
|
318
|
+
parseVeryLong(record.bytes, info.veryLong);
|
|
319
|
+
break;
|
|
320
|
+
case 11:
|
|
321
|
+
info.measures = parseMeasures(record, little);
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function applyExtensions(raw, little) {
|
|
326
|
+
const info = {
|
|
327
|
+
encoding: "utf-8",
|
|
328
|
+
sysmis: -Number.MAX_VALUE,
|
|
329
|
+
longNames: /* @__PURE__ */ new Map(),
|
|
330
|
+
veryLong: /* @__PURE__ */ new Map(),
|
|
331
|
+
measures: []
|
|
332
|
+
};
|
|
333
|
+
for (const record of raw.extensions) applyRecord(record, little, info);
|
|
334
|
+
return info;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/sav/header.ts
|
|
338
|
+
var ASCII = new TextDecoder("windows-1252");
|
|
339
|
+
function asCompression(v) {
|
|
340
|
+
if (v === 0 || v === 1 || v === 2) return v;
|
|
341
|
+
throw new Error(`Unsupported SPSS compression flag: ${v}`);
|
|
342
|
+
}
|
|
343
|
+
function readHeader(cur) {
|
|
344
|
+
if (cur.length < 176) throw new SavError("file too small to be a .sav");
|
|
345
|
+
const magic = cur.readStr(4, ASCII);
|
|
346
|
+
if (magic !== "$FL2" && magic !== "$FL3") throw new Error("Not an SPSS system file (bad magic)");
|
|
347
|
+
cur.little = cur.view.getInt32(64, true) === 2 || cur.view.getInt32(64, true) === 3;
|
|
348
|
+
cur.seek(64);
|
|
349
|
+
cur.readI32();
|
|
350
|
+
cur.readI32();
|
|
351
|
+
const compression = asCompression(cur.readI32());
|
|
352
|
+
cur.readI32();
|
|
353
|
+
const ncases = cur.readI32();
|
|
354
|
+
const bias = cur.readF64();
|
|
355
|
+
cur.skip(9 + 8);
|
|
356
|
+
const fileLabel = cur.readStr(64, ASCII).replace(/[\s\0]+$/, "");
|
|
357
|
+
cur.skip(3);
|
|
358
|
+
return { zlib: magic === "$FL3", compression, bias, ncases, fileLabel };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// src/sav/source.ts
|
|
362
|
+
function filled(byte) {
|
|
363
|
+
return new Uint8Array(8).fill(byte);
|
|
364
|
+
}
|
|
365
|
+
var UncompressedSource = class {
|
|
366
|
+
constructor(cur, sysmis) {
|
|
367
|
+
this.cur = cur;
|
|
368
|
+
this.sysmis = sysmis;
|
|
369
|
+
}
|
|
370
|
+
cur;
|
|
371
|
+
sysmis;
|
|
372
|
+
nextNumeric() {
|
|
373
|
+
const v = this.cur.readF64();
|
|
374
|
+
return v === this.sysmis ? null : v;
|
|
375
|
+
}
|
|
376
|
+
nextString8() {
|
|
377
|
+
return this.cur.readBytes(8);
|
|
378
|
+
}
|
|
379
|
+
atEnd() {
|
|
380
|
+
return this.cur.pos + 8 > this.cur.length;
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
var RleSource = class {
|
|
384
|
+
// a control code peeked by atEnd(), not yet spent on a cell
|
|
385
|
+
constructor(cur, bias) {
|
|
386
|
+
this.cur = cur;
|
|
387
|
+
this.bias = bias;
|
|
388
|
+
}
|
|
389
|
+
cur;
|
|
390
|
+
bias;
|
|
391
|
+
codes = new Uint8Array(8);
|
|
392
|
+
idx = 8;
|
|
393
|
+
// ≥ 8 forces an octet refill on the first pull
|
|
394
|
+
lookahead = null;
|
|
395
|
+
/** Pull the next raw octet code. Synthesizes 252 (end-of-data) once the stream is exhausted, so an
|
|
396
|
+
* unknown-case-count read (`ncases = -1`) terminates on atEnd() instead of over-reading past EOF. */
|
|
397
|
+
rawCode() {
|
|
398
|
+
if (this.idx >= 8) {
|
|
399
|
+
if (this.cur.pos >= this.cur.length) return 252;
|
|
400
|
+
this.codes = this.cur.readBytes(8);
|
|
401
|
+
this.idx = 0;
|
|
402
|
+
}
|
|
403
|
+
return this.codes[this.idx++];
|
|
404
|
+
}
|
|
405
|
+
/** Next cell-bearing control code: drains any peeked code, then skips 0 fillers (they encode no
|
|
406
|
+
* cell — proven by the labels_order oracle fixture's inter-row octet padding). */
|
|
407
|
+
nextCode() {
|
|
408
|
+
if (this.lookahead !== null) {
|
|
409
|
+
const code = this.lookahead;
|
|
410
|
+
this.lookahead = null;
|
|
411
|
+
return code;
|
|
412
|
+
}
|
|
413
|
+
for (; ; ) {
|
|
414
|
+
const code = this.rawCode();
|
|
415
|
+
if (code !== 0) return code;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
atEnd() {
|
|
419
|
+
if (this.lookahead === null) this.lookahead = this.nextCode();
|
|
420
|
+
return this.lookahead === 252;
|
|
421
|
+
}
|
|
422
|
+
nextNumeric() {
|
|
423
|
+
const code = this.nextCode();
|
|
424
|
+
if (code === 252 || code === 255) return null;
|
|
425
|
+
if (code === 253) return this.cur.readF64();
|
|
426
|
+
return code - this.bias;
|
|
427
|
+
}
|
|
428
|
+
nextString8() {
|
|
429
|
+
const code = this.nextCode();
|
|
430
|
+
if (code === 254) return filled(32);
|
|
431
|
+
if (code === 253) return this.cur.readBytes(8);
|
|
432
|
+
return filled(32);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
function readI64(cur) {
|
|
436
|
+
const v = cur.view.getBigInt64(cur.pos, cur.little);
|
|
437
|
+
cur.pos += 8;
|
|
438
|
+
return Number(v);
|
|
439
|
+
}
|
|
440
|
+
function concatToBuffer(parts) {
|
|
441
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
442
|
+
const buf = new ArrayBuffer(total);
|
|
443
|
+
const out = new Uint8Array(buf);
|
|
444
|
+
let ofs = 0;
|
|
445
|
+
for (const p of parts) {
|
|
446
|
+
out.set(p, ofs);
|
|
447
|
+
ofs += p.length;
|
|
448
|
+
}
|
|
449
|
+
return buf;
|
|
450
|
+
}
|
|
451
|
+
async function inflateDeflate(compressed, maxBytes) {
|
|
452
|
+
const bytes = new Uint8Array(compressed.length);
|
|
453
|
+
bytes.set(compressed);
|
|
454
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate"));
|
|
455
|
+
const reader = stream.getReader();
|
|
456
|
+
const parts = [];
|
|
457
|
+
let total = 0;
|
|
458
|
+
for (; ; ) {
|
|
459
|
+
const { done, value } = await reader.read();
|
|
460
|
+
if (done) break;
|
|
461
|
+
total += value.byteLength;
|
|
462
|
+
if (total > maxBytes) {
|
|
463
|
+
await reader.cancel();
|
|
464
|
+
throw new SavError("ZSAV inflated output exceeds limit");
|
|
465
|
+
}
|
|
466
|
+
parts.push(value);
|
|
467
|
+
}
|
|
468
|
+
return new Uint8Array(concatToBuffer(parts));
|
|
469
|
+
}
|
|
470
|
+
function readTrailerBlocks(cur, ztrailerOfs) {
|
|
471
|
+
cur.seek(ztrailerOfs);
|
|
472
|
+
readI64(cur);
|
|
473
|
+
readI64(cur);
|
|
474
|
+
cur.readI32();
|
|
475
|
+
const nBlocks = cur.readI32();
|
|
476
|
+
if (nBlocks < 0 || nBlocks > 1e6) throw new SavError("ZSAV block count out of range");
|
|
477
|
+
const blocks = [];
|
|
478
|
+
for (let i = 0; i < nBlocks; i++) {
|
|
479
|
+
readI64(cur);
|
|
480
|
+
const compressedOfs = readI64(cur);
|
|
481
|
+
cur.readI32();
|
|
482
|
+
blocks.push({ compressedOfs, compressedSize: cur.readI32() });
|
|
483
|
+
}
|
|
484
|
+
return blocks;
|
|
485
|
+
}
|
|
486
|
+
function makeSource(cur, header, sysmis) {
|
|
487
|
+
if (header.compression === 0) return new UncompressedSource(cur, sysmis);
|
|
488
|
+
return new RleSource(cur, header.bias);
|
|
489
|
+
}
|
|
490
|
+
async function inflateZsav(cur, maxInflatedBytes) {
|
|
491
|
+
readI64(cur);
|
|
492
|
+
const ztrailerOfs = readI64(cur);
|
|
493
|
+
readI64(cur);
|
|
494
|
+
const blocks = readTrailerBlocks(cur, ztrailerOfs);
|
|
495
|
+
const parts = [];
|
|
496
|
+
let total = 0;
|
|
497
|
+
for (const block of blocks) {
|
|
498
|
+
cur.seek(block.compressedOfs);
|
|
499
|
+
const inflated = await inflateDeflate(
|
|
500
|
+
cur.readBytes(block.compressedSize),
|
|
501
|
+
maxInflatedBytes - total
|
|
502
|
+
);
|
|
503
|
+
total += inflated.length;
|
|
504
|
+
parts.push(inflated);
|
|
505
|
+
}
|
|
506
|
+
return concatToBuffer(parts);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/sav/reader.ts
|
|
510
|
+
function rawToF64(raw, little) {
|
|
511
|
+
return new DataView(raw.buffer, raw.byteOffset, 8).getFloat64(0, little);
|
|
512
|
+
}
|
|
513
|
+
function valueLabelsFor(physicalIndex, isString, sets, little, dec) {
|
|
514
|
+
const out = [];
|
|
515
|
+
for (const set of sets) {
|
|
516
|
+
if (!set.varIndexes.includes(physicalIndex)) continue;
|
|
517
|
+
for (const entry of set.labels) {
|
|
518
|
+
const value = isString ? dec.decode(entry.raw).replace(/ +$/, "") : rawToF64(entry.raw, little);
|
|
519
|
+
out.push({ value, label: dec.decode(entry.labelRaw).replace(/\0+$/, "") });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return out.length > 0 ? out : void 0;
|
|
523
|
+
}
|
|
524
|
+
function baseVariable(rawVar, index, info) {
|
|
525
|
+
return {
|
|
526
|
+
name: info.longNames.get(rawVar.name) ?? rawVar.name,
|
|
527
|
+
type: rawVar.type > 0 ? "string" : "numeric",
|
|
528
|
+
missing: rawVar.missing,
|
|
529
|
+
format: { ...rawVar.print, isDate: isDateFormat(rawVar.print.type) },
|
|
530
|
+
measure: info.measures[index] ?? "unknown"
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
function buildVariable(rawVar, index, physicalIndex, ctx) {
|
|
534
|
+
const isString = rawVar.type > 0;
|
|
535
|
+
const variable = baseVariable(rawVar, index, ctx.info);
|
|
536
|
+
if (rawVar.label !== void 0) variable.label = rawVar.label;
|
|
537
|
+
if (isString) variable.width = rawVar.type;
|
|
538
|
+
const valueLabels = valueLabelsFor(physicalIndex, isString, ctx.sets, ctx.little, ctx.dec);
|
|
539
|
+
if (valueLabels) variable.valueLabels = valueLabels;
|
|
540
|
+
return variable;
|
|
541
|
+
}
|
|
542
|
+
function segmentWidths(raw, start, realWidth) {
|
|
543
|
+
const n = Math.ceil(realWidth / 252);
|
|
544
|
+
const widths = [];
|
|
545
|
+
for (let s = 0; s < n && start + s < raw.variables.length; s++) {
|
|
546
|
+
widths.push(raw.variables[start + s].type);
|
|
547
|
+
}
|
|
548
|
+
return widths;
|
|
549
|
+
}
|
|
550
|
+
function buildPlans(raw, ctx) {
|
|
551
|
+
const plans = [];
|
|
552
|
+
let i = 0;
|
|
553
|
+
while (i < raw.variables.length) {
|
|
554
|
+
const rawVar = raw.variables[i];
|
|
555
|
+
const variable = buildVariable(rawVar, i, raw.physicalIndexes[i], ctx);
|
|
556
|
+
const realWidth = rawVar.type > 0 ? ctx.info.veryLong.get(rawVar.name) : void 0;
|
|
557
|
+
if (realWidth === void 0) {
|
|
558
|
+
plans.push({ variable, segments: rawVar.type > 0 ? [rawVar.type] : [] });
|
|
559
|
+
i += 1;
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const segments = segmentWidths(raw, i, realWidth);
|
|
563
|
+
variable.width = realWidth;
|
|
564
|
+
plans.push({ variable, segments });
|
|
565
|
+
i += segments.length;
|
|
566
|
+
}
|
|
567
|
+
return plans;
|
|
568
|
+
}
|
|
569
|
+
async function readSav(buf, opts) {
|
|
570
|
+
const limits = { ...DEFAULT_LIMITS, ...opts };
|
|
571
|
+
let cur = new Cursor(buf);
|
|
572
|
+
const header = readHeader(cur);
|
|
573
|
+
const raw = readDictionary(cur);
|
|
574
|
+
const info = applyExtensions(raw, cur.little);
|
|
575
|
+
const little = cur.little;
|
|
576
|
+
const dec = new TextDecoder(toBunEncoding(info.encoding));
|
|
577
|
+
const plans = buildPlans(raw, { info, sets: raw.valueLabelSets, little, dec });
|
|
578
|
+
const variables = plans.map((plan) => plan.variable);
|
|
579
|
+
if (variables.length === 0) throw new SavError("file has no variables");
|
|
580
|
+
if (header.ncases >= 0 && header.ncases * variables.length > limits.maxCells) {
|
|
581
|
+
throw new SavError(
|
|
582
|
+
`case count ${header.ncases} \xD7 ${variables.length} vars exceeds cell limit ${limits.maxCells}`
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
if (header.zlib) {
|
|
586
|
+
const data = await inflateZsav(cur, limits.maxInflatedBytes);
|
|
587
|
+
cur = new Cursor(data);
|
|
588
|
+
cur.little = little;
|
|
589
|
+
}
|
|
590
|
+
const source = makeSource(cur, header, info.sysmis);
|
|
591
|
+
const rows = readCases(header, plans, info, source, limits);
|
|
592
|
+
return { format: "sav", encoding: info.encoding, sheets: [{ name: "data", variables, rows }] };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export { DEFAULT_LIMITS, SavError, applyUserMissing, readSav };
|
|
596
|
+
//# sourceMappingURL=index.js.map
|
|
597
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/limits.ts","../src/missing.ts","../src/sav/binary.ts","../src/sav/dates.ts","../src/sav/cases.ts","../src/sav/variable.ts","../src/sav/dictionary.ts","../src/sav/extensions.ts","../src/sav/header.ts","../src/sav/source.ts","../src/sav/reader.ts"],"names":[],"mappings":";AAGO,IAAM,QAAA,GAAN,cAAuB,KAAA,CAAM;AAAC;AAY9B,IAAM,cAAA,GAA4B;AAAA,EACvC,QAAA,EAAU,GAAA;AAAA,EACV,gBAAA,EAAkB,MAAM,IAAA,GAAO;AACjC;;;ACfA,SAAS,aAAA,CAAc,MAAc,IAAA,EAA4B;AAC/D,EAAA,QAAQ,KAAK,IAAA;AAAM,IACjB,KAAK,UAAA;AACH,MAAA,OAAO,IAAA,CAAK,MAAA,CAAO,QAAA,CAAS,IAAI,CAAA;AAAA,IAClC,KAAK,OAAA;AACH,MAAA,OAAO,IAAA,IAAQ,IAAA,CAAK,EAAA,IAAM,IAAA,IAAQ,IAAA,CAAK,EAAA;AAAA,IACzC,KAAK,gBAAA;AACH,MAAA,OAAQ,QAAQ,IAAA,CAAK,EAAA,IAAM,QAAQ,IAAA,CAAK,EAAA,IAAO,SAAS,IAAA,CAAK,KAAA;AAAA,IAC/D;AACE,MAAA,OAAO,KAAA;AAAA;AAEb;AAMA,SAAS,SAAA,CAAU,MAAiB,IAAA,EAA4B;AAC9D,EAAA,IAAI,OAAO,IAAA,KAAS,QAAA,EAAU,OAAO,aAAA,CAAc,MAAM,IAAI,CAAA;AAC7D,EAAA,IAAI,OAAO,IAAA,KAAS,QAAA,EAAU,OAAO,IAAA,CAAK,SAAS,SAAA,IAAa,IAAA,CAAK,MAAA,CAAO,QAAA,CAAS,IAAI,CAAA;AACzF,EAAA,OAAO,KAAA;AACT;AAOO,SAAS,iBAAiB,KAAA,EAAqB;AACpD,EAAA,MAAM,QAAQ,KAAA,CAAM,SAAA,CAAU,IAAI,CAAC,CAAA,KAAM,EAAE,OAAO,CAAA;AAClD,EAAA,MAAM,IAAA,GAAO,MAAM,IAAA,CAAK,GAAA;AAAA,IAAI,CAAC,GAAA,KAC3B,GAAA,CAAI,GAAA,CAAI,CAAC,IAAA,EAAM,GAAA,KAAS,SAAA,CAAU,IAAA,EAAM,KAAA,CAAM,GAAG,CAAC,CAAA,GAAI,OAAO,IAAK;AAAA,GACpE;AACA,EAAA,OAAO,EAAE,GAAG,KAAA,EAAO,IAAA,EAAK;AAC1B;;;AClCO,IAAM,SAAN,MAAa;AAAA,EACT,IAAA;AAAA,EACT,GAAA,GAAM,CAAA;AAAA,EACN,MAAA,GAAS,IAAA;AAAA,EAET,YAAY,GAAA,EAAkB;AAC5B,IAAA,IAAA,CAAK,IAAA,GAAO,IAAI,QAAA,CAAS,GAAG,CAAA;AAAA,EAC9B;AAAA,EAEA,IAAI,MAAA,GAAiB;AACnB,IAAA,OAAO,KAAK,IAAA,CAAK,UAAA;AAAA,EACnB;AAAA,EAEA,KAAK,CAAA,EAAiB;AACpB,IAAA,IAAA,CAAK,GAAA,GAAM,CAAA;AAAA,EACb;AAAA,EAEA,KAAK,CAAA,EAAiB;AACpB,IAAA,IAAA,CAAK,GAAA,IAAO,CAAA;AAAA,EACd;AAAA,EAEA,OAAA,GAAkB;AAChB,IAAA,MAAM,IAAI,IAAA,CAAK,IAAA,CAAK,SAAS,IAAA,CAAK,GAAA,EAAK,KAAK,MAAM,CAAA;AAClD,IAAA,IAAA,CAAK,GAAA,IAAO,CAAA;AACZ,IAAA,OAAO,CAAA;AAAA,EACT;AAAA,EAEA,OAAA,GAAkB;AAChB,IAAA,MAAM,IAAI,IAAA,CAAK,IAAA,CAAK,WAAW,IAAA,CAAK,GAAA,EAAK,KAAK,MAAM,CAAA;AACpD,IAAA,IAAA,CAAK,GAAA,IAAO,CAAA;AACZ,IAAA,OAAO,CAAA;AAAA,EACT;AAAA,EAEA,UAAU,CAAA,EAAuB;AAG/B,IAAA,IAAI,IAAI,CAAA,IAAK,IAAA,CAAK,GAAA,GAAM,CAAA,GAAI,KAAK,MAAA,EAAQ;AACvC,MAAA,MAAM,IAAI,QAAA,CAAS,CAAA,QAAA,EAAW,CAAC,CAAA,uBAAA,CAAyB,CAAA;AAAA,IAC1D;AACA,IAAA,MAAM,GAAA,GAAM,IAAI,UAAA,CAAW,CAAC,CAAA;AAC5B,IAAA,GAAA,CAAI,GAAA,CAAI,IAAI,UAAA,CAAW,IAAA,CAAK,IAAA,CAAK,MAAA,EAAQ,IAAA,CAAK,IAAA,CAAK,UAAA,GAAa,IAAA,CAAK,GAAA,EAAK,CAAC,CAAC,CAAA;AAC5E,IAAA,IAAA,CAAK,GAAA,IAAO,CAAA;AACZ,IAAA,OAAO,GAAA;AAAA,EACT;AAAA,EAEA,OAAA,CAAQ,GAAW,GAAA,EAA0B;AAC3C,IAAA,OAAO,GAAA,CAAI,MAAA,CAAO,IAAA,CAAK,SAAA,CAAU,CAAC,CAAC,CAAA;AAAA,EACrC;AACF,CAAA;;;AC9CA,IAAM,iBAAA,mBAAoB,IAAI,GAAA,CAAI,CAAC,EAAA,EAAI,EAAA,EAAI,EAAA,EAAI,EAAA,EAAI,EAAA,EAAI,EAAA,EAAI,EAAA,EAAI,EAAA,EAAI,EAAE,CAAC,CAAA;AAGtE,IAAM,yBAAA,GAA4B,WAAA;AAG3B,SAAS,aAAa,IAAA,EAAuB;AAClD,EAAA,OAAO,iBAAA,CAAkB,IAAI,IAAI,CAAA;AACnC;AAGO,SAAS,WAAW,OAAA,EAAuB;AAChD,EAAA,OAAO,IAAI,IAAA,CAAA,CAAM,OAAA,GAAU,yBAAA,IAA6B,GAAI,CAAA;AAC9D;;;ACEO,SAAS,cAAc,GAAA,EAAkD;AAC9E,EAAA,MAAM,CAAA,GAAI,IAAI,WAAA,EAAY;AAC1B,EAAA,IAAI,CAAA,CAAE,QAAA,CAAS,MAAM,CAAA,IAAK,EAAE,QAAA,CAAS,MAAM,CAAA,IAAK,CAAA,CAAE,SAAS,MAAM,CAAA,IAAK,CAAA,CAAE,QAAA,CAAS,OAAO,CAAA,EAAG;AACzF,IAAA,OAAO,cAAA;AAAA,EACT;AACA,EAAA,IAAI,CAAA,CAAE,QAAA,CAAS,IAAI,CAAA,EAAG,OAAO,QAAA;AAC7B,EAAA,OAAO,OAAA;AACT;AAKA,SAAS,WAAA,CAAY,OAAe,MAAA,EAAiC;AACnE,EAAA,MAAM,MAAA,GAAS,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,IAAA,CAAK,KAAA,GAAQ,CAAC,CAAC,CAAA;AAC/C,EAAA,MAAM,GAAA,GAAM,IAAI,UAAA,CAAW,MAAA,GAAS,CAAC,CAAA;AACrC,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,MAAA,EAAQ,CAAA,EAAA,EAAK,GAAA,CAAI,GAAA,CAAI,MAAA,CAAO,WAAA,EAAY,EAAG,CAAA,GAAI,CAAC,CAAA;AACpE,EAAA,OAAO,GAAA,CAAI,QAAA,CAAS,CAAA,EAAG,KAAK,CAAA;AAC9B;AAKA,SAAS,eAAA,CAAgB,QAAA,EAAoB,MAAA,EAAqB,GAAA,EAA0B;AAC1F,EAAA,MAAM,QAAsB,EAAC;AAC7B,EAAA,KAAA,MAAW,SAAS,QAAA,EAAU,KAAA,CAAM,KAAK,WAAA,CAAY,KAAA,EAAO,MAAM,CAAC,CAAA;AACnE,EAAA,MAAM,KAAA,GAAQ,MAAM,MAAA,CAAO,CAAC,GAAG,CAAA,KAAM,CAAA,GAAI,CAAA,CAAE,MAAA,EAAQ,CAAC,CAAA;AACpD,EAAA,MAAM,GAAA,GAAM,IAAI,UAAA,CAAW,KAAK,CAAA;AAChC,EAAA,IAAI,GAAA,GAAM,CAAA;AACV,EAAA,KAAA,MAAW,KAAK,KAAA,EAAO;AACrB,IAAA,GAAA,CAAI,GAAA,CAAI,GAAG,GAAG,CAAA;AACd,IAAA,GAAA,IAAO,CAAA,CAAE,MAAA;AAAA,EACX;AACA,EAAA,OAAO,IAAI,MAAA,CAAO,GAAG,CAAA,CAAE,OAAA,CAAQ,OAAO,EAAE,CAAA;AAC1C;AAGA,SAAS,eAAA,CAAgB,UAAoB,MAAA,EAAgC;AAC3E,EAAA,MAAM,CAAA,GAAI,OAAO,WAAA,EAAY;AAC7B,EAAA,IAAI,MAAM,IAAA,IAAQ,QAAA,CAAS,OAAO,MAAA,EAAQ,OAAO,WAAW,CAAC,CAAA;AAC7D,EAAA,OAAO,CAAA;AACT;AAEA,SAAS,QAAA,CAAS,IAAA,EAAoB,MAAA,EAAqB,GAAA,EAA6B;AACtF,EAAA,IAAI,IAAA,CAAK,SAAS,MAAA,GAAS,CAAA,SAAU,eAAA,CAAgB,IAAA,CAAK,QAAA,EAAU,MAAA,EAAQ,GAAG,CAAA;AAC/E,EAAA,OAAO,eAAA,CAAgB,IAAA,CAAK,QAAA,EAAU,MAAM,CAAA;AAC9C;AAMO,SAAS,SAAA,CACd,MAAA,EACA,KAAA,EACA,QAAA,EACA,QACA,MAAA,EACe;AACf,EAAA,MAAM,MAAM,IAAI,WAAA,CAAY,aAAA,CAAc,QAAA,CAAS,QAAQ,CAAC,CAAA;AAC5D,EAAA,MAAM,OAAsB,EAAC;AAC7B,EAAA,IAAI,MAAA,CAAO,UAAU,CAAA,EAAG;AAEtB,IAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,MAAA,CAAO,QAAQ,CAAA,EAAA,EAAK;AACtC,MAAA,IAAA,CAAK,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,CAAC,IAAA,KAAS,SAAS,IAAA,EAAM,MAAA,EAAQ,GAAG,CAAC,CAAC,CAAA;AAAA,IAC5D;AACA,IAAA,OAAO,IAAA;AAAA,EACT;AACA,EAAA,OAAO,CAAC,MAAA,CAAO,KAAA,EAAM,EAAG;AACtB,IAAA,IAAA,CAAK,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,CAAC,IAAA,KAAS,SAAS,IAAA,EAAM,MAAA,EAAQ,GAAG,CAAC,CAAC,CAAA;AAG1D,IAAA,IAAI,IAAA,CAAK,MAAA,GAAS,KAAA,CAAM,MAAA,GAAS,OAAO,QAAA,EAAU;AAChD,MAAA,MAAM,IAAI,SAAS,+BAA+B,CAAA;AAAA,IACpD;AAAA,EACF;AACA,EAAA,OAAO,IAAA;AACT;;;ACxFA,IAAM,cAAA,mBAAiB,IAAI,GAAA,CAAI,CAAC,EAAA,EAAI,IAAI,CAAA,EAAG,CAAA,EAAG,CAAA,EAAG,CAAC,CAAC,CAAA;AAa5C,SAAS,YAAA,CACd,MAAA,EACA,MAAA,GAAiC,MAAM,KAAA,EAC3B;AACZ,EAAA,MAAM,WAAW,MAAA,GAAS,GAAA;AAC1B,EAAA,MAAM,KAAA,GAAS,UAAU,CAAA,GAAK,GAAA;AAC9B,EAAA,MAAM,IAAA,GAAQ,UAAU,EAAA,GAAM,GAAA;AAC9B,EAAA,OAAO,EAAE,IAAA,EAAM,KAAA,EAAO,UAAU,MAAA,EAAQ,MAAA,CAAO,IAAI,CAAA,EAAE;AACvD;AAGO,SAAS,aAAA,CAAc,GAAW,MAAA,EAA+B;AACtE,EAAA,IAAI,CAAA,KAAM,CAAA,EAAG,OAAO,EAAE,MAAM,MAAA,EAAO;AACnC,EAAA,IAAI,IAAI,CAAA,EAAG,OAAO,EAAE,IAAA,EAAM,YAAY,MAAA,EAAO;AAC7C,EAAA,IAAI,CAAA,KAAM,EAAA,EAAI,OAAO,EAAE,IAAA,EAAM,OAAA,EAAS,EAAA,EAAI,MAAA,CAAO,CAAC,CAAA,EAAG,EAAA,EAAI,MAAA,CAAO,CAAC,CAAA,EAAE;AACnE,EAAA,OAAO,EAAE,IAAA,EAAM,gBAAA,EAAkB,EAAA,EAAI,OAAO,CAAC,CAAA,EAAG,EAAA,EAAI,MAAA,CAAO,CAAC,CAAA,EAAG,KAAA,EAAO,MAAA,CAAO,CAAC,CAAA,EAAE;AAClF;AAOA,SAAS,WAAA,CAAY,GAAA,EAAa,QAAA,EAAkB,IAAA,EAAc,GAAA,EAA+B;AAC/F,EAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,GAAA,CAAI,QAAQ,CAAA;AAC/B,EAAA,IAAI,OAAO,CAAA,EAAG;AACZ,IAAA,MAAM,SAAmB,EAAC;AAC1B,IAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,KAAA,EAAO,KAAK,MAAA,CAAO,IAAA,CAAK,GAAA,CAAI,OAAA,CAAQ,GAAG,GAAG,CAAA,CAAE,OAAA,CAAQ,KAAA,EAAO,EAAE,CAAC,CAAA;AAClF,IAAA,OAAO,KAAA,GAAQ,IAAI,EAAE,IAAA,EAAM,WAAW,MAAA,EAAO,GAAI,EAAE,IAAA,EAAM,MAAA,EAAO;AAAA,EAClE;AACA,EAAA,MAAM,OAAiB,EAAC;AACxB,EAAA,KAAA,IAAS,CAAA,GAAI,GAAG,CAAA,GAAI,KAAA,EAAO,KAAK,IAAA,CAAK,IAAA,CAAK,GAAA,CAAI,OAAA,EAAS,CAAA;AACvD,EAAA,OAAO,aAAA,CAAc,UAAU,IAAI,CAAA;AACrC;AAMO,SAAS,kBAAA,CAAmB,KAAa,GAAA,EAAgD;AAC9F,EAAA,MAAM,IAAA,GAAO,IAAI,OAAA,EAAQ;AACzB,EAAA,IAAI,SAAS,EAAA,EAAI;AACf,IAAA,GAAA,CAAI,KAAK,EAAE,CAAA;AACX,IAAA,OAAO,cAAA;AAAA,EACT;AACA,EAAA,MAAM,QAAA,GAAW,IAAI,OAAA,EAAQ;AAC7B,EAAA,MAAM,QAAA,GAAW,IAAI,OAAA,EAAQ;AAC7B,EAAA,IAAI,CAAC,eAAe,GAAA,CAAI,QAAQ,GAAG,MAAM,IAAI,SAAS,6BAA6B,CAAA;AACnF,EAAA,MAAM,KAAA,GAAQ,YAAA,CAAa,GAAA,CAAI,OAAA,EAAS,CAAA;AACxC,EAAA,MAAM,KAAA,GAAQ,YAAA,CAAa,GAAA,CAAI,OAAA,EAAS,CAAA;AACxC,EAAA,MAAM,IAAA,GAAO,IAAI,OAAA,CAAQ,CAAA,EAAG,GAAG,CAAA,CAAE,OAAA,CAAQ,YAAY,EAAE,CAAA;AACvD,EAAA,IAAI,KAAA;AACJ,EAAA,IAAI,QAAA,EAAU;AACZ,IAAA,MAAM,QAAA,GAAW,IAAI,OAAA,EAAQ;AAC7B,IAAA,KAAA,GAAQ,GAAA,CAAI,OAAA,CAAQ,QAAA,EAAU,GAAG,CAAA;AACjC,IAAA,GAAA,CAAI,IAAA,CAAA,CAAM,CAAA,GAAK,QAAA,GAAW,CAAA,IAAM,CAAC,CAAA;AAAA,EACnC;AACA,EAAA,MAAM,OAAA,GAAU,WAAA,CAAY,GAAA,EAAK,QAAA,EAAU,MAAM,GAAG,CAAA;AACpD,EAAA,OAAO,EAAE,IAAA,EAAM,IAAA,EAAM,KAAA,EAAO,OAAA,EAAS,OAAO,KAAA,EAAM;AACpD;;;ACxEA,IAAM,gBAAA,GAAmB,GAAA;AAGzB,IAAM,aAAA,GAAgB,GAAA;AA4BtB,IAAM,WAAA,GAAc,IAAI,WAAA,CAAY,OAAO,CAAA;AAM3C,SAAS,kBAAkB,GAAA,EAA+B;AACxD,EAAA,MAAM,KAAA,GAAQ,IAAI,OAAA,EAAQ;AAC1B,EAAA,MAAM,SAA0B,EAAC;AACjC,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,KAAA,EAAO,CAAA,EAAA,EAAK;AAC9B,IAAA,MAAM,GAAA,GAAM,GAAA,CAAI,SAAA,CAAU,CAAC,CAAA;AAC3B,IAAA,MAAM,QAAA,GAAW,GAAA,CAAI,SAAA,CAAU,CAAC,EAAE,CAAC,CAAA;AACnC,IAAA,MAAM,QAAA,GAAW,GAAA,CAAI,SAAA,CAAU,QAAQ,CAAA;AACvC,IAAA,GAAA,CAAI,IAAA,CAAA,CAAM,CAAA,GAAA,CAAM,CAAA,GAAI,QAAA,IAAY,KAAM,CAAC,CAAA;AACvC,IAAA,MAAA,CAAO,IAAA,CAAK,EAAE,GAAA,EAAK,QAAA,EAAU,CAAA;AAAA,EAC/B;AACA,EAAA,OAAO,EAAE,MAAA,EAAQ,UAAA,EAAY,EAAC,EAAE;AAClC;AAIA,SAAS,cAAc,GAAA,EAA2B;AAChD,EAAA,MAAM,OAAA,GAAU,IAAI,OAAA,EAAQ;AAC5B,EAAA,MAAM,IAAA,GAAO,IAAI,OAAA,EAAQ;AACzB,EAAA,MAAM,KAAA,GAAQ,IAAI,OAAA,EAAQ;AAC1B,EAAA,OAAO,EAAE,SAAS,IAAA,EAAM,KAAA,EAAO,OAAO,GAAA,CAAI,SAAA,CAAU,IAAA,GAAO,KAAK,CAAA,EAAE;AACpE;AAEA,SAAS,cAAA,CAAe,KAAa,KAAA,EAAwB;AAC3D,EAAA,MAAM,UAAU,KAAA,CAAM,WAAA;AACtB,EAAA,KAAA,CAAM,WAAA,IAAe,CAAA;AACrB,EAAA,MAAM,CAAA,GAAI,kBAAA,CAAmB,GAAA,EAAK,WAAW,CAAA;AAC7C,EAAA,IAAI,MAAM,cAAA,EAAgB;AACxB,IAAA,KAAA,CAAM,SAAA,CAAU,KAAK,CAAC,CAAA;AACtB,IAAA,KAAA,CAAM,eAAA,CAAgB,KAAK,OAAO,CAAA;AAAA,EACpC;AACF;AAEA,SAAS,iBAAA,CAAkB,KAAa,KAAA,EAAwB;AAC9D,EAAA,KAAA,CAAM,OAAA,GAAU,kBAAkB,GAAG,CAAA;AACrC,EAAA,KAAA,CAAM,cAAA,CAAe,IAAA,CAAK,KAAA,CAAM,OAAO,CAAA;AACzC;AAGA,SAAS,gBAAA,CAAiB,KAAa,KAAA,EAAwB;AAC7D,EAAA,MAAM,KAAA,GAAQ,IAAI,OAAA,EAAQ;AAC1B,EAAA,MAAM,MAAgB,EAAC;AACvB,EAAA,KAAA,IAAS,CAAA,GAAI,GAAG,CAAA,GAAI,KAAA,EAAO,KAAK,GAAA,CAAI,IAAA,CAAK,GAAA,CAAI,OAAA,EAAS,CAAA;AACtD,EAAA,IAAI,KAAA,CAAM,OAAA,EAAS,KAAA,CAAM,OAAA,CAAQ,UAAA,GAAa,GAAA;AAChD;AAGA,SAAS,eAAe,GAAA,EAAmB;AACzC,EAAA,MAAM,MAAA,GAAS,IAAI,OAAA,EAAQ;AAC3B,EAAA,IAAI,SAAS,CAAA,IAAK,MAAA,GAAS,eAAe,MAAM,IAAI,SAAS,yBAAyB,CAAA;AACtF,EAAA,GAAA,CAAI,IAAA,CAAK,SAAS,EAAE,CAAA;AACtB;AAEA,SAAS,eAAA,CAAgB,KAAa,KAAA,EAAwB;AAC5D,EAAA,KAAA,CAAM,UAAA,CAAW,IAAA,CAAK,aAAA,CAAc,GAAG,CAAC,CAAA;AAC1C;AAEA,IAAM,QAAA,GAAoE;AAAA,EACxE,CAAA,EAAG,cAAA;AAAA,EACH,CAAA,EAAG,iBAAA;AAAA,EACH,CAAA,EAAG,gBAAA;AAAA,EACH,CAAA,EAAG,cAAA;AAAA,EACH,CAAA,EAAG;AACL,CAAA;AAKO,SAAS,eAAe,GAAA,EAAsB;AACnD,EAAA,MAAM,KAAA,GAAmB;AAAA,IACvB,WAAW,EAAC;AAAA,IACZ,iBAAiB,EAAC;AAAA,IAClB,WAAA,EAAa,CAAA;AAAA,IACb,gBAAgB,EAAC;AAAA,IACjB,YAAY;AAAC,GACf;AACA,EAAA,KAAA,IAAS,OAAA,GAAU,KAAK,OAAA,EAAA,EAAW;AACjC,IAAA,IAAI,OAAA,GAAU,gBAAA,EAAkB,MAAM,IAAI,SAAS,6BAA6B,CAAA;AAChF,IAAA,MAAM,OAAO,GAAA,CAAI,GAAA;AACjB,IAAA,MAAM,OAAA,GAAU,IAAI,OAAA,EAAQ;AAC5B,IAAA,IAAI,YAAY,GAAA,EAAK;AACnB,MAAA,GAAA,CAAI,OAAA,EAAQ;AACZ,MAAA;AAAA,IACF;AACA,IAAA,MAAM,OAAA,GAAU,SAAS,OAAO,CAAA;AAChC,IAAA,IAAI,CAAC,OAAA,EAAS,MAAM,IAAI,KAAA,CAAM,CAAA,qCAAA,EAAwC,OAAO,CAAA,CAAE,CAAA;AAC/E,IAAA,OAAA,CAAQ,KAAK,KAAK,CAAA;AAGlB,IAAA,IAAI,IAAI,GAAA,IAAO,IAAA,EAAM,MAAM,IAAI,SAAS,6BAA6B,CAAA;AAAA,EACvE;AACA,EAAA,OAAO;AAAA,IACL,WAAW,KAAA,CAAM,SAAA;AAAA,IACjB,iBAAiB,KAAA,CAAM,eAAA;AAAA,IACvB,gBAAgB,KAAA,CAAM,cAAA;AAAA,IACtB,YAAY,KAAA,CAAM;AAAA,GACpB;AACF;;;AC7HA,IAAM,IAAA,GAAO,IAAI,WAAA,CAAY,OAAO,CAAA;AAGpC,SAAS,OAAO,KAAA,EAA6B;AAC3C,EAAA,OAAO,IAAI,QAAA,CAAS,KAAA,CAAM,QAAQ,KAAA,CAAM,UAAA,EAAY,MAAM,UAAU,CAAA;AACtE;AAGA,SAAS,QAAA,CAAS,QAAkB,KAAA,EAAmD;AACrF,EAAA,KAAA,MAAW,SAAS,MAAA,EAAQ;AAC1B,IAAA,MAAM,EAAA,GAAK,KAAA,CAAM,OAAA,CAAQ,GAAG,CAAA;AAC5B,IAAA,IAAI,EAAA,GAAK,CAAA,EAAG,KAAA,CAAM,KAAA,CAAM,KAAA,CAAM,CAAA,EAAG,EAAE,CAAA,EAAG,KAAA,CAAM,KAAA,CAAM,EAAA,GAAK,CAAC,CAAC,CAAA;AAAA,EAC3D;AACF;AAGA,SAAS,cAAA,CAAe,OAAmB,GAAA,EAAgC;AACzE,EAAA,QAAA,CAAS,IAAA,CAAK,MAAA,CAAO,KAAK,CAAA,CAAE,MAAM,GAAI,CAAA,EAAG,CAAC,GAAA,EAAK,KAAA,KAAU,GAAA,CAAI,GAAA,CAAI,GAAA,EAAK,KAAK,CAAC,CAAA;AAC9E;AAGA,SAAS,aAAA,CAAc,OAAmB,GAAA,EAAgC;AACxE,EAAA,MAAM,MAAA,GAAS,IAAA,CAAK,MAAA,CAAO,KAAK,EAC7B,KAAA,CAAM,IAAI,CAAA,CACV,OAAA,CAAQ,CAAC,KAAA,KAAU,KAAA,CAAM,KAAA,CAAM,GAAI,CAAC,CAAA;AACvC,EAAA,QAAA,CAAS,MAAA,EAAQ,CAAC,GAAA,EAAK,KAAA,KAAU,GAAA,CAAI,IAAI,GAAA,EAAK,MAAA,CAAO,KAAK,CAAC,CAAC,CAAA;AAC9D;AAEA,SAAS,YAAY,IAAA,EAAuB;AAC1C,EAAA,IAAI,IAAA,KAAS,GAAG,OAAO,SAAA;AACvB,EAAA,IAAI,IAAA,KAAS,GAAG,OAAO,SAAA;AACvB,EAAA,IAAI,IAAA,KAAS,GAAG,OAAO,OAAA;AACvB,EAAA,OAAO,SAAA;AACT;AAOA,SAAS,aAAA,CAAc,QAAsB,MAAA,EAA4B;AACvE,EAAA,MAAM,IAAA,GAAO,MAAA,CAAO,MAAA,CAAO,KAAK,CAAA;AAChC,EAAA,MAAM,WAAsB,EAAC;AAC7B,EAAA,MAAM,OAAA,GAAU,IAAA,CAAK,KAAA,CAAM,MAAA,CAAO,QAAQ,CAAC,CAAA;AAC3C,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,OAAA,EAAS,CAAA,EAAA,EAAK;AAGhC,IAAA,IAAI,CAAA,GAAI,EAAA,GAAK,EAAA,GAAK,MAAA,CAAO,MAAM,MAAA,EAAQ;AACvC,IAAA,QAAA,CAAS,IAAA,CAAK,YAAY,IAAA,CAAK,QAAA,CAAS,IAAI,EAAA,EAAI,MAAM,CAAC,CAAC,CAAA;AAAA,EAC1D;AACA,EAAA,OAAO,QAAA;AACT;AAEA,SAAS,WAAA,CAAY,MAAA,EAAsB,MAAA,EAAiB,IAAA,EAAsB;AAChF,EAAA,QAAQ,OAAO,OAAA;AAAS,IACtB,KAAK,EAAA;AACH,MAAA,IAAA,CAAK,WAAW,IAAA,CAAK,MAAA,CAAO,MAAA,CAAO,KAAK,EAAE,IAAA,EAAK;AAC/C,MAAA;AAAA,IACF,KAAK,CAAA;AACH,MAAA,IAAA,CAAK,SAAS,MAAA,CAAO,MAAA,CAAO,KAAK,CAAA,CAAE,UAAA,CAAW,GAAG,MAAM,CAAA;AACvD,MAAA;AAAA,IACF,KAAK,EAAA;AACH,MAAA,cAAA,CAAe,MAAA,CAAO,KAAA,EAAO,IAAA,CAAK,SAAS,CAAA;AAC3C,MAAA;AAAA,IACF,KAAK,EAAA;AACH,MAAA,aAAA,CAAc,MAAA,CAAO,KAAA,EAAO,IAAA,CAAK,QAAQ,CAAA;AACzC,MAAA;AAAA,IACF,KAAK,EAAA;AACH,MAAA,IAAA,CAAK,QAAA,GAAW,aAAA,CAAc,MAAA,EAAQ,MAAM,CAAA;AAC5C,MAAA;AAEA;AAEN;AAIO,SAAS,eAAA,CAAgB,KAAc,MAAA,EAA2B;AACvE,EAAA,MAAM,IAAA,GAAiB;AAAA,IACrB,QAAA,EAAU,OAAA;AAAA,IACV,MAAA,EAAQ,CAAC,MAAA,CAAO,SAAA;AAAA,IAChB,SAAA,sBAAe,GAAA,EAAI;AAAA,IACnB,QAAA,sBAAc,GAAA,EAAI;AAAA,IAClB,UAAU;AAAC,GACb;AACA,EAAA,KAAA,MAAW,UAAU,GAAA,CAAI,UAAA,EAAY,WAAA,CAAY,MAAA,EAAQ,QAAQ,IAAI,CAAA;AACrE,EAAA,OAAO,IAAA;AACT;;;AC1FA,IAAM,KAAA,GAAQ,IAAI,WAAA,CAAY,cAAc,CAAA;AAE5C,SAAS,cAAc,CAAA,EAAsB;AAC3C,EAAA,IAAI,MAAM,CAAA,IAAK,CAAA,KAAM,CAAA,IAAK,CAAA,KAAM,GAAG,OAAO,CAAA;AAC1C,EAAA,MAAM,IAAI,KAAA,CAAM,CAAA,mCAAA,EAAsC,CAAC,CAAA,CAAE,CAAA;AAC3D;AAIO,SAAS,WAAW,GAAA,EAAwB;AAGjD,EAAA,IAAI,IAAI,MAAA,GAAS,GAAA,EAAK,MAAM,IAAI,SAAS,6BAA6B,CAAA;AACtE,EAAA,MAAM,KAAA,GAAQ,GAAA,CAAI,OAAA,CAAQ,CAAA,EAAG,KAAK,CAAA;AAClC,EAAA,IAAI,UAAU,MAAA,IAAU,KAAA,KAAU,QAAQ,MAAM,IAAI,MAAM,qCAAqC,CAAA;AAC/F,EAAA,GAAA,CAAI,MAAA,GAAS,GAAA,CAAI,IAAA,CAAK,QAAA,CAAS,EAAA,EAAI,IAAI,CAAA,KAAM,CAAA,IAAK,GAAA,CAAI,IAAA,CAAK,QAAA,CAAS,EAAA,EAAI,IAAI,CAAA,KAAM,CAAA;AAClF,EAAA,GAAA,CAAI,KAAK,EAAE,CAAA;AACX,EAAA,GAAA,CAAI,OAAA,EAAQ;AACZ,EAAA,GAAA,CAAI,OAAA,EAAQ;AACZ,EAAA,MAAM,WAAA,GAAc,aAAA,CAAc,GAAA,CAAI,OAAA,EAAS,CAAA;AAC/C,EAAA,GAAA,CAAI,OAAA,EAAQ;AACZ,EAAA,MAAM,MAAA,GAAS,IAAI,OAAA,EAAQ;AAC3B,EAAA,MAAM,IAAA,GAAO,IAAI,OAAA,EAAQ;AACzB,EAAA,GAAA,CAAI,IAAA,CAAK,IAAI,CAAC,CAAA;AACd,EAAA,MAAM,SAAA,GAAY,IAAI,OAAA,CAAQ,EAAA,EAAI,KAAK,CAAA,CAAE,OAAA,CAAQ,YAAY,EAAE,CAAA;AAC/D,EAAA,GAAA,CAAI,KAAK,CAAC,CAAA;AACV,EAAA,OAAO,EAAE,IAAA,EAAM,KAAA,KAAU,QAAQ,WAAA,EAAa,IAAA,EAAM,QAAQ,SAAA,EAAU;AACxE;;;ACrBA,SAAS,OAAO,IAAA,EAA0B;AACxC,EAAA,OAAO,IAAI,UAAA,CAAW,CAAC,CAAA,CAAE,KAAK,IAAI,CAAA;AACpC;AAGA,IAAM,qBAAN,MAAgD;AAAA,EAC9C,WAAA,CACmB,KACA,MAAA,EACjB;AAFiB,IAAA,IAAA,CAAA,GAAA,GAAA,GAAA;AACA,IAAA,IAAA,CAAA,MAAA,GAAA,MAAA;AAAA,EAChB;AAAA,EAFgB,GAAA;AAAA,EACA,MAAA;AAAA,EAGnB,WAAA,GAA6B;AAC3B,IAAA,MAAM,CAAA,GAAI,IAAA,CAAK,GAAA,CAAI,OAAA,EAAQ;AAC3B,IAAA,OAAO,CAAA,KAAM,IAAA,CAAK,MAAA,GAAS,IAAA,GAAO,CAAA;AAAA,EACpC;AAAA,EAEA,WAAA,GAA0B;AACxB,IAAA,OAAO,IAAA,CAAK,GAAA,CAAI,SAAA,CAAU,CAAC,CAAA;AAAA,EAC7B;AAAA,EAEA,KAAA,GAAiB;AACf,IAAA,OAAO,IAAA,CAAK,GAAA,CAAI,GAAA,GAAM,CAAA,GAAI,KAAK,GAAA,CAAI,MAAA;AAAA,EACrC;AACF,CAAA;AAMA,IAAM,YAAN,MAAuC;AAAA;AAAA,EAKrC,WAAA,CACmB,KACA,IAAA,EACjB;AAFiB,IAAA,IAAA,CAAA,GAAA,GAAA,GAAA;AACA,IAAA,IAAA,CAAA,IAAA,GAAA,IAAA;AAAA,EAChB;AAAA,EAFgB,GAAA;AAAA,EACA,IAAA;AAAA,EANX,KAAA,GAAoB,IAAI,UAAA,CAAW,CAAC,CAAA;AAAA,EACpC,GAAA,GAAM,CAAA;AAAA;AAAA,EACN,SAAA,GAA2B,IAAA;AAAA;AAAA;AAAA,EAS3B,OAAA,GAAkB;AACxB,IAAA,IAAI,IAAA,CAAK,OAAO,CAAA,EAAG;AACjB,MAAA,IAAI,KAAK,GAAA,CAAI,GAAA,IAAO,IAAA,CAAK,GAAA,CAAI,QAAQ,OAAO,GAAA;AAC5C,MAAA,IAAA,CAAK,KAAA,GAAQ,IAAA,CAAK,GAAA,CAAI,SAAA,CAAU,CAAC,CAAA;AACjC,MAAA,IAAA,CAAK,GAAA,GAAM,CAAA;AAAA,IACb;AACA,IAAA,OAAO,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,GAAA,EAAK,CAAA;AAAA,EAC9B;AAAA;AAAA;AAAA,EAIQ,QAAA,GAAmB;AACzB,IAAA,IAAI,IAAA,CAAK,cAAc,IAAA,EAAM;AAC3B,MAAA,MAAM,OAAO,IAAA,CAAK,SAAA;AAClB,MAAA,IAAA,CAAK,SAAA,GAAY,IAAA;AACjB,MAAA,OAAO,IAAA;AAAA,IACT;AACA,IAAA,WAAS;AACP,MAAA,MAAM,IAAA,GAAO,KAAK,OAAA,EAAQ;AAC1B,MAAA,IAAI,IAAA,KAAS,GAAG,OAAO,IAAA;AAAA,IACzB;AAAA,EACF;AAAA,EAEA,KAAA,GAAiB;AACf,IAAA,IAAI,KAAK,SAAA,KAAc,IAAA,EAAM,IAAA,CAAK,SAAA,GAAY,KAAK,QAAA,EAAS;AAC5D,IAAA,OAAO,KAAK,SAAA,KAAc,GAAA;AAAA,EAC5B;AAAA,EAEA,WAAA,GAA6B;AAC3B,IAAA,MAAM,IAAA,GAAO,KAAK,QAAA,EAAS;AAC3B,IAAA,IAAI,IAAA,KAAS,GAAA,IAAO,IAAA,KAAS,GAAA,EAAK,OAAO,IAAA;AACzC,IAAA,IAAI,IAAA,KAAS,GAAA,EAAK,OAAO,IAAA,CAAK,IAAI,OAAA,EAAQ;AAC1C,IAAA,OAAO,OAAO,IAAA,CAAK,IAAA;AAAA,EACrB;AAAA,EAEA,WAAA,GAA0B;AACxB,IAAA,MAAM,IAAA,GAAO,KAAK,QAAA,EAAS;AAC3B,IAAA,IAAI,IAAA,KAAS,GAAA,EAAK,OAAO,MAAA,CAAO,EAAI,CAAA;AACpC,IAAA,IAAI,SAAS,GAAA,EAAK,OAAO,IAAA,CAAK,GAAA,CAAI,UAAU,CAAC,CAAA;AAC7C,IAAA,OAAO,OAAO,EAAI,CAAA;AAAA,EACpB;AACF,CAAA;AAGA,SAAS,QAAQ,GAAA,EAAqB;AACpC,EAAA,MAAM,IAAI,GAAA,CAAI,IAAA,CAAK,YAAY,GAAA,CAAI,GAAA,EAAK,IAAI,MAAM,CAAA;AAClD,EAAA,GAAA,CAAI,GAAA,IAAO,CAAA;AACX,EAAA,OAAO,OAAO,CAAC,CAAA;AACjB;AAGA,SAAS,eAAe,KAAA,EAAkC;AACxD,EAAA,MAAM,KAAA,GAAQ,MAAM,MAAA,CAAO,CAAC,GAAG,CAAA,KAAM,CAAA,GAAI,CAAA,CAAE,MAAA,EAAQ,CAAC,CAAA;AACpD,EAAA,MAAM,GAAA,GAAM,IAAI,WAAA,CAAY,KAAK,CAAA;AACjC,EAAA,MAAM,GAAA,GAAM,IAAI,UAAA,CAAW,GAAG,CAAA;AAC9B,EAAA,IAAI,GAAA,GAAM,CAAA;AACV,EAAA,KAAA,MAAW,KAAK,KAAA,EAAO;AACrB,IAAA,GAAA,CAAI,GAAA,CAAI,GAAG,GAAG,CAAA;AACd,IAAA,GAAA,IAAO,CAAA,CAAE,MAAA;AAAA,EACX;AACA,EAAA,OAAO,GAAA;AACT;AAKA,eAAe,cAAA,CAAe,YAAwB,QAAA,EAAuC;AAG3F,EAAA,MAAM,KAAA,GAAQ,IAAI,UAAA,CAAW,UAAA,CAAW,MAAM,CAAA;AAC9C,EAAA,KAAA,CAAM,IAAI,UAAU,CAAA;AACpB,EAAA,MAAM,MAAA,GAAS,IAAI,IAAA,CAAK,CAAC,KAAK,CAAC,CAAA,CAAE,MAAA,EAAO,CAAE,WAAA,CAAY,IAAI,mBAAA,CAAoB,SAAS,CAAC,CAAA;AACxF,EAAA,MAAM,MAAA,GAAS,OAAO,SAAA,EAAU;AAChC,EAAA,MAAM,QAAsB,EAAC;AAC7B,EAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,EAAA,WAAS;AACP,IAAA,MAAM,EAAE,IAAA,EAAM,KAAA,EAAM,GAAI,MAAM,OAAO,IAAA,EAAK;AAC1C,IAAA,IAAI,IAAA,EAAM;AACV,IAAA,KAAA,IAAS,KAAA,CAAM,UAAA;AACf,IAAA,IAAI,QAAQ,QAAA,EAAU;AACpB,MAAA,MAAM,OAAO,MAAA,EAAO;AACpB,MAAA,MAAM,IAAI,SAAS,oCAAoC,CAAA;AAAA,IACzD;AACA,IAAA,KAAA,CAAM,KAAK,KAAK,CAAA;AAAA,EAClB;AACA,EAAA,OAAO,IAAI,UAAA,CAAW,cAAA,CAAe,KAAK,CAAC,CAAA;AAC7C;AAGA,SAAS,iBAAA,CAAkB,KAAa,WAAA,EAA+B;AACrE,EAAA,GAAA,CAAI,KAAK,WAAW,CAAA;AACpB,EAAA,OAAA,CAAQ,GAAG,CAAA;AACX,EAAA,OAAA,CAAQ,GAAG,CAAA;AACX,EAAA,GAAA,CAAI,OAAA,EAAQ;AACZ,EAAA,MAAM,OAAA,GAAU,IAAI,OAAA,EAAQ;AAC5B,EAAA,IAAI,UAAU,CAAA,IAAK,OAAA,GAAU,KAAW,MAAM,IAAI,SAAS,+BAA+B,CAAA;AAC1F,EAAA,MAAM,SAAmB,EAAC;AAC1B,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,OAAA,EAAS,CAAA,EAAA,EAAK;AAChC,IAAA,OAAA,CAAQ,GAAG,CAAA;AACX,IAAA,MAAM,aAAA,GAAgB,QAAQ,GAAG,CAAA;AACjC,IAAA,GAAA,CAAI,OAAA,EAAQ;AACZ,IAAA,MAAA,CAAO,KAAK,EAAE,aAAA,EAAe,gBAAgB,GAAA,CAAI,OAAA,IAAW,CAAA;AAAA,EAC9D;AACA,EAAA,OAAO,MAAA;AACT;AAIO,SAAS,UAAA,CAAW,GAAA,EAAa,MAAA,EAAmB,MAAA,EAA6B;AACtF,EAAA,IAAI,OAAO,WAAA,KAAgB,CAAA,SAAU,IAAI,kBAAA,CAAmB,KAAK,MAAM,CAAA;AACvE,EAAA,OAAO,IAAI,SAAA,CAAU,GAAA,EAAK,MAAA,CAAO,IAAI,CAAA;AACvC;AASA,eAAsB,WAAA,CAAY,KAAa,gBAAA,EAAgD;AAC7F,EAAA,OAAA,CAAQ,GAAG,CAAA;AACX,EAAA,MAAM,WAAA,GAAc,QAAQ,GAAG,CAAA;AAC/B,EAAA,OAAA,CAAQ,GAAG,CAAA;AACX,EAAA,MAAM,MAAA,GAAS,iBAAA,CAAkB,GAAA,EAAK,WAAW,CAAA;AACjD,EAAA,MAAM,QAAsB,EAAC;AAC7B,EAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,EAAA,KAAA,MAAW,SAAS,MAAA,EAAQ;AAC1B,IAAA,GAAA,CAAI,IAAA,CAAK,MAAM,aAAa,CAAA;AAC5B,IAAA,MAAM,WAAW,MAAM,cAAA;AAAA,MACrB,GAAA,CAAI,SAAA,CAAU,KAAA,CAAM,cAAc,CAAA;AAAA,MAClC,gBAAA,GAAmB;AAAA,KACrB;AACA,IAAA,KAAA,IAAS,QAAA,CAAS,MAAA;AAClB,IAAA,KAAA,CAAM,KAAK,QAAQ,CAAA;AAAA,EACrB;AACA,EAAA,OAAO,eAAe,KAAK,CAAA;AAC7B;;;AC9KA,SAAS,QAAA,CAAS,KAAiB,MAAA,EAAyB;AAC1D,EAAA,OAAO,IAAI,QAAA,CAAS,GAAA,CAAI,MAAA,EAAQ,GAAA,CAAI,YAAY,CAAC,CAAA,CAAE,UAAA,CAAW,CAAA,EAAG,MAAM,CAAA;AACzE;AAMA,SAAS,cAAA,CACP,aAAA,EACA,QAAA,EACA,IAAA,EACA,QACA,GAAA,EAC0B;AAC1B,EAAA,MAAM,MAAoB,EAAC;AAC3B,EAAA,KAAA,MAAW,OAAO,IAAA,EAAM;AACtB,IAAA,IAAI,CAAC,GAAA,CAAI,UAAA,CAAW,QAAA,CAAS,aAAa,CAAA,EAAG;AAC7C,IAAA,KAAA,MAAW,KAAA,IAAS,IAAI,MAAA,EAAQ;AAC9B,MAAA,MAAM,KAAA,GAAQ,QAAA,GACV,GAAA,CAAI,MAAA,CAAO,MAAM,GAAG,CAAA,CAAE,OAAA,CAAQ,KAAA,EAAO,EAAE,CAAA,GACvC,QAAA,CAAS,KAAA,CAAM,KAAK,MAAM,CAAA;AAC9B,MAAA,GAAA,CAAI,IAAA,CAAK,EAAE,KAAA,EAAO,KAAA,EAAO,GAAA,CAAI,MAAA,CAAO,KAAA,CAAM,QAAQ,CAAA,CAAE,OAAA,CAAQ,MAAA,EAAQ,EAAE,GAAG,CAAA;AAAA,IAC3E;AAAA,EACF;AACA,EAAA,OAAO,GAAA,CAAI,MAAA,GAAS,CAAA,GAAI,GAAA,GAAM,MAAA;AAChC;AAGA,SAAS,YAAA,CAAa,MAAA,EAAqB,KAAA,EAAe,IAAA,EAA0B;AAClF,EAAA,OAAO;AAAA,IACL,MAAM,IAAA,CAAK,SAAA,CAAU,IAAI,MAAA,CAAO,IAAI,KAAK,MAAA,CAAO,IAAA;AAAA,IAChD,IAAA,EAAM,MAAA,CAAO,IAAA,GAAO,CAAA,GAAI,QAAA,GAAW,SAAA;AAAA,IACnC,SAAS,MAAA,CAAO,OAAA;AAAA,IAChB,MAAA,EAAQ,EAAE,GAAG,MAAA,CAAO,KAAA,EAAO,QAAQ,YAAA,CAAa,MAAA,CAAO,KAAA,CAAM,IAAI,CAAA,EAAE;AAAA,IACnE,OAAA,EAAS,IAAA,CAAK,QAAA,CAAS,KAAK,CAAA,IAAK;AAAA,GACnC;AACF;AAKA,SAAS,aAAA,CACP,MAAA,EACA,KAAA,EACA,aAAA,EACA,GAAA,EACU;AACV,EAAA,MAAM,QAAA,GAAW,OAAO,IAAA,GAAO,CAAA;AAC/B,EAAA,MAAM,QAAA,GAAW,YAAA,CAAa,MAAA,EAAQ,KAAA,EAAO,IAAI,IAAI,CAAA;AACrD,EAAA,IAAI,MAAA,CAAO,KAAA,KAAU,MAAA,EAAW,QAAA,CAAS,QAAQ,MAAA,CAAO,KAAA;AACxD,EAAA,IAAI,QAAA,EAAU,QAAA,CAAS,KAAA,GAAQ,MAAA,CAAO,IAAA;AACtC,EAAA,MAAM,WAAA,GAAc,eAAe,aAAA,EAAe,QAAA,EAAU,IAAI,IAAA,EAAM,GAAA,CAAI,MAAA,EAAQ,GAAA,CAAI,GAAG,CAAA;AACzF,EAAA,IAAI,WAAA,WAAsB,WAAA,GAAc,WAAA;AACxC,EAAA,OAAO,QAAA;AACT;AAKA,SAAS,aAAA,CAAc,GAAA,EAAc,KAAA,EAAe,SAAA,EAA6B;AAC/E,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,IAAA,CAAK,SAAA,GAAY,GAAG,CAAA;AACnC,EAAA,MAAM,SAAmB,EAAC;AAC1B,EAAA,KAAA,IAAS,CAAA,GAAI,GAAG,CAAA,GAAI,CAAA,IAAK,QAAQ,CAAA,GAAI,GAAA,CAAI,SAAA,CAAU,MAAA,EAAQ,CAAA,EAAA,EAAK;AAC9D,IAAA,MAAA,CAAO,KAAK,GAAA,CAAI,SAAA,CAAU,KAAA,GAAQ,CAAC,EAAE,IAAI,CAAA;AAAA,EAC3C;AACA,EAAA,OAAO,MAAA;AACT;AAKA,SAAS,UAAA,CAAW,KAAc,GAAA,EAAmC;AACnE,EAAA,MAAM,QAAwB,EAAC;AAC/B,EAAA,IAAI,CAAA,GAAI,CAAA;AACR,EAAA,OAAO,CAAA,GAAI,GAAA,CAAI,SAAA,CAAU,MAAA,EAAQ;AAC/B,IAAA,MAAM,MAAA,GAAS,GAAA,CAAI,SAAA,CAAU,CAAC,CAAA;AAC9B,IAAA,MAAM,QAAA,GAAW,cAAc,MAAA,EAAQ,CAAA,EAAG,IAAI,eAAA,CAAgB,CAAC,GAAG,GAAG,CAAA;AACrE,IAAA,MAAM,SAAA,GAAY,MAAA,CAAO,IAAA,GAAO,CAAA,GAAI,GAAA,CAAI,KAAK,QAAA,CAAS,GAAA,CAAI,MAAA,CAAO,IAAI,CAAA,GAAI,MAAA;AACzE,IAAA,IAAI,cAAc,MAAA,EAAW;AAC3B,MAAA,KAAA,CAAM,IAAA,CAAK,EAAE,QAAA,EAAU,QAAA,EAAU,MAAA,CAAO,IAAA,GAAO,CAAA,GAAI,CAAC,MAAA,CAAO,IAAI,CAAA,GAAI,IAAI,CAAA;AACvE,MAAA,CAAA,IAAK,CAAA;AACL,MAAA;AAAA,IACF;AACA,IAAA,MAAM,QAAA,GAAW,aAAA,CAAc,GAAA,EAAK,CAAA,EAAG,SAAS,CAAA;AAChD,IAAA,QAAA,CAAS,KAAA,GAAQ,SAAA;AACjB,IAAA,KAAA,CAAM,IAAA,CAAK,EAAE,QAAA,EAAU,QAAA,EAAU,CAAA;AACjC,IAAA,CAAA,IAAK,QAAA,CAAS,MAAA;AAAA,EAChB;AACA,EAAA,OAAO,KAAA;AACT;AAKA,eAAsB,OAAA,CAAQ,KAAkB,IAAA,EAAgD;AAC9F,EAAA,MAAM,MAAA,GAAS,EAAE,GAAG,cAAA,EAAgB,GAAG,IAAA,EAAK;AAC5C,EAAA,IAAI,GAAA,GAAM,IAAI,MAAA,CAAO,GAAG,CAAA;AACxB,EAAA,MAAM,MAAA,GAAS,WAAW,GAAG,CAAA;AAC7B,EAAA,MAAM,GAAA,GAAM,eAAe,GAAG,CAAA;AAC9B,EAAA,MAAM,IAAA,GAAO,eAAA,CAAgB,GAAA,EAAK,GAAA,CAAI,MAAM,CAAA;AAC5C,EAAA,MAAM,SAAS,GAAA,CAAI,MAAA;AACnB,EAAA,MAAM,MAAM,IAAI,WAAA,CAAY,aAAA,CAAc,IAAA,CAAK,QAAQ,CAAC,CAAA;AACxD,EAAA,MAAM,KAAA,GAAQ,UAAA,CAAW,GAAA,EAAK,EAAE,IAAA,EAAM,MAAM,GAAA,CAAI,cAAA,EAAgB,MAAA,EAAQ,GAAA,EAAK,CAAA;AAC7E,EAAA,MAAM,YAAY,KAAA,CAAM,GAAA,CAAI,CAAC,IAAA,KAAS,KAAK,QAAQ,CAAA;AAGnD,EAAA,IAAI,UAAU,MAAA,KAAW,CAAA,EAAG,MAAM,IAAI,SAAS,uBAAuB,CAAA;AACtE,EAAA,IAAI,MAAA,CAAO,UAAU,CAAA,IAAK,MAAA,CAAO,SAAS,SAAA,CAAU,MAAA,GAAS,OAAO,QAAA,EAAU;AAC5E,IAAA,MAAM,IAAI,QAAA;AAAA,MACR,CAAA,WAAA,EAAc,OAAO,MAAM,CAAA,MAAA,EAAM,UAAU,MAAM,CAAA,yBAAA,EAA4B,OAAO,QAAQ,CAAA;AAAA,KAC9F;AAAA,EACF;AACA,EAAA,IAAI,OAAO,IAAA,EAAM;AACf,IAAA,MAAM,IAAA,GAAO,MAAM,WAAA,CAAY,GAAA,EAAK,OAAO,gBAAgB,CAAA;AAC3D,IAAA,GAAA,GAAM,IAAI,OAAO,IAAI,CAAA;AACrB,IAAA,GAAA,CAAI,MAAA,GAAS,MAAA;AAAA,EACf;AACA,EAAA,MAAM,MAAA,GAAS,UAAA,CAAW,GAAA,EAAK,MAAA,EAAQ,KAAK,MAAM,CAAA;AAClD,EAAA,MAAM,OAAO,SAAA,CAAU,MAAA,EAAQ,KAAA,EAAO,IAAA,EAAM,QAAQ,MAAM,CAAA;AAC1D,EAAA,OAAO,EAAE,MAAA,EAAQ,KAAA,EAAO,QAAA,EAAU,KAAK,QAAA,EAAU,MAAA,EAAQ,CAAC,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAA,EAAW,IAAA,EAAM,CAAA,EAAE;AAC/F","file":"index.js","sourcesContent":["/** Thrown for any malformed or hostile `.sav` input that a resource bound rejects — a bad file, not\n * a reader bug. Distinct from the plain `Error`s raised for unsupported-but-valid constructs so a\n * caller can `catch` an attack (OOM/hang avoidance) separately if it wants to. */\nexport class SavError extends Error {}\n\n/** Resource ceilings that keep a hostile `.sav` from exhausting memory or looping unboundedly. The\n * defaults are generous, so no well-formed file is affected; pass a stricter `readSav(buf, opts)` in\n * memory-constrained environments. */\nexport type SavLimits = {\n /** Maximum `ncases × nvars` data cells the reader will materialize (~500k rows × 100 vars). */\n maxCells: number;\n /** Cumulative inflate output ceiling: total bytes a ZSAV may inflate to before the reader aborts (512 MiB). */\n maxInflatedBytes: number;\n};\n\nexport const DEFAULT_LIMITS: SavLimits = {\n maxCells: 50_000_000,\n maxInflatedBytes: 512 * 1024 * 1024,\n};\n","import type { CellValue, MissingSpec, Sheet } from \"./types\";\n\n/** True when the numeric `cell` matches a numeric `spec` (discrete / range / range+discrete). */\nfunction numberMatches(cell: number, spec: MissingSpec): boolean {\n switch (spec.kind) {\n case \"discrete\":\n return spec.values.includes(cell);\n case \"range\":\n return cell >= spec.lo && cell <= spec.hi;\n case \"range+discrete\":\n return (cell >= spec.lo && cell <= spec.hi) || cell === spec.value;\n default:\n return false; // \"none\" | \"strings\" never match a number\n }\n}\n\n/**\n * True when `cell` matches the variable's declared user-missing `spec`.\n * Only plain numbers/strings can match; a `Date` or already-`null` cell never does.\n */\nfunction isMissing(cell: CellValue, spec: MissingSpec): boolean {\n if (typeof cell === \"number\") return numberMatches(cell, spec);\n if (typeof cell === \"string\") return spec.kind === \"strings\" && spec.values.includes(cell);\n return false;\n}\n\n/**\n * Return a new {@link Sheet} in which every cell matching its variable's\n * declared {@link MissingSpec} is replaced by `null` (user-missing → system-missing).\n * Non-mutating: fresh `rows` and fresh inner arrays; the input is left untouched.\n */\nexport function applyUserMissing(sheet: Sheet): Sheet {\n const specs = sheet.variables.map((v) => v.missing);\n const rows = sheet.rows.map((row) =>\n row.map((cell, col) => (isMissing(cell, specs[col]) ? null : cell)),\n );\n return { ...sheet, rows };\n}\n","import { SavError } from \"../limits\";\n\n/** Endian-aware sequential reader over a DataView. `little` is set by the header's layout-code probe. */\nexport class Cursor {\n readonly view: DataView;\n pos = 0;\n little = true;\n\n constructor(buf: ArrayBuffer) {\n this.view = new DataView(buf);\n }\n\n get length(): number {\n return this.view.byteLength;\n }\n\n seek(p: number): void {\n this.pos = p;\n }\n\n skip(n: number): void {\n this.pos += n;\n }\n\n readI32(): number {\n const v = this.view.getInt32(this.pos, this.little);\n this.pos += 4;\n return v;\n }\n\n readF64(): number {\n const v = this.view.getFloat64(this.pos, this.little);\n this.pos += 8;\n return v;\n }\n\n readBytes(n: number): Uint8Array {\n // Bound every allocation to the bytes actually remaining: a hostile length field (`size*count`,\n // `labelLen`, `compressedSize`, …) can claim gigabytes, so refuse BEFORE `new Uint8Array(n)`.\n if (n < 0 || this.pos + n > this.length) {\n throw new SavError(`read of ${n} bytes exceeds the file`);\n }\n const out = new Uint8Array(n);\n out.set(new Uint8Array(this.view.buffer, this.view.byteOffset + this.pos, n));\n this.pos += n;\n return out;\n }\n\n readStr(n: number, dec: TextDecoder): string {\n return dec.decode(this.readBytes(n));\n }\n}\n","// SPSS format-type codes whose stored seconds-since-epoch map to a CALENDAR date/datetime:\n// DATE 20, DATETIME 22, ADATE 23, JDATE 24, MOYR 28, QYR 29, WKYR 30, EDATE 38, SDATE 39.\n// Deliberately EXCLUDED (they store seconds/counts but are not calendar dates, and haven decodes\n// them as durations / labelled numerics, not dates): TIME 21, DTIME 25, WKDAY 26, MONTH 27.\n// haven writes as.Date → 20 and as.POSIXct → 22 (verified against the dates fixture).\nconst DATE_FORMAT_TYPES = new Set([20, 22, 23, 24, 28, 29, 30, 38, 39]);\n\n// Seconds between the SPSS epoch (1582-10-14 00:00, the Gregorian calendar reform) and the Unix epoch.\nconst SPSS_EPOCH_OFFSET_SECONDS = 12219379200;\n\n/** True for the SPSS date/datetime format types whose stored seconds map to a calendar date. */\nexport function isDateFormat(type: number): boolean {\n return DATE_FORMAT_TYPES.has(type);\n}\n\n/** Convert an SPSS date value (seconds since 1582-10-14) to a JS `Date`. */\nexport function spssToDate(seconds: number): Date {\n return new Date((seconds - SPSS_EPOCH_OFFSET_SECONDS) * 1000);\n}\n","import type { SavLimits } from \"../limits\";\nimport type { CellValue, Variable } from \"../types\";\nimport type { DictInfo } from \"./extensions\";\nimport type { SavHeader } from \"./header\";\nimport type { ValueSource } from \"./source\";\n\nimport { SavError } from \"../limits\";\nimport { spssToDate } from \"./dates\";\n\n/** A variable paired with the physical segment layout the case reader needs. `segments` holds the\n * allocated byte widths of the string segments to read and concatenate — one entry for a normal\n * string (≤ 255), N entries for a very-long string reassembled from N segments; empty for numerics. */\nexport type VariablePlan = {\n variable: Variable;\n segments: number[];\n};\n\n/** Map the file's declared charset name onto the three TextDecoder labels Bun's types accept\n * (`Bun.Encoding`): a plain `string` isn't assignable, so collapse it here without an `as` cast.\n * (The three-way mapping is validated by Task 9's encoding fixtures.) */\nexport function toBunEncoding(enc: string): \"utf-8\" | \"windows-1252\" | \"utf-16\" {\n const e = enc.toLowerCase();\n if (e.includes(\"1252\") || e.includes(\"ansi\") || e.includes(\"8859\") || e.includes(\"latin\")) {\n return \"windows-1252\";\n }\n if (e.includes(\"16\")) return \"utf-16\";\n return \"utf-8\";\n}\n\n/** Read one string segment: `ceil(alloc/8)` 8-byte chunks, keeping only the segment's first `alloc`\n * content bytes. The chunk padding past `alloc` is inter-segment filler (a partial trailing 8-byte\n * unit) that must NOT survive into the concatenation of a very-long string's segments. */\nfunction readSegment(alloc: number, source: ValueSource): Uint8Array {\n const chunks = Math.max(1, Math.ceil(alloc / 8));\n const raw = new Uint8Array(chunks * 8);\n for (let c = 0; c < chunks; c++) raw.set(source.nextString8(), c * 8);\n return raw.subarray(0, alloc);\n}\n\n/** A string cell is one or more segments concatenated at the BYTE level (very-long strings span\n * several), decoded once with the file encoding (so a multi-byte char split across a segment\n * boundary survives), then stripped of SPSS's trailing-space padding. */\nfunction readStringValue(segments: number[], source: ValueSource, dec: TextDecoder): string {\n const parts: Uint8Array[] = [];\n for (const alloc of segments) parts.push(readSegment(alloc, source));\n const total = parts.reduce((n, p) => n + p.length, 0);\n const all = new Uint8Array(total);\n let ofs = 0;\n for (const p of parts) {\n all.set(p, ofs);\n ofs += p.length;\n }\n return dec.decode(all).replace(/ +$/, \"\");\n}\n\n/** A numeric cell is one f64; system-missing stays `null`, a date format maps its seconds to a Date. */\nfunction readNumericCell(variable: Variable, source: ValueSource): CellValue {\n const v = source.nextNumeric();\n if (v !== null && variable.format.isDate) return spssToDate(v);\n return v;\n}\n\nfunction readCell(plan: VariablePlan, source: ValueSource, dec: TextDecoder): CellValue {\n if (plan.segments.length > 0) return readStringValue(plan.segments, source, dec);\n return readNumericCell(plan.variable, source);\n}\n\n/** Read the flat data section into rows, one cell per plan in dictionary order. When the header\n * carries a real case count (`ncases >= 0`) exactly that many rows are read; when it is unknown\n * (`ncases = -1`, which real SPSS writes — e.g. the user's GenAI.sav — even though haven never does)\n * rows are read until the source reaches the end-of-data marker. */\nexport function readCases(\n header: SavHeader,\n plans: VariablePlan[],\n dictInfo: DictInfo,\n source: ValueSource,\n limits: SavLimits,\n): CellValue[][] {\n const dec = new TextDecoder(toBunEncoding(dictInfo.encoding));\n const rows: CellValue[][] = [];\n if (header.ncases >= 0) {\n // Bounded upfront by readSav's `ncases × nvars ≤ maxCells` check before we get here.\n for (let i = 0; i < header.ncases; i++) {\n rows.push(plans.map((plan) => readCell(plan, source, dec)));\n }\n return rows;\n }\n while (!source.atEnd()) {\n rows.push(plans.map((plan) => readCell(plan, source, dec)));\n // The unknown-count path has no header ceiling — cap materialized cells so a crafted\n // never-ending stream can't grow `rows` without bound.\n if (rows.length * plans.length > limits.maxCells) {\n throw new SavError(\"case count exceeds cell limit\");\n }\n }\n return rows;\n}\n","import type { MissingSpec, SpssFormat } from \"../types\";\nimport type { Cursor } from \"./binary\";\n\nimport { SavError } from \"../limits\";\n\n// The only n_missing_values SPSS emits: 0 none · 1..3 discrete · -2 range · -3 range+one discrete.\n// Anything else (a bogus 1000, or Math.abs(-2147483648) staying hugely negative) would drive\n// readMissing to read count-many slots past the buffer, so it is rejected outright.\nconst VALID_NMISSING = new Set([-3, -2, 0, 1, 2, 3]);\n\nexport type RawVariable = {\n name: string;\n type: number;\n label?: string;\n missing: MissingSpec;\n print: SpssFormat;\n write: SpssFormat;\n};\n\n/** Unpack SPSS's 3-byte format code: byte0 decimals, byte1 width, byte2 type. `isDate` stays a\n * defaulted pure predicate so this module needs no forward dep on date detection (wired in Task 8). */\nexport function decodeFormat(\n packed: number,\n isDate: (t: number) => boolean = () => false,\n): SpssFormat {\n const decimals = packed & 0xff;\n const width = (packed >> 8) & 0xff;\n const type = (packed >> 16) & 0xff;\n return { type, width, decimals, isDate: isDate(type) };\n}\n\n/** Map n_missing_values to a MissingSpec: 0 none · >0 discrete · -2 range · -3 range+one discrete. */\nexport function decodeMissing(n: number, values: number[]): MissingSpec {\n if (n === 0) return { kind: \"none\" };\n if (n > 0) return { kind: \"discrete\", values };\n if (n === -2) return { kind: \"range\", lo: values[0], hi: values[1] };\n return { kind: \"range+discrete\", lo: values[0], hi: values[1], value: values[2] };\n}\n\n/** Read a variable's `|n_missing|` trailing missing-value slots. A string variable (width > 0) stores\n * each as an 8-byte right-space-padded string; a numeric variable stores each as an f64 (and negative\n * counts select range / range+discrete). String missing counts are always positive (no ranges).\n * String slots are decoded with `dec` (the dictionary-time provisional decoder — ASCII-safe for the\n * short sentinels SPSS allows here; a non-ASCII string sentinel would need the file encoding). */\nfunction readMissing(cur: Cursor, nMissing: number, type: number, dec: TextDecoder): MissingSpec {\n const count = Math.abs(nMissing);\n if (type > 0) {\n const values: string[] = [];\n for (let i = 0; i < count; i++) values.push(cur.readStr(8, dec).replace(/ +$/, \"\"));\n return count > 0 ? { kind: \"strings\", values } : { kind: \"none\" };\n }\n const nums: number[] = [];\n for (let i = 0; i < count; i++) nums.push(cur.readF64());\n return decodeMissing(nMissing, nums);\n}\n\n/** Read one type-2 variable record. The cursor starts at the `type` field (the leading rec_type=2\n * int is consumed by the dictionary loop). Returns `\"continuation\"` for a long-string overflow\n * segment (type === -1): its body (has_label, n_missing, print, write, name — all zero/blank per\n * spec) still occupies the fixed 24 trailing bytes, which MUST be consumed or the loop desyncs. */\nexport function readVariableRecord(cur: Cursor, dec: TextDecoder): RawVariable | \"continuation\" {\n const type = cur.readI32();\n if (type === -1) {\n cur.skip(24); // has_label(4) + n_missing(4) + print(4) + write(4) + name(8), all ignored\n return \"continuation\";\n }\n const hasLabel = cur.readI32();\n const nMissing = cur.readI32();\n if (!VALID_NMISSING.has(nMissing)) throw new SavError(\"invalid missing-value count\");\n const print = decodeFormat(cur.readI32());\n const write = decodeFormat(cur.readI32());\n const name = cur.readStr(8, dec).replace(/[\\s\\0]+$/, \"\"); // SPSS pads short names with NUL or space\n let label: string | undefined;\n if (hasLabel) {\n const labelLen = cur.readI32();\n label = cur.readStr(labelLen, dec);\n cur.skip((4 - (labelLen % 4)) % 4);\n }\n const missing = readMissing(cur, nMissing, type, dec);\n return { name, type, label, missing, print, write };\n}\n","import type { Cursor } from \"./binary\";\nimport type { RawVariable } from \"./variable\";\n\nimport { SavError } from \"../limits\";\nimport { readVariableRecord } from \"./variable\";\n\n// A dictionary record is at least the 4-byte rec_type, so a well-formed file never approaches this;\n// the backstop only fires on a hostile stream that never reaches the type-999 terminator.\nconst MAX_DICT_RECORDS = 10_000_000;\n// SPSS documents are a handful of 80-char lines; a negative or absurd count is a malformed record and\n// (negative) would make `skip` — the one cursor primitive that can move backward — rewind the loop.\nconst MAX_DOC_LINES = 100_000;\n\nexport type RawValueLabel = { raw: Uint8Array; labelRaw: Uint8Array };\nexport type RawValueLabelSet = { labels: RawValueLabel[]; varIndexes: number[] };\nexport type RawExtension = { subtype: number; size: number; count: number; bytes: Uint8Array };\n\nexport type RawDict = {\n variables: RawVariable[];\n /** For each kept variable (same order as `variables`), its 1-based PHYSICAL dictionary position —\n * i.e. counting the type=-1 string-continuation records that `variables` drops. Type-4 value-label\n * variable indexes are physical, so labels must be matched against this, not the logical index. */\n physicalIndexes: number[];\n valueLabelSets: RawValueLabelSet[];\n extensions: RawExtension[];\n};\n\ntype DictState = {\n variables: RawVariable[];\n physicalIndexes: number[];\n physicalPos: number; // 1-based position of the NEXT variable record (real or continuation)\n valueLabelSets: RawValueLabelSet[];\n extensions: RawExtension[];\n pending?: RawValueLabelSet;\n};\n\n// The file's true encoding isn't known until the extension records are parsed (Task 6). Variable\n// names are ASCII, so this provisional UTF-8 decoder is enough here; value bytes AND label bytes are\n// kept raw and decoded downstream with the real file encoding (reader.ts).\nconst PROVISIONAL = new TextDecoder(\"utf-8\");\n\n/** Read one type-3 value-label record: an i32 count then `count` × (8 raw value bytes + a u8 label\n * length + label bytes), each entry padded so `(1 + label_len)` rounds up to a multiple of 8. The\n * raw value AND label bytes are kept undecoded — the value's numeric-vs-string kind is resolved by\n * the owning variable, and both are decoded with the file encoding in reader.ts. */\nfunction readValueLabelSet(cur: Cursor): RawValueLabelSet {\n const count = cur.readI32();\n const labels: RawValueLabel[] = [];\n for (let i = 0; i < count; i++) {\n const raw = cur.readBytes(8);\n const labelLen = cur.readBytes(1)[0];\n const labelRaw = cur.readBytes(labelLen);\n cur.skip((8 - ((1 + labelLen) % 8)) % 8);\n labels.push({ raw, labelRaw });\n }\n return { labels, varIndexes: [] };\n}\n\n/** Read one type-7 extension record's header (subtype, element size, count) and capture its\n * `size*count`-byte payload verbatim for Task 6 to interpret by subtype. */\nfunction readExtension(cur: Cursor): RawExtension {\n const subtype = cur.readI32();\n const size = cur.readI32();\n const count = cur.readI32();\n return { subtype, size, count, bytes: cur.readBytes(size * count) };\n}\n\nfunction handleVariable(cur: Cursor, state: DictState): void {\n const physIdx = state.physicalPos;\n state.physicalPos += 1; // both real records and continuations advance the physical position\n const v = readVariableRecord(cur, PROVISIONAL);\n if (v !== \"continuation\") {\n state.variables.push(v);\n state.physicalIndexes.push(physIdx);\n }\n}\n\nfunction handleValueLabels(cur: Cursor, state: DictState): void {\n state.pending = readValueLabelSet(cur);\n state.valueLabelSets.push(state.pending);\n}\n\n/** Type-4 record: an i32 count of 1-based variable indexes, attached to the preceding type-3 set. */\nfunction handleVarIndexes(cur: Cursor, state: DictState): void {\n const count = cur.readI32();\n const idx: number[] = [];\n for (let i = 0; i < count; i++) idx.push(cur.readI32());\n if (state.pending) state.pending.varIndexes = idx;\n}\n\n/** Type-6 document record: an i32 line count then `n_lines` × 80 bytes we discard. */\nfunction handleDocument(cur: Cursor): void {\n const nLines = cur.readI32();\n if (nLines < 0 || nLines > MAX_DOC_LINES) throw new SavError(\"invalid document record\");\n cur.skip(nLines * 80);\n}\n\nfunction handleExtension(cur: Cursor, state: DictState): void {\n state.extensions.push(readExtension(cur));\n}\n\nconst HANDLERS: Record<number, (cur: Cursor, state: DictState) => void> = {\n 2: handleVariable,\n 3: handleValueLabels,\n 4: handleVarIndexes,\n 6: handleDocument,\n 7: handleExtension,\n};\n\n/** Walk the SPSS dictionary from the cursor's position (byte 176) until the type-999 terminator,\n * collecting variables in order, value-label sets (each type-3 record plus its trailing type-4\n * index record), and raw type-7 extension records for Task 6; type-6 document records are skipped. */\nexport function readDictionary(cur: Cursor): RawDict {\n const state: DictState = {\n variables: [],\n physicalIndexes: [],\n physicalPos: 1,\n valueLabelSets: [],\n extensions: [],\n };\n for (let records = 0; ; records++) {\n if (records > MAX_DICT_RECORDS) throw new SavError(\"too many dictionary records\");\n const prev = cur.pos; // net cursor progress per record is the monotonic-advance guard below\n const recType = cur.readI32();\n if (recType === 999) {\n cur.readI32(); // terminator filler\n break;\n }\n const handler = HANDLERS[recType];\n if (!handler) throw new Error(`Unknown SPSS dictionary record type: ${recType}`);\n handler(cur, state);\n // A handler that left the cursor at or behind where the record began (e.g. a backward `skip`)\n // would spin the loop forever — reject the malformed record instead.\n if (cur.pos <= prev) throw new SavError(\"dictionary made no progress\");\n }\n return {\n variables: state.variables,\n physicalIndexes: state.physicalIndexes,\n valueLabelSets: state.valueLabelSets,\n extensions: state.extensions,\n };\n}\n","import type { Measure } from \"../types\";\nimport type { RawDict, RawExtension } from \"./dictionary\";\n\n/** The subset of the SPSS dictionary recovered from the type-7 extension records: the true text\n * encoding, the system-missing sentinel, the short→long variable-name map, the short→char-width map\n * for very-long strings, and the per-variable measurement level (positional, dictionary order). */\nexport type DictInfo = {\n encoding: string;\n sysmis: number;\n longNames: Map<string, string>;\n veryLong: Map<string, number>;\n measures: Measure[];\n};\n\n// Every extension payload we read here is ASCII/UTF-8 (charset names, `SHORT=LongName` tokens). The\n// file's declared charset (subtype 20) is only READ as a name — never used to decode data yet.\nconst UTF8 = new TextDecoder(\"utf-8\");\n\n/** Wrap a record payload in an endian-aware DataView (the ints/floats use the file's byte order). */\nfunction viewOf(bytes: Uint8Array): DataView {\n return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n}\n\n/** Split `bytes` into `KEY=VALUE` tokens and hand each key/value to `apply`; empty/keyless skipped. */\nfunction eachPair(tokens: string[], apply: (key: string, value: string) => void): void {\n for (const token of tokens) {\n const eq = token.indexOf(\"=\");\n if (eq > 0) apply(token.slice(0, eq), token.slice(eq + 1));\n }\n}\n\n/** Subtype 13: tab-separated `SHORT=LongName` pairs. */\nfunction parseLongNames(bytes: Uint8Array, out: Map<string, string>): void {\n eachPair(UTF8.decode(bytes).split(\"\\t\"), (key, value) => out.set(key, value));\n}\n\n/** Subtype 14: `SHORT=width` pairs delimited by NUL and/or tab. */\nfunction parseVeryLong(bytes: Uint8Array, out: Map<string, number>): void {\n const tokens = UTF8.decode(bytes)\n .split(\"\\0\")\n .flatMap((chunk) => chunk.split(\"\\t\"));\n eachPair(tokens, (key, value) => out.set(key, Number(value)));\n}\n\nfunction measureName(code: number): Measure {\n if (code === 1) return \"nominal\";\n if (code === 2) return \"ordinal\";\n if (code === 3) return \"scale\";\n return \"unknown\";\n}\n\n/** Subtype 11: `count/3` × (measure, width, alignment) i32 triples — keep the measure of each. The\n * triples are one per REAL variable record (they skip type=-1 continuations but DO include very-long\n * string segment sub-vars), so positional indexing against `raw.variables` aligns — verified in\n * Task 9 against the longstring + wide fixtures; the value-label physical-index remap (reader.ts) is\n * NOT applied here. */\nfunction parseMeasures(record: RawExtension, little: boolean): Measure[] {\n const view = viewOf(record.bytes);\n const measures: Measure[] = [];\n const triples = Math.floor(record.count / 3);\n for (let t = 0; t < triples; t++) {\n // A well-formed subtype-11 has size=4, so `bytes` holds 12 per triple; a hostile size<4 shrinks\n // `bytes` below the count-derived triple stride, so stop before getInt32 reads past the payload.\n if (t * 12 + 12 > record.bytes.length) break;\n measures.push(measureName(view.getInt32(t * 12, little)));\n }\n return measures;\n}\n\nfunction applyRecord(record: RawExtension, little: boolean, info: DictInfo): void {\n switch (record.subtype) {\n case 20: // character encoding: the whole payload is the charset name\n info.encoding = UTF8.decode(record.bytes).trim();\n break;\n case 4: // machine floating-point info: sysmis, highest, lowest — keep sysmis\n info.sysmis = viewOf(record.bytes).getFloat64(0, little);\n break;\n case 13:\n parseLongNames(record.bytes, info.longNames);\n break;\n case 14:\n parseVeryLong(record.bytes, info.veryLong);\n break;\n case 11:\n info.measures = parseMeasures(record, little);\n break;\n default: // 3/17/18/21/22 and any unknown subtype: enrichments handled later, or unneeded\n break;\n }\n}\n\n/** Interpret the captured type-7 extension records into a `DictInfo`, reading each record's ints and\n * floats with the file's byte order (`little`). Unhandled subtypes are ignored, never thrown. */\nexport function applyExtensions(raw: RawDict, little: boolean): DictInfo {\n const info: DictInfo = {\n encoding: \"utf-8\",\n sysmis: -Number.MAX_VALUE,\n longNames: new Map(),\n veryLong: new Map(),\n measures: [],\n };\n for (const record of raw.extensions) applyRecord(record, little, info);\n return info;\n}\n","import type { Cursor } from \"./binary\";\n\nimport { SavError } from \"../limits\";\n\nexport type SavHeader = {\n zlib: boolean;\n compression: 0 | 1 | 2;\n bias: number;\n ncases: number;\n fileLabel: string;\n};\n\n// \"windows-1252\" is the WHATWG decoder that \"ascii\" aliases to; Bun.Encoding lacks the \"ascii\" label.\nconst ASCII = new TextDecoder(\"windows-1252\");\n\nfunction asCompression(v: number): 0 | 1 | 2 {\n if (v === 0 || v === 1 || v === 2) return v;\n throw new Error(`Unsupported SPSS compression flag: ${v}`);\n}\n\n/** Read the 176-byte file header. Detects byte order from layout_code (must read as 2 or 3), sets\n * `cur.little`, and leaves the cursor at byte 176 (start of the dictionary). */\nexport function readHeader(cur: Cursor): SavHeader {\n // Every field below lives within the fixed 176-byte header; a shorter buffer would surface as a\n // raw RangeError from a DataView read, so reject it up front as a clean SavError.\n if (cur.length < 176) throw new SavError(\"file too small to be a .sav\");\n const magic = cur.readStr(4, ASCII);\n if (magic !== \"$FL2\" && magic !== \"$FL3\") throw new Error(\"Not an SPSS system file (bad magic)\");\n cur.little = cur.view.getInt32(64, true) === 2 || cur.view.getInt32(64, true) === 3;\n cur.seek(64);\n cur.readI32(); // layout_code\n cur.readI32(); // nominal_case_size (unreliable — recomputed from variables)\n const compression = asCompression(cur.readI32());\n cur.readI32(); // weight_index\n const ncases = cur.readI32();\n const bias = cur.readF64();\n cur.skip(9 + 8); // creation_date + creation_time\n const fileLabel = cur.readStr(64, ASCII).replace(/[\\s\\0]+$/, \"\");\n cur.skip(3); // padding → byte 176\n return { zlib: magic === \"$FL3\", compression, bias, ncases, fileLabel };\n}\n","import type { Cursor } from \"./binary\";\nimport type { SavHeader } from \"./header\";\n\nimport { SavError } from \"../limits\";\n\n/** A compression-agnostic reader over a .sav data section: sequential numeric cells and 8-byte\n * string chunks. `nextNumeric` returns `null` for a system-missing (or end-of-data) value. */\nexport interface ValueSource {\n nextNumeric(): number | null;\n nextString8(): Uint8Array;\n /** True when the next cell boundary is the end-of-data marker (or the stream is exhausted) — the\n * termination signal a reader uses when the header's case count is unknown (`ncases = -1`). */\n atEnd(): boolean;\n}\n\n/** One ZSAV compressed block descriptor: where its deflate stream sits in the file and its length. */\ntype ZBlock = { compressedOfs: number; compressedSize: number };\n\n/** A fresh 8-byte run filled with a single value (0x20 spaces or 0x00 NULs for string cells). */\nfunction filled(byte: number): Uint8Array {\n return new Uint8Array(8).fill(byte);\n}\n\n/** Compression flag 0: numeric = raw f64 (equal to sysmis → null); string = raw 8 bytes. */\nclass UncompressedSource implements ValueSource {\n constructor(\n private readonly cur: Cursor,\n private readonly sysmis: number,\n ) {}\n\n nextNumeric(): number | null {\n const v = this.cur.readF64();\n return v === this.sysmis ? null : v;\n }\n\n nextString8(): Uint8Array {\n return this.cur.readBytes(8);\n }\n\n atEnd(): boolean {\n return this.cur.pos + 8 > this.cur.length; // no full 8-byte cell remains → data section done\n }\n}\n\n/** Compression flag 1 (and the inflated ZSAV stream): an 8-code control octet drives each cell.\n * Codes — 0 pad · 1..251 numeric `code − bias` · 252 end-of-data · 253 literal (the next 8 bytes\n * of the stream) · 254 eight spaces (strings) · 255 system-missing. Literals for a given octet\n * follow it in the stream, in code order, which is why we read them from the cursor as encountered. */\nclass RleSource implements ValueSource {\n private codes: Uint8Array = new Uint8Array(8);\n private idx = 8; // ≥ 8 forces an octet refill on the first pull\n private lookahead: number | null = null; // a control code peeked by atEnd(), not yet spent on a cell\n\n constructor(\n private readonly cur: Cursor,\n private readonly bias: number,\n ) {}\n\n /** Pull the next raw octet code. Synthesizes 252 (end-of-data) once the stream is exhausted, so an\n * unknown-case-count read (`ncases = -1`) terminates on atEnd() instead of over-reading past EOF. */\n private rawCode(): number {\n if (this.idx >= 8) {\n if (this.cur.pos >= this.cur.length) return 252;\n this.codes = this.cur.readBytes(8);\n this.idx = 0;\n }\n return this.codes[this.idx++];\n }\n\n /** Next cell-bearing control code: drains any peeked code, then skips 0 fillers (they encode no\n * cell — proven by the labels_order oracle fixture's inter-row octet padding). */\n private nextCode(): number {\n if (this.lookahead !== null) {\n const code = this.lookahead;\n this.lookahead = null;\n return code;\n }\n for (;;) {\n const code = this.rawCode();\n if (code !== 0) return code;\n }\n }\n\n atEnd(): boolean {\n if (this.lookahead === null) this.lookahead = this.nextCode();\n return this.lookahead === 252;\n }\n\n nextNumeric(): number | null {\n const code = this.nextCode();\n if (code === 252 || code === 255) return null; // end-of-data / system-missing\n if (code === 253) return this.cur.readF64(); // literal double follows the octet\n return code - this.bias;\n }\n\n nextString8(): Uint8Array {\n const code = this.nextCode();\n if (code === 254) return filled(0x20); // eight spaces\n if (code === 253) return this.cur.readBytes(8); // literal 8 bytes\n return filled(0x20); // 252 end-of-data / 255 / other → space-filled (SPSS pads strings with 0x20)\n }\n}\n\n/** Read a byte-order-aware 64-bit signed int; file offsets/lengths fit in a JS number. */\nfunction readI64(cur: Cursor): number {\n const v = cur.view.getBigInt64(cur.pos, cur.little);\n cur.pos += 8;\n return Number(v);\n}\n\n/** Copy the parts into one contiguous ArrayBuffer sized to their total length. */\nfunction concatToBuffer(parts: Uint8Array[]): ArrayBuffer {\n const total = parts.reduce((n, p) => n + p.length, 0);\n const buf = new ArrayBuffer(total);\n const out = new Uint8Array(buf);\n let ofs = 0;\n for (const p of parts) {\n out.set(p, ofs);\n ofs += p.length;\n }\n return buf;\n}\n\n/** Inflate one zlib (\"deflate\") block through the platform DecompressionStream, streaming the output\n * chunk-by-chunk and aborting the moment it exceeds `maxBytes` — a few-KB block can inflate to\n * gigabytes (a decompression bomb), so we never materialize the whole output before checking. */\nasync function inflateDeflate(compressed: Uint8Array, maxBytes: number): Promise<Uint8Array> {\n // Copy into a fresh ArrayBuffer-backed view: a generic Uint8Array<ArrayBufferLike> isn't a valid\n // BlobPart under the DOM lib (the consumer app typechecks this source).\n const bytes = new Uint8Array(compressed.length);\n bytes.set(compressed);\n const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream(\"deflate\"));\n const reader = stream.getReader();\n const parts: Uint8Array[] = [];\n let total = 0;\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n total += value.byteLength;\n if (total > maxBytes) {\n await reader.cancel();\n throw new SavError(\"ZSAV inflated output exceeds limit\");\n }\n parts.push(value);\n }\n return new Uint8Array(concatToBuffer(parts));\n}\n\n/** Read the ZTRAILER at `ztrailerOfs` and return its per-block compressed descriptors. */\nfunction readTrailerBlocks(cur: Cursor, ztrailerOfs: number): ZBlock[] {\n cur.seek(ztrailerOfs);\n readI64(cur); // bias\n readI64(cur); // zero\n cur.readI32(); // block_size\n const nBlocks = cur.readI32();\n if (nBlocks < 0 || nBlocks > 1_000_000) throw new SavError(\"ZSAV block count out of range\");\n const blocks: ZBlock[] = [];\n for (let i = 0; i < nBlocks; i++) {\n readI64(cur); // uncompressed_ofs\n const compressedOfs = readI64(cur);\n cur.readI32(); // uncompressed_size\n blocks.push({ compressedOfs, compressedSize: cur.readI32() });\n }\n return blocks;\n}\n\n/** Pick the value source for a data section by its header compression flag. Flag 0 = raw f64/bytes;\n * flags 1 and 2 (2 = the already-inflated ZSAV stream) share the RLE control-octet decoding. */\nexport function makeSource(cur: Cursor, header: SavHeader, sysmis: number): ValueSource {\n if (header.compression === 0) return new UncompressedSource(cur, sysmis);\n return new RleSource(cur, header.bias);\n}\n\n/** Inflate a ZSAV ($FL3) data section into the RLE bytecode stream: read the ZHEADER offsets, walk\n * the ZTRAILER block descriptors, deflate-inflate each block, and concatenate. Offsets are absolute\n * file offsets, so `cur` must span the whole file.\n * Field framing (ZHEADER 3×i64; ZTRAILER bias/zero/block_size/n_blocks + per-block descriptors) and\n * the zlib-wrapped \"deflate\" (not \"deflate-raw\") stream are verified against the haven\n * `compress=\"zsav\"` fixture in Task 9's oracle matrix. `maxInflatedBytes` caps the TOTAL inflated\n * output across all blocks (a streaming budget) so a decompression bomb throws instead of OOMing. */\nexport async function inflateZsav(cur: Cursor, maxInflatedBytes: number): Promise<ArrayBuffer> {\n readI64(cur); // zheader_ofs\n const ztrailerOfs = readI64(cur);\n readI64(cur); // ztrailer_len\n const blocks = readTrailerBlocks(cur, ztrailerOfs);\n const parts: Uint8Array[] = [];\n let total = 0;\n for (const block of blocks) {\n cur.seek(block.compressedOfs);\n const inflated = await inflateDeflate(\n cur.readBytes(block.compressedSize),\n maxInflatedBytes - total,\n );\n total += inflated.length;\n parts.push(inflated);\n }\n return concatToBuffer(parts);\n}\n","import type { SavLimits } from \"../limits\";\nimport type { CellValue, ParsedFile, Variable } from \"../types\";\nimport type { VariablePlan } from \"./cases\";\nimport type { RawDict, RawValueLabelSet } from \"./dictionary\";\nimport type { DictInfo } from \"./extensions\";\nimport type { RawVariable } from \"./variable\";\n\nimport { DEFAULT_LIMITS, SavError } from \"../limits\";\nimport { Cursor } from \"./binary\";\nimport { readCases, toBunEncoding } from \"./cases\";\nimport { isDateFormat } from \"./dates\";\nimport { readDictionary } from \"./dictionary\";\nimport { applyExtensions } from \"./extensions\";\nimport { readHeader } from \"./header\";\nimport { inflateZsav, makeSource } from \"./source\";\n\ntype ValueLabel = { value: CellValue; label: string };\n\n/** Everything `buildVariable`/`buildPlans` need beyond the raw variable itself, bundled so the\n * builders stay within the parameter budget. */\ntype BuildContext = { info: DictInfo; sets: RawValueLabelSet[]; little: boolean; dec: TextDecoder };\n\n/** Read 8 raw value-label bytes as a numeric f64 key, in the file's byte order. */\nfunction rawToF64(raw: Uint8Array, little: boolean): number {\n return new DataView(raw.buffer, raw.byteOffset, 8).getFloat64(0, little);\n}\n\n/** Collect the value labels attached to the variable at 1-based PHYSICAL dictionary index\n * `physicalIndex` (type-4 var indexes count string continuations, which `raw.variables` drops),\n * decoding each key by the owning variable's kind: numeric → f64, string → trimmed decoded bytes,\n * and the label text with the file encoding. */\nfunction valueLabelsFor(\n physicalIndex: number,\n isString: boolean,\n sets: RawValueLabelSet[],\n little: boolean,\n dec: TextDecoder,\n): ValueLabel[] | undefined {\n const out: ValueLabel[] = [];\n for (const set of sets) {\n if (!set.varIndexes.includes(physicalIndex)) continue;\n for (const entry of set.labels) {\n const value = isString\n ? dec.decode(entry.raw).replace(/ +$/, \"\")\n : rawToF64(entry.raw, little);\n out.push({ value, label: dec.decode(entry.labelRaw).replace(/\\0+$/, \"\") });\n }\n }\n return out.length > 0 ? out : undefined;\n}\n\n/** The name/type/format/measure common to every variable, before optional fields are attached. */\nfunction baseVariable(rawVar: RawVariable, index: number, info: DictInfo): Variable {\n return {\n name: info.longNames.get(rawVar.name) ?? rawVar.name,\n type: rawVar.type > 0 ? \"string\" : \"numeric\",\n missing: rawVar.missing,\n format: { ...rawVar.print, isDate: isDateFormat(rawVar.print.type) },\n measure: info.measures[index] ?? \"unknown\",\n };\n}\n\n/** Assemble one `Variable` from its raw record plus the extension-derived long name, measure, real\n * date-format flag, and any value labels resolved against the owning variable's numeric/string kind.\n * `physicalIndex` is the variable's 1-based position INCLUDING continuations (value-label indexing). */\nfunction buildVariable(\n rawVar: RawVariable,\n index: number,\n physicalIndex: number,\n ctx: BuildContext,\n): Variable {\n const isString = rawVar.type > 0;\n const variable = baseVariable(rawVar, index, ctx.info);\n if (rawVar.label !== undefined) variable.label = rawVar.label;\n if (isString) variable.width = rawVar.type;\n const valueLabels = valueLabelsFor(physicalIndex, isString, ctx.sets, ctx.little, ctx.dec);\n if (valueLabels) variable.valueLabels = valueLabels;\n return variable;\n}\n\n/** The `ceil(realWidth/252)` segment allocated-widths of a very-long string, read from the segment\n * sub-variables that immediately follow it in the dictionary (SPSS packs one string variable per\n * segment: first segments alloc 255, the last alloc `realWidth − 252·(n−1)`). */\nfunction segmentWidths(raw: RawDict, start: number, realWidth: number): number[] {\n const n = Math.ceil(realWidth / 252);\n const widths: number[] = [];\n for (let s = 0; s < n && start + s < raw.variables.length; s++) {\n widths.push(raw.variables[start + s].type);\n }\n return widths;\n}\n\n/** Turn the raw variable list into read plans, merging each very-long string's N segment\n * sub-variables (identified by its short name in `info.veryLong`) into ONE logical string variable\n * of the real width. Normal strings get a single-segment plan; numerics get an empty plan. */\nfunction buildPlans(raw: RawDict, ctx: BuildContext): VariablePlan[] {\n const plans: VariablePlan[] = [];\n let i = 0;\n while (i < raw.variables.length) {\n const rawVar = raw.variables[i];\n const variable = buildVariable(rawVar, i, raw.physicalIndexes[i], ctx);\n const realWidth = rawVar.type > 0 ? ctx.info.veryLong.get(rawVar.name) : undefined;\n if (realWidth === undefined) {\n plans.push({ variable, segments: rawVar.type > 0 ? [rawVar.type] : [] });\n i += 1;\n continue;\n }\n const segments = segmentWidths(raw, i, realWidth);\n variable.width = realWidth;\n plans.push({ variable, segments });\n i += segments.length;\n }\n return plans;\n}\n\n/** Read an SPSS `.sav`/`.zsav` system file end-to-end: header → dictionary → extensions → variables\n * → (inflate if ZSAV) → data cases. Matches R `haven` on the committed golden fixtures. `opts`\n * tightens the resource ceilings (see `SavLimits`); a hostile file is rejected with a `SavError`. */\nexport async function readSav(buf: ArrayBuffer, opts?: Partial<SavLimits>): Promise<ParsedFile> {\n const limits = { ...DEFAULT_LIMITS, ...opts };\n let cur = new Cursor(buf);\n const header = readHeader(cur);\n const raw = readDictionary(cur);\n const info = applyExtensions(raw, cur.little);\n const little = cur.little; // capture before a ZSAV swap: the bytecode literals keep the file's order\n const dec = new TextDecoder(toBunEncoding(info.encoding));\n const plans = buildPlans(raw, { info, sets: raw.valueLabelSets, little, dec });\n const variables = plans.map((plan) => plan.variable);\n // No variables ⇒ every row consumes 0 cells, so the unknown-count (`ncases = -1`) loop can never\n // advance — reject instead of spinning forever.\n if (variables.length === 0) throw new SavError(\"file has no variables\");\n if (header.ncases >= 0 && header.ncases * variables.length > limits.maxCells) {\n throw new SavError(\n `case count ${header.ncases} × ${variables.length} vars exceeds cell limit ${limits.maxCells}`,\n );\n }\n if (header.zlib) {\n const data = await inflateZsav(cur, limits.maxInflatedBytes);\n cur = new Cursor(data);\n cur.little = little;\n }\n const source = makeSource(cur, header, info.sysmis);\n const rows = readCases(header, plans, info, source, limits);\n return { format: \"sav\", encoding: info.encoding, sheets: [{ name: \"data\", variables, rows }] };\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "datareader-spss",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Correct, zero-dependency SPSS .sav/.zsav reader for the browser and Node — validated against R haven.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "everydaydesign",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"spss",
|
|
27
|
+
"sav",
|
|
28
|
+
"zsav",
|
|
29
|
+
"parser",
|
|
30
|
+
"reader",
|
|
31
|
+
"browser",
|
|
32
|
+
"statistics",
|
|
33
|
+
"haven",
|
|
34
|
+
"pspp",
|
|
35
|
+
"data",
|
|
36
|
+
"datareader"
|
|
37
|
+
],
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/everydaydesign/datareader-spss.git"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/everydaydesign/datareader-spss#readme",
|
|
43
|
+
"bugs": "https://github.com/everydaydesign/datareader-spss/issues",
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsup",
|
|
46
|
+
"test": "bun test",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"fixtures": "Rscript oracle/generate-fixtures.R",
|
|
49
|
+
"prepublishOnly": "tsup && bun test"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/bun": "^1.1.14",
|
|
53
|
+
"tsup": "^8.5.1",
|
|
54
|
+
"typescript": "^5.7.2"
|
|
55
|
+
}
|
|
56
|
+
}
|