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

ค้นหาข้อความ PDF พร้อมพิกัดจุดที่พบใน Delphi: PDFlibPas

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

PDFlibPas คือไลบรารี PDF แบบ native Object Pascal สำหรับ Delphi และ C++Builder และตั้งแต่ v3.78.0 เป็นต้นมา มันก็ตอบคำถามนั้นได้ตรงตัว สาม API สำหรับ query วางอยู่เหนือ text-block extractor ที่มีอยู่แล้ว: SearchText ไล่ช่วงหน้าและคืนแต่ละผลลัพธ์พร้อมหน้าที่พบและกรอบที่จัดแนวตามแกน, EnumPageElements แสดงทุกอย่างบนหน้าหนึ่ง ทั้งบล็อกข้อความและรูปภาพที่ฝังอยู่, ส่วน GetTextInAreaEx รายงานกรอบของแต่ละบล็อกภายในพื้นที่แทนการยุบให้เป็นรายการสตริง ไม่มีตัวไหนแตะเส้นทางการเขียน ทั้งหมดเป็นการเพิ่มฝั่งอ่านที่ทำงานบนกลไกเดิมที่ไลบรารีมีอยู่แล้ว

ทำไมเรขาคณิตจึงอยู่ในรายการ text-block ไม่ใช่ใน funnel

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

พิกัดยังอยู่รอดในโครงสร้างอีกแบบหนึ่ง ExtractPageTextBlocks(3) คืน handle ของรายการ text-block ที่แต่ละรายการมี bounding quad แบบแปดค่า ค่า font name, font size และข้อความของบล็อกนั้น handle นี้คือที่เดียวที่เรขาคณิตถูกเก็บไว้หลังการแยกข้อความ ดังนั้น API query ใหม่ทุกตัวจึงสร้างบนฐานนี้แทนที่จะใช้ funnel การใช้รายการบล็อกซ้ำทำให้ search, enumeration และ region queries ใช้การ extraction เพียงรอบเดียวและใช้คำนิยามตำแหน่งบล็อกเดียวกัน

ดังนั้นรูปแบบของ SearchText จึงเป็นไปตามข้อจำกัดนี้ สำหรับแต่ละหน้าในช่วงที่กำหนด มันจะ extract รายการบล็อก อ่านข้อความของแต่ละบล็อกด้วย GetTextBlockText ทดสอบกับ query แล้วสำหรับบล็อกที่ตรงกันก็ลด quad ให้เป็น rectangle ผลลัพธ์ที่คืนกลับมาเป็น record ขนาดเล็ก:

type
  TPDFlibSearchHit = record
    Page: Integer;                       // 1-based page of the match
    Left, Top, Right, Bottom: Double;    // axis-aligned hit rectangle
    MatchText: WideString;               // the block text that contained the query
  end;

อาร์เรย์ bound เป็น X/Y สลับกัน ไม่ใช่สี่มุม

รายละเอียดตรงนี้แหละที่มักสะดุดก่อน GetTextBlockBound(ListID, Index, BoundIndex) รับ BoundIndex ตั้งแต่ 1 ถึง 8 และค่าทั้งแปดไม่ได้เรียงเป็น "มุม 1, มุม 2, มุม 3, มุม 4" โดยจับสองฟิลด์ไว้เป็นคู่ตามที่คุณอาจเดา แต่มันคือ X, Y, X, Y, X, Y, X, Y ดัชนีคี่คือพิกัด X ส่วนดัชนีคู่คือพิกัด Y รวมเป็นสี่จุด อ่านจับคู่ผิด ตัว rectangle ที่ได้จะเพี้ยนทันที

เหตุผลที่มี quad แทนที่จะเป็น rectangle ตรงๆ คือการหมุน บล็อกข้อความที่เอียงจะมี bounding polygon แบบสี่จุดจริง และค่า double ทั้งแปดตัวอธิบายมันได้ครบถ้วน สำหรับ use case แบบไฮไลต์แล้วกระโดดไปยังตำแหน่ง คุณแทบอยากได้กล่องตั้งตรงมากกว่า ดังนั้นไลบรารีจึงลด quad ให้เป็น axis-aligned rectangle โดยกวาดหาค่า X และ Y ต่ำสุดกับสูงสุดจากทั้งสี่จุด ข้อความที่หมุนจึงยุบเป็นกล่องตั้งตรงที่ครอบมันอยู่ ซึ่งเป็นสิ่งที่ highlight overlay ต้องใช้:

