Formats¶
Which kind of file it is comes from its bytes, never its name. What each kind reads as — and, at the end, what each format holds when written:
An OpenDocument cell says its own type¶
A workbook guesses dates from number formats; a .ods cell states its type beside its value, so
there is nothing to guess. float, percentage and currency read as numbers — a value that is no
finite number, such as NaN or 1e400, reads as the cell's text, as an xlsx one does —, date as a date,
time as the workbook serial of as many days — a time of day on 31 December 1899, the day an xlsx
time-only cell reads on, and a longer duration on the day that serial names; a spreadsheet that
states another null date in its calculation settings (LibreOffice offers 1 January 1904) has its
durations counted from that day — and boolean as a
boolean. Anything else is text: the cell's office:string-value when it has one, else its paragraphs
joined by a line feed, comments left out. A formula reads as the value the writer cached. ODF has no
error type; LibreOffice marks a failed formula in an extension attribute, and it reads as an error
carrying the text the cell shows — #N/A, #REF!, or LibreOffice's own Err:502.
A row or cell repeated by attribute is expanded only when it holds a value; the million empty rows LibreOffice declares after the last one cost nothing. Covered cells of a merge read as empty. Hidden sheets and rows read like any other, as in xlsx. A sheet is a table of the spreadsheet itself: a sub-table inside a cell, or the table a DDE link caches, is not one, and its text is not the cell's.
A sheet says whether it is hidden¶
SheetInfo.Visibility, and SheetProfile.Visibility beside it, say whether the author left a sheet
showing: Visible, Hidden, or VeryHidden (an xlsx sheet only code can show again). An xlsx sheet's
comes from its state, an ods table's from its style's table:display; a csv's single sheet is
always visible. Hidden sheets are still listed and read — lookup lists, instructions and calculations
in a template are data too — so a screen offering sheets, or an upload picking one, decides what to
do with them. A chart sheet, macro sheet or dialog sheet holds no cells and is not listed at all.
An archive reads as one workbook¶
A zip that is not itself a workbook, a tar, or a tar compressed with gzip is read as one workbook: its sheets are the sheets of every file in it
that can be read as a table — csv, xlsx and ods — in the order of their paths, each with its file's
path as SheetInfo.Source and its own Format. A csv file is named after its file; a workbook's
sheets keep their names, and Source tells two Sheet1 apart. Files are judged by their bytes, as a
file on its own is.
- Left out without a word: directories, links and other entries that are not files, hidden files
and folders (
.DS_Store, anything whose name starts with a dot, macOS._files) and__MACOSX/. - Skipped with a reason in
FileProfile.SkippedEntries: an encrypted file, a gzip-compressed file, a nested zip or tar, another OpenDocument type, a legacy.xls, an XML document, a binary file, and a workbook that is damaged or of a kind not read (.xlsb). Nested archives are not opened. A file whose path repeats an earlier one's is skipped too (DuplicatePath): its sheets could not be told apart from the first's, which is read. - Refused: an archive with nothing readable in it, as
format.unsupported.
A csv file is read as a stream straight out of the archive, never unpacked, with its dialect
decided from its own head; moving back to it reads it again from its first row. A workbook has to be
read with random access, so it is copied into memory while its sheets are read — one at a time, up to
ArchiveCursorOptions.MaxEmbeddedWorkbookBytes (256 MB by default, and any size a server can
afford: the copy is held in pieces, not one array). A mapping plan made from an archive records the
sheet's Source, and an import refuses the archive as structure.sheet-changed when another file now
stands at the plan's index.
A tar reads the same way, plain or compressed with gzip (.tar.gz, .tgz): FileProfile.Format is
Tar for both, Source is the entry's path as written, long paths (PAX and GNU) included. A plain
tar's files are read where they lie, workbooks included — nothing is copied. A compressed tar can only
be read from its start: opening it decompresses it once to list its sheets, and reading a sheet
decompresses it again up to that file, unless the cursor is moving forward through the archive
anyway — so reading the sheets in the order the archive stores them costs one pass. A tar cut off or
damaged anywhere, or missing the zero block that ends it, is refused as format.truncated or
format.corrupt; a tar holding a GNU sparse file, which cannot be read past, as format.unsupported.
Old v7 tars, which carry no magic, are not recognised.
A gzip file reads as the file inside it¶
A file compressed with gzip — data.csv.gz, report.xlsx.gz, table.ods.gz — is read as the file
it holds: a csv file's one sheet, or a workbook's sheets. FileProfile.Format is Gzip; each sheet
keeps the inner file's Format. The sheet's Source is the file name the gzip header stores (the
gzip tool stores it; .NET's GZipStream does not), and null when it stores none — so a mapping
plan does not depend on the name a caller passes. A csv sheet is named after that stored name, or
after the file without its .gz.
A csv file is decompressed as it is read, never unpacked; moving back to its sheet decompresses it
again. A workbook is decompressed into memory once, up to ArchiveCursorOptions.MaxEmbeddedWorkbookBytes.
What a file expands to is bounded by ArchiveCursorOptions.MaxUncompressedBytes, counted while
decompressing.
Every gzip member's checksum and size are checked, so a file cut off or damaged anywhere is refused
as format.truncated or format.corrupt — never read as a shorter file, which is what .NET's own
GZipStream does with a cut-off one. A file of several members (cat a.gz b.gz, bgzip) reads as one
file; bytes after the last member that do not start another are ignored, as the gzip tool does.
Not read: a zip archive inside a gzip file, a file compressed twice, and a gzip file inside a zip
archive — skipped there as Compressed.
A csv file's dialect is detected, or stated¶
A csv file's encoding and delimiter are decided from its head (CsvCursorOptions.DialectProbeBytes,
64 KB): the encoding from a byte order mark, else valid UTF-8, else Windows-1252; the delimiter as the
one of , ; tab | that divides the first records the same way. Where a caller knows better, it
states any part of it in CsvCursorOptions, each on its own — Delimiter, Encoding and Quote
(otherwise "):
TabularOpenOptions options = new() { Csv = new CsvCursorOptions { Delimiter = ';' } };
using ITabularCursor cursor = TabularFile.Open(stream, name, options);
What is stated wins, and the sheet's Dialect reports it as DialectSource.Specified; the rest is
still detected and reported as Detected, ByteOrderMark or Fallback. A stated encoding wins over
a byte order mark as well: a mark of that encoding is skipped, never read as content, and the mark of
another encoding is read as the characters its bytes are in the stated one. Stating the dialect does not
switch off the checks that refuse a file that is not csv at all (a legacy .xls, an XML document, a
binary file): it is still refused as format.unsupported. The same options apply to
every csv file in an archive and to a gzip-compressed one.
Malformed input is repaired, and the repair is counted¶
Files that people upload are not well-formed. A 572 MB real-world export carries quotes inside
unquoted fields (100 21"st AVE), text after a closing quote ("C" Road), and 302 quotes that
are never closed. Refusing such a file is defensible and useless; one bad address must not fail an
import of five million rows.
So the reader recovers — and counts what it recovered in CursorDiagnostics. A silent recovery is
indistinguishable from correct reading, and that is the defect: left unbounded, those 302 quotes
swallow about 39,000 records without a word.
Two repairs, both counted. A quoted field that has crossed a line ending and holds a whole record's
worth of delimiters was never a quote — a lone " that opened a field and swallowed the records
after it — and those records are read as records the moment that is clear (RecoveredStrayQuotes).
Past a line ending a quote also closes a field only where a field can end, so an inch mark in the
swallowed text (135"th) does not close it. Behind that, a quoted field longer than 100 lines, or
one the file ends inside, is abandoned the same way (RecoveredUnterminatedQuotes); that bound is
what protects tables narrower than five columns, where the delimiter test is off. Genuine multi-line
values — an address, a long note — hold a delimiter or two at most and are left alone.
What each format holds when written¶
A value a format cannot hold exactly is refused with a TabularWriteException that names the sheet,
row and column; nothing is rounded or cut silently. The rules are the same for every format wherever
they can be, so the same data fails the same way whichever format is chosen. See Exporting.
| csv | zip of csv | xlsx | ods | |
|---|---|---|---|---|
| Sheets | one, its name not written | any number, each <name>.csv |
any number | any number |
| Rows per sheet, header included | 2,147,483,647 (the largest row number the reader gives) | the same | 1,048,576 | 1,048,576 |
| Columns per sheet | 1 to 16,384 | 1 to 16,384 | 1 to 16,384 | 1 to 16,384 |
| Styles, layout, widths | ignored | ignored | yes | yes |
| Distinct styles per file | 4,096 | 4,096 | ||
| Merged ranges per sheet | written as the value and empty fields | the same | 65,536 | no limit |
| Text per cell | 16 M chars (the reader's field limit) | 16 M chars | 32,767 chars | 16 M chars, and the escaped text within the reader's token limit |
| Line breaks in one text | 100 (the reader's quoted-field limit) | 100 | no limit | no limit |
| Dates | any DateTime, ISO or the culture's pattern |
the same | from 1900-01-01 | any |
| Numbers | as written, in the culture | as written, in the culture | a double cell: a long or decimal only if a double holds it exactly (at most 15 significant digits for a decimal) | the same as xlsx |
What holds for all four:
- A
doublemust be finite and within 15 significant digits (write.not-finite,write.precision-loss): that is what reads back as itself. - Time is kept to whole milliseconds; a
DateTime'sKindis not kept; aDateOnlyreads back as aDateTimeat midnight. - Text cannot hold the control characters XML 1.0 forbids (all but tab, line feed and carriage return)
or an unpaired surrogate (
write.invalid-character), csv included. - A sheet's name is 1 to 31 characters, none of
[ ] : * ? / \, no apostrophe at either end, notHistory, unique ignoring case; a zip sheet's name may not hold< > " |or end with a dot or a space. A header is not empty, neither starts nor ends with whitespace, and is unique in its sheet ignoring case. - Csv: a quoted text whose line breaks the reader would take for a stray quote is refused
(
write.ambiguous-line-breaks) rather than written to be read back as several records. - Csv's culture also picks the delimiter when none is set (
;where the decimal separator is a comma), and a culture is accepted only if its numbers and dates read back through the import's own reader. - Other programs may show a value differently from the one stored (an xlsx date before 1900-03-01, a date before 1582-10-15, a number of more than 15 digits after a re-save). The stored value is unchanged; see Known issues.