Техническая статья

dBASE, SYLK и DIF из Delphi без потери не-ASCII текста

Китайские названия городов, экспортированные в .dbf на западной машине, могут вернуться вопросиками из экспорта, отчитавшегося об успехе: у форматов dBASE, SYLK и DIF в файле просто некуда записать кодировку. Надёжный писатель использует системную ANSI-кодовую страницу и сверяет с ней каждое имя и значение до первого байта; всё, что страница не вмещает, HotXLS отвергает

Кодовая страница машины — единственная кодировка, которая есть у этих файлов

Все три формата родом с ранних лет ПК: dBASE — от Ashton-Tate, SYLK — со времён Microsoft Multiplan, DIF — от Software Arts, компании, сделавшей VisiCalc. Каждый хранит текст одиночными байтами и предполагает, что читатель делит кодовую страницу с писателем — честная ставка, когда вся техника в офисе стоит на одном DOS-сетапе. На сегодняшней Windows это страница, которую вернёт GetACP: 1252 на западноевропейской системе, 936 на упрощённой китайской, 1251 на кириллической. dBASE IV и FoxPro позже завели метку language driver в байте 29 заголовка таблицы, но раскладка dBASE III, которую пишет HotXLS, держит этот байт нулём, а у SYLK, DIF и выровненного пробелами текста .prn поля под неё в заголовке нет вовсе. Так что кодовая страница договаривается вне файла. HotXLS использует системную ANSI-страницу и на записи, и на чтении и до записи проверяет, что у каждого символа в этой странице найдётся байт

ФорматРасширение при SaveAs / OpenКодировка, объявленная в файлеТекст, который страница машины не вмещает
Таблица dBASE III.dbf, плюс .dbt-файл memo при необходимостиНет (байт 29 остаётся нулём)EXlsDbf поднимается до записи, а только что созданные .dbf / .dbt удаляются
SYLK.slk, .sylkНетSaveAs возвращает -1 и файла не создаёт
DIF.difНетSaveAs возвращает -1 и файла не создаёт
Форматированный текст.prnНетSaveAs возвращает -1 и файла не создаёт

Десять байт на имя поля dBASE, считаемых в кодовой странице

Каждый размерный лимит в таблице dBASE — это счёт в байтах. Здесь и болят многобайтовые кодовые страницы. Дескриптор поля держит имя в одиннадцати байтах — десять на имя, один на завершающий NUL — и кладёт букву типа сразу за ним, по смещению 11, ширину по 16 и число десятичных по 17. На странице 936 китайский символ — два байта, значит десятизначное китайское имя — двадцать. Скопируйте эти байты внутрь без измерения — и они проедут прямо сквозь NUL, букву типа и ширину; ни один читатель потом дескриптор не восстановит, хотя имя прошло проверку «десять символов». Держите имена полей dBASE в ASCII даже на CJK-рабочей станции. На этих именах завязаны xBase-инструменты, ODBC-драйверы и ГИС-софт, читающий таблицы атрибутов shapefile, а имя, декодируемое только на 936, на каждой машине с другой страницей превращается в другие байты

Раскладка дескриптора поля DBF dBASE III в HotXLS на Delphi: слот имени в десять байт плюс NUL, буква типа по смещению 11, ширина по 16 и десятичные по 17 — AddField('CITY') влезает, а десятизначное китайское имя на странице 936 переезжает за двадцать байт
Внутри 32-байтового дескриптора поля dBASE китайское имя поля, прошедшее проверку «десять символов», на странице 936 разливается в NUL, букву типа и ширину
БайтыСодержимое dBASE III
0Версия: 0x03, или 0x83, когда таблицу сопровождает memo-файл .dbt
1–3Последнее обновление: год с 1900, месяц, день
4–7Число записей, 32-бит little-endian
8–11Длина заголовка и длина записи, 16-бит слова
С 32 и далееПо одному 32-байтовому дескриптору на поле, затем 0x0D; записи дальше, каждая за флагом удаления

