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

ดึงข้อความ PDF ตามลำดับโครงสร้างใน Delphi ด้วย HotPDF

ตัวดึงข้อความเชิงเรขาคณิตทุกตัวล้วนกำลังเดา มันอ่าน glyph ที่หน้าวาด เรียงตาม baseline และตำแหน่งแนวนอน แล้วหวังว่าการจัดวางที่เห็นจะตรงกับลำดับที่คนอ่าน กับรายงานแบบคอลัมน์เดียวการเดานั้นถูก แต่กับบทความวารสารสองคอลัมน์ ฟอร์มที่มี sidebar หรือตารางที่เซลล์ถูกปล่อยออกมาทีละคอลัมน์ มันผิดแบบที่สังเกตยากและค้นพบช้าอยู่ปลายทางแพงมาก HotPDF ตอบปัญหานี้ด้วย ExtractLoadedPageStructureText ซึ่งเมินเรขาคณิตทั้งหมด: มันเดินต้นไม้โครงสร้างของเอกสารตามลำดับการเขียนตามที่ ISO 32000-1 §14.8.4 นิยาม แล้วประกอบ glyph ของหน้ากลับตาม marked-content identifier ของแต่ละตัว สำหรับ tagged PDF นี่ไม่ใช่ heuristic แต่คือลำดับที่แอปพลิเคชันผู้ผลิตประกาศไว้

ฟังก์ชันคืน False เมื่อหน้าไม่มีต้นไม้โครงสร้างที่ใช้งานได้ ซึ่งเป็นสัญญาณให้ถอยไปตัวดึงเชิงเรขาคณิต แทนที่จะล้มเหลว การออกแบบสองเส้นทางนี้สำคัญกว่าอัลกอริทึมเสียอีก การรับเอกสารจริงเห็นฟอร์มภาครัฐแบบมีแท็กและผลสแกนนอนอยู่ในโฟลเดอร์เดียวกัน pipeline ที่จัดการได้แค่ฝั่งใดฝั่งหนึ่งไม่ใช่ pipeline

ทำไมการดึงเชิงเรขาคณิตจึงอ่านลำดับผิด

เพราะ content stream ของ PDF ไม่แบกลำดับการอ่านมาด้วยเลย มันคือลำดับของ operator การวาด และผู้ผลิตมีอิสระที่จะปล่อยมันออกมาในลำดับใดก็ตามที่ layout engine ของมันเหมาะ โปรแกรมประมวลผลคำมักปล่อยตามลำดับ flow และการเรียงเชิงเรขาคณิตดูดี เครื่องมือจัด layout, ตัวออกแบบฟอร์ม และตัวสร้างรายงานมักไม่เป็นเช่นนั้น: footer ของหน้าถูกปล่อยก่อนเนื้อหา ตารางถูกเติมแบบ column-major และหน้าสองคอลัมน์อาจสลับบรรทัดจากสองคอลัมน์เข้ามาเพราะตัวจัดวาง resolve มันพร้อมกัน

เปรียบเทียบหน้า PDF สองคอลัมน์ แสดงการดึงเชิงเรขาคณิตที่เรียงตาม baseline แล้วต่อคอลัมน์เข้าด้วยกัน เทียบกับการดึงตามลำดับโครงสร้างด้วย MCID ใน HotPDF
การเรียง glyph ตาม baseline สานสองคอลัมน์เข้าด้วยกันจนกลายเป็นความรกร้าง ขณะที่ต้นไม้โครงสร้างเล่นซ้ำลำดับที่ผู้ผลิตประกาศไว้

failure mode แบบนี้เงียบ เครื่องมือเชิงเรขาคณิตไม่เคยรายงาน error มันแค่ส่งร้อยแก้วกลับมาที่ประโยคถูกสานจากสองคอลัมน์ อะไรก็ตามที่กินข้อความนั้น ไม่ว่าจะเป็น search index, ตัว map field ของ e-invoice หรือ retrieval pipeline ที่ป้อนโมเดลภาษา ล้วนสืบทอดความเสียหายไปโดยไม่เตือน HotPDF ก็มีตัวดึงเชิงเรขาคณิตสำหรับเอกสารที่โหลดไว้ให้ใช้เช่นกัน และพวกมันยังเป็นเครื่องมือที่ถูกต้องสำหรับไฟล์ที่ไม่มีแท็ก จุดของเส้นทางตามลำดับโครงสร้างคือหยุดการเดา เมื่อเอกสารแบกคำตอบมาให้แล้ว

ต้นไม้โครงสร้างเก็บอะไรไว้จริง ๆ

