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

แยกข้อความจาก PDF ที่โหลดใน Delphi ด้วย HotPDF

คอมโพเนนต์ HotPDF Component สามารถแยกข้อความ Unicode จาก PDF ใดๆ ที่คุณโหลดใน Delphi ผ่านการเรียกใช้ฟังก์ชันสองตัว: ExtractLoadedPageText จะส่งกลับข้อความตามลำดับการอ่านของหน้า และ ExtractLoadedPageTextLayout (เพิ่มเข้ามาในเวอร์ชัน v2.263.0) จะสร้างการจัดวางภาพของหน้าเอกสารขึ้นใหม่เป็นข้อความธรรมดา เพื่อให้คอลัมน์ การเยื้อง และการจัดตำแหน่งตารางยังคงอยู่ในผลลัพธ์ ทั้งสองฟังก์ชันนี้สามารถทำงานกับเอกสารที่ HotPDF ไม่ได้สร้างขึ้นได้ ซึ่งเป็นกรณีการใช้งานจริงที่สำคัญที่สุด: เช่น ใบแจ้งหนี้ที่ลูกค้าส่งอีเมลมาหาคุณ รายงานจากสำนักสแกนเอกสาร หรือสัญญาที่สร้างขึ้นจากซอฟต์แวร์ที่ไม่มีใครจำชื่อได้แล้ว

การจะทำเช่นนั้นได้ต้องอาศัยกลไกการทำงานที่ซับซ้อนกว่าที่การเรียกใช้ทั้งสองแบบแสดงให้เห็น เนื่องจาก PDF ไม่ได้จัดเก็บข้อความในลักษณะเดียวกับไฟล์ข้อความทั่วไป บทความนี้จะนำคุณไปทำความเข้าใจโหมดการแยกข้อความทั้งสองแบบ จากนั้นจะเปิดเผยส่วนประกอบสามส่วนที่อยู่เบื้องหลัง — ได้แก่ ตัวอ่าน CMap ตัวตีความสตรีมเนื้อหา และห่วงโซ่การถดถอยกลับของการถอดรหัสฟอนต์ — เพราะการเข้าใจวิธีการแมปข้อมูลนี้คือสิ่งที่จะสร้างความแตกต่างระหว่างการมองผ่านข้อความผลลัพธ์ที่เป็นขยะกับการวิเคราะห์หาสาเหตุที่แท้จริง

ทำไมการแยกข้อความจึงยากกว่าการอ่านสตริงธรรมดาจากไฟล์?

สตรีมเนื้อหาของ PDF จะบันทึกรหัสอักขระ ไม่ใช่อักขระจริง ตัวดำเนินการ Tj และ TJ (ISO 32000-1 §9.4.3) จะเก็บสตริงของไบต์ซึ่งความหมายของมันจะขึ้นอยู่กับฟอนต์ที่เลือกโดยคำสั่ง Tf ก่อนหน้า: ไบต์ 0x41 อาจหมายถึงอักษร A ภายใต้ WinAnsi, อาจเป็น glyph ทั่วไปในฟอนต์ชุดย่อย หรือเป็นครึ่งหนึ่งของ CID ขนาดสองไบต์ในฟอนต์ภาษา CJK มาตรฐาน ISO 32000-1 §9.10 ได้จำกัดความการแยกข้อความว่าเป็นปัญหาของการถอดรหัสนี้ — นั่นคือการจับคู่รหัสแต่ละตัวกลับไปเป็น Unicode โดยใช้ข้อมูลใดๆ ที่พจนานุกรมฟอนต์มีให้ — และมาตรฐานดังกล่าวระบุไว้อย่างชัดเจนว่าไฟล์ที่เป็นไปตามข้อกำหนดไม่จำเป็นต้องให้ข้อมูลที่เพียงพอต่อการดำเนินการดังกล่าว

วรรคสุดท้ายนั้นช่วยอธิบายรายงานปัญหาประเภท "ทำไมการคัดลอกและวางจาก PDF นี้จึงกลายเป็นอักษรขยะ" ที่คุณเคยพบเจอมาทั้งหมด ตัวสร้างเอกสารที่ฝังฟอนต์ชุดย่อยโดยไม่มีตาราง /ToUnicode จะทำให้ได้ไฟล์ที่แสดงผลได้อย่างสมบูรณ์แบบแต่แยกข้อความออกมาเป็นขยะที่ไม่สมเหตุสมผล เนื่องจากมีแผนผังการจับคู่ระหว่างรหัสกับ glyph แต่ไม่เคยส่งแผนผังระหว่างรหัสกับ Unicode มาด้วย ดังนั้น API การแยกข้อความที่ตรงไปตรงมาจึงเป็นห่วงโซ่ความพยายามอย่างดีที่สุดในการย้อนกลับข้อมูล และคำถามที่มีประโยชน์คือห่วงโซ่นี้ลึกเพียงใด

