מאמר טכני

מצב זרם-תוכן ב-PDFlibPas: מעקב CTM וגזירה (clip)

PDFlibPas, ספריית רכיב ה-PDF הילידית מסוג VCL עבור Delphi ו-C++Builder, מנגנת מחדש את זרם התוכן של עמוד דרך מחלקת ה-TPDFContentStateTracker שלה בלי לגעת בקנבס עיבוד בכלל. הזנת המעקב (tracker) אופרטור מפוענח אחד בכל פעם שומרת רשומת מצב-גרפיקה רצה — מטריצת טרנספורמציה נוכחית, מטריצת טקסט, גבולות גזירה, ומחסנית שמירה q/Q — זמינה לתמונת-מצב (snapshot) לפני או אחרי שכל אופרטור מבוצע

שאל איפה ריצת טקסט בפועל נוחתת על העמוד המודפס, ומספרי הגלם של זרם התוכן לבדם יטעו אותך בכל פעם. ‏TPDFContentProgram.GetTextRuns כבר מדווחת את נקודת-העוגן של כל הוראת הצגת-טקסט דרך שדות OriginX ו-OriginY על TPDFTextRun, והערות השדה מפורשות בכך שהנקודה הזו יושבת במרחב-טקסט, כבר מקופלת דרך Tm, ‏Td, ‏TD, ו-T*. מה שעדיין חסר, ומה שהערות ההן אומרות שקוד קורא חייב לספק, הוא ה-CTM הפעיל בדיוק בהוראה ההיא — המכפלה של כל cm ששורשר עד עכשיו, מקונן בתוך כל כמות שתהיה של זוגות q/Q פתוחים באותה נקודה בזרם

למה לנגן מחדש זרם תוכן במקום לעבד אותו?

PDFlibPas שומרת שני מושגים נפרדים של מצב-גרפיקה עבור שתי עבודות נפרדות, והפיצול מכוון. רשומת המצב הפנימית של מנוע העיבוד נושאת ידית קנבס-התקן חיה, ידית אזור-גזירה, ומטמוני רסטריזציה-גופן — משאבים אמיתיים קשורים לכל משטח שכרגע מצויר, וחסרי-משמעות ברגע שהמשטח ההוא נעלם. ‏TPDFContentGraphicsState לא נושאת כלום מזה: היא רשומה פשוטה מוגבלת לערכים ש-ISO 32000-1 סעיף 8.4 מגדיר כנגישים מאופרטורי זרם-תוכן בלבד — ה-CTM, סגנון קו, צבע, מצב-טקסט, וגבולות הגזירה והנתיב הנגזרים. משום שהרשומה לא מחזיקה שום הפניית קנבס ושום ידית קובץ פתוחה, קוד קורא יכול לפענח זרם תוכן, לעבור עליו עם TPDFContentStateTracker, ולהמשיך להשתמש בתמונות-המצב שנוצרו הרבה אחרי שמה שיצר את הבייטים כבר נעלם

איך TPDFContentStateTracker בונה את ה-CTM

TPDFContentStateTracker.Apply משרשרת את ששת האופרנדים של אופרטור cm לתוך ה-CTM של המעקב באמצעות אותה כפל-קדים ש-PDF עצמה מציינת: המטריצה החדשה M2 משתלבת עם ה-CTM הנוכחי כ-M2 × CTM, במוסכמת וקטור-שורה שבה נקודה מתורגמת כ-P′ = P × M (‏ISO 32000-1 סעיף 8.4). החלק שקל לקבל שגוי יושב באיבר ההיסט, לא בחלק הליניארי: ההיסט של M2 עצמה חייב לעבור דרך רכיב הסיבוב-וקנה-המידה של ה-CTM הנוכחי לפני שההיסט של ה-CTM הנוכחי מתווסף מעל. דלג על הצעד הזה וקודד-קשיח שילוב תמים לפי-רכיב במקום זאת, וה-cm המבודד הראשון שאתה בודק ייראה נכון בעוד כל קואורדינטה במורד-הזרם של cm מקונן שני או שלישי סוחפת בשקט, שזה בדיוק הסוג של באג ששורד סקירת קוד משום שבדיקת היחידה שהייתה תופסת אותו זקוקה לפחות לשתי טרנספורמציות משורשרות כדי להיכשל

var
  Prog: TPDFContentProgram;
  Runs: TPDFTextRunArray;
  States: TPDFContentGraphicsStateArray;
  DeviceX, DeviceY: Double;
  I: Integer;
