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.
- package/README.en.md +90 -16
- package/README.md +89 -15
- package/assets/icon.webp +0 -0
- package/docs/ENSP-FORMAT.md +8 -8
- package/docs/SCENARIOS.md +332 -286
- package/docs/UI-ANCHORS.md +21 -0
- package/docs/plugin-detail.en.md +2 -0
- package/docs/plugin-detail.zh.md +2 -0
- package/package.json +4 -4
- package/scripts/gen-scenarios-doc.mjs +7 -3
- package/scripts/survey-topologies.mjs +15 -6
- package/src/cli/ensp.mjs +252 -28
- package/src/core/acceptance.mjs +258 -53
- package/src/core/build.mjs +15 -1
- package/src/core/compat.mjs +249 -17
- package/src/core/console.mjs +4 -0
- package/src/core/doctor.mjs +9 -12
- package/src/core/docx.mjs +359 -0
- package/src/core/index.mjs +28 -9
- package/src/core/labconfig.mjs +98 -0
- package/src/core/labs.mjs +12 -12
- package/src/core/launcher.mjs +96 -37
- package/src/core/models.mjs +27 -18
- package/src/core/ooxml.mjs +430 -0
- package/src/core/paths.mjs +59 -10
- package/src/core/presets.mjs +9 -5
- package/src/core/report.mjs +411 -0
- package/src/dsh/index.mjs +626 -652
- package/src/mcp/server.mjs +213 -356
- package/src/tools.mjs +271 -0
- package/screenshots.json +0 -3
- package/scripts/gen-knowledge.mjs +0 -196
- package/src/core/knowledge.json +0 -959
- package/src/core/knowledge.mjs +0 -103
package/src/core/launcher.mjs
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
515
|
-
*
|
|
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
|
-
*
|
|
518
|
-
*
|
|
519
|
-
|
|
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
|
-
|
|
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
|
*
|
package/src/core/models.mjs
CHANGED
|
@@ -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
|
|
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
|
|
190
|
-
const
|
|
191
|
-
const
|
|
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
|
+
}
|