การแยกข้อความตามลำดับการอ่านด้วย ExtractLoadedPageText

สำหรับการทำดัชนีเพื่อค้นหา การค้นหาด้วยคำสำคัญ หรือการส่งข้อความไปยังไพป์ไลน์การวิเคราะห์ ฟังก์ชัน ExtractLoadedPageText คือสิ่งที่คุณต้องการ รูปแบบคือ function ExtractLoadedPageText(PageIndex: Integer; out AText: UnicodeString): boolean — ดัชนีหน้ากระดาษเริ่มจากศูนย์ ผลลัพธ์จะถูกส่งกลับมาเป็น UnicodeString ในตัวของ Delphi และฟังก์ชันจะส่งกลับค่า False เมื่อหน้านั้นไม่มีสตรีมเนื้อหาที่สามารถอ่านได้ แทนที่จะสร้างข้อยกเว้น (exception)

var
  Pdf: THotPDF;
  PageCount, I: Integer;
  PageText, AllText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('invoice.pdf');
    AllText := '';
    for I := 0 to PageCount - 1 do
      if Pdf.ExtractLoadedPageText(I, PageText) then
        AllText := AllText + PageText + #13#10;
    // ตอนนี้ AllText จะเก็บข้อความตามลำดับการอ่านของเอกสาร
  finally
    Pdf.Free;
  end;
end;

การขึ้นบรรทัดใหม่ในข้อความผลลัพธ์มาจากการประเมินแบบง่ายๆ โดยเจตนา: เมื่อพิกัดแกนตั้งของ glyph มีการเปลี่ยนแปลงมากกว่าครึ่งหนึ่งของขนาดฟอนต์ปัจจุบัน — ซึ่งเป็นลักษณะเฉพาะของการเปลี่ยนคำสั่ง Td หรือ T* ในสตรีมเนื้อหา — จะทำการขึ้นบรรทัดใหม่ ตัวอักษรที่ตัวถอดรหัสไม่สามารถแปลความหมายได้จะกลายเป็นช่องว่างแทนที่จะหายไปทั้งหมด ดังนั้นขอบเขตของคำจึงยังคงอยู่แม้ว่า glyph แต่ละตัวจะไม่สามารถแปลงได้ก็ตาม สิ่งที่โหมดนี้ไม่ได้พยายามทำคือการจัดกลุ่มตามลำดับการอ่านหรือการตรวจหาหน้ากระดาษที่มีหลายคอลัมน์: หน้ากระดาษแบบสองคอลัมน์จะถูกนำออกมาปะปนกันตามลำดับของสตรีมเนื้อหา ซึ่งโดยทั่วไปมักจะเป็นลำดับทางสายตาแต่อาจไม่ใช่แบบนั้นเสมอไป

เมื่อใดที่คุณควรใช้การแยกข้อความแบบคงเค้าโครงเดิมแทน?

ฟังก์ชัน ExtractLoadedPageTextLayout เป็นตัวเลือกที่ถูกต้องเมื่อใดก็ตามที่ตำแหน่งของข้อมูลมีความหมาย: เช่น ตาราง แบบฟอร์ม รายการโค้ด หรือข้อมูลใดๆ ที่คุณต้องการนำไปวิเคราะห์ความต่าง (diff) ใช้ grep ค้นหา หรือแยกวิเคราะห์ตามคอลัมน์ แทนที่จะทำให้ glyph แบนราบเป็นสตรีมข้อมูล มันจะจัดกลุ่ม glyph เหล่านั้นให้อยู่ในแนวเดียวกัน เรียงลำดับแต่ละแนวตามแกน X และสร้างช่องว่างแนวนอนและแนวตั้งขึ้นใหม่บนตารางอักขระความกว้างคงที่ (monospaced) ซึ่งมีขนาดวัดจากค่ามัธยฐานของการเลื่อน glyph และขนาดฟอนต์ ช่องว่างขนาดใหญ่ระหว่างข้อความในแนวเดียวกันจะกลายเป็นช่องว่าง ส่วนช่องว่างขนาดใหญ่ระหว่างแนวจะกลายเป็นบรรทัดว่าง ทำให้ผลลัพธ์ที่ได้สามารถอ่านได้เหมือนกับหน้ากระดาษที่ปรากฏทางสายตา

var
  Grid: UnicodeString;
