מאמר טכני

הלוך ושוב של טבלת pivot ב-ODS בדלפי: תחום שמות XML

‏רכיב הגיליון HotXLS ל-Delphi שומר טבלאות data pilot של OpenDocument דרך מחזור פתיחה ושמירה של ODS בכך שהוא לוכד את תת-העץ <table:data-pilot-tables> של content.xml מילולית בזמן הפתיחה ומשמיע אותו מחדש בשמירה, מאז v2.382.0. מאז v2.382.1 הקטע נושא גם כל קישור מרחב שמות XML שהאבות שלו הצהירו, כך שהגדרת ה-pivot שנשמרת נשארת תקינה לכל צרכן, לא רק ל-HotXLS

הבאג שאילץ את שני השינויים יצא מהרצת קורפוס מחמירה. הדגימה official-pivot.ods, שנכתבה על ידי בניית פיתוח של LibreOffice 6.1, מחזיקה pivot אחד בשם DataPilot1 שקורא את Sheet1.A2:E30 וממקם את תוצאתו ב-Sheet1.G6:J18. פתחו אותה עם HotXLS, שמרו בלי שינוי, ספרו אלמנטים <table:data-pilot-table> בפלט: אחד נכנס, אפס יוצא, גם ב-Win32 וגם ב-Win64. שום דבר בבדיקה לא נגע ב-pivot. סבב הבדיקות הראשון השווה רק קבועי תאים ועבר; קביעת המבנה היא שחשפה את האובדן, וזו תזכורת ש"הערכים תואמים" היא הגדרה חלשה של נאמנות הלוך ושוב

למה טבלת pivot ב-ODS נעלמת אחרי שמירה מהספרייה?

טבלת pivot ב-ODS נעלמת כי ל-HotXLS אין מודל בזיכרון לטבלאות data pilot של OpenDocument, וכותב ה-ODS בונה את content.xml כולו מהמודל. הכותב מרכיב סגנונות אוטומטיים, <table:table> אחד לכל גיליון, <table:content-validations>, <table:named-expressions> ו-<table:database-ranges>, כל אחד נוצר מאובייקטים שהחוברת באמת מחזיקה. הגדרת pivot — ODF 1.3 חלק 3 §9.6, מכולה <table:data-pilot-tables> עם <table:data-pilot-table> אחד לכל pivot, שנושאת את table:source-cell-range שלה, את ילדי ה-table:data-pilot-field שלה, את table:target-range-address ואת table:buttons — אין לה אובייקט לחיות בו, כך שהחלק שנוצר מחדש פשוט משמיט אותה

הניגוד ל-XLSX הוא מכוון. HotXLS מפרסת מטמוני pivot וטבלאות pivot של SpreadsheetML למודל אמיתי שאפשר לבנות ממנו, להרחיב בשדות מחושבים ולרענן מדלפי, כך שאלה שורדים שמירה כי הם נכתבים מחדש, לא מועתקים. pivots ב-ODS הם בקשה נדירה בהרבה, ולדלות את אוצר המילים של ODF data pilot רק בשביל הלוך ושוב יהיה הרבה קוד שאיש לא עורך. התשובה הפרגמטית היא אותה תשובה ש-HotXLS כבר מחילה על בלוקי extLst לא מוכרים ב-XLSX: לשמור את מה שאתם לא מדלים, בית-אחר-בית אם אפשר, אירוע-אחר-אירוע אם אי אפשר

מה הלכידה הראשונה מבוססת Pos עשתה לא נכון?

הלכידה של v2.382.0 חתכה את הגדרת ה-pivot מתוך content.xml כמחרוזת פשוטה, ובחתיכה היו חסרות הצהרות מרחב השמות שהפכו אותה למשמעותית. המימוש היה קצר כמו שזה נשמע — לפענח את החלק ל-WideString, למצוא את תג הפתיחה עם Pos, למצוא את תג הסגירה שאחריו, ולהעתיק את הטווח אל FRawOdsDataPilotTablesXml על החוברת:

// HotXLS v2.382.0 -- הוחלף גרסה אחת לאחר מכן
function OdsCaptureDataPilotTablesXml(Stream: TStream): WideString;
const
  OpenTag: WideString = '<table:data-pilot-tables';
  CloseTag: WideString = '</table:data-pilot-tables>';
var
  Text: WideString;
  StartPos, ClosePos: Integer;
begin
  Result := '';
  Text := LoadPartAsWideString(Stream);   // כל content.xml בזיכרון
  StartPos := Pos(OpenTag, Text);
  if StartPos = 0 then Exit;
  ClosePos := Pos(CloseTag, Copy(Text, StartPos, MaxInt));
  if ClosePos = 0 then Exit;
  Result := Copy(Text, StartPos, ClosePos + Length(CloseTag) - 1);
end;

קביעת הספירה הפכה לירוקה, והתיקון יצא. מה שתפס אותו הייתה בדיקה שנייה ומחמירה יותר שהוספה באותו יום: כל חלק XML בחבילה שנשמרה מוזן למפרסר עצמאי שמכיר מרחבי שמות מחוץ ל-HotXLS, והמפרסר הזה דחה את ה-content.xml החדש בשגיאת prefix לא קשור. ה-pivot מ-LibreOffice נושא מאפייני הרחבה של המפיק — loext:ignore-selected-page="true" על שדה עמוד, calcext:repeat-item-labels="false" על כל רמה — והמחרוזת החתוכה הכילה את המאפיינים האלה אבל לא את ההצהרות xmlns:loext ו-xmlns:calcext שקושרות אותם. ההצהרות האלה ישבו על השורש <office:document-content> של קובץ המקור, שלושים וחמישה מהן, במרחק אלפיים תווים מה-pivot

‏W3C Namespaces in XML 1.0 §6.1 מגדיר את הכלל שהופך את זה לכשל קשה ולא לקוסמטי: הצהרת מרחב שמות נמצאת בתחום מתג הפתיחה של האלמנט שהיא מופיעה עליו ועד תג הסגירה שלו, וכל שם עם prefix בתוך התחום הזה נפתר לפיה. חתכו תת-עץ מתוך המסמך וחתכתם אותו מחוץ לתחום. HotXLS כותבת שורש <office:document-content> משל עצמה עם אחת עשרה הצהרות — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — כך ש-calcext: במקרה נפתר, table: במקרה נפתר, ו-loext: לא. מפרסר שמכיר מרחבי שמות מתייחס ל-prefix לא קשור כהפרת תקינות, מה שאומר שכל החלק אינו קריא, ולא רק מאפיין אחד

מה הלכידה מבוססת Pos של official-pivot.ods פספסה ב-HotXLS: תת-העץ של ה-pivot נושא מאפייני הרחבה loext ו-calcext בעוד הצהרות ה-xmlns שקושרות אותם יושבות על השורש office:document-content במרחק שלושים וחמישה קישורים, כך שהקטע החתוך השאיר כל prefix שבו בלי קישור ומפרסר שמכיר מרחבי שמות דחה את כל ה-content.xml
הצהרת מרחב שמות נמצאת בתחום מתג הפתיחה שלה עד תג הסגירה שלה, וחתיכת תת-עץ מתוך המסמך חותכת אותו מהתחום הזה, מה שהופך מאפיין אחד לחלק שאינו קריא

איך HotXLS מעבירה קישורי xmlns של אבות אל הקטע?

‏HotXLS v2.382.1 החליפה את חיתוך המחרוזת במעבר על content.xml דרך ה-TXMLReader הזורם שלה, שמתחזק מחסנית של קישורי מרחב שמות מתויגים בעומק שבו כל אחד הוצהר, ומעתיק את הקישורים שעוד בתוקף אל אלמנט השורש של הקטע ברגע שמגיעים ליעד. הקורא רץ עם PreserveWhitespaceText דלוק כדי שצמתי טקסט יחזרו בדיוק כפי שנכתבו, והתגים שנבנים מחדש משתמשים ב-TXMLReader.RawName וב-TXMLReader.Attribute[I].RawName — כתיב ה-prefix מהקובץ — ולא בשמות הקנוניים שהקורא בדרך כלל מוסר למפרסרי החלקים. הנה הליבה של הלולאה:

