מאמר טכני

חילוץ טבלאות PDF מטיפוסים ב-Delphi מעבר למעברי עמוד

HotPDF משחזרת טבלאות מתוך PDF קיים דרך ExtractLoadedTypedTables, API של Delphi שממזג את מקטעי השורות שמייצר מעבר הפריסה, בונה רשת עמודות קנונית אחת לכל טבלה, ממשיך את הטבלה מעבר למעבר עמוד כאשר הגיאומטריה תומכת בכך ומחזיר כל תא כערך מטופס שנושא provenance של עמוד, span של עמודות וגבולות. ExportLoadedTypedTables כותבת את אותה תוצאה ישירות ל-CSV או JSON. התרחיש שהופך את זה לשווה בנייה משעמם ונפוץ מאוד. רשם חשבוניות בן ארבעים עמודים, טבלה אחת מבחינה לוגית, מודפס עם כותרת שחוזרת בראש כל עמוד. מריצים עליו מעבר reading-order נאיבי ומקבלים ארבעים טבלאות, 39 שורות כותרת מזויפות ועמודת מטבע שמחליקה מיקום אחד שמאלה בכל שורה שבה התא האמצעי במקרה היה ריק. ניקוי זה downstream, בתוך האפליקציה הקוראת, הוא המקום שבו פרויקטי יבוא מסמכים מתים

מדוע עמוד PDF נותן לכם מקטעים ולא טבלה

מפני שלעמוד PDF אין סמנטיקה של טבלה כלל, אלא אם המסמך מתויג. זרם התוכן מחזיק אופרטורים שמציגים טקסט ומטריצות מיקום (ISO 32000-1 §9.4.3) ולא דבר נוסף; תיבת הקווים שרואים על המסך היא ציור path שאינו קשור לטקסט ואף מחלץ אינו חייב לקשר ביניהם. סוגי רכיבי המבנה Table, TR, TH ו-TD חיים רק בהיררכיית המבנה הלוגי של PDF מתויג (ISO 32000-1 §14.8.4), והרוב המכריע של מסמכים עסקיים שבמחזור אינו מתויג. כל מה שמתואר להלן הוא שחזור גיאומטרי ולא parsing, וכדאי לומר זאת בקול לפני שמישהו בונה עליו דוח reconciliation

לכן HotPDF מריצה קודם ניתוח layout סמנטי על הגליפים שחולצו, אותו מעבר שמאחורי חילוץ טקסט לפי סדר מבני מ-PDF טעון וייצואי HTML ו-XML מובנים. המעבר מקבץ קווי בסיס לריצות שהתאים בהן מיושרים אנכית, והוא ממשיך ריצה רק כל עוד לשורות עוקבות יש אותו מספר תאים. עבור מנוע layout זה כלל נכון וזול. עבור caller זה shape שגוי: שורה יחידה עם תא פנימי ריק מפצלת טבלה חזותית אחת לשתי טבלאות מקור. שכבת הטבלאות המטופסות יושבת מעל המעבר הזה בדיוק כדי לחבר את החלקים בחזרה

רשתות עמודות קנוניות וכפתור ColumnTolerance

ExtractLoadedTypedTables ממזגת מקטעים באותו עמוד לפני שהיא עושה דבר אחר, והיא ממזגת לפי גיאומטריית עמודות ולא לפי טקסט שורות. שתי טבלאות מקור סמוכות באותו עמוד מתחברות כאשר לכל אחת יש לפחות שתי עמודות, כאשר הפער האנכי בין השורה האחרונה של הראשונה לשורה הראשונה של השנייה נשאר בתוך רצועת הסבילות וכאשר מיקומי תחילת העמודות שלהן מיושרים. התחלות עמודה שבטווח ColumnTolerance זו מזו מתכווצות לעמודה קנונית אחת וממוצעות בזמן האיחוד. סבילות ברירת המחדל היא 12 יחידות user-space, מה שמתאים לטיפוגרפיה עסקית רגילה ודורש העלאה בפריסות בעלות tracking רחב או indentation עמוק

מה שקורה לשורה שחסר בה ערך פנימי הוא החלק החשוב. HotPDF מצמידה כל תא להתחלת העמודה הקנונית הקרובה ביותר ואז קובעת את ColumnSpan לפי המרחק מהעמודה הזו לעמודה התפוסה הבאה, במקום להזיז את התאים הנותרים שמאלה. שורה בת שלושה תאים ברשת של חמש עמודות משאירה את הערכים מתחת לכותרות הנכונות ומתעדת בדיוק היכן נמצאים הפערים. זהו ההבדל בין טבלה שאפשר לבצע לה reconciliation לבין טבלה שמייחסת כסף בשקט לעמודה הלא נכונה

