返回博客2026年1月19日

公众号排版工具如何实现样式的复制粘贴?

微信公众号MarkdownCSS前端

本文基于开源项目 bm.md 的实现原理撰写。bm.md 是一个支持微信公众号、知乎、掘金等多平台的 Markdown 排版工具,技术栈包括 React 19、TanStack Router、Vite 7、Zustand 等。项目架构详见 architecture.md


作为一个技术写作者,你可能用过 Markdown Nice、135编辑器这类排版工具。写好 Markdown,选个主题,点击复制,粘贴到微信公众号编辑器——样式完美保留。

这背后是怎么实现的?为什么有些样式能复制过去,有些却丢失了?

本文将揭开公众号排版工具的技术原理。


先理解问题:为什么普通复制不行?

试试看:在浏览器里打开任意网页,选中一段带样式的文字,复制粘贴到微信公众号编辑器。

你会发现:大部分样式都丢失了

这是因为微信公众号编辑器会对粘贴内容进行"清洗":

flowchart TB
    A["你复制的内容"] --> B["微信安全过滤器"]
    B --> C["移除 script、style 标签"]
    C --> D["移除 position、transform 等属性"]
    D --> E["移除 id 属性和事件"]
    E --> F["只保留'安全'的内联样式"]

这个过滤机制是为了安全(防止 XSS 攻击)和一致性(保证文章在不同设备上显示一致)。

所以,排版工具的核心挑战是:如何在这些限制下保留样式?


核心技术:CSS 内联化

答案是 CSS 内联化 (CSS Inlining)

什么是 CSS 内联化?

普通网页的样式是这样写的:

<style>
  h1 { color: blue; font-size: 24px; }
  p { line-height: 1.6; }
</style>

<h1>标题</h1>
<p>正文内容</p>

<style> 标签会被微信过滤掉,样式就丢失了。

内联化后变成这样:

<h1 style="color: blue; font-size: 24px;">标题</h1>
<p style="line-height: 1.6;">正文内容</p>

每个元素都带着自己的样式,不依赖 <style> 标签,微信就无法剥离。

如何实现内联化?

手动转换不现实。排版工具使用专门的库来完成这项工作,比如 juice

import juice from 'juice'

const css = `
  h1 { color: blue; font-size: 24px; }
  p { line-height: 1.6; }
`

const html = `
  <h1>标题</h1>
  <p>正文内容</p>
`

// 魔法发生在这里
const result = juice.inlineContent(html, css)

// 输出:
// <h1 style="color: blue; font-size: 24px;">标题</h1>
// <p style="line-height: 1.6;">正文内容</p>

juice 会解析 CSS 选择器,找到匹配的 HTML 元素,把样式规则写入 style 属性。


完整流程:从 Markdown 到剪贴板

以 bm.md 的架构为例,一个完整的公众号排版工具是这样工作的:

flowchart TB
    subgraph Input["输入层"]
        MD["Markdown 文本"]
    end

    subgraph Processing["处理层 (Web Worker)"]
        Parse["解析为 AST<br/>remark-parse"]
        GFM["GFM 扩展<br/>表格、任务列表"]
        Math["数学公式<br/>remark-math"]
        ToHTML["转换为 HTML<br/>remark-rehype"]
        Highlight["代码高亮<br/>rehype-highlight"]
    end

    subgraph Adaptation["平台适配层"]
        WeChat["微信适配"]
        Zhihu["知乎适配"]
        Juejin["掘金适配"]
    end

    subgraph Output["输出层"]
        Theme["加载主题 CSS"]
        Inline["CSS 内联化<br/>juice"]
        Clipboard["写入剪贴板<br/>text/html"]
    end

    MD --> Parse
    Parse --> GFM
    GFM --> Math
    Math --> ToHTML
    ToHTML --> Highlight
    Highlight --> WeChat
    Highlight --> Zhihu
    Highlight --> Juejin
    WeChat --> Theme
    Zhihu --> Theme
    Juejin --> Theme
    Theme --> Inline
    Inline --> Clipboard

关键代码示例

async function copyForWechat(markdown: string, themeCss: string) {
  // 1. Markdown → HTML (在 Web Worker 中执行)
  const processor = unified()
    .use(remarkParse)
    .use(remarkGfm)          // 支持表格、任务列表等
    .use(remarkMath)         // 数学公式支持
    .use(remarkRehype)
    .use(rehypeHighlight)    // 代码高亮
    .use(rehypeWechatAdapter) // 微信适配
    .use(rehypeStringify)

  const html = (await processor.process(markdown)).toString()

  // 2. CSS 内联化
  const wrapped = `<section id="bm-md">${html}</section>`
  const inlined = juice.inlineContent(wrapped, themeCss)

  // 3. 复制到剪贴板
  const blob = new Blob([inlined], { type: 'text/html' })
  await navigator.clipboard.write([
    new ClipboardItem({ 'text/html': blob })
  ])
}

架构亮点:Web Worker + oRPC

bm.md 的一个设计亮点是将 Markdown 渲染放在 Web Worker 中执行,使用 oRPC 实现类型安全的通信。

为什么用 Web Worker?

Markdown 处理涉及大量字符串操作和 AST 转换,长文档可能导致主线程卡顿。Web Worker 将计算密集型任务移到后台线程,保持 UI 流畅。

oRPC 通信

// Worker 端暴露的方法
const procedures = {
  render: async (markdown: string, theme: string) => {...},
  parse: async (html: string) => {...},
  extract: async (markdown: string) => {...},
  lint: async (markdown: string) => {...}
}

