Τεχνικό Άρθρο

Χρόνοι εγγράφων Excel σε Delphi: FILETIME, UTC και DST

Το HotXLS αποθηκεύει τους timestamps των ιδιοτήτων εγγράφων Excel ως UTC μέσα στο αρχείο και τους εκθέτει ως τοπική ώρα μέσω του API: TXLSWorkbook.CreatedDate και LastSavedDate για .xls, TXLSXWorkbook.Created και Modified για .xlsx. Από το v2.384.48 και οι δύο μηχανές μετατρέπουν την τοπική ώρα σε UTC κατά την εγγραφή και πίσω κατά την ανάγνωση, χρησιμοποιώντας τους κανόνες θερινής ώρας που ισχύουν στην ίδια την ημερομηνία της σφραγίδας. Η διαδρομή ως εκεί χρειάστηκε δύο fixes, και τα δύο bugs επιβίωσαν για τον ίδιο αμήχανο λόγο: κάθε αυτοματοποιημένο round trip πέρναγε, ενώ το παράθυρο File > Info του Excel έδειχνε λάθος μέρα ή λάθος ώρα. Αν έχετε διαβάσει την επισκόπησή μας για το ορισμό ιδιοτήτων εγγράφων Excel στο Delphi, εδώ είναι το σημείο όπου οι ημερομηνίες παύουν να είναι απλές τιμές

Γιατί ένα τεστ save-και-επανάνοιγμα έκρυβε ένα σφάλμα μίας μέρας;

Ένα round trip προς τον εαυτό του έκρυβε το σφάλμα επειδή ο writer και ο reader μοιράζονταν την ίδια λάθος σταθερά, οπότε το λάθος αναιρούσε τον εαυτό του. Μια ημερομηνία σε property set του OLE είναι μια FILETIME, μια μέτρηση 64-bit από ticks των 100 νανοδευτερολέπτων από την 1601-01-01 UTC ([MS-DTYP] §2.3.3), ενώ ένα TDateTime του Delphi μετρά ημέρες από την 1899-12-30, την ίδια αρχή serial που καλύπτει το Excel date serials στο Delphi και τα συστήματα 1900 vs 1904. Το κενό ανάμεσα στις δύο εποχές είναι 109205 ημέρες, πράγμα που ελέγχετε χωρίς ημερολόγιο: 25569 (η εποχή Unix ως TDateTime) συν 109205 δίνει 134774, την εποχή Unix μετρημένη σε ημέρες FILETIME. Builds του HotXLS πριν το v2.384.17 χρησιμοποιούσαν 109206, οπότε κάθε σφραγίδα δημιουργίας και αποθήκευσης γραφόταν μια μέρα αργά και διαβαζόταν μια μέρα νωρίς. Η test suite έβλεπε την τιμή που είχε αναθέσει· το Excel έβλεπε αύριο

const
  // ημέρες από την εποχή FILETIME (1601-01-01) ως την εποχή TDateTime (1899-12-30)
  // έλεγχος: 25569 + 109205 = 134774, η εποχή Unix σε ημέρες FILETIME
  FileTimeDayBias = 109205;

function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
  // Στρογγυλοποιήστε πρώτα σε ολόκληρα milliseconds, μετά κλιμακώστε σε ticks 100 ns.
  // Κλιμάκωση του Double απευθείας σε ticks μετατρέπει τις 04:00 σε 03:59:59.9999
  Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
Χρονοδιάγραμμα HotXLS της εποχής FILETIME 1601-01-01, της εποχής TDateTime 1899-12-30 και της εποχής Unix 1970, δείχνοντας το bias των 109205 ημερών πίσω από το UtcDateTimeToFileTimeTicks και πώς builds πριν το v2.384.17 έγραφαν κάθε σφραγίδα CreatedDate μια μέρα αργά και τη διάβαζαν μια μέρα νωρίς με 109206
Το σκίτσο UtcDateTimeToFileTimeTicks κρατά το bias εκεί όπου μια λάθος σταθερά αναιρεί τον εαυτό της — ένα συμμετρικό τεστ save-και-επανάνοιγμα έβλεπε την τιμή που ανέθεσε ενώ το παράθυρο Info του Excel έδειχνε αύριο