Ширины тоже в байтах. Символьное поле (C) упирается в 254 байта, значения N — выровненный вправо текст с объявленным числом десятичных, значения D — восемь символов в форме YYYYMMDD, а значение, не влезающее в своё поле, становится рядом звёздочек — так делает и сам dBASE. Более длинный текст уходит в memo-поле (M), хранящее номер блока в спутнике .dbt: заголовок 512 байт, затем блоки по 512 байт, каждая memo заканчивается 0x1A 0x1A. TXLSXWorkbook.SaveAs с именем .dbf превращает первую занятую строку активного листа в имена полей, выводит C, N, L или D по колонке, откатывается к C для смешанных колонок и уводит текст длиннее 254 символов в M. Когда ширины надо держать под контролем в байтах, соберите таблицу сами через TXlsDbfTable и задайте каждому полю C размер по закодированной длине самого длинного значения

uses
  Windows, SysUtils, Variants, lxDbf;

procedure WriteCustomerTable(const FileName: string);
var
  Table: TXlsDbfTable;
  Row: Integer;
begin
  Table := TXlsDbfTable.Create;
  try
    // ASCII-имена не длиннее десяти байт; ширины — в байтах системной страницы
    Table.AddField('CUSTNO', 'N', 6, 0);
    Table.AddField('CITY', 'C', 40, 0);
    Table.AddField('BALANCE', 'N', 12, 2);
    Table.AddField('OPENED', 'D', 8, 0);

    Row := Table.AddRow;
    Table.SetValue(Row, 0, 1001);
    Table.SetValue(Row, 1, WideString(#$5317#$4EAC#$5E02));  // 北京市, 6 байт в CP936
    Table.SetValue(Row, 2, 1234.5);
    Table.SetValue(Row, 3, EncodeDate(2026, 11, 12));

    try
      XlsWriteDbfFile(FileName, Table);
    except
      on E: EXlsDbf do
        // Ожидание на хосте 1252: в сообщении названы поле "CITY" и строка 1,
        // и на диске не остаётся .dbf
        raise Exception.CreateFmt('DBF export refused under code page %d: %s',
          [GetACP, E.Message]);
    end;
  finally
    Table.Free;
  end;
end;

Ожидание: на хосте 936 таблица записывается и переоткрывается со всеми четырьмя полями. На хосте 1252 XlsWriteDbfFile поднимает EXlsDbf, называя поле и строку в единичной нумерации. XlsWriteDbfStream переводит каждое имя поля и каждое значение C или M в страницу машины и обратно, сравнивает и мерит каждое имя против его десяти байт до выхода заголовка, так что отвергнутая таблица оставляет поток нетронутым. Файловая форма идёт дальше и удаляет уже созданные .dbf и, если был, .dbt

Как проверить, переживёт ли строка кодовую страницу машины?

Сконвертируйте её в AnsiString, верните обратно в WideString и сравните с оригиналом. Символ, которого в странице нет, возвращается заменителем — обычно ?, — так что сравнение проваливается ровно тогда, когда экспорт потерял бы данные. Писатели пользуются той же конвертацией, поэтому проверка и писатель не могут разойтись насчёт кодовой страницы. Держите и длину в байтах: это число, которое сопоставляется слоту имени в десять байт или ширине поля

uses
  Variants;

// True, когда каждый символ V переживает системную ANSI-страницу;
// ByteLen — закодированная длина для сравнения с шириной поля
function FitsHostCodePage(const V: Variant; out ByteLen: Integer): Boolean;
var
  W: WideString;
  A: AnsiString;
begin
  W := VarToWideStr(V);   // VarToStr может конвертировать раньше, чем проверка увидит текст
  A := AnsiString(W);
  ByteLen := Length(A);
  Result := WideString(A) = W;
end;

Настоящий урок — в комментарии к VarToWideStr. В Delphi 2007 и раньше string везде AnsiString, а под Free Pascal юнит Variants компилируется с собственным типом string RTL, AnsiString, в каком бы режиме ни был ваш юнит. На этих компиляторах VarToStr уже положил ? на месте каждого китайского символа. Защита на AnsiString(VarToStr(V)) сравнивает вопросики с вопросиками и пропускает всё. У TStringList там же проблема той же формы: присвойте широкий текст свойству Text, чтобы разбить строки, и каждый символ пройдёт через кодовую страницу машины на входе. XlsParseSlk, XlsParseDifText и XlsParsePrnText принимают WideString и на любом хосте сохраняют каждый его символ. Если собираете ещё и в Lazarus, смотрите с каким строковым типом юниты HotXLS компилируются под Free Pascal

Проверка обратного хода через системную ANSI-страницу в HotXLS для экспорта DBF, SYLK, DIF и PRN на Delphi: конвертация в AnsiString и обратно в WideString сохраняет китайский текст на хосте 936, но даёт вопросики на 1252 — отказ через EXlsDbf или SaveAs с -1
Конвертация каждого значения в страницу машины и обратно вскрывает потерянные символы до того, как байт доберётся до диска, — если текст читался через VarToWideStr, а не VarToStr

Записи SYLK и DIF, строка за строкой

SYLK — одна запись на строку, поля разделены точкой с запятой. HotXLS пишет заголовок ID;PHotXLS, по записи C;Yстрока;Xколонка;Kзначение на ячейку и терминатор E. Текст берётся в кавычки с удвоением внутренних кавычек — заодно это защищает точку с запятой внутри значения, — а читатель принимает и замену ;;, которой пользуются некоторые производители. Булева типа нет. Родные писатели формата шлют 1 и 0, так же пишет и SaveAs; ошибки уходят текстом вроде #N/A, даты — серийными числами. Эти две буквы в шапке — то, по чему Excel опознаёт SYLK, и потому же он принимает CSV, чья первая ячейка читается как ID, за SYLK-файл. Сборка и разбор работают на WideString, поэтому внутрипамятный круговой прогон ниже даёт одинаковый ответ на любом хосте

uses
  SysUtils, lxSlk;

function SylkRoundTripsInMemory: Boolean;
var
  Cells, Back: TXLSSlkCells;
  Text: WideString;
  I: Integer;
begin
  SetLength(Cells, 3);
  Cells[0].Row := 1; Cells[0].Col := 1; Cells[0].Kind := slkText;
  Cells[0].Text := WideString(#$4E0A#$6D77'; Pudong');   // 上海; Pudong
  Cells[1].Row := 1; Cells[1].Col := 2; Cells[1].Kind := slkText;
  Cells[1].Text := 'Pipe 12" steel';
  Cells[2].Row := 2; Cells[2].Col := 1; Cells[2].Kind := slkNumber;
  Cells[2].Number := 1234.5;

  Text := XlsBuildSlk(Cells);           // K"上海; Pudong", K"Pipe 12"" steel", K1234.5
  Result := XlsParseSlk(Text, Back) = Length(Cells);
  for I := 0 to High(Cells) do
    if Result then
      Result := (Back[I].Row = Cells[I].Row) and (Back[I].Col = Cells[I].Col) and
        (Back[I].Kind = Cells[I].Kind) and (Back[I].Text = Cells[I].Text) and
        ((Cells[I].Kind <> slkNumber) or (Back[I].Number = Cells[I].Number));
end;

DIF многословнее. Четыре группы заголовка — TABLE, VECTORS (число колонок), TUPLES (число строк) и DATA — берут по три строки; каждый элемент данных дальше берёт две: строку тип,число и строку с закавыченной строкой или индикатором значения. Строка открывается -1,0 с BOT, а -1,0 с EOD данные закрывает. Число — это 0,значение плюс V, логическое — 0,1 плюс TRUE или 0,0 плюс FALSE, строка — 1,0 плюс её закавыченный текст. На чтении HotXLS отображает индикаторы NA и ERROR в #N/A и #VALUE!, а NULL считает пустым. Лист с именем Orders, единственная строка которого держит Beijing, 1234.5 и True, экспортируется так:

TABLE
0,1
"Orders"
VECTORS
0,3
""
TUPLES
0,1
""
DATA
0,0
""
-1,0
BOT
1,0
"Beijing"
0,1234.5
V
0,1
TRUE
-1,0
EOD

Десятичная запятая ломает все три формата

Кодовая страница — не единственная настройка машины, протекающая в эти файлы. На рабочей станции с немецкими или французскими региональными настройками FloatToStr превращает 1234.5 в 1234,5. В DIF строка элемента становится 0,1234,5 и теряет форму тип,значение. В SYLK K1234,5 — не число. В поле N dBASE читатели xBase ждут точку. HotXLS пишет и читает все три через TFormatSettings.Invariant: SYLK получает кратчайший токен, читающийся обратно в то же double, DIF — инвариантный FloatToStr с добавленным .0 к целым числам, числовые поля dBASE — объявленное число десятичных, выравнивание вправо. Разбор тоже игнорирует локаль, так что файл с машины с десятичной запятой читается одинаково везде

Экспорт SYLK и DIF в HotXLS на Delphi: записи C;Y;X;K с заголовком ID;PHotXLS и булевым как 1, пары элементов тип,число DIF между BOT и EOD, а TFormatSettings.Invariant держит 1234.5 целым там, где десятичная запятая ломает DIF, SYLK и dBASE
Запись строки Orders как записей SYLK и пар элементов DIF показывает, где локаль с десятичной запятой исказила бы каждый формат, и почему HotXLS форматирует числа с инвариантными настройками

Читаем таблицы, написанные другими программами

Открытие чужой таблицы переносит вопрос о кодовой странице на читающую машину. XlsReadDbfFile и TXLSXWorkbook.Open с именем .dbf принимают байты версий 0x03, 0x83, 0x8B, 0x30, 0x31 и 0x32 — таблицы dBASE III и IV и Visual FoxPro, включая бинарные типы FoxPro I (32-битное целое), B (double), Y (валюта с масштабом 10 000) и T (юлианский день плюс миллисекунды). Текст memo берётся из .dbt рядом с таблицей или из .fpt с блочной раскладкой FoxPro, когда .dbt нет; решает расширение, так что ни один спутник не прочитается вместо другого. Символьные данные декодируются в странице машины. Таблица dBASE III, записанная DOS-программой на странице 866 с нулём в байте 29, покажет на машине 1252 неправильные буквы. Когда производитель известен, гоняйте импорт там, где его кодовая страница — системная. SYLK, DIF и .prn следуют тому же правилу: Open декодирует их байты в странице машины до того, как парсер широкого текста их увидит

Какой формат везёт не-ASCII текст между локалями?

При свободном выборе — ни один из этих четырёх. Используйте их, когда принимающая система настаивает — экран импорта ERP, xBase-приложение, ГИС-конвейер, — и когда данные ASCII или оба конца делят одну кодовую страницу. Всё остальное должно ездить в формате, объявляющем кодировку: XLSX, SpreadsheetML 2003 или CSV, который HotXLS пишет как UTF-8 с byte order mark (у экспорта CSV и TSV свои подводные камни транспорта). Поскольку SaveAs выбирает формат по расширению, ваш код обязан уметь оба стиля отказа с первой же таблицы: исключение для .dbf, результат -1 для текстовых форматов

uses
  SysUtils, lxHandleX, lxDbf;

function SaveForLegacyImport(const BaseName: string): string;
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Orders');   // первый добавленный лист становится активным
    Sheet.Cells[1, 1].Value := 'CUSTNO';
    Sheet.Cells[1, 2].Value := 'CITY';
    Sheet.Cells[2, 1].Value := 1001;
    Sheet.Cells[2, 2].Value := WideString(#$5317#$4EAC);  // 北京

    Result := BaseName + '.slk';
    if Book.SaveAs(Result) <> 1 then
    begin
      // Активная страница не вмещает текст: .slk не создан
      Result := BaseName + '.xlsx';
      Book.SaveAs(Result);
    end;

    try
      Book.SaveAs(BaseName + '.dbf');
    except
      on E: EXlsDbf do
        Result := Result + ' (no .dbf: ' + E.Message + ')';
    end;
  finally
    Book.Free;
  end;
end;

На хосте 936 эта функция выдаёт .slk и .dbf, где в обоих лежит 北京; на хосте 1252 она выдаёт .xlsx и сообщает, почему .dbf был отвергнут. Имена записей RTF, HTML и ZIP в компоненте электронных таблиц HotXLS для Delphi следуют собственным правилам кодирования