@waterwx/dsh-novel-forge 1.3.0 → 1.3.1-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 (147) hide show
  1. package/README.md +287 -279
  2. package/lib/client.js +56031 -12138
  3. package/lib/index.js +14206 -5585
  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/docx.d.ts +2 -0
  12. package/lib/types/client/index.d.ts +1 -1
  13. package/lib/types/client/llmLive.d.ts +31 -0
  14. package/lib/types/client/locales.d.ts +28 -67
  15. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  16. package/lib/types/client/panel/AiTag.d.ts +1 -0
  17. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  18. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  19. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  20. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  21. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  22. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  23. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  24. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  25. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  26. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  27. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  28. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  29. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  30. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  31. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  32. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  33. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  34. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  35. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  36. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  37. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  38. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  39. package/lib/types/client/panel/SubPage.d.ts +27 -0
  40. package/lib/types/client/panel/TensionView.d.ts +18 -0
  41. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  42. package/lib/types/client/panel/helpers.d.ts +5 -3
  43. package/lib/types/client/panel/toast.d.ts +2 -0
  44. package/lib/types/client/panel/views.d.ts +25 -7
  45. package/lib/types/client/run-status.d.ts +19 -0
  46. package/lib/types/client/text.d.ts +9 -0
  47. package/lib/types/docx-core.d.ts +16 -0
  48. package/lib/types/docx.d.ts +2 -3
  49. package/lib/types/engine.d.ts +186 -45
  50. package/lib/types/global-assets.d.ts +6 -0
  51. package/lib/types/index.d.ts +22 -8
  52. package/lib/types/llm-live.d.ts +75 -0
  53. package/lib/types/llm-retry.d.ts +38 -0
  54. package/lib/types/market-radar-scan.d.ts +19 -0
  55. package/lib/types/market-radar-sources.d.ts +31 -0
  56. package/lib/types/migrate-output.d.ts +25 -0
  57. package/lib/types/novel-context.d.ts +80 -0
  58. package/lib/types/prompt-slots.d.ts +57 -0
  59. package/lib/types/prompt-utils.d.ts +35 -0
  60. package/lib/types/prompting.d.ts +10 -0
  61. package/lib/types/protocol.d.ts +1356 -166
  62. package/lib/types/review-policy.d.ts +97 -0
  63. package/lib/types/revision.d.ts +76 -0
  64. package/lib/types/routes.d.ts +4 -0
  65. package/lib/types/run.d.ts +11 -1
  66. package/lib/types/snapshots.d.ts +46 -0
  67. package/lib/types/stage-contract.d.ts +60 -0
  68. package/lib/types/tension.d.ts +53 -0
  69. package/lib/types/timeline.d.ts +37 -0
  70. package/package.json +52 -27
  71. package/scripts/patch-schemastery.mjs +47 -0
  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/docx.ts +37 -69
  80. package/src/client/index.ts +3 -1
  81. package/src/client/llmLive.ts +111 -0
  82. package/src/client/locales.ts +28 -253
  83. package/src/client/mount.tsx +1 -1
  84. package/src/client/panel/AdaptModeView.tsx +535 -0
  85. package/src/client/panel/AiTag.tsx +7 -0
  86. package/src/client/panel/AssetsTab.tsx +448 -458
  87. package/src/client/panel/AssistantTab.tsx +355 -301
  88. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  89. package/src/client/panel/AuthorHome.tsx +309 -0
  90. package/src/client/panel/BookAnalysisView.tsx +63 -0
  91. package/src/client/panel/CmdPalette.tsx +102 -0
  92. package/src/client/panel/CreateBookView.tsx +366 -348
  93. package/src/client/panel/DirectorView.tsx +200 -0
  94. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  95. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  96. package/src/client/panel/ImportModal.tsx +303 -0
  97. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  98. package/src/client/panel/LiveFeedLog.tsx +204 -0
  99. package/src/client/panel/MarketRadarView.tsx +377 -0
  100. package/src/client/panel/ModelManager.tsx +205 -0
  101. package/src/client/panel/NovelPanel.tsx +3135 -2651
  102. package/src/client/panel/PageHeader.tsx +37 -0
  103. package/src/client/panel/ProgressConsole.tsx +63 -0
  104. package/src/client/panel/PromptSlotsView.tsx +97 -0
  105. package/src/client/panel/ReaderView.tsx +306 -306
  106. package/src/client/panel/ReasoningSection.tsx +31 -0
  107. package/src/client/panel/RunPanel.tsx +223 -204
  108. package/src/client/panel/SettingsView.tsx +340 -0
  109. package/src/client/panel/ShelfView.tsx +266 -236
  110. package/src/client/panel/SubPage.tsx +42 -0
  111. package/src/client/panel/TensionView.tsx +212 -0
  112. package/src/client/panel/TimelineView.tsx +208 -0
  113. package/src/client/panel/WorldTab.tsx +202 -202
  114. package/src/client/panel/helpers.ts +7 -5
  115. package/src/client/panel/panel.module.css +5764 -2941
  116. package/src/client/panel/toast.ts +51 -0
  117. package/src/client/panel/views.tsx +401 -314
  118. package/src/client/run-status.ts +143 -0
  119. package/src/client/sidebar-entry.ts +1 -1
  120. package/src/client/text.ts +28 -0
  121. package/src/docx-core.ts +53 -0
  122. package/src/docx.ts +44 -83
  123. package/src/engine.ts +4672 -2943
  124. package/src/global-assets.ts +59 -0
  125. package/src/index.ts +89 -33
  126. package/src/llm-live.ts +260 -0
  127. package/src/llm-retry.ts +77 -0
  128. package/src/market-radar-scan.ts +41 -0
  129. package/src/market-radar-sources.ts +173 -0
  130. package/src/migrate-output.ts +76 -0
  131. package/src/novel-context.ts +286 -0
  132. package/src/prompt-slots.ts +125 -0
  133. package/src/prompting.ts +26 -0
  134. package/src/protocol.ts +1382 -158
  135. package/src/review-policy.ts +161 -0
  136. package/src/revision.ts +283 -0
  137. package/src/routes.ts +1934 -434
  138. package/src/run.ts +81 -20
  139. package/src/snapshots.ts +174 -0
  140. package/src/stage-contract.ts +189 -0
  141. package/src/tension.ts +251 -0
  142. package/src/timeline.ts +159 -0
  143. package/lib/client.js.map +0 -1
  144. package/lib/index.js.map +0 -1
  145. package/lib/types/comic-presets.d.ts +0 -12
  146. package/src/client/panel/BookshelfBar.tsx +0 -170
  147. package/src/comic-presets.ts +0 -67
