מאמר טכני

שימוש חוזר במופע THotPDF בין מסמכים ב-Delphi

השגיאה אומרת Please load the document before using BeginDoc, והיא כמעט תמיד צצה בפעם השנייה. המסמך הראשון נכתב כשורה. ואז אותו מופע THotPDF מתבקש להתחיל מסמך שני, BeginDoc מעורר חריגה, וההודעה מצביעה על טעינת מסמך, שהיא ההפך ממה שהקוד מנסה לעשות. הפער בין הסימפטום להודעה הוא מה שהופך את התקלה הזו לדביקה. הנושא האמיתי הוא מחזור החיים של הרכיב, וברגע שזה מתיישב השגיאה מפסיקה להיות מסתורית

מחזור החיים של מסמך THotPDF המציג את Create, BeginDoc, EndDoc ו-Free לכל קובץ פלט
מופע THotPDF אחד ממופה למסמך אחד: Create, BeginDoc, ציור, EndDoc, Free

מופע THotPDF הוא מסמך אחד, לא מפעל למסמכים

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

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

מחזור החיים, בסדר שבו הוא חייב לקרות

כל מסמך ש-HotPDF כותב מאפס עוקב אחר אותן ארבע פעימות, והסדר אינו נתון למשא ומתן. Create מקצה את הרכיב. BeginDoc פותח את המסמך ומקבע את הבחירות המבניות, ולכן כל דבר שמשפיע על הקובץ כולו (גודל עמוד, דחיסה, הצפנה, שם קובץ הפלט) חייב להיקבע בין Create ל-BeginDoc. אחר כך אתה מצייר. אחר כך EndDoc כותב את הבתים לדיסק. Free משחרר את המופע. לקריאות ציור שהוצבו לפני BeginDoc אין עמוד לנחות עליו; ממאפיינים ברמת המסמך שהוקצו אחריו מתעלמים בלי תלונה

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.BeginDoc;                        // פותח את המסמך
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
    Pdf.EndDoc;                          // כותב את invoice.pdf וסוגר אותו
  finally
    Pdf.Free;                            // מופע אחד, מסמך אחד
  end;
end;

קרא את זה כיחידת העבודה. Create אחד, BeginDoc אחד, EndDoc אחד, Free אחד, קובץ אחד על הדיסק. ברגע שאתה רוצה קובץ שני, אתה מתחיל יחידת עבודה חדשה, ומשמעות הדבר מופע חדש

מה ש"שימוש חוזר" אמור להיות: מופע חדש לכל קובץ

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

procedure WriteBatch(const Names: TArray<string>);
var
  I: Integer;
  Pdf: THotPDF;
begin
  for I := 0 to High(Names) do
  begin
    Pdf := THotPDF.Create(nil);         // מופע חדש בכל מעבר
    try
      Pdf.FileName := Names[I] + '.pdf';
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('Arial', [], 12);
      Pdf.CurrentPage.TextOut(50, 760, 0, 'Statement for ' + Names[I]);
      Pdf.EndDoc;
    finally
      Pdf.Free;
    end;
  end;
end;

ה-try/finally שיושב בתוך הלולאה הוא החלק ששווה להגן עליו בסקירה. אם BeginDoc או כל קריאת ציור מעוררים חריגה באמצע מסמך אחד, המופע של אותה איטרציה עדיין משוחרר לפני שהבאה מתחילה, ולכן רשומה רעה אחת אינה משאירה רכיב בנוי למחצה תקוע ואינה מרעילה את שאר ההרצה. משוך את ה-Create החוצה מעל הלולאה כדי "לייעל" ואתה חוזר לבאג המקורי, עכשיו עוטה לולאת אצווה

