@waterwx/dsh-novel-forge 1.3.1 → 1.3.2-alpha

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 (161) hide show
  1. package/README.md +288 -279
  2. package/lib/client.js +56309 -12221
  3. package/lib/index.js +14239 -5595
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +36 -0
  6. package/lib/types/assets.d.ts +24 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +25 -0
  9. package/lib/types/bookshelf.d.ts +11 -1
  10. package/lib/types/client/api.d.ts +186 -10
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -0
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +28 -67
  16. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  20. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  21. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  22. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  23. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  24. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  25. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  26. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  27. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  28. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  29. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  30. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  31. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  32. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  33. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  34. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  35. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  36. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  37. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  38. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  39. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  40. package/lib/types/client/panel/SubPage.d.ts +27 -0
  41. package/lib/types/client/panel/TensionView.d.ts +18 -0
  42. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  43. package/lib/types/client/panel/helpers.d.ts +5 -3
  44. package/lib/types/client/panel/toast.d.ts +2 -0
  45. package/lib/types/client/panel/views.d.ts +25 -7
  46. package/lib/types/client/run-status.d.ts +21 -0
  47. package/lib/types/client/text.d.ts +9 -0
  48. package/lib/types/docx-core.d.ts +16 -0
  49. package/lib/types/docx.d.ts +2 -3
  50. package/lib/types/engine.d.ts +186 -45
  51. package/lib/types/global-assets.d.ts +6 -0
  52. package/lib/types/index.d.ts +22 -8
  53. package/lib/types/llm-live.d.ts +75 -0
  54. package/lib/types/llm-retry.d.ts +38 -0
  55. package/lib/types/market-radar-scan.d.ts +19 -0
  56. package/lib/types/market-radar-sources.d.ts +31 -0
  57. package/lib/types/migrate-output.d.ts +25 -0
  58. package/lib/types/novel-context.d.ts +80 -0
  59. package/lib/types/prompt-slots.d.ts +57 -0
  60. package/lib/types/prompt-utils.d.ts +35 -0
  61. package/lib/types/prompting.d.ts +10 -0
  62. package/lib/types/protocol.d.ts +1362 -166
  63. package/lib/types/review-policy.d.ts +97 -0
  64. package/lib/types/revision.d.ts +76 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/run.d.ts +23 -1
  67. package/lib/types/snapshots.d.ts +46 -0
  68. package/lib/types/stage-contract.d.ts +60 -0
  69. package/lib/types/tension.d.ts +53 -0
  70. package/lib/types/timeline.d.ts +37 -0
  71. package/package.json +52 -28
  72. package/src/action-guard.ts +127 -0
  73. package/src/ai-scan.ts +179 -0
  74. package/src/assets.ts +1806 -520
  75. package/src/assistant.ts +1098 -770
  76. package/src/author-assets.ts +149 -0
  77. package/src/bookshelf.ts +187 -151
  78. package/src/client/api.ts +378 -21
  79. package/src/client/chip-prefs.ts +121 -0
  80. package/src/client/docx.ts +37 -69
  81. package/src/client/index.ts +3 -1
  82. package/src/client/llmLive.ts +111 -0
  83. package/src/client/locales.ts +28 -253
  84. package/src/client/mount.tsx +1 -1
  85. package/src/client/panel/AdaptModeView.tsx +535 -0
  86. package/src/client/panel/AiTag.tsx +7 -0
  87. package/src/client/panel/AssetsTab.tsx +448 -458
  88. package/src/client/panel/AssistantTab.tsx +355 -301
  89. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  90. package/src/client/panel/AuthorHome.tsx +309 -0
  91. package/src/client/panel/BookAnalysisView.tsx +63 -0
  92. package/src/client/panel/CmdPalette.tsx +102 -0
  93. package/src/client/panel/CreateBookView.tsx +366 -348
  94. package/src/client/panel/DirectorView.tsx +200 -0
  95. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  96. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  97. package/src/client/panel/ImportModal.tsx +303 -0
  98. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  99. package/src/client/panel/LiveFeedLog.tsx +204 -0
  100. package/src/client/panel/MarketRadarView.tsx +377 -0
  101. package/src/client/panel/ModelManager.tsx +205 -0
  102. package/src/client/panel/NovelPanel.tsx +3135 -2651
  103. package/src/client/panel/PageHeader.tsx +37 -0
  104. package/src/client/panel/ProgressConsole.tsx +63 -0
  105. package/src/client/panel/PromptSlotsView.tsx +97 -0
  106. package/src/client/panel/ReaderView.tsx +306 -306
  107. package/src/client/panel/ReasoningSection.tsx +31 -0
  108. package/src/client/panel/RunPanel.tsx +64 -32
  109. package/src/client/panel/SettingsView.tsx +354 -0
  110. package/src/client/panel/ShelfView.tsx +266 -236
  111. package/src/client/panel/SubPage.tsx +42 -0
  112. package/src/client/panel/TensionView.tsx +212 -0
  113. package/src/client/panel/TimelineView.tsx +208 -0
  114. package/src/client/panel/WorldTab.tsx +202 -202
  115. package/src/client/panel/helpers.ts +7 -5
  116. package/src/client/panel/panel.module.css +4481 -1655
  117. package/src/client/panel/toast.ts +51 -0
  118. package/src/client/panel/views.tsx +401 -314
  119. package/src/client/run-status.ts +171 -0
  120. package/src/client/sidebar-entry.ts +1 -1
  121. package/src/client/text.ts +28 -0
  122. package/src/docx-core.ts +53 -0
  123. package/src/docx.ts +44 -83
  124. package/src/engine.ts +4672 -2943
  125. package/src/global-assets.ts +59 -0
  126. package/src/index.ts +89 -33
  127. package/src/llm-live.ts +260 -0
  128. package/src/llm-retry.ts +77 -0
  129. package/src/market-radar-scan.ts +41 -0
  130. package/src/market-radar-sources.ts +173 -0
  131. package/src/migrate-output.ts +76 -0
  132. package/src/novel-context.ts +286 -0
  133. package/src/prompt-slots.ts +125 -0
  134. package/src/prompting.ts +26 -0
  135. package/src/protocol.ts +1388 -158
  136. package/src/review-policy.ts +161 -0
  137. package/src/revision.ts +283 -0
  138. package/src/routes.ts +1934 -434
  139. package/src/run.ts +118 -21
  140. package/src/snapshots.ts +174 -0
  141. package/src/stage-contract.ts +189 -0
  142. package/src/tension.ts +251 -0
  143. package/src/timeline.ts +159 -0
  144. package/lib/client.js.map +0 -1
  145. package/lib/index.js.map +0 -1
  146. package/lib/types/comic-presets.d.ts +0 -12
  147. package/scripts/assistant-frames.txt +0 -8
  148. package/scripts/diagnose-adapter.mts +0 -81
  149. package/scripts/diagnose-assistant.mjs +0 -58
  150. package/scripts/diagnose-bible.mjs +0 -72
  151. package/scripts/diagnose-harness.mts +0 -75
  152. package/scripts/novel-forge-restart.log +0 -10
  153. package/scripts/novel-forge-web.stderr.log +0 -2
  154. package/scripts/novel-forge-web.stdout.log +0 -1
  155. package/scripts/outline-sample.txt +0 -449
  156. package/scripts/probe.mjs +0 -6
  157. package/scripts/restart-web.ps1 +0 -88
  158. package/scripts/smoke.mts +0 -72
  159. package/scripts/upload-github.ps1 +0 -51
  160. package/src/client/panel/BookshelfBar.tsx +0 -170
  161. package/src/comic-presets.ts +0 -67
