מאמר טכני

הגנת גיליון XLSX ב-Delphi: 15 אפשרויות הרשאה

אתם מוסרים חוברת עבודה גמורה לעמית ומבקשים ממנו לסנן אותה, לא לשכתב אותה. לכן מגנים על הגיליון. בגרסאות HotXLS ישנות המחווה הזו כתבה דבר אחד בלבד לקובץ: <sheetProtection sheet="1" objects="1" scenarios="1"/>, קשיח ומקובע בכל פעם. הגיליון ננעל, hash הסיסמה צורף, והמשתמש לא יכול היה לעשות דבר, אפילו לא את המיון והסינון שרציתם להשאיר פתוחים. בתיבת הדו-שיח של Excel להגנת גיליון יש חמש עשרה תיבות סימון בדיוק מהסיבה הזאת, והמנוע לא ידע לייצג אף אחת מהן. הפער הזה הוא מה שמודל ההגנה v2.91.0 סוגר

HotXLS הוא רכיב גיליונות עבודה VCL מקורי ל-Delphi ול-C++Builder שקורא וכותב XLS ו-XLSX בלי ש-Excel מותקן. המאמר הזה עוסק בצד ה-XLSX של הגנת גיליון עבודה: ה-enum החדש TXLSXSheetProtectionOption, המאפיין AllowOption שמחליף כל הרשאה בנפרד, וכלל הקידוד היחיד של OOXML שמכשיל כל מי שכותב רכיב <sheetProtection> ביד

מה באמת מגנה הגנת גיליון עבודה

ראשית, הגבול, כי הוא קובע כמה אפשר לסמוך על כל זה. הגנת גיליון בפורמט הגיליונות האלקטרוניים של OOXML ‏(ECMA-376) היא מדיניות אינטראקציה, לא הצפנה. היא אומרת לאפליקציה תואמת אילו עריכות לסרב להן כל עוד הגיליון מוגן. ערכי התאים עדיין יושבים ב-xl/worksheets/sheetN.xml כטקסט רגיל; פתחו את קובץ ה-.xlsx והם שם. הסיסמה האופציונלית נשמרת כ-hash קצר מיושן, לא כמפתח שמבלגן משהו. כל מי שישנה את שם הקובץ, יפתח את החלק, ויסיר את השורה <sheetProtection> יקרא ויערוך הכול

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

תרשים מנגד הגנת גיליון XLSX ב-Delphi ב-HotXLS כמדיניות אינטראקציה מול הצפנת חוברת AES כמנעול האמיתי היחיד
הגנת גיליון מסרבת לעריכות בזמן שהערכים נשארים טקסט גלוי; רק הצפנת החוברת מצפינה את החבילה

חמש עשרה האפשרויות ומאפיין AllowOption

כל גיליון עבודה נושא עכשיו קבוצה של TXLSXSheetProtectionOption שמתארים מה המשתמש עדיין רשאי לעשות בזמן שהגיליון מוגן. האיברים ממופים אחד לאחד אל מאפייני OOXML ואל תיבות הסימון בתיבת הדו-שיח של Excel:

  • xlsxSpoEditObjects, xlsxSpoEditScenarios - עריכת אובייקטי ציור ותרחישי what-if
  • xlsxSpoFormatCells, xlsxSpoFormatColumns, xlsxSpoFormatRows - עיצוב מחדש של תאים, עמודות, שורות
  • xlsxSpoInsertColumns, xlsxSpoInsertRows, xlsxSpoInsertHyperlinks - הוספת עמודות, שורות, קישורים
  • xlsxSpoDeleteColumns, xlsxSpoDeleteRows - מחיקת עמודות, שורות
  • xlsxSpoSelectLockedCells, xlsxSpoSelectUnlockedCells - העברת הבחירה לתאים נעולים או לא נעולים
  • xlsxSpoSort, xlsxSpoAutoFilter, xlsxSpoPivotTables - מיון טווחים, שימוש בתפריטי AutoFilter, עבודה עם PivotTables

אתם קוראים וכותבים ביטים בודדים דרך המאפיין המאונדקס AllowOption על TXLSXWorksheet. AllowOption[Opt] = True פירושו שהפעולה מותרת; קביעה שלו ל-False אוסרת אותה. את כל הקבוצה אפשר גם להגיע אליה בבת אחת דרך SheetProtectionOptions, שהיא TXLSXSheetProtectionOptions ‏(סתם set of בפסקל), כך שאפשר לשמור אותה, לשחזר אותה או להחליף אותה כולה

ברירת המחדל חשובה, ובכוונה: גיליון חדש מתחיל כשכל אפשרות מותרת. הבנאי מאתחל את SheetProtectionOptions עם הטווח המלא, [Low(TXLSXSheetProtectionOption)..High(TXLSXSheetProtectionOption)]. מכאן מצמצמים על ידי הוצאת הפעולות שרוצים לאסור, במקום לבנות סט הרשאות מאפס. הבחירה הזאת היא מה שגורמת לכלל הקידוד של הכותב, בהמשך, להתיישר עם ההתנהגות של Excel

