מאמר טכני

XPS ו־OpenXPS ל־PDF ב־Delphi: קואורדינטות ומברשות

HotPDF ממיר חבילות XPS ו־OpenXPS ל־PDF בתוך Delphi ו־C++Builder בלי מנהל התקן הדפסה, ממפה כל קואורדינטת fixed-page של 96 DPI דרך מטריצת עמוד אחת בקנה מידה 0.75 עם היפוך Y, מפרסם כל VisualBrush כ־Form XObject משותף, והופך מצבי אריחה של ImageBrush לתבניות אריחה מקוריות של PDF במקום ציורי תמונה חוזרים

התרחיש שגורר את רוב חנויות ה־Windows לעניין הזה הוא משעמם ובלתי־נמנע. משהו כבר מדפיס ל־Microsoft XPS Document Writer — דוח ERP ישן, טופס חתום, מנת חשבוניות — ומדיניות הארכיון אומרת PDF. XPS הוא פורמט לכידה מצוין ופורמט נוראי למסור למערכת רשומות בעוד עשר שנים. לכן קובץ ה־spool חייב להפוך ל־PDF עמוד־אחר־עמוד, וברגע שמתחילים לכתוב את הממיר מגלים שהחלק המעניין הוא לא ה־XML. הוא ש־XPS ו־PDF חלוקים לגבי איפה ראש הצירים, כמה שווה יחידה, ומה מותר למברשת להיות

מחבילה ל־PDF במעבר אחד

נקודת הכניסה היא רישום מטפלי המסמכים, לא מחלקת XPS מיוחדת. THPDFDocumentHandlerRegistry.RegisterStandardHandlers מתקין את מטפלי ה־XPS, EPUB ו־CBZ; הזיהוי מבוסס תוכן, ולכן חבילה שנושאת [Content_Types].xml ולפחות חלק אחד .fpage מקבלת ציון 95 גם כשסיומת הקובץ משקרת, בזמן שסיומת .xps או .oxps גרידא מקבלת רק 10. הסדר הזה חשוב כשמקבלים העלאות, כי תוקף שמשנה שם של EPUB ל־.xps לא צריך לכוון את הצינור

var
  Handled: IHPDFHandledDocument;
  Info: THPDFDocumentHandlerInfo;
  Options: THPDFDocumentHandlerOptions;
  Registry: THPDFDocumentHandlerRegistry;
  Output: TFileStream;
begin
  Registry := THPDFDocumentHandlerRegistry.Create;
  Output := TFileStream.Create('spool.pdf', fmCreate);
  try
    Registry.RegisterStandardHandlers;
    Options := THPDFDocumentHandlerOptions.Default;
    if not Registry.OpenFromFile('spool.xps', '', Options, Handled, Info) then
      raise Exception.Create('no registered handler recognised the package');
    if Handled.Format = hdfXPS then
      Handled.WritePDF(Output, Options, Info);
    // Info.UnsupportedFeatureCount הוא הציון הכן עבור ההמרה הזאת
  finally
    Output.Free;
    Registry.Free;
  end;
end;

כל מה שקשור להמרה תחום בתקציב לפני שמנסים אותו. THPDFDocumentHandlerOptions.Default מגביל רשומות ארכיון ל־10,000, בתי ארכיון מורחבים ל־1 GiB, את יחס הדחיסה ל־200, משאבים ל־4,096 ועמודים ל־10,000, והוא נושא CancellationToken אופציונלי כדי שעבודת צד שרת תוכל להיעצר באמצע חבילה. קראו את Info.UnsupportedFeatureCount אחר כך והתייחסו לערך שאינו אפס כממצא אמיתי: HotPDF סופר בכוונה את מה שלא יכול למפות במקום לצייר קירוב ולשתוק עליו

למה עמוד XPS צריך מטריצה במקום קואורדינטות מכתובות מחדש?

