Over the past two days, I continued polishing the Markdown publishing pipeline of CodeWF.Markdown and Vex, focusing on solving two small problems that significantly impact the writing experience:
- When exporting Markdown to PDF/Word, images should be embedded so that the file works offline when sent to others.
- When copying from Vex to WeChat Official Account, Zhihu, or Juejin, the pasted content should be rich text with styles, not plain HTML source code.
This article does not provide a full product introduction; instead, it focuses on the technical implementation behind these two issues.
Related repositories:
- CodeWF.Markdown: https://github.com/dotnet9/CodeWF.Markdown
- Vex: https://github.com/dotnet9/Vex
1. Issue 1: Markdown Images Are Not Real Images in the File
Markdown image syntax is lightweight:



But when exporting to PDF or Word, these strings are not "images in the file"—they are just image sources.
If the export logic simply converts Markdown to HTML and keeps the image URLs as-is, you'll encounter several problems:
- Relative paths are broken when the Markdown file is moved.
data:imageworks for preview but needs to be converted to a real media part in Word.- SVG, GIF, WebP have inconsistent support across different export targets.
- Remote images might fail to load when the exported file is opened offline.
- Implementing image reading separately for PDF/Word/PNG leads to inconsistent behavior.
Therefore, this round of work moved image processing capabilities into CodeWF.Markdown, allowing the preview control and host application export pipeline to share the same logic.
1.1 Image Loading: Unify Sources into Bytes
CodeWF.Markdown added a public loading entry point: MarkdownImageSourceLoader.
It solves the problem of "where does this Markdown image come from":
data:image/...;base64,...- Local absolute path
- Relative path
file://URI- HTTP(S) image
- URL-encoded local file name
Relative paths are resolved by combining the current Markdown file path (passed by Vex as document.FilePath or MarkdownViewer.ImageBasePath). This ensures common structures like the following work correctly:
article.md
images/
cover.png
flow.svg
Markdown:


The export service no longer receives the string images/cover.png but a structured result:
var imageSource = MarkdownImageSourceLoader.Load(image.Url, documentPath);
This result includes:
- Image bytes
- Original source
- Whether it is SVG
- Whether it is GIF
- Local path information
Subsequent PDF, PNG, and Word exporters no longer need to guess the image path again.
1.2 Image Rasterization: Export Targets Prefer PNG
After loading the bytes, there's another issue: different formats cannot be fed as-is to all export targets.
For example, SVG is great for web pages and Avalonia preview, but when writing to Word or rendering to PNG/PDF, it's better to rasterize first. GIF is animated; it can be placed in Word, but the current export needs a stable static first frame. WebP is also not stable across all consumers.
So CodeWF.Markdown provides MarkdownImageRasterizer:
var pngBytes = MarkdownImageRasterizer.RenderToPngBytes(imageSource);
The current strategy is simple but practical:
- SVG is rendered to PNG via
Svg.Skia. - GIF extracts the first frame and converts to PNG.
- Other bitmaps that Avalonia/Skia can decode are uniformly converted to PNG.
For Vex and other host applications, the benefit is direct: PDF, PNG, and Word exports no longer need their own branching logic for "if SVG, do this; if GIF, do that." They reuse common capabilities.
1.3 Word Export: Write to docx media Directory
Word .docx is essentially an OpenXML package. Images cannot just be a path string; they must be placed inside the package under word/media/, with references established in the document relationships.
The Word export in CodeWF.Markdown now follows this pipeline:
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));
Then write the relationship in document.xml.rels:
<Relationship
Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
Target="media/image1.png" />
In the body, reference this rId1 via DrawingML.
Thus, the exported .docx can be sent to others without requiring the original Markdown directory, local image files, or internet access for remote images. Images are already inside the Word file.
1.4 PDF Export: Selectable Text, Embedded Images
CodeWF.Markdown 12.0.3.13 already advanced PDF export from full-page bitmap slices to selectable text output. Paragraphs, headings, lists, etc., are written as PDF text with Unicode text mapping; users can select and copy text as in any normal PDF.
The image pipeline still reuses the common loading and rasterization capabilities. Before export, local, relative, data:image, HTTP(S), SVG/GIF/WebP sources are resolved into stable bytes, converted to PNG if needed, and then embedded as PDF image content:
var imageSource = MarkdownImageSourceLoader.Load(image.Url, documentPath);
var pngBytes = MarkdownImageRasterizer.RenderToPngBytes(imageSource);
This means exported PDFs are no longer just full-page screenshots. Text can be copied, and images will not be lost when the PDF leaves the original Markdown directory or becomes offline.
2. Issue 2: Why Does Copying to WeChat Show HTML Source Code
Another problem lies in the clipboard.
When clicking "Copy to WeChat Official Account" from Vex, we expect the result pasted into the WeChat backend to have:
- Titles as titles
- Paragraphs with spacing
- Colored links
- Blockquotes with borders
- Code blocks with background
- Tables with borders

