yarramate 1.4.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/visual/session-server.js +147 -45
- package/dist/ask-command.js +3 -3
- package/dist/cli-support.d.ts +1 -1
- package/dist/cli-support.js +1 -1
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +17 -1
- package/dist/design-command.js +10 -6
- package/dist/export-command.js +41 -5
- package/dist/import-command.d.ts +16 -0
- package/dist/import-command.js +156 -0
- package/dist/interrogate-command.d.ts +1 -1
- package/dist/interrogate-command.js +103 -1
- package/dist/visual-app/assets/{index-yCHtmQUH.js → index-C42oLqXj.js} +2 -2
- package/dist/visual-app/index.html +1 -1
- package/dist/visual-app-lib/editor.js +26880 -26790
- package/dist/visual-app-lib/types/interrogate-command.d.ts +1 -1
- package/dist/workbook-entry.d.ts +16 -0
- package/dist/workbook-entry.js +16 -0
- package/dist/workbook-merge.d.ts +77 -0
- package/dist/workbook-merge.js +108 -0
- package/dist/workbook-operations.d.ts +9 -0
- package/dist/workbook-operations.js +139 -0
- package/dist/workbook-read.d.ts +43 -0
- package/dist/workbook-read.js +314 -0
- package/dist/workbook-xlsx.d.ts +54 -0
- package/dist/workbook-xlsx.js +228 -0
- package/dist/workbook.d.ts +32 -0
- package/dist/workbook.js +293 -0
- package/docs/CONSUMING-YARRAMATE.md +64 -0
- package/package.json +5 -1
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading an `.xlsx` back, without a dependency (#355).
|
|
3
|
+
*
|
|
4
|
+
* Asymmetric with the writer on purpose. Writing uses stored entries and stays
|
|
5
|
+
* synchronous; **reading cannot**, because a workbook that has been through
|
|
6
|
+
* Excel comes back deflated. Verified against a real consulting template
|
|
7
|
+
* rather than assumed: every part reports `Defl:N`, every cell is `t="s"`
|
|
8
|
+
* against a `sharedStrings` part, and there is not one inline string left. So
|
|
9
|
+
* the reader inflates through `DecompressionStream('deflate-raw')` - present
|
|
10
|
+
* on Workers, in browsers and in Node 18+ - and resolves shared strings as
|
|
11
|
+
* well as the inline ones this repository writes.
|
|
12
|
+
*
|
|
13
|
+
* Two details that are easy to miss and silently corrupt a row:
|
|
14
|
+
*
|
|
15
|
+
* - **Excel omits empty cells entirely.** A row is not a dense list, so a
|
|
16
|
+
* cell's column comes from its `r` reference (`C7`) and nowhere else.
|
|
17
|
+
* Reading positionally shifts every value after the first blank.
|
|
18
|
+
* - **A zip carries directory entries** with zero length. They are not parts.
|
|
19
|
+
*/
|
|
20
|
+
const decoder = new TextDecoder();
|
|
21
|
+
const ENTITIES = {
|
|
22
|
+
amp: '&',
|
|
23
|
+
lt: '<',
|
|
24
|
+
gt: '>',
|
|
25
|
+
quot: '"',
|
|
26
|
+
apos: "'",
|
|
27
|
+
};
|
|
28
|
+
export const decodeXmlText = (value) => value.replace(/&(#x?[0-9a-fA-F]+|[a-z]+);/g, (whole, entity) => {
|
|
29
|
+
if (entity.startsWith('#x') || entity.startsWith('#X')) {
|
|
30
|
+
return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
|
|
31
|
+
}
|
|
32
|
+
if (entity.startsWith('#')) {
|
|
33
|
+
return String.fromCodePoint(Number.parseInt(entity.slice(1), 10));
|
|
34
|
+
}
|
|
35
|
+
return ENTITIES[entity] ?? whole;
|
|
36
|
+
});
|
|
37
|
+
/** `C7` -> 2. The letters are base-26 with no zero. */
|
|
38
|
+
export const columnIndexOf = (reference) => {
|
|
39
|
+
const letters = /^([A-Z]+)/.exec(reference)?.[1];
|
|
40
|
+
if (letters === undefined)
|
|
41
|
+
return 0;
|
|
42
|
+
let index = 0;
|
|
43
|
+
for (const letter of letters) {
|
|
44
|
+
index = index * 26 + (letter.charCodeAt(0) - 64);
|
|
45
|
+
}
|
|
46
|
+
return index - 1;
|
|
47
|
+
};
|
|
48
|
+
const inflateRaw = async (bytes) => {
|
|
49
|
+
// Fed through a stream rather than a Blob: `Blob` is not in the Node types
|
|
50
|
+
// this package builds against, and a one-chunk ReadableStream is available
|
|
51
|
+
// everywhere this has to run.
|
|
52
|
+
const source = new ReadableStream({
|
|
53
|
+
start(controller) {
|
|
54
|
+
controller.enqueue(bytes);
|
|
55
|
+
controller.close();
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
// Cast at the seam: DOM lib types `DecompressionStream.writable` as
|
|
59
|
+
// `WritableStream<BufferSource>`, which does not unify with the
|
|
60
|
+
// `ReadableStream<Uint8Array>` above. The runtime contract is the one this
|
|
61
|
+
// relies on, and it is the same on Workers, in browsers and in Node.
|
|
62
|
+
const inflated = source.pipeThrough(new DecompressionStream('deflate-raw'));
|
|
63
|
+
const parts = [];
|
|
64
|
+
const reader = inflated.getReader();
|
|
65
|
+
for (;;) {
|
|
66
|
+
const { done, value } = await reader.read();
|
|
67
|
+
if (done)
|
|
68
|
+
break;
|
|
69
|
+
if (value !== undefined)
|
|
70
|
+
parts.push(value);
|
|
71
|
+
}
|
|
72
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
73
|
+
const out = new Uint8Array(total);
|
|
74
|
+
let at = 0;
|
|
75
|
+
for (const part of parts) {
|
|
76
|
+
out.set(part, at);
|
|
77
|
+
at += part.length;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Every part in the archive, by path. Directory entries are skipped, and an
|
|
83
|
+
* unknown compression method is refused rather than guessed at: a part read
|
|
84
|
+
* wrongly would parse as an empty sheet, which is the silent failure this
|
|
85
|
+
* whole feature exists to avoid.
|
|
86
|
+
*/
|
|
87
|
+
export const unzipEntries = async (bytes) => {
|
|
88
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
89
|
+
let end = -1;
|
|
90
|
+
for (let at = bytes.length - 22; at >= 0; at -= 1) {
|
|
91
|
+
if (view.getUint32(at, true) === 0x06054b50) {
|
|
92
|
+
end = at;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (end < 0)
|
|
97
|
+
return { ok: false, reason: 'not a zip archive' };
|
|
98
|
+
const count = view.getUint16(end + 10, true);
|
|
99
|
+
let at = view.getUint32(end + 16, true);
|
|
100
|
+
const entries = new Map();
|
|
101
|
+
for (let index = 0; index < count; index += 1) {
|
|
102
|
+
if (view.getUint32(at, true) !== 0x02014b50) {
|
|
103
|
+
return { ok: false, reason: 'damaged central directory' };
|
|
104
|
+
}
|
|
105
|
+
const method = view.getUint16(at + 10, true);
|
|
106
|
+
const compressedSize = view.getUint32(at + 20, true);
|
|
107
|
+
const nameLength = view.getUint16(at + 28, true);
|
|
108
|
+
const extraLength = view.getUint16(at + 30, true);
|
|
109
|
+
const commentLength = view.getUint16(at + 32, true);
|
|
110
|
+
const localAt = view.getUint32(at + 42, true);
|
|
111
|
+
const name = decoder.decode(bytes.subarray(at + 46, at + 46 + nameLength));
|
|
112
|
+
at += 46 + nameLength + extraLength + commentLength;
|
|
113
|
+
// A directory, not a part.
|
|
114
|
+
if (name.endsWith('/'))
|
|
115
|
+
continue;
|
|
116
|
+
if (view.getUint32(localAt, true) !== 0x04034b50) {
|
|
117
|
+
return { ok: false, reason: `damaged entry ${name}` };
|
|
118
|
+
}
|
|
119
|
+
const localNameLength = view.getUint16(localAt + 26, true);
|
|
120
|
+
const localExtraLength = view.getUint16(localAt + 28, true);
|
|
121
|
+
const from = localAt + 30 + localNameLength + localExtraLength;
|
|
122
|
+
const raw = bytes.subarray(from, from + compressedSize);
|
|
123
|
+
if (method === 0) {
|
|
124
|
+
entries.set(name, raw);
|
|
125
|
+
}
|
|
126
|
+
else if (method === 8) {
|
|
127
|
+
entries.set(name, await inflateRaw(raw));
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
return { ok: false, reason: `unsupported compression in ${name}` };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, entries };
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* A scanner for the shapes SpreadsheetML uses, rather than a general XML
|
|
137
|
+
* parser. OOXML is machine-generated and narrow, and a full parser is more
|
|
138
|
+
* code than this file needs.
|
|
139
|
+
*/
|
|
140
|
+
const scan = (xml, onTag, onText) => {
|
|
141
|
+
let at = 0;
|
|
142
|
+
while (at < xml.length) {
|
|
143
|
+
const open = xml.indexOf('<', at);
|
|
144
|
+
if (open < 0)
|
|
145
|
+
break;
|
|
146
|
+
if (open > at)
|
|
147
|
+
onText(xml.slice(at, open));
|
|
148
|
+
if (xml.startsWith('<!--', open)) {
|
|
149
|
+
const done = xml.indexOf('-->', open);
|
|
150
|
+
at = done < 0 ? xml.length : done + 3;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (xml.startsWith('<?', open) || xml.startsWith('<!', open)) {
|
|
154
|
+
const done = xml.indexOf('>', open);
|
|
155
|
+
at = done < 0 ? xml.length : done + 1;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const close = xml.indexOf('>', open);
|
|
159
|
+
if (close < 0)
|
|
160
|
+
break;
|
|
161
|
+
const body = xml.slice(open + 1, close);
|
|
162
|
+
const closing = body.startsWith('/');
|
|
163
|
+
const selfClosing = body.endsWith('/');
|
|
164
|
+
const inner = body.slice(closing ? 1 : 0, selfClosing ? -1 : undefined);
|
|
165
|
+
const name = /^([^\s/>]+)/.exec(inner)?.[1] ?? '';
|
|
166
|
+
const attributes = {};
|
|
167
|
+
for (const match of inner
|
|
168
|
+
.slice(name.length)
|
|
169
|
+
.matchAll(/([\w:.-]+)\s*=\s*"([^"]*)"/g)) {
|
|
170
|
+
attributes[match[1]] = decodeXmlText(match[2]);
|
|
171
|
+
}
|
|
172
|
+
onTag({ name, attributes, selfClosing, closing });
|
|
173
|
+
at = close + 1;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const localName = (name) => {
|
|
177
|
+
const colon = name.indexOf(':');
|
|
178
|
+
return colon < 0 ? name : name.slice(colon + 1);
|
|
179
|
+
};
|
|
180
|
+
const sharedStringsOf = (xml) => {
|
|
181
|
+
const strings = [];
|
|
182
|
+
let inItem = false;
|
|
183
|
+
let inText = false;
|
|
184
|
+
let current = '';
|
|
185
|
+
scan(xml, (tag) => {
|
|
186
|
+
const name = localName(tag.name);
|
|
187
|
+
if (name === 'si') {
|
|
188
|
+
if (tag.closing) {
|
|
189
|
+
strings.push(current);
|
|
190
|
+
inItem = false;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
inItem = true;
|
|
194
|
+
current = '';
|
|
195
|
+
if (tag.selfClosing) {
|
|
196
|
+
strings.push('');
|
|
197
|
+
inItem = false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// A shared string can be split into runs; `t` inside `r` still counts.
|
|
203
|
+
if (name === 't')
|
|
204
|
+
inText = !tag.closing && !tag.selfClosing;
|
|
205
|
+
}, (text) => {
|
|
206
|
+
if (inItem && inText)
|
|
207
|
+
current += decodeXmlText(text);
|
|
208
|
+
});
|
|
209
|
+
return strings;
|
|
210
|
+
};
|
|
211
|
+
const rowsOf = (xml, shared) => {
|
|
212
|
+
const rows = [];
|
|
213
|
+
let row;
|
|
214
|
+
let column = 0;
|
|
215
|
+
let type = '';
|
|
216
|
+
let inValue = false;
|
|
217
|
+
let inInlineText = false;
|
|
218
|
+
let cell = '';
|
|
219
|
+
scan(xml, (tag) => {
|
|
220
|
+
const name = localName(tag.name);
|
|
221
|
+
if (name === 'row') {
|
|
222
|
+
if (tag.closing) {
|
|
223
|
+
if (row !== undefined)
|
|
224
|
+
rows.push(row);
|
|
225
|
+
row = undefined;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
row = [];
|
|
229
|
+
if (tag.selfClosing) {
|
|
230
|
+
rows.push(row);
|
|
231
|
+
row = undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (name === 'c') {
|
|
237
|
+
if (tag.closing || tag.selfClosing) {
|
|
238
|
+
if (row !== undefined) {
|
|
239
|
+
const resolved = type === 's' ? (shared[Number.parseInt(cell, 10)] ?? '') : cell;
|
|
240
|
+
while (row.length < column)
|
|
241
|
+
row.push('');
|
|
242
|
+
row[column] = resolved;
|
|
243
|
+
}
|
|
244
|
+
cell = '';
|
|
245
|
+
type = '';
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// Excel omits empty cells, so the column comes from `r` and nothing
|
|
249
|
+
// else. Reading positionally shifts every value after a blank.
|
|
250
|
+
column = tag.attributes.r === undefined ? row?.length ?? 0 : columnIndexOf(tag.attributes.r);
|
|
251
|
+
type = tag.attributes.t ?? '';
|
|
252
|
+
cell = '';
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (name === 'v')
|
|
256
|
+
inValue = !tag.closing && !tag.selfClosing;
|
|
257
|
+
if (name === 't')
|
|
258
|
+
inInlineText = !tag.closing && !tag.selfClosing;
|
|
259
|
+
}, (text) => {
|
|
260
|
+
if (inValue || inInlineText)
|
|
261
|
+
cell += decodeXmlText(text);
|
|
262
|
+
});
|
|
263
|
+
return rows;
|
|
264
|
+
};
|
|
265
|
+
/** The workbook's sheets, by name. Async, because a saved workbook is deflated. */
|
|
266
|
+
export const readWorkbook = async (bytes) => {
|
|
267
|
+
const archive = await unzipEntries(bytes);
|
|
268
|
+
if (!archive.ok)
|
|
269
|
+
return archive;
|
|
270
|
+
const { entries } = archive;
|
|
271
|
+
const workbookPart = entries.get('xl/workbook.xml');
|
|
272
|
+
if (workbookPart === undefined) {
|
|
273
|
+
return { ok: false, reason: 'no xl/workbook.xml: not a workbook' };
|
|
274
|
+
}
|
|
275
|
+
const relsPart = entries.get('xl/_rels/workbook.xml.rels');
|
|
276
|
+
const targets = new Map();
|
|
277
|
+
if (relsPart !== undefined) {
|
|
278
|
+
scan(decoder.decode(relsPart), (tag) => {
|
|
279
|
+
if (localName(tag.name) !== 'Relationship' || tag.closing)
|
|
280
|
+
return;
|
|
281
|
+
const id = tag.attributes.Id;
|
|
282
|
+
const target = tag.attributes.Target;
|
|
283
|
+
if (id !== undefined && target !== undefined)
|
|
284
|
+
targets.set(id, target);
|
|
285
|
+
}, () => { });
|
|
286
|
+
}
|
|
287
|
+
const sharedPart = entries.get('xl/sharedStrings.xml');
|
|
288
|
+
const shared = sharedPart === undefined ? [] : sharedStringsOf(decoder.decode(sharedPart));
|
|
289
|
+
const sheets = new Map();
|
|
290
|
+
const ordered = [];
|
|
291
|
+
scan(decoder.decode(workbookPart), (tag) => {
|
|
292
|
+
if (localName(tag.name) !== 'sheet' || tag.closing)
|
|
293
|
+
return;
|
|
294
|
+
const name = tag.attributes.name;
|
|
295
|
+
const relationship = tag.attributes['r:id'] ?? tag.attributes.id ?? tag.attributes['relId'];
|
|
296
|
+
if (name === undefined || relationship === undefined)
|
|
297
|
+
return;
|
|
298
|
+
const target = targets.get(relationship);
|
|
299
|
+
if (target !== undefined)
|
|
300
|
+
ordered.push({ name, target });
|
|
301
|
+
}, () => { });
|
|
302
|
+
for (const { name, target } of ordered) {
|
|
303
|
+
const path = target.startsWith('/')
|
|
304
|
+
? target.slice(1)
|
|
305
|
+
: target.startsWith('xl/')
|
|
306
|
+
? target
|
|
307
|
+
: `xl/${target}`;
|
|
308
|
+
const part = entries.get(path);
|
|
309
|
+
if (part === undefined)
|
|
310
|
+
continue;
|
|
311
|
+
sheets.set(name, rowsOf(decoder.decode(part), shared));
|
|
312
|
+
}
|
|
313
|
+
return { ok: true, sheets };
|
|
314
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal OPC/xlsx container, written by hand.
|
|
3
|
+
*
|
|
4
|
+
* An `.xlsx` file is a zip of XML parts, so this needs no dependency at all -
|
|
5
|
+
* which is the point. The workbook has to be produced inside a Cloudflare
|
|
6
|
+
* Worker (#355), where `fs` and Node streams do not exist and bundle size is a
|
|
7
|
+
* budget, and every xlsx library on npm is both larger than this file and
|
|
8
|
+
* built for Node.
|
|
9
|
+
*
|
|
10
|
+
* Entries are written with the **stored** method rather than deflated. Excel
|
|
11
|
+
* accepts stored entries, and it keeps writing SYNCHRONOUS: the only
|
|
12
|
+
* compressor available in a Worker is `CompressionStream`, which is async, and
|
|
13
|
+
* an async writer would infect every caller including ApertureX's synchronous
|
|
14
|
+
* `SourceStore` seam (ADR 0100). Reading is a different matter and is async,
|
|
15
|
+
* because Excel re-saves deflated.
|
|
16
|
+
*
|
|
17
|
+
* Timestamps are fixed rather than current, so identical input produces
|
|
18
|
+
* identical bytes. `export rtm` already holds that line - "the output carries
|
|
19
|
+
* no timestamp, so identical inputs produce identical bytes and CI can diff
|
|
20
|
+
* it" - and a workbook that differs on every run could not be reviewed.
|
|
21
|
+
*/
|
|
22
|
+
/** How a sheet appears in Excel's tab strip. */
|
|
23
|
+
export type SheetState = 'visible' | 'hidden' | 'veryHidden';
|
|
24
|
+
export interface WorkbookSheet {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
/** Defaults to visible. `veryHidden` cannot be unhidden from Excel's UI. */
|
|
27
|
+
readonly state?: SheetState;
|
|
28
|
+
/** Rows of cells. Every value is written as an inline string. */
|
|
29
|
+
readonly rows: readonly (readonly string[])[];
|
|
30
|
+
/** Rows to keep on screen when scrolling, usually 1 for a header. */
|
|
31
|
+
readonly frozenRows?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* XML text escaping.
|
|
35
|
+
*
|
|
36
|
+
* Control characters are dropped rather than escaped: XML 1.0 cannot represent
|
|
37
|
+
* most of them at all, and a NUL reaching a spreadsheet is the failure mode
|
|
38
|
+
* that silently defeats every text tool downstream.
|
|
39
|
+
*/
|
|
40
|
+
export declare const escapeXml: (value: string) => string;
|
|
41
|
+
/** `0 -> A`, `26 -> AA`, the spreadsheet column alphabet. */
|
|
42
|
+
export declare const columnName: (index: number) => string;
|
|
43
|
+
/**
|
|
44
|
+
* Excel forbids these in a sheet name, and caps it at 31 characters. A name
|
|
45
|
+
* that collides after truncation would produce a workbook Excel refuses to
|
|
46
|
+
* open, so callers keep their names short and distinct rather than relying on
|
|
47
|
+
* repair here.
|
|
48
|
+
*/
|
|
49
|
+
export declare const sheetNameIsLegal: (name: string) => boolean;
|
|
50
|
+
/**
|
|
51
|
+
* The workbook, as bytes. Synchronous, dependency-free, and deterministic:
|
|
52
|
+
* the same sheets always produce the same bytes.
|
|
53
|
+
*/
|
|
54
|
+
export declare const writeXlsx: (sheets: readonly WorkbookSheet[]) => Uint8Array;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal OPC/xlsx container, written by hand.
|
|
3
|
+
*
|
|
4
|
+
* An `.xlsx` file is a zip of XML parts, so this needs no dependency at all -
|
|
5
|
+
* which is the point. The workbook has to be produced inside a Cloudflare
|
|
6
|
+
* Worker (#355), where `fs` and Node streams do not exist and bundle size is a
|
|
7
|
+
* budget, and every xlsx library on npm is both larger than this file and
|
|
8
|
+
* built for Node.
|
|
9
|
+
*
|
|
10
|
+
* Entries are written with the **stored** method rather than deflated. Excel
|
|
11
|
+
* accepts stored entries, and it keeps writing SYNCHRONOUS: the only
|
|
12
|
+
* compressor available in a Worker is `CompressionStream`, which is async, and
|
|
13
|
+
* an async writer would infect every caller including ApertureX's synchronous
|
|
14
|
+
* `SourceStore` seam (ADR 0100). Reading is a different matter and is async,
|
|
15
|
+
* because Excel re-saves deflated.
|
|
16
|
+
*
|
|
17
|
+
* Timestamps are fixed rather than current, so identical input produces
|
|
18
|
+
* identical bytes. `export rtm` already holds that line - "the output carries
|
|
19
|
+
* no timestamp, so identical inputs produce identical bytes and CI can diff
|
|
20
|
+
* it" - and a workbook that differs on every run could not be reviewed.
|
|
21
|
+
*/
|
|
22
|
+
const FIXED_DOS_TIME = 0;
|
|
23
|
+
/** 1980-01-01, the zero of DOS date encoding. */
|
|
24
|
+
const FIXED_DOS_DATE = 33;
|
|
25
|
+
const encoder = new TextEncoder();
|
|
26
|
+
const crcTable = (() => {
|
|
27
|
+
const table = new Uint32Array(256);
|
|
28
|
+
for (let index = 0; index < 256; index += 1) {
|
|
29
|
+
let value = index;
|
|
30
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
31
|
+
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
|
32
|
+
}
|
|
33
|
+
table[index] = value >>> 0;
|
|
34
|
+
}
|
|
35
|
+
return table;
|
|
36
|
+
})();
|
|
37
|
+
const crc32 = (bytes) => {
|
|
38
|
+
let crc = 0xffffffff;
|
|
39
|
+
for (const byte of bytes) {
|
|
40
|
+
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
41
|
+
}
|
|
42
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* XML text escaping.
|
|
46
|
+
*
|
|
47
|
+
* Control characters are dropped rather than escaped: XML 1.0 cannot represent
|
|
48
|
+
* most of them at all, and a NUL reaching a spreadsheet is the failure mode
|
|
49
|
+
* that silently defeats every text tool downstream.
|
|
50
|
+
*/
|
|
51
|
+
export const escapeXml = (value) => value
|
|
52
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
|
|
53
|
+
.replace(/&/g, '&')
|
|
54
|
+
.replace(/</g, '<')
|
|
55
|
+
.replace(/>/g, '>')
|
|
56
|
+
.replace(/"/g, '"');
|
|
57
|
+
/** `0 -> A`, `26 -> AA`, the spreadsheet column alphabet. */
|
|
58
|
+
export const columnName = (index) => {
|
|
59
|
+
let name = '';
|
|
60
|
+
let remaining = index;
|
|
61
|
+
for (;;) {
|
|
62
|
+
name = String.fromCharCode(65 + (remaining % 26)) + name;
|
|
63
|
+
if (remaining < 26)
|
|
64
|
+
return name;
|
|
65
|
+
remaining = Math.floor(remaining / 26) - 1;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Excel forbids these in a sheet name, and caps it at 31 characters. A name
|
|
70
|
+
* that collides after truncation would produce a workbook Excel refuses to
|
|
71
|
+
* open, so callers keep their names short and distinct rather than relying on
|
|
72
|
+
* repair here.
|
|
73
|
+
*/
|
|
74
|
+
export const sheetNameIsLegal = (name) => name.length > 0 && name.length <= 31 && !/[\\/?*[\]:]/.test(name);
|
|
75
|
+
const sheetXml = (sheet) => {
|
|
76
|
+
const rows = sheet.rows
|
|
77
|
+
.map((cells, rowIndex) => {
|
|
78
|
+
const row = rowIndex + 1;
|
|
79
|
+
const written = cells
|
|
80
|
+
.map((value, columnIndex) => value === ''
|
|
81
|
+
? ''
|
|
82
|
+
: `<c r="${columnName(columnIndex)}${row}" t="inlineStr">` +
|
|
83
|
+
`<is><t xml:space="preserve">${escapeXml(value)}</t></is></c>`)
|
|
84
|
+
.join('');
|
|
85
|
+
return `<row r="${row}">${written}</row>`;
|
|
86
|
+
})
|
|
87
|
+
.join('');
|
|
88
|
+
const pane = sheet.frozenRows === undefined || sheet.frozenRows <= 0
|
|
89
|
+
? '<sheetView workbookViewId="0"/>'
|
|
90
|
+
: `<sheetView workbookViewId="0"><pane ySplit="${sheet.frozenRows}" ` +
|
|
91
|
+
`topLeftCell="A${sheet.frozenRows + 1}" activePane="bottomLeft" state="frozen"/></sheetView>`;
|
|
92
|
+
return ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
93
|
+
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
|
|
94
|
+
`<sheetViews>${pane}</sheetViews>` +
|
|
95
|
+
`<sheetData>${rows}</sheetData>` +
|
|
96
|
+
'</worksheet>');
|
|
97
|
+
};
|
|
98
|
+
const workbookXml = (sheets) => {
|
|
99
|
+
const entries = sheets
|
|
100
|
+
.map((sheet, index) => {
|
|
101
|
+
const state = sheet.state === undefined || sheet.state === 'visible'
|
|
102
|
+
? ''
|
|
103
|
+
: ` state="${sheet.state === 'veryHidden' ? 'veryHidden' : 'hidden'}"`;
|
|
104
|
+
return (`<sheet name="${escapeXml(sheet.name)}" sheetId="${index + 1}"` +
|
|
105
|
+
`${state} r:id="rId${index + 1}"/>`);
|
|
106
|
+
})
|
|
107
|
+
.join('');
|
|
108
|
+
return ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
109
|
+
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' +
|
|
110
|
+
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
|
|
111
|
+
`<sheets>${entries}</sheets>` +
|
|
112
|
+
'</workbook>');
|
|
113
|
+
};
|
|
114
|
+
const zip = (entries) => {
|
|
115
|
+
const locals = [];
|
|
116
|
+
const centrals = [];
|
|
117
|
+
let offset = 0;
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const name = encoder.encode(entry.path);
|
|
120
|
+
const crc = crc32(entry.bytes);
|
|
121
|
+
const size = entry.bytes.length;
|
|
122
|
+
const local = new Uint8Array(30 + name.length + size);
|
|
123
|
+
const localView = new DataView(local.buffer);
|
|
124
|
+
localView.setUint32(0, 0x04034b50, true);
|
|
125
|
+
localView.setUint16(4, 20, true);
|
|
126
|
+
localView.setUint16(6, 0, true);
|
|
127
|
+
localView.setUint16(8, 0, true); // stored
|
|
128
|
+
localView.setUint16(10, FIXED_DOS_TIME, true);
|
|
129
|
+
localView.setUint16(12, FIXED_DOS_DATE, true);
|
|
130
|
+
localView.setUint32(14, crc, true);
|
|
131
|
+
localView.setUint32(18, size, true);
|
|
132
|
+
localView.setUint32(22, size, true);
|
|
133
|
+
localView.setUint16(26, name.length, true);
|
|
134
|
+
localView.setUint16(28, 0, true);
|
|
135
|
+
local.set(name, 30);
|
|
136
|
+
local.set(entry.bytes, 30 + name.length);
|
|
137
|
+
locals.push(local);
|
|
138
|
+
const central = new Uint8Array(46 + name.length);
|
|
139
|
+
const centralView = new DataView(central.buffer);
|
|
140
|
+
centralView.setUint32(0, 0x02014b50, true);
|
|
141
|
+
centralView.setUint16(4, 20, true);
|
|
142
|
+
centralView.setUint16(6, 20, true);
|
|
143
|
+
centralView.setUint16(8, 0, true);
|
|
144
|
+
centralView.setUint16(10, 0, true); // stored
|
|
145
|
+
centralView.setUint16(12, FIXED_DOS_TIME, true);
|
|
146
|
+
centralView.setUint16(14, FIXED_DOS_DATE, true);
|
|
147
|
+
centralView.setUint32(16, crc, true);
|
|
148
|
+
centralView.setUint32(20, size, true);
|
|
149
|
+
centralView.setUint32(24, size, true);
|
|
150
|
+
centralView.setUint16(28, name.length, true);
|
|
151
|
+
centralView.setUint16(30, 0, true);
|
|
152
|
+
centralView.setUint16(32, 0, true);
|
|
153
|
+
centralView.setUint16(34, 0, true);
|
|
154
|
+
centralView.setUint16(36, 0, true);
|
|
155
|
+
centralView.setUint32(38, 0, true);
|
|
156
|
+
centralView.setUint32(42, offset, true);
|
|
157
|
+
central.set(name, 46);
|
|
158
|
+
centrals.push(central);
|
|
159
|
+
offset += local.length;
|
|
160
|
+
}
|
|
161
|
+
const centralSize = centrals.reduce((total, part) => total + part.length, 0);
|
|
162
|
+
const end = new Uint8Array(22);
|
|
163
|
+
const endView = new DataView(end.buffer);
|
|
164
|
+
endView.setUint32(0, 0x06054b50, true);
|
|
165
|
+
endView.setUint16(8, entries.length, true);
|
|
166
|
+
endView.setUint16(10, entries.length, true);
|
|
167
|
+
endView.setUint32(12, centralSize, true);
|
|
168
|
+
endView.setUint32(16, offset, true);
|
|
169
|
+
const parts = [...locals, ...centrals, end];
|
|
170
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
171
|
+
const out = new Uint8Array(total);
|
|
172
|
+
let at = 0;
|
|
173
|
+
for (const part of parts) {
|
|
174
|
+
out.set(part, at);
|
|
175
|
+
at += part.length;
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* The workbook, as bytes. Synchronous, dependency-free, and deterministic:
|
|
181
|
+
* the same sheets always produce the same bytes.
|
|
182
|
+
*/
|
|
183
|
+
export const writeXlsx = (sheets) => {
|
|
184
|
+
if (sheets.length === 0) {
|
|
185
|
+
throw new Error('A workbook needs at least one sheet');
|
|
186
|
+
}
|
|
187
|
+
for (const sheet of sheets) {
|
|
188
|
+
if (!sheetNameIsLegal(sheet.name)) {
|
|
189
|
+
throw new Error(`Illegal sheet name: ${JSON.stringify(sheet.name)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const names = new Set(sheets.map(({ name }) => name.toLowerCase()));
|
|
193
|
+
if (names.size !== sheets.length) {
|
|
194
|
+
throw new Error('Sheet names must be distinct, case-insensitively');
|
|
195
|
+
}
|
|
196
|
+
const contentTypes = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
197
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
|
198
|
+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
|
199
|
+
'<Default Extension="xml" ContentType="application/xml"/>' +
|
|
200
|
+
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
|
|
201
|
+
sheets
|
|
202
|
+
.map((_sheet, index) => `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ` +
|
|
203
|
+
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>')
|
|
204
|
+
.join('') +
|
|
205
|
+
'</Types>';
|
|
206
|
+
const rootRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
207
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
208
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
|
|
209
|
+
'</Relationships>';
|
|
210
|
+
const workbookRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
211
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
212
|
+
sheets
|
|
213
|
+
.map((_sheet, index) => `<Relationship Id="rId${index + 1}" ` +
|
|
214
|
+
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" ' +
|
|
215
|
+
`Target="worksheets/sheet${index + 1}.xml"/>`)
|
|
216
|
+
.join('') +
|
|
217
|
+
'</Relationships>';
|
|
218
|
+
return zip([
|
|
219
|
+
{ path: '[Content_Types].xml', bytes: encoder.encode(contentTypes) },
|
|
220
|
+
{ path: '_rels/.rels', bytes: encoder.encode(rootRels) },
|
|
221
|
+
{ path: 'xl/workbook.xml', bytes: encoder.encode(workbookXml(sheets)) },
|
|
222
|
+
{ path: 'xl/_rels/workbook.xml.rels', bytes: encoder.encode(workbookRels) },
|
|
223
|
+
...sheets.map((sheet, index) => ({
|
|
224
|
+
path: `xl/worksheets/sheet${index + 1}.xml`,
|
|
225
|
+
bytes: encoder.encode(sheetXml(sheet)),
|
|
226
|
+
})),
|
|
227
|
+
]);
|
|
228
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ProjectionResult } from './projection.js';
|
|
2
|
+
import { type WorkbookSheet } from './workbook-xlsx.js';
|
|
3
|
+
/**
|
|
4
|
+
* The model as a workbook an architect can work in (#355).
|
|
5
|
+
*
|
|
6
|
+
* The shape is borrowed from a real consulting workbook rather than invented:
|
|
7
|
+
* identity is columnar and readability is derived. A stable id in column A,
|
|
8
|
+
* foreign keys inline, and an adjacent `(auto)` column carrying the referenced
|
|
9
|
+
* subject's NAME so a sheet reads without hunting across tabs. The id is what
|
|
10
|
+
* round-trips; the `(auto)` column is a convenience and is ignored on import.
|
|
11
|
+
*
|
|
12
|
+
* Losslessness does not depend on this file enumerating every predicate
|
|
13
|
+
* correctly. Well-known predicates become columns because a person needs them
|
|
14
|
+
* to be columns; **everything else lands in `07 Other Facts` verbatim**, so a
|
|
15
|
+
* predicate added to the compiler after this was written still survives a
|
|
16
|
+
* round trip. A mapping that silently dropped what it did not recognise is the
|
|
17
|
+
* failure this design exists to avoid.
|
|
18
|
+
*/
|
|
19
|
+
export declare const WORKBOOK_FORMAT = "yarramate/workbook/v1";
|
|
20
|
+
export interface WorkbookProvenance {
|
|
21
|
+
readonly workspace: string;
|
|
22
|
+
readonly yarramateVersion: string;
|
|
23
|
+
/** What the graph was compiled from, the pin a later import checks. */
|
|
24
|
+
readonly sourceDigests: Readonly<Record<string, string>>;
|
|
25
|
+
/** Vocabulary for the dropdown lists, taken from the compiled profile. */
|
|
26
|
+
readonly conceptKinds: readonly string[];
|
|
27
|
+
readonly relationshipKinds: readonly string[];
|
|
28
|
+
readonly statuses: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
export declare const buildWorkbookSheets: (result: ProjectionResult, provenance: WorkbookProvenance) => readonly WorkbookSheet[];
|
|
31
|
+
/** The workbook, as bytes. Synchronous and deterministic. */
|
|
32
|
+
export declare const workbookFrom: (result: ProjectionResult, provenance: WorkbookProvenance) => Uint8Array;
|