CodeWF.Markdown:PDF 文字可複製、圖片可嵌入,複製到公眾號/知乎/掘金不再顯示 HTML 原始碼

CodeWF.Markdown:PDF 文字可複製、圖片可嵌入,複製到公眾號/知乎/掘金不再顯示 HTML 原始碼

分享 CodeWF.Markdown 與 Vex 中關於 Markdown 匯出與發佈複製的技術實作:MarkdownDocumentExporter、ExportKind、共享圖片載入、SVG/GIF/WebP 柵格化、Word 嵌入 media 資源、可選取文字 PDF、Windows CF_HTML 富 HTML 剪貼簿,以及可擴充排版主題。

最後更新 2026/5/25 下午4:31
dotnet9
預計閱讀 16 分鐘
分類
.NET Avalonia 桌面開發
專題
Avalonia
標籤
C# Avalonia Markdown CodeWF Vex PDF Word 微信公眾號

這兩天繼續打磨 CodeWF.MarkdownVex 的 Markdown 發佈鏈路,集中解決了兩個看起來很小、實際很影響寫作體驗的問題:

  1. Markdown 匯出 PDF / Word 後,圖片要能跟著檔案走,發給別人離線打開也能看。
  2. 從 Vex 複製到微信公眾號、知乎、稀土掘金時,貼上出來應該是帶排版樣式的富文本,而不是一段明晃晃的 HTML 原始碼。

這篇文章不做完整產品介紹,專門聊這輪背後的技術實現。

相關倉庫:

1. 問題一:Markdown 圖片不是檔案裡的圖片

Markdown 裡的圖片寫法很輕:

![封面](./images/cover.svg)
![截圖](data:image/png;base64,...)
![遠端圖](https://img1.dotnet9.com/2026/05/demo.png)

但匯出 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.FilePathMarkdownViewer.ImageBasePath 解析。這樣下面這種常見結構就能正常工作:

文章.md
images/
  cover.png
  flow.svg

Markdown 中寫:

![封面](images/cover.png)
![流程圖](images/flow.svg)

匯出服務拿到的不是 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 新增了 MarkdownHtmlClipboardMarkdownHtmlClipboardExtensions 和自媒體複製 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>

這裡最容易錯的是偏移。

StartHTMLEndHTMLStartFragmentEndFragment 不是字元位置,而是從整個酬載開頭算起的 位元組偏移。中文內容、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:微信公眾號、知乎、稀土掘金由內建 CopyKindMarkdownSocialCopyProfile 描述,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 明確拆成 ExportMarkdownExportFile,完整上下文仍然用 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。否則第三方應用想加 MyCompanyBlueProductLaunch 這種主題時,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
繼續探索

延伸閱讀

更多文章