@@ -0,0 +1,143 @@
1
+ /**
2
+ * 运行状态芯片:固定在页面顶部的「小说工坊生成状态」小徽标。
3
+ * 轮询 /run/status,实时显示 生成中/暂停/完成/停止/出错 + 当前章号;
4
+ * 点击可打开/关闭小说工坊面板。独立 DOM 注入,不触碰主面板逻辑,挂载失败静默降级。
5
+ *
6
+ * P0 重写说明(原实现的三个问题):
7
+ * 1. 整块背景染状态色 + 固定白字 → 浅色主题下「白字配亮底」对比度崩坏。
8
+ * 现在改为「玻璃底 + 主题前景色文字 + 左侧状态色点」,状态只由色点承载,
9
+ * 文字永远跟随主题,任何深浅色下都可读。
10
+ * 2. 样式写死在 JS 的 inline style 里,切主题纹丝不动。
11
+ * 现在改用 CSS Module class,配色走 --nf-chip-* 令牌,
12
+ * 由 body[data-ds-dark-theme] 跟随宿主深浅色。
13
+ * 3. role="button" 却没有 tabindex、没有键盘处理,键盘用户完全用不了。
14
+ * 现在补 tabindex + Enter/Space 激活 + aria-label/live。
15
+ */
16
+ import type { NovelApi } from './api.ts'
17
+ import type { PanelController } from './panel/controller.ts'
18
+ import type { RunState } from '../protocol.ts'
19
+ import css from './panel/panel.module.css'
20
+
21
+ const REFRESH_MS = 4000
22
+
23
+ /** 状态文案。状态语义由左侧色点承载,这里不再放 emoji(避免与色点重复表意)。 */
24
+ function label(s: RunState): string {
25
+ const no = s.currentNo
26
+ switch (s.status) {
27
+ case 'running': return `小说工坊 生成中 · 第${no}章`
28
+ case 'paused': return `小说工坊 已暂停 · 第${no}章`
29
+ case 'done': return `小说工坊 批完成 ${s.startNo}–${s.endNo}`
30
+ case 'stopped': return `小说工坊 已停止 · 第${no}章`
31
+ case 'error': return `小说工坊 出错 · 第${no}章`
32
+ default: return '小说工坊'
33
+ }
34
+ }
35
+
36
+ /** 生成一个固定顶部的状态芯片并开始轮询。返回 dispose。 */
37
+ export function mountRunStatus(controller: PanelController, api: NovelApi): () => void {
38
+ const chip = document.createElement('div')
39
+ chip.className = css.runChip
40
+ chip.setAttribute('role', 'button')
41
+ // P0: role="button" 必须可聚焦、可键盘激活,否则对键盘/读屏用户等同不存在。
42
+ chip.setAttribute('tabindex', '0')
43
+ chip.setAttribute('title', '小说工坊生成状态(点击打开工坊)')
44
+ // 状态是异步轮询出来的,用 aria-live 让读屏用户也能感知变化。
45
+ chip.setAttribute('aria-live', 'polite')
46
+
47
+ const dot = document.createElement('span')
48
+ dot.className = css.runChipDot
49
+ // 纯装饰,颜色信息已由文字表达,不要让读屏重复播报。
50
+ dot.setAttribute('aria-hidden', 'true')
51
+
52
+ const text = document.createElement('span')
53
+ text.className = css.runChipText
54
+
55
+ chip.append(dot, text)
56
+
57
+ const toggle = (): void => { controller.toggle() }
58
+
59
+ // 自由拖拽 + 位置记忆(与 AI 进度浮窗一致)
60
+ const POS_KEY = 'dsh-novel-forge.runChip.pos'
61
+ let dragging = false, moved = false, sx = 0, sy = 0, ox = 0, oy = 0
62
+ const readPos = (): { x: number; y: number } | null => {
63
+ try { const p = window.localStorage.getItem(POS_KEY); return p === null ? null : JSON.parse(p) as { x: number; y: number } } catch { return null }
64
+ }
65
+ const writePos = (x: number, y: number): void => {
66
+ try { window.localStorage.setItem(POS_KEY, JSON.stringify({ x, y })) } catch { /* ignore */ }
67
+ }
68
+ const clampX = (x: number): number => Math.max(0, Math.min(Math.max(0, window.innerWidth - 40), x))
69
+ const clampY = (y: number): number => Math.max(0, Math.min(Math.max(0, window.innerHeight - 40), y))
70
+ const applyPos = (x: number, y: number): void => {
71
+ chip.style.left = clampX(x) + 'px'
72
+ chip.style.top = clampY(y) + 'px'
73
+ chip.style.right = 'auto'
74
+ }
75
+ chip.addEventListener('pointerdown', (e) => {
76
+ if (e.button !== 0) return
77
+ dragging = true; moved = false; sx = e.clientX; sy = e.clientY
78
+ ox = chip.offsetLeft; oy = chip.offsetTop
79
+ try { chip.setPointerCapture(e.pointerId) } catch { /* ignore */ }
80
+ })
81
+ chip.addEventListener('pointermove', (e) => {
82
+ if (!dragging) return
83
+ const dx = e.clientX - sx, dy = e.clientY - sy
84
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true
85
+ if (moved) {
86
+ applyPos(ox + dx, oy + dy)
87
+ writePos(chip.offsetLeft, chip.offsetTop)
88
+ }
89
+ })
90
+ const endDrag = (): void => { dragging = false }
91
+ chip.addEventListener('pointerup', endDrag)
92
+ chip.addEventListener('pointercancel', endDrag)
93
+ // 拖拽后松开不触发开合
94
+ chip.addEventListener('click', (e) => {
95
+ if (moved) { e.preventDefault(); e.stopImmediatePropagation(); moved = false; return }
96
+ toggle()
97
+ })
98
+ chip.addEventListener('keydown', (e) => {
99
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
100
+ e.preventDefault()
101
+ toggle()
102
+ }
103
+ })
104
+
105
+ document.body.appendChild(chip)
106
+
107
+ // 恢复上次拖拽位置(若曾移动过)
108
+ const saved = readPos()
109
+ if (saved !== null) applyPos(saved.x, saved.y)
110
+
111
+ let disposed = false
112
+ let timer: number | undefined
113
+
114
+ const render = (s: RunState | null): void => {
115
+ if (s === null) {
116
+ chip.dataset.visible = 'false'
117
+ return
118
+ }
119
+ let body = label(s)
120
+ if (s.status === 'running' && s.stats.error > 0) body += ` · ×${s.stats.error}`
121
+ text.textContent = body
122
+ chip.dataset.status = s.status
123
+ chip.dataset.visible = 'true'
124
+ chip.setAttribute('aria-label', body)
125
+ }
126
+
127
+ const tick = async (): Promise<void> => {
128
+ if (disposed) return
129
+ try {
130
+ const s = await api.runStatus()
131
+ render(s)
132
+ } catch { /* best-effort */ }
133
+ if (!disposed) timer = window.setTimeout(() => { void tick() }, REFRESH_MS)
134
+ }
135
+
136
+ void tick()
137
+
138
+ return () => {
139
+ disposed = true
140
+ if (timer !== undefined) window.clearTimeout(timer)
141
+ chip.remove()
142
+ }
143
+ }
@@ -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
+ }