@paircode/tool-office 1.0.2 → 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.
Files changed (2) hide show
  1. package/index.js +563 -6
  2. package/package.json +1 -1
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
- // 生成来源(2026-08-16):内置 Go 工具组 → 磁盘外置插件(tool_plugin_gen.go
5
- // 自动生成,schema 完整外置拷贝)。api 声明在插件,execute 调 ctx.binary 复用本插件目录 bin/ 下的独立二进制(源码 plugins-src/plugins/<name>/,改实现重编译即更换)。
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 文件的文本内容。先尝试纯文本提取(解析 PDF 流对象);如提取结果为空或内容极少(\u003c50 字符),自动调用 pdftoppm/mutool 将页面渲染为图片 + Tesseract OCR 识别文字。page 指定页码(从 1 开始,默认全部);limit 限制返回字符数(默认 10000)。",
266
- "usageGuide": "提取 PDF 文本内容。先尝试纯文本提取,失败则回退 OCR。比手动打开 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
- purpose: '办公文档(csv_read/csv_write/json_to_table/table_stats/text_report/word_read)(自动生成,迁移自内置 Go 工具组)',
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
@@ -3,7 +3,7 @@
3
3
  "name": "@paircode/tool-office",
4
4
  "scope": "global",
5
5
  "type": "plugin",
6
- "version": "1.0.2",
6
+ "version": "1.0.3",
7
7
  "description": "办公文档(csv_read/csv_write/json_to_table/table_stats/text_report/word_read)(自动生成,迁移自内置 Go 工具组)",
8
8
  "keywords": [
9
9
  "paircode"