כי כתיבת קואורדינטות מחדש מאבדת את מחסנית הטרנספורמציות. FixedPage של XPS מוגדר ביחידות 96 DPI עם ראש הצירים בפינה השמאלית העליונה ו־Y שגדל מטה; מרחב המשתמש של PDF הוא 72 DPI עם ראש הצירים בפינה השמאלית התחתונה ו־Y שגדל מעלה. התיקון הנאיבי הוא לכפות כל מספר ב־0.75 ולחסר כל Y מגובה העמוד בזמן הפליטה. זה עובד לנתיב שטוח אחד ומתפרק ברגע שנכנס RenderTransform, Canvas מקונן או מטריצה מקומית למברשת, כי הטרנספורמציות האלה מוגדרות במרחב XPS והכתיבה פר־קואורדינטה שלכם כבר עזבה אותו. לכן HotPDF משאיר את ההקרנה כמטריצה ומרכיב אותה. HPDFXPSPageMatrix מחזיר את הקבועים פעם אחת לעמוד, HPDFMultiplyXPSMatrix משרשר אותה עם טרנספורמציית הנתיב המצטברת, והתוצאה נפלטת כאופרטור cm יחיד לפני הגיאומטריה. נתוני הנתיב נכתבים אז במספרי XPS לא משונים, וזו גם הסיבה שתחביר הגיאומטריה המקוצר יכול לשתף את אותו ניתוח חסום המשמש לנתוני נתיב SVG — רק אסימון כלל המילוי F0 או F1 הפותח מטופל על ידי מתאם ה־XPS. אם עקבתם אחר אותו היגיון בייבוא וקטורי של EMF ו־WMF, צורת הטיעון מוכרת: פורמטי ייבוא מומרים במטריצה, לעולם לא באריתמטיקה על קואורדינטות עלה

HotPDF מרכיב את מטריצת העמוד הקבועה של XPS עם טרנספורמציית הנתיב המצטברת כך שמערכת קואורדינטות XPS של 96 DPI עם ראש צירים בפינה העליונה מגיעה למרחב משתמש PDF של 72 DPI עם ראש צירים בפינה התחתונה, ונפלטת כאופרטור cm יחיד לכל ויזואל
ההקרנה נשארת מטריצה ומורכבת עם כל טרנספורמציה מקוננת, ולכן נתוני נתיב יכולים להיכתב במספרי XPS לא משונים
function HPDFXPSPageMatrix(PageHeight: Double): THPDFXPSMatrix;
begin
  Result.A :=  0.75;       // יחידת XPS של 96 DPI לנקודת PDF של 72 DPI
  Result.B :=  0;
  Result.C :=  0;
  Result.D := -0.75;       // Y של XPS גדל מטה, Y של PDF גדל מעלה
  Result.E :=  0;
  Result.F := PageHeight;  // גובה עמוד PDF, בנקודות
end;

// CTM מורכב אחד לכל ויזואל, נפלט לפני כל אופרטור נתיב
Effective := HPDFMultiplyXPSMatrix(HPDFXPSPageMatrix(PageHeightPDF), PathMatrix);
Page.AppendRawContent(HPDFXPSMatrixOperator(Effective));

איך משתמשים שוב ב־VisualBrush בלי לצייר אותו פעמיים?

VisualBrush צובע עץ ויזואלי שרירותי — ילדי Canvas, Path ו־Glyphs — לתוך אזור, ייתכן בחזרות על פניו. HotPDF מרכיב את הויזואל הזה פעם אחת ל־PDF Form XObject ואז ממקם אותו, וזו אותה אסטרטגיית משאבים המתוארת בייבוא SVG דרך Form XObjects. שני פרטים מכריעים האם זה עובד. ראשית, את הויזואל צריך לעבור כילדי XML ישירים: סריקה שטוחה אחר אלמנטים ראויים לאריחה מעלה ויזואלים מקוננים לרמת העמוד העליונה והורסת גם את תחום המשאבים וגם את סדר הצביעה. שנית, התוכן נלכד עם מטריצת העמוד XPS ל־PDF כבר מיושמת, ולכן פרסום ה־Form דורש כפל בהופך של אותה מטריצה, אחרת כל מיקום מיישם שוב את קנה המידה 0.75 ואת היפוך ה־Y. ה־Form גם צריך להחזיק את המשאבים שלו: HotPDF מעתיק רק את הגופנים, ה־XObjects, התבניות, ה־ExtGStates ומרחבי הצבע שזרם התוכן הנלכד באמת מפנה אליהם; שיבוט מילון משאבי העמוד כולו יגרור את ה־Form שמתרשם לתוך גרף המשאבים של עצמו ויבנה מעגל. גופנים נשארים במילון ישיר בעמודים רגילים ומועלים למילון עקיף משותף רק כשהתוכן הנלכד באמת מכיל Tf, כך שמסמך בלי ויזואלים לשימוש חוזר לא משלם על המכונה. שימו לב לגבול מפרט אחד שכדאי להכיר לפני שאתם פותחים באג: מקטע 13.4 של ECMA-388 דורש שגם ViewboxUnits וגם ViewportUnits על VisualBrush יהיו Absolute, ולכן יחידות יחסיות אינן תכונה חסרה — הן קלט שאינו תואם, ו־HotPDF מסרב להמציא להן סמנטיקת קואורדינטות