שינוי קובץ קיים הוא נקודת כניסה אחרת

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

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('contract.pdf');
    if PageCount > 0 then
    begin
      Pdf.CurrentPage.SetFont('Arial', [fsBold], 10);
      Pdf.CurrentPage.TextOut(40, 30, 0, 'REVIEWED');
      Pdf.SaveLoadedDocument('contract-reviewed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

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

בעיית נעילת הקובץ אמיתית, והתשובה אינה לחסל חלונות מציג

שגיאת השימוש החוזר נוסעת לעתים קרובות עם תלונה שנייה, והשתיים מסתבכות זו בזו מכיוון שהן צצות באותה זרימת עבודה של יצירת הקובץ מחדש. משתמש פותח את ה-PDF שהרגע הפקת, משאיר אותו פתוח ב-Acrobat או ב-Foxit, ואז מפעיל בנייה מחדש. EndDoc מנסה לכתוב לאותו נתיב, מערכת ההפעלה מסרבת מכיוון שהמציג מחזיק שיתוף קריאה שחוסם כותבים, ואתה מקבל כשל של גישה נדחתה. זו באמת בעיה של נעילת קבצים ב-Windows ולא בעיה של מצב הרכיב, והיא ראויה לתשובה אמיתית במקום לעקיפה

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

uses
  System.SysUtils, System.IOUtils;

procedure WritePdfAtomically(const FinalPath: string);
var
  Pdf: THotPDF;
  TempPath: string;
begin
  // קובץ זמני באותה תיקייה כמו היעד: שינוי שם בתוך כרך NTFS
  // אחד מחליף את השם באופן אטומי, בעוד העברה בין כרכים
  // מתדרדרת להעתקה ומחיקה ומאבדת את ההבטחה הזו
  TempPath := TPath.Combine(TPath.GetDirectoryName(FinalPath),
    TGUID.NewGuid.ToString + '.pdf.tmp');
  try
    Pdf := THotPDF.Create(nil);
    try
      Pdf.FileName := TempPath;
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('Arial', [], 11);
      Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
      Pdf.EndDoc;                    // הקובץ הזמני שלם על הדיסק בנקודה הזו
    finally
      Pdf.Free;
    end;

    // החלפה למקום. TFile.Move מסרבת לדרוס, ולכן נקה יעד ישן
    // תחילה; אם מציג עדיין מחזיק את הקובץ הישן, המחיקה היא
    // זו שנכשלת, בקול רם, לפני שנוגעים בבתים הטובים
    if TFile.Exists(FinalPath) then
      TFile.Delete(FinalPath);
    TFile.Move(TempPath, FinalPath); // או: RenameFile(TempPath, FinalPath)
  except
    if TFile.Exists(TempPath) then
      TFile.Delete(TempPath);        // לעולם אל תשאיר קובץ זמני חצי-כתוב תקוע
    raise;
  end;
end;

שתי הערות שוליים כנות על הקוד הזה. TFile.Move וה-RenameFile הקלאסי ממופים שניהם לאותו שינוי שם של Windows, שהוא אטומי רק כאשר המקור והיעד יושבים על אותו כרך, וזו בדיוק הסיבה שהקובץ הזמני הולך לתיקיית היעד ולא ל-TPath.GetTempPath. וזוג המחיקה-ואז-ההעברה אינו כשלעצמו צעד אטומי אחד: יש חלון קצר שבו אף אחד מהקבצים אינו קיים. עבור יישום שולחני שמייצר דוח מחדש החלון הזה חסר משמעות; קוראים שזקוקים לחוזה חזק יותר על אותו כרך יכולים לקרוא ישירות ל-ReplaceFile או ל-MoveFileEx של Win32 עם MOVEFILE_REPLACE_EXISTING, מה שמכווץ את ההחלפה לקריאה אחת

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

// נתיב פלט אחד לכל בקשה: שתי משימות מקבילות לעולם לא יתחרו
// על אותו שם, ולכן אין ריקוד של שינוי שם ואין נעילה לאבד
OutName := Format('statement-%s-%s.pdf',
  [CustomerId, TGUID.NewGuid.ToString.Trim(['{', '}'])]);
Pdf.FileName := TPath.Combine(OutputDir, OutName);

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

צורת התיקון

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

הקריאות BeginDoc, EndDoc, LoadFromFile ו-SaveLoadedDocument המוצגות כאן הן חלק מרכיב HotPDF ל-Delphi עבור Delphi ו-C++Builder