איך HotXLS v2.382.1 לוכדת את תת-העץ של data pilot עם תחום מרחב השמות שלו: מעבר זורם עם TXMLReader מחזיק מחסנית של קישורי xmlns מתויגים בעומק ההצהרה, הולך בה מהפנימי ביותר החוצה ביעד table:data-pilot-tables, מכבד האפלה דרך סט Seen, מדלג על prefixes שהאלמנט מצהיר בעצמו ושולף קישורים בתגי סגירה ובאלמנטים ריקים כאחד
התאמת היעד לפי השם הקנוני של הקורא משאירה מפיקים שמאייתים מחדש את ה-prefix של table בעבודה, ותת-עץ שאף פעם לא נסגר זורק חריגה במקום לכתוב חזרה קטע חצוי בשמירה
// Namespaces: TStringList של 'xmlns:p=uri' כשעומק ההצהרה ב-Objects[]
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // אלמנט, טקסט, CDATA, הערה
  if Reader.NodeType = xmlntElement then
  begin
    for I := 0 to Reader.AttributeCount - 1 do
    begin
      AttrName := Reader.Attribute[I].RawName;
      if (AttrName = 'xmlns') or (Pos(WideString('xmlns:'), AttrName) = 1) then
        Namespaces.AddObject(String(AttrName) + '=' + String(Reader.Attribute[I].Value),
          TObject(NativeInt(Depth)));
    end;
    if (CaptureDepth < 0) and (Reader.Name = 'table:data-pilot-tables') then
    begin
      Opening := XlsxRawXmlReaderOpenTag(Reader);   // להסיר תחילה את ה-'>' או '/>' שבסוף
      ...
      // להעביר את קישורי האב בתוקף אל שורש הקטע.
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // הקישור הפנימי ביותר מנצח
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // כבר מוצהר כאן? לדלג
          Opening := Opening + ' ' + AttrName + '="' +
            XlsxEscapeAttr(WideString(Namespaces.ValueFromIndex[I])) + '"';
      end;
      ...
      CaptureDepth := Depth;
    end;
    if not Reader.IsEmptyElement then Inc(Depth);
  end
  else if Reader.NodeType = xmlntEndElement then
  begin
    Dec(Depth);
    if Depth = CaptureDepth then Exit;                           // תת-העץ נסגר
  end;
  if (Reader.NodeType = xmlntEndElement) or
     ((Reader.NodeType = xmlntElement) and Reader.IsEmptyElement) then
    while (Namespaces.Count > 0) and
          (NativeInt(Namespaces.Objects[Namespaces.Count - 1]) >= Depth) do
      Namespaces.Delete(Namespaces.Count - 1);                   // לצאת מהתחום
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

שלושה פרטים בלולאה הזו נושאים את הנכונות. הליכה על המחסנית מהקישור הפנימי ביותר החוצה וזכירת כל prefix ב-Seen מממשת האפלה: אם אב קרוב יותר קושר מחדש את xmlns:table, הערך הקרוב מנצח, בדיוק כמו ש-§6.1 אומר שחייב לקרות. דילוג על prefixes שהאלמנט כבר מצהיר בעצמו מונע פליטה של אותו מאפיין פעמיים, וזו תהיה הפרת תקינות אחרת. וכלל השליפה פועל בתגי סגירה וגם באלמנטים ריקים, כי <x/> אף פעם לא מפיק אירוע EndElement — אותה מלכודת סגירה עצמית שלכידת ה-extLst ב-XLSX נאלצה ללמוד. התאמת היעד לפי Reader.Name ולא לפי RawName היא רווח שקט יותר: הקורא מקנן את ה-URI של מרחב השמות table ב-ODF ל-prefix‏ table, כך שמפיק שכותב אותו t:data-pilot-tables עדיין מותאם, בעוד שהקטע שנפלט שומר על ה-prefix שבו השתמש המפיק

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