הגנה על גיליון אבל השארת המיון והסינון פתוחים

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

var
  wb: TXLSXWorkbook;
  sh: TXLSXWorksheet;
begin
  wb := TXLSXWorkbook.Create;
  try
    sh := wb.Sheets.Add('Protected');
    sh.Cells[1, 1].Value := 'Region'; sh.Cells[1, 2].Value := 'Units';
    sh.Cells[2, 1].Value := 'North';  sh.Cells[2, 2].Value := 120;
    sh.Cells[3, 1].Value := 'South';  sh.Cells[3, 2].Value := 98;

    // הגנה עם סיסמה. זה רק מגדיר את מצב ההגנה + ה-hash;
    // ערכת האפשרויות נשארת בברירת המחדל שלה, שבה הכול מותר.
    sh.Protect('HotXLS-2026');

    // מצומצם: שמרו על מיון + AutoFilter, אסרו עיצוב מחדש ושינוי צורה.
    sh.AllowOption[xlsxSpoSort]          := True;
    sh.AllowOption[xlsxSpoAutoFilter]    := True;
    sh.AllowOption[xlsxSpoFormatCells]   := False;
    sh.AllowOption[xlsxSpoFormatColumns] := False;
    sh.AllowOption[xlsxSpoFormatRows]    := False;
    sh.AllowOption[xlsxSpoInsertRows]    := False;
    sh.AllowOption[xlsxSpoDeleteRows]    := False;

    if wb.SaveAs('protection.xlsx') <> 1 then
      Writeln('SaveAs failed');
  finally
    wb.Free;
  end;
end;

שני דברים אפשר לקרוא מהקטע הזה. השורות Sort ו-AutoFilter נכתבות במפורש אף ששתיהן ברירת המחדל היא True; זה תיעוד למתחזק הבא, לא דרישה פונקציונלית. ומכיוון שברירות המחדל נדיבות, השורות היחידות שמשנות את קובץ הפלט הן אלה שמגדירות אפשרות ל-False. זה לא מקרה של ה-API הזה, זה פורמט החיווט של OOXML שנחשף, וזה הסעיף הבא

כלל הקידוד: השמטה פירושה הרשאה, attr=0 פירושו איסור

זו העובדה היחידה הלא אינטואיטיבית בכל התכונה, וזה המקום שבו <sheetProtection> שנכתב ביד בדרך כלל טועה. ב-OOXML, כל מאפיין לכל פעולה הוא דגל איסור, והיעדרו הוא הרשאה. מאפיין שחסר פירושו שהפעולה מותרת. מאפיין שנכתב כ-"0" פירושו שהפעולה אסורה כל עוד הגיליון מוגן. אין בקובץ תקין formatCells="1" שפירושו "עיצוב מותר"; פשוט משאירים את המאפיין בחוץ. (ברירת המחדל למאפיין חסר היא ברירת המחדל הבוליאנית של OOXML, true, והמאפיינים האלה נקראים כך ש-"true" פירושו שהעריכה המתאימה מותרת.)

כותב HotXLS משקף זאת בדיוק. הוא פולט sheet="1" כדי להפעיל הגנה, ואז עובר על קבוצת האפשרויות וכותב attr="0" רק עבור האפשרויות שהגדרתם ל-False. פעולות מותרות לא תורמות דבר לפלט. לכן חוברת העבודה מהסעיף הקודם נרשמת למשהו כזה, כשהיא נושאת רק את הפעולות האסורות בתוספת hash הסיסמה:

// פלט מושגי עבור קטע הקוד שלמעלה (תכונות הושמטו לשם קיצור):
// <sheetProtection sheet="1"
//   formatCells="0" formatColumns="0" formatRows="0"
//   insertRows="0" deleteRows="0"
//   password="...4-hex..."/>
// שימו לב למה שלא נמצא שם: אין sort, אין autoFilter, אין selectLockedCells.
// היעדרם הוא בדיוק מה שאומר ל-Excel שהפעולות הללו נשארות מותרות.

אם הגעתם מהמחרוזת הישנה והקשיחה וציפיתם לראות כל מאפיין מפורש, זה נראה דליל, כמעט שגוי. אבל זה נכון. קובץ שהיה מפרט sort="1" ו-autoFilter="1" היה אומר אותו דבר לקורא תואם, אבל Excel עצמו כותב את הצורה המינימלית של איסור בלבד, וההתאמה אליה שומרת על diffs קטנים ועל round-trips משעממים. המאפיינים objects ו-scenarios פועלים לפי אותו כלל: הם מותרים כברירת מחדל, ולכן הם מופיעים כ-"0" רק כשאתם אוסרים אותם, בניגוד ל-objects="1" scenarios="1" הישן שנכתב תמיד

תרשים של חמישה-עשר ערכי TXLSXSheetProtectionOption ב-HotXLS המקובצים לשש משפחות ומופעלים דרך תכונת האינדקס AllowOption וקבוצת SheetProtectionOptions ב-Delphi
חמש-עשרה האפשרויות מתקבצות אל שש משפחות, כל ביט מופעל בנפרד או מוחלף בשלמותו כקבוצת Pascal

