מאמר טכני

PDFium Matrix Prepend מול Append ב-Delphi: סיבוב Pivot

מטריצות affine של PDF משתמשות במוסכמת וקטור-השורה של ISO 32000-1 §8.3.3, שבה נקודה מכפילה את המטריצה משמאל: point' = point * M. ב-PDFium Component עבור Delphi ו-C++Builder העובדה הבודדת הזו קובעת את כל שטח ה-API של TPdfMatrix: Multiply מצרפת בסוף, כך ש-M := M * Op, בעוד PreMultiply מצרפת בהתחלה, כך ש-M := Op * M

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

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

TPdfMatrix מאחסן את ששת האלמנטים בעלי-שם-המפרט ומחיל אותם בדיוק כפי שהפורמט מגדיר אותם, כך שהטרנספורמציה עצמה היא היכן שההיגיון מתחיל. TPdfMatrix.TransformPoint מחשב x' = x*a + y*c + e ו-y' = x*b + y*d + f, שזו הצורה בת-ששת-האלמנטים ש-ISO 32000-1 §8.3.4 מגדיר עבור אופרטור ה-cm שמשרשר מטריצה על מטריצת הטרנספורמציה הנוכחית. הזוג (a, b) הוא השורה הראשונה, (c, d) השנייה, ו-(e, f) שורת ההזזה. הרגלי וקטור-עמודה שנרכשו מ-OpenGL או מקורס אלגברה לינארית יטעו אתכם כאן, והם יטעו אתכם בשקט, כי מטריצה בסדר-שגוי היא עדיין מטריצה תקינה לחלוטין. קוראים מורכב במוסכמת השורה משמאל לימין וסדר ההחלה נופל בחינם: מכיוון ש-point * (M * Op) שווה ל-(point * M) * Op, פעולה מצורפת-בסוף פועלת על קואורדינטות שהמטריצה הקיימת כבר הפיקה, כלומר על מרחב-עמוד, בעוד פעולה מצורפת-בהתחלה פועלת לפני שהמטריצה הקיימת רצה, במרחב הקלט של האובייקט עצמו

var
  M: TPdfMatrix;
  Pt: FS_POINTF;
begin
  M := TPdfMatrix.Create;                // identity
  try
    // Append order: each call acts on what the previous calls produced.
    M.Scale(0.5, 0.5);                   // M := M * S   half size
    M.Rotate(90);                        // M := M * R   clockwise, degrees
    M.Translate(300, 400);               // M := M * T   then move on the page

    Pt := M.TransformPoint(0, 0);        // x*a + y*c + e, x*b + y*d + f
  finally
    M.Free;
  end;
end;

TPdfMatrix.Rotate ברירת מחדל היא עם-כיוון-השעון ובמעלות, עם ACounterClockwise ו-AAngleInRadians זמינים כשהנתונים המקוריים שלכם מסומנים אחרת. ה-properties הקריאים-בלבד a עד f ו-property ה-Handle נותנים לכם בחזרה את FS_MATRIX הגולמי, שזה מה ש-FPDFPageObj_SetMatrix רוצה. שום דבר במחלקה לא מסתיר מכם את ששת המספרים, וזה מכוון: כשטרנספורמציה מתנהגת רע, הדפסת a עד f היא האבחון המהיר ביותר שיש לכם

מדוע צירוף-בהתחלה של הזזה זקוק לחלק הליניארי?

כי הזזה מצורפת-בהתחלה כתובה במרחב הקלט של המטריצה, והיא חייבת לעבור דרך החלק הליניארי הנוכחי לפני שהיא יכולה להצטרף לשורת ההזזה. TPdfMatrix.PreTranslate לכן מחשב e := dx*a + dy*c + e ו-f := dx*b + dy*d + f. צירוף-בסוף הוא הכיוון הקל: TPdfMatrix.Translate כתוב במרחב-עמוד, שם שום דבר לא צריך המרה, כך שהוא רק מוסיף dx ל-e ו-dy ל-f. מי ש"מבצע אופטימיזציה" ל-PreTranslate לשתי חיבורים פשוט מחק את הסיבוב והקנה מהזזה

M := TPdfMatrix.Create;
try
  M.Rotate(90);              // a=0, b=-1, c=1, d=0

  M.Translate(10, 0);        // append: e := e + 10
                             // -> 10 points to the right on the page

  M.Reset;
  M.Rotate(90);
  M.PreTranslate(10, 0);     // prepend: e := 10*a + 0*c + e  (unchanged)
                             //          f := 10*b + 0*d + f  (f - 10)
                             // -> 10 points along the stamp own x axis,
                             //    which after the turn points down the page
