@paircode/tool-office 1.0.1 → 1.0.3
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/bin/tool-office.exe +0 -0
- package/index.js +563 -6
- package/package.json +1 -1
package/bin/tool-office.exe
CHANGED
|
Binary file
|
package/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// ═══════════════════════════════════════════════════════════════
|
|
2
2
|
// tool-office — 办公文档(csv_read/csv_write/json_to_table/table_stats/text_report/word_read)
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// ★ 2026-08-22 JS 原生化(3/11):csv_read/csv_write/json_to_table 实现
|
|
5
|
+
// 全在插件内(ctx.fs + JS 解析/渲染);其余(table_stats/text_report/
|
|
6
|
+
// word_read/word_write/read_xlsx/write_xlsx/read_pdf/markdown_to_html)
|
|
7
|
+
// 保留依赖 bin/tool-office.exe(ctx.binary.exec 桥接)。
|
|
6
8
|
// 工具清单:csv_read、csv_write、json_to_table、table_stats、text_report、word_read、word_write、read_xlsx、write_xlsx、read_pdf、markdown_to_html
|
|
7
9
|
// ═══════════════════════════════════════════════════════════════
|
|
8
10
|
const tools = [
|
|
@@ -262,8 +264,8 @@ const tools = [
|
|
|
262
264
|
},
|
|
263
265
|
{
|
|
264
266
|
"name": "read_pdf",
|
|
265
|
-
"description": "提取 PDF
|
|
266
|
-
"usageGuide": "提取 PDF
|
|
267
|
+
"description": "提取 PDF 文件的文本内容(解析 PDF 流对象,支持纯文本 PDF)。扫描/图片型 PDF(无嵌入文本)无法提取,返回说明。page 指定页码(从 1 开始,默认全部);limit 限制返回字符数(默认 10000)。",
|
|
268
|
+
"usageGuide": "提取 PDF 文件文本内容(解析 PDF 流对象,纯文本 PDF)。page 指定页码;limit 限制返回字符数。扫描/图片型 PDF 无嵌入文本时返回提示。",
|
|
267
269
|
"parameters": {
|
|
268
270
|
"properties": {
|
|
269
271
|
"limit": {
|
|
@@ -314,9 +316,564 @@ const tools = [
|
|
|
314
316
|
}
|
|
315
317
|
];
|
|
316
318
|
|
|
319
|
+
// ─── JS 原生化实现(csv_read/csv_write/json_to_table) ─────────
|
|
320
|
+
|
|
321
|
+
// CSV 解析(对齐 Go encoding/csv LazyQuotes 语义:字段开头引号才进入引号模式)。
|
|
322
|
+
function readCSV(text, delim) {
|
|
323
|
+
const rows = []
|
|
324
|
+
let row = [], field = '', inQuotes = false, fieldStart = true
|
|
325
|
+
for (let i = 0; i < text.length; i++) {
|
|
326
|
+
const c = text[i]
|
|
327
|
+
if (inQuotes) {
|
|
328
|
+
if (c === '"') {
|
|
329
|
+
if (text[i + 1] === '"') { field += '"'; i++ }
|
|
330
|
+
else inQuotes = false
|
|
331
|
+
} else field += c
|
|
332
|
+
} else if (fieldStart && c === '"') {
|
|
333
|
+
inQuotes = true
|
|
334
|
+
} else if (c === delim) {
|
|
335
|
+
row.push(field); field = ''; fieldStart = true
|
|
336
|
+
} else if (c === '\n') {
|
|
337
|
+
row.push(field); rows.push(row); row = []; field = ''; fieldStart = true
|
|
338
|
+
} else if (c === '\r') {
|
|
339
|
+
// 忽略(\r\n 随 \n 处理;单独 \r 作普通字符保留)
|
|
340
|
+
} else {
|
|
341
|
+
field += c; fieldStart = false
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (field !== '' || row.length > 0) { row.push(field); rows.push(row) }
|
|
345
|
+
return rows
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 生成 CSV(转义:含引号/逗号/换行的字段加引号包裹)。
|
|
349
|
+
function writeCSV(records, delim) {
|
|
350
|
+
return records.map(row =>
|
|
351
|
+
row.map(cell => {
|
|
352
|
+
const s = String(cell == null ? '' : cell)
|
|
353
|
+
return /["\n\r]/.test(s) || s.includes(delim) ? '"' + s.replace(/"/g, '""') + '"' : s
|
|
354
|
+
}).join(delim)
|
|
355
|
+
).join('\n') + '\n'
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function csvDelim(d) {
|
|
359
|
+
const s = String(d || '').trim().toLowerCase()
|
|
360
|
+
return (s === 'tab' || s === '\t' || s === '制表符') ? '\t' : ','
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function parseColIndex(spec, maxCols) {
|
|
364
|
+
const s = String(spec || '').trim()
|
|
365
|
+
if (!s) return null
|
|
366
|
+
const idx = []
|
|
367
|
+
for (const part of s.split(',')) {
|
|
368
|
+
const p = part.trim()
|
|
369
|
+
const i = Number(p)
|
|
370
|
+
if (Number.isInteger(i) && i >= 0 && i < maxCols) idx.push(i)
|
|
371
|
+
}
|
|
372
|
+
return idx
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function padRight(s, n) {
|
|
376
|
+
s = String(s || '')
|
|
377
|
+
return s.length >= n ? s : s + ' '.repeat(n - s.length)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// writeMarkdownTable 渲染二维数组为 Markdown 表格(colIdx 为 null 时全列)。
|
|
381
|
+
function writeMarkdownTable(records, colIdx) {
|
|
382
|
+
if (!records || records.length === 0) return ''
|
|
383
|
+
const selectCols = (row) => {
|
|
384
|
+
if (colIdx == null) return row
|
|
385
|
+
return colIdx.map(idx => (idx < row.length ? row[idx] : ''))
|
|
386
|
+
}
|
|
387
|
+
const header = selectCols(records[0])
|
|
388
|
+
const widths = header.map(h => String(h || '').length)
|
|
389
|
+
for (const row of records.slice(1)) {
|
|
390
|
+
const cols = selectCols(row)
|
|
391
|
+
cols.forEach((c, i) => {
|
|
392
|
+
if (i < widths.length && String(c || '').length > widths[i]) widths[i] = String(c || '').length
|
|
393
|
+
})
|
|
394
|
+
while (widths.length < cols.length) widths.push(0)
|
|
395
|
+
}
|
|
396
|
+
while (header.length < widths.length) header.push('')
|
|
397
|
+
let out = '| '
|
|
398
|
+
header.forEach((h, i) => { out += padRight(h, widths[i]) + ' | ' })
|
|
399
|
+
out += '\n| '
|
|
400
|
+
widths.forEach(w => { out += '-'.repeat(Math.max(w, 3)) + ' | ' })
|
|
401
|
+
out += '\n'
|
|
402
|
+
for (const row of records.slice(1)) {
|
|
403
|
+
const cols = selectCols(row)
|
|
404
|
+
out += '| '
|
|
405
|
+
cols.forEach((c, i) => { out += (i < widths.length ? padRight(c, widths[i]) : String(c || '')) + ' | ' })
|
|
406
|
+
out += '\n'
|
|
407
|
+
}
|
|
408
|
+
return out
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// parseMarkdownTable 从 Markdown 文本提取表格数据(简单实现,同 Go 版)。
|
|
412
|
+
function parseMarkdownTable(text) {
|
|
413
|
+
const records = []
|
|
414
|
+
let inTable = false
|
|
415
|
+
for (const ln of String(text).split('\n')) {
|
|
416
|
+
const line = ln.trim()
|
|
417
|
+
if (!line || !line.startsWith('|')) {
|
|
418
|
+
if (inTable) break
|
|
419
|
+
continue
|
|
420
|
+
}
|
|
421
|
+
const stripped = line.replace(/-/g, '').replace(/\|/g, '').replace(/ /g, '')
|
|
422
|
+
if (stripped === '') continue
|
|
423
|
+
inTable = true
|
|
424
|
+
let row = line.split('|').map(p => p.trim())
|
|
425
|
+
if (row.length > 0 && row[0] === '') row = row.slice(1)
|
|
426
|
+
if (row.length > 0 && row[row.length - 1] === '') row = row.slice(0, -1)
|
|
427
|
+
if (row.length > 0) records.push(row)
|
|
428
|
+
}
|
|
429
|
+
return records
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// csv_read:读 CSV/TSV → Markdown 表格
|
|
433
|
+
function csvRead(ctx, args) {
|
|
434
|
+
const path = String(args.path || '').trim()
|
|
435
|
+
if (!path) throw new Error('path 不能为空')
|
|
436
|
+
const data = ctx.fs.readFile(path)
|
|
437
|
+
if (data.length > 100 << 20) throw new Error('文件超过 100MB,请缩小范围后用 search_content 搜索特定内容')
|
|
438
|
+
const delim = csvDelim(args.delimiter)
|
|
439
|
+
let limit = Number(args.limit || 100)
|
|
440
|
+
let offset = Number(args.offset || 0)
|
|
441
|
+
const colSpec = String(args.columns || '').trim()
|
|
442
|
+
const records = readCSV(data, delim)
|
|
443
|
+
if (records.length === 0) return '(空文件)'
|
|
444
|
+
const colIdx = parseColIndex(colSpec, records[0].length)
|
|
445
|
+
if (colSpec && (!colIdx || colIdx.length === 0)) throw new Error('无效的 columns 参数: "' + colSpec + '",应为逗号分隔的列索引(从 0 开始)')
|
|
446
|
+
const total = records.length
|
|
447
|
+
if (offset > 0 && offset < records.length) records.splice(0, offset)
|
|
448
|
+
else if (offset >= records.length) return '(offset 超出文件行数)'
|
|
449
|
+
if (limit > 0 && limit < records.length) records.length = limit
|
|
450
|
+
let out = '**CSV 文件**: `' + path + '` · 共 ' + total + ' 行 × ' + records[0].length + ' 列'
|
|
451
|
+
if (limit > 0 && total > limit) out += ' · 显示前 ' + limit + ' 行'
|
|
452
|
+
if (offset > 0) out += ' · 跳过 ' + offset + ' 行'
|
|
453
|
+
if (colSpec) out += ' · 显示列 [' + colSpec + ']'
|
|
454
|
+
out += '\n\n' + writeMarkdownTable(records, colIdx)
|
|
455
|
+
return out
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// csv_write:表格数据 → CSV/TSV 文件
|
|
459
|
+
function csvWrite(ctx, args) {
|
|
460
|
+
const path = String(args.path || '').trim()
|
|
461
|
+
if (!path) throw new Error('path 不能为空')
|
|
462
|
+
const dataStr = String(args.data || '')
|
|
463
|
+
const delim = csvDelim(args.delimiter)
|
|
464
|
+
let records = null
|
|
465
|
+
try { records = JSON.parse(dataStr) } catch { records = parseMarkdownTable(dataStr) }
|
|
466
|
+
if (!Array.isArray(records) || records.length === 0 || !Array.isArray(records[0])) throw new Error('data 格式无效:无法解析为 JSON 二维数组或 Markdown 表格')
|
|
467
|
+
const headerJSON = String(args.header || '').trim()
|
|
468
|
+
if (headerJSON) {
|
|
469
|
+
let header
|
|
470
|
+
try { header = JSON.parse(headerJSON) } catch { throw new Error('header JSON 解析失败') }
|
|
471
|
+
if (!Array.isArray(header)) throw new Error('header 必须是 JSON 数组')
|
|
472
|
+
records = [header].concat(records)
|
|
473
|
+
}
|
|
474
|
+
const csvText = writeCSV(records, delim)
|
|
475
|
+
ctx.fs.writeFile(path, csvText)
|
|
476
|
+
return '已写入 CSV 文件 `' + path + '`(' + records.length + ' 行 × ' + records[0].length + ' 列,' + csvText.length + ' 字节)'
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// json_to_table:JSON 数组 → Markdown 表格
|
|
480
|
+
function jsonToTable(ctx, args) {
|
|
481
|
+
const jsonStr = String(args.json || '')
|
|
482
|
+
let records
|
|
483
|
+
try { records = JSON.parse(jsonStr) } catch { throw new Error('JSON 解析失败') }
|
|
484
|
+
if (!Array.isArray(records)) throw new Error('JSON 必须是数组')
|
|
485
|
+
if (records.length === 0) return '(空数组)'
|
|
486
|
+
const colSpec = String(args.columns || '').trim()
|
|
487
|
+
let limit = Number(args.limit || 100)
|
|
488
|
+
const title = String(args.title || '').trim()
|
|
489
|
+
let cols
|
|
490
|
+
if (colSpec) cols = colSpec.split(',').map(s => s.trim())
|
|
491
|
+
else cols = Object.keys(records[0] || {}).sort()
|
|
492
|
+
const total = records.length
|
|
493
|
+
if (limit > 0 && limit < records.length) records = records.slice(0, limit)
|
|
494
|
+
const rows = [cols]
|
|
495
|
+
for (const rec of records) {
|
|
496
|
+
rows.push(cols.map(col => (rec[col] !== undefined && rec[col] !== null) ? String(rec[col]) : ''))
|
|
497
|
+
}
|
|
498
|
+
let out = (title ? '**' + title + '**\n\n' : '') + '共 ' + total + ' 条记录'
|
|
499
|
+
if (limit > 0 && total > limit) out += ',显示前 ' + limit + ' 条'
|
|
500
|
+
out += '\n\n' + writeMarkdownTable(rows, null)
|
|
501
|
+
return out
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// table_stats:表格数值列统计(求和/均值/最大/最小/计数,group_by 分组)
|
|
505
|
+
function toRowsFromData(ctx, args, format, dataStr) {
|
|
506
|
+
if (format === 'file') {
|
|
507
|
+
const p = String(args.data || '').trim()
|
|
508
|
+
if (!p) throw new Error('data(文件路径)不能为空')
|
|
509
|
+
return readCSV(ctx.fs.readFile(p), ',')
|
|
510
|
+
}
|
|
511
|
+
if (format === 'json') {
|
|
512
|
+
const objs = JSON.parse(dataStr)
|
|
513
|
+
if (!Array.isArray(objs)) throw new Error('JSON 必须是数组')
|
|
514
|
+
if (objs.length === 0) return []
|
|
515
|
+
const cols = Object.keys(objs[0]).sort()
|
|
516
|
+
const rows = [cols]
|
|
517
|
+
for (const obj of objs) rows.push(cols.map(c => (obj[c] != null ? String(obj[c]) : '')))
|
|
518
|
+
return rows
|
|
519
|
+
}
|
|
520
|
+
return readCSV(dataStr, ',')
|
|
521
|
+
}
|
|
522
|
+
function writeStatsTable(stats) {
|
|
523
|
+
const rows = [['列名', '计数', '求和', '均值', '最小值', '最大值']]
|
|
524
|
+
for (const s of stats) {
|
|
525
|
+
const avg = s.count > 0 ? s.sum / s.count : 0
|
|
526
|
+
rows.push([s.name, String(s.count), s.sum.toFixed(2), avg.toFixed(2), s.min.toFixed(2), s.max.toFixed(2)])
|
|
527
|
+
}
|
|
528
|
+
return writeMarkdownTable(rows, null)
|
|
529
|
+
}
|
|
530
|
+
function tableStats(ctx, args) {
|
|
531
|
+
const dataStr = String(args.data || '')
|
|
532
|
+
const format = String(args.format || '').trim().toLowerCase() || 'csv'
|
|
533
|
+
const groupBy = String(args.group_by || '').trim()
|
|
534
|
+
const records = toRowsFromData(ctx, args, format, dataStr)
|
|
535
|
+
if (records.length < 2) return '(数据不足,至少需要表头 + 1 行数据)'
|
|
536
|
+
const header = records[0]
|
|
537
|
+
const dataRows = records.slice(1)
|
|
538
|
+
const colIdx = {}
|
|
539
|
+
header.forEach((h, i) => { colIdx[h] = i })
|
|
540
|
+
let groupColIdx = -1
|
|
541
|
+
if (groupBy && colIdx[groupBy] !== undefined) groupColIdx = colIdx[groupBy]
|
|
542
|
+
// 自动识别数值列
|
|
543
|
+
const numCols = []
|
|
544
|
+
if (dataRows.length > 0) {
|
|
545
|
+
header.forEach((h, ci) => {
|
|
546
|
+
if (ci === groupColIdx) return
|
|
547
|
+
let allNumeric = true
|
|
548
|
+
for (const row of dataRows) {
|
|
549
|
+
if (ci >= row.length) { allNumeric = false; break }
|
|
550
|
+
const v = String(row[ci]).trim()
|
|
551
|
+
if (v === '' || v === '-' || v === 'N/A') continue
|
|
552
|
+
if (isNaN(Number(v))) { allNumeric = false; break }
|
|
553
|
+
}
|
|
554
|
+
if (allNumeric) numCols.push({ name: h, sum: 0, count: 0, min: 0, max: 0 })
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
if (numCols.length === 0) return '(未找到数值列,无法统计)'
|
|
558
|
+
// 累计器
|
|
559
|
+
const accum = (st, row) => {
|
|
560
|
+
const ci = colIdx[st.name]
|
|
561
|
+
if (ci >= row.length) return
|
|
562
|
+
const v = String(row[ci]).trim()
|
|
563
|
+
if (v === '' || v === '-' || v === 'N/A' || isNaN(Number(v))) return
|
|
564
|
+
const f = Number(v)
|
|
565
|
+
st.sum += f
|
|
566
|
+
st.count++
|
|
567
|
+
if (st.count === 1) { st.min = f; st.max = f }
|
|
568
|
+
else { if (f < st.min) st.min = f; if (f > st.max) st.max = f }
|
|
569
|
+
}
|
|
570
|
+
if (groupColIdx >= 0) {
|
|
571
|
+
const groups = {}
|
|
572
|
+
dataRows.forEach(row => { const k = groupColIdx < row.length ? row[groupColIdx] : ''; (groups[k] || (groups[k] = [])).push(row) })
|
|
573
|
+
let out = '**分组统计**(按 `' + groupBy + '` 分组)\n\n'
|
|
574
|
+
Object.keys(groups).sort().forEach(gk => {
|
|
575
|
+
const stats = numCols.map(nc => {
|
|
576
|
+
const st = { name: nc.name, sum: 0, count: 0, min: 0, max: 0 }
|
|
577
|
+
groups[gk].forEach(row => accum(st, row))
|
|
578
|
+
return st
|
|
579
|
+
})
|
|
580
|
+
out += '**' + gk + '**(' + groups[gk].length + ' 行)\n\n' + writeStatsTable(stats) + '\n'
|
|
581
|
+
})
|
|
582
|
+
return out
|
|
583
|
+
}
|
|
584
|
+
let out = '**统计结果** · ' + dataRows.length + ' 行数据\n\n'
|
|
585
|
+
numCols.forEach(nc => { dataRows.forEach(row => accum(nc, row)) })
|
|
586
|
+
out += writeStatsTable(numCols)
|
|
587
|
+
return out
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// text_report:目录树代码行数统计
|
|
591
|
+
const SKIP_DIRS2 = { '.git': 1, 'node_modules': 1, 'vendor': 1, '.pair': 1, 'dist': 1, 'build': 1, 'target': 1, '.next': 1, '__pycache__': 1, 'coverage': 1, '.idea': 1, '.vscode': 1, '.cache': 1, '.venv': 1, 'venv': 1, 'Pods': 1 }
|
|
592
|
+
function countFileLines(lines) {
|
|
593
|
+
let total = 0, code = 0, comment = 0, blank = 0
|
|
594
|
+
for (const line of lines) {
|
|
595
|
+
const t = line.trim()
|
|
596
|
+
total++
|
|
597
|
+
if (!t) blank++
|
|
598
|
+
else if (t.startsWith('//') || t.startsWith('#') || t.startsWith('--') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('<!--')) comment++
|
|
599
|
+
else code++
|
|
600
|
+
}
|
|
601
|
+
return { total, code, comment, blank }
|
|
602
|
+
}
|
|
603
|
+
function textReport(ctx, args) {
|
|
604
|
+
const scanPath = String(args.path || '').trim() || '.'
|
|
605
|
+
const extStr = String(args.extensions || '').trim()
|
|
606
|
+
let groupBy = String(args.group_by || '').trim().toLowerCase() || 'ext'
|
|
607
|
+
const maxFiles = Number(args.max_files || 5000)
|
|
608
|
+
const extSet = {}
|
|
609
|
+
if (extStr) {
|
|
610
|
+
for (let e of extStr.split(',')) {
|
|
611
|
+
e = e.trim()
|
|
612
|
+
if (!e.startsWith('.')) e = '.' + e
|
|
613
|
+
extSet[e.toLowerCase()] = true
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const extStats = {}, dirStats = {}
|
|
617
|
+
const total = { total: 0, code: 0, comment: 0, blank: 0 }
|
|
618
|
+
let fileCount = 0
|
|
619
|
+
const processFile = (p, rel) => {
|
|
620
|
+
if (fileCount >= maxFiles) return
|
|
621
|
+
let st
|
|
622
|
+
try { st = ctx.fs.stat(p) } catch { return }
|
|
623
|
+
if (st.isDir) return
|
|
624
|
+
const ext = p.toLowerCase().match(/(\.[a-z0-9]+)$/)?.[1] || '(无扩展名)'
|
|
625
|
+
if (Object.keys(extSet).length > 0 && !extSet[ext.toLowerCase()]) return
|
|
626
|
+
let data
|
|
627
|
+
try { data = ctx.fs.readFile(p) } catch { return }
|
|
628
|
+
fileCount++
|
|
629
|
+
const fs2 = countFileLines(String(data).split('\n'))
|
|
630
|
+
const dirKey = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '(根目录)'
|
|
631
|
+
if (!extStats[ext]) extStats[ext] = { total: 0, code: 0, comment: 0, blank: 0 }
|
|
632
|
+
const es = extStats[ext]
|
|
633
|
+
es.total += fs2.total; es.code += fs2.code; es.comment += fs2.comment; es.blank += fs2.blank
|
|
634
|
+
if (!dirStats[dirKey]) dirStats[dirKey] = { total: 0, code: 0, comment: 0, blank: 0 }
|
|
635
|
+
const ds = dirStats[dirKey]
|
|
636
|
+
ds.total += fs2.total; ds.code += fs2.code; ds.comment += fs2.comment; ds.blank += fs2.blank
|
|
637
|
+
total.total += fs2.total; total.code += fs2.code; total.comment += fs2.comment; total.blank += fs2.blank
|
|
638
|
+
}
|
|
639
|
+
const walk = (dir, relPrefix) => {
|
|
640
|
+
if (fileCount >= maxFiles) return
|
|
641
|
+
let names = []
|
|
642
|
+
try { names = ctx.fs.readdir(dir) } catch { return }
|
|
643
|
+
for (const n of names.sort()) {
|
|
644
|
+
if (fileCount >= maxFiles) return
|
|
645
|
+
if (SKIP_DIRS2[n]) continue
|
|
646
|
+
const p = (dir === '.' ? '' : dir + '/') + n
|
|
647
|
+
const rel = relPrefix ? relPrefix + '/' + n : n
|
|
648
|
+
let st
|
|
649
|
+
try { st = ctx.fs.stat(p) } catch { continue }
|
|
650
|
+
if (st.isDir) walk(p, rel)
|
|
651
|
+
else processFile(p, rel)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
let scanSt
|
|
655
|
+
try { scanSt = ctx.fs.stat(scanPath) } catch { }
|
|
656
|
+
if (scanSt && !scanSt.isDir) processFile(scanPath, scanPath)
|
|
657
|
+
else walk(scanPath, '')
|
|
658
|
+
if (fileCount === 0) return '(未找到匹配的文件)'
|
|
659
|
+
let out = '**代码统计报告** · 扫描目录: `' + scanPath + '`\n\n共计 ' + fileCount + ' 个文件,' + total.total + ' 行(代码 ' + total.code + ' / 注释 ' + total.comment + ' / 空行 ' + total.blank + ')\n\n'
|
|
660
|
+
const rows = []
|
|
661
|
+
if (groupBy === 'dir') {
|
|
662
|
+
rows.push(['目录', '文件数(估算)', '总行数', '代码行', '注释行', '空行'])
|
|
663
|
+
Object.keys(dirStats).sort().forEach(d => {
|
|
664
|
+
const s = dirStats[d]
|
|
665
|
+
const estFiles = Math.ceil(s.total / 50)
|
|
666
|
+
rows.push([d, '~' + estFiles, String(s.total), String(s.code), String(s.comment), String(s.blank)])
|
|
667
|
+
})
|
|
668
|
+
} else {
|
|
669
|
+
rows.push(['扩展名', '文件数(估算)', '总行数', '代码行', '注释行', '空行'])
|
|
670
|
+
Object.keys(extStats).sort().forEach(e => {
|
|
671
|
+
const s = extStats[e]
|
|
672
|
+
const estFiles = Math.ceil(s.total / 50)
|
|
673
|
+
rows.push([e, '~' + estFiles, String(s.total), String(s.code), String(s.comment), String(s.blank)])
|
|
674
|
+
})
|
|
675
|
+
}
|
|
676
|
+
out += writeMarkdownTable(rows, null)
|
|
677
|
+
return out
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const impls = {
|
|
681
|
+
csv_read: csvRead,
|
|
682
|
+
csv_write: csvWrite,
|
|
683
|
+
json_to_table: jsonToTable,
|
|
684
|
+
table_stats: tableStats,
|
|
685
|
+
text_report: textReport,
|
|
686
|
+
word_read: wordRead,
|
|
687
|
+
read_xlsx: readXlsx,
|
|
688
|
+
// word_write / write_xlsx / read_pdf / markdown_to_html 保留独立二进制(生成/PDF 引擎)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ── read_xlsx 辅助 ─────────────────────────────────────────
|
|
692
|
+
// Excel 列字母 → 索引(A→1, Z→26, AA→27)
|
|
693
|
+
function parseColLetter(letters) {
|
|
694
|
+
if (!letters) return 0
|
|
695
|
+
let col = 0
|
|
696
|
+
for (const ch of String(letters).toUpperCase()) col = col * 26 + (ch.charCodeAt(0) - 64)
|
|
697
|
+
return col
|
|
698
|
+
}
|
|
699
|
+
// 剥离 XML 命名空间声明(对齐 Go 版 clean)
|
|
700
|
+
function cfgStripXmlns(s) { return String(s || '').replace(/xmlns="[^"]*"/g, '') }
|
|
701
|
+
|
|
702
|
+
// ── word_read:.docx 纯文本/Markdown 提取(zipReadEntry → JS XML 解析)─────
|
|
703
|
+
function wordRead(ctx, args) {
|
|
704
|
+
const path = String(args.path || '')
|
|
705
|
+
const format = (String(args.format || 'text') || 'text').toLowerCase() === 'markdown' ? 'markdown' : 'text'
|
|
706
|
+
const limit = parseInt(args.limit, 10)
|
|
707
|
+
const lim = isNaN(limit) ? 10000 : limit
|
|
708
|
+
let xml
|
|
709
|
+
try { xml = ctx.fs.zipReadEntry(path, 'word/document.xml') } catch (e) { throw new Error('无法打开 .docx 文件(不是有效的 ZIP 压缩包): ' + (e && e.message || e)) }
|
|
710
|
+
if (!xml || !xml.includes('document')) xml = String(xml || '')
|
|
711
|
+
// 先剥离表格块(表格单元格内的段落不算文档段落)
|
|
712
|
+
const tbls = []
|
|
713
|
+
let body = String(xml).replace(/<w:tbl>[\s\S]*?<\/w:tbl>/g, (m) => { tbls.push(m); return '\n@@TBL@@\n' })
|
|
714
|
+
// 提取段落
|
|
715
|
+
const paras = []
|
|
716
|
+
const reP = /<w:p\b[^>]*>([\s\S]*?)<\/w:p>/g
|
|
717
|
+
let m
|
|
718
|
+
while ((m = reP.exec(body)) !== null) {
|
|
719
|
+
const inner = m[1]
|
|
720
|
+
const styleM = inner.match(/<w:pStyle[^>]*w:val="([^"]+)"[^>]*\/?>/)
|
|
721
|
+
const style = styleM ? styleM[1] : ''
|
|
722
|
+
let text = ''
|
|
723
|
+
const reT = /<w:t[^>]*>([^<]*)<\/w:t>/g
|
|
724
|
+
let tm
|
|
725
|
+
while ((tm = reT.exec(inner)) !== null) text += tm[1]
|
|
726
|
+
paras.push({ text: text, style: style })
|
|
727
|
+
}
|
|
728
|
+
// 输出
|
|
729
|
+
let out = ''
|
|
730
|
+
for (const p of paras) {
|
|
731
|
+
if (!p.text) { out += format === 'markdown' ? '\n\n' : '\n'; continue }
|
|
732
|
+
if (format === 'markdown') {
|
|
733
|
+
if (/^(Heading1|1)/.test(p.style)) out += '# ' + p.text + '\n\n'
|
|
734
|
+
else if (/^(Heading2|2)/.test(p.style)) out += '## ' + p.text + '\n\n'
|
|
735
|
+
else if (/^(Heading3|3)/.test(p.style)) out += '### ' + p.text + '\n\n'
|
|
736
|
+
else if (/ListBullet/.test(p.style)) out += '- ' + p.text + '\n'
|
|
737
|
+
else if (/ListNumber/.test(p.style)) out += '1. ' + p.text + '\n'
|
|
738
|
+
else out += p.text + '\n\n'
|
|
739
|
+
} else {
|
|
740
|
+
out += p.text + '\n'
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// 表格
|
|
744
|
+
for (const tb of tbls) {
|
|
745
|
+
if (format === 'markdown') {
|
|
746
|
+
const rows = []
|
|
747
|
+
const reTr = /<w:tr[^>]*>([\s\S]*?)<\/w:tr>/g
|
|
748
|
+
let tm, tr
|
|
749
|
+
while ((tm = reTr.exec(tb)) !== null) {
|
|
750
|
+
const cells = []
|
|
751
|
+
const reTc = /<w:tc[^>]*>([\s\S]*?)<\/w:tc>/g
|
|
752
|
+
let cm, tc
|
|
753
|
+
while ((cm = reTc.exec(tm[1])) !== null) {
|
|
754
|
+
let ct = ''
|
|
755
|
+
const reCt = /<w:t[^>]*>([^<]*)<\/w:t>/g
|
|
756
|
+
let xt
|
|
757
|
+
while ((xt = reCt.exec(cm[1])) !== null) ct += xt[1]
|
|
758
|
+
cells.push(ct.trim())
|
|
759
|
+
}
|
|
760
|
+
if (cells.length) rows.push(cells)
|
|
761
|
+
}
|
|
762
|
+
if (rows.length) { out += '\n' + writeMarkdownTable(rows, null) + '\n' }
|
|
763
|
+
} else {
|
|
764
|
+
out += '\n[表格]\n'
|
|
765
|
+
const reTr = /<w:tr[^>]*>([\s\S]*?)<\/w:tr>/g
|
|
766
|
+
let tm, tr
|
|
767
|
+
while ((tm = reTr.exec(tb)) !== null) {
|
|
768
|
+
const cells = []
|
|
769
|
+
const reTc = /<w:tc[^>]*>([\s\S]*?)<\/w:tc>/g
|
|
770
|
+
let cm, tc
|
|
771
|
+
while ((cm = reTc.exec(tm[1])) !== null) {
|
|
772
|
+
let ct = ''
|
|
773
|
+
const reCt = /<w:t[^>]*>([^<]*)<\/w:t>/g
|
|
774
|
+
let xt
|
|
775
|
+
while ((xt = reCt.exec(cm[1])) !== null) ct += xt[1]
|
|
776
|
+
cells.push(ct.trim())
|
|
777
|
+
}
|
|
778
|
+
if (cells.length) out += '| ' + cells.join(' | ') + ' |\n'
|
|
779
|
+
}
|
|
780
|
+
out += '\n'
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
let result = out.trim()
|
|
784
|
+
if (lim > 0 && result.length > lim) result = result.slice(0, lim) + '…'
|
|
785
|
+
return result
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ── read_xlsx:.xlsx → Markdown 表格(zip 条目 → JS XML 解析)─────────────
|
|
789
|
+
function readXlsx(ctx, args) {
|
|
790
|
+
const path = String(args.path || '')
|
|
791
|
+
const sheetName = String(args.sheet || '').trim()
|
|
792
|
+
const limit = parseInt(args.limit, 10)
|
|
793
|
+
const lim = isNaN(limit) ? 200 : limit
|
|
794
|
+
// 共享字符串表
|
|
795
|
+
let sharedStrings = []
|
|
796
|
+
try {
|
|
797
|
+
const ssx = ctx.fs.zipReadEntry(path, 'xl/sharedStrings.xml')
|
|
798
|
+
const siRe = /<si>([\s\S]*?)<\/si>/g
|
|
799
|
+
let m
|
|
800
|
+
while ((m = siRe.exec(ssx)) !== null) {
|
|
801
|
+
let t = ''
|
|
802
|
+
const tRe = /<t[^>]*>([^<]*)<\/t>/g
|
|
803
|
+
let tm
|
|
804
|
+
while ((tm = tRe.exec(m[1])) !== null) t += tm[1]
|
|
805
|
+
sharedStrings.push(t)
|
|
806
|
+
}
|
|
807
|
+
} catch (e) { /* 无共享字符串表 */ }
|
|
808
|
+
// 找第一个 sheet(★ 与 Go 版一致:跳过 workbook 解析,取 sheet*.xml 首个)
|
|
809
|
+
const entries = ctx.fs.zipEntries(path)
|
|
810
|
+
const sheets = entries.filter(n => n.indexOf('xl/worksheets/sheet') === 0 && n.endsWith('.xml')).sort()
|
|
811
|
+
if (sheets.length === 0) return '(空文件或未找到数据)'
|
|
812
|
+
const sx = cfgStripXmlns(ctx.fs.zipReadEntry(path, sheets[0]))
|
|
813
|
+
// 行 → cell
|
|
814
|
+
const rows = []
|
|
815
|
+
const reRow = /<row[^>]*>([\s\S]*?)<\/row>/g
|
|
816
|
+
let m2
|
|
817
|
+
while ((m2 = reRow.exec(sx)) !== null) {
|
|
818
|
+
const cells = []
|
|
819
|
+
const reC = /<c\b([^>]*)>([\s\S]*?)<\/c>|<c\b([^>]*)\/>/g
|
|
820
|
+
let cm
|
|
821
|
+
while ((cm = reC.exec(m2[1])) !== null) {
|
|
822
|
+
const attrs = cm[1] || cm[3] || ''
|
|
823
|
+
const inner = cm[2] || ''
|
|
824
|
+
const refM = attrs.match(/r="([A-Za-z]+)\d+"/)
|
|
825
|
+
const typeM = attrs.match(/t="([^"]+)"/)
|
|
826
|
+
const colLetter = refM ? refM[1] : ''
|
|
827
|
+
let col = parseColLetter(colLetter)
|
|
828
|
+
if (col === 0) col = cells.length + 1
|
|
829
|
+
let val = ''
|
|
830
|
+
const vM = inner.match(/<v>([^<]*)<\/v>/)
|
|
831
|
+
if (vM) val = vM[1]
|
|
832
|
+
const isM = inner.match(/<is>([\s\S]*?)<\/is>/)
|
|
833
|
+
if (!val && isM) {
|
|
834
|
+
let t = ''
|
|
835
|
+
const tRe = /<t[^>]*>([^<]*)<\/t>/g
|
|
836
|
+
let tm
|
|
837
|
+
while ((tm = tRe.exec(isM[1])) !== null) t += tm[1]
|
|
838
|
+
val = t
|
|
839
|
+
}
|
|
840
|
+
const type = typeM ? typeM[1] : ''
|
|
841
|
+
if (type === 's') {
|
|
842
|
+
const idx = parseInt(val, 10)
|
|
843
|
+
val = (!isNaN(idx) && idx >= 0 && idx < sharedStrings.length) ? sharedStrings[idx] : val
|
|
844
|
+
}
|
|
845
|
+
cells.push({ col: col, val: val })
|
|
846
|
+
}
|
|
847
|
+
if (cells.length) rows.push(cells)
|
|
848
|
+
}
|
|
849
|
+
if (rows.length === 0) return '(空文件或未找到数据)'
|
|
850
|
+
// 最大列数
|
|
851
|
+
let maxCol = 0
|
|
852
|
+
for (const row of rows) for (const c of row.cells || row) maxCol = Math.max(maxCol, (c && c.col) || 0)
|
|
853
|
+
if (maxCol === 0) { for (const row of rows) maxCol = Math.max(maxCol, row.length) }
|
|
854
|
+
// 构造记录
|
|
855
|
+
let shown = rows
|
|
856
|
+
if (lim > 0 && lim < rows.length) shown = rows.slice(0, lim)
|
|
857
|
+
const records = []
|
|
858
|
+
for (const row of shown) {
|
|
859
|
+
const rec = new Array(maxCol).fill('')
|
|
860
|
+
for (const c of (row.cells || row)) {
|
|
861
|
+
const idx = (c && c.col || 0) - 1
|
|
862
|
+
if (idx >= 0 && idx < maxCol) rec[idx] = c.val
|
|
863
|
+
}
|
|
864
|
+
records.push(rec)
|
|
865
|
+
}
|
|
866
|
+
let out = `工作表: **${sheetName}** · 共 ${rows.length} 行 × ${maxCol} 列`
|
|
867
|
+
if (lim > 0 && rows.length > lim) out += ` · 显示前 ${lim} 行`
|
|
868
|
+
out += '\n\n' + writeMarkdownTable(records, null)
|
|
869
|
+
return out
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
|
|
317
873
|
return {
|
|
318
874
|
name: 'tool-office',
|
|
319
|
-
|
|
875
|
+
inject: ['fs'],
|
|
876
|
+
purpose: '办公文档(csv_read/csv_write/json_to_table/table_stats/text_report/word_read/read_xlsx JS 原生化;word_write/write_xlsx/read_pdf/markdown_to_html 保留独立二进制)——2026-08-22 迁移',
|
|
320
877
|
apply(ctx) {
|
|
321
878
|
for (const t of tools) {
|
|
322
879
|
ctx.tools.register({
|
|
@@ -328,7 +885,7 @@ return {
|
|
|
328
885
|
requiresApproval: t.requiresApproval,
|
|
329
886
|
systemTool: t.systemTool,
|
|
330
887
|
parameters: t.parameters,
|
|
331
|
-
execute: (args) => ctx.binary.exec(t.name, args || {}),
|
|
888
|
+
execute: (args) => (impls[t.name] ? impls[t.name](ctx, args || {}) : ctx.binary.exec(t.name, args || {})),
|
|
332
889
|
})
|
|
333
890
|
}
|
|
334
891
|
},
|
package/package.json
CHANGED