@zzclub/pipeline 0.2.0 → 0.4.0

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 (194) hide show
  1. package/README.md +366 -170
  2. package/package.json +4 -7
  3. package/src/adapter-loader.ts +138 -0
  4. package/src/adapter-types.ts +172 -0
  5. package/src/adapters/builtin-image-renderer.ts +353 -0
  6. package/src/adapters/builtin-markdown-renderer.ts +63 -0
  7. package/src/adapters/index.ts +2 -0
  8. package/src/args.test.ts +118 -0
  9. package/src/args.ts +90 -0
  10. package/src/cli.ts +51 -0
  11. package/src/commands/abandon.test.ts +104 -0
  12. package/src/commands/abandon.ts +202 -0
  13. package/src/commands/attach-body-images.ts +88 -0
  14. package/src/commands/attach-body.ts +86 -0
  15. package/src/commands/attach-newspic-spec.ts +41 -0
  16. package/src/commands/checkpoint.test.ts +91 -0
  17. package/src/commands/checkpoint.ts +66 -0
  18. package/src/commands/config.ts +106 -0
  19. package/src/commands/cos-upload.ts +62 -0
  20. package/src/commands/doctor.ts +66 -0
  21. package/src/commands/find-run.ts +55 -0
  22. package/src/commands/hermes-metrics.ts +226 -0
  23. package/src/commands/imgx.ts +60 -0
  24. package/src/commands/ingest-handoff.ts +509 -0
  25. package/src/commands/init.ts +126 -0
  26. package/src/commands/prepare-finalize.ts +348 -0
  27. package/src/commands/prepare.ts +310 -0
  28. package/src/commands/publish.ts +147 -0
  29. package/src/commands/reconcile.ts +36 -0
  30. package/src/commands/render.ts +244 -0
  31. package/src/commands/reset.test.ts +151 -0
  32. package/src/commands/reset.ts +159 -0
  33. package/src/commands/review.ts +77 -0
  34. package/src/commands/status.ts +68 -0
  35. package/src/commands/sync-blog.ts +39 -0
  36. package/src/commands/tasks.ts +50 -0
  37. package/src/commands/wechat-export.ts +68 -0
  38. package/src/config.ts +0 -0
  39. package/src/imgx/ascii-portrait.ts +77 -0
  40. package/src/imgx/assets/fonts/AlimamaShuHeiTi-Bold.ttf +0 -0
  41. package/src/imgx/assets/fonts/LXGWNeoZhiSongPlus.ttf +0 -0
  42. package/src/imgx/assets/fonts/LXGWWenKai-Regular.ttf +0 -0
  43. package/src/imgx/assets/icons/logo.png +0 -0
  44. package/src/imgx/assets/icons/logo.svg +8 -0
  45. package/{dist/assets → src}/imgx/assets/templates/longform-3-4.html +1 -1
  46. package/{dist/assets → src}/imgx/assets/templates/poster-3-4.html +69 -30
  47. package/{dist/assets → src}/imgx/assets/templates/wechat-cover-split.html +58 -27
  48. package/src/imgx/cli.ts +57 -0
  49. package/src/imgx/geometry.ts +68 -0
  50. package/src/imgx/index.ts +5 -0
  51. package/src/imgx/longform-theme.ts +243 -0
  52. package/src/imgx/poster-recipe.ts +81 -0
  53. package/src/imgx/pretext-adapter.ts +14 -0
  54. package/src/imgx/pretext-runtime.ts +61 -0
  55. package/src/imgx/references/ascii-portrait-3-4.md +59 -0
  56. package/src/imgx/references/longform-3-4.md +173 -0
  57. package/src/imgx/references/poster-3-4.md +87 -0
  58. package/src/imgx/references/tips-3-4.md +69 -0
  59. package/src/imgx/references/wechat-cover-split.md +89 -0
  60. package/src/imgx/references/x-like-posts.md +139 -0
  61. package/src/imgx/render-article.test.ts +53 -0
  62. package/src/imgx/render-article.ts +1364 -0
  63. package/src/imgx/render-ascii-portrait.ts +53 -0
  64. package/src/imgx/render-card.ts +232 -0
  65. package/src/imgx/render-x-like-posts.ts +235 -0
  66. package/src/imgx/runtime.ts +403 -0
  67. package/src/imgx/typographic-scale.test.ts +56 -0
  68. package/src/imgx/typographic-scale.ts +44 -0
  69. package/src/output.ts +632 -0
  70. package/src/plugins.ts +101 -0
  71. package/src/profiles.ts +149 -0
  72. package/src/providers/blog.test.ts +58 -0
  73. package/src/providers/blog.ts +154 -0
  74. package/src/providers/cos.ts +183 -0
  75. package/src/providers/index.test.ts +26 -0
  76. package/src/providers/index.ts +185 -0
  77. package/src/providers/wechat.test.ts +215 -0
  78. package/src/providers/wechat.ts +548 -0
  79. package/src/routes.ts +287 -0
  80. package/src/runtime-paths.ts +179 -0
  81. package/src/schema/config.ts +133 -0
  82. package/src/schema/state.ts +376 -0
  83. package/src/spawn.ts +103 -0
  84. package/src/state.ts +265 -0
  85. package/src/task-manager.ts +657 -0
  86. package/src/task-views.ts +222 -0
  87. package/src/text.test.ts +689 -0
  88. package/src/text.ts +683 -0
  89. package/src/wechat-preview/assets/fonts/SweiCurveLegCJKsc-Regular.woff +0 -0
  90. package/src/wechat-preview/browser/editor-export.css +241 -0
  91. package/src/wechat-preview/browser/editor-export.ts +103 -0
  92. package/src/wechat-preview/frontmatter-handler.ts +73 -0
  93. package/src/wechat-preview/index.test.ts +154 -0
  94. package/src/wechat-preview/index.ts +294 -0
  95. package/src/wechat-preview/themes.ts +141 -0
  96. package/src/wechat-preview/wechat-formatter.test.ts +288 -0
  97. package/src/wechat-preview/wechat-formatter.ts +1506 -0
  98. package/src/wechat-preview.test.ts +73 -0
  99. package/src/workflow-materials.test.ts +225 -0
  100. package/src/workflow-materials.ts +217 -0
  101. package/src/workflow.test.ts +2087 -0
  102. package/dist/assets/imgx/assets/icons/avatar_jinx_cartoon.jpg +0 -0
  103. package/dist/assets/imgx/assets/icons/fishbone-logo-square.png +0 -0
  104. package/dist/assets/imgx/assets/icons/fishbone-logo.jpg +0 -0
  105. package/dist/assets/imgx/assets/icons/fishbone-logo.png +0 -0
  106. package/dist/assets/imgx/assets/icons/openclaw-logo.svg +0 -22
  107. package/dist/assets/imgx/assets/icons/zzclub-logo-black.jpg +0 -0
  108. package/dist/assets/imgx/assets/icons/zzclub-logo-gray.svg +0 -25
  109. package/dist/cli.js +0 -432
  110. package/dist/node_modules/@chenglou/pretext/dist/analysis.d.ts +0 -33
  111. package/dist/node_modules/@chenglou/pretext/dist/analysis.js +0 -1063
  112. package/dist/node_modules/@chenglou/pretext/dist/bidi.d.ts +0 -1
  113. package/dist/node_modules/@chenglou/pretext/dist/bidi.js +0 -175
  114. package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.d.ts +0 -4
  115. package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.js +0 -979
  116. package/dist/node_modules/@chenglou/pretext/dist/layout.d.ts +0 -70
  117. package/dist/node_modules/@chenglou/pretext/dist/layout.js +0 -496
  118. package/dist/node_modules/@chenglou/pretext/dist/line-break.d.ts +0 -36
  119. package/dist/node_modules/@chenglou/pretext/dist/line-break.js +0 -820
  120. package/dist/node_modules/@chenglou/pretext/dist/measurement.d.ts +0 -28
  121. package/dist/node_modules/@chenglou/pretext/dist/measurement.js +0 -219
  122. package/dist/node_modules/@chenglou/pretext/dist/rich-inline.d.ts +0 -51
  123. package/dist/node_modules/@chenglou/pretext/dist/rich-inline.js +0 -401
  124. /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.d.ts +0 -0
  125. /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.js +0 -0
  126. /package/{dist/assets → src}/imgx/assets/icons/clover.svg +0 -0
  127. /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-3-4.html +0 -0
  128. /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-tile.html +0 -0
  129. /package/{dist/assets → src}/imgx/assets/templates/tips-3-4.html +0 -0
  130. /package/{dist/assets → src}/imgx/assets/templates/x-like-posts.html +0 -0
  131. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/.vite/manifest.json +0 -0
  132. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  133. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  134. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  135. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  136. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  137. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  138. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  139. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  140. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  141. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  142. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  143. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  144. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  145. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  146. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  147. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  148. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  149. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  150. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  151. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  152. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  153. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  154. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  155. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  156. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  157. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  158. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  159. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  160. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  161. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  162. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  163. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  164. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  165. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  166. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  167. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  168. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  169. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  170. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  171. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  172. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  173. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  174. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  175. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  176. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  177. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  178. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  179. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  180. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  181. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  182. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  183. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  184. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  185. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  186. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  187. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  188. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  189. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  190. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  191. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/SweiCurveLegCJKsc-Regular-B3Ut5bwH.woff +0 -0
  192. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/style-BYNXnUvc.css +0 -0
  193. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/editor-export.js +0 -0
  194. /package/{dist/assets → src}/wechat-preview/assets/templates/export-shell.html +0 -0
