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

การแยกข้อความ รูปภาพ และฟอนต์จาก PDF ใน Delphi ด้วย PDF Library for Delphi

การดึงข้อความ รูปภาพ และฟอนต์ออกจาก PDF ที่มีอยู่แล้วฟังดูเหมือนเป็นปัญหาที่แก้ไขได้แล้ว จนกว่าคุณจะรันคลังเอกสารจริงผ่านมันดู ลองชี้ search indexer ไปที่ไฟล์ลูกค้าสี่หมื่นไฟล์ แล้วความเสียหายจะแยกออกเป็นกลุ่มที่จำได้ไม่กี่แบบ คำต่าง ๆ ติดกันเป็นพืดเพราะไม่มีใครบอกตัว extractor ว่าช่องว่างกว้างเท่าใดจึงนับเป็น space หน้าอื่น ๆ กลับมาเป็นอักขระที่อ่านไม่ออก เพราะฟอนต์ที่ถูก subset ไม่มี map จาก glyph code ไปสู่ตัวอักษรจริง และ "โลโก้บริษัท" กลับกลายเป็นภาพวัตถุแยกกันเก้าชิ้นที่ซ้อนกันอยู่หลัง soft mask ไม่มีอันไหนเลยที่เป็นบั๊กของไลบรารี มันคือความแตกต่างระหว่างการเรียกฟังก์ชัน extraction กับการเข้าใจว่าฟังก์ชันนั้นกู้คืนอะไรได้และกู้คืนอะไรไม่ได้จาก byte บนดิสก์

losLab PDF Library รุ่น Pascal มอบให้โค้ด Delphi และ C++Builder มีวิธีอ่านสตรีมทั้งสามแบบนี้มากกว่าหนึ่งวิธี และแต่ละระดับก็รับประกันสิ่งที่ต่างกัน เคล็ดลับคือการจับคู่ระดับให้เหมาะกับงาน: search index, ตัวรีวิว redaction และการ preflight แบบ PDF/A ล้วนต้องการสิ่งที่ต่างกันจากหน้าเดียวกัน และการเรียกใช้คำสั่งผิดตัวจะเสียแรงเปล่าหรือสร้าง output ที่คุณไม่สามารถไว้ใจได้

ระดับของการดึงข้อความ และสิ่งที่แต่ละระดับรับประกัน

GetPageText รับค่า options ตั้งแต่ 0 ถึง 8 และตัวเลขนั้นเลือก engine ไม่ใช่เลือกรูปแบบไฟล์ ค่า 0 ถึง 2 รันแบบ lightweight ที่เหมาะสำหรับพรีวิวอย่างรวดเร็ว ค่า 3 ถึง 8 จะผ่าน engine ที่รับรู้ layout ซึ่งสร้างบรรทัดและระยะห่างขึ้นใหม่จากตำแหน่งจริงที่ glyph วางอยู่บนหน้ากระดาษ ภายในช่วงนั้นความแตกต่างมีความสำคัญ: 4 และ 6 แยก output ออกเป็นคำ ๆ, 5 และ 6 ให้ค่าความกว้างต่อ glyph และ 7 คืนข้อความล้วนโดยตั้งใจตัด metadata ของฟอนต์ สี และ block ออกไป ตัวเลือก 7 คือตัวที่ควรป้อนให้ search index เพราะ index ต้องการแค่คำเท่านั้นและไม่ต้องการอะไรอื่น

