מאמר טכני

ציור תרשימים ב-PDF עם הפרימיטיבים של HotPDF

ל-HotPDF אין אובייקט תרשים. אין TPDFChart, אין AddBarSeries, שום דבר שמקבל מערך של מספרים ומחזיר בחזרה גרף מרונדר. מה שהוא נותן לך במקום זאת הוא בד ציור (canvas) לעמוד עם אותו אוצר מילים ברמה נמוכה שבו כל מודל ציור של PDF משתמש: מלבנים, קווים, עיגולים, מילויים, קווי רקיע (strokes) וטקסט הממוקמים בקואורדינטות מדויקות. לפיכך, תרשים במסמך HotPDF הוא משהו שאתה בונה, לא משהו שאתה מבקש. זה נשמע כמו יותר עבודה ממה שזה באמת. ברגע שכתבת את מתמטיקת הקואורדינטות פעם אחת, תרשים עמודות הוא לולאה על פני מלבנים, תרשים קווים הוא פוליליין (polyline), ותרשים עוגה הוא מניפה של קשתות, ואתה שולט בכל פיקסל של התוצאה

זה חשוב מכיוון שהחלופה שאנשים שולחים אליה יד ראשונה, יצירת מפת סיביות (rasterizing) מפקד תרשים על המסך ל-bitmap והדבקת התמונה לתוך העמוד, נותנת לך תרשים הנעול לרזולוציית המסך שמודפס מטושטש ומנפח את הקובץ. ציור התרשים עם הפרימיטיבים הוקטוריים של HotPDF שומר על פלט חד בכל רמת תקריב (zoom) ובכל DPI הדפסה, מכיוון שהעמודות והצירים הם אופרטורי נתיב PDF אמיתיים, לא פיקסלים. המחיר הוא שאתה הבעלים של הפריסה. המכניקה מסתכמת במספר מהלכים: היפוך הקואורדינטות האחד שתופס את כולם, תרשים עמודות מעובד, טריק ה-polyline עבור תרשימי קווים, ומתמטיקת הקשתות לפרוסות עוגה

החלק הקשה היחיד הוא מערכת הקואורדינטות

גרפיקת מסך מציבה את נקודת ההתחלה בפינה השמאלית-עליונה עם ציר Y שגדל כלפי מטה. PDF עושה את ההפך. נקודת ההתחלה יושבת בפינה השמאלית-תחתונה של העמוד וציר Y גדל כלפי מעלה, נמדד בנקודות (1/72 אינץ'). כל קריאת ציור ב-HotPDF, TextOut, Rectangle, MoveTo, LineTo, Circle, משתמשת במוסכמה זו של שמאל-למטה, Y-למעלה. אם אתה מעביר הלאה אינסטינקטים של גרפיקת-מסך, התרשים הראשון שלך מצויר הפוך ורץ מחוץ לחלק התחתון של העמוד

לכן העבודה האמיתית בכל תרשים היא מיפוי אחד: הפיכת ערך נתונים לקואורדינטת Y המכבדת את הכלל שציר Y עולה (Y-up). החלט על מלבן שרטוט (plot rectangle), ארבעה מספרים עבור האזור בו התרשים חי, לאחר מכן מפה את הערך הקטן ביותר בנתונים שלך לקצה התחתון ואת הגדול ביותר לעליון. עבור עמודה בערך V בסולם שרץ מ-0 ל-MaxValue, הקצה העליון של העמודה הוא PlotBottom + (V / MaxValue) * PlotHeight, והעמודה גדלה כלפי מעלה מ-PlotBottom. קבל את הביטוי האחד הזה נכון וכל השאר הוא הנהלת חשבונות (bookkeeping). העזר (helper) להלן מחזיק את הגיאומטריה של השרטוט ומבצע את ההמרה, כך שקוד הציור לעולם אינו נוגע באריתמטיקה גולמית פעמיים:

type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // PDF points, bottom-left origin
    MaxValue: Single;                     // top of the value scale
  end;

// Map a data value to its Y coordinate inside the plot, Y growing upward.
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
  Result := Plot.Bottom + (V / Plot.MaxValue) * Plot.Height;
end;

קריאת שיפוט אחת מתחבאת ב-MaxValue. אם תגדיר אותה לנקודת הנתונים הגדולה ביותר המדויקת, העמודה הגבוהה ביותר תיגע בקצה העליון של השרטוט ותיראה חתוכה. עגל אותה כלפי מעלה למספר נקי מעל המקסימום, נניח לכפולה הבאה של 10 או 100, כך שלתרשים יהיה מרווח ראש (headroom) ותוויות קווי הרשת ייקראו כמספרים עגולים במקום כל מה שהנתונים הגיעו אליו בשיא

תרשים עמודות הוא לולאה על פני מלבנים

עם המיפוי מוסדר, תרשים עמודות כותב את עצמו. חלק את רוחב השרטוט לחריץ (slot) אחד לכל קטגוריה, השאר רווח בין העמודות כך שהן לא ייגעו, וצייר כל עמודה כמלבן מלא שהגובה שלו מגיע מ-ValueToY. ה-Rectangle של HotPDF מקבל את הפינה השמאלית-התחתונה בתוספת רוחב וגובה, מה שמתיישר בדיוק עם עמודה שגדלה כלפי מעלה מהיסוד (baseline). הגדר תחילה את צבע המילוי, הנח את הנתיב, ולאחר מכן קרא ל-Fill כדי לצבוע אותו. תווית הקטגוריה מגיעה מתחת ליסוד, הערך מעל העמודה:

procedure DrawBarChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single; const Labels: array of string);
var
  I, Count: Integer;
  SlotW, BarW, BarX, BarH, Gap: Single;