var
  Pdf: TPDFlib;
  Hits: array[0..255] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('contract.pdf', '');
    // Search pages 1 to 10, case-insensitive, substring match.
    Found := Pdf.SearchText('indemnity', [], '1-10', Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Format('p%d: [%.1f %.1f %.1f %.1f] %s',
          [Hits[I].Page, Hits[I].Left, Hits[I].Top,
           Hits[I].Right, Hits[I].Bottom, Hits[I].MatchText]));
  finally
    Pdf.Free;
  end;
end;

สังเกตว่า rectangle นี้อยู่ในพิกัด user-space ของ PDF เป็นจุด โดยมีจุดกำเนิดอยู่มุมล่างซ้ายของหน้า ซึ่งเป็นระบบพิกัดเดียวกับที่คุณส่งให้คำสั่งวาดและ annotation นั่นตั้งใจไว้แบบนั้น rectangle ที่ได้จากผลค้นหาคือ rectangle ที่คุณส่งต่อไปยัง highlight annotation หรือคำสั่ง "เลื่อนไปตรงนี้" ได้ทันทีโดยไม่ต้องแปลงอะไร

ความไวต่อการสะกด, คำเต็ม และจุดที่ CJK ต่างออกไป

พารามิเตอร์ตัวที่สองคือชุด TPDFlibSearchOptions ที่ประกอบจาก soCaseSensitive และ soWholeWord เซตว่าง [] คือกรณีที่ใช้บ่อยที่สุด: ค้นหาแบบไม่แยกตัวพิมพ์เล็กใหญ่และจับ substring เพิ่ม soCaseSensitive เพื่อให้ Indemnity กับ indemnity แยกจากกัน เพิ่ม soWholeWord เพื่อไม่ให้ sign ไปแมตช์อยู่ใน signature หรือใช้สองตัวร่วมกัน

การจับแบบ whole-word ต้องมีนิยามว่าขอบคำคืออะไร และตรงนี้ควรพูดให้ชัดเพราะมันตั้งอยู่บน ASCII เป็นหลักตามการออกแบบ ตัวอักษรจะนับเป็นส่วนของคำเมื่อเป็นอักษร ASCII, ตัวเลข ASCII หรือ underscore: คลาส [A-Za-z0-9_] ที่คุ้นจากกฎของ identifier การจะนับว่าแมตช์เป็น whole-word ได้ ต้องมีอักขระที่อยู่ก่อนและหลังมันไม่ใช่อักขระของคำ หรือไม่ก็ให้แมตช์อยู่ที่ขอบของบล็อก

ผลที่ตามมากับสคริปต์ที่ไม่ใช่ละตินคือสิ่งที่ควรรู้ก่อนปล่อยช่องค้นหาแบบหลายภาษา เพราะอักขระ Han, kana และตัวอักษรที่ไม่ใช่ ASCII อื่นๆ อยู่นอกคลาสนั้น ขอบที่อยู่ติดกับมันจึงถูกมองว่าเป็น non-word edge ทั้งคู่ ในทางปฏิบัติ นั่นหมายความว่าการค้นหาแบบ whole-word บนข้อความ CJK จะทำงานราวกับทุกตำแหน่งเป็นขอบคำที่ใช้ได้ ดังนั้นแฟล็กนี้จึงแทบถอยกลับไปเป็นการจับ substring ที่นั่น ข้อจำกัดนี้มีบันทึกไว้ ไม่ใช่บั๊ก และสอดคล้องกับพฤติกรรมของต้นแบบฟีเจอร์ ถ้าคลังเอกสารของคุณเป็น CJK เป็นหลัก โหมด whole-word จะไม่ให้การแบ่งคำแบบ tokenizer เฉพาะทาง คุณต้องออกแบบเผื่อไว้ ไม่ใช่พึ่งมัน

มีหมายเหตุการใช้งานอีกข้อที่อธิบายความล้มเหลวแบบละเอียดอ่อนในที่อื่นได้บางส่วน: การเปรียบเทียบแบบไม่สนตัวพิมพ์ใหญ่เล็กใช้ UpperCase กับ WideString ไม่ใช่ AnsiUpperCase ตัวแปร Ansi จะคืน AnsiString ซึ่งไม่สอดคล้องกับ WideString ที่เส้นทางส่วนอื่นใช้อยู่ และการผสมทั้งสองแบบจะทำให้เกิด type mismatch และที่แย่กว่านั้นคือการ fold ตัวอักษรที่อยู่นอก code page ปัจจุบันได้ไม่ครบถ้วน Unicode เข้า Unicode ออกตลอดทาง

