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

การดึงข้อความ PDF แบบมีโครงสร้างใน Delphi ด้วย PDFium VCL

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

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

แผนภาพลำดับชั้น structured text ของ PDFium ที่สกัดใน Delphi: หน้า, blocks, lines และ spans ที่มีสไตล์ ซึ่งแบกขอบเขตกับดัชนีอักขระต้นทาง
GetStructuredText คืนหน้าหนึ่งหน้าที่มีบล็อก แต่ละบล็อกถือบรรทัดและ span ที่มีสไตล์ พร้อมขอบเขตในหน่วยหน้าและดัชนีอักขระต้นทางทุกระดับ

เหตุใดสตริงแบนจึงเป็นผลลัพธ์ที่ผิดสำหรับงานส่วนใหญ่

เพราะคำถามที่คนถามข้อความที่ดึงออกมาแทบไม่เคยเป็น “ตัวอักษรอะไรอยู่บนหน้านี้” เลย มันมักเป็น “อะไรคือหัวข้อเรื่อง” “นี่เป็นตารางหรือไม่” “ย่อหน้านี้อยู่ในส่วนที่ 4 หรือไม่” “ฉันควรวาดไฮไลต์ตรงไหน” สตริงเดียวตอบคำถามเหล่านี้ไม่ได้เลยสักข้อ และทุกคำตอบที่คุณสร้างขึ้นใหม่จากมันก็คือฮิวริสติกที่คุณต้องรับผิดชอบเอง

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

ลำดับตามเนื้อหา หรือเลย์เอาต์ทางกายภาพ

TPdfStructuredTextOptions.ReadingOrder เลือกระหว่าง roContentOrder และ roPhysicalLayout และคำตอบที่ถูกต้องขึ้นอยู่กับว่าคุณเชื่ออะไรมากกว่ากัน ระหว่างผู้สร้างไฟล์กับรูปทรงเรขาคณิต

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

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfStructuredTextOptions;
  Page: TPdfStructuredTextPage;
  B, L: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'article.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // เริ่มนับที่ 1

    Options := TPdfStructuredTextOptions.Default;
    Options.ReadingOrder := roPhysicalLayout;
    Options.IncludeFontInfo := True;
    Options.IncludeSemantics := True;
    Options.MaxCharacters := 200000;         // งบประมาณแบบล้มเหลวปิดกั้น

    Page := Pdf.GetStructuredText(Options);

    for B := 0 to High(Page.Blocks) do
    begin
      if Page.Blocks[B].Kind = cfHeading then
        Emit(Format('H%d: %s',
          [Page.Blocks[B].HeadingLevel, Page.Blocks[B].Text]))
      else
        for L := 0 to High(Page.Blocks[B].Lines) do
          Emit(Page.Blocks[B].Lines[L].Text);
    end;
  finally
    Pdf.Free;
  end;
end;

การติดแท็กเพิ่มอะไรที่รูปทรงเรขาคณิตให้ไม่ได้

เจตนา เมื่อเปิดใช้ IncludeSemantics บล็อกจาก PDF แบบมีแท็กจะพก Kind ที่ดึงมาจากโครงสร้างต้นไม้ ดังนั้นหัวข้อจึงเป็นหัวข้อเพราะผู้สร้างไฟล์บอกไว้เช่นนั้น ไม่ใช่เพราะฟอนต์ของมันใหญ่กว่าค่าเฉลี่ย ชนิดที่ครอบคลุมคือรูปทรงที่สำคัญต่อการใช้ซ้ำ: cfParagraph, cfHeading พร้อม HeadingLevel, cfListItem, cfTableCell, cfCaption, cfFigure และค่าสำรอง cfPlain สำหรับที่ไม่มีแท็ก