Το σχόλιο για τη στρογγυλοποίηση σε εκείνο το σκίτσο είναι το δεύτερο, μικρότερο μάθημα από τον ίδιο κώδικα. Αν πολλαπλασιάσετε ένα κλασματικό TDateTime απευθείας επί 864.000.000.000 ticks ανά ημέρα, το σφάλμα δυαδικής κινητής υποδιαστολής διαρρέει στα κάτω ψηφία, και μια σφραγίδα ακριβώς 04:00 επέστρεφε ως 03:59:59.9999. Το HotXLS v2.384.48 στρογγυλοποιεί σε ολόκληρα milliseconds πριν την κλιμάκωση, ώστε οι τιμές στην ώρα ακριβώς να επιβιώνουν από τη διαδρομή ανέπαφες. Η ίδια έκδοση πρόσθεσε και το βήμα ζώνης ώρας που το σκίτσο αφήνει σκόπιμα έξω, επειδή η είσοδος εδώ είναι ήδη UTC

Ποια property IDs του SummaryInformation κρατούν τις ημερομηνίες;

Στο property set \005SummaryInformation που ορίζει το [MS-OLEPS], ο χρόνος δημιουργίας ζει κάτω από το property ID $0C (PIDSI_CREATE_DTM), ο χρόνος τελευταίας αποθήκευσης κάτω από το $0D (PIDSI_LASTSAVE_DTM), και ο συνολικός χρόνος επεξεργασίας κάτω από το $0A (PIDSI_EDITTIME). Παλαιότερα builds του HotXLS έγραφαν τη σφραγίδα τελευταίας αποθήκευσης στο $0E, που είναι το PIDSI_PAGECOUNT, οπότε το Excel δεν είχε save date να δείξει και μια ιδιότητα αριθμού σελίδων κρατούσε timestamp. Από το v2.384.17 ο reader σέβεται και εκείνη την παρωχημένη διάταξη: όταν λείπει το $0D και το $0E κουβαλά VT_FILETIME, η τιμή παίρνεται ως χρόνος τελευταίας αποθήκευσης. Κάθε PROPVARIANT που διαβάζεται απελευθερώνεται πλέον και με PropVariantClear, επειδή ένα κακοσχηματισμένο αρχείο μπορεί να παρκάρει string κάτω από οποιοδήποτε από αυτά τα IDs. Αν θέλετε να δείτε εκείνα τα streams με τα μάτια σας, το walk-through για το διάβασμα OLE2 compound files στο Delphi χωρίς COM IStorage δείχνει πώς να τα προσεγγίσετε

Το PIDSI_EDITTIME είναι η παγίδα μέσα στην παγίδα. Η ιδιότητα τυποποιείται ως VT_FILETIME αλλά κρατά διάρκεια, τον raw αριθμό παρελθόντων ticks των 100 ns χωρίς καμία εποχή προστεθείσα. Ο παλιός writer τη μεταχειριζόταν ως ημερομηνία, διαιρώντας το EditTimeMinutes διά 1440 και σπρώχνοντας το αποτέλεσμα από τη μετατροπή εποχής, οπότε 125 λεπτά επεξεργασίας κατέληγαν στο αρχείο ως περίπου 299 χρόνια. Ο τρέχων reader αναγνωρίζει εκείνη την κωδικοποίηση από το μέγεθός της: καμία πραγματική συνεδρία επεξεργασίας δεν απλώνεται σε τρεις αιώνες, οπότε κάθε τιμή 109206 ημερών και πάνω αφαιρεί το παρωχημένο offset πριν συμπληρωθεί το EditTimeMinutes

Χάρτης HotXLS του property set 005SummaryInformation όπου το PIDSI_CREATE_DTM στο $0C κρατά τον χρόνο δημιουργίας, το PIDSI_LASTSAVE_DTM στο $0D τη σφραγίδα αποθήκευσης, το PIDSI_EDITTIME στο $0A μια raw διάρκεια αντί για ημερομηνία, και το $0E PIDSI_PAGECOUNT η θέση που παρερμηνεύονταν από παλαιότερα builds για timestamps
Το PIDSI_EDITTIME είναι η παγίδα μέσα στην παγίδα — τυποποιημένο ως VT_FILETIME όμως κρατώντας παρελθόντα ticks χωρίς εποχή, που κάποτε μετέτρεπαν 125 λεπτά επεξεργασίας σε περίπου 299 χρόνια μέχρι να έρθει μια ευρετική ανάγνωση με βάση το μέγεθος
var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Title := 'Q3 settlement';
    // οι τιμές του API είναι τοπική ώρα· το αρχείο αποθηκεύει UTC FILETIMEs
    Book.CreatedDate := EncodeDate(2026, 1, 15) + EncodeTime(9, 30, 0, 0);
    Book.LastSavedDate := Now;     // το HotXLS γράφει ό,τι αναθέτετε, δεν σφραγίζει μόνο του Now
    Book.RevisionNumber := 7;
    Book.EditTimeMinutes := 125;   // διάρκεια, αποθηκεύεται ως raw ticks
    if Book.SaveAs('settlement.xls') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Book.Free;
  end;
