מאמר טכני

גבולות טבלה כמלבנים מלאים כקווי רשת ב-PDFium לדלפי

חילוץ הטבלאות של רכיב PDFium, מגרסה 3.117.0, מתייחס למלבן מלא דק כאל קו רשת של טבלה. כשהאפשרות DetectFilledRulings דלוקה, וזו ברירת המחדל, תיבה מלאה מיושרת לצירים שאינה עבה מ-MaxRulingThickness (3 נקודות) הופכת לקו אחד לאורך הציר הארוך שלה, תיבה מלאה גדולה יותר תורמת את ארבע הצלעות שלה, וכל קואורדינטה של קו מוצמדת בתוך RulingSnapTolerance (4 נקודות) לפני שהרשת מורכבת. טבלאות שיוצאות מ-Word, מ-Google Docs ומדפדפנים מגיעות לכן למזהה הקווים כרשתות שלמות במקום ליפול לזיהוי לפי רווחים לבנים כמקטעים בודדים

המאמר הקודם על זיהוי וחילוץ טבלאות הצהיר שזיהוי טבלאות עם קווים משתמש בקווים המצוירים וכל מקטע נתיב נמתח מומר לקואורדינטות עמוד. המשפט הזה היה נכון ולא שלם. ספירת אובייקטי נתיב על פני סט של 13 מסמכי דגימה מהעולם האמיתי הראתה ש-9 מהם לא מכילים נתיב נמתח בכלל, ובכל זאת כל אחד מהעמודים שלהם נושא מאות מלבנים מלאים בעובי 0.5 עד 1 נקודה. המזהה שראה רק קווים נמתחים לא ראה כלום, כל עמוד נפל לזיהוי לפי רווחים לבנים, והפלט היה פיזור של מקטעים קטנים במקום טבלאות. הפריסט "עמודות קומפקטיות" שנוסף ב-3.116.4 ריכך את זה ברמת המקטע; הסיבה השורשית הייתה שהמזהה קרא את אופרטור הצביעה הלא נכון

למה לטבלה שיצאה מ-Word אין קווים נמתחים?

מעבד תמלילים לא תופס גבול כקו; הוא תופס אותו כתיבה עם רוחב, והוא צובע את התיבה הזו במילוי. ISO 32000-1 §8.5.2.1 מגדיר את האופרטור re כמוסיף תת-נתיב של מלבן, ו-§8.5.3 מפריד בין אופרטורי הצביעה: S משרטט את הנתיב בעובי הקו הנוכחי, f ממלא את פנימו. גבול תא בעובי 0.5 נקודות יוצא כ-x y w 0.5 re f, ומנגנון השרטוט, כולל עובי הקו, החיבורים ותבנית המקווקו, אף פעם לא רץ. הצללת תאים היא אותה בנייה עם תיבה גדולה יותר. רשת משורטטת עם m, l ו-S היא מה שהמזהה המקורי ציפה לו, וזה מה שכמעט שום דבר שיוצא מאפליקציית משרד לא מפיק:

% גבול תא אחד מייצוא של מעבד תמלילים: מלבן מלא בגובה 0.5 נקודות
72 700 468 0.5 re f
% הצללת תא: מלבן מלא בגודל התא
72 676 117 24 re f
% קו הרשת המשורטט שהמזהה המקורי נכתב בשבילו
72 700 m 540 700 l S

למזהה ששואל את FPDFPath_GetDrawMode רק אם דגל השרטוט דלוק, שתי התיבות המלאות בלתי נראות. המילים שבתוך התאים מגיעות אז לזיהוי לפי רווחים לבנים, שם עמודות שמופרדות במרווח של 6 נקודות יושבות מתחת ל-MinColumnGap שבדיפולט הוא 12 נקודות, ומה שחוזר הוא תת-קבוצה אקראית של שורות שבמקרה מתיישרת מספיק טוב כדי לעבור את MinRows. זו התנהגות המקטעים, ושום כוונון פרמטרים לא הופך אותה לרשת שהמחבר שרטט

איך רכיב PDFium הופך תיבה מלאה לקו רשת?