begin
  if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
    TFile.WriteAllText('page1.txt', Grid, TEncoding.UTF8);
  // คอลัมน์ การเยื้อง และการจัดตำแหน่งตารางยังคงอยู่เป็น
  // ช่องว่างและบรรทัดว่างบนตารางอักขระ
end;

ทั้งสองโหมดนี้ใช้กลไกการถอดรหัสร่วมกันทุกส่วน และแตกต่างกันเฉพาะวิธีการจัดวาง glyph ที่ถอดรหัสได้เท่านั้น ดังนั้นการเลือกจึงไม่มีผลต่อความแม่นยำของข้อมูล เลือก ExtractLoadedPageText เมื่อคุณต้องการเฉพาะถ้อยคำ และเลือก ExtractLoadedPageTextLayout เมื่อการจัดวางมีความสำคัญ ส่วนการตรวจหาลำดับการอ่านแบบหลายคอลัมน์ยังคงอยู่นอกเหนือขอบเขตการทำงานของทั้งสองโหมด — ตารางแสดงผลของหน้าแบบสองคอลัมน์จะแสดงให้คุณเห็นทั้งสองคอลัมน์เคียงข้างกันอย่างถูกต้อง ซึ่งเหมาะอย่างยิ่งสำหรับการตรวจสอบความต่าง แต่จะไม่เหมาะสำหรับการไหลของข้อความประโยคต่อเนื่อง

HotPDF ถอดรหัสอักขระกลับไปเป็น Unicode อย่างไร?

คอมโพเนนต์ HotPDF Component จะแปลความหมายของรหัสอักขระแต่ละตัวผ่านห่วงโซ่การถดถอยตามลำดับความสำคัญ: เริ่มต้นจากตาราง /ToUnicode CMap ที่ฝังอยู่ในฟอนต์เป็นอันดับแรก จากนั้นเป็นข้อมูลรายการ /Encoding (สตรีม หรือ CMap ที่ระบุชื่อ) และสำหรับฟอนต์คอมโพสิต — จะตรวจสอบไฟล์ CMap มาตรฐานของ Adobe สำหรับการรวบรวมอักขระ เช่น Adobe-GB1, Adobe-CNS1, Adobe-Japan1 และ Adobe-KR และขั้นตอนสุดท้ายคือตาราง WinAnsi และ MacRoman ที่ติดตั้งมาในตัวสำหรับฟอนต์แบบธรรมดา หากขั้นตอนใดขั้นตอนหนึ่งไม่สามารถให้คำตอบได้ ระบบจะถดถอยไปยังขั้นตอนถัดไปอย่างเงียบๆ แทนที่จะแจ้งข้อผิดพลาด และหากไม่พบคำตอบจากทุกขั้นตอนในห่วงโซ่ จะส่งกลับค่าเป็น 0 เพื่อให้ผู้เรียกใช้สามารถนับจำนวนข้อผิดพลาดแทนที่จะเป็นการเดาข้อมูล

ตาราง /ToUnicode CMap (ISO 32000-1 §9.10.3) อยู่เป็นลำดับแรกเนื่องจากเป็นแผนผังการจับคู่ที่ผู้สร้างเขียนขึ้นมาโดยเฉพาะสำหรับการแยกข้อมูล เส้นทาง CMap มาตรฐานของ Adobe มีความสำคัญสำหรับเอกสารภาษา CJK ที่ใช้ CMap ที่กำหนดไว้ล่วงหน้า เช่น UniGB-UTF16-H แทนที่จะฝังไว้: HotPDF จะส่งไฟล์เหล่านั้นมาให้ภายใต้ไดเรกทอรี resources\CMap โดยจะค้นหาตามความสัมพันธ์กับไฟล์รันไทม์ และทำการแคชแผนผังแต่ละอันแยกเป็นรายกระบวนการ — ซึ่งข้อนี้มีประโยชน์เนื่องจากแผนผังที่มีขนาดใหญ่ที่สุดอย่าง Adobe-GB1 จะมีขนาดข้อความประมาณ 2 MB ซึ่งคุณคงไม่ต้องการให้วิเคราะห์ใหม่ในทุกๆ หน้า หากไม่มีไดเรกทอรีนี้ ตัวถอดรหัสจะข้าม CMaps ที่พึ่งพาดิสก์ไป และทำงานเฉพาะตารางที่ฝังควบคู่กับตัวเข้ารหัสในตัว นี่คือกุญแจสะท้อนฝั่งการอ่านของปัญหาเรื่องการจัดรูปร่างตัวอักษรที่กล่าวไว้ในการจัดรูปร่างข้อความอักษรซับซ้อนด้วย HotPDF ซึ่งมีการแยกความแตกต่างระหว่างรหัสกับ glyph เช่นเดียวกันในฝั่งผู้เขียน