end;

Γιατί οι ημερομηνίες XLSX έπεφταν εκτός κατά ακριβώς το offset της ζώνης;

Οι ημερομηνίες XLSX έπεφταν εκτός κατά το offset της ζώνης επειδή τα dcterms:created και dcterms:modified στο docProps/core.xml είναι τιμές W3CDTF επισημασμένες με Z, που σημαίνει UTC στο μοντέλο core properties του ECMA-376 Part 2, και το HotXLS σφράγιζε τοπική ώρα με εκείνο το Z κολλημένο. Ένα workbook που δημιουργήθηκε στις 09:30 σε μηχανή σε UTC+8 κουβαλούσε 09:30:00Z, και το Excel στην ίδια μηχανή το μετέτρεπε σε 17:30. Η classic μηχανή είχε το πανομοιότυπο ελάττωμα στις FILETIME τιμές της, και οι custom date properties που προσθέτονταν μέσω TXLSXWorkbook.CustomProperties.AddDate (γραμμένες ως vt:filetime) το μοιράζονταν κι αυτές. Από το v2.384.48 και οι τρεις δρόμοι μετατρέπουν πριν γράψουν και ξαναμετατρέπουν στην ανάγνωση όποτε η σφραγίδα κουβαλά Z, και από το v2.384.59 η πλευρά ανάγνωσης σέβεται και τα κλασματικά δευτερόλεπτα και τα ρητά offset +hh:mm / -hh:mm

Η μετατροπή καθαυτή είναι εκεί όπου ένα αφελές fix πηγαίνει στραβά. Το LocalFileTimeToFileTime εφαρμόζει το offset που ισχύει αυτή τη στιγμή, οπότε μια σφραγίδα Ιανουαρίου που μετατρέπεται τον Ιούλιο βγαίνει εκτός κατά μία ώρα σε κάθε ζώνη με θερινή ώρα. Το HotXLS καλεί αντί αυτού TzSpecificLocalTimeToSystemTime και SystemTimeToTzSpecificLocalTime, που διαλέγουν χειμερινή ή θερινή από την ημερομηνία που μετατρέπεται, και μια μη ορισμένη τιμή μηδέν περνά ανέγγιχτη ώστε να μη γίνεται ποτέ ημερομηνία 1899 μετατοπισμένη κατά λίγες ώρες

Μονοπάτια μετατροπής τοπικού σε UTC του HotXLS για σφραγίδα 17:00 CET Ιανουαρίου αποθηκευμένη τον Ιούλιο: το LocalFileTimeToFileTime εφαρμόζει το σημερινό offset θερινής και καταλήγει μια ώρα εκτός στις 15:00Z, ενώ το TzSpecificLocalTimeToSystemTime διαλέγει το offset από την ίδια την ημερομηνία της σφραγίδας και γράφει το σωστό 16:00Z
Το offset ζώνης ανήκει στην ίδια την ημερομηνία της σφραγίδας, όχι στους τρέχοντες κανόνες της μηχανής — ένα Windows API διαλέγει τη σωστή πλευρά μιας αλλαγής θερινής ώρας, το άλλο μετατοπίζει ήσυχα σφραγίδες Ιανουαρίου που μετατρέπονται τον Ιούλιο κατά μία ώρα
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.Open('report-template.xlsx') <> 1 then
      raise Exception.Create('Template not available');
    Book.Created := EncodeDate(2026, 7, 1) + EncodeTime(9, 30, 0, 0);
    Book.Modified := Now;
    Book.CustomProperties.AddDate('ApprovedOn',
      EncodeDate(2026, 1, 20) + EncodeTime(17, 0, 0, 0));
    Book.SaveAs('report.xlsx');
    // Σε μηχανή ρυθμισμένη σε Central European Time, το core.xml κρατά τώρα
    // <dcterms:created xsi:type="dcterms:W3CDTF">2026-07-01T07:30:00Z</dcterms:created>
    // (UTC+2 τον Ιούλιο), ενώ το ApprovedOn γράφεται ως 16:00Z (UTC+1 τον Ιανουάριο)
  finally
    Book.Free;
  end;
end;

Τι δεν μετατρέπει το HotXLS όταν διαβάζει timestamps;

