この2日間、CodeWF.Markdown と Vex の Markdown 公開パイプラインを引き続き磨き上げ、一見小さく見えても実際には執筆体験に大きく影響する2つの問題に集中して取り組みました。
- Markdown を PDF / Word にエクスポートした後、画像がファイルに含まれて移動し、オフラインで開いても表示できること。
- Vex から微信公衆号、知乎、稀土掘金にコピーするとき、ペースト結果がレイアウト付きのリッチテキストであり、生の HTML ソースコードではないこと。
この記事は完全な製品紹介ではなく、このラウンドの背後にある技術的実装に特化して説明します。
関連リポジトリ:
- CodeWF.Markdown: https://github.com/dotnet9/CodeWF.Markdown
- Vex: https://github.com/dotnet9/Vex
1. 問題1: Markdown 画像がファイル内の画像ではない
Markdown での画像記述は非常に軽量です:



しかし、PDF や Word にエクスポートする際、これらの文字列自体はまだ「ファイル内の画像」ではありません。単なる画像ソースです。
エクスポートロジックが Markdown を HTML に変換し、画像アドレスをそのまま配置するだけだと、いくつかの問題が発生します:
- 相対パスの画像は元の Markdown ディレクトリから離れると見つからない。
data:imageはプレビュー可能だが、Word では実際のメディアパートに変換する必要がある。- 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 は Web や 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 のように本文を選択・コピーできます。
画像のパイプラインは引き続き前述の共通読み込みおよびラスタライズ機能を再利用します。エクスポート前に、ローカル、相対、data:image、HTTP(S)、SVG/GIF/WebP などのソースを安定したバイト列に解析し、必要に応じて PNG に変換してから画像として PDF に埋め込みます:
var imageSource = MarkdownImageSourceLoader.Load(image.Url, documentPath);
var pngBytes = MarkdownImageRasterizer.RenderToPngBytes(imageSource);
これにより、エクスポートされた PDF は単なるページスクリーンショットではなくなります。本文をコピーでき、画像は元の Markdown ディレクトリやネットワークが利用不可でも失われません。
2. 問題2: 微信公衆号にコピーすると HTML ソースが表示される
もう一つの問題はクリップボードにあります。
Vex で「微信公衆号にコピー」をクリックしたとき、微信公衆号のバックエンドにペーストした結果が次のようになることを期待します:
- 見出しは見出しとして
- 段落に間隔がある
- リンクに色が付いている
- 引用に境界線がある
- コードブロックに背景色がある
- テーブルに枠線がある