ไม่มีการตั้งค่า option ใดกู้เอกสารที่ไม่เคยมีข้อมูลนั้นอยู่ตั้งแต่แรกได้ PDF แม็ป character code ไปสู่รูปทรง glyph และสิ่งเดียวที่แม็ป code เหล่านั้นกลับไปเป็นข้อความที่อ่านได้คือ ToUnicode CMap ของฟอนต์ (ISO 32000-1 §9.10) เมื่อฟอนต์ที่ถูก subset มาโดยไม่มี CMap นั้น ตัว extractor ทุกตัวจะติดขัดหมด ไม่ว่าจะเป็นไลบรารีนี้ การ copy-paste ในโปรแกรมแสดงผล หรือ toolkit คู่แข่งใด ๆ ทั้งหมดต่างต้องเดาจากชื่อ glyph หรือไม่ก็คืนค่าว่างเปล่า คำตอบเชิงปฏิบัติคือการตรวจจับ ไม่ใช่ความกล้าหาญ ให้คะแนนหน้านั้นว่ามีความเชื่อมั่นต่ำและส่งไปทำ OCR เพราะการทำดัชนีขยะไปเงียบ ๆ นั้นแย่กว่าการยอมรับว่าคุณอ่านมันไม่ได้

แผนภาพระดับการสกัดข้อความ PDF ของ Delphi: ออปชัน GetPageText 0 ถึง 8 เลือกเข้าสู่รอบงานเบาหรือเอนจินที่รู้เลย์เอาต์ และหน้าที่ฟอนต์แบบ subset ไม่มี ToUnicode CMap จะสำรองไปใช้ OCR
ค่าออปชัน 0 ถึง 8 ของ GetPageText เลือกระหว่างการประมวลผลพรีวิวเบา ๆ กับเอนจินที่รู้เลย์เอาต์ โดยออปชัน 7 สงวนไว้สำหรับดัชนีค้นหา และ ToUnicode CMap ที่ขาดหายถูกส่งต่อไปยัง OCR

สำหรับกรณีที่ตัวเลือกแบบเรียบง่ายไม่ครอบคลุม ไม่ว่าจะเป็นการทำ tokenization แบบกำหนดเอง การพิสูจน์หลักฐานระดับ content-stream หรือ text funnel ที่สร้างขึ้นตามกฎของคุณเอง ตัว decoder อยู่ต่ำลงไปอีกหนึ่งชั้น TPDFExtractor ถูกสร้างขึ้นบน resources dictionary และชุดฟอนต์ของหน้าเอกสาร เมธอด ExtractTextW ของมันรันคำสั่งข้อความดิบใน content-stream ย้อนกลับผ่านกลไกฟอนต์ชุดเดียวกันเพื่อกู้คืน Unicode และ event OnFindObject ของมันจะส่งมอบแต่ละ object ให้คุณขณะที่มันไหลผ่าน โค้ดส่วนใหญ่ไม่มีความจำเป็นต้องลงลึกไปถึงระดับนี้ แอปพลิเคชันที่ต้องใช้คือกลุ่มที่ดีใจที่ชั้นนี้เปิดเผยต่อสาธารณะแทนที่จะถูกฝังไว้

Positioned block: หน่วยของผลลัพธ์การค้นหาและการรีวิว redaction

ข้อความล้วนบอกคุณว่าหน้ากระดาษพูดอะไร แต่ไม่ช้าก็เร็วผลิตภัณฑ์ก็ต้องรู้ด้วยว่ามันพูดตรงไหน เพื่อให้ไฮไลต์ผลการค้นหา วาดกรอบรอบผู้สมัคร redaction หรือยึด annotation ไว้ในตำแหน่งที่ถูกต้อง ExtractPageTextBlocks คืนค่า handle ของลิสต์ข้อความที่วิ่งต่อเนื่องกัน (text run) โดยแต่ละ run พกพาข้อความของมัน bounding box ของมัน และชื่อฟอนต์กับขนาดที่มันถูกตั้งไว้:

var
  Pdf: TPDFlib;
  Blocks, I: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('contract.pdf', '') <> 1 then
      raise Exception.Create('load failed');
    Pdf.SelectPage(1);
    Blocks := Pdf.ExtractPageTextBlocks(0);
    for I := 0 to Pdf.GetTextBlockCount(Blocks) - 1 do
      Writeln(Format('%s  [%s %.1f pt at %.0f,%.0f]',
        [Pdf.GetTextBlockText(Blocks, I),
         Pdf.GetTextBlockFontName(Blocks, I),
         Pdf.GetTextBlockFontSize(Blocks, I),
         Pdf.GetTextBlockBound(Blocks, I, 0),
         Pdf.GetTextBlockBound(Blocks, I, 1)]));
    Pdf.ReleaseTextBlocks(Blocks);
  finally
    Pdf.Free;
  end;