איפה הקטע נוחת ב-content.xml שנשמר?

‏HotXLS כותבת את הקטע שנלכד לתוך <office:spreadsheet> מיד אחרי ה-<table:named-expressions> שהיא מייצרת ולפני <table:database-ranges>. מודל התוכן של <office:spreadsheet> ב-ODF 1.3 חלק 3 קובע רצף קבוע לילדי הזנב האלה, כך שקטע מילולי לא יכול סתם להיתלות בכל מקום שבו הכותב נמצא; הוא חייב להיכנס לחריץ מסוים. מצד הקורא אין API ושום דבר להגדיר; ההגדרה נוסעת יחד עם פתיחה ושמירה רגילות:

איפה הגדרת ה-pivot שנלכדה נוחתת בשמירת ODS של HotXLS: ילדי office:spreadsheet עוקבים אחרי רצף ה-ODF הקבוע מהאלמנטים table שנוצרו דרך table:content-validations ו-table:named-expressions, הקטע המילולי table:data-pilot-tables נכנס לפני table:database-ranges, ואין API כי ההגדרה נוסעת יחד עם OpenODS ו-SaveAsODS
קטע מילולי לא יכול להיתלות בכל מקום שבו הכותב נמצא, והעותקים של קישורי האב שהוא נושא אינם מזיקים כי Namespaces in XML מתיר להצהיר מחדש prefix בתחום מקונן
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.OpenODS('official-pivot.ods') <> 1 then
      raise Exception.Create('open failed');
    Book.Sheets[0].Cells[2, 5].Value := 1250.0;   // עריכה בתוך טווח המקור של ה-pivot
    Book.SaveAsODS('official-pivot-out.ods');
    // ה-content.xml בפלט עדיין נושא את DataPilot1 עם
    // טווח המקור, השדות, טווח היעד, הכפתורים והמאפיינים loext:/calcext:
  finally
    Book.Free;
  end;
end;

הכפילות מכוונת וראויה להכרה. שורש הקטע חוזר כעת על xmlns:table ועל xmlns:calcext למרות שגם שורש המסמך שנשמר מצהיר עליהם; Namespaces in XML מתיר להצהיר prefix מחדש בתחום מקונן, כך שהכפילות אינה מזיקה. עבור דגימת LibreOffice הקבוצה שנישאת היא כל שלושים וחמש הצהרות השורש, כשני קילובתים מעל ההגדרה שאורכה 8,357 תווים, כי הלכידה לא מנתחת אילו prefixes תת-העץ באמת משתמש בהם. סריקת prefixes בשימוש תקצץ את זה, ואולי תבוא בהמשך; נכונות קודם, קומפקטיות אחר כך

כלל לחיתוך תת-עצים מתוך XML להשמעה מילולית

הלקח הכללי הוא שתת-עץ הוא עצמאי רק אחרי שעשיתם אותו כזה, ותחום מרחב השמות הוא הדבר הראשון שנשבר כששוכחים. רשימת הבדיקה ש-HotXLS מחילה כעת על כל לכידה של "לשמור את מה שאנחנו לא מדלים":

  • לעבור על המסמך עם קורא אמיתי ולעקוב אחרי הקישורים שנמצאים בתחום. חיפוש מחרוזת עם Pos לא רואה תחום בכלל, והוא גם מתאים לא נכון באלמנטים מקוננים באותו שם, במחרוזת תואמת בתוך הערה או מקטע CDATA, ובערכי מאפיינים שבמקרה מכילים את טקסט התג
  • להעתיק את הקישורים שבבתוקף אל שורש הקטע, מהפנימי ביותר החוצה, פעם אחת לכל prefix, ולדלג על מה שהשורש כבר מצהיר
  • לשמור על כתיב ה-prefix הגולמי בתגים שנפלטים; להתאים את היעד לפי מרחב שמות מתוקנן, לא לפי prefix מילולי
  • לשמר צמתי טקסט של רווחים לבנים, ולזכור שאלמנט ריק סוגר את התחום של עצמו בלי אירוע תג סגירה
  • לאמת את החלק שנשמר עם מפרסר שאינו הספרייה הנבדקת. הספרייה תשמח לקרוא מחדש את הפלט של עצמה דרך אותו מסלול קוד סלחני שכתב אותו