HotPDF מרכיב עץ ויזואלי של XPS VisualBrush פעם אחת ל־PDF Form XObject, מפרסם אותו דרך ההופך של מטריצת הקבוע־עמוד כך שמיקומים לא מיישמים שוב את קנה המידה, ומעתיק רק את המשאבים שהתוכן הנלכד באמת מפנה אליהם
התוכן נלכד עם מטריצת העמוד כבר מיושמת, ולכן ה־Form מפורסם דרך ההופך שלה ונושא רק את המשאבים שזרם התוכן שלו עצמו מפנה אליהם

אריחת ImageBrush: ארבעה מצבים, ארבעה גדלי תא

מצבי האריחה של XPS ממופים לתבניות אריחה של PDF ממקטע 8.7.3 של ISO 32000-1 במקום להתרחב למיקומי תמונה חוזרים על פני האזור המכוסה, מה ששומר על גודל הפלט וזמן ההמרה בלתי־תלויים בכמה מהעמוד המברשת מכסה. המיפוי מכני ברגע שרואים אותו: השתקפות מבוטאת בהצבת מיקומים מראת־מראה בתוך תא תבנית אחד והגדלת התא להתאים

  • Tile — מיקום אחד, התא נשאר viewport של 1×1
  • FlipX — שני מיקומים, התא מורחב ל־2×1
  • FlipY — שני מיקומים, התא מוגבה ל־1×2
  • FlipXY — ארבעה מיקומים, התא מורחב ל־2×2

כל מיקום נושא מלבן חיתוך משלו, כי מיפוי Viewbox שחורג מהתא־המשנה שלו ידמם אל ההשתקפות השכנה. ה־/Matrix של התבנית הוא החלק שתופס אנשים. תבנית אריחה נעוגה למרחב המשתמש הברירת־מחדל של זרם התוכן ההורה, לא למצב הגרפיקה הנוכחי ברגע בחירת התבנית, ולכן המטריצה צריכה להרכיב את שלוש השכבות במפורש — את הקרנת הקבוע־עמוד, את טרנספורמציית ה־Path ואת ה־Transform המקומי למברשת — במקום לסמוך על CTM סביבתי. HotPDF גם מתקף לפני שהוא מקצה: RegisterImageTilingPattern מגביל תבנית ל־1,024 מיקומים ודוחה חיתוכים ניווניים, מטריצות שאינן הפיכות ואינדקסי תמונה לא תקפים. אם אתם רוצים את המודל הכללי בצד ה־PDF מאחורי זה, תבניות אריחה ומרחב הצבע Pattern מכסה את האופרטורים הבסיסיים

// הקרנת הקבוע־עמוד מקופלת למטריצת התבנית, ואז זו המקומית למברשת
PatternMatrix := HPDFMatFromOps( 0.75 * PathMatrix.A, -0.75 * PathMatrix.B,
                                 0.75 * PathMatrix.C, -0.75 * PathMatrix.D,
                                 0.75 * PathMatrix.E,
                                 PageHeight - 0.75 * PathMatrix.F);
if Brush.HasTransform then
  PatternMatrix := HPDFMatMul(PatternMatrix, BrushMatrix);

PatternName := Document.RegisterImageTilingPattern(Resource.ImageIndex,
  Brush.Viewport.Left, Brush.Viewport.Top,
  Brush.Viewport.Left + CellWidth, Brush.Viewport.Top + CellHeight,
  CellWidth, CellHeight, Placements, pttNoDistortion, PatternMatrix);

מה קורה כשגרדיאנט רדיאלי אינו מעגל?

XPS מגדיר RadialGradientBrush עם GradientOrigin, Center, RadiusX ו־RadiusY, ולכן המברשת היא אליפסה. הצללה מסוג 3 של PDF, במקטע 8.7.4.5.4 של ISO 32000-1, מערבבת בין שני מעגלים ואין לה דרך לבטא אליפסה ישירות. מיצוע שני הרדיוסים למספר אחד הוא הקיצור המפתה והוא טעות נראית לעין בכל מברשת שאינה קרובה לעגולה. HotPDF מעביר במקום זאת את הבעיה למערכת הקואורדינטות: הוא מתאים את Y בקנה המידה RadiusY / RadiusX, רושם הצללה עגולה כנה באותו מרחב מתואם, בוחר את התבנית, ומיד פולט את קנה המידה ההפוך כך שגיאומטריית הנתיב שנכתבת אחר כך נשארת במרחב המשתמש המקורי של XPS

