題目 中文 實現一個以 T 作為泛型參數的 IsNever類型. 如果 T 是never, 返回 true, 否則返回 false. 示例: type A = IsNever<never>; // expected to be true type B = IsNever<undefined>; // ...
我在做一個圖表工具軟體,這個軟體使用 MAUI 開發。我的需求是圖表的內容需要和 PPT 的圖表對接,需要用到 OpenXML 解析 PPT 內容,讀取到 PPT 圖表元素的內容,接著使用 MAUI 渲染層繪製圖表元素。圖表工具軟體需要在 Windows 平臺和 Linux 平臺上運行。在 Windows 下,我採用 WPF 應用,用來闢謠說 MAUI 不支持 WPF 應用。 在 Linux 選用 Ubuntu 系統,採用 GTKSharp 應用加上 Skia 渲染對接 MAUI 框架
圖表工具軟體的開發架構如下,可以看到只有和具體平臺對接的一層不相同
本文將包含兩個部分,一個是解析渲染面積圖圖表,另一個是使用 MAUI 開發跨平臺應用。解析面積圖圖表是用到 OpenXML 解析 PPT 的知識,本文只包含很少量的 OpenXML 的知識,我將詳細的使用 OpenXML 解析 PPT 的面積圖的方法放在了 dotnet OpenXML 解析 PPT 圖表 面積圖入門 博客里。本文的用到的解析 PPT 的代碼也是從此博客裡面抄的,這部分代碼將不會在本文上貼出。 如對 OpenXML 解析 PPT 毫無概念的伙伴,閱讀本文也不會存在問題,只需要假定本文的解析 PPT 的代碼是通過某個方式獲取到了圖表的相關信息即可,請將重點放在圖表的繪製渲染,以及如何做跨平臺對接上
本文使用的代碼只能用來做例子,本文的解析 PPT 圖表的代碼只能支持本文例子里的測試文件,本文的測試文件和代碼可以從本文最後獲取
在開始之前,先看一下本文實現的效果
效果
這是在 PPT 的圖表:
在 Windows 下,使用 Skia 繪製為圖片文件,然後使用 Image 控制項顯示圖片,界面效果如下:
以上只是將 MAUI 接入 WPF 的一個方法。不代表只能通過圖片文件的方式接入,其他繪製方法請看 WPF 使用 MAUI 的自繪製邏輯
在 Linux 下,使用 Skia 對接 Gtk 框架,界面效果如下:
動態運行效果如下
接下來將告訴大家如何實現
解析繪製面積圖圖表
開始實現繪製 PPT 的圖表之前,需要先解析圖表的內容
圖表的解析部分需要用到 OpenXML 知識,這部分解析的內容,在 dotnet OpenXML 解析 PPT 圖表 面積圖入門 博客裡面有詳細說明。使用 dotnet OpenXML 解析 PPT 圖表 面積圖入門 的方法解析出圖表的內容將獲取到的內容放入到 AreaChartRenderContext 類型,此類型用來提供渲染繪製使用的上下文,包括以下屬性
/// <summary>
/// 用來提供圖表 面積圖 渲染的上下文信息
/// </summary>
public class AreaChartRenderContext
{
// 忽略代碼
public ChartSpace ChartSpace { get; }
public SlideContext SlideContext { get; }
public Pixel Width { get; }
public Pixel Height { get; }
/// <summary>
/// 類別軸上的數據 橫坐標軸上的數據
/// </summary>
public ChartValueList CategoryAxisValueList { get; } = null!;
/// <summary>
/// 面積圖的系列信息集合
/// </summary>
public AreaChartSeriesInfoList AreaChartSeriesInfoList { get; } = new();
}
上面代碼的 ChartSpace
屬性是圖表元素,通過 dotnet OpenXML 解析 PPT 圖表 面積圖入門 博客可以瞭解到裡面包含圖表的信息。上面代碼的 SlideContext
屬性是我所在的團隊開源的 OpenXml 解析輔助庫提供的包含元素所在頁面的類型,詳細請看: https://github.com/dotnet-campus/DocumentFormat.OpenXml.Extensions
圖表關鍵的信息包含類別軸上的數據,也稱為橫坐標軸上的數據,放在 CategoryAxisValueList
屬性。系列信息集合,放在 AreaChartSeriesInfoList
屬性。這兩個屬性是從 ChartSpace
讀取,讀取的方法請看 dotnet OpenXML 解析 PPT 圖表 面積圖入門 博客或者閱讀本文用到的代碼
在獲取到了圖表的各個信息之後,即可進行繪製圖表。開始進行繪製之前,還請先瞭解圖表的各個組成部分
- 橫坐標軸 類別坐標軸數據:
- 縱坐標軸:
- 數據系列:
在圖表裡面有數據系列的概念,每個系列的數據組成一個個的數據系列。對於大部分圖表來說,數據層都是由一個個數據系列組成的
每個數據系列可以有自己的系列名稱
系列名稱大部分時候都放在圖例裡面,也就是圖例裡面的內容就是由系列名稱提供的
在圖表裡面,核心就是對數據的處理,系列的數據內容就是核心的
如圖,面積圖有兩個數據系列,通過上面的 Excel 內容可以瞭解到兩個系列的數據分別如下
系列 1:32,32,28,12,15
系列 2:12,12,12,21,28
為了讓繪製邏輯更方便閱讀,定義 AreaChartRender 類用來繪製圖表
圖表繪製 AreaChartRender 需要兩個參數,一個是 AreaChartRenderContext
用來提供信息,一個是 Microsoft.Maui.Graphics.ICanvas
用來提供渲染繪製方法。在各個平臺上,可以使用不同的實現對接 MAUI 的渲染,也就是 Microsoft.Maui.Graphics.ICanvas
介面可以對應不同的實現。在解析渲染模塊里不耦合具體的平臺渲染實現,只使用抽象的介面,定義的類型如下
public class AreaChartRender
{
public AreaChartRender(AreaChartRenderContext context)
{
Context = context;
}
public AreaChartRenderContext Context { get; }
public void Render(ICanvas canvas)
{
// 忽略代碼
}
圖表繪製 AreaChartRender 基礎的使用方法是在和 OpenXML 解析 PPT 的圖表這一層對接,通過 AreaChartRenderContext 類型拿到圖表的內容,創建出 AreaChartRender 對象,傳遞給具體的渲染層。在渲染層里,將區分平臺進行渲染,各個平臺定義 Microsoft.Maui.Graphics.ICanvas
的實現,傳入到 AreaChartRender 的 Render 方法。在 Render 方法將繪製圖表內容,即可通過抽象的 Microsoft.Maui.Graphics.ICanvas
介面,調用各個平臺具體的繪製實現
使用以下代碼即可使用 OpenXML 解析 PPT 的圖表,獲取圖表內容,關於以下代碼的細節邏輯,請看 dotnet OpenXML 解析 PPT 圖表 面積圖入門
public class ModelReader
{
/// <summary>
/// 構建出面積圖上下文
/// </summary>
/// <param name="file">這裡是例子,要求只能傳入 Test.pptx 文件。其他文件沒有支持</param>
/// <returns></returns>
public AreaChartRender BuildAreaChartRender(FileInfo file)
{
using var presentationDocument = PresentationDocument.Open(file.FullName, false);
var slide = presentationDocument.PresentationPart!.SlideParts.First().Slide;
/*
<p:cSld>
<p:spTree>
<p:graphicFrame>
...
</p:graphicFrame>
</p:spTree>
</p:cSld>
*/
// 獲取圖表元素,在這份課件里,有一個面積圖。以下使用 First 忽略細節,獲取圖表
var graphicFrame = slide.Descendants<GraphicFrame>().First();
/*
<p:graphicFrame>
<a:graphic>
<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">
<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2" />
</a:graphicData>
</a:graphic>
</p:graphicFrame>
*/
// 獲取到對應的圖表信息,圖表是引用的,內容不是放在 Slide 頁面裡面,而是放在獨立的圖表 xml 文件里
var graphic = graphicFrame.Graphic;
var graphicData = graphic?.GraphicData;
var chartReference = graphicData?.GetFirstChild<ChartReference>();
// 獲取到 id 也就是 `r:id="rId2"` 根據 Relationship 的描述,可以知道去 rels 文件裡面獲取關聯的內容。在 OpenXml SDK 里,封裝好了獲取方法,獲取時需要有兩個參數,一個是 id 另一個是去哪裡獲取的 Part 內容
var id = chartReference?.Id?.Value;
// 這裡需要告訴 OpenXml SDK 去哪裡獲取資源。詳細請看 https://blog.lindexi.com/post/dotnet-OpenXML-%E4%B8%BA%E4%BB%80%E4%B9%88%E8%B5%84%E6%BA%90%E4%BD%BF%E7%94%A8-Relationship-%E5%BC%95%E7%94%A8.html
// 如果是放在模版裡面,記得要用模版的 Part 去獲取
var currentPart = slide.SlidePart!;
if (!currentPart.TryGetPartById(id!, out var openXmlPart))
{
// 在這份課件里,一定不會進入此分支
// 一定能從頁面找到對應的資源內容也就是圖表
}
var chartPart = (ChartPart) openXmlPart!;
// 這裡的 ChartPart 對應的就是 charts\chartN.xml 文件。這裡的 chartN.xml 表示的是 chart1.xml 或 chart2.xml 等文件
var chartSpace = chartPart.ChartSpace;
var slideContext = new SlideContext(slide, presentationDocument);
var transformData = graphicFrame.GetOrCreateTransformData(slideContext);
return new AreaChartRender(new AreaChartRenderContext(chartSpace, slideContext, transformData.Width.ToPixel(),
transformData.Height.ToPixel()));
}
}
具體的平臺渲染實現部分,放在下一章。下麵先在 Render 方法對接 MAUI 的抽象的 Microsoft.Maui.Graphics.ICanvas
介面,進行繪製圖表。繪製圖表的工作量包括繪製坐標軸信息,計算刻度線,對各個系列的繪製
本文這裡採用的是絕對佈局方式,相對來說用到的知識簡單。缺點是很多計算都會放在下麵代碼,看起來比較複雜,好在計算只是小學數學的加減
下麵的繪製代碼只能作為本文的例子使用,很多原本需要進行排版計算的值,為了方便理解,我都使用常量,如下麵代碼,還請忽略這部分的細節
public void Render(ICanvas canvas)
{
// 圖表標題
float chartTitleHeight = 52;
// 圖例高度,圖例是放在最下方
float chartLegendHeight = 42;
// 類別信息的高度
float axisValueHeight = 20;
float yAxisLeftMargin = 42;
float yAxisRightMargin = 42;
float xAxisBottomMargin = 25;
}
從 OpenXML 解析的 PPT 圖表獲取到的 AreaChartRenderContext 拿到圖表的元素尺寸,用來作為圖表繪製畫布的限制尺寸
var chartWidth = (float) Context.Width.Value;
var chartHeight = (float) Context.Height.Value;
以上的數值定義全部採用 float 類型,其原因是 MAUI 為了更好的適配更多的平臺,選用了 float 作為渲染繪製的參數的通用類型。這一點和 WPF 的不相同,在 WPF 或 UWP 或 WinFroms 等,通用的繪製計算都採用 double 類型。對於渲染繪製,大部分情況,使用 float 也是夠用的。如果一個 double 值的範圍是在 float 內,那進行 double 轉 float 也是安全的。至於性能的損耗,如果不是熱點代碼,也可以忽略
通過以上的信息即可計算出圖表的繪製範圍,包括坐標和尺寸
var plotAreaOffsetX = yAxisLeftMargin;
var plotAreaOffsetY = chartTitleHeight;
var plotAreaWidth = chartWidth - yAxisLeftMargin - yAxisRightMargin;
var plotAreaHeight = chartHeight - chartTitleHeight - chartLegendHeight - xAxisBottomMargin;
這些信息屬於佈局信息,本文這裡只是使用簡單的固定數值計算,而不是跟隨具體的圖表數據進行計算,以上的代碼比較“塑料”還請不要抄到實際項目代碼。完成佈局計算之後,開始繪製坐標軸信息。坐標軸信息包含了刻度信息,也就是 Y 軸的刻度。刻度信息包括了每個刻度之間的數值間隔是多少,最大值和最小值是多少的信息。我採用了玄學的計算方法 GetRatio 獲取到了刻度的間隔的值,以及和這份 PPT 的圖表一樣固定了只有 8 條線
var rowLineCount = 8; // 這份 PPT 測試文件里只有 8 條線
// 獲取數據最大值
var maxData = GetMaxValue();
// 獲取刻度的值
var ratio = GetRatio(maxData, rowLineCount); // 這是一個玄學的方法。才不告訴你方法裡面直接返回了一個常量
var maxValue = ratio * (rowLineCount - 1);
完成了基礎計算,接下來可以開始繪製坐標軸。繪製坐標軸就需要用到 MAUI 的繪製知識,對這些繪製知識感興趣還請參閱官方文檔: Graphics - .NET MAUI Microsoft Docs
繪製坐標軸,本質上是繪製網格線,步驟是先繪製 Y 軸,再繪製 X 軸。如 PPT 的圖表效果,這份文檔的 Y 軸只有刻度,也就是需要繪製 Y 軸的刻度和 x 行的線。在 MAUI 里,繪製線條只需要使用 DrawLine 方法,傳入兩個點即可。控制線條的粗細和顏色等,是通過在 DrawLine 方法之前,先設置好參數屬性。如下麵代碼繪製 X 行的線
for (var i = 0; i < rowLineCount; i++)
{
canvas.StrokeSize = 2;
canvas.StrokeColor = Colors.Gray;
var offsetX = plotAreaOffsetX;
var offsetY = plotAreaOffsetY + plotAreaHeight - plotAreaHeight / (rowLineCount - 1) * i;
canvas.DrawLine(offsetX, offsetY, offsetX + plotAreaWidth, offsetY);
}
以上代碼通過 StrokeSize 設置繪製的線條的粗細是 2 的值,這裡的值是沒有一個單位的,具體的單位是具體的渲染平臺自己賦予的,可以認為是像素。使用 StrokeColor 設置線條的顏色,再使用 DrawLine 傳入兩個點,繪製出線條
接下來繼續繪製 Y 軸的刻度。繪製刻度需要用到文本繪製的方法,文本繪製中存在一個小問題,那就是中文字體設置的問題,好在此問題被我修複了,詳細請看 Fix set the Font to Microsoft.Maui.Graphics.Skia by lindexi · Pull Request #9124 · dotnet/maui
以下代碼只是繪製數字而已,不需要設置中文字體,也就不會踩到上文說到的坑。為了讓繪製文本對齊到刻度,需要給定繪製文本的範圍,這裡稍微有一些知識需要瞭解,詳細請看 Microsoft.Maui.Graphics.Skia 使用 DrawString 繪製文本的坐標問題
// 獲取刻度的值
var fontSize = 16f;
canvas.FontSize = fontSize;
var textRightMargin = 5;
var textX = 0;
var textY = offsetY - fontSize / 2f;
var textWidth = plotAreaOffsetX - textX - textRightMargin;
var textHeight = 25;
// 獲取刻度的文本
var value = (ratio * i).ToString(CultureInfo.CurrentCulture);
canvas.DrawString(value, textX, textY, textWidth, textHeight, HorizontalAlignment.Right, VerticalAlignment.Top);
和繪製線條相同的是,在繪製文本之前,通過參數屬性設置文本的屬性,例如上面代碼設置了文本的字體大小。同樣,這裡的字體大小也是沒有具體單位的,由具體的平臺實現決定,大部分情況可以認為是像素單位
完成了繪製 Y 軸的刻度和 x 行的線,繼續繪製放在 X 軸底部的類別信息,也就是對應本文的圖表的日期信息。好在日期的表示的字元串也沒有用到中文,依然不會踩到上文描述的中文字體的坑
// 繪製 X 軸,繪製類別信息
var categoryAxisValueList = Context.CategoryAxisValueList.ValueList;
for (var i = 0; i < categoryAxisValueList.Count; i++)
{
var offsetX = plotAreaOffsetX + plotAreaWidth * i / (categoryAxisValueList.Count - 1);
var offsetY = plotAreaOffsetY + plotAreaHeight;
var textX = offsetX - 20;
var textY = offsetY + xAxisBottomMargin;
if (i < categoryAxisValueList.Count)
{
var text = categoryAxisValueList[i].GetViewText();
canvas.DrawString(text, textX, textY, HorizontalAlignment.Left);
}
}
繪製類別信息的工作量就是計算出文本的坐標,和使用 GetViewText 方法,獲取到具體類別里的用戶可見的文本的字元串,然後調用 DrawString 方法即可
完成坐標軸的繪製之後,就進入關鍵的 DrawArea 方法,在此方法裡面,將會繪製圖表的數據信息。將圖表的各個系列的數據作為面積圖繪製
繪製面積圖圖表的方法是獲取到圖表的各個系列的數值信息,根據這些數值創建出一段 Path Geometry 路徑幾何用於填充面積圖。創建路徑幾何可使用 PathF 類型創建一個基於 float 存儲信息的路徑幾何。這裡的 PathF 就是 Path + Float 的意思,如以下代碼進行創建
using var path = new PathF();
在 MAUI 里,這個 PathF 是推薦做釋放的,在各個平臺的 PathF 的底層實現有所不同,不代表著一定需要釋放。好在多調用釋放是安全的,這裡就加上 using 用來在方法執行結束釋放。開始繪製之前,先準備一點點路徑幾何創建的知識。按照 Path 的創建慣例,開始點採用 Move 方法設置,如以下代碼
path.Move(startX, startY);
在 MAUI 的設計里,可以使用連續的方法,輸入繪製參數,如畫兩條線,然後設置幾何關閉,可以採用如下代碼
path.LineTo(x1, y1)
.LineTo(x2, y2)
.Close();
如上面代碼即可畫出一段路徑集合出來,本文會用到的也僅僅只是以上幾個方法,這也就是本文用到的核心繪製路徑的知識。當然,路徑幾何 PathF 是一個複雜的類型,擁有的方法和功能可遠不止本文介紹的這一點,更多繪製知識,還請參閱官方文檔。在瞭解了基礎用法,接下來開始繪製面積圖
繪製面積圖只是一些計算邏輯,通過給定的數據計算出 PathF 的內容,代碼如下
for (var chartDataIndex = 0; chartDataIndex < Context.AreaChartSeriesInfoList.Count; chartDataIndex++)
{
var chartSeriesInfo = Context.AreaChartSeriesInfoList[chartDataIndex];
if (chartSeriesInfo.ChartValueList is null)
{
continue;
}
using var path = new PathF();
var startX = plotAreaOffsetX;
var startY = plotAreaOffsetY + plotAreaHeight;
path.Move(startX, startY);
for (var i = 0; i < chartSeriesInfo.ChartValueList.ValueList.Count; i++)
{
var value = chartSeriesInfo.ChartValueList.ValueList[i];
if (value is NumericChartValue numericChartValue)
{
var offsetX = plotAreaOffsetX + plotAreaWidth * i / (categoryAxisValueList.Count - 1);
var offsetY = plotAreaOffsetY + plotAreaHeight -
numericChartValue.GetValue() / maxValue * plotAreaHeight;
path.LineTo(offsetX, (float) offsetY);
}
}
path.LineTo(plotAreaOffsetX + plotAreaWidth, plotAreaOffsetY + plotAreaHeight)
.LineTo(startX, startY)
.Close();
}
創建 path 路徑完成,即可繪製到畫布。按照慣例,繪製需要先設置填充顏色,再繪製
// 在這份課件里,一定是純色
var (success, a, r, g, b) =
BrushCreator.ConvertToColor(chartSeriesInfo.FillBrush!.GetFill<SolidFill>()!.RgbColorModelHex!.Val!);
var color = new Color(r, g, b, a); // 獲取到各個系列的填充顏色
canvas.FillColor = color;
canvas.FillPath(path);
以上簡單的代碼即可完成圖表的繪製。我將上面代碼放在一個方法,方便大家閱讀
public void Render(ICanvas canvas)
{
var chartWidth = (float) Context.Width.Value;
var chartHeight = (float) Context.Height.Value;
// 圖表標題
float chartTitleHeight = 52;
// 圖例高度,圖例是放在最下方
float chartLegendHeight = 42;
// 類別信息的高度
float axisValueHeight = 20;
float yAxisLeftMargin = 42;
float yAxisRightMargin = 42;
float xAxisBottomMargin = 25;
var plotAreaOffsetX = yAxisLeftMargin;
var plotAreaOffsetY = chartTitleHeight;
var plotAreaWidth = chartWidth - yAxisLeftMargin - yAxisRightMargin;
var plotAreaHeight = chartHeight - chartTitleHeight - chartLegendHeight - xAxisBottomMargin;
// void CreateCoordinate()
// 繪製坐標系
// 先找到 Y 軸的刻度,找到最大值
// 有多少條行的線,保持和 PPT 相同
var rowLineCount = 8;
// 獲取數據最大值
var maxData = GetMaxValue();
// 獲取刻度的值
var ratio = GetRatio(maxData, rowLineCount);
var maxValue = ratio * (rowLineCount - 1);
// 繪製網格線,先繪製 Y 軸,再繪製 X 軸
// 繪製 Y 軸的刻度和 x 行線
for (var i = 0; i < rowLineCount; i++)
{
canvas.StrokeSize = 2;
canvas.StrokeColor = Colors.Gray;
var offsetX = plotAreaOffsetX;
var offsetY = plotAreaOffsetY + plotAreaHeight - plotAreaHeight / (rowLineCount - 1) * i;
canvas.DrawLine(offsetX, offsetY, offsetX + plotAreaWidth, offsetY);
// 獲取刻度的值
var fontSize = 16f;
canvas.FontSize = fontSize;
var textRightMargin = 5;
var textX = 0;
var textY = offsetY - fontSize / 2f;
var textWidth = plotAreaOffsetX - textX - textRightMargin;
var textHeight = 25;
var value = (ratio * i).ToString(CultureInfo.CurrentCulture);
canvas.DrawString(value, textX, textY, textWidth, textHeight, HorizontalAlignment.Right,
VerticalAlignment.Top);
}
// 繪製 X 軸,繪製類別信息
var categoryAxisValueList = Context.CategoryAxisValueList.ValueList;
for (var i = 0; i < categoryAxisValueList.Count; i++)
{
var offsetX = plotAreaOffsetX + plotAreaWidth * i / (categoryAxisValueList.Count - 1);
var offsetY = plotAreaOffsetY + plotAreaHeight;
var textX = offsetX - 20;
var textY = offsetY + xAxisBottomMargin;
if (i < categoryAxisValueList.Count)
{
var text = categoryAxisValueList[i].GetViewText();
canvas.DrawString(text, textX, textY, HorizontalAlignment.Left);
}
}
// void DrawArea()
// 繪製內容
for (var chartDataIndex = 0; chartDataIndex < Context.AreaChartSeriesInfoList.Count; chartDataIndex++)
{
var chartSeriesInfo = Context.AreaChartSeriesInfoList[chartDataIndex];
if (chartSeriesInfo.ChartValueList is null)
{
continue;
}
using var path = new PathF();
var startX = plotAreaOffsetX;
var startY = plotAreaOffsetY + plotAreaHeight;
path.Move(startX, startY);
for (var i = 0; i < chartSeriesInfo.ChartValueList.ValueList.Count; i++)
{
var value = chartSeriesInfo.ChartValueList.ValueList[i];
if (value is NumericChartValue numericChartValue)
{
var offsetX = plotAreaOffsetX + plotAreaWidth * i / (categoryAxisValueList.Count - 1);
var offsetY = plotAreaOffsetY + plotAreaHeight -
numericChartValue.GetValue() / maxValue * plotAreaHeight;
path.LineTo(offsetX, (float) offsetY);
}
}
path.LineTo(plotAreaOffsetX + plotAreaWidth, plotAreaOffsetY + plotAreaHeight)
.LineTo(startX, startY)
.Close();
if (chartDataIndex < Context.AreaChartSeriesInfoList.Count)
{
// 在這份課件里,一定是純色
var (success, a, r, g, b) =
BrushCreator.ConvertToColor(chartSeriesInfo.FillBrush!.GetFill<SolidFill>()!.RgbColorModelHex!
.Val!);
var color = new Color(r, g, b, a);
canvas.FillColor = color;
}
canvas.FillPath(path);
}
}
原本是將上面代碼拆開作為多個函數,為了方便調試,還是放在一個函數里。在實際項目上,不要讓一個方法的代碼如此多
開發跨平臺應用
完成圖表的繪製邏輯,接下來需要各個平臺進行對接。與 MAUI 的對接是十分簡單的,按照慣例,是先安裝 NuGet 庫,然後調用庫提供的方法即可完成對接。先對接 Windows 平臺的 WPF 應用
在 WPF 應用里,這次採用的是對接圖片文件渲染方法。如本文開始的開發架構圖所述,在 Windows 上通過 Microsoft.Maui.Graphics.Skia
將 Skia 和 MAUI 對接,使用 Skia 作為 MAUI 的畫布,在繪製完成之後使用 Skia 保存本地圖片文件,再使用 WPF 渲染保存的圖片
這不代表著在 WPF 裡面,只能通過 Skia 才能和 MAUI 對接,也不代表著 WPF 對接 Skia 只能通過本地圖片的顯示。關於在 WPF 裡面,直接對接 MAUI 的方法請看 WPF 使用 MAUI 的自繪製邏輯
關於在 WPF 裡面,使用 WriteableBitmap 控制項作為 Skia 的輸出的方式,讓 WPF 對接 Skia 的方法請看 WPF 使用 Skia 繪製 WriteableBitmap 圖片
回到對接的邏輯,由於本文的 WPF 應用只負責將 Skia 保存的圖片進行渲染,也就是說 WPF 層是可以不知道任何 MAUI 和 Skia 的邏輯,只需要知道保存的圖片文件在哪即可。既然沒有什麼 WPF 的邏輯,那就先來關註一下 Skia 的對接邏輯
這裡的 Skia 邏輯包括兩個部分,一個是 Skia 輸出到本地圖片文件,另一個是 Skia 對接 MAUI 的邏輯。關於 Skia 對接 MAUI 的邏輯,細節可參閱 dotnet 控制台 使用 Microsoft.Maui.Graphics 配合 Skia 進行繪圖入門 文檔,本文將不包含細節邏輯
開始之前,按照慣例先安裝 NuGet 庫。在 dotnet 6 應用里,通過編輯 csproj 項目文件的方式可以快速安裝 NuGet 庫,在 csproj 文件上加上以下代碼用來安裝 NuGet 庫。安裝的 NuGet 庫包括用來解析 PPT 的 dotnetCampus.DocumentFormat.OpenXml.Flatten
和 dotnetCampus.OpenXmlUnitConverter
和 DocumentFormat.OpenXml
庫,和 MAUI 的 Microsoft.Maui.Graphics
和 Microsoft.Maui.Graphics.Skia
庫
<ItemGroup>
<PackageReference Include="dotnetCampus.DocumentFormat.OpenXml.Flatten" Version="2.1.0" />
<PackageReference Include="dotnetCampus.OpenXmlUnitConverter" Version="2.1.0" />
<PackageReference Include="DocumentFormat.OpenXml" Version="2.17.1" />
<PackageReference Include="Microsoft.Maui.Graphics" Version="6.0.403" />
<PackageReference Include="Microsoft.Maui.Graphics.Skia" Version="6.0.403" />
</ItemGroup>
為了方便開發,我將 Skia 對接 MAUI 的邏輯,封裝到 SkiaPngImageRenderCanvas 類型。此類型繼承 IRenderCanvas 介面,介面定義如下
/// <summary>
/// 渲染畫板
/// </summary>
public interface IRenderCanvas
{
/// <summary>
/// 開始進行渲染
/// </summary>
/// <param name="action"></param>
void Render(Action<ICanvas> action);
}
通過調用 Render 方法,傳入委托,委托的參數就是 Microsoft.Maui.Graphics.ICanvas
介面,在此委托裡面完成實際的繪製邏輯
創建 SkiaPngImageRenderCanvas 需要三個參數,分別是寬度高度的畫布尺寸,也就是保存的圖片的尺寸,這裡的單位是像素,和保存的文件。上層業務調用 Render 完成,將輸出文件
/// <summary>
/// 提供使用 png 作為輸出的 Skia 畫板
/// </summary>
public class SkiaPngImageRenderCanvas : IRenderCanvas
{
private readonly int _width;
private readonly int _height;
private readonly FileInfo _outputPngFile;
public SkiaPngImageRenderCanvas(int width, int height, FileInfo outputPngFile)
{
_width = width;
_height = height;
_outputPngFile = outputPngFile;
}
public void Render(Action<ICanvas> action)
{
// 忽略代碼
}
在 Render 方法里,將先創建 Skia 的畫布,接著使用 Skia 的畫布創建 MAUI 的畫布,將 MAUI 的畫布傳入到委托作為參數,繪製完成保存本地文件
在 Skia 裡面,最重要的概念是畫布 SKCanvas 類型,基本的繪製邏輯都是調用此類型的方法完成。通過此類型即可在上面繪製內容。而 Skia 與 MAUI 的對接里,也需要用到此類型,對接的方法是創建 Microsoft.Maui.Graphics.Skia.SkiaCanvas
對象,此 SkiaCanvas 對象繼承了 Microsoft.Maui.Graphics.ICanvas
介面,即可用來傳入圖表的繪製層作為繪製的畫布
初始化 SkiaCanvas 對象就需要用到 SKCanvas 對象,以下代碼包含了創建 SKCanvas 對象和使用 SKCanvas 對象創建出 SkiaCanvas 對象
public void Render(Action<ICanvas> action)
{
var skImageInfo = new SKImageInfo(_width, _height, SKColorType.Bgra8888, SKAlphaType.Unpremul, SKColorSpace.CreateSrgb());
using (var skImage = SKImage.Create(skImageInfo))
{
using (var skBitmap = SKBitmap.FromImage(skImage))
{
using (var skCanvas = new SKCanvas(skBitmap))
{
skCanvas.Clear(SKColors.Transparent);
var skiaCanvas = new SkiaCanvas();
skiaCanvas.Canvas = skCanvas;
ICanvas canvas = skiaCanvas;
action(canvas);
// 忽略代碼
}
}
}
}
接著在執行 action
委托完成之後,保存為本地圖片,代碼如下
skCanvas.Clear(SKColors.Transparent);
var skiaCanvas = new SkiaCanvas();
skiaCanvas.Canvas = skCanvas;
ICanvas canvas = skiaCanvas;
action(canvas);
skCanvas.Flush();
using (var skData = skBitmap.Encode(SKEncodedImageFormat.Png, 100))
{
var file = _outputPngFile;
using (var fileStream = file.OpenWrite())
{
fileStream.SetLength(0);
skData.SaveTo(fileStream);
}
}
以上代碼忽略細節邏輯,更多對接細節請看 dotnet 控制台 使用 Microsoft.Maui.Graphics 配合 Skia 進行繪圖入門
以上就完成了 Skia 的對接,接下來就交給 WPF 層,將 OpenXML 解析和 Skia 和 MAUI 對接一起
先對接 OpenXML 解析 PPT 圖表的邏輯。獲取測試文件,將測試文件傳入 ModelReader 構建出 AreaChartRender 用來繪製,如此即可完成 OpenXML 的對接
var file = new FileInfo("Test.pptx");
var modelReader = new ModelReader();
var areaChartRender = modelReader.BuildAreaChartRender(file);
接著定義輸出的本地圖片,創建 SkiaPngImageRenderCanvas 用來做畫布。這裡是隨便找一個文件用來輸出
var tempFile = Path.GetTempFileName();
var outputFile = new FileInfo(tempFile);
var skiaPngImageRenderCanvas = new SkiaPngImageRenderCanvas((int) Math.Ceiling(areaChartRender.Context.Width.Value), (int) Math.Ceiling(areaChartRender.Context.Height.Value), outputFile);
讓 AreaChartRender 使用 Skia 提供的畫布進行渲染,這就是關鍵的對接代碼
skiaPngImageRenderCanvas.Render(areaChartRender.Render);
如此即可將讓圖表繪製到 SkiaPngImageRenderCanvas 提供的 SkiaCanvas 對象上,最終使用 SKCanvas 保存到本地文件
最後一步就是在 WPF 裡面將保存的文件在界面顯示
var image = new Image()
{
Width = areaChartRender.Context.Width.Value,
Height = areaChartRender.Context.Height.Value,
Margin = new Thickness(10, 10, 10, 10),
Source = new BitmapImage(new Uri(outputFile.FullName))
};
Root.Children.Add(image);
以上的 Root 是一個放在 XAML 的 Grid 元素
<Grid x:Name="Root"></Grid>
這就是在 WPF 上對接的方法,所有的代碼如下
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
Loaded += MainWindow_Loaded;
}
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
var file = new FileInfo("Test.pptx");
var modelReader = new ModelReader();
var areaChartRender = modelReader.BuildAreaChartRender(file);
var tempFile = Path.GetTempFileName();
var outputFile = new FileInfo(tempFile);
var skiaPngImageRenderCanvas = new SkiaPngImageRenderCanvas((int) Math.Ceiling(areaChartRender.Context.Width.Value), (int) Math.Ceiling(areaChartRender.Context.Height.Value), outputFile);
skiaPngImageRenderCanvas.Render(areaChartRender.Render);
var image = new Image()
{
Width = areaChartRender.Context.Width.Value,
Height = areaChartRender.Context.Height.Value,
Margin = new Thickness(10, 10, 10, 10),
Source = new BitmapImage(new Uri(outputFile.FullName))
};
Root.Children.Add(image);
}
}
運行效果如下
可以看到在 Windows 下,通過 WPF 對接 MAUI 是十分簡單的
下麵開始對接 Linux 平臺的應用,在 Linux 平臺上使用 GtkSharp 框架做應用,依然使用 Skia 做 MAUI 的渲染層
在 Linux 平臺上的對接分為多個任務:
- 創建 GtkSharp 應用
- 將 Skia 與 GtkSharp 對接
- 將 Skia 與 MAUI 的對接
上文已經有了 Skia 和 MAUI 的對接邏輯的細節,接下來將跳過 Skia 與 MAUI 的對接部分的細節邏輯。本文接下來將重點放在如何創建 GtkSharp 應用以及將 Skia 與 GtkSharp 對接上
在開始 GtkSharp 應用的創建之前,需要先聊一點歷史。嗯,本考古學家要聊的不是上古的歷史了,只是聊聊現代的歷史。關於上古的 Gtk 的故事,還請自行查詢。回到歷史故事上,很久之前 mono 組織就創建了 https://github.com/mono/gtk-sharp 倉庫,此倉庫在 2020 之前還能勉力支持,但漸漸就跟不上 gtk 的發展了,只能支持到 gtk2 的版本。後來大佬們專門給 GtkSharp 創建了組織和倉庫,在 mono 組織的 gtk-sharp 的基礎上繼續維護,現在支持到了 gtk3 的版本,請看 https://github.com/GtkSharp/GtkSharp
本文創建的 GtkSharp 應用,就是使用 https://github.com/GtkSharp/GtkSharp 提供的支持
手動創建的方法是先創建一個 dotnet 6 的控制台應用,接著編輯 csproj 文件,修改為以下代碼,安裝 GtkSharp 和 SkiaSharp.Views.Gtk3 庫。如以下代碼可以瞭解到創建一個 GtkSharp 項目十分簡單,只需要安裝上支持 .NET Standard 2.0 及以上框架的 GtkSharp 庫即可
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<None Remove="**\*.glade" />
<EmbeddedResource Include="**\*.glade">
<LogicalName>%(Filename)%(Extension)</LogicalName>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<PackageReference Include="GtkSharp" Version="3.24.24.*" />
<PackageReference Include="SkiaSharp.Views.Gtk3" Version="2.88.0" />
<PackageReference Include="dotnetCampus.DocumentFormat.OpenXml.Flatten" Version="2.1.0" />
<PackageReference Include="dotnetCampus.OpenXmlUnitConverter" Version="2.1.0" />
<PackageReference Include="DocumentFormat.OpenXml" Version="2.17.1" />
<PackageReference Include="Microsoft.Maui.Graphics" Version="6.0.403" />
<PackageReference Include="Microsoft.Maui.Graphics.Skia" Version="6.0.403" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.0" />
</ItemGroup>
</Project>
其實 https://github.com/GtkSharp/GtkSharp 倉庫的細節還是做的很好的,除了以上手工創建的方法外,還可以通過 dotnet new
命令創建項目。以下是使用 dotnet new
命令創建項目的方法
第一步是安裝 dotnet new
模版,在控制台命令行輸入以下代碼即可進行安裝
dotnet new --install GtkSharp.Template.CSharp
安裝完成之後,即可使用如下命令創建項目,請將下麵命令的 MyApplication 替換為你的項目名
dotnet new gtkapp -o MyApplication
創建好了 GtkSharp 項目和安裝完成了必要的 NuGet 包之後,接下來是讓 Skia 和 GtkSharp 進行對接。在開始對接之前,需要說明的是,我推薦是在 Ubuntu 上構建和運行此項目,而不是在 Windows 上運行。儘管 GtkSharp 聲稱是支持 Windows 平臺的,而且 https://github.com/GtkSharp/GtkSharp 倉庫也做了很多輔助構建工作,但是實際在 Windows 平臺上的構建體驗還是比較鬧心的。為什麼這麼說?構建的第一步是需要將依賴下載了,依賴放在 https://github.com/GtkSharp/Dependencies 倉庫里,將依賴下載到 %LocalAppData%\Gtk\3.24.24\gtk.zip
文件。然而這是一個 50MB 左右的文件,在國內的垃圾網速下……
如果想要在 Windows 下構建,同時嫌棄拉 gtk-3.24.24.zip 的速度太慢,可以試試我上傳到 CSDN 下載的資源 https://download.csdn.net/download/lindexi_gd/86362889
如果構建成功,但是運行提示 System.DllNotFoundException: Gtk: libgtk-3-0.dll
失敗,請參閱 https://github.com/GtkSharp/GtkSharp/issues/337
回到讓 Skia 和 GtkSharp 進行對接的邏輯,編輯 MainWindow.glade 文件,替換為以下代碼
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.1 -->
<interface>
<requires lib="gtk+" version="3.18"/>
<object class="GtkWindow" id="MainWindow">
<property name="can_focus">False</property>
<property name="title" translatable="yes">SkiaSharp</property>
<property name="default_width">600</property>
<property name="default_height">600</property>
</object>
</interface>
這個文件就是 GTK 的界面描述,更多關於這個文件的知識,還請自行瞭解,這不是本文的重點。如果對 GtkSharp 不熟悉,不知道如何配置,推薦到本文最後獲取所有的代碼
編輯 MainWindow.cs 修改構造函數為以下代碼,以下代碼的含義是將一個 SKDrawingArea 對象作為視窗顯示的內容,這裡的 SKDrawingArea 對象里提供了 PaintSurface 事件,通過此事件即可獲取到 Skia 的畫布。在構造函數里,對接了 GtkSharp 和 Skia 的邏輯
public MainWindow()
: this(new Builder("MainWindow.glade"))
{
}
private MainWindow(Builder builder)
: base(builder.GetObject("MainWindow").Handle)
{
builder.Autoconnect(this);
DeleteEvent += OnWindowDeleteEvent;
var skiaView = new SKDrawingArea();
skiaView.PaintSurface += OnPaintSurface;
skiaView.Show();
Child = skiaView;
}
private void OnWindowDeleteEvent(object sender, DeleteEventArgs a)
{
Application.Quit();
}
private void OnPaintSurface(object sender, SKPaintSurfaceEventArgs e)
{
// 忽略代碼
}
在 OnPaintSurface
方法裡面就是 Skia 的渲染回調,有點和 WPF 的 OnRender 方法類似,在此函數里,通過 e.Surface.Canvas
繪製的內容,將會輸出到 GtkSharp 的視窗
根據上文的 WPF 對接 Skia 和 MAUI 的邏輯,可以瞭解到對接的方式是使用 Skia 的畫布創建 MAUI 的 SkiaCanvas 畫布,如以下代碼
// the the canvas and properties
var canvas = e.Surface.Canvas;
var skiaCanvas = new SkiaCanvas()
{
Canvas = canvas,
};
儘管推薦 OnPaintSurface 方法只處理繪製邏輯,不要在這個方法裡面寫業務邏輯,但為了方便理解,在本文的例子就在 OnPaintSurface 方法處理了 PPT 解析和圖表繪製邏輯。請不要在實際的項目上,在 PaintSurface 事件里,處理業務邏輯
解析 PPT 文件需要先獲取到測試文件,再使用上文的 ModelReader 創建出 AreaChartRender 對象,這些邏輯在各個平臺都是相同的
var file = new FileInfo(System.IO.Path.Combine(System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "Test.pptx"));
var modelReader = new ModelReader();
AreaChartRender areaChartRender = modelReader.BuildAreaChartRender(file);
再使用和上文一樣的對接 Skia 和 MAUI 的邏輯進行對接。對接方法依然是獲取到 skiaCanvas
對象,傳入到 AreaChartRender 繪製,這就是最關鍵的代碼
areaChartRender.Render(skiaCanvas);
可以看到,關鍵的代碼也只需要一句即可完成
這就是在 GtkSharp 上對接的方法,核心的代碼如下
private void OnPaintSurface(object sender, SKPaintSurfaceEventArgs e)
{
var file = new FileInfo(System.IO.Path.Combine(System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "Test.pptx"));
var modelReader = new ModelReader();
AreaChartRender areaChartRender = modelReader.BuildAreaChartRender(file);
// the the canvas and properties
var canvas = e.Surface.Canvas;
// make sure the canvas is blank
canvas.Clear(SKColors.White);
var skiaCanvas = new SkiaCanvas()
{
Canvas = canvas,
};
areaChartRender.Render(skiaCanvas);
}
運行的效果如下
這就是使用 MAUI 在 Windows 和 Linux 上解析和繪製 PPT 的圖表的例子,本文忽略了很多細節,更多細節請閱讀本文使用的代碼
整個 MAUI 是一個非常龐大和強大的框架,如此龐大的框架想要完全完成還是需要一些時間的。本文所用到的僅僅只是 MAUI 的渲染層,我將 MAUI 的渲染層拆開,即可放入到現有的應用裡面,也可以輸出到本地圖片文件。既支持 Windows 平臺,又支持 Linux 平臺。可以使用預設自帶的 MAUI 具體平臺實現,也可以自己基於介面,自己實現一套渲染進行對接
代碼
本文以上的測試文件和代碼放在github 和 gitee 歡迎訪問
可以通過如下方式獲取本文的源代碼,先創建一個空文件夾,接著使用命令行 cd 命令進入此空文件夾,在命令行裡面輸入以下代碼,即可獲取到本文的代碼
git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin c5477e93289a71c05787af4b1ab1dbb23f18b0e6
以上使用的是 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源
git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
獲取代碼之後,打開 Pptx.sln 文件,裡面的包含三個項目:
- PptxCore 是 PPT 解析和圖表繪製的項目,此項目可以在 Windows 和 Linux 平臺使用
- Pptx 是一個 WPF 項目
- PptxGtk 是一個 GtkSharp 項目
更多
更多關於 OpenXML 解析請看 Office 使用 OpenXML SDK 解析文檔博客目錄
更多關於 MAUI 請看 博客導航
博客園博客只做備份,博客發佈就不再更新,如果想看最新博客,請到 https://blog.lindexi.com/
本作品採用知識共用署名-非商業性使用-相同方式共用 4.0 國際許可協議進行許可。歡迎轉載、使用、重新發佈,但務必保留文章署名[林德熙](http://blog.csdn.net/lindexi_gd)(包含鏈接:http://blog.csdn.net/lindexi_gd ),不得用於商業目的,基於本文修改後的作品務必以相同的許可發佈。如有任何疑問,請與我[聯繫](mailto:[email protected])。