הנקודה האחרונה היא זו שבאמת מצאה את HXLS-003 בפעם השנייה. בדיקת הקבלה של v2.382.0 הייתה ביטוי רגולרי שספר תגי פתיחה של data-pilot-table ב-content.xml שנשמר, וביטוי רגולרי רואה תג, לא מסמך — הוא עיוור לשאלה אם ה-prefixes על התג הזה קשורים. מריץ הקורפוס המחמיר שהוסף ב-v2.382.1 מפרס כל חלק XML ו-.rels בחבילה שנשמרה עם מפרסר שמכיר מרחבי שמות, ואז משווה את עץ ה-pivot — תג, מאפיינים ממוינים, טקסט, ילדים, רקורסיבית — מול המקור. ההשוואה הזו מורחבת למרחבי שמות, כך שאיות מחדש של prefix עדיין יעבור ו-prefix לא קשור לא יכול

איפה נגמרת הבטחת המילוליות

השמעה מילולית משמרת הגדרה; היא לא מבינה אותה, והגבולות נובעים מכך. HotXLS לא חושפת API לקרוא, לערוך או לרענן pivot ב-ODS, כך ש-FRawOdsDataPilotTablesXml הוא שדה פנימי וההתנהגות היחידה שנצפית היא שההגדרה שורדת. הקטע מסוריאל מחדש מאירועי הקורא, לא מועתק כבתים: מירכאות במאפיינים וצורות סגירה עצמית מנורמלות, בעוד שטקסט ורווחים לבנים נשמרים. ה-XML שנלכד נפלט רק על ידי כותב התוכן של ODS, כך שחוברת שנפתחה מ-.ods ונשמרה כ-.xlsx מאבדת את ה-pivot, ולחוברת שנפתחה מ-.xlsx אין מה להשמיע לתוך שמירת .ods — האסימטריות של מסלולי הייבוא והייצוא של ODS חלות כאן כמו בכל מקום. וכיוון שההגדרה אטומה, היא לא יכולה ללכת אחרי העריכות שלכם: שנו את שם Sheet1 או הזיזו את נתוני המקור ב-HotXLS וה-pivot שנשמר עדיין מצביע ל-Sheet1.A2:E30, ומשאיר לצרכן לדווח על טווח שבור כשהוא ירענן בפעם הבאה. הסתייגות סדר אחת שייכת לכאן גם כן: HotXLS פולטת טווחי AutoFilter כ-<table:database-ranges> אחרי קטע ה-pivot, ודגימת הקורפוס אינה נושאת טווח מסד נתונים, כך שחוברת עם גם סינון וגם pivot כדאי להעביר דרך מאמת סכימה של ODF לפני שאתם סומכים על הסדר היחסי של שני האלמנטים האלה

בדקו עם קבצים של המפיק שלכם, לא רק עם דגימת הקורפוס. העברת מרחבי השמות מטפלת בכל prefix שמפיק מצהיר על אב כלשהו, אבל מסמך שמצהיר prefix על אלמנט ה-pivot עצמו, או שמשתמש במרחב שמות ברירת מחדל לאוצר המילים של table, מפעיל את ענפי הדילוג וההאפלה שדגימת LibreOffice לא מפעילה. שניהם ממומשים; לאף אחד מהם אין עדיין דגימה בקורפוס, וההבחנה הזו היא בדיוק מסוג הדברים שרשומת שינויים נוטה לטשטש

לכידת ה-data pilot המילולית ב-v2.382.0 ותיקון תחום מרחב השמות ב-v2.382.1 מגיעים ברכיב הגיליון HotXLS ל-Delphi הנוכחי, שעמוד המוצר שלו מפרט את כיסוי הקריאה והכתיבה המלא של ODS, XLSX ו-XLS ל-Delphi ו-C++Builder