Technical Article

Unicode-sikker eksport av regneark i Delphi: RTF og HTML

Et regneark inneholder en kolonne med kundenavn. Noen er på kinesisk, noen i kyrillisk, noen få har tyske tødler eller en fransk aksent. Du eksporterer det til CSV og åpner resultatet, og hvert tegn er intakt. Du eksporterer den samme arbeidsboken til RTF for en brevmal (mail-merge template), åpner den i et tekstbehandlingsprogram, og de ikke-ASCII-navnene har kollapset til rader med spørsmålstegn. Dataene endret seg aldri. Det som endret seg er kodingskontrakten for formatet du skrev, og hver eksportsti har sin egen kontrakt.

Dette er fellen som fanger et bibliotek som ser fullt Unicode-klart ut på overflaten. Celleteksten holdes internt som WideString, så modellen mister aldri et tegn. Tapet skjer ved grensen, i skriveren som må serialisere denne teksten til et format med sine egne regler om hvilke byte som er lovlige, og hvordan alt utenfor det lovlige området må kodes. Får du én skriver riktig, kan du fortsatt levere en annen som kverker den samme teksten. Løsningen er ikke en global bryter. Det er en separat, riktig avgjørelse på hver sti.

RTF er et 7-bits-sikkert format av design

Rich Text Format er eldre enn Unicode og ble spesifisert for å overleve transporter som bare sender utskrivbar ASCII. Et RTF-dokument erklærer en tegnkoding (code page) i headeren, og ethvert tegn skriveren ikke kan representere i den tegnkodingen, må sendes ut som en escape i stedet for som en rå byte. Den relevante escapen er \u, som bærer en signert 16-bits kodeenhet etterfulgt av et ASCII-reserve-tegn (fallback character) for lesere som er for gamle til å forstå escapen i det hele tatt.

HotXLS skriver RTF på denne måten. Dokumentheaderen åpner med å erklære tegnkodingen i formen \ansi\ansicpg1252\uc1, og skriveren i lxRTF-enheten går gjennom hver streng og sender ut alle tegn over vanlig ASCII som en \u-escape, slik at byte-strømmen forblir 7-bits ren uansett hva den erklærte tegnkodingen kan inneholde. A code point such as U+4E2D becomes the literal sequence  3?, not a raw byte that a viewer would then try to interpret through whatever code page it happened to assume. Without that discipline, anything outside the declared code page has no legal byte representation, and a writer that emits the raw value produces the question marks that started this article.

Detaljen du må huske på er at den erklærte tegnkodingen og escapene er to halvdeler av én kontrakt. Å bare erklære tegnkodingen hjelper ikke tekst som ligger utenfor den. Å sende ut escaper uten en erklært tegnkoding etterlater reserve-tegnene tvetydige. Begge må være korrekte sammen, og det er grunnen til at en skriver som bare håndterer en av dem, fortsatt feiler på den første flerspråklige arbeidsboken.

HTML-escaping handler om mer enn vinkelparenteser

HTML-eksporten produserer et dokument med flere ark der navigasjonsrammene har arknavnene som synlig tekst. Disse navnene er forfatterstyrte strenger som kan inneholde alle tegn, inkludert de som er viktige for markering. Et ark som bokstavelig talt heter Q1 & Q2 <draft>, må nå siden som escaped enheter, ellers åpner vinkelparentesene en fantomtagg og og-tegnet starter en enhetsreferanse som aldri var tiltenkt. Dette er vanlig HTML-escaping, og å hoppe over det på en rammeetikett er typen utelatelse som består alle tester bygget med arknavn som bare inneholder ASCII.

Kodingsspørsmålet sitter ett lag under dette. Når ikke-ASCII-tegn havner i en kontekst som ikke er garantert å bli servert as UTF-8, det sikre representasjonen er en numerisk tegnreferanse, slik at U+00E9 skrives som é i stedet for som en rå byte hvis betydning avhenger av svar-tegnsettet. Speilbildet av denne regelen gjelder på vei inn. En arbeidsbok som leses tilbake fra XLSX, har delte strenger der et tegn already kan være lagret som en numerisk XML-enhet, og den enheten må dekodes til ett helt tegn før den går inn i cellemodellen. Decode it carelessly, splitting a code point into separate bytes, and a single character re-emerges as two pieces of mojibake that no later export can repair.

XLSX-beholderen er en ZIP, og ZIP har sin egen navnekoding