begin
  Prog := TPDFContentProgram.Create;
  try
    Prog.Parse(ContentBytes);
    Runs := Prog.GetTextRuns;
    // One before-instruction snapshot per operator, computed in a single pass
    States := Prog.TraceGraphicsStates(nil, False);
    for I := 0 to High(Runs) do
    begin
      // OriginX/OriginY already fold in Tm/Td/TD/T*; only the CTM active
      // at this instruction is still missing (ISO 32000-1 8.4)
      with States[Runs[I].InstructionIndex].CTM do
      begin
        DeviceX := Runs[I].OriginX * M11 + Runs[I].OriginY * M21 + DX;
        DeviceY := Runs[I].OriginX * M12 + Runs[I].OriginY * M22 + DY;
      end;
      LogTextOrigin(Runs[I].Text, DeviceX, DeviceY); // caller-supplied handler
    end;
  finally
    Prog.Free;
  end;
end;

הלולאה למעלה עונה על נקודת-הכאב מהפתיחה: TPDFContentProgram.GetTextRuns מוסרת בחזרה OriginX ו-OriginY כבר מקופלים דרך Tm, ‏Td, ‏TD, ו-T*, ו-TraceGraphicsStates(nil, False) מספקת את הפיסה הנותרת האחת, ה-CTM לפני-ההוראה בדיוק באינדקס שכל ריצה נלכדה בו, במעבר ליניארי בודד על פני כל התוכנית. מסירת nil נותנת לפונקציה להחזיק מעקב פרטי עבור הקריאה ולשחרר אותו פנימית, מה שהבחירה הנכונה עבור סריקה חד-פעמית; מסירת מופע TPDFContentStateTracker קיים במקום זאת היא מה ששומר על מצב רציף על פני עמוד שהורכב מיותר מזרם תוכן אחד, שכן ISO 32000-1 מתייחס למערך /Contents של עמוד כזרם לוגי אחד ומחסנית ה-q/Q חייבת להסכים

מטריצת הטקסט שורדת את Q; מצב-הגרפיקה לא

‏ISO 32000-1 סעיף 9.4.2 מגדיר את Td, ‏TD, ‏Tm, ו-T* כאופרטורים שבונים את מטריצת הטקסט ומטריצת שורת-הטקסט בתוך בלוק BT/ET, ו-PDFlibPas שומרת על ההבחנה הזו חדה: Td ו-TD משרשרות היסט טהור לתוך מטריצת שורת-הטקסט, T* עושה אותו דבר באמצעות השלילי של ה-leading הנוכחי, ורק Tm מחליפה את שתי המטריצות לחלוטין עם ששת המספרים שהיא מקבלת. BT מאפסת את שתי המטריצות לזהות, בדיוק פעם אחת, בתחילת אובייקט-הטקסט — אבל q ו-Q לא נוגעות בהן בכלל. ‏TPDFContentStateTracker.Apply מטפלת ב-coRestoreState כמקרה מיוחד בדיוק מהסיבה הזו: לפני שהיא מקפיצה (pop) את המצב השמור מהמחסנית, היא לוכדת את מטריצת הטקסט הנוכחית, מטריצת שורת-הטקסט, ודגל BT/ET, ומיישמת אותם מחדש מעל מה שהמצב שקופץ במקרה החזיק, משום שזוג q/Q שעוטף ריצת-טקסט לא אמור להזיז את מיקום הטקסט בחזרה

var
  Tracker: TPDFContentStateTracker;
  Prog: TPDFContentProgram;
  I: Integer;
begin
  Prog := TPDFContentProgram.Create;
  Tracker := TPDFContentStateTracker.Create;
  try
    Prog.Parse('BT 100 700 Td q 2 0 0 2 0 0 cm (A) Tj Q (B) Tj ET');
    for I := 0 to Prog.Count - 1 do
    begin
      Tracker.Apply(Prog[I]);
      if Prog[I].Op in [coShowText, coRestoreState] then
        LogState(Prog[I].OpName, Tracker.Snapshot); // caller-supplied handler
    end;
  finally
    Tracker.Free;
    Prog.Free;
  end;
end;

הרץ את הרצף ההוא וה-CTM המדווח ב-Tj השני חזר לקנה-המידה של הזהות שהיה לו לפני q — ה-2 0 0 2 0 0 cm בתוך זוג ה-שמור/שחזר נעלם, כפי ש-q/Q דורש. ‏TextMatrix.DX באותה הוראה בדיוק, לעומת זאת, עדיין 100: ה-Td שהגדיר אותו רץ לפני ה-q, כך שהוא לא מצב-גרפיקה ש-Q אי-פעם הייתה מוסמכת לגעת בו, וכלי שהניח אחרת היה מדווח על ריצת הגליף השנייה מתחילה במיקום האופקי הלא-נכון בעמוד

