מאמר טכני

רינדור PDF ברצועות ב-Delphi: היסט Y שלילי

הרצועה הראשונה הכילה את כל הציור כשהוא מעוך לרצועה אחת, וחמש הרצועות שאחריה חזרו ריקות. זה היה הייצוא הישן ברצועות, ו-PDFiumPas תיקנה אותו בגרסה 3.66.0: RenderPageBanded מעבירה כעת ל-FPDF_RenderPageBitmap בכל רצועה ורצועה את הרוחב והגובה המלאים של יעד העמוד, יחד עם offset אנכי שלילי, כך שה-clip הטבעי כותב רק את השורות של הרצועה הנוכחית בעוד שהעמוד שומר על הגיאומטריה המלאה שלו. התרחיש שמאחורי כל זה משעמם ובלתי נמנע. מישהו מוסר לכם plot בגודל E או עמוד פנורמה stitched ורוצה raster ברזולוציה של 600 DPI. גיליון ISO A0 ב-600 DPI הוא 19866 על 28086 פיקסלים, ומפת סיביות של 32 ביט בגודל כזה היא קצת יותר מ-2 GB של זיכרון רציף. ב-Delphi של 32 סיביות ההקצאה פשוט נכשלת. ב-64 סיביות היא מצליחה מספיק פעמים כדי להפוך את הכשל לבעיה של לקוח ולא של בדיקה. רינדור ברצועות קיים כדי שההקצאה בשיא תהיה רצועה אחת ולא עמוד אחד

מדוע כל רצועה הכילה את העמוד כולו

הקוד הישן בלבל בין שני זוגות ארגומנטים שונים בקריאת רינדור העמוד של PDFium. FPDF_RenderPageBitmap מקבלת start_x, start_y, size_x ו-size_y, כאשר זוג הגודל אומר לאיזה גודל יש לשנות את קנה המידה של העמוד כולו וזוג ההתחלה אומר היכן העמוד המותאם לקנה מידה נוחת בתוך מפת הסיביות של היעד. לולאת הרצועות שלפני v3.66.0 קראה ל-helper של הספרייה RenderPage עם ראש הרצועה כ-offset של היעד ועם גובה הרצועה כגובה העמוד. שני המספרים עברו ישירות לקריאה הטבעית, ולכן PDFium התאימה את כל העמוד למלבן שגובהו רק BandHeight שורות ואז ציירה אותו ב-y = BandTop בתוך מפת סיביות שגם היא הייתה בגובה BandHeight בלבד. התוצאה הייתה בדיוק מה שאפשר לחזות ברגע שרואים אותה. רצועה אפס קיבלה את כל העמוד כשהוא מעוך אנכית לגובה הרצועה. כל רצועה מאוחרת יותר קיבלה את אותו עמוד מעוך כשהוא דחוף מתחת לקצה התחתון של מפת הסיביות שלה, ולכן חזרה כמילוי רקע. הבאג מסתתר במקרה היחיד שרוב smoke tests משתמשים בו, עמוד שגובה הרינדור שלו קטן מגובה הרצועה, מפני שאז יש רצועה אחת והגיאומטריה השגויה במקרה חופפת לנכונה. כל דבר שגבוה מרצועה אחת חושף אותו מיד

מה ההיסט השלילי מבטיח

המימוש המתוקן מנתב כל רצועה דרך RenderTile, המקום היחיד ברכיב שכבר הבין את ההבדל. RenderTile מקבלת origin של tile בקואורדינטות פיקסלים של העמוד המלא, יחד עם PageWidth ו-PageHeight נפרדים, ומעבירה ל-PDFium את -Left ו--Top כשגודל העמוד נשאר ללא שינוי. שלילת ה-offset מחליקה את העמוד בגודל מלא כלפי מעלה עד שהרצועה המבוקשת יושבת בשורה אפס של מפת סיביות היעד; לאחר מכן PDFium מבצעת clip באופן טבעי לפי גבולות מפת הסיביות, ולכן שום דבר מחוץ לרצועה אינו עובר rasterization. מיפוי העמוד להתקן שמתואר ב-ISO 32000-1 סעיף 8.3.2 נשאר זהה מהרצועה הראשונה ועד האחרונה, וזה כל העניין: רצועה N זהה ביט-לביט לשורות BandTop עד BandTop + h של רינדור עמוד מלא יחיד, וחבילת הרגרסיה טוענת בדיוק זאת, פיקסל אחר פיקסל, מול פלט RenderPage באותן מידות