ฟิลด์ Source บันทึกไว้ว่าการจัดประเภทแต่ละครั้งมาจากไหน คือ rosStructure สำหรับโครงสร้างต้นไม้ และ rosHeuristic สำหรับการอนุมาน ซึ่งเป็นฟิลด์ที่ควรบันทึกไว้เมื่อคุณกำลังตัดสินใจว่าจะเชื่อไปป์ไลน์การดึงข้อมูลได้มากแค่ไหนทั่วทั้งชุดเอกสาร รูปภาพเป็นกรณีพิเศษที่ควรรู้ไว้: สำหรับบล็อก cfFigure ข้อความมาจากคำบรรยายทางเลือกแทนที่จะมาจากกลิฟใด ๆ เพราะรูปภาพไม่มีตัวอักษรเป็นของตัวเอง ข้อความทางเลือกที่ไม่ตรงกับอะไรก็ยังคงถูกแสดงไว้แทนที่จะถูกทิ้ง ซึ่งทำให้การตรวจสอบการเข้าถึงเห็นได้ว่ามีคำบรรยายอยู่ แม้ไม่มีอะไรบนหน้าวาดมันออกมา โมเดลการติดแท็กเองอธิบายไว้ใน การตรวจสอบโครงสร้างต้นไม้ PDF/UA

ช่วงข้อความพกทั้งสไตล์และที่มา

TPdfStructuredTextSpan แต่ละตัวเก็บข้อความของตัวเอง ขอบเขตในพื้นที่หน้า FontName, FontSize, FontWeight และ Angle พร้อม SourceStartIndex และ SourceCharacterCount ช่วงข้อความจะแตกออกตรงจุดที่สไตล์เปลี่ยน ดังนั้นประโยคที่มีคำตัวหนาสามคำจะกลายเป็นสามช่วง และการสร้างการเน้นข้อความขึ้นใหม่ใน HTML หรือ Markdown ก็เป็นเพียงเรื่องของการอ่านคุณสมบัติ ไม่ใช่การเดาจากชื่อฟอนต์

แผนภาพเปรียบเทียบลำดับการอ่านแบบ roPhysicalLayout กับ roContentOrder สำหรับหน้า PDF สองคอลัมน์หน้าเดียวกัน ในคอมโพเนนต์ PDFium ของ Delphi
หน้าสองคอลัมน์หน้าเดียวกันเดินได้ต่างกันใต้ roContentOrder และ roPhysicalLayout และตัวเลือกบันทึกว่าการตัดสินใจเรื่องลำดับใดถูกทำไว้

ฟิลด์ดัชนีต้นทางทั้งสองคือสิ่งที่เปลี่ยนการดึงข้อมูลให้กลายเป็นฟีเจอร์ ไม่ใช่แค่รายงาน มันชี้กลับไปยังลำดับตัวอักษรของหน้า หมายความว่าบล็อกที่คุณจับคู่ได้ในการค้นหาสามารถแปลงเป็นรูปทรงเรขาคณิตของการเลือกระดับตัวอักษร หรือสี่เหลี่ยมไฮไลต์ได้โดยไม่ต้องผ่านข้อความอีกรอบด้วยลำดับที่ต่างออกไป กลไกนี้อธิบายไว้ใน การเลือกบรรทัดข้อความด้วยกล่องตัวอักษร ฟิลด์ Angle สำคัญกว่าที่เห็น: ข้อความที่หมุนในตราประทับหรือลายน้ำจะอยู่ในพื้นที่พิกัดเดียวกับข้อความเนื้อหา และไปป์ไลน์ที่ละเลยมุมจะรวม “DRAFT” ที่เอียงเข้าไปกลางย่อหน้าโดยไม่รู้ตัว

งบประมาณ และตัวนับคุณภาพสองตัว

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

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

var
  Page: TPdfStructuredTextPage;
  B, S, L: Integer;
  Emphasised: Boolean;
