บทความเทคนิค

การแยกข้อความจากไฟล์ PDF ด้วย PDFium Component ใน Delphi

การแยกข้อความ PDF ดูเหมือนง่ายจนกระทั่งคุณพบเอกสารที่ไม่มีเลเยอร์ข้อความ เสียหาย หรือแยกส่วนกระจายอยู่ตามลำดับตัวอักษรขนาดเล็กหลายสิบกลุ่มโดยไม่มีลำดับที่มีความหมาย PDFium Component ให้จุดเข้าใช้งานสองจุดแก่คุณ: อาร์เรย์ Character[] สำหรับการเข้าถึงไกลฟ์ (glyph) แต่ละตัวบนหน้าตามดัชนีดิบ และ ReadablePageContent สำหรับมุมมองที่มีโครงสร้างซึ่งสร้างย่อหน้าและหัวเรื่องขึ้นใหม่จากแผนผังแท็กของ PDF หรือการวิเคราะห์ฮิวริสติก ไม่มีตัวเลือกใดที่เป็นคำตอบที่ถูกต้องเสมอไป ดังนั้นการทำความเข้าใจสิ่งที่แต่ละตัวแสดงออกมาจึงเป็นเรื่องสำคัญ

การเปิดเอกสารและกับดักความล้มเหลวแบบเงียบ

TPdf จะเปิดไฟล์โดยกำหนดค่า FileName และเปลี่ยน Active := True รายละเอียดที่สำคัญคือ: Active := True จะไม่มีการส่งข้อยกเว้นออกไป หากไม่มีไฟล์ดังกล่าวอยู่ ได้รับการป้องกันด้วยรหัสผ่าน หรือเกิดความเสียหาย PDFium จะตรวจจับข้อผิดพลาดภายในระบบ และค่า Active จะคงเป็น False เท่านั้น นั่นหมายความว่าทุก ๆ ลูปของการแยกข้อมูลจะต้องป้องกันในเรื่องนี้:

Pdf := TPdf.Create(nil);
try
  Pdf.FileName := 'report.pdf';
  Pdf.Active := True;
  if not Pdf.Active then
  begin
    ShowMessage('Could not open PDF (damaged or wrong password)');
    Exit;
  end;
  // extraction follows here
finally
  Pdf.Active := False;
  Pdf.Free;
end;

ไฟล์ที่ได้รับการป้องกันด้วยรหัสผ่านจำเป็นต้องกำหนด Pdf.Password := '...' ก่อนที่จะเปลี่ยน Active := True ไม่มีโอกาสครั้งที่สอง: เมื่อ Active ล้มเหลว คุณต้องปิดและเปิดใหม่อีกครั้งพร้อมด้วยรหัสผ่านที่ถูกต้อง

การแยกข้อมูลทีละหน้าด้วย Character[]

แนวทางในระดับต่ำสุด (lowest-level) คือการไล่ดูอักขระทุกตัวในแต่ละหน้า กำหนด Pdf.PageNumber เพื่อโหลดเลเยอร์ข้อความสำหรับหน้านั้น จากนั้นทำซ้ำรายการ CharacterCount โดยใช้พร็อพเพอร์ตี้ Character[] แฟล็กสองตัวในแต่ละรายการควรตรวจสอบ: CharacterGenerated[i] จะทำเครื่องหมายไกลฟ์สังเคราะห์ที่แทรกโดยตัวเรนเดอร์ (เช่น ยัติภังค์อ่อนที่จุดแบ่งบรรทัด) ซึ่งไม่มีค่า Unicode จริง และ CharacterMapError[i] จะส่งสัญญาณว่า PDFium ไม่สามารถจับคู่ไกลฟ์กับจุดรหัส (code point) ได้ ซึ่งจะเกิดขึ้นกับการเข้ารหัสฟอนต์ที่ไม่มีตาราง ToUnicode

procedure ExtractAllText(Pdf: TPdf; Output: TStrings);
var
  Page, I: Integer;
  Line: string;
  Ch: WideChar;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := Page;
    Line := '';
    for I := 0 to Pdf.CharacterCount - 1 do
    begin
      if Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I] then
        Continue;
      Ch := Pdf.Character[I];
      if Ch = #13 then
        Ch := #10;   // normalize CR to LF
      Line := Line + Ch;
    end;
    Output.Add(Line);
  end;
end;

ผลลัพธ์ที่ได้คือสตริงแบน ๆ ของจุดรหัส Unicode ตามลำดับที่ PDFium แสดงรายการไว้ ซึ่งเป็นลำดับที่ปรากฏในกระแสเนื้อหา (content stream) ไม่จำเป็นต้องเป็นลำดับการอ่านจากซ้ายไปขวา สำหรับเอกสารอักษรละตินส่วนใหญ่ที่สร้างโดยเครื่องมือสำนักงานมาตรฐานแบบนี้จะใช้ได้ สำหรับ PDF ที่สแกนมาซึ่งผ่านกระบวนการ OCR ด้วยลำดับไกลฟ์ที่ผิดปกติ หรือสำหรับข้อความที่อ่านจากขวาไปซ้าย ลำดับดังกล่าวอาจไม่ถูกต้อง นั่นคือช่วงเวลาที่ ReadablePageContent จะมีประโยชน์มากกว่า