var
  Pdf: THotPDF;
  Options: THPDFTypedTableExtractionOptions;
  Tables: THPDFTypedTables;
  Info: THPDFTypedTableExtractionInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('register.pdf', '') <= 0 then
      Exit;
    Options := THPDFTypedTableExtractionOptions.Default;
    Options.ColumnTolerance := 12;           // יחידות user-space
    Options.MinimumTableConfidence := 0.55;  // מתחת לזה הטבלאות מושמטות
    Options.DateOrder := ttdoDMY;            // 03/04/2026 הוא 3 באפריל
    Options.DecimalSeparator := ',';
    Options.ThousandsSeparator := '.';
    if Pdf.ExtractLoadedTypedTables([0, 1, 2, 3], Options, Tables, Info) then
      // Info.TableCount מול Info.SourceTableCount מראה כמה מוזג
      ProcessTables(Tables)
    else if Info.Status = ttesBudgetExceeded then
      Log(string(Info.Diagnostic));
  finally
    Pdf.Free;
  end;
end;

מה באמת מבטיח מיזוג בין עמודים

הוא מבטיח שמרנות, בכוונה. HotPDF מחברת שתי טבלאות מעבר לגבול עמוד רק כאשר MergeAcrossPages מופעל, כאשר הטבלה השנייה מתחילה בדיוק באינדקס העמוד שאחרי סוף הראשונה, כאשר לשתיהן לפחות שתי עמודות וכאשר לפחות שתי התחלות עמודה קנוניות מיושרות בתוך ColumnTolerance. תנאי העמודים העוקבים הוא נושא המשקל. Callers מעבירים את PageIndices כ-open array בכל סדר שירצו, וללא הבדיקה הזו בקשה לעמודים 3, 9 ו-14 יכולה לרתך שלוש טבלאות לא קשורות לתוצאה אחת שנראית סבירה לחלוטין. המחיר הוא שהמשך אמיתי שמדלג על עמוד, נספח משולב או סריקה דו-צדדית עם צד אחורי ריק חוזרים כשתי טבלאות, ושום option אינו מרפה את זה. חיבור מחדש של אלה הוא החלטת policy שרק האפליקציה הקוראת יכולה לקבל, ולכן ה-API חושפת FirstPageIndex, LastPageIndex, SourceTableCount ו-PageIndex לכל שורה ומשאירה את ההחלטה היכן שהיא שייכת

כותרות חוזרות מסומנות ולעולם לא נמחקות

ExtractLoadedTypedTables לעולם אינה מסירה שורת כותרת חוזרת מהתוצאה. כאשר מיזוג בין עמודים מוצא שהטבלה הנכנסת נפתחת בטקסט כותרת זהה לטבלה שנצברה, לאחר trim ו-case folding, היא מסמנת את השורות כ-IsHeader וכ-IsRepeatedHeader ומוסיפה אותן בכל זאת בסדר המקור. מחיקה היא החלטה מאבדת ובלתי הפיכה, ולצרכנים שונים נדרשות תשובות שונות: יבוא CSV רוצה שהחזרות ייעלמו, audit trail רוצה שהן יישארו עם מספרי העמודים שלהן וכלי diff רוצה שסדר המקור יישמר בייט-לבייט. לכן הספרייה מדווחת וה-caller מחליט

var
  T, R, C: Integer;
  Row: THPDFTypedTableRow;
  Total: Double;
begin
  Total := 0;
  for T := 0 to High(Tables) do
    for R := 0 to High(Tables[T].Rows) do
    begin
      Row := Tables[T].Rows[R];
      if Row.IsRepeatedHeader then
        Continue;                    // שמור רק את בלוק הכותרת הראשון
      for C := 0 to High(Row.Cells) do
        if Row.Cells[C].ValueKind = ttvkCurrency then
          Total := Total + Row.Cells[C].NumberValue;
    end;
end;

ערכים מטופסים והמפרידים שאתם חייבים לספק

הסקת הטיפוס רצה בסדר קבוע שפותר את העמימויות בכיוון ההגיוני היחיד: boolean קודם, אחריו date, אחריו percentage, אחריו currency, אחריו מספר רגיל, וכל מה שלא התאים נשאר מחרוזת. הסדר הוא מה שמונע מ-2026 בעמודת תאריך להיקבע על ידי parser של מספר לפני שה-parser של תאריך רואה אותו. מטבע מזוהה מ-$, £, ¥ או מובילים, או מקוד ISO 4217 בן שלוש אותיות שאחריו רווח, והקוד נשמר ב-CurrencyCode. חשוב מכך, HotPDF אינה מנחשת את ה-locale שלכם. DecimalSeparator, ThousandsSeparator ו-DateOrder מגיעים מה-options, מפני ש-1.234 הוא או מספר אחד או אלף מאתיים ושלושים וארבעה, לפי עובדה שה-PDF אינו מכיל. ה-Text הגולמי ב-Unicode נשמר בכל תא לצד הערך המטופס, ולכן ניחוש שגוי תמיד ניתן לשחזור ללא מעבר חילוץ נוסף