begin
  Page := Pdf.GetStructuredText(Options);

  if Page.UnmappedCharacterCount > 0 then
    Log(Format('page %d: %d characters without a Unicode mapping',
      [Page.PageNumber, Page.UnmappedCharacterCount]));
  if Page.GeometryFailureCount > 0 then
    Log(Format('page %d: %d characters without geometry',
      [Page.PageNumber, Page.GeometryFailureCount]));

  for B := 0 to High(Page.Blocks) do
    for L := 0 to High(Page.Blocks[B].Lines) do
      for S := 0 to High(Page.Blocks[B].Lines[L].Spans) do
      begin
        Emphasised := Page.Blocks[B].Lines[L].Spans[S].FontWeight >= 600;
        AppendRun(Page.Blocks[B].Lines[L].Spans[S].Text, Emphasised,
          Page.Blocks[B].Lines[L].Spans[S].SourceStartIndex);
      end;
end;

ประสิทธิภาพบนหน้าจริง

การดึงข้อมูลแบบเลย์เอาต์ทางกายภาพเป็นโหมดที่มีต้นทุนสูง และการใช้งานนี้ถูกสร้างขึ้นมาสำหรับหน้าที่ใหญ่จริง ๆ: การเรียงลำดับตัวอักษรทำงานที่ O(n log n) แทนที่จะสแกนซ้ำ บัฟเฟอร์บรรทัดและช่วงข้อความเติบโตแบบเรขาคณิตแทนที่จะจัดสรรใหม่ทีละตัวอักษร ข้อความ Unicode ถูกสร้างในบัฟเฟอร์แทนที่จะต่อสตริง และการค้นหาฟอนต์สำหรับอ็อบเจกต์ข้อความที่อยู่ติดกันถูกแคชไว้ การผสมผสานนี้เองที่ทำให้หน้าที่มีตัวอักษรหนาแน่นถึง 5,000 ตัวคาดเดาได้ แทนที่จะเป็นแบบกำลังสอง

สำหรับงานที่มีจำนวนหน้ามาก ก็ยังคุ้มค่าที่จะเลือกโหมดที่ประหยัดกว่าเมื่อทำได้ ใช้ roContentOrder พร้อมเปิด semantics สำหรับเอกสารแบบมีแท็กที่คุณเชื่อถือ และสงวน roPhysicalLayout ไว้สำหรับวัสดุที่สแกนและไฟล์รุ่นเก่าที่รูปทรงเรขาคณิตเป็นสัญญาณเดียวที่มี ถ้าสิ่งที่คุณต้องการคือสตริงธรรมดา API ที่ง่ายกว่าตามที่อธิบายไว้ใน การดึงข้อความจากเอกสาร PDF ยังคงเป็นเส้นทางที่เร็วกว่า และเมื่อคุณต้องการสืบข้อความกลับไปยังตัวระบุเนื้อหาที่ทำเครื่องหมายไว้ การอ่านและเขียนเนื้อหาที่ทำเครื่องหมาย BDC และ MCID ครอบคลุมชั้นนั้นไว้

โมเดลแบบบล็อกยังแมปเข้ากับสิ่งที่ไปป์ไลน์การสืบค้นต้องการได้อย่างลงตัว: หัวข้อพร้อมย่อหน้าของมันคือชิ้นข้อมูลที่มีชื่อเรื่อง และขอบเขตทำให้การอ้างอิงชี้ไปยังตำแหน่งบนหน้าได้ แทนที่จะชี้ไปแค่ทั้งเอกสาร PDFiumPas เป็นคอมโพเนนต์สำหรับ Delphi และ Lazarus ที่ห่อหุ้มเอนจิน PDFium ไว้ พร้อมเอกสารและตัวอย่างที่ หน้าคอมโพเนนต์ PDFium สำหรับ Delphi

แผนภาพงบ MaxCharacters แบบ fail-closed กับตัวนับคุณภาพสองตัวที่การสกัด structured text ของ PDFium รายงานใน Delphi
MaxCharacters ทำหน้าที่เป็นงบแบบ fail-closed ในขณะที่ UnmappedCharacterCount และ GeometryFailureCount รายงานคุณภาพการดึงข้อมูลโดยตรง