// רצועה אחת ידנית. מפת הסיביות של היעד היא בגובה BandHeight בלבד,
// אך גודל יעד העמוד נשאר Width x Height מלא
Band := Pdf.RenderTile(0, BandTop,          // origin של tile בפיקסלי העמוד
                       Width, BandHeight,   // גודל מפת הסיביות של היעד
                       Width, Height);      // גודל היעד של העמוד המלא
try
  // Band מחזיקה כעת את השורות BandTop .. BandTop + BandHeight - 1 של העמוד
finally
  Band.Free;
end;

ה-API הציבורי של רצועות הוא לולאת callback. RenderPageBanded(Width, Height, BandHeight, BandCallback, Rotation, Options, Color) מחזירה את מספר הרצועות שרינדרה בפועל, או 0 כאשר הארגומנטים נדחים, ומחזיקה את נעילת הרינדור של הרכיב לאורך כל המעבר. חתימת ה-callback היא TPdfBandCallback = function(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean of object. מפת הסיביות היא pf32bit, ברוחב Width פיקסלים ובגובה שאינו עולה על BandHeight, והיא משתחררת מיד כשה-handler שלכם חוזר, לכן יש להעתיק כל דבר שתרצו לשמור. החזרת False עוצרת את המעבר אחרי הרצועה הנוכחית, ומעניקה לכם את אותו מודל ביטול שיתופי שמשמש רינדור PDF מתקדם וניתן לביטול ב-Delphi, רק ברמת רצועה במקום ברמת המשך של PDFium

type
  TBandSink = class
  private
    FCancelled: Boolean;
    FRows: Integer;
  public
    function HandleBand(BandIndex, BandTopY: Integer;
      Bitmap: TBitmap): Boolean;
    property Rows: Integer read FRows;
  end;

function TBandSink.HandleBand(BandIndex, BandTopY: Integer;
  Bitmap: TBitmap): Boolean;
begin
  // Bitmap מתה כשהמתודה חוזרת - צרוך אותה כאן
  Inc(FRows, Bitmap.Height);
  Result := not FCancelled;
end;

// ...
Pdf.PageNumber := 1;
Bands := Pdf.RenderPageBanded(19866, 28086, 256, Sink.HandleBand);

הזרמת PNG ו-TIFF בלי מפת סיביות של עמוד מלא

רינדור ברצועות עוזר רק אם גם המקודד רציף, לכן v3.66.0 הוסיפה את RenderPageBandedToStream, שכותבת PNG או TIFF ישירות לזרם של caller. TPdfBandedImageStreamOptions.Default מאתחלת גובה רצועה של 256 שורות, רמת דחיסת PNG של 6 ו-MaxOutputBytes של 0, שפירושו ללא הגבלה. ה-TPdfBandedImageReport המוחזר נושאת את Format, Width, Height, BandsRendered, BandsEncoded, RowsEncoded, PeakBandBytes, OutputBytes ו-Completed. PeakBandBytes הוא המספר שבאמת אכפת לכם ממנו כשמתכננים job: הוא Width * BandHeight * 4, לכן גיליון ה-A0 שלמעלה מגיע בשיא לכ-19 MB של buffer לרצועה במקום 2 GB של buffer לעמוד

מקודד ה-PNG צר בכוונה. הוא פולט RGB8 קבוע, כותב IHDR עם עומק ביט 8 ו-color type 2, ואז בונה כל scanline עם filter type 0 (ISO/IEC 15948 filter method 0, filter type None) ודוחף אותו דרך זרם הדחיסה zlib של הפלטפורמה. הבתים הדחוסים יוצאים בחזרה כ-chunks של IDAT עם CRC, שנכתבים לפי הסדר. האילוץ המעניין הוא הזרם שיושב מתחת לשכבת ה-deflate: הוא עונה לשאילתות position מפני שזרם הדחיסה שואל אותן, אך כל ניסיון לבצע seek אמיתי מעלה שגיאה. זה מכוון. ברגע ש-chunk של IDAT וה-CRC שלו נמצאים על החוט אין דרך לחזור ולתקן אותם, ו-seek שקט היה משחית פלט שעדיין נראה תקין מבנית

מקודד ה-TIFF כותב TIFF קלאסי ב-little-endian, את סימן סדר הבתים II ואחריו magic 42, עם strip אחד לכל רצועה. הפיקסלים מוזרמים תחילה וה-IFD בן עשרת האיברים נבנה בסוף, ברגע ש-offsets של ה-strips ומספרי הבתים ידועים. הדחיסה היא tag 259 value 1, לכן אין entropy coding כלל: ה-payload הוא בדיוק Width * Height * 3 בתים, PhotometricInterpretation הוא RGB, PlanarConfiguration הוא chunky ו-RowsPerStrip מתעד את גובה הרצועה, בעוד שה-strip הקצר האחרון מתואר בערך StripByteCounts משלו. לכן גובה הרצועה משנה את הזיכרון בשיא ואת מספר ה-strips אך לא את גודל הפלט, וזה כדאי לדעת לפני כוונון. אם אתם רוצים קבצים קטנים ולא lossless, הנתיב לפי עמוד בהמרת עמודי PDF לתמונות JPEG עם רכיב PDFium VCL ב-Delphi נשאר הכלי הטוב יותר

var
  StreamOptions: TPdfBandedImageStreamOptions;
  Report: TPdfBandedImageReport;
  Output: TFileStream;
begin
  StreamOptions := TPdfBandedImageStreamOptions.Default(pbifPng);
  StreamOptions.BandHeight := 512;
  StreamOptions.CompressionLevel := 6;
  StreamOptions.MaxOutputBytes := Int64(256) * 1024 * 1024;

  Output := TFileStream.Create('sheet-a0-600dpi.png', fmCreate);
  try
    Report := Pdf.RenderPageBandedToStream(Output, 19866, 28086,
      StreamOptions);
  finally
    Output.Free;
  end;

  if not Report.Completed then
    raise Exception.Create('Banded export stopped before the last row');
  // Report.PeakBandBytes = 19866 * 512 * 4, לא 19866 * 28086 * 4
end;

היכן ייצוא ברצועות נעצר

שתי תקרות מגבילות את הפלט, והן נכשלות במקומות שונים בכוונה. הראשונה היא תקציב ה-caller: MaxOutputBytes נאכף על ידי זרם כתיבה מוגבל שמעלה EPdfError לפני כל כתיבה שהייתה חוצה את הגבול, לכן התקציב הוא cap קשיח ולא דוח בדיעבד. השנייה היא מבנית. TIFF קלאסי שומר offsets של strips כערכי 32 ביט, לכן BeginImage מאמת את Width * Height * 3 בתוספת header ו-directory מול התקרה ודוחה את ה-job לפני שנכתב פיקסל אחד; אותה בדיקה רצה מראש מול MaxOutputBytes, מפני ש-TIFF שהתקציב שלו אינו יכול להכיל את ה-payload של הפיקסלים שלו אינו שווה התחלה. ל-PNG אין מגבלה מקבילה, מפני ש-chunks של IDAT הם רציפים בלבד ואין טבלת offset של 32 ביט שעלולה לעלות על גדותיה

היו מפוכחים לגבי מה שייצוא שנעצר משאיר מאחור. כאשר המעבר אינו מגיע לשורה האחרונה, Completed נשאר False והמקודד מפורק עם EndImage(False), שכותבת בכוונה לא את chunk ה-PNG IEND ולא את ה-IFD של TIFF. לכן הקובץ החלקי אינו חוקי וכל decoder יאמר זאת, במקום להיות תמונה שנראית סבירה אך חסרות בה שורות. הניקוי עטוף כך שכשל משני בתוך EndImage לא יחליף את החריגה המקורית, וזה ההבדל בין stack trace שמציין את הסיבה האמיתית לבין כזה שמציין את המנקה. אם אתם זקוקים להתקדמות ששרדה, שמרו checkpoint לכל רצועה בתוך ה-callback שלכם; טקטיקות cache ברמת strip במדריך cache הרינדור וה-zoom של PDFium ל-Delphi חלות גם כאן

חיבור codec משלכם

כאשר PNG ו-TIFF אינם היעד, RenderPageBandedToEncoder מקבלת descendant של TPdfBandedImageEncoder ומניעה את אותה לולאה. מחזור החיים מפורש וקצר: BeginImage(Width, Height), אחר כך WriteBand(BandIndex, BandTopY, Bitmap) פעם אחת לכל strip בסדר עולה בהחלט, ולבסוף EndImage(Completed), כאשר GetBytesWritten מזינה את Report.OutputBytes. המקודדים המובנים דוחים רצועה שמגיעה מחוץ לסדר במקום לנסות לשמור אותה במאגר, וגם כל encoder שתכתבו צריך לעשות זאת, מפני ש-codec שמסדר strips מחדש בשקט מפיק קובץ שנפתח ומשקר. זה התפר להשתמש בו עבור tiles של JPEG 2000, writer של JPEG שמוזן שורת MCU אחת בכל פעם או הזנה ישירה ל-print spooler

type
  TCodecBandEncoder = class(TPdfBandedImageEncoder)
  private
    FNextBand: Integer;
    FWritten: Int64;
  public
    procedure BeginImage(Width, Height: Integer); override;
    function WriteBand(BandIndex, BandTopY: Integer;
      Bitmap: TBitmap): Boolean; override;
    procedure EndImage(Completed: Boolean); override;
    function GetBytesWritten: Int64; override;
  end;

function TCodecBandEncoder.WriteBand(BandIndex, BandTopY: Integer;
  Bitmap: TBitmap): Boolean;
begin
  if BandIndex <> FNextBand then
    raise EPdfError.Create('Bands must arrive in order');
  Bitmap.PixelFormat := pf32bit;
  // הזן כאן את Bitmap.ScanLine[0 .. Bitmap.Height - 1] ל-codec
  Inc(FNextBand);
  Result := True;
end;

מלכודת cross-compiler אחת שכדאי להכיר

יחידת zlib נקראת אחרת בכל toolchain נתמך: Delphi XE5 ומאוחר יותר משתמשות ב-System.ZLib, FPC משתמשת ב-zstream ו-Delphi ישנה יותר משתמשת ב-ZLib רגיל. זה conditional compilation שבשגרה. המלכודת היא ששלושתן מייצאות קבועי רמת דחיסה בשם clNone ו-clDefault, שמתנגשים חזיתית עם איברי TColor באותו שם ביחידת הגרפיקה. ברגע שיחידת zlib מופיעה בסעיף uses של המימוש, clNone ללא qualification בקוד הרינדור יכול להיפתר לרמת דחיסה במקום לצבע, ללא אבחון. PDFiumPas מקבעת זאת באמצעות aliases מפורשים של sentinels לצבע, PdfGraphicsColorNone ו-PdfGraphicsColorDefault, שנקשרים פעם אחת לקבועי הגרפיקה המוסמכים במלואם ומשמשים בכל מקום שבו משווים sentinel של רקע או scheme צבע. שלוש שורות קוד ועולם פתרון הסמלים מפסיק לנדוד בין מהדרים

רינדור ברצועות נראה כמו תכונת נוחות עד שפוגשים עמוד שאינו נכנס ל-RAM, ואז זה הנתיב היחיד שעובד. גיאומטריית הרצועות המתוקנת, מקודדי ה-PNG וה-TIFF הרציפים והתפר ל-codec מותאם נשלחים כולם כחלק מרכיב PDFium ל-Delphi, עם השוואת הפיקסלים המלאה בין רצועה לעמוד שרצה בחבילת הרגרסיה מול Delphi, Lazarus ו-C++Builder