begin
  Count := Length(Values);
  SlotW := Plot.Width / Count;
  Gap := SlotW * 0.25;          // quarter-slot gap on each side
  BarW := SlotW - Gap;

  // Baseline (the X axis) along the bottom of the plot.
  Page.SetLineWidth(1.0);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
  Page.Stroke;

  Page.SetFont('Arial', [], 9);
  for I := 0 to Count - 1 do
  begin
    BarX := Plot.Left + I * SlotW + Gap / 2;
    BarH := ValueToY(Plot, Values[I]) - Plot.Bottom;

    Page.SetRGBFillColor(RGB(56, 110, 219));
    Page.Rectangle(BarX, Plot.Bottom, BarW, BarH);  // X, Y, Width, Height
    Page.Fill;

    // Category label below the baseline, value above the bar.
    Page.SetRGBFillColor(clBlack);
    Page.TextOut(BarX, Plot.Bottom - 14, 0, Labels[I]);
    Page.TextOut(BarX, Plot.Bottom + BarH + 4, 0,
      FormatFloat('0', Values[I]));
  end;
end;

שני פרטים מרוויחים את מקומם ביושר. ה-Gap (הפער) הוא שבר מהחריץ ולא ספירת נקודות קבועה, כך שהעמודות נשארות מרווחות באופן פרופורציונלי בין אם אתה משרטט ארבע קטגוריות או ארבעים. ותווית הערך ממוקמת באותו גובה שנגזר מ-ValueToY בו משתמשת העמודה, כך שהיא תמיד יושבת ממש מעל העמודה שלה במקום לרחף בהיסט (offset) מנוחש. אם אתה רוצה קווי רשת אופקיים מאחורי העמודות, צייר אותם לפני הלולאה: בחר שלושה או ארבעה ערכים עגולים, הרץ את ValueToY על כל אחד, ושרטט קו חלש לרוחב השרטוט באותו Y. ציורם קודם מציב אותם מאחורי העמודות בהערמת מודל-הצייר (painter's-model stacking) בה PDF משתמש

צירים, שנתות (ticks) ותוויות הם רק עוד קווים וטקסט

התרשים אינו גמור עד שקורא יכול לדעת למה מתכוונות העמודות, וזוהי לחלוטין עבודת ציר (axis). הציר האנכי הוא קו אחד מסורטט במעלה הקצה השמאלי של השרטוט עם קומץ סימוני שנתות (tick marks) וערכיהם. עשה שימוש חוזר ב-ValueToY כך שהשנתות ינחתו באותו סולם שבו משתמשות העמודות, אחרת עמודה וקו הרשת שלה לא יסכימו והתרשים ישקר בשקט:

procedure DrawValueAxis(Page: THPDFPage; const Plot: TPlotArea;
  TickCount: Integer);
var
  I: Integer;
  TickV, TickY: Single;
begin
  Page.SetLineWidth(1.0);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left, Plot.Bottom + Plot.Height);
  Page.Stroke;

  Page.SetFont('Arial', [], 8);
  for I := 0 to TickCount do
  begin
    TickV := (Plot.MaxValue / TickCount) * I;
    TickY := ValueToY(Plot, TickV);
    Page.MoveTo(Plot.Left - 4, TickY);   // short tick outside the axis
    Page.LineTo(Plot.Left, TickY);
    Page.Stroke;
    Page.TextOut(Plot.Left - 30, TickY - 3, 0, FormatFloat('0', TickV));
  end;
end;

תוויות הן המקום שבו תרשימים נשברים לרוב בייצור (production), והכשל הוא תמיד זהה: טקסט שהתאים על המסך חורג מהמקום שלו ב-PDF. שמות קטגוריות ארוכים מתנגשים עם השכנים שלהם, ושמות חודשים שעברו לוקליזציה כמו "septembre" או "Dezember" רחבים יותר מה-"Sep" האנגלי שבדקת איתו. אין התאמת-גודל-אוטומטית (autosize) שתושיע אותך כאן, אז השאר שוליים אמיתיים מתחת ליסוד, כווץ את הגופן בנקודה או שתיים עבור סטים צפופים של קטגוריות, ואם השמות באמת ארוכים, סובב אותם. TextOut מקבל זווית כארגומנט השלישי שלו, ולכן העברת 90 מעמידה את התווית על הקצה וקונה לך מקום ללא חפיפה. בדוק את הפריסה עם התווית הרחבה ביותר הצפויה שלך, לא הקצרה ביותר, לפני שהיצוא (export) נשלח