@@ -0,0 +1,171 @@
1
+ /**
2
+ * 运行状态芯片:固定在页面顶部的「小说工坊生成状态」小徽标。
3
+ * 轮询 /run/status,实时显示 生成中/暂停/完成/停止/出错 + 当前章号;
4
+ * 点击可打开/关闭小说工坊面板。独立 DOM 注入,不触碰主面板逻辑,挂载失败静默降级。
5
+ * 显示偏好(关 / 仅生成中暂停时 / 总是)存在 localStorage,见 chip-prefs.ts:
6
+ * 书架页「设置 · 全局 → 外观与主题」里有对应下拉,选择即时生效(跨标签页也同步)。
7
+ *
8
+ * P0 重写说明(原实现的三个问题):
9
+ * 1. 整块背景染状态色 + 固定白字 → 浅色主题下「白字配亮底」对比度崩坏。
10
+ * 现在改为「玻璃底 + 主题前景色文字 + 左侧状态色点」,状态只由色点承载,
11
+ * 文字永远跟随主题,任何深浅色下都可读。
12
+ * 2. 样式写死在 JS 的 inline style 里,切主题纹丝不动。
13
+ * 现在改用 CSS Module class,配色走 --nf-chip-* 令牌,
14
+ * 由 body[data-ds-dark-theme] 跟随宿主深浅色。
15
+ * 3. role="button" 却没有 tabindex、没有键盘处理,键盘用户完全用不了。
16
+ * 现在补 tabindex + Enter/Space 激活 + aria-label/live。
17
+ */
18
+ import type { NovelApi } from './api.ts'
19
+ import type { PanelController } from './panel/controller.ts'
20
+ import type { RunState } from '../protocol.ts'
21
+ import css from './panel/panel.module.css'
22
+ import { isRunChipVisible, readRunChipMode, subscribeRunChipMode, type RunChipMode } from './chip-prefs.ts'
23
+
24
+ const REFRESH_MS = 4000
25
+
26
+ /** 状态文案。状态语义由左侧色点承载,这里不再放 emoji(避免与色点重复表意)。 */
27
+ function label(s: RunState): string {
28
+ const no = s.currentNo
29
+ // 带书名的原因:状态是"全局单实例 + 恢复时会扫书架",不带书名根本不知道是谁的批次。
30
+ const book = typeof s.bookName === 'string' && s.bookName.trim() !== '' ? `《${s.bookName.trim()}》` : ''
31
+ const head = book === '' ? '小说工坊' : book
32
+ switch (s.status) {
33
+ case 'running': return `${head} 生成中 · 第${no}章`
34
+ case 'paused': return `${head} 已暂停 · 第${no}章`
35
+ case 'done': return `${head} 批完成 ${s.startNo}–${s.endNo}`
36
+ case 'stopped': return `${head} 已停止 · 第${no}章`
37
+ case 'error': return `${head} 出错 · 第${no}章`
38
+ default: return book === '' ? '小说工坊' : book
39
+ }
40
+ }
41
+
42
+ /** 生成一个固定顶部的状态芯片并开始轮询。返回 dispose。 */
43
+ export function mountRunStatus(controller: PanelController, api: NovelApi): () => void {
44
+ const chip = document.createElement('div')
45
+ chip.className = css.runChip
46
+ chip.setAttribute('role', 'button')
47
+ // P0: role="button" 必须可聚焦、可键盘激活,否则对键盘/读屏用户等同不存在。
48
+ chip.setAttribute('tabindex', '0')
49
+ chip.setAttribute('title', '小说工坊生成状态(点击打开工坊)')
50
+ // 状态是异步轮询出来的,用 aria-live 让读屏用户也能感知变化。
51
+ chip.setAttribute('aria-live', 'polite')
52
+
53
+ const dot = document.createElement('span')
54
+ dot.className = css.runChipDot
55
+ // 纯装饰,颜色信息已由文字表达,不要让读屏重复播报。
56
+ dot.setAttribute('aria-hidden', 'true')
57
+
58
+ const text = document.createElement('span')
59
+ text.className = css.runChipText
60
+
61
+ chip.append(dot, text)
62
+
63
+ const toggle = (): void => { controller.toggle() }
64
+
65
+ // 自由拖拽 + 位置记忆(与 AI 进度浮窗一致)
66
+ const POS_KEY = 'dsh-novel-forge.runChip.pos'
67
+ let dragging = false, moved = false, sx = 0, sy = 0, ox = 0, oy = 0
68
+ const readPos = (): { x: number; y: number } | null => {
69
+ try { const p = window.localStorage.getItem(POS_KEY); return p === null ? null : JSON.parse(p) as { x: number; y: number } } catch { return null }
70
+ }
71
+ const writePos = (x: number, y: number): void => {
72
+ try { window.localStorage.setItem(POS_KEY, JSON.stringify({ x, y })) } catch { /* ignore */ }
73
+ }
74
+ const clampX = (x: number): number => Math.max(0, Math.min(Math.max(0, window.innerWidth - 40), x))
75
+ const clampY = (y: number): number => Math.max(0, Math.min(Math.max(0, window.innerHeight - 40), y))
76
+ const applyPos = (x: number, y: number): void => {
77
+ chip.style.left = clampX(x) + 'px'
78
+ chip.style.top = clampY(y) + 'px'
79
+ chip.style.right = 'auto'
80
+ }
81
+ chip.addEventListener('pointerdown', (e) => {
82
+ if (e.button !== 0) return
83
+ dragging = true; moved = false; sx = e.clientX; sy = e.clientY
84
+ ox = chip.offsetLeft; oy = chip.offsetTop
85
+ try { chip.setPointerCapture(e.pointerId) } catch { /* ignore */ }
86
+ })
87
+ chip.addEventListener('pointermove', (e) => {
88
+ if (!dragging) return
89
+ const dx = e.clientX - sx, dy = e.clientY - sy
90
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true
91
+ if (moved) {
92
+ applyPos(ox + dx, oy + dy)
93
+ writePos(chip.offsetLeft, chip.offsetTop)
94
+ }
95
+ })
96
+ const endDrag = (): void => { dragging = false }
97
+ chip.addEventListener('pointerup', endDrag)
98
+ chip.addEventListener('pointercancel', endDrag)
99
+ // 拖拽后松开不触发开合
100
+ chip.addEventListener('click', (e) => {
101
+ if (moved) { e.preventDefault(); e.stopImmediatePropagation(); moved = false; return }
102
+ toggle()
103
+ })
104
+ chip.addEventListener('keydown', (e) => {
105
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
106
+ e.preventDefault()
107
+ toggle()
108
+ }
109
+ })
110
+
111
+ document.body.appendChild(chip)
112
+
113
+ // 恢复上次拖拽位置(若曾移动过)
114
+ const saved = readPos()
115
+ if (saved !== null) applyPos(saved.x, saved.y)
116
+
117
+ let disposed = false
118
+ let timer: number | undefined
119
+ let mode: RunChipMode = readRunChipMode()
120
+ /** 最近一次拿到的生产单状态:切换偏好时据此立刻重算可见性,不必等下一轮询。 */
121
+ let last: RunState | null = null
122
+
123
+ const render = (s: RunState | null): void => {
124
+ last = s
125
+ if (!isRunChipVisible(mode, s)) {
126
+ chip.dataset.visible = 'false'
127
+ return
128
+ }
129
+ let body = label(s!)
130
+ if (s!.status === 'running' && s!.stats.error > 0) body += ` · ×${s!.stats.error}`
131
+ text.textContent = body
132
+ chip.dataset.status = s!.status
133
+ chip.dataset.visible = 'true'
134
+ // 芯片单行且限宽:书名过长会省略号截断,全名与归属目录放进 title / aria-label。
135
+ const full = `${body}(第 ${s!.startNo}-${s!.endNo} 章)`
136
+ chip.setAttribute('aria-label', full)
137
+ chip.setAttribute('title', full + ' · 点击打开小说工坊')
138
+ }
139
+
140
+ const tick = async (): Promise<void> => {
141
+ if (disposed) return
142
+ try {
143
+ const s = await api.runStatus()
144
+ render(s)
145
+ } catch { /* best-effort */ }
146
+ if (!disposed && mode !== 'off') timer = window.setTimeout(() => { void tick() }, REFRESH_MS)
147
+ }
148
+
149
+ /** 偏好变化:立即重算可见性;「关闭」时停掉轮询,重新打开时续上。 */
150
+ const applyMode = (next: RunChipMode): void => {
151
+ const wasOff = mode === 'off'
152
+ mode = next
153
+ render(last)
154
+ if (mode === 'off') {
155
+ if (timer !== undefined) { window.clearTimeout(timer); timer = undefined }
156
+ return
157
+ }
158
+ if (wasOff && timer === undefined) void tick()
159
+ }
160
+
161
+ const unsubscribe = subscribeRunChipMode(applyMode)
162
+
163
+ void tick()
164
+
165
+ return () => {
166
+ disposed = true
167
+ unsubscribe()
168
+ if (timer !== undefined) window.clearTimeout(timer)
169
+ chip.remove()
170
+ }
171
+ }
@@ -11,7 +11,7 @@ import css from './panel/panel.module.css'
11
11
  export const ENTRY_SELECTOR = '[data-dsh-novelforge-entry]'