Ο reader W3CDTF του HotXLS μετατρέπει κάθε μορφή με ετικέτα ζώνης του προφίλ από το v2.384.59, και η μία περίπτωση που ακόμα αφήνει ήσυχη είναι ώρα χωρίς ζώνη. Πριν από εκείνη την έκδοση ο parser έπαιρνε τους πρώτους 19 χαρακτήρες και μετέτρεπε από UTC μόνο όταν ο 20ός χαρακτήρας ήταν Z, οπότε μια σφραγίδα με κλασματικά δευτερόλεπτα (01:30:00.5Z) ή ρητό offset (+08:00) διαβαζόταν ως τοπική ώρα χωρίς καμία ρύθμιση και κατέληγε εκτός κατά το offset της ζώνης. Από το HotXLS 2.384.59, τα Created, Modified και οι custom properties με τιμή ημερομηνίας κάνουν parse κλασματικά δευτερόλεπτα οποιουδήποτε μήκους, Z, και offset +hh:mm / -hh:mm, μετατρέπουν τη στιγμή σε UTC και μετά σε τοπική ώρα, και διαβάζουν μια σφραγίδα μόνο-ημερομηνίας όπως το 2026-07-01 ως εκείνη την ημερομηνία. Μια σφραγίδα με ώρα αλλά χωρίς δείκτη ζώνης, που το προφίλ W3CDTF δεν επιτρέπει και το ECMA-376 Part 2 δεν δίνει κανόνα για αυτήν, διαβάζεται ακόμα ως τοπική ώρα αμετάβλητη, και μια σφραγίδα που δεν κάνει parse καθόλου επιστρέφει ως μηδέν. Τα workbooks που περνούν από Excel είναι μια χαρά· τα packages που παράγουν άλλοι γεννήτορες που πετούν τη ζώνη αξίζουν ένα spot check

Τα αρχεία που έγραψαν παλαιότερα builds του HotXLS είναι το άλλο τίμιο όριο. Μια σφραγίδα XLSX γραμμένη πριν το v2.384.48 ήταν τοπική ώρα φορώντας Z, και τίποτα στο αρχείο δεν τη διακρίνει από μια σωστή, οπότε ο τρέχων reader τη μετατοπίζει κατά το offset της ζώνης. Οι classic σφραγίδες FILETIME από εκείνα τα builds παίρνουν την ίδια μετατόπιση, και μια ημερομηνία δημιουργίας γραμμένη πριν το v2.384.17 διαβάζεται επιπλέον μια μέρα αργά, επειδή η παραπάνω μέρα της παλιάς σταθεράς δεν ανιχνεύεται κι αυτή· μόνο η κωδικοποίηση του edit-time και η τοποθέτηση στο $0E έχουν αναγνωρίσιμη υπογραφή. Έχετε επίσης υπόψη ότι η τιμή του API είναι τοπική για τη μηχανή που διαβάζει, οπότε ένα service σε UTC και ένας desktop στο Τόكيο θα αναφέρουν διαφορετικές τιμές CreatedDate για το ίδιο αρχείο, και οι δύο σωστές

Πώς πρέπει να τεστάρετε timestamps εγγράφων;

Τεστάρετε τα timestamps εγγράφων απέναντι σε κάτι που ο δικός σας κώδικας δεν έγραψε. Και τα δύο αυτά bugs πέρασαν έλεγχο save-και-επανάνοιγμα, επειδή ένα συμμετρικό λάθος είναι αόρατο σε ένα συμμετρικό τεστ. Συγκρίνετε με ένα workbook αποθηκευμένο από Excel, ή κάνετε assert τα raw bytes και το κείμενο XML μετά την αποθήκευση, και τρέξτε τη σουίτα σε μηχανή ρυθμισμένη σε μη-UTC ζώνη με δοκιμαστική ημερομηνία από κάθε πλευρά μιας αλλαγής θερινής ώρας. Ένα build agent που τρέχει σε UTC θα περάσει χαρούμενα τον παλιό, χαλασμένο κώδικα

Τα timestamps εγγράφων είναι μικρά, αλλά είναι αυτό που τα συστήματα αρχείων, οι search indexes και τα audit trails ταξινομούν κατά, και μια ημερομηνία εκτός κατά μια μέρα ή κατά οκτώ ώρες είναι χειρότερη από μια χαμένη επειδή κανείς δεν την αμφισβητεί. Το HotXLS Delphi spreadsheet component αναλαμβάνει την αριθμητική εποχών, τα property IDs και τη μετατροπή UTC και για .xls και για .xlsx, ώστε ο κώδικάς σας να αναθέτει σκέτες τοπικές τιμές TDateTime και να αφήνει τη μορφή αρχείου στη βιβλιοθήκη