קריאת ההגנה בחזרה, נאמנות round-trip

מודל הרשאות שאפשר לכתוב אבל לא לקרוא הוא דלת חד-כיוונית, והסימפטום הרגיל הוא מחזור טעינה-עריכה-שמירה שמרחיב בשקט את ההרשאות. HotXLS סוגר את זה. כאשר ParseWorksheetXml פוגש רכיב <sheetProtection> הוא מסמן את הגיליון כמוגן, קולט את hash הסיסמה אם יש כזה, ואז מפענח כל מאפיין לכל פעולה בחזרה אל AllowOption באותה מוסכמה בכיוון ההפוך: מאפיין שקיים ושווה ל-"0" אוסר את הפעולה; מאפיין חסר משאיר את האפשרות בברירת המחדל המותרת שלה

var
  wb: TXLSXWorkbook;
  sh: TXLSXWorksheet;
begin
  wb := TXLSXWorkbook.Create;
  try
    wb.Open('protection.xlsx');
    sh := wb.Sheets[1];                  // גיליונות XLSX מבוססי-1
    if sh.IsProtected then
    begin
      Writeln('Protected; password hash present: ',
        sh.SheetProtectHash <> '');
      Writeln('Sort allowed:       ', sh.AllowOption[xlsxSpoSort]);
      Writeln('AutoFilter allowed: ', sh.AllowOption[xlsxSpoAutoFilter]);
      Writeln('FormatCells allowed:', sh.AllowOption[xlsxSpoFormatCells]);
    end;
  finally
    wb.Free;
  end;
end;

טענו את הקובץ שהכותב יצר, ותקבלו את Sort ו-AutoFilter חזרה כ-True, ואת FormatCells כ-False, הקבוצה ששמרתם, שלמה. הסימטריה הזאת היא כל העניין: ערכו תא אחד בגיליון מוגן וחלקית מותר, ושמרו שוב, וארבע עשרה ההרשאות שלא נגעתם בהן ישרדו במקום להתמוטט בחזרה לברירת המחדל הישנה של הכול או כלום

תרשים כלל הקידוד של sheetProtection ב-XLSX ב-HotXLS ב-Delphi שבו תכונה שהושמטה מאפשרת פעולה ו-attr=0 אוסר אותה, מנגד את השורה הקבועה-בקוד הישנה מול פלט הכותב המינימלי של איסור בלבד
פעולה מותרת אינה תורמת אף תכונה כלל; רק פעולות אסורות מופיעות כ-attr=0

הערות מעשיות ומגבלות

כמה דברים שכדאי לדעת לפני שמחברים את זה לצינור דיווח:

  • הסיסמה חלשה לפי התכנון הגנת גיליון XLSX שומרת hash מיושן בן 16 ביט, אותו אחד ש-Excel משתמש בו כבר עשרות שנים, והוא נשמר כאן לטובת תאימות. הוא מרתיע עריכות מקריות, לא עומד מול תוקף. אל תתייחסו אליו כשומר סודות. להגנה אמיתית, הצפינו את חוברת העבודה
  • קביעת אפשרויות לפני ההגנה היא בסדר AllowOption אפשר להקצות בין אם הגיליון מוגן כרגע ובין אם לא; המתגים פשוט מתארים מה ההגנה תאפשר ברגע ש-Protect ייכנס לפעולה. UnProtect מנקה את המצב המוגן ואת ה-hash אבל משאיר את קבוצת האפשרויות שלכם במקומה לפעם הבאה
  • סמנטיקת תאים נעולים עדיין חלה ההגנה חוסמת רק עריכות לתאים שמאפיין Locked שלהם מוגדר, ברירת המחדל של חוברת העבודה. השארת אזור קלט ניתן לעריכה היא עבודה של סגנון התא, לא אפשרות הגנה; שתי השכבות משתלבות כמו ב-Excel
  • זהו מנוע ה-XLSX מודל האפשרויות משקף את המאפיינים הישנים יותר של מנוע ה-XLS מסוג Allow*, אבל שמות ה-enum והמאפיינים כאן (xlsxSpo*, AllowOption) שייכים ל-TXLSXWorksheet ב-lxHandleX. אם אתם גם מפעילים פריסת הדפסה על אותם גיליונות, ההדרכה על הגנה והגדרת עמודים מסבירה איך ההגדרות האלה יושבות לצד אזורי הדפסה וכותרות, ואימות נתונים, AutoFilter וטבלאות משתלב באופן טבעי עם השארת xlsxSpoAutoFilter פתוח בדוח נעול

מודל ההגנה המפורט ושאר מנוע הקריאה והכתיבה של XLSX נשלחים ב-HotXLS Delphi Component עבור Delphi ו-C++Builder; דף המוצר כולל את ממשק ה-API המלא של הגיליון, כולל ההפניה המלאה לאפשרויות ההגנה