【C#】C#中使用GDAL3(二):Windows下讀寫Shape檔案及超詳細解決中文亂碼問題

泥頭發表於2021-07-28

  轉載請註明原文地址:https://www.cnblogs.com/litou/p/15035790.html

 

  本文為《C#中使用GDAL3》的第二篇,總目錄地址:https://www.cnblogs.com/litou/p/15004877.html

本目錄
一、介紹
二、讀寫資料內容
三、中文亂碼問題
3.1、資料路徑或資料檔名含中文時開啟失敗
3.2、讀取中文字串顯示亂碼
3.3、函式傳入中文字串引數報錯

 

  一、介紹

  Shape檔案是ESRI公司開發的一種空間資料開放格式,全稱是ESRI Shapefile,該檔案格式是由多個檔案組成的,表示同一資料的一組檔案的檔名必須相同。

  要組成一份Shapefile,有三個檔案是必不可少的,它們分別是shp、shx和dbf檔案。組成如下:

必須檔案 .shp 主檔案,記錄要素幾何實體
.shx 索引檔案,記錄每一個幾何體在shp檔案之中的位置
.dbf 資料檔案,以dBase IV的資料表格式儲存每個幾何形狀的屬性資料
可選檔案 .prj 投影檔案,儲存地理座標系統與投影資訊
.sbx .sbn 其他檔案

 

  二、讀寫資料內容

  GDAL庫內建支援讀寫ESRI Shapefile檔案,無需其他外掛支援。

  示例Shapefile檔案如下,存放在"C:\shp資料"下,圖層名稱為"測試面",型別為面,自定義欄位有"Id"、"名稱"和"大小",有兩條記錄。

  

  以VS2015為例,修改自上一篇《C#中使用GDAL3(一):Windows下超詳細編譯C#版GDAL3.3.0(VS2015+.NET 4+32位/64位)》中第九部分"C#呼叫測試"的Demo程式。

  由於Shapefile檔案屬於向量資料,所以只需註冊OGR驅動。

  1、開啟資料

  呼叫Ogr.Open開啟資料獲取DataSource。這裡有兩種開啟方法:

  1)開啟shp檔案,即Ogr.Open的第一個引數是shp檔案的路徑,開啟後得到的DataSource裡面只含shp檔案本身的一份資料。

  2)開啟shp檔案所在目錄,即Ogr.Open的第一個引數是shp檔案所在目錄的路徑,開啟後得到的DataSource裡面包含該目錄下所有shp檔案資料。

  另外,Open的第二個引數為開啟方式,值0表示以只讀方式開啟,值1表示以讀寫方式開啟。

  2、獲取圖層物件和圖層名稱

  呼叫DataSource.GetLayerByXXXXX獲取圖層物件,這裡呼叫的是GetLayerByIndex,再呼叫Layer.GetName獲取圖層名稱。

  3、獲取要素定義、欄位定義和欄位名稱

  呼叫Layer.GetLayerDefn獲取要素定義,然後呼叫FeatureDefn.GetFieldDefn獲取欄位定義,再呼叫FieldDefn.GetName獲取欄位名稱。

  4、遍歷要素記錄

  迴圈呼叫Layer.GetNextFeature獲取每一條要素記錄,直到獲取的要素記錄為null則迴圈結束。如需要重頭開始遍歷,需要呼叫Layer.ResetReading重置為開頭位置。

  5、讀取要素欄位值

  呼叫Feature.GetFieldAsXXXXX獲取要素欄位值,這裡呼叫的是GetFieldAsInteger、GetFieldAsString和GetFieldAsDouble的傳入欄位索引值的方法。

  6、設定要素欄位值

  呼叫Feature.SetField寫入要素欄位值。

  7、更新要素

  呼叫Layer.SetFeature使要素修改生效。

using OSGeo.OGR;
using System;