finally
  M.Free;
end;

אותו חוסר-סימטריה עובר דרך זוג הקנה, ושווה לדעת אילו אלמנטים כל אחד נוגע בהם לפני שמנפים אחד בשלוש בבוקר. TPdfMatrix.PreScale מכפיל שורות, מקנה את a ו-b ב-scaleX ואת c ו-d ב-scaleY, ומשאיר את ההזזה בשקט כי ההזזה כבר קרתה במורד הזרם. ה-TPdfMatrix.Scale המצרף-בסוף מכפיל עמודות במקום, לוקח את a, c, e ב-scaleX ואת b, d, f ב-scaleY, כך שההיסט הקיים מקנה יחד עם כל השאר. שניהם נתיבים חד-מטרתיים שמדלגים על המכפלה הכללית בת-ששת-האלמנטים, ושניהם משמרים את סמנטיקת ההרכבה של הצורה הכללית בדיוק

איפה שתי ההזזות הולכות בסיבוב pivot?

סביב הפעולה, לא סביב כל המטריצה, ובסדר הזה. TPdfMatrix.RotateAt מצרף Translate(-pivot), אז הסיבוב, אז Translate(+pivot), שתחת מוסכמת וקטור-השורה מרכיב כ-Translate(-pivot) * Op * Translate(pivot). הרצף הזה הוא מה ששומר את ה-pivot קבוע תחת הפעולה החדשה תוך עדיין לתת למטריצה הקיימת להפיק את הקואורדינטות שלה קודם ולמסור אותן הלאה. כותבים את הזוג בסדר ההפוך, כפי שהיה נכון בספריית וקטור-עמודה, והאובייקט מקיף את הראשית במקום להסתובב במקום, שזה בדיוק איך סימן מים ממורכז מסתיים מחוץ ל-crop box

procedure RotateStampAboutPageCenter(AObj: FPDF_PAGEOBJECT;
  const AAngleDegrees, APageWidth, APageHeight: Single);
var
  M: TPdfMatrix;
  Raw: FS_MATRIX;
begin
  if not FPDFPageObj_GetMatrix(AObj, Raw) then
    raise Exception.Create('Page object carries no matrix');
  M := TPdfMatrix.Create(Raw);
  try
    // Appends Translate(-pivot) * Rotate * Translate(+pivot) in one call.
    M.RotateAt(AAngleDegrees, APageWidth / 2, APageHeight / 2);
    Raw := M.Handle;
    FPDFPageObj_SetMatrix(AObj, Raw);
  finally
    M.Free;
  end;
end;

אותה הרכבה תומכת ב-ScaleAt, SkewAt, HorizontalFlipAt, VerticalFlipAt, ו-CentralFlipAt, כך שברגע שאתם סומכים על התבנית עבור סיבוב אתם יכולים לסמוך עליה עבור השאר. TPdfMatrix.CentralFlip שווה ציון מיוחד: הוא הופך את סימני כל ששת האלמנטים כדי לתת לכם סיבוב 180 מעלות ללא שום טריגונומטריה, מה שאומר שאין cos של ערך שהיה אמור להיות בדיוק אפס ואין נדנוד מצטבר כשמחילים אותו בלולאה. אם אתם ממקמים סימנים חוזרים במקום לסובב אחד, המכניקה של המיקום עצמו מכוסה בחותמות עמוד ניתנות לשימוש חוזר עם Form XObjects, ועבודת המטריצה כאן יושבת ישירות מעליה

מה TryDecompose אומר לכם על מטריצה?

TPdfMatrix.TryDecompose מדווח הזזה, קנה, סיבוב, גזירה (shear), דטרמיננטה ודגל שיקוף תחת מוסכמת קנה-ואז-סיבוב, והוא מדווח אותם בכנות מספקת כדי להיות שימושי להחלטות ולא רק ליומן. ScaleX מגיע מאורך השורה הראשונה, Sqrt(a*a + b*b), כך שהוא תמיד חיובי. ScaleY אז הוא Determinant / ScaleX, מה שהופך אותו חתום. הסיבוב מגיע מ-ArcTan2(-b, a) במעלות, והגזירה משתי מכפלות הנקודה של השורות מנורמל על ידי שני הקנים

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

var
  D: TPdfMatrixDecomposition;