TableCollectObjectRulings בוחנת כל אובייקט נתיב תת-נתיב אחד בכל פעם. מצב הצביעה מגיע מ-FPDFPath_GetDrawMode; נתיב נחשב מלא כש-DetectFilledRulings דלוק ומצב המילוי אינו none. כל נקודה מותמרת דרך מטריצת האובייקט ונאספת, עד MaxSubpathPoints (8) לתת-נתיב, וכל מקטע עקומה מסמן את תת-הנתיב כמעוקל. כשהתת-נתיב נסגר או מתחיל MoveTo חדש, FlushSubpath מחליטה מה הוא היה: תת-נתיב מעוקל נזרק, וכך גם כל מצולע סגור שנקודותיו לא כולן יושבות בתוך PointTolerance (0.05 נקודות) מצלעות תיבת החוסן בלפחות ציר אחד. משולש, שברון או לשונית מעוגלת אף פעם לא הופכים לקו רשת, וזה מה שמרחיק קישוטים מהרשת

תרשים ברכיב PDFium של איך TableCollectObjectRulings הופכת תת-נתיבים סגורים לקווי רשת של טבלה בדלפי: FlushSubpath זורק קווי מתאר מעוקלים ומצולעים שאינם על צלעות תיבת החוסן, MaxRulingThickness מפצל תיבות דקות לקו אחד לכל ציר ארוך, תאים מוצלים נותנים ארבעה קווי צלע ו-DetectFilledRulings מוציא ריבועים זעירים מהמשחק
תת-נתיב סגור שורד רק כשהוא מיושר לצירים, ואז תיבת החוסן מחליטה אם הוא קו אחד, ארבע צלעות של תא מוצל, או כלום

מה ששורד הוא מלבן מיושר לצירים, מסווג לפי תיבת החוסן שלו. רוחב בגובה MaxRulingThickness או מתחתיו עם גובה שמעליו מניב קו אנכי אחד במרכז האופקי, שמשתרע על התיבה מלמטה למעלה; המקרה המשתקף מניב קו אופקי אחד. שני הממדים מעל הסף פירושם תא מוצל, והתיבה תורמת ארבעה קווים, אחד לכל צלע. שני הממדים בגובה הסף או מתחתיו לא תורמים כלום, כך שתבליט ריבועי בגודל 2 נקודות לא נחשב לקו. נתיב משורטט הולך במסלול הישן דרך AddLine, קו אחד לכל מקטע מיושר לצירים, כך שרשת ששורטטה עם S מטופלת בדיוק כמו קודם, ונתיב שנצבע במילוי ובשרטוט גם יחד מפיק חלקים חופפים שמעבר המיזוג מקפל:

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  Mode: string;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // באינדוקס 1

    Options := TPdfTableExtractionOptions.Default;
    // אלה ברירות המחדל של 3.117.0, כתובות במפורש לשם הבהירות
    Options.DetectFilledRulings := True;     // מלבנים מלאים דקים הופכים לקווים
    Options.MaxRulingThickness := 3.0;       // נקודות; מלבנים עבים יותר נחשבים להצללה
    Options.RulingSnapTolerance := 4.0;      // נקודות; 0 מכבה את ההצמדה
    Options.IncludeFormXObjects := True;

    Tables := Pdf.ExtractTables(Options);
    for I := 0 to High(Tables) do
    begin
      if Tables[I].DetectionMode = ptdmRuled then
        Mode := 'ruled'
      else
        Mode := 'whitespace';
      Writeln(Format('%dx%d %s, confidence %.2f',
        [Tables[I].RowCount, Tables[I].ColumnCount, Mode,
         Tables[I].Confidence]));
    end;
  finally
    Pdf.Free;
  end;
end;

מה RulingSnapTolerance עושה לטבלאות של תאים מוצלים?

RulingSnapTolerance הוא מה שגורם לטבלה שנבנתה מהצללה בלבד להתחבר לרשת אחת. יש ייצואים שלא משרטטים גבול בכלל: כל תא הוא תיבה מלאה בצבע משל עצמו, ותיבות שכנות מופרדות במרווח לבן של 1 עד 3 נקודות. כל תיבה מניבה ארבעה קווי צלע, אבל הצלע הימנית של תא אחד והשמאלית של הבא יושבות במרחק 2 נקודות זו מזו, ובדיקת הקישוריות משתמשת ב-RulingTolerance, שדיפולט שלו נקודה אחת. בלי הצמדה, כל תא יוצר רכיב קשיר משל עצמו של ארבעה קווים, שום רכיב לא מגיע ל-MinRows, והעמוד לא מדווח כלום. TableSnapRulings אוסף כל קואורדינטת X שנמצאת במשחק (המיקום של כל קו אנכי ועוד ההתחלה והסוף של כל קו אופקי) וכל קואורדינטת Y באותה דרך, ממיין כל רשימה, מקבץ אותה על ידי שרשור ערכים שהשכן שלהם אינו נבדל ביותר מהסבילות, מחליף כל אשכול בממוצע שלו, ואז מזיז כל מיקום, התחלה וסוף למרכז האשכול הקרוב. שתי הצלעות של מרווח הופכות לאותו קו, והקישוריות מתקיימת