But if the clipboard only contains plain text, even if that text is:
<section id="vex" style="font-size:16px">
<h2>Title</h2>
<p>Body</p>
</section>
The browser editor might treat it as plain text, causing the user to see HTML source code.
This is not a problem of HTML generation but of clipboard format.
2.1 Rich HTML Clipboard: Not Just a String
This round, CodeWF.Markdown added MarkdownHtmlClipboard, MarkdownHtmlClipboardExtensions, and social media copy profiles specifically for host applications to write rich HTML to the clipboard.
Now, when Vex copies to WeChat, Zhihu, or Juejin, it calls:
await clipboard.TrySetMarkdownHtmlAsync(
markdown,
typographyTheme,
"wechat",
typographySize);
Internally, it writes several formats:
text/plain: plain text fallback.text/html: generic HTML MIME.public.html: common HTML clipboard format on macOS.HTML Format: Windows native CF_HTML format.
The most critical is Windows' HTML Format. Editors like WeChat Official Account, Zhihu, and Juejin mostly run in Chromium-based browsers, and on Windows, they prefer CF_HTML.
2.2 CF_HTML: Offsets Must Be Calculated in UTF-8 Bytes
CF_HTML content is not a simple HTML string; it's a payload with a header:
Version:1.0
StartHTML:0000000105
EndHTML:0000000860
StartFragment:0000000200
EndFragment:0000000740
<!doctype html>
<html>
<body>
<!--StartFragment-->
...
<!--EndFragment-->
</body>
</html>
The part that is easy to get wrong is the offsets.
StartHTML, EndHTML, StartFragment, and EndFragment are not character positions but byte offsets from the beginning of the entire payload. Chinese characters, emoji, and full-width symbols cause discrepancies between "character count" and "byte count."
Therefore, MarkdownHtmlClipboard calculates using 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]);
Also, Windows HTML Format is written in Avalonia as DataFormat<byte[]>:
public static readonly DataFormat<byte[]> WindowsHtmlFormat =
DataFormat.CreateBytesPlatformFormat("HTML Format");
This is important: it's not a UTF-16 string format but a native clipboard byte payload.
2.3 Fragment Markers: Tell the Editor Which Part to Paste
Web editors don't necessarily need a full HTML document; they care about the fragment to paste.
Therefore, the HTML must include:
<!--StartFragment-->
<section id="vex">
...
</section>
<!--EndFragment-->
MarkdownHtmlClipboard checks if the incoming HTML already has valid fragment markers:
- If present, reuse them.
- If absent but
<body>exists, insert markers inside the body. - If not even a complete document, wrap in a minimal HTML document.
This way, host applications only need to generate content; they don't have to reimplement the CF_HTML spec for each project.
2.4 Why Styles Must Be Inline
Even with correct clipboard format, there's a real-world problem: WeChat, Zhihu, and Juejin will not load external CSS.
If the pasted content depends on:
<link rel="stylesheet" href="theme.css">
or uses classes:
<p class="markdown-body paragraph">Body</p>
The styles will likely be lost after pasting.
Therefore, the social media copy renderer in CodeWF.Markdown converts the current typography theme into inline styles:
<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;">
Title
</h2>
<p style="font-size: 15px; line-height: 26.25px; color: #333333;">
Body
</p>
</section>
This part is also now in CodeWF.Markdown: WeChat, Zhihu, and Juejin are described by built-in CopyKind and MarkdownSocialCopyProfile. Vex only needs to choose the target platform and pass the current Markdown, typography theme, and compact layout. Adding support for new publishing platforms later will only require extending profiles, not rewriting CF_HTML and base rendering in each application.
2.5 Three Platforms Are Not Three Fixed Color Schemes
The easiest previous approach was to have a fixed set of template colors for each platform.
This round also fixed that. Social media copy now reads the current MarkdownExportStyle. The simple call path resolves the built-in theme name and compact layout using MarkdownExportStyle.Resolve(themeName, typographySize):
var exportStyle = MarkdownExportStyle.Resolve(
currentTypographyTheme,
currentTypographySize);
If the application registers its own XAML typography resources, it can also create a MarkdownExportStyle manually, for example, using MarkdownThemes.CreateExportStyle("MyCompanyBlue"), and then pass it to the export or copy API.
Thus, the current preview theme, export theme, and social media copy theme all come from the same set of resources:
- Root container text color, background, font, line height.
- Heading font sizes and colors.
- Paragraph font size, line height, text color.
- Link color and underline.
- Blockquote border and background.
- Code block background.
- Table border and header background.
- Horizontal rule color.
- Juejin footnote text and link colors.
In other words, when the user switches typography themes in Vex, not only does the preview change, but also the HTML/print export, PNG/PDF/Word export, and "Copy to WeChat / Zhihu / Juejin" should all follow the same visual mapping.
3. API and Extensions: Another Layer of Export API
Initially, when migrating export capabilities, CodeWF.Markdown already provided:
MarkdownDocumentExporter.ExportPng(document, path, style);
MarkdownDocumentExporter.ExportPdf(document, path, style);
MarkdownDocumentExporter.ExportWord(document, path, style);
But for application developers, there was room for simplification.
So this round added ExportKind:
public enum ExportKind
{
Png,
Pdf,
Word
}
Host applications can now call unified methods based on export type:
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");
We avoided creating separate overloads Export(string, ...) for both Markdown string and file path because C# cannot distinguish between them by parameter name. So the API clearly splits into ExportMarkdown and ExportFile, while the full context is still handled by MarkdownExportDocument.
Social media copy is also wrapped into a similar layer. Platform targets are first expressed as an enum for built-in capabilities:
public enum CopyKind
{
Wechat,
Zhihu,
Juejin
}
The most common use for host applications is via the Avalonia clipboard extension method:
await clipboard.TrySetMarkdownHtmlAsync(
markdown,
MarkdownTypographyThemes.Simple,
"wechat",
MarkdownTypographySizes.Small);
await clipboard.SetMarkdownHtmlAsync(
markdown,
exportStyle,
CopyKind.Juejin);
This way, Vex doesn't need to maintain a bunch of platform-specific HTML generation code. It simply gets the Markdown string, current typography theme, and menu target, and performs the copy.
If a Markdown string is passed, relative images in social media copy are resolved based on the current working directory. If a file path is used to create copy content, images can be resolved based on the Markdown file's directory. This keeps the API simple while still covering common local image scenarios.
3.1 How Applications Can Extend Custom Typography Themes
This round also tidied up the method for extending typography themes.
MarkdownTypographyThemes continues to be string constants rather than an enum. The reason is simple: built-in themes are suitable for constants, but application themes need string keys. Otherwise, when a third-party app wants to add MyCompanyBlue or ProductLaunch themes, an enum would block extensibility.
The new extension point is 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
});
After registration, the preview can directly use this theme:
MarkdownThemes.OverrideTypographyResources(
Application.Current!,
"MyCompanyBlue",
MarkdownTypographySizes.Normal);
Export and copy can also reuse the same resources:
var style = MarkdownThemes.CreateExportStyle("MyCompanyBlue");
MarkdownDocumentExporter.ExportMarkdown(
markdown,
ExportKind.Pdf,
style,
"article.pdf");
await clipboard.SetMarkdownHtmlAsync(markdown, style, CopyKind.Wechat);
If the application already has a XAML resource dictionary, you can register a factory:
MarkdownTypographyThemeRegistry.Register(
"MyCompanyBlue",
() => new MyCompanyMarkdownResources());
Thus, the application maintains only one set of typography resources, and preview, PNG/PDF/Word export, and social media copy inline styles all derive from that same set. For products with many custom themes, this is more reliable than maintaining a separate MarkdownExportStyle mapping in the application layer.
4. Architecture Boundary: Why in CodeWF.Markdown, Not Only in Vex
This round had a principle: common problems go into the common library; business differences stay in the application layer.
Image loading and rasterization are not unique to Vex. Any Avalonia Markdown host application that needs to export PDF, Word, or PNG will face the same issues. So they belong in CodeWF.Markdown.
CF_HTML is also not unique to Vex. Any project that wants to paste HTML into a Chromium-based web editor may hit the same pitfall. Therefore, MarkdownHtmlClipboard should be a common capability.
The HTML structures, footnotes, and compatibility for WeChat, Zhihu, and Juejin are also reusable publishing profiles; they have been moved into CodeWF.Markdown. Vex retains only the application experience layer: reading the current document, current theme, current menu target, and then calling the public API.
The current boundary is roughly:
CodeWF.Markdown
- ExportKind
- MarkdownImageSourceLoader
- MarkdownImageRasterizer
- MarkdownHtmlClipboard
- MarkdownHtmlClipboardExtensions
- CopyKind
- MarkdownSocialCopyRenderer
- MarkdownSocialCopyProfiles
- MarkdownDocumentExporter
- MarkdownExportDocument
- MarkdownExportStyle
- MarkdownTypographyThemeRegistry
Vex
- Reads current document and typography theme
- Selects publishing target
- Calls common clipboard capabilities
- Calls common PNG / PDF / Word export capabilities
This boundary is more stable than "reinvent everything in Vex."
5. Tests Added
This round added several test categories to CodeWF.Markdown:
data:imagerecognition.- URL-encoded relative paths fallback via
ImageBasePath. - SVG rasterization to PNG.
- GIF to static PNG conversion.
- HTML fragment marker normalization.
- UTF-8 byte offset calculation in CF_HTML.
- Windows
HTML Formatusing byte format. - Unified export entry via
ExportKind. - Social media copy rendering with
CopyKind/profiles. - Local images embedded in social media copy HTML.
- Custom typography theme registration can generate
MarkdownExportStyle.
Currently, CodeWF.Markdown.Tests has 42 passing tests.
Vex also confirmed the local package reference approach: first package CodeWF.Markdown locally as version 12.0.3.13, then have Vex reference it via a local NuGet package source rather than a cross-repo ProjectReference. This is closer to how the real NuGet package will be consumed and can catch issues with NuGet content files, version numbers, and dependency restoration earlier.
6. Practical Effect
For users, the changes in this round should ultimately manifest as two things:
First, export is more reliable.
Common image sources like local relative paths, data:image, HTTP(S) images, SVG, GIF, WebP will be processed into the export results as much as possible for PDF and Word. PDF text can be selected and copied, and images will be embedded in the PDF file. Images in Word .docx will go into word/media/, so they remain visible even when the Markdown directory is not available.
Second, copying feels more like a publishing tool.
When clicking "Copy to WeChat Official Account / Zhihu / Juejin", the clipboard no longer contains plain text but rich HTML that web editors understand. After pasting, the formatted result should appear directly, not <section>...</section> source code.


7. Summary
Many experience problems in Markdown editors hide in the "last mile."
Being able to see images during preview does not guarantee they will survive export. Being able to generate HTML does not guarantee pasting into WeChat will be rich text. Being able to switch themes in the app does not guarantee the styles will be retained when copying out.
This round moved image loading, image rasterization, document export, rich HTML clipboard, and typography theme extension into CodeWF.Markdown, and then had Vex's PDF, Word, and social media copy pipelines reuse them. The result is not a particularly visible new big button, but rather making the steps "export, copy, publish" after finishing an article have fewer strange breakpoints.
We will continue to polish two areas:
- PDF: complete support for complex block-level elements, page break points, and typography theme details.
- Social media copy: continue to add compatibility details based on the real editor behavior of WeChat, Zhihu, and Juejin.
But the most critical holes are already filled: images should not live only in local paths, and HTML should not lie as plain text in the clipboard.
- Markdown control repository: https://github.com/dotnet9/CodeWF.Markdown
- Vex application repository: https://github.com/dotnet9/Vex