Китайские названия городов, экспортированные в .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, на каждой машине с другой страницей превращается в другие байты
| Байты | Содержимое 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
Записи 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 — объявленное число десятичных, выравнивание вправо. Разбор тоже игнорирует локаль, так что файл с машины с десятичной запятой читается одинаково везде
Читаем таблицы, написанные другими программами
Открытие чужой таблицы переносит вопрос о кодовой странице на читающую машину. 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 следуют собственным правилам кодирования