end;

มีรายละเอียดหนึ่งในส่วนนี้ที่ทำให้การผสานระบบสะดุดมากกว่าเรื่องอื่นใด SetTextExtractionArea, SetTextExtractionWordGap และ SetTextExtractionOptions คือ state ระดับเอกสารที่คงอยู่ต่อเนื่อง ไม่ใช่ argument ที่คุณส่งเข้าไปในแต่ละครั้งที่เรียก ตั้งค่าข้อจำกัดพื้นที่สำหรับฟีเจอร์หนึ่ง เช่น อ่านเฉพาะแถบส่วนหัวเพื่อจำแนกประเภทเอกสาร แล้วมันจะตัดทอนทุกการดึงข้อมูลที่ตามมาบน handle เดียวกันอย่างเงียบ ๆ รวมถึงระดับของ GetPageText ที่รับรู้ layout ซึ่งคุณจะเรียกใช้ในภายหลังด้วย ให้รีเซ็ต state ของการดึงข้อมูลระหว่างงานเชิงตรรกะแต่ละงาน หรือไม่ก็ให้แต่ละงานมี document handle ของตัวเอง

threshold ของ word-gap คือคันโยกสำหรับกองความล้มเหลวกองแรกนั้น คือคำที่ติดกันเป็นพืด SetTextExtractionWordGap บอก layout engine ว่าระยะห่างแนวนอนเท่าใด วัดเทียบกับระยะห่างของ glyph ในหน้านั้นเอง ที่จะแยกคำหนึ่งออกจากคำถัดไป ตารางที่แน่นต้องการช่องว่างที่เล็กกว่าหน้าการตลาดที่จัดวางแบบหลวม ๆ ดังนั้น threshold ที่ปรับให้เหมาะกับแต่ละประเภทเอกสารจึงดีกว่าค่าคงที่แบบเดียวที่ใช้ทั่วทั้งระบบ มันคงอยู่ต่อเนื่องบนเอกสารเหมือน state อื่น ๆ ของการดึงข้อมูล จึงควรวางแผนตั้งค่ามันอย่างตั้งใจ ไม่ใช่ตั้งครั้งเดียวแล้วลืมไป

แผนภาพสถานะการสกัดระดับเอกสารของ PDF ใน Delphi ที่คงอยู่ข้ามการเรียกบน handle เดียวจนกว่าจะรีเซ็ต ซึ่งช่วยป้องกันการตัดขาดเงียบๆ ของการสกัดครั้งถัดไป
พื้นที่ extraction, word-gap และการตั้งค่าออปชันยังคงค้างอยู่บน document handle บริเวณที่กำหนดไว้ให้ฟีเจอร์หนึ่งจึงตัดทอนการ extraction หลัง ๆ ทุกครั้งอย่างเงียบ ๆ จนกว่าสถานะจะถูกรีเซ็ตหรือเปลี่ยน handle

รูปภาพ: สตรีมต้นฉบับ ไม่ใช่ screenshot

วิธีที่ผิดในการดึงรูปภาพออกจาก PDF คือการ render หน้ากระดาษแล้ว crop มัน วิธีนั้นจะ resample พิกเซล ฝังการหมุนใด ๆ เข้าไปถาวร และทิ้งสิ่งที่เป็นต้นฉบับไป GetPageImageList จะแจกแจง resource รูปภาพจริงที่หน้ากระดาษอ้างอิงถึงแทน และแต่ละรายการจะส่งคืนคุณสมบัติของมันและข้อมูลต้นฉบับที่ไม่ถูกแตะต้องเลย:

var
  ImgList, I: Integer;