תרשים ברכיב PDFium של RulingSnapTolerance שמחבר טבלה של תאים מוצלים בדלפי: תאים שכנים משאירים מרווח של 2 נקודות, קווי הצלע שלהם יושבים מעבר ל-RulingTolerance של נקודה אחת, ו-TableSnapRulings משרשרת את שני ערכי ה-X לאשכול אחד עם ממוצע כך שבדיקת הקישוריות רואה סוף סוף קו רשת משותף
ההצמדה רצה לפני המיזוג ולפני מזהה הקווים, כך ששתי הצלעות של מרווח לבן הופכות לקו אחד וכל תא מפסיק להיות אי של ארבעה קווים

ההצמדה רצה לפני TableMergeRulings, שממיין את הקווים ומאחה חלקים על אותו קו שנוגעים זה בזה או חופפים בתוך RulingTolerance, ושתיהן רצות לפני ש-TableDetectRuled רואה את הנתונים בכלל, כך שבדיקת הקישוריות הזוגית פרופורציונלית למספר קווי הרשת ולא למספר המקטעים לכל תא. ברשת משורטטת המעברים לא מזיקים, כי קואורדינטות שכבר היו זהות מוצמדות לעצמן. הדבר האחד שכדאי לזכור הוא שלקיבוץ בשרשור אין הגבלת רוחב משל עצמו: רצף קואורדינטות שנמצאות במרחק 3 נקודות זו מזו מתמוטט למרכז אחד. בברירת המחדל של 4 נקודות זה משפיע רק על עמודות צרות מתו, אבל אם למסמך יש מרווחים אמיתיים של 3 נקודות שחייבים להישאר נפרדים, הנמיכו את הסבילות או הגדירו אותה ל-0 כדי לכבות את ההצמדה:

// לבודד את אסטרטגיית הקווים ולהשוות מה כל הגדרה רואה בעמוד אחד
function CountRuledTables(Pdf: TPdf; FilledRulings: Boolean;
  SnapTolerance: Double): Integer;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.DetectWhitespaceTables := False;
  Options.DetectFilledRulings := FilledRulings;
  Options.RulingSnapTolerance := SnapTolerance;
  Result := Length(Pdf.ExtractTables(Options));
end;

// ייצוא מ-Word מדווח בדרך כלל 0, אחר כך N ואז פחות מ-N:
// זיהוי קווים מצוירים בלבד לא רואה כלום, ההצמדה מחברת את התאים המוצלים,
// וכיבוי ההצמדה משאיר כל תא מוצל כאי בפני עצמו
Writeln(CountRuledTables(Pdf, False, 4.0));
Writeln(CountRuledTables(Pdf, True, 4.0));
Writeln(CountRuledTables(Pdf, True, 0.0));

קווי רשת בתוך form XObjects

כלי פריסת עמודים עוטפים לעיתים קרובות טבלה, או את כל גוף העמוד, בתוך form XObject וצובעים אותו עם Do. ISO 32000-1 §8.10.1 קובע שמטריצת הטופס משורשרת למטריצת ההעתקה הנוכחית כשהטופס נצבע, כך שמלבן בתוך הטופס חי במרחב הטופס ונוחת על העמוד רק אחרי שתי תמורות או יותר. TableCollectObjectRulings יורדת לתוך אובייקטי טופס כש-IncludeFormXObjects דלוקה: היא קוראת את מטריצת האובייקט, משלבת אותה עם מטריצת האב דרך TableMultiplyMatrix, שסדר הארגומנטים שלה אומר "למפות דרך המטריצה הראשונה ואז השנייה", וממנה את הילדים עם FPDFFormObj_CountObjects ו-FPDFFormObj_GetObject, ומורידה את המטריצה המשולבת הלאה. קינון עמוק יותר מ-MaxFormDepth (8) מדולג בשקט, וזה שומר מפני קבצים פתולוגיים ולא מגבלה שאיזה ייצוא אמיתי מתקרב אליה. הסיבה שסדר הכפל חשוב היא אותה סיבה שנידונה במטריצה: prepend מול append: החלפת האופרנדים מזיזה את איבר ההזזה, וקו רשת שאמור לנחות בראש העמוד נוחת בראשית הצירים במקום

