Technical Article

Unicode-Safe εξαγωγή υπολογιστικών φύλλων στο Delphi: RTF και HTML

Ένα υπολογιστικό φύλλο περιέχει μια στήλη με ονόματα πελατών. Ορισμένα είναι στα κινεζικά, άλλα στα κυριλλικά, μερικά φέρουν γερμανικά umlauts ή γαλλικό τόνο. Το εξάγετε σε CSV και ανοίγετε το αποτέλεσμα, και κάθε χαρακτήρας είναι ανέπαφος. Εξάγετε το ίδιο βιβλίο εργασίας σε RTF για ένα πρότυπο συγχώνευσης αλληλογραφίας (mail-merge template), το ανοίγετε σε έναν επεξεργαστή κειμένου, και τα μη ASCII ονόματα έχουν καταρρεύσει σε σειρές από ερωτηματικά. Τα δεδομένα δεν άλλαξαν ποτέ. Αυτό που άλλαξε είναι η σύμβαση κωδικοποίησης (encoding contract) της μορφής που γράψατε, και κάθε διαδρομή εξαγωγής φέρει μια διαφορετική.

Αυτή είναι η παγίδα που πιάνει μια βιβλιοθήκη η οποία φαίνεται πλήρως Unicode-aware στην επιφάνεια. Το κείμενο του κελιού διατηρείται εσωτερικά ως WideString, επομένως το μοντέλο δεν χάνει ποτέ χαρακτήρα. Η απώλεια συμβαίνει στο όριο, στον writer που πρέπει να σειριοποιήσει (serialise) αυτό το κείμενο σε μια μορφή με τους δικούς της κανόνες σχετικά με το ποια byte είναι νόμιμα και πώς πρέπει να κωδικοποιείται οτιδήποτε εκτός του νόμιμου εύρους. Διορθώστε έναν writer και μπορεί να εξακολουτίτε να στέλνετε έναν άλλο που καταστρέφει το ίδιο κείμενο. Η διόρθωση δεν είναι ένας καθολικός διακόπτης. Είναι μια ξεχωριστή, σωστή απόφαση σε κάθε διαδρομή.

Το RTF είναι μια μορφή 7-bit-safe από σχεδιασμό

Η μορφή Rich Text Format προηγείται του Unicode και καθορίστηκε για να επιβιώνει από μεταφορές που περνούν μόνο εκτυπώσιμο ASCII. Ένα έγγραφο RTF δηλώνει μια κωδικοσελίδα (code page) στην κεφαλίδα του, και οποιοσδήποτε χαρακτήρας δεν μπορεί να αναπαρασταθεί από τον writer σε αυτήν την κωδικοσελίδα πρέπει να εκπέμπεται ως escape χαρακτήρας και όχι ως ακατέργαστο byte. Το σχετικό escape είναι το \u, το οποίο φέρει μια υπογεγραμμένη μονάδα κώδικα 16 bit ακολουθούμενη από έναν χαρακτήρα ασφαλείας ASCII (fallback character) για αναγνώστες πολύ παλιούς για να κατανοήσουν καθόλου το escape.

Το HotXLS γράφει το RTF με αυτόν τον τρόπο. Η κεφαλίδα του εγγράφου ανοίγει δηλώνοντας την κωδικοσελίδα, στη μορφή \ansi\ansicpg1252\uc1, και ο writer στη μονάδα lxRTF περπατά κάθε συμβολοσειρά εκπέμποντας οποιονδήποτε χαρακτήρα πάνω από το απλό ASCII ως escape \u, ώστε η ροή byte να παραμένει 7-bit clean ανεξάρτητα από το τι μπορεί να κρατήσει η δηλωμένη κωδικοσελίδα. Ένα σημείο κώδικα όπως το U+4E2D γίνει η αυτούσια ακολουθία \u20013?, όχι ένα ακατέργαστο byte που ένας αναγνώστης θα προσπαθούσε στη συνέχεια να ερμηνεύσει μέσω οποιασδήποτε κωδικοσελίδας έτυχε να υποθέσει. Χαρακτήρες εκτός της δηλωμένης κωδικοσελίδας δεν έχουν νόμιμη αναπαράσταση byte, και ένας writer που εκπέμπει την ακατέργαστη τιμή παράγει τα ερωτηματικά που ξεκίνησαν αυτό το άρθρο.

