CodeWF.Markdown:PDF 文本可复制、图片可嵌入,复制到公众号/知乎/掘金不再显示 HTML 源码
这两天继续打磨CodeWF.Markdown和Vex的 Markdown 发布链路集中解决了两个看起来很小、实际很影响写作体验的问题Markdown 导出 PDF / Word 后图片要能跟着文件走发给别人离线打开也能看。从 Vex 复制到微信公众号、知乎、稀土掘金时粘贴出来应该是带排版样式的富文本而不是一段明晃晃的 HTML 源码。这篇文章不做完整产品介绍专门聊这轮背后的技术实现。相关仓库CodeWF.Markdownhttps://github.com/dotnet9/CodeWF.MarkdownVexhttps://github.com/dotnet9/Vex1. 问题一Markdown 图片不是文件里的图片Markdown 里的图片写法很轻  但导出 PDF、Word 时这些字符串本身还不是“文件里的图片”。它们只是图片来源。如果导出逻辑只把 Markdown 转成 HTML再把图片地址原样放进去就会遇到几个问题相对路径图片离开原 Markdown 目录后找不到。data:image可以预览但 Word 里需要转成真正的 media part。SVG、GIF、WebP 在不同导出目标里的支持情况不一致。远程图片在别人离线打开导出文件时可能加载失败。PDF/Word/PNG 各自实现一遍图片读取很容易行为不一致。所以这轮把图片处理能力下沉到了CodeWF.Markdown让预览控件和宿主应用导出链路可以共用。1.1 图片加载先把来源统一成字节CodeWF.Markdown新增了一个公共加载入口MarkdownImageSourceLoader。它解决的是“这个 Markdown 图片到底从哪里来”的问题data:image/...;base64,...本地绝对路径相对路径file://URIHTTP(S) 图片URL 编码后的本地文件名相对路径会结合当前 Markdown 文件路径也就是 Vex 传进来的document.FilePath或MarkdownViewer.ImageBasePath解析。这样下面这种常见结构就能正常工作文章.md images/ cover.png flow.svgMarkdown 中写 导出服务拿到的不是images/cover.png这个字符串而是一个结构化结果var imageSource MarkdownImageSourceLoader.Load(image.Url, documentPath);这个结果里包含图片字节原始来源是否 SVG是否 GIF本地路径信息后续 PDF、PNG、Word 都不需要重新猜一遍图片路径。1.2 图片栅格化导出目标更喜欢 PNG加载到字节以后还有一个问题不同格式不能原样塞给所有导出目标。比如 SVG 很适合网页和 Avalonia 预览但写入 Word 或渲染成 PNG/PDF 页面时最好先栅格化。GIF 是动态图Word 里可以放但当前导出更需要稳定的静态首帧。WebP 也不是每个消费端都稳定。所以CodeWF.Markdown又提供了MarkdownImageRasterizervar pngBytes MarkdownImageRasterizer.RenderToPngBytes(imageSource);当前策略比较朴素但实用SVG 通过Svg.Skia渲染为 PNG。GIF 取静态帧转 PNG。其他 Avalonia/Skia 能解码的位图统一转 PNG。对 Vex 和其他宿主应用来说收益很直接PDF、PNG、Word 导出不再各自写一套“如果是 SVG 怎么办、如果是 GIF 怎么办”的分支而是复用公共能力。1.3 Word 导出写进 docx 的 media 目录Word.docx本质上是一个 OpenXML 压缩包。图片不能只写一个路径字符串需要放进包里的word/media/再在文档关系里建立引用。CodeWF.Markdown的 Word 导出现在大致是这条链路var imageSource MarkdownImageSourceLoader.Load(image.Url, documentPath); var bytes MarkdownImageRasterizer.RenderToPngBytes(imageSource); var relationshipId $rId{ImageParts.Count 1}; var target $media/image{ImageParts.Count 1}.png; ImageParts.Add(new DocxImagePart(relationshipId, target, bytes));然后在document.xml.rels里写关系Relationship IdrId1 Typehttp://schemas.openxmlformats.org/officeDocument/2006/relationships/image Targetmedia/image1.png /正文里再通过 DrawingML 引用这个rId1。这样导出的.docx发给别人以后不需要原 Markdown 目录、不需要本地图片文件、不需要网络图片还能访问。图片已经在 Word 文件内部。1.4 PDF 导出文本可选择图片仍跟着文件走CodeWF.Markdown12.0.3.13 已经把 PDF 导出从整页位图切片推进到可选择文本输出。正文段落、标题、列表等内容会按页面布局写入 PDF 文本并带上 Unicode 文本映射别人打开 PDF 时可以像普通 PDF 一样选择、复制正文。图片链路仍然复用前面的公共加载和栅格化能力。导出前先把本地、相对、data:image、HTTP(S)、SVG/GIF/WebP 这些来源解析成稳定字节需要时转成 PNG再把图片作为 PDF 图片内容嵌入var imageSource MarkdownImageSourceLoader.Load(image.Url, documentPath); var pngBytes MarkdownImageRasterizer.RenderToPngBytes(imageSource);这样导出的 PDF 不再只是整页截图。正文能复制图片也不会因为离开原 Markdown 目录或网络不可用而丢失。2. 问题二复制到公众号为什么会显示 HTML 源码另一个问题出在剪贴板。从 Vex 点击“复制到公众号”时我们期望粘贴到微信公众号后台后是这样标题是标题段落有间距链接有颜色引用有边线代码块有背景表格有边框但如果剪贴板只写普通文本哪怕文本内容是section idvex stylefont-size:16px h2标题/h2 p正文/p /section浏览器编辑器也可能把它当普通文本粘进去结果用户看到的是 HTML 源码。这不是 HTML 生成的问题而是剪贴板格式的问题。2.1 富 HTML 剪贴板不能只写字符串这轮CodeWF.Markdown新增了MarkdownHtmlClipboard、MarkdownHtmlClipboardExtensions和自媒体复制 profile专门给宿主应用写富 HTML 剪贴板。Vex 现在复制到公众号、知乎、稀土掘金时调用的是await clipboard.TrySetMarkdownHtmlAsync( markdown, typographyTheme, wechat, typographySize);内部会同时写入几种格式text/plain纯文本兜底。text/html通用 HTML MIME。public.htmlmacOS 常用 HTML 剪贴板格式。HTML FormatWindows 原生 CF_HTML 格式。真正关键的是 Windows 的HTML Format。微信公众号、知乎、稀土掘金这些编辑器大多跑在 Chromium 系浏览器里Windows 下它们更认 CF_HTML。2.2 CF_HTML偏移必须按 UTF-8 字节算CF_HTML 的内容不是简单的 HTML 字符串而是一个带头部的载荷Version:1.0 StartHTML:0000000105 EndHTML:0000000860 StartFragment:0000000200 EndFragment:0000000740 !doctype html html body !--StartFragment-- ... !--EndFragment-- /body /html这里最容易错的是偏移。StartHTML、EndHTML、StartFragment、EndFragment不是字符位置而是从整个载荷开头算起的字节偏移。中文内容、emoji、全角符号都会让“字符数”和“字节数”不一致。所以MarkdownHtmlClipboard用 UTF-8 计算var startHtml Encoding.UTF8.GetByteCount(blankHeader); var endHtml startHtml Encoding.UTF8.GetByteCount(clipboardHtml); var startFragment startHtml Encoding.UTF8.GetByteCount( clipboardHtml[..(startMarkerIndex StartFragmentMarker.Length)]); var endFragment startHtml Encoding.UTF8.GetByteCount( clipboardHtml[..endMarkerIndex]);同时 WindowsHTML Format在 Avalonia 里按DataFormatbyte[]写入public static readonly DataFormatbyte[] WindowsHtmlFormat DataFormat.CreateBytesPlatformFormat(HTML Format);这一点也很重要。它不是 UTF-16 字符串格式而是原生剪贴板字节载荷。2.3 Fragment 标记告诉编辑器粘哪一段网页编辑器不一定需要整份 HTML 文档它更关心要粘贴的片段。所以 HTML 里要有!--StartFragment-- section idvex ... /section !--EndFragment--MarkdownHtmlClipboard会检查传入 HTML 是否已经有合法片段标记有就沿用。没有但有body就插入到 body 内。连完整文档都不是就包一层最小 HTML 文档。这样宿主应用可以只关心生成内容不必每个项目都重新实现一遍 CF_HTML 规范。2.4 样式为什么要 inline剪贴板格式正确以后还有一个现实问题公众号、知乎、掘金不会帮你加载外部 CSS。复制进去的内容如果依赖link relstylesheet hreftheme.css或者依赖一堆 classp classmarkdown-body paragraph正文/p粘贴后大概率样式就没了。所以CodeWF.Markdown的自媒体复制渲染器会把当前排版主题转换成 inline stylesection idvex >var exportStyle MarkdownExportStyle.Resolve( currentTypographyTheme, currentTypographySize);如果应用注册了自己的 XAML 排版资源也可以自行创建MarkdownExportStyle例如继续用MarkdownThemes.CreateExportStyle(MyCompanyBlue)再传给导出或复制 API。这样当前预览主题、导出主题、自媒体复制主题会尽量来自同一套资源根容器文字色、背景、字体、行高。标题字号和标题色。段落字号、行高、正文色。链接色和下划线。引用边框和背景。代码块背景。表格边框和表头背景。分割线颜色。掘金尾注的正文色和链接色。也就是说用户在 Vex 里切换排版主题后不只是预览区变了HTML/打印导出、PNG/PDF/Word 导出以及“复制到公众号 / 知乎 / 稀土掘金”也应该尽量保持同一套视觉映射。3. API 与扩展导出 API 再收一层最开始迁移导出能力时CodeWF.Markdown已经提供了MarkdownDocumentExporter.ExportPng(document, path, style); MarkdownDocumentExporter.ExportPdf(document, path, style); MarkdownDocumentExporter.ExportWord(document, path, style);但对应用开发者来说还可以再少写一点。所以这轮继续补了ExportKindpublic enum ExportKind { Png, Pdf, Word }宿主应用现在可以按导出类型统一调用MarkdownDocumentExporter.ExportMarkdown( markdown, ExportKind.Pdf, MarkdownTypographyThemes.Simple, article.pdf); MarkdownDocumentExporter.ExportFile( C:\docs\article.md, ExportKind.Word, MarkdownTypographyThemes.Simple, article.docx); var document new MarkdownExportDocument(markdown, filePath, fileName); MarkdownDocumentExporter.Export(document, ExportKind.Png, article.png);这里没有把 Markdown 字符串和 Markdown 文件路径都做成同名Export(string, ...)因为 C# 无法只靠参数名区分这两种string。所以 API 明确拆成ExportMarkdown和ExportFile完整上下文仍然用MarkdownExportDocument。自媒体复制也收成了类似的一层。平台目标先用 enum 表达内置能力public enum CopyKind { Wechat, Zhihu, Juejin }宿主应用最常用的是 Avalonia 剪贴板扩展方法await clipboard.TrySetMarkdownHtmlAsync( markdown, MarkdownTypographyThemes.Simple, wechat, MarkdownTypographySizes.Small); await clipboard.SetMarkdownHtmlAsync( markdown, exportStyle, CopyKind.Juejin);这样 Vex 端不用再维护一堆平台 HTML 生成代码。它能拿到 Markdown 字符串、当前排版主题和菜单目标就可以完成复制。如果传入的是 Markdown 字符串自媒体复制里的相对图片会按当前工作目录解析如果用文件路径创建复制内容图片则可以按 Markdown 文件所在目录解析。这样 API 保持简单同时仍然覆盖常见的本地图片场景。3.1 应用如何扩展个性化排版主题这次也顺手整理了排版主题扩展方式。MarkdownTypographyThemes继续保持字符串常量而不是改成 enum。原因很简单内置主题适合常量应用主题适合字符串 Key。否则第三方应用想加MyCompanyBlue、ProductLaunch这种主题时enum 反而会挡住扩展。新的扩展入口是MarkdownTypographyThemeRegistryMarkdownTypographyThemeRegistry.Register( MyCompanyBlue, () new ResourceDictionary { [MarkdownStyleKeys.TextBrushResource] new SolidColorBrush(Color.Parse(#1F2937)), [MarkdownStyleKeys.MutedTextBrushResource] new SolidColorBrush(Color.Parse(#64748B)), [MarkdownStyleKeys.AccentBrushResource] new SolidColorBrush(Color.Parse(#0E88EB)), [MarkdownStyleKeys.BorderBrushResource] new SolidColorBrush(Color.Parse(#BFDBFE)), [MarkdownStyleKeys.ParagraphFontSizeResource] 16d, [MarkdownStyleKeys.ParagraphLineHeightResource] 28d, [MarkdownStyleKeys.Heading1FontSizeResource] 32d, [MarkdownStyleKeys.CodeBlockFontSizeResource] 13d });注册后预览区可以直接使用这个主题MarkdownThemes.OverrideTypographyResources( Application.Current!, MyCompanyBlue, MarkdownTypographySizes.Normal);导出和复制也能复用同一套资源var style MarkdownThemes.CreateExportStyle(MyCompanyBlue); MarkdownDocumentExporter.ExportMarkdown( markdown, ExportKind.Pdf, style, article.pdf); await clipboard.SetMarkdownHtmlAsync(markdown, style, CopyKind.Wechat);如果应用已有 XAML 资源字典也可以注册工厂MarkdownTypographyThemeRegistry.Register( MyCompanyBlue, () new MyCompanyMarkdownResources());这样应用侧只维护一套排版资源预览、PNG/PDF/Word 导出、自媒体复制 inline style 都能尽量从同一套资源里取值。对个性化主题比较多的产品来说这比在应用端再维护一份MarkdownExportStyle映射更稳。4. 架构边界为什么放在 CodeWF.Markdown而不是只写在 Vex 里这轮有一个原则公共问题进公共库业务差异留在应用层。图片加载和栅格化不是 Vex 独有的。任何 Avalonia Markdown 宿主应用只要要导出 PDF、Word、PNG都会遇到同样问题。所以放在CodeWF.Markdown更合适。CF_HTML 也不是 Vex 独有的。任何项目只要想把 HTML 粘贴到 Chromium 系网页编辑器都可能踩到同一个坑。所以MarkdownHtmlClipboard也应该是公共能力。公众号、知乎、掘金的 HTML 结构、尾注和兼容习惯也属于可复用的发布 profile这次已经下沉到CodeWF.Markdown。Vex 仍然保留的是应用体验层读取当前文档、当前主题、当前菜单目标然后调用公共 API。现在的边界大概是CodeWF.Markdown - ExportKind - MarkdownImageSourceLoader - MarkdownImageRasterizer - MarkdownHtmlClipboard - MarkdownHtmlClipboardExtensions - CopyKind - MarkdownSocialCopyRenderer - MarkdownSocialCopyProfiles - MarkdownDocumentExporter - MarkdownExportDocument - MarkdownExportStyle - MarkdownTypographyThemeRegistry Vex - 读取当前文档和当前排版主题 - 选择发布目标 - 调用公共剪贴板能力 - 调用公共 PNG / PDF / Word 导出能力这个边界会比“Vex 里全写死一遍”更稳。5. 测试补了哪些这轮 CodeWF.Markdown 补了几类测试data:image识别。URL 编码相对路径按ImageBasePath回退。SVG 栅格化为 PNG。GIF 转静态 PNG。HTML fragment 标记规范化。CF_HTML 的 UTF-8 字节偏移。WindowsHTML Format使用字节格式。ExportKind统一导出入口。CopyKind/profile 自媒体复制渲染。本地图片在自媒体复制 HTML 中嵌入。自定义排版主题注册后可生成MarkdownExportStyle。目前CodeWF.Markdown.Tests里 42 个测试通过。Vex 侧也确认了本地包引用方式先在CodeWF.Markdown本地打包12.0.3.13再让 Vex 通过本地 NuGet 包源引用而不是跨仓库ProjectReference。这样更接近真实发布包的使用方式也能提前发现 NuGet content files、版本号、依赖还原这类问题。6. 实际效果对用户来说这轮改动最后应该只体现成两件事第一导出更安心。本地相对图片、data:image、HTTP(S) 图片、SVG、GIF、WebP 这些常见来源导出 PDF 和 Word 时会尽量被处理进结果里。PDF 正文可以选择复制图片会进入 PDF 文件Word.docx里的图片会进入word/media/离开原始 Markdown 目录后仍然能看。第二复制更像发布工具。点击“复制到公众号 / 知乎 / 稀土掘金”剪贴板里不再只是普通文本而是网页编辑器能识别的富 HTML。粘贴后应该直接显示排版结果而不是section.../section这种源码文本。7. 小结Markdown 编辑器的很多体验问题都藏在“最后一公里”。预览时能看到图片不代表导出后图片还在能生成 HTML不代表粘贴到公众号就是富文本主题能在应用里切换不代表复制出去还能保留样式。这轮把图片加载、图片栅格化、文档导出、富 HTML 剪贴板和排版主题扩展这几块公共能力补到CodeWF.Markdown再让 Vex 的 PDF、Word、自媒体复制链路复用它们。结果不是新增一个特别显眼的大按钮而是让写完文章以后“导出去、粘出去、发出去”这几步少掉一些奇怪的断点。后面还会继续打磨两块PDF 继续补齐复杂块级元素、分页断点和排版主题细节。自媒体复制继续按公众号、知乎、掘金的真实编辑器行为补兼容细节。但这次最核心的坑已经填上了图片不该只活在本机路径里HTML 也不该只作为明文躺在剪贴板里。Markdown控件仓库https://github.com/dotnet9/CodeWF.MarkdownVex应用仓库https://github.com/dotnet9/Vex