תרשים ברכיב PDFium של קווי רשת בתוך form XObject בדלפי: מלבן דק שנכתב כ-72 700 468 0.5 re f חי במרחב הטופס ונוחת על העמוד רק אחרי ש-TableMultiplyMatrix משלבת את ה-CTM של האב עם מטריצת הטופס, תוך רקורסיה דרך FPDFFormObj_CountObjects עד MaxFormDepth
המלבן נכתב במרחב הטופס ומגיע לראש העמוד רק אחרי שהמטריצות מוכפלות בסדר שמשאיר את איבר ההזזה במקום שאליו הוא שייך

למה תקציב קווי הרשת גדל פי ארבעה?

ברירת המחדל MaxRulingSegments עלתה מ-4096 ל-16384 ב-3.117.0 כי גבולות לפי תא מגיעים במספרים גדולים בהרבה מקווי רשת משורטטים. טבלה משורטטת של 30 שורות ו-6 עמודות היא 38 מקטעי קו. אותה טבלה שיוצאה כתיבות מלאות היא עד ארבעה גבולות לכל תא, 720 חלקים לפני מיזוג, וטופס עם תאים מוצלים מכפיל את זה. שתי טבלאות כאלה בעמוד אחד היו ממצות את התקציב הישן. התקציב נאכף ב-TableAppendRuling דרך Check, שזורקת EPdfError עם ההודעה "Table ruling-segment budget exceeded"; אין תוצאה מדורגת, אין רשת חלקית, ומעבר הזיהוי לפי רווחים לבנים גם הוא לא רץ. אם תגדירו תקציב הדוק משלכם לקלט שאינו מהימן, תפסו את החריגה והחליטו, במקום לקרוא תוצאה ריקה כ"אין טבלאות":

Options := TPdfTableExtractionOptions.Default;
Options.MaxRulingSegments := 2048;        // הדוק בכוונה לקלט שאינו מהימן
try
  Tables := Pdf.ExtractTables(Options);
except
  on E: EPdfError do
  begin
    Log(E.Message);                       // 'Table ruling-segment budget exceeded'
    Options.MaxRulingSegments := 16384;   // ברירת המחדל של 3.117.0
    Tables := Pdf.ExtractTables(Options);
  end;
end;

תוצאות מדודות ואיפה הגישה נעצרת

על אותם 13 מסמכי דגימה, החילוץ עבר מ-43 טבלאות, 9 מהן עם קווים ו-34 מקטעים לפי רווחים לבנים או זיהויים שגויים, ל-41 טבלאות עם קווים ובלי זיהויים שגויים לפי רווחים לבנים. חלק מהניקוי הזה שייך לשני שינויים נלווים ב-3.117.0: מילים שכבר נתפסו על ידי רשת עם קווים מוסרות לפני שזיהוי הרווחים הלבנים רץ, כך שטבלה אף פעם לא מדווחת פעמיים, וגבול עמודה לפי רווחים לבנים חייב כעת להיות מסדרון נטול טקסט על פני כל שורה שהוא מפריד, וזה מה שהפסיק לתת לפסקאות מיושרות לשני הצדדים ציון של טבלאות 5x4. קורא המלבנים המלאים הוא מה שהעביר את הטבלאות עצמן מעמודת המקטעים לעמודת הקווים

הגבולות ראויים להיאמר בפשטות. עמוד בלי שכבת טקסט עדיין מניב את שלד הרשת, כל תא ריק, כי קווי הרשת מגיעים מהגאומטריה והטקסט מגיע מעמוד הטקסט; עמודים סרוקים צריכים OCR קודם. צורות מלאות עם עקומות, פינות מעוגלות או קווי מתאר לא מלבניים נזרקות לגמרי, כך שטבלה שהגבולות שלה משורטטים כקווי מתאר של מלבנים מעוגלים צריכה זיהוי לפי רווחים לבנים כמו קודם. טבלה בלי גבולות ובלי הצללה לא משתנה מאף אחד מכל אלה ונשארת נחלתה של אסטרטגיית הרווחים הלבנים שמתוארת במאמר על חילוץ טבלאות; וכשגם זה לא מספיק, תיבות המילים והבלוקים מטקסט מובנה וסדר קריאה הם חומר הגלם לקורא ייעודי לתחום. הדמו TableExtractionLab שמגיע עם הרכיב חושף את DetectFilledRulings בפאנל האפשרויות שלו, וזו הדרך המהירה ביותר לראות איך ייצוא מסוים נראה איתו ובלעדיו; ה-API המלא מתואר בעמוד רכיב PDFium ל-Delphi