การแยกโครงสร้างด้วย ReadablePageContent

ReadablePageContent จะก้าวขึ้นไปอีกระดับหนึ่ง: มันจะส่งคืนเรคคอร์ด TPdfReadableContent ซึ่งอาร์เรย์ Fragments จะเก็บส่วนย่อยเนื้อหาที่มีแท็กกำกับ แต่ละชิ้นจะมี Kind ที่ระบุย่อหน้า หัวเรื่อง รายการในลิสต์ เซลล์ตาราง และอื่น ๆ เมื่อ PDF มีแผนผังโครงสร้าง (ตรวจสอบด้วย Pdf.IsTagged) แหล่งที่มาจะเป็น rosStructure และลำดับการอ่านจะมีผลผูกพันอย่างเป็นทางการ สำหรับไฟล์ที่ไม่มีแท็ก PDFium จะถอยกลับไปใช้ rosHeuristic ซึ่งจะจัดกลุ่มอักขระตามกล่องขอบเขต (bounding boxes) เป็นหน่วยการอ่านที่น่าจะเป็นไปได้ แต่ไม่สามารถรับประกันความถูกต้องแม่นยำได้

procedure ExtractStructured(Pdf: TPdf; Output: TStrings);
var
  Page: Integer;
  Content: TPdfReadableContent;
  Fragment: TPdfContentFragment;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Content := Pdf.ReadablePageContent(Page);
    for Fragment in Content.Fragments do
    begin
      case Fragment.Kind of
        cfHeading   : Output.Add('# ' + Fragment.Text);
        cfParagraph : Output.Add(Fragment.Text);
        cfListItem  : Output.Add('- ' + Fragment.Text);
      else
        Output.Add(Fragment.Text);
      end;
    end;
  end;
end;

หาก Content.Source = rosHeuristic and ผลลัพธ์ของคุณดูสับสน เลเยอร์ข้อความของเอกสารอาจไม่ได้ถูกเขียนขึ้นโดยคำนึงถึงลำดับการอ่าน ณ จุดนั้นการแก้ไขที่น่าเชื่อถือเพียงอย่างเดียวคือการส่งออกใหม่จากแอปพลิเคชันต้นทางพร้อมกับแท็กที่เหมาะสม หรือดำเนินขั้นตอนหลังการประมวลผลที่จัดเรียงต้นกำเนิดของอักขระตาม Y แล้วตามด้วย X

สิ่งที่ CharacterOrigin และ CharacterRectangle มอบให้คุณ

พร็อพเพอร์ตี้ทั้งสองจะส่งคืนตำแหน่งของอักขระในพื้นที่หน้าเว็บ (พอยต์, ต้นกำเนิดที่มุมล่างซ้าย, Y เพิ่มขึ้นด้านบน) CharacterOrigin[i] คือจุดยึดเส้นฐานของไกลฟ์; CharacterRectangle[i] คือกล่องขอบเขตแบบเต็ม สิ่งเหล่านี้คือบล็อกก่อร่างสำหรับสิ่งใด ๆ ที่นอกเหนือจากข้อความธรรมดา: การตรวจจับขอบเขตคอลัมน์ การจัดกลุ่มอักขระเป็นบรรทัดโดยการเปรียบเทียบพิกัด Y ภายในค่าเผื่อ (tolerance) หรือการสร้างแผนที่ทดสอบการตี (hit-test map) สำหรับการเลือกข้อความในโปรแกรมดู หากคุณต้องการค้นหาว่าอักขระตัวใดอยู่ใต้การคลิกเมาส์ ฟังก์ชัน CharacterIndexAtPos(X, Y, ToleranceX, ToleranceY) จะดำเนินการค้นหานั้นโดยตรงโดยที่คุณไม่จำเป็นต้องทำซ้ำสี่เหลี่ยม

การจัดวาง DLL ให้เข้าที่