Η λεπτομέρεια που πρέπει να έχετε κατά νου είναι ότι η δηλωμένη κωδικοσελίδα και τα escapes είναι δύο μισά μιας σύμβασης. Η δήλωση της κωδικοσελίδας από μόνη της δεν βοηθά το κείμενο που βρίσκεται έξω από αυτήν. Η εκπομπή escapes χωρίς δηλωμένη κωδικοσελίδα αφήνει τους fallback χαρακτήρες ασαφείς. Και τα δύο πρέπει να είναι σωστά μαζί, γι' αυτό και ένας writer που χειρίζεται μόνο ένα από αυτά εξακολουθεί να αποτυγχάνει στο πρώτο πολύγλωσσο βιβλίο εργασίας.

Το HTML escaping αφορά περισσότερα από τις γωνιακές αγκύλες

Η εξαγωγή σε HTML παράγει ένα έγγραφο πολλαπλών φύλλων του οποίου τα navigation frames μεταφέρουν τα ονόματα των φύλλων ως ορατό κείμενο. Αυτά τα ονόματα είναι συμβολοσειρές ελεγχόμενες από τον δημιουργό που μπορούν να περιέχουν οποιονδήποτε χαρακτήρα, συμπεριλαμβανομένων εκείνων που είναι σημαντικοί για τη σήμανση. Ένα φύλλο κυριολεκτικά ονομασμένο ως Q1 & Q2 <draft> πρέπει να φτάσει στη σελίδα ως escaped οντότητες (escaped entities), αλλιώς οι γωνιακές αγκύλες ανοίγουν μια ετικέτα φάντασμα και το ampersand ξεκινά μια αναφορά οντότητας που δεν προοριζόταν ποτέ. Αυτό είναι το σύνηθες HTML escaping, και η παράλειψή του σε μια ετικέτα πλαισίου είναι το είδος της παράλειψης που περνά κάθε δοκιμή κατασκευασμένη με ονόματα φύλλων που περιέχουν μόνο ASCII.

Το ερώτημα της κωδικοποίησης βρίσκεται ένα επίπεδο κάτω από αυτό. Όταν χαρακτήρες μη ASCII καταλήγουν σε ένα πλαίσιο που δεν είναι εγγυημένο ότι θα σερβιριστεί ως UTF-8, η ασφαλής αναπαράσταση είναι μια αριθμητική αναφορά χαρακτήρα (numeric character reference), έτσι ώστε το U+00E9 να γράφεται ως é αντί για ένα ακατέργαστο byte του οποίου η σημασία εξαρτάται από το charset της απόκρισης. Η κατοπτρική εικόνα αυτού του κανόνα ισχύει κατά την εισαγωγή. Ένα βιβλίο εργασίας που διαβάζεται πίσω από XLSX μεταφέρει κοινόχρηστες συμβολοσειρές (shared strings) στις οποίες ένας χαρακτήρας μπορεί να είναι ήδη αποθηκευμένος ως αριθμητική οντότητα XML, και αυτή η οντότητα πρέπει να αποκωδικοποιηθεί σε έναν ολόκληρο χαρακτήρα πριν εισέλθει στο μοντέλο κελιού. Αποκωδικοποιήστε την απρόσεκτα, χωρίζοντας ένα σημείο κώδικα σε ξεχωριστά byte, και ένας μεμονωμένος χαρακτήρας επανεμφανίζεται ως δύο κομμάτια mojibake που καμία μεταγενέστερη εξαγωγή δεν μπορεί να επισκευάσει.

Το κοντέινερ XLSX είναι ένα ZIP, και ZIP έχει τη δική του κωδικοποίηση ονομάτων

Ένα αρχείο XLSX είναι ένα αρχείο αρχειοθέτησης ZIP (ZIP archive), και το αρχείο αποθηκεύει ένα όνομα για κάθε μέλος που περιέχει. Το ZIP είναι αρκετά παλιό ώστε η αρχική του προδιαγραφή να μην λέει τίποτα για την κωδικοποίηση αυτών των ονομάτων, επομένως ένας αναγνώστης που δεν βρίσκει σήμα υποθέτει την τοπική κωδικοσελίδα του αρχείου. Αυτή η υπόθεση είναι λάθος τη στιγμή που ένα όνομα μέλους περιέχει έναν χαρακτήρα μη ASCII, πράγμα που συμβαίνει με τοπικοποιημένα ονόματα τμημάτων υπολογιστικού φύλλου και με ενσωματωμένα μέσα των οποίων τα ονόματα αρχείων φέρουν τόνους ή μη λατινική γραφή.