מה קורה כאשר אופרטור נתיב-גזירה רץ?

אופרטור W או W* לא מכווץ את הגזירה מיד; הוא רק רושם איזה כלל-מילוי להשתמש, וההצטלבות בפועל ממתינה לאיזה אופרטור-ציור-נתיב שבא אחריו, כולל צייר ה-no-op n שכותבי PDF משתמשים בו בשגרה בדיוק כדי לגזור בלי לצייר שום דבר. ‏TPDFContentStateTracker משקפת את התזמון הדו-שלבי הזה בדיוק: coClip ו-coClipEvenOdd רק מגדירים דגל כלל-גזירה-ממתין, ו-EndCurrentPath — נקראת על ידי כל אופרטור-ציור-נתיב — היא מה שבפועל מצטלבת עם גבולות הנתיב הממתין לתוך ClipMinX, ‏ClipMinY, ‏ClipMaxX, ו-ClipMaxY. קבלת התזמון הזה נכון חשובה עבור חוזה תמונת-המצב לפני/אחרי עצמו: תמונת-מצב-לפני שנלקחת בדיוק בהוראת ה-W עדיין חייבת להראות את הגזירה הישנה, הרחבה יותר, משום שהגזירה עדיין לא נכנסה לתוקף באותה נקודה בזרם, וקיפול שני השלבים לאחד היה שובר בשקט כל קוד קורא שנשען על כך שמצב-לפני אומר מה שהוא אומר

ClipBoundsExact אומרת לקוד קורא באיזה משני מצבים הוא מסתכל, והיא רק אי-פעם True עבור מלבן ישר-ציר בודד שנבנה על ידי re על נתיב אחרת-ריק — הצורה היחידה ש-PDFlibPas יכולה לייצג בדיוק כארבעה מספרים. כל דבר אחר — מלבן מסובב, מתאר עקום, נתיב מורכב עם כמה תת-נתיבים, או גזירה שנבנתה ממצב-עיבוד-טקסט — עדיין מייצר ClipMinX עד ClipMaxY, אבל עם ClipBoundsExact מנוקה ל-False, איתות כן שארבעת המספרים הם גבול-חיצוני בטוח ולא צורת-הגזירה האמיתית; קוד קורא שרק זקוק לגבול ההוא, כמו בידוד תת-אזור מלבני לפני ההמרה-למטה ל-halftone של GDI המתוארת בעיבוד עמודי PDF למונוכרום 1-סיבית, יכול לקרוא אותו ישירות במקום לגזור אותו מחדש מגיאומטריית העמוד

עקומות בזייה: גבול מדויק או בטוח

הדרך הזולה ביותר לגבול מקטע Bézier מעוקב היא לקחת את הקמור (convex hull) של ארבע נקודות הבקרה שלו, וזה תמיד בטוח משום שהעקומה אף פעם לא יוצאת ממנו — אבל עקומה רדודה ורחבה יכולה לדווח תיבת-גבול הרבה יותר גדולה ממה שהעקומה בפועל תופסת, מה שמחליש סינון מבוסס-גזירה בדיוק כשזה הכי חשוב, בנתיבים דקורטיביים גדולים. PDFlibPas פותרת את הבעיה ההדוקה יותר במקום זאת: עבור כל ציר, היא פותרת את הנגזרת של עקומת המעוקב עבור שורשים בתוך המרווח הפתוח (0, 1) ומעריכה את העקומה בכל שורש שהיא מוצאת, יחד עם שתי נקודות הקצה, שזו הדרך הצורה-סגורה התקנית לקבל את ההיקף האמיתי, ישר-ציר, של עקומה במקום הערכת-יתר. דיוק לפי-עקומה לא עובר לגזירה עצמה, עם זאת: ברגע שמתאר מעוקב הופך לנתיב-גזירה, ‏ClipBoundsExact עדיין נופל ל-False עבורו, משום שתיבת-גבול, לא משנה כמה הדוקה, עדיין לא אותה צורה כמו העקומה שהיא גובלת, ומעקב-המצב מעדיף לומר כך מאשר לתת לקוד קורא להניח מלבן היכן שבפועל עקומה נמצאת

קריאת מצב לפני ואחרי כל אופרטור