ตัว parser ช่วงหน้าตัวเดียวสำหรับทั้งไลบรารี

พารามิเตอร์ตัวที่สามคือสตริงช่วงหน้า เช่น "1,3,5-9" ไม่มีอะไรเฉพาะกิจในการ parse เพราะตัว PLParsePageRangeList เดียวกันที่รองรับ PrintPages และ routine คัดลอกหน้าก็ใช้ที่นี่ด้วย ดังนั้นช่วงหน้าที่พิมพ์ได้ถูกต้องก็ค้นหาได้ถูกต้องเช่นกัน สตริงช่วงหน้าว่างคือสัญญาณพิเศษของ "ทุกหน้า" ซึ่งในกรณีนั้น SearchText จะสร้างรายการครบชุดขึ้นมาเอง

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

ภายใน search และ enumeration จะเปลี่ยนหน้าที่เลือกอยู่ขณะไล่ลูปเหมือนกัน ดังนั้นแต่ละฟังก์ชันจึงบันทึกหน้าที่เลือกเดิมของผู้เรียกไว้เมื่อเริ่มต้น และคืนค่ามันกลับในบล็อก finally เรียก SearchText กลางทางของการสร้างหน้า แล้ว selection ของคุณจะกลับมาอยู่ตรงที่วางไว้เมื่อคำสั่งคืนค่า สัญญาเก็บแล้วคืนค่ากลับเป็นสิ่งที่เรามักจะสังเกตเห็นก็ต่อเมื่อมันหายไป และนั่นคือเหตุผลว่าทำไมมันถึงมีอยู่

ไล่ทั้งหน้า: ข้อความและรูปภาพในรายการเดียว

Search ตอบคำถามว่า "คำนี้อยู่ที่ไหน" ส่วนอีกครึ่งหนึ่งของการสำรวจคือ "บนหน้านี้มีอะไรบ้าง" และนั่นคือ EnumPageElements มันคืนรายการรวมหนึ่งชุดที่แต่ละ element เป็นได้ทั้ง text block หรือรูปภาพที่ฝังอยู่ โดยแยกด้วยฟิลด์ Kind:

type
  TPDFlibPageElementKind = (ekText, ekImage);

  TPDFlibPageElement = record
    Kind: TPDFlibPageElementKind;
    Page: Integer;
    Left, Top, Right, Bottom: Double;
    Text: WideString;        // ekText
    FontName: WideString;    // ekText
    FontSize: Double;        // ekText
    ImageID: Integer;        // ekImage; usable with SelectImage / GetImageID
  end;

element ประเภทข้อความมาจากการเรียก ExtractPageTextBlocks ชุดเดียวกัน ดังนั้นแต่ละรายการจึงมาพร้อม rectangle, font name และขนาด font ที่เติมไว้ครบแล้ว ส่วน element ประเภทรูปภาพมาจากรายการ embedded image ของหน้าผ่าน FindImages และ GetImageID ImageID ที่พวกมันถืออยู่คือ handle ที่คุณส่งต่อให้ SelectImage เพื่อสำรวจรูปภาพต่อได้ ทั้งสองประเภทถูกรวมลงในอาร์เรย์เดียว เพื่อให้การเดินดูหน้าหนึ่งรอบเดียวเห็นทุกอย่างที่อยู่บนหน้า

var
  Pdf: TPDFlib;
  Elems: array[0..511] of TPDFlibPageElement;
  Total, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('report.pdf', '');
    Total := Pdf.EnumPageElements(1, Elems);
    for I := 0 to Total - 1 do
      if I <= High(Elems) then
        if Elems[I].Kind = ekText then
          WriteLn(Format('text  %s/%.1f  "%s"',
            [Elems[I].FontName, Elems[I].FontSize, Elems[I].Text]))
        else
          WriteLn(Format('image id=%d', [Elems[I].ImageID]));
  finally
    Pdf.Free;
  end;
end;