begin
  Pdf.SelectPage(1);
  ImgList := Pdf.GetPageImageList(0);
  for I := 0 to Pdf.GetImageListCount(ImgList) - 1 do
  begin
    Writeln(Pdf.GetImageListItemFormatDesc(ImgList, I, 0));
    Pdf.SaveImageListItemDataToFile(ImgList, I, 0,
      Format('page1-img%.2d.bin', [I]));
  end;
  Pdf.ReleaseImageList(ImgList);
end;

ตรวจสอบ GetImageListItemFormatDesc ก่อนที่จะสันนิษฐานอะไรเกี่ยวกับรายการนั้น เพราะสิ่งที่หน้ากระดาษอ้างอิงถึงนั้นแทบไม่เคยเป็นภาพเดียวที่เรียบร้อยต่อหนึ่งภาพที่มองเห็น soft mask ปรากฏเป็น entry แยกต่างหากของตัวมันเอง XObject เดียวกันมักซ้ำกันในหลายหน้า ดังนั้นควร deduplicate ด้วย content hash ก่อนที่คุณจะเก็บ export แบบ "ภาพทั้งหมด" ไม่เช่นนั้นคุณจะเขียนโลโก้เดิมซ้ำเป็นร้อยครั้ง JPEG แบบ CMYK ต้องผ่านการจัดการสีในขั้นตอนถัดไป ไม่เช่นนั้นมันจะแสดงผลกลับสีในโปรแกรมแสดงผลที่อ่านค่าช่องสีตรง ๆ ตามที่เป็น เมื่อคุณต้องการบัญชีรายการทั้งเอกสารแทนที่จะทำทีละหน้า FindImages ร่วมกับ SetFindImagesMode จะสแกนทั้งไฟล์ในรอบเดียว

มีขอบเขตหนึ่งที่ควรหยิบยกขึ้นคุยกับผู้มีส่วนได้ส่วนเสียก่อนที่ใครจะเขียนเกณฑ์การยอมรับ: การดึงรูปภาพคืนค่าเฉพาะ resource แบบ raster เท่านั้น โลโก้หรือแผนภูมิที่วาดเป็นเส้นทาง vector ไม่ใช่ภาพในความหมายของ resource และจะไม่มีวันปรากฏในลิสต์รูปภาพใด ๆ เลย ไม่ว่ามันจะดูเหมือนภาพบนหน้าจอชัดเจนแค่ไหนก็ตาม เมื่อความต้องการที่แท้จริงคือการส่งมอบแผนภูมินั้นเป็นไฟล์ วิธีที่ตรงไปตรงมาคือ render บริเวณของหน้ากระดาษออกมาเป็น bitmap ซึ่งเป็นการดำเนินการคนละแบบที่มีความเที่ยงตรงต่างกัน output ทั้งสองแบบไม่ควรอยู่ในโฟลเดอร์ export เดียวกันโดยไม่มีป้ายบอกว่าอันไหนเป็นอันไหน

เปรียบเทียบการเรนเดอร์หน้า PDF ของ Delphi เพื่อแย่งภาพ กับการสกัด stream รูปภาพต้นฉบับด้วย GetPageImageList รวมข้อควรระวังเรื่อง soft mask, XObject ซ้ำ และ CMYK
การเรนเดอร์และการครอปรีแซมเปิลพิกเซลและทิ้งข้อมูลรูปภาพต้นฉบับ ขณะที่ GetPageImageList แจกแจงทรัพยากรรูปภาพที่เก็บไว้พร้อมคุณสมบัติและสตรีมที่ไม่ถูกรบกวน

ฟอนต์: พื้นที่สำหรับ audit ไม่ใช่ฟีเจอร์ export

API ของฟอนต์ตอบคำถามเกี่ยวกับฟอนต์ มันไม่ได้ส่งไฟล์ฟอนต์จริง ๆ ให้คุณ และความแตกต่างนั้นเป็นตัวกำหนดทุกสิ่งที่คุณสร้างต่อยอดจากมันได้ หลังจาก FindFonts สแกนเอกสารแล้ว การไล่ลำดับจะเดินผ่านฟอนต์ทีละ ID และคำสั่งอ่านคุณสมบัติจะรายงานผลของฟอนต์ที่ถูกเลือกอยู่ในขณะนั้น:

var
  I: Integer;
begin
  Pdf.FindFonts;
  for I := 1 to Pdf.FontCount do        // ดัชนีฟอนต์เริ่มที่ 1 ไม่ใช่ 0
    if Pdf.SelectFont(Pdf.GetFontID(I)) = 1 then
      Writeln(Format('%s  type=%d  embedded=%d  subset=%d',
        [Pdf.FontName, Pdf.FontType,
         Pdf.GetFontIsEmbedded, Pdf.GetFontIsSubsetted]));
end;

ระวังขอบเขตของลูปให้ดี ดัชนีฟอนต์วิ่งจาก 1 ถึง FontCount ในขณะที่ดัชนีของ text-block และ image-list ที่กล่าวถึงไปก่อนหน้านี้ไม่กี่ย่อหน้าเริ่มที่ 0 หากคุณนำธรรมเนียมหนึ่งไปปนกับอีกธรรมเนียมหนึ่ง คุณจะได้ off-by-one ที่ข้ามฟอนต์ตัวแรกไปหรือไม่ก็วิ่งเลยตัวสุดท้าย และมันจะผ่านการทดสอบแบบผิวเผินได้ เพราะเอกสารส่วนใหญ่มีฟอนต์หลายตัว และตัวที่ผิดก็ยังดูสมเหตุสมผลอยู่ดี ต้องชัดเจนเรื่องขอบเขตด้วยเช่นกัน API นี้ไม่มีการ export ฟอนต์ระดับ byte ไม่มีคำสั่งใดคืนค่าโปรแกรมฟอนต์ที่ฝังอยู่ออกมาเป็นไฟล์ TTF หรือ OTF และการไล่ลำดับบวกกับการตรวจสอบ metadata คือโมเดลทั้งหมดที่ตั้งใจไว้ โมเดลนั้นยังคงครอบคลุมสิ่งที่งานจริงในโปรดักชันต้องการจากฟอนต์: การตรวจจับ subset ด้วยรูปแบบชื่อ, การ audit การฝังฟอนต์ก่อนการแปลงเพื่อจัดเก็บถาวร (ฟอนต์ที่ไม่ได้ฝังไว้เป็นตัวขวางกั้น PDF/A แบบเด็ดขาด ตามที่ PDF/A และ PDF/UA preflight ใน Delphi กล่าวถึงในรายละเอียด) และการวินิจฉัย encoding สำหรับตอนที่ความเชื่อมั่นในการดึงข้อมูลลดลง ยังมีเหตุผลด้านการอนุญาตสิทธิ์ (licensing) ที่ทำให้ขอบเขตอยู่ตรงนี้ด้วย โปรแกรมฟอนต์แบบ subset คือวัสดุที่มีสัญญาอนุญาตกำกับ และเมื่อขาด glyph ไปส่วนใหญ่ก็ไร้ประโยชน์ในฐานะฟอนต์ที่ติดตั้งใช้งานได้อยู่แล้ว การปฏิบัติต่อมันในฐานะ metadata สำหรับ audit แทนที่จะเป็นทรัพย์สินที่ดึงออกมาได้ คือจุดยืนที่คุณสามารถยืนหยัดปกป้องได้

คำสั่งสุดท้ายนั้นทำหน้าที่คุ้มค่าในการคัดกรอง (triage) รัน GetFontEncoding กับฟอนต์แต่ละตัว อ่านผลควบคู่ไปกับ flag ของ subset แล้วคุณจะทำนายคุณภาพของการดึงข้อมูลได้ก่อนที่จะดึงตัวอักษรแม้แต่ตัวเดียว หน้าที่ฟอนต์ทั้งหมดถูก subset ด้วย encoding ที่ไม่เป็นมาตรฐานคือผู้สมัคร OCR ได้ตั้งแต่การตรวจสอบเบื้องต้นเพียงอย่างเดียว ซึ่งทำให้ batch pipeline ส่งมันไปยังเส้นทางที่ถูกต้องได้โดยไม่ต้องเสียเวลาไปกับการดึงข้อมูลที่ล้มเหลวก่อน