กับดักทางไวยากรณ์ของ CMap สองจุดที่ควรระวัง

ไฟล์ CMap ดูเหมือนจะวิเคราะห์ไวยากรณ์ได้ง่ายแต่จริงๆ แล้วไม่ใช่ และมีรายละเอียดสองประการที่เป็นสาเหตุของความล้มเหลวในการสร้างตัววิเคราะห์ครั้งแรก ประการแรกคือจำนวนเรกคอร์ดจะอยู่ ก่อน คำสำคัญของส่วนข้อมูล: เช่น ส่วนข้อมูลจะเขียนว่า 2 beginbfchar ไม่ใช่ beginbfchar 2 ตัววิเคราะห์ที่คาดหวังจะพบจำนวนเรกคอร์ดหลังคำสำคัญจะรับรู้ตัวเลขนั้นเป็นโทเค็นส่วนเกิน แล้วประเมินว่ามีรายการเป็นศูนย์ในทุกส่วน วิธีการแก้ไขที่ทนทาน — ซึ่งตัวอ่านของ HotPDF เลือกใช้ — คือการละเว้นจำนวนเรกคอร์ดทั้งหมดและวนลูปจนกว่าจะพบคีย์เวิร์ด endbfchar / endbfrange ที่สอดคล้องกัน ซึ่งช่วยให้ทำงานกับไฟล์จริงที่มีค่าจำนวนเรกคอร์ดผิดพลาดได้อย่างราบรื่น

กับดักข้อที่สองคือเป้าหมายของ bfchar และ bfrange เป็นสตริง UTF-16BE สตริง ไม่ใช่จำนวนเต็ม ค่าปลายทาง <D83DDE00> หมายถึง U+1F600 — ซึ่งเป็นคู่ซูโรเกต (surrogate pair) ที่ต้องรวมกลับเป็นหนึ่งโค้ดพอยต์ (code point) และการอ่านไบต์ทั้งสี่นี้เป็นจำนวนเต็มแบบบิ๊กเอนเดียน (big-endian) จะทำให้ได้ค่าที่ไม่มีความหมายในทุกจุดรหัสที่อยู่นอก Basic Multilingual Plane อีโมจิใน PDF ไม่ใช่เรื่องแปลกใหม่อีกต่อไป ดังนั้นตัวถอดรหัสที่ข้ามขั้นตอนการรวมคู่ซูโรเกตจะล้มเหลวในการทำงานกับไฟล์จริงของผู้ใช้ของคุณ HotPDF จะแปลงข้อความฐานสิบหกเป็นไบต์ดิบก่อน จากนั้นจึงประกอบหน่วยรหัส UTF-16BE กลับคืน ซึ่งจะครอบคลุมถึงอักษรควบ (ligature) ที่สร้างจากหลายอักขระอีกด้วย

การเข้าถึงระดับ glyph ด้วย ExtractLoadedPageGlyphs

ฟังก์ชันข้อความทั้งสองถูกสร้างขึ้นบน ExtractLoadedPageGlyphs และโครงสร้าง THPDFGlyphArray ที่อยู่เบื้องหลังยังคงสามารถใช้งานได้ในรหัสของคุณเช่นกัน THPDFGlyphRecord แต่ละรายการจะเก็บค่าโค้ดพอยต์ Unicode ที่แปลผลแล้ว ควบคู่กับรหัสอักขระดิบ ขนาดความกว้างไบต์ของรหัส (1, 2 หรือ 4 ตามขนาด codespacerange ของ CMap) คีย์ทรัพยากรฟอนต์และขนาดที่ใช้งาน พิกัดจุดเริ่มต้น X และ Y และระยะเลื่อนแนวนอน ซึ่งเพียงพอสำหรับการนำไปประยุกต์ตรวจหาขอบเขตของคำ การเน้นสีข้อความตามพิกัด หรือสร้างตรรกะการจัดวางโครงสร้างแบบกำหนดเองได้โดยตรง

var
  Glyphs: THPDFGlyphArray;
  I, Unresolved: Integer;
begin
  if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
  begin
    Unresolved := 0;
    for I := 0 to High(Glyphs) do
      if Glyphs[I].Unicode = 0 then
        Inc(Unresolved);
    if Unresolved > 0 then
      ShowMessageFmt('%d จากทั้งหมด %d glyphs ไม่มีรายละเอียดการจับคู่ Unicode',
        [Unresolved, Length(Glyphs)]);
  end;