begin
  if M.TryDecompose(D) then
  begin
    // D.ScaleX is always positive; D.ScaleY carries the determinant sign.
    if D.IsReflected then
      Log('mirrored, ScaleY = %.3f', [D.ScaleY]);

    if Abs(D.RotationDegrees) > 0.5 then
      SkipDisplayRotation;      // the object already carries its own turn
  end
  else
    UseIdentityFallback;        // near-singular or non-finite: no answer
end;

התאמת מלבן אחד לתוך אחר בלי לנחש

TPdfMatrix.TryCreateRectMapping בונה עבורכם את מטריצת מקור-ליעד ולוקח TPdfMatrixFitMode של pmfStretch, pmfContain, או pmfCover. הוא מנרמל את שני המלבנים תחילה, כי מלבני PDF לא נדרשים להגיע עם שמאל מתחת לימין או תחתית מתחת לראש, ואז גוזר קני X ו-Y עצמאיים: pmfStretch משאיר אותם עצמאיים, pmfContain לוקח את הקטן יותר וממרכז את ה-letterbox, pmfCover לוקח את הגדול יותר וממרכז את ה-crop. ה-MapRectToRect הנלווה מצרף את אותה מיפוי על מטריצה קיימת, ו-NewRectMapping מעלה EPdfMatrixError היכן שהצורה Try מחזירה False. זה הפרימיטיב מתחת לכל מיקום תא בimposition N-up וסידור מחדש עמודים, שבו כל עמוד מקור חייב לנחות בתוך תא מחושב בלי שתגזרו מחדש את האריתמטיקה לכל פריסה

מטריצות מנוונות ונתיב הכשל הכן

קלטים סופיים לא מבטיחים תוצאה סופית, כך שקוד ההתאמה מחשב ב-Double ואז בודק שוב את מועמד ה-Single המצומצם לסופיות לפני שהוא מפרסם אותו; מיפוי המכיל אינסוף לעולם לא נמסר בחזרה כאילו היה תקף. אותה משמעת שולטת בהיפוך. TPdfMatrix.TryGetInverse דוחה מטריצה באמצעות סף יחסי, משווה את הדטרמיננטה מול האפסילון כפול ריבוע האלמנט הליניארי הגדול ביותר במקום מול קבוע קבוע, וזה מה ששומר על הבדיקה משמעותית בין אם היחידות שלכם הן נקודות או מיקרומטרים. TryDecompose נסוג באותה דרך, מסרב כשאורך השורה הראשונה או ה-ScaleY הנגזר נופל בשווה או מתחת לאפסילון

בוחרים את סגנון הכשל שמתאים לנקודת הקריאה במקום לעטוף הכל ב-try-except מהרגל. TryInvert, TryGetInverse, TryInverseTransformPoint, TryTransformBounds ו-TryCreateRectMapping מחזירים False ומשאירים את היעדים שלהם ללא נגיעה, מה שמתאים ל-hit-testing וללולאות לכל-אובייקט שבהן אובייקט מנוון צריך לדולג ולא להיות קטלני. Invert, InverseCopy, InverseTransformPoint, MapRectToRect ו-TransformBounds מעלים EPdfMatrixError במקום, מה שמתאים לקוד הקמה שבו מטריצה יחידה משמעה שהקורא חישב משהו לא נכון. עבור עבודת אצווה, TransformPoints ו-TransformRects מקצים את מערך התוצאה שלהם בדיוק פעם אחת, TransformPointsInPlace ו-TransformRectsInPlace עושים שימוש חוזר באחסון שלכם, ו-TryTransformBounds צובר את תיבת התוחם במעבר יחיד במקום להמחיש נקודות מותמרות תחילה

שום דבר מזה אינו מתמטיקה אקזוטית. זו מוסכמה אחת, מוחלת בעקביות, עם ה-API בעל שם כך שהמוסכמה נראית בנקודת הקריאה: Multiply והפעלים הרגילים מצרפים בסוף, משפחת ה-Pre מצרפת בהתחלה, משפחת ה-At ממסגרת את הפעולה עם זוג ה-pivot שלה. כותבים את הסדר בהערה ליד כל מורכב שבונים, כי הקוד שנקרא נכון היום הוא הקוד שמישהו יהפוך בעוד שישה חודשים. הפניית TPdfMatrix המלאה, יחד עם ה-API-ים לאובייקט-עמוד ולרינדור שהטרנספורמציות הללו מזינות, חיה עם PDFium Component עבור Delphi ו-C++Builder