การดึงข้อมูลขนาดใหญ่โดยไม่ต้องโหลดเอกสาร

ใน batch pipeline การโหลดทั้งเอกสารเพียงเพื่ออ่านหน้าเดียวคือ I/O ที่เสียเปล่า และมันสะสมขึ้นอย่างรวดเร็วเมื่อทำกับคลังเอกสารทั้งชุด ตัวแปรแบบเรียกครั้งเดียว คือ ExtractFilePageText และ ExtractFilePageTextBlocks รับชื่อไฟล์ รหัสผ่าน และหมายเลขหน้าเข้าไปโดยตรง และข้ามการโหลดแบบเต็มไปเลย สำหรับไฟล์ขนาดกิกะไบต์ยังมีเกียร์ที่ต่ำกว่านั้นอีก เส้นทาง direct-access เปิดไฟล์ผ่านการอ่าน xref แบบสตรีม ดังนั้น DAOpenFileReadOnly ตามด้วย DAExtractPageText จะแตะเฉพาะ object ที่หน้านั้นต้องการจริง ๆ เท่านั้น มันมาพร้อมการเปลี่ยนธรรมเนียมที่ควรจดจำไว้: ฟังก์ชัน DA อ้างอิงหน้าเอกสารด้วย PageRef ซึ่งเป็น handle แบบ object-reference ที่คุณได้มาจาก DAFindPage ไม่ใช่ด้วยหมายเลขหน้าดิบ ๆ หากคุณส่งหมายเลขไปในตำแหน่งที่ควรเป็น handle คำสั่งจะทำงานกับ object ที่ผิดโดยไม่แจ้งข้อผิดพลาดใด ๆ เลย ซึ่งเป็นความผิดพลาดประเภทที่ debug ยากที่สุด ส่วนที่เหลือของชุดเครื่องมือ direct-access อธิบายไว้ใน การผสาน การแบ่ง และการเข้าถึงโดยตรงของ PDF ขนาดใหญ่

หากมีนิสัยเดียวที่แยกโค้ด extraction ที่รอดจากคลังเอกสารจริงออกจากโค้ดที่เดินกะเผลก นิสัยนั้นคือการปฏิบัติต่อหน้ากระดาษเป็น input ที่ไม่น่าไว้ใจ แทนที่จะเป็นแหล่งข้อมูลที่สะอาด ข้อความที่ไม่ตรงกับสิ่งที่โปรแกรมแสดงผล render ออกมาแทบทุกครั้งเป็นปัญหาเรื่อง encoding เช่น ligature ที่ยุบรวมเหลือ glyph เดียว หรือฟอนต์แบบ subset ที่ขาด entry ของ ToUnicode และวิธีแก้คือวัดค่าความเชื่อมั่นแล้วเบนหน้าที่แย่ไปทำ OCR ไม่ใช่ต่อสู้กับ byte เอง API ของฟอนต์จะไม่มีวันสร้าง TTF หรือ OTF ออกมาได้ ตามการออกแบบ ดังนั้นให้สร้าง workflow เกี่ยวกับฟอนต์รอบคำถามเชิง audit และ state ของการดึงข้อมูลที่คงอยู่ต่อเนื่อง โดยเฉพาะสี่เหลี่ยมของพื้นที่ (area rectangle) คือการตั้งค่าที่คุณเป็นเจ้าของตลอดอายุของ document handle ไม่ใช่ parameter ที่คุณลืมไปหลังเรียกใช้ครั้งเดียว ทำสามปฏิกิริยาตอบสนองนี้ให้ถูกต้อง แล้วส่วนที่เหลือของ API จะทำงานอย่างที่ควรจะเป็น

ตัวติดตั้งสำหรับประเมิน โปรเจกต์เดโม และเอกสารอ้างอิง API การดึงข้อมูลแบบสมบูรณ์อยู่ที่หน้าผลิตภัณฑ์ losLab PDF Library สำหรับ Delphi