Quantcast
Channel: 黑暗執行緒
Viewing all 2311 articles
Browse latest View live

為 PDF、Office 檔案產生文字索引

$
0
0

遇到文件檔全文檢索需求,打算用 SQL Server 全文檢索或 lucent.net實現,無論使用何者都免不了從 Word、Excel、PowerPoint 或 PDF 檔萃取純文字內容建立索引的程序。經簡單評估,使用微軟的 IFilter 介面應是較簡單可行的做法。搜索引擎面對的檔案種類五花八門,不太可能涵蓋各種檔案格式,知道從中取出文字內容的方法,IFilter 制定統一程式介面,不管檔案格式為何,只要廠商或第三方有提供專屬 IFilter,搜尋引擎便可使用呼叫統一的 API 方法傳入檔案名稱取得文字內容,再為文字建立索引方便日後查詢。

專案面臨的檔案種類還算單純,只需涵蓋 Office 文件及 PDF 檔,而二者都有現成的 IFilter 可用:

  • PDF iFilter 64 下載
  • Office 2010 Filter Packs 下載
    包含 Legacy Office Filter (97-2003; .doc, .ppt, .xls)、Metro Office Filter (2007; .docx, .pptx, .xlsx) 、Zip Filter、
    OneNote filter、Visio Filter、Publisher Filter、Open Document Format Filter
    有 32/64 版本可選擇,由於 PDF iFilter 為 64,建議 Office Filter 也裝 64bit

簡單如何說明由 Registry 找出副檔名對應 IFilter 的原理。首先在 NTLM\SOFTWARE\Classes\.副檔名 可以找到 PersistentHandler 機碼,預設值指向一個 GUID:

在 NTLM\SOFTWARE\Classes\CLSID\{PersistentHandler GUID}\PersistentAddinsRegistered 可以找到名稱是 GUID 的機碼,預設值再指向另一個 GUID:

繼續在 CLSID 找尋該 GUID,InprocSever32 預設值即指向其 IFilter DLL: (下圖以 PDF iFilter 11 為例)

同理,我們也能找到 Office Filter 的實際位置:

上述的 Registry 大地遊戲過程有點繁瑣,加上爬文發現 PDF iFilter 有些眉角要克服,我找到網友寫好的懶人包元件(參考: Adobe PDF IFilter 11 - My Technical Diary),經實測只需幾行程式可通吃 Office/PDF,方便許多。

為了測試,我準備了doc, docx, ppt, pptx, xls, xlsx, pdf 七種檔案,內容都只有單純一行"XXXX測試"字樣。

程式如下:

        [STAThread] 
staticvoid Main(string[] args) 
        {
 
            List<string> names = 
"測試.pdf,測試.docx,測試.doc,測試.xlsx,測試.xls,測試.pptx,測試.ppt"
                .Split(',').ToList();
 
            names.ForEach(f => 
            { 
                Console.WriteLine($"[{f}]"); 
using (var reader = 
new EPocalipse.IFilter.FilterReader($"e:\\tests\\{f}")) 
                { 
string text = reader.ReadToEnd(); 
                    Console.WriteLine(text); 
                } 
            });
 
            Console.Read();
 
        } 

測試成功!


Oracle 自訂函式查詢加速密技–Scalar Subquery Caching

$
0
0

在 SELECT 指令對欄位執行自訂函式行運算通常很傷效能,但實務上無法完全避免。查詢一萬筆資料代表呼叫自訂函式一萬次,若函式包含資料表查詢,就如同在迴圈裡跑 SQL,是典型的效能殺手,經驗裡也是許多複雜查詢逾時的主因。

見識到同事露了一手,簡單加幾個字元一口氣將內含自訂函式的 Oracle SELECT 加速數十倍! 瞠目結舌之餘,立馬實驗證明效果驚人,特筆記並分享如下。

以下為實驗環境,JeffTest 資料表有 IDX, N 兩個 NUMBER 欄位,先塞入 256 筆資料,IDX 由 0 - 255,N 則是 IDX 除 4 的餘數,依序為 0, 1, 2, 3, 0, 1, 2, 3, ...。

我寫了一個無聊的自訂函式 FN_SQR 計算傳入數值的平方,為了讓效能差異明顯一點,還加了一個 dbms_lock.sleep(0.01) 延遲 0.01 秒,順便加上 dbms_output.put_line(n) 方便觀察執行次數:

createor replace function FN_SQR(n number) return number is
  FunctionResult number;
begin
  FunctionResult := n * n;
  dbms_lock.sleep(0.01);
  dbms_output.put_line(n);
return(FunctionResult);
end FN_SQR;

以下是兩個結果相同的查詢,第一個查詢是最直覺的寫法,SELECT 256 筆資料的 IDX 及 FN_SQR(N);第二個查詢動點手腳,將 FN_SQR(N) 改成子查詢 (SELECT FN_SQR(N) FROM DUAL),猜猜二者效能差多少?

2.655 秒 vs 0.145 秒! 快了近 20 倍,為什麼?

看一下 dbms_output.put_line() 的輸出結果就知道差異何在,SELECT IDX, FN_SQR(N) 呼叫了 FN_SQR() 256 次,而 SELECT IDX, (SELECT FN_SQR(N) FROM DUAL) 只執行了四次,0,1,2,3 各一次,參數相同的函式呼叫可使用 Cache 結果,不必重複執行。

這裡的效能提升來自 Scalar Subquery Caching– Oracle 會為 SELECT 出現的 Scalar Subquery (只傳回單一值的子查詢) 在記憶體建立一個 Hashtable,整理不同參數與查詢結果的對應表。若參數欄位值先前出現過,即可直接由 Hashtable 取值不用重新計算。在這個案例中,N 欄位只有 0,1,2,3 四種變化,故只有前四筆資料需要計算,之後的 252 筆全由 Hashtable 取值。

換句話說,使用 SELECT 自訂函數(參數欄位) FROM DUAL 技巧將自訂函式呼叫包成子查詢,就可享受 Oracle Scalar Subquery Caching 的加速效果,資料筆數愈多、參數欄位值重複性愈高、自訂函數運算愈耗時,就愈能感受到明顯的效能提升。使用 Oracle 的同學們別錯過這招加速技。

2017 根除小兒麻痺扶輪社公益路跑

$
0
0

避暑沈寂了大半年,下半年第一場馬拉松登場 - 2017 扶輪社根除小身麻痺公益路跑。(學到新單字 POLIO - 小兒麻痺,目前全球僅存阿富汗及巴基斯坦仍有病例,扶輪社長期致力於讓小兒麻痺從地球絕跡,並可望於今年提前達標)

跟觀音山馬一樣從微風運河出發,繞河畔一周近 4 公里再進入河濱。多雲無風,溫度 22 度左右,是慢跑的好天氣。

六點多,朝陽從雲間探出頭來,難得一睹日出美景~

跑淡水河左岸必經的關渡大橋,拍到有點膩了,但沒拍又怪怪的,所以來一張吧。

看到有人開著小船(如下圖)在河中央撒網捕魚,心中浮現兩個問題: 1) 捕到的魚要拿到市場賣嗎? 2) 在淡水河開船捕魚合法嗎? 需要執照嗎? 我可以弄艘船開著玩嗎?
解惑:
1) 近出海口污染較少且魚獲頗豐,多銷往台北市場(參考),關渡大橋上游因污染嚴重則禁捕(參考)
2) 的確有【動力小船駕駛執照】這種東西,坊間也有駕訓班
3) 河川捕魚行為屬漁業法管轄範圍,需申請

跑過淡水河左岸多次,但最北只跑到媽媽嘴,這回倒是踏上沒來過的八里渡船頭,碰上沙雕展,胡亂拍了些照片也算意外收獲。

往北一路都是沒見過的風景,渡船頭再過去有好些景觀餐廳,全馬半馬在此分道揚鑣,半馬折返,全馬繼續前進。

再往北跑到接近十三行博物館換全馬折返,但不是原地調頭迴轉,而要沿著林間小道繞一圈迴轉,此時有樹蔭有涼風,很讓人陶醉的一段路程。

從八里遠眺對岸,可以看到淡水福容飯店。

今年跑運不錯,賽事當天幾乎都是微雨到微陰的好天氣,雖然一度陽光露臉,但更多是陽光從雲隙穿出的壯觀耶穌光,哈里路亞~

折返通過會場還要向南跑到忠孝橋第二次折返。早上車行經過重陽橋到會場,現在又從會場跑回重陽橋,等下折返後要再從重陽橋跑回會場,想想自己都覺得好笑! XD

太久沒跑長距離,平日練跑最多只有 10K,一口氣加碼到 42 大小腿不意外都跳出來抗議,明明沒跑快也又僵又酸,腳趾還起了一個水泡,但為了不負好天氣,還是連滾帶爬跑進五小時,以 4:58:19 再下一馬。

說說心得: 不到千人(本場約650)的小比賽還是我的最愛,開跑前十分鐘有實體廁所上還不用排隊,水站滿滿的水、舒跑、維大力、香蕉、葡萄(超甜)、西瓜、小蕃茄、蘇打餅、小蛋糕... 我還喝到綠豆湯、仙草冰、檸檬汁。然後,在賽道旁看到這一幕,誰好意思隨手亂扔水杯? (PS: 我有自備水杯,更上層樓) 整場賽道十分乾淨整潔,為大會的用心跟跑友們的素質按個讚~

賽後便當是豪邁的滷雞腿,在會場看到廚餘回收、垃圾分類區,果然也很環保。

沈甸甸的黃金色完賽獎牌深得我心,色澤與份量頗有金磚的氣勢,而齒輪造型符合工程師調性又巧妙減少土豪感,I Love It,哈!

 

【茶包射手日記】Win7 + Chrome 才看得到的網頁特殊字元

$
0
0

