Coding4Fun - 從 PDF 擷取純文字並保留排版
4 |
隨手練習題。
要從 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 物件模型存取。