@@ -0,0 +1,1506 @@
1
+ export interface WechatExportTheme {
2
+ containerStyle?: string
3
+ footerText?: string
4
+ footerStyle?: string
5
+ fontFamily?: string
6
+ bodyColor?: string
7
+ mutedColor?: string
8
+ primaryColor?: string
9
+ dividerColor?: string
10
+ blockquoteBorderColor?: string
11
+ bodyLineHeight?: string
12
+ bodyLetterSpacing?: string
13
+ }
14
+
15
+ export function escapeHtml(value: string): string {
16
+ return value
17
+ .replaceAll('&', '&')
18
+ .replaceAll('<', '&lt;')
19
+ .replaceAll('>', '&gt;')
20
+ .replaceAll('"', '&quot;')
21
+ .replaceAll("'", '&#39;')
22
+ }
23
+
24
+ // 微信公众号允许的标签白名单
25
+ const ALLOWED_TAGS = [
26
+ 'h1',
27
+ 'h2',
28
+ 'h3',
29
+ 'h4',
30
+ 'h5',
31
+ 'h6',
32
+ 'p',
33
+ 'blockquote',
34
+ 'ul',
35
+ 'ol',
36
+ 'li',
37
+ 'pre',
38
+ 'code',
39
+ 'span',
40
+ 'strong',
41
+ 'em',
42
+ 'b',
43
+ 'i',
44
+ 'u',
45
+ 's',
46
+ 'del',
47
+ 'a',
48
+ 'img',
49
+ 'br',
50
+ 'hr',
51
+ 'table',
52
+ 'thead',
53
+ 'tbody',
54
+ 'tr',
55
+ 'th',
56
+ 'td',
57
+ 'section',
58
+ 'div', // div 会被转换为 section
59
+ 'figure',
60
+ 'figcaption',
61
+ ]
62
+
63
+ // 允许的属性白名单
64
+ const ALLOWED_ATTRS: Record<string, string[]> = {
65
+ '*': ['style', 'data-code-block'],
66
+ 'img': ['src', 'alt', 'width', 'height', 'data-src', 'data-ratio', 'data-w'],
67
+ 'a': ['href', 'target', 'title'],
68
+ 'th': ['colspan', 'rowspan', 'align'],
69
+ 'td': ['colspan', 'rowspan', 'align'],
70
+ }
71
+
72
+ // 需要提取的 CSS 属性白名单 (合并了 demo 中的 EffectCssAttrs)
73
+ const STYLE_WHITELIST = [
74
+ 'font-family',
75
+ 'font-size',
76
+ 'font-weight',
77
+ 'font-style',
78
+ 'color',
79
+ 'text-align',
80
+ 'line-height',
81
+ 'letter-spacing', // 字间距
82
+ 'text-decoration',
83
+ 'background-color',
84
+ 'background-image',
85
+ 'border',
86
+ 'border-top',
87
+ 'border-right',
88
+ 'border-bottom',
89
+ 'border-left',
90
+ 'border-color',
91
+ 'border-width',
92
+ 'border-style',
93
+ 'border-radius',
94
+ 'padding',
95
+ 'padding-top',
96
+ 'padding-right',
97
+ 'padding-bottom',
98
+ 'padding-left',
99
+ 'margin',
100
+ 'margin-top',
101
+ 'margin-right',
102
+ 'margin-bottom',
103
+ 'margin-left',
104
+ // 'display', // 移除 display,防止 flex 布局复制导致缺少对齐属性错乱
105
+ 'width',
106
+ 'max-width',
107
+ // 'height', // 移除 height,防止高度固定导致内容溢出
108
+ 'list-style-type',
109
+ 'list-style-position',
110
+ 'white-space',
111
+ 'word-break',
112
+ 'overflow-x',
113
+ // 'vertical-align', // 移除 vertical-align,防止基线对齐问题
114
+ 'box-sizing',
115
+ 'text-size-adjust', // demo
116
+ ]
117
+
118
+ // 排除的类名
119
+ const EXCLUDE_CLASS_LIST = [
120
+ 'md-editor-copy-button', // 复制按钮
121
+ 'md-editor-icon',
122
+ 'md-editor-katex-inline', // 不支持 kateX 公式导出
123
+ 'md-editor-katex-block',
124
+ // Crepe Image Block exclude
125
+ 'operation',
126
+ 'operation-item',
127
+ 'image-resize-handle',
128
+ 'image-edit',
129
+ // 'not-prose', // 排除不需要样式的元素
130
+ // milkedown里代码块tools
131
+ 'cm-gutters',
132
+ 'cm-layer',
133
+ 'cm-announced',
134
+ 'tools',
135
+ ]
136
+
137
+ // 驼峰转连字符
138
+ // const camelCaseToHyphen = (str: string) => {
139
+ // return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
140
+ // }
141
+
142
+ // 缓存
143
+ // const htmlCache: Record<string, Record<string, string>> = {}
144
+ // const styleValueCache: Record<string, string> = {}
145
+
146
+ interface LinkReference {
147
+ text: string
148
+ url: string
149
+ }
150
+
151
+ interface ImageDimensionInput {
152
+ attrWidth?: string | null
153
+ attrHeight?: string | null
154
+ styleWidth?: string | null
155
+ styleHeight?: string | null
156
+ dataHeight?: string | null
157
+ renderedWidth?: number | null
158
+ renderedHeight?: number | null
159
+ }
160
+
161
+ export function normalizeCssLength(value?: string | null): string | null {
162
+ const trimmed = value?.trim()
163
+ if (!trimmed || trimmed === 'auto')
164
+ return null
165
+ if (/^\d+(?:\.\d+)?$/.test(trimmed))
166
+ return `${trimmed}px`
167
+ return trimmed
168
+ }
169
+
170
+ export function resolveImageDimensionStyles(input: ImageDimensionInput): string[] {
171
+ const styles: string[] = []
172
+ const renderedWidth = Number.isFinite(input.renderedWidth) ? input.renderedWidth ?? 0 : 0
173
+ const renderedHeight = Number.isFinite(input.renderedHeight) ? input.renderedHeight ?? 0 : 0
174
+ const explicitWidth = normalizeCssLength(input.attrWidth) ?? normalizeCssLength(input.styleWidth)
175
+ const explicitHeight
176
+ = normalizeCssLength(input.attrHeight)
177
+ ?? normalizeCssLength(input.styleHeight)
178
+ ?? normalizeCssLength(input.dataHeight)
179
+
180
+ if (explicitWidth) {
181
+ styles.push(`width: ${explicitWidth}`)
182
+ styles.push(`height: ${explicitHeight ?? 'auto'}`)
183
+ return styles
184
+ }
185
+
186
+ if (renderedWidth > 0) {
187
+ styles.push(`width: ${Math.round(renderedWidth)}px`)
188
+ styles.push('max-width: 100%')
189
+ }
190
+ else {
191
+ styles.push('max-width: 100%')
192
+ }
193
+
194
+ if (explicitHeight) {
195
+ styles.push(`height: ${explicitHeight}`)
196
+ }
197
+ else if (renderedHeight > 0) {
198
+ styles.push(`height: ${Math.round(renderedHeight)}px`)
199
+ }
200
+ else {
201
+ styles.push('height: auto')
202
+ }
203
+
204
+ return styles
205
+ }
206
+
207
+ export function rewriteStyleDeclarations(
208
+ styleText: string,
209
+ replacements: Record<string, string>,
210
+ ): string {
211
+ const entries = new Map<string, string>()
212
+
213
+ styleText
214
+ .split(';')
215
+ .map(part => part.trim())
216
+ .filter(Boolean)
217
+ .forEach((part) => {
218
+ const separatorIndex = part.indexOf(':')
219
+ if (separatorIndex === -1)
220
+ return
221
+ const key = part.slice(0, separatorIndex).trim()
222
+ const value = part.slice(separatorIndex + 1).trim()
223
+ if (!key)
224
+ return
225
+ entries.set(key, value)
226
+ })
227
+
228
+ Object.entries(replacements).forEach(([key, value]) => {
229
+ entries.set(key, value)
230
+ })
231
+
232
+ return Array.from(entries.entries())
233
+ .map(([key, value]) => `${key}: ${value}`)
234
+ .join('; ')
235
+ }
236
+
237
+ export function normalizeInlineEmphasisStyles(html: string): string {
238
+ return html.replace(
239
+ /<(strong|em|b|i)\b([^>]*?)style="([^"]*)"/g,
240
+ (_match, tagName: string, beforeStyle: string, styleText: string) => {
241
+ const normalized = rewriteStyleDeclarations(styleText, {
242
+ 'font-size': 'inherit',
243
+ 'line-height': 'inherit',
244
+ 'letter-spacing': 'inherit',
245
+ })
246
+ return `<${tagName}${beforeStyle}style="${normalized}"`
247
+ },
248
+ )
249
+ }
250
+
251
+ export function normalizeLinkStyles(html: string): string {
252
+ return html.replace(
253
+ /<(span|a)\b([^>]*?)style="([^"]*text-decoration:\s*underline[^"]*)"/g,
254
+ (_match, tagName: string, beforeStyle: string, styleText: string) => {
255
+ const normalized = rewriteStyleDeclarations(styleText, {
256
+ 'text-decoration': 'underline wavy',
257
+ })
258
+ return `<${tagName}${beforeStyle}style="${normalized}"`
259
+ },
260
+ )
261
+ }
262
+
263
+ export function normalizeHrStyles(html: string): string {
264
+ return html.replace(
265
+ /<hr\b([^>]*?)style="([^"]*)"/g,
266
+ (_match, beforeStyle: string, styleText: string) => {
267
+ const normalized = rewriteStyleDeclarations(styleText, {
268
+ 'display': 'block',
269
+ 'height': '0',
270
+ 'padding': '0',
271
+ 'padding-top': '0',
272
+ 'padding-right': '0',
273
+ 'padding-bottom': '0',
274
+ 'padding-left': '0',
275
+ 'margin': '2em 0',
276
+ 'background-color': 'transparent',
277
+ 'background-image': 'none',
278
+ 'border': 'none',
279
+ 'border-top': '1px solid #dadce0',
280
+ 'overflow-x': 'visible',
281
+ 'font-size': '0',
282
+ 'line-height': '0',
283
+ 'color': 'transparent',
284
+ })
285
+ return `<hr${beforeStyle}style="${normalized}"`
286
+ },
287
+ )
288
+ }
289
+
290
+ export interface ParsedTagInfo {
291
+ tagName: string | null
292
+ isClosing: boolean
293
+ isSelfClosing: boolean
294
+ preservesWhitespace: boolean
295
+ }
296
+
297
+ export function parseHtmlTag(tagText: string): ParsedTagInfo {
298
+ const trimmed = tagText.trim()
299
+ if (!trimmed.startsWith('<') || trimmed.startsWith('<!--')) {
300
+ return {
301
+ tagName: null,
302
+ isClosing: false,
303
+ isSelfClosing: false,
304
+ preservesWhitespace: false,
305
+ }
306
+ }
307
+
308
+ const isClosing = /^<\//.test(trimmed)
309
+ const nameMatch = trimmed.match(/^<\/?\s*([a-zA-Z0-9-]+)/)
310
+ const tagName = nameMatch?.[1]?.toLowerCase() ?? null
311
+ const isSelfClosing = /\/\s*>$/.test(trimmed) || ['img', 'br', 'hr', 'input', 'meta', 'link'].includes(tagName ?? '')
312
+ const preservesWhitespace = !isClosing && Boolean(
313
+ tagName && ['pre', 'code'].includes(tagName)
314
+ || /\sdata-code-block=(["'])true\1/i.test(trimmed),
315
+ )
316
+
317
+ return {
318
+ tagName,
319
+ isClosing,
320
+ isSelfClosing,
321
+ preservesWhitespace,
322
+ }
323
+ }
324
+
325
+ export function minifyHtmlPreservingCodeBlocks(html: string): string {
326
+ const openStack: Array<{ tagName: string; preservesWhitespace: boolean }> = []
327
+ let result = ''
328
+ let index = 0
329
+
330
+ while (index < html.length) {
331
+ const nextTagIndex = html.indexOf('<', index)
332
+
333
+ if (nextTagIndex === -1) {
334
+ const tail = html.slice(index)
335
+ result += openStack.some(entry => entry.preservesWhitespace) || tail.trim() ? tail : ''
336
+ break
337
+ }
338
+
339
+ const textChunk = html.slice(index, nextTagIndex)
340
+ if (openStack.some(entry => entry.preservesWhitespace) || textChunk.trim()) {
341
+ result += textChunk
342
+ }
343
+
344
+ let tagEndIndex = nextTagIndex
345
+ let quoteChar: '"' | "'" | null = null
346
+ while (tagEndIndex < html.length) {
347
+ const char = html[tagEndIndex]
348
+ if (quoteChar) {
349
+ if (char === quoteChar) {
350
+ quoteChar = null
351
+ }
352
+ }
353
+ else if (char === '"' || char === "'") {
354
+ quoteChar = char
355
+ }
356
+ else if (char === '>') {
357
+ break
358
+ }
359
+ tagEndIndex += 1
360
+ }
361
+
362
+ const tagText = html.slice(nextTagIndex, Math.min(tagEndIndex + 1, html.length))
363
+ result += tagText
364
+
365
+ const tagInfo = parseHtmlTag(tagText)
366
+ if (tagInfo.tagName) {
367
+ if (tagInfo.isClosing) {
368
+ for (let stackIndex = openStack.length - 1; stackIndex >= 0; stackIndex -= 1) {
369
+ const entry = openStack[stackIndex]
370
+ openStack.pop()
371
+ if (entry?.tagName === tagInfo.tagName) {
372
+ break
373
+ }
374
+ }
375
+ }
376
+ else if (!tagInfo.isSelfClosing) {
377
+ openStack.push({
378
+ tagName: tagInfo.tagName,
379
+ preservesWhitespace: tagInfo.preservesWhitespace,
380
+ })
381
+ }
382
+ }
383
+
384
+ index = tagEndIndex + 1
385
+ }
386
+
387
+ return result.trim()
388
+ }
389
+
390
+ function removeStyle(styles: string[], property: string): void {
391
+ for (let index = styles.length - 1; index >= 0; index -= 1) {
392
+ if (styles[index]?.startsWith(`${property}:`)) {
393
+ styles.splice(index, 1)
394
+ }
395
+ }
396
+ }
397
+
398
+ function setStyle(styles: string[], property: string, value: string): void {
399
+ removeStyle(styles, property)
400
+ styles.push(`${property}: ${value}`)
401
+ }
402
+
403
+ function isCodeContextElement(el: HTMLElement | null | undefined): boolean {
404
+ if (!el)
405
+ return false
406
+
407
+ return el.hasAttribute('data-code-block')
408
+ || el.classList.contains('milkdown-code-block')
409
+ || el.classList.contains('cm-editor')
410
+ || el.classList.contains('cm-content')
411
+ || el.classList.contains('cm-line')
412
+ || el.classList.contains('cm-lineWrapping')
413
+ || el.tagName.toLowerCase() === 'pre'
414
+ }
415
+
416
+ function hasPreformattedComputedStyle(el: HTMLElement | null | undefined): boolean {
417
+ if (!el || typeof window === 'undefined' || typeof window.getComputedStyle !== 'function')
418
+ return false
419
+
420
+ try {
421
+ const computed = window.getComputedStyle(el)
422
+ const whiteSpace = (computed.whiteSpace || '').toLowerCase()
423
+ return whiteSpace.startsWith('pre')
424
+ }
425
+ catch {
426
+ return false
427
+ }
428
+ }
429
+
430
+ function hasBlockCodeAncestor(el: HTMLElement | null | undefined): boolean {
431
+ let current = el
432
+ while (current) {
433
+ if (isCodeContextElement(current) || hasPreformattedComputedStyle(current))
434
+ return true
435
+ current = current.parentElement
436
+ }
437
+ return false
438
+ }
439
+
440
+ function normalizeTypography(
441
+ styles: string[],
442
+ originalTagName: string,
443
+ outTagName: string,
444
+ theme: WechatExportTheme,
445
+ el: HTMLElement,
446
+ isInCodeBlockContext: boolean,
447
+ ): void {
448
+ const fontFamily = theme.fontFamily
449
+ ?? "'SweiCurveLeg', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
450
+ const bodyColor = theme.bodyColor ?? '#2f3a33'
451
+ const mutedColor = theme.mutedColor ?? '#66756b'
452
+ const primaryColor = theme.primaryColor ?? '#486b58'
453
+ const dividerColor = theme.dividerColor ?? '#d8e0da'
454
+ const blockquoteBorderColor = theme.blockquoteBorderColor ?? '#9cb2a3'
455
+ const bodyLineHeight = theme.bodyLineHeight ?? '1.92'
456
+ const bodyLetterSpacing = theme.bodyLetterSpacing ?? '0.03em'
457
+ const parentTagName = el.parentElement?.tagName.toLowerCase()
458
+ const isInsideCodeBlock = isInCodeBlockContext || hasBlockCodeAncestor(el)
459
+
460
+ const clearTextFrame = () => {
461
+ removeStyle(styles, 'width')
462
+ removeStyle(styles, 'max-width')
463
+ removeStyle(styles, 'font-family')
464
+ }
465
+
466
+ if (isInsideCodeBlock && (outTagName === 'span' || outTagName === 'strong' || outTagName === 'em' || outTagName === 'b' || outTagName === 'i' || outTagName === 'u' || outTagName === 's' || outTagName === 'del')) {
467
+ clearTextFrame()
468
+ setStyle(styles, 'font-family', 'inherit')
469
+ setStyle(styles, 'font-size', 'inherit')
470
+ setStyle(styles, 'line-height', 'inherit')
471
+ setStyle(styles, 'letter-spacing', 'inherit')
472
+ return
473
+ }
474
+
475
+ if (originalTagName === 'a' || outTagName === 'a') {
476
+ clearTextFrame()
477
+ setStyle(styles, 'font-family', fontFamily)
478
+ setStyle(styles, 'font-size', 'inherit')
479
+ setStyle(styles, 'color', primaryColor)
480
+ setStyle(styles, 'text-decoration', 'underline wavy')
481
+ setStyle(styles, 'line-height', bodyLineHeight)
482
+ setStyle(styles, 'letter-spacing', bodyLetterSpacing)
483
+ return
484
+ }
485
+
486
+ if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(originalTagName)) {
487
+ clearTextFrame()
488
+ setStyle(styles, 'font-family', fontFamily)
489
+ setStyle(styles, 'font-weight', originalTagName === 'h3' ? '600' : '700')
490
+ setStyle(styles, 'color', originalTagName === 'h1' ? bodyColor : primaryColor)
491
+
492
+ if (originalTagName === 'h1') {
493
+ setStyle(styles, 'font-size', '2.1em')
494
+ setStyle(styles, 'line-height', '1.32')
495
+ setStyle(styles, 'letter-spacing', '-0.02em')
496
+ setStyle(styles, 'margin', '1.6em 0 0.7em')
497
+ }
498
+ else if (originalTagName === 'h2') {
499
+ setStyle(styles, 'font-size', '18px')
500
+ setStyle(styles, 'line-height', '1.4')
501
+ setStyle(styles, 'letter-spacing', '-0.01em')
502
+ setStyle(styles, 'margin', '1.45em 0 0.62em')
503
+ }
504
+ else if (originalTagName === 'h3') {
505
+ setStyle(styles, 'font-size', '1.14em')
506
+ setStyle(styles, 'line-height', '1.58')
507
+ setStyle(styles, 'letter-spacing', '0.01em')
508
+ setStyle(styles, 'margin', '1.28em 0 0.52em')
509
+ }
510
+ else {
511
+ setStyle(styles, 'font-size', '1em')
512
+ setStyle(styles, 'line-height', '1.65')
513
+ setStyle(styles, 'letter-spacing', '0.02em')
514
+ setStyle(styles, 'margin', '1.15em 0 0.45em')
515
+ }
516
+ return
517
+ }
518
+
519
+ if (outTagName === 'p') {
520
+ clearTextFrame()
521
+ setStyle(styles, 'font-family', fontFamily)
522
+ setStyle(styles, 'font-size', parentTagName === 'blockquote' ? '15px' : '16px')
523
+ setStyle(styles, 'color', parentTagName === 'blockquote' ? mutedColor : bodyColor)
524
+ setStyle(styles, 'line-height', bodyLineHeight)
525
+ setStyle(styles, 'letter-spacing', bodyLetterSpacing)
526
+
527
+ if (parentTagName === 'td' || parentTagName === 'th' || parentTagName === 'blockquote') {
528
+ setStyle(styles, 'margin', '0')
529
+ }
530
+ else if (!(el.parentElement && (el.parentElement.classList.contains('content-dom') || el.parentElement.closest('.list-item')))) {
531
+ setStyle(styles, 'margin', '1.12em 0')
532
+ }
533
+ return
534
+ }
535
+
536
+ if (outTagName === 'blockquote') {
537
+ clearTextFrame()
538
+ setStyle(styles, 'font-family', fontFamily)
539
+ setStyle(styles, 'color', mutedColor)
540
+ setStyle(styles, 'line-height', bodyLineHeight)
541
+ setStyle(styles, 'letter-spacing', bodyLetterSpacing)
542
+ setStyle(styles, 'margin', '1.35em 0')
543
+ setStyle(styles, 'padding-left', '16px')
544
+ setStyle(styles, 'border-left', `4px solid ${blockquoteBorderColor}`)
545
+ setStyle(styles, 'background-color', 'rgba(72, 107, 88, 0.04)')
546
+ return
547
+ }
548
+
549
+ if (outTagName === 'ul' || outTagName === 'ol') {
550
+ clearTextFrame()
551
+ setStyle(styles, 'font-family', fontFamily)
552
+ setStyle(styles, 'color', bodyColor)
553
+ setStyle(styles, 'line-height', bodyLineHeight)
554
+ setStyle(styles, 'letter-spacing', bodyLetterSpacing)
555
+ setStyle(styles, 'margin', '1em 0')
556
+ setStyle(styles, 'padding-left', '1.2em')
557
+ return
558
+ }
559
+
560
+ if (outTagName === 'span' || outTagName === 'strong' || outTagName === 'em' || outTagName === 'b' || outTagName === 'i' || outTagName === 'u' || outTagName === 's' || outTagName === 'del') {
561
+ clearTextFrame()
562
+ setStyle(styles, 'font-family', fontFamily)
563
+ setStyle(styles, 'font-size', 'inherit')
564
+ setStyle(styles, 'line-height', bodyLineHeight)
565
+ setStyle(styles, 'letter-spacing', bodyLetterSpacing)
566
+ if (outTagName === 'strong' || outTagName === 'b') {
567
+ setStyle(styles, 'font-weight', '700')
568
+ setStyle(styles, 'color', bodyColor)
569
+ }
570
+ if (outTagName === 'em' || outTagName === 'i') {
571
+ setStyle(styles, 'font-style', 'italic')
572
+ }
573
+ return
574
+ }
575
+
576
+ if (outTagName === 'hr') {
577
+ clearTextFrame()
578
+ removeStyle(styles, 'padding')
579
+ removeStyle(styles, 'padding-top')
580
+ removeStyle(styles, 'padding-right')
581
+ removeStyle(styles, 'padding-bottom')
582
+ removeStyle(styles, 'padding-left')
583
+ removeStyle(styles, 'background-color')
584
+ removeStyle(styles, 'background-image')
585
+ removeStyle(styles, 'font-size')
586
+ removeStyle(styles, 'line-height')
587
+ removeStyle(styles, 'color')
588
+ removeStyle(styles, 'overflow-x')
589
+ removeStyle(styles, 'border-radius')
590
+ setStyle(styles, 'border', 'none')
591
+ setStyle(styles, 'border-top', `1px solid ${dividerColor}`)
592
+ setStyle(styles, 'display', 'block')
593
+ setStyle(styles, 'height', '0')
594
+ setStyle(styles, 'padding', '0')
595
+ setStyle(styles, 'background-color', 'transparent')
596
+ setStyle(styles, 'overflow-x', 'visible')
597
+ setStyle(styles, 'margin', '2em 0')
598
+ return
599
+ }
600
+
601
+ if (outTagName === 'table') {
602
+ clearTextFrame()
603
+ setStyle(styles, 'font-family', fontFamily)
604
+ setStyle(styles, 'font-size', '15px')
605
+ setStyle(styles, 'line-height', '1.75')
606
+ setStyle(styles, 'letter-spacing', '0.02em')
607
+ setStyle(styles, 'width', '100%')
608
+ setStyle(styles, 'margin', '1.4em 0')
609
+ setStyle(styles, 'border-collapse', 'collapse')
610
+ return
611
+ }
612
+
613
+ if (outTagName === 'th' || outTagName === 'td') {
614
+ clearTextFrame()
615
+ setStyle(styles, 'font-family', fontFamily)
616
+ setStyle(styles, 'font-size', '15px')
617
+ setStyle(styles, 'line-height', '1.72')
618
+ setStyle(styles, 'letter-spacing', '0.02em')
619
+ setStyle(styles, 'padding', '10px 12px')
620
+ setStyle(styles, 'border', `1px solid ${dividerColor}`)
621
+ if (outTagName === 'th') {
622
+ setStyle(styles, 'font-weight', '700')
623
+ setStyle(styles, 'background-color', 'rgba(72, 107, 88, 0.06)')
624
+ setStyle(styles, 'color', primaryColor)
625
+ }
626
+ else {
627
+ setStyle(styles, 'color', bodyColor)
628
+ setStyle(styles, 'background-color', '#ffffff')
629
+ }
630
+ }
631
+ }
632
+
633
+ /**
634
+ * 生成自定义的上标 HTML (用于替代 sup 标签)
635
+ */
636
+ function getSupHtml(content: string): string {
637
+ // 微信公众号不支持伪元素(inline style无法定义),且 position: relative 兼容性不佳
638
+ // 改用 vertical-align: super 配合 line-height: 0
639
+ return `<span style="font-size: 0.75em; vertical-align: super; line-height: 0; margin: 0 2px;">${content}</span>`
640
+ }
641
+
642
+ /**
643
+ * 获取单个 DOM 的带内联样式的 HTML (所见即所得模式)
644
+ * @param node 要处理的节点
645
+ * @param references 链接引用数组
646
+ * @param targetStyles 目标样式属性列表
647
+ * @param isInCodeBlock 是否在代码块内 (用于空格处理)
648
+ */
649
+ function getOneDomCssStyle(
650
+ node: Node,
651
+ references: LinkReference[] = [],
652
+ theme: WechatExportTheme = {},
653
+ targetStyles?: string[],
654
+ isInCodeBlock: boolean = false,
655
+ ): string {
656
+ if (!node)
657
+ return ''
658
+
659
+ // 文本节点
660
+ if (node.nodeType === Node.TEXT_NODE) {
661
+ const text = node.nodeValue || ''
662
+ const textParent = node.parentElement
663
+ const shouldPreserveWhitespace = isInCodeBlock || hasBlockCodeAncestor(textParent)
664
+ // 在代码块内,保留所有空格和换行
665
+ // 将所有空格转换为 &nbsp; 以防止被 HTML 合并
666
+ if (shouldPreserveWhitespace && text) {
667
+ return text
668
+ .replace(/ /g, '\u00A0') // 所有空格转为 &nbsp;
669
+ .replace(/\t/g, '\u00A0\u00A0\u00A0\u00A0') // 制表符转为 4 个 &nbsp;
670
+ }
671
+ return text
672
+ }
673
+
674
+ // 注释节点
675
+ if (node.nodeType === Node.COMMENT_NODE) {
676
+ return ''
677
+ }
678
+
679
+ // 只处理元素节点
680
+ if (node.nodeType !== Node.ELEMENT_NODE) {
681
+ return ''
682
+ }
683
+
684
+ const el = node as HTMLElement
685
+ const tagName = el.tagName.toLowerCase()
686
+
687
+ // 标记代码块容器,用于后续压缩时保护
688
+ let isCodeBlockContainer = false
689
+ if (el.classList.contains('milkdown-code-block')) {
690
+ isCodeBlockContainer = true
691
+ }
692
+
693
+ // 判断当前是否进入代码块区域(用于子节点的空格保护)
694
+ // 包括 milkdown-code-block、CodeMirror 容器(cm-content、cm-editor)、以及传统的 pre/code
695
+ const currentIsInCodeBlock = isInCodeBlock
696
+ || isCodeContextElement(el)
697
+ || hasPreformattedComputedStyle(el)
698
+
699
+ // 降级兼容易处理:列表项 (LI) 统一转为 "- 文本" 形式
700
+ // 解决微信端列表渲染的各种对齐、间距和兼容性问题
701
+ if (tagName === 'li') {
702
+ let contentHtml = ''
703
+ let nestedListHtml = ''
704
+
705
+ // 辅助函数:提取子元素内容,尽可能扁平化 Block 元素 (P, DIV, SECTION) 为 Inline,以保持文本流
706
+ const extractInlineContent = (target: Node): string => {
707
+ let resHtml = ''
708
+ if (target.hasChildNodes()) {
709
+ Array.from(target.childNodes).forEach((child) => {
710
+ if (child.nodeType === Node.TEXT_NODE) {
711
+ // 文本节点直接处理
712
+ resHtml += getOneDomCssStyle(child, references, theme, [], isInCodeBlock)
713
+ }
714
+ else if (child.nodeType === Node.ELEMENT_NODE) {
715
+ const cEl = child as HTMLElement
716
+ const cTag = cEl.tagName.toLowerCase()
717
+
718
+ // 忽略图标容器
719
+ if (cEl.classList.contains('label-wrapper'))
720
+ return
721
+
722
+ // 嵌套列表稍后单独处理
723
+ if (['ul', 'ol'].includes(cTag))
724
+ return
725
+
726
+ // 遇到 Block 容器,递归提取内容 (剥离外壳)
727
+ if (['p', 'div', 'section', 'article', 'aside'].includes(cTag)) {
728
+ resHtml += extractInlineContent(child)
729
+ }
730
+ else {
731
+ // Inline 元素 (span, strong, code, a...), 保留样式
732
+ resHtml += getOneDomCssStyle(child, references, theme, [], isInCodeBlock)
733
+ }
734
+ }
735
+ })
736
+ }
737
+ return resHtml
738
+ }
739
+
740
+ // 1. 尝试获取 Milkdown/Crepe 的内容容器 (.content-dom)
741
+ const contentDom = el.querySelector('.content-dom')
742
+ if (contentDom) {
743
+ contentHtml = extractInlineContent(contentDom)
744
+ }
745
+ else {
746
+ // 2. 非 Standard Milkdown 结构,直接处理 li 子元素
747
+ contentHtml = extractInlineContent(el)
748
+ }
749
+
750
+ // 3. 处理嵌套列表 (递归)
751
+ const nestedLists = el.querySelectorAll(':scope > ul, :scope > ol')
752
+ nestedLists.forEach((list) => {
753
+ nestedListHtml += getOneDomCssStyle(list, references, theme, [], isInCodeBlock)
754
+ })
755
+
756
+ // 构造最终 HTML - 文本缩进布局 (Hanging Indent)
757
+ // 摒弃 Flex,使用 margin/padding/text-indent 组合,兼容性最好
758
+ const rowStyle = [
759
+ 'margin: 0.32em 0',
760
+ `line-height: ${theme.bodyLineHeight ?? '1.92'}`,
761
+ `color: ${theme.bodyColor ?? '#2f3a33'}`,
762
+ 'font-size: 15px',
763
+ `font-family: ${theme.fontFamily ?? "'SweiCurveLeg', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"}`,
764
+ 'padding-left: 1.35em',
765
+ 'text-indent: -0.92em',
766
+ `letter-spacing: ${theme.bodyLetterSpacing ?? '0.03em'}`,
767
+ ].join('; ')
768
+
769
+ let rowHtml = ''
770
+ if (contentHtml.trim()) {
771
+ rowHtml = `<p style="${rowStyle}">- ${contentHtml}</p>`
772
+ }
773
+
774
+ return rowHtml + nestedListHtml
775
+ }
776
+
777
+ // 任务列表 checkbox 处理
778
+ if (tagName === 'input' && (el.getAttribute('type') === 'checkbox' || el.classList.contains('task-list-item-checkbox'))) {
779
+ const isChecked = el.hasAttribute('checked') || (el as HTMLInputElement).checked
780
+ // 使用 emoji 替代 checkbox
781
+ // 强制使用 inline-flex 并垂直居中,确保在 flex 容器中对齐
782
+ return `<span style="display: inline-flex; align-items: center; justify-content: center; margin-right: 6px; width: 1.2em; height: 1.2em; font-size: 1em;">${isChecked ? '✅' : '❌'}</span>`
783
+ }
784
+
785
+ // sup 标签处理 (使用自定义样式替代)
786
+ if (tagName === 'sup') {
787
+ // 递归处理子元素以保留内部样式
788
+ let childrenHtml = ''
789
+ if (el.hasChildNodes()) {
790
+ Array.from(el.childNodes).forEach((child) => {
791
+ childrenHtml += getOneDomCssStyle(child, references, theme, undefined, currentIsInCodeBlock)
792
+ })
793
+ }
794
+ else {
795
+ childrenHtml = el.innerHTML
796
+ }
797
+ return getSupHtml(childrenHtml)
798
+ }
799
+
800
+ // Crepe 列表图标 (SVG) 处理 - 转为 Base64 图片
801
+ // 宽松匹配:只要包含了 SVG 的 milkdown-icon (bullet 或 label) 都进行处理
802
+ if (tagName === 'span' && el.classList.contains('milkdown-icon') && (el.classList.contains('bullet') || el.classList.contains('label'))) {
803
+ try {
804
+ const svg = el.querySelector('svg')
805
+ if (svg) {
806
+ const serializer = new XMLSerializer()
807
+ const svgString = serializer.serializeToString(svg)
808
+ // 修正颜色:强制使用黑色或深色
809
+ // 对于 checkbox (label),如果是 checked 状态,路径通常是 filled
810
+ // 这里统一将 currentColor 替换为 #333333
811
+ const fixedSvgString = svgString.replace(/currentColor/g, '#333333')
812
+
813
+ const base64 = window.btoa(unescape(encodeURIComponent(fixedSvgString)))
814
+ const imgSrc = `data:image/svg+xml;base64,${base64}`
815
+
816
+ // 根据类型调整尺寸和位置
817
+ let iconStyle = 'display: inline-block; flex-shrink: 0;'
818
+
819
+ // 普通行高假设为 1.6 * 16px ≈ 25.6px 或 24px。视实际编辑器样式而定。
820
+ // 这里按常见的 line-height: 1.6 (约 26px) 或 1.5 (24px) 来计算对齐。
821
+ // 假设行高为 24px。
822
+
823
+ if (el.classList.contains('label')) {
824
+ // Task list checkbox (增大至 22px)
825
+ iconStyle += 'width: 22px; height: 22px; margin-right: 4px;'
826
+ // 垂直偏移:(24 - 22) / 2 = 1px。
827
+ iconStyle += 'margin-top: 1px;'
828
+ }
829
+ else {
830
+ // Bullet point (增大至 12px)
831
+ iconStyle += 'width: 12px; height: 12px; margin-right: 6px;'
832
+ // 垂直偏移:(24 - 12) / 2 = 6px。
833
+ iconStyle += 'margin-top: 6px;'
834
+ }
835
+
836
+ // 返回 img
837
+ return `<img src="${imgSrc}" style="${iconStyle}" />`
838
+ }
839
+ }
840
+ catch (e) {
841
+ console.error('List icon conversion failed', e)
842
+ }
843
+ }
844
+
845
+ // Mermaid 图表处理 (转为图片)
846
+ if (el.classList.contains('md-editor-mermaid')) {
847
+ try {
848
+ const svg = el.querySelector('svg')
849
+ if (svg) {
850
+ // 获取 SVG 的实际渲染宽度,避免图片过大
851
+ const rect = svg.getBoundingClientRect()
852
+ const width = rect.width
853
+
854
+ const serializer = new XMLSerializer()
855
+ const svgString = serializer.serializeToString(svg)
856
+ // 处理 Unicode 字符
857
+ const base64 = window.btoa(unescape(encodeURIComponent(svgString)))
858
+ const imgSrc = `data:image/svg+xml;base64,${base64}`
859
+
860
+ // 设置宽度样式,如果获取到了有效宽度
861
+ const widthStyle = width ? `width: ${width}px;` : ''
862
+ return `<img src="${imgSrc}" style="display: block; margin: 14px auto; max-width: 100%; ${widthStyle} height: auto;" />`
863
+ }
864
+ }
865
+ catch (e) {
866
+ console.error('Mermaid SVG conversion failed:', e)
867
+ }
868
+ }
869
+
870
+ // 忽略无效标签 (Crepe caption-input 除外)
871
+ if (['script', 'style', 'button', 'link', 'meta', 'input', 'textarea', 'select'].includes(tagName)) {
872
+ // 特殊处理 Crepe 图片描述输入框
873
+ if (tagName === 'input' && el.classList.contains('caption-input')) {
874
+ const val = (el as HTMLInputElement).value
875
+ if (!val)
876
+ return ''
877
+ // 返回居中的 figcaption
878
+ return `<figcaption style="margin-top: 6px; text-align: center; color: #888; font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">${val}</figcaption>`
879
+ }
880
+ return ''
881
+ }
882
+
883
+ // 检查排除的 class
884
+ if (el.classList && Array.from(el.classList).some(c => EXCLUDE_CLASS_LIST.includes(c))) {
885
+ return ''
886
+ }
887
+
888
+ // 标签过滤
889
+ let outTagName = tagName
890
+ if (!ALLOWED_TAGS.includes(tagName)) {
891
+ if (el.hasChildNodes()) {
892
+ let childrenHtml = ''
893
+ Array.from(el.childNodes).forEach((child) => {
894
+ childrenHtml += getOneDomCssStyle(child, references, theme, undefined, currentIsInCodeBlock)
895
+ })
896
+ return childrenHtml
897
+ }
898
+ return ''
899
+ }
900
+
901
+ // div -> section
902
+ if (outTagName === 'div') {
903
+ outTagName = 'section'
904
+ }
905
+
906
+ // h1-h6 -> section (避免移动端编辑器覆盖标题标签的默认样式)
907
+ if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(outTagName)) {
908
+ outTagName = 'section'
909
+ }
910
+
911
+ // 链接处理 (a -> span + sup)
912
+ let linkSupHtml = ''
913
+ if (outTagName === 'a') {
914
+ const href = el.getAttribute('href')
915
+ // 简单的外部链接判断 (包含 http 且不是锚点)
916
+ if (href && (href.startsWith('http') || href.startsWith('//'))) {
917
+ const text = el.textContent || ''
918
+ references.push({ text, url: href })
919
+ const index = references.length
920
+ linkSupHtml = getSupHtml(`[${index}]`)
921
+ // 将 a 标签转换为 span,保留样式但移除链接功能
922
+ outTagName = 'span'
923
+ }
924
+ }
925
+ // 获取计算样式
926
+ const computedStyle = window.getComputedStyle(el)
927
+ const styles: string[] = []
928
+
929
+ // 确定要提取的属性列表
930
+ const attrsToExtract = (targetStyles && targetStyles.length > 0) ? targetStyles : [...STYLE_WHITELIST]
931
+
932
+ // Crepe 列表项 (List Item) 特殊处理
933
+ if (el.classList.contains('list-item')) {
934
+ // 强制使用 flex 布局,确保图标/序号和内容在同一行
935
+ if (!attrsToExtract.includes('display'))
936
+ attrsToExtract.push('display')
937
+ if (!attrsToExtract.includes('align-items'))
938
+ attrsToExtract.push('align-items')
939
+ // 移除可能导致换行的属性
940
+ // flex-direction 默认为 row
941
+ }
942
+
943
+ // Crepe 列表 Label (序号/图标容器) 特殊处理
944
+ if (el.classList.contains('label-wrapper')) {
945
+ if (!attrsToExtract.includes('display'))
946
+ attrsToExtract.push('display')
947
+ // 确保不换行
948
+ if (!attrsToExtract.includes('white-space'))
949
+ attrsToExtract.push('white-space')
950
+ }
951
+
952
+ // Crepe Image Wrapper 特殊处理:强制居中对齐
953
+ if (el.classList.contains('image-wrapper')) {
954
+ // 强制使用 flex 居中
955
+ if (!attrsToExtract.includes('display'))
956
+ attrsToExtract.push('display')
957
+ if (!attrsToExtract.includes('justify-content'))
958
+ attrsToExtract.push('justify-content')
959
+ if (!attrsToExtract.includes('align-items'))
960
+ attrsToExtract.push('align-items')
961
+ if (!attrsToExtract.includes('flex-direction'))
962
+ attrsToExtract.push('flex-direction')
963
+ }
964
+
965
+ // 特殊处理:对于任务列表项、代码块头部、代码块内容,保留 display 属性以维持布局
966
+ if (el.classList.contains('task-list-item')
967
+ || el.classList.contains('md-editor-code-head')
968
+ || el.classList.contains('md-editor-code-flag') // 圆点容器
969
+ || el.classList.contains('md-editor-code-action') // 语言标识容器
970
+ || tagName === 'pre'
971
+ || tagName === 'code') {
972
+ if (!attrsToExtract.includes('display'))
973
+ attrsToExtract.push('display')
974
+ if (!attrsToExtract.includes('align-items'))
975
+ attrsToExtract.push('align-items')
976
+ if (!attrsToExtract.includes('justify-content'))
977
+ attrsToExtract.push('justify-content')
978
+ if (!attrsToExtract.includes('flex-direction'))
979
+ attrsToExtract.push('flex-direction')
980
+ if (!attrsToExtract.includes('flex-wrap'))
981
+ attrsToExtract.push('flex-wrap')
982
+ }
983
+
984
+ // 特殊处理:代码块头部的圆点需要 height 和 width
985
+ const parent = el.parentElement
986
+ // 注意:实际类名是 md-editor-code-flag,不是 md-editor-code-head-dots
987
+ const isCodeHeadDot = parent && parent.classList.contains('md-editor-code-flag') && tagName === 'span'
988
+ // 如果是代码块头部的圆点,直接返回固定样式的 span,避免样式丢失
989
+ if (isCodeHeadDot) {
990
+ const computedDotStyle = window.getComputedStyle(el)
991
+ const bgColor = computedDotStyle.backgroundColor || '#ff5f56'
992
+ // 强制设置圆点样式:固定 12px 圆形,带背景色和右间距
993
+ return `<span style="display: inline-block; width: 12px; height: 12px; border-radius: 50%; background-color: ${bgColor}; margin-right: 8px;"></span>`
994
+ }
995
+
996
+ attrsToExtract.forEach((attr) => {
997
+ const camelAttr = attr.replace(/-([a-z])/g, g => (g[1] ? g[1].toUpperCase() : ''))
998
+ let value = computedStyle[camelAttr as any]
999
+
1000
+ // 过滤无效值
1001
+ if (!value || value === 'initial' || value === 'none' || value === 'normal' || value === 'auto') {
1002
+ // 保留 display 的特殊情况
1003
+ if (attr === 'display') {
1004
+ // 忽略默认的 display
1005
+ }
1006
+ else {
1007
+ // 颜色透明忽略
1008
+ if (value === 'rgba(0, 0, 0, 0)')
1009
+ return
1010
+ // 0px 忽略
1011
+ if (value === '0px')
1012
+ return
1013
+ // 过滤 border: 0px ...
1014
+ if (attr.startsWith('border') && value && value.startsWith('0px') && attr !== 'border-radius')
1015
+ return
1016
+ }
1017
+ }
1018
+
1019
+ // 强制保留的属性 (demo 逻辑)
1020
+ if (outTagName === 'pre') {
1021
+ if (attr === 'white-space')
1022
+ value = 'pre-wrap'
1023
+ if (attr === 'word-break')
1024
+ value = 'break-all'
1025
+ if (attr === 'overflow-x')
1026
+ value = 'auto'
1027
+ }
1028
+
1029
+ // code 标签样式处理 (根据上下文判断)
1030
+ if (outTagName === 'code') {
1031
+ // 注意: 这里在提取样式阶段,具体处理在后面的专门代码块中
1032
+ // 此处先保留原逻辑,避免过早干预
1033
+ if (attr === 'white-space') {
1034
+ // 暂不强制设置,留给后续判断
1035
+ // value = 'pre-wrap'
1036
+ }
1037
+ }
1038
+
1039
+ // 微信公众号不支持 grid 布局,转换为 flex
1040
+ if (attr === 'display' && value === 'grid') {
1041
+ value = 'flex'
1042
+ }
1043
+
1044
+ if (value) {
1045
+ styles.push(`${attr}:${value}`)
1046
+ }
1047
+ })
1048
+
1049
+ // Crepe 列表项强制样式修正
1050
+ if (el.classList.contains('list-item')) {
1051
+ // 强制 Flex Row
1052
+ // 移除可能存在的 block
1053
+ const displayIdx = styles.findIndex(s => s.startsWith('display:'))
1054
+ if (displayIdx > -1)
1055
+ styles.splice(displayIdx, 1)
1056
+ styles.push('display: flex')
1057
+
1058
+ // 顶部对齐或基线对齐,防止文字过多时图标位置奇怪
1059
+ styles.push('align-items: flex-start')
1060
+
1061
+ // 间距
1062
+ if (!styles.some(s => s.startsWith('margin-bottom:'))) {
1063
+ styles.push('margin-bottom: 2px')
1064
+ }
1065
+ }
1066
+
1067
+ // Crepe 列表 Label 修正
1068
+ if (el.classList.contains('label-wrapper')) {
1069
+ styles.push('flex-shrink: 0') // 防止被压缩
1070
+ styles.push('margin-right: 6px') // 和内容的间距
1071
+ // 移除了 margin-top: 2px,因为现在通过 icon 的 margin-top 来控制精确对齐
1072
+ }
1073
+
1074
+ // Crepe 列表内容修正
1075
+ if (el.classList.contains('content-dom')) {
1076
+ styles.push('flex: 1') // 占据剩余空间
1077
+ styles.push('min-width: 0') // 防止溢出
1078
+ // 强制移除 content-dom 可能存在的 margin,防止多行列表间距过大
1079
+ const marginIdx = styles.findIndex(s => s.startsWith('margin:'))
1080
+ if (marginIdx > -1)
1081
+ styles.splice(marginIdx, 1)
1082
+ styles.push('margin: 0')
1083
+ }
1084
+
1085
+ // Crepe Image Wrapper 强制修正样式
1086
+ if (el.classList.contains('image-wrapper')) {
1087
+ // 强制宽度100%或居中
1088
+ const widthIdx = styles.findIndex(s => s.startsWith('width:'))
1089
+ if (widthIdx > -1)
1090
+ styles.splice(widthIdx, 1)
1091
+ styles.push('width: 100%') // 铺满容器,配合内部 img 的 margin: auto 或 flex center
1092
+
1093
+ // 强制 margin: 0
1094
+ const marginIdx = styles.findIndex(s => s.startsWith('margin:'))
1095
+ if (marginIdx > -1)
1096
+ styles.splice(marginIdx, 1)
1097
+ styles.push('margin: 0')
1098
+ }
1099
+
1100
+ // Blockquote 引用块特殊处理
1101
+ if (outTagName === 'blockquote') {
1102
+ // 移除原有 border-left (如果有)
1103
+ const borderLeftIdx = styles.findIndex(s => s.startsWith('border-left:'))
1104
+ if (borderLeftIdx > -1)
1105
+ styles.splice(borderLeftIdx, 1)
1106
+
1107
+ // 移除原有 padding-left
1108
+ const paddingLeftIdx = styles.findIndex(s => s.startsWith('padding-left:'))
1109
+ if (paddingLeftIdx > -1)
1110
+ styles.splice(paddingLeftIdx, 1)
1111
+
1112
+ // 移除原有 color
1113
+ const colorIdx = styles.findIndex(s => s.startsWith('color:'))
1114
+ if (colorIdx > -1)
1115
+ styles.splice(colorIdx, 1)
1116
+
1117
+ // 注入模拟的引用条样式 (Github 风格)
1118
+ styles.push(`border-left: 4px solid ${theme.blockquoteBorderColor ?? '#9cb2a3'}`)
1119
+ styles.push('padding-left: 16px')
1120
+ styles.push(`color: ${theme.mutedColor ?? '#66756b'}`)
1121
+ // 确保有垂直间距
1122
+ if (!styles.some(s => s.startsWith('margin:'))) {
1123
+ styles.push('margin-top: 1.2em')
1124
+ styles.push('margin-bottom: 1.2em')
1125
+ }
1126
+ }
1127
+
1128
+ // 列表项特殊处理 (li)
1129
+ if (outTagName === 'li') {
1130
+ // 强制移除 li 自身的 margin/padding,防止额外间距
1131
+ const marginIdx = styles.findIndex(s => s.startsWith('margin:'))
1132
+ if (marginIdx > -1)
1133
+ styles.splice(marginIdx, 1)
1134
+
1135
+ // 只保留极小的底部间距
1136
+ const marginBottomIdx = styles.findIndex(s => s.startsWith('margin-bottom:'))
1137
+ if (marginBottomIdx > -1)
1138
+ styles.splice(marginBottomIdx, 1)
1139
+ styles.push('margin-bottom: 2px')
1140
+
1141
+ const paddingIdx = styles.findIndex(s => s.startsWith('padding:'))
1142
+ if (paddingIdx > -1)
1143
+ styles.splice(paddingIdx, 1)
1144
+ styles.push('padding: 0')
1145
+
1146
+ // 强制移除 list-style,防止任务列表出现双重标记
1147
+ if (el.classList.contains('task-list-item')) {
1148
+ styles.push('list-style: none')
1149
+
1150
+ // 强制 Flex 布局
1151
+ // 先移除可能存在的 display (比如 block)
1152
+ const displayIdx = styles.findIndex(s => s.startsWith('display:'))
1153
+ if (displayIdx > -1)
1154
+ styles.splice(displayIdx, 1)
1155
+ styles.push('display: flex')
1156
+
1157
+ // 强制移除可能存在的 align-items: normal,然后设置为 flex-start 以确保多行文本时图标顶对齐
1158
+ const alignIdx = styles.findIndex(s => s.startsWith('align-items:'))
1159
+ if (alignIdx > -1)
1160
+ styles.splice(alignIdx, 1)
1161
+ styles.push('align-items: flex-start')
1162
+ }
1163
+ }
1164
+
1165
+ // 代码块头部特殊处理 (grid -> flex 布局兼容)
1166
+ if (el.classList.contains('md-editor-code-head')) {
1167
+ // 确保使用 flex 布局
1168
+ const displayIdx = styles.findIndex(s => s.startsWith('display:'))
1169
+ if (displayIdx > -1)
1170
+ styles.splice(displayIdx, 1)
1171
+ styles.push('display: flex')
1172
+ // 强制横向排列
1173
+ const flexDirIdx = styles.findIndex(s => s.startsWith('flex-direction:'))
1174
+ if (flexDirIdx > -1)
1175
+ styles.splice(flexDirIdx, 1)
1176
+ styles.push('flex-direction: row')
1177
+ // 左右分布
1178
+ if (!styles.some(s => s.startsWith('justify-content:'))) {
1179
+ styles.push('justify-content: space-between')
1180
+ }
1181
+ // 垂直居中
1182
+ if (!styles.some(s => s.startsWith('align-items:'))) {
1183
+ styles.push('align-items: center')
1184
+ }
1185
+ // 移除固定宽度,让内容自适应
1186
+ const widthIdx = styles.findIndex(s => s.startsWith('width:'))
1187
+ if (widthIdx > -1)
1188
+ styles.splice(widthIdx, 1)
1189
+ styles.push('width: 100%')
1190
+ // 强制高度为紧凑的小细条
1191
+ const heightIdx = styles.findIndex(s => s.startsWith('height:'))
1192
+ if (heightIdx > -1)
1193
+ styles.splice(heightIdx, 1)
1194
+ // 移除可能导致高度问题的 line-height
1195
+ const lineHeightIdx = styles.findIndex(s => s.startsWith('line-height:'))
1196
+ if (lineHeightIdx > -1)
1197
+ styles.splice(lineHeightIdx, 1)
1198
+ styles.push('height: 32px')
1199
+ styles.push('line-height: 32px')
1200
+ styles.push('padding: 0 12px')
1201
+ }
1202
+
1203
+ // 代码块头部的圆点容器特殊处理
1204
+ if (el.classList.contains('md-editor-code-flag')) {
1205
+ // 移除固定宽度,使用自适应
1206
+ const widthIdx = styles.findIndex(s => s.startsWith('width:'))
1207
+ if (widthIdx > -1)
1208
+ styles.splice(widthIdx, 1)
1209
+ styles.push('width: auto')
1210
+ // 确保是 flex
1211
+ const displayIdx = styles.findIndex(s => s.startsWith('display:'))
1212
+ if (displayIdx > -1)
1213
+ styles.splice(displayIdx, 1)
1214
+ styles.push('display: flex')
1215
+ styles.push('align-items: center')
1216
+ styles.push('flex-direction: row')
1217
+ }
1218
+
1219
+ // 代码块头部的语言标识容器特殊处理
1220
+ if (el.classList.contains('md-editor-code-action')) {
1221
+ // 移除固定宽度,使用自适应
1222
+ const widthIdx = styles.findIndex(s => s.startsWith('width:'))
1223
+ if (widthIdx > -1)
1224
+ styles.splice(widthIdx, 1)
1225
+ styles.push('width: auto')
1226
+ // 确保是 flex 横向排列
1227
+ const displayIdx = styles.findIndex(s => s.startsWith('display:'))
1228
+ if (displayIdx > -1)
1229
+ styles.splice(displayIdx, 1)
1230
+ styles.push('display: flex')
1231
+ styles.push('align-items: center')
1232
+ styles.push('flex-direction: row')
1233
+ }
1234
+
1235
+ // Milkdown 代码块容器特殊处理
1236
+ if (el.classList.contains('milkdown-code-block')) {
1237
+ // 移除原有 padding (工具栏相关的大 padding)
1238
+ const paddingIdx = styles.findIndex(s => s.startsWith('padding:'))
1239
+ if (paddingIdx > -1)
1240
+ styles.splice(paddingIdx, 1)
1241
+ // 强制设置紧凑的 padding
1242
+ styles.push('padding: 10px')
1243
+ }
1244
+
1245
+ // 语言标识 span 特殊处理
1246
+ if (el.classList.contains('md-editor-code-lang')) {
1247
+ // 移除固定宽度
1248
+ const widthIdx = styles.findIndex(s => s.startsWith('width:'))
1249
+ if (widthIdx > -1)
1250
+ styles.splice(widthIdx, 1)
1251
+ styles.push('width: auto')
1252
+ // 确保横向显示
1253
+ const displayIdx = styles.findIndex(s => s.startsWith('display:'))
1254
+ if (displayIdx > -1)
1255
+ styles.splice(displayIdx, 1)
1256
+ styles.push('display: inline-block')
1257
+ // 调整行高
1258
+ const lineHeightIdx = styles.findIndex(s => s.startsWith('line-height:'))
1259
+ if (lineHeightIdx > -1)
1260
+ styles.splice(lineHeightIdx, 1)
1261
+ styles.push('line-height: 1')
1262
+ }
1263
+
1264
+ // code 标签特殊处理
1265
+ if (outTagName === 'code') {
1266
+ // 判断是否为行内 code (父元素不是 pre)
1267
+ const isInlineCode = !parent || parent.tagName.toLowerCase() !== 'pre'
1268
+
1269
+ if (isInlineCode) {
1270
+ // 行内 code: 作为强调样式,允许正常折行
1271
+ // 移除可能导致不换行的样式
1272
+ const whiteSpaceIdx = styles.findIndex(s => s.startsWith('white-space:'))
1273
+ if (whiteSpaceIdx > -1)
1274
+ styles.splice(whiteSpaceIdx, 1)
1275
+ styles.push('white-space: normal')
1276
+
1277
+ // 允许单词内断行,避免单词过长撑破布局
1278
+ if (!styles.some(s => s.startsWith('word-break:')))
1279
+ styles.push('word-break: break-word')
1280
+
1281
+ // 移除固定宽度,防止安卓机型显示异常
1282
+ const widthIdx = styles.findIndex(s => s.startsWith('width:'))
1283
+ if (widthIdx > -1)
1284
+ styles.splice(widthIdx, 1)
1285
+
1286
+ const maxWidthIdx = styles.findIndex(s => s.startsWith('max-width:'))
1287
+ if (maxWidthIdx > -1)
1288
+ styles.splice(maxWidthIdx, 1)
1289
+
1290
+ // 确保 inline 显示,不独占一行
1291
+ const displayIdx = styles.findIndex(s => s.startsWith('display:'))
1292
+ if (displayIdx > -1)
1293
+ styles.splice(displayIdx, 1)
1294
+ styles.push('display: inline')
1295
+
1296
+ // 移除可能存在的固定高度
1297
+ const heightIdx = styles.findIndex(s => s.startsWith('height:'))
1298
+ if (heightIdx > -1)
1299
+ styles.splice(heightIdx, 1)
1300
+ }
1301
+ else {
1302
+ // 代码块内的 code: 保留空格和换行
1303
+ if (!styles.some(s => s.startsWith('white-space:')))
1304
+ styles.push('white-space: pre-wrap')
1305
+ if (!styles.some(s => s.startsWith('word-break:')))
1306
+ styles.push('word-break: break-all')
1307
+ }
1308
+ }
1309
+
1310
+ // pre 标签特殊处理 (确保空格和换行保留)
1311
+ if (outTagName === 'pre') {
1312
+ if (!styles.some(s => s.startsWith('white-space:'))) {
1313
+ styles.push('white-space: pre-wrap')
1314
+ }
1315
+ }
1316
+
1317
+ // 图片特殊处理
1318
+ if (outTagName === 'img') {
1319
+ // 获取图片在页面上的实际渲染尺寸
1320
+ const rect = el.getBoundingClientRect()
1321
+ const renderedWidth = rect.width
1322
+ const renderedHeight = rect.height
1323
+
1324
+ // 移除可能存在的固定宽高
1325
+ const widthIdx = styles.findIndex(s => s.startsWith('width:'))
1326
+ if (widthIdx > -1)
1327
+ styles.splice(widthIdx, 1)
1328
+ const heightIdx = styles.findIndex(s => s.startsWith('height:'))
1329
+ if (heightIdx > -1)
1330
+ styles.splice(heightIdx, 1)
1331
+ const maxWidthIdx = styles.findIndex(s => s.startsWith('max-width:'))
1332
+ if (maxWidthIdx > -1)
1333
+ styles.splice(maxWidthIdx, 1)
1334
+
1335
+ styles.push(...resolveImageDimensionStyles({
1336
+ attrWidth: el.getAttribute('width'),
1337
+ attrHeight: el.getAttribute('height'),
1338
+ styleWidth: el.style.width,
1339
+ styleHeight: el.style.height,
1340
+ dataHeight: el.getAttribute('data-height'),
1341
+ renderedWidth,
1342
+ renderedHeight,
1343
+ }))
1344
+
1345
+ if (!styles.some(s => s.startsWith('display:'))) {
1346
+ styles.push('display: block')
1347
+ styles.push('margin: 14px auto')
1348
+ }
1349
+ }
1350
+
1351
+ normalizeTypography(styles, tagName, outTagName, theme, el, currentIsInCodeBlock)
1352
+
1353
+ const styleStr = styles.join(';')
1354
+
1355
+ // 递归处理子元素
1356
+ let childrenHtml = ''
1357
+ if (el.childNodes && el.childNodes.length > 0) {
1358
+ Array.from(el.childNodes).forEach((child) => {
1359
+ // 针对 pre/code 的子元素进行样式精简,防止污染
1360
+ // 如果当前是 pre,子元素只保留颜色等基本属性?
1361
+ // demo 逻辑:child.tagName === 'pre' ? ['color'] : []
1362
+ // 这里我们采用更智能的策略:如果父级是 pre/code,子级 span (通常是高亮) 只保留颜色和字体样式
1363
+ let childTargetStyles: string[] | undefined
1364
+ if (['pre', 'code'].includes(outTagName) && child.nodeName === 'SPAN') {
1365
+ childTargetStyles = ['color', 'font-weight', 'font-style', 'text-decoration', 'background-color', 'white-space']
1366
+ }
1367
+
1368
+ childrenHtml += getOneDomCssStyle(child, references, theme, childTargetStyles, currentIsInCodeBlock)
1369
+ })
1370
+ }
1371
+ else {
1372
+ // 如果没有子节点 (可能是空标签,或者某些特殊情况),尝试使用 innerHTML 作为兜底
1373
+ // 注意:这可能会导致内部标签没有被内联样式处理,但至少能保留内容
1374
+ if (el.innerHTML) {
1375
+ childrenHtml = el.innerHTML
1376
+ }
1377
+ }
1378
+
1379
+ // 组装属性
1380
+ const attrs: string[] = []
1381
+ if (styleStr) {
1382
+ // 修复 font-family 等可能包含双引号导致 style 属性截断的问题
1383
+ const safeStyleStr = styleStr.replace(/"/g, '\'')
1384
+ attrs.push(`style="${safeStyleStr}"`)
1385
+ }
1386
+
1387
+ // 添加代码块标记
1388
+ if (isCodeBlockContainer) {
1389
+ attrs.push('data-code-block="true"')
1390
+ }
1391
+
1392
+ const allowedForTag = ALLOWED_ATTRS[outTagName] || []
1393
+ const globalAllowed = ALLOWED_ATTRS['*'] || []
1394
+ const allAllowed = new Set([...allowedForTag, ...globalAllowed])
1395
+
1396
+ Array.from(el.attributes).forEach((attr) => {
1397
+ if (allAllowed.has(attr.name)) {
1398
+ if (attr.name === 'style')
1399
+ return
1400
+ attrs.push(`${attr.name}="${attr.value}"`)
1401
+ }
1402
+ })
1403
+
1404
+ if (['img', 'br', 'hr'].includes(outTagName)) {
1405
+ return `<${outTagName} ${attrs.join(' ')} />`
1406
+ }
1407
+
1408
+ // 如果是链接,在内容后面追加上标
1409
+ return `<${outTagName} ${attrs.join(' ')}>${childrenHtml}</${outTagName}>${linkSupHtml}`
1410
+ }
1411
+
1412
+ /**
1413
+ * 生成微信公众号格式的 HTML
1414
+ */
1415
+ export const getWeChatStyledHTML = (rootEl: HTMLElement, theme: WechatExportTheme = {}): string => {
1416
+ if (!rootEl)
1417
+ return ''
1418
+
1419
+ // 清空缓存 (如果启用了缓存)
1420
+ // htmlCache = {}
1421
+ // styleValueCache = {}
1422
+
1423
+ // 外层容器样式
1424
+ const containerStyle = theme.containerStyle || `
1425
+ padding: 20px 16px;
1426
+ background-size: 20px 20px;
1427
+ background-position: center center;
1428
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
1429
+ font-size: 16px;
1430
+ color: #333;
1431
+ word-wrap: break-word;
1432
+ letter-spacing: 2px;
1433
+ `.replace(/\s+/g, ' ').trim()
1434
+
1435
+ const references: LinkReference[] = []
1436
+ let contentHtml = ''
1437
+ Array.from(rootEl.childNodes).forEach((child) => {
1438
+ contentHtml += getOneDomCssStyle(child, references, theme)
1439
+ })
1440
+
1441
+ // 生成相关链接部分
1442
+ let referencesHtml = ''
1443
+ if (references.length > 0) {
1444
+ const refList = references.map((ref, index) => {
1445
+ return `<li style="margin-bottom: 5px;">[${index + 1}] ${ref.text}: ${ref.url}</li>`
1446
+ }).join('')
1447
+
1448
+ referencesHtml = `
1449
+ <section style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee;">
1450
+ <h3 style="font-size: 16px; font-weight: bold; margin-bottom: 10px;">相关链接</h3>
1451
+ <ul style="font-size: 14px; color: #666; padding-left: 20px; margin: 0;">
1452
+ ${refList}
1453
+ </ul>
1454
+ </section>
1455
+ `
1456
+ }
1457
+
1458
+ const footerText = theme.footerText ?? 'Powered by ZotePad'
1459
+ const footerStyle = theme.footerStyle || 'margin-top: 32px; text-align: center; font-size: 12px; color: #999;'
1460
+ const footer = footerText
1461
+ ? `
1462
+ <section style="${footerStyle}">
1463
+ <p style="margin: 0;">${escapeHtml(footerText)}</p>
1464
+ </section>
1465
+ `
1466
+ : ''
1467
+
1468
+ return `<section style="${containerStyle}">${contentHtml}${referencesHtml}${footer}</section>`
1469
+ }
1470
+
1471
+ /**
1472
+ * 生成压缩版微信公众号格式的 HTML (适用于手机端公众号助手)
1473
+ */
1474
+ export const getWeChatMinimalHTML = (rootEl: HTMLElement, theme: WechatExportTheme = {}): string => {
1475
+ if (!rootEl)
1476
+ return ''
1477
+
1478
+ // 获取完整版 HTML
1479
+ let result = getWeChatStyledHTML(rootEl, theme)
1480
+
1481
+ // 压缩 HTML:移除标签之间纯装饰性的空白文本节点,
1482
+ // 但保留 data-code-block / pre / code 内部的空格和换行。
1483
+ result = minifyHtmlPreservingCodeBlocks(result)
1484
+
1485
+ return normalizeHrStyles(normalizeLinkStyles(normalizeInlineEmphasisStyles(result)))
1486
+ }
1487
+
1488
+ /**
1489
+ * 复制 HTML 到剪贴板 (使用 ClipboardItem 以支持富文本)
1490
+ */
1491
+ export const copyToClipboard = async (html: string) => {
1492
+ try {
1493
+ const blobHtml = new Blob([html], { type: 'text/html' })
1494
+ const blobText = new Blob([html], { type: 'text/plain' })
1495
+ const item = new ClipboardItem({
1496
+ 'text/html': blobHtml,
1497
+ 'text/plain': blobText,
1498
+ })
1499
+ await navigator.clipboard.write([item])
1500
+ return true
1501
+ }
1502
+ catch (e) {
1503
+ console.error('Copy failed:', e)
1504
+ return false
1505
+ }
1506
+ }