@spacecorexdev/core 0.0.4 → 0.0.5

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 (132) hide show
  1. package/package.json +2 -36
  2. package/src/api/_base.ts +243 -0
  3. package/src/api/index.ts +17 -0
  4. package/src/api/project.ts +1345 -0
  5. package/src/api/user.ts +1174 -0
  6. package/src/contract.ts +50 -0
  7. package/src/hooks/index.ts +2 -0
  8. package/{dist/hooks/index.js → src/hooks/useLongPress.ts} +53 -49
  9. package/src/hooks/useOnlineStatus.ts +22 -0
  10. package/src/index.ts +14 -0
  11. package/src/types/id.ts +2 -0
  12. package/src/types/index.ts +5 -0
  13. package/src/types/project/base.ts +63 -0
  14. package/src/types/project/contract.ts +79 -0
  15. package/src/types/project/contractStatus.tsx +15 -0
  16. package/src/types/project/index.ts +5 -0
  17. package/src/types/project/member.ts +46 -0
  18. package/src/types/project/progress.ts +43 -0
  19. package/src/types/projectRole.ts +17 -0
  20. package/src/types/theme.ts +1 -0
  21. package/src/types/user/index.ts +1 -0
  22. package/src/types/user/role.ts +7 -0
  23. package/src/ui/CommentTimeline/index.tsx +330 -0
  24. package/src/ui/FileManager/FileContextMenu.tsx +628 -0
  25. package/src/ui/FileManager/FileGridView.tsx +529 -0
  26. package/src/ui/FileManager/FileTableView.tsx +405 -0
  27. package/src/ui/FileManager/FileToolbar.tsx +119 -0
  28. package/src/ui/FileManager/FileVersionHistoryModal.tsx +175 -0
  29. package/src/ui/FileManager/MoveCopyToProjectModal.tsx +119 -0
  30. package/src/ui/FileManager/ShareToWechatModal.tsx +419 -0
  31. package/src/ui/FileManager/hooks/useFileManagerActions.ts +2532 -0
  32. package/src/ui/FileManager/hooks/useFileManagerState.ts +419 -0
  33. package/src/ui/FileManager/index.tsx +580 -0
  34. package/src/ui/FileManager/projectFileManagerApi.ts +57 -0
  35. package/src/ui/FileManager/types.ts +184 -0
  36. package/src/ui/FileManager/utils/fileDeltaWorker.ts +193 -0
  37. package/src/ui/FileManager/utils/fileTree.ts +178 -0
  38. package/src/ui/FileManager/utils/fileUpload.ts +714 -0
  39. package/src/ui/FileManager/utils/fileUtils.ts +65 -0
  40. package/src/ui/InvoiceRecordsList/index.tsx +813 -0
  41. package/src/ui/MyFileSelectorModal.tsx +261 -0
  42. package/src/ui/ProjectSelector/index.tsx +472 -0
  43. package/src/ui/ProjectSelector/projectCache.ts +40 -0
  44. package/src/ui/RoleSelector/index.tsx +473 -0
  45. package/src/ui/RoleSelector/roleCache.ts +40 -0
  46. package/src/ui/UIAlert.tsx +399 -0
  47. package/src/ui/UIAlertExample.tsx +129 -0
  48. package/src/ui/UIConfirm.tsx +335 -0
  49. package/src/ui/UIConfirmExample.tsx +165 -0
  50. package/src/ui/UIDatePicker.tsx +146 -0
  51. package/src/ui/UIDateRangePicker.tsx +38 -0
  52. package/src/ui/UIFilePreview/ExcelEditor/index.tsx +617 -0
  53. package/src/ui/UIFilePreview/ExcelEditor/types.ts +24 -0
  54. package/src/ui/UIFilePreview/ExportDropdown.css +85 -0
  55. package/src/ui/UIFilePreview/ExportDropdown.tsx +109 -0
  56. package/src/ui/UIFilePreview/IndexedDBUtils.ts +564 -0
  57. package/src/ui/UIFilePreview/MarkdownEditor/exportMarkdownPdf.ts +576 -0
  58. package/src/ui/UIFilePreview/MarkdownEditor/index.tsx +677 -0
  59. package/src/ui/UIFilePreview/MarkdownEditor/markdownEditor.css +1005 -0
  60. package/src/ui/UIFilePreview/MarkdownEditor/types.ts +15 -0
  61. package/src/ui/UIFilePreview/PPTXEditor/index.tsx +602 -0
  62. package/src/ui/UIFilePreview/RichDocEditor/exportRDocMarkdown.ts +77 -0
  63. package/src/ui/UIFilePreview/RichDocEditor/exportRDocPdf.ts +374 -0
  64. package/src/ui/UIFilePreview/RichDocEditor/index.tsx +730 -0
  65. package/src/ui/UIFilePreview/RichDocEditor/richdoc-editor.css +59 -0
  66. package/src/ui/UIFilePreview/UIFilePreview.tsx +611 -0
  67. package/src/ui/UIFilePreview/WordEditor/index.tsx +3933 -0
  68. package/src/ui/UIFilePreview/WordEditor/style.ts +1213 -0
  69. package/src/ui/UIFilePreview/WordEditor/word-editor.css +999 -0
  70. package/src/ui/UIFilePreview/filePreview.css +193 -0
  71. package/src/ui/UIFilePreview/filetypes/archive.tsx +66 -0
  72. package/src/ui/UIFilePreview/filetypes/cad.tsx +287 -0
  73. package/src/ui/UIFilePreview/filetypes/excel.tsx +355 -0
  74. package/src/ui/UIFilePreview/filetypes/image.tsx +20 -0
  75. package/src/ui/UIFilePreview/filetypes/imageLoader.ts +63 -0
  76. package/src/ui/UIFilePreview/filetypes/pdf.tsx +156 -0
  77. package/src/ui/UIFilePreview/filetypes/ppt.tsx +535 -0
  78. package/src/ui/UIFilePreview/filetypes/pptExportPdf.ts +523 -0
  79. package/src/ui/UIFilePreview/filetypes/video.tsx +482 -0
  80. package/src/ui/UIFilePreview/filetypes/word.tsx +245 -0
  81. package/src/ui/UIFilePreview/filetypes/wordExportMarkdown.ts +86 -0
  82. package/src/ui/UIFilePreview/filetypes/wordExportPdf.tsx +427 -0
  83. package/src/ui/UIFilePreview/index.ts +35 -0
  84. package/src/ui/UIFilePreview/pdfPagination.ts +143 -0
  85. package/src/ui/UIFilePreview/previewUtils.tsx +531 -0
  86. package/src/ui/UIFileUpload.tsx +382 -0
  87. package/src/ui/UIImageUpload.tsx +195 -0
  88. package/src/ui/UILoading.tsx +255 -0
  89. package/src/ui/UILoadingExample.tsx +139 -0
  90. package/src/ui/UISelect.tsx +105 -0
  91. package/src/ui/UIToast.tsx +381 -0
  92. package/src/ui/UIToastExample.tsx +69 -0
  93. package/src/ui/UIToastSingle.tsx +279 -0
  94. package/src/ui/UIViewToggle.tsx +64 -0
  95. package/src/ui/UserSelector/index.tsx +583 -0
  96. package/src/ui/index.ts +84 -0
  97. package/src/ui/upload/image.ts +135 -0
  98. package/src/ui/upload/index.ts +73 -0
  99. package/src/ui/upload/submitFile.ts +106 -0
  100. package/src/ui/upload/submitForm.ts +124 -0
  101. package/src/ui/upload/upload.err.ts +15 -0
  102. package/src/ui/upload/upload.ts +250 -0
  103. package/src/utils/CacheDepartmentInfo.ts +372 -0
  104. package/src/utils/CacheFileContent.ts +315 -0
  105. package/src/utils/CachePermissionInfo.ts +235 -0
  106. package/src/utils/CacheRoleInfo.ts +303 -0
  107. package/src/utils/CacheUserInfo.ts +818 -0
  108. package/src/utils/CacheViewToggleInfo.ts +182 -0
  109. package/src/utils/MessageContentUtils.ts +268 -0
  110. package/src/utils/clipboard.ts +70 -0
  111. package/src/utils/companyEmail.ts +19 -0
  112. package/src/utils/forbidDebugger.ts +59 -0
  113. package/src/utils/formatters.ts +94 -0
  114. package/src/utils/htmlUtils.tsx +59 -0
  115. package/src/utils/index.ts +18 -0
  116. package/src/utils/keyboard.ts +114 -0
  117. package/src/utils/mammothApi.ts +16 -0
  118. package/src/utils/polyfills.ts +928 -0
  119. package/src/utils/regex.ts +14 -0
  120. package/src/utils/storage.ts +95 -0
  121. package/src/utils/workerBootstrap.ts +87 -0
  122. package/dist/api/index.js +0 -1553
  123. package/dist/contract.js +0 -5
  124. package/dist/index.js +0 -4492
  125. package/dist/types/index.js +0 -49
  126. package/dist/ui/index.css +0 -1876
  127. package/dist/ui/index.js +0 -22245
  128. package/dist/utils/index.js +0 -4296
  129. /package/{dist → src}/styles/ant-modal.css +0 -0
  130. /package/{dist → src}/styles/ant-spin.css +0 -0
  131. /package/{dist → src}/styles/ant-table.css +0 -0
  132. /package/{dist → src}/styles/index.css +0 -0