12
12
 
13
13
  /** Inline icon: an open book / writing glyph. */
14
- const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 3.5h4a2 2 0 0 1 2 2v7a2 2 0 0 0-2-2h-4z"/><path d="M13.5 3.5h-4a2 2 0 0 0-2 2v7a2 2 0 0 1 2-2h4z"/><path d="M8 5.5v7"/></svg>'
14
+ const ICON = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.67 19a2 2 0 0 0 1.416-.588l6.154-6.172a6 6 0 0 0-8.49-8.49L5.586 9.914A2 2 0 0 0 5 11.328V18a1 1 0 0 0 1 1z"/><path d="M16 8 2 22"/><path d="M17.5 15H9"/></svg>'
15
15
 
16
16
  /** Find the sidebar shell root element. */
17
17
  function sidebarRoot(): HTMLElement | undefined {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 客户端文件文本智能解码:兼容 UTF-8 BOM / UTF-16 / UTF-8 / GB18030(GBK)。
3
+ * 浏览器 File.text() 固定按 UTF-8 解码,GBK 中文网文 txt 会乱码,这里兜底。
4
+ */
5
+
6
+ /**
7
+ * 读取一个 File 的文本,按编码探测兜底解码。
8
+ * @returns 解码后的文本。
9
+ */
10
+ export async function readFileTextSmart(file: File): Promise<string> {
11
+ const buf = await file.arrayBuffer()
12
+ const u8 = new Uint8Array(buf)
13
+ // UTF-8 BOM
14
+ if (u8.length >= 3 && u8[0] === 0xEF && u8[1] === 0xBB && u8[2] === 0xBF) {
15
+ return new TextDecoder('utf-8').decode(u8.subarray(3))
16
+ }
17
+ // UTF-16 LE / BE
18
+ if (u8.length >= 2 && u8[0] === 0xFF && u8[1] === 0xFE) return new TextDecoder('utf-16le').decode(u8.subarray(2))
19
+ if (u8.length >= 2 && u8[0] === 0xFE && u8[1] === 0xFF) return new TextDecoder('utf-16be').decode(u8.subarray(2))
20
+ // UTF-8(宽松),含替换符则回退 GB18030
21
+ const utf8 = new TextDecoder('utf-8', { fatal: false }).decode(u8)
22
+ if (!utf8.includes('\uFFFD')) return utf8
23
+ try {
24
+ return new TextDecoder('gb18030').decode(u8)
25
+ } catch {
26
+ return utf8
27
+ }
28
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * docx 文本提取共享核心:服务端(src/docx.ts)与浏览器端(src/client/docx.ts)
3
+ * 共用 XML → 纯文本的解析逻辑,避免两份实现漂移。
4
+ * zip 解压入口因运行时不同(fflate vs fflate/browser,浏览器端必须走 browser
5
+ * 入口以避开 node:module 的 require 探测)留在两端各自处理。
6
+ */
7
+
8
+ /** 解码 docx 正文实际会用到的少量 XML 实体。 */
9
+ export function decodeEntities(text: string): string {
10
+ return text
11
+ .replace(/&lt;/g, '<')
12
+ .replace(/&gt;/g, '>')
13
+ .replace(/&quot;/g, '"')
14
+ .replace(/&apos;/g, "'")
15
+ .replace(/&amp;/g, '&')
16
+ .replace(/&nbsp;/g, ' ')
17
+ }
18
+
19
+ /**
20
+ * 从 word/document.xml 的 XML 字符串提取纯文本:每个 <w:p> 段落一行,
21
+ * <w:tab>/<w:br> 保留为空白符;表格与嵌套结构按文档顺序拍平
22
+ * (它们的段落同样是 <w:p>)。
23
+ * @param xml - document.xml 解码后的字符串。
24
+ * @returns 正文文本(空文本时抛错)。
25
+ */
26
+ export function extractDocxTextFromXml(xml: string): string {
27
+ const paragraphs: string[] = []
28
+ // 按段落边界切分;逐段取 run 文本。
29
+ const parts = xml.split(/<w:p\b[^>]*>/)
30
+ for (let i = 1; i < parts.length; i++) {
31
+ const segment = parts[i]!
32
+ // 匹配 <w:t ...>…</w:t>;同时把 <w:tab/> 与 <w:br/> 当作空白保留。
33
+ const runs: string[] = []
34
+ const runRe = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:tab\b[^>]*\/>|<w:br\b[^>]*\/>/g
35
+ for (const match of segment.matchAll(runRe)) {
36
+ if (match[0].startsWith('<w:tab')) {
37
+ runs.push('\t')
38
+ } else if (match[0].startsWith('<w:br')) {
39
+ runs.push('\n')
40
+ } else {
41
+ runs.push(decodeEntities(match[1] ?? ''))
42
+ }
43
+ }
44
+ paragraphs.push(runs.join('').replace(/\u00a0/g, ' ').trimEnd())
45
+ }
46
+
47
+ // 折叠 3+ 连续空行并去首尾空白。
48
+ const text = paragraphs.join('\n').replace(/\n{3,}/g, '\n\n').trim()
49
+ if (text.length === 0) {
50
+ throw new Error('docx 中没有可提取的文本')
51
+ }
52
+ return text
53
+ }
package/src/docx.ts CHANGED
@@ -1,83 +1,44 @@
1
- /**
2
- * docx outline extraction: a .docx is a zip whose word/document.xml holds the
3
- * body text in <w:t> runs inside <w:p> paragraphs. We unzip with fflate and
4
- * walk the XML with a tiny tokenizer — no heavyweight XML/DOM dependency.
5
- */
6
-
7
- import { readFileSync } from 'node:fs'
8
- import { unzipSync, strFromU8 } from 'fflate'
9
-
10
- /** Decode the handful of XML entities docx bodies actually use. */
11
- function decodeEntities(text: string): string {
12
- return text
13
- .replace(/&lt;/g, '<')
14
- .replace(/&gt;/g, '>')
15
- .replace(/&quot;/g, '"')
16
- .replace(/&apos;/g, "'")
17
- .replace(/&amp;/g, '&')
18
- .replace(/&nbsp;/g, ' ')
19
- }
20
-
21
- /**
22
- * Extract plain text from a docx buffer: one line per <w:p> paragraph, with
23
- * <w:tab>/<w:br> preserved as whitespace. Tables and nested structures are
24
- * flattened in document order (their paragraphs are just <w:p> too).
25
- * @param buffer - the raw .docx bytes.
26
- * @returns the body text.
27
- */
28
- export function extractDocxText(buffer: Uint8Array): string {
29
- let files: ReturnType<typeof unzipSync>
30
- try {
31
- files = unzipSync(buffer)
32
- } catch (error) {
33
- throw new Error(`not a valid docx (zip open failed): ${(error as Error).message}`)
34
- }
35
- const document = files['word/document.xml']
36
- if (document === undefined) {
37
- throw new Error('not a valid docx (word/document.xml missing)')
38
- }
39
- const xml = strFromU8(document)
40
-
41
- const paragraphs: string[] = []
42
- // Split on paragraph boundaries; keep the segment text of each.
43
- const parts = xml.split(/<w:p\b[^>]*>/)
44
- for (let i = 1; i < parts.length; i++) {
45
- const segment = parts[i]!
46
- // Runs <w:t ...>…</w:t>; also honor <w:tab/> and <w:br/> as spaces.
47
- const runs: string[] = []
48
- const runRe = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:tab\b[^>]*\/>|<w:br\b[^>]*\/>/g
49
- for (const match of segment.matchAll(runRe)) {
50
- if (match[0].startsWith('<w:tab')) {
51
- runs.push('\t')
52
- } else if (match[0].startsWith('<w:br')) {
53
- runs.push('\n')
54
- } else {
55
- runs.push(decodeEntities(match[1] ?? ''))
56
- }
57
- }
58
- const line = runs.join('').replace(/\u00a0/g, ' ').trimEnd()
59
- paragraphs.push(line)
60
- }
61
-
62
- // Collapse 3+ blank lines and trim.
63
- const text = paragraphs.join('\n').replace(/\n{3,}/g, '\n\n').trim()
64
- if (text.length === 0) {
65
- throw new Error('docx contains no extractable text')
66
- }
67
- return text
68
- }
69
-
70
- /**
71
- * Read and extract a docx outline from disk.
72
- * @param path - absolute path to the .docx file.
73
- * @returns the extracted outline text.
74
- */
75
- export function readOutlineFromDocx(path: string): string {
76
- let buffer: Buffer
77
- try {
78
- buffer = readFileSync(path)
79
- } catch (error) {
80
- throw new Error(`cannot read outline file "${path}": ${(error as Error).message}`)
81
- }
82
- return extractDocxText(new Uint8Array(buffer))
83
- }
1
+ /**
2
+ * docx outline extraction: a .docx is a zip whose word/document.xml holds the
3
+ * body text in <w:t> runs inside <w:p> paragraphs. We unzip with fflate and
4
+ * walk the XML with a tiny tokenizer — no heavyweight XML/DOM dependency.
5
+ * XML 解析共享核心在 ./docx-core.ts(与浏览器端同源)。
6
+ */
7
+
8
+ import { readFileSync } from 'node:fs'
9
+ import { unzipSync, strFromU8 } from 'fflate'
10
+ import { extractDocxTextFromXml } from './docx-core.ts'
11
+
12
+ /**
13
+ * Extract plain text from a docx buffer: one line per <w:p> paragraph.
14
+ * @param buffer - the raw .docx bytes.
15
+ * @returns the body text.
16
+ */
17
+ export function extractDocxText(buffer: Uint8Array): string {
18
+ let files: ReturnType<typeof unzipSync>
19
+ try {
20
+ files = unzipSync(buffer)
21
+ } catch (error) {
22
+ throw new Error(`不是有效的 docx(zip 解压失败):${(error as Error).message}`)
23
+ }
24
+ const document = files['word/document.xml']
25
+ if (document === undefined) {
26
+ throw new Error('不是有效的 docx(缺少 word/document.xml)')
27
+ }
28
+ return extractDocxTextFromXml(strFromU8(document))
29
+ }
30
+
31
+ /**
32
+ * Read and extract a docx outline from disk.
33
+ * @param path - absolute path to the .docx file.
34
+ * @returns the extracted outline text.
35
+ */
36
+ export function readOutlineFromDocx(path: string): string {
37
+ let buffer: Buffer
38
+ try {
39
+ buffer = readFileSync(path)
40
+ } catch (error) {
41
+ throw new Error(`cannot read outline file "${path}": ${(error as Error).message}`)
42
+ }
43
+ return extractDocxText(new Uint8Array(buffer))
44
+ }