使用者報案網頁多了一個像 L 的字元,在同事的電腦可重現,但在我的機器卻看不到。進一步測試,發現這個像 L 的字元在同事的 Windows 7 要用 Chrome 才會出現,用 IE 看不到;而在我的 Windows 10 上,不管用 Chrome、IE 還是 Firefox 都看不到。透過 F12 開發者工具鎖定可疑字元,複製貼上到中文編碼解析工具,發現原來是 ASCII 0x03 字元,有可能是網頁製作者從 Office 文件複製貼上被夾帶過來的。ASCII 0x03 這類控制碼字元,看不見是合理的,為何在 Win7 + Chrome 會現形則是個謎。

於是我寫了一個測試網頁,列舉從 ASCII 01-65("A") 的 65 個字元。

StringBuilder sb = new StringBuilder();
sb.AppendLine("<html><body><table>");
for (int i = 1; i < 65; i++)
{
char c = (char) i;
    sb.AppendLine($"<tr><td>{i}</td><td>{c}</td></tr>");
}
sb.AppendLine("</table></body></html>");
System.IO.File.WriteAllText("e:\\ASCII.html", sb.ToString());

用 Notepad++ 看會像這樣,那些 SOH、STX、ETX 就是 ASCII 32 以下的隱形控制字元。

用我的電腦實測,不管 Chrome、IE 還是 Firefox,都看不到空白字 (ASCII 32) 之前的控制字元。

改用 Windows 7 + Chrome 檢視,原本該隱形的字元都冒出來了。如下圖,ASCII 1-4 分別是方框的左上、右上、左下、右下角,問題網頁內容的 ASCII 3 是左下角,才被使用者誤認為 L。進一步使用 F12 開發者工具檢查,得知關鍵在於 ASCII 控制碼所在的 <TD> 使用了 Gulim 這個字型,Gulim 這個韓文字型有個特色,ASCII 控制碼被對應到可見的特殊符號,造成我們遇到的狀況。

我查不到關於 Chrome 此一行為的官方說明,由找到的文章,Gulim 似乎是 Windows 7 Chrome 針對 CJK(中日韓) 字型無對應時的 Fallback 字型。參考:

  • 最佳 Web 中文默認字體 - 問問題
    ... 在英文 Win7 下,只要 charset=“gbk”, 當 font-family 為 arial, sans-serif 時, 是 fallback 到瞭韓文字體 Dotum/Gulim(gulim.ttc)來顯示 ...
  • Chrome 3.0的字有夠醜! – 蘋果豬日記V4
    ... 把Arialuni殺掉之後,接下來fallback的順序依序是:Arial Unicode > Simsun > Gulim > MS Gothic > Mingliu
    分別是Unicode預設>簡體中文預設>韓文預設>日文預設>正體中文預設。...

由以上這些線索,大致可以推論「在 Win 7 Chrome 中,小於 ASCII 32 的控制字元會使用 Gulim 這個字型顯示而變成可見的符號。」

結案~

Notepad++ 7.5 取消預設安裝 Plugin Manager

$
0
0

在新安裝的 Notepad++找不到 Plugin Manager 可用,先前遇過安裝 64bit 版本有些 Plugin(插件) 無法使用,但確定我裝的是 32bit 版本沒錯,所以是哪邊出了問題? (什麼? 你沒聽過 Notepad++,快安裝它取代記事本 Notepad 吧! 好用豈止十倍? 而且還是台灣開發者的開放原始碼專案,舉世聞名獲獎無數,又一項台灣之光! 維基百科)

Release Note 明載預設安裝的插件只剩 NppExport、 Converter、Mime Tool,確實未包含 Plugin Manager。 再爬文得知是作者厭惡 Plugin Manager 夾帶廣告,自 2017 年 8 月 7.5 版起將它移除,並承諾儘快寫出官方版本取代它。

You may notice that Plugin Manager plugin has been removed from the official distribution. The reason is Plugin Manager contains the advertising in its dialog. I hate Ads in applications, and I ensure you that there was no, and there will never be Ads in Notepad++.

A built-in Plugin Manager is in progress, and I will do my best to ship it with Notepad++ ASAP.

由 Notepad++ 討論串追出故事大概是這樣:

nppPluginManager需要伺服器提供清單及插件檔下載,故有主機及頻寬的營運成本(總流量 127GB/月,若由免費 CDN 服務廠商 CloudFlare扛下大部分下載流量,仍需要約 1 CPU/2GB RAM/每月 2GB 流量的主機資源),Plugin Manager 作者因此在操作介面放上贊助廠商 Logo 廣告交換免費使用主機服務(如下圖所示,Why is this here? 連結可看原委)。但 Notepad++ 作者認為此舉違返了開源免費精神,斷然從內建安裝移除了 Plugin Manager,即使 nppPluginManager 作者事後找到免費贊助商願意取消廣告,但似乎已無轉圜空間。

目前的狀況,要安裝插作我們有兩種選擇:

第一種選擇是自己手動把 Plugin Manager 裝回來。到 Github 下載 nppPluginManager ZIP 檔,解壓縮後有兩個目錄,將 plugins 下的 PluginManager.dll 放到 C:\Program Files (x86)\Notepad++\plugins,將 updater 下的 gpup.exe 放在 C:\Program Files (x86)\Notepad++\,Plugin Manager 就回來了。

第二種選擇則是自己下載插件手動安裝。在 Notepad++ 官網有一份完整的 Plugin 清單,找到所需項目,連結到下載網頁取得檔案,再依各插件的安裝說明將所需檔案放到 C:\Program Files (x86)\Notepad++ 的 plugins 目錄及程式主目錄即可。

使用 Open XML SDK 在 Word 插入圖片

$
0
0

客戶提了需求,套表應用想在文件範本的特定位置插入圖片。花了點時間研究如何用 OpenXML SDK 實現,以下是我的筆記。

Word docx 其實是一個 ZIP 檔,文件主體是一份 XML。如果你有興趣研究,可以將 docx 更名成 zip 解壓縮(或在 docx 按右鍵選單直接用 7-Zip 解開),其中 word 資料夾有一個 document.xml,打開它會發現 Word 文件是由一堆 <w:p> 包 <w:r> 組成,其中 <w:p> 對應到 Open XML 中的 Paragraph,<w:r> 則對應到 Run

例如以下的 Test.docx:

解壓縮 Test.docx 後檢視 word\document.xml,可看到 Paragraph 文字內容被拆得很細,像「圖片插入位置 –> CAT」幾個字就被拆成 6 個 Run,字型顏色大小不同要拆成不同的 Run 無可厚非,但連中文、英文、符號也被獨立切開,甚至「圖片插入」與「位置」被分成兩個 Run。如何拆成 Run 對文件編輯者沒有任何影響,Word 的確可以全權做主,但使用 Open XML SDK 讀取時就得留心其中差異。

Open XML SDK 官方文件有一個完整的插入圖片範例: 如何: 將圖片插入文書處理文件 (開啟 XML SDK),照方煎藥就能在文件末端插入圖片。不過,我遇到的需求還需要調整圖檔大小及插入位置,便試著改寫較有彈性的版本。

我先將圖片內容及設定抽取成獨立類別 ImageData,偵測 附檔名決定 OpenXML ImagePartType(我只打算支援 JPG、PNG、GIF、BMP),由圖檔寬度高度 Pixel 數除以 DPI(預設300) 換算出以公分為單位的預設寬度與高度,但圖片寬高允許自由調整。Open XML 使用 EMU 作為長度單位,使用時公分要乘上 360000 轉成 EMU 以符合 OpenXML 要求。我選擇不直接插入圖片,而是透過一個 GenerateImageRun() 公用函式將圖片轉成 Run,開發者再視需要決定該插入到文件末端、特定位置或置換現有 Run。

publicclass ImageData
    {
publicstring FileName = string.Empty;
publicbyte[] BinaryData;
public Stream DataStream => new MemoryStream(BinaryData);
public ImagePartType ImageType
        {
            get
            {
                var ext = Path.GetExtension(FileName).TrimStart('.').ToLower();
switch (ext)
                {
case"jpg":
return ImagePartType.Jpeg;
case"png":
return ImagePartType.Png;
case"":
return ImagePartType.Gif;
case"bmp":
return ImagePartType.Bmp;
                }
thrownew ApplicationException($"Unsupported image type: {ext}");
            }
        }
publicint SourceWidth;
publicint SourceHeight;
publicdecimal Width;
publicdecimal Height;
publiclong WidthInEMU => Convert.ToInt64(Width * CM_TO_EMU);
publiclong HeightInEMU => Convert.ToInt64(Height * CM_TO_EMU);
privateconstdecimal INCH_TO_CM = 2.54M;
privateconstdecimal CM_TO_EMU = 360000M;       
publicstring ImageName;
public ImageData(string fileName, byte[] data, int dpi = 300)
        {
            FileName = fileName;
            BinaryData = data;
            Bitmap img = new Bitmap(new MemoryStream(data));
            SourceWidth = img.Width;
            SourceHeight = img.Height;
            Width = ((decimal)SourceWidth) / dpi * INCH_TO_CM;
            Height = ((decimal)SourceHeight) / dpi * INCH_TO_CM;
            ImageName = $"IMG_{Guid.NewGuid().ToString().Substring(0, 8)}";
        }
public ImageData(string fileName, int dpi = 300) : 
this(fileName, File.ReadAllBytes(fileName), dpi)
        {
        }
    }
