diff --git a/package.json b/package.json
index 200f9172..b4ec9aa3 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"license": "MIT",
"scripts": {
"dev": "electron-vite dev",
- "test": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/shared/continuation-import.test.mjs electron/main/workspace-types.test.mjs renderer/src/features/workspace/outlineReorder.test.mjs renderer/src/features/workspace/volumeCollapseState.test.mjs renderer/src/features/workspace/workbenchMenu.test.mjs renderer/src/features/ai/taskRegistry.test.mjs renderer/src/features/ai/outlineReferences.test.mjs renderer/src/features/tutorials/tutorials.test.mjs renderer/src/features/knowledge/knowledgeCenter.test.mjs electron/main/story-state-store.test.mjs electron/main/knowledge-document-schema.test.mjs electron/main/ai/settings.test.mjs electron/main/ai/generate.test.mjs electron/main/ai/provider.test.mjs electron/main/ai/protocol-adapter.test.mjs electron/main/ai/proxy-fetch.test.mjs electron/main/ai/knowledge-retrieval.test.mjs electron/main/ai/state-backfill-store.test.mjs electron/main/ai/state-backfill-task-controller.test.mjs electron/main/ai/runtime/background-task-coordinator.test.mjs electron/main/ai/runtime/chapter-processing-store.test.mjs electron/main/ai/tasks/chapter-session-note.test.mjs electron/main/ai/tasks/outline-context.test.mjs electron/main/ai/tasks/story-state-generation.test.mjs electron/main/ai/tasks/worldview-type.test.mjs electron/main/ai/tasks/ranking-analysis.test.mjs electron/main/ai/tasks/qimao-ranking.test.mjs electron/main/ai/tasks/zongheng-ranking.test.mjs electron/main/ai/runtime-v2/conversation-manager.test.mjs electron/main/ai/runtime-v2/staged-changes-store.test.mjs electron/main/ai/runtime-v2/context-builder.test.mjs electron/main/ai/runtime-v2/agent-loop.test.mjs electron/main/ai/agent/tools/text-window.test.mjs electron/main/ai/agent/tools/knowledge-tools.test.mjs electron/main/ai/agent/tools/chapter-data-access.test.mjs electron/main/ai/agent/tools/chapter-html-edit.test.mjs electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs",
+ "test": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/shared/continuation-import.test.mjs electron/main/workspace-types.test.mjs renderer/src/features/workspace/outlineReorder.test.mjs renderer/src/features/workspace/volumeCollapseState.test.mjs renderer/src/features/workspace/workbenchMenu.test.mjs renderer/src/features/chapters/chapterFormatting.test.mjs renderer/src/features/ai/taskRegistry.test.mjs renderer/src/features/ai/outlineReferences.test.mjs renderer/src/features/tutorials/tutorials.test.mjs renderer/src/features/knowledge/knowledgeCenter.test.mjs electron/main/story-state-store.test.mjs electron/main/knowledge-document-schema.test.mjs electron/main/ai/settings.test.mjs electron/main/ai/generate.test.mjs electron/main/ai/provider.test.mjs electron/main/ai/protocol-adapter.test.mjs electron/main/ai/proxy-fetch.test.mjs electron/main/ai/knowledge-retrieval.test.mjs electron/main/ai/state-backfill-store.test.mjs electron/main/ai/state-backfill-task-controller.test.mjs electron/main/ai/runtime/background-task-coordinator.test.mjs electron/main/ai/runtime/chapter-processing-store.test.mjs electron/main/ai/tasks/chapter-session-note.test.mjs electron/main/ai/tasks/outline-context.test.mjs electron/main/ai/tasks/story-state-generation.test.mjs electron/main/ai/tasks/worldview-type.test.mjs electron/main/ai/tasks/ranking-analysis.test.mjs electron/main/ai/tasks/qimao-ranking.test.mjs electron/main/ai/tasks/zongheng-ranking.test.mjs electron/main/ai/runtime-v2/conversation-manager.test.mjs electron/main/ai/runtime-v2/staged-changes-store.test.mjs electron/main/ai/runtime-v2/context-builder.test.mjs electron/main/ai/runtime-v2/agent-loop.test.mjs electron/main/ai/agent/tools/text-window.test.mjs electron/main/ai/agent/tools/knowledge-tools.test.mjs electron/main/ai/agent/tools/chapter-data-access.test.mjs electron/main/ai/agent/tools/chapter-html-edit.test.mjs electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs",
"test:ranking": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/main/ai/tasks/ranking-analysis.test.mjs",
"typecheck": "vue-tsc --noEmit",
"build": "pnpm run typecheck && electron-vite build",
diff --git a/renderer/src/components/chapterWorkspace/ChapterEditorPane.vue b/renderer/src/components/chapterWorkspace/ChapterEditorPane.vue
index adda732d..0b833513 100644
--- a/renderer/src/components/chapterWorkspace/ChapterEditorPane.vue
+++ b/renderer/src/components/chapterWorkspace/ChapterEditorPane.vue
@@ -1,6 +1,6 @@
@@ -242,10 +255,14 @@ defineExpose({ editor, restoreRecovery, discardRecovery })
}
:deep(.simple-editor p) {
- margin-bottom: 16px;
+ margin: 0 0 1.8em;
text-indent: 2em;
}
+:deep(.simple-editor p:last-child) {
+ margin-bottom: 0;
+}
+
:deep(.simple-editor p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: left;
diff --git a/renderer/src/features/chapters/chapterFormatting.test.mjs b/renderer/src/features/chapters/chapterFormatting.test.mjs
new file mode 100644
index 00000000..4b09f606
--- /dev/null
+++ b/renderer/src/features/chapters/chapterFormatting.test.mjs
@@ -0,0 +1,64 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { formatChapterEditorDocument } from './chapterFormatting.ts'
+
+test('一键排版将段内换行拆为段落并删除空段', () => {
+ const result = formatChapterEditorDocument({
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ { type: 'text', text: ' 第一段 ' },
+ { type: 'hardBreak' },
+ { type: 'hardBreak' },
+ { type: 'text', text: ' 第二段 ' }
+ ]
+ },
+ { type: 'paragraph' }
+ ]
+ })
+
+ assert.equal(result.changed, true)
+ assert.equal(result.paragraphCount, 2)
+ assert.deepEqual(result.document.content, [
+ { type: 'paragraph', content: [{ type: 'text', text: '第一段' }] },
+ { type: 'paragraph', content: [{ type: 'text', text: '第二段' }] }
+ ])
+})
+
+test('一键排版保留行内格式和非段落节点', () => {
+ const heading = { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '场景一' }] }
+ const result = formatChapterEditorDocument({
+ type: 'doc',
+ content: [
+ heading,
+ {
+ type: 'paragraph',
+ content: [
+ { type: 'text', text: ' 重要', marks: [{ type: 'bold' }] },
+ { type: 'text', text: '内容', marks: [{ type: 'underline' }] }
+ ]
+ }
+ ]
+ })
+
+ assert.deepEqual(result.document.content?.[0], heading)
+ assert.deepEqual(result.document.content?.[1]?.content, [
+ { type: 'text', text: '重要', marks: [{ type: 'bold' }] },
+ { type: 'text', text: '内容', marks: [{ type: 'underline' }] }
+ ])
+})
+
+test('空正文排版后仍保留一个可编辑段落', () => {
+ const result = formatChapterEditorDocument({
+ type: 'doc',
+ content: [{ type: 'paragraph' }, { type: 'paragraph' }]
+ })
+
+ assert.deepEqual(result.document, {
+ type: 'doc',
+ content: [{ type: 'paragraph' }]
+ })
+ assert.equal(result.paragraphCount, 0)
+})
diff --git a/renderer/src/features/chapters/chapterFormatting.ts b/renderer/src/features/chapters/chapterFormatting.ts
new file mode 100644
index 00000000..1cda982a
--- /dev/null
+++ b/renderer/src/features/chapters/chapterFormatting.ts
@@ -0,0 +1,115 @@
+import type { JSONContent } from '@tiptap/core'
+
+export type ChapterFormatResult = {
+ document: JSONContent
+ changed: boolean
+ paragraphCount: number
+}
+
+/**
+ * 整理正文的块级结构:
+ * - 段落内的硬换行拆为独立段落;
+ * - 删除多余空段和段首/段尾手工空格;
+ * - 保留文字的 marks(加粗、下划线等)和非段落块节点。
+ *
+ * 首行缩进和段间距由编辑器 CSS 统一呈现,避免在正文中插入
+ * 全角空格或空段落,导致字数和导出内容被污染。
+ */
+export function formatChapterEditorDocument(document: JSONContent): ChapterFormatResult {
+ const sourceContent = Array.isArray(document.content) ? document.content : []
+ const content: JSONContent[] = []
+ let paragraphCount = 0
+
+ for (const node of sourceContent) {
+ if (node.type !== 'paragraph') {
+ content.push(node)
+ continue
+ }
+
+ const paragraphs = splitParagraphNode(node)
+ paragraphCount += paragraphs.length
+ content.push(...paragraphs)
+ }
+
+ if (content.length === 0) {
+ content.push({ type: 'paragraph' })
+ }
+
+ const formatted: JSONContent = {
+ ...document,
+ type: document.type || 'doc',
+ content
+ }
+
+ return {
+ document: formatted,
+ changed: JSON.stringify(formatted) !== JSON.stringify(document),
+ paragraphCount
+ }
+}
+
+function splitParagraphNode(node: JSONContent): JSONContent[] {
+ const segments: JSONContent[][] = [[]]
+
+ for (const inline of node.content ?? []) {
+ if (inline.type === 'hardBreak') {
+ segments.push([])
+ continue
+ }
+
+ if (inline.type === 'text' && typeof inline.text === 'string' && /[\r\n]/.test(inline.text)) {
+ const parts = inline.text.replace(/\r\n?/g, '\n').split('\n')
+ parts.forEach((part, index) => {
+ if (index > 0) segments.push([])
+ if (part) segments[segments.length - 1].push({ ...inline, text: part })
+ })
+ continue
+ }
+
+ segments[segments.length - 1].push(inline)
+ }
+
+ return segments
+ .map(trimInlineWhitespace)
+ .filter((segment) => hasVisibleInlineContent(segment))
+ .map((segment) => ({
+ ...node,
+ content: segment
+ }))
+}
+
+function trimInlineWhitespace(content: JSONContent[]): JSONContent[] {
+ const trimmed = content.map((node) => ({ ...node }))
+
+ for (let index = 0; index < trimmed.length; index += 1) {
+ const node = trimmed[index]
+ if (node.type !== 'text' || typeof node.text !== 'string') continue
+ node.text = node.text.replace(/^[\s\u3000]+/u, '')
+ if (!node.text) {
+ trimmed.splice(index, 1)
+ index -= 1
+ continue
+ }
+ break
+ }
+
+ for (let index = trimmed.length - 1; index >= 0; index -= 1) {
+ const node = trimmed[index]
+ if (node.type !== 'text' || typeof node.text !== 'string') continue
+ node.text = node.text.replace(/[\s\u3000]+$/u, '')
+ if (!node.text) {
+ trimmed.splice(index, 1)
+ continue
+ }
+ break
+ }
+
+ return trimmed
+}
+
+function hasVisibleInlineContent(content: JSONContent[]): boolean {
+ return content.some((node) => {
+ if (node.type === 'text') return Boolean(node.text?.trim())
+ return node.type !== 'hardBreak'
+ })
+}