PDFium Component มอบหมายการแยกวิเคราะห์ PDF ทั้งหมดให้กับ DLL ดั้งเดิม ไม่ว่าจะเป็น pdfium32.dll หรือ pdfium64.dll ขึ้นอยู่กับแพลตฟอร์มเป้าหมายของคุณ คอมโพเนนต์จัดส่งสคริปต์ CopyDlls.bat ซึ่งจะคัดลอกไฟล์ที่ถูกต้องไปยังไดเรกทอรีระบบของ Windows การสั่งงานในฐานะผู้ดูแลระบบ (Administrator) เพียงครั้งเดียวบนเครื่องพัฒนาซอฟต์แวร์นั้นเพียงพอแล้ว; สำหรับการปรับใช้จริง คุณต้องคัดลอก DLL ควบคู่ไปกับไฟล์ปฏิบัติการของแอปพลิเคชันแทน รุ่นที่เปิดใช้งาน V8 (pdfium32v8.dll, pdfium64v8.dll) จะมีขนาดใหญ่กว่าอย่างมาก และจำเป็นเฉพาะเมื่อ PDF ของคุณมี JavaScript ที่ต้องประมวลผลเท่านั้น สำหรับการแยกข้อความบริสุทธิ์ รุ่นมาตรฐานคือตัวเลือกที่ถูกต้อง

หากไม่มี DLL อยู่ในขณะรันไทม์ การรัน Active := True จะล้มเหลวอย่างเงียบเชียบเช่นเดียวกับกรณีไฟล์ขาดหาย เนื่องจากคอมโพเนนต์จะตรวจจับข้อผิดพลาดในการโหลดเป็นการภายใน โปรดทดสอบบนเครื่องที่สะอาดก่อนส่งมอบงานเสมอ

การใช้ FontSize[] ควบคู่ไปกับ Character[] สำหรับการวิเคราะห์เค้าโครง

นอกเหนือจากข้อความธรรมดา API ในระดับอักขระยังเปิดเผย FontSize[i] ซึ่งส่งคืนขนาดพอยต์เรนเดอร์ของไกลฟ์แต่ละตัว เมื่อผสมผสานกับ CharacterOrigin[i] และ CharacterRectangle[i] สิ่งนี้จะช่วยให้คุณจำแนกข้อความหลักออกจากหัวเรื่องได้โดยไม่ต้องพึ่งพาแผนผังโครงสร้าง กลุ่มอักขระที่ขนาดฟอนต์กระโดดสูงกว่าเกณฑ์ที่กำหนดนั้นเกือบจะเป็นหัวเรื่องอย่างแน่นอนในเอกสารที่ไม่มีแท็ก เทคนิคเดียวกันนี้ใช้กับการตรวจจับคำบรรยายภาพ (ข้อความขนาดเล็กใต้กล่องขอบเขตภาพ) หรือเชิงอรรถ (ข้อความขนาดเล็กใกล้ด้านล่างของหน้า) ทั้งหมดนี้ไม่จำเป็นต้องมีการเรนเดอร์; พร็อพเพอร์ตี้ทั้งสามตัวอ่านข้อมูลโดยตรงจากเลเยอร์ข้อความที่ PDFium สร้างขึ้นในระหว่าง Active := True

ความละเอียดอ่อนประการหนึ่ง: FontSize[i] สะท้อนขนาดหลังจากใช้ CTM (เมทริกซ์การแปลงปัจจุบัน) ของหน้าเว็บแล้ว ดังนั้นเอกสารที่ผู้เขียนปรับขนาดทั้งหน้าจะรายงานขนาดที่ปรับปรุงตามสัดส่วน หากคุณเปรียบเทียบขนาดระหว่างหน้ากระดาษที่มีขนาดหน้าต่างกัน ให้ปรับให้เป็นมาตรฐานเทียบกับความสูง MediaBox ของแต่ละหน้าก่อนจะตัดสินใจเลือกเกณฑ์ค่า

การเขียนผลลัพธ์ไปยังไฟล์

TStringList ของ Delphi จัดการผลลัพธ์ UTF-8 ได้อย่างถูกต้องตั้งแต่เวอร์ชัน XE กำหนดค่า WriteBOM := False หากคุณต้องการไฟล์ที่ไม่มี BOM (ผู้ใช้ปลายน้ำจำนวนมากมักมีปัญหาขัดข้องกับ BOM นำหน้า):

var
  Lines: TStringList;
begin
  Lines := TStringList.Create;
  try
    ExtractAllText(Pdf, Lines);
    Lines.WriteBOM := False;
    Lines.SaveToFile('output.txt', TEncoding.UTF8);
  finally
    Lines.Free;
  end;
end;

สำหรับเอกสารขนาดใหญ่มากซึ่งมีความกังวลเกี่ยวกับหน่วยความจำ ให้เขียนโดยตรงไปยัง TStreamWriter พร้อม TEncoding.UTF8 ภายในลูปหน้าเว็บ แทนที่จะสะสมข้อมูลทุกอย่างลงในรายการลิสต์ก่อนในตอนแรก

API ของ Character[], CharacterCount, CharacterOrigin[], CharacterRectangle[], ReadablePageContent และ CharacterIndexAtPos ที่แสดงที่นี่เป็นส่วนหนึ่งของ PDFium Component สำหรับ Delphi และ C++Builder