這兩天繼續打磨 CodeWF.Markdown 和 Vex 的 Markdown 發佈鏈路,集中解決了兩個看起來很小、實際很影響寫作體驗的問題:
- Markdown 匯出 PDF / Word 後,圖片要能跟著檔案走,發給別人離線打開也能看。
- 從 Vex 複製到微信公眾號、知乎、稀土掘金時,貼上出來應該是帶排版樣式的富文本,而不是一段明晃晃的 HTML 原始碼。
這篇文章不做完整產品介紹,專門聊這輪背後的技術實現。
相關倉庫:
- CodeWF.Markdown:https://github.com/dotnet9/CodeWF.Markdown
- Vex:https://github.com/dotnet9/Vex
1. 問題一: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://URI- HTTP(S) 圖片
- URL 編碼後的本機檔案名稱
相對路徑會結合目前 Markdown 檔案路徑,也就是 Vex 傳進來的 document.FilePath 或 MarkdownViewer.ImageBasePath 解析。這樣下面這種常見結構就能正常工作:
文章.md
images/
cover.png
flow.svg
Markdown 中寫:


匯出服務拿到的不是 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 又提供了 MarkdownImageRasterizer:
var 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
Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
Target="media/image1.png" />
正文裡再透過 DrawingML 引用這個 rId1。
這樣匯出的 .docx 發給別人以後,不需要原 Markdown 目錄、不需要本機圖片檔案、不需要網路圖片還能存取。圖片已經在 Word 檔案內部。
1.4 PDF 匯出:文字可選取,圖片仍跟著檔案走
CodeWF.Markdown 12.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 id="vex" style="font-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.html:macOS 常用 HTML 剪貼簿格式。HTML Format:Windows 原生 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]);
同時 Windows HTML Format 在 Avalonia 裡按 DataFormat<byte[]> 寫入:
public static readonly DataFormat<byte[]> WindowsHtmlFormat =
DataFormat.CreateBytesPlatformFormat("HTML Format");
這一點也很重要。它不是 UTF-16 字串格式,而是原生剪貼簿位元組酬載。
2.3 Fragment 標記:告訴編輯器貼哪一段
網頁編輯器不一定需要整份 HTML 文件,它更關心要貼上的片段。
所以 HTML 裡要有:
<!--StartFragment-->
<section id="vex">
...
</section>
<!--EndFragment-->
MarkdownHtmlClipboard 會檢查傳入 HTML 是否已經有合法片段標記:
- 有就沿用。
- 沒有但有
<body>,就插入到 body 內。 - 連完整文件都不是,就包一層最小 HTML 文件。
這樣宿主應用可以只關心產生內容,不必每個專案都重新實作一遍 CF_HTML 規範。
2.4 樣式為什麼要 inline
剪貼簿格式正確以後,還有一個現實問題:公眾號、知乎、掘金不會幫你載入外部 CSS。
複製進去的內容如果依賴:
<link rel="stylesheet" href="theme.css">
或者依賴一堆 class:
<p class="markdown-body paragraph">正文</p>
貼上後大概率樣式就沒了。
所以 CodeWF.Markdown 的自媒體複製渲染器會把目前排版主題轉換成 inline style:
<section
id="vex"
data-tool="{localized tool name}"
data-website="https://codewf.com"
style="font-size: 15px; color: #333333; line-height: 1.75;">
<h2 style="font-size: 26px; color: #333333; border-bottom: 2px solid #dfe2e5;">
標題
</h2>
<p style="font-size: 15px; line-height: 26.25px; color: #333333;">
正文
</p>
</section>
這部分現在也下沉到了 CodeWF.Markdown:微信公眾號、知乎、稀土掘金由內建 CopyKind 和 MarkdownSocialCopyProfile 描述,Vex 只負責選擇目標平台並傳入目前 Markdown、排版主題和緊湊佈局。後續如果要支援新的發佈平台,可以繼續擴展 profile,而不需要每個應用重寫 CF_HTML 和基礎渲染。
2.5 三個平台不是三套固定顏色
之前最容易偷懶的做法,是給公眾號、知乎、掘金各放一套固定模板色。
這次順手把這點也修掉了。自媒體複製現在會讀取目前 MarkdownExportStyle。簡單呼叫路徑會用 MarkdownExportStyle.Resolve(themeName, typographySize) 解析內建主題名和緊湊佈局:
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);
但對應用開發者來說,還可以再少寫一點。
所以這輪繼續補了 ExportKind:
public 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 反而會擋住擴展。
新的擴展入口是 MarkdownTypographyThemeRegistry:
MarkdownTypographyThemeRegistry.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 位元組偏移。
- Windows
HTML 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.Markdown
- Vex 應用倉庫:https://github.com/dotnet9/Vex