// 主线程调用
const html = await worker.render(markdown, 'elegant')

oRPC 提供了类型推断,调用 Worker 方法就像调用普通函数一样,但实际是跨线程通信。


微信的特殊限制与应对

微信公众号编辑器有一些独特的"怪癖",排版工具需要专门处理。

问题 1:代码块空格被吃掉

微信会把连续空格合并成一个,导致代码缩进全乱了:

def hello():
    print("world")  # 原本 4 个空格

粘贴后变成:

def hello():
 print("world")  # 只剩 1 个空格

解决方案:用 \u00A0 (non-breaking space) 替换普通空格:

// 处理代码块中的空格
text = text.replace(/^( +)/gm, (spaces) =>
  '\u00A0'.repeat(spaces.length)
)

\u00A0 是不可断空格,微信不会合并它们。

问题 2:换行符失效

代码块里的 \n 换行可能不生效。

解决方案:换成 <br> 标签:

// \n → <br>
codeContent = codeContent.split('\n').join('<br>')

问题 3:任务列表的复选框

Markdown 的任务列表渲染成 <input type="checkbox">,但微信不支持表单元素。

- [x] 已完成
- [ ] 未完成

解决方案:用 Unicode 字符替代:

// <input type="checkbox" checked> → ☑
// <input type="checkbox"> → ☐
const symbol = isChecked ? '☑' : '☐'

问题 4:外部链接警告

点击微信文章里的外部链接,会弹出"即将离开微信"的安全警告,体验不好。

解决方案:把链接转成文末脚注:

原文:详见 [官方文档](https://example.com)

转换后:详见 官方文档[1]

---
参考链接
1. 官方文档: https://example.com

这样读者既能看到链接,又不会被警告打断阅读。


哪些样式能用,哪些不能用?

这是最常见的问题。简单总结:

能用

属性说明
color, background-color颜色没问题
font-size, font-weight字体大小、粗细
margin, padding间距,用 px 单位
border, border-radius边框、圆角
text-align文本对齐
line-height行高
box-shadow阴影

不能用

属性原因
position: absolute/fixed安全考虑,全部失效
transform, animation会被过滤
:hover 等伪类没有 <style> 标签就无法定义
@media 媒体查询会被移除
百分比单位 %不稳定,用 px 更可靠

经验法则

如果一个 CSS 效果需要 JavaScript 或 <style> 标签才能实现,那它在微信里大概率不行。


多平台支持

bm.md 不仅支持微信,还支持知乎、掘金等平台。每个平台有不同的限制:

平台主要限制
微信公众号最严格,过滤最多属性
知乎支持更多 CSS,但有代码块限制
掘金相对宽松,支持大部分样式

架构上通过平台适配器(Adapter)模式处理差异,复用核心处理逻辑。


存储与状态管理

本地存储

bm.md 使用 IndexedDB 存储文档内容,支持多文件编辑。如果 IndexedDB 不可用,自动回退到内存存储。

状态管理

使用 Zustand 管理 4 个独立的 Store:

Store职责
Files Store文件列表、当前文件
Editor Store编辑器设置(字体、缩进等)
Preview Store预览偏好(主题、平台)
Command Store命令面板状态

图片存储

支持多种云存储后端:

  • Cloudflare R2
  • AWS S3
  • MinIO
  • 默认图床服务

通过环境变量配置自动选择。


MCP 集成

bm.md 实现了 Model Context Protocol (MCP) 服务器,将 Markdown 处理能力暴露给 AI 模型:

// MCP Tools
- render: MarkdownHTML
- parse: HTMLMarkdown
- extract: MarkdownPlain Text
- lint: Markdown 格式修复

这意味着你可以在 Claude 等 AI 工具中直接调用 bm.md 的渲染能力。


技术选型参考

如果你想自己实现一个排版工具:

核心依赖

pnpm add unified remark-parse remark-gfm remark-rehype
pnpm add rehype-stringify rehype-highlight
pnpm add juice

可选依赖

pnpm add remark-math rehype-katex  # 数学公式
pnpm add rehype-sanitize           # XSS 防护

推荐架构

src/
├── components/
│   ├── Editor.tsx          # CodeMirror 编辑器
│   ├── Preview.tsx         # 预览区
│   └── CopyButton.tsx
├── lib/
│   ├── markdown/
│   │   ├── processor.ts    # unified 处理管道
│   │   ├── render.ts       # Markdown → 内联 HTML
│   │   └── adapters/
│   │       ├── wechat.ts   # 微信适配
│   │       ├── zhihu.ts    # 知乎适配
│   │       └── juejin.ts   # 掘金适配
│   └── storage/
│       └── indexeddb.ts    # 本地存储
├── stores/                  # Zustand stores
├── themes/
│   ├── default.css
│   └── elegant.css
└── workers/
    └── markdown.worker.ts   # Web Worker

总结

公众号排版工具的核心原理:

  1. Markdown → HTML:使用 unified 生态处理
  2. CSS 内联化:用 juice 把样式写入 style 属性
  3. 平台适配:处理微信的特殊限制(空格、换行、链接等)
  4. Web Worker:避免主线程卡顿
  5. 剪贴板操作:使用 text/html MIME 类型写入

记住微信的限制:只有内联样式生效,没有动画,没有定位,没有伪类。在这些约束下做创意设计,才是排版工具的价值所在。


参考资料


往期回顾

准备开始了吗?

先简单说明目标,我会给出最合适的沟通方式。