Η διόρθωση είναι ένα μόνο bit. General-purpose bit 11 σε κάθε τοπική κεφαλίδα αρχείου δηλώνει ότι το όνομα του μέλους είναι κωδικοποιημένο ως UTF-8. Το HotXLS ελέγχει ακριβώς αυτό το bit όταν διαβάζει ένα αρχείο, δοκιμάζοντας τις general-purpose σημαίες έναντι της μάσκας $0800, και ένας αναγνώστης ή writer που το αγνοεί θα διαβάσει λάθος ένα όνομα που μια σωστή υλοποίηση αποθήκευσε ως UTF-8. Το bit είναι φθηνό να οριστεί και φθηνό να τηρηθεί, και είναι όλη η διαφορά ανάμεσα σε ένα όνομα μέλους που επιβιώνει από το round trip και σε ένα που φτάνει κατεστραμμένο πριν καν αναλυθεί το περιεχόμενο του υπολογιστικού φύλλου.

Το case folding και η σάρωση αριθμών κρύβουν τον ίδιο κίνδυνο

Η αξιολόγηση τύπων είναι το σημείο όπου η ασφάλεια Unicode παύει να αφορά τη σειριοποίηση και αρχίζει να αφορά τη σύγκριση. Η συνάρτηση SEARCH δεν κάνει διάκριση πεζών-κεφαλαίων (case-insensitive), πράγμα που σημαίνει ότι πρέπει να μετατρέψει τα πεζά-κεφαλαία (fold case) πριν αναζητήσει μια υποσυμβολοσειρά. Ο λάθος τρόπος είναι μέσω της κωδικοσελίδας ANSI, επειδή η μετατροπή κειμένου μη ASCII σε κεφαλαία με αυτόν τον τρόπο δρομολογεί τους χαρακτήρες μέσω μιας στενής κωδικοσελίδας και καταστρέφει οτιδήποτε έξω από αυτήν. Ο σωστός τρόπος είναι η μετατροπή wide-string σε κεφαλαία, η οποία διατηρεί ολόκληρο το εύρος UTF-16. Το HotXLS κάνει fold με τη WideUpperCase ακριβώς για αυτόν το λόγο, έτσι ώστε η αναζήτηση για τονισμένο ή μη λατινικό κείμενο να ταιριάζει με τους ίδιους χαρακτήρες που δόθηκαν και όχι με μια παραμορφωμένη προσέγγιση κωδικοσελίδας.

Το formula tokenizer φέρει μια σχετική υποχρέωση που δεν έχει να κάνει με γράμματα αλλά με το πού τελειώνει ένα token. Η επιστημονική σημειογραφία όπως το 1E3 ή το 2.5E-3 είναι ένα ενιαίο αριθμητικό λεκτικό (numeric literal), και ο σαρωτής πρέπει να αναγνωρίσει το E, ένα προαιρετικό πρόσημο, και τα ακόλουθα ψηφία ως μέρος του αριθμού αντί να σπάσει την εισαγωγή σε ένα όνομα ακολουθούμενο από έναν ξεχωριστό αριθμό. Ένας σαρωτής που το χειρίζεται λάθος αυτό μετατρέπει μια απόλυτα έγκυρη σταθερά σε σφάλμα ανάλυσης (parse error) ή, χειρότερα, σε μια σιωπηλά λανθασμένη έκφραση. Ανήκει στην ίδια συζήτηση επειδή και οι δύο περιπτώσεις αφορούν έναν αναγνώστη που λαμβάνει μια σωστή απόφαση σε επίπεδο χαρακτήρα: η μία σχετικά με το πώς να μετατρέψει έναν χαρακτήρα για σύγκριση, η άλλη σχετικά με το αν ένας χαρακτήρας συνεχίζει το τρέχον token.

Δημιουργία και εξαγωγή πολύγλωσσου βιβλίου εργασίας

Το δημόσιο API δεν σας ζητά να σκεφτείτε τίποτα από αυτά. Δημιουργείτε το βιβλίο εργασίας από τιμές κελιών WideString και καλείτε το σημείο εισόδου εξαγωγής που θέλετε. Οι αποφάσεις κωδικοποίησης λαμβάνονται μέσα σε κάθε writer. Το παρακάτω παράδειγμα τροφοδοτεί ένα φύλλο με κείμενο σε διάφορα σενάρια γραφής, και στη συνέχεια γράφει τόσο ένα αρχείο RTF όσο και ένα αρχείο HTML από το ίδιο βιβλίο εργασίας, έτσι ώστε οι δύο διαδρομές να εκτελούνται έναντι πανομοιότυπης εισαγωγής.