publicclass DocxImgHelper
    {
publicstatic Run GenerateImageRun(WordprocessingDocument wordDoc, ImageData img)
        {
            MainDocumentPart mainPart = wordDoc.MainDocumentPart;
 
            ImagePart imagePart = mainPart.AddImagePart(ImagePartType.Jpeg);
            var relationshipId = mainPart.GetIdOfPart(imagePart);
            imagePart.FeedData(img.DataStream);
 
// Define the reference of the image.
            var element =
new Drawing(
new DW.Inline(
//Size of image, unit = EMU(English Metric Unit)
//1 cm = 360000 EMUs
new DW.Extent() { Cx = img.WidthInEMU, Cy = img.HeightInEMU },
new DW.EffectExtent()
                         {
                             LeftEdge = 0L,
                             TopEdge = 0L,
                             RightEdge = 0L,
                             BottomEdge = 0L
                         },
new DW.DocProperties()
                         {
                             Id = (UInt32Value)1U,
                             Name = img.ImageName
                         },
new DW.NonVisualGraphicFrameDrawingProperties(
new A.GraphicFrameLocks() { NoChangeAspect = true }),
new A.Graphic(
new A.GraphicData(
new PIC.Picture(
new PIC.NonVisualPictureProperties(
new PIC.NonVisualDrawingProperties()
                                         {
                                             Id = (UInt32Value)0U,
                                             Name = img.FileName
                                         },
new PIC.NonVisualPictureDrawingProperties()),
new PIC.BlipFill(
new A.Blip(
new A.BlipExtensionList(
new A.BlipExtension()
                                                 {
                                                     Uri =
"{28A0092B-C50C-407E-A947-70E740481C1C}"
                                                 })
                                         )
                                         {
                                             Embed = relationshipId,
                                             CompressionState =
                                             A.BlipCompressionValues.Print
                                         },
new A.Stretch(
new A.FillRectangle())),
new PIC.ShapeProperties(
new A.Transform2D(
new A.Offset() { X = 0L, Y = 0L },
new A.Extents() { 
                                                    Cx = img.WidthInEMU, Cy = img.HeightInEMU }),
new A.PresetGeometry(
new A.AdjustValueList()
                                         )
                                         { Preset = A.ShapeTypeValues.Rectangle }))
                             )
                             { Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" })
                     )
                     {
                         DistanceFromTop = (UInt32Value)0U,
                         DistanceFromBottom = (UInt32Value)0U,
                         DistanceFromLeft = (UInt32Value)0U,
                         DistanceFromRight = (UInt32Value)0U,
                         EditId = "50D07946"
                     });
returnnew Run(element);
        }
    }

有了公用函式,要插入圖片就簡單了,試試將圖片加在文件末端:

staticvoid Main(string[] args)
{
    var workFileName = $"Test-{DateTime.Now:HHmmss}.docx";
    File.Copy("Test.docx", workFileName);
using (WordprocessingDocument document =
        WordprocessingDocument.Open(workFileName, true))
    {
        var cat2Img = new ImageData("Cat2.png");
        var imgRun = DocxImgHelper.GenerateImageRun(document, cat2Img);
        document.MainDocumentPart.Document.Body.AppendChild(new Paragraph(imgRun));
    }
}

測試成功! 測試圖片尺寸為 300x300,因預設 300 DPI,300 Pixel 等於 1 吋,故變成 2.54cm x 2.54cm 的圖片,置於文件最後一段 Paragraph。

接著再來測試置換現有內容。用 Document.Body.Descendants() 取回 document.xml 所有 XML 節點,如果我們確定 CAT 文字被包在單一 <w:r> 中(小訣竅: 使用純英文並套用不同字型可確保該段文字自成一個 Run),用 LINQ .Single(o => o.Local == "r" && o.InnerText == "CAT") 可找到 CAT 所在的 Run,接著將其 InnerXml 換成圖片的 InnerXml,CAT 文字就變成圖檔囉~ (本例順便示範改變圖片寬高為 1cm x 1cm)

staticvoid Main(string[] args)
{
    var workFileName = $"Test-{DateTime.Now:HHmmss}.docx";
    File.Copy("Test.docx", workFileName);
using (WordprocessingDocument document =
        WordprocessingDocument.Open(workFileName, true))
    {
        var cat1Img = new ImageData("Cat1.gif")
        {
            Width = 1,
            Height = 1
        };
        var imgRun = DocxImgHelper.GenerateImageRun(document, cat1Img);
//找到 CAT 所在的 Run
        var runCAT = document.MainDocumentPart.Document.Body.Descendants()
            .Single(o => o.LocalName == "r"&& o.InnerText == "CAT");
 
//將 InnerXML 置換成圖片 Run 的 InnerXML
        runCAT.InnerXml = imgRun.InnerXml;
    }
}

測試成功,YA!

MemoryStream 不可擴展錯誤

$
0
0

記錄自己遇到的蠢問題一枚。

抽象類別 Stream 常被當成輸入輸出參數 ,如此資料可以來自檔案、網路、記憶體或使用者自訂來源,還可套用裝飾者模式(Decorator Pattern)壓縮加密一次完成,提供強大彈性。實務上我常應用的情境是 ClosedXML/OpenXML SDK 之類原本要讀寫 Excel、Word 檔案的場合,函式接受檔案路徑或 Stream 以開啟現有檔案。遇到檔案保存於資料庫,取出的資料格式為 byte[],沒必要寫成暫存檔再開啟,我習慣用 new MemoryStream(byte[] data) 建立 MemoryStream,使用起來跟 FileStream 沒有兩樣。過去這個技巧我主要用在 ClosedXML 讀取 Excel 檔案,最近搬到 OpenXML SDK 操作修改 Word 內容,才發現這個程式寫法有問題。

如下圖所示:

使用 new MemoryStream(byte[]) 建立的 Stream 可正確讀取 Word 文件檔,但在試圖新增內容時遇上「記憶體資料流是不可擴展的 / MemoryStream is not expandable」錯誤。

爬文發現我在這裡犯了一個低級錯誤,依據 MSDN 文件:

MemoryStream Constructor (Byte[])

Initializes a new non-resizable instance of the MemoryStream class based on the specified byte array.

傳入 byte[] 建構的 MemoryStream 大小是固定的,用於唯讀沒什麼問題,甚至只置換少量內容資料空間未變時也還過得去,但遇上要需要擴充大小就必死無疑。

var ms = new MemoryStream();
ms.Write(bin, 0, bin.Length);

搞清楚問題根源,要修好不是難事! 如以上範例,先用 new MemoryStream() 建構可擴展的 MemoryStream,再用 .Write() 寫入 byte[] 內容,搞定收工!

全文檢索筆記 - Lucene.Net (1)

$
0
0

網站專案的規格提到了網站內容的全文檢索,不要求比美 Google 的速度與精準度,提供最基本的關鍵字查詢就成。陸續評估了一些解決方案,整理成筆記備忘兼分享。

談到在 .NET 做全文檢索,不能不提 Lucene.Net這個開源全文檢索引擎!

如果你對 Lucene.Net 很陌生,推薦 CSDN 有篇不錯的入門指引: 使用Lucene.Net实现全文检索

剛開始接觸 Lucene.Net 被一堆術語搞得昏頭轉向,尤其是建立索引欄位時,參數裡有一堆 ANALYZE、NORMS、POSITION、OFFSETS,搞得我一頭霧水。以下整理使用 Lucene 程式庫時可能用到的一些術語:

  • Document 文件
    文件是一堆欄位( Field )的集合,也搜索結果項目的基本單位,一般會對應到實際的文件、檔案、網頁... 等。
  • Filed 欄位
    可分為四類: 參考
    1. Keyword – 不分析但要建立索引,例如: URL、檔案路徑、人名、身分證號、電話號碼… 等。
    2. Unindexed – 不分析也不建索引,呈現結果時一併顯示,但不作為查詢條件。
    3. UnStored –分析並建索引但不保存,可當成查詢條件,但不需出現在查詢結果,例如: 文件內容。
    4. Text - 分析、建索引且保存完整內容可顯示於查詢結果。
  • Term 詞彙
    搜尋條件的基本單位,包含欄位名稱及關鍵字內容。分詞器解析文字內容拆成一個個 Term,搜索時透過 Term 比對才能快速找到目標,分析出的 Term 會儲存於索引檔方便日後查詢。
  • Analyzer 分析器/分詞器
    分詞器負責將文字內容拆解成一個個 Term 以建立索引,搜索時以 Term 為對象遠比 LIKE '%...%' 有效率(Index Seek vs Index Scan)。分詞過程是先將文字內容單元化(Tokenization),提取出 Token 後結合欄位名稱形成 Term。參考
  • 常見中文分詞器
    WhitespaceAnalyzer只去除空白,不支援中文
    SimpleAnalyzer去除字母以外的符號,不支援中文
    StopAnalyzer比 SimpleAnalyzer 再多去除 the, a, this 等虛詞,不支援中文。
    StandardAnalyzer英文部分比照 StopAnalyzer,但支援中文單字切割,每個中文字元一個 Term。
    CJKAnalyzer支援中日韓文字,但中文支援不理想。
    SmartChineseAnalyzer比較標準的中文分詞,但效果也不是很好。
    PanGuAnalyzer盤古分詞器,專為中文研發,有字典檔提高準確度。可用 NuGet 安裝,是蠻流行的中文分詞器。參考1 參考2
    MMSeg也可用 NuGet 直接安裝,以演算法為核心,快速且簡單,但準確率不如盤古。 
    IKAnalyzer中國研發,頗熱門且持續在發展的分詞器,效果看起來很少,但 .NET 版本難尋(Github 有一些棄守的 .NET 版半成品,大陸網站找到的下載連結多已失效,唯一找到的 DLL 版來自 2008 年相容性存疑,放棄未試)
  • Boost 是什麼?
    簡單地說,Boost 是賦與欄位不同權重讓查詢結果更符合預期的機制,可想像成 Google 搜索關鍵字時決定那一筆優先顯示的邏輯,Lucene 透過 Boost 決定查詢結果符合度。
    Boost 可以當名詞用,例如 Document Boost、Field Boost、Query Boost,是一個浮點數,可用於查詢結果排序。預設值為1.0,再乘上多個因子得到最後結果,數字愈大代表愈相關愈重要。Lucence 查詢過程包含布林符合比對以及排序分數計算,Boost 用於排序比分階段。
    Boost 當動詞用時意指增加某個項目的 Boost 值,例如 Boost 某個 Field 代表增加該欄位的 Boost 值,Boost 某個 Documentd 代表增加該文件的 Boost 值。
    當我們對預設的查詢結果不滿意,便可透過 Boost 某個欄位或文件讓結果符合預期。
    例如: 我們可以將標題欄位的權重設定比內文欄位高,如此,標題包含關鍵字與內文包含關鍵字相比,標題有關鍵字應視為更符合查詢條件,我們可以 Boost 標題欄位,使其優先顯示在查詢結果中。Field.SetBoost()可設定Boost值。
    Boost 分為 Index Time Boost 及Query Time Boost。Index Time Boost 指在建立索引時就計算好欄位或文件的 Boost 值。Query Time Boost 則是查詢時賦與搜尋條件不同的 Boost 值以影響顯示結果。 參考
  • Norms 是什麼?
    Norms 是 Index Time Boost 保存 Boost 因子的方法,在建立索引階段產生 Boost 數字儲存下來。Norms 的計算依據是欄位內容長短,符合條件且字數愈少的結果愈優先。
    建立索引時啟用 Norms 會耗用一些運算並佔用每個欄位 1 Byte 的空間,如不需要可停用它以省效能。參考

要了解上述術語的原因是我們在建立 Field 時有一堆參數要指定,初上手常會搞不清楚該怎麼設定,即使讀了參數說明文件若不懂 NORMS、POSITION、OFFSET 等術語還是不知如何下手,以下做個簡單整理:
public Field(string name, string value, Field.Store store, Field.Index index, Field.TermVector termVector);

  • Field.Store
    • YES 或 NO。
      當文字內容很長且不需顯示在查詢結果中,可選擇不存文字本體只留索引以節省空間。
  • Field.Index
    • ANALYZED - 建立索引並分詞(適用內文、標題、摘要等)
    • NOT_ANALYZED - 索引但不分詞,使用 NORMS
    • ANALYZED_NO_NORMS - 索引並分詞,不使用NORMS
    • NOT_ANALYZED_NO_NORMS - 索引不分詞且不用NORMS
    • NOT - 不索引
  • Field.TermVector 參考 Position 與 Offset 差異
    • YES - 統計詞彙(Term)出現頻率
    • WITH_POSITIONS - 統計頻率,記錄出現位置(以詞彙為單位)
    • WITH_OFFSETS - 統計頻率,記錄起迄字元位置
    • WITH_POSITIONS_OFFSETS - 統計頻率,記錄出現位置及起迄字元位置
    • NO - 完全不統計

文章一開始提到的介紹文針對不同應用整理了簡單的欄位參數建議:

結論

Lucene.Net 提供很高的擴充空間與客製彈性,嚴格來說一個框架而非完整解決方案,開發者可以自行調整權重、索引邏輯,滿足各式刁鑽需求。由其運作原理,中文文件的分詞將是搜尋結果符合預期與否的關鍵,盤古分詞器很簡便易用,但既有的分詞及查詢結果離一般對中文檢索期待仍有距離,評估要投入不少的時間優化改善,值不值投資見仁見智,下篇文章再進一步闡述。


全文檢索筆記 - Lucene.Net (2) 盤古分詞

$
0
0

前一篇筆記談完 Lucene.Net 術語與基本觀念,感覺用盤古中文分詞器是不錯的主意。先來個最簡單的「盤古中文分詞->建立索引->查詢關鍵字」 Lucene.Net 範例:

privatestaticstring IndexPath = "E:\\LuceneIndex";
publicstaticvoid SimpleDemo()
{
//指定索引資料儲存目錄
    var fsDir = FSDirectory.Open(IndexPath);
 
//建立IndexWriter
using (var idxWriter = new IndexWriter(
        fsDir, //儲存目錄
new PanGuAnalyzer(), //使用盤古分詞器
true, //清除原有索引,重新建立
        IndexWriter.MaxFieldLength.UNLIMITED //不限定欄位內容長度
        ))
 
    {
//示範為兩份文件建立索引
        var doc = new Document();
//每份文件有兩個Field: Source、Word
        doc.Add(new Field("Source", "阿甘正傳", Field.Store.YES, Field.Index.ANALYZED));
        doc.Add(new Field("Word", "人生就像一盒巧克力,你永遠也不會知道你將拿到什麼。", 
            Field.Store.YES, Field.Index.ANALYZED));
        idxWriter.AddDocument(doc);
 
        doc = new Document();
        doc.Add(new Field("Source", "Spider Man", Field.Store.YES, Field.Index.ANALYZED));
        doc.Add(new Field("Word", "Remember, with great power, comes great responsibility.", 
            Field.Store.YES,
            Field.Index.ANALYZED));
        idxWriter.AddDocument(doc);
 
//建立索引
        idxWriter.Commit();
        idxWriter.Optimize();
    }
 
//查詢示範
 
//若不需刪除文件或修改Norms,第二個參數傳入true採唯讀方式效能較好
    var searcher = new IndexSearcher(fsDir, true); 
//指定欄位名傳入參數
    QueryParser qp = new QueryParser(Version.LUCENE_30, "Word", new PanGuAnalyzer());
    Query q = qp.Parse("巧克力");
    var hits = searcher.Search(q, 10); //查詢前10筆
    Debug.WriteLine($"找到{hits.TotalHits}筆");
foreach (var doc in hits.ScoreDocs)
    {
        Debug.WriteLine($"{searcher.Doc(doc.Doc).Get("Word")}");
    }
}

使用盤古分詞器建立索引,試著查詢「巧克力」,不孚眾望,真的找到了!

不過再多試幾下,就被澆了冷水。改查詢"永遠"... 登楞! 找不到。

分詞搜尋跟傳統印象中 Word/Excel/Notepad 尋找不太相同。分詞器會將整段文字分成一個個 Term,"永"、"遠"、跟 "永遠" 是不同的東西,使用 Luke.Net觀察建立的索引,盤古分詞的真實分詞結果如下。永遠被拆成了永跟遠,而查詢「永遠」PanGuAnalyzer 會判定沒有相符合內容。

用盤古分詞再多做了一些測試:

有些詞被拆成單字或斷錯位置,預期如果直接查詢"永遠"、"什麼"、"不會"、"馬蹄"、"收拾"、"賤人"、"百分之九十九"等詞將不會得到符合的結果:

生命/就/像/一盒/巧克力/你/永/遠/也不/會/知道/你/將/拿到/什/麼
我/達/達/的/馬/蹄/是美麗/的/錯/誤/我不是/歸/人/是個過客
天才/是/百分之一/的/靈/感/加上/百分之/九十九/的/汗水
賤/人/就是/矯/情
再/冷/也不/能拿別人/的/血/暖/自己
我/對/你/的/敬仰/真是/如/滔滔/江水/連綿不絕/又/有如/黃河泛濫一發/不可收拾
對/對/本/為/消遣/作/樂/今日/穿腸兄/居然/對到嘔出幾十兩血/謂/空前/絕/後/小弟/佩服/佩服
未/傳/你/你/就站出來/要不是/做賊心虛/就是/身上/有/屎/你/說/你/是不是/犯/賤

由此可知,中文分詞器決定"查詢效率與準確性",愈是精準將文字解析成單字,索引檔愈小,愈能快速查到正確結果。英文有空白,很容易精確切割詞與詞,將沒有標點的連續中文正確切成詞彙明顯難上許多。字典檔是找出詞彙的捷徑,但仍存在白痴造句法陷阱,例如: 這書本來就不是給小孩、啤酒不如果汁好喝。由於難以 100% 掌握字句原意,有些分詞器會透過針對同一段文字列舉不同組合提高命中率(多元分詞,或稱為最細粒度分詞),例如: 我是程式設計師,拆解成: 我/是/程式/程式設計/設計師/程式設計師。另一個思考方向是乾脆將文字拆成單字或較小的詞彙,例如: 我/是/程/式/設/計/師,查詢「程式設計」相當於找尋同時出現 程/式/設/計 四個詞彙,但如此查詢效能勢必要打折扣。

我找到一個替代做法在這個盤古分詞範例中查到「永遠」- 用 PhraseQuery 拼裝多個字元:

不過,用這招查詢 "永" "遠" "也" 會失敗,原因是分詞結果中的 Term 是 "也不","也" 比對不符! 除非字典檔夠完整,能讓盤古分詞產生更理想的結果,遇到分詞不正確或被拆成單詞都會導致查不到預期結果。(也可能我錯過什麼簡便做法,懇請十方大德賜教)

在盤古分詞器踩到一些坑之後,我回頭改用 Lucene.Net 內附的 StandardAnalyzer,結果好多了! 只要文字相連,就可以查到,不管關鍵字是否為有意義(例如: 「到什」),其邏輯接近 LIKE '%關鍵字%',但預期搜尋效能不如字彙分詞。至於跳幾個字組裝出的「永不知」及順序顛倒的「力巧克」則如預期沒有吻合項目。

經過以上簡單測試,若不考慮效能跟索引空間,看起來 StandardAnalyzer 比盤古分詞簡單可靠,滿足最基本的全文檢索要求,算是已立於不敗之地,確認用 Lucene.Net 不致開天窗。至於中文分詞器的運作細節,就留待下篇筆記再來探討。

全文檢索筆記 - Lucene.Net (3) 自動分詞 vs 詞庫分詞

$
0
0

前篇筆記試用了盤古分詞器跟 StadnardAnalyzer,繼續研究其他分詞器選擇。

英文能依據空白快速精準分詞,中文沒這麼幸運,必須借助演算法,邏輯複雜許多。中文分詞主要有兩個方向: 第一種是自動分詞,依循固定規則自動切分,例如: 一元分詞、二元分詞;第二種則是詞庫分詞,查詢詞庫識找出已知詞彙;也有分詞器選擇兩種做法兼用,以求互補。

一元分詞與二元分詞的優點是做法簡單,不需維護詞庫,但其索引幾乎跟原文一樣大,查詢效率也較差;詞庫分詞的索引可縮小到原文的 30%(參考),但詞庫完整性是成敗關鍵,需要持續訓練(甚至要考慮借助機器學習、人工智慧)提高精準度,要投注的心力不容小覷。

大陸對 Lucene 中文分詞的研究較多,我找到一篇 Lucene中文分析器的中文分词准确性和性能比较實測多種分詞器,看實例比較容易搞清楚什麼是一元分詞、二元分詞與詞庫分詞:

原文

2008年8月8日晚,举世瞩目的北京第二十九届奥林匹克运动会开幕式在国家体育场隆重举行。

StandardAnalyzer 一元分詞

2008/年/8/月/8/日/晚/举/世/瞩/目/的/北/京/第/二/十/九/届/奥/林/匹/克/运/动/会/开/幕/式/在/国/家/体/育/场/隆/重/举/行/

ChineseAnalyzer 一元分詞但去除英文字

年/月/日/晚/举/世/瞩/目/的/北/京/第/二/十/九/届/奥/林/匹/克/运/动/会/开/幕/式/在/国/家/体/育/场/隆/重/举/行/

CJKAnalyzer 二元分詞,不管合不合理,兩兩組合就算一個詞

2008/年/8/月/8/日晚/举世/世瞩/瞩目/目的/的北/北京/京第/第二/二十/十九/九届/届奥/奥林/林匹/匹克/克运/运动/动会/会开/开幕/幕式/式在/在国/国家/家体/体育/育场/场隆/隆重/重举/举行/

PaodingAnalyzer 庖丁分詞器,細粒度全切,字典查不到就二元分詞

2008/年/8/月/8/日/晚/举世/瞩目/举世瞩目/目的/北京/二/第二/十/二十/第二十/九/十九/二十九/九届/奥林/奥林匹克/运动/运动会/奥林匹克运动会/开幕/开幕式/国家/体育/体育场/隆重/举行/隆重举行/

IK_CAnalyzer 細粒度全切,字典查不到的二元分詞

2008年/2008/年/8月/8/月/8日/8/晚/举世瞩目/举世/瞩目/目的/北京/第二十九届/第二十九/第二十/第二/二十九/二十/十九/九届/九/奥林匹克运动会/奥林匹克/奥林/运动会/运动/开幕式/开幕/在国/国家/国/体育场/体育/隆重举行/隆重/举行/行/

MIK_CAnalyzer 最大匹配與細粒度全切搭配

2008年/8月/8日/晚/举世瞩目/目的/北京/第二十九届/奥林匹克运动会/开幕式/在国/国家/体育场/隆重举行/

MMAnalyzer 字典查不到時一元分詞

2008/年/8/月/8/日/晚/举世瞩目/北京/第二十/九届/奥林匹克运动会/开幕式/国家/体育场/隆重举行/

中文分詞是門深奧學問,有膨脹率(原文跟索引大小比率)、準確率、召回率、F值、消歧義... 等等議題值得探討,但我的目標是尋找現成全文檢索解決方案,一頭裁進去還要不要驗收? 但對於有興趣深入了解的同學,附上我找到的幾篇文章:

回到中文分詞器選擇上,各家中文分詞器都有自己的設計哲學,除了考量命中率,也必須考慮建立索引耗用資源及產生的詞彙數,詞彙數愈多命中率上升,但要付出索引檔變大及查詢效能下降的代價。庖丁分詞、IKAnalyzer、MMAnalyzer 可靠字典檔找出詞彙,找不到的部分則用二元或一元分詞,避免如盤古分詞拆錯就回天乏術的缺點。但使用 Lucene.Net 要有心理準備,有些好用的中文分詞器只有 Java 版,未移植到 .NET,看得到不一定吃得到,Lucene.Net 可用的選項沒那麼多。

所以我們再把焦點要放在 Lucene.Net 現成可用中文分詞器的比較上。

除了盤古分詞跟 StandardAnalyzer,我還找到兩個 NuGet 可下載安裝的中文分詞器:

MMSeg 是不少人推崇的演算法,簡單、快速、有效。而 CWSharp 將詞庫分詞、一元分詞、二元分詞一網打盡,基本上有了這兩個分詞器已囊括本次評估的主要分詞演算法。經過一番摸索,我成功使用 MMSegAnalyzer、SimpleAnalyzer、ComplexAnalyzer、CWSharp 詞庫、CWSharp 一元分詞、CWSharp 二元分詞完成索引及查詢,測試範例如下。

class Program
    {
staticvoid Main(string[] args)
        {
            AnalyzerTest("盤古分詞", "D:\\PanGuIndex", new PanGuAnalyzer());
            AnalyzerTest("標準分詞", "D:\\StdAnalyzerIndex", 
new StandardAnalyzer(Version.LUCENE_30));
            AnalyzerTest("MMSeg MaxWord", "D:\\MMSegIndex", new MMSegAnalyzer());
            AnalyzerTest("MMSeg Simple", "D:\\MMSegSimpIndex",
new Lucene.Net.Analysis.MMSeg.SimpleAnalyzer());
            AnalyzerTest("MMSeg Complex", "D:\\MMSegCompIndex",
new Lucene.Net.Analysis.MMSeg.ComplexAnalyzer());
            AnalyzerTest("CWSharp詞庫分詞", "D:\\CWStdIndex", 
new CwsAnalyzer(
new Yamool.CWSharp.StandardTokenizer(
new FileStream("cwsharp.dawg", FileMode.Open))));
            AnalyzerTest("CWSharp一元分詞", "D:\\CWUniIndex", 
new CwsAnalyzer(new UnigramTokenizer()));
            AnalyzerTest("CWSharp二元分詞", "D:\\CWBiIndex", 
new CwsAnalyzer(new BigramTokenizer()));
            Console.Read();
        }
 
publicstaticvoid AnalyzerTest(string title, 
string indexPath, Analyzer analyzer)
        {
//指定索引資料儲存目錄
            var fsDir = FSDirectory.Open(indexPath);
 
//建立IndexWriter
using (var idxWriter = new IndexWriter(
                fsDir, //儲存目錄
                analyzer, 
true, //清除原有索引,重新建立
                IndexWriter.MaxFieldLength.UNLIMITED //不限定欄位內容長度
            ))
 
            {
//示範為兩份文件建立索引
                var doc = new Document();
//每份文件有兩個Field: Source、Word
                doc.Add(new Field("Word", 
"生活就像一盒巧克力,你永遠也不會知道你將拿到什麼。",
                    Field.Store.YES, Field.Index.ANALYZED));
                idxWriter.AddDocument(doc);
 
//建立索引
                idxWriter.Commit();
                idxWriter.Optimize();
            }
 
 
            var searcher = new IndexSearcher(fsDir, true);
//指定欄位名傳入參數
            QueryParser qp = new QueryParser(Version.LUCENE_30, 
"Word", analyzer);
 
            Action<string> testQuery = (kwd) =>
            {
                var q = qp.Parse(kwd);
                var hits = searcher.Search(q, 10);
                Console.WriteLine($"查詢「{kwd}」找到{hits.TotalHits}筆");
            };
            Console.WriteLine($"{title}測試");
            testQuery("生活");
            testQuery("就像");
            testQuery("一盒");
            testQuery("巧克力");
            testQuery("永遠");
            testQuery("不會知道");
            testQuery("拿到");
            testQuery("什麼");
            Console.WriteLine("========================================");
        }
    }

使用內建詞庫或範例詞庫,實測結果如下:

盤古分詞測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到0筆
查詢「不會知道」找到0筆

查詢「拿到」找到1筆
查詢「什麼」找到0筆
========================================
標準分詞測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到1筆
查詢「不會知道」找到1筆
查詢「拿到」找到1筆
查詢「什麼」找到1筆
========================================
chars loaded time=89986ms,line=12638,on file=C:\Lab\LuceneTest\bin\Debug\data\chars.dic
words loaded time=2465010ms,line=149852,on file=words.dic
load all dic user time=2980004ms
unit loaded time=0ms,line=22,on file=C:\Lab\LuceneTest\bin\Debug
MMSeg MaxWord測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到1筆
查詢「不會知道」找到0筆
查詢「拿到」找到1筆
查詢「什麼」找到1筆
========================================
MMSeg Simple測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到1筆
查詢「不會知道」找到0筆
查詢「拿到」找到1筆
查詢「什麼」找到1筆
========================================
MMSeg Complex測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到1筆
查詢「不會知道」找到0筆
查詢「拿到」找到1筆
查詢「什麼」找到1筆
========================================
CWSharp詞庫分詞測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到1筆
查詢「不會知道」找到1筆
查詢「拿到」找到1筆
查詢「什麼」找到1筆
========================================
CWSharp一元分詞測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到1筆
查詢「不會知道」找到1筆
查詢「拿到」找到1筆
查詢「什麼」找到1筆
========================================
CWSharp二元分詞測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到1筆
查詢「不會知道」找到1筆
查詢「拿到」找到1筆
查詢「什麼」找到1筆
========================================

以「生活就像一盒巧克力,你永遠也不會知道你將拿到什麼。」為例,分別查詢「生活」、「就像」、「一盒」、「巧克力」、「永遠」、「不會知道」、「拿到」、「什麼」。其中盤古分詞最不理想,找不到「永遠」、「不會知道」、「什麼」;MMSeg 的三種實作只錯過「不會知道」;CWSharp 跟內建的 StandardAnalyzer 則全部都查得到。

單以查詢結果涵蓋度,CWSharp 跟 StandardAnalyzer 全過,MMSeg 錯失不算標準常用詞彙的「不會知道」稍稍遜色。StandardAnalyzer 全部拆成單詞,查詢原理不同先不納入比較,進一步研究都有使用詞庫的 CWSharp 及 MMSeg,會發現查不查得到跟詞庫分詞結果有關。MMSeg 之所以查不到「不會知道」是因為依詞庫拆成「也不」「會」「知道」,三個詞彙組不出「不會知道」。而 CWSharp 的詞庫拆成「也」「不」「會」「知道」,可以由後三者組出「不會知道」。

依據這個原理,如果我們查詢「活就像」這種不合理詞彙,所有使用詞庫的分詞器(盤古、MMSeg、CWSharp語庫法)全軍覆沒,只有一元分詞跟二元分詞能過關。

盤古分詞測試
查詢「活就像」找到0筆
========================================
標準分詞(一元分詞)測試
查詢「活就像」找到1筆
========================================
MMSeg MaxWord測試
查詢「活就像」找到0筆
========================================
MMSeg Simple測試
查詢「活就像」找到0筆
========================================
MMSeg Complex測試
查詢「活就像」找到0筆
========================================
CWSharp詞庫分詞測試
查詢「活就像」找到0筆
========================================
CWSharp一元分詞測試
查詢「活就像」找到1筆
========================================
CWSharp二元分詞測試
查詢「活就像」找到1筆
========================================

由上述測試,對 Lucene.Net 分詞器選擇可做個粗淺結論: 如果不在乎索引大小且對搜尋效能要求不嚴嚴苛,可選擇一元分詞或二元分詞;選擇詞庫分詞,索引較小且查詢效能較好,但遇到詞庫未涵蓋的詞彙會出現查不到的狀況,而找不到又分為詞彙本身不合理以及詞庫未函蓋兩種狀況,前者需向使用者解釋查詢無效字彙不在系統支援範圍,後者則需要持續增補詞庫。要選哪一種策略,甚至二者並用,可視專案的需求而定。

全文檢索筆記 – Lucent.Net (4) 詞庫校正

$
0
0

體會過自動分詞(一元分詞、二元分詞)與詞庫分詞的特性差異,但是到目前為止有個問題一直被忽略,我測試用的詞庫直接下載自網路,內容是簡體中文,拆解精準度大有問題。

以 CWSharp 詞庫分詞為例,使用 Github 下載的 cwsharp.dawg 詞庫檔分析這句中文「競選活動已日趨白熱化,參選人莫不全力尋求廠商支援,其中以鄭少秋勝算最大。」,使用 Luke.net 查看分詞結果如下:

雖然還是能查到關鍵字,但分詞結果並不好,幾乎都拆成單一字元,跟一元分詞沒什麼兩樣。這意味詞庫命中率極低,其根本原因在於我們用的詞庫是簡體。將原文換成簡體 - 「竞选活动已日趋白热化,参选人莫不全力寻求厂商支持,其中以郑少秋胜算最大。」 再測一次即可看出差異。大部分的動名詞都被挑出來,連人名「鄭少秋」都能被識別成單一詞彙,這才是應有的結果:

由此可知,直接用簡體字庫僅能命中簡繁體用字相同的詞彙,準確率有限,要有效解析繁體中文內容,詞庫必須轉成繁體,或更進一步改用繁體專屬詞庫。在此以 CWSharp 為例,示範如何轉換及製作詞庫。CWSharp 使用名為 DAWG(Directed Acyclic Word Graph) 的檔案格式保存詞庫(其查詢效率狂勝清單及二元樹,參考),DAWG 是由詞彙清單及字元頻率表轉換而成,Github有簡體範例詞庫的原始檔 – cwsharp.dic, cwsharp.freq,以及轉換程式範例

借用 NuGet 可得的微軟簡繁轉換元件 ChineseConverter將簡體轉成繁體,就能轉換出繁體中文版詞庫檔 - cwsharp-tw.dawg。

staticvoid BuildDawgFile()
        {
            var rootPath = ".\\DAWG";
            var wordUtil = new WordDict();
//加载默认的词频
using (var sr = new StreamReader(rootPath + @"\cwsharp.freq", Encoding.UTF8))
            {
string line = null;
while ((line = sr.ReadLine()) != null)
                {
if (line == string.Empty) continue;
                    line = ChineseConverter.Convert(line, //簡體轉繁體
                        ChineseConversionDirection.SimplifiedToTraditional);
                    var array = line.Split(' ');
                    wordUtil.Add(array[0], int.Parse(array[1]));
                }
            }
//加载新的词典
using (var sr = new StreamReader(rootPath + @"\cwsharp.dic", Encoding.UTF8))
            {
string line = null;
while ((line = sr.ReadLine()) != null)
                {
if (line == string.Empty) continue;
                    line = ChineseConverter.Convert(line, //簡體轉繁體
                        ChineseConversionDirection.SimplifiedToTraditional);
                    wordUtil.Add(line);
                }
            }
//保存新的dawg文件
            wordUtil.SaveTo(new FileStream(".\\cwsharp-tw.dawg", FileMode.Create));
        }

改用 cwsharp-tw.dawg 重測,大部分詞彙就被正確識別出來了!

理論上,詞庫愈專業愈完整愈貼近檢索文章的用語習慣,建立索引及搜尋效能愈好。中研院有數萬甚至百萬筆等級的中文詞庫,堪稱最完整最權威的來源,不過它們不是開放資料,需考量授權可用性與成本。採 CC 3.0 授權的教育部國語辭典,則是另一個專業且可行的來源。需記住一點,詞庫分詞真要做到犀利精準,不可能單靠現成詞庫,看的是長期養成培育的功夫,持續補充新名詞(例如: 比特幣、聖結石),不斷學習及優化,是條不歸路。

考量飼養詞庫的成本,若全文檢索非網站的核心價值所在,文件數不多且對搜尋效能要求不高,二元分詞(索引及效率較一元分詞稍好)會是較省事的選擇。

Chrome 網頁中文變醜之謎

$
0
0

我習慣將 Chrome 標準字型設成思源黑體字型, 除非網頁硬將 font-family 指定成細明體(例如: Mobile01),換了字型讓網頁質感變好,比新細明體賞心悅目許多。

Pocket是我慣用的稍後再讀服務,在 FB 或爬文時看到不急著看但值得花時間讀的相關文章,我會先丟進 Queue 裡收藏,有空再讀。在使用 Pocket 網頁介面閱讀文章時我注意到一件事 – 文字閱讀模式(不開啟原始網頁,改用 Pocket 自訂樣式呈現文章內容) 下,標題字型是 Chrome 預設的思源黑體沒錯,但內文部分中文變得很醜,但不是新細明體。

原以為是 CSS 被設成某種特殊中文字型,但使用 F12 工具檢查,內文區的 font-family 是 inherit,Rendered Fonts 則顯示最後用的是 SimSun 新宋體(相當於簡體中文界的新細明體吧)。inherit 理論上該使用 Chrome 的預設字型,應該是 Noto Sans CJK TC Regular 才對。

爬文找到相關文章 Chrome 18 以上中文字體變醜的原因及暫時解法 - Yu-Cheng Chuang’s Blog,提到 Chrome 遇到 lang="zh" 會視為簡體中文(lang="zh-tw" 才是繁體中文)的行為。檢查 HTML,果然在內文 <div> 發現 lang="zh",而標題沒有,這樣就能解釋標題跟內文字型為何不同,二者的 font-family 都是 inherit,但內文因 lang="zh" 被視為簡體,故用了簡體中文的預設字型 SimSun。

使用 Chrome Adavanced Font Settings外掛可找到 Chrome 針對簡體中文的進階字型設定,三種字型風格中的 Serif 被設定新宋體,Standard/Sans-Serif 則是微軟雅黑。將三者都改成思源黑體,就可以解決 lang="zh" 讓字型變醜的問題了。

另外,Pocket 閱讀介面可選擇使用 Serif 或 Sans Serif 字型,若不改進階字型設定,選 Sans Serif 會改用微軟雅黑體,也可避開醜醜的新宋體。但如果不想在其他網站也因 lang="zh" 中文變醜,修改簡體中文預設字型是根本解決之道。

【茶包射手日記】網頁在某支手機無法使用

$
0
0

同事貢獻新鮮茶包一枚。查到最後發現是低級錯誤,但念在用電話跟 LINE 遠端偵錯耗了三個小時,值得記錄並列為日後問題排除參考。

最初的報案內容是某位使用者剛換了 iPhone 8 新手機,要連上某個例行工作網站查資料,輸入帳號密碼卻無法登入。我們試了自己的手機及平板檢測正常,原以為是使用個人帳號被鎖或失效導致登入失敗,但檢查帳號狀態正常,歷經一陣同鴨講追問細節後才搞清楚,其實登入有成功,而是畫面不正常且無法操作。(跟報案內容大不相同,隔空抓藥好刺激呀)

為對照比較,商請使用者借其他人的手機測試結果正常,這下確認問題出在新手機上,集中火力嚴加拷問,終於查到關鍵:

問題手機的 JavaScript 被停用了被停用了被停用了!

JavaScript 啟用後問題果然煙消雲散,但留下兩個謎團:

  1. 停用 JavaScript 後會有一大堆網站無法使用,何以使用者未察覺?
    推測是新手機的關係,使用者平日以電話、簡訊及 LINE 為主,還沒太多機會用瀏覽器上網,因此沒注意到 JavaScript 被停用。
  2. JavaScript 啟用選項藏在 Safari 進階設定選單的深處,非一般人會想到要更改的設定(使用者本身也不知有此選項),誤觸可能性極低。JavaScript 到底是被誰關閉的,真的是謎。

總之,由這個案例學到一些經驗:

  1. 先使用對照法釐清問題與帳號資料還是裝置有關,有助於加速破案。
  2. 行動裝置的 App 使用比率高,瀏覽器非日常必備,JavaScript 停用未必會被察覺,應列入行動裝置瀏覽網站異常之優先檢查項目。
  3. 如網頁缺少 JavaScript 就無法運作,可考慮加註警示以便在第一時間示警,最簡單的做法是用 <noscript>標籤加註文字。
    例如: <noscript>快把他X的JavaScript給我打閞,不然別碰我!</noscript>(誤)

使用 CSS 實現標題單行置中多行靠左

$
0
0

跟同事討論到一個需求,要在顯示文章的網頁實現「標題只有一行時置中顯示;若文字較多折行時則靠左對齊」的效果。起初程序員大腦想到的做法是用 JavaScript 依文字長度動態調整 text-align 樣式,但由於折行與否是瀏覽器依字型大小、容器寬度自行裁量,難以依據字數直接推算,於是我開始揣摩由文字元素高度偵測行數的雞鳴狗盜招術...

爬文後才發現我把事情想得太複雜了,這個需求用 CSS 就能搞定,一行程式都不用寫。做法是用 <div> 包住 display: inline-block 的 <span>,將 <div> 設成 text-align: center,<span> 設成 text-align: left。

<style>
.box {
  border: 1px solid gray;
  margin: 12px;
  padding: 6px;
  width: 420px;
}
.flex-align-title {
  text-align: center;
  width: 100%;
}
.flex-align-title span {
  text-align: left;
  display: inline-block;
}
</style>
<divclass="box">
<divclass="flex-align-title">
<span>網路盛傳連中樂透頭彩 黑大:子虛烏有</span>
</div>
</div>
<divclass="box">
<divclass="flex-align-title">
<span>資訊史重大里程碑 某部落客成功實現GUID碰撞實驗 專家:機率比被隕石爆頭還低</span>
</div>
</div>

搞定收工,就這麼簡單!

想動手實測的同學可試玩 CodePen Live Demo

註: GUID 重複與樂透梗來自這裡

筆記:比特幣挖礦在挖什麼?

$
0
0

區塊鏈跟比特幣最近熱到發燙,沒幻想過靠它致富(甚至覺得仰賴鉅量能源運作的虛擬貨幣很不環保),倒是對其原理奧義充滿興趣。先前看過不少深淺文章,限於慧根,對其運作原理仍一知半解,知道所謂礦工挖礦類似暴力破解雜湊(Hash)函式,對為什麼驗證交易真實性會扯上破解雜湊值毫無概念。

今天看完一部介紹短片(其實不算短,26分20秒)豁然開朗,欣喜之餘推薦給跟我一樣有興趣了解比特幣挖礦在挖什麼碗粿的同學:(記得開中文字幕。影片還算淺白,但真心覺得要懂公私鑰、雜湊碼,有粗淺密碼學基礎才能下嚥)

影片

隨手筆記重點如下:

  • 加密貨幣(以下以比特幣為主)的基礎是一份公開流傳的帳冊,記載全市場每一筆交易記錄(例如Alice給Bob 100元)
  • 在帳冊加入交易記錄時,交易者需以其私有金鑰對該記錄加上數位簽章,以產生不可否認性
  • 交易記錄包含時間戳記及唯一序號,即使兩筆記錄的交易對象與金額相同,數位簽章也不會相同,故無法靠複製產生重複交易記錄
  • 帳冊被拆成多個區塊,每個區塊附有依其內容產生的雜湊值(SHA256),下一個區塊需包含前一區塊的雜湊值,串連成所謂的區塊鏈
  • 篡改某個區塊內容,會改變該區塊的雜湊值,連帶下一個區塊跟下下一個區塊的雜湊值也要修改
  • 為讓篡改難如登天,建立區塊時需加入一段內容讓SHA256雜湊值的前N個(例如60個)位元組剛好是0,這只能靠嘗試各種數字組合暴力破解
  • 礦工負責傾聽網路上的交易訊息,設法用最短的時間嘗試在包含新交易記錄的區塊加入不同內容組合,讓區塊SHA256前60位為零,符合此一條件就算區塊建立完成,馬上廣播出去,第一個建立區塊的礦工可以獲得一定數目的比特幣做為獎勵
  • 礦工建立區塊的過程就稱為挖礦,由於只有第一個建立區塊的礦工獲得獎勵,即使擁有強大的運算力,也要憑運氣拼人品,還真的跟挖礦淘金沒兩樣
    延伸閱讀: 挖比特幣的礦工都賺瘋了:直擊中國四川、東北、內蒙古的超級大礦場 - TechOrange
  • GPU原本用於3D圖形運算,其運算原理剛好與SHA256吻合,用來破解SHA256效率高成本又比用CPU低,故成為挖礦機主流
  • 為防止有人偽造交易,比特幣有個防止偽造交易的終極武器—當區塊鏈出現多個版本時,以較長的一份為準
  • 建立區塊需要可觀的運算能力(等同暴力破解SHA256),除非篡改區塊者擁有的超過全球礦工總和一半以上的運算能力,才可能維持偽造版本的區塊鏈長度不被直實版本超越,一旦被超越,篡改版作廢便白忙一場
  • 比特幣規定每10分鐘產生一個區塊,當參與的礦工數愈來愈多、計算機能力愈來愈強大,可透過提高建立區塊SHA256所需的起始0長度(例如從60個提高到72個),藉以調節挖礦難度
  • 2009年開始時建立一個區塊礦工可得到50枚比特幣獎勵,2012 Nov起是25枚,2016 Jul起12.5枚,2020 Feb起6.25枚,每4年減半,故總數2100萬個,永遠不會超過
  • 區塊建立獎勵會不斷遞減,礦工的另一個收入來源來自支付者額外支付交易手續費

同場加映:另一部有趣的相關影片,破解SHA256到底有多難?

影片

SHA256相當於40億相乘8次,假設:

最頂級的GPU每秒可以算10億次SHA256,我們在電腦塞進多顆GPU,做成一台一秒可完成40億次SHA256計算的超級電腦。
假設Google所有伺服器運算能力的1000倍才能跟40億台上述超級電腦不相上下。
假設地球的人口有一半的人,每個人擁有1000家Google的運算能力。
假設銀河系1%的恆星有一顆像地球的行星,剛好有4億人口每個人持有1000家Google的運算力。
假設宇宙碰巧就有40億個像銀河系這樣有一堆地球且人人有Google的星系。
請以上40億個星系上的所有地球,人人都拿出自己的1000倍Google運算能力,一起算上5070億年(差不多是宇宙年齡的37倍),將會有40億分之1的機會可以破解SHA256。


2017 觀音山馬

$
0
0

山路跑滿跑好的小而美觀音山馬,連續第三年。(20162015)

氣象預報週五放晴一天,週末兩日又再陰雨濕冷。週六一早雨勢不小,心想不妙,今年「跑馬總有好天氣」運勢已劃上句點了嗎?週日一早起床,啊哈! 雨停了,感謝老天。

七點才起跑不用摸黑早起真好,六點半抵達微風運河,會場跟上一場根除小兒麻痺扶輪社公益路跑相同,只是今天有硬斗的山路等著我... (抖)

遠方天空滿是黑鴉鴉的烏雲,但沒下雨已屬萬幸~

蘆洲地方特色—神將,大會口號:「跑馬有神助,輕鬆跑山路」。連跑三年從沒感覺山路輕鬆過,可能是跑前忘了跟神將合照,下回試試。

長官、民代各方人馬致詞時,四軸空拍機在頭上飛呀飛,空拍似乎已成大型戶外活動的趨勢。

七點準時起跑,全馬參賽人數不及七百人,又是地方路跑社團主辦,是我最愛的小而美比賽。

起跑後先繞微河運河一圈約3.5K後進入河濱道,途經觀音坑溪橋、獅子頭釣魚台(釣魚台是我們的!)、關渡橋一路北行。這條路線是三重、土城、蘆洲、板橋一帶路跑的必經賽道,加上不到一個月前才剛跑過,早無新鮮感,但是為什麼一看到關渡大橋,我還是會舉起相機呢?

過了關渡大橋沒多久,左轉過天橋彎進小巷繞了一陣後開始爬坡,可怕的山路來了~

路旁人家養的鴨子,大清早驚見大批人馬跑來跑去看傻了,一整群動也不動,很有趣。

遠方山頭被雲籠罩,今天有機會回味跑進雲裡的感覺囉~

果不其然,還不到觀音山遊客中心已身處雲霧中。

一群人在雲霧中奔跑,別有趣味。

最高點:硬漢嶺步道口。天候不佳遊客少,難得拍照不用避開路人。

前途一片白茫茫,追著遠方跑者的背影前進,又是另一番意境。

今年路線微調,起跑前大會宣告全馬折返點有驚喜,是一處鮮為人知的祕境。喝! 居然有霸王龍擋道?

祕境是兩塊嶙峋怪石包夾一塊滿是綠意的狹小腹地,景色稱不上壯闊倒也別緻,是小而美的秘境~

折返後轉往八里方向下山。大會貼心,行至此地若被山路折磨到精神失常,可直送八里療養院。(喂)

繞回觀音山風景區入口,天氣漸晴,路途也接近尾聲。

回程經過關渡大橋,不爭氣的我又... 拍了一張。(跪地吶喊) 為什麼我意志力這麼薄弱呢?

淡水河邊有好多人在釣魚,跑著遇到兩個年輕人拎著釣竿要回家,好奇一問「有釣到嗎?」,對方搖搖頭,我再問「是經常釣不到還是偶爾會釣不到」,對方楞了足足五秒,才勉強擠出一句「經常釣不到」。現在回想,我好像做了一個在傷口撒鹽加搓揉的動作,槓龜哥(喂),對不起!

花了 5:22:32 回到會場,霸王龍已在終點久候(該不會跑回來的吧?),開心跟牠擊掌,再下一馬。

本屆由澎澎裙美眉們負責掛牌。

補上獎牌照

      

我的 Windows 10 倉頡中文輸入筆記

$
0
0

使用倉頡輸入超過二十年,當年升級 Windows 8 時最震驚的莫過於「新倉頡輸入法」被移除,回頭改用必須選字的「倉頡輸入法」內心有萬頭羚羊狂奔。(但是另外也有很多人因為必須選字的「ㄅ半」注音輸入被移除哀嚎;輸入法這玩意跟信仰一樣,大家各有所愛且難以撼動)

Windows 8 時代要裝回新倉頡跟ㄅ半很麻煩,還需要複製安裝輸入法檔案(參考: 如何在 Windows 8 中新增注音 (ㄅ半)、新倉頡、新速成輸入法 ),Windows 8.1 起新倉頡等輸入法改回內建但隱藏,修改機碼(Registry)即可開啟(參考: 在 Windows 8.1 中新增注音 (ㄅ半)、新倉頡、新速成輸入法 (僅適用桌面模式))。做法是將以下這段文字存成「新倉頡.reg」,直接點兩下匯入或按右鍵選「合併」,輸入法清單就會有新倉頡可選。

Windows Registry Editor Version 5.00
 
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\CTF\TIP\{B115690A-EA02-48D5-A231-E3578D2FDF80}\LanguageProfile\0x00000404\{F3BA907A-6C7E-11D4-97FA-0080C882687E}]
"Description"="Microsoft New ChangJie"
"Display Description"=hex(2):40,00,25,00,53,00,79,00,73,00,74,00,65,00,6d,00,\
  52,00,6f,00,6f,00,74,00,25,00,5c,00,53,00,59,00,53,00,54,00,45,00,4d,00,33,\
  00,32,00,5c,00,69,00,6e,00,70,00,75,00,74,00,2e,00,64,00,6c,00,6c,00,2c,00,\
  2d,00,35,00,30,00,39,00,33,00,00,00
"IconFile"=hex(2):25,00,53,00,79,00,73,00,74,00,65,00,6d,00,52,00,6f,00,6f,00,\
  74,00,25,00,5c,00,73,00,79,00,73,00,74,00,65,00,6d,00,33,00,32,00,5c,00,49,\
  00,4d,00,45,00,5c,00,49,00,4d,00,45,00,54,00,43,00,5c,00,49,00,6d,00,54,00,\
  43,00,54,00,69,00,70,00,2e,00,44,00,4c,00,4c,00,00,00
"IconIndex"=dword:00000002
"Enable"=dword:00000000
"ProfileFlags"=dword:00000004

不過,使用密技招喚的新倉頡有些限制,它只能用於傳統桌面程式,在 Windows 8/10 App 跟新一代的 Windows 系統介面(例如下圖)敲不出中文:

另外,我常遇到新倉頡切換視窗後失效,在 Windows Live Writer 之類的老 Windows Form 程式被停用(如下圖),必須改用其他中文輸入法或重啟程式。

除了新倉頡,我也試過幾個替代方案:

  • demoshop 新自然輸入法特別版
    新自然的字庫選字及學習功能很強大,demo 的佛心版擁有付費版才支援的倉頡輸入,我在 Windows 8 時代用得很開心,可惜與 Windows 10 不相容,一直狂當,只得棄用。
  • PIME 輸入法平台內附的酷倉輸入法
    新酷音威力強大,但酷倉相對陽春許多,不支援自動選字也無法先輸一段文字再調整選字,除了標點符號便捷輸入,功能相當於 Windows 內建的倉頡輸入法。

最後,在 Windows 10 還是新倉頡最順手,遇到新介面及 App 則改用酷倉,至於快速輸入及切換,磨合一陣子試出順手的設定,整理如下:

  1. 新倉頡全形標點快速鍵
    ,= Ctrl+,
    、= Ctrl+'
    。= Ctrl+.
    ?= Ctrl+Shift+/
    != 先按`再按1
    ;= Ctrl+;
    := Ctrl+Shift+;
    【= Ctrl+[
    】= Ctrl+]
    「」『』... = Ctrl+[ 與 Ctrl+] 再按上下鍵選取
    通用技巧: 先按`再輸入符號可選取相關符號,例如: 輸入`@ 可選取@⊕⊙㊣﹫
    ()= `( 及 `)
    += `+
    -= `-
    /= `/
    ÷= `/再按向下鍵選取
    ×= `*再按向下鍵選取
    ± = `+再按向下鍵選取
    —(波折號) = `-再按向下鍵選取
    …(刪節點) = 輸入倉頡字根 Z難日中(ZXAL)
    符號螢幕小鍵盤 Ctrl+Alt+,
  2. 酷倉全形標點快速鍵
    Ctrl+標點 帶出選取視窗(新酷音則是 Shift+標點) 各鍵對應的符號可自訂
  3. 中英文輸入切換
    中文輸入時按 Shift 可切換輸入中文或英文,切到英文模式雖然輸入英文沒問題,但標點符號 Ctrl 快速鍵會跟部分軟體打架。例如 Visual Studio 預設使用 Ctrl+. 帶出智慧標籤會跟句號「。」相衝,雖然可以修改 Visual Studio 設定避開,但相衝按鍵不只一組比較麻煩,故我會按 Alt-Shift 關閉中文輸入比較省事。
    後來,我找到最順手的解法是新增一個「英文(美國)」語系,並使用跟中文輸入法相似的 Ctrl-Shift+N 快速鍵切換到英文輸入法,詳細做法可參考下一點。
  4. 輸入法切換快速鍵
    Windows 預設使用 Ctrl-Shift 切換不同中文輸入法,開啟關閉中文輸入則靠 Alt-Shift,我找到一個更直覺快速的做法,為新倉頡、酷倉、新酷音設定 Ctrl-Shift-1/2/3 快速鍵,另外新增英文語系英文輸入法 設為 Ctrl-Shift-0,用一致的按鍵組合切換新倉頡、酷倉、新酷音及純英文。
    快速鍵的設定選單藏在「控制台\語言\進階設定\變更語言列快速鍵」

    為各輸入法指定 Ctrl-Shift-數字 組合,切換輸入一鍵到位。比起切中文 Ctrl-Shift 循環打檔,切純英文改按 Alt-Shift 更順手。

【茶包射手筆記】Chrome 開發者工具看不到 Form Data

$
0
0

使用 Chrom F12 開發者工具偵察 Web Form 送回內容,正常情況應如下圖所示,Content-Type 為 application/x-www-form-urlencoded,Request Headers 下方應有一區 Form Data 可檢視 Post 送回內容:

我所偵察的 ASP.NET 網頁,遇特定條件會透過 Resonse.Redirect() 轉址,此時 Response 收到 HTTP Status 302 很合理,但向下想查傳回內容,卻發現 Form Data 資訊區不見了!

爬文在 stackoverflow 查到相關討論,這是仍存在於當前 Chrome 穩定版(2017年12月,v62) 的 Bug (推估從 v61 起就有),目前測試中的 v64/v63 已修正,預計月底穩定版更新到 v63 後會解決。

【茶包射手日記】Notepad 改 config 後程式掛點

$
0
0

倉頡輸入筆記文網友 s793016 留言提到 PRIME(中州韻輸入法) 內含倉頡輸入,簡單試用挺驚豔的(心得容後再寫),不過有個問題:必須新增簡體中文語系才能用,解法是修改 ime.json 檔將語系改為 zh-TW 重新註冊 PIMETextService.dll (參考: 在 Windows 10 下安裝最新版的 PRIME 中州韻輸入法方法 - Hiraku Dev)。修改 Program Files 目錄下的檔案需要管理權限,我選擇用管理者權限開 CMD,切到指定目錄下指令 notepad ime.json 用筆記本簡單修改後存檔,接著下指令跑 regsvr32 跑完程序,不料踩到 Notepad 地雷一枚。

反註冊沒什麼問題,但註冊 PIMETextService.dll 時程式崩潰,註冊失敗~

再做了測試,發現如不修改 ime.json,regsvr32 反註冊及註冊都沒問題。反覆測試了幾次,某次改用 Notepad++ 修改,居然註冊成功了!兇手現身,立刻拘提 Notepad 到案嚴刑拷打。

測試使用 Notepad 更動 ime.json 的一個字元,理論上檔案大小不變。但比對修改前後,發現檔案差了 3 個 Byte。

使用 Notepad++ 開啟修改後的 ime-notepad.json,右下角 UTF8-BOM 足以解釋 3 個 Byte 從何而來。

ime.json 原本的格式是不包含 BOM的 UTF8,Notepad 存檔時卻自做主張在檔案前方補上 BOM(0xEF 0xBB 0xBF),用 Notepad++ 的 Hex Editor 外掛可以看得很清楚:

換句話說,問題點在於 Notepad 修改 ime.json 時雞婆為檔案補上 BOM,而恰巧 PIME 程式無法識別包含 BOM 的檔案格式且未捕捉到例外,然後... BOOM! 程式就爆炸了~

Notepad 為什麼要雞婆加上 BOM?在一篇十年前文章(BOM BOM BOM - 就是愛程式)找到詳細說明,Notepad 會加 BOM 的行為由來以久,只是我今天才遇上。又長見識了。

最後提一下 PRIME,中州韻輸入法引擎連續輸入整句話字根(不用敲空白)再用詞庫解析的做法感覺相當聰慧,還有自我學習能力,開源開放甚至允許你發明自己的輸入法令人耳目一新。可惜測試期間好幾次因切換輸入法讓應用程式(Chrome、Live Writer)崩潰閃退,穩定性讓人擔憂,暫時是無緣了。

C# 小技巧 - 不必再靠 switch case 副檔名決定 ContentType 囉

$
0
0

由 ASP.NET 伺服器端傳回檔案內容,需指定適當的 ContentType,瀏覽器才會將其視為圖檔、HTML、CSS 或 JavaScript 處理。過去我都是土法煉鋼,取得副檔名再用 switch … case 針對已知檔案種類列舉對應 ContentType,像這樣:

string contentType = "";
switch (fileName.Split('.').Last())
{
case"jpg":
        contentType="image/jpeg";
break;
case"gif":
        contentType="image/gif";
break;
case"png":
        contentType="image/png";
break;
case"htm":
        contentType="text/html";
break;
case"css":
        contentType="text/css";
break;
case"js":
        contentType="text/javascript";
break;
default:
thrownew ApplicationException("Not supperted file type!");
}

隨便搜尋我的舊文章就能找到應用案例: 淺嚐Data URIHTML5 Canvas的Origin-Clean安全原則

最近發現好東西,.NET 4.5 起 System.Web 內建的 MimeMapping.GetMimeMapping()可以直接將檔名(注意: 是檔名不是副檔名)對應成 ContentType,不用再自己徒手硬刻:

所以,文章開頭的程式邏輯可以簡化成:

if (!"jpg,gif,png,htm,css,js".Split(',').Contains(fileName.Split('.').Last())
thrownew ApplicationException("Not supperted file type!");
var contentType=MimeMapping.GetMimeMapping(fileName);

順便補充,從 ASP.NET WebForm 傳回 jpg、png 等圖檔,ContentType 已指定為 image/jpeg、image/png,若希望瀏覽器不要直接顯示而是下載另存檔案,可透過 Content-Disposition Header 搞定:
Response.AddHeader("Content-Disposition", "attachment; filename=\"" + Server.UrlEncode(fileName) + "\"");
如果是 ASP.NET MVC Action,直接使用 return File(byteArray, contentType, fileName),MVC 會在背後搞定 ContentType 及 ContentDisposition Header,是最省事的做法。

延伸閱讀:

Viewing all 2311 articles
Browse latest View live