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

เพิ่มเลเยอร์ข้อความที่ค้นหาได้ให้ PDF ที่สแกนใน Delphi

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

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

เลเยอร์ข้อความที่ค้นหาได้คืออะไรกันแน่

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

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

การนำผู้ให้บริการไปใช้งานจริง

ข้อตกลงของผู้ให้บริการมีเมธอดเดียว มันรับระเบียนภาพหน้าที่พกมิติ สไตรด์ DPI รูปแบบพิกเซล และไบต์พิกเซลเอง บวกกับโทเคนการยกเลิก และคืนค่าคำหรือข้อความแสดงข้อผิดพลาด:

uses
  PDFium;

type
  TMyOcrProvider = class(TInterfacedObject, IPdfOcrProvider)
  public
    function RecognizePage(const Image: TPdfOcrImage;
      const CancellationToken: IPdfCancellationToken;
      out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
  end;

function TMyOcrProvider.RecognizePage(const Image: TPdfOcrImage;
  const CancellationToken: IPdfCancellationToken;
  out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
var
  I: Integer;
begin
  // Image.Pixels เก็บแถว BGRA ต้นกำเนิดบนขนาด Image.Stride ไบต์
  // ส่งให้เอนจินของคุณ แล้วเติมหนึ่งรายการต่อหนึ่งคำที่รู้จำได้
  SetLength(Words, RecognisedCount);
  for I := 0 to RecognisedCount - 1 do
  begin
    Words[I].Text := EngineWordText(I);
    Words[I].Confidence := EngineWordConfidence(I);   // ช่วง 0 ถึง 1
    Words[I].Quad := TPdfOcrQuad.FromRectangle(
      EngineLeft(I), EngineTop(I), EngineRight(I), EngineBottom(I));
  end;
  ErrorMessage := '';
  Result := True;
end;

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

เหตุใดตำแหน่งของคำจึงปรับสัดส่วนแบบง่ายๆ ไม่ได้

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

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

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

การรันมันทั่วทั้งเอกสาร

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

var
  Pdf: TPdf;
  Options: TPdfOcrOptions;
  Report: TPdfOcrReport;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'scanned-contract.pdf';
    Pdf.LoadDocument;

    Options := TPdfOcrOptions.Default;
    Options.Dpi := 300;                  // ความละเอียดสำหรับการรู้จำ
    Options.MinConfidence := 0.60;       // ทิ้งคำที่ไม่แน่ใจ
    Options.SkipPagesWithText := True;   // ปล่อยหน้าที่เกิดมาเป็นดิจิทัลไว้เฉยๆ
    Options.ContinueOnError := True;     // หนึ่งหน้าที่แย่ต้องไม่หยุดงานทั้งหมด
    Options.MaxPixelsPerPage := 40 * 1000 * 1000;

    if Pdf.ApplyOcrSearchLayer(TMyOcrProvider.Create, Options, Report) then
      Pdf.SaveAs('scanned-contract-searchable.pdf');

    for I := 0 to High(Report.Pages) do
      if Report.Pages[I].Status = popsFailed then
        Writeln(Format('page %d failed: %s',
          [Report.Pages[I].PageNumber, Report.Pages[I].ErrorMessage]));
    Writeln(Format('%d word(s) inserted, %d rejected, %d page(s) skipped',
      [Report.InsertedWordCount, Report.RejectedWordCount,
       Report.SkippedPageCount]));
  finally
    Pdf.Free;
  end;
end;

SkipPagesWithText สมควรได้รับการเน้นย้ำในคลังเก็บแบบผสม PDF ที่มีข้อความจริงอยู่แล้ว ไม่ว่าจะเกิดมาเป็นดิจิทัลหรือผ่านการประมวลผลมาก่อน จะได้เลเยอร์ข้อความชั้นที่สองหากคุณรัน OCR ทับมันแบบไม่ดูตาม้าตาเรือ และการซ้ำนี้ทำให้การสกัดคืนค่าทุกคำเป็นสองเท่า สถานะรายหน้า popsSkippedExistingText จะบอกคุณอย่างแม่นยำว่าหน้าใดถูกปล่อยไว้เฉยๆ

งบประมาณ การยกเลิก และการควบคุมความล้มเหลว

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

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

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

การตรวจสอบว่าภาพไม่ถูกแตะต้องจริงๆ

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

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

การใส่เลเยอร์ OCR การเรนเดอร์ การสกัด และการแก้ไข ล้วนทำงานกับอ็อบเจกต์เอกสารเดียวกันใน Delphi, C++Builder และ Lazarus พื้นผิว API ทั้งหมดอธิบายไว้ที่หน้าคอมโพเนนต์ PDFium สำหรับ Delphi