uses
  lxHandle;

procedure ExportMultilingualWorkbook;
var
  Book: IXLSWorkbook;
  Sheet: IXLSWorksheet;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Customers');

    Sheet.Cells[1, 1].Value := 'Name';
    Sheet.Cells[1, 2].Value := 'City';

    // Cell text is held as WideString, so every script survives the model.
    Sheet.Cells[2, 1].Value := '王伟';          // Chinese
    Sheet.Cells[2, 2].Value := '北京';
    Sheet.Cells[3, 1].Value := 'Müller';        // German umlaut
    Sheet.Cells[3, 2].Value := 'Köln';
    Sheet.Cells[4, 1].Value := 'Иванов';        // Cyrillic
    Sheet.Cells[4, 2].Value := 'Москва';
    Sheet.Cells[5, 1].Value := 'Désirée';       // French accents
    Sheet.Cells[5, 2].Value := 'Montréal';

    // RTF: the lxRTF writer declares the code page and emits every
    // non-ASCII character as a \u escape, keeping the file 7-bit clean.
    Book.SaveAsRTF('Customers.rtf');

    // HTML: sheet names are HTML-escaped and non-ASCII text is written
    // so it does not depend on a guessed response charset.
    Book.SaveAsHTML('Customers.html');
  finally
    Book := nil;
  end;
end;

Και οι δύο κλήσεις επιστρέφουν μια κατάσταση Integer, και οι δύο καταναλώνουν το ίδιο κείμενο στη μνήμη. Τίποτα στον κώδικα που καλεί δεν δηλώνει κωδικοσελίδα ή κάνει escape χαρακτήρες, επειδή η ευθύνη ανήκει στον writer που γνωρίζει τη δική του μορφή. Η SaveAsCSV σε επίπεδο βιβλίου εργασίας ακολουθεί το ίδιο σχήμα εάν χρειάζεστε εξαγωγή με οριοθέτες (delimited export) από την ίδια πηγή.

// Same workbook, a third export path with its own encoding rules.
Book.SaveAsCSV('Customers.csv');

Η ασφάλεια Unicode είναι ανά διαδρομή, όχι ανά βιβλιοθήκη

Το μάθημα που αξίζει να κρατήσετε είναι ότι δεν υπάρχει ένα μόνο μέρος για να είστε Unicode-safe. RTF χρειάζεται μια δηλωμένη κωδικοσελίδα συν \u escapes. Το HTML χρειάζεται entity escaping για χαρακτήρες σημαντικούς για τη σήμανση και αριθμητικές αναφορές όπου το charset δεν είναι εγγυημένο, συν σωστή αποκωδικοποίηση των οντοτήτων που φτάνουν σε shared strings. Το κοντέινερ ZIP χρειάζεται το general-purpose bit 11 ορισμένο ώστε ένα όνομα μέλους UTF-8 να διαβάζεται ως UTF-8. Η αξιολόγηση τύπων χρειάζεται wide-string case folding και ένα tokenizer που διατηρεί την επιστημονική σημειογραφία σε ένα κομμάτι. Καθένα από αυτά είναι μια διαφορετική σύμβαση, και μια βιβλιοθήκη μπορεί να ικανοποιεί τη μία ενώ αθόρυβα παραβιάζει την άλλη. Αυτός είναι ο λόγος που ένα εργαλείο που κάνει σωστά το CSV μπορεί να σας παραδώσει ένα RTF γεμάτο ερωτηματικά.

Εάν οι εξαγωγές σας βασίζονται σε μορφές με οριοθέτες, οι συμβιβασμοί μεταξύ τους καλύπτονται στον οδηγό μας για εξαγωγή CSV, TSV και HTML, και όταν η πηγή είναι ένα σύνολο αποτελεσμάτων αντί για ένα χειροποίητο φύλλο, τα μοτίβα στην εξαγωγή βάσης δεδομένων για αναφορές Delphi συνδυάζονται φυσικά με τους κανόνες κωδικοποίησης που περιγράφονται εδώ. Όλα αυτά αποστέλλονται ως μέρος του HotXLS Component για Delphi και C++Builder, μαζί με τα API ανάγνωσης, τύπων και μορφοποίησης που καλύπτονται αλλού σε αυτό το ιστολόγιο.