ตรงนี้มีรูปแบบการนับที่เดินตามไลบรารีส่วนอื่นและคุณต้องเคารพ ไม่เช่นนั้นคุณจะไปอ่านหน่วยความจำที่ยังไม่ได้กำหนด ค่า return คือจำนวน element ทั้งหมด ซึ่งอาจมากกว่าอาร์เรย์ที่คุณส่งเข้าไป ฟังก์ชันจะเติมได้เท่าที่ช่องว่างมีและนับส่วนที่เหลือต่อไป เหมือนกับการ enumerate signature ดังนั้นกฎที่ต้องจำจึงเหมือนเดิมเสมอ: จำกัดลูปให้ไม่เกินค่าที่น้อยกว่าระหว่างจำนวนที่คืนมากับ High(array) อย่าไล่ถึงจำนวนที่คืนโดยไม่เช็ก ตัวอย่างข้างบนแสดงการตรวจ I <= High(...) ด้วยเหตุผลนี้ ถ้าค่าที่คืนมากกว่าบัฟเฟอร์ของคุณ ให้ขยายอาร์เรย์แล้วเรียกอีกครั้ง

ถ้าคุณเคยใช้คำสั่ง text-block ระดับต่ำของไลบรารี นี่คือเลเยอร์ที่มีชนิดข้อมูลและรับรู้เรขาคณิตทับอยู่ด้านบน การ extraction เบื้องหลังเป็นแบบเดียวกับที่อธิบายไว้ใน การแยกข้อความ รูปภาพ และฟอนต์ของ PDF ใน Delphi ด้วย PDFlibPas และเมื่อเป้าหมายไม่ใช่ "ข้อความนี้อยู่ตรงไหน" แต่เป็น "เอกสารนี้มีโครงสร้างอย่างไรสำหรับเทคโนโลยีช่วยเหลือ" เรื่องอ่านฝั่งคู่ขนานที่สอดรับกันก็คือ tree โครงสร้างของ tagged-PDF ซึ่งเปิดเผยลำดับการอ่านเชิงตรรกะมากกว่าการวางบล็อกเชิงกายภาพ

ใช้การ query พื้นที่เมื่อคุณรู้แล้วว่าจะมองตรงไหน

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

var
  Pdf: TPDFlib;
  Hits: array[0..63] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf', '');
    Pdf.SelectPage(1);
    // Left, Top, Width, Height in PDF points on the selected page.
    Found := Pdf.GetTextInAreaEx(360, 720, 180, 60, Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Hits[I].MatchText);
  finally
    Pdf.Free;
  end;
end;

ต้องแยกสองเรื่องนี้ให้ชัด GetTextInAreaEx ทำงานบนหน้าที่ถูกเลือกอยู่ในขณะนั้น ดังนั้นต้องเรียก SelectPage ก่อน และมันไม่ได้รับช่วงหน้าเหมือน SearchText อีกทั้งบล็อกจะถูกเก็บไว้เมื่อมัน intersects กับ rectangle ที่ query ไม่ใช่เฉพาะเมื่อมันถูกบรรจุอยู่ทั้งหมด ดังนั้นบรรทัดที่พาดผ่านขอบจึงยังผ่านเข้ามาด้วย โดยทั่วไปนั่นคือสิ่งที่ต้องการสำหรับกล่องเลือกที่วาดด้วยมือ แต่ถ้าคุณต้องการการบรรจุแบบเคร่งครัด คุณกรอง rectangle ที่คืนกลับมาด้วยตัวเองได้ เพราะตอนนี้คุณมีมันอยู่แล้ว

นำไปใช้งาน

แก่นร่วมของทั้งสามคำสั่งคือ เรขาคณิตไม่ใช่สิ่งที่คุณต้องสร้างใหม่หลังข้อเท็จจริงอีกต่อไป ผลค้นหาหนึ่งรายการรู้ทั้งหน้าที่อยู่และกรอบของมัน องค์ประกอบของหน้าแต่ละตัวรู้ทั้ง rectangle ของตัวเอง และถ้าเป็นข้อความก็รู้ฟอนต์ด้วย ส่วนการ query พื้นที่ก็รายงานว่าบรรทัดแต่ละบรรทัดตกอยู่ตรงไหน เพียงเท่านี้ก็พอจะสร้างฟีเจอร์ค้นหาและไฮไลต์จริง, ดัชนีคลิกแล้วพาไปตำแหน่ง หรือ extractor ที่รับรู้เลย์เอาต์ได้ โดยไม่ต้องลงต่ำกว่าระดับ public API หรือเขียน pipeline การแยกข้อความขึ้นมาใหม่ด้วยมือ

API การ query เหล่านี้มาพร้อมกับ PDFlibPas Delphi PDF Library ควบคู่กับเลเยอร์การแยก text-block แบบเต็มที่มันสร้างบนฐานนั้น และ surface ฝั่งอ่านที่เหลือสำหรับ Delphi และ C++Builder