dsh-ensp 0.6.0 → 0.8.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.
@@ -477,7 +477,7 @@ function reopenTopology(topology) {
477
477
  /**
478
478
  * Take a screenshot of the client's canvas, cropped to the drawing area.
479
479
  *
480
- * Used by `topologyRendered` and by the probe diagnostics. The canvas excludes the
480
+ * Used by the probe diagnostics. The canvas excludes the
481
481
  * left device palette and the toolbar, so anything found inside it came from the
482
482
  * open topology.
483
483
  *
@@ -509,14 +509,57 @@ $bmp.Dispose(); $g.Dispose()
509
509
  }
510
510
 
511
511
  /**
512
- * Does the open topology show devices on the canvas?
512
+ * How many devices the open topology has on its canvas.
513
+ *
514
+ * The canvas itself is painted with DirectX, so it has no window tree, but eNSP
515
+ * gives every device on the canvas a hidden `Edit` child of the frame window. The
516
+ * count matches the device count exactly: the welcome page has none, one device has
517
+ * one, three devices have three.
513
518
  *
514
- * eNSP paints with DirectX, so there is no window tree to query. Two independent
515
- * signals are combined, because either alone is weak:
519
+ * This replaced a pixel heuristic that compared saturated-blue and near-black
520
+ * pixel counts against fixed thresholds. That heuristic was wrong in both
521
+ * directions. A single-device lab scores about 1980 icon pixels and 16 dark ones
522
+ * (there are no link strokes to count) so it was reported as "never rendered" and
523
+ * the launch retried until it gave up, while the welcome page scores 4010 and 2517
524
+ * and was accepted as a rendered lab.
516
525
  *
517
- * 1. **Icon colour.** Device icons are a saturated blue on a pale canvas; the
518
- * empty canvas has no such pixels.
519
- * 2. **Link lines.** Rendered links are near-black thin strokes across the canvas.
526
+ * @param {string} hwnd
527
+ * @returns {number} device count, or -1 when the window cannot be read
528
+ */
529
+ export function canvasDeviceCount(hwnd) {
530
+ const r = runPowerShell(
531
+ `
532
+ Add-Type -TypeDefinition @'
533
+ using System;
534
+ using System.Text;
535
+ using System.Runtime.InteropServices;
536
+ public class CanvasProbe {
537
+ public delegate bool EnumProc(IntPtr h, IntPtr p);
538
+ [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr parent, EnumProc f, IntPtr p);
539
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassNameW(IntPtr h, StringBuilder s, int n);
540
+ public static int CountEdits(IntPtr parent) {
541
+ int n = 0;
542
+ EnumChildWindows(parent, delegate (IntPtr h, IntPtr p) {
543
+ StringBuilder sb = new StringBuilder(256);
544
+ GetClassNameW(h, sb, 256);
545
+ if (sb.ToString() == "Edit") { n++; }
546
+ return true;
547
+ }, IntPtr.Zero);
548
+ return n;
549
+ }
550
+ }
551
+ '@
552
+ 'edits=' + [CanvasProbe]::CountEdits([IntPtr]::new([int64]${hwnd}))
553
+ `,
554
+ { timeoutMs: 20000 },
555
+ )
556
+ if (!r.ok) return -1
557
+ const m = /edits=(-?\d+)/.exec(r.stdout)
558
+ return m ? Number(m[1]) : -1
559
+ }
560
+
561
+ /**
562
+ * Does the open topology show devices on the canvas?
520
563
  *
521
564
  * This is the check that gates "the topology actually opened". Callers retry on a
522
565
  * negative result rather than reporting success for a blank window.
@@ -525,36 +568,7 @@ $bmp.Dispose(); $g.Dispose()
525
568
  * @returns {boolean}
526
569
  */
527
570
  export function topologyRendered(hwnd) {
528
- const png = captureCanvas(hwnd)
529
- if (!png) return false
530
- const r = runPowerShell(
531
- `
532
- Add-Type -AssemblyName System.Drawing
533
- $bmp = [System.Drawing.Bitmap]::FromFile('${png.replace(/\\/g, '\\\\')}')
534
- $w = $bmp.Width; $ht = $bmp.Height
535
- # Canvas only: skip the palette column and the toolbar band.
536
- $x0 = [int]($w * 0.22); $x1 = [int]($w * 0.97)
537
- $y0 = [int]($ht * 0.10); $y1 = [int]($ht * 0.92)
538
- $icon = 0; $dark = 0
539
- for ($y = $y0; $y -lt $y1; $y += 2) {
540
- for ($x = $x0; $x -lt $x1; $x += 2) {
541
- $c = $bmp.GetPixel($x, $y)
542
- if ($c.B -gt 140 -and ($c.B - $c.R) -gt 55 -and ($c.B - $c.G) -gt 25) { $icon++ }
543
- elseif ($c.R -lt 90 -and $c.G -lt 90 -and $c.B -lt 90) { $dark++ }
544
- }
545
- }
546
- $bmp.Dispose()
547
- 'icon=' + $icon + ' dark=' + $dark
548
- `,
549
- { timeoutMs: 40000 },
550
- )
551
- if (!r.ok) return false
552
- const m = /icon=(\d+) dark=(\d+)/.exec(r.stdout)
553
- if (!m) return false
554
- const icon = Number(m[1])
555
- const dark = Number(m[2])
556
- // A rendered lab shows dozens of icon pixels and a few hundred link pixels.
557
- return icon >= 15 && dark >= 40
571
+ return canvasDeviceCount(hwnd) >= 1
558
572
  }
559
573
 
560
574
  /**
@@ -592,6 +606,51 @@ Start-Sleep -Milliseconds 250
592
606
  return r.ok && r.stdout.split(/\s+/).pop() === 'True'
593
607
  }
594
608
 
609
+ /**
610
+ * Bring eNSP back to the front, out of the taskbar, on demand.
611
+ *
612
+ * The tool minimises eNSP while devices boot so the desktop stays usable, which
613
+ * leaves the user with a taskbar button and no obvious way back to a window they
614
+ * want to look at. A taskbar click depends on the shell's own activation logic;
615
+ * this does the two steps that are actually required and reports both, so a
616
+ * failure says which half did not happen.
617
+ *
618
+ * Restoring and raising are separate problems on this frame: `ShowWindow(h, 9)`
619
+ * returns false and leaves the window at its icon size, while
620
+ * `WM_SYSCOMMAND/SC_RESTORE` does the real work (measured from a medium integrity
621
+ * process, the same tier as Explorer, so it works for a taskbar click too).
622
+ *
623
+ * @param {string} hwnd
624
+ * @returns {{found: boolean, restored: boolean, raised: boolean, foreground: string|null}}
625
+ */
626
+ export function showEnsp(hwnd) {
627
+ if (!hwnd || !windowAlive(hwnd)) {
628
+ return { found: false, restored: false, raised: false, foreground: null }
629
+ }
630
+ const r = runPowerShell(
631
+ `
632
+ $h = [IntPtr]::new([int64]${hwnd})
633
+ $wasIconic = [EnspWin]::IsIconic($h)
634
+ [EnspWin]::RestoreWindow($h)
635
+ Start-Sleep -Milliseconds 250
636
+ $restored = -not [EnspWin]::IsIconic($h)
637
+ $raised = [EnspWin]::FocusWindow($h)
638
+ Start-Sleep -Milliseconds 250
639
+ $fg = [EnspWin]::GetForegroundWindow()
640
+ "FOUND=True WASICONIC=$wasIconic RESTORED=$restored RAISED=$raised FOREGROUND=" + ([int64]$fg).ToString() + " INFRONT=" + ($fg -eq $h)
641
+ `,
642
+ { timeoutMs: 20000 },
643
+ )
644
+ const text = r.stdout || ''
645
+ const m = /FOREGROUND=(-?\d+)/.exec(text)
646
+ return {
647
+ found: /FOUND=True/i.test(text),
648
+ restored: /RESTORED=True/i.test(text),
649
+ raised: /INFRONT=True/i.test(text),
650
+ foreground: m ? m[1] : null,
651
+ }
652
+ }
653
+
595
654
  /**
596
655
  * The window handle that currently holds the foreground, or null.
597
656
  *
@@ -184,30 +184,39 @@ function cliPrefixForGroup(sztype, enspPrefix, model, groupIndex) {
184
184
  */
185
185
  export function portsFromDeclarations(declarations, model) {
186
186
  if (!declarations?.length) return []
187
- const base = MODEL_SPECS[model]?.base ?? 0
187
+ const spec = MODEL_SPECS[model]
188
+ const base = spec?.base ?? 0
189
+
190
+ // The file records how many ports a group has, not the number they start from.
191
+ // An AR2220 writes `GE count="1"` and then `GE count="2"`, and reading both from
192
+ // `base` produced `GigabitEthernet0/0/0` twice: flat index 1 came back as port 0,
193
+ // so every link into the second group were labelled with the wrong interface.
194
+ // The model table knows the real numbering, so it supplies the first number for
195
+ // the nth group of a given interface family.
196
+ const specbyFamily = new Map()
197
+ for (const group of spec?.groups ?? []) {
198
+ const key = `${group[0]}:${group[1]}`
199
+ if (!specbyFamily.has(key)) specbyFamily.set(key, [])
200
+ specbyFamily.get(key).push(group)
201
+ }
202
+ const ordinals = new Map()
203
+
188
204
  const ports = []
189
- declarations.forEach((d, groupIndex) => {
190
- const first = d.firstNumber ?? base
191
- const cli = cliPrefixForGroup(d.sztype, d.name, model, groupIndex)
205
+ declarations.forEach((d) => {
206
+ const key = `${d.sztype}:${d.name}`
207
+ const ordinal = ordinals.get(key) ?? 0
208
+ ordinals.set(key, ordinal + 1)
209
+ const fromSpec = specbyFamily.get(key)?.[ordinal]
210
+ const first = d.firstNumber ?? fromSpec?.[3] ?? base
211
+ // `cliPrefixForGroup` reads the override of the group at the index it is given,
212
+ // so it is only asked when the spec has no matching group; asking it with the
213
+ // per-family ordinal would hand back another group's override.
214
+ const cli = fromSpec ? (fromSpec[4] ?? cliPrefixForGroup(d.sztype, d.name, model, -1)) : cliPrefixForGroup(d.sztype, d.name, model, ordinal)
192
215
  for (let i = 0; i < d.count; i += 1) ports.push(`${cli}${first + i}`)
193
216
  })
194
217
  return ports
195
218
  }
196
219
 
197
- /**
198
- * Resident interface declarations of a model, as `{sztype, name, count, firstNumber}`.
199
- *
200
- * Callers use this when they need to emit the `<interface>` block for a device.
201
- */
202
- export function declarationsFor(model) {
203
- return (MODEL_SPECS[model]?.groups ?? []).map(([sztype, name, count, firstNumber]) => ({
204
- sztype,
205
- name,
206
- count,
207
- firstNumber: firstNumber ?? null,
208
- }))
209
- }
210
-
211
220
  /** Total declared interface count for a model. */
212
221
  export function portCount(model) {
213
222
  const spec = MODEL_SPECS[model]
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Extracts plain text from OOXML packages (.docx, .pptx, .xlsx).
3
+ *
4
+ * A modern Office file is a zip whose parts are XML. This module reads the zip
5
+ * itself (stored and deflate entries are enough for the formats it targets) and
6
+ * scans the XML with a small tokenizer that keeps only real text nodes, so
7
+ * nothing has to be installed and no external converter is spawned. It is read
8
+ * only: the counterpart writer lives in `core/docx.mjs`.
9
+ *
10
+ * Extraction is best effort. A part that does not parse is skipped and a
11
+ * package that is not a zip at all yields an empty string, because these
12
+ * functions are usually called on user supplied reference material.
13
+ *
14
+ * @module core/ooxml
15
+ */
16
+
17
+ import { inflateRawSync } from 'node:zlib'
18
+
19
+ const SIG_EOCD = 0x06054b50
20
+ const SIG_CENTRAL = 0x02014b50
21
+ const SIG_LOCAL = 0x04034b50
22
+
23
+ /** Size of the fixed parts of each record, without name, extra and comment. */
24
+ const EOCD_SIZE = 22
25
+ const CENTRAL_SIZE = 46
26
+ const LOCAL_SIZE = 30
27
+
28
+ /** The comment length field is 16 bit, so the record can sit at most this far back. */
29
+ const MAX_COMMENT = 0xffff
30
+
31
+ /**
32
+ * True when the buffer starts with the local file header signature.
33
+ *
34
+ * @param {Buffer|Uint8Array|null|undefined} buffer
35
+ * @returns {boolean}
36
+ */
37
+ export function isZip(buffer) {
38
+ if (!buffer || typeof buffer.length !== 'number' || buffer.length < 4) return false
39
+ return buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x03 && buffer[3] === 0x04
40
+ }
41
+
42
+ /** Return a Buffer view over any binary input, or null when there is nothing. */
43
+ function asBuffer(buffer) {
44
+ if (Buffer.isBuffer(buffer)) return buffer
45
+ if (buffer instanceof Uint8Array) return Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength)
46
+ return null
47
+ }
48
+
49
+ /**
50
+ * Locate the End of Central Directory record. The archive comment may follow it,
51
+ * so the search walks backwards from the end instead of assuming a fixed tail.
52
+ */
53
+ function findEocd(buffer) {
54
+ const lowest = Math.max(0, buffer.length - EOCD_SIZE - MAX_COMMENT)
55
+ for (let offset = buffer.length - EOCD_SIZE; offset >= lowest; offset -= 1) {
56
+ if (buffer.readUInt32LE(offset) === SIG_EOCD) return offset
57
+ }
58
+ return -1
59
+ }
60
+
61
+ /**
62
+ * Read a zip archive into a map of internal path to raw contents.
63
+ *
64
+ * Directory entries are dropped, and entries using a compression method this
65
+ * module does not know are skipped rather than failing the whole archive. The
66
+ * optional data descriptor is ignored: central directory sizes are authoritative.
67
+ *
68
+ * @param {Buffer|Uint8Array} buffer
69
+ * @returns {Map<string, Buffer>} keys look like `word/document.xml`
70
+ * @throws {Error} when the buffer is not a readable zip archive
71
+ */
72
+ export function readZip(buffer) {
73
+ const data = asBuffer(buffer)
74
+ if (!data || data.length < EOCD_SIZE) throw new Error('not a zip archive')
75
+
76
+ const eocd = findEocd(data)
77
+ if (eocd < 0) throw new Error('not a zip archive: end of central directory is missing')
78
+
79
+ const count = data.readUInt16LE(eocd + 10)
80
+ const directoryOffset = data.readUInt32LE(eocd + 16)
81
+ if (directoryOffset >= data.length) throw new Error('not a zip archive: central directory is out of range')
82
+
83
+ const files = new Map()
84
+ let cursor = directoryOffset
85
+
86
+ for (let index = 0; index < count; index += 1) {
87
+ if (cursor + CENTRAL_SIZE > data.length) throw new Error('not a zip archive: truncated central directory')
88
+ if (data.readUInt32LE(cursor) !== SIG_CENTRAL) throw new Error('not a zip archive: bad central directory entry')
89
+
90
+ const method = data.readUInt16LE(cursor + 10)
91
+ const compressedSize = data.readUInt32LE(cursor + 20)
92
+ const nameLength = data.readUInt16LE(cursor + 28)
93
+ const extraLength = data.readUInt16LE(cursor + 30)
94
+ const commentLength = data.readUInt16LE(cursor + 32)
95
+ const localOffset = data.readUInt32LE(cursor + 42)
96
+ const nameEnd = cursor + CENTRAL_SIZE + nameLength
97
+ if (nameEnd > data.length) throw new Error('not a zip archive: truncated file name')
98
+
99
+ const name = data.toString('utf8', cursor + CENTRAL_SIZE, nameEnd)
100
+ cursor = nameEnd + extraLength + commentLength
101
+
102
+ if (name.endsWith('/')) continue
103
+ if (localOffset + LOCAL_SIZE > data.length) continue
104
+ if (data.readUInt32LE(localOffset) !== SIG_LOCAL) continue
105
+
106
+ // The local header repeats its own name and extra lengths, and those are the
107
+ // ones that place the payload. They can differ from the central copy.
108
+ const localNameLength = data.readUInt16LE(localOffset + 26)
109
+ const localExtraLength = data.readUInt16LE(localOffset + 28)
110
+ const start = localOffset + LOCAL_SIZE + localNameLength + localExtraLength
111
+ const end = start + compressedSize
112
+ if (start > data.length || end > data.length) continue
113
+
114
+ const payload = data.subarray(start, end)
115
+ if (method === 0) {
116
+ files.set(name, Buffer.from(payload))
117
+ } else if (method === 8) {
118
+ try {
119
+ files.set(name, inflateRawSync(payload))
120
+ } catch {
121
+ // A part that cannot be inflated is dropped; the rest of the package is
122
+ // still worth reading.
123
+ }
124
+ }
125
+ }
126
+
127
+ return files
128
+ }
129
+
130
+ /** Decode the five XML entities plus decimal and hexadecimal character references. */
131
+ export function decodeEntities(text) {
132
+ return String(text ?? '').replace(/&(#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z]+);/g, (match, body) => {
133
+ if (body[0] === '#') {
134
+ const hex = body[1] === 'x' || body[1] === 'X'
135
+ const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10)
136
+ if (!Number.isFinite(code) || code <= 0 || code > 0x10ffff) return ''
137
+ // Lone surrogates are not valid code points and would throw.
138
+ if (code >= 0xd800 && code <= 0xdfff) return ''
139
+ return String.fromCodePoint(code)
140
+ }
141
+ switch (body) {
142
+ case 'amp':
143
+ return '&'
144
+ case 'lt':
145
+ return '<'
146
+ case 'gt':
147
+ return '>'
148
+ case 'quot':
149
+ return '"'
150
+ case 'apos':
151
+ return "'"
152
+ default:
153
+ return match
154
+ }
155
+ })
156
+ }
157
+
158
+ /** One comment, one CDATA section, one tag, or one run of character data. */
159
+ const TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[([\s\S]*?)\]\]>|<[^>]*>|([^<]+)/g
160
+
161
+ /**
162
+ * Elements whose character data is never shown. Field instructions carry the
163
+ * table of contents plumbing (` PAGEREF ...`) and deleted text is tracked
164
+ * revision history, so both would otherwise land in the middle of the prose.
165
+ */
166
+ const HIDDEN_ELEMENTS = new Set(['instrtext', 'deltext'])
167
+
168
+ /** A paragraph is a `p` element, or the ODF style `text:para` name. */
169
+ function isParagraph(element) {
170
+ return element.local === 'p' || element.local === 'para'
171
+ }
172
+
173
+ /** An element is a text container when it is a `...:t` run or an ODF `text:` node. */
174
+ function holdsText(element) {
175
+ if (!element || element.hidden) return false
176
+ return element.local === 't' || element.prefix === 'text'
177
+ }
178
+
179
+ /**
180
+ * Turn an XML part into plain text.
181
+ *
182
+ * Only character data inside a text element survives: `w:t`, `a:t`, `m:t` and the
183
+ * ODF `text:` nodes. Everything else, including the numbers in positioning
184
+ * elements and the keywords in alignment elements, is markup and is dropped.
185
+ * Paragraph ends and breaks become newlines, tabs become tab characters, entities
186
+ * are decoded, trailing spaces are dropped and runs of empty lines collapse to a
187
+ * single blank line.
188
+ *
189
+ * @param {string} xml
190
+ * @returns {string}
191
+ */
192
+ export function xmlText(xml) {
193
+ const source = String(xml ?? '')
194
+ if (!source) return ''
195
+
196
+ const parts = []
197
+ const stack = []
198
+ let hidden = 0
199
+
200
+ for (const token of source.matchAll(TOKEN)) {
201
+ const cdata = token[1]
202
+ const data = token[2]
203
+
204
+ // Character data: keep it only where a reader would see it.
205
+ if (data !== undefined || cdata !== undefined) {
206
+ if (hidden || !holdsText(stack[stack.length - 1])) continue
207
+ parts.push(data !== undefined ? data : cdata)
208
+ continue
209
+ }
210
+
211
+ const raw = token[0]
212
+ if (raw.startsWith('<!--') || raw.startsWith('<!') || raw.startsWith('<?')) continue
213
+ if (!raw.startsWith('<')) continue
214
+
215
+ const nameMatch = /^<\/?\s*([^\s/>]+)/.exec(raw)
216
+ if (!nameMatch) continue
217
+
218
+ const name = nameMatch[1].toLowerCase()
219
+ const colon = name.indexOf(':')
220
+ const local = colon === -1 ? name : name.slice(colon + 1)
221
+ const prefix = colon === -1 ? '' : name.slice(0, colon)
222
+ const closing = raw.startsWith('</')
223
+ const selfClosing = raw.endsWith('/>')
224
+
225
+ if (closing) {
226
+ const element = stack.pop()
227
+ if (!element) continue
228
+ if (element.hidden) hidden -= 1
229
+ if (!hidden && isParagraph(element)) parts.push('\n')
230
+ continue
231
+ }
232
+
233
+ if (selfClosing) {
234
+ if (hidden) continue
235
+ if (local === 'br' || local === 'cr' || local === 'line-break') parts.push('\n')
236
+ else if (local === 'tab') parts.push('\t')
237
+ else if (local === 'p' || local === 'para') parts.push('\n')
238
+ continue
239
+ }
240
+
241
+ const element = { local, prefix, hidden: HIDDEN_ELEMENTS.has(local) }
242
+ stack.push(element)
243
+ if (element.hidden) hidden += 1
244
+ }
245
+
246
+ return decodeEntities(parts.join(''))
247
+ .replace(/\r\n?/g, '\n')
248
+ .replace(/[ \t\u00a0]+(?=\n)/g, '')
249
+ .replace(/\n{3,}/g, '\n\n')
250
+ .trim()
251
+ }
252
+
253
+ /** Read one part as text, returning an empty string when it is missing or unreadable. */
254
+ function partText(files, name) {
255
+ const entry = files.get(name)
256
+ if (!entry) return ''
257
+ try {
258
+ return xmlText(entry.toString('utf8'))
259
+ } catch {
260
+ return ''
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Extract the body text of a .docx.
266
+ *
267
+ * Only `word/document.xml` is read. Headers, footers, footnotes and comments are
268
+ * deliberately left out: they repeat on every page and add noise to a document
269
+ * that is being mined for its content.
270
+ *
271
+ * @param {Buffer|Uint8Array} buffer
272
+ * @returns {string}
273
+ */
274
+ export function docxText(buffer) {
275
+ let files
276
+ try {
277
+ files = readZip(buffer)
278
+ } catch {
279
+ return ''
280
+ }
281
+ return partText(files, 'word/document.xml')
282
+ }
283
+
284
+ /** Match a slide part and capture its number, so ordering is numeric. */
285
+ const SLIDE_PART = /^ppt\/slides\/slide(\d+)\.xml$/i
286
+
287
+ /**
288
+ * Extract the text of a .pptx, one slide after another in slide order.
289
+ *
290
+ * @param {Buffer|Uint8Array} buffer
291
+ * @returns {string}
292
+ */
293
+ export function pptxText(buffer) {
294
+ let files
295
+ try {
296
+ files = readZip(buffer)
297
+ } catch {
298
+ return ''
299
+ }
300
+
301
+ const slides = []
302
+ for (const name of files.keys()) {
303
+ const match = SLIDE_PART.exec(name)
304
+ if (match) slides.push({ name, number: Number(match[1]) })
305
+ }
306
+ slides.sort((left, right) => left.number - right.number)
307
+
308
+ return slides
309
+ .map((slide) => partText(files, slide.name))
310
+ .filter(Boolean)
311
+ .join('\n')
312
+ }
313
+
314
+ /** Match a worksheet part and capture its number. */
315
+ const SHEET_PART = /^xl\/worksheets\/sheet(\d+)\.xml$/i
316
+ const STRING_ITEM = /<si\b[^>]*>([\s\S]*?)<\/si>/gi
317
+ const CELL = /<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/gi
318
+ const CELL_VALUE = /<v\b[^>]*>([\s\S]*?)<\/v>/i
319
+ const CELL_INLINE = /<is\b[^>]*>([\s\S]*?)<\/is>/i
320
+ const CELL_TYPE = /\bt\s*=\s*"([^"]*)"/i
321
+ const ROW = /<row\b[^>]*?(?:\/>|>([\s\S]*?)<\/row>)/gi
322
+
323
+ /** Build the shared string table, which every `t="s"` cell indexes into. */
324
+ function sharedStrings(files) {
325
+ const entry = files.get('xl/sharedStrings.xml')
326
+ if (!entry) return []
327
+ const xml = entry.toString('utf8')
328
+ const values = []
329
+ for (const match of xml.matchAll(STRING_ITEM)) {
330
+ let text = ''
331
+ try {
332
+ text = xmlText(match[1]).replace(/\s*\n\s*/g, ' ')
333
+ } catch {
334
+ text = ''
335
+ }
336
+ values.push(text)
337
+ }
338
+ return values
339
+ }
340
+
341
+ /** Turn one `<c>` element into its display text, resolving shared strings. */
342
+ function cellText(attributes, body, strings) {
343
+ const type = CELL_TYPE.exec(attributes || '')
344
+ const kind = type ? type[1].toLowerCase() : ''
345
+ const inner = body || ''
346
+
347
+ if (kind === 's') {
348
+ const value = CELL_VALUE.exec(inner)
349
+ const index = value ? Number.parseInt(decodeEntities(value[1]).trim(), 10) : NaN
350
+ return Number.isInteger(index) && index >= 0 && index < strings.length ? strings[index] : ''
351
+ }
352
+
353
+ const inline = CELL_INLINE.exec(inner)
354
+ if (inline) return xmlText(inline[1]).replace(/\s*\n\s*/g, ' ')
355
+
356
+ const value = CELL_VALUE.exec(inner)
357
+ if (!value) return ''
358
+ return decodeEntities(value[1]).replace(/\s+/g, ' ').trim()
359
+ }
360
+
361
+ /**
362
+ * Extract the text of a .xlsx, one worksheet after another.
363
+ *
364
+ * Each row becomes one line with cells joined by ` | `. Empty rows are skipped.
365
+ * Shared strings are resolved through their index; every other cell contributes
366
+ * the raw `<v>` value, which is what a spreadsheet shows for numbers and dates.
367
+ *
368
+ * @param {Buffer|Uint8Array} buffer
369
+ * @returns {string}
370
+ */
371
+ export function xlsxText(buffer) {
372
+ let files
373
+ try {
374
+ files = readZip(buffer)
375
+ } catch {
376
+ return ''
377
+ }
378
+
379
+ const strings = sharedStrings(files)
380
+ const sheets = []
381
+ for (const name of files.keys()) {
382
+ const match = SHEET_PART.exec(name)
383
+ if (match) sheets.push({ name, number: Number(match[1]) })
384
+ }
385
+ sheets.sort((left, right) => left.number - right.number)
386
+
387
+ const lines = []
388
+ for (const sheet of sheets) {
389
+ const entry = files.get(sheet.name)
390
+ if (!entry) continue
391
+ const xml = entry.toString('utf8')
392
+ for (const row of xml.matchAll(ROW)) {
393
+ const body = row[1] || ''
394
+ const cells = []
395
+ for (const cell of body.matchAll(CELL)) {
396
+ cells.push(cellText(cell[1], cell[2], strings))
397
+ }
398
+ if (cells.some((value) => value !== '')) lines.push(cells.join(' | '))
399
+ }
400
+ }
401
+
402
+ return lines.join('\n')
403
+ }
404
+
405
+ /**
406
+ * Extract plain text from a Word, PowerPoint or Excel file, picked by extension.
407
+ *
408
+ * @param {string} fileName name or path, only the extension is inspected
409
+ * @param {Buffer|Uint8Array} buffer
410
+ * @returns {string|null} null for a format this module does not handle
411
+ */
412
+ export function ooxmlText(fileName, buffer) {
413
+ const name = String(fileName ?? '').toLowerCase()
414
+ const dot = name.lastIndexOf('.')
415
+ const extension = dot === -1 ? '' : name.slice(dot + 1)
416
+
417
+ switch (extension) {
418
+ case 'docx':
419
+ case 'docm':
420
+ return docxText(buffer)
421
+ case 'pptx':
422
+ case 'pptm':
423
+ return pptxText(buffer)
424
+ case 'xlsx':
425
+ case 'xlsm':
426
+ return xlsxText(buffer)
427
+ default:
428
+ return null
429
+ }
430
+ }