隨手練習題。

要從 PDF 擷取純文字內容不是難事,但如果要保留原本的縮排跟換行,這就有點意思了。

例如以下 PDF 原始文字及轉換後純文字:

感覺蠻酷的吧?

我目前還沒找到屬意的 C# PDF 程式庫,iTextSharp 有版權疑慮,之前用過 IFilterTextReader 能取出整份 PDF 的文字,但結果是一個字串,用在全文檢閱或用 Regex 解析還堪用,不可能保留排版格式。PDF 商業元件很多,但我還是想找找開源程式庫,這回找到一個 - PdfPig

它的功能不多,只有基本的文件建立及讀取,但讀取結果是一個個 Word 區塊,可以得知其在頁面上的座標 (BoundingBox,有 Top/Bottom/Left/Right/Height/Width 屬性)。知道哪個位置有哪些文字,理論上我們就能還原出排版相近的純文字檔(純文字不比 Word/PDF,無字體大小區別,不能微調 X/Y 座標,無法 100 相同)。

初測發現一個問題,就是同一列的兩個 Word,BoundingBox 的 Top 或 Bottom 未必完全相同,尤其是中文跟英數字,因為字型,可能會差幾個 Pixel,

using (PdfDocument document = PdfDocument.Open(@"example.pdf"))
{
    foreach (Page page in document.GetPages())
    {
        foreach (Word word in page.GetWords())
        {
            Console.WriteLine($"{word.Text} (Top: {word.BoundingBox.Bottom:n0}, Bottom: {word.BoundingBox.Bottom:n0})");
        }
    }
}

如上圖所示,25. 題目列的 Bottom 有 410, 409、(A) 選項有 391, 392、(B) 選項拆成五截,有 372 跟 373。

最後,我想了一個簡單的演算規則,用 Bottom 相差小於列高的都聚合成一列,再用 Left 由左到右排列顯示,遇到 Left 與前一 Word Right 相距過大時,再補上適當的空白字元,試圖貼近原本的排版。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UglyToad.PdfPig.Content;

namespace pdfpig_lab
{
    public class Line
    {
        public List<Word> Words { get; set; } = new List<Word>();
        public double? Bottom {get; set;} = null;
        public double Left => Words.Min(w => w.BoundingBox.Left);
        public void AddWord(Word word)
        {
            if (!InSameLine(word))
                throw new Exception("Word is not in the same line");
            Words.Add(word);
            if (Bottom == null) {
                Bottom = word.BoundingBox.Bottom;
            }
            else {
                Bottom = Math.Max(Bottom.Value, word.BoundingBox.Bottom);
            }
        }
        public bool InSameLine(Word word)
        {
            return Bottom == null || 
                Math.Abs(word.BoundingBox.Bottom - Bottom.Value) < word.BoundingBox.Height;
        }

        public string ToString(int leftMargin)
        {
            var sb = new StringBuilder();
            Word prevWord = null;
            var avgCharWidth = Convert.ToInt32(Words.Average(w => w.BoundingBox.Width / w.Text.Length));
            if (leftMargin > 0) sb.Append(new String(' ', (int)(Words[0].BoundingBox.Left - leftMargin)/avgCharWidth));
            foreach (var word in Words.OrderBy(w => w.BoundingBox.Left)) {
                if (prevWord != null && word.BoundingBox.Left - prevWord.BoundingBox.Right > avgCharWidth)
                    sb.Append(new String(' ', (int)(word.BoundingBox.Left - prevWord.BoundingBox.Right)/avgCharWidth));
                sb.Append(word.Text + " ");
                prevWord = word;
            }
            return sb.ToString();
        }
        public override string ToString() => ToString(0);

    }
}

核心邏輯都寫在 Line 物件,Program.cs 的工作是讀檔逐頁取得所有 Word 物件,依座標分列,再將該列顯示出來。有個小眉角是所有 Word 的 Left 並非從零開始,所以我用 Min 取得最小的 Left 視為該頁的左邊界,若 Word 的 Left 大於左邊界較多時補上空白以還原縮排。

using pdfpig_lab;
using UglyToad.PdfPig;
using UglyToad.PdfPig.Content;

using (PdfDocument document = PdfDocument.Open(@"example.pdf"))
{
    foreach (Page page in document.GetPages())
    {
        List<Line> lines = new List<Line>();
        var currLine = new Line();
        lines.Add(currLine);
        foreach (Word word in page.GetWords())
        {
            var box = word.BoundingBox;
            if (!currLine.InSameLine(word))
            {
                currLine = new Line();
                lines.Add(currLine);
            }
            currLine.AddWord(word);
        }
        var leftMargin = lines.Min(l => l.Left);
        foreach (var line in lines)
        {
            var indent = line.Left - leftMargin;
            if (indent > 0)
                Console.Write(new string(' ', (int)indent/14));
            Console.WriteLine(line);
        }
    }
}

就醬,又愉快地完成一項挑戰。


Comments

# by Jackson2734

請問在 vb.net 裡面 -- 想把時間表示法 01:01:50 一時一分50秒, 全轉換成秒(60*60+1*60+50),有比較好的方法嗎?

# by Jeffrey

to Jackson2734,我的話會用 TimeSpan.Parse("01:01:50").TotalSeconds

# by Nick Yang

請問如果是表格,你有好的方法可以把它轉成適當的格式,並存到匯出的文字檔嗎?

# by Jeffrey

to Nick Yang,PdfPig 無法解析表格物件,實作上有難度。商業元件這方面支援得比較完整,可以用 Table、Row、Cell 物件模型存取。

Post a comment