end;

การนับเรกคอร์ดที่ Unicode = 0 ดังแสดงข้างต้น เป็นวิธีการที่ตรงไปตรงมาที่สุดในการวัดคุณภาพการแยกข้อความในเอกสารที่กำหนด ก่อนที่คุณจะนำข้อความนั้นไปประมวลผลต่อในระบบ เรกคอร์ดของ glyph ยังช่วยยึดโยงอักขระแต่ละตัวเข้ากับโอเปอแรนด์ต้นทางในสตรีมเนื้อหา ซึ่งทำให้การค้นหาและการแทนที่ข้อความในเอกสารที่โหลดขึ้นมาของ HotPDF ทำงานได้บนรากฐานเดียวกันนี้

PDF แบบใดที่ไม่สามารถแยกข้อความออกมาได้?

ไฟล์บางประเภทอาจทำให้ไม่สามารถสกัดข้อความใดๆ ออกมาได้เลย และจะดีกว่าหากเราตรวจพบปัญหาเหล่านั้นล่วงหน้า เอกสารสแกนเป็นกรณีที่ชัดเจนที่สุด: หน้ากระดาษที่เป็นภาพขนาดใหญ่เพียงภาพเดียวจะไม่มีคำสั่งจัดการข้อความใดๆ เลย ดังนั้นการแยกข้อความจึงส่งกลับค่าเป็นสตริงว่างอย่างถูกต้อง — ทางแก้ไขคือการทำ OCR และการแยกรูปภาพออกจากไฟล์ PDF ที่โหลดขึ้นมา จะเป็นขั้นตอนแรกของระบบนั้น ฟอนต์ชุดย่อยที่ไม่มีตาราง /ToUnicode จะเป็นกรณีที่ยากกว่า: หากไม่มีข้อมูลเส้นทาง /Encoding และตาราง CMaps มาตรฐานด้วย glyph เหล่านั้นจะถูกระบุค่าเป็น 0 และแสดงผลออกมาเป็นช่องว่างในระหว่างการเรียกใช้ข้อความ เอกสารที่เข้ารหัสไว้จะแยกข้อความได้ตามปกติหากคุณโหลดเอกสารเหล่านั้นพร้อมรหัสผ่านผ่านทาง LoadFromFile เพื่อถอดรหัสสตรีมข้อมูลก่อนที่ตัวแปลคำสั่งจะเข้ามาจัดการประมวลผล

ข้อจำกัดที่ควรชี้แจงให้ชัดเจนคือ: ห่วงโซ่การถอดรหัสจะอ่าน CMap และสตรีมเนื้อหาผ่านทางเดิน Flate ของ HotPDF ดังนั้นฟอนต์ที่สตรีม ToUnicode ใช้ตัวกรองที่ไม่คุ้นเคยจะถูกปรับลดระดับการทำงานไปยังกลวิธีถัดไปแทนที่จะทำผิดพลาดทั้งหน้ากระดาษ ในทางปฏิบัติ FlateDecode ครอบคลุมการทำงานเกือบทั้งหมดที่สร้างขึ้นในช่วงสองทศวรรษที่ผ่านมา และการลดระดับนี้จะเป็นไปอย่างเงียบเชียบโดยเจตนา — เพื่อให้คุณได้รับข้อความที่ดีที่สุดเท่าที่ไฟล์จะเอื้ออำนวยแทนการเกิดข้อยกเว้น กลไกวัตถุฝั่งการอ่านที่ช่วยแปลพจนานุกรมฟอนต์ในจุดนี้ยังทำหน้าที่เป็นกลไกขับเคลื่อนการแก้ไขเมทาดาตาในเอกสารที่โหลดขึ้นมา เพื่อให้ไพป์ไลน์การประมวลผลเอกสารสามารถแยกข้อมูล ตรวจสอบ และเขียนบันทึกเพิ่มเติมได้ในคราวเดียว

การแยกข้อความ การเรนเดอร์แบบคงเค้าโครงเดิม การเข้าถึงในระดับ glyph และฟีเจอร์การค้นหาและแทนที่ข้อมูล ทั้งหมดนี้เป็นส่วนหนึ่งของมาตรฐาน HotPDF Component สำหรับ Delphi และ C++Builder — ไม่จำเป็นต้องใช้ DLLs ภายนอก ไม่มีบริการข้อความจาก OS มีเพียง Object Pascal ที่คุณสามารถดีบักตรวจสอบการทำงานทีละบรรทัดได้เมื่อมีไฟล์แปลกๆ เข้ามาในคิวประมวลผล