ScaleY := Brush.RadiusY / Brush.RadiusX;
PatternName := Document.RegisterMultiStopRadialGradient(
  Brush.StartX, Brush.StartY / ScaleY, 0,
  Brush.EndX,   Brush.EndY   / ScaleY, Brush.RadiusX,
  StopPositions, StopColours, 3);

if Abs(ScaleY - 1) > 0.000001 then
  Page.AppendRawContent('1 0 0 ' + HPDFPDFNumber(ScaleY) + ' 0 0 cm'#10);
Page.SetFillPattern(PatternName);   // התבנית לוכדת את ה־CTM בדיוק כאן
if Abs(ScaleY - 1) > 0.000001 then
  Page.AppendRawContent('1 0 0 ' + HPDFPDFNumber(1 / ScaleY) + ' 0 0 cm'#10);

הסדר בקטע הקוד הזה הוא כל התרגיל, והוא לא סגנוני. תבנית הצללה של PDF לוכדת את מטריצת הטרנספורמציה הנוכחית ברגע שהיא נבחרת כצבע הנוכחי, ולכן קנה המידה הזמני חייב להיפלט לפני SetFillPattern או SetStrokePattern, וההפוך חייב לבוא אחרי הבחירה אך לפני אופרטורי הנתיב. תשגו את הסדר לרעה באחד הכיוונים ותקבלו גרדיאנט שמתעדף נכון בנתיב הראשון וסוטה בכל הבא אחריו. מגבלה קרובה חלה על מצב קואורדינטות יחסיות: RadiusX ו־RadiusY חייבים להיפתר מול רוחב הנתיב וגובהו בנפרד, כי קנה מידה אחד לשניהם לפי אורך צלע בודד משנה בשקט את יחס הממדים של האליפסה בכל נתיב שאינו מרובע

HotPDF ממפה אליפסה של XPS RadialGradientBrush להצללה מסוג 3 של PDF על ידי קנה מידה של Y, רישום הצללה עגולה במרחב המתואם, ופליטת קנה המידה ההפוך רק אחרי שהתבנית לכדה את מטריצת הטרנספורמציה הנוכחית
האליפסה נבלעת במערכת הקואורדינטות ולא בהצללה, וקנה המידה הזמני חייב להקיף את בחירת התבנית בדיוק בסדר הזה

איפה ההמרה כנה לגבי מגבלותיה

חלק ממבני ה־XPS מומרים בקירוב וחלק לא מומרים בכלל, והבחירה העיצובית לאורך כל הדרך היא לספור אותם ולא לזייף אותם. חלקי TIFF ו־JPEG XR נסרקים לרסטר דרך WIC ואינם נושאים הבטחה על אלפא שנשמר, בזמן ש־PNG עם ערוץ אלפא תקף מפוצל לתמונת בסיס ועוד /SMask. גודלה הטבעי של תמונה נגזר כ־pixel * 96 / DPI, קורא קודם את pHYs של PNG או את צפיפות ה־JFIF של JPEG ונופל אחורה ל־96 DPI, כך שכותרת צפיפות פגומה נוחתת בגודל צפוי ולא בגודל שרירותי. משאבי מטריצה שלא נפתרו, טרנספורמציות יחסיות שאינן סטנדרטיות, ColorConvertedBitmap, מצבי פרישת גרדיאנט שאינם נתמכים וגיאומטריה פגומה — כולם מגדילים את UnsupportedFeatureCount, וקלט פגום נכשל סגור במקום להידרדר לציור שונה בשקט

זו העמדה השימושית לממיר ארכיוני: המרה שמקרבת בשקט גרועה מכזאת שאומרת לכם אילו ארבעה אלמנטים לא יכלה לייצג, כי רק השנייה נותנת לכם על מה לבדוק לפני שהמסמך נחתם למערכת רשומות. אם אתם מעריכים המרת XPS ו־OpenXPS לצד שאר צינור המסמכים — הרכבת עמודים, גופנים, חתימה, פלט PDF/A — עמוד HotPDF Delphi PDF component מפרט את מלוא ערכת התכונות ואת גרסאות Delphi ו־C++Builder הנתמכות