רכיב HotXLS ל-Delphi מאחסן שורת ODS שנושאת table:number-rows-repeated וגובה שורה כרשומת TXLSXRowHeightRun אחת — שורה ראשונה, שורה אחרונה, גובה אחד — במקום רשומת גובה אחת לכל שורה חוזרת, ומקפל את סגנון התא הריק שהשורות האלה יורשות לשכבת סגנון אחת של מקטע. זו כל הסיבה ש-HotXLS 2.382.2 פותחת גיליון שהזנב שלו חוזר 1,048,530 שורות ריקות בתוך 0.02 שניות היכן ש-2.382.1 נכשלה בזמן קצוב, וזו הסיבה שאותו קובץ נשמר חזרה ל-ODS עם ספירת החזרות כשהיא שלמה ולא כמיליון שורות מפורשות
הקובץ המדובר רגיל לגמרי. LibreOffice Calc כותבת גיליון בארבע עשרה עמודות עם 45 שורות נתונים, ואז מתארת את כל מה שמתחתיהן באלמנט אחד: <table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>. הסגנון ro1 קובע style:row-height="0.452cm", וכל <table:table-column> נושאת table:default-cell-style-name שכל תא ריק במקטע יורש. כל ה-content.xml הוא 103 KB. שום דבר בקובץ לא אומר "יקר"; ההוצאה הייתה כולה שלנו
למה שורה חוזרת אחת מפילה ייבוא ODS בזמן קצוב?
כי היבואן נהג להרחיב אותה. בגרסה 2.382.1 לולאת סיום השורה קראה ל-SetRowHeight(RowIndex + i, RowHeight) פעם אחת לכל שורה חוזרת, וכתבה כל גובה לרשימת מחרוזות Name=Value שממופתחת לפי מספר שורה. כל הכנסה לרשימה הזו הריצה חיפוש IndexOfName על כל מה שכבר היה בה, כך שמיליון גבהים עלו מיליון סריקות לינאריות — חיפוש הרשימה הריבועי שנגדו נפתח HXLS-005. במקביל OdsCommitRow יצרה אובייקט תא לכל עמודה שירשה סגנון, בכל אחת מהשורות החוזרות, כי תא ריק עם סגנון עדיין נחשב לתא
לצד השמירה היה עותק משל עצמו של הבעיה. קובץ LibreOffice נגמר בעוד שורת ro1 אחת אחרי החזרה הגדולה, כך שהשורה המעוצבת הגבוהה ביותר ישבה בתחתית הגיליון ממש, ו-OdsBuildTableXml עברה על כל שורה עד אליה ופלטה אלמנטי <table:table-row> אחד אחד. אפילו חוברת שיובאה בזול הייתה נכתבת ביוקר. תיקון הייבוא בלי תיקון הייצוא היה מזיז את הפסקת הזמן, לא מסיר אותה
מהו מקטע גובה שורה ב-HotXLS?
מקטע הוא הדבר הקטן ביותר שיכול לתאר "שורות 46 עד 1,048,575 כולן בגובה 12.81 נקודות" בלי להגיד את זה 1,048,530 פעמים. TXLSXRowHeightRun הוא רשומה של FirstRow, LastRow ו-Height; TXLSXRowHeightRuns הוא מערך דינמי שלהם, וכל TXLSXWorksheet מחזיק אחד כזה ב-FRowHeightRuns לצד רשימת הגבהים הקיימת לכל שורה. בייבוא ODS לולאת סיום השורה מסתעפת כעת לפי ספירת החזרות: ספירה של 1 עדיין קוראת ל-SetRowHeight, כל דבר גדול יותר קורא ל-XlsxAssignRowHeightRun פעם אחת לכל הטווח. הטווח נחתך ל-XlsxMaxRow, שהוא 1,048,576, כך שספירת חזרות שחורגת מהגיליון נחתכת ולא נדחית
XlsxAssignRowHeightRun הוא הכותב היחיד של המערך, והוא שומר על מקטעים זרים זה לזה מעצם הבנייה. בהינתן מקטע חדש הוא מעתיק כל מקטע קיים שנמצא כולו מחוצה לו, מפצל כל מקטע שחופף אותו לחלק שלפניו ולחלק שאחריו, ואז מוסיף את המקטע החדש כשהדגל Present דלוק — או לא מוסיף כלום כש-Present כבוי, וכך ClearRowHeight מנקבת חור בגודל שורה אחת. שני דברים נובעים מכך. המערך אף פעם לא מכיל מקטעים חופפים, כך שחיפוש יכול לעצור בהתאמה הראשונה. והמערך אף פעם לא משתנה במקום; נבנה עותק חדש בכל קריאה, מה שלא עולה כלום בסדרי הגודל המדוברים ומסיר סוג שלם של באגי כינויים
var
Workbook: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
begin
Workbook := TXLSXWorkbook.Create;
try
// גיליון ששורת הזנב שלו חוזרת 1,048,530 פעמים תחת סגנון שורה אחד
Workbook.OpenODS('conditional-formatting.ods');
Sheet := Workbook.Sheets[1];
// שתי הקריאות נפתרות דרך אותו מקטע; שום דבר לא הורחב
Writeln(Sheet.RowHeight[46]:0:2, ' pt');
Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
// דריסה של שורה בודדת מאפילה על המקטע בלי לפצל אותו
Sheet.RowHeight[500000] := 36;
// ניקוי שורה אחת בתוך המקטע חותך את המקטע לשני חלקים
Sheet.ClearRowHeight(500001);
Writeln(Sheet.HasRowHeight(500001)); // False
Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // עדיין גובה המקטע
finally
Workbook.Free;
end;
end;
סדר החיפוש הוא החלק ששווה לשנן. TXLSXWorksheet.GetRowHeight בודקת תחילה את הרשימה לכל שורה ומתייעצת עם המקטעים רק כשבשורה אין רשומה מפורשת, ו-HasRowHeight עושה אותו דבר. כך Sheet.RowHeight[500000] := 36 לא נוגע במקטע בכלל — הוא מוסיף רשומה אחת לרשימה לכל שורה, והרשומה הזו מנצחת כי היא נבדקת ראשונה. ClearRowHeight הוא ההפך: הוא מסיר כל רשומה לכל שורה ואז קורא ל-XlsxAssignRowHeightRun עם Present = False, כי שורה שנוקתה חייבת להיקרא כ"בלי גובה" גם אם מקטע מכסה אותה. ClearRowHeights מרוקן את שני המבנים בבת אחת
לאן הולכים סגנונות התאים הריקים בירושה?
לשכבת סגנון אחת של מקטע לכל עמודה, לא לאובייקטי תא. OdsCommitRow מחליטה לכל ערך עמודה אם הוא ריק קומפקטי: השורה חוזרת יותר מפעם אחת, ולתא אין ערך, אין נוסחה ואין טקסט עשיר. עבור ריק קומפקטי היא יוצרת תא אמיתי רק בשורה הראשונה של המקטע, מחילה עליו את הסגנון בירושה, ואז רושמת את אותם שישה אינדקסי סגנון — גופן, מילוי, מסגרת, תבנית מספר, יישור, הגנה — כרשומת StyleOverlays.Add שמכסה שורות שתיים עד סוף המקטע באותה עמודה. שורות שאחרי הראשונה מדולגות לגמרי בלולאת המימוש
בדיקת הרגרסיה ממחישה את הצורה. אחרי פתיחת גיליון ששורתו השנייה חוזרת 1,048,575 פעמים תחת סגנון עמודה מודגש כברירת מחדל, נבדק ש-Sheet.Cells.Count קטן מ-10, ו-Sheet.Cells[700000, 1].FontIndex עדיין מתברר כגופן המודגש — השכבה מספקת את הסגנון ברגע שנוגעים בקואורדינטה הזו. זה אותו מנגנון שמונע מעמודה מעוצבת אך ריקה לעלות מיליון תאים בצד ה-XLSX; הרשומות על אחסון תאים בבלוקי שורות ושכבות סגנון של מקטעים מכסות איך שכבות מסתדרות ונפתרות. מה שחדש כאן הוא שהיבואן של ODS יוצר אותן בעצמו, מתוך ספירת החזרות, במקום לחכות שאפליקציה תעצב טווח
איך SaveAsODS כותבת את ספירת החזרות חזרה?
בכך שהיא מפצלת את הזנב הריק של הגיליון רק במקום שמשהו באמת משתנה. OdsBuildTableXml עוקבת כעת אחרי שני גבולות: contentMaxRow, השורה האחרונה שמחזיקה ערך, נוסחה, היפר-קישור או שבירת שורה ידנית, ו-maxRow, שמושך גם דרך תאים ריקים שיש להם רק סגנון, גבהים של שורה בודדת, ה-LastRow של כל מקטע והקצה התחתון של כל שכבה. תא ריק עם סגנון בלבד כבר לא נחשב לתוכן — TXLSXCells.IsStyleOnlyBlank הוא מה שמוציא אותו — כך שהשורה המעוצבת שבזנב בקובץ LibreOffice מפסיקה לגרור את גבול התוכן לתחתית הגיליון
מעל contentMaxRow שורות נכתבות אחת אחת בדיוק כמו קודם. מתחתיו הכותב מחשב את nextRow כקטן מבין: ה-FirstRow של המקטע הבא, ה-LastRow + 1 של המקטע הנוכחי, רשומת הגובה הבאה של שורה בודדת, קצה השכבה הבא, והתא הממומש הבא. כל מה שמהשורה הנוכחית ועד nextRow - 1 נפלט אז כאלמנט <table:table-row> אחד עם table:number-rows-repeated שמוגדר להפרש, ונושא <table:table-cell/> אחד לכל עמודה עם שם הסגנון שנפתר דרך השכבה כששכבה מכסה את העמודה הזו. סגנון השורה עצמו מגיע מ-TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec), שמקפל כעת את טקסט הגובה — 12.81pt, למשל — לתוך מפתח הסרת הכפילויות שלו לצד דגלי ההסתרה ושבירת העמוד, כך שכל שורה במקטע חולקת סגנון ro<N> אחד עם מאפיין style:row-height בודד
var
Workbook, Reopened: TXLSXWorkbook;
Saved: TMemoryStream;
begin
Workbook := TXLSXWorkbook.Create;
Reopened := TXLSXWorkbook.Create;
Saved := TMemoryStream.Create;
try
Workbook.OpenODS('conditional-formatting.ods');
Workbook.Sheets[1].RowHeight[500000] := 36;
Workbook.Sheets[1].ClearRowHeight(500001);
// הזנב הריק נכתב כקומץ שורות חוזרות, לא מיליון
Workbook.SaveAsODS(Saved);
Writeln('ODS size: ', Saved.Size, ' bytes');
Saved.Position := 0;
Reopened.Open(Saved);
// הדריסה, החור והמקטע שורדים כולם את הלוך ושוב
Writeln(Reopened.Sheets[1].RowHeight[500000]:0:2); // 36.00
Writeln(Reopened.Sheets[1].HasRowHeight(500001)); // False
Writeln(Reopened.Sheets[1].RowHeight[500002]:0:2); // גובה המקטע
finally
Saved.Free;
Reopened.Free;
Workbook.Free;
end;
end;
הבדיקה שמקבעת את זה קובעת שהזרם שנשמר קטן מ-64 KB עבור גיליון שמקטע הגובה שלו משתרע על 1,048,575 שורות עם דריסה וחור שננקבו באמצע. שני גבולות ישרים שייכים לצד המספר הזה. ראשית, גיליון עם ולידציות נתונים כלשהן קובע את contentMaxRow ל-maxRow, כך שוולידציות מכבות את כיווץ הזנב בגיליון הזה והוא נכתב שורה-שורה שוב. שנית, ל-XLSX אין מאפיין חזרה — <row> ב-SpreadsheetML מתאר שורה אחת — כך שייצוא של גיליון שמגובה במקטעים ל-.xlsx מונה את השורות שהמקטע מכסה וכותב מאפיין ht על כל אחת. המודל נשאר קומפקטי בזיכרון; פורמט הקובץ מחליט איך הקובץ נראה
מה כל עריכה שממספרת שורות מחדש חייבת כעת למקטעים?
תחזוקה. ייצוג חדש של מטא-נתוני שורה נכון רק אם כל פעולה שמשנה מספרי שורות מזיזה אותו יחד עם הרשימות לכל שורה שהוא יושב לצידן, והשינוי נוגע בכל אחת מהפעולות האלה. InsertRows ו-DeleteRows עוברות דרך XlsxShiftRowHeightRuns, שבונה את המערך מחדש בכך שהוא שומר את החלק של כל מקטע שלפני נקודת העריכה, משליך כל מה שנופל בתוך חלון מחיקה, ומוסיף מחדש את השארית מוזזת בדלתא — כך שמקטע שחוצה הכנסה הופך לשני מקטעים עם רווח, ומקטע שחוצה מחיקה מתכווץ. TileRangeAxisMetadata מנקה את המקטעים על כל הטווח המשוכפל, ואז רושמת מחדש כל מקטע מקור פעם אחת לכל עותק בהיסט שלו. TXLSXWorksheet.CopyFrom ו-TXLSXSheets.AddCopy לוקחות Copy() של המערך במקום להקצות אותו, ולכן הבדיקה יכולה לנקות את כל הגבהים בעותק ועדיין למצוא את הגיליון המקורי שלם בשורה 1,048,576
var
Sheet: TXLSXWorksheet;
begin
Sheet := Workbook.Sheets[1];
Sheet.RowHeight[500000] := 36;
Sheet.ClearRowHeight(500001);
// להכניס שתי שורות ב-500000: הדריסה עוברת ל-500002, החור ל-500003
Sheet.InsertRows(500000, 2);
Writeln(Sheet.RowHeight[500002]:0:2); // 36.00
Writeln(Sheet.HasRowHeight(500003)); // False
// למחוק אותן שוב: הכול חוזר אחורה
Sheet.DeleteRows(500000, 2);
Writeln(Sheet.RowHeight[500000]:0:2); // 36.00
// לשכפל שורות 2..4 פעמיים במורד הגיליון; גבהי המקטע עוקבים אחרי כל עותק
Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
Writeln(Sheet.RowHeight[7]:0:2); // גובה המקטע
end;
גם לגבולות בצד הקריאה יש אותה חובה. GetUsedRange מרימה את הקצה התחתון שלה ל-FirstRow ול-LastRow של כל מקטע, ו-BuildRowMajorCellOrder מותחת את השורה המקסימלית שלה שכוללת מטא-נתונים דרך כל מקטע כדי שכותב ה-XLSX עדיין יבקר שורות שהן רק גובה. אם אי פעם תוסיפו מבנה ממופתח לפי שורה משלכם מעל מודל האובייקטים של HotXLS, זו רשימת הבדיקה: הכנסה, מחיקה, שיכפול, העתקה, טווח בשימוש, וכל סריאליזר. תפספסו אחד והכשל שקט — הגבהים נסחפים במספר השורות שהוכנסו, ושום דבר לא זורק חריגה
מה נשאר לפי שורה, ואיך המספרים נראים עכשיו
דגלי הסתרה, רמות מתאר ומצב מכווץ עדיין מורחבים. לולאת סיום השורה קוראת ל-SetRowHidden ול-SetRowOutlineLevel פעם אחת לכל שורה חוזרת, כך שגיליון שמסתיר זנב של מיליון שורות, או מקנן אותו בתוך table:table-row-group, משלם רשומה לכל שורה עבור כל אחד מהמאפיינים האלה. השינוי ב-2.382.2 מוגבל לשני הדברים ש-HXLS-005 באמת מדד — גבהים וסגנונות ריקים בירושה — ואותה טכניקת מקטעים תחול על האחרים אם קובץ אי פעם ידרוש זאת. קורא ה-ODS גם לא פועל לפי style:use-optimal-row-height; סגנון שורה שאומר "אופטימלי" ונותן גובה מיובא עם הגובה הזה
מול הקורפוס, conditional-formatting.ods משלים כעת את מחזור הפתיחה, הבדיקה, השמירה, הפתיחה מחדש והבדיקה מחדש ב-0.178 שניות ב-Win32 וב-0.158 שניות ב-Win64, כששלב הפתיחה עצמו הוא 0.020 שניות, בתוך תקציב של 60 שניות שהוא מיצה קודם לכן. ממשקי רמת החוברת שהפורמט זורם דרכם מתוארים במדריך על פתיחה ושמירה של קובצי ODS, וקבוצת המנופים הרחבה יותר לקבצים גדולים בביצועים של חוברות גדולות; אלמנט השורה של ODF עצמו, עם מאפייני החזרה והסגנון שלו, מוגדר ב-ODF 1.3 חלק 3 §9.1.4
HotXLS קוראת וכותבת XLS, XLSX ו-ODS מקוד דלפי ו-C++Builder ילידי בלי Excel או LibreOffice מותקנים, ולכן חזרה של מיליון שורות היא משהו שהספרייה חייבת לדלות היטב ולא להעביר לתהליך חיצוני — עמוד רכיב הגיליון HotXLS ל-Delphi מפרט את הפורמטים הנתמכים ואת גרסאות RAD Studio