namespace GdalDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            Ogr.RegisterAll();

            ReadShapeFile();

            Console.ReadKey();
        }

        static void ReadShapeFile()
        {
            //開啟資料
            string path = @"C:\shp資料";
            DataSource ds = Ogr.Open(path, 1);  //以可寫方式開啟
            int lCount = ds.GetLayerCount();
            for (int i = 0; i < lCount; i++)
            {
                //讀取圖層資訊
                Layer layer = ds.GetLayerByIndex(i);
                string layerName = layer.GetName();
                Console.WriteLine(String.Format("圖層名:{0}", layerName));

                //讀取欄位資訊
                FeatureDefn featureDefn = layer.GetLayerDefn();
                int fCount = featureDefn.GetFieldCount();
                for (int j = 0; j < fCount; j++)
                {
                    FieldDefn fieldDefn = featureDefn.GetFieldDefn(j);
                    string fieldName = fieldDefn.GetName();
                    Console.WriteLine(String.Format("欄位名:{0}", fieldName));
                }

                //遍歷要素
                Feature feature;
                while ((feature = layer.GetNextFeature()) != null)
                {
                    //讀取要素資訊
                    int id = feature.GetFieldAsInteger(0);
                    Console.WriteLine(String.Format("欄位值-id:{0}", id));
                    string name = feature.GetFieldAsString(1);
                    Console.WriteLine(String.Format("欄位值-名稱:{0}", name));
                    double size = feature.GetFieldAsDouble(2);
                    Console.WriteLine(String.Format("欄位值-大小:{0}", size));

                    //設定要素資訊
                    feature.SetField(0, id + 1);
                    feature.SetField(1, name + "");
                    feature.SetField(2, size + 10.12);

                    //更新要素
                    layer.SetFeature(feature);

                    //讀取修改後要素資訊
                    Console.WriteLine(String.Format("欄位值-修改後-id:{0}", feature.GetFieldAsInteger(0)));
                    Console.WriteLine(String.Format("欄位值-修改後-名稱:{0}", feature.GetFieldAsString(1)));
                    Console.WriteLine(String.Format("欄位值-修改後-大小:{0}", feature.GetFieldAsDouble(2)));

                    //用欄位名讀取欄位值
                    Console.WriteLine(String.Format("欄位值-欄位名值-id:{0}", feature.GetFieldAsInteger("id")));
                    try
                    {
                        Console.WriteLine(String.Format("欄位值-欄位名值-名稱:{0}", feature.GetFieldAsString("名稱")));
                    }
                    catch { }
                }
            }
        }
    }
}

  執行結果如下:

  1)資料讀取正常

  2)中文圖層名稱和欄位名稱均顯示為亂碼

  3)讀取欄位值並顯示中文內容正常

  4)寫入中文內容到欄位正常

  5)使用中文欄位名獲取欄位值報錯

  

 

  三、中文亂碼問題

  要解決亂碼問題,首先要理解為什麼會出現亂碼。根據GDAL的文件資料顯示(https://gdal.org/development/rfc/rfc5_unicode.html),GDAL內部字串使用UTF8編碼,也就是說輸入和輸出的字串均為UTF8編碼,而我們使用的作業系統大部分都是簡體中文版的Windows,其預設的字串編碼是GB2312(可通過C#下的System.Text.Encoding.Default.EncodingName得到),如果不做編碼轉換直接顯示的話就會出現亂碼問題

  3.1、資料路徑或資料檔名含中文時開啟失敗

  該情況在GDAL 3.3.0的C#介面中是不存在的。以Ogr庫為例,在Ogr.cs中可以找到Open方法,其方法內通過Ogr.StringToUtf8Bytes函式處理,把傳入的路徑字串轉化為UTF8編碼的位元組陣列,再傳入內部的Open方法,所以在呼叫Ogr.Open方法時,無需對傳入的路徑字串進行編碼處理,也能正常使用。

  另外在GDAL內部,引數GDAL_FILENAME_IS_UTF8的預設值是YES,所以無需顯式重複設定為YES也能正常讀取,設定為NO反而導致讀取失敗。

//Ogr.cs
public static DataSource Open(string utf8_path, int update)
{
    IntPtr cPtr = OgrPINVOKE.Open(Ogr.StringToUtf8Bytes(utf8_path), update);
    DataSource ret = (cPtr == IntPtr.Zero) ? null : new DataSource(cPtr, true, ThisOwn_true());
    if (OgrPINVOKE.SWIGPendingException.Pending) throw OgrPINVOKE.SWIGPendingException.Retrieve();
    return ret;
}

internal static byte[] StringToUtf8Bytes(string str)
{
    if (str == null)
        return null;

    int bytecount = System.Text.Encoding.UTF8.GetMaxByteCount(str.Length);
    byte[] bytes = new byte[bytecount + 1];
    System.Text.Encoding.UTF8.GetBytes(str, 0, str.Length, bytes, 0);
    return bytes;
}

  3.2、讀取中文字串顯示亂碼

  同樣是讀取字串,讀取中文圖層名稱和欄位名稱顯示亂碼,而讀取中文欄位值則正常。

//Layer.cs
public string GetName()
{
    string ret = OgrPINVOKE.Layer_GetName(swigCPtr);
    if (OgrPINVOKE.SWIGPendingException.Pending) throw OgrPINVOKE.SWIGPendingException.Retrieve();
    return ret;
}

//FieldDefn.cs
public string GetName()
{
    string ret = OgrPINVOKE.FieldDefn_GetName(swigCPtr);
    if (OgrPINVOKE.SWIGPendingException.Pending) throw OgrPINVOKE.SWIGPendingException.Retrieve();
    return ret;
}

//Feature.cs
public string GetFieldAsString(int id)
{
    IntPtr cPtr = OgrPINVOKE.Feature_GetFieldAsString__SWIG_0(swigCPtr, id);
    string ret = Ogr.Utf8BytesToString(cPtr);

    if (OgrPINVOKE.SWIGPendingException.Pending) throw OgrPINVOKE.SWIGPendingException.Retrieve();
    return ret;
}

//Ogr.cs
internal unsafe static string Utf8BytesToString(IntPtr pNativeData)
{
    if (pNativeData == IntPtr.Zero)
        return null;

    byte* pStringUtf8 = (byte*)pNativeData;
    int len = 0;
    while (pStringUtf8[len] != 0) len++;
    return System.Text.Encoding.UTF8.GetString(pStringUtf8, len);
}

  對比GetName和GetFieldAsString兩個函式可以很明顯看出來,GetFieldAsString通過呼叫Ogr.Utf8BytesToString將返回的UTF8編碼的位元組陣列以UTF8方式解碼為字串,所以能夠正常顯示;而GetName則直接返回字串(實際上編譯器隱性呼叫了System.Text.Encoding.Default.GetString解碼為字串),由於沒有使用UTF8解碼導致顯示為亂碼。

  不完美處理方法1:在C#中將亂碼字串還原為位元組陣列並重新以UTF8方式解碼字串

  具體方法為,將亂碼的字串先通過System.Text.Encoding.Default.GetBytes轉換回亂碼狀態前的位元組陣列,再呼叫System.Text.Encoding.UTF8.GetString以UTF8的方式解碼為系統識別的字串。

  該方法處理偶數箇中文字元時可以正常還原,但處理奇數箇中文字元時最後一箇中文字元還原失敗。測試程式碼如下:

using System;
using System.Text;

namespace Demo
{
    class Program
    {
        static void Main(string[] args)
        {
            string sOdd = "測試";
            Console.WriteLine("原字串:" + sOdd);
            string sOddUtf8 = Encoding.Default.GetString(Encoding.UTF8.GetBytes(sOdd));
            Console.WriteLine("UTF8字串:" + sOddUtf8);
            string sOddURestore = Encoding.UTF8.GetString(Encoding.Default.GetBytes(sOddUtf8));
            Console.WriteLine("還原字串:" + sOddURestore);

            Console.WriteLine();

            string sEven = "測試面";
            Console.WriteLine("原字串:" + sEven);
            string sEvenUtf8 = Encoding.Default.GetString(Encoding.UTF8.GetBytes(sEven));
            Console.WriteLine("UTF8字串:" + sEvenUtf8);
            string sEvenURestore = Encoding.UTF8.GetString(Encoding.Default.GetBytes(sEvenUtf8));
            Console.WriteLine("還原字串:" + sEvenURestore);

            Console.ReadKey();
        }
    }
}

  結果如下,"測試"可以正常還原,而"測試面"最後一個字還原失敗。其原因是編碼轉換的問題,與平臺無關,具體可參考該文章(https://blog.csdn.net/yuwenruli/article/details/6911401)。

  

  要解決字串亂碼問題,只需要將原始UTF8編碼的位元組陣列正確的使用UTF8解碼即可。

  前面提到GDAL中返回亂碼字串的函式(如GetName)已經把UTF8編碼的位元組陣列返回為錯誤編碼的字串,且無法還原為完整的UTF8編碼的位元組陣列,只能從源頭開始處理。

  解決方法2:在GDAL的C#原始碼中修正返回亂碼字串的函式。

  以Layer.GetName為例,修改OgrPINVOKE.cs裡面SWIGStringHelper的CreateString函式說明,並增加UTF8編碼處理。(如沒有找到.cs原始碼檔案,執行一次nmake -f makefile.vc interface即可生成)

//OgrPINVOKE.cs
//修改前
protected class SWIGStringHelper
{
    public delegate string SWIGStringDelegate(string message);
    static SWIGStringDelegate stringDelegate = new SWIGStringDelegate(CreateString);

    [global::System.Runtime.InteropServices.DllImport("ogr_wrap", EntryPoint = "SWIGRegisterStringCallback_Ogr")]
    public extern static void SWIGRegisterStringCallback_Ogr(SWIGStringDelegate stringDelegate);

    static string CreateString(string cString)
    {
        return cString;
    }

    static SWIGStringHelper()
    {
        SWIGRegisterStringCallback_Ogr(stringDelegate);
    }
}

//修改後
protected class SWIGStringHelper
{
    public delegate string SWIGStringDelegate(IntPtr ptr);  //委託型別改為IntPtr
    static SWIGStringDelegate stringDelegate = new SWIGStringDelegate(CreateString);

    [global::System.Runtime.InteropServices.DllImport("ogr_wrap", EntryPoint = "SWIGRegisterStringCallback_Ogr")]
    public extern static void SWIGRegisterStringCallback_Ogr(SWIGStringDelegate stringDelegate);

    static string CreateString(IntPtr ptr)
    {
        return Ogr.Utf8BytesToString(ptr);  //返回UTF8解碼的字串
    }

    static SWIGStringHelper()
    {
        SWIGRegisterStringCallback_Ogr(stringDelegate);
    }
}

   修改完畢後,重新執行nmake -f makefile.vc和nmake -f makefile.vc install,將新生成的ogr_csharp.dll替換原來引入到C#專案中的檔案並重新執行,發現圖層名已經能夠正常顯示外,且欄位名也同樣正常顯示了。

   

  注:其他類庫也需要同樣修改,修改內容彙總如下:

OgrPINVOKE.cs -> Ogr.Utf8BytesToString
GdalPINVOKE.cs -> Gdal.Utf8BytesToString
OsrPINVOKE.cs -> Osr.Utf8BytesToString
GdalConst.cs 補充Utf8BytesToString函式
GdalConstPINVOKE.cs -> GdalConst.Utf8BytesToString

  修改原理可參考下圖:

  1)在Feature.GetFieldAsString方法的呼叫鏈中,用IntPtr表示C++返回的字元指標(橙色部分),然後將其用UTF8解碼為字串。

  2)在Layer.GetName方法的呼叫鏈中,C++將得到的字元指標回撥至C#端處理(橙色部分),處理後的字串回到C++中繼續流轉,最後返回到C#中。而回撥的C#部分直接把字元指標返回為字串,編譯器隱性呼叫了System.Text.Encoding.Default.GetString解碼為字串,故後面得到的字串都是解碼錯誤的。

  

  所以Layer.GetName解決亂碼的思路有兩種:

  1)在SWIGStringHelper.CreateString處用UTF8解碼字串,也就是本解決方法。且除Layer.GetName之外,其他返回字串的函式均呼叫了相同的回撥函式,故其他返回亂碼字串的問題也一併解決了(如FieldDefn.GetName等)。

  2)跳過ogr_wrap的所有包裝函式(包括C#回撥),直接呼叫gdal的函式獲取,因此引申出下面的解決方法。

  解決方法3:在C#中呼叫GDAL介面獲取內容。

  以Layer.GetName為例,在C#中增加呼叫gdal303.dll的OGR_L_GetName介面,並使用UTF8編碼處理。FieldDefn.GetName需要呼叫OGR_Fld_GetNameRef介面(介面名稱可查閱https://gdal.org/python)。

static string Utf8BytesToString(IntPtr ptr)
{
    if (ptr == IntPtr.Zero)
        return null;

    MemoryStream ms = new MemoryStream();
    byte b;
    int ofs = 0;
    while ((b = Marshal.ReadByte(ptr, ofs++)) != 0)
    {
        ms.WriteByte(b);
    }
    return Encoding.UTF8.GetString(ms.ToArray());
}

//Layer.GetName
[DllImport("gdal303.dll", EntryPoint = "OGR_L_GetName", CallingConvention = CallingConvention.Cdecl)]
static extern IntPtr OGR_L_GetName(HandleRef handle);
static string GetLayerName(Layer layer)
{
    HandleRef handle = Layer.getCPtr(layer);
    IntPtr ptr = OGR_L_GetName(handle);
    return Utf8BytesToString(ptr);
}

//FieldDefn.GetName
[DllImport("gdal303.dll", EntryPoint = "OGR_Fld_GetNameRef", CallingConvention = CallingConvention.Cdecl)]
static extern IntPtr OGR_Fld_GetNameRef(HandleRef handle);
static string GetFieldDfnName(FieldDefn fieldDefn)
{
    HandleRef handle = FieldDefn.getCPtr(fieldDefn);
    IntPtr ptr = OGR_Fld_GetNameRef(handle);
    return Utf8BytesToString(ptr);
}

  執行結果如下,圖層名和欄位名已經正常顯示。

  

  3.3、函式傳入中文字串引數報錯

  以Feature.GetFieldAsString(string field_name)為例,前面已通過列舉的方式列出所有欄位名稱且包含欄位名"名稱",但呼叫Feature.GetFieldAsString方法並傳入"名稱"作為引數時,卻報錯Invalid field name。

  參考其方法的呼叫鏈,C#中傳入的字串引數直接傳遞為C++的字元指標,編譯器隱性呼叫了System.Text.Encoding.Default.GetBytes將傳入的字串編碼為GB2312位元組陣列,故GDAL無法識別導致報錯。

  

  解決方法:把傳入的字串做編碼處理。

  根據上面的分析結果逆向處理,先把字串用UTF8編碼為位元組資料,再用Default編碼為字串,把結果傳入函式即可。

static string Utf8String(string s)
{
    if (!String.IsNullOrEmpty(s))
        return Encoding.Default.GetString(Encoding.UTF8.GetBytes(s));
    return s;
}

   執行結果如下,已經可以識別中文字串呼叫引數了。

  

相關文章