var
  Stream: TFileStream;
  Info: THPDFTypedTableExtractionInfo;
begin
  Stream := TFileStream.Create('tables.json', fmCreate);
  try
    if not Pdf.ExportLoadedTypedTables([0, 1, 2], ttefJSON,
      Stream, Options, Info) then
      case Info.Status of
        ttesInvalidOptions:   ReportBadConfiguration;
        ttesBudgetExceeded:   ReportOversizedDocument;
        ttesCancelled:        ReportUserCancelled;
        ttesWriteFailed:      ReportDestinationProblem;
      else
        ReportExtractionFailure;
      end;
  finally
    Stream.Free;
  end;
end;

שני פורמטי הייצוא עונים על שאלות שונות ובכוונה אינם שקולים. CSV כותב את עמודות ההמשך של span ממוזג כשדות ריקים, וזה מה שגיליון אלקטרוני או loader המוני מצפים לו. JSON שומר כל מה שהחילוץ ידע: את הערך המטופס תחת הסוג שלו, columnSpan, confidence לכל תא ולכל שורה, גבולות התא ו-provenance של העמוד ושל טבלת המקור. שני הפורמטים מכינים את כל המסמך במאגר זיכרון מוגבל ורק אז מפרסמים לזרם היעד, תוך שחזור הבתים, האורך והמיקום המקוריים אם הכתיבה נכשלת באמצע, כך שייצוא כושל לעולם אינו משאיר קובץ שנכתב למחצה. תקציבים לעמודים, גליפים לעמוד, טבלאות, שורות, תאים, תווים ובתי פלט נספרים בנפרד, ושורות נספרות לפני ההקצאה מפני ש-SetLength לכל שורה מידרדר להעתקה ריבועית הרבה לפני תקרת ברירת המחדל של מיליון שורות

היכן שחזור טבלאות גיאומטרי מוותר

הבהירות לגבי מצבי הכשל שימושית יותר מרשימת תכונות, מפני שכל אחד מאלה הוא מקום שבו caller זקוק למדיניות משלו ולא לערך option טוב יותר

  • מיזוגים אנכיים אינם משוחזרים. HotPDF מדווחת על ColumnSpan עבור spans אופקיים ומשאירה את RowSpan על 1, לכן תא שמשתרע על שלוש שורות בטבלה המודפסת מגיע כתא אחד ועוד שני gaps
  • זיהוי כותרת מונחה-נתונים ולא חזותי. בלוק הכותרת הוא ריצת השורות שלפני השורה הראשונה שמכילה ערך מטופס שאינו מחרוזת, ולכן טבלה שגופה כולו טקסט מדווחת HeaderRowCount כאפס בלי קשר לעיצוב שלה
  • טבלאות שמתחת ל-MinimumTableConfidence מושמטות מהתוצאה ללא שגיאה. השוו את Info.TableCount ל-Info.SourceTableCount כאשר אתם צריכים לדעת שמשהו הושלך
  • ריצה זקוקה לפחות לשתי שורות ולפחות לשתי עמודות לפני שמעבר ה-layout יקרא לה טבלה בכלל, לכן pseudo-table של שורה אחת או פריסת שתי עמודות של prose ארוך אינן טבלה, בצדק ובאופן לא שימושי
  • עמודים סרוקים אינם מכילים אופרטורים של טקסט, לכן אין דבר לשחזר גיאומטרית עד שקיימת בעמוד שכבת טקסט של OCR

אם קובצי ה-PDF שלכם יוצאים ממחסנית דוחות שבניתם בעצמכם, התיקון הזול ביותר לכל זה הוא upstream: להפיק טבלאות מתויגות או לשמור את נתוני המקור, ולהתייחס לחילוץ כ-fallback עבור מסמכים שלא אתם הפקתם. עבור כל השאר כדאי ללמוד את הצינור בסדר הזה, מפני שכל שכבה נבנית על התחתונה: מתחילים בחילוץ טקסט פשוט מ-PDF טעון, עולים ל-API של טבלאות מטופסות כאשר צריך לשמר את הגיאומטריה ומסתכלים על רינדור טבלת נתונים ל-PDF חדש כאשר אתם בצד המפיק ויכולים להחליט עד כמה הפלט יהיה ניתן לשחזור

ExtractLoadedTypedTables ו-ExportLoadedTypedTables נשלחות כחלק מHotPDF Delphi PDF Component הטבעי עבור Delphi ו-C++Builder, ללא DLL חיצוני וללא תלות runtime; דף המוצר נושא את ההפניה המלאה לאפשרויות, לסטטוסים ולרשומות של API הטבלאות המטופסות