しかし、クリップボードにプレーンテキストしか書き込まない場合、たとえテキストの内容が:
<section id="vex" style="font-size:16px">
<h2>見出し</h2>
<p>本文</p>
</section>
であっても、ブラウザのエディタがそれをプレーンテキストとして貼り付ける可能性があり、ユーザーには HTML のソースコードが見えてしまいます。
これは HTML 生成の問題ではなく、クリップボードのフォーマットの問題です。
2.1 リッチ HTML クリップボード:単なる文字列では不十分
今回、CodeWF.Markdown に MarkdownHtmlClipboard、MarkdownHtmlClipboardExtensions およびソーシャルメディアコピープロファイルを追加し、ホストアプリがリッチ 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 は文字位置ではなく、ペイロード全体の先頭からの バイトオフセット です。中国語、絵文字、全角記号があると「文字数」と「バイト数」が一致しません。
そのため 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 スタイルをインラインにする理由
クリップボードフォーマットが正しくなっても、別の現実問題があります。微信公衆号、知乎、掘金は外部 CSS を読み込みません。
貼り付けられたコンテンツが次のようなものに依存している場合:
<link rel="stylesheet" href="theme.css">
または多数のクラスに依存:
<p class="markdown-body paragraph">本文</p>
貼り付け後にスタイルが失われる可能性が高いです。
そのため、CodeWF.Markdown のソーシャルメディアコピーレンダラーは、現在のタイポグラフィテーマをインラインスタイルに変換します:
<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、タイポグラフィテーマ、コンパクトレイアウトを渡すだけで済みます。今後新しい公開プラットフォームをサポートする場合は、プロファイルを追加するだけでよく、各アプリが CF_HTML や基本レンダリングを再実装する必要はありません。
2.5 3つのプラットフォームは固定された色セットではない
以前は簡単な方法として、微信公衆号、知乎、掘金それぞれに固定のテンプレート色を設定していました。
今回、この点も修正しました。ソーシャルメディアコピーは現在の 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# ではパラメータ名だけでこれら 2 つの 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 生成コードを維持する必要がなくなります。Vex は Markdown 文字列、現在のタイポグラフィテーマ、メニューターゲットを取得できればコピーを完了できます。
Markdown 文字列を渡す場合、ソーシャルメディアコピー内の相対画像は現在の作業ディレクトリに基づいて解決されます。ファイルパスを使用してコピーコンテンツを作成する場合は、Markdown ファイルのディレクトリに基づいて画像を解決できます。これにより API はシンプルさを保ちながら、一般的なローカル画像のシナリオをカバーします。
3.1 アプリが独自のタイポグラフィテーマを拡張する方法
今回、タイポグラフィテーマの拡張方法も整理しました。
MarkdownTypographyThemes は引き続き文字列定数として保持し、enum には変更しません。理由は簡単で、組み込みテーマは定数に適し、アプリテーマは文字列キーに適しているからです。そうしないと、サードパーティアプリが 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());
これにより、アプリ側では 1 セットのタイポグラフィリソースのみを管理すればよく、プレビュー、PNG/PDF/Word エクスポート、ソーシャルメディアコピーのインラインスタイルができるだけ同じリソースセットから値を取得できるようになります。カスタムテーマが多い製品にとっては、アプリ側で別途 MarkdownExportStyle マッピングを維持するよりも安定します。
4. アーキテクチャの境界:なぜ CodeWF.Markdown に実装し、Vex だけに書かないのか
今回の基本原則は「共通の問題は共通ライブラリに、ビジネスの違いはアプリ層に」です。
画像の読み込みとラスタライズは Vex だけの問題ではありません。どの Avalonia Markdown ホストアプリでも PDF、Word、PNG にエクスポートする際に同じ問題に直面します。そのため、CodeWF.Markdown に配置する方が適切です。
CF_HTML も Vex だけの問題ではありません。HTML を Chromium 系ウェブエディタに貼り付けたいプロジェクトはすべて同じ落とし穴に陥る可能性があります。そのため、MarkdownHtmlClipboard も共通機能であるべきです。
微信公衆号、知乎、掘金の HTML 構造、フッター注釈、互換性の慣習も再利用可能な公開プロファイルに該当し、今回 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 フラグメントマーカーの正規化。
- 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. 実際の効果
ユーザーにとって、今回の変更は最終的に 2 つのことに現れるはずです。
1 つ目は、より安心してエクスポートできること。
ローカルの相対画像、data:image、HTTP(S) 画像、SVG、GIF、WebP などの一般的なソースが、PDF と Word のエクスポート時に可能な限り結果に含まれます。PDF の本文は選択してコピーでき、画像は PDF ファイル内に取り込まれます。Word .docx 内の画像は word/media/ に格納され、元の Markdown ディレクトリから離れても表示可能です。
2 つ目は、コピーが公開ツールのように機能すること。
「微信公衆号 / 知乎 / 稀土掘金にコピー」をクリックすると、クリップボードには単なるプレーンテキストではなく、ウェブエディタが認識できるリッチ HTML が含まれます。貼り付け後は直接レイアウトされた結果が表示され、<section>...</section> のようなソースコードテキストは表示されません。


7. まとめ
Markdown エディタの多くの体験上の問題は「ラストワンマイル」に潜んでいます。
プレビューで画像が見えても、エクスポート後に画像が残っているとは限りません。HTML を生成できても、微信公衆号に貼り付ければリッチテキストになるとは限りません。テーマをアプリ内で切り替えられても、コピーした先でスタイルが維持されるとは限りません。
今回、画像読み込み、画像ラスタライズ、ドキュメントエクスポート、リッチ HTML クリップボード、タイポグラフィテーマ拡張といった共通機能を CodeWF.Markdown に追加し、Vex の PDF、Word、ソーシャルメディアコピーパイプラインでそれらを再利用できるようにしました。結果として、特に目立つ大きなボタンが増えたわけではありませんが、記事を書き終えた後の「エクスポート、コピー、公開」という手順において、奇妙な断絶をいくつか減らすことができました。
今後も以下の 2 つの領域を引き続き磨いていきます:
- PDF: 複雑なブロック要素、改ページ、タイポグラフィテーマの詳細を引き続き補完。
- ソーシャルメディアコピー: 微信公衆号、知乎、掘金の実際のエディタ動作に基づいて互換性の詳細を補完。
しかし、今回最も核心的な問題はすでに埋められています。画像はローカルパスにのみ存在すべきではなく、HTML はプレーンテキストとしてクリップボードに置かれるべきでもありません。
- Markdown コントロールリポジトリ: https://github.com/dotnet9/CodeWF.Markdown
- Vex アプリリポジトリ: https://github.com/dotnet9/Vex