אם קוד קורא רוצה את המצב-לפני או המצב-אחרי תלוי לגמרי במה שהאופרטור עושה: שאלת ציור או בדיקת-פגיעה על נתיב או ריצת-טקסט רוצה את המצב כפי שהוא עמד רגע לפני שהאופרטור ההוא רץ, שכן זה מה שבפועל קבע איך האופרטור צייר, בעוד שאלה אבחונית על אופרטור שמגדיר-מצב כמו gs בדרך כלל רוצה לראות מה הוא הרגע שינה. ‏TPDFContentProgram.TraceGraphicsStates(Tracker, AfterInstruction) חושפת בדיוק את הבחירה הזו כבוליאני יחיד, מחשבת TPDFContentGraphicsState אחד לכל הוראה במעבר ליניארי בודד על פני כל התוכנית ללא קשר לאיזה רגע מתבקש. ‏GetGraphicsState(InstructionIndex, AfterInstruction, State) מציעה את אותה בחירת לפני/אחרי עבור הוראה בודדת במקום כל התוכנית, אבל היא מנגנת מחדש מהוראה אפס בכל קריאה כדי להגיע לשם, כך שסריקת הרבה אינדקסים על ידי קריאה לה בלולאה עולה O(n²) מול קריאת O(n) בודדת ל-TraceGraphicsStates על פני אותה תוכנית

var
  Before, After: TPDFContentGraphicsState;
begin
  // Same instruction index, two different instants: before vs. after it runs
  Prog.GetGraphicsState(CmIndex, False, Before);
  Prog.GetGraphicsState(CmIndex, True, After);
  // Before.CTM reflects every earlier cm; After.CTM already folds in
  // this instruction's own concatenation as well
end;

חיים עם זרמי תוכן פגומים

שני סוגי קלט פגום נפוצים מספיק ביצרני PDF אמיתיים ש-TPDFContentStateTracker חייבת לסבול אותם ולא להיכשל עליהם. הראשון הוא נתיב שמשתרע על פני גבול q/Q: הנתיב הנוכחי, הנקודה הנוכחית, וספירת תת-הנתיב אינם פרמטרי מצב-גרפיקה — ‏ISO 32000-1 סעיף 8.4 מכסה מה q ו-Q שומרים ומשחזרים, והנתיב הנוכחי שבבנייה לא ביניהם — כך ש-TPDFContentStateTracker עוקבת אחר הנתונים ההם מחוץ למצב השמור לגמרי, ותת-נתיב שהתחיל לפני q עדיין שם, בלתי-צבוע, מיד אחרי ה-Q המתאים. השני הוא Q ערום ללא q מתאים בשום מקום לפניו בזרם, לא נדיר בפלט ממחוללים שמרכיבים פיסות זרם-תוכן על ידי שרשור ומטעים את החשבונאות. ‏TPDFContentStateTracker.RestoreUnderflowCount סופרת כל אחד מהאירועים האלה במקום להעלות חריגה או להשחית מצב: Q בלתי-מתואם פשוט משאיר את מצב-הגרפיקה הנוכחי בדיוק כפי שהיה, כאילו ההוראה ההיא הייתה no-op, כך שהשאר של הזרם ממשיך להתנגן מחדש על מצב שפוי וקוד קורא עדיין יכול להחליט אחר-כך, מהספירה, אם הקלט שווה סימון בחזרה למי שיצר אותו

הרכבת ה-CTM, עצמאות מטריצת הטקסט מ-q/Q, ומימוש-בשלבים של נתיב-גזירה לא תלויים באיך או האם זרם התוכן אי-פעם מצויר, וזו בדיוק הנקודה: אותה תמונת-מצב של TPDFContentStateTracker נכונה בין אם העמוד אף פעם לא מעובד בכלל ובין אם הוא עומד להימסר לאיזה back end ש-PDFlibPas בוחרת עבור הקובץ ההוא, כולל החלפת מנוע בזמן-ריצה המכוסה בהמדריך לעיבוד PDF רב-מנועי ב-PDFlibPas. ניתוח תוכן, מיפוי קואורדינטות, וכלי מחיקה-מוחלטת (redaction) כולם יכולים לרוץ לגמרי על הפלט של המעקב, הרבה לפני, או לגמרי בלי לבקש ממנוע עיבוד להיות מעורב

ניגון-מחדש של זרם-תוכן דרך TPDFContentStateTracker הוא חלק ממסגרת עריכת התוכן המובנה הבנויה לתוך PDFlibPas, ספריית רכיב ה-PDF הילידית מסוג VCL עבור Delphi ו-C++Builder