@opencraw/office-reader 0.0.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/README.md +201 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +6 -0
- package/dist/pptx.d.ts +1 -0
- package/dist/pptx.esm.js +5 -0
- package/dist/read-pptx.use-case.esm.js +591 -0
- package/dist/read-source.client.esm.js +289 -0
- package/dist/read-xlsx.use-case.esm.js +381 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/ooxml-package/index.d.ts +7 -0
- package/dist/src/ooxml-package/ooxml-package.client.d.ts +47 -0
- package/dist/src/ooxml-package/relationships.mapper.d.ts +27 -0
- package/dist/src/ooxml-package/xml-walk.algorithm.d.ts +37 -0
- package/dist/src/presentation/chart.mapper.d.ts +13 -0
- package/dist/src/presentation/deck.model.d.ts +56 -0
- package/dist/src/presentation/index.d.ts +6 -0
- package/dist/src/presentation/notes.mapper.d.ts +9 -0
- package/dist/src/presentation/placeholder-geometry.mapper.d.ts +53 -0
- package/dist/src/presentation/read-pptx.use-case.d.ts +32 -0
- package/dist/src/presentation/slide.mapper.d.ts +24 -0
- package/dist/src/read-error/index.d.ts +3 -0
- package/dist/src/read-error/office-read.error.d.ts +29 -0
- package/dist/src/source-bytes/index.d.ts +3 -0
- package/dist/src/source-bytes/read-source.client.d.ts +18 -0
- package/dist/src/spreadsheet/cell-value.algorithm.d.ts +47 -0
- package/dist/src/spreadsheet/index.d.ts +6 -0
- package/dist/src/spreadsheet/number-formats.mapper.d.ts +10 -0
- package/dist/src/spreadsheet/read-xlsx.use-case.d.ts +27 -0
- package/dist/src/spreadsheet/shared-strings.mapper.d.ts +10 -0
- package/dist/src/spreadsheet/workbook.model.d.ts +35 -0
- package/dist/src/spreadsheet/worksheet.mapper.d.ts +23 -0
- package/dist/xlsx.d.ts +1 -0
- package/dist/xlsx.esm.js +5 -0
- package/package.json +77 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { unzipSync, strFromU8 } from 'fflate';
|
|
2
|
+
import { Parser } from 'htmlparser2';
|
|
3
|
+
|
|
4
|
+
/** A file `office-reader` cannot read, with a `code` to branch on and a message that says what to do. */
|
|
5
|
+
class OfficeReadError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
name = 'OfficeReadError';
|
|
8
|
+
constructor(code, message, options) {
|
|
9
|
+
super(message, options);
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MIB = 1024 * 1024;
|
|
15
|
+
const DEFAULT_ENTRY_BYTES = 256 * MIB;
|
|
16
|
+
const DEFAULT_TOTAL_BYTES = 512 * MIB;
|
|
17
|
+
/** An Office Open XML package: a zip of XML parts, read part by part. */
|
|
18
|
+
class OoxmlPackage {
|
|
19
|
+
bytes;
|
|
20
|
+
entries;
|
|
21
|
+
limits;
|
|
22
|
+
/**
|
|
23
|
+
* Opens a package, refusing what is not one with the reason: a legacy or
|
|
24
|
+
* password-protected Office file (both are OLE compound files), an
|
|
25
|
+
* OpenDocument file, anything else that is not a zip.
|
|
26
|
+
*
|
|
27
|
+
* @param bytes - The file.
|
|
28
|
+
* @param limits - How much it may inflate to.
|
|
29
|
+
* @returns The package; no part is inflated yet.
|
|
30
|
+
* @throws OfficeReadError
|
|
31
|
+
*/
|
|
32
|
+
static open(bytes, limits = {}) {
|
|
33
|
+
if (isCompoundFile(bytes)) {
|
|
34
|
+
if (holdsEncryptionInfo(bytes)) throw new OfficeReadError('encrypted', 'a password-protected Office file: remove the password and save it again');
|
|
35
|
+
throw new OfficeReadError('legacy-format', 'a legacy binary Office file (.xls, .ppt, .doc): save it as .xlsx or .pptx, or export it as PDF');
|
|
36
|
+
}
|
|
37
|
+
const entries = new Map();
|
|
38
|
+
try {
|
|
39
|
+
unzipSync(bytes, {
|
|
40
|
+
filter: file => {
|
|
41
|
+
entries.set(partKey(file.name), {
|
|
42
|
+
name: file.name,
|
|
43
|
+
size: file.originalSize
|
|
44
|
+
});
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new OfficeReadError('not-zip', `not an Office file: not a readable zip (${error.message})`, {
|
|
50
|
+
cause: error
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const opened = new OoxmlPackage(bytes, entries, {
|
|
54
|
+
entryBytes: limits.entryBytes ?? DEFAULT_ENTRY_BYTES,
|
|
55
|
+
totalBytes: limits.totalBytes ?? DEFAULT_TOTAL_BYTES
|
|
56
|
+
});
|
|
57
|
+
const mimetype = opened.has('mimetype') ? opened.text('mimetype').trim() : '';
|
|
58
|
+
if (mimetype.startsWith('application/vnd.oasis.opendocument')) throw new OfficeReadError('unsupported-format', `an OpenDocument file (${mimetype}): save it as .xlsx or .pptx`);
|
|
59
|
+
return opened;
|
|
60
|
+
}
|
|
61
|
+
declared = 0;
|
|
62
|
+
/**
|
|
63
|
+
* Entries by part key: part names are case-insensitive (OPC), and some
|
|
64
|
+
* generators store `xl\\sharedstrings.xml` for `xl/sharedStrings.xml`.
|
|
65
|
+
*/
|
|
66
|
+
constructor(bytes, entries, limits) {
|
|
67
|
+
this.bytes = bytes;
|
|
68
|
+
this.entries = entries;
|
|
69
|
+
this.limits = limits;
|
|
70
|
+
}
|
|
71
|
+
/** The names of every part, as stored. */
|
|
72
|
+
get names() {
|
|
73
|
+
return Array.from(this.entries.values(), entry => entry.name);
|
|
74
|
+
}
|
|
75
|
+
has(name) {
|
|
76
|
+
return this.entries.has(partKey(name));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Inflates one part as UTF-8 text.
|
|
80
|
+
*
|
|
81
|
+
* @param name - The part, as stored (`xl/workbook.xml`).
|
|
82
|
+
* @returns The text; empty when the part is missing.
|
|
83
|
+
* @throws OfficeReadError: `too-large` past the limits, `malformed` when the part does not inflate.
|
|
84
|
+
*/
|
|
85
|
+
text(name) {
|
|
86
|
+
const entry = this.entries.get(partKey(name));
|
|
87
|
+
if (entry === undefined) return '';
|
|
88
|
+
if (entry.size > this.limits.entryBytes) throw new OfficeReadError('too-large', `${name} declares ${entry.size} bytes, over the ${this.limits.entryBytes}-byte entry limit`);
|
|
89
|
+
this.declared += entry.size;
|
|
90
|
+
if (this.declared > this.limits.totalBytes) throw new OfficeReadError('too-large', `the parts read declare over ${this.limits.totalBytes} bytes together`);
|
|
91
|
+
let inflated;
|
|
92
|
+
try {
|
|
93
|
+
inflated = unzipSync(this.bytes, {
|
|
94
|
+
filter: file => file.name === entry.name
|
|
95
|
+
})[entry.name];
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw new OfficeReadError('malformed', `${name} does not inflate (${error.message}): the file is damaged`, {
|
|
98
|
+
cause: error
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return inflated === undefined ? '' : strFromU8(inflated);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** How a part is looked up: forward slashes, no leading slash, lower case. */
|
|
105
|
+
function partKey(name) {
|
|
106
|
+
return name.replaceAll('\\', '/').replace(/^\//, '').toLowerCase();
|
|
107
|
+
}
|
|
108
|
+
/** An OLE compound file (`D0 CF 11 E0 A1 B1 1A E1`): a legacy Office file, or an encrypted new one. */
|
|
109
|
+
function isCompoundFile(bytes) {
|
|
110
|
+
const signature = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
|
111
|
+
return signature.every((byte, index) => bytes[index] === byte);
|
|
112
|
+
}
|
|
113
|
+
/** Whether a compound file holds an `EncryptionInfo` stream: an encrypted .xlsx/.pptx, not a legacy file. */
|
|
114
|
+
function holdsEncryptionInfo(bytes) {
|
|
115
|
+
const name = [...'EncryptionInfo'].flatMap(char => [char.codePointAt(0) ?? 0, 0]);
|
|
116
|
+
const last = bytes.length - name.length;
|
|
117
|
+
for (let start = 0; start <= last; start += 1) {
|
|
118
|
+
if (name.every((byte, index) => bytes[start + index] === byte)) return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Walks an XML part as a stream of events, never building a tree, so a sheet
|
|
125
|
+
* of a million cells costs its text, not a DOM. htmlparser2 does no DTD
|
|
126
|
+
* processing: entities a document declares are not expanded (no "billion
|
|
127
|
+
* laughs") and no external entity is fetched (no XXE); only the XML built-ins
|
|
128
|
+
* and numeric references decode.
|
|
129
|
+
*
|
|
130
|
+
* Element names lose their prefix (`x:c`, `p:sp` → `c`, `sp`): generators
|
|
131
|
+
* choose prefixes freely. Attributes keep theirs; read a namespaced one with
|
|
132
|
+
* {@link namespacedAttribute}.
|
|
133
|
+
*
|
|
134
|
+
* @param xml - The part's text.
|
|
135
|
+
* @param handlers - What to do on each event.
|
|
136
|
+
*/
|
|
137
|
+
function walkXml(xml, handlers) {
|
|
138
|
+
const parser = new Parser({
|
|
139
|
+
onopentag: (name, attributes) => handlers.open?.(localName(name), attributes),
|
|
140
|
+
ontext: text => handlers.text?.(text),
|
|
141
|
+
onclosetag: name => handlers.close?.(localName(name))
|
|
142
|
+
}, {
|
|
143
|
+
xmlMode: true,
|
|
144
|
+
decodeEntities: true
|
|
145
|
+
});
|
|
146
|
+
parser.write(xml);
|
|
147
|
+
parser.end();
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* An attribute in a namespace whatever its prefix (`r:id`, `ns1:id`).
|
|
151
|
+
*
|
|
152
|
+
* @param attributes - The element's attributes.
|
|
153
|
+
* @param name - The local name (`id`).
|
|
154
|
+
* @returns The value, or `undefined`.
|
|
155
|
+
*/
|
|
156
|
+
function namespacedAttribute(attributes, name) {
|
|
157
|
+
const key = Object.keys(attributes).find(candidate => candidate.endsWith(`:${name}`) && !candidate.startsWith('xmlns'));
|
|
158
|
+
return key === undefined ? undefined : attributes[key];
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Whether an XML boolean attribute is on (`1`, `true`, `on`).
|
|
162
|
+
*
|
|
163
|
+
* @param value - The attribute's value.
|
|
164
|
+
* @returns Whether it is set.
|
|
165
|
+
*/
|
|
166
|
+
function isOn(value) {
|
|
167
|
+
return value !== undefined && ['1', 'true', 'on'].includes(value);
|
|
168
|
+
}
|
|
169
|
+
function localName(name) {
|
|
170
|
+
const colon = name.indexOf(':');
|
|
171
|
+
return colon === -1 ? name : name.slice(colon + 1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The relationships of a part, targets resolved to part names. External
|
|
176
|
+
* targets (hyperlinks) are left out.
|
|
177
|
+
*
|
|
178
|
+
* @param pkg - The package.
|
|
179
|
+
* @param part - The part (`xl/workbook.xml`), or `''` for the package's own.
|
|
180
|
+
* @returns The relationships by id.
|
|
181
|
+
*/
|
|
182
|
+
function relationshipsOf(pkg, part) {
|
|
183
|
+
const directory = part.includes('/') ? part.slice(0, part.lastIndexOf('/')) : '';
|
|
184
|
+
const file = part.slice(part.lastIndexOf('/') + 1);
|
|
185
|
+
const relationships = new Map();
|
|
186
|
+
walkXml(pkg.text(`${directory === '' ? '' : `${directory}/`}_rels/${file}.rels`), {
|
|
187
|
+
open: (name, attributes) => {
|
|
188
|
+
if (name !== 'Relationship' || attributes.TargetMode === 'External' || attributes.Id === undefined || attributes.Target === undefined) return;
|
|
189
|
+
relationships.set(attributes.Id, {
|
|
190
|
+
id: attributes.Id,
|
|
191
|
+
type: (attributes.Type ?? '').split('/').at(-1) ?? '',
|
|
192
|
+
target: resolvePart(directory, attributes.Target)
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
return relationships;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The first relationship of a type.
|
|
200
|
+
*
|
|
201
|
+
* @param relationships - A part's relationships.
|
|
202
|
+
* @param type - The type's last segment.
|
|
203
|
+
* @returns It, or `undefined`.
|
|
204
|
+
*/
|
|
205
|
+
function relationshipOfType(relationships, type) {
|
|
206
|
+
for (const relationship of relationships.values()) {
|
|
207
|
+
if (relationship.type === type) return relationship;
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
/** A target relative to its source part's directory (`worksheets/sheet1.xml`, `../media/x.png`) or absolute (`/xl/…`). */
|
|
212
|
+
function resolvePart(directory, target) {
|
|
213
|
+
const segments = target.startsWith('/') ? [] : directory.split('/').filter(segment => segment !== '');
|
|
214
|
+
for (const segment of target.split('/')) {
|
|
215
|
+
if (segment === '..') segments.pop();else if (segment !== '.' && segment !== '') segments.push(segment);
|
|
216
|
+
}
|
|
217
|
+
return segments.join('/');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Reads a source into one `Uint8Array`. Only a path or a `file:` URL touches
|
|
222
|
+
* the file system, through a dynamic import, so the package runs in browsers,
|
|
223
|
+
* workers and edge runtimes too. Fetching is the caller's job: an `http:` URL
|
|
224
|
+
* is refused.
|
|
225
|
+
*
|
|
226
|
+
* @param source - The file.
|
|
227
|
+
* @returns Its bytes.
|
|
228
|
+
* @throws OfficeReadError (`bad-source`) for anything else.
|
|
229
|
+
*/
|
|
230
|
+
async function readSource(source) {
|
|
231
|
+
if (typeof source === 'string') return readPath(source);
|
|
232
|
+
if (source instanceof URL) {
|
|
233
|
+
if (source.protocol !== 'file:') throw new OfficeReadError('bad-source', `${source.href}: office-reader reads files, not URLs; fetch it and pass the bytes`);
|
|
234
|
+
return readPath(source);
|
|
235
|
+
}
|
|
236
|
+
if (source instanceof Uint8Array) return source;
|
|
237
|
+
if (source instanceof ArrayBuffer) return new Uint8Array(source);
|
|
238
|
+
if (ArrayBuffer.isView(source)) return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
|
|
239
|
+
if (typeof Blob !== 'undefined' && source instanceof Blob) return new Uint8Array(await source.arrayBuffer());
|
|
240
|
+
if (isReadableStream(source)) return concat(await readStream(source));
|
|
241
|
+
if (isAsyncIterable(source)) return concat(await readIterable(source));
|
|
242
|
+
throw new OfficeReadError('bad-source', 'office-reader reads a path, a file: URL, bytes, a Blob, a ReadableStream or an async iterable of bytes');
|
|
243
|
+
}
|
|
244
|
+
async function readPath(path) {
|
|
245
|
+
const {
|
|
246
|
+
readFile
|
|
247
|
+
} = await import('node:fs/promises');
|
|
248
|
+
return new Uint8Array(await readFile(path));
|
|
249
|
+
}
|
|
250
|
+
function isReadableStream(value) {
|
|
251
|
+
return typeof value === 'object' && value !== null && typeof value.getReader === 'function';
|
|
252
|
+
}
|
|
253
|
+
function isAsyncIterable(value) {
|
|
254
|
+
return typeof value === 'object' && value !== null && typeof value[Symbol.asyncIterator] === 'function';
|
|
255
|
+
}
|
|
256
|
+
async function readStream(stream) {
|
|
257
|
+
const reader = stream.getReader();
|
|
258
|
+
const chunks = [];
|
|
259
|
+
for (;;) {
|
|
260
|
+
const {
|
|
261
|
+
done,
|
|
262
|
+
value
|
|
263
|
+
} = await reader.read();
|
|
264
|
+
if (done) return chunks;
|
|
265
|
+
chunks.push(chunkOf(value));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async function readIterable(iterable) {
|
|
269
|
+
const chunks = [];
|
|
270
|
+
for await (const chunk of iterable) chunks.push(chunkOf(chunk));
|
|
271
|
+
return chunks;
|
|
272
|
+
}
|
|
273
|
+
function chunkOf(chunk) {
|
|
274
|
+
if (chunk instanceof Uint8Array) return chunk;
|
|
275
|
+
throw new OfficeReadError('bad-source', 'a stream of text is not a file: read it as bytes (no encoding set)');
|
|
276
|
+
}
|
|
277
|
+
function concat(chunks) {
|
|
278
|
+
if (chunks.length === 1) return chunks[0];
|
|
279
|
+
const bytes = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
|
|
280
|
+
let offset = 0;
|
|
281
|
+
for (const chunk of chunks) {
|
|
282
|
+
bytes.set(chunk, offset);
|
|
283
|
+
offset += chunk.length;
|
|
284
|
+
}
|
|
285
|
+
return bytes;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export { OfficeReadError as O, OoxmlPackage as a, relationshipOfType as b, relationshipsOf as c, isOn as i, namespacedAttribute as n, readSource as r, walkXml as w };
|
|
289
|
+
//# sourceMappingURL=read-source.client.esm.js.map
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { w as walkXml, i as isOn, a as OoxmlPackage, r as readSource, O as OfficeReadError, b as relationshipOfType, c as relationshipsOf, n as namespacedAttribute } from './read-source.client.esm.js';
|
|
2
|
+
import 'fflate';
|
|
3
|
+
|
|
4
|
+
/** Built-in number formats that show a date (14–17, 22 and the East Asian ones) or a time of day (18–21, 45–47). */
|
|
5
|
+
const BUILT_IN_DATES = new Set([14, 15, 16, 17, 22, 27, 28, 29, 30, 31, 34, 35, 36, 50, 51, 52, 53, 54, 57, 58]);
|
|
6
|
+
const BUILT_IN_TIMES = new Set([18, 19, 20, 21, 32, 33, 45, 46, 47, 55, 56]);
|
|
7
|
+
/**
|
|
8
|
+
* What each cell style (`s`, an index into `cellXfs`) makes of a number, from
|
|
9
|
+
* `styles.xml`: only dates and times need telling apart from numbers.
|
|
10
|
+
*
|
|
11
|
+
* @param stylesXml - The styles part; empty when the workbook has none.
|
|
12
|
+
* @returns The kind per style index.
|
|
13
|
+
*/
|
|
14
|
+
function formatKinds(stylesXml) {
|
|
15
|
+
const codes = new Map();
|
|
16
|
+
const kinds = [];
|
|
17
|
+
let inCellXfs = false;
|
|
18
|
+
walkXml(stylesXml, {
|
|
19
|
+
open: (name, attributes) => {
|
|
20
|
+
if (name === 'numFmt') codes.set(Number(attributes.numFmtId), attributes.formatCode ?? '');else if (name === 'cellXfs') inCellXfs = true;else if (name === 'xf' && inCellXfs) kinds.push(kindOf(Number(attributes.numFmtId ?? 0), codes));
|
|
21
|
+
},
|
|
22
|
+
close: name => {
|
|
23
|
+
if (name === 'cellXfs') inCellXfs = false;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
return kinds;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* What a format code shows: a date when it has a day, month or year, a time
|
|
30
|
+
* of day when it has only hours, minutes or seconds. Quoted text, escaped
|
|
31
|
+
* characters, colours and locales (`[Red]`, `[$-409]`) are ignored; an
|
|
32
|
+
* elapsed time (`[h]:mm`) is a duration, so a number.
|
|
33
|
+
*/
|
|
34
|
+
function kindOf(id, codes) {
|
|
35
|
+
const code = codes.get(id);
|
|
36
|
+
if (code === undefined) {
|
|
37
|
+
if (BUILT_IN_DATES.has(id)) return 'date';
|
|
38
|
+
return BUILT_IN_TIMES.has(id) ? 'time' : 'number';
|
|
39
|
+
}
|
|
40
|
+
const firstSection = code.split(';', 1)[0];
|
|
41
|
+
if (/\[(?:h+|m+|s+)\]/i.test(firstSection)) return 'number';
|
|
42
|
+
const bare = firstSection.replaceAll(/"[^"]*"|\\.|\[[^\]]*\]/g, '').replaceAll(/am\/pm|a\/p/gi, '');
|
|
43
|
+
if (/[dy]/i.test(bare) || hasMonth(bare)) return 'date';
|
|
44
|
+
return /[hs]/i.test(bare) ? 'time' : 'number';
|
|
45
|
+
}
|
|
46
|
+
/** Whether a run of `m` is a month: minutes sit after an hour or before seconds (`h:mm`, `mm:ss`). */
|
|
47
|
+
function hasMonth(code) {
|
|
48
|
+
for (const run of code.matchAll(/m+/gi)) {
|
|
49
|
+
const before = code.slice(0, run.index).trimEnd().at(-1)?.toLowerCase();
|
|
50
|
+
const after = code.slice(run.index + run[0].length).trimStart()[0]?.toLowerCase();
|
|
51
|
+
const minutes = before === 'h' || before === ':' || after === ':' || after === 's';
|
|
52
|
+
if (!minutes) return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The shared string table: the text of every `<si>`, rich-text runs joined.
|
|
59
|
+
* Phonetic guides (`<rPh>`, the furigana over Japanese text) are not part of
|
|
60
|
+
* the text and are left out.
|
|
61
|
+
*
|
|
62
|
+
* @param xml - The shared strings part; empty when the workbook has none.
|
|
63
|
+
* @returns The strings, by index.
|
|
64
|
+
*/
|
|
65
|
+
function sharedStrings(xml) {
|
|
66
|
+
const strings = [];
|
|
67
|
+
let current;
|
|
68
|
+
let inText = false;
|
|
69
|
+
let phonetic = 0;
|
|
70
|
+
walkXml(xml, {
|
|
71
|
+
open: name => {
|
|
72
|
+
if (name === 'si') current = '';else if (name === 'rPh') phonetic += 1;else if (name === 't' && phonetic === 0) inText = true;
|
|
73
|
+
},
|
|
74
|
+
text: text => {
|
|
75
|
+
if (inText && current !== undefined) current += text;
|
|
76
|
+
},
|
|
77
|
+
close: name => {
|
|
78
|
+
switch (name) {
|
|
79
|
+
case 't':
|
|
80
|
+
{
|
|
81
|
+
inText = false;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case 'rPh':
|
|
85
|
+
{
|
|
86
|
+
phonetic -= 1;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case 'si':
|
|
90
|
+
{
|
|
91
|
+
strings.push(current ?? '');
|
|
92
|
+
current = undefined;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
// No default
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
return strings;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const MS_PER_DAY = 86_400_000;
|
|
103
|
+
/** 1970-01-01 as a 1900-system serial number. */
|
|
104
|
+
const UNIX_EPOCH_SERIAL = 25_569;
|
|
105
|
+
/** Days between the 1900 and the 1904 systems' day zero. */
|
|
106
|
+
const DAYS_1904 = 1462;
|
|
107
|
+
/**
|
|
108
|
+
* A stored cell as a typed value.
|
|
109
|
+
*
|
|
110
|
+
* @param cell - The cell.
|
|
111
|
+
* @param context - Shared strings and the date system.
|
|
112
|
+
* @returns The value; `null` for an empty cell.
|
|
113
|
+
*/
|
|
114
|
+
function typedValue(cell, context) {
|
|
115
|
+
switch (cell.type) {
|
|
116
|
+
case 's':
|
|
117
|
+
{
|
|
118
|
+
return context.sharedStrings[Number(cell.value)] ?? '';
|
|
119
|
+
}
|
|
120
|
+
case 'str':
|
|
121
|
+
case 'inlineStr':
|
|
122
|
+
{
|
|
123
|
+
return cell.value;
|
|
124
|
+
}
|
|
125
|
+
case 'b':
|
|
126
|
+
{
|
|
127
|
+
return cell.value === '1' || cell.value === 'true';
|
|
128
|
+
}
|
|
129
|
+
case 'e':
|
|
130
|
+
{
|
|
131
|
+
return {
|
|
132
|
+
error: cell.value
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
case 'd':
|
|
136
|
+
{
|
|
137
|
+
const date = new Date(cell.value.endsWith('Z') || /[+-]\d\d:\d\d$/.test(cell.value) ? cell.value : `${cell.value}Z`);
|
|
138
|
+
return Number.isNaN(date.getTime()) ? cell.value : date;
|
|
139
|
+
}
|
|
140
|
+
default:
|
|
141
|
+
{
|
|
142
|
+
if (cell.value.trim() === '') return null;
|
|
143
|
+
const number = Number(cell.value);
|
|
144
|
+
if (Number.isNaN(number)) return cell.value;
|
|
145
|
+
return cell.format === 'number' ? number : dateOf(number, context.date1904);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* A stored cell as canonical text: numbers in their shortest round-trip form
|
|
151
|
+
* (`78.6`, not `78.599999999999994`), dates as ISO (`2026-06-01`, or
|
|
152
|
+
* `2026-06-01T09:30:00` with a time, `09:30:00` for a time of day), booleans
|
|
153
|
+
* as `true`/`false`, errors as written, `''` when empty. Display formats are
|
|
154
|
+
* not applied: a percentage stays a fraction (`0.125`).
|
|
155
|
+
*
|
|
156
|
+
* @param cell - The cell.
|
|
157
|
+
* @param context - Shared strings and the date system.
|
|
158
|
+
* @returns The text.
|
|
159
|
+
*/
|
|
160
|
+
function textValue(cell, context) {
|
|
161
|
+
const value = typedValue(cell, context);
|
|
162
|
+
if (value === null) return '';
|
|
163
|
+
if (value instanceof Date) return isoText(value, cell.format === 'time' && Number(cell.value) < 1);
|
|
164
|
+
if (typeof value === 'object') return value.error;
|
|
165
|
+
return String(value);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Reads a cell in the mode asked for.
|
|
169
|
+
*
|
|
170
|
+
* @param cell - The cell.
|
|
171
|
+
* @param context - Shared strings and the date system.
|
|
172
|
+
* @param mode - `typed` or `text`.
|
|
173
|
+
* @returns The value.
|
|
174
|
+
*/
|
|
175
|
+
function cellValue(cell, context, mode) {
|
|
176
|
+
return mode === 'text' ? textValue(cell, context) : typedValue(cell, context);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* A serial date as a `Date` holding the wall-clock time as UTC, rounded to the
|
|
180
|
+
* second. The 1900 system counts the 29th of February 1900 that never was
|
|
181
|
+
* (Lotus's bug, kept for compatibility): serials before it are a day early.
|
|
182
|
+
*/
|
|
183
|
+
function dateOf(serial, date1904) {
|
|
184
|
+
let days = date1904 ? serial + DAYS_1904 : serial;
|
|
185
|
+
if (!date1904 && serial >= 1 && serial < 60) days += 1;
|
|
186
|
+
const seconds = Math.round((days - UNIX_EPOCH_SERIAL) * (MS_PER_DAY / 1000));
|
|
187
|
+
return new Date(seconds * 1000);
|
|
188
|
+
}
|
|
189
|
+
function isoText(date, timeOfDay) {
|
|
190
|
+
const iso = date.toISOString();
|
|
191
|
+
if (timeOfDay) return iso.slice(11, 19);
|
|
192
|
+
return iso.slice(11, 19) === '00:00:00' ? iso.slice(0, 10) : iso.slice(0, 19);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reads a worksheet part as a stream: rows of cells (formulas give their
|
|
197
|
+
* cached value, never evaluated), hidden rows and merged ranges. A row or a
|
|
198
|
+
* cell without its reference (some generators leave `r` out) follows the one
|
|
199
|
+
* before it.
|
|
200
|
+
*
|
|
201
|
+
* @param xml - The worksheet part.
|
|
202
|
+
* @param context - Shared strings, number format kinds and the date system.
|
|
203
|
+
* @param mode - `typed` or `text`.
|
|
204
|
+
* @returns The content.
|
|
205
|
+
*/
|
|
206
|
+
function readWorksheet(xml, context, mode) {
|
|
207
|
+
const rows = [];
|
|
208
|
+
const hiddenRows = [];
|
|
209
|
+
const merges = [];
|
|
210
|
+
let row = -1;
|
|
211
|
+
let column = -1;
|
|
212
|
+
let cell;
|
|
213
|
+
let capturing;
|
|
214
|
+
let phonetic = 0;
|
|
215
|
+
const finish = open => {
|
|
216
|
+
const stored = {
|
|
217
|
+
type: open.type,
|
|
218
|
+
value: open.value,
|
|
219
|
+
format: context.formats[open.style] ?? 'number'
|
|
220
|
+
};
|
|
221
|
+
rows[open.row] ??= [];
|
|
222
|
+
rows[open.row][open.column] = cellValue(stored, context, mode);
|
|
223
|
+
};
|
|
224
|
+
walkXml(xml, {
|
|
225
|
+
open: (name, attributes) => {
|
|
226
|
+
switch (name) {
|
|
227
|
+
case 'row':
|
|
228
|
+
{
|
|
229
|
+
row = attributes.r === undefined ? row + 1 : Number(attributes.r) - 1;
|
|
230
|
+
column = -1;
|
|
231
|
+
rows[row] ??= [];
|
|
232
|
+
if (isOn(attributes.hidden)) hiddenRows.push(row);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case 'c':
|
|
236
|
+
{
|
|
237
|
+
const reference = attributes.r === undefined ? undefined : cellOf(attributes.r);
|
|
238
|
+
if (reference !== undefined) row = reference.row;
|
|
239
|
+
column = reference?.column ?? column + 1;
|
|
240
|
+
cell = {
|
|
241
|
+
row,
|
|
242
|
+
column,
|
|
243
|
+
type: attributes.t ?? 'n',
|
|
244
|
+
style: Number(attributes.s ?? 0),
|
|
245
|
+
value: ''
|
|
246
|
+
};
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
case 'v':
|
|
250
|
+
{
|
|
251
|
+
if (cell !== undefined) capturing = 'value';
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
case 'rPh':
|
|
255
|
+
{
|
|
256
|
+
phonetic += 1;
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
case 't':
|
|
260
|
+
{
|
|
261
|
+
if (phonetic === 0 && cell?.type === 'inlineStr') capturing = 'inline';
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
case 'mergeCell':
|
|
265
|
+
{
|
|
266
|
+
if (attributes.ref !== undefined) merges.push(attributes.ref);
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
// No default
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
text: text => {
|
|
273
|
+
if (capturing !== undefined && cell !== undefined) cell.value += text;
|
|
274
|
+
},
|
|
275
|
+
close: name => {
|
|
276
|
+
if (name === 'v' || name === 't') {
|
|
277
|
+
capturing = undefined;
|
|
278
|
+
} else if (name === 'rPh') {
|
|
279
|
+
phonetic -= 1;
|
|
280
|
+
} else if (name === 'c' && cell !== undefined) {
|
|
281
|
+
finish(cell);
|
|
282
|
+
cell = undefined;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
const empty = mode === 'text' ? '' : null;
|
|
287
|
+
return {
|
|
288
|
+
rows: Array.from(rows, cells => Array.from(cells ?? [], value => value ?? empty)),
|
|
289
|
+
hiddenRows,
|
|
290
|
+
merges
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/** `AB12` as 0-based row and column. */
|
|
294
|
+
function cellOf(reference) {
|
|
295
|
+
const match = /^\$?([A-Z]+)\$?(\d+)$/i.exec(reference);
|
|
296
|
+
if (match === null) return undefined;
|
|
297
|
+
const letters = match[1].toUpperCase();
|
|
298
|
+
let column = 0;
|
|
299
|
+
for (const letter of letters) column = column * 26 + (letter.codePointAt(0) ?? 64) - 64;
|
|
300
|
+
return {
|
|
301
|
+
row: Number(match[2]) - 1,
|
|
302
|
+
column: column - 1
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function readXlsx(source, options = {}) {
|
|
307
|
+
const pkg = OoxmlPackage.open(await readSource(source), options.limits);
|
|
308
|
+
const workbookPart = mainPart(pkg);
|
|
309
|
+
let date1904 = false;
|
|
310
|
+
const entries = [];
|
|
311
|
+
let isWorkbook = false;
|
|
312
|
+
walkXml(pkg.text(workbookPart), {
|
|
313
|
+
open: (name, attributes) => {
|
|
314
|
+
switch (name) {
|
|
315
|
+
case 'workbook':
|
|
316
|
+
{
|
|
317
|
+
isWorkbook = true;
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
case 'workbookPr':
|
|
321
|
+
{
|
|
322
|
+
date1904 = isOn(attributes.date1904);
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
case 'sheet':
|
|
326
|
+
{
|
|
327
|
+
{
|
|
328
|
+
entries.push({
|
|
329
|
+
name: attributes.name ?? '',
|
|
330
|
+
hidden: attributes.state === 'hidden' || attributes.state === 'veryHidden',
|
|
331
|
+
id: namespacedAttribute(attributes, 'id')
|
|
332
|
+
});
|
|
333
|
+
// No default
|
|
334
|
+
}
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
if (!isWorkbook) throw new OfficeReadError('not-xlsx', `not a spreadsheet: the package's main part is ${workbookPart === '' ? 'missing' : workbookPart}${workbookPart.endsWith('presentation.xml') ? ' (a presentation: read it with readPptx)' : ''}`);
|
|
341
|
+
const relationships = relationshipsOf(pkg, workbookPart);
|
|
342
|
+
const stringsPart = relationshipOfType(relationships, 'sharedStrings')?.target;
|
|
343
|
+
const stylesPart = relationshipOfType(relationships, 'styles')?.target;
|
|
344
|
+
const context = {
|
|
345
|
+
sharedStrings: stringsPart === undefined ? [] : sharedStrings(pkg.text(stringsPart)),
|
|
346
|
+
formats: stylesPart === undefined ? [] : formatKinds(pkg.text(stylesPart)),
|
|
347
|
+
date1904
|
|
348
|
+
};
|
|
349
|
+
const selected = selector(options.sheets);
|
|
350
|
+
const sheets = [];
|
|
351
|
+
for (const entry of entries) {
|
|
352
|
+
const part = entry.id === undefined ? undefined : relationships.get(entry.id);
|
|
353
|
+
// Chart sheets, dialog sheets and macro sheets hold no cells.
|
|
354
|
+
if (part?.type !== 'worksheet' || !selected(entry)) continue;
|
|
355
|
+
const content = readWorksheet(pkg.text(part.target), context, options.values ?? 'typed');
|
|
356
|
+
sheets.push({
|
|
357
|
+
name: entry.name,
|
|
358
|
+
hidden: entry.hidden,
|
|
359
|
+
...content
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
date1904,
|
|
364
|
+
sheets
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
/** The package's main part, from its root relationships (`xl/workbook.xml` by convention, not by rule). */
|
|
368
|
+
function mainPart(pkg) {
|
|
369
|
+
const main = relationshipOfType(relationshipsOf(pkg, ''), 'officeDocument')?.target;
|
|
370
|
+
if (main !== undefined) return main;
|
|
371
|
+
return pkg.has('xl/workbook.xml') ? 'xl/workbook.xml' : '';
|
|
372
|
+
}
|
|
373
|
+
function selector(filter) {
|
|
374
|
+
if (filter === undefined) return () => true;
|
|
375
|
+
if (typeof filter === 'string') return sheet => sheet.name === filter;
|
|
376
|
+
if (filter instanceof RegExp) return sheet => filter.test(sheet.name);
|
|
377
|
+
return filter;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export { readXlsx as r };
|
|
381
|
+
//# sourceMappingURL=read-xlsx.use-case.esm.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { readXlsx } from './spreadsheet/index.js';
|
|
2
|
+
export type { ReadXlsxOptions, CellValue, Sheet, Workbook, ValueMode, SheetFilter } from './spreadsheet/index.js';
|
|
3
|
+
export { readPptx } from './presentation/index.js';
|
|
4
|
+
export type { ReadPptxOptions, Deck, Slide, SlideShape, SlideChart, ChartSeries, SlideFilter } from './presentation/index.js';
|
|
5
|
+
export { OfficeReadError } from './read-error/index.js';
|
|
6
|
+
export type { OfficeReadErrorCode } from './read-error/index.js';
|
|
7
|
+
export type { OfficeSource } from './source-bytes/index.js';
|
|
8
|
+
export type { PackageLimits } from './ooxml-package/index.js';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { OoxmlPackage } from './ooxml-package.client.js';
|
|
2
|
+
export type { PackageLimits } from './ooxml-package.client.js';
|
|
3
|
+
export { walkXml, namespacedAttribute, isOn } from './xml-walk.algorithm.js';
|
|
4
|
+
export type { XmlHandlers } from './xml-walk.algorithm.js';
|
|
5
|
+
export { relationshipsOf, relationshipOfType } from './relationships.mapper.js';
|
|
6
|
+
export type { Relationship } from './relationships.mapper.js';
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|