מאמר טכני

בחירת שורת טקסט PDF עם תיבות תווים PDFium בדלפי

עמוד טקסט PDF חושף תווים ותיבות, אף פעם שורות. PDFium Component בונה שורה ויזואלית על ידי אשכול תיבות תווים שהמרכזים האנכיים שלהם נופלים בתוך חצי מגובה תו-הזרע, סורקת החוצה מהתו שנלחץ עד שהסבילות חורגת. כל נתיב בחירה בצופה קורא לאותו עוזר אחד, כך שעכבר, מקלדת וקוד מסכימים

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

למה לעמוד טקסט PDF אין אובייקטי שורה?

מפני שזרם תוכן PDF מתאר ציור, לא מבנה. תקן ISO 32000-1 §9.4 מגדיר אובייקט טקסט כזוג BT / ET שמכיל אופרטורי מיקום והצגה. אופרטורי המיקום של §9.4.2 (Td, TD, Tm, T*) מזיזים מטריצת טקסט סביב העמוד, ואופרטורי ההצגה של §9.4.3 (Tj, TJ, ', ") צובעים גליפים בכל מקום שהמטריצה הזו מצביעה כעת. שום דבר במודל הזה לא אומר "הריצה הזו של גליפים היא שורה". שורה היא מה שבן-אדם רואה אחרי שהצביעה הסתיימה

מפיקים מחמירים את זה בדרכים שאתה לא יכול לשלוט בהן. פסקה מוצדקת עשויה להיפלט כמערך TJ אחד לכל שורה, או כ-Tj אחד לכל מילה עם Tm מפורש לפני כל אחת, או כפעולת הצגה בודדת עם התאמות ריווח-אותיות שנושאות את הריווח. פריסה דו-עמודתית עשויה לפלוט את העמודה השמאלית מלמעלה למטה ואז את הימנית, או שהיא עשויה לשזור אותן אם המפיק הלך על רשימת האובייקטים הפנימית שלו עצמו בסדר שונה. רצף התווים ש-PDFium מוסרת לך עוקב אחרי זרם התוכן, וזרם התוכן עוקב אחרי מה שהיישום המייצר הרגיש לעשות. אז שתי הפונקציות שאתה בעצם מקבל הן FPDFText_CountChars, שמדווחת כמה תווים העמוד מחזיק, ו-FPDFText_GetCharBox, שמחזירה את תיבת הגבולות של תו אחד במרחב-עמוד. זה כל אוצר המילים הגולמי. כל מה שמעל זה, מילים, שורות, פסקאות, עמודות, הוא הסקה שאתה מבצע על גיאומטריה

למה זיהוי CR ו-LF הוא הבדיקה השגויה?

מפני שהתווים שהיית בודק כנגדם לא נוכחים באורח מהימן, וכשהם כן נוכחים הם לא שלך באורח מהימן. PDFium מזריקה תווים סינתטיים לתוך עמוד הטקסט כדי להפוך טקסט מחולץ לקריא: רווח שבו שתי ריצות מופרדות ויזואלית, CR או LF שבו הריצה הבאה מתחילה בבייסליין חדש. FPDFText_IsGenerated קיימת בדיוק כדי שתוכל להבחין ביניהם לבין תווים שיצאו מהקובץ, ו-PDFium Component חושפת אותה כתכונת CharacterGenerated

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

אשכול תיבות תווים לפי מרכז אנכי

האות המהימן הוא גיאומטריה. קח את התו שהמשתמש לחץ עליו כזרע, חשב את המרכז האנכי של התיבה שלו, וסרוק החוצה בשני הכיוונים בעוד תיבות שכנות שומרות על המרכזים האנכיים שלהן בתוך סבילות. PDFium Component משתמשת בחצי מגובה תיבת-הזרע כסבילות הזו, עם רצפה של 0.5 יחידות-עמוד כך שתיבות מנוונות, נקודה, רווח דק, גליף עם תיבה שגובהה קרוב-לאפס, לא יקרסו את הסבילות לכלום ויחתכו את השורה אחרי תו אחד

function TPdfView.LineRangeAt(TxtPage: FPDF_TEXTPAGE; CharIndex: Integer;
  out StartIndex, Count: Integer): Boolean;
var
  Lo, Hi, Total: Integer;
  SeedBox, Box: TPdfRectangle;
  SeedYMid, BoxYMid, HalfH: Double;
begin
  Result := False;
  StartIndex := -1;
  Count := 0;
  Total := FPDFText_CountChars(TxtPage);
  if (CharIndex < 0) or (CharIndex >= Total) then
    Exit;

  if FPDFText_GetCharBox(TxtPage, CharIndex, SeedBox.Left, SeedBox.Right,
    SeedBox.Bottom, SeedBox.Top) = 0 then
    Exit;
  SeedYMid := (SeedBox.Top + SeedBox.Bottom) / 2;
  HalfH := Abs(SeedBox.Top - SeedBox.Bottom) / 2;
  if HalfH < 0.5 then          // floor for degenerate boxes
    HalfH := 0.5;

  Lo := CharIndex;
  Hi := CharIndex;
  while Lo > 0 do
  begin
    if FPDFText_GetCharBox(TxtPage, Lo - 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Dec(Lo);
  end;
  while Hi < Total - 1 do
  begin
    if FPDFText_GetCharBox(TxtPage, Hi + 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Inc(Hi);
  end;
  StartIndex := Lo;
  Count := Hi - Lo + 1;
  Result := True;
end;

שלושה פרטים בלולאה הזו מרוויחים את מקומם. הסבילות נגזרת מהזרע ולא מקבוע, כך שכותרת בגודל 24pt מקבלת רצועה רחבה וטקסט הערת-שוליים בגודל 7pt מקבל צרה, ואף אחת לא גונבת תווים מהאחרת. ההשוואה משתמשת במרכזים אנכיים ולא בבייסליינים או ראשי תיבה, מה שמשאיר superscript, ריצה בגודל-שונה בתוך-שורה, או משפט בגופן-מעורב על אותה שורה כמו שכניו. וקריאת FPDFText_GetCharBox שנכשלה מפסיקה את הסריקה במקום להידלג עליה, כי לתו ללא גיאומטריה ניתנת-לשליפה אין לך ראיה בכל כיוון, והמשך מעבר לו היה מאפשר להליכה לקפוץ על פני גבול אמיתי בכוח תו נוסף בהמשך

למה כל נתיב בחירה חייב לחלוק עוזר אחד?

מפני ששלושה נתיבי קוד שכל אחד מיישם "השורה" יסטו, והם יסטו בשקט. ב-PDFium Component, הרחבת טריפל-קליק, Shift+Home, Shift+End, והשיטה הציבורית SelectLineAt כולן פותרות את הגבולות שלהן דרך אותה קריאת LineRangeAt. טריפל-קליק זורע ממעגן הבחירה; מקשי ה-shift זורעים מסמן הבחירה ומזיזים רק את הקצה הזה; SelectLineAt זורעת מאינדקס תו שהקורא סיפק ומוסרת את התוצאה ל-SelectTextRange, אותו מאמת-טווח שנתיב העכבר משתמש בו. שכפל את הלוגיקה במקום זאת והכישלון אינו קריסה, הוא סחיפה איטית. מישהו מכוונן את סבילות הטריפל-קליק כדי לתקן דוח עם leading צפוף, ועכשיו Shift+End עוצר תו אחד קצר מהמקום שבו טריפל-קליק עוצר על אותה פסקה. משתמש בוחר שורה עם העכבר, מרחיב אותה עם המקלדת, וצופה בבחירה מתכווצת. מכיוון ש-SelectLineAt מזינה את pipeline הבחירה הרגיל, בחירה תכנותית גם נשארת בלתי-תלויה באם קלט עכבר מופעל, ועדיין מקבלת אימות-טווח, ציור-מחדש, והודעת OnSelectionChange חינם

// Select the visual line under a client-space point, then read it back
procedure TForm1.SelectLineUnderCursor(X, Y: Integer);
var
  CharIndex: Integer;
begin
  CharIndex := PdfView1.CharacterIndexAtPos(X, Y, 6.0, 6.0);
  if CharIndex < 0 then
    Exit;
  if PdfView1.SelectLineAt(PdfView1.CurrentPage, CharIndex) then
    Memo1.Lines.Add(PdfView1.SelectedText);
end;

שים לב לארגומנטי הסבילות ב-CharacterIndexAtPos. בדיקת-פגיעה יש לה חופש-פעולה משלה, מבוטא ביחידות-עמוד, וזה עניין נפרד מסבילות השורה. קליק שנוחת ב-leading בין שתי שורות נפתר לתו הקרוב ביותר בתוך התיבה הזו; סריקת השורה אז רצה מכל תו שזה מתברר להיות. הזנת סבילות-פגיעה נדיבה מדי לתוך הזרע היא אחת הדרכים הקלות יותר לבחור שורה שהמשתמש לא הצביע עליה

שני מרחבי אינדקס: אינדקס תו ואינדקס טקסט

ברגע שיש לך טווח, התאפק מהדחף להשתמש בו כהיסט מחרוזת. FPDFText_GetText מחזירה את טקסט העמוד כמאגר UTF-16, אבל האינדקסים שלה אינם אותו מרחב-אינדקס כמו אינדקסי התווים ש-FPDFText_GetCharBox ו-FPDFText_CountChars משתמשים בהם. התווים המסונתזים שנדונו קודם יושבים במאגר הטקסט בעוד הם תופסים משבצות תו ללא גיאומטריה שמישה, ושתי המספרים נסחפים זה מזה על פני העמוד. הגשרים הם FPDFText_GetTextIndexFromCharIndex ו-FPDFText_GetCharIndexFromTextIndex, עטופים על ידי PDFium Component כ-CharacterIndexToTextIndex ו-TextIndexToCharacterIndex

var
  TextStart, TextEnd: Integer;
begin
  // char-index range from LineRangeAt -> offsets into the page text buffer
  TextStart := Pdf.CharacterIndexToTextIndex(StartIndex);
  TextEnd   := Pdf.CharacterIndexToTextIndex(StartIndex + Count - 1);
  if (TextStart >= 0) and (TextEnd >= TextStart) then
    Caption := Pdf.Text(TextStart, TextEnd - TextStart + 1);
end;

הכיוון שנושך הכי חזק הוא ההפוך. חיפוש שמיושם על פני המחרוזת המחולצת נותן לך אינדקסי טקסט, והעברתם ישירות ל-API של תיבה או בחירה כותבת כתובת בשקט לתווים השגויים, עם שגיאה שגדלה ככל שאתה יורד יותר בעמוד. המר עם TextIndexToCharacterIndex לפני שמשהו גיאומטרי נוגע במספר. זוגות surrogate מוסיפים בעיית-היסט שנייה, עצמאית, מעל זו, שמכוסה במאמר על אימוג'י, סינית-יפנית-קוריאנית, וזוגות surrogate

איפה ההיוריסטיקה מתכופפת

היה כן עם עצמך לגבי הגבולות, כי הם אמיתיים והם נגישים. טקסט מסובב הוא המקרה הברור ביותר: תיבת תו היא מלבן מיושר-לציר במרחב-עמוד, כך שעבור טקסט מסובב 90 מעלות, התיבות של שורה ויזואלית אחת מפוזרות מרכזים אנכיים על פני העמוד, והסריקה עוצרת כמעט מיד. מה שאתה מקבל הוא בחירה קצרה ולא בחירה שגויה, שהוא מצב הכישלון הטוב יותר, אבל זה עדיין כישלון. מצבי כתיבה אנכית מתנהגים באותה דרך מאותה סיבה. פריסות דו-עמודתיות עובדות כשהעמודות מוסטות אנכית זו מזו ונשברות כשהן לא. אם שתי העמודות חולקות רשת-בייסליין, תווים מהעמודה הימנית יושבים בתוך סבילות של שורת העמודה השמאלית, והסריקה תרוץ ישר על פני התעלה, כי בגיאומטריה טהורה אין שם כלום לעצור בו. זיהוי זה צריך בדיקת-פער אופקית מעל האשכול האנכי, ובחירת סף-הפער היא החלטת-שיפוט משלה לגבי אילו מסמכים אתה מוכן לטעות לגביהם. גדלי-גופן מעורבים הם המקרה שהסבילות היחסית-לזרע מטפלת בו היטב: קטע-קוד inline בגודל 8pt בתוך גוף-טקסט 11pt שומר על המרכז שלו בתוך הרצועה, וכותרת 24pt על הבייסליין הבא לא מושכת את שורת הגוף לתוכה

סמנטיקת בחירת-השורה המתוארת כאן משתלבת ב-PDFium Component עבור דלפי ו-C++Builder, לצד בדיקת-הפגיעה, טווח הבחירה, וה-API של אינדקס-הטקסט שמשמשים בדוגמאות; עמוד המוצר נושא את הפניית המלאה עבור עמוד הטקסט ומודל הבחירה