תרשימי קווים: polyline אחד דרך נקודות ממופות

תרשים קווים עושה שימוש חוזר בכל מיפוי הערכים ומשנה רק כיצד הנקודות מתחברות. במקום מלבן לכל קטגוריה, אתה הולך על פני הנתונים פעם אחת, ממיר כל ערך ל-(X, Y) שלו בעזרת ValueToY, ותופר את הנקודות יחד עם קריאת MoveTo יחידה ואחריה קריאות LineTo, ומסורטטת (stroked) בסוף. הנקודה הראשונה פותחת את הנתיב; כל נקודה מאוחרת יותר מאריכה אותו:

procedure DrawLineChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single);
var
  I, Count: Integer;
  StepX, X, Y: Single;
begin
  Count := Length(Values);
  if Count < 2 then Exit;
  StepX := Plot.Width / (Count - 1);

  Page.SetLineWidth(1.5);
  Page.SetRGBStrokeColor(RGB(214, 92, 36));
  for I := 0 to Count - 1 do
  begin
    X := Plot.Left + I * StepX;
    Y := ValueToY(Plot, Values[I]);
    if I = 0 then
      Page.MoveTo(X, Y)        // open the path at the first point
    else
      Page.LineTo(X, Y);       // extend it through every later point
  end;
  Page.Stroke;                 // one stroke paints the whole polyline
end;

שים לב להבדל הריווח. תרשים עמודות מחלק את הרוחב במספר העמודות, מכיוון שכל עמודה מחזיקה בחריץ. תרשים קווים מחלק במספר המרווחים (intervals), קרי Count - 1, מכיוון שהנקודות הראשונה והאחרונה יושבות על קצוות השרטוט והקו משתרע על הפערים שביניהן. לבלבל בין השניים זוהי הסיבה הרגילה לכך שתרשים קווים סוטה בחצי-חריץ מתרשים העמודות שהוא נועד להיות מונח עליו. אם אתה רוצה סמן בכל נקודת נתונים, הנח Circle קטן וקרא ל-Fill בכל (X, Y) לאחר שה-polyline מסורטט

תרשימי עוגה: קשתות, או משולשים (wedges) אם אתה שומר על זה פשוט

פרוסות עוגה הן הצורה היחידה שצריכה טריגונומטריה, מכיוון שמשולש (wedge) תחום על ידי שני רדיוסים וקשת. הגרסה הכנה מטאטאת (sweeps) את הקשת על ידי צעידת קטעי קו קטנים לאורך ההיקף, מה שמקרב את העקומה קרוב מספיק כך ששום קורא לא יוכל להבחין. זווית הטאטוא של כל פרוסה היא חלקה מתוך הסך הכל, (Value / Total) * 2π, ואתה צובר את הזווית הרצה תוך כדי שאתה מתקדם מסביב:

procedure DrawPieChart(Page: THPDFPage; CX, CY, Radius: Single;
  const Values: array of Single; const Colors: array of TColor);
var
  I, Step, Steps: Integer;
  Total, Start, Sweep, A: Single;
begin
  Total := 0;
  for I := 0 to High(Values) do Total := Total + Values[I];
  Start := 0;

  for I := 0 to High(Values) do
  begin
    Sweep := (Values[I] / Total) * 2 * Pi;
    Steps := Round(Sweep / (Pi / 90)) + 1;  // ~2 degrees per segment

    Page.SetRGBFillColor(Colors[I]);
    Page.MoveTo(CX, CY);                     // wedge apex at the center
    for Step := 0 to Steps do
    begin
      A := Start + Sweep * (Step / Steps);
      Page.LineTo(CX + Radius * Cos(A), CY + Radius * Sin(A));
    end;
    Page.LineTo(CX, CY);                      // close back to the center
    Page.Fill;

    Start := Start + Sweep;                   // advance to the next slice
  end;
end;

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

שום דבר מזה לא תלוי בספריית תרשימים כלשהי שתהיה מותקנת, וזהו היתרון השקט של ציור הפרימיטיבים ישירות. אותן קריאות ציור בבד הציור שמציבות לוגו או תיבת חתימה בונות את התרשימים הללו, ואותו TextOut שמתייג שדה טופס מתייג ציר. שים את הגיאומטריה של השרטוט ברשומה (record), מפה ערכים ל-Y פעם אחת, ותרשים עמודות, קווים או עוגה הוא רוטינה קצרה מעל Rectangle, LineTo ו-Circle שאתה יכול לזרוק לתוך כל דוח. הקריאות Rectangle, MoveTo, LineTo, Circle, Fill, Stroke, ו-TextOut המשמשות כאן הן חלק מרכיב HotPDF עבור Delphi ו-C++Builder