tagged PDF ถือคำอธิบายเสริมอีกชุดของหน้าที่ทำงานขนานกัน catalog ชี้ไปที่ /StructTreeRoot ซึ่งลูกของ /K ประกอบเป็นต้นไม้ของ structure element: /Document, /Sect, /P, /Table, /TR, /TD และอื่น ๆ ใบของต้นไม้นั้นคือ marked-content reference ที่เป็นจำนวนเต็มระบุช่วงหนึ่งของ content stream ของหน้า ฝั่งเนื้อหา ช่วงเหล่านั้นถูกเปิดด้วย operator BDC ที่แบก /MCID และปิดด้วย EMC structure element แต่ละตัวยังแบก entry /Pg ระบุหน้าที่มันสังกัด ซึ่งทำให้การเดินแบบรายหน้าเป็นไปได้กับเอกสารที่ต้นไม้โครงสร้างกินพื้นที่หลายร้อยหน้า

กายวิภาคของต้นไม้โครงสร้าง PDF ที่ผูก element เช่น Sect, Table, TR และ TD เข้ากับช่วง BDC MCID ใน content stream ของหน้า HotPDF
ใบของต้นไม้คือ marked-content reference และ element แต่ละตัวแบก entry Pg ที่ทำให้การเดินกรองเหลือเฉพาะหน้าปัจจุบัน

HotPDF เดินต้นไม้นั้นด้วยเพดานความลึก 128 ระดับ และกรองด้วย /Pg จึงมีเฉพาะหน้าปัจจุบันเท่านั้นที่มีส่วนร่วม ผลของการเดินไม่ใช่ข้อความ แต่คือรายการ MCID ที่เรียงตามลำดับ: ลำดับการเขียนของช่วง marked-content บนหน้านี้ การประกอบข้อความกลับจึงเหลือเพียงการเล่น glyph ซ้ำตามลำดับนั้น

MCID ถูกบันทึกระหว่างการดึง glyph ไม่ใช่ไปค้นทีหลัง

นี่คือรายละเอียดการ implement ที่ทำให้ฟีเจอร์นี้ราคาถูก HotPDF บันทึก marked-content identifier ที่ทำงานอยู่ของ glyph ทุกตัวที่มันดึงอยู่แล้ว ในฟิลด์ MCID ของ THPDFGlyphRecord เพราะตัวตีความ content stream รู้ว่า scope ของ BDC ตัวไหนเปิดอยู่ในขณะที่มันประมวลผล operator Tj หรือ TJ แต่ละตัว การดึงตามลำดับโครงสร้างจึงไม่ต้องเดิน content stream รอบที่สอง มันเก็บลำดับ MCID จากต้นไม้โครงสร้าง แล้วจัด glyph ที่ดึงไว้แล้วเข้ากระบอกตาม MCID และปล่อยออกมาตามลำดับนั้น

var
  Pdf: THotPDF;
  PageCount, I, Untagged: Integer;
  PageText, AllText: UnicodeString;
  Report: TStrings;   // ที่รวบข้อมูลวินิจฉัยเป็นของผู้เรียก
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('accessible-form.pdf');
    AllText := '';
    for I := 0 to PageCount - 1 do
    begin
      if Pdf.ExtractLoadedPageStructureText(I, PageText, Untagged) then
      begin
        // ลำดับการเขียนตรงจากต้นไม้โครงสร้าง
        if Untagged > 0 then
          Report.Add(Format('page %d: %d glyphs outside the structure tree',
            [I, Untagged]));
      end
      else
        // หน้านี้ไม่มีต้นไม้โครงสร้างที่ใช้ได้: ถอยไปเชิงเรขาคณิต
        Pdf.ExtractLoadedPageText(I, PageText);
      AllText := AllText + PageText + #13#10;
    end;
  finally
    Pdf.Free;
  end;
end;

glyph ที่ไม่มีแท็กถูกนับ ไม่เคยถูกทิ้งอย่างเงียบ ๆ

หน้าหนึ่งสามารถถูกแท็กบางส่วน ผู้ผลิตเพิ่มเส้นตกแต่ง เลขหน้า หรือลายน้ำระยะท้ายออกนอก scope ของ BDC ทุกตัว และ glyph เหล่านั้นไม่สังกัด MCID ใด การทิ้งมันคือ implementation ที่เรียบร้อยและผิด เพราะช่องว่างแบบเดียวกันปรากฏอีกเมื่อผู้ผลิตแท็กเนื้อหาแต่ลืมตาราง และคุณจะเสียตารางไปโดยไม่สังเกตเห็น

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

ผังการตัดสินใจของการดึงข้อความตามโครงสร้างใน HotPDF พร้อม fallback เชิงเรขาคณิตเมื่อหน้าไม่มีต้นไม้โครงสร้างที่ใช้ได้หรือแท็กเชิงตกแต่ง
ค่า True หมายถึงลำดับโครงสร้างพร้อมหาง untagged ต่อท้าย ส่วน False ส่งหน้าไปตัวดึงเชิงเรขาคณิตแทนการล้มเหลว
function ExtractPageBestEffort(Pdf: THotPDF; PageIndex: Integer;
  out AText: UnicodeString; out UsedStructure: Boolean): Boolean;