En XLSX-fil is et ZIP-arkiv, og arkivet lagrer et navn for hvert medlem det inneholder. ZIP er gammelt nok til at dets opprinnelige spesifikasjon ikke sa noe om kodingen av disse navnene, så en leser som ikke finner noe signal, antar arkivets lokale tegnkoding. Den antagelsen er feil i det øyeblikket et medlemsnavn inneholder et ikke-ASCII-tegn, noe som skjer med lokaliserte delnavn på regneark og med innebygd medieinnhold der filnavnene har aksenter eller ikke-latinske skrifter.

Løsningen er en enkelt bit. Generell bit 11 i hver lokale filheader erklærer at medlemsnavnet er kodet som UTF-8. HotXLS sjekker akkurat den biten når den leser et arkiv, og tester de generelle flaggene mot masken $0800, og en leser eller skriver som ignorerer den, vil mislese et navn som en korrekt implementering lagret som UTF-8. Biten er billig å sette og billig å respektere, og det er hele forskjellen mellom et medlemsnavn som overlever rundreisen og ett som ankommer skadet før regnearkinnholdet i det hele tatt er tolket.

Case-folding og tallskanning skjuler den samme faren

Formelevaluering er der Unicode-sikkerhet slutter å handle om serialisering og begynner å handle om sammenligning. SEARCH-funksjonen skiller ikke mellom store og små bokstaver (case-insensitive), noe som betyr at den må konvertere skriftstørrelse (fold case) før den ser etter en understreng. Feil måte å konvertere på er gjennom ANSI-tegnkodingen, fordi det å gjøre ikke-ASCII-tekst til store bokstaver på den måten ruter tegnene gjennom en smal tegnkoding og skader alt utenfor den. Riktig måte er store bokstaver for WideString, som bevarer hele UTF-16-området. HotXLS konverterer med WideUpperCase av akkurat denne grunn, slik at et søk etter tekst med aksent eller ikke-latinsk skrift matcher de samme tegnene det ble gitt, i stedet for en tegnkodingsskadet tilnærming av dem.

Formel-tokenizeren bærer på en beslektet forpliktelse som ikke har noe med bokstaver å gjøre og alt å gjøre med hvor et token slutter. Vitenskapelig notasjon som 1E3 eller 2.5E-3 is a single numeric literal, and the scanner has to recognise the E, an optional sign, and the following digits as part of the number rather than breaking the input into a name followed by a separate number. A scanner that mishandles this turns a perfectly valid constant into a parse error or, worse, a silently wrong expression. It belongs in the same discussion because both cases are about a reader making a correct character-level decision: one about how to fold a character for comparison, the other about whether a character continues the current token.

Bygge og eksportere en flerspråklig arbeidsbok

Det offentlige API-et ber deg ikke tenke på noe av dette. Du bygger arbeidsboken fra WideString-celleverdier og kaller eksportinngangspunktet du ønsker. Kodingsbeslutningene skjer inne i hver skriver. Eksempelet under fyller et ark med tekst i flere skrifter, og skriver deretter både en RTF-fil og en HTML-fil fra samme arbeidsbok, slik at de to stiene kjører mot identiske inndata.

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;

Begge kallene returnerer en Integer-status, og begge forbruker den samme teksten i minnet. Ingenting i den kallende koden erklærer en tegnkoding eller escaper et tegn, fordi ansvaret ligger hos skriveren som kjenner sitt eget format. SaveAsCSV på arbeidsboknivå følger samme form hvis du trenger en avgrenset eksport fra den identiske kilden.

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

Unicode-sikkerhet er per sti, ikke per bibliotek

Leksjonen verdt å ta med seg er at det ikke finnes et enkelt sted å være Unicode-sikker. RTF trenger en erklært tegnkoding pluss \u-escaper. HTML trenger enhet-escaping for tegn som er viktige for markering, og numeriske referanser der tegnsettet ikke er garantert, pluss korrekt dekoding av enheter som ankommer i delte strenger. ZIP-beholderen trenger generell bit 11 satt slik at et UTF-8-medlemsnavn leses som UTF-8. Formelevaluering trenger store/små bokstaver for WideString (case folding) og en tokenizer som holder vitenskapelig notasjon i ett stykke. Hver av disse er en ulik kontrakt, og et bibliotek kan tilfredsstille én mer det stille bryter en annen. Det er grunnen til at et verktøy som får CSV riktig, fortsatt kan gi deg en RTF full av spørsmålstegn.

If you are exports lean on the delimited formats, the trade-offs between them are covered in our walkthrough of CSV, TSV and HTML export, and when the source is a result set rather than a hand-built sheet, the patterns in database export for Delphi reports pair naturally with the encoding rules described here. All of it ships as part of the HotXLS Component for Delphi and C++Builder, alongside the reading, formula, and formatting APIs covered elsewhere on this blog.