@@ -0,0 +1,1213 @@
1
+ import { Document, Style } from "@eigenpal/docx-editor-core";
2
+ import { createEmptyDocument } from "@eigenpal/docx-editor-react";
3
+
4
+ const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
5
+
6
+ // 自定义字体列表 - 显示在字体下拉菜单中
7
+ // 注意:@eigenpal/docx-editor-core 的 buildFontString 只使用 fontFamily 中的首个字体名,
8
+ // 然后根据 category 自动追加系统级的回退字体栈。因此 fontFamily 的第一个名字必须是
9
+ // macOS/Windows 都可识别的系统字体名(如 STFangsong),而不是仅限 Windows 的名字(如 FangSong)。
10
+ export const CUSTOM_FONT_FAMILIES = [
11
+ // 中文字体
12
+ { name: '宋体', fontFamily: 'STSong', category: 'serif' as const },
13
+ { name: '黑体', fontFamily: 'STHeiti', category: 'sans-serif' as const },
14
+ { name: '微软雅黑', fontFamily: '"Microsoft YaHei"', category: 'sans-serif' as const },
15
+ { name: '楷体', fontFamily: 'STKaiti', category: 'serif' as const },
16
+ { name: '仿宋', fontFamily: 'STFangsong', category: 'serif' as const },
17
+ { name: '华文宋体', fontFamily: 'STSong', category: 'serif' as const },
18
+ { name: '华文黑体', fontFamily: 'STHeiti', category: 'sans-serif' as const },
19
+ { name: '华文楷体', fontFamily: 'STKaiti', category: 'serif' as const },
20
+ { name: '华文仿宋', fontFamily: 'STFangsong', category: 'serif' as const },
21
+ // 英文字体
22
+ { name: 'Arial', fontFamily: 'Arial', category: 'sans-serif' as const },
23
+ { name: 'Times New Roman', fontFamily: '"Times New Roman"', category: 'serif' as const },
24
+ { name: 'Courier New', fontFamily: '"Courier New"', category: 'monospace' as const },
25
+ { name: 'Georgia', fontFamily: 'Georgia', category: 'serif' as const },
26
+ { name: 'Verdana', fontFamily: 'Verdana', category: 'sans-serif' as const },
27
+ { name: 'Tahoma', fontFamily: 'Tahoma', category: 'sans-serif' as const },
28
+ { name: 'Helvetica', fontFamily: 'Helvetica', category: 'sans-serif' as const },
29
+ ];
30
+
31
+ // 预设样式定义
32
+ export const PRESET_STYLES: Style[] = [
33
+ // 正文样式(默认)
34
+ {
35
+ styleId: 'Normal',
36
+ type: 'paragraph',
37
+ name: '正文',
38
+ default: true,
39
+ qFormat: true,
40
+ rPr: {
41
+ fontSize: 30, // 15pt
42
+ bold: false,
43
+ fontFamily: { ascii: 'STFangsong', hAnsi: 'STFangsong', eastAsia: 'STFangsong' },
44
+ },
45
+ pPr: {
46
+ lineSpacing: 360,
47
+ lineSpacingRule: 'auto',
48
+ },
49
+ },
50
+ // 标题 1
51
+ {
52
+ styleId: 'Heading1',
53
+ type: 'paragraph',
54
+ name: '标题 1',
55
+ basedOn: 'Normal',
56
+ next: 'Normal',
57
+ qFormat: true,
58
+ uiPriority: 9,
59
+ pPr: {
60
+ lineSpacing: 320,
61
+ lineSpacingRule: 'exact',
62
+ spaceBefore: 320,
63
+ spaceAfter: 320,
64
+ beforeAutospacing: false,
65
+ afterAutospacing: false,
66
+ spacingExplicit: { before: true, after: true },
67
+ outlineLevel: 1,
68
+ },
69
+ rPr: {
70
+ fontSize: 32, // 16pt
71
+ bold: true,
72
+ fontFamily: { ascii: 'STHeiti', hAnsi: 'STHeiti', eastAsia: 'STHeiti' },
73
+ },
74
+ },
75
+ // 标题 2
76
+ {
77
+ styleId: 'Heading2',
78
+ type: 'paragraph',
79
+ name: '标题 2',
80
+ basedOn: 'Normal',
81
+ next: 'Normal',
82
+ qFormat: true,
83
+ uiPriority: 9,
84
+ pPr: {
85
+ lineSpacing: 450,
86
+ lineSpacingRule: 'exact',
87
+ spaceBefore: 150,
88
+ spaceAfter: 150,
89
+ beforeAutospacing: false,
90
+ afterAutospacing: false,
91
+ spacingExplicit: { before: true, after: true },
92
+ outlineLevel: 2,
93
+ },
94
+ rPr: {
95
+ fontSize: 30, // 15pt
96
+ bold: true,
97
+ fontFamily: { ascii: 'STKaiti', hAnsi: 'STKaiti', eastAsia: 'STKaiti' },
98
+ },
99
+ },
100
+ // 标题 3
101
+ {
102
+ styleId: 'Heading3',
103
+ type: 'paragraph',
104
+ name: '标题 3',
105
+ basedOn: 'Normal',
106
+ next: 'Normal',
107
+ qFormat: true,
108
+ uiPriority: 9,
109
+ pPr: {
110
+ lineSpacing: 450,
111
+ lineSpacingRule: 'exact',
112
+ spaceBefore: 150,
113
+ spaceAfter: 120,
114
+ beforeAutospacing: false,
115
+ afterAutospacing: false,
116
+ spacingExplicit: { before: true, after: true },
117
+ outlineLevel: 3,
118
+ },
119
+ rPr: {
120
+ fontSize: 30, // 15pt
121
+ bold: true,
122
+ fontFamily: { ascii: 'STFangsong', hAnsi: 'STFangsong', eastAsia: 'STFangsong' },
123
+ },
124
+ },
125
+ // 标题 4
126
+ {
127
+ styleId: 'Heading4',
128
+ type: 'paragraph',
129
+ name: '标题 4',
130
+ basedOn: 'Normal',
131
+ next: 'Normal',
132
+ qFormat: true,
133
+ uiPriority: 9,
134
+ pPr: {
135
+ lineSpacing: 450,
136
+ lineSpacingRule: 'exact',
137
+ spaceBefore: 150,
138
+ spaceAfter: 100,
139
+ beforeAutospacing: false,
140
+ afterAutospacing: false,
141
+ spacingExplicit: { before: true, after: true },
142
+ outlineLevel: 4,
143
+ },
144
+ rPr: {
145
+ fontSize: 30, // 15pt
146
+ bold: true,
147
+ fontFamily: { ascii: 'STKaiti', hAnsi: 'STKaiti', eastAsia: 'STKaiti' },
148
+ },
149
+ },
150
+ // 标题
151
+ {
152
+ styleId: 'Title',
153
+ type: 'paragraph',
154
+ name: '标题',
155
+ basedOn: 'Normal',
156
+ next: 'Normal',
157
+ qFormat: true,
158
+ uiPriority: 10,
159
+ pPr: {
160
+ spaceBefore: 240,
161
+ spaceAfter: 240,
162
+ beforeAutospacing: false,
163
+ afterAutospacing: false,
164
+ spacingExplicit: { before: true, after: true },
165
+ alignment: 'center',
166
+ },
167
+ rPr: {
168
+ fontSize: 44, // 22pt
169
+ bold: true,
170
+ fontFamily: { ascii: 'STHeiti', hAnsi: 'STHeiti', eastAsia: 'STHeiti' },
171
+ },
172
+ },
173
+ // 副标题
174
+ {
175
+ styleId: 'Subtitle',
176
+ type: 'paragraph',
177
+ name: '副标题',
178
+ basedOn: 'Normal',
179
+ next: 'Normal',
180
+ qFormat: true,
181
+ uiPriority: 11,
182
+ pPr: {
183
+ spaceBefore: 200,
184
+ spaceAfter: 200,
185
+ beforeAutospacing: false,
186
+ afterAutospacing: false,
187
+ spacingExplicit: { before: true, after: true },
188
+ alignment: 'center',
189
+ },
190
+ rPr: {
191
+ fontSize: 32, // 16pt
192
+ color: { rgb: '666666' },
193
+ fontFamily: { ascii: 'STKaiti', hAnsi: 'STKaiti', eastAsia: 'STKaiti' },
194
+ },
195
+ },
196
+ // 引用
197
+ {
198
+ styleId: 'Quote',
199
+ type: 'paragraph',
200
+ name: '引用',
201
+ basedOn: 'Normal',
202
+ next: 'Normal',
203
+ qFormat: true,
204
+ uiPriority: 29,
205
+ pPr: {
206
+ spaceBefore: 200,
207
+ spaceAfter: 200,
208
+ beforeAutospacing: false,
209
+ afterAutospacing: false,
210
+ spacingExplicit: { before: true, after: true },
211
+ indentLeft: 720, // 0.5 inch
212
+ },
213
+ rPr: {
214
+ fontSize: 22, // 11pt
215
+ italic: true,
216
+ color: { rgb: '404040' },
217
+ fontFamily: { ascii: 'STKaiti', hAnsi: 'STKaiti', eastAsia: 'STKaiti' },
218
+ },
219
+ },
220
+ // 强调
221
+ {
222
+ styleId: 'IntenseQuote',
223
+ type: 'paragraph',
224
+ name: '强调引用',
225
+ basedOn: 'Normal',
226
+ next: 'Normal',
227
+ qFormat: true,
228
+ uiPriority: 30,
229
+ pPr: {
230
+ spaceBefore: 200,
231
+ spaceAfter: 200,
232
+ beforeAutospacing: false,
233
+ afterAutospacing: false,
234
+ spacingExplicit: { before: true, after: true },
235
+ indentLeft: 720,
236
+ },
237
+ rPr: {
238
+ fontSize: 22,
239
+ italic: true,
240
+ bold: true,
241
+ color: { rgb: '000080' },
242
+ fontFamily: { ascii: 'STKaiti', hAnsi: 'STKaiti', eastAsia: 'STKaiti' },
243
+ },
244
+ },
245
+ // 红头(党政机关公文红头标题:方正小标宋/居中/砖红色大字号)
246
+ {
247
+ styleId: 'HongTou',
248
+ type: 'paragraph',
249
+ name: '红头',
250
+ basedOn: 'Normal',
251
+ next: 'Normal',
252
+ qFormat: true,
253
+ uiPriority: 8,
254
+ pPr: {
255
+ alignment: 'center',
256
+ lineSpacing: 360,
257
+ lineSpacingRule: 'auto',
258
+ spaceBefore: 240,
259
+ spaceAfter: 200,
260
+ beforeAutospacing: false,
261
+ afterAutospacing: false,
262
+ spacingExplicit: { before: true, after: true },
263
+ },
264
+ rPr: {
265
+ // 68 half-point = 34pt ≈ 45px:比普通「标题」样式(44=22pt≈29px)更大,
266
+ // 符合截图里红头占据页面最醒目位置的视觉比例。
267
+ fontSize: 68,
268
+ // 小标宋本身是设计好的显示字体,但在缺失方正小标宋/华文中宋的系统上,
269
+ // 回退到的宋体系列需要加粗才能撑住红头的厚重感,所以统一 bold=true。
270
+ bold: true,
271
+ // 贴近截图中国网文件头的橘红/砖红
272
+ color: { rgb: 'FF0000' },
273
+ fontFamily: {
274
+ ascii: 'Times New Roman',
275
+ hAnsi: 'Times New Roman',
276
+ // 主字体:方正小标宋(FZXiaoBiaoSong-B05)— 党政机关红头文件标准字体
277
+ // 回退:华文中宋 STZhongsong → 宋体 STSong → 仿宋 STFangsong(兜底显示)
278
+ eastAsia: 'FZXiaoBiaoSong-B05',
279
+ },
280
+ },
281
+ },
282
+ // 红头分隔线(文号下面那条红色单实线底边,公文字体居中,带合适段间距)
283
+ {
284
+ styleId: 'HongTouSeparator',
285
+ type: 'paragraph',
286
+ name: '红头分隔线',
287
+ basedOn: 'Normal',
288
+ next: 'Normal',
289
+ qFormat: true,
290
+ uiPriority: 8, // 和红头排在一起
291
+ pPr: {
292
+ alignment: 'center', // 文号如「专纪〔2025〕8号」都是居中
293
+ lineSpacing: 360,
294
+ lineSpacingRule: 'auto',
295
+ // 段前适中:红头 (spaceAfter 200 ≈ 10pt) 与文号之间不要太挤
296
+ spaceBefore: 160,
297
+ // 段后适中:文号分隔线与正文日期段落之间保持一点呼吸感
298
+ spaceAfter: 240,
299
+ beforeAutospacing: false,
300
+ afterAutospacing: false,
301
+ spacingExplicit: { before: true, after: true },
302
+ // 段落底边框 = 红头下面的红色分隔线
303
+ // 对应 Word 里的 <w:pBdr><w:top w:val="none"/><w:left w:val="none"/><w:right w:val="none"/>
304
+ // <w:bottom w:val="single" w:sz="12" w:space="1" w:color="FF0000"/></w:pBdr>
305
+ //
306
+ // 注意:必须显式把 top / left / right 标为 'none'。
307
+ // 原因:@eigenpal/docx-editor-core 在把 paragraph.attrs.borders 转成 CSS border-* 时,
308
+ // 如果其他三边是 undefined,它的 fallback 逻辑会复制已有边(bottom)的值上去,
309
+ // 导致明明只想要「底横线」结果画出「四边框」(用户截图里文字周围一圈红框就是这个 bug)。
310
+ // 写死 style: 'none' 就一定不会画,从根上断掉 fallback。
311
+ borders: {
312
+ // —— WPS 兼容加固:top/left/right 三边不仅写 style:'none',
313
+ // 还要显式补 sz:0 + color:auto。
314
+ // OOXML 标准下写 <w:top w:val="nil" w:sz="0" w:color="auto"/> 最稳,
315
+ // WPS 部分老版本在 style.xml 里读到仅 <w:top w:val="none"/> 时
316
+ // 会"退化成 fallback"(把 bottom 的属性复制到其他三边),
317
+ // 导致出现一圈红框。这里 4 边信息齐全,彻底堵死 fallback。
318
+ top: { style: 'none' as const, size: 0, color: { rgb: 'auto' as any } },
319
+ left: { style: 'none' as const, size: 0, color: { rgb: 'auto' as any } },
320
+ right: { style: 'none' as const, size: 0, color: { rgb: 'auto' as any } },
321
+ bottom: {
322
+ style: 'single', // 单实线,公文分隔线最标准样式
323
+ // size 单位是 1/8 pt:12 / 8 = 1.5pt,和国标公文常用横线厚度一致
324
+ // (0.75pt 太细打印不显,2pt 太粗,1.5pt 是最常用的折中值)
325
+ size: 12,
326
+ // space = 1pt:线与文字之间留 1pt 间距,不会贴在文字底下
327
+ space: 1,
328
+ color: { rgb: 'FF0000' }, // 与红头同色,保持统一的红头视觉语言
329
+ },
330
+ },
331
+ },
332
+ rPr: {
333
+ // 文号字号和正文保持一致:30 half-point = 15pt ≈ 20px
334
+ fontSize: 30,
335
+ bold: false, // 文号不用加粗(和截图里「专纪〔2025〕8号」一致)
336
+ // 公文字体:正文仿宋 GB2312 / STFangsong,和默认「正文」样式一致
337
+ fontFamily: { ascii: 'STFangsong', hAnsi: 'STFangsong', eastAsia: 'STFangsong' },
338
+ color: { rgb: '000000' }, // 文号是黑色,只有底边框是红色
339
+ },
340
+ },
341
+ ];
342
+
343
+ // 创建带有预设样式的空文档
344
+ export function createDocumentWithStyles(): Document {
345
+ const doc = createEmptyDocument();
346
+
347
+ // 使用与 applyPresetStylesToDocument 相同的合并策略(按 styleId + name 去重),
348
+ // 避免 createEmptyDocument() 自带的默认样式与预设样式重复
349
+ applyPresetStylesToDocument(doc);
350
+
351
+ return doc;
352
+ }
353
+
354
+ /**
355
+ * 将预设样式应用到已解析的文档中。
356
+ * 合并策略:
357
+ * 1. 从现有样式中删除所有与任一预设"名称相同(规范化后)"或"styleId 相同"的冲突样式
358
+ * (同时解决原文档 styleId=1/name=标题1 与 styleId=Heading1/name=Heading1 两套共存的重复问题)
359
+ * 2. 在样式列表末尾统一追加一份完整的预设样式集合
360
+ * 这样可以确保我们的预设样式(如仿宋正文、黑体标题等)始终作为可用样式存在,且无重复。
361
+ */
362
+ export function applyPresetStylesToDocument(doc: Document): Document {
363
+ if (!doc.package) return doc;
364
+
365
+ const existingStyles = doc.package.styles?.styles || [];
366
+
367
+ const presetStyleIds = new Set(PRESET_STYLES.map(p => p.styleId));
368
+ const presetNormalizedNames = new Set(
369
+ PRESET_STYLES.filter(p => p.name).map(p => normalizeStyleName(p.name!)),
370
+ );
371
+
372
+ // 第一步:过滤掉与预设冲突的现有样式(按 name 或 styleId 任一匹配即视为冲突)
373
+ const keptStyles = existingStyles.filter(s => {
374
+ const hitByName = s.name && presetNormalizedNames.has(normalizeStyleName(s.name));
375
+ const hitById = presetStyleIds.has(s.styleId);
376
+ return !(hitByName || hitById);
377
+ });
378
+
379
+ // 第二步:在末尾统一追加所有预设样式
380
+ const finalStyles = [...keptStyles, ...PRESET_STYLES];
381
+
382
+ doc.package.styles = {
383
+ docDefaults: doc.package.styles?.docDefaults,
384
+ styles: finalStyles,
385
+ };
386
+
387
+ return doc;
388
+ }
389
+
390
+ // ============================================================================
391
+ // 文档网格(w:docGrid)行距对齐
392
+ //
393
+ // @eigenpal/docx-editor-core 的布局引擎(dist/chunk-LNHPDJCA.js 的 Ue/Ze)虽然会
394
+ // 解析 docGrid,但从不消费它:段落没有显式行距时,行高 = 字号 × 字体 singleLineRatio
395
+ // (simsun=1.15),导致带 <w:docGrid w:type="lines" w:linePitch="312"/> 的
396
+ // 中文公文在编辑器里行距(~26pt@小四)明显小于 WPS(2 格网格 = 31.2pt)。
397
+ //
398
+ // Word/WPS 的真实规则:每行占整数个网格,units=ceil(字体自然行高 / linePitch)。
399
+ // 宋体/新宋体/仿宋/楷体/黑体等传统中文字体 Word 行高度量(winAscent+winDescent)
400
+ // ≈ 1.32em,12pt(小四)自然高 15.84pt > 15.6pt → 占 2 格=31.2pt;五号10.5pt 占 1 格。
401
+ // 该规则与只读预览的渲染器补丁(scripts/patch-docx-grid.cjs)完全一致,两处必须同步演进。
402
+ //
403
+ // 做法:parseDocx 之后,对「无显式行距」的段落注入 atLeast 网格行距(数值单位 twips,
404
+ // 与 OOXML <w:spacing w:line w:lineRule> 一致;编辑器布局层 atLeast 走 max(行距, 自然行高),
405
+ // 语义和 Word 网格一致——大字号/图片行仍可继续撑高)。已有显式行距的段落(exact/atLeast/倍数)
406
+ // 一律不动;保存时编辑器会把 w:line/w:lineRule 原样序列化回 document.xml,Word/WPS 打开同样生效。
407
+ // ============================================================================
408
+
409
+ const DOC_GRID_CJK_FONT_RE = /simsun|nsimsun|fangsong|kaiti|simhei|microsoft yahei|dengxian|宋体|仿宋|楷体|黑体|雅黑|等线|mingliu|pmingliu|microsoft jhenghei/i;
410
+ const DOC_GRID_CJK_FACTOR = 1.32;
411
+ const DOC_GRID_OTHER_FACTOR = 1.2;
412
+ // 缺省字号:五号半磅值 = 24(12pt)。
413
+ const DOC_GRID_DEFAULT_HALF_FONT_SIZE = 24;
414
+
415
+ interface DocGridFontFamily {
416
+ ascii?: string;
417
+ hAnsi?: string;
418
+ eastAsia?: string;
419
+ cs?: string;
420
+ }
421
+ interface DocGridTextFormatting {
422
+ fontSize?: number;
423
+ fontFamily?: DocGridFontFamily | null;
424
+ }
425
+ /** 段落 / run / 表格 / 单元格共用的结构外形(按需字段,不参与的节点缺省)。 */
426
+ interface DocGridNode {
427
+ type?: string;
428
+ formatting?: DocGridTextFormatting & {
429
+ lineSpacing?: number;
430
+ lineSpacingRule?: string;
431
+ styleId?: string;
432
+ runProperties?: DocGridTextFormatting;
433
+ };
434
+ content?: DocGridNode[];
435
+ rows?: Array<{ cells?: Array<{ content?: DocGridNode[] }> }>;
436
+ }
437
+
438
+ type StyleChainResolver = (styleId?: string) => Style[];
439
+
440
+ /** 收集段落内参与字号判定的全部半磅字号(run 内联 > 段落标记 rPr > 样式链 rPr > 缺省 12pt),取最大。 */
441
+ function resolveParagraphHalfFontSize(p: DocGridNode, resolveStyleChain: StyleChainResolver): number {
442
+ let size = 0;
443
+ for (const run of p.content ?? []) {
444
+ if (run.type === 'run' && run.formatting?.fontSize) {
445
+ size = Math.max(size, run.formatting.fontSize);
446
+ }
447
+ }
448
+ size = Math.max(size, p.formatting?.runProperties?.fontSize ?? 0);
449
+ for (const style of resolveStyleChain(p.formatting?.styleId)) {
450
+ if (style.rPr?.fontSize) {
451
+ size = Math.max(size, style.rPr.fontSize);
452
+ break;
453
+ }
454
+ }
455
+ return size || DOC_GRID_DEFAULT_HALF_FONT_SIZE;
456
+ }
457
+
458
+ /** 收集段落涉及的字体名(run 内联 / 段落标记 rPr / 样式链 rPr 的 ascii/hAnsi/eastAsia/cs)。 */
459
+ function collectParagraphFontFamilies(p: DocGridNode, resolveStyleChain: StyleChainResolver): string[] {
460
+ const names: Array<string | undefined> = [];
461
+ const pushFamily = (family?: DocGridFontFamily | null) => {
462
+ if (!family) return;
463
+ names.push(family.eastAsia, family.ascii, family.hAnsi, family.cs);
464
+ };
465
+ for (const run of p.content ?? []) {
466
+ if (run.type === 'run') pushFamily(run.formatting?.fontFamily);
467
+ }
468
+ pushFamily(p.formatting?.runProperties?.fontFamily);
469
+ for (const style of resolveStyleChain(p.formatting?.styleId)) {
470
+ pushFamily(style.rPr?.fontFamily as DocGridFontFamily | undefined);
471
+ }
472
+ return names.filter((n): n is string => typeof n === 'string' && n.length > 0);
473
+ }
474
+
475
+ /** 递归遍历段落 / 表格(含嵌套表格)/ SDT 等块级节点,对每个段落回调。 */
476
+ function walkDocGridBlocks(nodes: DocGridNode[] | undefined, onParagraph: (p: DocGridNode) => void): void {
477
+ if (!Array.isArray(nodes)) return;
478
+ for (const node of nodes) {
479
+ if (!node || typeof node !== 'object') continue;
480
+ if (node.type === 'paragraph') onParagraph(node);
481
+ if (Array.isArray(node.content)) walkDocGridBlocks(node.content, onParagraph);
482
+ if (Array.isArray(node.rows)) {
483
+ for (const row of node.rows) {
484
+ if (Array.isArray(row?.cells)) {
485
+ for (const cell of row.cells) walkDocGridBlocks(cell.content, onParagraph);
486
+ }
487
+ }
488
+ }
489
+ }
490
+ }
491
+
492
+ /**
493
+ * 按节 docGrid 设置,为缺少显式行距的段落注入「整数格对齐」的 atLeast 行距。
494
+ * 必须在 parseDocx 之后、文档交给 <DocxEditor> 之前调用。幂等:已有显式行距的段落不动。
495
+ */
496
+ export function applyDocGridLineSpacing(doc: Document): Document {
497
+ const documentModel = doc.package?.document as unknown as {
498
+ sections?: Array<{
499
+ properties?: { docGrid?: { type?: string; linePitch?: number } };
500
+ content?: DocGridNode[];
501
+ headers?: Map<unknown, { content?: DocGridNode[] }>;
502
+ footers?: Map<unknown, { content?: DocGridNode[] }>;
503
+ }>;
504
+ } | undefined;
505
+ const sections = documentModel?.sections;
506
+ if (!Array.isArray(sections)) return doc;
507
+
508
+ const styles: Style[] = doc.package?.styles?.styles ?? [];
509
+ const resolveStyleChain: StyleChainResolver = (styleId) => {
510
+ const chain: Style[] = [];
511
+ const seen = new Set<string>();
512
+ let current = styleId;
513
+ while (current && !seen.has(current)) {
514
+ seen.add(current);
515
+ const style = styles.find(s => s.styleId === current);
516
+ if (!style) break;
517
+ chain.push(style);
518
+ current = style.basedOn;
519
+ }
520
+ return chain;
521
+ };
522
+
523
+ for (const section of sections) {
524
+ const grid = section.properties?.docGrid;
525
+ const pitch = grid?.linePitch;
526
+ // type 为 lines / linesAndChars 时行网格才生效(default 是仅页边距网格、snapToChars 是字符网格)。
527
+ if (!pitch || (grid?.type !== 'lines' && grid?.type !== 'linesAndChars')) continue;
528
+
529
+ const applyToParagraph = (p: DocGridNode) => {
530
+ const formatting = p.formatting ?? (p.formatting = {});
531
+ // 已有显式行距(exact / atLeast / 倍数)一律保留,作者意图优先。
532
+ if (formatting.lineSpacing !== undefined && formatting.lineSpacing !== null) return;
533
+
534
+ const halfFontSize = resolveParagraphHalfFontSize(p, resolveStyleChain);
535
+ const fontNames = collectParagraphFontFamilies(p, resolveStyleChain);
536
+ const cjk = fontNames.some(name => DOC_GRID_CJK_FONT_RE.test(name));
537
+ const factor = cjk ? DOC_GRID_CJK_FACTOR : DOC_GRID_OTHER_FACTOR;
538
+ // 自然行高(twips)= 字号(pt) × 系数 × 20;字号(pt) = 半磅值 / 2
539
+ const naturalTwips = halfFontSize * factor * 10;
540
+ const units = Math.max(1, Math.ceil(naturalTwips / pitch - 1e-6));
541
+ formatting.lineSpacing = Math.round(units * pitch);
542
+ formatting.lineSpacingRule = 'atLeast';
543
+ };
544
+
545
+ walkDocGridBlocks(section.content, applyToParagraph);
546
+ for (const furnitureMap of [section.headers, section.footers]) {
547
+ if (furnitureMap instanceof Map) {
548
+ for (const furniture of furnitureMap.values()) {
549
+ walkDocGridBlocks(furniture?.content, applyToParagraph);
550
+ }
551
+ }
552
+ }
553
+ }
554
+
555
+ return doc;
556
+ }
557
+
558
+ // ============================================================================
559
+ // 中日韩字体回写:让在线编辑与只读预览字体一致
560
+ //
561
+ // 根因:@eigenpal/docx-editor-core 在把 run 的 rFonts 转成 CSS 时只取
562
+ // ascii/hAnsi(layout-bridge 的 toFlowBlocks、prosemirror 的 fontFamily mark
563
+ // toDOM 都是 `ascii || hAnsi`),**完全忽略 w:eastAsia**;而只读预览器
564
+ // (docx-preview) 走 eastAsianFontFamilyStack,优先用 eastAsia。
565
+ // 中文文档普遍是 ascii/hAnsi=Calibri/Times New Roman + eastAsia=宋体,
566
+ // 于是预览是宋体、编辑器却回退成系统无衬线字体(macOS 上是苹方)。
567
+ //
568
+ // 修复:parseDocx 之后,把 eastAsia 是中日韩字体的 fontFamily 的
569
+ // ascii/hAnsi 也指向同一个字体,使编辑器解析出的字体栈与预览一致
570
+ // (预览本来就优先 eastAsia,因此这不会让编辑器「多」出预览没有的效果)。
571
+ // 必须在 setInitialDocument / 交给 <DocxEditor> 之前调用;幂等。
572
+ // ============================================================================
573
+
574
+ const CJK_EAST_ASIA_FONT_RE =
575
+ /simsun|nsimsun|songti|宋体|宋體|fangsong|仿宋|kaiti|楷体|楷體|simhei|黑体|黑體|heiti|yahei|雅黑|dengxian|等线|等線|mingliu|pmingliu|細明體|细明体|microsoft jhenghei|微軟正黑體|微软正黑体|stsong|stheiti|stkaiti|stfangsong|stzhongsong|华文|方正|fangzheng/i;
576
+
577
+ function applyCjkEastAsiaFont(family?: DocGridFontFamily | null): void {
578
+ if (!family) return;
579
+ const eastAsia = family.eastAsia?.trim();
580
+ if (!eastAsia || !CJK_EAST_ASIA_FONT_RE.test(eastAsia)) return;
581
+ family.ascii = eastAsia;
582
+ family.hAnsi = eastAsia;
583
+ }
584
+
585
+ /** 递归遍历段落 / run / 表格 / SDT 等节点,处理所有 fontFamily。 */
586
+ function walkFontFamilyNodes(nodes: DocGridNode[] | undefined, onNode: (node: DocGridNode) => void): void {
587
+ if (!Array.isArray(nodes)) return;
588
+ for (const node of nodes) {
589
+ if (!node || typeof node !== 'object') continue;
590
+ onNode(node);
591
+ if (Array.isArray(node.content)) walkFontFamilyNodes(node.content, onNode);
592
+ if (Array.isArray(node.rows)) {
593
+ for (const row of node.rows) {
594
+ if (Array.isArray(row?.cells)) {
595
+ for (const cell of row.cells) walkFontFamilyNodes(cell.content, onNode);
596
+ }
597
+ }
598
+ }
599
+ }
600
+ }
601
+
602
+ /**
603
+ * 把中日韩文档的 eastAsia 字体回写到 ascii/hAnsi,修正在线编辑器「忽略了
604
+ * w:eastAsia 导致中文字体与预览不一致」的问题。同时在段落 run 与 styles.xml
605
+ * (含 docDefaults)上生效,保证继承链路的任一层都能拿到正确字体。
606
+ */
607
+ export function applyCjkFontFallbackToDocument(doc: Document): Document {
608
+ const documentModel = doc.package?.document as unknown as {
609
+ sections?: Array<{
610
+ content?: DocGridNode[];
611
+ headers?: Map<unknown, { content?: DocGridNode[] }>;
612
+ footers?: Map<unknown, { content?: DocGridNode[] }>;
613
+ }>;
614
+ } | undefined;
615
+
616
+ const handleNode = (node: DocGridNode) => {
617
+ applyCjkEastAsiaFont(node.formatting?.fontFamily);
618
+ applyCjkEastAsiaFont(node.formatting?.runProperties?.fontFamily);
619
+ };
620
+
621
+ for (const section of documentModel?.sections ?? []) {
622
+ walkFontFamilyNodes(section.content, handleNode);
623
+ for (const furnitureMap of [section.headers, section.footers]) {
624
+ if (furnitureMap instanceof Map) {
625
+ for (const furniture of furnitureMap.values()) {
626
+ walkFontFamilyNodes(furniture?.content, handleNode);
627
+ }
628
+ }
629
+ }
630
+ }
631
+
632
+ const styleDefinitions = doc.package?.styles;
633
+ applyCjkEastAsiaFont(styleDefinitions?.docDefaults?.rPr?.fontFamily);
634
+ for (const style of styleDefinitions?.styles ?? []) {
635
+ applyCjkEastAsiaFont(style.rPr?.fontFamily);
636
+ }
637
+
638
+ return doc;
639
+ }
640
+
641
+ // ============================================================================
642
+ // 预设样式 → styles.xml 合并
643
+ //
644
+ // 背景:docx-editor 的 repack / selective save 均不写 styles.xml(保持原文件不动),
645
+ // 而 applyPresetStylesToDocument 在 parseDocx 之后才覆盖内存中的样式,已无法影响
646
+ // 已解析段落的渲染。这导致外部 docx(其 styles.xml 中 Heading1 没有 spaceBefore/
647
+ // spaceAfter)在保存→重载后,标题样式的段间距丢失,必须重新应用样式才生效。
648
+ //
649
+ // 修复:在 parseDocx 之前,将预设样式合并进 styles.xml,使解析器读到正确的样式定义,
650
+ // 段落才能继承到 spaceBefore/spaceAfter 等属性;同时 repack 会保留这份修正后的
651
+ // styles.xml,保证保存后再次打开依然生效。
652
+ // ============================================================================
653
+
654
+ const escapeXmlAttr = (value: string) =>
655
+ value
656
+ .replace(/&/g, '&amp;')
657
+ .replace(/</g, '&lt;')
658
+ .replace(/>/g, '&gt;')
659
+ .replace(/"/g, '&quot;');
660
+
661
+ /** 将预设样式的 pPr 序列化为 OOXML <w:pPr> 片段(仅处理预设用到的字段)。 */
662
+ function serializePPrToXml(pPr: NonNullable<Style['pPr']>): string {
663
+ const children: string[] = [];
664
+ // OOXML CT_PPrBase 子元素顺序:spacing → ind → jc → outlineLvl
665
+ const hasSpacing =
666
+ pPr.spaceBefore !== undefined ||
667
+ pPr.spaceAfter !== undefined ||
668
+ pPr.lineSpacing !== undefined ||
669
+ pPr.lineSpacingRule !== undefined ||
670
+ pPr.beforeAutospacing !== undefined ||
671
+ pPr.afterAutospacing !== undefined;
672
+ if (hasSpacing) {
673
+ const attrs: string[] = [];
674
+ if (pPr.spaceBefore !== undefined) attrs.push(`w:before="${pPr.spaceBefore}"`);
675
+ if (pPr.spaceAfter !== undefined) attrs.push(`w:after="${pPr.spaceAfter}"`);
676
+ if (pPr.lineSpacing !== undefined) attrs.push(`w:line="${pPr.lineSpacing}"`);
677
+ if (pPr.lineSpacingRule !== undefined) attrs.push(`w:lineRule="${pPr.lineSpacingRule}"`);
678
+ if (pPr.beforeAutospacing !== undefined) attrs.push(`w:beforeAutospacing="${pPr.beforeAutospacing ? 1 : 0}"`);
679
+ if (pPr.afterAutospacing !== undefined) attrs.push(`w:afterAutospacing="${pPr.afterAutospacing ? 1 : 0}"`);
680
+ children.push(`<w:spacing ${attrs.join(' ')}/>`);
681
+ }
682
+ if (pPr.indentLeft !== undefined || pPr.indentRight !== undefined || pPr.indentFirstLine !== undefined) {
683
+ const attrs: string[] = [];
684
+ if (pPr.indentLeft !== undefined) attrs.push(`w:left="${pPr.indentLeft}"`);
685
+ if (pPr.indentRight !== undefined) attrs.push(`w:right="${pPr.indentRight}"`);
686
+ if (pPr.indentFirstLine !== undefined) {
687
+ if (pPr.hangingIndent) attrs.push(`w:hanging="${Math.abs(pPr.indentFirstLine)}"`);
688
+ else attrs.push(`w:firstLine="${pPr.indentFirstLine}"`);
689
+ }
690
+ children.push(`<w:ind ${attrs.join(' ')}/>`);
691
+ }
692
+ if (pPr.alignment !== undefined) children.push(`<w:jc w:val="${pPr.alignment}"/>`);
693
+ if (pPr.outlineLevel !== undefined) children.push(`<w:outlineLvl w:val="${pPr.outlineLevel}"/>`);
694
+ // spacingExplicit 是运行时字段,不属于 OOXML,不序列化
695
+ return children.length ? `<w:pPr>${children.join('')}</w:pPr>` : '';
696
+ }
697
+
698
+ /** 将预设样式的 rPr 序列化为 OOXML <w:rPr> 片段(仅处理预设用到的字段)。 */
699
+ function serializeRPrToXml(rPr: NonNullable<Style['rPr']>): string {
700
+ const children: string[] = [];
701
+ // OOXML CT_RPr 子元素顺序:rFonts → b → bCs → i → iCs → color → sz → szCs
702
+ if (rPr.fontFamily) {
703
+ const attrs: string[] = [];
704
+ if (rPr.fontFamily.ascii) attrs.push(`w:ascii="${escapeXmlAttr(rPr.fontFamily.ascii)}"`);
705
+ if (rPr.fontFamily.hAnsi) attrs.push(`w:hAnsi="${escapeXmlAttr(rPr.fontFamily.hAnsi)}"`);
706
+ if (rPr.fontFamily.eastAsia) attrs.push(`w:eastAsia="${escapeXmlAttr(rPr.fontFamily.eastAsia)}"`);
707
+ if (rPr.fontFamily.cs) attrs.push(`w:cs="${escapeXmlAttr(rPr.fontFamily.cs)}"`);
708
+ if (attrs.length) children.push(`<w:rFonts ${attrs.join(' ')}/>`);
709
+ }
710
+ if (rPr.bold) children.push('<w:b/>');
711
+ if (rPr.boldCs) children.push('<w:bCs/>');
712
+ if (rPr.italic) children.push('<w:i/>');
713
+ if (rPr.italicCs) children.push('<w:iCs/>');
714
+ if (rPr.color) {
715
+ const rgb = typeof rPr.color === 'string' ? rPr.color : rPr.color.rgb;
716
+ if (rgb) children.push(`<w:color w:val="${rgb}"/>`);
717
+ }
718
+ if (rPr.fontSize !== undefined) children.push(`<w:sz w:val="${rPr.fontSize}"/>`);
719
+ if (rPr.fontSizeCs !== undefined) children.push(`<w:szCs w:val="${rPr.fontSizeCs}"/>`);
720
+ return children.length ? `<w:rPr>${children.join('')}</w:rPr>` : '';
721
+ }
722
+
723
+ /** 将单个预设样式序列化为 OOXML <w:style> 元素字符串。 */
724
+ function serializeStyleToXml(style: Style): string {
725
+ const parts: string[] = [];
726
+ const typeAttr = style.type ? ` w:type="${style.type}"` : '';
727
+ const styleIdAttr = style.styleId ? ` w:styleId="${escapeXmlAttr(style.styleId)}"` : '';
728
+ const defaultAttr = style.default ? ' w:default="1"' : '';
729
+ parts.push(`<w:style${typeAttr}${styleIdAttr}${defaultAttr}>`);
730
+
731
+ if (style.name !== undefined) parts.push(`<w:name w:val="${escapeXmlAttr(style.name)}"/>`);
732
+ if (style.basedOn !== undefined) parts.push(`<w:basedOn w:val="${escapeXmlAttr(style.basedOn)}"/>`);
733
+ if (style.next !== undefined) parts.push(`<w:next w:val="${escapeXmlAttr(style.next)}"/>`);
734
+ if (style.link !== undefined) parts.push(`<w:link w:val="${escapeXmlAttr(style.link)}"/>`);
735
+ if (style.uiPriority !== undefined) parts.push(`<w:uiPriority w:val="${style.uiPriority}"/>`);
736
+ if (style.qFormat) parts.push('<w:qFormat/>');
737
+ if (style.hidden) parts.push('<w:hidden/>');
738
+ if (style.semiHidden) parts.push('<w:semiHidden/>');
739
+ if (style.unhideWhenUsed) parts.push('<w:unhideWhenUsed/>');
740
+
741
+ if (style.pPr) {
742
+ const pPrXml = serializePPrToXml(style.pPr);
743
+ if (pPrXml) parts.push(pPrXml);
744
+ }
745
+ if (style.rPr) {
746
+ const rPrXml = serializeRPrToXml(style.rPr);
747
+ if (rPrXml) parts.push(rPrXml);
748
+ }
749
+
750
+ parts.push('</w:style>');
751
+ return parts.join('');
752
+ }
753
+
754
+ /**
755
+ * 规范化样式名称用于「去重/等值比较」。
756
+ *
757
+ * 注意:这是强归一化函数,不仅去空格/转小写,还做:
758
+ * 1) 全角数字/字母/符号 → 半角
759
+ * 2) 中文全角空格 → 半角空格 → 最终所有空格全部剥离
760
+ * 3) 中文前缀同义词归一:「标题 N」= 「headingN」= 「heading N」(因为文件自带的
761
+ * Normal/Heading1 等 style 在中文环境里 name 可能是"标题 1",在英文环境里
762
+ * name 是 "Heading 1",两套 name 实际指向同一个公文里的"标题 1"语义)
763
+ *
764
+ * 只有经过这么彻底的归一,才能把原 docx 里 styleId 数字 1/name=标题1(全角)、
765
+ * styleId 数字 1/name=Heading 1、preset styleId Heading1/name=标题 1(半角)
766
+ * 这三个本质上是同一个样式的不同表达都识别成等价项,不会在样式下拉框里出现
767
+ * 三个重复项。
768
+ */
769
+ export function normalizeStyleName(name: string | undefined | null): string {
770
+ if (!name) return '';
771
+ let s = String(name);
772
+
773
+ // —— 1) 全角 → 半角
774
+ // 全角 Unicode 范围:U+FF01..U+FF5E 对应 ASCII !..~ 差 0xFEE0
775
+ // 全角空格 U+3000 → 半角空格
776
+ s = s.replace(/[\uFF01-\uFF5E]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0));
777
+ s = s.replace(/\u3000/g, ' ');
778
+
779
+ // —— 2) 统一小写 + 去所有空白
780
+ s = s.toLowerCase().replace(/\s+/g, '');
781
+
782
+ // —— 3) 中文前缀同义词归一化
783
+ // "标题1" ↔ "heading1";"标题2" ↔ "heading2";最多支持到标题 9(公文里最多 9 级标题)
784
+ // 同时兼容"第1章/第1节"这类模板写法,这里只合并最常见的「标题」↔ Heading。
785
+ for (let i = 1; i <= 9; i++) {
786
+ const chineseVariant = `标题${i}`;
787
+ const englishVariant = `heading${i}`;
788
+ // 如果字符串包含 "标题{数字}",就把 "标题{数字}" 子串整体替换成 "heading{数字}"
789
+ // (反之如果字符串是 "heading{数字}" 不变,最后两边都统一成 heading{数字})
790
+ if (s.includes(chineseVariant)) {
791
+ s = s.replace(chineseVariant, englishVariant);
792
+ }
793
+ }
794
+
795
+ // —— 4) 正文 / default / normal 同义
796
+ if (s === '正文' || s === 'default' || s === 'standard') s = 'normal';
797
+
798
+ return s;
799
+ }
800
+
801
+ /**
802
+ * 将预设样式合并进 styles.xml 内容:
803
+ * 1. 扫描现有样式,删除所有与任一预设"名称相同(规范化后)"或"styleId 相同"的旧样式定义
804
+ * (这一步同时解决:原文件用 styleId=1/name=标题1 与 styleId=Heading1/name=Heading1 两套共存的重复问题)
805
+ * 2. 在 </w:styles> 前统一追加一份完整的预设样式定义
806
+ *
807
+ * 返回合并后的 styles.xml 字符串。若原内容已经包含正确的预设集合且无冲突,返回原值。
808
+ *
809
+ * 使用纯字符串替换,避免 DOMParser/XMLSerializer 破坏 OOXML 结构。
810
+ */
811
+ export function mergePresetStylesIntoStylesXml(stylesXml: string): string {
812
+ // 第一阶段:收集现有样式
813
+ const existingStyles: Array<{ styleId: string; name: string; normalizedName: string; matchStart: number; matchEnd: number }> = [];
814
+ {
815
+ const styleRegex = /<w:style\b[^>]*w:styleId="([^"]+)"[^>]*>[\s\S]*?<\/w:style>/g;
816
+ let match: RegExpExecArray | null;
817
+ while ((match = styleRegex.exec(stylesXml))) {
818
+ const styleXml = match[0];
819
+ const styleId = match[1];
820
+ const nameMatch = styleXml.match(/<w:name\s+w:val="([^"]*)"[^>]*>/);
821
+ const name = nameMatch ? nameMatch[1] : '';
822
+ existingStyles.push({
823
+ styleId,
824
+ name,
825
+ normalizedName: normalizeStyleName(name),
826
+ matchStart: match.index,
827
+ matchEnd: match.index + styleXml.length,
828
+ });
829
+ }
830
+ }
831
+
832
+ if (existingStyles.length === 0) {
833
+ return stylesXml.replace('</w:styles>', `${PRESET_STYLES.map(serializeStyleToXml).join('\n')}\n</w:styles>`);
834
+ }
835
+
836
+ const presetStyleIds = new Set(PRESET_STYLES.map(p => p.styleId));
837
+ const presetNormalizedNames = new Set(
838
+ PRESET_STYLES.filter(p => p.name).map(p => normalizeStyleName(p.name!)),
839
+ );
840
+
841
+ // 第二阶段:找出所有与预设冲突的现有样式(按 name 匹配 或 按 styleId 匹配,都需要删掉)
842
+ const replaceRanges: Array<{ start: number; end: number }> = [];
843
+ for (const existing of existingStyles) {
844
+ const hitByName = existing.normalizedName && presetNormalizedNames.has(existing.normalizedName);
845
+ const hitById = presetStyleIds.has(existing.styleId);
846
+ if (hitByName || hitById) {
847
+ replaceRanges.push({ start: existing.matchStart, end: existing.matchEnd });
848
+ }
849
+ }
850
+
851
+ // 第三阶段:从后往前删除冲突区间(避免位置偏移)
852
+ let result = stylesXml;
853
+ if (replaceRanges.length > 0) {
854
+ replaceRanges.sort((a, b) => b.start - a.start);
855
+ for (const { start, end } of replaceRanges) {
856
+ result = result.slice(0, start) + result.slice(end);
857
+ }
858
+ }
859
+
860
+ // 第四阶段:在 </w:styles> 前追加所有预设样式
861
+ const newStylesXml = PRESET_STYLES.map(serializeStyleToXml).join('\n');
862
+ result = result.replace('</w:styles>', `${newStylesXml}\n</w:styles>`);
863
+
864
+ // 若最终结果与原值相同(不可能,因为我们总会追加预设),则返回原值
865
+ return result;
866
+ }
867
+
868
+ // ============================================================================
869
+ // DRAWING GUARD:drawing / pict / shape 等富媒体元素的「保护套」工具
870
+ //
871
+ // 背景:ensureInlineSpacingInDocumentXml / ensureHongTouSeparatorBorderInDocumentXml
872
+ // 用纯字符串正则匹配 <w:pPr>…</w:pPr> 并替换,虽然正则是非贪婪模式,
873
+ // 理论上只会匹配"从一个开标签到最近一个闭标签"的最小块,但:
874
+ // - Word 会在 drawing 内嵌 <pic:nvPicPr><pic:cNvPr descr="描述文字"/>
875
+ // 等带任意字符串的属性,如果用户把"</w:pPr>"写进图片描述(极端但合法),
876
+ // 非贪婪就会在错误位置截断;
877
+ // - 更常见的情况:OOXML 里 drawing/pict(VML)是「跨 paragraph 的浮动元素」
878
+ // (例如文档末尾页脚区域的绝对定位骑缝章),正则会把 drawing 里某段
879
+ // 子结构误识别成"新的 pPr 块的一部分",replace 后 drawing 的 XML
880
+ // byte 被改动(哪怕只是前后拼接多了些字符),导致 parseDocx 时
881
+ // drawing → a:blip r:embed 关联断裂,最终渲染成一个「<img src= width=192
882
+ // height=192 style=position absolute...>」的破损占位图。
883
+ //
884
+ // 防御原则:**彻底隔离富媒体元素与 <w:pPr> 正则处理**。每次开始处理 document.xml
885
+ // 前,先把所有 drawing/pict/vml-shape 整块换成一个"不含 XML 标签特征"的字符串
886
+ // 占位符(例如 `__VEN_DRAWING_0__`),这样 <w:pPr> 正则永远不会碰到这些
887
+ // 富媒体的 byte,100% 不会误伤;等 <w:pPr> 处理完成后,再按序把占位符还原
888
+ // 回原始 drawing XML 片段;最后再跑 sanity check 确保 drawing 数量不变,
889
+ // 任何异常就直接回滚原 documentXml 不做任何修改(宁可不注入内联 spacing/
890
+ // pBdr,也不能把用户的页脚签章图片搞坏)。
891
+ // ============================================================================
892
+
893
+ // 所有需要「保护套」的富媒体开/闭标签对(OOXML 常用的 3 种图片容器):
894
+ const DRAWING_TAG_PAIRS: Array<{ open: RegExp; close: string; guardPrefix: string }> = [
895
+ // OOXML drawing(Word 2007+ 主流,图片/形状/Chart 都会包一层 w:drawing)
896
+ { open: /<w:drawing(\s|>)/i, close: '</w:drawing>', guardPrefix: 'VEN_DRAWING' },
897
+ // 兼容旧版 Word 的绘图对象(mc:AlternateContent 里的 w:pict)
898
+ { open: /<w:pict(\s|>)/i, close: '</w:pict>', guardPrefix: 'VEN_PICT' },
899
+ // VML 形状(页眉页脚水印图,WPS 很喜欢写 <v:shape id=...>...</v:shape>)
900
+ { open: /<v:shape(\s|>)/i, close: '</v:shape>', guardPrefix: 'VEN_VML' },
901
+ ];
902
+
903
+ /** 统计某个标签(区分大小写但允许 <tag 开空格/>)在 xml 中出现的次数 */
904
+ function countOccurrences(xml: string, openRegex: RegExp, closeTag: string): number {
905
+ // 开标签:<tag<space 或 <tag>
906
+ let openCount = 0;
907
+ const openG = new RegExp(openRegex.source, openRegex.flags.includes('i') ? 'gi' : 'g');
908
+ while (openG.exec(xml) !== null) openCount++;
909
+ // 闭标签
910
+ const closeG = new RegExp(closeTag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
911
+ let closeCount = 0;
912
+ while (closeG.exec(xml) !== null) closeCount++;
913
+ return Math.min(openCount, closeCount);
914
+ }
915
+ /**
916
+ * 把所有富媒体块替换成占位符,返回替换后的 xml 以及一个按顺序可还原的片段数组。
917
+ * 匹配规则:对每类 tag pair,从左到右扫描,每次找到第一个开标签,然后找
918
+ * "对应的最近一个闭标签"(做嵌套深度计数:遇到开标签 depth++,遇到
919
+ * 同名闭标签 depth--,depth 回到 0 时截断整块)。嵌套处理能防止 drawing
920
+ * 里又包了子 drawing/VML 的复杂情况(虽然 OOXML 很少见,但要稳)。
921
+ */
922
+ function protectDrawings(xml: string): { guarded: string; drawings: string[]; placeholderReg: RegExp } {
923
+ const drawings: string[] = [];
924
+ let guarded = xml;
925
+
926
+ for (const pair of DRAWING_TAG_PAIRS) {
927
+ // 为每类 tag 构建开标签全局正则(保证能 exec 多次推进 lastIndex)
928
+ const openG = new RegExp(pair.open.source, pair.open.flags.includes('i') ? 'gi' : 'g');
929
+
930
+ // 多轮扫描:每轮找到一个 pair 整块替换成占位符,继续扫剩余字符串
931
+ // (每次把结果重新塞回 guarded;注意:每轮占位符前缀+索引是全局递增 drawings 数组的 index)
932
+ // 为了支持"不同 tag pair 之间也不冲突",我们实际一次只处理一种 tag 类型,
933
+ // 处理方式:迭代式地做字符串拼接,避免正则 replace 回调里难以做嵌套计数。
934
+ let result = '';
935
+ let cursor = 0;
936
+ let match: RegExpExecArray | null;
937
+ while ((match = openG.exec(guarded)) !== null) {
938
+ const openStart = match.index;
939
+ const openEnd = openStart + match[0].length; // 开标签结束位置(<w:drawing 的 '>' 后面)
940
+ // 现在从 openEnd 往后找 depth 归 0 的 pair.close 位置
941
+ // 为了正确匹配"同名闭标签 + 同名开标签嵌套",做嵌套深度计数 +
942
+ // 开/闭标签位置交替前进(depth 归 0 时截断整块)
943
+ let depth = 1;
944
+ // 正则:开标签(<tagname<space|>) 和 闭标签(</tagname>)
945
+ const innerOpenG = new RegExp(pair.open.source, pair.open.flags.includes('i') ? 'gi' : 'g');
946
+ innerOpenG.lastIndex = openEnd;
947
+ const closeG = new RegExp(pair.close.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
948
+ closeG.lastIndex = openEnd;
949
+
950
+ let nextOpen = innerOpenG.exec(guarded);
951
+ let nextClose = closeG.exec(guarded);
952
+ while (depth > 0) {
953
+ // 选 nextOpen 和 nextClose 里位置更靠前的那个
954
+ if (nextClose === null) break; // 没闭标签 → 结构异常,退出循环,不替换这块
955
+ const openPos = nextOpen ? nextOpen.index : Infinity;
956
+ const closePos = nextClose.index;
957
+ if (closePos < openPos) {
958
+ // 先遇到闭标签 → depth--
959
+ depth--;
960
+ if (depth === 0) {
961
+ const blockEnd = closePos + pair.close.length;
962
+ const block = guarded.slice(openStart, blockEnd);
963
+ const placeholder = `__${pair.guardPrefix}_${drawings.length}__`;
964
+ drawings.push(block);
965
+ // 拼接:[cursor, openStart) + placeholder + new search
966
+ result += guarded.slice(cursor, openStart) + placeholder;
967
+ cursor = blockEnd;
968
+ break;
969
+ }
970
+ nextClose = closeG.exec(guarded);
971
+ } else {
972
+ // 先遇到开标签 → depth++(嵌套)
973
+ depth++;
974
+ // 推进 nextOpen(不能让它停在原地,否则死循环)
975
+ nextOpen = innerOpenG.exec(guarded);
976
+ }
977
+ }
978
+ // 如果循环是因为"没找到闭标签"跳出 → 保持 cursor 不动,openG 会自动推进 lastIndex 继续找下一个
979
+ }
980
+ result += guarded.slice(cursor);
981
+ guarded = result;
982
+ // 处理下一类 tag pair(此时 drawing 占位符已经在 guarded 里,不会再被新 pair 匹配)
983
+ }
984
+
985
+ // 还原占位符用的正则:匹配 __VEN_DRAWING_N__ / __VEN_PICT_N__ / __VEN_VML_N__
986
+ const placeholderReg = /__(VEN_DRAWING|VEN_PICT|VEN_VML)_(\d+)__/g;
987
+ return { guarded, drawings, placeholderReg };
988
+ }
989
+
990
+ /** 把 protectDrawings 生成的占位符还原成原始 drawing XML 片段 */
991
+ function restoreDrawings(guarded: string, drawings: string[], placeholderReg: RegExp): string {
992
+ // 重置 lastIndex,防止外部用过这个正则
993
+ placeholderReg.lastIndex = 0;
994
+ return guarded.replace(placeholderReg, (_full, _prefix: string, idxStr: string) => {
995
+ const idx = Number(idxStr);
996
+ if (Number.isNaN(idx) || idx < 0 || idx >= drawings.length) return _full;
997
+ return drawings[idx];
998
+ });
999
+ }
1000
+
1001
+ /**
1002
+ * 高阶工具:对 document.xml 做任何会改写其内容的字符串操作前,先套一层
1003
+ * DRAWING GUARD(保护富媒体元素不被误伤),并做 sanity check:
1004
+ * - 若处理前后 drawing 数量不一致 → 直接返回原值,不应用修改
1005
+ * 这样即使未来再增加 <w:pPr> 之外的新正则修改,也不会损坏图片。
1006
+ */
1007
+ function runWithDrawingGuard(
1008
+ documentXml: string,
1009
+ worker: (guardedBodyXml: string) => { result: string; modified: boolean },
1010
+ ): string {
1011
+ // 保存 xml decl 拆分(和 ensureInlineSpacingInDocumentXml 一致)
1012
+ const xmlDeclMatch = documentXml.match(/^<\?xml[^?]*\?>\s*/);
1013
+ const xmlDecl = xmlDeclMatch ? xmlDeclMatch[0] : '';
1014
+ const bodyXml = xmlDeclMatch ? documentXml.slice(xmlDeclMatch[0].length) : documentXml;
1015
+
1016
+ // —— 处理前 sanity snapshot:drawing/pict/vml 计数
1017
+ const beforeCounts = DRAWING_TAG_PAIRS.map(p => countOccurrences(bodyXml, p.open, p.close));
1018
+ const totalBefore = beforeCounts.reduce((a, b) => a + b, 0);
1019
+
1020
+ // —— 1) 保护 drawings
1021
+ const { guarded, drawings, placeholderReg } = protectDrawings(bodyXml);
1022
+
1023
+ // —— 2) 实际 worker 干活(worker 内只会碰 guarded,里面所有富媒体都是 __VEN_XXX_N__
1024
+ // 纯字母数字下划线占位符,没有任何 <...> XML 特征,pPr 正则不可能命中)
1025
+ const { result: guardedResult, modified } = worker(guarded);
1026
+ if (!modified) return documentXml; // 没改,直接返回原始 documentXml
1027
+
1028
+ // —— 3) 还原 drawings
1029
+ const bodyResult = restoreDrawings(guardedResult, drawings, placeholderReg);
1030
+
1031
+ // —— 4) 处理后 sanity check:drawing 数量必须与处理前完全一致
1032
+ const afterCounts = DRAWING_TAG_PAIRS.map(p => countOccurrences(bodyResult, p.open, p.close));
1033
+ const totalAfter = afterCounts.reduce((a, b) => a + b, 0);
1034
+ if (totalBefore !== totalAfter) {
1035
+ // 理论上 protectDrawings 已经隔离了富媒体,这里不应触发。
1036
+ // 万一触发(比如 worker 内部不小心把 __VEN_XXX 占位符改了/删了),
1037
+ // 直接回滚原始值,坚决不输出损坏的 document.xml。
1038
+ return documentXml;
1039
+ }
1040
+
1041
+ return xmlDecl + bodyResult;
1042
+ }
1043
+
1044
+ // ============================================================================
1045
+ // document.xml 内联间距注入
1046
+ //
1047
+ // 背景:布局引擎的 spacingExplicit 机制会折叠未标记为显式的样式继承间距。
1048
+ // 当段落仅通过 <w:pStyle> 引用 Heading1 等样式时,间距来自样式继承,
1049
+ // parseDocx 不会为这些继承值设置 spacingExplicit 标志,导致重载后
1050
+ // spaceBefore/spaceAfter 不生效。
1051
+ //
1052
+ // 之前尝试在 parseDocx 后遍历 Document 对象设置 spacingExplicit,但发现
1053
+ // Document 的 Paragraph 接口中不含 styleId 字段(styleId 只存在于
1054
+ // ProseMirror 的 ParagraphAttrs 中),导致该遍历完全无效。
1055
+ //
1056
+ // 修复:直接在 document.xml 层面,为引用了带间距样式的段落添加内联
1057
+ // <w:spacing> 标签。parseDocx 解析内联 <w:spacing> 时会自动设置
1058
+ // spacingExplicit 标志,确保间距在重载后生效。
1059
+ // ============================================================================
1060
+
1061
+ /**
1062
+ * 确保 document.xml 中引用了带间距样式的段落有内联 <w:spacing> 标签。
1063
+ *
1064
+ * 规则:
1065
+ * - 仅对引用了 PRESET_STYLES 中带间距样式(如 Heading1)的段落处理
1066
+ * - 如果段落已有 <w:spacing>,只补充缺失的属性(不覆盖用户手动设置的值)
1067
+ * - 如果段落没有 <w:spacing>,创建并插入到 <w:pStyle> 之后
1068
+ * (遵循 OOXML CT_PPr 子元素顺序:pStyle → ... → spacing → ind → jc)
1069
+ *
1070
+ * 使用字符串操作而非 DOM API,避免 XMLSerializer 丢失 XML 声明、
1071
+ * 命名空间处理不一致等问题。
1072
+ */
1073
+ export function ensureInlineSpacingInDocumentXml(documentXml: string): string {
1074
+ // —— 0) 预计算 spacing attrs map(不依赖 bodyXml,可以放在 guard 外层)
1075
+ const spacingAttrsByStyleId = new Map<string, string>();
1076
+ for (const style of PRESET_STYLES) {
1077
+ if (!style.pPr) continue;
1078
+ if (
1079
+ style.pPr.spaceBefore === undefined &&
1080
+ style.pPr.spaceAfter === undefined &&
1081
+ style.pPr.lineSpacing === undefined
1082
+ )
1083
+ continue;
1084
+ const attrs: string[] = [];
1085
+ if (style.pPr.spaceBefore !== undefined) attrs.push(`w:before="${style.pPr.spaceBefore}"`);
1086
+ if (style.pPr.spaceAfter !== undefined) attrs.push(`w:after="${style.pPr.spaceAfter}"`);
1087
+ if (style.pPr.lineSpacing !== undefined) attrs.push(`w:line="${style.pPr.lineSpacing}"`);
1088
+ if (style.pPr.lineSpacingRule !== undefined) attrs.push(`w:lineRule="${style.pPr.lineSpacingRule}"`);
1089
+ spacingAttrsByStyleId.set(style.styleId, attrs.join(' '));
1090
+ }
1091
+ if (spacingAttrsByStyleId.size === 0) return documentXml;
1092
+
1093
+ // —— 1) 套上 DRAWING GUARD,worker 里跑正则,永远不会误伤 drawing/pict/vml
1094
+ return runWithDrawingGuard(documentXml, (guardedBodyXml) => {
1095
+ let modified = false;
1096
+ const result = guardedBodyXml.replace(/(<w:pPr\b[^>]*>)([\s\S]*?)<\/w:pPr>/g, (fullMatch, openTag: string, inner: string) => {
1097
+ const pStyleMatch = inner.match(/<w:pStyle\s+w:val=(?:"([^"]+)"|'([^']+)')[^>]*?(?:\/>|><\/w:pStyle>)/);
1098
+ if (!pStyleMatch) return fullMatch;
1099
+
1100
+ const styleId = pStyleMatch[1] || pStyleMatch[2];
1101
+ if (!styleId) return fullMatch;
1102
+
1103
+ const spacingAttrs = spacingAttrsByStyleId.get(styleId);
1104
+ if (!spacingAttrs) return fullMatch;
1105
+
1106
+ if (/<w:spacing[\s/>]/.test(inner)) {
1107
+ const newSpacingTag = `<w:spacing ${spacingAttrs}/>`;
1108
+ const updatedInner = inner.replace(/<w:spacing\b[^>]*\/>/, newSpacingTag);
1109
+ if (updatedInner !== inner) modified = true;
1110
+ return `${openTag}${updatedInner}</w:pPr>`;
1111
+ }
1112
+
1113
+ const spacingTag = `<w:spacing ${spacingAttrs}/>`;
1114
+ const updatedInner = inner.replace(pStyleMatch[0], pStyleMatch[0] + spacingTag);
1115
+ modified = true;
1116
+ return `${openTag}${updatedInner}</w:pPr>`;
1117
+ });
1118
+ return { result, modified };
1119
+ });
1120
+ }
1121
+
1122
+ // ============================================================================
1123
+ // document.xml 红头分隔线底边框「解继承」注入(WPS 兼容终极方案)
1124
+ //
1125
+ // 根因:WPS 打开 docx 时,对 <w:style styleId="HongTouSeparator"> 里定义的
1126
+ // <w:pPr><w:pBdr><w:bottom w:val="single" w:color="FF0000" w:sz="12" .../>
1127
+ // </w:pBdr></w:pPr> 这种"style 级继承边框"读取很不稳定:
1128
+ // - WPS 2019/2021 部分版本不读 style 上的 pBdr,只读 <w:p> 元素上
1129
+ // 内联的 <w:pPr><w:pBdr>...</w:pBdr></w:pPr>
1130
+ // - Word/Microsoft 365 能正确继承 style 级 pBdr,所以我们编辑器里
1131
+ // 预览、保存后 Word 打开都没问题,只有 WPS 不显示红线。
1132
+ //
1133
+ // 修复策略(100% 命中,不依赖任何客户端实现):在把 docx 写出前,直接
1134
+ // 遍历 document.xml 所有 <w:p>,找到 <w:pStyle w:val="HongTouSeparator"/>
1135
+ // 的段落,给其 <w:pPr> 强制补一段完整的「内联 4 边 <w:pBdr>」:
1136
+ // <w:pBdr>
1137
+ // <w:top w:val="nil" w:sz="0" w:space="0" w:color="auto"/>
1138
+ // <w:left w:val="nil" w:sz="0" w:space="0" w:color="auto"/>
1139
+ // <w:right w:val="nil" w:sz="0" w:space="0" w:color="auto"/>
1140
+ // <w:bottom w:val="single" w:sz="12" w:space="1" w:color="FF0000"/>
1141
+ // </w:pBdr>
1142
+ // —— 这就是国标党政机关公文「红头分隔线」在 OOXML 里最标准、兼容性最好
1143
+ // 的写法,Word/WPS/永中 Office/Google Docs 全都会渲染出一条横贯整段宽的
1144
+ // 红色单实线,不会有任何兼容性问题。
1145
+ // ============================================================================
1146
+
1147
+ // 给 HongTouSeparator 段落使用的「标准 4 边 pBdr 字符串」:
1148
+ // - nil 等价于 none,在 WPS 里比 'none' 更稳
1149
+ // - 顶/左/右 sz=0 + color=auto,彻底锁死其他三边无颜色、无宽度
1150
+ // - bottom w:val="single" w:sz="12" (= 1.5pt) w:space="1" w:color="FF0000"
1151
+ // 对应预设样式里的 size 12、space 1、color FF0000
1152
+ const HONGTOU_SEP_PBDR_XML = `<w:pBdr><w:top w:val="nil" w:sz="0" w:space="0" w:color="auto"/><w:left w:val="nil" w:sz="0" w:space="0" w:color="auto"/><w:right w:val="nil" w:sz="0" w:space="0" w:color="auto"/><w:bottom w:val="single" w:sz="12" w:space="1" w:color="FF0000"/></w:pBdr>`;
1153
+
1154
+ /**
1155
+ * 在 document.xml 中为所有引用了 styleId=HongTouSeparator 的段落,
1156
+ * 强制注入/替换一段完整的 4 边内联 <w:pBdr>,保证 WPS 打开也能显示红线。
1157
+ */
1158
+ export function ensureHongTouSeparatorBorderInDocumentXml(documentXml: string): string {
1159
+ const HONGTOU_STYLE_ID = 'HongTouSeparator';
1160
+
1161
+ // 快速短路:文档里完全没有出现 HongTouSeparator 就原样返回
1162
+ if (!documentXml.includes(HONGTOU_STYLE_ID)) {
1163
+ return documentXml;
1164
+ }
1165
+
1166
+ // —— 套上 DRAWING GUARD,worker 里的 pPr 正则永远不会误伤 drawing/pict/vml
1167
+ return runWithDrawingGuard(documentXml, (guardedBodyXml) => {
1168
+ let modified = false;
1169
+
1170
+ // 匹配每个 <w:pPr> 块(与 ensureInlineSpacingInDocumentXml 同模式)
1171
+ const result = guardedBodyXml.replace(/(<w:pPr\b[^>]*>)([\s\S]*?)<\/w:pPr>/g, (fullMatch, openTag: string, inner: string) => {
1172
+ // 检查这段 pPr 里是否引用了 HongTouSeparator 样式
1173
+ const pStyleMatch = inner.match(/<w:pStyle\s+w:val=(?:"([^"]+)"|'([^']+)')[^>]*?(?:\/>|><\/w:pStyle>)/);
1174
+ if (!pStyleMatch) return fullMatch;
1175
+ const styleId = pStyleMatch[1] || pStyleMatch[2];
1176
+ if (styleId !== HONGTOU_STYLE_ID) return fullMatch;
1177
+
1178
+ let newInner: string;
1179
+
1180
+ // 情况 1:已有 <w:pBdr>...</w:pBdr> → 整块替换成标准 4 边版本
1181
+ if (/<w:pBdr\b[^>]*>[\s\S]*?<\/w:pBdr>/.test(inner)) {
1182
+ newInner = inner.replace(/<w:pBdr\b[^>]*>[\s\S]*?<\/w:pBdr>/, HONGTOU_SEP_PBDR_XML);
1183
+ if (newInner !== inner) modified = true;
1184
+ return `${openTag}${newInner}</w:pPr>`;
1185
+ }
1186
+
1187
+ // 情况 2:没有 <w:pBdr> → 按 OOXML CT_PPr 顺序插入(优先在 spacing/ind/jc 前插)
1188
+ const insertionHints = [
1189
+ { re: /<w:spacing[\s/>]/ },
1190
+ { re: /<w:ind[\s/>]/ },
1191
+ { re: /<w:jc[\s/>]/ },
1192
+ ];
1193
+ let insertPos = -1;
1194
+ for (const h of insertionHints) {
1195
+ const m = inner.match(h.re);
1196
+ if (m && m.index !== undefined) {
1197
+ insertPos = m.index;
1198
+ break;
1199
+ }
1200
+ }
1201
+
1202
+ if (insertPos >= 0) {
1203
+ newInner = inner.slice(0, insertPos) + HONGTOU_SEP_PBDR_XML + inner.slice(insertPos);
1204
+ } else {
1205
+ newInner = inner + HONGTOU_SEP_PBDR_XML;
1206
+ }
1207
+ if (newInner !== inner) modified = true;
1208
+ return `${openTag}${newInner}</w:pPr>`;
1209
+ });
1210
+
1211
+ return { result, modified };
1212
+ });
1213
+ }