var
  Untagged, TotalGlyphs: Integer;
  Glyphs: THPDFGlyphArray;
begin
  UsedStructure := False;
  if Pdf.ExtractLoadedPageStructureText(PageIndex, AText, Untagged) then
  begin
    TotalGlyphs := 0;
    if Pdf.ExtractLoadedPageGlyphs(PageIndex, Glyphs) then
      TotalGlyphs := Length(Glyphs);
    // เชื่อต้นไม้โครงสร้างเมื่อมันอ้างว่าครอบหน้าส่วนใหญ่เท่านั้น
    if (TotalGlyphs = 0) or (Untagged * 4 <= TotalGlyphs) then
    begin
      UsedStructure := True;
      Result := True;
      Exit;
    end;
  end;
  Result := Pdf.ExtractLoadedPageText(PageIndex, AText);
end;

อะไรทำให้ฟังก์ชันคืน False

มีสามกรณี และสมควรแยกกัน เพราะมีเพียงกรณีเดียวที่เป็นข้อบกพร่องของเอกสาร กรณีแรกคือ PDF ที่ไม่มีแท็กธรรมดา: ไม่มี /StructTreeRoot ไม่มีอะไรให้เดิน False จึงเป็นเพียงความจริง กรณีที่สองคือหน้าสแกนที่ข้อความมาจากชั้น OCR ที่ไม่เคยถูกแท็ก กรณีที่สามน่าสนใจที่สุด: เนื้อหาที่พก operator BDC พร้อมค่า /MCID แต่หน้าของมันไม่มี entry /StructParents และต้นไม้โครงสร้างไม่เคยอ้างถึง identifier เหล่านั้น marked content มีอยู่ ฝั่งโครงสร้างไม่มี จึงไม่มีลำดับให้กู้คืน HotPDF รายงาน False แทนที่จะแต่งลำดับขึ้นมา

กรณีสุดท้ายโผล่ในไฟล์ที่ถูกแก้มือและในผลลัพธ์ของเครื่องมือที่ปล่อย marked content เพื่อ optional-content หรือ artifact โดยไม่สร้างต้นไม้โครงสร้าง ถ้าคุณผลิต tagged PDF เอง ความไม่สมมาตรแบบเดียวกันคือสิ่งที่การตรวจสอบ PDF/UAเช็ก และฝั่งตัวเขียนที่คู่กันอยู่ในlayout DOM ที่ปล่อยผลลัพธ์แบบมีแท็กและแบ่งหน้า

จุดที่ลำดับโครงสร้างคุ้มค่าตัวเอง

การ audit ด้าน accessibility คือกรณีที่ชัดที่สุด: ถ้าคุณกำลังรับรองเอกสารกับ PDF/UA ลำดับการอ่านที่ screen reader จะประกาศคือลำดับโครงสร้างพอดี การดึงมันออกมาจึงเป็นวิธีทวนสอบโดยไม่ต้องมี screen reader การดึงข้อมูลเชิงโครงสร้างเป็นกรณีเชิงพาณิชย์ที่ใหญ่กว่า ฟอร์มภาครัฐแบบมีแท็ก เอกสารเปิดเผยที่มีกฎเกณฑ์กำกับ และไฟล์แนบ e-invoice แบก label และค่าของฟิลด์ไว้ตามลำดับที่ประกาศ การอ่านมันตามลำดับนั้นตัดบั๊กการ map ทั้งชั้นที่การดึงเชิงเรขาคณิตสร้างขึ้นบน layout หลายคอลัมน์ทิ้งไป

ผู้บริโภคใหม่ล่าสุดคือ retrieval สำหรับโมเดลภาษา การแบ่งเอกสารเป็นชิ้นเพื่อ embedding ดีเท่ากับลำดับข้อความเท่านั้น และชิ้นที่สานสองคอลัมน์เข้าด้วยกันผลิตประโยคที่ไม่เคยมีอยู่จริง การดึงตามลำดับโครงสร้างเป็นทางแก้ที่ถูกที่สุดเท่าที่หาได้ เพราะกับเอกสารแบบมีแท็ก ลำดับที่ถูกต้องอยู่ในไฟล์แล้ว เหลือเพียงการอ่านมัน

HotPDF เป็น VCL component แบบ native สำหรับ Delphi และ C++Builder การเดินต้นไม้โครงสร้างและการเล่น glyph ซ้ำล้วนทำงานใน process เดียวกับเอกสารที่โหลดไว้ โดยไม่มี renderer ภายนอกเข้ามาเกี่ยวข้อง รายละเอียด API ฉบับเต็มของตระกูลการดึงข้อมูลระดับเอกสารที่โหลดไว้อยู่บนหน้าผลิตภัณฑ์HotPDF Delphi PDF component