The implementation spec of a pure-Swift library that reads and writes the three spreadsheet format families — XLSX / XLSM, ODS and Apple Numbers — through a single format-neutral model. It records every design decision needed to combine the public standards (ECMA-376 / ISO IEC 26300) with reverse-engineered knowledge (the IWA format) while keeping the licence MIT.
This chapter defines what the library does not do with the same weight as what it does. The licence boundary in particular is fixed here as an implementation rule, and every chapter that follows assumes it.
Provide a pure-Swift spreadsheet I/O library that runs on Apple platforms (iOS / macOS / visionOS) and on Linux. The existing Swift ecosystem is split between "read only" (CoreXLSX) and "write only" (SwiftXLSX and others); nothing offers, under a single API, round-trip editing — open an existing file, edit it, and save it with its formatting intact — together with conversion between several formats. This library fills that gap.
| Format | Read | Create new | Edit existing (with preservation) | Notes |
|---|---|---|---|---|
| XLSX | ◎ Full | ◎ Full | ◎ Full | The reference format. Quality is guaranteed here first |
| XLSM | ◎ Full | ○ Supported | ◎ VBA kept | Macros are only kept as opaque data; never executed or interpreted |
| ODS | ◎ Full | ◎ Full | ○ Supported | ODF 1.3 conformant. One wing of xlsx⇄ods conversion |
| Numbers | ◎ Supported | ◎ Supported | ○ Supported | An undocumented format, so anything it cannot express is always reported as a warning (Chapter 11) |
| CSV | ◎ Supported | ◎ Supported | — (no such concept) | Values only (F1). UTF-8 with or without BOM is detected automatically; an encoding can be specified (Chapter 9) |
SheetFormulaEval (see Chapter 5). v1 goes as far as parsing formulas and converting dialectsSheetDecrypt (decryption) and SheetEncrypt (encryption) take this on, and the core refuses an encrypted file by name (Rev 4.29, B.39.9). From 0.12.0 to 0.16.1 the core opened them through ReadOptions.passwordThis library is published under the MIT licence. To make that hold, the following are fixed as development rules.
The name is SwiftSheets (the working name at drafting time was SwiftSheets). Following Apple's framework
naming (a countable subject takes the plural, as in Charts, Contacts, Photos) and the Swift package convention
(swift-collections → Collections), the plural was settled on 2026-08-22 and the package was published on
GitHub the same day as nanbu/SwiftSheets.
The library has four layers. The upper two (the public API and the format-neutral model) know nothing about formats; only the lower two (the codecs and the foundation) know each format's quirks. This separation supports both the three formats of today and the formats added later.
| Layer | Responsibility | Forbidden |
|---|---|---|
| L4 Public API | Sugar that is easy for a human to write. Format detection and conversion API | Any format-specific branching |
| L3 Format-neutral model | Semantic storage of values, styles, formulas and structure | Holding "representation" such as sharedStrings indices or XML fragments |
| L2 Codecs | Two-way projection between the model and each format. Generating the warnings for losses | Depending on another codec |
| L1 Foundation | Mechanical handling of ZIP, XML, Snappy and Protobuf | Knowing what a spreadsheet means |
Package.swift (excerpt)// Split finely enough that a user can import only the codecs they need
let package = Package(
name: "SwiftSheets",
products: [
.library(name: "SheetCore", targets: ["SheetCore"]), // model + abstractions
.library(name: "SheetXLSX", targets: ["SheetXLSX"]), // xlsx/xlsm
.library(name: "SheetODS", targets: ["SheetODS"]),
.library(name: "SheetNumbers", targets: ["SheetNumbers"]),
.library(name: "SwiftSheets", targets: ["SwiftSheets"]), // everything
],
dependencies: [
.package(url: "…/ZIPFoundation", from: "0.9.0"),
.package(url: "…/swift-protobuf", from: "1.25.0"), // SheetNumbers only
]
)
SheetCore and SheetFormula have zero dependencies, SheetXLSX / SheetODS
depend only on ZIPFoundation, and SwiftProtobuf is confined to SheetNumbers. A user who does not need
Numbers is not made to carry the Protobuf runtime.
The five entry points — open, query before reading, streaming read, streaming write and convert — belong to
CodecSet in SheetCore (a table of codecs keyed by SheetFormat); the all-in-one
SwiftSheets holds a single CodecSet.all with the 5 codecs plugged in and merely delegates the
old names to it. A user who links only the products they need can build their own set and use the same entry points.
A format absent from the table is refused by naming the format and the product that adds it.
The model is not the greatest common divisor of the three formats but "their union, normalised by meaning". The key design point is the Sheet = [Table] structure, which absorbs the Numbers canvas model from the start.
Sources/SheetCore/Model.swift (outline)public struct Workbook: Sendable {
public var sheets: [Sheet]
public var definedNames: [DefinedName]
package var preserved: PreservationStore // Chapter 6: preservation of unknown elements
}
public struct Sheet: Sendable {
public var name: String
public var tables: [Table] // always 1 element when read from XLSX/ODS
}
public struct Table: Sendable {
public var name: String?
public var anchor: CellRef // top-left position on the sheet (default A1)
public var cells: [CellRef: Cell] // sparse. Empty cells are not stored
public var columnWidths: [Int: Double]
public var merges: [CellRange]
}
public enum CellValue: Sendable, Equatable {
case text(String)
case number(Decimal) // keeps Numbers' decimal128 precision
case date(Date) // serial values are converted at the exit
case bool(Bool)
case formula(FormulaExpr, cached: String?) // AST + last computed value
case error(String) // "#N/A" etc.
case empty // for a cell that carries only a style
}
| Value kind | Internal representation | Design rationale |
|---|---|---|
| Number | Decimal | Numbers stores decimal128. Going through Double loses precision on a round trip |
| Date | Date (+ the display format in CellStyle) | XLSX serial values (1900 and 1904 systems), ODS ISO 8601 and the Numbers epoch are pushed into the exit conversion |
| Formula | Canonical AST (Chapter 5) | Dialects (separators, reference notation) are representation, not meaning |
| Coordinates | CellRef(row:column:) with the on-screen numbers (1-based) (Rev 4.50, B.61. Before that 0-based, and col: until Rev 4.49) | Conversion to and from A1 notation is isolated in a utility. Bijective base-26 is pinned down by unit tests |
Do not bring a "format's internal representation" — sharedStringIndex, styleIndex, XML
fragments, serial date values and the like — into the format-neutral model. Indexing and serialisation are all
write-time work of each codec. If this line is crossed, support for three formats effectively collapses.
This chapter defines the common contract the three codecs follow. The contract has four parts — read, write, detect and report losses — and the return value of a write is designed to always include the warnings.
Sources/SheetCore/Codec.swiftpackage protocol SpreadsheetCodec { // package since B.50. The format is chosen through the public Codec value
static var format: SheetFormat { get } // .xlsx / .xlsm / .ods / .numbers / .csv
static func canDecode(_ container: ZipInspection) -> Bool
static func read(_ data: Data, options: ReadOptions) throws -> Workbook
static func write(_ wb: Workbook, options: WriteOptions) throws -> WriteResult
}
public struct WriteResult: Sendable {
public let data: Data
public let warnings: [ConversionWarning] // losses are never dropped in silence (Chapter 6)
}
public struct ConversionWarning: Sendable {
public enum Kind { case dropped, degraded, substituted, truncated }
public let kind: Kind
public let location: CellRef?
public let message: String // e.g. "STYLE() at Sheet1!C3 cannot be expressed in XLSX"
}
XLSX / ODS / Numbers are all ZIP containers (starting with PK\x03\x04), so the format is detected not
from the extension but from which entries actually exist inside the archive. The order of detection is fixed.
mimetype entry exists and its content is application/vnd.oasis.opendocument.spreadsheet → ODS[Content_Types].xml exists → OOXML. If the ContentType of the workbook part is
…sheet.macroEnabled… → XLSM, otherwise XLSXIndex/Document.iwa exists → Numbers (for the directory-bundle form, Index.zip is extracted first)PK\x03\x04 → run BOM detection and an encoding validity check; if it can be interpreted as text → CSV / TSV (the delimiter is sniffed; an extension, if present, takes priority — Chapter 9)SheetError.unrecognizedFormatThree rules added in Rev 4.14 (B.39.4).
① When detecting from a file, do not read the whole file (SheetFormat.detect(contentsOf:)):
the first 4 bytes decide whether it is an envelope; if it is, only the directory at the end and mimetype /
[Content_Types].xml are read; if it is a text file, only the first 64 KiB. The amount read is the same
whatever the size of the file, and the file is neither mapped nor copied.
② A result type that answers in one call (SheetFormat.probe → FormatProbe):
the format, or the reason it cannot be opened (encrypted OOXML / ODF / Numbers, legacy .xls), or unknown.
③ A Numbers document in folder form (a directory holding Index.zip or Index/) is
detected as .numbers, and Workbook(contentsOf:) / inspect(contentsOf:) open it as is.
Deciding whether a file is text does not decode it into a string: the raw bytes are checked for UTF-8 / UTF-16 validity and control characters.
public enum SheetError: Error {
case unrecognizedFormat
case corruptedContainer(detail: String) // failure in the ZIP/Snappy layer
case malformedPart(path: String, detail: String)
case unopenable(UnopenableInput) // recognised but cannot be opened (encrypted, legacy .xls — B.52)
case noCodec(for: SheetFormat) // that CodecSet has no codec for it (B.52)
case unsupportedEncryption(detail: String) // password protection we do not handle (reading and writing — B.52)
case unsupportedFeature(String) // a limit of the format or environment itself (CSV holds one sheet, etc.)
case unsupportedVersion(found: String, supported: ClosedRange<Int>) // for Numbers
case formulaSyntax(offset: Int, detail: String)
case invalidWorkbook(String) // a model that cannot be written in the requested shape (0 sheets, etc.)
case sheetNotFound(name: String) // the sheet named does not exist (editSheet — Rev 4.0, B.30)
case ioFailure(detail: String) // failure of file I/O itself
case wrongPassword // the password given does not open it (Rev 4.28, B.39.9)
}
Workbook(contentsOf:) is a thin layer that only detects the format and calls the matching codec's read.
The dispatch is not a per-format switch but the table CodecSet, keyed by SheetFormat
(SheetCore, Rev 4.32, B.44); the all-in-one product delegates to CodecSet.all with the 5 codecs plugged in.
A format absent from the table is refused with noCodec(for:), naming the format and the product that adds it (Rev 4.41, B.52; formerly unsupportedFeature).
Writing is explicit by default through wb.write(to:as:); inferring the format from the extension is offered as a convenience.
Writing to a format other than the one read is conversion, and needs no extra implementation (the format-neutral model mediates).
Carrying formulas around as strings breaks down at dialect conversion. The responsibility of v1 ends at "parse, hold a canonical AST, and write it back in each dialect at the exit". Evaluation (recalculation) is left to a separate package in v2.
| Item | XLSX | ODS (OpenFormula) | Handling in the canonical form (AST) |
|---|---|---|---|
| Argument separator | , | ; | An array in the AST. Chosen at the exit |
| Cell reference | A1 / Sheet2!A1 | [.A1] / [Sheet2.A1] | CellRef (the on-screen numbers) + sheet name |
| Unary minus | -2^2 = 4 (- binds tighter than ^) | Shared through the parser's precedence table | |
| Formula prefix | = (absent inside the XML) | of:= | Not part of the AST. Added at the exit |
| Function names | Mostly shared. Format-specific functions cannot be converted | Held under the canonical name (upper-case). Unconvertible ones become ConversionWarning.dropped | |
public indirect enum FormulaExpr: Sendable, Equatable {
case number(Decimal)
case string(String)
case boolean(Bool)
case ref(CellRef, sheet: String?, absRow: Bool, absCol: Bool)
case range(FormulaExpr, FormulaExpr) // binds two references together
case unary(FormulaOp, FormulaExpr)
case binary(FormulaOp, FormulaExpr, FormulaExpr)
case call(name: String, args: [FormulaExpr])
case unparsed(String, dialect: SheetFormat) // fallback when parsing fails (see below)
}
A formula that cannot be parsed on read is not an error; it is kept as .unparsed(original text, dialect).
When writing back to the same dialect the original text can be emitted as is, so the round trip does not break.
Only when converting to another dialect does it become ConversionWarning.degraded (replaced by the cached value).
The v2 evaluator is implemented in stages along the conformance levels the OpenFormula specification defines
(Small → Medium → Large Group Evaluator). The test cases bundled with the specification can be reused directly
as regression tests, so the implementation order also follows the specification's function list. Topological
sorting of the dependency graph and circular-reference detection (the #REF! family of errors) are
the evaluator's responsibility and are not part of the v1 code base.
A cross-cutting policy so that "I opened the file, fixed one cell, saved, and the chart was gone" never happens. Where openpyxl is designed to discard unknown elements, this library preserves them by default, and that is its point of difference.
| Level | What is guaranteed | Where it applies |
|---|---|---|
| F1 value fidelity | Cell values, formulas and sheet structure are equivalent | The minimum guarantee for conversion between any two formats |
| F2 format fidelity | F1 + fonts, borders, fills, number formats, merges, column widths | The target level for xlsx⇄ods conversion |
| F3 full preservation | F2 + unsupported features (charts, pivots, VBA, and so on) kept without degradation | Required for saving over a file in the same format |
The preservation structure stays inside the package. A user reads the original format, the number of opaque parts and whether VBA is present from Workbook.preservationSummary,
and tells grid, unread and non-grid apart through Sheet.contentState (Appendix B.46). Direct editing of raw parts, XML or relationship IDs is not part of the public API.
ConversionWarning.droppedIf the relationships (rId) an opaque part refers to are renumbered when an interpreted part is regenerated, the references break. Existing rIds are immutable, and new elements are numbered from the maximum + 1 — a rule shared by every codec.
The reference format. It targets SpreadsheetML from ECMA-376 (OOXML), and the acceptance criterion is "Excel opens it without showing the repair dialog". XLSM is specified only as its difference from XLSX.
OPC package structurebook.xlsx (ZIP)
├── [Content_Types].xml ← MIME declaration of every part. A missing declaration = a file that will not open
├── _rels/.rels ← root relationships. The entry point to the workbook
└── xl/
├── workbook.xml ← sheet list, definedNames, the date1904 flag
├── _rels/workbook.xml.rels
├── sharedStrings.xml ← the string pool (built at write time)
├── styles.xml ← numFmt/font/fill/border → indirect references through cellXfs
├── worksheets/sheet1.xml ← the cells themselves
└── vbaProject.bin ← XLSM only. Kept as an opaque part
t attribute: s (shared-string index) / inlineStr / b / str / e / unspecified (number). A date is recognised by its number format, not by its type (a numFmt of the date family)workbookPr/@date1904 is always consulted to switch the epoch. On the 1900 system the Lotus-compatible "1900 leap-year bug" (the non-existent 1900-02-29 = serial value 60) is reproduced as specified behaviour<f> is turned into an AST and <v> is stored as the cached value in .formula(ast, cached:)PreservationStore (Chapter 6)inlineStr (to keep the pool from bloating)r attribute, each cell's A1 reference and the dimension are recomputed from the actual data| Item | Rule |
|---|---|
| Detection | The ContentType of the workbook part is …ms-excel.sheet.macroEnabled.main+xml |
| VBA | xl/vbaProject.bin is kept as an opaque part. No API to interpret, run or modify it is provided (a security boundary) |
| Saving xlsm → xlsm | vbaProject.bin and its rels are repackaged without degradation (F3) |
| Saving xlsm → xlsx | The VBA is discarded and the ContentType is swapped for the ordinary one. ConversionWarning.dropped is always issued |
| Saving xlsx → xlsm | Legal with only the ContentType swapped (an xlsm without VBA is valid) |
(1) A missing Content_Types declaration, (2) a broken rId reference in the rels, (3) a mistake in the bijective base-26 conversion that generates A1 references. These three account for most corruption reports. All of them are detected mechanically by the verification harness of Chapter 12.
Conforms to ODF 1.3 (the ISO/IEC 26300 series). The structure is more straightforward than XLSX, but get either of the two ODS-specific points wrong — the ZIP packaging rule and RLE compression — and everything breaks.
book.ods (ZIP)
├── mimetype ← uncompressed, must be the first entry (8.2)
├── META-INF/manifest.xml ← media-type registration of every file
├── content.xml ← the cells and automatic styles of every sheet, in a single file
├── styles.xml ← named styles, page layouts
├── meta.xml
└── settings.xml
The mimetype entry is placed uncompressed (Stored) and first in the archive.
LibreOffice detects the format from the leading bytes without unpacking the ZIP, so writing it with deflate
yields "a file that is a valid ZIP yet will not open".
SheetODS/Writer.swift (the essentials)let mime = Data("application/vnd.oasis.opendocument.spreadsheet".utf8)
try archive.addEntry(with: "mimetype", type: .file,
uncompressedSize: Int64(mime.count),
compressionMethod: .none) { pos, size in // ← .none is the key
mime.subdata(in: Int(pos)..<Int(pos) + size)
}
// the remaining entries may use deflate
table:number-columns-repeated / table:number-rows-repeated are expanded. However, huge repeats of empty cells (end-of-row padding, the 16,384 limit and the like) are cut off rather than expanded. Expanding them makes memory explodeoffice:value-type + the type-specific attribute (office:value / office:date-value and so on), with the display string in a child <text:p>. Dates are ISO 8601, so no serial-value conversion is neededtable:formula="of:=…". It is emitted from the AST in the ODS dialect ([.A1] references, ; separator) (Chapter 5)automatic-styles inside content.xml and referenced from the cell by table:style-name
The acceptance criterion is "LibreOffice opens it without a warning and soffice --headless --convert-to xlsx succeeds".
Headless conversion can be built into CI (Chapter 12). Excel's ODS implementation has its own dialect, so
ODS files generated by Excel are included in the test corpus to make the reader robust to them.
The minimal interchange format. The structure takes RFC 4180 as the baseline and accepts real-world dialects. The core of this chapter is the character-encoding policy, and the principle shared by reading and writing is "UTF-8 unless specified (BOM presence detected automatically); any encoding can be stated explicitly when needed".
Workbook with one sheet and one table. Formatting, formula structure and merges do not exist in the format, so fidelity is F1 by definition (Chapter 6)ConversionWarning.dropped (the target sheet can be chosen through an option). If that sheet has several tables (as Numbers files do), the number of tables that were not written is reported in the same way (Appendix B.12)| Situation | Unspecified (default) | Explicitly specified |
|---|---|---|
| Reading | The leading bytes are inspected. A UTF-8 BOM (EF BB BF) present → strip the BOM and read as UTF-8. No BOM → read as UTF-8. Either way, with or without a BOM, the file reads correctly with no extra setting. If a UTF-16 BOM (FF FE / FE FF) is detected, read as UTF-16 |
Pass a String.Encoding to CSVReadOptions(encoding:) (.shiftJIS, .japaneseEUC, .utf16LittleEndian and so on). When specified, BOM auto-detection is not performed (though a leading BOM is stripped when .utf8 is specified) |
| Read failure | A byte sequence that is invalid in the specified (or default) encoding is not turned into mojibake in silence; it fails with SheetError.malformedPart (with the byte offset). Only when lossy: true is given does reading continue with replacement characters and return a degraded warning | |
| Writing | UTF-8, without a BOM | CSVWriteOptions(encoding:includesBOM:). includesBOM: true adds a UTF-8 BOM. Any encoding can be given in encoding (adding a BOM takes effect only for the UTF-8 / UTF-16 family) |
| Write failure | A character that cannot be represented in the specified encoding (for example one that does not exist in Shift_JIS) is an error by default. With lossy: true it is replaced and a degraded warning (with the cell position) is returned | |
Double-clicking a UTF-8 CSV without a BOM in a Japanese-locale Excel produces mojibake (Excel
interprets it in a legacy encoding). If the file is handed to people to open in Excel, use includesBOM: true;
for system-to-system exchange or programmatic import, no BOM — that is the rule of thumb. This guidance is also stated in the API documentation.
| Item | Reading | Writing |
|---|---|---|
| Delimiter | Default ,. When unspecified, , / ; / tab are sniffed from the first line (the .tsv extension prefers tab) | Default ,. Changed through CSVDialect(delimiter:) (TSV and so on) |
| Quote character | ". Doubling ("") is interpreted as the escape. Line breaks and delimiters inside quotes are preserved | A field containing a delimiter, a quote character or a line break is always quoted, and quote characters are doubled |
| Line breaks | CRLF / LF / CR are all accepted (mixed is fine too) | Default CRLF (RFC 4180). .lf can be specified |
The Excel extension sep=; | If present on the first line it is interpreted as the delimiter setting and not included in the data | Not written |
| Empty lines | Kept as empty records (except the final trailing line break) | Empty-line semantics are maintained |
.text for every field. No implicit type inference — the two classic CSV accidents, "01234" (a postal code) becoming a number and losing its leading zeros, and "1-2" becoming a date, are ruled out at the specification levelinferTypes: true is given are numbers, booleans and dates (formats given through dateFormats) inferred into typed CellValues., no thousands separator), dates default to ISO 8601 (dateFormat can be specified), booleans are TRUE / FALSE, and a formula cell writes its cached value (or, if there is none, the formula string plus a degraded warning)public struct CSVReadOptions: Sendable {
public var encoding: String.Encoding? // nil = UTF-8 (BOM presence detected automatically)
public var dialect: CSVDialect? // nil = sniffing
public var inferTypes: Bool = false
public var dateFormats: [String] = []
public var lossy: Bool = false
}
public struct CSVWriteOptions: Sendable {
public var encoding: String.Encoding = .utf8
public var includesBOM: Bool = false // true is recommended for files distributed to Excel users
public var dialect: CSVDialect = .comma
public var newline: CSVNewline = .crlf
public var sheet: String? // nil = the active sheet
public var lossy: Bool = false
}
// Default: reads with or without a BOM, no need to care
let wb = try Workbook(contentsOf: URL(filePath: "customers.csv"))
// Read a legacy Shift_JIS CSV with an explicit encoding
let legacy = try Workbook(
contentsOf: url,
options: .init(csv: .init(encoding: .shiftJIS))
)
// Write UTF-8 with a BOM so Excel opens it directly
try wb.write(to: outURL, as: .csv,
options: .init(csv: .init(includesBOM: true)))
The only group of chapters that deals with the undocumented IWA format. Reading is a reimplementation of knowledge already reverse-engineered, and is technically established. The centre of the design is "a structure that lets version tracking be run as routine maintenance".
protoc + protoc-gen-swift and commit the generated files to the repository (not generated at build time — for reproducibility and to avoid a dependency on protoc)Index.zip is treated as the substanceDecimal without lossSheet.tables (the model of Chapter 3 accommodates them)Apple does not guarantee compatibility of the format. Therefore (1) the README states the verified range of Numbers versions,
(2) a file outside that range does not raise SheetError.unsupportedVersion but is read as far as possible and then a warning is returned,
and (3) the procedure for a new Numbers release — regenerate the corpus → replace the protos → run the regression tests — is
documented as MAINTENANCE.md. As an indicator for read failures, the generating version from
Metadata/BuildVersionHistory.plist is included in the error information.
Numbers is supported on the same footing as the other formats. The v1 writing feature set (values, formulas, formatting, structure) is fully supported for Numbers too, and whatever cannot be expressed because the format is undocumented is reported, without omission, through the warning mechanism shared by all formats. The acceptance bar is "Numbers opens the file without a warning and editing can continue as it is".
Rather than building the object graph from nothing, we bundle a minimal empty document created in Numbers as a template resource and swap out its graph. Because the template already holds the document-wide consistency (settings, theme, the roots of style inheritance), the implementation only touches the subgraph around the tables.
degraded warningpreview.jpg reuses the template's generic image| Feature | Support | Behaviour when unsupported |
|---|---|---|
| Cell values (string, number, date, boolean) | ◎ | — |
| Formulas | ◎ generated in the internal representation | A function that cannot be mapped: cached value plus a degraded warning |
| Formatting (font, fill, borders, alignment, number format) | ◎ the whole v1 formatting model | Differences of expression between formats: a degraded warning |
| Merged cells, column widths, row heights, multiple tables, frozen panes | ◎ | — |
| Charts, conditional highlighting, pivots, images | — not generated | For all formats alike, v1 leaves these outside the generation API (§14.11). Preserved on a read → write-back; a dropped warning on conversion |
Warnings and suggestion are not a device that singles out Numbers. If xlsx → ods has a
difference of expression, it is reported through the same mechanism. Still, since recipients frequently require
the Excel format, the suggestion that proposes an alternative format (XLSX and so on) when the
warnings pass a threshold continues to be returned.
"Supported — but any problem is always reported" is the contract common to every format.
The quality of a file-format library can only be measured by "does the real application open the file in silence". How far verification with the real application as the judge can be automated is the centre of the design.
Tests/Corpus/
├── xlsx/
│ ├── generated-excel/ ← files covering many features, saved by real Excel (Win/Mac)
│ ├── generated-libreoffice/ ← xlsx produced by LibreOffice (dialect tolerance)
│ └── malformed/ ← broken ZIPs, missing declarations, dangling references (verifying the error paths)
├── xlsm/ ← with VBA. For preservation checks
├── ods/ ← produced by LibreOffice + produced by Excel (dialect)
└── numbers/ ← per-version directories (v13/ v14/ …) for generation management
soffice --headless --convert-to, and the exit code and the converted output decide by machine "whether LibreOffice can open it"corruptedContainer)The project's first test is "open an existing file made in Excel, change one cell, save, read it again, and the other cells are untouched". This is the very reason this library exists, and it becomes the regression anchor for every change that follows.
Each phase defines a "shippable finished state". Every phase includes, among its acceptance conditions, not breaking what the previous phase delivered.
| Phase | Deliverable | Definition of Done |
|---|---|---|
| P1 | SheetCore + XLSX read/write + CSV | Excel opens a one-cell edit-and-save of an existing xlsx without a warning. F3 preservation works. The CSV character-encoding spec (Chapter 9) is met |
| P2 | XLSM support + formula AST | VBA-preserving round trip. The formula parse→emit fixed-point test passes |
| P3 | ODS read/write | The soffice headless verification CI is green. xlsx⇄ods conversion at the F2 level |
| P4 | Numbers reading | The whole corpus is read. Values agree with numbers-parser |
| P5 | Numbers writing | Numbers opens a file generated with the Chapter 11 feature set without a warning, and it is not corrupted after further editing. The warning API works |
| P6 | v1.0 release | Documentation, NOTICE and MAINTENANCE.md in order. SemVer operation begins |
| v2 | SheetFormulaEval | An OpenFormula Small Group Evaluator. Split off as a separate package |
| Risk | Impact | Response |
|---|---|---|
| An incompatible change to the Numbers format | P4/P5 deliverables go stale | Declare the supported versions and document the proto-replacement procedure (Chapter 10). Reading and writing both avoid total loss by partial success plus warnings |
| Dialects between OOXML implementations | Holes in reading tolerance | Grow the corpus by producing application. Even when repair is impossible, return a partial read |
| Scope creep | P1 never finishes | Fix each phase's completion conditions first (this chapter). Implementing abstractions ahead of need is limited to the model definition (Chapter 3) |
| Continuity of a one-person project | Maintenance stalls | The split into targets makes partial contributions and hand-overs possible. CI as the judge lowers the dependence on one person |
A correspondence table that redesigns the whole API surface of openpyxl in the Swift manner (value types, throws, typed values, warnings as first-class citizens). It is exhaustive enough that someone who knows openpyxl can look up "that operation is this one", and the ODS / Numbers-specific APIs are collected at the end.
Workbook is a struct. Editing changes a value; persisting is write(to:) only. No implicit side effectsWriteResult.warnings. No path drops anything in silencecell.value", reading yields the CellValue enum and assignment takes literal conversion (Int / String / Date / Bool / Formula)[row, column] are both 1-based and point at the same place (Rev 4.50, B.61. Until then only the integers were 0-based, and the next sentence is the decision of that era). Formerly: both an A1 string (1-based) and [row, col] (0-based) are provided. To prevent mix-ups, no 1-based variant of the integer form exists| openpyxl | SwiftSheets | Description |
|---|---|---|
load_workbook(path) | try Workbook(contentsOf: url) | Loading. The format is detected from the content (Chapter 4) |
| (no equivalent) | try SheetFormat.detect(contentsOf: url) / probe(contentsOf:) | Decides the format without reading the whole file. probe also returns, in one call, the reason a file cannot be opened (encrypted, old .xls) and "unknown" (Rev 4.14, B.39.4) |
| (no equivalent) | try Workbook.inspect(contentsOf: url) | Ask before reading: sheets, declared cell count, bytes after decompression, producing application (Rev 4.13, B.39.3). The material for choosing cellLimit |
| (no equivalent) | ReadOptions(concurrency:) | How many XLSX / XLSM sheets are read at the same time (Rev 4.23, B.41). nil = automatic (up to the core count when there are 2 or more sheets declaring 4 MiB or more), 1 = one at a time, n = at most n. The number read at the same time is the ceiling on the memory increase |
load_workbook(data_only=True) | try Workbook(contentsOf: url, options: .init(formulaCells: .cachedValues)) | Read formula cells as their computed values (B.54) |
load_workbook(read_only=True) | try StreamingReader(contentsOf: url) | Read row by row without building a workbook. The format is detected from the content and the call is the same for XLSX / XLSM, ODS, Numbers and CSV (Rev 4.22, B.40). forEachRow(inSheet:) or for try await row in rows(inSheet:). The second and later tables of a Numbers sheet via table: |
load_workbook(keep_vba=True) | (nothing to specify — kept by default) | VBA is always kept without degradation in the PreservationStore (Chapter 7) |
Workbook() | Workbook() | New workbook. Holds one empty sheet "Sheet1" |
wb.save(path) | try wb.write(to: url) | Format inferred from the extension. Returns WriteResult (an unused return value warns, B.47) |
| — | try wb.write(to: url, as: .ods) | Naming the format explicitly = a format conversion as it is |
| — | try wb.write(as: .xlsx).data | Get Data without going through a file (server use) |
wb.sheetnames | wb.sheets.map(\.name) | The list of sheet names |
wb.properties | wb.metadata | Document metadata such as author and title |
wb.defined_names | wb.definedNames | The dictionary of named ranges |
import SwiftSheets
var wb = try Workbook(contentsOf: URL(filePath: "budget.xlsx"))
print(wb.sheets.map(\.name)) // ["Summary", "Details", "Master"]
try wb.write(to: URL(filePath: "budget.ods")) // this alone is the xlsx → ods conversion
| openpyxl | SwiftSheets | Description |
|---|---|---|
wb['Sales'] | wb.sheets["Sales"] | Access by name. Returns Sheet? (an Optional, not a KeyError) |
wb.active | wb.activeSheet | The active sheet (the first by default) |
wb.create_sheet('X', 0) | wb.addSheet(named: "X", at: 0) | Create. Omit the position to append at the end. Returns the index of the created Sheet |
wb.remove(ws) | wb.removeSheet(named: "X") | Delete |
wb.copy_worksheet(ws) | wb.duplicateSheet(named: "X", as: "X2") | Duplicate (formatting and formulas included) |
| (no equivalent — a reference type has no write-back) | wb.editSheet(named: "X") { sheet in … } | Edit in one go. Applied when the closure returns; a throw part-way discards everything. An unknown name is SheetError.sheetNotFound (Rev 4.0, B.30) |
ws.title = 'New' | wb.sheets[0].name = "New" | Rename. Sheet references inside formulas follow via the AST |
ws.sheet_state = 'hidden' | sheet.isHidden = true | Hide |
ws.sheet_properties.tabColor | sheet.tabColor = Color(hex: "1072BA") | Tab colour. The same Color as every other colour (Rev 4.48, B.59) |
| (no equivalent) | sheet.tables | The key to Numbers support. All tables on the canvas (Chapter 3) |
| (implicitly one grid) | sheet.table | The default table. Always the single one if the file came from XLSX/ODS. Created if absent |
If you only handle XLSX / ODS, direct access through sheet (the cell API below is also
forwarded on Sheet) is all you need, and there is no reason to think about Table. You touch sheet.tables
only when handling the multiple tables of Numbers.
| openpyxl | SwiftSheets | Description |
|---|---|---|
ws['A1'].value | sheet["A1"] | Read. CellValue? (an empty cell is nil) |
ws['A1'] = 42 | sheet["A1"] = 42 | Assign. Accepts Int / Double / Decimal / String / Date / Bool / Formula / nil by literal conversion |
ws.cell(row=1, column=2) | sheet[1, 2] | The same numbers as on screen, like openpyxl (Rev 4.50, B.61). Only positions in Swift collections are 0-based |
ws['A1':'C3'] | sheet.range("A1:C3") | A range view (lazy, no copy) |
ws.iter_rows(values_only=True) | for row in sheet.rows(in: "A2:D100") | Row-by-row traversal. row is [CellValue?] |
ws.iter_cols(...) | sheet.columns(in:) | Column-by-column traversal |
ws.values | sheet.rows(in:) | A two-dimensional array of values only (the values(in:) alias went away in Rev 4.55) |
ws.append([...]) | sheet.append(["Alice", 30, Date()]) | Add one row after the last row |
cell.coordinate / row / column_letter | ref.address / ref.row / ref.columnName | Properties of CellRef |
ws.max_row / ws.max_column | sheet.extent | Returns the used range as CellRange? (nil when empty — avoiding the max_row=1 problem) |
cell.data_type | switch value { case .number: … } | The type is told apart by matching the enum cases |
// reading gives a typed enum, assignment takes a literal
sheet["A1"] = "Sales"
sheet["B1"] = 1_250_000
sheet["C1"] = Date()
if case .number(let n) = sheet["B1"] {
print("Amount: \(n)")
}
// convenience accessors for when you want to fix the type up front
let amount = sheet["B1"]?.numberValue // Decimal?
let title = sheet["A1"]?.stringValue // String? (a number is also stringified)
| openpyxl | SwiftSheets | Description |
|---|---|---|
ws.insert_rows(2, amount=3) | sheet.insertRows(at: 2, count: 3) | Insert rows (before the on-screen row number. Rev 4.50). References inside formulas are shifted automatically via the AST (openpyxl does not make formulas follow — a point where this library differs) |
ws.delete_rows(2) | sheet.deleteRows(at: 1, count: 1) | Delete rows. A dangling reference is replaced by the #REF! error value |
ws.insert_cols / delete_cols | sheet.insertColumns / deleteColumns | The column versions. Behaviour is symmetric with rows |
ws.merge_cells('A1:C1') | sheet.merge("A1:C1") | Merge cells |
ws.unmerge_cells('A1:C1') | sheet.unmerge("A1:C1") | Unmerge |
ws.merged_cells.ranges | sheet.merges | [CellRange] |
ws.column_dimensions['A'].width = 18 | sheet.setWidth(18, ofColumn: "A") | Column width (in character units. Projected to ODS by conversion to mm) |
ws.row_dimensions[1].height = 24 | sheet.setHeight(24, ofRow: 0) | Row height (pt) |
ws.freeze_panes = 'B2' | sheet.freezePanes = "B2" | Frozen panes |
ws.auto_filter.ref = 'A1:D1' | sheet.autoFilter = "A1:D100" | The auto-filter range |
| openpyxl | SwiftSheets | Description |
|---|---|---|
cell.font = Font(bold=True, size=14) | sheet.setStyle("A1") { $0.font.bold = true; $0.font.size = 14 } | Partial update through a closure. A range may be given |
PatternFill(fgColor='FFF2CC', fill_type='solid') | $0.fill = .solid("FFF2CC") | Fill. An RGB hex string |
Border(bottom=Side(style='thin')) | $0.border.bottom = .thin / $0.border = .all(.thin) | Borders. Both per side and all at once |
Alignment(horizontal='center', wrap_text=True) | $0.alignment = .init(horizontal: .center, wrap: true) | Alignment and wrapping |
cell.number_format = '#,##0' | $0.numberFormat = "#,##0" | Number format. The XLSX-style format code is canonical and is converted for ODS |
NamedStyle(name='header') | wb.namedStyles["header"] | Named styles (planned for v1.1. v1 only reads and keeps them) |
cell.style = 'header' | sheet.applyStyle(named: "header", to: "A1:D1") | Same as above |
// style a header row in one go
sheet.setStyle("A1:D1") {
$0.font.bold = true
$0.fill = .solid("1D1D1F")
$0.font.color = "FFFFFF"
$0.alignment = .init(horizontal: .center)
$0.border.bottom = .medium
}
sheet.setStyle("B2:B100") { $0.numberFormat = "#,##0" }
| openpyxl | SwiftSheets | Description |
|---|---|---|
cell.value = '=SUM(A1:B2)' | sheet["C1"] = .formula("=SUM(A1:B2)") | Turned into an AST on assignment. If it cannot be parsed it is set aside as .unparsed (Chapter 5) |
load_workbook(data_only=True) | ReadOptions(formulaCells: .cachedValues) | Read with the computed values (B.54) |
| (string only) | case .formula(let ast, let cached) | Both the AST and the cached value are always accessible |
| — | formula.rendered(as: .ods) | Dialect rendering (of:=SUM([.A1:.B2];[.C3])) |
openpyxl.formula.translate.Translator | (automatic) | Reference adjustment on row/column insertion and sheet renaming is done by the library itself (14.5) |
| openpyxl | SwiftSheets | Description |
|---|---|---|
wb.save('a.xlsx') | let result = try wb.write(to: url) | WriteResult (an unused return value warns, B.47) |
| — | result.warnings | [ConversionWarning]. The full record of losses (Chapters 4 and 6) |
| — | result.suggestion | For any format, returns a proposal of an alternative format when the degradation passes a threshold (Chapter 11) |
| — | try Workbook.convert(url, to: destination, as: .ods) | A one-shot read → write conversion shortcut |
| — | wb.preservationSummary | A summary of what is kept (source format, number of opaque parts, whether VBA is present) |
| API | Format | Description |
|---|---|---|
SheetFormat.detect(data) | Common | Content-based format detection (the detection rules of Chapter 4 as a public API) |
ODSWriteOptions(strict: true) | ODS | Conservative ODF 1.2-compatible output (for old implementations) |
sheet.tables[i].name / .anchor | Numbers | The name and position of a table on the canvas. Readable and writable |
sheet.addTable(named:anchor:) | Numbers | Add a table. When written to XLSX / ODS, only the first table survives and the rest become a dropped warning (Chapter 3, Appendix B.12) |
NumbersReadOptions(versionPolicy: .tolerant) | Numbers | Keep reading even an unverified version, and report it with a warning (Chapter 10) |
| (there is no flattening option) | Numbers→other formats | Writes the first table and says how many were dropped in a dropped warning. Why there is no API for choosing the rule: the table in Appendix B.12 |
CSVReadOptions(encoding: .shiftJIS) | CSV | Explicit character encoding. UTF-8 when unspecified (BOM presence detected automatically — Chapter 9) |
CSVWriteOptions(encoding:includesBOM:dialect:) | CSV | Default is UTF-8 without BOM. A BOM can be added for distribution to Excel |
CSVDialect(delimiter: "\t") | CSV | Dialect selection: TSV, semicolon-separated and so on |
wb.sourceInfo | Common | The source's format, producing application and version (for Numbers, from BuildVersionHistory) |
| openpyxl | SwiftSheets | Description |
|---|---|---|
get_column_letter(3) → 'C' | CellRef.columnName(3) → "C" | 1 = A (Rev 4.50). Bijective base-26 |
column_index_from_string('C') | ColumnIndex("C") → 2 | The inverse conversion |
coordinate_from_string('B3') | CellRef("B3") | A failable initializer |
| — | CellRange("A1:C3").contains(CellRef("B2")) | Range arithmetic |
| openpyxl feature | Treatment in v1 |
|---|---|
| Chart creation / image insertion | No read/write API; F3 preservation only (existing elements are kept without degradation, Chapter 6). What is read and written is data, not drawing (§1.3) |
| Conditional formatting | Reading and writing supported (Sheet.conditionalFormatting, Rev 2.0, B.15). Only rules that involve the <extLst> extension are written back as their original XML |
Differential formats (dxfs) and gradient fills | Read and write supported (DifferentialStyle, Fill.gradient, Rev 2.0, B.15) |
Data validation (DataValidation) | Read and write supported (Sheet.dataValidations; writing since Rev 1.8, B.13; reading since Rev 2.0, B.15). Only rules with attributes outside the model are written back as their original XML, and Sheet.hasUnmodelledValidations reports that |
| Comments | Read and write supported (VML included, Rev 1.7, B.12) |
Structured tables (ws.tables) | Read and write supported (Sheet.structuredTables, Rev 2.0, B.15; names since Rev 4.45, B.56) |
| Advanced filters (colour, icon, top 10, dynamic, date groups) | Read and write supported (FilterColumn, Rev 2.0, B.15). Only the <extLst> extension stays as its original XML |
Custom document properties (custom_doc_props) | Read and write supported (Workbook.customProperties, Rev 2.0, B.15). They also round-trip with ODF's meta:user-defined |
read_only / write_only (streaming) | Supported (StreamingReader / StreamingWriter, Rev 2.0, B.15). They handle values and formats only, with no preservation. Since Rev 4.22 (B.40) reading has one entry point shared by the four formats XLSX / XLSM, ODS, Numbers and CSV. Since Rev 4.25 (B.42) writing has one entry point for the same four formats too (StreamingWriter(to:); the extension decides the format) |
| Sheet protection and scenarios | Read and write supported (SheetProtection / WorkbookProtection / ProtectedRange / ScenarioList, Rev 2.0, B.15). Only the legacy 16-bit password hash is generated |
| Workbook encryption | Supported only when SheetDecrypt / SheetEncrypt is linked (Rev 4.29, B.39.9). Excel's agile format and the ODF 1.2–1.3 envelope. The core alone refuses by name (§1.3) |
| Pivot tables | Placement read and write supported (Sheet.pivotTables, Rev 2.0, B.15). No aggregation is computed — the file is written with "recalculate on open" set, so the application that opens it fills in the numbers |
A collection of recipes that combine the Chapter 14 API in practical contexts. Each recipe is written at a granularity that can be reused as is as a README / DocC sample.
The very reason this library exists (the first test in Chapter 12). Everything not touched — charts, pivots, print settings and so on — survives intact through F3 preservation.
var wb = try Workbook(contentsOf: URL(filePath: "monthly-report.xlsx"))
try wb.editSheet(named: "Summary") { sheet in // SheetError.sheetNotFound if the name does not exist
sheet["B4"] = 1_380_000 // update only this month's actual
sheet["B5"] = Formula("=B4/B3") // achievement rate
} // applied by the time this returns; no write-back line is needed (B.30)
try wb.write(to: URL(filePath: "monthly-report.xlsx")) // charts are intact (F3)
var wb = Workbook()
var sheet = wb.sheets[0]
sheet.name = "Sales"
sheet.append(["Department", "Owner", "Amount", "Date"])
for row in records {
sheet.append([row.dept, row.owner, row.amount, row.date])
}
sheet.setStyle("A1:D1") { // header row
$0.font.bold = true
$0.fill = .solid("F5F5F7")
$0.border.bottom = .medium
}
sheet.setStyle("C2:C1000") { $0.numberFormat = "#,##0" }
sheet.setStyle("D2:D1000") { $0.numberFormat = "yyyy/mm/dd" }
sheet.setWidth(14, ofColumn: "C")
sheet.freezePanes = "A2"
sheet.autoFilter = "A1:D1"
// total row (as a formula)
let last = sheet.extent!.max.row + 1
sheet[last, 1] = "Total"
sheet[last, 2] = Formula("=SUM(C2:C\(last))")
wb.sheets[0] = sheet
try wb.write(to: outURL)
Not swallowing conversion losses is this library's way of working. Logging the warnings is the standard example.
let files = try FileManager.default
.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
.filter { $0.pathExtension.lowercased() == "xlsx" }
for src in files {
let dst = src.deletingPathExtension().appendingPathExtension("ods")
let result = try Workbook.convert(src, to: dst, as: .ods)
for w in result.warnings {
logger.warning("\(src.lastPathComponent): \(w.message)")
} // e.g. "2 charts were discarded because they cannot be carried into ODS"
}
The multiple tables of Numbers do not fit into a one-grid world. Writing emits only the first table
and reports the number dropped with a dropped warning (Chapter 3, Appendix B.12). If you want one sheet
per table, the caller chooses the rule — sheet.tables is a public model, so it takes a few lines.
let wb = try Workbook(contentsOf: URL(filePath: "household.numbers"))
// inspect the sheet layout — a Numbers sheet may hold several tables
for sheet in wb.sheets {
print(sheet.name, sheet.tables.map { $0.name ?? "(unnamed)" })
}
// table → its own sheet (written as is, the second table onwards is dropped with a dropped warning)
var flat = Workbook(sheets: [])
for sheet in wb.sheets {
for (i, table) in sheet.tables.enumerated() {
var s = i == 0 ? sheet : Sheet(name: "\(sheet.name) \(i + 1)") // sheet-level settings stay on the first
s.tables = [table]
flat.sheets.append(s)
}
}
let result = try flat.write(to: URL(filePath: "household.xlsx"))
print(result.warnings.count, "conversion warnings")
The pattern of reading formulas as their computed values with formulaCells: .cachedValues and sticking to value aggregation without touching formats.
var total = Decimal.zero
for url in reportURLs { // xlsx / ods / numbers may be mixed — detection is automatic
let wb = try Workbook(contentsOf: url, options: .init(formulaCells: .cachedValues))
guard let sheet = wb.sheets["Summary"] else { continue }
for row in sheet.rows(in: "B2:B100") {
if let n = row[0]?.numberValue { total += n }
}
}
print("Company total: \(total)")
Macros are kept as an opaque part, so the only thing to keep in mind is "save it as xlsm" (§7.4).
var wb = try Workbook(contentsOf: URL(filePath: "input-tool.xlsm"))
print(wb.preservationSummary) // original format, number of opaque parts, whether VBA is present
var master = wb.sheets["Master"]!
master.deleteRows(at: 1, count: master.extent!.max.row) // clear everything but the header
for item in latestMaster { master.append([item.code, item.name, item.price]) }
wb.sheets["Master"] = master
try wb.write(to: URL(filePath: "input-tool.xlsm")) // the VBA is intact
// saving as .xlsx instead always puts "VBA discarded" in the warnings
Numbers output is fully supported and normally completes with zero warnings. Anything that cannot be represented is listed in the warnings, and once a threshold is crossed a suggestion is attached. This is the shape meant for a UI.
let result = try wb.write(to: URL(filePath: "shared.numbers"), as: .numbers)
if let suggestion = result.suggestion {
// e.g. "12 conditional formats will be lost. Consider XLSX output if fidelity matters"
let proceed = await confirmWithUser(suggestion.message)
if !proceed {
try wb.write(to: URL(filePath: "shared.xlsx")) // example of following the alternative-format suggestion
}
}
The practical form of the Chapter 9 character-encoding rules. Reading names the encoding explicitly; the CSV written for distribution uses UTF-8 with a BOM.
// a Shift_JIS CSV emitted by a back-office system (every type is received as a string, then inferred)
var wb = try Workbook(contentsOf: csvURL, options: .init(csv: .init(
encoding: .shiftJIS,
inferTypes: true,
dateFormats: ["yyyy/MM/dd"]
)))
var sheet = wb.sheets[0]
sheet.name = "Transactions"
sheet.setStyle("A1:E1") { $0.font.bold = true; $0.fill = .solid("F5F5F7") }
sheet.freezePanes = "A2"
wb.sheets[0] = sheet
try wb.write(to: xlsxURL) // xlsx for internal sharing
// also write a CSV that does not turn to garbage when double-clicked in Excel
try wb.write(to: csvOutURL, as: .csv,
options: .init(csv: .init(includesBOM: true)))
15.1 (round-trip editing) and 15.3 (batch conversion) double as the P1 / P3 acceptance tests. The documentation samples and the CI test code are generated from the same source, so a "sample that does not run" is ruled out structurally (using DocC's snippet mechanism).
Open Packaging Conventions. The ZIP packaging rules of OOXML. A declare-and-reference model built on Content_Types and rels
The shared string pool of XLSX. Cells refer to it by index. A derived product built at write time
Excel's cache of the formula recalculation order. Unless it is deleted when formulas change, it causes the repair dialog
The Mac-derived date-base flag. Confusing it with the 1900 system shifts every date by about four years
The formula specification in the ODF Parts. It carries conformance levels (Small/Medium/Large Group) and test cases
The RLE compression attribute for ODS cells. Beware of the unbounded expansion of empty-cell padding at the end of a row
iWork Archive. A Snappy + Protobuf stream with its own framing. Undocumented by Apple
The table mapping IWA type integers to Protobuf message types. Knowledge extracted from inside the iWork applications
The number storage format of Numbers. Converting through Double loses precision, so it is received as Decimal
| Rev | Date | Contents |
|---|---|---|
| 1.0 Draft | 2026-08-22 | First draft. All 12 chapters plus the appendix |
| 1.1 Draft | 2026-08-22 | Added Chapter 14 (the full API list and the openpyxl correspondence table) and Chapter 15 (use cases) |
| 1.2 Draft | 2026-08-22 | Promoted Numbers output to full support (the warning mechanism redefined as a contract shared by all formats). New Chapter 9, the CSV codec (automatic UTF-8 BOM detection, explicit character encoding) |
| 1.3 Draft | 2026-08-22 | Decisions and deviations from implementing P1 and P2 (SheetCore, XLSX/XLSM, CSV, the formula AST, F3 preservation) recorded in Appendix B. The name fixed as SwiftSheets |
| 1.4 Draft | 2026-08-22 | Implementation policy for P3 (ODS) and P4/P5 (Numbers) fixed in Appendix B.8: Protobuf as a zero-dependency dynamic tree; Numbers formula output falls back to cached values plus a warning |
| 1.6 Draft | 2026-08-22 | The API decisions before 1.0 recorded in Appendix B.11: reading also returns ReadResult, ReadOptions.cellLimit, the subjects of warnings and how the alternative format is chosen, a single detection path, the range-view implementation of §14.4, in-place editing through name subscripts, LocalizedError, a single version number. Linux / visionOS not yet reached added to B.1 |
| 1.5 Draft | 2026-08-22 | The limits and checks that satisfy §12 pillar 5 (no crash on malformed input) fixed in Appendix B.9: validation of coordinates, formula depth and ZIP lengths; how many cells merges and the ODS RLE may expand to; atomic saving. The implementation for large data (format sharing between Cells, incremental maintenance of the used range, elimination of temporary copies) recorded in B.10 |
| 1.7 Draft | 2026-08-23 | Of the not-started items in B.7 and the checks §12 demands, those with a judge on this Mac were implemented and recorded in Appendix B.12. What stays unimplemented is fixed in the same section with reasons |
| 1.8 Draft | 2026-08-23 | The data-validation writing API (Sheet.dataValidations) fixed in Appendix B.13. Reading stays under F3 preservation (moved to "left unimplemented" with a reason) |
| 1.9 Draft | 2026-08-23 | The alternative-format suggestion gave wrong guidance about the loss of tables; recorded in Appendix B.14. .tables added to the subjects and the choice of suggestion made a three-way branch |
| 2.4 Draft | 2026-08-26 | Made it possible to write real pivot tables in Numbers and recorded it in Appendix B.19. The placement was read from 17 samples we had Numbers itself create (the 11 unnamed agg_type values, how group-by is built for each shape, the identity of the UID that names the source table). One defect in the judge fixed along the way — front document can be a document opened earlier, so the name of the document that answered is cross-checked |
| 2.3 Draft | 2026-08-25 | Numbers.app took the stand as judge, and conditional formatting is now read and written (the 14 unnamed predicate_type values observed in documents we had Numbers itself create). Two writing defects surfaced (an empty locator on a duplicated part; a duplicated sheet bringing an extra table along) and were fixed. Formula output to Numbers implemented as well (formula tree → TSCE.ASTNodeArrayArchive). Shapes with no real-world example (cross-table references, defined names, functions Numbers lacks, column ranges, intersection / union) fall back to the cached value and say why. Appendix B.18 |
| 2.2 Draft | 2026-08-25 | The count in the opposite direction — features ODF has and OOXML lacks — fixed in Appendix B.17. Label ranges, consolidation definitions, detective arrows, calculation settings (regular expressions / wildcards / case / two-digit years), the date origin and the currency cell type are targeted for implementation; the change history and external data connections stay at "read, remember, and say they were dropped". The source is the RelaxNG schema of OASIS ODF 1.3 |
| 2.1 Draft | 2026-08-25 | The record of putting ODS and Numbers on the same footing as the Excel formats fixed in Appendix B.16. ODS now reads and writes conditional formatting, data validation, print settings, page breaks, print ranges/titles, sheet protection, array formulas, structured tables with filter criteria, and pivot tables; Numbers now handles cell formats, number formats and links (reading). Conditional formatting for Numbers was decided against because there is no judge; the reason and "the conditions for adding it" are fixed in the same section |
| 2.0 Draft | 2026-08-24 | Nine of the gaps against openpyxl (pivot tables, custom document properties, advanced filters, streaming read/write, conditional formatting, structured tables, gradient fills / differential formats, protection / scenarios, reading data validation) implemented and recorded in Appendix B.15. The places where the meaning of F3 changed from "byte preservation" to "semantic preservation of what is modelled" are fixed in the same section |
| 2.4 Draft | 2026-08-26 | Numbers output was discarding "cells that have no value but do have a format" without a warning; recorded in Appendix B.20. Cell extraction skipped valueless cells before it reached the format, so drawings made of fills alone (Gantt bars, weekend columns) vanished entirely. Also fixed: a size equal to Font.default (Calibri 11) was omitted as "the default, so no need to write it" — the template's default is HelveticaNeue 10, so only cells that specified 11pt were drawn 1pt smaller |
| 2.5 Draft | 2026-08-27 | Fixed the same kind of defect for the typeface, left in B.20 as "a candidate breaking change for 1.0" (Appendix B.21). font_name was also omitted when it matched Font.default (Calibri), so cells that specified Calibri were drawn in the template's HelveticaNeue. A typeface the model states is always written. Also measured that Numbers draws correctly even though this Mac's Calibri lives only in the Microsoft Office private folder (the real Calibri is embedded in Numbers' PDF) |
| 2.6 Draft | 2026-08-27 | A full inspection of cross-format conversion, recorded in Appendix B.22. Excel files with pivot tables broke on the second save (the reader memorised attributes the writer writes itself as "unknown attributes", and the next save wrote them twice — duplicate attributes are not well-formed XML). Also fixed three cases dropped in silence — array formulas and protected ranges to Numbers, VBA to ODS / Numbers — and made readers that silently skipped broken parts emit warnings. New per-direction inspection (CrossFormatConversionTests) and generation tests |
| 2.7 Draft | 2026-08-27 | The two items left in B.22 as "decide the policy first" were fixed after deciding the policy (Appendix B.23). Workbook.convert was discarding the reading warnings — converting a Numbers document with drawings and cell controls to .xlsx returned zero warnings. Both the outbound and the return leg are now returned. The calculation-settings warning fired on every conversion from ODS — it compared against the model's defaults, and the defaults LibreOffice writes into every file differ from them. The criterion changed to "would the destination behave differently", and settings with no effect are not counted |
| 2.8 Draft | 2026-08-27 | The diagnosis in Appendix B.19 withdrawn by experiment. "The stamp is an old version, so it is rebuilt on load" was wrong: a sample stamped as 14.1 still draws completely. Furthermore, the lookup table the translator consults is refilled from empty on load (TSTSummaryModel._columnRowUIDMap), so every match check against the saved table is void. Also, the scaffolding of the pivot copy is now deleted per island (the 30 problems in the consistency check disappear), and the fingerprint of assertion counts per shape was recorded |
| 2.9 Draft | 2026-08-27 | One rule of the format established and written into Appendix B.19 — Numbers looks up a pivot's group not by the written UID but by computing "the owner UID of the source copy plus the sub-owner number". Backed by an experiment: merely moving the sample's group UID away from the derived position turns the sample into the same empty shell as ours. The writer now follows this, but the rendering is still not fixed (the fingerprint is unchanged). The haunted owner, 19 other owner UIDs, the header records, the frozen headers, the format table and our encoder were cleared by experiment |
| 3.0 Draft | 2026-08-27 | Numbers pivot tables render. The cause was pinned down by observing the actual load behaviour — the number counting starts from is the table's table_id, not the calculation engine's base owner UID (the two coincide in the samples, so looking only inside the samples cannot tell them apart). From there the body cell formulas, the sentinel of the lane with no group and the placement of a single axis were fixed by measurement, and of the 17 sample shapes the 15 that can be written all render correctly with 0 assertions. The default is ON; the 2 shapes that cannot be written (two row/column levels, two values) are dropped with a warning. The pivot row in the public table went from × to △ |
| 3.1 Draft | 2026-08-27 | List-type data validation is read and written as a Numbers pop-up menu (Appendix B.24). The basis is that we measured Numbers' own substitution in both directions (import: dropdown → pop-up; export: pop-up → list-type rule). Only the form that spells out the options is written; range references and numeric conditions are dropped with a warning as before. A rule covering empty entry rows is placed by widening the table, and a whole-column rule stops at the table edge as Numbers itself does. Specimen popup-15.numbers added to the corpus. Data validation in the Numbers column of the feature table went from × to △ |
| 3.2 Draft | 2026-08-27 | The remaining four cell controls — checkbox, stepper, slider and rating — became the model term CellControl and are read and written in both directions with Numbers (Appendix B.25). The samples were created by Numbers itself via AppleScript (controls cannot be created through Excel). Following the Numbers rule that a control cell always has a value, a resting value (false, the minimum, 0) is written into empty cells. The judge, beyond opening the file, asks Numbers directly for the format of each cell in the written document (all 8 cells answered with the control's name). The Excel and ODS writers gained a "control dropped" warning. The feature table has 47 items (a cell-controls row added) |
| 3.3 Draft | 2026-08-27 | Two leftovers settled by having Numbers itself answer (Appendix B.26). Autofilter: measured that Numbers itself discards it on import (neither the rules nor the hidden rows survive), settled as "where Numbers recognises no substitute, we invent none and write none". Array formulas: the placement (the formula at the origin plus function 337 in the covered cells) was found and reading implemented — an expansion produced by Numbers is restored as a range into Table.arrayFormulas and passes to xlsx as a real array formula. For writing, two experiments (same-shape output; faking the sample's version) showed that "337 cannot produce values on recalculation", which is incompatible with the old-version template design, so it is dropped with a warning as before. The revision-history number collision (2.8–2.9 reused after 3.0) renumbered in chronological order. Specimen array-15.numbers added |
| 3.4 Draft | 2026-08-27 | The handling of the stock and currency functions STOCK, STOCKH, CURRENCY, CURRENCYH, CURRENCYCONVERT and CURRENCYCODE settled (Appendix B.27). To Numbers they are written as formulas (refetched on open; judge passed); to Excel and ODS the fetched value is written with a warning — measured that Numbers' own Excel export makes the same substitution, and matched it. The sample is stock-15.numbers, seeded from a document the maintainer made by hand and extended by AppleScript with every attribute shape. The feature table has 48 items (a stock and currency functions row added; Numbers 20 surviving) |
| 3.5 Draft | 2026-08-27 | The remaining pivot-table shapes settled (Appendix B.28). Multiple levels on both rows and columns — the placement (display lanes in postfix order, a group-by per column prefix (205, 206, …), node UUIDs shared along the value path, the constant lanes of "aggre names row/col", subtotals into the third model) was read from 7 samples, and a batch write of all 17 shapes measured as fully rendered with 0 coordinate assertions. At most one value settled by experiment — the lane of an axis with no group is looked up by the shared marker (1,0), and in a rebuilt document the distinction does not survive (Numbers' own two-value document survives because it is not rebuilt, isolated by faking the version). The second value onwards is dropped with a warning. Specimen pivot-mixed-15.numbers added |
| 3.6 Draft | 2026-08-28 | Reading of categories, filters and stock cells settled (Appendix B.29). The samples are 3 documents the maintainer made by hand in the UI. Filters: hidden rows are kept and the rules counted in a warning (the same deal as Numbers' own Excel export; measured to match row by row). Categories: the plain rows are kept and the grouped columns named in a warning (Numbers itself, exporting to Excel, inserts header-only rows and changes the table's shape — 5 rows become 14 — so that approach was not taken). Stock cells turned out to be plain STOCK formulas in current Numbers, already carried by B.27. The 2026-08-26 list ② (silent without reading) is now empty. 3 specimens added; 888 tests |
| 3.7 Draft | 2026-08-28 | The one unmeasured item in B.29 — a document whose filter is turned off — resolved with a second sample the maintainer made by turning that same filter off. Numbers clears the hidden state when the filter is off (confirming that the safe-side reading was right) and keeps the rule, still off. An off rule has no place in the model, so it is counted, dropped and reported (measured: Numbers' own Excel export discards it without a trace too). Specimen filter-off-15.numbers added; 889 tests |
| 3.8 Draft | 2026-08-28 | A document whose categories are turned off measured with a sample made the same way (Appendix B.29 continued). Only is_enabled flips; the grouping columns and the tree remain — the same shape as a filter turned off. What remains is dropped and reported with the column names listed, and we measured that Numbers' own Excel export, when categories are off, inserts no heading rows and leaves no trace of the categories. Specimen category-off-15.numbers added; 891 tests |
| 3.9 Draft | 2026-08-28 | Sorting measured with a fifth sample hand-made by the maintainer (Appendix B.29 supplement). The rule remains in sort_order as column number plus direction, and applying it reorders the stored rows themselves — the data is already read in sorted order. The rule is dropped and reported with the column names listed. Numbers' own Excel export writes no sortState either and hands over only the sorted rows (measured). Sorting in the Organize panel has no on/off (checked in the UI). Specimen sort-15.numbers added; 892 tests |
| 4.0 Draft | 2026-08-28 | Adopted the batch-editing API Workbook.editSheet(named:_:) / (at:_:) and recorded it in Appendix B.30 (the maintainer's proposal, adopted after review; motive 2 — that chained assignment also copies — was corrected as already solved by the in-place editing of B.11). Changes take effect when the closure returns; a throw discards everything, as a transaction. A missing name is the model layer's first throws, the new SheetError.sheetNotFound(name:) (the line drawn: a lookup is Optional, an operation that cannot be carried out throws). The label was changed to named: to match its siblings. A ~Copyable session type was set aside for the difficulty that "an old edit silently overwrites a new structure". Four lines of hand-written error handling disappeared from the usage example in §15.1. 896 tests |
| 4.1 Draft | 2026-08-28 | Implemented generating the modern protection hash and recorded it in Appendix B.31 (stage 1 of the MIT adoption plan). The iterated SHA-512 of ECMA-376 §18.2.29 (16-byte salt, spinCount default 100,000, the iteration number appended as 0-based LE32). setModernPassword / modernPasswordMatches added as a pair to the three protection types; the computation is public as ModernPasswordHash. SHA-512 comes from CryptoKit (the framework dependency widens by one, to "+ CryptoKit"; zero SwiftPM dependencies is unchanged), the salt from SystemRandomNumberGenerator. Origin: XLKit (MIT; NOTICE item 3). Verified against three known answers from an independent Python hashlib implementation plus a round trip. Unlocking in the real Excel not yet confirmed |
| 4.2 Draft | 2026-08-28 | Implemented the API for placing an image on a cell and recorded it in Appendix B.32 (stage 2 of the adoption plan). SheetImage (PNG / JPEG / GIF detected from the bytes; width and height read from the header) plus addImage(_:at:sizing:) / addImage(_:over:) (original size, fitCell, resizeCellToFit, stretched over a range). Writing takes one of two routes — if there is no drawing part, a full set is generated; if there is, only the anchor is appended to the end of the existing part (the existing portion stays byte for byte; rIds continue from the next after the maximum; the anchor declares its own namespaces). F3 is refined to "a drawing part with an image added is appended; the others match byte for byte". For ODS / Numbers / CSV the images are counted, dropped and reported. Origin: XLKit and XlsxReaderWriter (MIT). The judge is openpyxl |
| 4.3 Draft | 2026-08-28 | Implemented automatic column width and recorded it in Appendix B.33 (stage 3 of the adoption plan). A translation of XlsxWriter's (BSD-2) autofit and CHAR_WIDTHS (measured in Calibri 11; the 95 ASCII characters). Text is the sum of table lookups, numbers are digits × 7 px, TRUE 31 / FALSE 36, a margin of 7 px, +16 px for a filtered column, a ceiling of 255 characters, and a column widened by hand only grows. One departure: where the source gives a flat 8 px for non-ASCII, the East Asian full-width range is 16 px |
| 4.4 Draft | 2026-08-28 | Implemented creating a chart and recorded it in Appendix B.34 (the final stage of the adoption plan). Four kinds: column, bar, line and pie. Chart / addSeries / addChart(_:over:). Written as a chart part plus a graphicFrame anchor; the drawing part is handled by the mechanism shared with B.32 (generate if absent, append if present, number from the next after the maximum). Ranges are qualified with the sheet name and made absolute. Pie has no axes. Other formats count and drop. The structure follows libxlsxwriter chart.c for reference (BSD-2; no code was copied). The judges are openpyxl (kind, series, references, title) and LibreOffice |
| 4.5 Draft | 2026-08-28 | Measurement only. The real Excel (16.112.2) unlocked the protection hash of B.31 — the verification column of Appendix B.31 updated. A new judge, Tests/ExcelParity/verify_with_excel_app.py, drives Excel over AppleScript and reads "protected / does not unlock with the wrong password / unlocks with the right password" as state. Three specimens — ASCII, Japanese (the UTF-16LE route) and workbook structure — all with the modern hash only. Two points learned by measurement are fixed in the spec: Excel does not refuse a wrong password with an error, it simply does not unlock in silence; and unprotect does nothing on something already unprotected, so unless the wrong password is tried first the test passes falsely. ProtectedRange is out of scope, as there is no way to try it in the real application |
| 4.6 Draft | 2026-08-31 | Sheets that are not worksheets recorded in Appendix B.35. Reading a chart sheet parsed it as a worksheet, and writing it back replaced the <chartsheet> part with a <worksheet>, down to the content type and the relationship type becoming worksheet — without a warning. Added SheetPreservation.foreignSheet (ForeignSheet), which carries the part, the content type and the relationship type as they are. degraded on read; dropped on write if cells were written to that sheet. The specimen was made by the real Excel, and the judge is the page count LibreOffice renders (2 against 3 before the fix) |
| 4.7 Draft | 2026-08-31 | The form tab recorded in Appendix B.36. Loading discarded in silence any tab other than TN.SheetArchive (a defect of the same shape as B.35). A form is an input screen for an existing table and holds no values, so instead of being dressed up as an empty sheet it is dropped and reported, listing the form's name and the name of the table it writes to. The specimen can only be made on an iPhone, so the maintainer made it by hand. With this, the "unverified" rows in the feature matrix are down to 0 |
| 4.11 Draft | 2026-08-31 | Fixed the four rows that the implementation had overtaken in the list "What was left unimplemented". Numbers formula generation (implemented in Rev 2.3, B.18) and reading formats (implemented in Rev 2.1, B.16) were still written as unimplemented although the premise had changed once Numbers.app stood as judge. Encryption detection had its state wrong from the start; in fact it looks at .iwph and refuses with unsupportedFeature (that it does not decrypt is unchanged). Confirming that the real application opens the file is an AppleScript judge for both Numbers.app and Excel. At the same time, Numbers F3 patch write-back was split into a row of its own, with the reason a template is used as the base. The machine-checked feature matrix (191 rows) and the prose list had disagreed, and the feature matrix was the one that was right |
| 4.10 Draft | 2026-08-31 | Linux achieved (added to Appendix B.38). The judge went red three times, and what remained at the end was Foundation's own XML part — a byte that is invalid as UTF-8 in an element name brings down the whole process in _NSXMLParserStartElementNs. The specimen (fuzz seed 1, round 174, 4131 bytes) was reproduced byte for byte, and a guard that checks the input holds up as UTF-8 before handing it over was added (a part declaring another encoding passes through; the scan makes no copy). All 944 tests on Linux and 945 on macOS green, fuzz with nothing excluded. "Runs on Linux" in §1.1 is marked achieved, resolving the unmet item in B.1. visionOS stays unmet, as there is no judge |
| 4.9 Draft | 2026-08-31 | The replaceable compression slot recorded in Appendix B.38. What blocked Linux was not the in-house ZIP but two Apple-only parts inside it that actually fold the bytes — Compression and CryptoKit. The folding tool was moved behind Deflate, so that on a machine without Compression the system zlib answers (Sources/CZlib is a single module map; SwiftPM dependencies do not grow). SHA-512 goes back to in-house (100,000 iterations take 0.29 seconds in release). XMLParser lives in FoundationXML on Linux, so a conditional import was added to 8 files. The judge is -DSWIFTSHEETS_ZLIB — on macOS, which has both toolboxes, the same 941 tests are run through the zlib route as well. Linux support is not claimed until CI is green |
| 4.8 Draft | 2026-08-31 | Undeclared crossings recorded in Appendix B.37. Putting a format on the second or later sheet made Numbers refuse to open the document — the parts of a duplicated sheet are only reserved while being written and are not in the inventory, so the declaration of crossings into the stylesheet was skipped altogether. Crossings are now collected and registered right after flushComponents(). No general invariant is introduced (Numbers' own documents have 47 undeclared crossings too). Nailed down with sample 19-style-on-second-sheet and NumbersCrossingTests |
| 4.12 Draft | 2026-09-04 | Opened the second round of speed and size as Appendix B.39 and recorded its first and second sections. Starting from measurements taken beforehand (at one million cells, about 70% of the 5.7-second read is around Foundation's XML part; inflating takes 0.02 seconds), the envelope (ZIP) was rebuilt: an abstraction over where bytes come from (mapping / positioned reads), ZIP64 read and write, inflating chunk by chunk, validation of the declarations (part count, totals, compression ratio, overlaps) with ReadOptions.limits, copies kept compressed, and CRC-32 moved to zlib. The default ceiling on the number of cells was removed (cellLimit is Int.max; the setting remains). The ZIP64 row disappeared from the README's Limits, and the Cell budget row became "no default". 960 tests |
| 4.13 Draft | 2026-09-04 | An API for asking before reading, Workbook.inspect, recorded in Appendix B.39.3. Returns the sheets, the declared cell count, the inflated byte count and the generator without creating a cell. XLSX reads only the first chunk of each part, ODS walks the body once as bytes and multiplies by the repeat counts, Numbers takes rows × columns of the table archive. InspectOptions.countsCells counts the actual number too. A new byte scanner that picks out only tags, TagScanner (the forerunner of the later tokenizer). 971 tests |
| 4.14 Draft | 2026-09-04 | Format detection recorded in Appendix B.39.4, with three rules added to §4.2. SheetFormat.detect(contentsOf:) does not read the whole file (the first 4 bytes, the central directory at the end, one small part). SheetFormat.probe → FormatProbe answers the format, the reason it cannot be opened, or unknown, in one call. Numbers documents in folder form can now be opened (until then they failed with "is a directory"). Text detection is done on the bytes. 977 tests |
| 4.15 Draft | 2026-09-04 | Small fixes with little rework recorded in Appendix B.39.5. Date-format detection once per format id; the style index keyed by identity of the shared style; 64 KiB batching in the streaming write; a fast path for ODS paragraphs; the array copies for Snappy and IWA removed. The formats do not change. In a same-time comparison, read −25%, streaming read −37%, write −13%. 977 tests |
| 4.16 Draft | 2026-09-05 | The in-house tokenizer XMLScanner recorded in Appendix B.39.6. UTF-8 parts are read by it by default; UTF-16 and other encodings go to Foundation. The promise to the receiver is unchanged; preservation fragments are the original bytes themselves. -DSWIFTSHEETS_FOUNDATION_XML switches back to the old route, and CI runs everything through all three routes. Across all 289 parts of the sample set, both engines produce identical events. Read 5.5 → 2.6 s, streaming read 4.6 → 1.8 s, ODS read 13.9 → 3.7 s. 984 tests |
| 4.17 Draft | 2026-09-05 | Writing does not hold recorded in Appendix B.39.7. Parts that are not interpreted travel compressed (OpaquePart.compressed; opaqueParts inflates only when taken out). XLSX sheets stream rows to the compressor every 64 KiB, and the ODS body spills to a temporary file past 8 MiB and is streamed afterwards (TextSpill). Write peak 360 → 258 MB, ODS write 641 → 313 MB, open-fix-one-cell-save 6.9 → 3.8 s and 397 → 288 MB. 988 tests |
| 4.18 Draft | 2026-09-05 | Reading does not hold either recorded in Appendix B.39.8. The tokenizer was made resumable (feed(startingAt:final:)) and reads ZipEntryStream chunk by chunk. XLSX sheets, the shared string table, the streaming read and the ODS body ride on it. Streaming-read peak 61 → 23 MB, read 254 → 221 MB, ODS read 318 → 233 MB. The maximum number of bytes held at once is nailed down by a test. 988 tests |
| 4.19 Draft | 2026-09-05 | Reading and writing password-protected files recorded in Appendix B.39.9. Excel's agile format (AES-256 / SHA-512, 100,000 iterations) and the ODF 1.2 to 1.3 wrapper (AES-CBC + PBKDF2, the same 1,024 iterations as LibreOffice), through ReadOptions.password / WriteOptions.password. AES, SHA-1, SHA-256, HMAC, PBKDF2 and the OLE compound file written in-house and cross-checked against the standards' known answers. The judges are msoffcrypto-tool (both directions) and an independent ODF decryptor built on cryptography. Encryption was removed from the non-scope in §1.3; Excel 2007's standard format, RC4, Blowfish and Numbers are refused by name. Two rows of the feature matrix updated. 1,008 tests |
| 4.20 Draft | 2026-09-05 | Three API items recorded in Appendix B.39.10. ReadOptions.sheets (XLSX carries the sheets it does not read as bytes and writes them back with the shared string table and the cellXfs numbers preserved; ODS / Numbers carry them empty and say so on both read and write), CSVStreamingReader / CSVStreamingWriter, and for try await over rows(inSheet:) / rows(). 1,017 tests |
| 4.23 Draft | 2026-09-05 | Concurrent reading recorded in Appendix B.41 and merged into the main body. XLSX / XLSM sheets are read at the same time (the lazy dictionaries of the style tables are filled first, the work for one sheet is moved into a container that only reads, and the results are put back in sheet order). ReadOptions.concurrency (nil = automatic for 2 or more sheets declared at 4 MiB or more, 1 = one at a time, n = at most n). The extra memory is only the working area of the sheets running at once, so this integer is the cap. A column of 8 sheets on the bench: one at a time 2.7 s, 145 MB → concurrent 1.2 s, 257 MB |
| 4.24 Draft | 2026-09-05 | Added to Appendix B.38: "a part declaring another encoding passes through" was a hole. The Linux judge for 0.14.0 crashed once more in the same function, and the cause was that libxml2 keeps reading a part whose declared encoding name is broken as raw bytes in recovery mode (Snappy, suspected first, was unrelated; its rewrite stays as hardening). Pass-through is now limited to the 8-bit names the parser knows; an unknown name is malformedPart. The fuzzer puts the mutation it is reading on disk, and CI brings it back only when there is a crash |
| 4.85 Draft | 2026-09-13 | The ODS reader finds external links without sorting every cell (B.94): the whole-model read of ten million ODS cells built an array of every cell to find a few formulas. |
| 4.84 Draft | 2026-09-12 | A shared-string table keeps a phonetic slot only where there is furigana (B.93): the row-by-row read of ten million XLSX cells peaked 16 MB higher for slots it never read. |
| 4.83 Draft | 2026-09-12 | What two migrations found (B.92): row 0 and column 0 stop when read, as they stop when written; ConversionWarning.Kind.truncated for a read stopped at the cell limit; SourceInfo.isVerifiedVersion; streamingReader(contentsOf:format:); CivilDate(serial:epoch:); the migration guide's missing entries. Labels on the integer subscripts were considered and not added. The ODS writer names a dropped filter by its sheet column. |
| 4.82 Draft | 2026-09-11 | Cells, faster: the default style answered without hashing or copying, numbers trimmed only when needed, a cell stored once, dense rows written from the extent, dated cells sharing a style, bounded style caches. ODS cell facts per style, CSV rules on bytes, Numbers integers without long division. On a million cells, measured alternately: XLSX write −29%, read −29%, edit −33%; ODS write −34%; CSV read −25%; Numbers write −44%; cells made without the default-style comparison and a CSV table reserved up front, CSV read peak memory −31% (B.91). |
| 4.81 Draft | 2026-09-11 | The cell limit counts in every reader that holds cells: XLSX, Numbers and delimited text join ODS, through one budget shared by the sheets parsed side by side (B.90). |
| 4.80 Draft | 2026-09-11 | The last look at the surface before 1.0. SheetImage.Anchor.absolute(CanvasRect); addSparkline(_:dataRange:at:) with a typed twin, and Sparkline(dataRange:at:); addTable(named:at:) for a cell as for a point; IconSet.Icon.setName; PhoneticText.Run.range; DataBar.gradient; Alignment.wrapsText and shrinksToFit, with a naming scan over the whole public surface; five codec tools become package; Cell.init(thread:); the preserved-parts warning names what the parts hold (B.89). |
| 4.79 Draft | 2026-09-11 | Numbers charts. The series ranges are read from the formulas in TSCH.ChartDrawableArchive and the mediator TN.ChartMediatorArchive; writing creates the template's style presets, non-style presets, the cache grid and the mediator, and registers with the calculation engine as an owner (kind 2). Chart.frame (CanvasRect) added (B.88). |
| 4.78 Draft | 2026-09-11 | The geometry of Numbers shapes. Rectangle, rounded rectangle, ellipse, diamond, triangle, arrows in 4 directions and line are written as bezier paths in a 100 × 100 unit space, and reading recognises the same paths and maps them back to the shape's name (B.87). |
| 4.77 Draft | 2026-09-11 | The rest of the Numbers print setup. The paper is the document's single paper_id / page_size; title rows / columns are the first table's header rows / columns plus show_repeating_headers. The print area and page breaks have nowhere to go and are dropped by name (B.86). |
| 4.76 Draft | 2026-09-11 | Where a Numbers table sits. Table.position (a CanvasPoint in pt) is read; writing places the table by position, then by a non-default anchor, then below the previous table, in that order (B.85). |
| 4.75 Draft | 2026-09-11 | The Numbers print setup. The orientation, scale, margins and starting page number held by the sheet archive, and the storages of the header / footer in three positions each (left, centre, right), are read and written. &P becomes Numbers' own page number; the other codes are dropped and named. Paper, print area, title rows and page breaks are dropped and reported as before (B.84). |
| 4.74 Draft | 2026-09-11 | Pictures, shapes and text boxes are read from and written to the Numbers canvas. TSD.ImageArchive with the bytes under Data/, TSWP.ShapeInfoArchive with the text storage. The anchor is read as a point on the canvas (.absolute); a cell anchor is converted to a point using the first table's dimensions when written (B.83). |
| 4.73 Draft | 2026-09-11 | Extended conditional formatting. DataBar gains the negative colour, the axis colour and position, direction, solid fill and border; IconSet gains customIcons. XLSX folds the x14 extension into the rule on read and rebuilds it alongside the rule. ODS carries what a LibreOffice data bar can express (B.82). |
| 4.72 Draft | 2026-09-11 | Links per run of rich text. TextRun.hyperlink added; ODS and Numbers read and write several links in one cell run by run. Excel takes the first and reports (B.81). |
| 4.71 Draft | 2026-09-11 | Threaded comments. CommentThread (body, replies, resolved, date and time) held in cell.thread; the XLSX threadedComments / persons parts are read and written (the copy rule applies; person ids are kept; the mirror note for old readers is hidden), and ODS and Numbers substitute a note and report (B.80). |
| 4.70 Draft | 2026-09-11 | Sparklines. SparklineGroup (kind, colours, point markers, treatment of empty cells, several sparklines) held in sheet.sparklines; XLSX's x14:sparklineGroups (preserved under the copy rule) and ODS's calcext:sparkline-groups (LibreOffice) are read and written. Numbers drops and reports (B.79). |
| 4.69 Draft | 2026-09-11 | The targets of external workbook references. wb.externalLinks: [ExternalLink] (number, target, sheet names) is read from the XLSX externalLinks/ parts, and for ODS derived from the documents the formulas name. Values are not resolved (B.78). |
| 4.68 Draft | 2026-09-11 | An inventory of the parts that are not interpreted. preservationSummary.parts: [PreservedPartKind: Int] counts, by kind, what did not enter the model. What was read (drawings, charts, themes, notes) is not counted (B.77). |
| 4.67 Draft | 2026-09-11 | The remaining words of the sheet view, and the calculation mode. SheetView gains row and column headings, zero display, right-to-left, scroll position and view kind; CalculationSettings.calcMode. XLSX carries all of it, ODS what LibreOffice's settings and table styles can express (B.76). |
| 4.66 Draft | 2026-09-11 | Shapes and text boxes. Shape (preset geometry, text, one font, fill, line, anchor) held in sheet.shapes; XLSX's xdr:sp / xdr:cxnSp and ODS's draw:custom-shape / draw:line / draw:text-box are read and written. The copy rule and the prefix rule are extended to shapes. SmartArt and shape groups do not enter the model; they are noted by name and reported as dropped when the part is rebuilt (B.75). |
| 4.65 Draft | 2026-09-11 | ODS tab colours. style:table-properties@table:tab-color (ODF 1.3; the attribute LibreOffice writes) is read and written, retiring the "ODF 1.3 has no tab colour" warning. Theme colours are resolved to RGB before writing (B.74). |
| 4.64 Draft | 2026-09-11 | ODS pictures and charts are read, and charts are written. The draw:frame in cells and in table:shapes is read into sheet.images, the chart documents under Object N/ into sheet.charts, and both are written back as new parts. addChart can write to ODS too (B.73). |
| 4.63 Draft | 2026-09-11 | XLSX pictures and charts are read. The anchors of the drawing part and the media and chart parts go into sheet.images / sheet.charts. Untouched, they stay as bytes; added to, they are appended; changed, they are rebuilt and reported. SheetImage.Anchor.absolute and Chart.Series.nameReference added (B.72). |
| 4.62 Draft | 2026-09-11 | The theme is read. Workbook.theme: Theme? (12 colours, 2 fonts) and wb.rgb(of:) (resolving theme tints and indexed colours). An untouched theme part stays as bytes; a changed one is rebuilt at the same path. The ODS and Numbers writers write the resolved RGB (B.71). |
| 4.61 Draft | 2026-09-11 | Carry furigana. Cell.phonetic: PhoneticText? (the reading runs, the conversion kind, the alignment and the font) is read from the <rPh> / <phoneticPr> of shared and inline strings and written back to the shared-string table. The one point where we deliberately part from openpyxl's "skip it" (B.70). |
| 4.60 Draft | 2026-09-11 | Growing lists become structs. Chart.Kind, SheetImage.Format and DateEpoch become RawRepresentable structs with static members (the call sites are unchanged; a switch needs a default). DateEpoch(origin:) carries ODF's arbitrary date origin as it is, and says it to Excel and Numbers re-based on the 1900 family. A chart kind that cannot be drawn is dropped by the writer by name (B.69). |
| 4.59 Draft | 2026-09-11 | "A1" leaves the names. freezePanesA1 and autoFilterA1 are removed (write the typed freezePanes = CellRef("B2") and autoFilter = CellRange("A1:D9")). The getters that build A1 notation move to the address family: CellRef.a1 / CellRange.a1 → address, absoluteA1 → absoluteAddress, qualifiedA1 → qualifiedAddress, Sheet.dimensions / Table.dimensions → extentAddress. The ISO 8601 initialisers are unified on iso8601: (the iso: of CivilDate, TimeOfDay and CivilDateTime renamed) (B.68). |
| 4.58 Draft | 2026-09-11 | Part of B.62 is put back. The typed side of style(at:) and setStyle(at:_:) keeps at: (the method name does not say "cell"). cell(_:) and removeCell(_:) stay unlabelled. |
| 4.57 Draft | 2026-09-11 | Internal tools leave the public surface. CRC32, ZipInspection, TextEncodingSniffer, OOXMLEscape, Units, CellPixels, TextWidth, LegacyPasswordHash, ModernPasswordHash, Table.cleanMergedRange, WriteResult.suggest, Workbook.noteUnmodelledODFFeatures and UnopenableInput.probe(in:) become package. The three remaining abbreviations (baseColWidth, defaultColWidth, firstDataCol) become words (B.67). |
| 4.56 Draft | 2026-09-11 | What Swift's types can say is said with types. hashValue: String? (three protection types; the same name as Hashable's requirement) → saltedHash. validationError() -> String? (two types) → validate() throws. The from/to functions of ExcelDate move to CellValue(serial:epoch:), serial(epoch:), CellValue(iso8601:), iso8601, CivilDateTime.serial, CivilDate.serial, Duration(serialDays:) and serialDays. Five enumerations the specifications close (Font.verticalAlignment (formerly vertAlign), Font.scheme, ConditionalFormattingRule.timePeriod, StructuredTableColumn.totalsRowFunction, DynamicFilter.kind) go from String to enum (B.66). |
| 4.55 Draft | 2026-09-11 | One name per thing. values(in:) (= rows(in:)) and Sheets.names (= Workbook.sheetNames) are removed. freezePanes(at:) is removed and folded into the setter of freezePanesA1 ("A1" also clears it). The string twins of the print settings become settable printTitlesFormula and printAreaFormula (both String?); setPrintArea, setPrintTitles, setPrintTitleRows and setPrintTitleColumns are removed (B.65). |
| 4.54 Draft | 2026-09-11 | Bool properties are spelled predicatively. The 28 imperative words (includeStyles, allowBlank, showGridLines, lockStructure, refreshOnLoad …) become includesStyles, allowsBlank, showsGridLines, locksStructure, refreshesOnLoad. The attribute names in the files do not change (B.64). |
| 4.53 Draft | 2026-09-11 | The same action gets the same word and the same label. CellRef.offset(rows:columns:) → shifted(rows:columns:) (the word CellRange.shifted uses), CellRange.shrunk(right:bottom:left:top:) → shrunk(right:down:left:up:) (the labels expanded uses), streamingWriter(url:format:) and StreamingWriter(url:format:) → (to:as:) (as write(to:as:) and withStreamingWriter(to:as:)), streamingReader(data:) → streamingReader(_:) (as read(_:)), and the four declarations of SheetFormat.detect become two, detect(_:filename:) and detect(contentsOf:) (the shape of probe; detect(in:) goes package) (B.63). |
| 4.52 Draft | 2026-09-11 | The labels of the A1-string twins are aligned. The at: of the typed cell(at:), removeCell(at:), style(at:) and setStyle(at:_:) is dropped, so they are unlabelled like their string partners (cell("B2")) (eight declarations on Sheet and Table; B.62). |
| 4.51 Draft | 2026-09-11 | An omission of B.60 is fixed. SheetView.sqref → selectedRanges was in the migration table and the revision history, but the code still had the old name (and the test expected the old name, so it was green). A contract test now cross-checks the CHANGELOG's "old → new" against the code. |
| 4.50 Draft | 2026-09-10 | Integer cell coordinates start at 1. CellRef(row: 1, column: 1) is A1. Coordinates (CellRef, CellRange, subscripts, row and column dimensions, insert and delete, groups, print titles, the row index of a streamed row, the column-name functions) are the numbers the sheet shows; positions in Swift collections (sheets, pivot fields, arrays) stay 0-based. rowNumber is no longer needed and goes. FilterColumn.column becomes columnOffset, and the relative subscript of RangeView becomes [rowOffset:columnOffset:] (B.61). |
| 4.49 Draft | 2026-09-10 | Abbreviations become words. col → column, cols → columns, minCol/maxCol → minColumn/maxColumn (the subscripts of CellRef, CellRange, RangeBounds, offset, shift, moveRange, size and RangeView), SheetView.sqref → selectedRanges. Coordinate system, range ends, defaults and the saved form do not change (B.60). |
| 4.48 Draft | 2026-09-10 | Sheet.tabColor goes from String? to Color?. It becomes the front door to the same value as SheetProperties.tabColor, and stops turning into nil because a theme or indexed colour is not RGB (B.59). |
| 4.47 Draft | 2026-09-10 | The convenience API is tidied. dataType (openpyxl's one letter) is dropped, pythonString is folded into stringValue (the representation is unchanged), and the global function Formula(_:) becomes CellValue.formula(_:dialect:). The A1-string twin properties (freezePanesA1, autoFilterA1, dimensions) stay (B.58). |
| 4.46 Draft | 2026-09-10 | The NumberFormat constants go from copies of openpyxl's FORMAT_* (dateXLSX14, dateTime1…8, number00 … 34 of them) to 18 that name a meaning. Built-in formats whose display depends on the locale have no name and are looked up with builtinCode(_:). Not one value string changes (B.57). |
| 4.45 Draft | 2026-09-10 | Three type names in the editing model. ExcelTable → StructuredTable (and structuredTables, addStructuredTable, StructuredTableColumn), Cell.comment → Cell.note (the type CellNote stays), Top10Filter → RankFilter (FilterColumn.rank). No compatibility aliases are kept (B.56). |
| 4.44 Draft | 2026-09-10 | Added DataValidation.list(choices:over:) (failable), which builds a data validation's choice list from an array of strings, and listChoices on the reading side. The knowledge of how an inline list is spelled ("a,b,c") moves from three codecs to one place in the model (B.55). |
| 4.43 Draft | 2026-09-10 | How formula cells are read changes from dataOnly: Bool to formulaCells: FormulaCellReading (.formulas / .cachedValues). The three places — the normal read, the streaming read and Workbook — share the same enum, and a formula cell with no computed value is defined as empty (B.54). |
| 4.42 Draft | 2026-09-10 | The six declarations that change a style are renamed to setStyle, and the handling of an unreadable A1 string is made uniform: "the writing entry points stop, the reading entry points return the default" (B.53). |
| 4.41 Draft | 2026-09-10 | SheetError is classified. unopenable(UnopenableInput), noCodec(for:) and unsupportedEncryption(detail:) are added, and the judgement in read/inspect is aligned with SheetFormat.probe (B.52). |
| 4.40 Draft | 2026-09-09 | Streaming writes go through a temporary file and reach the destination only on a normal finish. close() returns a StreamingWriteResult; cancel() and CodecSet.withStreamingWriter are added (B.51). |
| 4.39 Draft | 2026-09-09 | Format selection moves to public Codec values. SpreadsheetCodec and the per-format implementations are closed to package access; StreamingReader.tableNames(inSheet:) is added (B.50). |
| 4.38 Draft | 2026-09-08 | The convert methods of Workbook and CodecSet are unified to to: destination and as: format (B.49). |
| 4.37 Draft | 2026-09-08 | data(as:) is removed from the plain and the encrypted variants and folded into write(as:).data (B.48). |
| 4.36 Draft | 2026-09-08 | @discardableResult is removed from the five save and convert declarations. An overlooked return value is warned about; an intentional discard is written explicitly (B.47). |
| 4.35 Draft | 2026-09-07 | The preservation structures move to package access. The public PreservationSummary and Sheet.contentState give the summary and the not-yet-read state. The four editing operations stay public (B.46). |
| 4.34 Draft | 2026-09-06 | Date formats in Numbers (0.19.1). Numbers ignores a CUSTOM_DATE format struct unless the document carries a matching custom format archive, so a yyyy/m/d cell came out as 01/09/2026 0:00. Changed to putting the same date_time_format on the built-in DATE kind (Numbers draws 2026/9/1 as written; confirmed on the real application). Reading is unchanged, since it already mapped both kinds to the Excel format code. The date paragraph of B.8 follows |
| 4.33 Draft | 2026-09-06 | Runs on WebAssembly (wasm32-wasi), recorded in Appendix B.45 and folded into the body (0.19.0). For an environment with neither zlib nor Compression, a pure-Swift DEFLATE path (reading: a complete RFC 1951 inflater; writing: stored blocks) and a table-driven CRC-32 are provided under #if os(WASI), and five points are branched: the ZIP64 markers and the decompression limits that overflow a 32-bit Int, parallel reading, temporary files, atomic writes and resource bundles. The three existing paths (Apple / zlib / Linux) are unchanged. The trigger was the web version of Stream wanting to read and write the three formats inside the browser (request of 2026-09-06) |
| 4.32 Draft | 2026-09-05 | The codec set, recorded in Appendix B.44 and folded into the body (0.18.0). So that an app which links only the products it needs still gets the entry points for opening, inspecting, streaming read, streaming write and conversion, SheetCore gains a table CodecSet keyed by SheetFormat, SpreadsheetCodec gains the requirements for inspect, opening from a URL and streaming read/write, and the declarations of StreamingReader / StreamingWriter move to the core. The all-in-one product becomes a thin entry point holding one CodecSet.all; the calls are unchanged. A format not in the set is refused with the format name and the product name. §2.2, §4.3 and B.40.1 follow. A new test target PartialLinkTests (imports of only four products). The trigger was a request from Left Right (linking the all-in-one product costs about 115 KB measured, but the real point is closing the hole in the README's promise of "only the parts you need") |
| 4.31 Draft | 2026-09-05 | Fixed the two causes of the streaming-read peak growing with file size (Appendix B.39.8 Rev 4.31, 0.17.2): the inflater's output per call is cut at 1 MiB (an ODS body swells 60-fold, and even after a 15 MB piece was discarded the macOS allocator did not return it from resident memory), and a streaming read opened from a URL uses positioned reads instead of a mapping (the touched pages of the mapped file were counted in the peak). Streaming read of 10 million cells (the 0.17.2 record): XLSX 62 → 19 MB, ODS 709 → 14 MB, Numbers 329 → 61 MB — it no longer grows with the file size; what remains is the strings the reader holds. Added to Appendix B.39.11: "performance is measured on macOS; Linux only when asked". |
| 4.30 Draft | 2026-09-05 | The measurement baseline is revised (Appendix B.39.11): two tiers, 100 columns × 10,000 rows (1 million cells) and 100 columns × 100,000 rows (10 million cells). The headline numbers in the README are taken from the first tier. The 10 columns × 100,000 rows record and the three 200-column tiers are retired (the numbers stay as history). One driver, scripts/bench.py; one record, tiers; the read numbers are for files written whole-model, and reading a file written by streaming is a separate row |
| 4.29 Draft | 2026-09-05 | Decryption and encryption move to separate products (Appendix B.39.9 Rev 4.29, 0.17.0). ReadOptions.password / InspectOptions.password / WriteOptions.password and the password: of the streaming read are removed from the core (SwiftSheets and the five products), so that no encryption or decryption code enters the binary. Decryption is SheetDecrypt (decrypt(_:password:) and extensions such as Workbook(contentsOf:password:)); encryption is SheetEncrypt (encrypt(_:as:password:) and write(to:…password:)). The reason is what the linking app has to declare. The judge is the symbol table (scripts/check-no-crypto.sh, with a positive control, CI on both OSes). §1.3, §14.11 and B.40 follow. At the same time, the detector's window that made the core misread a protected file over 1 MiB as "old .xls" was removed (found by the 200 columns × 5,000 rows measurement) |
| 4.28 Draft | 2026-09-05 | Fixed the UTF-8 check of the streaming read in Appendix B.39.8: the re-check on every arriving piece started at a fixed position, "3 bytes from the end", so when that position was a continuation byte of a completed 3-byte character, a valid part was refused as "not UTF-8". The 200-column baseline (4.27) found it in a 10-million-cell XLSX written by streaming. The re-check now starts where the previous one stopped (the first byte of the character cut at the boundary). The regression test puts a character on the 1 MiB boundary of an uncompressed part |
| 4.27 Draft | 2026-09-05 | Added the 200-column baseline to Appendix B.39.11: scripts/bench.sh --grid measures the same operations at a fixed 200 columns and 5,000 / 50,000 / 500,000 rows. An operation that will not fit the machine is not measured — it is skipped by estimate (cells × 320 B, free disk × 1.5) before it can fail, and the reason is recorded. The bench widens the column count through an environment variable and adds the CSV streaming write |
| 4.26 Draft | 2026-09-05 | Writing images into ODS, recorded in Appendix B.43 and folded into the body. A picture from addImage is written as a part under Pictures/ and a draw:frame / draw:image inside the anchor cell, and the manifest carries the media-type. Dimensions are in cm at 96 dpi; a picture fitted to a range uses table:end-cell-address. Reading is unchanged (the support table states that it does not come back through our own reader). The judge is LibreOffice |
| 4.25 Draft | 2026-09-05 | Streaming write for the three formats, recorded in Appendix B.42. The umbrella's StreamingWriter(url:) picks the format from the extension and hands over to XLSXStreamingWriter (the old StreamingWriter, renamed) / ODSStreamingWriter / NumbersStreamingWriter / CSVStreamingWriter. ODS parks the rows in a TextSpill and flushes them after the styles on close. Numbers puts each tile into the envelope as it fills (NumbersDocument.addStreamed) and accumulates the row headers in wire format (ProtoMessage.Value.raw). What cannot be carried is warned about with a count |
| 4.22 Draft | 2026-09-05 | Streaming read for the three formats, recorded in Appendix B.40. The umbrella's StreamingReader detects the format and hands over to XLSXStreamingReader / ODSStreamingReader / NumbersStreamingReader / CSVStreamingReader; the row type (StreamedRow) and the options move to SheetCore. ODS walks the body once and delivers only the rows of the requested table (reading a cell's text shares one part, ODSCellText, with the normal reader). Numbers builds only the index of parts first, then decodes and discards a tile on every row advance (NumbersObjectIndex). One million cells in Numbers were measured for the first time, and the decimal128 decoding was fixed. Three holes in the common spec (ODS workbook-structure protection, the iterative calculation and display-precision settings of XLSX's calcPr, the ZIP64 row of the support table). The design and prototype measurements of parallel reading (tier 6) are fixed in B.40.6; the implementation goes to the next version. The streaming reads of the three formats are added to the fuzz targets |
| 4.21 Draft | 2026-09-05 | The bench becomes a public thing, recorded in Appendix B.39.11. Benchmarks/, scripts/bench.sh, docs/performance.json → docs/performance.html. CI's --check cross-checks the three MB figures in the README against their source. The 0.12.0 record: read 2.8 s, 221 MB; streaming read 1.9 s, 23 MB; write 1.7 s, 258 MB; ODS write 3.4 s, 313 MB; ODS read 3.9 s |
This spec is revised as the implementation progresses. When the spec and the implementation diverge, this document is corrected first and the code is changed afterwards.
As of 2026-08-22, with P1 and P2 implemented, this appendix fixes the points where the body and the implementation differ and the points the body had not decided. This appendix takes precedence over the body. There is no deviation without a reason — every deviation is listed with its reason, and when we judge that it should be reverted, we correct this appendix, not the body.
SheetCore / SheetXLSX / SheetCSV / the all-in-one SwiftSheets. No empty targets are created for the unimplemented SheetODS / SheetNumbers (they are added in P3 and P4). CSV is an independent target, as §9 says.Formula/).
Rev 4.9 removed both Apple-only parts (B.38): the compressor moved behind the Deflate replacement point,
and on a machine without Compression the system zlib answers (Sources/CZlib is a single module map;
the dependencies SwiftPM resolves do not increase). SHA-512 also went back from CryptoKit to our own.
§1.1's "runs on Linux" is achieved (2026-08-31, B.38) — the Linux CI that §12.2 installed as the external judge
went all green, so the README badge and the Limits table were rewritten. visionOS remains unachieved:
nothing blocks it any more, but the README does not claim a platform on which nobody has run the full suite.package access (L1 is not a separate target — users need not see it).| Body | Implementation | Reason |
|---|---|---|
case number(Decimal) only | Two cases, .integer(Int) and .number(Decimal) | openpyxl's int / float distinction affects the display string ("1" versus "1.0"), and Stream's cross-checks depend on it. Decimal keeps the file's numeric string as it is (0.1 does not become 0.1000…55) |
case date(Date) | .date(CivilDateTime) (a calendar date-time without a time zone) plus .time(TimeOfDay) / .duration(Duration) | A spreadsheet date has no time zone. Foundation.Date is converted explicitly with CellValue(Date, in: TimeZone) |
case text / case empty | .text (the same). An empty cell is Cell.value == nil; there is no .empty. .richText([TextRun]) is added | Optional is the Swift way. A cell that has only a style stays in cells with value == nil |
formula(FormulaExpr, cached: String?) | formula(FormulaExpr, cached: CellValue?) | A cached value has a type (t="b" / "str" / "e" / numeric). A dataOnly read returns that type |
Sheet.tables, Table.columnWidths | The same structure. Table has rowDimensions / columnDimensions (height, width, hidden, outline, column default style) and merges. Sheet forwards the cell API to the default table | Guarantees §14.3's "with XLSX / ODS alone you never think about Table" in the API |
| Sheet name validation | Validated and de-duplicated when placed into Workbook.sheets (the Sheets collection): an invalid name keeps the old name, a duplicate gets a numeric suffix. On rename, references inside the formulas of every sheet follow via the AST | With value types a sheet on its own does not know its siblings |
Workbook() gives "Sheet1" | The same (changed from openpyxl's "Sheet") | As the body says |
ColumnName(2) / ColumnIndex("C") | CellRef.columnName(3) / CellRef.columnIndex("C") | A capitalised function name looks like a type in Swift. Numbering is 1 = A (0-based until Rev 4.50) |
Formula("=…") (a type) | CellValue.formula(_:dialect:) (until Rev 4.47 the global function Formula(_:)) — sheet["C1"] = .formula("=SUM(A1:B2)") can be written as the body shows). The type is FormulaExpr | To keep the subscript's assigned type a single CellValue? |
| — | Comment is CellNote; the property is cell.note (Rev 4.45, B.56) | Excel's current name for it is "note". "Comment" is kept free for the threaded kind, which is a different thing |
| — | Workbook.epoch (1900 / 1904), indexedColors and codeName are kept in the model | They are semantic attributes of the file as a whole, and a user may set them when writing (the serial values themselves are not kept) |
.error, .column (a whole column), .row (a whole row), .name (defined names and structured references), .array and .missing (an omitted argument). The body's range(a, b) also takes columns and rows as endpoints.rendered(as: .xlsx) has no = (the content of <f>); rendered(as: .ods) has the of:= prefix (as in the §14.7 example). FormulaExpr.text is the Excel notation with =._xlfn. stay lower case (Excel requires it).[1]Sheet!A1 (kept as a string in a name). Neither breaks a round trip, thanks to .unparsed / .name.Sheet.insertRows(at:count:) and friends move references within the same sheet (unqualified and qualified with the sheet's own name). To move references from other sheets as well, use Workbook.insertRows(inSheet:at:count:). When a deletion removes part of a range it is shrunk; when the whole range goes, it becomes #REF! (as in Excel). Inserting and deleting rows and columns also moves merges and dimensions (openpyxl does not — recorded as adapted in the ledger).Workbook.preserved (PreservationStore) there is Sheet.preserved (SheetPreservation: fragments of uninterpreted child elements, rels other than hyperlinks, the original part path, rId, sheetId and root attributes). It moves and is renamed together with the sheet, so no name key needs re-pointing.opaqueParts. Unknown children of <workbook> / <worksheet> / <styleSheet> are kept as XML fragments and re-emitted at their schema-order position (attribute order is normalised to dictionary order — the meaning is the same).dxfs / tableStyles / extLst are verbatim. The dxfId of conditional formatting and tables does not break. cellStyleXfs / cellStyles were verbatim too until Rev 1.7, but since named styles can now be held, the scheme changed to "keep the original entries, order and raw XML included, and append only the new names at the end" (B.12).calcChain.xml is always deleted and fullCalcOnLoad="1" is set.<c cm= vm=> (rich value metadata) and some attributes of <sheetView>. Resolved in Rev 1.7: <pageSetup r:id> and array formula ranges (B.12).XLSMCodec shares its implementation with XLSXCodec.CSVCodec.readWithWarnings, a side door
for this format only, but read now returns a ReadResult and every format has the same shape (B.11).yyyy-mm-dd and the like) does not count as a "style" — a workbook that merely reads a CSV and writes it back gets no summary warning."" (an empty quoted field) is no cell, the same as an empty field.PreservationTests.editOneCellKeepsEverythingElse: a workbook generated with openpyxl containing charts, tables, conditional formatting, data validation, comments and defined names is edited in one cell and saved; then the other cells, the byte identity of the opaque parts, the resolution of rIds, the consistency of Content_Types and the element order are checked. Each of the three major causes (§7.4) is detected mechanically.Tests/OpenpyxlParity/parity.json) continues. Because the API changed to value types and 0-based numbering, the ported tests were rewritten mechanically; the source comments are unchanged.PropertyTests) and fuzzing (FuzzTests) — B.12.Sources/SheetNumbers/Resources/), never copied by hand.degraded warning is returned — §11.2's fallback rule for "functions that cannot be mapped", applied to every function. Reason: the reference implementation numbers-parser itself cannot write formula archives (it only reads), and this Mac has no Numbers, so a newly generated internal representation cannot be verified.number-columns-spanned / rows-spanned and covered-table-cell; dates / times are ISO text (PT26H and above is a duration); formulas are the AST's ODS dialect (of:=). Column width is 2.0 mm per character (one constant for reading and writing; we do not match LibreOffice's default of 8.43 characters = 2.267 cm); row height is in pt (use-optimal-row-height="false"; on reading, a height with optimal=true is ignored). Borders thin / medium / thick = 0.75 / 1.75 / 2.5 pt. A column's default-cell-style-name is mapped to ColumnDimension.style and applied to cells that have no style of their own (LibreOffice folds column formatting into it). Display formats are converted token by token in both directions between XLSX codes and number:*-style; multiple sections (first only, substituted), formats with style:map, era names, quarters and fractions become General plus degraded (once per data style). Freeze panes are the Tables map in settings.xml, hidden sheets are table:display, defined names are named-expressions, notes are office:annotation, links are text:a. Writing ODS back to ODS regenerates content.xml, so shapes and charts are not re-linked (a dropped warning; Pictures and the like are kept opaque and re-registered in the manifest). Row groups are read only. An .error that has no formula becomes a string. Verification: a generated ODS converted to xlsx by headless LibreOffice matches in values, merges, freeze panes, styles and notes, and a LibreOffice-made ODS (two bundled corpus files) read back matches the original xlsx. The corpus of Excel-made ODS is not collected.FormulaExpr.parse (a reference to another table is 'Sheet::Table'!A1; on failure .unparsed); merges (the formula store's COLON_TRACT first, then the region map); column widths (pt ÷ 5.7 = characters) / row heights; hidden rows and columns; the table position as Table.anchor (rounded to a 20pt × 98pt grid); header rows and columns as freezePanes. Styles (bold, fills, display formats) are not read (F1 level; F2 is on the roadmap). The version policy is §10.3 (tolerant by default; the last entry of BuildVersionHistory.plist goes into sourceInfo.version; the verified generations are Numbers 11–15). Verification: 11 documents from numbers-parser's test suite are bundled, and the values, formula trees, merges, table names and row counts match what numbers-parser reads (NumbersReaderTests).empty.numbers bundled with numbers-parser (MIT; see NOTICE). The first sheet / table is patched in place; the second and later sheets and tables copy a subgraph of "only the table-specific types" (TableInfo / TableModel / DataList / Tile / HeaderStorageBucket / each FormulaOwner and so on; styles are shared), re-point the references, reassign UUIDs consistently, and add the calculation engine's owner table entries and the component registrations in PackageMetadata. Cells are packed into the tile format (cell storage v5, wide offsets), strings are registered in the TableDataList, merges are a MergeRegionMapArchive, row heights / column widths are header buckets, freezePanes becomes header rows and columns. Not supported: formulas (cached value plus degraded), styles (one degraded per table), hidden sheets (Numbers has no such concept). Writing a loaded file back also goes through this generation path (an F3 patch on top of the document as read is on the roadmap). Verification: our own round trip, reading back with numbers-parser (Tests/NumbersParity/verify_with_numbers_parser.py), and matching values in LibreOffice's Numbers importer (libetonyek). Opening in the real Numbers is unconfirmed.NOTICE (what comes from numbers-parser: the registry, the function table, the descriptor-derived schema, the template, the test fixtures) and MAINTENANCE.md (the version-tracking procedure of §10.3) sit at the repository root.
§12's pillar 5, "no crash, no infinite loop and no memory explosion on the malformed corpus and randomly corrupted input", was
not achieved as of 0.2.1. An audit reproduced five kinds of failure (four process crashes, one unbounded memory growth), so the limits below are fixed as implementation decisions.
Every one of them means "stop there", not "cannot read", and stopping is always reported: as a degraded warning when a value is lost,
as a SheetError when the structure is broken.
FormulaParser.maxDepth), the same as Excel's own limit. A formula beyond it is kept as .unparsed
with its original text, so a same-format round trip is byte-identical, but reference adjustment on row and column insertion and dialect conversion are not performed for that cell.
One level of recursive descent costs about 7 KB measured without optimisation and a fraction of that with it, and 64 levels fit the 512 KB stack that Dispatch hands out and
every release build. Only a debug build run on a stack of 256 KB or less does not fit.CellRef). Up to 3 letters for the column, up to 1,048,576 for the row.
Previously the check came after the loop, and a 20-digit row number overflowed the accumulator and trapped.
<row r="0"> and anything above 1,048,576 fail with malformedPart (previously they passed in silence).Table.maxMaterialisedMergeCells).
In a merge larger than that, the anchor's borders and protection propagate only to "cells that already exist" (openpyxl creates
placeholders). At 65,536 or below the behaviour is identical to openpyxl. In addition, an empty cell that would carry nothing but the default protection is not created
— a 2 KB file with a single <mergeCell ref="A1:CZ2000"/> had become 208,000 cells and 554 MB.
The anchor cell alone is always created (the ODS writer puts the span attributes on it). The ODS writer has the same limit:
for a merge larger than that, the covered cells are not expanded into a set but judged by geometry (previously writing a merge over the whole sheet was OOM-killed).ODSReader.maxCells). §8.3's cut-off looked only at
"repeats with no content", and for a cell with a value number-columns-repeated × number-rows-repeated was
expanded faithfully up to 16,384 × 1,048,576 (a 1 KB file: 10 minutes, over 425 MB, never stopping). The excess is not read, and
one degraded warning per sheet is returned. 1,000,000 is 0.006% of one Excel sheet (16,384 × 1,048,576), the scale at which
one can first say "this is a specification, not content". This properly belongs in ReadOptions
and will move there once a user has a reason to choose it.corruptedContainer. The Numbers side likewise
verifies length-delimited field lengths and the digit count of varints. From Rev 4.12 (B.39.1) the plausibility of the declarations themselves
— the number of parts, the total expanded size, the compression ratio, overlap between parts — is also checked by the defaults of ReadOptions.limits,
and expansion stops at exactly the declared size.write(to:) is atomic (Data.WritingOptions.atomic). Since overwriting an opened file
is the reason this library exists, an interrupted write must not destroy the original file. The inode is replaced.As §14.11 says, v1 is a whole-model design and has no streaming. In return, "one sheet fits in memory" becomes the implementation's responsibility. At the time of the audit, 200,000 cells took 458 MB of RSS and 1,000,000 cells over 1.5 GB — untenable as long as iOS is listed as a supported platform. We record the causes and the remedies.
Cell does not embed its style by value (496 B → 24 B). CellStyle rides on
SharedStyle (an immutable, Sendable reference type), and a cell with the default style holds nothing. The rare
hyperlink and note are folded into CellExtras.
Identical styles are shared — one instance per xf index in XLSX, per automatic style name in ODS,
and per closure result in style(range). What §3.2 forbids is "bringing file-format indices
(sharedStringIndex, styleIndex) into the model", not sharing equal values inside the model. The public API is unchanged
(cell.style still reads and writes a value). Measured: building 600,000 cells went from 982 MB to 105 MB, and
reading 150,000 cells with a style on every cell from 469 MB to 100 MB.Table.extent) is maintained incrementally. It was a computed property that
scanned every cell, and because rowCount / row(_:) / rows(in:) / every writer calls it
internally, the natural code for r in 0..<sheet.rowCount { sheet.row(r) } was O(n²). The typed mutation paths
(subscripts, append, store(_:at:)) update the range, and only when cells is rewritten
directly does it fall to "unknown" and scan once on the next call. Walking 3,000 rows of a 600,000-cell sheet went from
4.49 s to 0.00 s.withUnsafeBytes. The XLSX / ODS writers were repacking the cells
of every row, so they now collect only the keys.We fixed, all at once and at this point, the things that could no longer be corrected once frozen at 1.0. Each change brings the code closer to the intent of the main text; where the main text (§4.1, §14) differs, this section takes precedence.
SpreadsheetCodec.read returns
ReadResult (workbook + warnings) rather than Workbook. Until now only ODS / CSV / Numbers had a
side door called readWithWarnings, and the facade (Workbook(contentsOf:)) showed no read warnings
at all — §6's "never drop a loss in silence" is a contract that binds reading as well, so the side door was removed and
everything given one shape. Workbook.read(contentsOf:) / read(_:format:options:) return the result
type, and the convenience Workbook(contentsOf:) stays — its warnings ride along in
Workbook.readWarnings, so nothing vanishes in silence through either entry point.ReadOptions.cellLimit (default 1,000,000). What B.9 had put in as an
internal constant moved to an option (closing the carried-over note "this really belongs in ReadOptions").ConversionWarning.subject (.macros / .objects /
.formatting / .formulas / .sheets / .other).
WriteResult.suggest chooses the alternative format from what was lost, not from the destination
(XLSM if macros were dropped, otherwise XLSX, and no suggestion if the file is already in that format). Until now it could
say "XLSM would keep this" every time you wrote an xlsx. Rev 1.9 added .tables, turning the
two-way choice into three branches (B.14).SheetFormat.detect(in: ZipInspection) was added, and
each codec's canDecode merely compares it with its own format. This removes the state where §4.2's detection order
was written twice.sheet.range("A1:C3") returns a RangeView.
Rows are built one at a time on request, and cells are shared with the table (a two-dimensional array of values is built only
when .values is called). It is a snapshot: editing the sheet afterwards does not change what the view shows.wb.sheets["Summary"]?["A1"] = 1 now goes
through a _modify path (take it out of the collection temporarily and put it back), so the cell dictionary is not
copied. activeSheet is the same. The "copy and write back" style of §15.1 still works as it is.SheetError is a LocalizedError. localizedDescription returns the
reason rather than "The operation couldn’t be completed."Equatable: Workbook / Sheet /
Table are no longer Hashable (Cell and CellValue stay as they were — the string
pool uses them as keys). Comparison is needed, but hashing only opened the door to "put one sheet in a Set and pay
for a computation over every cell in silence".unsupportedFeature (it used to be a preconditionFailure that took the whole process down).
Only set on the writing side remains a precondition, because an inconsistency with the bundled schema is a build
problem.SwiftSheetsInfo (name / version / generator / appVersion).
docProps/app.xml, ODS's meta:generator and the README's Status line all point at the same value, and a
test checks agreement with the README (APIContractTests).
Verification is Tests/SwiftSheetsTests/MalformedInputTests.swift (coordinates, row numbers, formula depth, broken
ZIPs, a merge covering everything, 200 rounds of fixed-seed random byte corruption) and the RLE cap tests in
ODSCodecTests; each turns the reproductions above into regressions as they are. The malformed corpus of §12.1 is
assembled inside the tests rather than kept as files (the point is a difference of a few bytes, so generating code is easier to read).
The items listed as "not started" in B.7, and the verification §12 asks for but B.6 had marked "not implemented", were implemented as far as this Mac has a judge for them. What has no judge (a real Numbers, a real Excel) and what the main text itself declares out of scope for v1 (streaming, the chart / pivot generation API) stay unimplemented, with the reasons kept at the end of this section.
UnopenableInput.probe looks
for the OLE compound file signature ([MS-CFB] §2.2); if the directory contains the stream name EncryptedPackage
(UTF-16LE) it is encrypted OOXML, otherwise a compound file of the 1997–2003 generation (.xls and the like). ODF leaves its
mimetype in plain text in the package and so would be detected as .ods, so the reader looks at
manifest:encryption-data in META-INF/manifest.xml (ODF 1.3 §4.3). All of these land in
SheetError.unsupportedFeature with a reason (they used to be corruptedContainer /
unrecognizedFormat, i.e. indistinguishable from "a broken file"). The check sits before the
facade's format detection — placed after it, a name like secret.csv would route an encrypted package into the CSV
reader. The fixtures come from Tests/FixtureGenerator/make_encrypted_fixtures.py (msoffcrypto-tool, LibreOffice,
and our own ODF 1.3 §4.3 encryption). No decryption — still out of scope as the main text says.PropertyTests covers (1) the bijection A1 ⇄ (row, col) (sampled, plus every column name 0…ZZZ exhaustively),
(2) that ODS RLE folding and expansion are inverses (write a randomly generated sheet full of gaps and read it back),
(3) the parse → emit → parse fixed point of randomly generated formula trees (both dialects),
(4) that parse is stable on random strings.
FuzzTests runs 7 kinds of byte-level corruption of the corpus and 6 kinds that open the package, corrupt just one
part inside and repack it (so the input survives the ZIP layer and reaches the XML / IWA readers), and checks that the landing is
always a SheetError. The default is a round count swift test can afford;
SWIFTSHEETS_FUZZ_ROUNDS / SWIFTSHEETS_FUZZ_SEEDS switch to a serious search.
Defects these two actually found: (a) FormulaExpr.parse("+漢!") indexed out of bounds on
input with "nothing after the sheet prefix" and took the whole process down
(a violation of pillar 5 in §12; a bounds check was added at the head of lexRowRange), (b) only one leading
= was stripped, so .unparsed("=X") became .unparsed("X") on re-parse and the fixed point was
broken (changed to strip all of them).cellStyles / cellStyleXfs, and the cell's xfId):
Workbook.namedStyles: [NamedStyle] (default [.normal]) and
CellStyle.namedStyle: String? (nil means "Normal"). NamedStyle.applied corresponds to openpyxl's
cell.style = "Title" (the style's formatting plus the link).
The link does not decide the appearance — a cell's CellStyle carries resolved formatting just like
the file's cellXf, and changing a named style does not change existing cells. This is exactly Excel's behaviour, and
it can also express "linked to Heading, with only italic overridden".
The writing invariant: not one entry of the source's cellStyleXfs is dropped, and both order and raw XML
are kept (unnamed entries really exist; dropping one shifts every later index). A known name reuses its slot, and only
the entry whose formatting changed in the model is rebuilt. New names are appended at the end.
Gaps in the judgement are closed on reading: an out-of-range xfId, duplicate names (first wins),
cellStyles alone / cellStyleXfs alone — all fall to "no link".
ODS is out of scope — it bundles automatic styles by value and so never had names, and the cell formatting itself rides on
CellStyle and round-trips, so there is no new loss.
dxfs / tableStyles remain verbatim preservation only.
Judges: openpyxl reads back cell.style == "Accent X" and wb._named_styles
(both directions of verify_with_openpyxl.py), the named-styles.xlsx made by openpyxl round-trips, and
LibreOffice opens the output.xl/comments*.xml, the shape of the callout in legacy VML
(xl/drawings/commentsDrawing*.vml). Without the VML Excel asks "do you want to repair". Implemented from
ECMA-376 Part 1 §18.7 and Part 4 §14.1, in the minimal form that both Excel and LibreOffice accept.
Reading puts the text and author into Cell.note and picks the size up from the VML style
(the unit is px — the same as openpyxl's Comment(width:height:)).
Coexistence with F3: the notes as read are kept in SheetPreservation.comments,
and on writing each sheet's current notes are cross-checked against them. If equal, the source's 2 parts are repacked byte for
byte (the byte-identity check in PreservationTests keeps passing). If different, both are rebuilt
at the same path and the same position, and if all notes are gone the parts, the relationship and
<legacyDrawing> are removed together (a dangling r:id is precisely what makes Excel ask to repair).
If the VML contained shapes other than notes (form controls and so on), a dropped warning says they are lost by the
rebuild. With this, notes no longer vanish in ODS → XLSX (one "known difference" in
make-verification-samples.sh disappears too). Judges: openpyxl reads back
ws["A7"].comment.text / .author (on the openpyxl side; both directions), and LibreOffice imports them as
office:annotation.A1:B5 B1:D5 is
B1:B5), OpenFormula uses ! (confirmed by observing LibreOffice's output — union is ~).
The lexer emits an operator only when the left of the space is a reference, column, row or name and the right can be a
reference. Any other space is skipped as before (SUM(A1, B1) does not break). The only case where the right
side begins with a digit is a whole-row range (A1:C10 2:2), so a digit is accepted only when a : follows
it. A space after ) is not covered — (A1,B2) (C3,D4) remains .unparsed.
The ODS pitfall: ! is also the separator of a sheet-qualified name, so
MyName!Other can be read as "Other on the sheet MyName". An intersection of two names is judged inexpressible in
OpenFormula (FormulaExpr.isExpressible(in:)), and the ODS writer falls back to the cached value plus a
degraded warning — we do not write something with a different meaning in silence.
Before the fix, FormulaEmitter wrote .intersect as a space in both dialects, so a tree containing an
intersection became invalid OpenFormula (it never surfaced because there was no path that reached it).Table.arrayFormulas: [CellRef: CellRange].
<f t="array" ref> is read and written back. Without the range Excel treats it as an ordinary formula and the
computed result changes.<pageSetup r:id> — the printerSettings part itself was preserved opaquely, yet the
attribute pointing at it alone was dropped (a dangling part).
It is kept in SheetPreservation.pageSetupRelationshipId and written back only while the relationship survives.Sheet.headerFooter.
The coded string with &L / &C / &R is carried as is, without
splitting (openpyxl splits it into left, centre and right). So that codes the model does not know are not lost in a
round trip.Sheet.rowBreaks / columnBreaks, 0-based indices.
The value of <brk id> is held with the file's own spelling (the number of the row above the break.
Rev 4.50, B.61).dropped / degraded warning per sheet — nothing is dropped in silence.Sheet.filterColumns
(the value list <filters> and the comparison <customFilters>) and
Sheet.sortState. Column numbers are, like OOXML's colId, relative to the first column of the
filter range. Not interpreting halfway is the point — colour, icon, dynamic (top 10, above average, this
month) and date-group filters are not in the model, so such an <autoFilter> is written back as the
source XML and Sheet.hasUnmodelledFilters is set (only one of the generated and the preserved output is
emitted, so there is no double output). Writing back the criteria we could read, half done, would change the rows Excel shows.
ODS writes only the range (table:database-ranges) and turns the criteria into a dropped warning.Sheet.tables[0],
and the second and later tables read from Numbers (or added with addTable)
vanished without a warning — the only breach of §14.1's "failure throws, loss warns".
A worksheet is one grid, an ODS sheet is one <table:table>, CSV is one grid, so
dropping them is itself correct. We made it say so: a dropped warning per sheet
(subject: .sheets, with the sheet name, the number dropped and ".numbers would keep them" in the text).
CSV had only ever warned about "the other sheets", so the same hole was open in all 3 formats.
No flattening (see the table below).
Judges: write a two-table sheet to .xlsx / .ods / .csv and check that the warning appears
and that only the first table is written correctly (WriterTests.extraTablesOfASheetAreReported,
ODSCodecTests.extraTablesOfASheetAreReportedWhenWritingODS,
CSVCodecTests.extraTablesOfASheetAreReported).
At this point the subject piggybacked on .sheets, so WriteResult.suggest
gave the wrong advice "XLSX would keep them" — resolved in B.14 of Rev 1.9.What was left unimplemented (with reasons). To keep "not implemented" from being written and then forgotten, the reason for each is fixed here, one by one. If a judgement changes, this table is corrected, not the main text.
| Item | Current state | Why it is not implemented |
|---|---|---|
Streaming read and write (read_only / write_only) |
Implemented in Rev 2.0 (B.15) — StreamingReader / StreamingWriter. Reading in Rev 4.22 (B.40) and writing in Rev 4.25 (B.42) gave ODS, Numbers and CSV the same entry point |
(The former reason: the optimisations stacked on the whole-model design have a different contract.) In practice the model did not have to be rebuilt — the existing codecs were left alone and a separate path handling only values and styles was placed beside them. Deciding not to preserve made the contract conflict disappear. |
| Conditional formatting API | Implemented in Rev 2.0 (B.15) — Sheet.conditionalFormatting |
(The former reason: no value is lost, and there is no reason to widen the API surface.) The policy changed on 2026-08-24 — the use case "decide programmatically under what condition a cell turns red" cannot be met by preservation alone. |
| Modelling data validation on reading | Implemented in Rev 2.0 (B.15) | (The former reason: writing alone meets the use case, and we did not want to take on consistency with the x14
extension form.) It turned out that x14 lives in the worksheet's <extLst>, a separate child
element, so <dataValidations> alone can be modelled while that is kept preserved. |
Flattening multiple tables (the NumbersWriteOptions(flattening:) that §14.9 once listed) |
Only the first table is written; the rest become a dropped warning (B.12) |
The flattening rule (one sheet per table / concatenate vertically / place at the anchor position) varies with the use case,
and whichever is made the default, the library would silently decide a meaning the caller should decide.
sheet.tables is a public model, so the caller can build whatever rule it needs in a few lines (§15.4). The loss is
now visible as a warning. The API surface was only just fixed in B.11, and there is no reason to widen it. |
| A generation API for charts and images | F3 preservation only | Out of scope, as designed in §1.3 and §14.11. What we read and write is data, not drawing. |
| Aggregation in pivot tables | The layout is written. The numbers are left to "recalculate on open" (B.15) | If we did the aggregation ourselves, this product would become a spreadsheet engine. That is a different product. Writing the layout and asking for recalculation is the road Excel itself takes. |
| GradientFill API | Implemented in Rev 2.0 (B.15) — Fill.gradient |
(The former reason: changing the type of CellStyle.fill breaks the API fixed in B.11.)
The policy changed on 2026-08-24. The reason is fixed in B.15 as a breaking change before 1.0. |
Extended properties (app.xml) | Regenerated | openpyxl rebuilds it too. Custom properties (custom.xml) were modelled in Rev 2.0 (B.15). |
| ZIP64 | Read and written (Rev 4.12, B.39.1) | Parts over 4 GB and more than 65,535 parts are read, and ZIP64 records are written only when needed. It used to be
corruptedContainer. |
| visionOS | Not reached (Linux was reached in B.38) | There are no Apple-only parts any more and nothing blocks it. But this environment has no judge that runs the full suite. We do not claim a platform nobody has run on. |
| Numbers formula generation (TSCE archives) | Implemented in Rev 2.3 (B.18) — the formula tree is
converted to TSCE.ASTNodeArrayArchive and written |
(The former reason: the reference implementation numbers-parser cannot write them itself, and this Mac had no Numbers.app.)
The premise changed on 2026-08-25 when Numbers.app took the stand as judge —— we can confirm that a written
formula is computed as a real formula on the real application. Shapes with no sample to copy from (defined
names, functions Numbers lacks, A:C column ranges, intersection / union) still fall to the cached value and say why. |
| Numbers style reading (F2) | Implemented in Rev 2.1 (B.16) — cell styles, number formats, links | As above: the premise changed once the judge was in place. |
| Numbers F3 —— patching back onto the document that was read | Not implemented. Writing patches the bundled empty template as §11.1 says | Using the document we read as the base would mean taking on consistency with every part we do not interpret. With a template the base is known, and when something breaks we can tell which side caused it. Even with F2 implemented, this judgement does not change. |
| Detecting Numbers encryption | Done —— if the package has an .iwph, we refuse with
unsupportedFeature("encrypted Numbers documents are not supported") (no decryption) |
This row was originally written with the state mistaken. All 3 formats refuse by naming the file "encrypted"
rather than "broken" —— OOXML by the compound file signature, ODF by the manifest, Numbers by the .iwph part.
Still no decryption (out of scope per §1.3). |
Writing dates as ISO 8601 (openpyxl's iso_dates) | Always serial values | What Excel reads is serial values. ISO text is openpyxl's own compatibility mode, and having both paths would give dates two ways to round-trip. |
| Opening checks on a real Excel and a real Numbers | Became a machine judge —— Tests/NumbersParity/verify_with_numbers_app.py (Rev 2.3) and
Tests/ExcelParity/verify_with_excel_app.py (Rev 4.5, Excel 16.112.2).
The remaining manual work is the pre-release checklist in MAINTENANCE.md |
(The former reason: not available in the development environment.) Both became available, so they are driven over AppleScript. Not everything is automated —— only the dialog the real application shows ("We found a problem") is looked at by a person. Where the screen state or Automation permission is required, the check says "cannot judge" rather than failing when they are absent. |
The version goes to 0.4.0 (a SemVer 0.x minor — additions only; the API surface fixed in B.11 is not broken).
A test watches that SwiftSheetsInfo.version and the README do not drift apart.
§14.11 said "data validation will be considered for v1.1", but a caller (Stream's schedule) needed to write to Excel a "drop-down that offers candidates but accepts other values too", so writing alone is opened first. Because the judgement changed, this section and the table above are canonical, not the main text.
DataValidation (SheetCore/Model) and
Sheet.dataValidations: [DataValidation]. It carries every attribute of CT_DataValidation
(kind / ranges (sqref) / formula1 / formula2 /
operator / errorStyle / allowsBlank / hidesDropDown /
showsInputMessage / showsErrorMessage / the 4 titles and texts / imeMode).
Defaults follow the file format's defaults (all false) — no "library preference" such as openpyxl's
showErrorMessage=True.hidesDropDown has its name turned round. The file attribute showDropDown is an
inverted attribute where 1 means "do not show the drop-down" (openpyxl too gives it the alias
hide_drop_down), and exposing it as is would be misread every time.
One instance of "a value carries meaning, not representation" (§3).<dataValidations> found in the file goes, as before, into
SheetPreservation.fragments and comes out without degradation when written back to the same format.
That a sheet read from a file has one can be seen from
Sheet.hasUnmodelledValidations (the same convention as hasUnmodelledFilters).degraded warning
(the schema allows only one <dataValidations>, so both cannot be emitted).
A newly built workbook — the caller's actual use case — has no fragment, so this path is not taken.dropped warning
(ODS appends to its existing per-sheet list). What cannot be written is not thrown away in silence, here as everywhere.type, formula1,
sqref and each flag must match (the OpenpyxlParity path). In addition
PreservationTests watches the element order (after conditionalFormatting, before hyperlinks).
The version goes to 0.5.0 (a SemVer 0.x minor — public API additions only; the surface fixed in B.11 is not broken.
The one exception is B.14's .tables, which goes into the same 0.5.0 and affects the compilation of callers that switch
exhaustively over the subject).
When B.12 added "the second and later tables on a sheet become a dropped warning", the subject piggybacked on
.sheets. As a result WriteResult.suggest
advised "saving as XLSX would keep them" — which it cannot, since XLSX is also 1 worksheet = 1 grid.
The record of the loss (the warning text) correctly said ".numbers would keep them", so only the one line of the suggestion was
off, but giving wrong information is worse than giving none.
.tables was added to ConversionWarning.Subject
(distinct from .sheets = the loss of a sheet itself, because the format that can keep it differs), and the choice
became three branches — macros → XLSM, tables only → Numbers, everything else → XLSX. The 3 formats' writers
switched the subject to .tables..numbers is named only when tables are the
whole of the loss comes from here. Not over-promising is protected both in the choice of format and in the count.switch exhaustively over the subject notice, on recompiling (runtime behaviour
is unchanged). After 1.0 this would amount to a major, so the decision was to put it in now, before publication
(2026-08-23). A deliberate one-time exception to B.11's "fix the API before 1.0", with the reason fixed in this section.APIContractTests.theSuggestedFormatFollowsWhatWasLost (tables only → Numbers,
mixed → XLSX with the count excluding tables, silence when the destination is Numbers) and, in the write tests of the 3 formats,
suggestion?.format == .numbers.The version stays at 0.5.0 (riding on the same minor as B.13. That it goes in while unpublished is the premise of this change).
Of the 12 items counted as differences from openpyxl 3.1.5, nine were implemented in this revision (decided 2026-08-24).
The remaining three are all by design and are not moved in this revision —
charts (213 entries in the ledger) and shapes / images (148 entries) follow §1.3: "what we read and write is data,
not drawing". The formula-decomposition API (42 entries) is not a difference in capability:
formulas are held as a tree, not a string, and follow row insertion (§5). We simply do not expose parts in the same shape as openpyxl
(Tokenizer / Translator); exposing them would create a second entry point beside
FormulaExpr.
Conditional formatting, data validation, structured tables, pivot tables and differential styles (dxfs) had until now been
preserved byte for byte. Now that they are modelled, they are read back and written again —
XML with the same meaning comes out, but not necessarily the same byte sequence. We removed them from what
PreservationTests checks under "byte identity of opaque parts" and instead check identity after reading back.
For charts, VBA, themes, images and everything else not modelled, F3 is unchanged.
The decisions, one item at a time. The common pattern is "what is in the model is rebuilt; what is not in the model stays as the original XML", and the judgement per item is how far the model reaches.
| Item | What went in | The boundary (what was not modelled) |
|---|---|---|
| Data validation (read) | Sheet.dataValidations is filled on read as well. The B.13 decision "reading is not modelled" is withdrawn |
If a <dataValidation> carries attributes outside the convention, that whole block is written back as the original XML and the model stays empty (nothing is better than reading half). The x14 form lives in the worksheet's <extLst> and is preserved as before |
| Advanced filters | RankFilter (Top10Filter until Rev 4.45) / DynamicFilter / ColorFilter / IconFilter / DateGroup added to FilterColumn |
Only <extLst> extensions remain under hasUnmodelledFilters. A column can be filtered in only one way (the format's xsd:choice), so if two or more are set, only the first is written, with a degraded warning |
| Document properties | Workbook.customProperties (docProps/custom.xml). Six kinds: text, integer, decimal, boolean, date-time and defined-name link |
ODF has meta:user-defined, so these round-trip with ODS too. Only the link kind does not exist in ODF; the target name is written as text with a substituted warning |
| Gradient fills and differential styles | Fill became an enum (.pattern / .gradient), and DifferentialStyle (dxfs) is read and written |
A breaking change before 1.0: the type of CellStyle.fill / Cell.fill changed from PatternFill to Fill. .solid(_:) / .none and reading patternType / foregroundColor still go through, so the only thing to fix is code that assigned a PatternFill(...) directly. A differential style cannot say "explicitly remove the underline" (<u val="none"/>), but the original entry is re-emitted as raw XML, so nothing is lost on a round trip |
| Conditional formatting | Sheet.conditionalFormatting. 17 kinds of rule, colour scales, data bars, icon sets, and a DifferentialStyle per rule |
A rule of an unknown kind, or one carrying an <extLst>, keeps its whole block as the original XML (hasUnmodelledConditionalFormats). Priorities are renumbered 1…n across the whole sheet — Excel dislikes duplicates, and only the order carries meaning |
| Structured tables (Excel's "tables", ODF's database ranges) | Sheet.structuredTables (StructuredTable, ExcelTable until Rev 4.45). Generates the set of four: the part, the content type, the relationship and <tableParts> |
The name clashes with Table, which represents the grid, so it gets a different name. It is not Excel-specific (it is written to ODS and read back too), so it does not carry Excel in its name (B.56). Duplicate and empty column names are repaired the way Excel does it (Column2, Item2). A table the format will not accept is not written, with a dropped warning. Attributes and child elements outside the model round-trip verbatim |
| Protection and scenarios | SheetProtection / WorkbookProtection / ProtectedRange / ScenarioList |
The format's booleans point in the direction of "forbidden", but the model names them allows…, in the direction of permission, and the codec inverts them (the same policy as hidesDropDown). Only the legacy 16-bit password hash is generated; the Excel 2010+ saltedHash (hashValue until Rev 4.56) / saltValue are only read and written back (as openpyxl does). The type's documentation says this is a lock that prevents accidents, not one that guards a secret |
| Pivot tables | Sheet.pivotTables (PivotTable plus PivotCache). Generates the layout part, the cache definition, the relationship and <pivotCaches>; Workbook.addPivotTable builds one from a range |
No aggregation is performed. A newly created cache sets saveData="0" refreshOnLoad="1" and has no records part — the application that opens the file reads the source range and computes. The reason: we do not write numbers we have not computed. The judges are openpyxl reading back and LibreOffice (we checked all the way to the converted values being aggregated correctly). Not opened in Excel itself — as with Numbers, it sits on the release checklist in MAINTENANCE.md |
| Streaming read and write | StreamingReader (openpyxl's read_only) and StreamingWriter (write_only). The writer streams to the file through ZipFileWriter, compressing one row at a time |
At Rev 2.0, XLSX / XLSM only (reading spread to ODS and Numbers in Rev 4.22, B.40; writing in Rev 4.25, B.42). Values and formatting only. Merges, comments and preservation are not handled. Strings are written directly into cells rather than into the shared string table (at the first row we cannot know what the last row will repeat), so a file with many repeats comes out larger than from the normal writer. Measured (750,000 cells): streaming write +3 MB, streaming read +54 MB (one sheet's XML after decompression), against +190 MB for a whole-model read |
<dxf> (LibreOffice does this often).
So that changing one rule's format does not repaint the other, we count references and, for a shared entry,
append a new entry at the end instead of overwriting.containsText and its kin are written as both a condition and a formula,
and the formula is read relative to the top-left cell of the range the rule covers. addConditionalFormatting
derives the origin from the range and rewrites the formula (hard-coding A1 would look at the neighbouring cell).features.xlsx) and a fourth (streamed.xlsx) were added to
verify_with_openpyxl.py. openpyxl 3.1.5 reads the output of
conditional formatting, differential styles, gradient fills, structured tables, pivot tables, the top-10 filter, custom properties and the streaming writer
exactly as expected. Headless LibreOffice passes both the PDF conversion and the re-conversion to XLSX.na_api 866 entries (before 950), adapted 411 entries (before 327).
84 entries moved from "no corresponding API" to "the same behaviour verified in Swift's shape".
The version goes to 0.6.0 (a SemVer 0.x minor. It contains one breaking change, the type change of Fill, but
in 0.x a minor plays the role of a major. This is the second deliberate exception to B.11's "fix the API before 1.0",
and the reason is recorded in the table above).
Decided 2026-08-25. Until then the SheetCore model had grown with XLSX as its reference, and the ODS and Numbers
codecs handled only "the bones of values and appearance". We decided to carry the features already implemented for the Excel format
over to ODS and Numbers as far as possible, with conditional formatting first, and record here the result and the reasons for what could not be carried over.
ODF's extension vocabulary (calcext:) and Numbers' undocumented format are both areas that must not be written from memory.
So both were taken from the real thing — for ODS, SwiftSheets itself wrote an "xlsx with every rule",
LibreOffice 26.2 ran --convert-to ods on it, and the XML that came out was used as the specification as is.
For Numbers, numbers-parser 4.16.3 stood as judge and every cell of the 11 fixtures was cross-checked.
Every spelling in this appendix is an observed value; not one is a guess.
What went into ODS. What ODF 1.3 proper can express is written in the core vocabulary; what it cannot is written in LibreOffice's extension namespace. The extension is treated as the truth, and the core form as backward compatibility when reading.
| Item | Where it lives in ODF | The boundary |
|---|---|---|
| Conditional formatting (first priority) | calcext:conditional-formats. All 18 kinds of rule — comparison, formula, the four text rules, the four top/bottom rules,
above/below average, duplicate / unique, blank / error, time period, colour scale, data bar, icon set.
The format applied is a named cell style in styles.xml (because calcext:apply-style-name points at a named style) |
ODF 1.3's own style:map is read only, never written. LibreOffice writes both, so
on a sheet that has even one calcext: block, style:map is ignored (otherwise the rules would be doubled).
ODF has no priority attribute, so document order is the priority. "Hide the value" on data bars and icon sets does not exist in ODF |
| Data validation | table:content-validations, and table:content-validation-name on the cell.
All 8 kinds, with the input message and the error message |
ODF lists the rules one by one at the head of the document, and cells point at them by name. Consequently the empty cells a rule covers must be materialised as well
(a rectangle that is too large is cut off at the same limit as existing merges). imeMode does not exist in ODF |
| Print setup | A style:page-layout and style:master-page per sheet (styles.xml).
Margins, orientation, paper, scale, centring, what to print, header / footer |
Excel's &L&C&R and field codes are converted both ways to ODF's three regions and text:page-number and friends.
Font specifications (&"Arial,Bold"&12) are dropped. The margin mapping is
"ODF's fo:margin-top = Excel's header margin; the header's fo:min-height = the difference" |
| Page breaks, print area, print titles | A page break is fo:break-before="page" on the row / column automatic style. The print area is the table's table:print-ranges;
title rows / columns are table:named-expressions inside the table, with table:range-usable-as |
The reader looks at table:range-usable-as, but the name is made _xlnm.Print_Titles —
LibreOffice's ODF import decides by the name (measured). Only when both rows and columns are present does the column side get a different name |
| Sheet protection | table:protected and loext:table-protection |
Protected ranges cannot be written. ODF protects the whole table and has no open windows inside it |
| Array formulas | table:number-matrix-columns-spanned / -rows-spanned on the origin cell | — |
| Structured tables and autofilters | table:database-range. A named one as a table; an anonymous one (__Anonymous_Sheet_DB__n) as the sheet's filter.
Per-column conditions are table:filter, sorting is table:sort |
Columns are joined with "and", so they are laid out flat, not nested (LibreOffice does not read a nested filter-and — measured).
Colour, icon, dynamic and date-group filters do not exist in ODF; they are dropped and reported. Banded-row styles are dropped too |
| Pivot tables | table:data-pilot-table (ODF's "data pilot"). The source range,
where each column is placed, and the aggregation function |
ODF refers to columns by heading text, not by position. ODF does not record where the headings go when there are two or more values, so when reading back we add them on the column side. The limit is the same as XLSX — the layout is written, but nothing is aggregated |
| Still dropped | — | Scenarios: an ODF scenario is a "shadow sheet", not a set of input values. Writing one adds a sheet. Tab colour: not in ODF 1.3 (LibreOffice drops it in the same conversion). Rich-text runs: ODF cannot hold formatting differences within a cell. All are reported as warnings |
What went into Numbers. Raised one step above F1 (values only): formatting can now be read and written.
| Item | Where it lives in Numbers | The boundary |
|---|---|---|
| Cell formatting (read and write) | A cell points at two entries in the table's style list — TST.CellStyleArchive (fill, borders, vertical alignment, wrapping) and
TSWP.ParagraphStyleArchive (font, colour, horizontal alignment). Both are deltas, so they are resolved by walking up to the parent.
A cell that points at nothing follows the table's defaults (body_text_style / header_row_text_style …) |
When writing, a new delta archive whose parent is the table's default is created and placed in the same
Index/DocumentStylesheet.iwa as the template's own styles. References across parts are registered in the package metadata's
external_references (real Numbers documents do this).
Font names are held as PostScript names, not family names |
| Number formats | TSK.FormatStructArchive — not a format string but a description: "kind + decimal places + thousands separator + currency + date pattern" |
A Numbers format describes only one appearance. Excel's multiple sections, colours and conditions are dropped and reported. Rating, checkbox and base-n have no Excel counterpart and fall back to General |
| Hyperlinks (read) | Attached to a range of rich text (TSWP.HyperlinkFieldArchive) |
The model has one per cell, as in Excel, so the first is taken and the presence of two or more is reported. Writing is not supported |
| Conditional formatting (could not be added) | It exists in the schema — TST.ConditionalStyleSetArchive,
TableDataList.CONDITIONAL_STYLE = 9, the cell record flag 0x80 |
Not written, because there is no judge. A rule's condition is FormulaPredicateArchive.predicate_type, an
int32 without names: it stayed a C enum on Apple's side and no names survive in the Protobuf.
This environment has no Numbers.app, numbers-parser has no API for conditional formatting,
and none of the 11 fixtures contains an example (scanned). Writing a guessed integer would produce a document that
Numbers asks to "repair", or one that paints a different cell in silence.
For the same reason as B.8's decision not to generate formula archives, it stays a dropped warning.
The condition for adding it: create one document with conditional formatting in Numbers.app,
add it to the fixtures, and observe the values of predicate_type |
| Still dropped | — | Data validation, pivot tables, structured tables, autofilters and sorting, sheet protection, scenarios, print setup, defined names, tab colour, row and column grouping, hidden sheets, notes, and writing links. All 19 are reported as warnings (nothing is dropped in silence) |
Following §10.1's "no hand copying", the two tables the Numbers side now needs are also
extracted from numbers-parser by scripts/extract-numbers-schema.py:
constants.json (the constants Apple left as int32 — number format kinds, alignment, duration styles) and
fonts.json (PostScript name → family name, 572 entries).
Mind the version mismatch: schema.json / registry.json / functions.json were
extracted with numbers-parser 4.19.0, while the two new ones come from 4.16.3 (the latest that installs on this Mac's Python 3.9).
Once Python 3.10 or later is available, re-extract everything with 4.19.0 (MAINTENANCE.md).
Judges. For ODS, LibreOffice 26.2 is both the supplier of the ground truth and the verifier —
we check that an ods we wrote, converted back to xlsx, restores all 24 rules, and that LibreOffice's own ods and
ours read back into the same model. For Numbers, we check that numbers-parser 4.16.3
matches exactly on the cell formatting of every fixture (bold, italic, font, size, text colour, horizontal / vertical alignment),
reads the documents we write without warnings, that LibreOffice's Numbers import can open them,
and that integrityProblems() is empty.
Numbers.app alone still has not stood as judge — opening the formatted output is
on the pre-release checklist in MAINTENANCE.md.
Decided 2026-08-25. B.16 was work in the direction of "carry the features Excel has over to ODS and Numbers". This is the reverse —
we count the features ODF has and OOXML does not, and put them in the model.
The source is the RelaxNG schema of OASIS OpenDocument 1.3 (OpenDocument-v1.3-schema.rng);
every element name, attribute name and candidate value is transcribed from there. Unlike the calcext case, a formal specification exists here,
so the specification, not an implementing application's output, is the primary source (LibreOffice's output is used only to confirm "it can be read").
| ODF feature | On the OOXML side | How it went in |
|---|---|---|
table:label-ranges — using heading text directly in formulas
(the "Sales" in =SUM(Sales) is a column heading, not a defined name) |
None. It corresponds to Excel 2003's "natural-language formulas", which were not carried into OOXML | Workbook.labelRanges. Three things: the heading range, the data range, and the orientation (column / row) |
table:consolidation — the definition of a consolidation (source ranges, destination, aggregation function) kept in the document |
None. Excel's "Consolidate" is a one-off operation; the definition is not saved | Workbook.consolidation (singular and optional in the schema too) |
table:detective — precedent / dependent arrows and circular-reference marks kept on cells |
None. Excel's trace arrows are on-screen only and do not survive in the file | Table.detective (a sparse dictionary [CellRef: CellDetective]).
Not a field on Cell — Cell is a hot type managed on a budget of 100–200 bytes each, and
we do not fatten every cell for a feature that is rarely used (the same judgement as B.10) |
Of table:calculation-settings,
use-regular-expressions / use-wildcards / case-sensitive /
search-criteria-must-apply-to-whole-cell / automatic-find-labels / null-year |
None. These settings change the meaning of search criteria; Excel holds them as application settings and does not write them to the file | Workbook.calculationSettings. Iteration (table:iteration) and
precision (precision-as-shown) also exist in Excel's <calcPr>, so they go into the same container |
table:null-date — the origin of dates |
Exists (date1904). But ODF can make any date the origin |
Mapped onto the existing Workbook.epoch. Only 1899-12-30 (the default, omitted) and 1904-01-01 are understood;
any other origin is read as the 1900 system with a degraded warning.
LibreOffice omits the default origin and writes it only for 1904 (measured), so we do the same |
office:currency — a cell holds "how much, in which currency" as data |
None. Excel's currency exists only inside the number format | The model does not grow. On read, we confirmed by measurement that the currency symbol can be recovered from the number format, so
office:currency may be discarded. On write, when the number format is a currency we emit
office:value-type="currency" and office:currency (so that other applications
can tell "this is an amount of money") |
table:tracked-changes / table:dde-links /
table:table-source / table:cell-range-source |
OOXML has counterparts (revision history, DDE, external data connections), but their shape is entirely different | Not added. However, remember them on read and report on write that they were dropped — at present they are discarded in silence, which violates "what is dropped is always reported" |
| Multiple hyperlinks in one cell | Excel has one per cell | The model stays at one. Report that the second and later ones were discarded (the Numbers side did so in Rev 2.1) |
| Shapes, cell background images, charts | — | Out of scope. Follows §1.3: "what we read and write is data, not drawing" |
What was added here is ODF-specific, so writing the same workbook to XLSX naturally drops it.
Because SheetXLSX is not changed (since Rev 2.1 the policy is not to touch the Excel side's reading and writing), the warning is
added by the facade in Sources/SwiftSheets — the warnings in the return value of Workbook.write(to:as:) /
write(as:) lets you confirm that "n ODF-specific features were dropped".
It does not appear on the path that calls XLSXCodec directly inside the package (since B.50 that path is not reachable from outside). This is a deliberate trade-off,
and we record it here as the only departure from the principle that "the codec speaks for itself".
Judges. The ODF 1.3 schema is the truth, and whether what we wrote is valid is checked against the schema's shape (required attributes, candidate values). On top of that, we have LibreOffice re-save ODS → ODS and see whether the elements come out intact — if they survive, LibreOffice understood the element; the same criterion B.16 used for conditional formatting.
All six elements survive LibreOffice's re-save. However, of the calculation settings, the four attributes
table:case-sensitive / table:use-regular-expressions /
table:use-wildcards / table:null-year are
overwritten by LibreOffice with its own application settings on re-save (
table:precision-as-shown and table:search-criteria-must-apply-to-whole-cell are kept).
So for these four, this loop yields no proof that "LibreOffice understood them" —
the file is written exactly as the schema says, and our own reader round-trips them completely, but
they are lost on the way through LibreOffice. This is LibreOffice's behaviour, not a defect in the writer, and
it is why the test checks the presence of the element rather than the calculation settings as a whole.
2026-08-25. Until this day there were three judges for Numbers support — our own reader's round trip, numbers-parser (the reference implementation) and LibreOffice's Numbers import. All three said "readable". The fourth, namely Numbers.app itself, the file's destination, arrived on the machine, so we had it open the files for the first time.
Numbers 15.3.1 refused the sample.numbers SwiftSheets had written as
"can't be opened because it is damaged". The template (empty.numbers) opens.
A document with one table on one sheet opens too. The moment a second sheet, or a second table is added, it stops opening.
The three judges had all missed it because all three look objects up by identifier, while
Numbers alone trusts the package's table of contents (TSP.PackageMetadata) and goes looking for files.
Two defects were found. Both lie on the copying path (the second and later sheets; the second and later tables).
| Defect | What was happening | The fix |
|---|---|---|
| A copied component has an empty locator | The template has components without a locator. An empty locator means
"it lives in the file named by preferred_locator", which is
Index/Tables/DataList.iwa. The copy inherited that as is while being written into
Index/Tables/DataList-907375.iwa, its own file.
Numbers trusts the table of contents, opens the original file, does not find the copy there, and judges the whole document "damaged" |
registerComponent builds the locator from the path the copy was actually written to.
It uses the real values in doc.locations, not a guess |
| A copied sheet brings an extra table with it | The source of a sheet copy was the template's sheet itself, after the second table had been added while processing the first sheet.
The second sheet was copied together with that table; the model has no corresponding table, so nobody touches it.
Numbers shows it as "a table whose cells cannot be read" (fetching
table 2 of sheet 2 over AppleScript fails) |
Copy all sheets first, before any is touched. Only the template's shape before it has grown is ever the source of a copy |
Since B.8 we had said "Numbers formula archives cannot be generated (the reference implementation cannot either)" and fell back to cached values;
now it is in. We read the TST.TableDataList (kind FORMULA) of the 11 fixtures
field by field and wrote NumbersFormulaEncoder as the inverse mapping of the reader
(NumbersFormulaDecoder). The formula tree becomes a reverse-Polish node sequence
(TSCE.ASTNodeArrayArchive) placed in the table's formula list, and the cell record's
formulaID (flag 0x200) points at it.
| What can be written | What falls back to the cached value (and why) |
|---|---|
Arithmetic, power, concatenation, the 6 comparisons, negation, percent,
function calls (names in the 345-entry function table), ranges (A1:B2),
relative / absolute references, whole columns (A:A), array constants ({1,2;3,4}),
empty arguments, and numeric, string and boolean constants |
References to another table (Sheet2::Table 1) — no example among our 11 fixtures nor in
the documents we had Numbers create.Defined names — Numbers has none. Functions Numbers lacks — the name is not in the function table. Column ranges such as A:C and the intersection and union operators — no example.Formulas that could not be parsed ( .unparsed).In every case a degraded warning says where and why it was dropped |
One more thing was fixed along the way. Swift's Decimal normalises 10 as "1×101", but
Numbers writes 100 as 100×100 (measured in the fixtures).
Written normalised, the reference implementation displays 2^10.0 — the value is the same, but it no longer looks like the formula that was asked for.
encodeDecimal128 now resets the exponent to 0 when the value is an integer.
Tests/NumbersParity/numbers_app.py (a thin driver's seat over AppleScript) and
verify_with_numbers_app.py (the judgement itself). The judgement has four parts —
(1) does it open without asking "Repair?", (2) are the values, merges and dimensions what we put in,
(3) are the formulas still formulas in Numbers, and do they give the same answers, (4) can it still be read after Numbers is made to save it again.
The document written for (3) lays out the "requested expression" in column D, the formula itself in column E and the "expected answer" in column F —
so that the judge does not have to carry a second table of expected values, and the numbers-parser judgement reads the same columns.
open but then answers nothing about that document.
Since this is indistinguishable from a broken file, available() looks at
CGSSessionScreenIsLocked and names it$TMPDIR/swiftsheets-numbers-*) and says "the operation is not permitted" in a dialog.
The document is first copied to .build/numbers-judge/ and opened with
open -a (LaunchServices), not AppleScript's open —
the former hands the sandbox the right to read; the latter does notdocuments never returns.
exists front document answers at once. Sheets and tables, too, must be iterated by index rather than
repeat with x in …, or the loop fails while still pointing at a table on another sheet
A set of specimens of stepwise increasing complexity (the 13 that NumbersProbeTests writes to
.build/numbers-judge/probes) is provided as well. 00 is the template itself; if that does not open,
we know the problem is the machine. The next specimen to be refused is then the name of the feature we broke.
The only reason B.16 decided "not included" was that predicate_type is an int32 without names
and we had not a single real example. Now that Numbers.app was available, we had it make the examples.
.xlsx with the 25 kinds of Excel conditional formatting, one kind per column.
Give every rule a value no other rule shares (11, 12, 13–14, 15–16, 17, 18, 19, 20,
"pp", "qq", "rr", "ss"), so that they can be identified without relying on order.numbers_app.resave and save it as .numbers.TST.ConditionalStyleSetArchive back to the original columns by parameter.
Of the 25 kinds, 14 survived. The 11 that did not survive are Numbers saying "I do not have that rule",
so the writer turns the same 11 kinds into dropped warnings — not a gap in our support but
the same behaviour Numbers itself shows on import.
predicate_type | Rule in the model |
|---|---|
| 7 / 8 / 9 / 10 | cellIs greaterThan / greaterThanOrEqual / lessThan / lessThanOrEqual |
| 5 / 6 | cellIs equal / notEqual |
| 13 / 32 | cellIs between / notBetween |
| 3 / 4 / 1 / 2 | containsText / notContainsText / beginsWith / endsWith |
| 17 / 34 | duplicateValues / uniqueValues |
| The remaining 11 kinds | Colour scales, data bars, icon sets, top/bottom, above/below average, blanks, errors, time periods, expressions — the same ones Numbers drops on import |
We stumbled twice on how to write them. Both are things only Numbers can tell us; without the judge we would not have noticed.
TST.ConditionalStyleSetArchive
in the same file as the list that points to it. We had put it in the stylesheet, so
Numbers could not resolve the reference and refused the whole document. Placed in the list's file, it opened.
The judge's answer. Numbers opens the document we wrote without asking to repair it, and the rules survive a re-save.
What is more, the saved document carries a formula field we never wrote —
Numbers rebuilt an expression from the predicate, which is the best possible evidence that it understood the condition.
Numbers fills in rules_prepivot in the same way. The real example is
Tests/SwiftSheetsTests/Fixtures/numbers/conditional-formats-15.numbers (made with Numbers 15.3.1).
Taken the same way. An .xlsx carrying these three was read by Numbers 15.3.1,
saved as .numbers, and the placement was read one field at a time.
TST.RichTextPayloadArchive,
and beyond it a TSWP.StorageArchive (the same text container Pages uses). The cell record has type
automatic (9) and a richID (flag 0x10).table_smartfield with a character position points to a
TSWP.HyperlinkFieldArchive (url_ref and a UUID string).
At the same time table_char_style points to the template's
character-style-hyperlink — when reading, this is not counted as "formatting the author applied".table_char_style a
TSWP.CharacterStyleArchive (a variant of character-style-null) is created.
An entry with no object means "back to the default".TSD.CommentStorageArchive (body, author, UUID), and the cell record's flag is
0x80000 (a bit that had no name until now). The author is a
TSK.AnnotationAuthorArchive, registered in the document's AnnotationAuthorStorage.
Notes with the same author name share one author.
A cell's text container needs the paragraph tables even when there is only one line —
table_para_style, table_para_data, table_list_style,
table_para_starts and in_document. Numbers will not open a document that omits them.
Our own reader and numbers-parser both read it without complaint, so without the judge we would not have noticed.
In addition, the stylesheet, paragraph style, list and character style that the container names by name live in another part,
so unless that crossing is declared in the package's table of contents it again will not open (as with conditional formatting).
The reference takes the form of a CELL_REFERENCE_NODE with an AST_cross_table_reference_extra_info attached,
and the coordinates stay relative to the cell holding the formula even though they point into another table.
The problem is how the target is named:
the table's table_id string (the 16 bytes of the UUID packed into four little-endian words).
Not the table's haunted_owner, and not the base owner of the dependency records — write either of those and
Numbers reads #REF! (we found that out by getting both wrong).
The reader had the same hole: cross-table references written by Numbers were not being resolved.
We had Numbers 15.3.1 create an empty document, swapped it in as the writer's template and measured. All 810 automated tests stay green,
and Numbers opens the document. But the formulas are not calculated — every formula cell without a cached value
comes up empty. The current template was written by an older version of Numbers, and when Numbers opens it, it
recalculates everything. For a document written by the same version it does not; it trusts the saved state.
Removing refs_to_dirty (the "needs recalculation" record only the new template carries) changed nothing.
So before moving to the new template, we must first be able to write the dependency records ourselves. The maintenance procedure's condition — "replace the template when Numbers refuses our output" — is not yet met. We decided not to replace it here (approved 2026-08-26).
The corpus only had files from the Numbers 11–14 era. Two written by Numbers 15.3.1 are added —
conditional-formats-15.numbers (5 kinds of conditional formatting) and
links-notes-15.numbers (links, in-cell formatting, notes, a cross-table formula).
Both have the theme images and the preview stripped to keep them at 110–120 KB (decoration Numbers adds,
not needed for reading or writing). They are included in the numbers-parser cross-check as well.
| Open item | What is missing now |
|---|---|
| Conditional formatting that references another table | The rules themselves are in (below). Only the form where the reference reaches into another table needs a real example |
| Formulas that reference another table | Likewise needs a real example. Having Numbers create one would give it to us |
| The Numbers 15 template | Swapping it in stops formulas from being calculated (above). After we can write the dependency records |
| Array-formula ranges, data validation, pivot tables, print setup and others | Things Numbers does not have at all, or whose placement we have no real example of. Dropping is reported with a warning |
2026-08-26. After the previous night's cross-check (B.18), among the Excel features the pivot table was the only one left where Numbers itself preserves more than we do. When Numbers imports an Excel pivot it turns it into a real Numbers pivot (not the aggregated values, but a live table that can be rearranged). We were dropping it. That gap is closed here.
The usual method. A workbook with 17 pivot arrangements was written as .xlsx, read into Numbers 15.3.1,
saved as .numbers, and the resulting archives were read one entry at a time
(.build/numbers-judge/ground/pivot-shapes-by-numbers.numbers).
| Archive | Role |
|---|---|
TST.TableInfoArchive |
A pivot is one table info holding two table models.
is_a_pivot_table is true, tableModel is the table that is shown,
pivot_data_model is a copy of the source data (not placed on the sheet), and
pivot_order / view_column_row_uids give the ordering |
TST.PivotOwnerArchive |
The rules themselves. grouping_columns_for_rows / _for_columns name columns by
the source table's column UIDs, aggregate_columns is the aggregation, and
source_table_uid is the base owner UID of the source table's TableInfo
(neither the haunted_owner nor the table_id) |
TST.GroupByArchive |
Which groups exist. group_column is the column of the level, group_node_root is
a node per actual value (the value and the source row numbers that fall into it). Aggregated values are not needed here |
TST.ColumnRowUIDMapArchive |
The base_column_row_uids of the source-data copy. Column UIDs are assigned here, and the rules name them |
agg_type values observed
agg_type is a uint32 Apple has given no names to. As with the conditional-formatting
predicate_type, we had Numbers read one workbook with one kind per sheet
and read the result off the number of archives produced and their correspondence to the sheets. All 11 kinds have a counterpart on the Numbers side.
| SwiftSheets | agg_type | SwiftSheets | agg_type |
|---|---|---|---|
| count | 1 | average | 18 |
| sum | 2 | product | 21 |
| min | 4 | stdDev | 26 |
| max | 5 | stdDevp | 28 |
| countNums | 13 | var | 30 |
| varp | 32 |
The number and content of the group_by entries in the source-data copy's CategoryOwnerRefArchive depend on the arrangement.
| Pivot shape | group-by on the source-data side |
|---|---|
| Row levels only (one or two) | One — holding the row-level columns in order |
| Both rows and columns | Two — (1) the column levels followed by the row levels, (2) the row levels alone |
| Column levels only | Two — (1) the column levels, (2) an empty one holding no columns at all |
We can now write the rules, the source-data copy, the group tree, the column UIDs and the owner registration, and
our own reader reads back the correct aggregates (East 8/4/12, grand total 36). The consistency check passes too.
But opened in Numbers 15.3.1, the pivot table's cells show errors.
So NumbersWriter.writesPivotTables stays false, and pivots are
dropped with a warning, as before.
Passing a broken document through in silence is worse than dropping.
| What was isolated | Result |
|---|---|
| Transplant our group tree into a working sample | The sample breaks → the tree had gaps too. Matching the node entries one by one, we found the missing
row_lookup_uids on the root and fixed it; after that, at least the grand total appears |
| Match the scaffolding (table info, the two models) entry by entry | Three differences — the shown table's base_column_row_uids (which patch had been erasing),
the source-data copy's number_of_header_rows, and spill_owner on both models.
The first two are fixed. What remains is spill_owner and how the owner chain is assembled |
| The owner chain | In the sample the source-data copy hangs under the pivot's base owner with owner_kind = 100.
We made the same shape, but something is still missing. This and spill_owner are the next things to look at |
For a while we read the situation as "Numbers recomputes the aggregated values, so they need not be written". That was wrong.
Measured twice more with the corrected judge, a document without the aggregator has
every aggregate cell showing 0. Numbers draws the saved aggregates; it does not
re-add the source data every time it opens. So the writer
must also write the accumulators (accum in TST.GroupByArchive.AggregatorArchive).
The judge's waitForDocument picks up front document. Numbers keeps documents open, and
when it is killed with killall -9, the next launch restores the previous session.
The result was the observation that two documents identical as archives return different answers.
The same document answering both 8|4|12 and 0|0|0, and the kitchen sink (the previous experiment's document)
being saved when we had asked for the 17-pivot sample — all of it was this.
Two fixes. Close every document, then quit gracefully (a forced quit triggers the restore). And match the name of the document that answered against the name we asked for; if the names differ, the measurement is discarded as a failure. Not one observation from before these were in place has been adopted.
The remainder that B.19 called "one or two hills to go" was pushed on by the same method. This time the sample is
a one-to-one control made from the very same workbook — the
.xlsx we wrote, read into Numbers 15.3.1 and saved as .numbers.
Same input, same pivot, so every difference is our fault.
Field paths present in the sample and absent from ours went from 72 to 0. What was filled in:
| Item | Until then |
|---|---|
The aggregation accumulators (aggregator / TST.AccumulatorArchive) |
Not written. Count, min, max, sum and product are walked in the same order as the group tree and placed at the same coordinates |
| The root of the group tree claimed only one row | The sample claims 1…8. The set of row numbers is now also written as contiguous runs
(range_begin + range_end) — the same shape as the sample |
| The cells of the summary table did not carry the group's UUID | In the sample, the column and row that draw a group carry that group's own UUID. This is the knot that ties the tree to the table,
and we had been assigning UUIDs unrelated to the table. In the order list, too, the sentinel (1, 0) was
at the head, not the tail (B.19's description was wrong) |
A category_owner_deprecated left over in the source-data copy |
Came from the template. It meant the group list existed twice |
| The owner kinds | The owner of the spill area is 12, the copy's group-bys are 205 and 206 (we had been writing 8 and 205) |
| The reference-form tree and accumulators | The sample writes the tree twice — embedded in the group-by, and again as one archive per node
linked by child_ref. 15 of them for two group-bys |
formula_store (the three references by which the pivot declares "I read this table") |
The first cell, the range below the header, and the header row. Each is taken by function 168 with one argument |
column_agg_type, row_uid_lookup, refresh_timestamp |
Not written |
The previous section's diagnosis, "what remains is the calculation engine's ledger", was wrong. Strip the ledger
(the owners' dependency vessels, the cell-record tiles, refs_to_dirty) from the sample and the sample still draws perfectly —
Numbers draws directly from the tree, the accumulators and the rules, not from the ledger. On this day, an experiment breaking a working sample one piece at a time
(the T series), a normalised diff of subgraphs and an exhaustive cross-check of the UID wiring fixed all of the following, each by measurement:
| What was fixed | How it was found |
|---|---|
The sorting of the UID maps and their permutation tables. sorted_*_uids is literally the UUIDs in lexicographic order, with
index_for_uid[sorted position]=actual position and uid_for_index[actual position]=sorted position.
Ours was unsorted with an identity mapping — a map a reader doing binary search cannot look anything up in |
Working backwards from the sample's index arrays |
| The semantic position of the sentinel (1,0) is the end of the axis (the previous section's description was right, and the later correction to "the head" was wrong; it only looks first in the sorted table because 0 is small). Further, in the summary model's space the sentinel denotes the grand-total lane itself | Same as above |
The summary grid is exactly the size of the axes. The grand-total row and column do not go into the grid but into
the data store of a third model, TST.SummaryModelArchive ("Grand Total" + each row total + each column total + the grand total) |
Decoding the sample's tiles |
The body cells are all formulas. An expression of a single node, CATEGORY_REF of type 66, naming the
group-by's UID, the aggregate column's UID, the aggregation kind, the number of levels and the UID of the group node the cell stands on.
A row total is the outer node (level 1) of the row-axis group-by, a column total the outer node (level 1) of the first group-by, and the grand total the root's sentinel (level 0).
The cached value lives in the cell record alongside it |
Matching the sample's formula table against the group nodes one by one |
What the owner kinds really are: kind 17 = the pivotOwner's own UID, kind 8 = the TableInfo's
group_by_uuid, kind 9 = the summary model's aggregate_formula_owner_uuid,
kind 4 = the owner of the hidden state, kind 11 = its column-side extent, kind 3/5/6/10 = per-table free space.
Every owner has 8 dependency vessels even when empty (adding a single bare owner makes the whole document unopenable) |
Reverse lookup of the registered UIDs across every entry of both models |
| Dissolving the source-data copy's identity as a table. The sample's copy has no TableInfo and no kind-1 owner; its haunted sits directly under the kind-100. Ours kept the clone's identity, so one model had two owners | Matching the owner graphs |
A wrong prefix on the clone's copy target: we wrote TST.TrackedReferenceStoreArchive
but the correct one is TSCE.. Because of this every table cloned from the template shared
a single reference tracker (a real bug, not limited to pivots) |
Detected as the only point where both models pointed at the same object |
Row sets written as contiguous runs; the root claims all rows; grouping_column_uid is
shared between rules and tree and is in fact the UID of the label lane; refresh_timestamp;
the materialised tree (one archive per node, linked by child_ref); formula_store (the three references) |
Various |
As a result, field paths present in the sample and absent from ours are at 0, and the five-way UID wiring (tree, order, grid, summary model, view) matches completely. numbers-parser and our reader read back the correct aggregates, and Numbers itself opens the document without repair and recalculates from our rules on its own, all the way to the grand total of 36 (the summary tiles of the re-saved document had 12/9/15/16/20/36 written in them). Only the display is missing.
Transplanting in both directions — our whole pivot set (rules + group-by + all maps) into the sample's environment works completely, the sample's set into our environment does not work — settles that the pivot archives themselves are innocent. On the environment side, these were ruled out: part wiring (both are a single part with no cross-references), ViewState (removing it changes nothing), the sheet ledger (removing the empty Table 1 changes nothing), layout caches, merge maps, the presence or absence of owner registration (the sample still works with 205/206/17 removed), the ledger, and swapping the version records. As a control, a re-save of the sample is adopted unmodified (the uids are identical too), whereas ours is saved with every uid regenerated and an "empty sort" inserted at the head — that is, at load time Numbers stops adopting our saved state, falls into the rebuild path, and that path's sort comes out empty. Only that trigger is still unidentified.
The diagnosis the previous two sections left standing was "our document is stamped with an old version, so at load time Numbers stops adopting it and falls into the rebuild path". That is wrong. A control experiment swapping nothing but the version stamps refuted it.
| Experiment | Result |
|---|---|
| Open the sample (26.3.1) stamped as 14.1 (all four layers: the package's file_format_version / read/write_version, the CalculationEngine part's version and
feature_infos, Properties.plist, BuildVersionHistory.plist) |
Zero assertions, full rendering. It passes the old-version audit and draws correctly |
| Open ours (14.1) stamped as 26.3.1 | The "table corruption" audit disappears, but the 7 TSTPivotStoreIterator assertions stay exactly as they were, an empty shell |
Sound content passes even with an old version; broken content fails even with a new one. The version and the upgrade path are innocent, and this line of inquiry is closed here. The experiment matching all three layers of version stamps — package, component and object — to the sample (34 re-stamps) was likewise without effect.
Until now we had measured only the most complex shape. Writing four shapes and having each judged as its own document, removing the entire grand-total lane leaves the number of assertions unchanged, and simplifying the shape does not fix it. And the number of cells that pass equals "number of header columns + 1" in all four shapes.
| Shape | view / base | Non-base cells | Assertions | Passed | Header columns |
|---|---|---|---|---|---|
| Everything (rows, columns, grand total) | 4c×6r / 3c×5r | 9 | 7 | 2 | 1 |
| No grand total | 4c×6r / 3c×5r | 9 | 7 | 2 | 1 |
| Row levels only | 3c×5r / 2c×4r | 7 | 5 | 2 | 1 |
| Column levels only (degenerate) | 3c×3r / 2c×2r | 5 | 4 | 1 | 0 |
| Sample | 4c×6r / 3c×5r | 9 | 0 | 9 | 1 |
The two maps the translator consults are the plain getters of TSTTableTranslator, and neither
TSTPivotTranslator nor TSTCategoryTranslator overrides them. They are
TSTTableInfo._columnRowUIDMap and TSTSummaryModel._columnRowUIDMap.
And both are created empty at load time —
-[TSTSummaryModel loadFromUnarchiver:] creates it by calling
initWithContext:columnUIDs:rowUIDs: with two empty arrays,
and afterwards uses insertColumnsWithUIDs:atIndex: / insertRowsWithUIDs:atIndex:
to fill in only the summary skeleton. So the key set at run time is
rows = {the grand-total and summary rows}, cols = {header columns} ∪ {aggregate and grand-total columns}, and
the body's data columns and data rows are not in it.
| Non-base cell | Row key | Column key | Result |
|---|---|---|---|
| Grand-total row × header column | present | present | goes through |
| Grand-total row × grand-total column (the grand-total corner) | present | present | goes through |
| Grand-total row × body column | present | absent | gives up (2 cells) |
| Data row × grand-total column | absent | present | gives up (5 cells) |
The number that goes through is header columns + aggregate columns = hdrCols + 1, which matches what we measured for the four shapes above.
We retract one statement in this appendix.
The previous two sections confirmed matches such as "the five-way UID wiring matches completely" and
"view∩summary all match" by cross-checking the maps saved in the archive
(view_column_row_uids, column_row_uids and base_column_row_uids).
Those are rebuilt at load time and are not used as they are.
So the argument "the maps match, therefore they are cleared" does not hold, and every match check in that
direction was comparing values that are never used at run time.
It is no surprise that no difference was found.
The run-time map is filled from the summary skeleton. In the sample that skeleton is built with the groups included;
in ours it is built with zero groups. The evidence is in re-saving — when we let Numbers itself save our document again,
the view map shrinks from 6 rows × 4 columns to 3 rows × 2 columns. That is the natural size of a pivot with zero groups.
So the coordinate assertions are a result, not a cause: at load time not a single group is adopted.
The next thing to look at is not the maps but why the code that assembles the summary skeleton at load time
does not adopt our group-by (the side that drives insertRowsWithUIDs:).
Things we tried on our side that had no effect: the version stamps (all 3 layers), the objects' message version stamps,
the order of the summary rows, filling the summary's header buckets, the tiles' numrows, removing the wide offsets,
deleting the layout cache, deleting merge_region_map, replacing every group-node UUID with the sample's values,
injecting the cell flag 0x1000, deleting the empty group_node_map, deleting the UI selection state.
Things the sample kept drawing even after we broke them: emptying the summary buckets, making the pivot tiles wide,
swapping the groups' coordinates, removing the flag 0x1000, replacing the old-style cell records with 🤠 filler,
trimming the offsets to exactly the column count, fully reversing the ZIP entry order, stamping the equivalent of 14.1.
We also checked that our means of observation is sound. Round-tripping all 81 IWAs of the sample through numbers-parser without modification still draws completely. So the decoder drops nothing, and the comparison results can be trusted. We also scanned the unknown fields directly from the wire, but both documents had only chart decoration there, and it was identical. On top of that we did an exhaustive comparison of values matched by role (shortest field path) — 1215 against 1242 items — and confirmed that every remaining difference is one of (a) ordering that stems from uids being random, (b) styles and dimensions, (c) things the experiments above cleared, or (d) an identifier, a name or a timestamp.
deleteCloneScaffolding(tableInfo:) — the copy of the pivot's source data is made by hijacking the
tableModel of a table cloned from the template. After the hijack, the clone's scaffolding (the TableInfo, the caption,
its own summary model, the category order) is now deleted as a whole island. The island is decided by reachability, so
anything the rest of the document names (the copy's model itself, the sheet, the shared stand-ins) falls outside the island,
and no survivor ever references a deleted object. The ledgers (ComponentInfo and
object_uuid_map_entries) are cleaned at the same time. The copy's model now has exactly one owner,
and the 30 consistency-check problems and the "table corruption" audit are gone. The drawing is not fixed.
Evening of 2026-08-27. We followed, in the disassembly, the one point the previous section named — "the next thing to look at is the side
that drives insertRowsWithUIDs:" — and pinned down one rule of this format. The drawing is not fixed yet,
but the rule itself is backed by experiment.
Numbers does not look up a pivot's group-by by the UIDs written in the archive. It computes a UID by adding a sub-owner number to the owner UID of the source copy (the pivot data table), and asks the category owner for that UID. If nothing is found, that slot stays empty.
-[TSTGroupBySet restoreFromPivotDataTable:…] @ 0x6bd928 (TSTables arm64, 15.3.1)
loop 0x6bdbd4–0x6bdc50:
[categoryOwner groupByByUid:
formulaOwnerUIDForBaseUIDAndSubownerIndex(pivotDataTableUID, 205 + i)]
cbz x0 → that slot stays nil
groupByForRowGroups (0x6bee88) = _groupBys[columnGroupings.count]
Once the groups are zero, -[TSTPivotTranslator resetViewMap] (0xb6b5c8) throws away the contents of the view map with
replaceRowsWithUids: / replaceColumnsWithUids: and refills it, so the size becomes
header columns + 1 × header rows + 1 + grand-total row =
2 columns × 3 rows. The phenomenon the previous section recorded as "on re-save the view map shrinks from 4×6 to 2×3"
is this. The coordinate assertions are merely a consequence of that shrunken map.
Checking the sample, there is not a single exception. It is 128-bit addition.
| Kind | Base | Owner | Check |
|---|---|---|---|
| 1 (the table itself) | — | …49451aa0c1a872e3 | base |
| 8 | …872e3 | …872eb | +0x08 |
| 17 | …872e3 | …872f4 | +0x11 |
| 35 | …872e3 | …87306 | +0x23 |
| 100 (source copy) | …872e3 | …87347 | +0x64 |
| 205 (column-side group) | …87347 | …87414 | +0xCD |
| 206 (row-side group) | …87347 | …87415 | +0xCE |
The group_by_uid of TST.GroupByArchive is identical to the owner UID of 205 / 206.
The check is tools/ownercheck.py in the workshop repository: 33 of 33 in the one-to-one sample,
381 of 381 across the 17-variant sample. Not a coincidence but an invariant of the format.
We confirmed by experiment that it is a necessary condition. Merely moving the sample's two group UIDs away from their
derived positions (changing not one other byte) turns the sample into exactly the same empty shell as ours
— a 20-byte Pivot-Summary.csv and the coordinate assertions.
So the rule is real, and if it is not obeyed, nothing is drawn.
Every UID is supposed to look random. So the exhaustive comparison of values matched by role had classified this difference as cleared, under "ordering that stems from uids being random". The difference is not in the values but in the arithmetic relation between values, and a method that matches items one to one and compares them cannot see it, as a matter of principle. It is not an oversight but a blind spot of the method. From now on, for UIDs we look at relations, not values.
We added NumbersUUID.subowner(of:kind:) and now derive the source copy = the summary's base + 100,
the first group = the copy + 205, the second group = the copy + 206. The chain in the written document is now
isomorphic to the sample's. Numbers still does not draw it.
What is more, the fingerprint of the assertions does not move by even one from before the fix — full 7, rows only 5, columns only 4.
The gate we get stuck at is earlier than the group UID lookup.
| Suspect | How we broke it | Result |
|---|---|---|
| The haunted owner UID of the source copy | To random (3 places) | Draws completely; cleared |
| The 19 owner UIDs of kinds other than 100/205/206 | All to random (68 places) | Draws completely; cleared |
| The summary model's header records (ours are empty) | Emptied the sample's 4 | Draws completely; cleared |
header_columns_frozen (we delete it) | Deleted from the sample | Draws completely; cleared |
| The number-format table (we do not have one) | Deleted from the sample (5 places) | Draws completely; cleared |
Tried on our side with no effect — removing the empty table left on the pivot's sheet (the sample has none). And our encoder was cleared too: re-encoding our document through numbers-parser without modification gives the same symptom, so the way we write protobuf / IWA is innocent.
Night of 2026-08-27. By the previous section the static cross-checking was exhausted. We stopped comparing documents with each other and checked what the load actually asks for; the cause became clear from a single observation, and from there four errors fell one after another. Numbers 15.3.1 now correctly draws all 15 of the pivot tables we can write. Not a single assertion fires.
table_id
We checked, for real, the UIDs that -[TSTCategoryOwner groupByByUid:] (0x69b868) asks for during load.
| Document | UID Numbers asked for | Result |
|---|---|---|
| Sample | …49451aa0c1a87414 (+205) → …87415 (+206) → …87416 |
found, found, stop |
| Ours | 79 times in sequence starting from …734160be216f995b | none found |
Checking the requested numbers, both were exactly that table's table_id + 305
(305 = 100 + 205). The previous section read the starting point as the calculation engine's base owner UID,
and that was wrong. In the sample, table_id and the base owner UID are the same value, so
looking at the sample alone cannot tell them apart. In ours the two were unrelated random numbers.
| Error | Symptom | Fix |
|---|---|---|
| We wrote formulas into the body cells | The groups, row totals, column totals and grand total appear, but only the body cells are empty | Do not write them. Numbers itself places a CATEGORY_REF, and we wrote one in the same shape
(same group-by, same level, same node, same aggregate kind), but ours evaluated to empty
and erased the values that were already there. If we do not write it, the computed values show as they are.
Why the same formula computes on their side and not on ours is unresolved |
| We assigned random UIDs to lanes with no group | In a pivot table with only one axis, every value on that axis is empty | A lane that belongs to no group carries the sentinel UID (1,0). With two values, two sentinels stand side by side. The grand-total sentinel goes only on the axis that has a group (a rows-only pivot table has 2 columns, not 3) |
| The layout when there is no row split | A columns-only pivot table has no value row at all | 1 header row, 1 header column, 1 body row. We had written 2 header rows and 0 body rows |
What we can write is up to one vertical level, up to one horizontal level, up to one aggregated value. Of the 17 variants in the sample workbook, 15 fall under this, and all of them are drawn correctly, including all 11 aggregate kinds.
The remaining 2 are dropped without being written, and we say so (NumbersWriter.writes(_:)). Reading the sample,
a pivot table with two levels gets a subtotal row per group (9 rows vertically + the grand total), and a pivot table with two values
has the same sentinel twice side by side on the axis with no group. Numbers tells the latter apart by something,
but our map has no way to express that distinction. For the shapes we drop, we do not create the cloned table either — if we do,
the leftover table makes the document itself fail to open.
2026-08-26. When Stream (a WBS + Gantt editing app)
wrote out a .numbers with SwiftSheets 0.7.0, not a single Gantt bar was in it.
In a 70-row sample, 66 cells vanished, and not one warning was issued. It does not happen with .xlsx or .ods.
A sheet is not made of values alone. Gantt bars, weekend columns, legend swatches, striped backgrounds are
all drawn with cells that have only a colour and no value. The model keeps this kind of cell properly
(Cell.isBlank becomes false the moment the formatting is no longer the default), and the reading side has restored them all along.
Only the write side dropped them, and it did not issue a ConversionWarning either.
That is a head-on violation of this library's central promise, "never discard in silence" (§10.3, README).
| Place | What it did | Fix |
|---|---|---|
The cell scan in NumbersWriter.patch(tableInfo:with:…) |
guard let stored = cell.value else { continue } — a cell with no value was skipped
before its formatting, link or note were touched |
Carry the value around as CellValue? and keep scanning.
The model does not keep genuinely empty cells, so any cell still here is here for a reason |
The case nil in NumbersWriter.record(for:…) |
When there was no value, no record was created at all unless there was a formula or a note. So merely passing the check above still produced no fill | If the cell names a style (cell / character / number format / conditional), create a
.generic record. Only a cell that says nothing at all stays nil —
otherwise the table's whole rectangle becomes records, and the file size is decided by the dimensions rather than the content |
That formatting can be expressed by a .generic record was something this document itself had already proved —
conditional-formatting rules place a CellStorage.encode(type: .generic, conditionalStyleID:) even on cells with no value.
So the right fix was not to add a warning but to make it writable.
Since it is written, nothing is dropped, and no warning is issued.
NumbersStyles.characterProperties said
if let size = font.size, size != base.size, meaning
"a size equal to the model's default need not be written". But base = Font.default is
Calibri 11 (Excel's default), while the default of the body_text_style in the Numbers template we write into
is HelveticaNeue 10 (measured). The omitted 11 does not come back through inheritance, so
only cells that asked for exactly 11pt were drawn at 10pt
(8, 12 and 14 pass straight through, which makes it hard to notice). If the model states a size, write it as it is.
(1) A style that states only a size is not a style.
A CellStyle that specifies nothing but $0.font.size = 11 is
CellStyle.default itself, and the model has no distinction between "11 was specified" and "nothing was said".
So that cell is written as plain and inherits the template's HelveticaNeue 10 —
burning an explicit Calibri 11 into every plain cell would be a change to the look of all output,
not the fix of a defect. So the current behaviour stays, pinned by elevenPointAloneIsTheDefaultStyle —
if it is ever changed, it is treated as a breaking change for 1.0.
(2) The same error remains in font_name.
The name != base.name on the line just below has base = Calibri while the template's default is HelveticaNeue, so
a cell that specifies Calibri becomes HelveticaNeue in silence (confirmed by measurement).
The structure is identical to the size case, but fixing it changes the typeface of every document this library writes.
For the same reason it stays as it is, with only a note in the code — also a candidate breaking change for 1.0.
| Judge | Before the fix | After the fix |
|---|---|---|
Round trip through our own reader (NumbersValuelessCellTests) |
All 80 of the 80 filled cells vanish | All restored. 827 green overall |
numbers-parser 4.19.0 (verify_valueless_cells.py) |
0 of the 9 bar cells. C1 asked for 11pt and got 10.0 | 9 of the 9 bar cells. C1 is 11.0 |
| Numbers.app 15.3.1 itself (PDF export) | An empty grid with not a single bar — exactly what Stream reported | All 3 staircase bars drawn across all 7 columns. The document opens clean |
| LibreOffice's Numbers import (to PDF) | Bars blank (the route by which Stream first noticed) | Bars appear. But it is a weak judge — we measured that even for a values-only 4×7 table it draws only up to the 4th column. It cannot be used to check the right edge of the columns |
During the work, /Applications held an unfamiliar name, Numbers Creator Studio.app, and
mdfind kMDItemCFBundleIdentifier == 'com.apple.iWork.Numbers' returned nothing, so
we wrongly concluded "there is no Numbers on this Mac; a different app is posing as it".
Two mistakes. The identifier of Numbers on macOS is com.apple.Numbers, not
com.apple.iWork.Numbers; and the bundle's name comes from the raw
CFBundleDisplayName, which each .lproj translates back to "Numbers", so
both the Finder and the Dock show "Numbers" — only ls shows the bundle's name.
The real thing is Apple's own Numbers 15.3.1.
spctl -a -vvv gives source=Mac App Store /
origin=Apple Mac OS Application Signing, _MASReceipt/receipt is present,
codesign shows Identifier=com.apple.Numbers with an Authority chain up to the Apple Root CA,
version.plist says ProjectName = Numbers, the copyright notice is Apple Inc.,
App Store id 361304891. The right question was neither "the name" nor "the location", but
whether the signature of what LaunchServices resolves to is Apple's.
Because of this mix-up we came close to discarding the fourth judge that B.18 had set up.
2026-08-27. The item B.20 wrote up as "an error of the same structure remains in font_name; fixing it changes the typeface of every document,
so it stays as a candidate breaking change for 1.0" went in as that breaking change.
| Before the fix | After the fix |
|---|---|
if let name = font.name, name != base.name — base is Font.default,
that is, Calibri. A cell that specified Calibri was omitted as "same as the default, need not be written" and
was drawn in the destination template's default HelveticaNeue |
if let name = font.name — a typeface the model states is always written.
Exactly the same fix as for the size (B.20), and with it the comparison against
Font.default has disappeared entirely from characterProperties |
The model has no distinction between "the caller specified Calibri" and "the caller said nothing"
(both are style.font.name == "Calibri"). Therefore every cell that has a style is now written
with an explicit Calibri, even if it did not specify a typeface. It comes out in Excel-like Calibri rather than the
HelveticaNeue of plain Numbers — a change to the look of all output.
Since it is a change of policy on appearance rather than the fix of a defect, it went in explicitly as a pre-1.0 breaking change
(2026-08-27; the pre-1.0 proviso in §14). The CHANGELOG also puts it under Changed, not Fixed.
The boundary is the same as in B.20. A style that states only a typeface is CellStyle.default itself, so
that cell is written as plain and inherits the template's HelveticaNeue
(pinned by calibriAloneIsTheDefaultStyle).
Calibri is not a standard macOS font. On this Mac too it is in none of
~/Library/Fonts, /Library/Fonts or /System/Library/Fonts, and exists
only inside /Applications/Microsoft Excel.app/Contents/Resources/DFonts/
(fonts bundled with Office are known to be private to Office). Asking plain CoreText for
Calibri falls back to Helvetica — treated the same as a typeface name that does not exist.
So we measured whether "even if written, Numbers would draw it in a different typeface". The answer is no — Numbers 15.3.1 finds Calibri correctly and draws it. Counting the typefaces embedded in the PDF it exported, Calibri, Georgia, Verdana and HelveticaNeue are each present as the genuine article:
/AAAAAD+Calibri ← the cell that specified "Calibri" /AAAAAE+Georgia /AAAAAF+Verdana /AAAAAB+HelveticaNeue
Even in a document with a single cell that stated no typeface (but does have a style), what gets embedded is
Calibri (plus the title's HelveticaNeue), which directly confirms that the side effect above really happens.
Whether it can be drawn locally is the reader's business; the writer's job is to write the name the model stated, as it is
— that line has not changed. On a Mac without Office, Calibri is substituted (an ordinary thing that happens with any reader).
| Judge | Result |
|---|---|
| Round trip through our own reader | The 5 typefaces Calibri, Georgia, Arial, Verdana and Helvetica Neue round-trip. 832 green overall |
| Numbers 15.3.1 itself (typefaces embedded in the PDF) | Each specified typeface is embedded as the genuine article (above) |
2026-08-27. We inspected in full how the proprietary features of Excel / ODS / Numbers are handled in cross-conversion. The inspection took two forms. One is an inspection per direction — open a "real file saved once in that format", write it in another format, and for each thing that vanished check whether there is a warning that names it. The other is an inspection across generations — save the same document 3 times and see whether every part stays well-formed each time, and whether anything drops along the way. The former yielded 3 defects, the latter 1.
The root element of xl/pivotTables/pivotTable1.xml has attributes the writer always writes on its own judgement
(applyNumberFormats, updatedVersion, createdVersion,
outline and so on — 15 of them). But the reader's list of "known attributes" had only five:
name, cacheId, dataCaption, rowGrandTotals and
colGrandTotals. The rest were remembered in
otherAttributes as "attributes the model cannot express", and on the next save were written once more, alongside the writer's own.
<pivotTableDefinition … applyNumberFormats="0" … updatedVersion="8" …
applyNumberFormats="0" … updatedVersion="8" …> ← the same attributes twice
A duplicated attribute is XML that is not well-formed, not "XML that is hard to read".
XMLParser stops with NSXMLParserAttributeRedefinedError (111),
and Excel says "We found a problem with some content". What is more, our reader swallowed that failure with
try?, so at the moment of the second save the pivot table had vanished without a warning
— the hardest-to-find way of breaking the promise "never drop in silence".
pivotCacheDefinition has the same structure (createdVersion, refreshedVersion,
minRefreshableVersion, saveData). And since
files written by Excel carry these attributes too, this defect is not limited to round-tripping our own files.
Open a workbook with a pivot table made in Excel with SwiftSheets and save it, and it was broken on the first save.
The obvious fix is "add the 15 to the reader's known list", but that guarantees the same defect recurs — every time the writer adds one attribute, someone has to remember to fix a list somewhere else. The defect this time was precisely that lapse of memory.
So we put in RootAttributes (SheetCore/Utils/XML.swift), which
collects the names actually written in that call, and appends the attributes from the file only if they are not among them.
Even for attributes such as saveData or r:id that are written or not depending on a condition,
on a pass where it was not written the file's value survives as it is — which a static exclusion list would have dropped.
On the reader's side, we also stopped doing continue when a part's XML could not be read, and it now says
<path> could not be parsed; the pivot table it describes was skipped.
This is why the return value of WorkbookReader.read went from Workbook to
(Workbook, [ConversionWarning]) (matching "reading also returns losses" in §6).
| Item | Until now | From now on |
|---|---|---|
| Array formula → Numbers | Only the formula of the anchor cell was written; the range it spilled over vanished without a word | degraded "N array formula(s) written as ordinary formulas in their anchor cell".
Says that both the value and the formula remain and only the range is lost |
| Protected ranges → Numbers | Lost inside "sheet protection dropped"; a document that set only protected ranges was told nothing | Split into a warning separate from sheet protection (matching the wording on the ODS side) |
| VBA → ODS / Numbers | Buried in the .objects / .other tally of "9 parts cannot be carried".
As a result WriteResult.suggest advised "it survives if you use XLSX" —
macros are dropped in XLSX too |
Looks at PreservationStore.hasVBAProject and names it with .macros.
The suggestion becomes .xlsm |
The subject (ConversionWarning.Subject) is not decoration for display but
the basis for deciding which format to recommend instead (B.14). So saying "parts dropped" for
"macros dropped" was not a coarseness of wording but wrong guidance.
There was one more, a 4th of the same shape. The names of pivot columns (pivotField/@name) had been
excluded from otherAttributes by the reader on the grounds that "the model has a place for it", yet
the writer was not writing it. So it vanished in silence on every round trip (the table of who owns which attribute
was inconsistent in both directions). The writer now writes it.
| Item | What happens today | Recommendation |
|---|---|---|
Workbook.convert throws away the read warnings |
The return value is only a WriteResult. Converting a Numbers document with a chart and
8 cell controls to .xlsx returns 0 warnings (the reader had said it properly) |
Fold the read warnings into the return value. But that changes the meaning of the public API, so get approval before putting it in |
| ODS to any other format always says "a calculation setting is dropped" | LibreOffice writes automatic-find-labels="false",
null-year="1950" and the iteration threshold into every ODS, so even when the user
set nothing, the values differ from the model's defaults and the warning is raised |
Narrow it to "say it only when a setting that affects the calculated result differs from the default". But which settings count as "affecting" needs judgement, so decide the policy first |
| Judge | Result |
|---|---|
| Per-direction inspection (9 directions + CSV) | Every item that disappears has a warning naming it by name.
CrossFormatConversionTests.everyLossIsNamedByItsOwnWarning |
| Generation test (3 formats × 3 generations) | Every part is well-formed and nothing is dropped across generations. Confirmed that reintroducing the defect turns 3 tests red at once |
Numbers.app (readsWhatNumbersMadeFromExcel) |
That Numbers itself replaces Excel's data validation with a pop-up menu is now fixed as an assertion, not a guess (until then it fell through a hole in the allow list) |
| Overall | 837 tests green |
2026-08-27. The two items found in the B.22 inspection but left unfixed, because the meaning of the public API and where to draw the line for warnings needed judgement, were put in once the policy was decided. Both say the same thing from different sides: the promise "nothing is dropped in silence" is made of both speaking when there is something to say and staying silent when there is not.
Workbook.convert reported only half the trip
convert is the only entry point that does "open, then write in another format" in one line,
and it is also the only entry point that does not hand the workbook back to the caller. Even so, its return value was
only the write's WriteResult; the read warnings were discarded together with readWarnings.
Measured. Converting chart-and-control-15.numbers, which holds one chart and 8 cell controls, to .xlsx:
What the read said Data!C2: 8 cells starting at C2 carry a Numbers control …
Data: the sheet holds a chart, which the model has no place for
What convert() returned 0 warnings
The reader had said it properly. It was the entry point that threw it away. What is more, a Numbers chart and cell controls are reported only on read: they never enter the model, so the write has nothing left to report. In other words, a user who came through this entry point had no way at all to learn of it.
It now returns the outbound warnings first and the return-leg warnings after them, concatenated. suggestion is unchanged,
because "which format would have kept it" is a question about the write. The type does not change, so
public API compatibility is preserved (only the count grows).
This one is the opposite mistake. The test was !calculationSettings.isDefault, that is,
does it match the default the model decided on. But LibreOffice writes
its own defaults into every ODS it saves. And those differ from ours:
| Setting | Model default | Value LibreOffice writes | Does it actually take effect? |
|---|---|---|---|
table:automatic-find-labels | true (the ODF default) | false | No. This is a permission, "a formula may name a heading it has not declared", and it only means something once there is a formula that uses it. A formula that does is reported separately as a label range |
table:null-year | none | 1950 | Yes. But it changes how a two-digit year typed from now on is read, not the existing content |
table:maximum-difference | none | 0.0001 | No. Iterative calculation is off, so no calculation engine ever reaches this threshold |
As a result, even a LibreOffice file in which the user had set nothing at all raised a warning on every conversion. Not a lie. But a warning that appears every time stops being read: the value of "nothing is dropped in silence" rests on warnings being rare. This warning was eating into the credibility of the others.
We changed the question. Not "does it differ from the model's default" but
"would the destination read this document differently".
CalculationSettings.asAssumedOutsideODF records what an application reading anything other than ODF
always does, whatever the file says: Excel has no per-file calculation settings,
always ignores case, always reads search criteria as wildcards, always matches whole cells,
never iterates circular references, and places the two-digit years 30–99 in the 1900s.
differences(from:) returns only those settings that are in effect right now and differ from the destination,
as human-readable clauses.
a calculation setting is dropped — a two-digit year starts its hundred years at 1950 here, and at 1930 there; only OpenDocument keeps it in the file
"In effect right now" is why the step count and the threshold are not counted while iterative calculation is off. A step count that no calculation engine ever reaches is not a loss worth a sentence.
| Document | Before | After |
|---|---|---|
| A blank workbook | 0 | 0 |
| An ODS saved by LibreOffice (the user set nothing) | 1 "a calculation setting (search criteria, iteration, the two-digit-year window) is dropped" | 1 "the two-digit-year window starts at 1950 here and at 1930 there": the count is the same, but what it says is different. Not a vague set of 5 items but the 1 item that actually differs, named by name |
| A workbook holding only an iteration step count (iteration off) | 1 | 0 |
| A workbook with regular expressions enabled | 1 (a set) | 1 (by name) |
| Judge | Result |
|---|---|
convertAnswersWithBothHalvesOfTheTrip |
The 2 read warnings are in convert's return value, in the order they were read.
Confirmed that reverting to the implementation before the fix turns 3 assertions red at once |
aCalculationSettingIsReportedOnlyWhenItWouldChangeSomething |
A blank workbook and LibreOffice's defaults give 0; the two-digit-year window, regular expressions and the iteration switch are named by name. Likewise, 4 assertions go red on the implementation before the fix |
| Overall | 839 tests green |
2026-08-27. Among the data validations that the Numbers support table counted as "Numbers has no word for it",
the list type (drop-down) did have a word: the pop-up menu.
The evidence is Numbers' own behaviour, measured in both directions. Import an Excel list-type validation and it becomes
a pop-up menu (observed since around B.18). Conversely, have Numbers export a document with a pop-up to Excel and it comes back as
a type="list" validation listing the choices
(allowsBlank, showsInputMessage and showsErrorMessage all on,
errorStyle unspecified = stop). We make the same replacement, on both read and write.
| Layer | Entity |
|---|---|
| The table's list | TST.DataStore.control_cell_spec_table,
a TST.TableDataList (listType CONTROL_CELL_SPEC = 12). The template's tables carry an empty one too |
| A list entry | cell_spec (TST.CellSpecArchive):
interaction_type: 7 (pop-up), chooser_control_popup_model pointing at a
TST.PopUpMenuModel, chooser_control_start_w_first: true.
The menu body lives in the same part file as the list |
| The menu's choices | A sequence of tsce_item (TSCE.CellValueArchive).
The first is always NIL_TYPE (the blank choice). Strings are STRING_TYPE (format_type 260),
numbers NUMBER_TYPE (format_type 256, decimal_places 253, both the double and the decimal128 representation) |
| The cell side | Cell storage flag 0x400 names the list key. A cell with no value
holds only the control key in a generic-type record (Numbers' own empty drop-down takes this shape) |
Of the list type we write only the form whose choices are listed inside the rule ("a,b,c").
A range-reference list (Choices!$A$2:$A$4) would change the rule's meaning if frozen to today's referenced values,
so it is dropped and reported as before. Strict, blank-allowed and input-message distinctions do not survive in Numbers anyway:
in an experiment importing 3 rules (lenient, numeric, strict), Numbers made all 3 into identical pop-ups.
Reading back is the reverse: a .list in the same shape as Numbers' own write-back
(allowsBlank, showsInputMessage, showsErrorMessage on).
As with conditional formatting, rules are read per table; the first table's go onto the sheet, and those of the second table onward are dropped and reported.
The relation to the grid also follows Numbers. A pop-up can only sit on a cell that exists, so a rule covering empty entry rows (the entry-form shape) widens the table that far, but only for rules that fit within 10,000 rows and 256 columns. The "whole column" rule common in Excel (a million rows) stops at the table's edge and is reported as degraded. In the experiment importing C2:C1048576 into Numbers, Numbers too stopped at its own grid (10 rows).
| Judge | Result |
|---|---|
| Numbers itself (opening) | All 18 staged specimens (including 17-popup-menus with 3 kinds of pop-up)
open without "Do you want to repair?" |
| Numbers itself (round trip) | When Numbers exports a document we wrote to .xlsx,
all 3 rules (string, numeric, a column of empty cells only) come back as type="list".
menu-choices also survives a save in Numbers format |
| Reference implementation | numbers-parser reads the 3 menus we wrote and their choices as they are.
The new specimen popup-15.numbers (made by Numbers 15.3.1; MAINTENANCE.md) was also
added to the expected.json cell cross-check |
| Overall | 863 tests green. The measured test of the feature table follows Numbers' surviving items 17 → 18 and warnings 24 → 23 |
2026-08-27. The 4 cell controls that B.24 left at "read only the value and say it was dropped",
checkbox, stepper, slider and rating, become a first-class word in the model,
CellControl (Cell.control), read from and written to Numbers in both directions.
Excel and ODF have no equivalent concept, so when writing to them only the value is kept and the drop is reported (a warning added to both writers).
Samples so far were "import an xlsx and save it", but controls do not exist on the Excel side, so that road is closed.
Instead we used the fact that Numbers' AppleScript dictionary has
checkbox / stepper / slider / rating for a cell's format, and
had Numbers itself create the 4 kinds of control and save them (specimen controls-15.numbers,
two cells per control: one with a value entered and one untouched).
| Control | interaction_type | Extra cell_spec fields | Cell value and format |
|---|---|---|---|
| Checkbox | 8 | none | Boolean (double 1/0) + format format_type: 263 (CHECKBOX) |
| Stepper | 4 | range_control_min / max / inc (the knob's range) |
Number (decimal128) + a DECIMAL format with automatic decimals (an explicit number format wins if there is one) |
| Slider | 5 | ||
| Rating | 6 | min 0 / max 5 / inc 1 |
Number + format format_type: 267 (RATING) |
Cells wearing the same control share a list entry (in the sample, the 2 checkbox cells are 1 entry with refcount 2;
the writer also shares an entry for equal CellControl values). One more rule learned from the sample:
a control cell always has a value. Numbers itself had put a value into the untouched cells
(false for the checkbox, the minimum for the stepper, 0 for the rating). The writer fills in the same way, so
if you place a control on a cell with no value and write it out, reading back returns false / the minimum / 0.
This is by design and is stated in the CHANGELOG.
On a cell of the wrong type (a checkbox on a string, and so on) the value wins; the control is dropped and reported as degraded (the same line as "the value weighs more" for links). If a list rule and a control contend for the same cell, the cell's own control wins, and that is reported too. On read, the sample's stepper had an irregular knob "with the cell's value set as the maximum", but it is read as data, without normalising.
When a control silently degrades to a plain cell, no "Do you want to repair?" appears. So in addition to the opening check,
we asked Numbers directly, over AppleScript, for the format of each cell in the document we wrote.
| Judge | Result |
|---|---|
| Numbers itself (opening) | All 19 staged specimens (including 18-cell-controls with the 4 kinds of control)
open without "Do you want to repair?" |
| Numbers itself (per-cell query) | Answered checkbox / stepper / slider / rating for all 8 control cells
we wrote, and automatic for the plain cells |
| Reference implementation | numbers-parser reads the list entries (kind, knob, shared refcount) and the resting values as they are.
Specimen controls-15 was also added to the expected.json cell cross-check |
| Overall | 871 tests green. The feature table grows to 47 items (a cell-control row added; Numbers ○ / Excel and ODS ×), and the warning counts for the everything-in document follow: Excel 6, ODS 9, Numbers 23 |
2026-08-27. The 2 remaining candidates (autofilter, array formulas) were first put to Numbers itself. Both by "import an Excel file and see what it makes", the usual method.
Importing an xlsx with a value filter (pass only East) and hidden rows gave this result:
the filter list stayed empty (FilterSetArchiveTypeAll, is_enabled: false, the default empty set
every table has), and even the hidden rows were restored.
Since no replacement that Numbers itself recognises exists, we do not invent one and write it either (the same line as the pivot's "do not write a shape that cannot be drawn").
Dropped and reported, as before. This is settled.
Importing an xlsx with =A1:A5*2 in B1:B5:
the origin B1 is the ordinary formula itself, and the covered B2 to B5 are
a formula passing an absolute reference to the origin to the nameless function 337 (numbers-parser
renders it as UNDEFINED!($B$1)). The 4 cells share one formula archive, and each cell holds
the value computed at import as its cache.
Reading is now in hand. The 337 shape (2 nodes: a cell reference plus function 337) is recognised structurally,
such cells are read as values, and they are gathered per origin and restored as ranges in Table.arrayFormulas.
Converting a Numbers-made document to xlsx now carries the array formula as a real array formula with its range.
The decode warning (function id 337 is unknown) is gone too. Specimen array-15.numbers added to the corpus.
| Experiment | Result |
|---|---|
| Write the same shape (origin formula + 337 expansion) byte for byte and have Numbers open it (the formula archive matched the sample exactly except for the presence of translation_flags; that was added and the experiment repeated) | The origin evaluates as =@A1:A5×2 (with @ for scalar context) and
the expansion cells are all empty. translation_flags is innocent too |
| Disguise Numbers' own sample as an older version (14.1) and have Numbers open it, forcing recalculation on load (reusing B.19's version-spoofing tool) | Even Numbers' own expansion loses its values. 337 cannot produce a value on recalculation; it is only a vessel carrying the value computed at import |
So the sample only appears to work because "a document of the same version is not recalculated". Our template is designed to be opened as an older version so that every formula is recalculated, and that stands on the trade measured and chosen on 2026-08-26, "switching to a newer template blanks every formula that has no cache" (B.18; recorded in MAINTENANCE.md). That design and writing 337 cannot coexist. It is the same wall as B.19's "a formula evaluates to empty and erases the value that was there".
Writing is settled as before (write the origin's formula and each cell's value, and report that the range does not survive), and this reason is written into the warning text. If a way opens, it will be when we can write the dependency records ourselves and so use a newer template, which shares the same premise as B.19's remaining items (the 2 pivot-table shapes).
| Judge | Result |
|---|---|
| Numbers itself | 2 import measurements (filter, array formula), 2 write experiments, 1 version spoofing. All staged specimens open without repair |
| Reference implementation | The values and formulas of array-15.numbers cross-checked against expected.json
(the 337 cells are excluded from the formula cross-check because numbers-parser itself has no name for them; the reason is stated in the test) |
| Overall | 874 tests green. The row's rating in the feature table is unchanged (× as the round trip measured). The note records the read/write asymmetry and the reason |
Numbers has 6 functions that fetch quotes from the internet:
STOCK, STOCKH, CURRENCY, CURRENCYH,
CURRENCYCONVERT and CURRENCYCODE (function table 298 to 303). They differ from every other function in that
the answer is not in the sheet but comes from Apple's quote service. Neither Excel nor OpenFormula has
functions of these names.
The sample was seeded from a document the maintainer made by hand (containing STOCK("VEEV",2)), and
Numbers 15.3.1 itself was made to add the full range of attribute forms over AppleScript: a numeric attribute, no attribute, a string attribute
(the form Numbers itself answers with #VALUE!), and STOCKH with a nested DATE.
Merely setting a cell's value to "=STOCK(…)" makes Numbers accept it as a formula and actually perform the fetch,
so no UI is needed to make samples for this family.
When Numbers exports the same document to Excel, all 6 functions become "the value that had been fetched", while
the =E2*2+1 placed beside them went out as a formula (measured). So the replacement Numbers itself has decided on is
"an ordinary formula stays a formula, a quote function becomes a value". We do the same:
stock-15.numbers added to the corpusFormulaExpr.remoteDataFunction finds the family,
writes the fetched value and reports it as degraded. With no cache, an empty cell
(empty rather than a wrong value)| Judge | Result |
|---|---|
| Numbers itself (write) | Opened the document we wrote, answered a formula of the form =STOCK(…) for all 7 cells,
and fetched the values again (queried with formula of cell) |
| Numbers itself (the export as a model) | Measured that on Excel export the 6 functions become values and an ordinary formula stays a formula |
| Reference implementation | The values and formulas of stock-15.numbers (including the #VALUE! error cache)
cross-checked against expected.json |
| Overall | 879 tests green. A "stock and currency functions" row added to the feature table (48 items; Numbers 20 surviving; Excel / ODS △ with one more warning each) |
The "2 shapes we cannot write" left by B.19, 2 levels on one axis and 2 aggregated values, were resolved by growing the samples to 7. Conclusion: multiple levels can be written on both axes (measured up to 3 nested levels and a mix on both axes). Values are limited to 1, and experiment established that this is a wall in the mechanism, not a hole in the implementation.
SwiftSheets itself can write multi-level Excel pivots, so one workbook per shape was written and imported into and saved by Numbers 15.3.1: a control (1×1×1), 2 levels (row side, column side), 2 values, 2 mixes and 3 levels, 7 workbooks in all. The judgement is by the byte count and content of the CSV export (a judging basis already checked as valid even while the screen is locked).
pivot_order) is in the same post-order plus a sentinel, and holds no heading lanesis_enabled stays truegrouping_column_uid (different per level), and the lane of heading words is the ASCII constant
"aggre names row" (rows) / "aggre names col" (columns)agg_formula_coords as there are values, and
there is one aggregator (aggregator) per value, walking the tree with the coordinates of its own block.
The list's order is the reverse of the rule's (as measured)
The lanes of an axis with no groups are all looked up on the map under the same marker (1,0);
Numbers' own document with 2 values also lists this marker twice. A map with the same key twice cannot be
told apart by binary search. Numbers' own document survives because it is not rebuilt on opening
(even with the version spoofed and the aggregators stripped, no rebuild happens; it just shows zeros), whereas our document, written from the older-version template,
is always rebuilt. Every arrangement we tried (the order of values, inventing lane markers, the body-cell formulas Numbers itself writes,
which in our document were re-measured to erase values on recalculation) drew 1 value or 0.
So it is settled: write only the first value, and drop and report the second onward.
If the door opens, it will be when we can write a document that triggers no rebuild (a newer template with the dependency records written by us),
the same key as B.26's array formulas.
| Judge | Result |
|---|---|
| Numbers itself (samples) | All 7 import samples drew completely (the correct CSVs were captured) |
| Numbers itself (write) | Our 7 shapes plus the all-17-shapes document all draw completely with 0 coordinate assertions (the numbers match down to the subtotal rows of 2 levels, the intermediate subtotals of 3 levels and the crossing cells of the mixes. 2 values draw completely in the shape of the first value) |
| Numbers itself (experiments) | Randomised node UUIDs (innocent), version spoofing plus aggregator stripping (established that no rebuild fires), attaching body formulas (re-measured that values disappear), inventing lane markers (confirmed that the value looked up changes) |
| Reference implementation | New specimen pivot-mixed-15.numbers (both axes, 2 levels) cross-checked against expected.json |
| Overall | 884 tests green (5 new unit tests, one per shape) |
The 3 items left unread had no samples, because none has vocabulary in AppleScript and they can only be made in the UI. The maintainer made 3 workbooks by hand in the UI of Numbers 15.3.1 (2 category levels, 2 filter rules, a stock cell in both the cell and the table form), and they were settled on those samples. The model for how to read them was, as always, asked of Numbers itself: we had Numbers export the same document to Excel, saw what survives and what disappears, and then decided how we read.
filtered in
hidden_states_owner → row_hidden_state_extent → base_hidden_states are resolved to row numbers through
base_column_row_uids and mapped to hidden rows, and the rules in filter_set are counted, dropped and reported.
The hidden rows read survive both to Excel and on write-back to Numbers (fixed by a round-trip test).
Measured, it matches Numbers' own export row for row.
The document with the filter switched off, first noted as "not measured", has since been measured too, on a second sample
in which the maintainer turned the same document's filter off: when it is off Numbers empties the list of hidden states
(the safe-side reading was correct as meant) and keeps the rules, still off. The model has no place for them, so
the off rules are counted, dropped and reported (measured that Numbers' own Excel export also discards them without a trace)category_owner (kind 8, with group columns). To decide how to read them, we had Numbers itself export a
categorised sample to Excel, and Numbers wrote out into cells exactly what is visible on screen:
a 5-row table became 14 rows with heading-only rows such as "関西" and "大阪府" inserted between,
and the whole of the data also shifted one column to the right. For a person looking at it, it is the original screen, but
the row count and the addresses change, so as data it becomes a different table (read this Excel
programmatically and a list that should hold 5 items looks like 14 rows). SwiftSheets' job is to carry data
faithfully, so we did not adopt this "export as it looks" approach and chose instead to pass the original rows as they are and
warn, naming the columns: "the categorisation by 地域, 都道府県 is dropped".
It does not fire for the zero-group-column group-by held by a pivot table's summary (the discriminator is whether group columns are present).
The document with categories switched off has been measured too, on a second sample: all that toggles is
is_enabled; the grouped columns and the tree remain whole (the same shape as filters). The remaining setup has
no place in the model and is dropped and reported, naming the columns (Numbers' own Excel export, when off, inserts no
heading rows and leaves no trace of the categories; measured)sort_order as a sequence of
"column number + ascending/descending", and applying them reorders the stored rows themselves.
So the data is already read in sorted order, and every reader already receives it. The rules, on the other hand, have
no place in the model and are dropped and reported, naming the columns. Numbers' own Excel export writes no
sortState (the sort record) either, passing only the sorted rows (measured)=STOCK("AAPL",0); the table form is
IFERROR(STOCK($A2,B$1),"—") referencing the heading cells, plus an attribute pop-up). So B.27 was already carrying it, and
no read-side implementation was needed. The table form's attribute pop-up is dropped and reported, as is usual for a second table (existing behaviour).
The warning text for the older generation's "stock control" is kept (no such specimen turned up)| Judge | Result |
|---|---|
| Numbers itself (the model for the export) | Collected the Excel exports of both the category and the filter samples. The filter's hidden rows match our reading row for row. Confirmed that the category export is one where "heading rows are interposed and the table changes shape" |
| Reference implementation | Three new specimens (category-15, filter-15, stockcell-15) cross-checked against expected.json. The joint cross-check of all 21 specimens is green |
| Overall | 888 tests green (3 read tests and 1 round-trip test added). List (2) "read nothing and stay silent" (the leftover from 2026-08-26) is now empty — drawn objects such as charts are not read, but every one of them is reported |
2026-08-28. The maintainer proposed an API for making several changes to a sheet within one scope —
a two-stage plan of a closure-based editSheet and, later, a ~Copyable session type.
Before adopting it as proposed, we reviewed the three frictions cited as motivation against the existing implementation and this spec.
Conclusion: adopt the closure version only, with transaction semantics (option 2 of the proposal).
The session type is deferred, with reasons.
The frictions the proposal cited were: (1) forgetting to write back is not a compile error (the edit vanishes in silence);
(2) taking the sheet out creates sharing, and the first change copies the whole sheet;
(3) what remains after a throw in the middle depends on how the code was written.
(1) and (3) are as stated. The best evidence was in this spec itself —
the usage example in §15.1 defined its own MyError.sheetNotFound, did a guard,
and carried a line annotated "it is a value type, so write it back". In other words, the recipe collection had confessed from the start
that every user was fated to hand-write the same four lines.
(2) is only half real. A one-statement edit through the name subscript (wb.sheets["Summary"]?["B4"] = 42)
already benefits from the in-place editing introduced in B.11 — the sheet is taken out of the array for the duration of the edit,
rewritten as its sole owner, and put back — so no copy runs. The copy runs only in the "take it into a variable and
write it back" pattern. What the new API rescues is not the copy itself but
being driven into the copying pattern whenever several changes are wanted together.
We adopted it after restating where the friction lies in these terms.
/// The changes reach the workbook only when the closure returns normally. On a throw, all of them are discarded
@discardableResult
public mutating func editSheet<R>(named name: String, _ body: (inout Sheet) throws -> R) throws -> R
/// Positional version. Out of range stops as a programmer error, the same as the collection itself (it does not throw)
@discardableResult
public mutating func editSheet<R>(at index: Int, _ body: (inout Sheet) throws -> R) rethrows -> R
The name was changed from the proposal's editSheet("Summary") (no label) to editSheet(named:).
Its siblings on Workbook that name a sheet — addSheet(named:), removeSheet(named:),
duplicateSheet(named:as:), moveSheet(named:to:) — all line up on named:,
and this is the answer to open question 3 of the proposal.
Every change inside the sheet is written within the closed scope. Renaming is one of them: change
sheet.name inside the closure and the usual validation, duplicate avoidance and formula follow-through work as they always do
(because the write-back goes through the Sheets subscript, which we nailed down with a regression test).
Option 1 (lend out the storage directly: zero copies, but a throw leaves the partial changes behind) and option 2 (edit a copy and swap it in only on normal completion: one copy, but after a throw nothing has happened) cannot both be had — "undoing what was changed part-way" needs the shape from before the change, and keeping that is exactly a copy. We chose option 2. "After a throw, nothing has happened" is in the same spirit as §14.1's "never create a path that drops in silence": it leaves no half-finished state behind in silence. The price of one copy is the same amount the old take-out-and-write-back pattern was already paying, so it is not a regression. For a one-statement edit that wants zero copies, the in-place editing through the name subscript (B.11) remains as it is. A reference-type model (openpyxl and the like) can only do this by hand-writing a deep copy — what has been called the cost of choosing value types becomes, here, a one-line dividend. This restatement is the core of the proposal, and it held up as is under review.
§14.3 defines access by name as "Optional, not KeyError", and the model layer had until now been a layer that does not throw
(removeSheet returns Bool, duplicateSheet returns Optional).
editSheet does not cross that convention; it redraws the line — those are queries
(not finding is one of the answers), and this is an operation (the requested edit not being performed
at all is a failure). An API whose purpose is to remove forgotten write-backs by construction would defeat itself if a mistyped name
made it do nothing in silence, so a missing name throws the new
SheetError.sheetNotFound(name:). The error that §15.1 had users build by hand
became part of the library's vocabulary. Only the positional version does not throw; it stops when out of range —
the same treatment as sheets[i] itself and removeSheet(at:), in line with
Swift's line "names are data, indices are the programmer's responsibility" and the asymmetry already present in §14.3.
Re-entrancy (touching wb itself inside the closure) needs no mechanism written to forbid it;
Swift's exclusivity rule refuses it at compile time — while editSheet holds wb exclusively,
a closure that tries to capture wb gets an overlapping-access diagnostic
(because the closure is a non-escaping argument, and this is the answer to open question 2 of the proposal).
~Copyable session type
The proposal's second stage (an ownership-managed session type with beginEditing / commit / discard)
is not added. Besides the fact that a need for edits that do not fit in a closure has not been confirmed
(the proposal itself says "once the demand is confirmed"), the review added one positive difficulty:
while the session is alive the workbook side is free to change, so a commit(into:) after a sheet has been deleted or renamed
would have the old edit overwrite the new structure in silence
(with the closure version, the exclusivity rule forbids that overlap at the syntax level). If it is ever added, the rule for detecting
and refusing that overwrite is decided first.
| Pillar | Content |
|---|---|
| Guarantee of application | Several changes inside the closure (value, formula, style) are all visible once it returns. Carrying the return value out is checked too |
| Transaction | Edit part-way, then throw → the workbook is == to what it was before the call (Workbook is Equatable, so this is one line) |
| Missing name | sheetNotFound(name:) is thrown and the workbook is untouched |
| The rename nail | A rename inside the closure makes other sheets' formulas follow, and a colliding name escapes with a serial number — this catches a future rewrite in which the write-back bypasses the subscript |
| Overall | 896 tests green (4 tests on the editSheet contract added) |
2026-08-28. Stage 1 of the feature adoption plan drawn from a field survey of earlier libraries whose MIT licence we can keep
(four stages: protection key → images → column autofit → charts).
Until now the "modern key" on sheet, workbook and protected-range protection (algorithmName /
saltedHash (then named hashValue) / saltValue / spinCount, Excel 2010 and later) was
only carried; all we could produce was the 16-bit key of the 1990s.
We implement generation and close this asymmetry.
H0 = SHA-512(salt ‖ UTF-16LE(password)), then
Hn = SHA-512(Hn−1 ‖ LE32(n−1)) for spinCount rounds
(the iteration number is 0-based, 32-bit little-endian, and appended after the hash).
The salt is 16 random bytes; the default spinCount is 100,000, the same as Excel.
The salt and the final hash are written to the file in Base64, together with algorithmName="SHA-512".
The implementation is an adaptation of XLKit's (MIT, NOTICE item 3) CoreUtils.excelModernSheetPasswordHash.
We confirmed that the 0-based, appended iteration number agrees between that implementation and our reading of ECMA-376.
SHA-512 comes from CryptoKit. Putting the principle of never hand-writing cryptographic primitives first,
SheetCore's framework dependencies widen by one, from "Foundation + Compression" to "+ CryptoKit"
(B.1's zero external dependencies is unchanged — CryptoKit is an Apple-supplied framework on the same footing as Compression, and
no SwiftPM dependency is added. The situation of Linux being out of reach is unchanged too — and swapping in swift-crypto would give
the same API on Linux, which is if anything an advantage for a future return).
(Replaced by our own implementation in Rev 4.9 — B.38. Merely having CryptoKit meant the protection key was lost altogether on Linux.
swift-crypto would have been the first SwiftPM dependency, so it was not taken. The judges are the FIPS 180-4 known answers and CryptoKit itself.)
The salt's randomness comes from SystemRandomNumberGenerator — documented as cryptographically secure on Apple platforms,
so we do not go as far as adding the Security framework that XLKit uses.
The API is added as a pair on each of the three protection types:
/// Computes the modern (SHA-512) key and stores it in the 4 attributes. nil clears them all. Salt defaults to 16 random bytes
public mutating func setModernPassword(_ password: String?, spinCount: Int = 100_000, salt: Data? = nil)
/// Checks against the stored modern key. false if there is no key
public func modernPasswordMatches(_ password: String) -> Bool
The meaning of the old setPassword(_:) (the 16-bit key) is unchanged — so as not to change the behaviour of existing calls
in silence. Both methods can coexist, and Excel too accepts both sets of attributes side by side.
The hash computation itself is exposed as ModernPasswordHash.hash(_:salt:spinCount:)
(paired with LegacyPasswordHash, so users can do their own checking and inspection). — In Rev 4.57 both were lowered to package (B.67). passwordMatches(_:) / modernPasswordMatches(_:) are enough for checking.
An empty password is defined by the method, so it is not rejected (that Excel's UI rejects it is a matter for the UI).
spinCount <= 0 stops as a programmer error.
We implemented the same method independently in Python hashlib and nailed three known answers into the tests,
made with a fixed salt (AAECAwQFBgcICQoLDA0ODw== = the 16 bytes 00…0F):
"secret" × spinCount 10, "secret" × 100,000, and "秘密" × 100,000 — including the non-ASCII UTF-16LE path.
We also check the round trip (write, read back, all 4 attributes equal), whether checking succeeds and fails as it should, and that the old method is unchanged.
On 2026-08-29, the real Excel unlocked it (Microsoft Excel 16.112.2).
The judge is Tests/ExcelParity/verify_with_excel_app.py — not eyes but AppleScript driving
Excel, reading three points as state for each of three specimens: "protected right after opening", "the wrong password
does not unlock", "the right password unlocks". The three are
an ASCII password, a Japanese password (the UTF-16LE path), and workbook structure protection
(a separate path with different attribute names), and every one carries only the modern key — if the old method's
4 hex digits were written alongside, we could not say which one Excel had looked at. All three specimens passed.
This settled that the implementation agrees with Excel down to the position of the iteration number (appended or prepended).
There is a reason the wrong password is tried first: unprotect does nothing on a sheet
that is already unprotected. If the right one went through first, the wrong password tried afterwards would also
"raise no error", so an implementation that accepts any password would pass.
And one more thing we learned by measurement — Excel does not refuse a wrong password with an error.
It returns without a word and simply leaves the protection in place. That is why the verdict reads state, not the presence of an exception.
Only ProtectedRange (an editable window inside a protected sheet) was left out, because there is no straightforward
way to try unlocking it on the real application.
The misses on the way are recorded too. The attempt to make LibreOffice the judge (have it convert a protected ODS and take
LO's own SHA-512 as the specimen) failed: LO does not know the plaintext, so it cannot rebuild the key and drops it.
The attempt to have it apply the key from the plaintext through UNO was abandoned because the bundled Python wrapper would not run the script.
On the side of driving the real Excel we stumbled three times (each is written at the top of the judge script as etiquette):
AppleScript's open POSIX file summons the sandbox's file-selection dialog and
silences Excel (with open -a, LaunchServices hands over just that file); a forced quit
raises one more barrier, Microsoft's error-reporting dialog
(use only quit saving no); and an Excel with no document open is showing
the template chooser, which does not answer Apple events either.
2026-08-28. Stage 2 of the feature adoption plan. Until now images were only "kept as bytes if the source file had them";
there was no way to place a new one. We implement the placing side. The placement arithmetic and the shape of the drawing XML
follow XLKit (MIT); the shape of the "in this cell, over this range" API and the idea of appending to an existing drawing part
follow XlsxReaderWriter (MIT). The EMU conversion uses the already-introduced Units as is.
let img = try SheetImage(data: pngData) // format (PNG / JPEG / GIF) and dimensions are determined from the bytes
sheet.addImage(img, at: "B2") // place at natural size (oneCellAnchor)
sheet.addImage(img, at: "B2", sizing: .fitCell) // fit into the cell's current size, keeping the aspect ratio
sheet.addImage(img, at: "B2", sizing: .resizeCellToFit) // widen the cell to fit the image instead (the XLKit way)
sheet.addImage(img, over: "B2:D6") // stretch over the whole range (twoCellAnchor)
The format is determined from the leading bytes (PNG signature, JPEG SOI, GIF87a/89a); the dimensions are read from PNG's IHDR, GIF's header,
and for JPEG by scanning for the SOF marker. An unknown format throws unsupportedFeature,
a broken header throws malformedPart. .resizeCellToFit changes the column width and row height
at the moment of addImage (the writer never changes the model in silence).
Pixels to column width is the standard (px − 5) ÷ 7 (the MDW of Calibri 11); row height is px × 0.75 pt.
We did not take XLKit's px ÷ 8 approximation.
By the rules of OOXML, a worksheet can point at only one drawing. So the writer has two paths:
a sheet with no drawing part gets a full set generated — the part, the relationship, the content type and
<drawing r:id> (the same planned approach as comments and structured tables). A sheet with a preserved drawing part
(charts or existing images) has only the new anchors inserted just before that part's </xdr:wsDr>,
and the image relationships are added to the part's relationship file starting from the next rId after the existing maximum. Existing anchors and
references to charts are not touched at all, as strings. The inserted anchor carries its own namespace declarations on the element itself,
so it does not break whatever prefixes the original part uses.
The meaning of F3 becomes one step finer here: a part that had an image added changes from "byte-identical" to
"the existing portion stays byte-identical, with an appendix at the end", and the drawing part of a sheet with no image added stays
byte-identical as before. We added to what PreservationTests checks that adding an image to a workbook containing charts
leaves the existing anchors and relationships intact. The read side is unchanged — drawings are not interpreted but
kept as bytes, so a read workbook's existing images do not appear in sheet.images
(only the ones you placed yourself appear).
When writing to Numbers or CSV, images are counted and reported as dropped (ODS became writable in Rev 4.26, B.43)
(never deleted in silence; §6). Support for ODS's draw:frame and Numbers' drawing objects
is left to a later stage. Streaming write (values and formats only) also remains out of scope.
Structural agreement of the part, relationship, content type and EMU (px × 9525) in a new workbook; format determination and dimension reading for the 3 formats
(the specimens are real bytes cross-converted with sips); appending to a fixture with charts leaves
the chart parts byte-identical; the from/to of a range anchor;
the dimension effect of .resizeCellToFit; the warnings on other formats. The external judge is openpyxl —
it reads the image-bearing workbook we wrote, and we confirm that the presence and dimensions of the images agree.
2026-08-28. Stage 3 of the feature adoption plan. Until now there was no side that "measures and decides" a column width
(we only carried the bestFit flag). We implement it by translating into Swift the
autofit of XlsxWriter (Python, BSD-2, NOTICE item 5) and its character-width table CHAR_WIDTHS
(Excel's Calibri 11 as measured, the pixel widths of the 95 ASCII characters).
The measuring follows the source: a string is looked up character by character and summed (with line breaks, the maximum per line); a number is its displayed character count × 7 px; a date is a fixed width; booleans are TRUE = 31 / FALSE = 36 px; a formula is measured by its computed value, and skipped if there is none. Add a margin of 7 px to the column's maximum, add 16 px for the button on an autofilter column, and convert to character units with (px − 5) ÷ 7. The default cap is 255 characters, the same as Excel. A column the user widened by hand is moved only in the widening direction (the same rule as the source). There is one departure from the source: the source treats all non-ASCII as a flat 8 px, but that squashes Japanese text to half its width, so the East Asian full-width ranges (CJK, kana, full-width forms and so on) are 16 px. That this is an approximation is stated in the API documentation — the exact width depends on the default typeface, and the viewer itself approximates it too.
sheet.autofitColumns() // every column with content
sheet.autofitColumn("A") // one column only
sheet.autofitColumns(maxWidth: 60) // with a cap (in character units)
The implementation is a mutating API on the model side — the line that the writer never changes the model in silence (the same as B.32). Verification: the sum over the character-width table agrees (known cases that come out equal to the source's Python implementation), full-width 16 px, the measuring of booleans, numbers and dates, the 7 px margin, the cap, and that an already wider column is not narrowed.
2026-08-28. The final stage of the feature adoption plan. Charts were until now "kept only" — we implement the creating side.
The scope is as planned: the 4 kinds that make up most of everyday work: column, bar, line and pie.
Stacked, scatter, area and radar remain outside the plan's scope (the next plan, if a need appears).
For the XML structure (the order of elements, the wiring of axes, the shape of a series) we read libxlsxwriter's chart.c
as a structural reference (BSD-2; not a single line of code was copied — as noted in NOTICE item 5).
var chart = Chart(.column) // .column / .bar / .line / .pie
chart.title = "Monthly sales"
chart.addSeries(values: "B2:B13", categories: "A2:A13", name: "Sales")
sheet.addChart(chart, over: "D2:K16") // the position is a range anchor (rides on the same drawing part as images)
A series range can be written without a sheet name — at write time it is qualified with the name of the sheet it is placed on and turned into an absolute reference with $ (because Excel charts accept only sheet-qualified absolute references; a reference that is already sheet-qualified is left as is). A chart with no series is not written; it is counted and reported.
A chart is written as two things: a chart part (c:chartSpace) and an
xdr:graphicFrame anchor on the drawing part. The handling of the part shares B.32's mechanism as is —
generate a full set if there is no part, append only the anchor at the end if there is a preserved part, and add
the image (image type) and chart (chart type) relationships to the part's relationship file under the same numbering. The chart part's number,
relationship id and shape id all start from the next after the existing maximum. The order of elements follows the schema order of CT_Chart / CT_PlotArea /
CT_BarChart and so on (title → plotArea → legend → plotVisOnly,
barDir → grouping → varyColors → ser → axId, and inside ser idx → order → tx → cat → val).
Only the pie chart has no axes. The legend is on by default (right) and disappears with legend = false.
To ODS, Numbers and CSV they are counted and dropped (images became writable to ODS in Rev 4.26, B.43, but charts are as before).
Reading back a workbook with charts we wrote ourselves leaves charts empty
(the chart part and the drawing part go into the preserved bytes), and saving once more is byte-identical — B.22's nail is
driven into charts as well.
The judge is openpyxl — it reads each of the 4 kinds, and the type (BarChart / LineChart / PieChart), the number of series, the reference strings and the title must agree. LibreOffice's PDF conversion must go through. Appending to a fixture with charts leaves the existing charts and images as bytes. The second save is byte-identical. Confirmed on the real Excel too — on 2026-08-28 the maintainer opened the written file containing all 4 kinds in the real Excel and confirmed there was no problem (the unlocking of B.31's protection key was also confirmed on the real application on 2026-08-29, so all 4 stages of the adoption plan reached the real applications).
2026-08-31. When we built the per-format feature support table (docs/spec-feature-matrix.html),
the chart sheet row alone had no specimen and went out marked "unverified". To fill that row we had the real Excel 16.112.2
create a document with 2 sheets (a worksheet plus a chart sheet) and read it — and a defect came out.
The reader parsed every <sheet> in the workbook as a worksheet. The chart sheet entered the model as
a sheet with 0 cells, and not a single warning was raised. On writing back,
xl/chartsheets/sheet1.xml was written with a <worksheet> root,
the declaration in [Content_Types].xml became the worksheet type, and the relationship type in the workbook had changed to
/worksheet. Only the relationship's name still pointed at chartsheets/ —
a package that contradicts itself. LibreOffice opened it leniently, but the PDF it drew
had 3 pages against the original's 2. One of the charts had turned into an ordinary sheet.
This is a counterexample to the central promise of this library, "never drop in silence", and worse for having not merely dropped but broken something.
Besides worksheets, SpreadsheetML has chart sheets, dialog sheets and macro sheets, none of which is a grid. The model has no word for their contents (nor any need to make one — there is no need). But they too are sheets: they sit in the sheet order, have names, and formulas can use those names. So we decided to carry them without interpreting.
public struct ForeignSheet: Sendable, Hashable {
public var root: String // "chartsheet" / "dialogsheet" / "macrosheet"
public var relationshipType: String // the relationship type the workbook used to point at it
public var contentType: String // the type [Content_Types].xml gave it
public var body: Data // the part as it arrived
}
sheet.contentState == .nonGrid // a sheet that is not a grid
The discriminator is the relationship type — if the workbook's relationship does not end in /worksheet,
that part is taken as bytes without parsing (only the root element name is read, from the first 4 KB). On writing, those bytes are written
to the same part path, and the content type and relationship type are the original ones.
The chart parts and the drawing part go into the preserved bytes as before, so they round-trip together.
We say it on reading (degraded). This is the point: in silence, a user iterating
Workbook.sheets reads "a sheet with no cells" as "it was an empty sheet".
It only makes sense once told "this is not a grid". Further, if the file is saved with cells written into that sheet,
the writer reports dropped — the part goes out as is, so the writes are not saved.
When converting to other formats they are counted and reported too.
The specimen was made by the real Excel itself (Tests/FixtureGenerator/make_chartsheet_fixture.py,
AppleScript), so that we read the chart sheet Excel writes, not one we imagined.
ChartSheetTests has 5 tests — the warning on reading and the contents of ForeignSheet, the
byte-identical part plus content type and relationship type on writing back, the warning when cells are written, conversion to other formats,
and the judge: LibreOffice draws the written-back file with the same page count as the original
(before the fix it was 2 against 3).
The one remaining row, the Numbers input form (TN.FormBasedSheetArchive), can only be created
on iPhone / iPad and we have no specimen, so it is left as "unverified" in the support table.
Not writing ○ or × by guesswork is this document's policy.
2026-08-31, the same day as B.35. The specimen for the other "unverified" left in the support table — the Numbers input form — arrived from the maintainer. A form can be created only in Numbers on iPhone and iPad; there is no way to make one on the Mac. So this row alone was not filled in by guesswork; we waited for a hand-made specimen to arrive (this makes 8 documents produced by the pattern of asking the maintainer for a specimen that only the UI can create).
The reader took only the TN.SheetArchive ones among the document's sheets references and
threw the rest away without a word. The specimen that arrived has 2 tabs, one table and one form, but
it reads as 1 sheet, with 0 warnings. A defect of the same shape as B.35's chart sheet
(found the same way, too — it came out when we went to fill in a row we had marked "unverified").
We treated it differently from the chart sheet. A chart sheet has contents that only that tab holds, so
we carried it as bytes; but a form is a screen for typing into a table that already exists, and
holds no values of its own (TN.FormBasedSheetArchive embeds a TN.SheetArchive as
super and merely points at its table by table_id).
Putting it into the model as an empty sheet would have the writer copy that lie and
produce "a document with an empty tab". So we drop it.
Dropping means saying so. The warning names the form and the table it writes into — because what matters to the user is not only "what was lost" but also "what was not lost".
// Measured (Fixtures/numbers/form-15.numbers, made on an iPhone)
dropped the form "表1フォーム" is dropped: a Numbers form is a way of typing into
the table シート1::表1, not a sheet of its own, and the model has no word
for it — the table it fills in is read as usual
Should a tab that is neither TN.SheetArchive nor TN.FormBasedSheetArchive appear in future,
it is reported the same way, naming the type (so that not one path that vanishes in silence is left).
NumbersFormTests has 4 tests — that the specimen really has a form storage,
that the values of the table the form fills in are read normally, that there is exactly one warning and it names both the form and the table,
and that writing back keeps the table and does not bring the form back.
With this, the support table's "unverified" count is 0 (all 191 rows are backed by a measurement or a code check).
2026-08-31. Having set up openpyxl through uv, we ran the pre-release checklist in MAINTENANCE.md to the end for the first time, and a document came out that Numbers.app would not open.
A single sheet (with or without formatting)… opens / 2–3 sheets, no formatting… opens / 2 sheets, formatting on the first only… opens / 2 sheets, formatting on the second (fill, number format or font, any of them)… does not open. The 5 sheets of the sample workbook all open one at a time, and no pair of them opens.
numbers-parser, LibreOffice and our own reader all read the broken document without complaint. Only Numbers objects — a shape we saw once before in Appendix B.18.
Numbers follows references across components through the document's inventory
(TSP.ComponentInfo.external_references). An undeclared crossing is a reference that cannot be followed, and
Numbers refuses the document without giving a reason.
The style objects that a table's style list names by name live in the stylesheet's component, so the crossing needs declaring.
But the component of a duplicated sheet is, while that sheet is being written, still only reserved
and not yet in the inventory (flushComponents runs once, at the end — walking the inventory again for every object
would take the write from 1 second to 2 minutes). So componentID(forObject:) returned nil, and
the declaration itself was skipped wholesale.
The first sheet rewrites the template's table in place, so its component already exists and the declaration succeeds — that is why "formatting on the first sheet is fine, formatting on the second breaks".
Crossings are collected and registered once the parts are all present (pendingCrossings →
registerPendingCrossings(), right after flushComponents()).
A crossing that still has nowhere to go is reported with a degraded warning — it is not dropped in silence.
NumbersDocument.addExternalReferences also stopped doing nothing in silence for a part it does not know,
and now holds the reference until the part appears.
We did not add the general invariant "every crossing must be declared".
When we measured, documents made by Numbers itself had 47 undeclared crossings (templates had 42).
We do not know where Numbers draws the line between what it requires and what it does not, so we will not guess at an invariant.
Instead we placed a check limited to crossings in the style list in NumbersCrossingTests —
nailing down only the range that measurement backs up.
Sample 05 has formatting but a single sheet; 09 has two sheets but no formatting; the four-sheet comprehensive sample 15 has
all its formatting on the first sheet.
There was not one document with formatting on a sheet other than the first. On top of that, the pre-release check
starts from openpyxl and so had not run. When two holes line up, nobody looks.
We added sample 19-style-on-second-sheet and NumbersCrossingTests, which needs no real application,
so CI catches a recurrence.
2026-08-31. This resolves the state B.1 recorded as "the price is that §1.1's 'runs on Linux' is not met".
To begin with, there is no external ZIP library to replace — opening and closing the envelope is our own 371 lines,
and what stood in the way were the two Apple-only parts that actually fold the bytes inside it:
Compression (two files for ZIP reading and writing) and CryptoKit (one file for the key of B.31's protection).
Not one line of the envelope handling itself was moved.
SheetCore/Container/Deflate.swift gathers the
one-shot compress / decompress and the streaming DeflateEncoder,
with two implementations under #if canImport(Compression) && !SWIFTSHEETS_ZLIB.
ZIP's method 8 is raw DEFLATE; COMPRESSION_ZLIB is exactly that, and zlib produces the same stream with
windowBits = -15.Sources/CZlib is a single module map over the zlib.h
that is already there; SwiftPM has nowhere to fetch from. It ships in Apple's SDK and on Linux from the start, so
both may depend on it unconditionally (B.1's "zero external dependencies" is unchanged).XMLParser lives in FoundationXML on Linux,
so the eight files that use it gained #if canImport(FoundationXML).
Without Linux CI we cannot say "the zlib path is correct". So we added -DSWIFTSHEETS_ZLIB and
run the same 941 tests through the zlib path too, on macOS, which has both toolboxes (added to the CI steps).
Every xlsx / ods / numbers round trip passes under zlib. In addition, CompressionTests holds a DEFLATE blob
that neither implementation produced (57 bytes made by zlib's own compressor) and confirms that whichever
side was compiled can read it. The judge for SHA-512 is three known answers from FIPS 180-4 and a cross-check against
CryptoKit on Apple platforms (ten boundary cases).
Even so, the behaviour of the Linux Foundation cannot be measured on a Mac
(dates, decimals, the table of encodings String(data:encoding:) supports).
Therefore we do not yet claim Linux support: a Linux job (swift:6.2-noble) goes into CI,
and until it is green the README badge and §1.1's achievement stay as they are. No claim goes in without a judge.
The Linux job went red three times, for a different reason each time.
Build: the type checker could not solve two concatenated expressions (the named formats in StylesWriter,
the origin cell of conditional formatting in ODSReader; the latter took 1.3 seconds even on a Mac) — rewritten statement by statement.
Tests: the whole process aborted. Running suites one at a time, the report named the culprit —
Illegal instruction in _NSXMLParserStartElementNs of libFoundationXML (called from libxml2).
Not one frame of ours lies inside it
(our innermost frame is the XMLParser.parse() line in SAXDriver.run).
With that one suite (FuzzTests) excluded, all 938 tests are green on Linux
(six of them pass with the known issue of LibreOffice being absent).
CSV's Shift_JIS passes too — the suspicion noted as "doubtful" above was wrong.
Fuzz seed 1, round 174. The material is generated.xlsx, 4131 bytes, CRC32 4279849508.
Bytes 233–236 of [Content_Types].xml were mutated to ff ff ff ff, turning
<Default …> into <De + 4 bytes + t …> —
bytes that are invalid as UTF-8 in the middle of an element name. libxml2 reports it as a start tag,
and Foundation, receiving it, crashes while turning the name into a String.
Reproduction is 175 rounds with SWIFTSHEETS_FUZZ_SEEDS=1 plus -DSWIFTSHEETS_ZLIB
— unless the compressed bytes of the material match, even the way the random numbers are consumed afterwards changes.
That match is itself evidence that the two DEFLATE paths emit the same bytes.
This collides head-on with §12 pillar 5 (no crash on malformed input) and the promise in SECURITY.md.
The fix is our responsibility — the cause of the crash lies outside, but handing over input that does not crash is our job.
We are considering checking that a part is well-formed UTF-8 before handing it over, and throwing
malformedPart as before when it is not (parts with a BOM, or whose declaration names an encoding other than UTF-8, pass through).
Whether that closes every kind of breakage is unknown, however; the only way is to iterate while asking the judge.
2026-08-31. The policy: "add a defence before handing over, and only then claim Linux".
An XML part is checked to be well-formed UTF-8 before it is handed to Foundation
(SAXDriver.rejectUndecodableBytes). If it is not, malformedPart is thrown as before,
with the byte offset attached — the reasoning being that the cause of the crash lies outside, but
handing over input that does not crash is our job.
A part whose BOM names another encoding, or whose XML declaration names something other than UTF-8, passes through
(the parser decodes those itself, so the question does not apply).
The scan reads in place with withUnsafeBytes — a part of tens of MB is not copied for the sake of a check.
firstInvalidUTF8Offset moved from SheetCSV to SheetCore (TextEncodingSniffer) and is shared by both.
The specimen was taken in as Fixtures/malformed/content-types-invalid-utf8.xlsx, and
a hand-made twin (confirming that the byte offset is named) and two pass-through cases (a UTF-16 BOM, a Shift_JIS declaration)
were placed in MalformedInputTests.
The judge for 0.14.0 (Linux) crashed once more in the same function (_NSXMLParserStartElementNs).
At first we suspected the Snappy decoding that was running at the same time and rewrote it, but re-reading the crashed thread showed
the culprit was the exception in the defence above. The mechanism is this.
① A part whose XML declaration names something other than UTF-8 was handed over unchecked, on the assumption that the parser decodes it itself.
② When a fuzz mutation breaks the declared name itself (one character of UTF-8 becomes another), libxml2
reports "unsupported encoding" and then, because Foundation runs it in recovery mode (XML_PARSE_RECOVER),
keeps reading the bytes as they are. If an element name further on contains a byte that is not UTF-8,
it crashes at the same place as the first hole.
③ So the pass-through was limited to "8-bit names the parser knows" (SAXDriver.decodableEncodings:
ASCII, ISO-8859-x, Windows-125x, Shift_JIS, EUC-JP, ISO-2022-JP, GB, Big5, EUC-KR, KOI8-R).
An unknown name is malformedPart ("encoding … is not one this library can decode").
A part that declares UTF-16 / UTF-32 over an 8-bit byte sequence with no BOM is read by libxml2 as UTF-8, so it is
checked as UTF-8 (wideEncodings).
The crashing mutation itself could not be recovered from CI — from now on the fuzzer puts the mutation it is reading on disk
(SWIFTSHEETS_FUZZ_KEEP_DIR, deleted once read) and brings it home as an artifact only when a crash occurs.
Two tests in MalformedInputTests (an unknown name is refused; a known name and a false UTF-16 claim).
Result: 944 tests on Linux, 945 on macOS, all green (the one difference is the Apple-only check that
cross-checks against CryptoKit). The fuzz suite is no longer excluded either. Locally, 6,000 rounds of fuzzing also pass.
With this, §1.1's "runs on Linux" is achieved — the README badge, Limits, docs/index.html and
CONTRIBUTING were rewritten.
Note that we cannot say "every kind of breakage is closed": what was closed is the one kind that actually crashed,
and the possibility of another kind remaining shrinks only by keeping the fuzzer running.
We reported it upstream as swiftlang/swift-corelibs-foundation#5536
(2026-09-01, a one-line fix plus a regression test). Even once it is fixed, this defence stays —
on a machine with an older Swift the defect would remain.
2026-09-04. The theme of 0.12.0 is speed and size. Before starting we measured reading and writing on synthetic data of one million cells (10 columns × 100,000 rows) and took a profile of the calls. Three things emerged: of the 5.7 seconds to read, about 70% is Foundation's XML part and the string handling around it, while inflating takes a mere 0.02 seconds. Writing copies the same 33 MB of sheet XML five times. Format detection already takes 0.1 milliseconds, and what needs adding is not speed but "an entry point that does not read the whole file". The bench and the raw data are kept in the workshop repository; the public side carries only the results of measurement (the numbers are cross-checked by machine, not "remembered" — as B.6 laid down). This section records each stage of 0.12.0 as a subsection, in the order it was started.
The in-house envelope handling that B.38 described with "there is no external ZIP library to replace" was this time rebuilt from the inside. Three motives. ① Parts beyond 4 GiB and more than 65,535 parts (ZIP64) could be neither read nor written (the README's Limits said "not supported" explicitly). ② A part could only be inflated whole, and the +54 MB of the streaming read (B.15) was fixed by this one point. ③ The guard against a construction that swells without limit when inflated (the so-called ZIP bomb) was only "verify the declared length" and never questioned the validity of the declaration itself.
ByteSource). Two implementations: a byte sequence in memory (including a mapped file)
and a file read on demand by positioned reads (pread). A reader asks only for the central directory and the range of
the one part in hand; there is no path that requests the whole file. It opens even where mapping is unsafe (a shared folder, say)
and even an envelope larger than memory. The same implementation on Linux (pread from Glibc).forceZip64,
and also read a sample that Info-ZIP produced with -fz.ZipEntryStream / DeflateDecoder).
Compressed bytes are read 256 KiB at a time, inflated, and only what was inflated is handed on. A streaming decoder was added to both
the Apple Compression path and the zlib path (behind B.38's seam). The later stages of B.39 (streaming) build on this.ZipLimits, ReadOptions.limits).
Inflation runs up to exactly the declared size — a stream that tries to produce more is stopped as corruption
(the one-shot inflation on the Apple path allocates one extra byte to detect overrun). In addition, at the point the central directory is read,
the number of parts (default 100,000), the declared total of all parts (default 16 GiB) and the compression ratio (1,000× for a part inflating to
16 MiB or more) are checked, and anything beyond is refused with corruptedContainer. Overlapping parts
— several directory entries pointing at one local header, a construction in which a small file claims large contents — are refused too.
All of these can be relaxed with ReadOptions.limits by a caller who knows its own files.compressed(_:) / addCompressed). Parts that are not interpreted
(charts, pictures, pivot-table records, VBA) can be put into the next envelope as compressed bytes, without inflating or recompressing.
F3's "byte-identical" becomes literal, and the time and memory of a round trip no longer scale with the size of those parts.
Moving the preservation writer (B.4) onto this is a later stage.Data(prefix);
now the length is simply shortened in place.
ZipTests: Info-ZIP's ZIP64 sample, a round trip of our own (forced) ZIP64, a round trip of 65,540 parts, positioned reads from a file
matching the mapping, piece-by-piece inflation matching one-shot, byte-identity of a copy made while still compressed, refusal of the four
constructions (overlap, ratio, total, part count), refusal of a stream exceeding its declaration. The existing MalformedInputTests
and FuzzTests are unchanged. Everything on both paths (Apple / zlib).
The cellLimit that B.9 described with "it properly belongs in ReadOptions and moves there once a user has a reason to choose it"
had stayed at a default of 1,000,000 since Rev 1.5. This time the default ceiling is removed (Int.max).
Two reasons. ① A million cells is large for one sheet on iOS, but on a Mac or a server it is not unusual for one file.
② Whether a ceiling is needed is the reader's business (memory, whether the sender is trusted), not something a library decides
uniformly. The setting remains: for untrusted input set ReadOptions(cellLimit:), and beyond it reading stops as before with one
degraded warning per sheet. ODS's judgement that "an empty repetition is a specification, not content"
(paddingRepeat, §8.3) is unchanged. An entry point for asking how many cells there are before reading is added in
the next subsection (B.39.3).
Workbook.inspect
Having removed the ceiling in B.39.2, the reading side needs material to decide with.
Workbook.inspect(contentsOf:) / inspect(_:) return, without creating a single cell,
"which sheets there are, how many cells each sheet declares, how many bytes the envelope inflates to, and who wrote it"
as a WorkbookSummary. The caller looks at this and either sets ReadOptions.cellLimit or does not read.
workbook.xml, the declared range from the <dimension> at the head of
each sheet part. Only the first piece of the part is inflated (B.39.1's ZipEntryStream).
Declarations lie (there are writers that write "A1" and hold 100,000 rows), so InspectOptions.countsCells provides a separate path
that walks the whole part as bytes and counts <c> — this matches the count a read actually holds.content.xml is walked once as bytes and the cells holding values are counted
by multiplying by the repeat count (without expanding). A construction that claims a billion cells in 1 KB returns that number as is.
What walks is not an XML tokenizer but a byte scanner that picks out only tags (TagScanner).
A tag split at a piece boundary is carried over and joined. It is the forerunner of the real tokenizer built in a later stage of B.39;
the shape "read bytes without creating strings or dictionaries" was tried out here first.
Detection was fast to begin with: 0.1 milliseconds for a 5 MB xlsx, 0.4 milliseconds even for 400 MB when opened by mapping (measured before starting). But the path that reads the whole file without mapping used 48 milliseconds and 390 MB, and on an iPhone or a shared folder that difference matters. What was added is not speed but shape.
SheetFormat.detect(contentsOf:) opens with FileByteSource (B.39.1) and reads only the first 4 bytes →
for an envelope, the central directory in the last 1 KiB (a 64 KiB window if it is not there) and one small part; for text, the first 64 KiB.
A test nails down "a 700 KB xlsx is detected in under 16 KiB" with a double source that counts the bytes read.SheetFormat.probe does not make the caller ask "what format is it" and "is it encrypted" separately.
FormatProbe is one of three: .spreadsheet(format) / .unopenable(reason) / .unrecognized.
encryptedNumbers (.iwph) was added to the reasons, and the refusal on the Numbers side now speaks with the same word.Data(contentsOf:) fail with "is a directory", so it could not be opened
(measured). NumbersDocument(folder:) gathers Index.zip (or Index/), Metadata/ and Data/
from disk into the same object as a single file. Writing stays single-file.String before scanning; it looks at the bytes for UTF-8 validity (reusing B.38's scanner),
UTF-16 surrogate pairs and control characters. A multibyte character cut at the edge of the window is treated as a boundary, not a defect.
The results of detection are unchanged (the existing tests pass as they are).Fixes that ranked high in the profile taken before starting and change not one byte of any format. Before and after were compared on the same machine at the same time (the machine itself had slowed by about 10% under prolonged load, so comparisons against a baseline from another time are discounted).
StylesParser.numericKind now remembers xf number → plain / date / elapsed time.CellStyle for every cell
(about 20% of the profile). StyleRegistry.index(for: Cell) / ODSStyleRegistry.cell(of:) look up by the identity of the
SharedStyle the cell points to, and do not recompute for the same object.Data, not an array.Measured (one million cells): read 5.58 → 4.17 s (−25% at the same time of day), streaming read 5.1 → 3.2 s, write 2.0 → 1.7 s, streaming write 2.4 → 1.9 s, ODS write 4.0 → 3.3 s, Numbers write (100,000 cells) 0.94 → 0.81 s. Memory is unchanged (the theme of the next stage). The ODS read was out of scope and did not change.
The largest item the profile named before starting. Of the 5.7 seconds to read one million cells, Foundation's XMLParser itself took 0.7 seconds,
and around it the bridging strings and attribute dictionaries created per element, and the comparisons and conversions on the receiving side
took about 3.5 seconds. The lower bound for cutting up the same 33 MB as bytes is 0.02 seconds. Most of the difference is the price of "make a string, then read it".
XMLScanner (SheetCore): a tokenizer over UTF-8 bytes. The declaration, comments, processing instructions, DOCTYPE (skipped together with its
internal subset; entities are not expanded), CDATA, the five predefined entities and character references, both quote characters, attribute-value normalisation
(raw tabs and newlines become spaces; character references stay as they are), newline normalisation in text (\r\n → \n).
Names are compared as bytes; strings are made directly from UTF-8 (validity is guaranteed by B.38's prior check). A mismatched end tag, an unclosed element,
an unclosed tag or an out-of-range character reference is malformedPart.
An undefined entity is left as written (Foundation stops, but spreadsheets have no DTD).SAXHandler (start / text / end) and the preservation cut-outs stay as they are;
the driver SAXDriver switches between two engines inside. The eight readers (every XLSX and ODS parser) are unchanged by a single line.< to the end of the end tag, attribute order and whitespace are exactly as in the source file.Engine.automatic).
-DSWIFTSHEETS_FOUNDATION_XML returns every part to the Foundation path. The macOS CI job runs everything under this flag too,
built the same way as the zlib path. Keeping the old path for one version is so that a "path known to be correct" stands
beside any defect in the tokenizer.
XMLScannerTests: every XML part of every sample (27 documents, 289 parts) is run through both engines, and the sequence of events
(element name, attributes, text, end) and the root attributes must be entirely identical. The corner cases (entities, character references, CDATA, comments,
DOCTYPE with internal subset, both quote characters, attribute normalisation, the three kinds of newline) also match across both engines. A cut-out fragment is the original bytes.
Refusal of nine kinds of broken syntax. A UTF-16 part is routed to Foundation automatically. Abort from the handler. Two mistakes of our own found during development —
whitespace outside the root was passed as text, and Swift's where applies only to the last item of a list, so newlines in text were being turned into spaces too —
both of which the cross-check turned red and told us. The whole: 984 tests green on three paths (default, Foundation, zlib).
Measured (one million cells, against the previous binary at the same time): read 5.50 → 2.64 s, streaming read 4.58 → 1.81 s, ODS read 13.86 → 3.71 s. Against the 5.7 seconds before starting, 2.2× faster; ODS 3.7×. Memory is still unchanged (next stage).
The write profile showed "the same 33 MB of sheet XML is copied five times" (the opening of B.39). The peak for writing one million cells was 360 MB against a model of 203 MB, and 641 MB for ODS. On top of that, opening a file, fixing one cell and saving — the reason this library exists — was paying time and memory in proportion to the size of the parts it does not interpret (charts, pictures, pivot-table records, VBA) by inflating them when reading and recompressing them when writing.
OpaquePart.compressed). The reader takes the compressed bytes, CRC and sizes from the envelope as they are
(B.39.1's compressed(_:)), and a writer of the same format copies them straight into the envelope
(addCompressed). No inflation or recompression occurs, and F3's "byte-identical" became literal.
The public opaqueParts became a computed property that inflates only when accessed,
and names and counts are answered without inflating (opaquePartNames / opaquePartCount).
FoldedPartTests: the compressed bytes of charts, pictures and themes are identical between input and output.sheetPart returns what comes before and after <sheetData> as strings,
and the rows as a closure that "writes into a given buffer". The buffer is a PieceBuffer that hands itself to the compressor each time 64 KiB accumulates.
Whether there is a shared-string table must be decided before the rows are written, or the directory and the content disagree, so it is decided up front from the model
by "is there a text value that is not a formula" (we noticed that because PreservationTests went red on
sharedStrings.xml being undeclared). The sheetXML the tests read flows the same closure into a string.TextSpill). ODS must write the styles registered while building the body
before the body, so the whole body has to be built first. Up to 8 MiB is held as fragments; beyond that it spills to a temporary file, and after the styles are written
it is read back 1 MiB at a time and handed to the compressor. The table XML flows into the buffer row by row and is not grown as one string.Measured (one million cells, against the previous binary at the same time): write peak 360 → 258 MB (time 1.8 → 1.7 s), ODS write 641 → 313 MB (4.0 → 3.2 s), open, fix one cell and save 6.9 → 3.8 s, 397 → 288 MB. What remains is the model itself (203 MB) and the shared-string table, a matter for the next version (the opening of B.39). Once during measurement, an incremental build on the bench mixed in stale parts and the output looked broken (2 bytes duplicated in the middle of a multibyte character). A rebuild made it vanish. We wrote "delete .build before measuring" into the bench guide.
B.39.6's tokenizer assumed the bytes of the whole part. Combined with B.39.1's ZipEntryStream, it now
inflates 256 KiB of compressed bytes, reads them, and discards what has been read (XMLScanner.feed plus
SAXDriver.run(stream:)). A tag split at a piece boundary, text in mid-flow and an open preservation cut-out are carried over to the head of the next piece.
The carry-over starts from "the first byte still needed", and what has already become an event is not read twice
(feed(startingAt:)). The UTF-8 check (B.38) is also done per piece, and a multibyte sequence cut at the boundary waits for the next piece.
UTF-16 parts and parts in another encoding have their pieces collected before going to Foundation.
Four places were moved onto this: the XLSX sheet parts and shared-string table, the streaming read, and the ODS body. The handlers are unchanged by a single line.
StreamingTests counts "the largest number of bytes of a part held at once" during a streaming read of 100,000 rows and nails down that it is at most two pieces plus 1 MiB
— the README's "+2 MB" stands on this number.
Measured (one million cells): streaming-read peak 61 → 23 MB (most of it the shared-string table), read 254 → 221 MB, ODS read 318 → 233 MB, open, fix one cell and save 288 → 262 MB. Time is unchanged (2.8 / 1.9 / 4.0 s). Reading one million cells went from 5.7 s and 256 MB before starting to 2.8 s and 221 MB, and the streaming read from 5.1 s and 61 MB to 1.9 s and 23 MB. Of the remaining 221 MB, 203 MB is the model itself.
Fixing the UTF-8 check at the boundary (Rev 4.28, 2026-09-05): each time a piece arrives, only what arrived is checked as UTF-8,
and a character cut at the boundary waits for the next piece — the starting point of this re-check had been the fixed position "3 bytes from the end of what is held".
When that position lands on a continuation byte of a completed 3-byte character, the continuation byte on its own is invalid UTF-8,
so a valid part was refused with "byte N is not valid UTF-8". B.39.11's 200-column benchmark found it in a 10-million-cell streaming-written XLSX
(every row holding 「分類A」). The re-check now starts from where the previous check stopped (the end if everything passed; the head of the character
if one was cut at the boundary). The regression test uses an uncompressed part (so a piece is exactly 1 MiB), places 「分」 at 2 MiB − 4,
and confirmed that before the fix it failed at byte 2097149 (StreamingTests).
Two reasons the streaming-read peak grew with the row count, and the fixes (Rev 4.31, 2026-09-05): B.39.11's two-tier benchmark showed the streaming read alone swelling several-fold at the second tier (100 columns, 1 million → 10 million cells: XLSX 19 → 62 MB, ODS 228 → 709 MB, Numbers 53 → 329 MB; the streaming write is flat). Measuring each stage in a separate process split the cause in two.
ZipEntryStream read compressed bytes 256 KiB at a time and inflated all of it
in one go. An XLSX sheet part swells only about 6×, so a piece is 1.7 MB, but an ODS body
(content.xml, full of repeated identical values) swells 60×, and a piece becomes 15 MB.
Even after the handler finishes reading the piece and discards it, the macOS allocator merely marks a release of this size "for later reuse",
and the resident-memory figures (ru_maxrss / phys_footprint) do not go down. Each arriving piece stacked up another 11–15 MB,
and it did not stop until the compressed bytes were exhausted (the same growth in an experiment that only discarded 1 MiB pieces;
wrapping in autoreleasepool changed nothing, and stripping out the tokenizer, the carry-over and the byte source to leave only the inflater gave the same result).
The fix: cap the inflater's output per call at 1 MiB (DeflateDecoder.pieceCap = pieceSize × 4, the same as an
uncompressed piece). Compressed bytes not consumed when the cap is hit are kept by ZipEntryStream in its own 256 KiB copy
and carried to the next call (the source's bytes are borrowed, so they are copied). The inflater now returns "the number of input bytes consumed"
(on both the Apple Compression path and the zlib path). As a by-product, the hole where a part with a compression ratio of 1,000× would make a 256 MB piece is closed too.
The regression test streams a 24 MiB part that compresses 50×, confirmed that before the fix a 10.7 MB piece came out, and then nails down
largestPiece ≤ 1 MiB (ZipTests).
StreamingReader(contentsOf:) and the readers of the three formats opened the file with
Data(contentsOf:options: .mappedIfSafe), and the pages walked stay resident as they are.
An experiment that did nothing but touch the whole file gave XLSX 50 MB, Numbers 275 MB (= the size of the file). It was not the reader's own belongings but
the touched pages of the mapped file that sat in the peak. The fix: a streaming read opened from a URL uses positioned reads, not a mapping
(FileByteSource, the same source as B.39.4). The readers of the three formats gained an entry point "build from an opened container"
(init(archive:); for Numbers, NumbersObjectIndex(url:)), and the unified reader does format detection on the source as well
(SheetFormat.probe(source:filename:limits:) — detected with the same limits as the reader, so that a container refused under the default
limits is not answered with "unrecognised"). The refusal of compound files is also looked at on the source
(UnopenableInput.probe(source:)). The public entry points that take Data stay as they are — the pages of a mapping handed in
are the caller's belongings.
Measured (ru_maxrss in a separate process; walk = walking every row of the first sheet): ODS 10 million cells, open only 576 → 12 MB,
walk 709 → 15 MB; XLSX 10 million cells walk 62 → 16 MB; Numbers 10 million cells walk 329 → 58 MB (the rest is the index and the string table —
the reader's genuine load). The figures are the same for 1 million and 10 million cells, and the streaming read is now flat regardless of row count.
Time is unchanged (the old and new builds run alternately on the same machine at the same time — the 0.17.2 record in B.39.11).
§1.3 said "encrypted files — to be considered later; v1 raises an explicit error". In 0.12.0 we finished considering it. One correction before starting: rebuilding the envelope (ZIP) does not open an encrypted file. Excel's password protection is not ZIP encryption; it puts the whole ZIP inside another container (an OLE compound file) and wraps it in AES, and ODF's protection wraps each part of the ZIP individually in AES. Both are jobs separate from the envelope, and the only thing the rebuild of B.39.1 helped with was "a part can be taken out while still compressed".
EncryptionInfo declares every parameter (salt, iteration count, hash, key length), and
EncryptedPackage holds the ZIP wrapped in AES-CBC in 4096-byte segments. The keys are derived, one per purpose, from the password hashed
100,000 times, and tampering with the package is detected by an HMAC. Reading accepts what the XML declares (SHA-1 / 256 / 512, AES-128 / 192 / 256);
writing produces Excel's own combination (AES-256, SHA-512, 100,000 iterations). The Excel 2007 standard format (SHA-1, AES-ECB) and
the RC4 formats before it are refused by name — we have no tool at hand that can produce a sample, and an implementation without a judge does not ship (B.6).mimetype and the manifest stay in plain text (which is why
the file keeps being detected as .ods).
The iteration count on write is the same as LibreOffice's, 1,024 — derivation is per part, so paying Excel's 100,000 once per part
would make the file take seconds to open. The Blowfish format of ODF 1.1 is refused by name.
The reasons of B.38 apply unchanged: CryptoKit is Apple's, Linux has nothing, and we do not add SwiftPM dependencies.
In SheetCore/Crypto we wrote AES (FIPS 197, the table-lookup form), SHA-1, SHA-256 (FIPS 180-4), HMAC (RFC 2104),
PBKDF2 (RFC 8018), and reading and writing of OLE compound files ([MS-CFB]). The existing SHA-512 was reworked into a form that keeps per-block state,
so that the password's 100,000 rounds pay no allocations. The judges are the known answers of each standard itself: FIPS 197 Appendix C,
SP 800-38A F.2, FIPS 180-4, RFC 4231 / 2202, RFC 6070, RFC 7914 §11 (CryptoTests).
Fixtures/encrypted/agile.xlsx) is opened with its password.EncryptionParityTests,
Tests/EncryptionParity/verify_encryption.py, run with uv). The 4 DataSpaces parts of the compound file are written
as the fixed byte sequences that msoffcrypto-tool's output contained.SheetError.wrongPassword (new).
The API is ReadOptions.password / WriteOptions.password / InspectOptions.password, and
StreamingReader(data:password:) for the streaming read. A password for CSV or Numbers is unsupportedFeature
(CSV is plain text by definition; Numbers' encryption is undocumented). The tests write protected files with a short iteration count to keep the time down (reading
follows the count the file declares, so what can be opened does not change), and just one round-trips with the same count as Excel.
In 0.17.0 password handling was taken out of the core. Whichever of SwiftSheets and the 5 products (SheetCore / SheetXLSX / SheetCSV / SheetODS / SheetNumbers) you link, no encryption or decryption code goes into the binary. The reason is declarations: an app that links this library may want to answer export-control or App Store questions with "uses no cryptography" or "decryption only", and a form in which whether the code is present is decided by the Package dependencies alone is the most certain and the easiest to explain. The alternative of a plug-in socket in the core (a protocol plus registration) was rejected — a forgotten registration becomes a run-time error, and reading the code does not tell you whether it was linked.
SheetDecrypt (decryption only; depends on the umbrella SwiftSheets): the decryption side of AES (key expansion,
decryptBlock, decryptCBC), key derivation, reading OLE compound files, and the procedures that open the OOXML / ODF wrappers.
The entry points are SheetDecrypt.decrypt(_:password:) / decrypt(contentsOf:password:) — they detect OOXML versus ODF automatically and
return the plain package. A package that is not protected is returned as it is (the same meaning as the old ReadOptions.password being
ignored for a plain-text file; whether a file is protected is answered separately by SheetFormat.probe). On top of it, thin extensions give
Workbook(contentsOf:password:options:), Workbook.read(…password:…), Workbook.inspect(…password:…) and
StreamingReader(contentsOf:password:…). Each of them is nothing more than "decrypt, then call the core's plain API", so
the decryption procedure exists in one place only.SheetEncrypt (encryption as well; depends on SheetDecrypt and re-exports it): the encryption side of AES
(encryptBlock, encryptCBC, ECB), random salts and IVs, writing compound files, and the procedures that build the wrappers. The entry points are
SheetEncrypt.encrypt(_:as:password:) (CSV / Numbers are refused with the same wording as before) and
Workbook.write(to:as:options:password:) / write(as:options:password:) (the bytes are the data of the return value).UnopenableInput; the detection method is unchanged;
the refusal message says "can be opened with SheetDecrypt"). SheetError.wrongPassword also stays (removing a case would be a separate breakage).ReadOptions.password / InspectOptions.password / WriteOptions.password, and the
password: of StreamingReader / XLSXStreamingReader / ODSStreamingReader.
The replacements are listed one to one in the table of CHANGELOG 0.17.0.
2 points differ from the shape of the request. (1) The name — the writing side is SheetEncrypt, not SheetCrypto: naming each product
by the capability it adds suits the wording of a declaration better. (2) The direction of the dependency — SheetDecrypt depends on the umbrella
SwiftSheets, not on SheetCore / XLSX / ODS. Workbook(contentsOf:) and StreamingReader live in the umbrella,
and their extensions cannot be written without importing it. The reverse direction (the umbrella knowing SheetDecrypt) is never drawn —
the moment it is, the point of the split is gone.
AES was split into files by decryption side and encryption side. Key expansion, the inverse S-box, the inverse tables (Td) and decryption are in SheetDecrypt; the forward tables (Te) and encryption are
an extension in SheetEncrypt. Not one byte of the arithmetic changed, and the known answers of FIPS 197 Appendix C and SP 800-38A F.2
pass as they are. Speed and memory are unchanged too — the plain path merely lost one branch, and we measured before and after on 200 columns × 5,000 rows (1,000,000 cells; the first tier of the benchmark of that time — revised the same day in Rev 4.30 to 100 columns × 10,000 rows). One run each on the same machine on the same day (seconds, peak MB; the spread is about 5%):
| Operation (200 columns × 5,000 rows) | 0.16.1 | 0.17.0 |
|---|---|---|
| XLSX write | 1.51 s, 242 MB | 1.56 s, 252 MB |
| XLSX read | 2.61 s, 215 MB | 2.78 s, 207 MB |
| XLSX streaming read | 1.83 s, 19 MB | 1.89 s, 19 MB |
| ODS write | 3.10 s, 212 MB | 3.25 s, 212 MB |
| ODS read | 3.71 s, 249 MB | 3.65 s, 267 MB |
| Protect (SheetEncrypt) XLSX / ODS | 0.37 s, 27 MB (4.49 → 4.53 MB) / 0.38 s, 111 MB (1.69 MB; the cost of re-deflating each part) | |
| Back to the plain package (SheetDecrypt) XLSX / ODS | 0.36 s, 22 MB / 0.37 s, 111 MB | |
| Read a protected file (decrypt → read) XLSX / ODS | 2.44 s, 212 MB / 3.53 s, 257 MB — the same as a plain read | |
This measurement found one defect. Passing a protected 4.5 MB XLSX to decrypt was refused as "legacy .xls".
The core's detector (UnopenableInput.probe) looked only at the first 1 MiB of the compound file for the name
EncryptedPackage, but a compound file's directory is placed after the parts — Office writes it that way and so does this library — so
every protected file whose package exceeded 1 MiB looked like "legacy .xls" (the same with ReadOptions.password in 0.12.0).
The detection method (signature plus the part name in UTF-16LE) is unchanged; the window is removed and the scan runs to the end in overlapping 1 MiB steps
(SheetFormat.probe(contentsOf:) does the same scan over a ByteSource and never holds the whole file).
The regression tests are a compound file with a 2 MiB part and a real protected workbook whose package exceeds 1 MiB (EncryptionTests).
The judge is the symbol table (scripts/check-no-crypto.sh, run in CI on macOS and Linux). 3 executables — one linking only the plain products,
one adding SheetDecrypt, one adding SheetEncrypt — are built in a single scratch package and
inspected with nm | swift demangle. After first confirming that the all-in executable does contain the encryption symbols (the positive control — if the grep
is broken, it fails here), we check that the plain executable has none of SheetDecrypt / SheetEncrypt / AES /
OOXMLEncryption / ODSEncryption / CompoundFile, and that the decryption-only executable has none of
SheetEncrypt and encryptBlock / encryptCBC / encryptECB.
An executable links only the objects it references, so the same is checked at the object level too — the .o files of the 6 plain modules have neither definitions nor references,
and the .o files of SheetDecrypt have no encryption side. That is the real proof of "the product contains no such code", and the nm of the executables is the form an app
can reproduce with its own hands.
ReadOptions.sheets (.named / .indices).
In XLSX, a sheet that was not chosen is not parsed; it is carried as the bytes that arrived (the ForeignSheet mechanism of B.35
gained a root: "worksheet" and an isUnread mark). Writing back changes not one byte — but
the cells of that sheet point into the shared string table and the style table by number, so the writer places the original shared string table
first, as it was (PreservationStore.sharedStrings), and keeps cellXfs in its original order too
(StyleTables.cellXfs, StyleRegistry(keepCellXfs:)). The first implementation did not keep the numbers; on read-back
"one" became a different string, and a test turned red to tell us. ODS and Numbers cannot carry bytes, so
a sheet that was not chosen becomes an empty sheet with only its name, reported as degraded on read and dropped on write.
A cell written into a sheet that was not read is dropped as well.CSVStreamingReader / CSVStreamingWriter (SheetCSV).
Reads 256 KiB at a time, and carries a multi-byte character, a surrogate pair or a CR LF split at a piece boundary over to the next piece.
The dialect is inferred from the first piece (as the normal reader does from the first line). UTF-8 and UTF-16 with a BOM are decoded piece by piece;
legacy encodings that do not self-synchronise (Shift_JIS and the like) are decoded whole. The RFC 4180 state machine was made resumable
(a quoted field may span pieces; whether a quote at the end of a piece is a closing quote or a doubled one is decided by the next piece).
The writer renders one row at a time and flushes to the file every 64 KiB.StreamingReader.rows(inSheet:) / CSVStreamingReader.rows() —
can be looped with for try await row in …. The piece feeding of B.39.8 (SAXDriver.PieceFeeder) was turned into a form pulled one piece at a time,
reading only as much as the loop asks for (a break part-way means the rest is never read). It is asynchronous because
that is the shape Swift gives to "an enumeration that can throw"; there is no parallelism. forEachRow stays.
Every number in B.39 was taken on the bench in the workshop repository. From 0.12.0 the bench itself lives on the public side (Benchmarks/,
a separate package that references the library by relative path; the library gains neither an executable nor a dependency). scripts/bench.sh rebuilds in release
first (because of real harm from an incremental build mixing in stale parts), then runs one measurement per process, and writes to docs/performance.json
with a description of the machine, OS, toolchain, commit and material. scripts/build-performance-page.py generates
docs/performance.html from it, and --check in CI verifies "does the page match its source" and
"do the 3 MB figures the README's Limits row claims match the source" — the same construction as the feature matrix (B.15), which
prevents the accident of only the README's numbers going stale. Lower bounds on time are not put into tests (a check that turns red on a slow CI gets removed).
The material is synthetic data (10 columns × 100,000 rows), and the page says so. A real-world workbook (heavy in formatting and drawings) and "open, fix one cell, save" on a sample with a chart and VBA remain as the next materials to add as measurement columns (one of the 3 materials the plan's stage 1 listed). The 0.12.0 record: read 2.8 s, 221 MB; streaming read 1.9 s, 23 MB; write 1.7 s, 258 MB; streaming write 1.9 s, 11 MB; open, fix one cell, save 4.1 s, 262 MB; ODS write 3.4 s, 313 MB; ODS read 3.9 s, 234 MB; CSV streaming read 1.3 s, 10 MB; detection under 1 ms; inspect 2 ms. Set beside the figures before we started (5.7 s, 256 MB; streaming read 5.1 s, 61 MB; write 2.0 s, 360 MB; ODS write 4.0 s, 641 MB; ODS read 13.0 s): reads are 2× faster, ODS reads 3.3× faster, and streaming-read memory is down by a factor of 2.6.
2026-09-05. This section covers together the 2 items the 0.12.0 plan (B.39) sent to "the next version" — streaming reads of ODS and Numbers — and the
investigation of the deferred stage 6 (parallel read). A streaming read is a way of reading that does not assemble the whole workbook as a model but takes rows out one at a time from the
start of the file, hands each to the caller and discards it as soon as it has been handed over (the StreamingReader of B.15, at that time XLSX / XLSM only).
With this revision, every format is called the same one way. 3 things were decided: ① there is one entry point, and the format is
detected from the content by the same rule as Workbook(contentsOf:) ② there is one row type, and the reader of every format returns the same type
③ Numbers' "several tables on one sheet" is reached through a table: argument (the default is the first table — the same as the model's Sheet.table).
The 200-column benchmark (Rev 4.27, 2026-09-05): in addition to the 1,000,000-cell record, we set a benchmark that fixes the column count at 200 and
raises the row count tenfold at a time, 5,000 / 50,000 / 500,000 (1 million, 10 million and 100 million cells)
(at that time scripts/bench.sh --grid → scripts/bench-grid.py, unified into scripts/bench.py in Rev 4.30; the
grid of docs/performance.json, the second table on the same page). The material repeats the 10-column pattern across the width, shifts the numbers per block,
and draws the strings of the second block onwards from a vocabulary of 50 words (in real-world exports the strings grow with the rows, not with the width).
A size that does not fit the machine is not measured rather than crashed: a whole-model operation is not measured when its expected peak (cells × 320 B —
the largest per-cell peak in the 1,000,000-cell record) exceeds 60% of physical memory, an operation that writes a file is not measured when
free disk falls below 1.5 times what it needs, and both leave the reason in the record. bench-grid.py --self-test
pins this judgement. The README's numbers continue to be cross-checked against the 1,000,000-cell record — so Rev 4.27 decided, but Rev 4.30 the same day revised it as follows.
Revision of the benchmark (Rev 4.30, 2026-09-05): the benchmark has 2 tiers, 100 columns × 10,000 rows (1,000,000 cells) and
100 columns × 100,000 rows (10,000,000 cells). 100 columns × 10,000 rows covers the common data-heavy case and
100 columns × 100,000 rows the bulk-data case, and the numbers are round and easy to grasp — that was the reason for adopting them.
The README's headline numbers (the 9 memory figures, in MB, in the Limits row) are taken from the first tier. The 10 columns × 100,000 rows record and
the 3 tiers of 200 columns are retired, and their numbers stay in this section and the CHANGELOG as history (the 100-million-cell tier could not be measured whole-model on the 8 GB machine, and
two points — "the everyday amount" and "the bulk amount" — are enough for a benchmark). The driver is the single scripts/bench.sh → scripts/bench.py,
and the record is the single tiers of docs/performance.json (the first tier is not held in 2 places —
from the real harm, the same day, of the page and the report disagreeing on the numbers of the same tier). The read numbers are the values from reading a file written whole-model, and
the values from reading a file written by streaming go in a separate "file written by streaming" row (no hidden precedence rule).
The judgement not to measure on the estimate (cells × 320 B against 60% of physical memory; 1.5× free disk) stays as it is — both tiers pass on the 8 GB machine, but it is kept
for a 4 GB machine. Performance is measured and checked on macOS (Rev 4.31, 2026-09-05): measuring on Linux happens only
when instructed — development hands and time go towards keeping the numbers to a single source. CI's Linux stays the judge of behaviour and
produces no numbers. A memory figure (MB) is always accompanied by the same operation's run time (seconds) (instruction of the same day as Rev 4.31:
in a table the columns are paired, in a sentence they come as one pair, "15 MB, 4.4 s". Memory alone does not tell whether speed was sacrificed).
A difference in time is stated from the old and the new build run alternately on the same machine at the same time, not from records of different days. The 0.17.1 record (first tier, 100 columns × 10,000 rows): read 2.7 s, 215 MB; streaming read 1.9 s, 19 MB;
write 1.6 s, 254 MB; streaming write 1.8 s, 10 MB; ODS read 3.8 s, 233 MB; ODS streaming read 4.0 s, 228 MB (grows with the width —
almost the same as whole-model, and the plan's next move); Numbers streaming read 0.8 s, 53 MB; streaming write 3.7 s, 25 MB; 8 sheets at the same time 1.1 s, 249 MB
/ one at a time 2.6 s, 141 MB. Second tier (× 100,000 rows): whole-model read 28 s, 1,023 MB; streaming read XLSX 62 / ODS 709 / Numbers 329 MB;
streaming write flat (XLSX 11, ODS 17, Numbers 62, CSV 8 MB). What stands out against the old 10-column record is the ODS streaming read (42 → 228 MB) and
the Numbers streaming write (60 → 25 MB — the unique strings are decided by the row count, and there are few at 10,000 rows).
The 0.17.2 record (Rev 4.31, 2026-09-05, after the fix of Appendix B.39.8): the first tier (100 columns × 10,000 rows) is read 3.8 s, 201 MB; streaming read 2.5 s, 13 MB; streaming write 10 MB; ODS streaming read 15 MB, streaming write 17 MB; Numbers streaming read 26 MB, streaming write 23 MB; 8 sheets at the same time 228 MB, one at a time 135 MB. The streaming reads of the second tier (100 columns × 100,000 rows) are XLSX 19 MB, ODS 14 MB, Numbers 61 MB (0.17.1 was 62 / 709 / 329 MB). They no longer grow with the size of the file: ODS is the same as the first tier, 15 → 14, and the growth that remains in XLSX and Numbers is the shared string table and the string tables and indexes — the strings a reader has to hold, the baggage the README has explained all along as "what every reader carries". Streaming writes are flat as before. Whole-model reads come down too, because the parts are inflated in the same pieces: the record is 215 → 201 MB in the first tier, and in a comparison with old and new run alternately at the same time, the 1,000,000-cell XLSX went 179–191 → 136–155 MB and ODS 175–206 → 135–144 MB (3 runs each; the seconds are the same). Time is unchanged: with old and new run alternately on the same machine at the same time, the 1,000,000-cell streaming read is XLSX 1.6–2.5 s versus 1.9 s, ODS 4.1–5.5 s versus 3.9–5.8 s (3 runs each). The seconds of the second tier in this record are not to be relied on: they were taken on the 8 GB machine with other apps running and 6 GB of swap in use (the whole-model CSV read, which we did not touch, went 21.7 → 48.6 s, and two takes of the whole-model Numbers read gave 41.5 s and 18.9 s). The memory of streaming reads and streaming writes is not affected by swap (a few tens of MB stay resident), so those can be read as they are. For the same reason the whole-model memory is not compared with 0.17.1.
StreamingReader (SwiftSheets) and the 4 per-format readers
StreamingReader is placed in the umbrella module SwiftSheets and delegates to
XLSXStreamingReader (SheetXLSX), ODSStreamingReader (SheetODS), NumbersStreamingReader (SheetNumbers) and
CSVStreamingReader (SheetCSV). The row types StreamedRow / StreamedCell and the options
StreamingReadOptions moved from SheetXLSX to SheetCore (so that the 4 readers return the same types).
The 4 readers satisfy the package-level contract StreamingRowSource — sheetNames, tableCount(inSheet:),
the push form forEachRow (the reader drives the parse and calls once per row) and the pull form rowWalk (the caller asks for one row at a time, and
the reader reads no further than that). The for try await sequence (rows(inSheet:)) is assembled from the pull form in one place in SheetCore
(StreamingRowSequence). The walk that pulls rows out of the piece feeding (the SAXDriver.PieceFeeder of B.39.10),
PieceFedRowWalk, is also placed once in SheetCore and shared by XLSX and ODS.
Rev 4.32 (B.44): the declaration of StreamingReader moved to SheetCore, and it is created by a codec's
streamingReader(contentsOf:) / streamingReader(data:…) (a requirement of SpreadsheetCodec).
The all-in StreamingReader(contentsOf:) is an alias for CodecSet.all.streamingReader(contentsOf:), and
a user who links only the products they need gets the same object from their own CodecSet. The streaming write (B.42) is the same.
StreamingReader became XLSXStreamingReader.
A user of import SwiftSheets writes StreamingReader(contentsOf:) as before and can now open
ODS, Numbers and CSV as well as XLSX. For a user who used StreamingReader with only import SheetXLSX, this is
a breaking change before 1.0, stated in the CHANGELOG. A typealias for the old name is not provided — with the umbrella import
two StreamingReaders would be visible and ambiguous.table: (default 0) exists to reach the second and later tables of Numbers. XLSX / ODS / CSV have one table per sheet, so
anything other than 0 throws (not an empty walk in silence). tableCount(inSheet:) answers the same number as
SheetSummary.tableCount of Workbook.inspect.SheetDecrypt's decrypt before it is walked (neither a compound file
nor an encrypted part can be walked row by row; up to Rev 4.29 the reader decrypted by itself). The core readers refuse by name. A protected Numbers document is refused by name too.
A Numbers document in folder form is opened as well (B.39.4).ReadOptions.cellLimit has
no effect — there is nothing to accumulate.
In ODS every sheet is in the single part content.xml. Walking the third sheet means reading past the first and second;
the lexer runs but not a single cell is created (each table is skipped as a subtree). Parsing stops at the end of the requested table, so the tables after it
are not read. The automatic styles (automatic-styles) come before the tables in the same part, so formatting is resolved in the same single walk
(styles.xml is small and is read first).
Reading a cell's text shares one part with the normal reader. The text of an ODF cell involves 5 things — paragraphs (text:p),
formatting boundaries (text:span), whitespace collapsing (ODF 6.1.2), links over a string (text:a) and
annotations inside the cell (office:annotation — this paragraph must not become the cell's text) —
and writing it twice invites the accident where only one copy gets fixed. So this portion was cut out of ContentParser into ODSCellText, and
the normal reader and the streaming reader feed the same events into the same part. Value interpretation (reading each type of office:value-type) was placed in the same part.
Not one test of the normal reader was changed before or after the cut.
Repeats are expanded by the same rule as the normal reader: a row with content is delivered number-rows-repeated times
(the file says so). A row without content is delivered only when includesEmptyRows is set, and only when the count is below paddingRepeat (1,000)
— the trailing number-rows-repeated="1048000" is a description of the table, not content (§8.3). Column repeats are the same:
an empty styled cell is delivered as a styled nil cell only below 1,000 (the same treatment as the XLSX streaming read delivering <c s="3"/>).
Covered cells (in the shadow of a merge) are not delivered.
Measured before starting (1,000,000 cells, the first time for Numbers): write 8.4 s, 413 MB; read 5.9 s, 323 MB (the model 203 MB).
The document is 34 MB in 433 parts, of which 392 are tiles (Index/Tables/Tile-*.iwa, one per 256 rows).
A call profile showed that nearly half the read time was decoding decimal128 — a 16-byte number was assembled with 14 Decimal multiplications and
a conversion through a string (CellStorage.decodeDecimal128). The test for whether it is an integer went through a string too, Int("\(d)").
Decoding the parts (IWA → the Protobuf tree) fits in the first second; it is a memory problem, not a time problem.
The lazy document NumbersObjectIndex: it enumerates the IWA parts from the envelope's directory, undoes each part's Snappy, decodes
only the archive headers (TSP.ArchiveInfo), and builds an index of object id → (part, position, type). An object's body is
decoded when it is asked for and cached. For a part that holds only tiles (TST.Tile), the inflated body is discarded — it is
inflated again during the walk. The other parts (the document, sheets, table models, string lists, styles) keep their inflated bodies. The normal reader's NumbersDocument
(the mutable container the writer and inspect use too) was left untouched; a small contract that both satisfy, NumbersObjectStore, was placed so that
style resolution (NumbersStyleResolver), text formatting boundaries and value reading (cellValue) are shared.
The fuzzing of §12 applies to the streaming reads of the 3 formats as well (a mutated sample is passed to StreamingReader and the first table is walked).
Building the index returns malformedPart when a header or object length a part declares is negative or larger than what remains
(comparing against "what remains" so that an addition cannot overflow). Cutting out a record treats a negative column count as 0. Broken input is always returned as a SheetError, and
never crashes — this is a part shared with the normal reader, so it works for both.
Row order is a promise: the normal reader does store(at:) for tiles and rows in the order they arrive, so an arbitrary order does it no harm, but the streaming read
promises "top to bottom", so tiles are sorted by tileid and the rows within a tile by tile_row_index before delivery.
Tiles from before BNC (last_saved_in_BNC not set) are skipped with a warning by the normal reader, but the streaming read has no channel for warnings, so it
throws (it does not skip in silence). Formulas are turned back from the tree into text as in the normal reader (the names of references to other tables are collected first), and
a formula that cannot be decoded is delivered as its cached value. The covered cells of an array formula (function 337) are delivered as values only (B.26).
The decimal128 decoding was fixed (this benefits the normal reader too): a number whose upper significand is 0 and fits in 64 bits (nearly every real-world number) is
built directly from a UInt64 with Decimal(sign:exponent:significand:), and whether it is an integer is decided from the significand and exponent.
That not a single digit of the value changes was nailed down by comparing boundary values (a single bit in the 14th byte, negative exponents, around 2^64) against the normal path.
The measurements are in B.40.5.
We counted "common in the specification proper, but unimplemented in one of the formats" from the feature matrix (scripts/spec-feature-matrix.json) and
the 48 rows of docs/format-support.html. There were many candidates, but those the public specification really has, for which
a judge is at hand, and for which the model already has words came to 3.
<office:spreadsheet table:structure-protected="true"> of ODF 1.3 §9.1.2.
The attribute written by LibreOffice's "Tools ▸ Protect Spreadsheet Structure", with the same meaning as Excel's workbookProtection locksStructure
(forbids adding, deleting, renaming and reordering sheets). The feature matrix said "no such concept in ODF (na)", which was wrong.
Reading gives wb.protection.locksStructure; writing writes only the flag. The key is not carried — ODF's table:protection-key is
the Base64 of a SHA-family digest, compatible neither with Excel's 16-bit password hash nor with the SHA-512 of Excel 2010 and later, and
writing "protected" while unable to translate the key is the honest form (LibreOffice treats structure protection without a key as protection that needs no password to lift).
The "workbook protection is dropped" warning that ODS writes used to produce goes away.<calcPr iterate iterateCount iterateDelta fullPrecision> is
the same thing as ODF's table:iteration and table:precision-as-shown (whether iterative calculation is on, its count and convergence, and whether to calculate with the displayed digits).
The model has had CalculationSettings since B.17, yet the XLSX reader did not read calcPr, and the writer rewrote
<calcPr calcId="124519" fullCalcOnLoad="1"/> every time. As a result ① opening and saving an xlsx with iterative calculation enabled
lost the setting in silence, and ② converting ODS to XLSX produced a warning contrary to fact, "this setting exists only in OpenDocument".
Reading takes the 4 attributes into CalculationSettings in WorkbookXMLParser (precisionAsShown = !fullPrecision, default true), and
writing adds them to calcPr. The other attributes of the calcPr that was read (calcMode, refMode and so on) are
carried in PreservationStore.calcPrAttributes and written out as they were when writing back to the same format (the same construction as the workbookPr of B.22).
openDocumentOnlyWarnings no longer says these 4 items are "dropped" when the destination is XLSX.xlsx.package.zip64 was read and written in B.39.1 yet still stood at "none / none"
(the table's as_of was 0.11.1). The README's Limits already said "read and written"; only the table was stale. Corrected to full / full, and
streaming-read rows were added for ODS and Numbers as well.Counted, and not included (with reasons). To change a judgement, edit this table, not the body text.
| Item | Public spec | Why it was not included |
|---|---|---|
| Writing Numbers array formulas | Yes (scattering function 337) | As in B.26, 337 cannot produce a value on recalculation and is incompatible with the design of the older-version template. Read only |
| Writing Numbers filters, sorts and categories | Yes | As in B.26 and B.29, Numbers itself discards filters when importing from Excel. We do not invent a replacement that Numbers does not recognise |
| Writing images to ODS | Yes (draw:frame / draw:image) | XLSX can write them (B.32), so this is a gap. But the ODS reader does not model images either (preservation only), so it would be a one-way feature. → Writing was implemented in Rev 4.26, B.43 (reading left as is) |
| Writing the Numbers 1904 date origin | base_date_1904 exists | Numbers holds dates as seconds from 2001, and there is no sample to confirm what the flag means (whether it is an Excel-compatible reinterpretation). Reading stays as in B.28 |
| Numbers print settings, footer rows and table style presets | Yes | The model has no words for them (printing can be written in the Excel / ODS vocabulary, but there is no sample confirming where Numbers keeps its print information) |
(Measured on the bench of B.39.11. The numbers in this subsection are same-machine, same-material, same-time comparisons.)
Streaming read in the 3 formats (1 million cells, 2026-09-05, from the bench record): XLSX 2.0 s, 24 MB (whole-model 2.9 s, 221 MB), ODS 4.2 s, 42 MB (whole-model 4.0 s, 233 MB), Numbers 1.6 s, 85 MB (whole-model 2.6 s, 323 MB). In every format, reading row by row takes 1/5 to 1/9 of the whole-model memory. The ODS streaming read is 0.3 s slower than the whole-model read because it assembles and hands over one row at a time; the tokenising of the body is the same single pass in both. The 42 MB of ODS breaks down into the pieces (256 KiB each), the style inventory, and the mapped file itself (pages that were touched count as resident). The 85 MB of Numbers is the mapped document, 34 MB, plus the table's string list (100 thousand header strings) turned into a dictionary, and one tile in hand.
Numbers before and after (1 million cells): read 5.9 → 2.6 s (the fix to decimal128 decoding. Memory unchanged at 323 MB —
the model itself is 203 MB). At 100 thousand cells, 0.58 → 0.29 s. The write, 8.0 s and 413 MB, was not touched and is a candidate for the next
version (as with the read, take a call record first, then fix). From this measurement on, scripts/bench.sh measures Numbers at 1 million cells
too (until then the material was an order of magnitude smaller). The MB figures the README's Limits row states go from 3 to 5 (streaming write, streaming read
xlsx / ods / numbers, whole-model), each cross-checked by machine against docs/performance.json.
Two things learned while fixing. (1) The ordinary Numbers reader was hashing the whole 384-byte CellStyle per cell to
look up the shared style (7% of the call record). It now looks up by the integer key the style resolver uses. (2) A tile row's cell record
was copied twice: a slice of Data and a copy into [UInt8]. We added
CellStorage.decode(UnsafeBufferPointer), which reads the buffer in place, and both the ordinary reader and the streaming read use it.
Stage 6, "after reading the shared strings and the styles, read the sheets at the same time", was left out of 0.12.0 (the B.39 plan said "automatic, with no knob").
The reason is a single one: the two lazy dictionaries of StylesParser — sharedStyle(index) (the shared style per xf) and
numericKind(index) (whether that format is a date or an elapsed time) — are shared by every sheet's parser, and each writes to them the first time it sees an xf.
If two parsers write at once, they break. This revision does not implement it; it settles the design and measures the effect with a prototype.
Inventory — the shared things a sheet parse touches (from grep static var / nonisolated(unsafe) read against the code):
| Shared thing | Current form | Condition for going parallel |
|---|---|---|
Shared string table sst.strings | Value-type array. Read only | As is (passed to each parser; no copy happens) |
StylesParser.sharedStyle / numericKind | Writes to a dictionary per reference (lazy) | Fill for every xf first (prefill()). A missing index returns the default without writing |
ZipArchive and ZipEntryStream | The envelope is Sendable (positional reads). One decompression stream per sheet | As is |
| Tokeniser and the SAX driver | One per parser | As is |
warnings, consumed, sheets | Appended in order to the reader's local variables | Collect into a result box per sheet, then order by sheet after finishing. For errors, throw the first one in sheet order |
FormulaExpr.parse, NumberFormat.isDateFormat | Static tables are immutable. The only cache is numericKinds | As is |
cellLimit | The XLSX reader does not count (only ODS does) | Not relevant now. If it counts, an atomic counter |
The streaming read's lastLargestCarry | nonisolated(unsafe) static variable (for tests) | Outside the scope of parallelism (a streaming read is one sheet) |
Design: move the sheet for-loop of WorkbookReader.read into SheetReadContext (an @unchecked Sendable container
holding the read-only shared things) that carries the work for one sheet (resolving relationships, decompressing and parsing the part, table parts,
pivots, comments), and run it once per sheet with DispatchQueue.concurrentPerform (a synchronous API that also works on Linux; the caller's
Workbook(contentsOf:) stays synchronous). The results are reordered by sheet index and the warnings concatenated in sheet order. Parallelism on the
write side is outside this design, because the indices in the shared string table and the style table are assigned first come, first served (as in B.39).
Prototype measurements (on a separate working copy with the design above applied to the 0.12.0 code. 1 million cells, same machine, median of 3 runs. The switch is an environment variable, so the same executable was run serially and in parallel): 1 sheet 2.80 → 2.82 s, 221 MB unchanged (no regression — with one sheet there is no work to divide). 8 sheets × 125 thousand cells: 2.73 → 1.20 s (2.3×), peak 145 → 258 MB (+113 MB). 32 sheets × 31 thousand cells: 2.70 → 1.15 s (2.3×), 136 → 183 MB (+47 MB). The floor on speed is the parsing of the shared string table, which stays serial (about 0.5 s), and the assembly of the model. Memory grows because, while 8 parsers grow their tables at the same time, the working space that dictionaries and arrays double into overlaps; the size of the tables themselves does not change.
Decision — implementation goes to the next version, the shape stays "automatic". Go parallel only when (1) there are 2 or more sheets and (2) the sum of the
declared cell counts exceeds 100 thousand (to avoid the cost of spinning up threads for a small workbook. The declared counts come from the same place as inspect in B.39.3).
No knob (the B.39 decision stands). But since the extra memory is a change visible to users, add an "8-sheet workbook" column to the bench and put the
parallel peak in the README's Limits with machine cross-checking. What production needs: besides the 2 points prefill() and
SheetReadContext, fixing the order of warnings (sheet order), an addition to the fuzz target (hand over 2 parts broken at the same time), and
measurement on Linux. The prototype patch and raw data go to the experiments area of the workshop repository, not into the main tree.
2026-09-05. Stage 6, which B.40.6 took as far as the design and the prototype measurements and deferred "to the next version", is now in the main tree. The B.39 plan was "automatic, with no knob",
but we put in one integer (ReadOptions.concurrency). The reason is that the number of sheets read at once is itself the upper bound on the
extra memory (see "the memory reasoning" below), and that number was the most straightforward shape for a cap. It applies only to the whole-model read of XLSX / XLSM
(Workbook(contentsOf:)). ODS has a single body part that cannot be divided, and Numbers reads from a table of contents (B.40.3), so neither reads this
setting.
Mechanism (3 stages):
StylesParser.prefill()). A nonexistent xf index returns the default only and does not write to the dictionary. With this, the style table is
read only while the sheets are being read.SheetReadContext (an @unchecked Sendable holding the read-only shared things), and sheets are taken from a queue
(SheetQueue) one at a time and run. How many run at once is decided by the rule below, and they are driven by
DispatchQueue.concurrentPerform (the same on Linux). The caller's API stays synchronous.SheetResultsBox) and taken out in sheet order after finishing. Warnings are concatenated in sheet order, and if 2 or more sheets failed,
the first one in sheet order is thrown. Whatever order they finish in, the result is the same.
The rule — from light information available without reading: the ZIP directory declares each part's decompressed size. The read already has the
directory, so summing the declared sizes of the sheet parts to be parsed (excluding those removed by ReadOptions.sheets and those that are not grids) reads
nothing extra. When concurrency is nil (the default), sheets are read at the same time, up to the core count, only when there are 2 or more sheets and 4 MiB or more in total
(4 MiB is roughly 100 thousand cells in SpreadsheetML; this avoids the cost of spinning up threads for a small workbook). 1 means one at a time regardless of size,
and n (2 or more) means up to n at a time even for a small workbook (a value above the sheet count is rounded down to the sheet count; 0 or below becomes 1).
The memory reasoning — why one integer is enough: whichever way you read, every sheet remains in the model at the end, so what a concurrent read adds is only
"the working space of sheets that are growing at the same time" (the amount arrays and dictionaries double into). That cannot exceed the number of sheets running at once.
So if the caller holds the upper bound on concurrency, it holds the upper bound on the increase. We did not put in a mechanism that throttles automatically in step with physical memory:
it would make the reader guess at the size of the model itself (declared cell count × 100–200 bytes), which the caller knows better through Workbook.inspect
and cellLimit. The prototype numbers (+113 MB with 8 sheets at once, +47 MB with 32 sheets × 31 thousand cells) say
"the smaller each sheet, the smaller the increase", consistent with the increase being the working space of the sheets running at once.
Measured (a workbook of 1 million cells split into 8 sheets, same machine, release, added to the bench scripts/bench.sh as
writeSheets / readSheetsSerial / readSheets):
one at a time 2.7 s, 145 MB → concurrent 1.2 s, 257 MB (2.4×, +112 MB).
A one-sheet workbook stays at 2.8 s, 214 MB (unchanged, since there is no work to divide). The README's Limits states these 2 MB figures from the
readme of docs/performance.json with machine cross-checking. On Linux, CI runs the same checks to judge correctness, but
speed is not measured.
Promises that do not change: warnings are in sheet order. The first failure is in sheet order. Compatible with ReadOptions.sheets
(removed sheets are not parsed and are not included in the declared total). formulaCells and preservesUnknownParts pass through unchanged to each
sheet's container. Parallelism on the write side is outside this design, because the indices in the shared string table and the style table are assigned first come, first served (as in B.39).
Tests: ParallelReadTests — the one-at-a-time and concurrent reads agree on both the model and the warnings (6 sheets with shared strings, 2 styles,
formulas, links, comments and merges) / the warnings for a selection that removes 4 sheets are in sheet order (repeated 5 times) / when 2 sheets are missing their parts at the same time,
the one thrown is the earlier sheet (repeated 5 times) / the rule table (sheet count, size, setting, core count) / the dictionaries are filled up front and do not grow on a missing xf.
A 3-sheet workbook was added to the fuzz (§12) samples, and every format's mutations are read with concurrency: 4 — even when 2 broken parts arrive at once,
the result is a SheetError.
What we did not do: restoring Numbers tiles concurrently (the read is 2.5 s, and structurally the table of contents is built first — measure first, next time). Parallelism on the write side (above). ODS (a single part).
2026-09-05. We built the same shape as the streaming read (B.40) on the write side. The caller only hands rows to StreamingWriter(url:) and
calls close(), and can write XLSX / XLSM, ODS, Numbers or CSV without assembling a workbook.
Shape (a mirror of the read):
StreamingRowSink (addSheet(named:) / append([Cell]) /
close() / warnings). The values-only append([CellValue?]) is a default implementation.StreamingWriter(url:format:sheetName:epoch:csv:). The format is decided from the extension by the same rule as
Workbook.write(to:) (XLSX if none), and can be overridden with format:.
The per-format writers XLSXStreamingWriter (the former SheetXLSX.StreamingWriter, renamed —
to pair with the reader's XLSXStreamingReader) / ODSStreamingWriter / NumbersStreamingWriter /
CSVStreamingWriter can also be used on their own.warnings, finalised at close(). Anything the format cannot carry always appears here (§6).
A second addSheet for CSV throws rather than folding into one sheet in silence.
The ODS pressure point — one part, with styles before the body: the ODS body is a single content.xml, and the styles cells wear
(office:automatic-styles) must by rule come before the tables, so rows cannot be streamed into the envelope in arrival order as in XLSX.
Each row is turned into XML as it arrives (sharing ODSWriter.cellXML; styles grow in the register ODSStyleRegistry) and spilled to a
TextSpill (in memory up to 8 MiB, then a temporary file); at close(), the head, the accumulated styles, each sheet's
table:table with its column declarations (the column count is that of the widest row), the spilled rows and the tail are streamed in that order into one streamed entry.
Trailing empty cells are not written, empty cells in the middle are written with repetition, and a row with no cells gets one empty cell (an ODF table row has at least one cell).
Memory is a few MB regardless of the row count, and one uncompressed copy of the body on disk remains until close() — the price a single-part format pays.
The way to read the spill back is read(2) (found by measurement): the first implementation read the spill file back 1 MiB at a time with
FileHandle.read(upToCount:), but the streaming-write ODS peak was 42 MB at 20 thousand rows and 123 MB at 100 thousand rows (1 million cells) —
it grew in proportion to the row count. While rows are being spilled it is flat at 19 MB, so the growth is on the read-back side of close().
An isolating experiment (30 lines that only write a 55 MB temporary file and read it back) showed that Darwin's FileHandle keeps what it has read
resident in the process (59 MB with both read(upToCount:) and readData(ofLength:); 8 MB with
read(2)). TextSpill.forEachPiece reuses one 1 MiB buffer and reads with read(2)
—— the streaming-write ODS went 123 → 23 MB (100 thousand rows), independent of the row count, and the whole-model ODS write (which uses the same spill) went 246 → 217 MB.
The Numbers pressure point — a 256-row tile is a part: each tile is turned into IWA and written to the envelope as soon as it fills, and the body is not held
(NumbersDocument.addStreamed records only the id and the location and returns the byte string; encoded(into:) writes the remaining parts
into the same envelope). What remains to the end is the table's model, the string list (one entry per unique string — the same nature as the shared string table
in the XLSX read, and unavoidable), the lists of styles and formats, the cell count per column, and the row headers (both Numbers and numbers-parser reach row
records via the row headers, so they cannot be omitted. About 12 bytes per row are kept in wire format — ProtoMessage.Value.raw was added
for this. 12 MB for 1 million rows). Formulas are written as their cached value, rich text as plain text, and links, comments and cell controls are dropped,
each counted and warned about (the formula archive of B.18 requires the table's UUID and is left to the whole-model writer).
The second and later sheets duplicate the template's already-written sheet: with one table per sheet, the "inheriting a second table" problem of B.18 does not
arise, and the inherited lists, dimensions and frame are overwritten by its own close (tiles already written out are not objects, so they are not
carried into the duplicate).
The Numbers width rule: a tile row's cell_offsets must by rule have one entry per table column (numbers-parser reads
number_of_columns of them). A tile that has been written out cannot be widened, so the table width is decided by the widest row, and
a row wider than a tile already written out is refused (unsupportedFeature, saying "put every column in the first row").
Widening within the first tile (256 rows) is fine.
Measured (1 million cells; streamWriteODS / streamWriteNumbers added to the bench):
streaming write XLSX 1.9 s, 11 MB; ODS 4.2 s, 22 MB; Numbers 10.3 s, 60 MB
(the whole-model writes are XLSX 1.7 s, 258 MB; ODS 3.6 s, 212 MB; Numbers 9.0 s, 289 MB). The README's Limits states these 3 MB figures from the
readme of docs/performance.json with machine cross-checking.
Tests: StreamingWriteFormatsTests — stream 2 sheets of 700 rows (3 Numbers tiles' worth) in the 3 formats and
read back both whole-model and streaming / consistency of the Numbers document (parts and references; each table's 3 tiles are separate parts) / numbers-parser reads it
(skipped if not installed) / LibreOffice reads it and writes it back to XLSX (skipped if absent) / the width rule / a second sheet for CSV / the extension and
format: (.tsv, no extension, .dat + ods, .xlsm) / warnings for what cannot be carried (counts of formulas, links and comments; XLSX has no warnings) /
a sheet with no rows.
What we did not do: streaming write of Numbers formulas (above). Row heights and column widths (not on Cell). Merges, comments, conditional formatting (the same line as the streaming read — values and formats only).
Purpose: write the images that B.32 made placeable in XLSX (addImage(_:at:sizing:) /
addImage(_:over:)) to ODS too, with the same API. Until now, writing to ODS only counted them and said
dropped (B.32; the row "Writing images to ODS" in the gap table of B.39.10).
Mechanism. (1) What LibreOffice does: it puts the picture's bytes under Pictures/ in the envelope, one part each, and adds a
manifest:file-entry (with manifest:media-type) to META-INF/manifest.xml.
In the body it puts, inside the cell where the picture goes, a draw:frame (svg:width / svg:height, and
svg:x / svg:y from the cell's top-left), and the draw:image inside it points at the part
with xlink:href="Pictures/…". For a picture stretched to fit a range, the frame also carries
table:end-cell-address (the cell one past the bottom-right of the range) and table:end-x / table:end-y
—— LibreOffice reads these as the anchor "resize with cell".
(2) What breaks if you do not imitate it: a part not in the manifest makes LibreOffice treat the document as "corrupt" and send it to repair. Putting the frame outside the cell
(in table:shapes) loses the anchor, and the picture does not move when a row is inserted.
(3) So this is what we did: part names are numbered from Pictures/image1.png across the whole document, avoiding the names under
Pictures/ carried over in preserved from the source ODS (the original pictures are carried along without being reconnected —— that warning is
as before). The frame is inserted into the B.8 writer's cellXML: after the comment and the detective arrows, before the body paragraphs
(the order of ODF 1.3 §9.1.4). A position that has a picture but no cell also gets its row and cell written, as with a merge anchor. A picture anchored inside a merge is moved to the merge's origin cell
(the element of a covered cell is empty and can hold nothing —— LibreOffice also moves it to the origin).
Dimension conversion: pixels are converted to cm at 96 dpi (px × 2.54 / 96, 3 decimal places —— the way LibreOffice writes it).
.original keeps the pixel count, .scaled uses the specified pixel count, and .fitCell is the largest that fits the anchor cell's current size
(column width is character count × 7 + 5 px, row height is pt / 0.75 px —— the same conversion as B.32). For a picture fitted to a range, the
svg:width / svg:height are provisionally written as the sum of the range's column widths (B.8's 2.0 mm per character) and row heights (pt), and
LibreOffice decides the actual size from table:end-cell-address. The judge's verdict: LibreOffice gave priority to end-cell-address and stretched the picture to fill the range —— a picture placed at C3:D4, converted to xlsx, became a twoCellAnchor editAs="twoCell" with its end at the bottom-right of D4 (a range of column width 2.267 cm × 2 by row height 0.452 cm × 2, not the svg:width 3.372 cm we wrote). In a workbook that does not set column widths explicitly, LibreOffice's default column width is wider than our 2.0 mm per character, so the provisional dimensions differ from the actual range, but the picture fits the range.
Reading does not change: the B.8 reader skips draw:frame, so the pictures this writer places
do not come back through our own reader (unlike the XLSX read, which keeps pictures in preserved). In the support table,
ods.draw.image honestly states write full, read none. The ODS streaming write (B.42) has no entry point for images.
Tests (ODSImageTests): the part goes into the envelope as the caller's bytes unchanged and appears in the manifest with a media-type,
the frame is inside the anchor cell, the cm of the 3 sizings (original, specified, fit to cell), the range's
end-cell-address, a picture at a position with no cell, 2 pictures in one cell, no name collision with the source ODS's Pictures/.
The judge is LibreOffice (skipped visibly via .enabled(if:) if absent):
with --convert-to xlsx the picture makes it to xl/media/, and after re-saving with --convert-to ods the
draw:image remains.
What we did not do: charts, shapes, text boxes (addChart is still dropped for ODS, as before).
Raising pictures into the model in the ODS read. Images in the streaming write.
2026-09-05. The trigger was a request from Left Right (an in-house comparison app). It wanted
Workbook.inspect, which refuses by declared cell count before reading a table, and StreamingReader, which reads one row at a time, but both
existed only in the all-in-one product SwiftSheets, and linking the all-in-one puts the unused SheetCSV into the shipped binary (measured: release with symbols
stripped, 4.09 → 4.20 MB, about 115 KB. The figure includes the inspect and streaming-read paths). The cost is small, but the essence of the request is not the cost.
The README promises "link only the parts you need", yet the moment any one format was left out, all 5 entry points — open, inspect, streaming read, streaming write and
convert — disappeared, because they were written as 6 switch statements inside the all-in-one, which also literally contradicted Chapter 2's
"L4 has no format-specific branching".
The dispatch calls each format's reader. The core SheetCore is on the side that does not know the readers (the far end of the dependency arrow), so the
branching cannot simply be moved into the core. Only the "table" can be pushed down; the table's contents (which readers exist) must be
handed to the core by the caller. That is CodecSet (explicit composition).
We did not adopt a registration scheme where readers write themselves into a roster at startup (the plugin registry of other languages) — the caller cannot see what is in it,
it depends on order, and it sits badly with Swift 6 concurrency checking. Nor did we adopt combination products such as "all-in-one minus CSV" —
the next combination would add another. With a set, the caller decides the combination.
CodecSet (SheetCore): a table keyed by SheetFormat, built from
[any SpreadsheetCodec.Type]. read(contentsOf:) / read(_:format:options:),
inspect(…), write(_:as:) / write(_:to:as:), convert,
streamingReader(contentsOf:) / streamingReader(data:…), streamingWriter(url:…).
The rules that stand in front of every codec — format detection (SheetFormat.detect / probe), refusing compound files and encryption
by name (§1.3, §14.11), the folder form of Numbers (§4.2), telling CSV from TSV by file name — are written here once.
The format-independent openDocumentOnlyWarnings (B.17, B.23) and outputFormat also move to the core.
If the same format is declared twice, the later one remains (this is not a place to catch a caller's typo, so it is not a precondition).SpreadsheetCodec: inspect(_:options:),
read(contentsOf:) / inspect(contentsOf:) (the default implementations map the file and defer to the data versions; only Numbers, which has a folder form, overrides them),
streamingReader(contentsOf:) / streamingReader(data:…), streamingWriter(url:…).
The URL version of the streaming read has no default implementation because each reader has the positional read of B.39.8 Rev 4.31 (a mapping default would be a regression).
The 3 formats' inspectors (XLSXInspector / ODSInspector / NumbersInspector) stay package-level, and
each codec's inspect exposes them outside the product. The CSV inspect (which only counts rows) was taken over from the all-in-one's inline code
into CSVCodec.StreamingReader (B.40.1) and StreamingWriter (B.42) move from the all-in-one
to SheetCore. Only a codec can create them (package init) — the reader contracts StreamingRowSource /
StreamingRowSink stay package-level, so an outside party cannot plug a home-made format into the set. An intended restriction
(publishing the plug-in point would make that contract an API and freeze it).CodecSet.all (5 codecs), and Workbook(contentsOf:) /
Workbook.inspect / write(to:as:) / convert / StreamingReader(contentsOf:) /
StreamingWriter(url:) are all one line into .all. Neither the call forms nor the arguments change.
Because StreamingWriter(url:) is a convenience init on a class in another module, it takes the sink out of the vessel the set built and passes it to
self.init(sink:format:) (one extra vessel is created, but harmless, since the sink holds the state).SheetError.unsupportedFeature (the "unimplemented format" slot of §4.3) with
"no codec for .csv is in this CodecSet — link the SheetCSV product, or the SwiftSheets product, which has every codec".
It names the format and the product. Since the core knows every format for detection, CSV is not made an "unknown format". No new case is added.
Changed in Rev 4.41 (B.52) — the refusal text stays, but the answer is SheetError.noCodec(for:).SheetDecrypt / SheetEncrypt keep depending on the all-in-one and
keep using the existing call forms. scripts/check-no-crypto.sh only walks the 6 module names in order, and since no module is added, it is unchanged.Users who link the all-in-one: no change. Users who link only the products they need build one set:
import SheetCore // plus SheetXLSX / SheetODS / SheetNumbers — no SheetCSV, no SwiftSheets
let codecs = CodecSet([.xlsx, .xlsm, .ods, .numbers])
let summary = try codecs.inspect(contentsOf: url) // before reading: sheets, declared cell count
let workbook = try codecs.read(contentsOf: url).workbook // the format is judged from the content
let reader = try codecs.streamingReader(contentsOf: url) // one row at a time
CodecSetTests: CodecSet.all holds all of SheetFormat.allCases / the all-in-one's call forms and the set's
methods return the same answer / an XLSX-only set refuses ODS by format name and product name / declaring the same codec twice gives one entry.
A new test target, PartialLinkTests (dependencies are SheetCore, SheetXLSX, SheetODS and SheetNumbers only;
it does not use @testable either): opens, inspects, walks and stream-writes each of the 3 formats from bytes and from a URL, and
is refused CSV by name at all 7 entry points. Since SwiftPM links the test targets into one bundle, what this target proves is
"compiling and running works with the import of just the 4 products". That SheetCSV is absent at the link level was confirmed by the symbol tables and sizes of the
2 executables built for the proposal (the measurement above).
SwiftSheets.StreamingReader / SwiftSheets.StreamingWriter change to
SheetCore.… (the all-in-one re-exports SheetCore, so nothing changes without qualification).SpreadsheetCodec gained 6 requirements. No outside conformers are assumed.Plugging in outside codecs (above). Combination products (above). The display design on the Left Right side (how to show the streaming read on screen is their plan).
2026-09-06. The trigger was the Web version of Stream. Mac and iPad can handle xlsx / ods / numbers with this library, but
the Web version alone could handle only xlsx, through a separate Python part. Running this library as is inside the browser makes the conversion implementation
one for all 3 surfaces (unlike the "write common parts in Rust and ship them as WebAssembly" idea that Stream's plan rejected, no language is added).
Xcode's Swift cannot emit WebAssembly (it lacks the LLVM wasm backend), so we got --swift-sdk swift-6.3.3-RELEASE_wasm through with the
swift.org-distributed toolchain of the same version and the Wasm SDK.
Deflate.swift gained
a third path under #if os(WASI) — reading is a complete RFC 1951 inflater (fixed and dynamic Huffman, stored;
the same "walk the canonical table one bit at a time" shape as zlib's puff), writing is uncompressed stored blocks
(65,535 bytes each, BFINAL on the last). It is valid DEFLATE that every reader accepts, but the written files are larger
(for Stream's 70-row plan, xlsx 102 KB against 20 KB, ods 299 KB against 16 KB; numbers was all stored to begin with, so it is unchanged).
The streaming inflater InflateStream accumulates the fragments and inflates them in one go when the end arrives (quadratic effort on a large part; negligible at the size of a plan).
CRC-32 is table-driven (the README's "Swift's table lookup does 33 MB in 0.09 s"). The CZlib dependency in Package.swift is
limited to the platforms other than WASI (.when(platforms:) can only enumerate positively).Int: 16 << 30 becomes 0, and the default expansion limit turned into "refuse every file"
(measured: every read reported unrecognized format). ZipLimits.defaultMaxExpandedBytes is
Int.max under _pointerBitWidth(_32). The ZIP64 marker 0xFFFF_FFFF is read with Int(truncatingIfNeeded:)
and compared against Zip.marker32 (the same value on 64-bit, -1 on 32-bit). The writer's limit32 likewise.DispatchQueue.concurrentPerform. The parallel sheet read of XLSX (B.41) is
serial on WASI. The workers value is accepted as the caller's wish and not honoured.Data.write(options: .atomic) is unavailable on WASI.
CodecSet.write(to:) writes directly on WASI. TextSpill (B.39.7) fails in an environment with no temporary area
and falls back to the normal path.Bundle.module bakes in a path on the build machine. The SheetNumbers resources
(the schema, the registry, the empty template) go through NumbersResources.swift, which on WASI reads the fixed
/SwiftSheets_SheetNumbers.resources/. The runtime (node's WASI, a browser's thin WASI implementation) mounts
a folder under that name.open / fstat / pread used by ByteSource and TextSpill come from
WASILibc.
The full native test suite (1,071 tests) is unchanged. For the WebAssembly build, Stream's scripts/wasm-smoke.mjs (node's WASI)
confirmed that importing an xlsx matches the reference JSON byte for byte, and that writing and reading back xlsx / ods / numbers round-trips.
LibreOffice converted the written files of all three formats to PDF. Not confirmed in Numbers itself or Excel itself (stored-block DEFLATE is
to the standard, but B.9 has an example of a lenient reader letting something through). There is no WASI job in the Linux CI — the toolchain is
limited to the swift.org distribution, and keeping its version in step with the CI's Swift would be a separate chore. The local verification steps are
written at the top of Stream's scripts/build-wasm.sh.
Approved 2026-09-07. PreservationStore, OpaquePart, XMLFragment, Relationship, StyleTables, SheetPreservation, ForeignSheet and their members, and Workbook.preserved and Sheet.preserved become package. Closing off writes to the raw data does not change the existing F3, partial reads or loss warnings. anchorTextFormula, sanitizedName, cleanMergedRange and asAssumedOutsideODF stay public. Helper APIs such as the ZIP check are tied to the public SpreadsheetCodec contract, so they are handled on the side that changes CodecSet's public surface.
public enum SheetContentState: Sendable, Hashable {
case grid
case unread
case nonGrid
}
// Sheet
public var contentState: SheetContentState { get }
grid is an ordinary grid (including new, empty and loaded). unread is a worksheet that ReadOptions.sheets excluded from reading.
nonGrid is a sheet that the model does not interpret as a grid. The check looks at isUnread first, then at whether a foreignSheet is present.
Holding the bytes of an unread XLSX worksheet does not make it nonGrid.
However, a chart sheet left out of the selection stays nonGrid — its kind is declared by the relationship type in the workbook,
and is known without parsing the part. Calling it unread would throw away information the reader already has (the warning text in Appendix B.35 also says "a chart sheet").
A grid that could only be partly read because of cellLimit and the like is also grid; this is not a guarantee of a complete read. Check readWarnings for read losses; a read stopped at cellLimit gives a truncated warning naming the sheet (B.92).
Sheet.state is visibility and is independent of this state. contentState is a computed property read from the preservation information, and follows the sheet after a rename or move.
Duplication does not follow — duplicateSheet rebuilds the copy's preservation information (it cannot claim the original's part path, r:id or sheetId), so
duplicating an unread / non-grid sheet yields an ordinary grid carrying only the cells the model holds. The original bytes are written once, on the original sheet's side.
Setting cells on an unread / non-grid sheet does not change its state; the existing save-time warning and byte-preservation rules stand.
The limitation that an unread ODS or Numbers sheet comes out empty when saved to the same format, and the limitation that Numbers forms are excluded from the model, are unchanged.
public struct PreservationSummary: Sendable {
public let sourceFormat: SheetFormat?
public let opaquePartCount: Int
public let hasVBAProject: Bool
public init(sourceFormat: SheetFormat?, opaquePartCount: Int, hasVBAProject: Bool)
}
// Workbook
public var preservationSummary: PreservationSummary { get }
An immutable snapshot that mirrors sourceFormat, parts.count and hasVBAProject at the moment it is taken. Taking it does not expand any part. A new workbook gives nil, 0, false. The public initializer exists only to construct summary values and cannot change what the Workbook preserves. The part count does not count XML fragments or the editing model's images and charts, and 0 does not mean there is no preservation information. The source format is no guarantee about the destination format, and the presence of VBA is no guarantee that the destination can carry macros. The generating application is read from sourceInfo, read warnings from readWarnings, write losses from WriteResult. No public equivalent is provided for the list of part names, a per-category summary, raw XML, or removing or replacing individual parts.
From a separate SwiftPM package, call the public summary, state and the 4 editing operations, and also confirm that access to the preservation types and to preserved fails. Test partial reads in every format, non-grid sheets, rename / move / duplicate, changes after the summary was taken, and that taking it does not expand compressed data. Pass the existing preservation, style, relationship-ID and warning tests and the full swift test, and run the XLSX / Numbers parity.
Approved 2026-09-08. @discardableResult is removed from the 5 declarations CodecSet.write(_:to:as:options:), CodecSet.convert(_:to:output:readOptions:writeOptions:),
Workbook.write(to:as:options:), Workbook.convert(_:to:output:readOptions:writeOptions:) and
Workbook.write(to:as:options:password:).
A call that does not use the return value becomes a compiler warning. Builds that treat warnings as errors need a migration.
Receive it with let result = try workbook.write(to: url) and check warnings; when discarding it on purpose, say so with
_ = try workbook.write(to: url).
This is not a mechanism that forces the warnings to be checked. Using only the result's data is possible, and so is an explicit discard. The output bytes, warnings, suggestion, error contract, arguments, return type and save order are unchanged. By the time write(to:) returns, the file is saved. To check before saving, produce the result with write(as:) and save the same data you checked. Read warnings are checked from ReadResult.warnings or Workbook.readWarnings. convert returns the read warnings and then the write warnings, in that order (B.23).
At the time of this decision the plain and encrypting data(as:) were kept. The follow-up B.48 folds them into write(as:).data. Renaming convert's arguments, the streaming API and turning warnings into errors are out of scope. scripts/check-write-result-api.py compiles an external client and confirms, for each of the 5 declarations, the unused-result warning, the warnings-as-errors failure, and that using the result and discarding it explicitly both succeed. It runs on the CI's macOS and Linux.
Approved 2026-09-08. The 2 declarations Workbook.data(as:options:) and Workbook.data(as:options:password:) are deleted.
No deprecated aliases are kept. The plain form migrates to try wb.write(as: format, options: options).data,
the encrypting form to try wb.write(as: format, options: options, password: password).data.
Argument defaults follow write's existing contract. Old calls become compile errors.
Both old APIs only returned the data of the corresponding write, so the output processing, errors and save order are unchanged. write(as:) returns a WriteResult without saving to a file. To check warnings and suggestion, receive the result. Taking only data is also possible; the consolidation does not force the warnings to be checked. Losses at read time are checked separately from Workbook.readWarnings. When checking before saving, save the same result.data you checked (B.47).
scripts/check-write-result-api.py checks, in an external client, that using the 2 old declarations fails and that using the plain and encrypting write succeeds. The result's warnings and suggestion, the decrypted result of the encrypted output and the error for an unsupported format are tested. convert's arguments, CodecSet's public surface and streaming are unchanged.
Approved 2026-09-08. Workbook.convert and CodecSet.convert become
convert(_ source: URL, to output: URL, as format: SheetFormat, readOptions: ReadOptions = ReadOptions(), writeOptions: WriteOptions = WriteOptions()) throws -> WriteResult.
The Workbook side is static. The old overload with to: for the format and output: for the destination is not kept; old calls become compile errors.
Migrate to try Workbook.convert(source, to: destination, as: .csv).
The format stays required. No feature that infers the format from the destination's extension is added. The order, types and defaults of readOptions and writeOptions, throws, WriteResult and the unused-result warning are kept. The order read → write → save, the order of warnings (read side then write side) and the write side's suggestion are kept. The contract that the file is saved by the time it returns is not changed either. The keep-the-arguments position taken at the time of B.47 and B.48 is updated by this follow-up decision.
scripts/check-write-result-api.py verifies the new calls of both declarations (default and explicit options) and confirms that the old labels and omitting the format fail. The conversion result, the saved content and the passing of read and write options are confirmed by behavioural tests.
Approved 2026-09-09. Public format selection is concentrated in CodecSet. SpreadsheetCodec, the per-format codecs and the per-format streaming readers and writers (122 declarations) become package. CodecSet's 2 declarations stay public but change shape.
The format is chosen by a public Codec value that has no public initializer and whose only public member is format.
The values come from static properties of each product module — SheetXLSX gives .xlsx and .xlsm, SheetODS .ods,
SheetNumbers .numbers, SheetCSV .csv. The format of a product that is not imported cannot be referenced.
Change to CodecSet.init(_ codecs: [Codec]) and codec(for:) throws -> Codec. The old initializer is not kept.
The returned Codec gets no public methods such as read or write. Reading and writing go through CodecSet, and through the shared detection and warning handling.
When the same format is given more than once the last wins, and formats keeps the order of first appearance. An empty array is allowed too.
What contains and codec(for:) consult is the formats registered in that CodecSet;
a format that is merely linked into the binary is not treated as available. The error for an unregistered format keeps the current SheetError.unsupportedFeature,
and its text points to both linking the product and registering it in the CodecSet (changed to noCodec(for:) in Rev 4.41, B.52; the text is kept). SwiftSheets' CodecSet.all continues to register every format.
StreamingReader.tableNames(inSheet:) throws -> [String?] is added. The order and count of the array match the table index of
tableCount(inSheet:) and forEachRow for the same sheet. A table with no name returns nil; "Table" is not synthesised
(an actually recorded "Table" or an empty string is returned as is). The default implementation is an array of nils of the same count as tableCount; only Numbers fills in real names.
A sheet that does not exist keeps the same error contract as tableCount in the same format.
There are 4 breaking changes with no public replacement: external conformance to SpreadsheetCodec (implementing your own format is not part of the 1.0 public surface),
canDecode(ZipInspection), NumbersCodec.templateURL and CSVStreamingReader.pieceSize.
Moving from direct calls to CodecSet may add loss warnings for ODF-specific features (an exact match of count and content is not promised).
CSVStreamingReader.init(data:options:filename:) goes through the shared input detection and therefore needs try.
Out of scope (separate decisions): new SheetError cases, forcing a format on URL input, removing canDecode itself.
scripts/check-codec-api.py checks from a separate SwiftPM package that depends on the checkout.
The positive controls are CodecSet([.xlsx, .csv]) and the empty array in the 4 configurations SheetXLSX only, SheetCSV only, several products, and SwiftSheets,
and the format of the value codec(for:) returns. The negative controls are the per-format codec types, Codec's initializer,
the property of a format that is not imported, read on the returned Codec, and the 2 declarations with no replacement. It runs on the CI's macOS and Linux.
Approved 2026-09-09. The 4 writers of streaming write (B.42) opened the destination itself — CSV with createFile at initialization,
XLSX, XLSM and Numbers with ZipFileWriter at initialization, ODS inside close. If something threw midway,
the file that had been there before was left truncated. Every format is aligned on writing to a temporary file in the same parent directory as the destination,
and moving it into place with a single rename-equivalent only when everything up to the finishing steps succeeded. A failed path does not touch the destination.
The contract that the whole-model write(to:as:) already had (B.47) is given to streaming write as well.
The public API has 3 points. StreamingWriter.close() returns a StreamingWriteResult (changed from Void;
no @discardableResult). StreamingWriter.cancel() is added — it releases the resources without saving.
CodecSet.withStreamingWriter(to:as:sheetName:epoch:csv:_:) is added, and the library performs
close and the move only when the closure returns normally. The existing StreamingWriter(url:) and
CodecSet.streamingWriter(url:format:sheetName:epoch:csv:) keep their arguments and use the same save contract.
Making only the helper API safe is not adopted (the manual API would keep the danger of overwriting directly). Dropping the manual API is not adopted either
(there are uses where the code supplying rows is spread over several functions). No synonymous alias API is added on the SwiftSheets side —
CodecSet.all.withStreamingWriter(…) is enough.
StreamingWriteResult is a public struct holding only format (the format actually chosen) and warnings (the losses settled at finish),
with no public initializer. It does not return Data (that would bring the finished file back into memory in full).
It does not carry a row count, a byte count or a suggestion either — it must not become an incomplete substitute result under the same names as WriteResult.
Format determination stays the current rule of explicit value → destination extension → xlsx; the refusal for an unregistered format, the CSV options and the date epoch also stay as they are.
Saving has 4 stages. (1) Secure, in the same parent as the destination, a temporary file with a non-colliding name using O_EXCL
(permissions are left to 0o666 and the umask, so it looks the same as the current createFile). (2) Write the rows —
the destination is not touched. (3) The finishing steps (the ZIP directory, flushing the buffers, closing every handle). (4) Move into place with a single rename-equivalent and
return the result. Nothing that can fail is placed after the move. A destination that is a directory or a symbolic link is refused at the start
(ioFailure). The parent directory is not created. Because the temporary file is created in the same parent as the destination,
the output disk needs extra free space for one finished output (the ODS row spill area is needed on top of that).
| Current state | Operation | Result |
|---|---|---|
| open | append / addSheet succeeds | stays open |
| open | append / addSheet throws | to failed. The first error is recorded and writing is not resumed |
| open | close succeeds through the finishing steps and the move | to finished. The settled result is kept and returned |
| open | close fails in the finishing steps or the move | to failed. No success result is returned. The destination is as it was |
| finished | close is called again | does not save again; returns the same settled result |
| failed / cancelled | close, append, addSheet | refused with invalidWorkbook. No crash |
| finished | append / addSheet | refused with invalidWorkbook |
| open / failed | cancel | releases the resources without saving and goes to cancelled. A failure in the clean-up throws |
| cancelled | cancel | retries only the clean-up that is left |
| finished | cancel | does nothing. The finished file is not deleted |
The state is internal; no public state property is added. A later call never makes a failed close look like a success.
Operations after finishing or after a failure are refused with throws, not precondition (the current XLSX addSheet had
no check at all). The warnings property stays: before finishing it is the running tally, and the settled result is read from result.warnings.
The contract when failures pile up. A failure in initialization, in the body, in append or in the per-format close throws the original error as is if the clean-up succeeded,
and the destination is as it was (for a new file, not created). A failure in the move (rename) does not fall back to deleting and then moving;
it tries to discard the finished temporary file and returns the move's error. Only when the original failure and a clean-up failure coincide
are both kept in the public StreamingCleanupError (primaryError and cleanupErrors; no public initializer)
— so that a clean-up failure never hides the original cause. When only cancel fails, the first clean-up error is thrown,
and if there are several the same wrapper carries the rest. When the writer is released without close or cancel, deinit attempts the clean-up
and neither throws nor saves. Zero leftover temporary files after a forced process termination is not promised.
withStreamingWriter runs a synchronous, non-escaping closure. The StreamingWriter it passes is
not Sendable (concurrent use from another task is not part of the contract). If the body throws, that error is kept and
the writer is cancelled, not closed. If the body catches an append error and returns, the writer is failed, so nothing is saved and the first recorded
write error is returned (when the body threw a different error, the body's error takes precedence). close and cancel from inside the managed closure
are refused with invalidWorkbook and put the writer into failed — even if the caller catches that error, nothing is saved.
Even if the writer can be carried out of the closure, once the processing has ended it is finished or cancelled, so no rows can be added afterwards
(the non-escaping annotation alone cannot prevent carrying it out, so the runtime state prevents it).
Making clear what is protected. The subject is ordinary local files, and the promise is not to damage the old file on the I/O errors this processing returns.
Durability after a power failure, network file systems, concurrent replacement by another process, and inheriting an existing file's permissions and extended attributes
are not promised. For two writers on the same destination the contract is "the last successful save remains"; no conflict detection is added.
macOS, iOS and Linux use a same-file-system rename (not FileManager.replaceItemAt).
WASI is enabled only in an environment where the same contract could be confirmed by running it; until it can be confirmed, it refuses with
unsupportedFeature before touching the destination — no fallback to a non-atomic copy is provided.
Approved 2026-09-10: ship it refusing. At implementation time a WASI build could not be made at all
(the Swift bundled with Xcode has no WebAssembly backend, and even with the wasm SDK installed it fails with
No available targets are compatible with triple "wasm32-unknown-wasip1"),
so that rename holds could not be confirmed by running it even once. Keeping the old direct write for WASI alone is not adopted, because it would leave
the "path on which the destination can be damaged" that this decision closed off open on WASI alone.
The condition for lifting the refusal is confirming the B.51 save contract by running it with the swift.org toolchain and a WASI runtime.
No API that lets the caller look at the warnings before saving and then decide is added (by the time the result comes back the save has happened). For that use, write to a different destination or
use the whole-model write(as:). A completion result for streaming read, encrypted streaming write, async closures and
transactions spanning several files are out of scope.
Checks. For each of the 5 formats, with an existing file and with a new one, confirm that before finishing not a single byte of the old file changes and that
a new destination does not exist, and after a successful finish confirm the content with a whole-model read and a streaming read.
Failures are injected individually into securing the file (destination not an ordinary file, parent missing), append, addSheet, the per-format close (where each format closes its
package and closes the handles), rename and the temporary-file deletion, and the check also asserts that the injection point was actually reached
(not relying on a chance permission or full-disk error). When the format is unregistered, confirm that no temporary file is created either. The body's error is thrown as a reference-type error that can be identified,
and the check confirms that the same error comes back when the clean-up succeeds and that both remain in a compound failure. For a body that catches a failure and carries on,
a repeated close, a repeated cancel, an append after finishing, close/cancel inside the closure, and a writer carried outside — in every case,
the process does not crash and nothing is saved. scripts/check-streaming-api.py confirms from a separate SwiftPM client
the unused-result warning and the explicit discard (the CI's macOS and Linux). Measurements are taken before and after the change, alternately on the same machine,
and reported with time and peak memory paired.
Approved 2026-09-10. SheetError.unsupportedFeature(String) housed failures of different natures — it is thrown from 30 places
across Sources, and an encrypted file, a format absent from the set, a kind of encryption we do not handle, and a limit of the format itself all land in the same case.
For the caller to tell these 4 apart, the only way was to inspect the refusal text as a string: does it contain encrypted, does it start with no codec for?
The consumer was left to parse prose the library wrote. The pre-read query
SheetFormat.probe already answers the same thing with a type, FormatProbe.unopenable(UnopenableInput), so
it was also an inconsistency where only the throwing side had fallen back to strings. Adding a case to a public enum after 1.0 breaks the consumer's exhaustive switch, so this is decided before 1.0.
3 cases are added. No new type and no new vocabulary is created; the public types that already exist are carried as the values.
unopenable(UnopenableInput) — an input that was identified but this library does not open.
For the 4 of encrypted OOXML, encrypted ODF, encrypted Numbers and the old .xls, the value returned by UnopenableInput.error
switches from unsupportedFeature to this (the 17 throw sites converge on one accessor).
The case name and value are aligned with FormatProbe.unopenable.noCodec(for: SheetFormat) — that CodecSet has no codec (the refusal of B.44 and B.50). The text is unchanged and
keeps pointing to both linking the product and registering it in the CodecSet.unsupportedEncryption(detail: String) — password protection this library does not handle. 10 places on the reading side
(Blowfish of ODF 1.1, the standard encryption of Excel 2007, unknown hash, key length or checksum) and 2 on the writing side
(CSV is plain text by definition; Numbers encryption is undocumented). It covers both reading and writing.The remaining 16 places of unsupportedFeature are limits of the format or the environment itself (CSV has 1 sheet, characters the CSV encoding cannot represent,
Numbers' 1,000,000 rows × 1,000 columns and types absent from the registry, image formats, streaming write on WASI), and its meaning is narrowed to "cannot be done in this library, this format,
this environment". A new refusal that fits no classification can go here, so closing the enum at 1.0 is not a dead end.
Names are given by the fact, not the remedy. The passwordRequired from the 2026-09-07 proposal is not adopted.
(1) This library has no "waiting for a password" state — the password is handed over before reading
(SheetDecrypt.decrypt(_:password:)), and there is no way to ask back in the middle of a read.
(2) A password resolves only 2 of the 4 (encrypted OOXML and encrypted ODF); returning the same name for encrypted Numbers and the old .xls
would have the caller's UI keep showing a password field, and the user could type it any number of times and never open the file.
(3) Therefore the 4 values of UnopenableInput, which say "what it is", are used as they are.
formatNotLinked is not adopted either — it would be a lie when the product is linked and merely has no name in the CodecSet.
The refusal text (the body of description) is not changed by a single character. What changes is only the prefix
unsupported: that unsupportedFeature attached: unopenable gives the reason as is, noCodec gives
no codec for … as is, and unsupportedEncryption uses the prefix unsupported protection: .
SheetFormat.productName is raised from package to public — so that whoever receives noCodec(for:)
can write "please add SheetODS" in their own words.
The order of the checks. The answer differed by entry point — handing an encrypted .ods to a set without the .ods
codec, read / inspect answered "not in the set"
(after telling compound files apart they go on to detect, and ODF encryption is looked at inside the ODS codec), while
streamingReader answered "encrypted ODF" (it goes through SheetFormat.probe, which looks at the package's manifest first).
read(_:format:options:) and inspect(_:format:options:) are aligned by going through
SheetFormat.probe(source:filename:limits:) when no format is specified
(both ReadOptions and InspectOptions have filename and limits).
The contract is "an encrypted input is answered as encrypted first, whatever is linked". When the format is explicit, only compound files are told apart, as before
— the codec named answers for itself about encryption inside the package. The order is
(1) tell compound files apart → (2) determine the format and detect encryption inside the package → (3) take the codec from the set → (4) the codec's read.
Checks. That the 4 distinctions can be written with switch alone (fails before the change). That handing the same encrypted .ods to
read, inspect and streamingReader gives the same case from all 3 even when .ods is not in the set.
That it is the same case when .ods is added to the set. That the body of the refusal text is unchanged.
The 8 existing check lines that use UnopenableInput.error follow automatically; only the 6 lines that named unsupportedFeature are rewritten.
Because a refusal message is touched, scripts/check-no-crypto.sh is run. Passing read through probe
reads one extra manifest in a ZIP package, so the read time is taken old and new alternately on the same machine and confirmed with time and peak memory paired.
Approved 2026-09-10. Two decisions are bundled into one migration. Both concern the model's public API and do not reach
the strings a codec reads from a file — a broken reference in a file is handled as before by a warning or malformedPart, and does not stop the process.
Decision 1: the 6 declarations that change a style are renamed to setStyle.
sheet.style("A1") returns the style and sheet.style("A1") { … } changes it — the same name pointed at both
getting and changing. Every API of the same shape (editing a value through a trailing closure) in this library begins with set:
setRowDimension(_:_:), setColumnDimension(_:_:), setColumnDimension(_ name:_:),
setWidth(_:ofColumn:), setHeight(_:ofRow:). Only style was out of line.
Renamed are 3 declarations each on Sheet and Table,
setStyle(at:_:), setStyle(_ a1:_:) and setStyle(_ range:_:) (B.62 dropped at: from cell and removeCell, but style and setStyle keep it).
The 4 getters (style(at:), style(_:)) change neither name nor behaviour. The old names are not kept for compatibility —
1.0 would freeze both. The updateStyle from the roadmap is not adopted: it would be a third verb after set… and edit….
For a single cell, direct assignment to sheet[cell: "A1"].style keeps working as before, and
the closure form remains for ranges (painting "A1:D1000" with one CellStyle, shared internally).
The reason for the rename is readability and consistency only. The argument that "overloading the same name makes the compiler's diagnostics worse"
was measured and does not hold (in the 4 cases of a misspelling inside the closure, changing to let, a closure returning a value, and forgetting the
closure, the diagnostic is exactly right in every one).
Decision 2: the answer to an unreadable A1 string is aligned on "a writing entry point stops / a reading entry point returns the default".
The code's doc comments stated one policy in 3 places, "an unreadable string is a programming error", but the implementation was split 5 ways —
5 places stopping with a message giving the reason, 2 stopping with no message (CellRef(a1)!), 14 returning a default, nil or false in silence,
4 where nothing happens in silence, and 4 that mean something else in silence (freezePanes(at:) released the freeze).
The same one-character typo, depending on the entry point, crashed the process, wrote nothing, or unfroze the panes.
There are only 2 rules to align on. (a) An entry point that changes a value stops on an unreadable string — with a
preconditionFailure whose message says which string could not be read. (b) An entry point that reads a value returns the
default as before (nil, an empty array, false, the default style). The reading side is not made to stop — for code that merely looks up a
coordinate for display, taking the whole process down on an input error is not worth it.
Exceptions, and what is treated separately. Table.range(_ a1:) is a reading entry point but returns a non-Optional RangeView,
and there is no representation of an empty CellRange, so it keeps stopping (the only exception).
The empty string and nil remain the meaning "release" — freezePanes(at: "") and freezePanes(at: "A1")
release the freeze, and setPrintArea(nil), setPrintTitleRows(nil), freezePanesA1 = nil and
autoFilterA1 = nil also release; this is documented behaviour. Until now this release was implemented by "CellRef("")
returns nil", so empty (not specified) and unreadable (a typo) were not distinguished. This decision is also
the change that separates those two.
10 entry points change behaviour. Table: removeCell(_ a1:), moveRange(_ a1:rows:columns:),
unmerge(_ a1:), setColumnDimension(_ name:_:) (setWidth(_:ofColumn name:) follows it),
groupColumns(_:_:outlineLevel:hidden:). Sheet: addStructuredTable(named:over a1:styleInfo:),
addConditionalFormatting(_:over sqref:), freezePanes(at:), the setter of freezePanesA1,
the setter of autoFilterA1. In addition, addImage(_:at a1:sizing:) and addImage(_:over a1:)
still stop as before, but the silent stop through ! is replaced with a message giving the reason.
The 4 that receive a defined-name expression stay lenient — setPrintArea(_:),
setPrintTitles(_:), setPrintTitleRows(_:) and setPrintTitleColumns(_:).
These receive the _xlnm.Print_Area / _xlnm.Print_Titles expression in the form the file saved it,
and the XLSX reader itself (WorkbookReader.assignLocalNames) goes through this entry point. A document whose print area is
MySheet!#REF! — the form Excel leaves when the referenced sheet is deleted — must open.
The unreadable part is dropped (openpyxl's test_print_settings asks for the same behaviour).
The first implementation made these stop too; an existing cross-check failed and showed it. This line is the border between "coordinates a programmer writes" and
"strings that came from a file".
The nil of moveRange and the false of unmerge continue to be returned for meanings other than an unreadable string (the destination goes negative / it was not merged).
The return value of addStructuredTable(named:over a1:) changes from String? to String —
nil had no meaning other than "could not be read".
Aligning on throws cannot be adopted. The value assignments sheet["A1"] = 5 and sheet[cell: "A1"] = cell are
subscript setters, and a Swift subscript cannot throw. Giving a different answer just there would defeat the purpose of "align on one".
A caller who wants to validate a received string before using it has a separate route — CellRef(_:) and CellRange(_:) are
failable initializers that return nil when the string cannot be read. A coordinate typed by a user is caught there once.
Checks. The rename is found in full by the compiler on the calling side (54 lines in the sources and tests), so the check looks at the 4 getters still being present and
at the examples in the README and the spec using the new names. For the A1 rule, for each of the 13 entry points,
"stops on an unreadable string" is confirmed in a separate process (Swift Testing's exitTest), and the release by the empty string and nil
is confirmed by ordinary checks. That the reading entry points keep returning the default is also looked at on representative entry points.
If exitTest cannot be used in this environment, the stopping checks are dropped and the reason is appended here —
"cannot check, so nothing is written" is not done in silence.
2026-09-10. The review before 1.0 (roadmap item 6). The three — ReadOptions.dataOnly: Bool,
StreamingReadOptions.dataOnly: Bool and Workbook.dataOnly: Bool — are replaced by one enum,
FormulaCellReading, held as formulaCells. No compatibility aliases are kept (for the same reason as B.53 —
1.0 would freeze both).
Why the Bool goes. dataOnly is a name copied straight from openpyxl's data_only, and
the name does not say "data of what" or "what disappears with true". ① openpyxl writes it like this: load_workbook(path, data_only=True).
② Copied into Swift it becomes ReadOptions(dataOnly: true), and the caller can only check "values only? is formatting dropped too?" in the doc
comments (in fact, the Numbers reader needed a disclaimer that "dataOnly is not about formatting"). ③ So the choice itself gets a
name: ReadOptions(formulaCells: .cachedValues).
The two answers. .formulas (the default) turns the formula into a syntax tree and hands it over with the value the
producing application last computed alongside (CellValue.formula(_:cached:)). .cachedValues hands over only that computed value, as an ordinary value
(.number, .text …), and erases the distinction between a typed-in cell and a computed one.
A formula cell with no computed value. Read with .cachedValues, a formula cell for which the file holds no computed result is
empty (nil). The formula string is not returned as the value, and this library does not compute either. This writes down as a rule the behaviour
the 3 readers already had (XLSX turns a <c> without <v> into nil as it is, ODS turns a
table:formula without office:value into no value, Numbers does not look up the formula table), and the behaviour
does not change.
The same enum in 3 places. The normal read (ReadOptions), the streaming read (StreamingReadOptions) and
Workbook.formulaCells after reading (the record of which way it was read) hold the same type. The
Bool the per-format parsers hold internally (SheetParser, ContentParser, NumbersCells) is not public surface, so it
stays.
Checks. For each of XLSX and ODS, in both the normal read and the streaming read, we look at ".formulas hands over both the formula and the computed
value", ".cachedValues hands over only the value" and "a formula that was never computed becomes empty"
(FormulaCellReadingTests). Numbers cannot produce "a formula cell with no computed result" from the writer, so this one point is
not checked for Numbers — the ground is that the reader's code does not look up the formula table. That the default is .formulas is
looked at directly in all 3 places.
2026-09-10. The review before 1.0 (roadmap item 5). DataValidation.list(_ source:over:) stays, and
list(choices:over:allowsBlank:rejects:), which receives the choices themselves, is added. The existing declaration does not change —
passing a range reference ('Choices'!$A$2:$A$4) is a separate use, and Stream uses that one.
Why it is added. ① Excel saves inline choices as <formula1>"a,b,c"</formula1> —
wrapped in quotes, separated by commas. ② A user copying this ends up writing .list("\"a,b,c\"", over:), and
the knowledge of quotes and commas leaks to the caller. The same knowledge was also written 3 more times: in the ODS writer (turning it into ("a";"b";"c")), the Numbers writer
(turning it into a pop-up menu) and the Numbers reader (building it from the menu). ③ So the model gets one entry point that receives an array of
choices, paired with listChoices: [String]? on the reading side, and the 3 places in the codecs look that up.
Why it is failable. Some choices do not fit in an inline list — a choice containing a comma (the separator itself, with no escaping
rule), a choice containing a double quote (it would close the list; whether "" escapes it has not been checked in Excel), zero choices, and
a joined string longer than inlineListLimit (255 characters, the length including the separators and excluding the quotes). This is not a typo but
a constraint of the format, so instead of B.53's "a writing entry point stops" it is failable (nil), the same as CellRef(_:). For such choices
the route remains of putting them on a sheet and passing the range to list(_:over:). 255 is the limit Excel states in its documentation, not a value this library
measured (unverified) — whether the quotes count is equally unverified, and the check uses the length without them.
The reading side. listChoices returns an array only for a .list that is an inline list, and keeps empty elements
empty ("a,,b" is 3 elements). A list by range reference and other kinds of rule give nil. "" is not unescaped
(it is unverified, as above, so it is passed through as it is).
Separate from validating values on the Swift side. This is about "a rule written into the file", not a feature that stops sheet["A2"] = "x" because
the value is not among the choices. That will be considered separately once someone asks for it.
Checks. The round trip array → formula1 → array; the 4 conditions that give nil, and a string right at the limit going through; a range reference giving
listChoices nil; and the same choices coming back after writing to XLSX and ODS and reading back (DataValidationChoicesTests).
For writing to Numbers, the existing pop-up menu check (NumbersPopUpMenuTests) goes through the same path.
2026-09-10. The review before 1.0 (roadmap item 7). The 3 were judged separately, and each was decided for its own reason. No compatibility aliases are kept (B.53).
1. ExcelTable → StructuredTable. This type is "a frame on the grid that has a name, can be referenced from a formula as
Sales[Amount], and whose header row carries a filter" — Excel's "table", openpyxl's Table,
ODF's table:database-range. ① Excel writes it to xl/tables/tableN.xml. ② This library also takes it to ODS as
table:database-range, and writes it and reads it back (ODSWriter.databaseRangesXML,
ODSReader). Only Numbers drops it and reports it. ③ Putting Excel's name on something that is not specific to Excel makes a user working with ODS
misread it as "a feature for Excel". So it gets a neutral name. Table cannot be used, as the grid (the Numbers table) has taken it.
The candidates were StructuredTable (Excel's term "structured reference"; the proposal in the direction memo of 2026-09-07), NamedTable
(easily confused with the named range DefinedName) and ListObject (Excel's VBA name, again specific to Excel).
StructuredTable is adopted. Sheet.structuredTables, addStructuredTable(named:over:),
structuredTable(containing:) and StructuredTableColumn are aligned on the same word.
2. Cell.comment → Cell.note; the type CellNote stays.
The type was CellNote, the property comment, the list Sheet.notes — two words for the same
thing. Which to align on? ① Since 2019, Excel calls this "balloon attached to a cell" a Note, and has moved the name
Comment to a separate, threaded feature (the threadedComments part). The element name in the file,
<comment>, and openpyxl's Comment keep the old name. ② What this library models is only the balloon,
and threads are carried over as opaque parts. If threads are modelled in the future, CellComment is the natural name for that type.
③ So we align on "note": CellNote, cell.note, sheet.notes. The CellComment proposal listed in the direction memo
is not adopted, as it would compete for the name with the future thread type.
3. Top10Filter → RankFilter, FilterColumn.top10 → rank.
① Excel's dialog is named "Top 10", and the element name is <top10> too. ② But what it holds is "the top/bottom n items or
n%", and 10 is merely the default — in Top10Filter(count: 5, top: false) the name and the content disagree.
③ Whether by count or by percentage, it is a filter that "keeps by rank", hence RankFilter. Conditional formatting's .top10 (a kind of rule, a copy of OOXML's
cfRule type="top10") is outside this decision — that is an enum that keeps the spec's word as it is, and it already has a rank attribute.
Checks. The rename is found in full by the compiler on the calling side (about 70 lines in the sources and tests). Only the names change and the behaviour does
not, so we look at the existing checks (StructuredTableTests, CommentTests, CellParityTests, the ODS round trip)
passing unchanged.
2026-09-10. The review before 1.0 (roadmap item 8). The 34 constants of NumberFormat were openpyxl's
FORMAT_* turned into camelCase as they were, and included dateXLSX14 (Excel's built-in number 14),
dateTime1 to dateTime8 (the numbers mean nothing), number00, numberCommaSeparated2
(an openpyxl quirk with a trailing _-) and dateTime7 = "i:s.S" (not a valid Excel format).
① openpyxl writes FORMAT_DATE_XLSX14. ② Copying that gives NumberFormat.dateXLSX14, and
a user cannot tell "this is mm-dd-yy" without looking up Excel's internal number table. ③ So the constants are narrowed to a few that name
a meaning, and everything else is left to the route of writing the format code string directly into numberFormat (which was there from the start).
The names kept (18) and their values. general, text (@), number (0),
numberTwoDecimals (0.00), numberThousands (#,##0, new), numberThousandsTwoDecimals
(#,##0.00), percent (0%), percentTwoDecimals (0.00%), scientific (0.00E+00, new),
isoDate (yyyy-mm-dd), isoDateTime (yyyy-mm-dd h:mm:ss), time24 (h:mm),
time24Seconds (h:mm:ss), time12 (h:mm AM/PM), time12Seconds (h:mm:ss AM/PM),
minutesSeconds (mm:ss), elapsed ([hh]:mm:ss). Not one value string has changed —
that is also why elapsed stays [hh]:mm:ss rather than built-in 46's [h]:mm:ss
(whether to change the representation is a separate decision). dateTime4 and dateTime6, which were the same string, become one
(not "merely looking the same when displayed" but the identical string).
The names removed, and what replaces them. dateXLSX14 to 17 and 22 are
looked up with builtinCode(14) …. These are built-in formats whose display Excel changes by the locale of the reading side
(in a Japanese Excel, 14 becomes yyyy/m/d), and giving them names would mislead people into thinking "it will look like this".
dateYYMMDD, dateDDMMYY, dateDMYSlash, dateDMYMinus, dateDMMinus,
dateMYMinus, dateYYMMDDSlash (trailing @), dateTime7, dateTime8 (trailing @),
numberCommaSeparated2, currencyUSD, currencyUSDSimple and currencyEURSimple
either depend on a region or a currency or are doubtful as Excel formats; where needed, write the format code directly.
The compatibility alias dateYYYYMMDD is removed too (B.53).
What does not change. builtin, builtinCode(_:), builtinID(_:),
isBuiltin(_:), firstCustomID, localeDependentIDs, kind(of:) and
the isDateFormat family keep both their names and their behaviour. Cell's rule that gives a default format from the kind of value
(date → isoDate, date-time → isoDateTime, time → time24Seconds, duration → elapsed)
also keeps the same strings.
Checks. That each of the 18 is the code its name claims, that the 14 that are also built-in resolve to the spec's numbers,
and that the default formats are named constants (NumberFormatVocabularyTests). The classification check that came from openpyxl
(kind(of:)) keeps looking at the same 22 cases, listing strings instead of constants.
2026-09-10. The review before 1.0 (roadmap item 9). "Removing equivalent aliases" and "changing a representation" are kept apart, and this time only the former is done.
1. CellValue.dataType and Cell.dataType are removed. ① openpyxl returns in
cell.data_type one letter out of "n", "s", "b", "d", "f", "e".
② Mapping this into Swift has the user memorise a table of 6 letters and compare with == "d". ③ Swift has
switch value { case .date, .time, .duration: … }, which separates by type. The checks ported from openpyxl
(25 lines marked // openpyxl:) keep this one letter through a helper on the test side (openpyxlDataType,
Tests/…/Parity/OpenpyxlLetters.swift) — the cross-check ledger and check.py pass unchanged.
The 2 places in the sources that wrote dataType == "d" (the default date format, and CSV's format detection) were replaced with the package-access
isDated.
2. pythonString is folded into stringValue. The two were the same function
(stringValue { pythonString }). What stays is the neutral name stringValue, and not one character of the output changes:
True / False, a whole-number Decimal as 1.0, a duration as 1 day, 1:00:00,
a formula in the XLSX dialect with =. That this is the spelling of Python's str() stays in the doc comments — the CSV and ODS writers
write this string into files, and the cross-checks against openpyxl expect that spelling. Whether to make the representation more Swift-like
(true, 1) is a separate decision, and doing so would also move the CSV / ODS output. Stream
uses pythonString in 21 places, and the migration is a mechanical rename (sed).
3. The global function Formula(_:dialect:) becomes CellValue.formula(_:dialect:).
① A function that starts with a capital letter looks like a type (Formula("=A1") wears the face of an initializer). ② CellValue already
has the enum case formula(_ expr: FormulaExpr, cached:), and we confirmed by compiling that the string version can sit beside it as a static function of the same name
(it is chosen by the argument types, and the presence or absence of cached: leaves no ambiguity). ③ Hence
sheet["C1"] = .formula("=SUM(A1:B2)"). With a dialect, .formula("=…", dialect: .ods).
The global namespace loses one of the library's identifiers.
What stays (withdrawn in Rev 4.59 — removed in B.68). The A1-string twin properties Sheet.freezePanesA1 and autoFilterA1
(paired with the typed freezePanes: CellRef? and autoFilter: CellRange?) and Table.dimensions
(paired with extent: CellRange?) stay. B.53 has only just re-made the contract that "an unreadable string stops", and an entry point that receives a
string is worth having as a beginner's first step (freezePanesA1 = "B2"). For the same reason
CellValue(inferring:) (the inference that reads "=A1" as a formula and "#N/A" as an error) also stays.
Checks. The removals and renames are all found by compiling. That the output of stringValue has not changed is looked at by
the existing CSV and ODS round-trip checks and the openpyxl cross-check (the 2 rich-text cases in CellParityTests). For .formula("=…"),
we look at the examples in the README, the spec and the feature matrix, and at the existing formula checks (27 lines) passing with the new spelling.
Color as every other colour (Rev 4.48)2026-09-10. The review before 1.0 (roadmap item 10). Sheet.tabColor was a String? ("RRGGBB" / "AARRGGBB"), and
SheetProperties.tabColor: Color? held the same value too. ① openpyxl takes it as a string: ws.sheet_properties.tabColor = "1072BA".
② When the tab colour read from a file was .theme(4) or
.indexed(12) (Excel writes both), the String? entry point copied from it returned nil — the colour is there, yet it answers "none". The writing side, too,
could write only RGB. ③ So the type becomes Color?, the front door to properties.tabColor. It is the same type as the colours of fonts and fills, so
Color(hex: "1072BA"), .theme(4, tint: 0.4) and .indexed(12) go straight in.
Migration. sheet.tabColor = "1072BA" → sheet.tabColor = Color(hex: "1072BA"). Code that took the hex out on the
reading side becomes if case .rgb(let hex) = sheet.tabColor. The ODS and Numbers writers only look at "is there a tab colour"
(neither format has tab colours, so it is dropped and reported), and their behaviour does not change. XLSX reading and writing already went through
properties.tabColor, and that does not change either.
Checks. The existing ModelTests and FormatSupportTests (the tab colour round trip) and
WorksheetPartsParityTests (reading and writing <tabColor rgb>) pass with the new type. One case is added showing that a tab with a theme colour is
visible through Sheet.tabColor too.
col is column, sqref is the selected ranges (Rev 4.49)2026-09-10. The review before 1.0 (roadmap item 11, the last). Only names change; the coordinate system (0-based), the meaning of a range's ends (both inclusive), the defaults and
the attribute names in the file (sqref stays sqref in the file) do not. No compatibility aliases are kept (B.53).
Why. ① openpyxl uses cell.col_idx, min_col and ws.cell(row, column),
mixing col and column. ② This library inherited that, so next to CellRef.col there were
columnName, columnDimension(_:) and FilterColumn.column, spelling the same thing two ways.
Typing col for completion lists both. ③ The Swift API Design Guidelines put "clarity before brevity".
When row is not abbreviated, there is no reason to abbreviate col alone.
Migration table.
| Up to Rev 4.48 | From Rev 4.49 |
|---|---|
CellRef(row:col:), CellRef.col, CellRef.maxCol | CellRef(row:column:), .column, .maxColumn |
CellRef.offset(rows:cols:) | offset(rows:columns:) |
CellRange(minRow:minCol:maxRow:maxCol:sheet:), .minCol, .maxCol | CellRange(minRow:minColumn:maxRow:maxColumn:sheet:), .minColumn, .maxColumn |
CellRange.size.cols, .cols (iteration column by column) | size.columns, .columns |
CellRange.shifted(rows:cols:), shift(rows:cols:) | shifted(rows:columns:), shift(rows:columns:) |
RangeBounds(minCol:minRow:maxCol:maxRow:) and the properties of the same names | RangeBounds(minColumn:minRow:maxColumn:maxRow:) |
Sheet / Table: moveRange(_:rows:cols:) | moveRange(_:rows:columns:) |
RangeView[row:col:] | RangeView[row:column:] |
SheetView.sqref (String) | SheetView.selectedRanges (still String; A1 ranges separated by spaces) |
What does not change. Unlabelled arguments (columnName(_:), columnDimension(_:),
autofitColumn(_:)) only change their internal name, and the call stays the same. The subscript sheet[row, col] is also unlabelled.
SheetView.activeCell and selectedRanges stay String — whether to make them typed (CellRef,
MultiCellRange) is a separate decision, under the principle "keep renames and type changes apart". The other sqrefs
(addConditionalFormatting(_:over:), ProtectedRange.init(name:_:), MultiCellRange.init(_:)) are
unlabelled or use over:, so sqref does not appear in the call.
The consumers (measured). Compiling Stream against the new library stops at CellRef(row:col:) in 5 places (the rest are A1 strings).
LeftRight has .ref.col and CellRef(row:col:) in 4 places in the app itself, and .col in 2 places in a measurement script (scripts/perf/table-probe). Both migrate with a one-line bulk replace.
Checks. The rename is found in full by the compiler on the calling side (about 500 lines in the sources and tests). The behaviour does not change, so
we look at the coordinate checks (CoordinateStringTests, CellParityTests, WorksheetParityTests) and the round trips of every format
passing unchanged.
Decided 2026-09-10 ("to 1-based"). The comparison of how the APIs out there count is summarised in "The rule is one sentence" in this section.
CellRef(row: 1, column: 1) becomes A1. The earlier "integers are 0-based, only A1 strings are 1-based" (README, §3, Chapter 14) is revised.
No compatibility aliases are provided (B.53).
The rule is one sentence. A cell's coordinates are the on-screen numbers; a position in a Swift collection is a Swift index. ① Excel VBA, Numbers' AppleScript,
Google Apps Script, openpyxl, ClosedXML, EPPlus, ExcelJS — the APIs that deal with the table on screen are all 1-based, while POI, SheetJS, xlsxwriter, LibreOffice UNO,
the Google Sheets API, numbers-parser — the APIs that treat a table as a matrix — are 0-based. The split follows what the API faces, not the language. ② This library's surface is made of A1 and row numbers,
and to pass 0-based integers Stream was converting with ws[r - 1, c - 1] in 15 lines of its import. It also had to keep rowNumber
separately for text meant for people. ③ So coordinates become the on-screen numbers, and the conversion is gathered inside the codecs (Numbers, whose file is 0-based; shape anchors; VML) and at
the one point where an array is indexed (values[cell.column - 1]). That openpyxl has had no trouble with the same rule (sheets from 0, cells from 1) for more than 10 years was also part of the grounds.
What becomes the on-screen numbers (1-based). CellRef.row / .column; CellRef.maxRow (1,048,576) /
.maxColumn (16,384); the 4 ends of CellRange and RangeBounds; the subscripts of sheet[row, column] and
Table; the keys of rowDimensions / columnDimensions; rowDimension(_:) / columnDimension(_:) /
setWidth(_:ofColumn:) / setHeight(_:ofRow:); insertRows(at:) and its 3 siblings (on both Sheet and Workbook;
at: 2 means the same as Excel's "insert at row 2"); the ranges of groupRows / groupColumns; printTitleRows /
printTitleColumns; autofitColumn(_:); the keys of append([Int: CellValue?]); Table.anchor; nextAppendRow;
StructuredTable.dataRows; StreamedRow.index; CellRef.columnName(_:) (1 → "A") / columnIndex(_:) ("A" → 1) /
columnNames(from:to:); the arguments of Table.column(_:) / row(_:); the numbers in FormulaExpr.column / .row.
rowNumber goes, as it has become row itself.
What stays a Swift index (0-based). The position of a sheet (sheets[i], removeSheet(at:), editSheet(at:),
addSheet(named:at:), moveSheet); pivot field numbers (rowFields, columnFields, PivotItem.index);
the table: argument of the streaming read; the index of RangeView.Row as a collection; APIs that return arrays (rows(in:), columns(in:),
values(in:), the return values of Table.column(_:) / row(_:), StreamedRow.values(width:) — index 0 is column A).
The 2 that were renamed. FilterColumn.column had a coordinate's name but a position's meaning (the file's colId, the 0-based distance from the start of the
filter range), so it becomes columnOffset, with the value unchanged. The relative subscript of RangeView, view[0, 0], becomes
view[rowOffset: 0, columnOffset: 0] — a distance from the top-left of the range, and it too stays 0-based.
What keeps the file's spelling. rowBreaks / columnBreaks hold the value of <brk id> (the number of the row above the page break).
We have no sample of page breaks written by Excel, and "is id 5 below row 5 or below row 6" is unverified, so no conversion is applied.
The codec boundaries. In XLSX, <row r>, <col min max> and A1 are 1-based in the file, so the conversion disappears, while
the shape anchors <xdr:col> / <xdr:row> and VML's <x:Row> / <x:Column> (0-based) gain a ±1 on both
read and write. The frozen panes' xSplit / ySplit are the number of rows and columns frozen (= the number of the frozen cell − 1). Numbers' IWA is
0-based for both rows and columns, and ±1 is applied to the cell records, the row and column dimensions, merged ranges and a formula's absolute references — relative references are offsets, so they do not move. ODS has no
integer indices in the file; the reader's row and column cursors simply start counting from 1 (a column declaration that comes after the cursor has passed the last column, 16,384, is discarded even if it carries a width or hidden state —
so that it does not build a reversed range and crash). CSV adds +1 to the position from enumerated().
The consumers. Stream: 17 lines (15 in the import, 2 in the export) — remove the - 1 from ws[r - 1, c - 1], and
change the 2 lines with CellRef(row: 0, …) to 1. LeftRight: 5 lines — 2 places that change cells[cell.ref.column], which unpacks a streamed row into an array, to
[cell.ref.column - 1]; 2 places in the measurement script that copies them; and 1 check that writes to row 0.
This migration is something the compiler does not tell you: a name change (col → column) stops the build, but
ws[r - 1, c - 1] still compiles and reads the row above, and cells[cell.ref.column] crashes at run time on the last column.
So, in the same commit as the name migration, the conversions are read one line at a time and removed.
Checks. All checks (1,128) are rewritten to the new counting and pass. The checks ported from openpyxl can now pass openpyxl's 1-based arguments unchanged, so there is less conversion. Only the numbers-parser cross-check data (0-based) gets +1 on the test side. Writing entry points stop at row 0 or column 0 with a message giving the reason (the rule of B.53). The conversion boundaries are checked by round trips in every format showing that a value placed at A1 reads back as A1 (the existing round-trip checks carry this as they are).
cell(ref) and cell("B2"), style(at: ref) and style("B2") (Rev 4.52, 4.58)Decided 2026-09-11. The consistency review before 1.0 (group 1). In 4 pairs on Sheet and Table — cell, removeCell, style and
setStyle — only the typed side had at:, and the A1-string side was unlabelled. Arguments with the same role followed a different rule in each pair.
The sheet.cell("B2") spelling stays, so for cell and removeCell the at: on the typed side is removed and both sides become unlabelled.
style and setStyle have at: on the typed side kept (decided in Rev 4.58) — cell(ref) has a method name that says "cell", but style(ref)
cannot be read as "the cell's style" from the name alone, and at: supplies the "where". No compatibility aliases are kept (B.53).
Why. ① merge(_:), isMerged(_:), range(_:), moveRange(_:) and contains(_:) are
unlabelled in both the typed and the string form, and most of the library's twins have this shape. ② addImage(_:at:) and subscript(cell:) have the same label on both sides — this too is "both sides of a pair are the same".
③ The only pairs where at: was attached to one side alone were the 4 of cell, removeCell, style and setStyle, and these 4 pairs were the exception to the rule.
The Swift API Design Guidelines do not require a label on overloads of the same operation whose arguments can be told apart by type.
Migration table.
| Up to Rev 4.51 | From Rev 4.52 |
|---|---|
sheet.cell(at: ref) | sheet.cell(ref) |
sheet.removeCell(at: ref) | sheet.removeCell(ref) |
sheet.style(at: ref), setStyle(at: ref) { … } | Unchanged (as in B.53; the string partners style("B2") and setStyle("A1:D1") also stay unlabelled) |
Impact. No ambiguity arises — a CellRef cannot be made from a string literal, so cell("B2") resolves uniquely to
the string side and cell(ref) to the typed side. setStyle comes in 3 forms — the CellRef one with at:, and the unlabelled String and CellRange ones — told apart by type and label. What B.53 decided, "an unreadable A1 string stops at an entry point that changes a value", is behaviour, so it does not change.
Only the calls that wrote cell(at:) or removeCell(at:) change, and the compiler points out every one of them. Stream has cell(at:) in 6 places; LeftRight has 0.
What does not change. addImage(_:at:) (the first argument is the image, so the position needs a label), subscript(cell:),
conditionalFormattingRules(at:) (a standalone declaration with no string partner), and RangeView.Row.cell(_:) (unlabelled from the start).
Decided 2026-09-11. The consistency review before 1.0 (group 2). Only names and labels change; behaviour, defaults and return values do not. No compatibility aliases are kept (B.53).
Why. ① Shifting a CellRange was shifted(rows:columns:) and shifting a CellRef was offset(rows:columns:) —
the same operation had a different verb for each type. ② The inverse of expanded(right:down:left:up:) was shrunk(right:bottom:left:top:), calling the same 4 sides by different words.
③ Against the rule that the destination URL is to: and the format is as: (B.49's write(to:as:) and convert(_:to:as:), B.51's
withStreamingWriter(to:as:)), only streamingWriter(url:format:) and the umbrella's StreamingWriter(url:format:) were out of line.
④ The read that takes bytes is read(_ data), unlabelled, yet the streaming read alone was streamingReader(data:).
⑤ There were 6 ways to ask "what is this file": 4 detects (from:, from:filename:, in:, contentsOf:) and
2 probes (_:filename:, contentsOf:), with different labels on the same input.
Migration table.
| Up to Rev 4.52 | From Rev 4.53 |
|---|---|
ref.offset(rows: 1, columns: 2) | ref.shifted(rows: 1, columns: 2) |
range.shrunk(right: 1, bottom: 1, left: 0, top: 0) | range.shrunk(right: 1, down: 1, left: 0, up: 0) |
codecs.streamingWriter(url: u, format: .xlsx, sheetName:) | codecs.streamingWriter(to: u, as: .xlsx, sheetName:) |
StreamingWriter(url: u, format: .xlsx) | StreamingWriter(to: u, as: .xlsx) |
codecs.streamingReader(data: d, format:) | codecs.streamingReader(d, format:) |
SheetFormat.detect(from: d), detect(from: d, filename: n) | SheetFormat.detect(d), detect(d, filename: n) |
SheetFormat.detect(in: zip) (public) | package — used only by the codecs' canDecode |
What does not change. The initializers StreamingReader(data:format:) and Workbook(data:format:) keep their noun labels (an initializer
names the role of its arguments). detect(contentsOf:), probe(contentsOf:) and probe(_:filename:) stay as they are. So do CellRange.shifted /
shift and expanded. On the reading side, where format: means an "override" of the format (read(_:format:), streamingReader(_:format:)), it
stays format: — as: is the word for the format being written, not a word for overriding what a read infers.
The consumers. LeftRight: StreamingWriter(url:) in 2 places, streamingReader(data:) in 1, detect(from:) in 4. Stream: 0. All of them are a bulk replace.
allowsBlank, showsGridLines (Rev 4.54)Decided 2026-09-11. The consistency review before 1.0 (group 3). Only names change; the defaults and the attribute names in the file (showGridLines="0" and refreshOnLoad="1" stay as they are) do not.
No compatibility aliases are kept (B.53).
Why. ① The Swift API Design Guidelines spell Boolean properties so that they read as "assertions about the receiver" (isEmpty, intersects).
② Of this library's Bools, 45 names read as assertions (includesBlanks, allowsSorting, isHidden, hasStyle) and 28 were imperatives;
the ones copied from OOXML attribute names (showGridLines, lockStructure) had stayed imperative. ③ SheetProtection had allowsSorting but
WorkbookProtection had lockStructure; FilterColumn had includesBlanks but StreamingReadOptions had includeStyles —
the same family of types was split down the middle.
Migration table (28 names, by type).
| Type | Up to Rev 4.53 | From Rev 4.54 |
|---|---|---|
StreamingReadOptions | includeStyles | includesStyles |
CSVWriteOptions | includeBOM | includesBOM |
ReadOptions | preserveUnknownParts | preservesUnknownParts |
InspectOptions | countCells | countsCells |
DataValidation (also the labels of list(choices:over:…)) | allowBlank, showInputMessage, showErrorMessage, hideDropDown | allowsBlank, showsInputMessage, showsErrorMessage, hidesDropDown |
SheetView | showGridLines | showsGridLines |
SheetProperties | fitToPage | fitsToPage |
PageSetup | useFirstPageNumber | usesFirstPageNumber |
DataBar, IconSet | showValue | showsValue |
TableStyleInfo, PivotStyleInfo | showFirstColumn, showLastColumn, showRowStripes, showColumnStripes, showRowHeaders, showColumnHeaders | showsFirstColumn, showsLastColumn, showsRowStripes, showsColumnStripes, showsRowHeaders, showsColumnHeaders |
PivotTable, PivotField, PivotCache | showRowGrandTotals, showColumnGrandTotals, showAll, refreshOnLoad | showsRowGrandTotals, showsColumnGrandTotals, showsAll, refreshesOnLoad |
WorkbookProtection | lockStructure, lockWindows, lockRevision | locksStructure, locksWindows, locksRevision |
CalculationSettings | useWildcards, useRegularExpressions | usesWildcards, usesRegularExpressions |
Consolidation | linkToSourceData | linksToSourceData |
What does not change. Adjective and noun Bools (bold, hidden, collapsed, enabled, lossy, pivot, percent,
tabSelected, customHeight, horizontalCentered, headings, gridLines, caseSensitive, descending) already read as assertions, so they stay.
The 45 names such as isDefault, hasStyle, includesBlanks and allows… stay as well. ConditionalFormattingRule.bottom and aboveAverage are
OOXML's own words (the third item of B.56: "keep the industry's common words").
How. The 28 names were replaced with a replacer that skips the inside of string literals (the file's attribute names) and fixes only the inside of interpolations \( ) — so as not to repeat the accident in which
B.60's bulk replace rewrote even the <xdr:col> tags (a2bd5a2). After the replacement we confirmed in the diff that no string such as "shows… had been created. The replacer is kept in the workshop repository's scripts as rename-swift-identifiers.py, and is used by passing it a table of name pairs (JSON).
The consumers. Stream: allowBlank in 1 place, preserveUnknownParts in 2, hideDropDown in 1, showErrorMessage in 1. LeftRight: 0.
rows(in:), freezePanesA1, printTitlesFormula (Rev 4.55)Decided 2026-09-11. The consistency review before 1.0 (group 4). B.58's rule, "one name for one thing", is applied to 4 places B.58 had not reached. No compatibility aliases are kept (B.53).
Why. ① The doc comment of values(in:) said "same as rows(in:)", and the return value and the implementation were the same too. ② Sheets.names was
the same array as Workbook.sheetNames and was not used from outside (we keep Workbook.sheetNames, which has the same name as StreamingReader.sheetNames).
③ freezePanes was the name of both a stored property (CellRef?) and a method, freezePanes(at: "B2"), and on the A1-string side there was also
the setter of freezePanesA1 — two entry points for the same assignment. ④ The print settings had printTitles (get) + setPrintTitles(_:),
printAreaFormula (get) + setPrintArea(_:), and also setPrintTitleRows(_:) and setPrintTitleColumns(_:) —
6 string entry points against the typed ones (printArea: [CellRange], printTitleRows, printTitleColumns).
The shape of B.53's twins is "a typed property + a settable string property …A1 / …Formula", and these 2 pairs were the only ones whose getter and setter had different names.
Migration table.
| Up to Rev 4.54 | From Rev 4.55 |
|---|---|
sheet.values(in: "A1:C3"), table.values(in:) | sheet.rows(in: "A1:C3") (same return value) |
wb.sheets.names | wb.sheetNames |
sheet.freezePanes(at: "B2") | sheet.freezePanesA1 = "B2" (or sheet.freezePanes = CellRef(row: 2, column: 2)) |
sheet.freezePanes(at: ""), freezePanes(at: "A1") (release) | sheet.freezePanesA1 = nil (assigning "A1" also releases — A1 is the corner and freezes nothing) |
sheet.printTitles (get), setPrintTitles(f) | sheet.printTitlesFormula (get / set, String?) |
sheet.setPrintTitleRows("1:4"), setPrintTitleColumns("A:F") | sheet.printTitleRows = 1...4, printTitleColumns = 1...6 (typed), or printTitlesFormula = "1:4" (string) |
sheet.printAreaFormula (get; "" when not set), setPrintArea(f) | sheet.printAreaFormula (get / set, String?, nil when not set) |
Behaviour does not change. The setters of the 2 …Formula properties do not drop an expression that came from a file (MySheet!#REF!) and discard only the unreadable part — they stay
lenient (the line drawn in B.53). The setter of freezePanesA1 stops on an unreadable string (B.53). The only change of representation is that an unset
printAreaFormula becomes nil instead of "", which puts it under the same rule as printTitlesFormula (formerly printTitles).
What does not change. freezePanes: CellRef?, autoFilter / autoFilterA1 and dimensions (B.53's twins);
rows(in:) / columns(in:) / cells(in:) / range(_:); StreamingReader.sheetNames.
The consumers. Stream: its 3 places using sheetNames are Workbook.sheetNames, so they do not change. LeftRight: likewise, none of its 4 places change. values(in:), freezePanes(at:) and
setPrint… appear 0 times in both apps.
saltedHash, validate(), CellValue(serial:), closed enumerations (Rev 4.56)Decided 2026-09-11. The consistency review before 1.0 (group 5). Four kinds of fix are bundled. None of them changes a value written to a file. No compatibility aliases are kept (B.53).
1. hashValue → saltedHash. ① SheetProtection, WorkbookProtection and ProtectedRange are Hashable, and
Swift's Hashable has hashValue: Int. ② The same types also declared public var hashValue: String?, so writing protection.hashValue
returned not an Int but a String?, and the compiler said nothing. ③ The attribute name in the file (hashValue) stays; only the Swift name changes, to saltValue's partner
saltedHash. As of today no call has confused the two (all 11 places in the sources and checks meant the string).
2. validationError() -> String? → validate() throws. StructuredTable and PivotTable. In Swift, a function that returns a reason string
takes the throws form, and what it throws is SheetError.invalidWorkbook(reason). The writers, as before, drop a table that does not pass with a warning giving the reason (dropped; the wording is the same).
The Bool return value of addPivotTable(named:…) does not change.
3. The from/to of ExcelDate → members of the types. The Python quirk B.58 removed (copies of from_excel / to_excel) had survived in date conversion alone.
The namespace ExcelDate goes away, and the conversions are attached to the value types.
| Up to Rev 4.55 | From Rev 4.56 |
|---|---|
ExcelDate.fromSerial(46266, epoch: .windows1900) | CellValue(serial: 46266, epoch: .windows1900) (failable) |
ExcelDate.toSerial(cellValue, epoch:) | cellValue.serial(epoch:) (Double?) |
ExcelDate.toSerial(civilDateTime, epoch:), toSerial(civilDate, epoch:) | civilDateTime.serial(epoch:) (Double), civilDate.serial(epoch:) (Int) |
ExcelDate.durationFromSerial(1.125), toSerial(duration) | Duration(serialDays: 1.125), duration.serialDays |
ExcelDate.fromISO8601("2011-12-25T14:23:55"), toISO8601(cellValue) | CellValue(iso8601: "2011-12-25T14:23:55"), cellValue.iso8601 |
SheetProtection.hashValue (3 types) | saltedHash |
table.validationError() == nil | try table.validate() |
4. Enumerations the specification closes become enums. The 5 whose set of values the OOXML schema closes change from String to enums with a rawValue. A misspelling stops at compile time, and completion works.
The rawValue is written to the file as it is, so the output is the same. When the reading side meets a value outside the schema, it does not turn it into another value in silence; it sets the sheet's hasUnmodelledFilters /
hasUnmodelledConditionalFormats and drops it. An unknown value of totalsRowFunction becomes nil, and the read's warning (dropped, tables) reports the column name and the value. These 3 are changes of behaviour, so each has a check (SchemaClosedValuesTests — an unknown value is dropped and the flag is set; a known value does not set it).
| Property | Up to Rev 4.55 | From Rev 4.56 | "none" in the file |
|---|---|---|---|
Font.vertAlign, DifferentialFont.vertAlign | String? ("superscript") | verticalAlignment: Font.VerticalAlignment? (.baseline / .superscript / .subscript) — the abbreviation becomes a word too | nil (as before) |
Font.scheme | String? ("minor") | Font.Scheme? (.major / .minor) | nil (as before — not a theme font) |
ConditionalFormattingRule.timePeriod | String? ("lastWeek") | TimePeriod? (10 values) | — |
StructuredTableColumn.totalsRowFunction | String? ("sum", "countNums", "stdDev", "var") | TotalsRowFunction? (.sum, .countNumbers, .standardDeviation, .variance, .custom …) | nil |
DynamicFilter.kind | String ("aboveAverage", "Q1", "M3") | Kind (36 values; .quarter1, .month3 …) | — (.null is one of the values) |
What stays String (the set of values is open). IconSet.name, Chart.Series.values (a reference expression), PivotFieldItem.itemType,
FilterColumn.calendarType, PivotDataField.showDataAs, StructuredTable.tableType, Sheet.definedNames / Workbook.definedNames (formula strings),
SheetView.activeCell / selectedRanges (B.60). An enumeration frozen at 1.0 cannot gain values, so whether to add enums for these is decided during 1.x. numberFormatID (3 types) is
a number the pivot cache holds in the file, and it is not given names (the same treatment as B.57's builtinCode(_:)).
The consumers. Stream: ExcelDate in 1 place. LeftRight: 0.
Decided 2026-09-11. The consistency review before 1.0 (group 6, the last). Tools that play no part in a user's work are taken off the public surface promised at 1.0. What is taken off does not disappear but
becomes package, and stays visible to the codecs and the checks as before (package access reaches the tests too). No compatibility aliases are kept (B.53).
Why. ① Whatever is public at 1.0 remains a promise throughout 1.x (the same reason as B.46). ② CRC32, ZipInspection, TextEncodingSniffer,
OOXMLEscape, Units (dxa and EMU conversion), CellPixels and TextWidth (the character-width table for autofitting column widths) were not things that a user
who reads and writes spreadsheet documents calls; they were parts of the codecs. The doc comment of Table.cleanMergedRange cited a private function of openpyxl, and only the readers call it.
WriteResult.suggest and Workbook.noteUnmodelledODFFeatures are steps by which a codec assembles its result. LegacyPasswordHash /
ModernPasswordHash were made public in B.31 "so that callers can do the checking themselves", but checking is covered by passwordMatches(_:) / modernPasswordMatches(_:),
and generating the key by setPassword / setModernPassword. ③ In the machine-made proposal of the ledger (the workshop repository's api-ledger), too, these 9 types were almost all marked "demote".
What becomes package.
| Declaration | What users use instead |
|---|---|
ZipInspection, CRC32, UnopenableInput.probe(in:) | SheetFormat.probe(_:filename:) / probe(contentsOf:), UnopenableInput.probe(_:) |
TextEncodingSniffer | SheetFormat.detect(_:filename:), CSVReadOptions.encoding |
OOXMLEscape | — (the model holds structured-table column names as plain text, and the writer encodes them) |
Units, CellPixels | ColumnDimension.width (characters), RowDimension.height (points), SheetImage.displaySize(cellSize:) |
TextWidth | autofitColumn(_:) / autofitColumns() |
LegacyPasswordHash, ModernPasswordHash (and defaultSpinCount) | setPassword(_:), setModernPassword(_:spinCount:) (default 100,000), passwordMatches(_:), modernPasswordMatches(_:) |
Table.cleanMergedRange(_:) | merge(_:) (does the same cleanup when merging) |
WriteResult.suggest(from:target:options:) | WriteResult.suggestion (already in the result) |
Workbook.noteUnmodelledODFFeatures(_:) | Workbook.unmodelledODFFeatures (read only) |
The remaining abbreviations (continuing B.60). SheetFormatProperties.baseColWidth → baseColumnWidth, defaultColWidth → defaultColumnWidth,
PivotLocation.firstDataCol → firstDataColumn. The attribute name in the file (baseColWidth) stays as it is.
Checks. The 9 types and 3 members are added to the "must not be visible" list of the build check from an external package (scripts/check-preservation-api.py), which
looks at the build stopping with inaccessible. Use from inside the package is guaranteed by compiling.
The consumers. 0 in both Stream and LeftRight (neither calls these types).
address family of getters, and iso8601: made uniform (Rev 4.59)Decided 2026-09-11. Three points. No compatibility aliases are kept (B.53).
1. freezePanesA1 and autoFilterA1 are removed. These are the 2 settable properties of the "A1-string twins" that B.53 kept. Their names carry the name of a notation (A1), which sits awkwardly, and
the same thing can be written by assigning CellRef("B2") / CellRange("A1:D9") to the typed freezePanes: CellRef? / autoFilter: CellRange?.
The special case of releasing by assigning "A1" goes with them (freezePanes = nil releases). For an unreadable string CellRef(_:) returns nil, so the caller checks it —
for these 2, B.53's "an entry point that changes a value stops" is closed in the form "the entry point no longer exists". B.65's printTitlesFormula / printAreaFormula are
entry points that receive an _xlnm expression (a string that came from a file), so they stay.
2. The getters that produce A1 notation get names from the address family. "A1" is the name of a notation and did not say the role of the value (the cell's address). The names are aligned on the word Excel uses in Range.Address.
| Up to Rev 4.58 | From Rev 4.59 | Example value |
|---|---|---|
CellRef.a1, CellRange.a1 | address | "B2", "A1:C3" |
CellRef.absoluteA1, CellRange.absoluteA1 | absoluteAddress | "$B$2", "$A$1:$C$3" |
CellRange.qualifiedA1 | qualifiedAddress | "'Sheet 1'!A1:C3" |
Sheet.dimensions, Table.dimensions | extentAddress (paired with the typed extent: CellRange?) | "A1:D9" ("A1:A1" when empty — the same as openpyxl's ws.dimensions) |
What does not change. description (the same string as address), the unlabelled arguments on the side that takes an A1 string (cell("B2"), CellRef("B2"), merge("A1:B2")),
and CellRef.columnName(_:) / columnIndex(_:).
3. The ISO 8601 initializers take iso8601:. CivilDate(iso:), TimeOfDay(iso:) and CivilDateTime(iso:) are renamed to iso8601:,
aligning them with B.66's CellValue(iso8601:) and the .iso8601 read-out. The bare iso spelling is a Python habit (fromisoformat); Foundation spells out
the standard's number too: Date.ISO8601FormatStyle, ISO8601DateFormatter, .iso8601. isoDate: would be inaccurate for initializers that also make times and elapsed times,
and isoString: is a type name, which does not fit the way labels are named. NumberFormat.isoDate / isoDateTime are display-format constants, a different thing.
The consumers. 0 in both Stream and LeftRight (neither calls freezePanesA1, .a1 or (iso:)).
Chart.Kind, SheetImage.Format, DateEpoch (Rev 4.60)Decided 2026-09-11. In the draft of the 1.0 promise (the review plan of 2026-09-05), adding a case to a public enum is a breaking change
— a switch in which a consumer spelled out every case stops compiling once a case is added. We do not use library evolution (-enable-library-evolution)
and have no document asking for @unknown default, so that judgement holds. But for 3 types, features that are about to arrive will certainly add cases:
Chart.Kind — once charts are read from files (B.72, B.73), kinds outside the 4 the writer can draw (scatter, area, doughnut) enter the model.SheetImage.Format — once pictures are read from files, we meet formats outside PNG, JPEG and GIF (BMP, TIFF, EMF, SVG).DateEpoch — ODF's table:null-date can make any date the origin. A choice between 1900 and 1904 cannot hold it.Schools. The frozen school (cases are added only in 2.0 — the Swift standard library's frozen enums, Go 1) / the policy school (add in a minor release and ask consumers for a default — the language itself backs this, with Rust's
#[non_exhaustive]) / the struct school (a struct with static members — Foundation's Notification.Name, UTType) /
the spare-case school (put in .other(String) from the start — SwiftNIO's HTTPMethod.RAW). These 3 take the struct school.
The call sites (Chart(.column), chart.kind == .pie, wb.epoch = .mac1904) do not change by a single character, and unknown values (kinds in a file we read
that we do not know) can be held in the same type. All we lose is the exhaustiveness check of switch; we added a default in 2 places in the library itself (date conversion and chart writing).
The other enums stay in the frozen school (the Kind-like types whose values the specification fixes do not grow).
Shape. All 3 types are Hashable, Sendable, RawRepresentable. The raw values of Chart.Kind are column /
bar / line / pie, and an unknown kind keeps its original name (the OOXML element name scatterChart, the ODF class chart:area)
as it is. Kind.drawable and isDrawable answer "is this one of the 4 kinds the writer can draw", and an addChart of a kind that cannot be drawn is
dropped by the XLSX writer with dropped "a scatterChart chart was not written: the writer draws column, bar, line and pie charts" (no empty chart part is created).
The raw value of SheetImage.Format is the file extension (png / jpeg / gif); contentType names the 3 known ones,
and anything else is image/<raw>. init(data:) accepts the same 3 formats as before. DateEpoch has an origin: CivilDate,
and .windows1900 (1899-12-30, skipping the phantom 1900-02-29) and .mac1904 (1904-01-01) are its static members. The phantom day is handled only for
.windows1900, and isExcelOrigin answers "is this one of the 2 origins the Excel format can name".
Round-tripping an arbitrary origin. The ODS reader reads 1899-12-30 and 1900-01-01 (LibreOffice's setting names) as .windows1900, 1904-01-01 as
.mac1904, and any other date as DateEpoch(origin:) — the old degraded warning "read as the 1900 system, and say so" is gone (it remains only for values that are not dates).
The writer writes an origin other than .windows1900 to table:null-date. XLSX and Numbers can name only 2 origins, so any other origin is re-based on the 1900 system
with the degraded warning "the date origin 2000-01-01 is written as the 1900 system: … (dates keep their day; a raw serial formatted as a date shifts)" — the model's dates are
calendar dates, so they land on the same day; the only thing that shifts is "a raw serial number carrying a date format".
Checks. ReservedEnumTests, 4 tests: the call sites and the serial numbers of the 2 origins are unchanged; a custom origin has no phantom day; ODS round-trips an arbitrary origin
while XLSX re-bases it and says so; a kind that cannot be drawn is dropped by name. The consumers. Stream uses DateEpoch as an argument and with == in 6 places, with no switch, so 0 lines to fix.
LeftRight uses none of the 3 types.
Cell.phonetic (Rev 4.61)Decided 2026-09-11. We carry furigana (Excel's "Show Phonetic Field"), which since B.19 had been handled as "skip <rPh>, in line with openpyxl".
It was the only row in the spec's feature matrix that "is dropped, yet raises no warning". It is the first point where we deliberately part from openpyxl, and the reason is that our users handle Japanese tables —
save a table that has lost its readings again, and a column that Excel sorted by furigana comes out in a different order.
Shape. PhoneticText has runs: [Run] (the reading string, and its range start..<end within the original string, in
UTF-16 code units, the same unit as Excel's sb / eb), kind (phoneticPr@type: halfwidthKatakana / fullwidthKatakana / hiragana /
noConversion — the schema fixes these 4 values, so it stays an enum), alignment (phoneticPr@alignment: noControl / left / center / distributed, likewise 4 values) and
font: Font? (the font of phoneticPr@fontId; nil is the default font). PhoneticText("カンジ", over: "漢字") is the shortcut that puts one reading over the whole string.
On the cell it is Cell.phonetic, carried in CellExtras alongside hyperlinks, notes and controls (a reading is a description attached to the value, not part of the value —
no case is added to CellValue). It is included in equality and in the hash.
Reading. SharedStringsParser collects, for each <si>, the <rPh> and <phoneticPr>, and
fontId is resolved to a Font after the style table has been read (the shared-string table is read before the style table). The sheet reader looks up phonetics[i] in step with
the shared-string index and puts it on the cell, and reads the same elements inside inline strings (t="inlineStr") the same way. The streaming read (StreamingReader)
keeps skipping them, true to its "values and styles only" promise.
Writing. An entry of the shared-string table becomes a pair of "string + furigana" — the same characters with and without a reading, or with different readings, are separate entries (as in Excel's own table).
After the <t> (or the sequence of <r>) come the <rPh sb eb><t>reading</t></rPh> elements, and last
<phoneticPr fontId type alignment/> — the schema's order. The font is registered with StyleRegistry.fontID (the shared-string table is written before styles.xml,
but registration goes to the same registry, so the font lands in styles.xml, which is written later). The seed used when unread sheets are carried as bytes (preserved.sharedStrings) gets the readings too,
so that the indexes do not shift.
Other formats. Neither ODF nor Numbers has a place for a cell's reading (ODF's text:ruby is an element of document body text and is not used in table cells).
The ODS and Numbers writers say "N phonetic guide(s) (furigana) dropped: … the text is kept (write .xlsx to keep the readings)" with dropped, and keep the text.
Checks. FuriganaTests, 4 tests: the table's XML (ranges, kind, alignment, font registration, the same characters as separate entries); round trips (a whole-string reading, several ranges, beside rich text);
readings in inline strings; the ODS and Numbers warnings. We have no file with furigana written by Excel itself — a check on the real application was added to the manual checklist in MAINTENANCE.md.
Measuring ODS (added 2026-09-11). ODF has text:ruby (text:ruby-base and text:ruby-text), and it can be placed inside a cell's text:p.
When we had LibreOffice 26.2.3 open a hand-made ODS and save it again, the readings disappeared both in ODS and in the XLSX export, and only the base text remained (Calc does not hold ruby inside a cell).
Writing in a form that nothing reads would only silence the dropped warning we issue now, so ODS furigana stays dropped and reported.
Workbook.theme and rgb(of:) (Rev 4.62)Decided 2026-09-11. Color.theme(_:tint:) is a reference meaning "colour number n of the theme, at tint tint", and the answer lies in xl/theme/theme1.xml.
Until now the reader only preserved this part without interpreting it, and the ODS and Numbers writers, unable to resolve the reference, wrote black and said "theme/indexed colours written as default"
(Numbers did not even say so, and dropped the colour). The colours Excel uses for the headings of a new workbook are all theme colours, so these were most of the colours lost in conversion.
Shape. Theme has colors: [String] (12 ARGB values, in the order of the number in <color theme="n"/> — 0 = lt1, 1 = dk1, 2 = lt2,
3 = dk2, 4–9 = accent 1–6, 10 = hlink, 11 = folHlink; inside the part the order is dk1, lt1, dk2, lt2, and the numbering swaps each pair, as Excel does), majorFont and
minorFont (a:latin@typeface). Theme.office is the default theme of Excel 2013 and later, with the same values as the part the writer has generated until now.
Workbook.theme: Theme? is the theme of the file that was read, and nil in a new workbook (resolved as .office). wb.rgb(of: Color) -> String? answers
.rgb as it is, .theme by applying the tint to the theme's colour, and .indexed from indexedColors (when the file has them) or from
the default palette of ECMA-376 §18.8.27 (64 colours plus the system foreground and background); for .auto it answers nil.
Tint. The rule of ECMA-376 §18.3.1.15: convert RGB to HLS, set the luminance to L × (1 + tint) when tint < 0 and to
L × (1 − tint) + tint when tint > 0, and convert back. That Excel's "White, Background 1, Darker 5%" comes out as F2F2F2, 15% as D9D9D9, 25% as BFBFBF, 35% as A6A6A6 and 50% as 808080
is nailed down by ThemeTests (matching the values of Excel's colour swatches).
Reading. ThemeParser collects the 12 slots of a:clrScheme (a:srgbClr@val, and a:sysClr@lastClr for dk1 / lt1) and,
from a:majorFont / a:minorFont, the a:latin@typeface. The part is not consumed but stays preserved (the format scheme and the effect scheme are not in the model, so
they travel as bytes). A copy of the theme that was read is put in preserved.theme.
Writing (XLSX). If there is a preserved theme part and wb.theme equals its copy, the part stays as bytes. If wb.theme differs, the part is rebuilt from the model at the same path and
with the same relationship (the same "rebuild if it differs from the copy" rule as for notes). If there is no part, it is generated from wb.theme ?? .office —
the generation of the default theme, fixed until now, has simply come to take the model as an argument. The generator inside the XLSX writer is renamed ThemePart, handing the name Theme over to the model.
Writing (ODS, Numbers). At the writer's entry point the workbook goes through resolvingColors(): for cell styles, row and column styles, named styles, differential formats, the colour scales and
data bars of conditional formatting, and the fonts of rich-text runs, the writer writes a copy of the workbook in which theme and indexed colours are replaced by what rgb(of:) answers, as .rgb. The exception is
the text colour .theme(1) (no tint) — it is the "text colour" the default font always has, and expanding it to black would put fo:color on every cell and erase its meaning as the default, so
it is treated as "default" as it is (the ODS writer already treated it that way). As a result, "theme/indexed colours written as default" remains only for colours that cannot be resolved (a number the theme does not have), and
ODSCodecTests.inexpressibleStylesAreReportedOnce was rewritten to this contract. The streaming write (StreamingWriter) holds no workbook, so it stays as before.
Checks. ThemeTests, 5 tests: reading the part (dk2, accent1, a system colour, the fonts); Excel's 5 tint steps and indexed colours; ODS writes the resolved colour (a round trip gives
.rgb); Numbers writes the resolved colour; the theme part follows the model (generated for a new workbook, byte-identical for an untouched source, rebuilt when changed).
Decided 2026-09-11. B.32 and B.34 covered "the adding side only", and the pictures and charts of an opened file were only carried as preserved bytes. We make them readable —
they enter sheet.images and sheet.charts on reading, and consumers can count them, look at them and remove them. No new type is created.
Reading. The part that the worksheet's <drawing r:id> points to is read anchor by anchor by DrawingParser: oneCellAnchor
(from + ext), twoCellAnchor (from + to), absoluteAnchor (pos + ext).
If the anchor holds pic/blipFill/blip@r:embed, it is a picture — the media part is passed through SheetImage(data:) (anything other than PNG, JPEG or GIF is noted as "a picture the model cannot hold").
If it holds graphicFrame/…/c:chart@r:id, it is a chart — ChartPartParser decides the kind from the element name of the first chart group in plotArea
(barChart + barDir → column / bar, lineChart, pieChart; anything else puts the element name into Chart.Kind as a raw value, B.69),
and collects, for each ser, the tx (a name if v, a reference if strRef/f — we added Chart.Series.nameReference; Excel usually records it this way),
the reference formulas of cat / xVal (categories) and val / yVal (values), the title outside plotArea, and whether there is a legend.
sp, grpSp, cxnSp and the like are noted as "things not in the model".
Anchor mapping. Pictures: a one-cell anchor becomes .cell(ref, sizing:) (if the pixels of ext equal the picture's pixels, .original; otherwise .scaled);
a two-cell anchor becomes .span (to is exclusive; if its offset is not 0, the picture is taken to reach into that cell); an absolute position becomes the new .absolute(x:y:width:height:)
(in points; EMU ÷ 12700) — added now, because before 1.0 is the last chance to add a case. The XLSX writer writes it as xdr:absoluteAnchor; the ODS writer anchors it at A1 and
offsets it with svg:x / svg:y. Charts: a two-cell anchor gives its range; for a one-cell anchor or an absolute position, the EMU of ext is divided by the sheet's column widths and row heights (the defaults where there are none)
to find the range it reaches — openpyxl places charts with one-cell anchors, so without this mapping they could not be read.
The write-back rule (the same "copy" approach as the footnote on notes in B.32). The reader puts copies of the pictures and charts it read in SheetPreservation.images / .charts,
together with the path of the drawing part, the paths of the media and chart parts the drawing referred to, and a note of the things that were not in the model. The parts are not consumed but stay in preservation. The writer splits into 3 cases:
① if the model's images / charts begin with the copies (untouched, or only added to), the original drawing and parts stay as bytes, and only what was added is
appended as before (B.32's splice). ② If even one of the copied things has changed or disappeared, the original drawing, its rels and the parts it referred to are set aside, and the drawing is rebuilt from the model
(the names of the set-aside parts are reused, so the other parts need not change). If there were things not in the model, dropped "N object(s) of the sheet's drawing the model could not read
(sp, …) dropped: the drawing was rebuilt because a picture or chart was changed or removed". A chart of a kind that cannot be drawn is dropped by name, as in B.69.
③ If everything is gone, the drawing element and its relationship are removed too.
Why "begin with". With "equal to", adding a single picture to a drawing that holds only shapes would rebuild the drawing and lose the shapes — worse than the behaviour so far (appending). With a prefix, adding stays as cheap as before, and the cost of a rebuild is paid only when something is changed or removed.
Checks. DrawingReadTests, 5 tests: the 4 kinds of chart openpyxl wrote (the scatter chart is raw scatterChart, yVal is the values, xVal the categories, and the range is found from a one-cell
anchor); an untouched save leaves the drawing and chart parts byte-identical; changing a title rebuilds the drawing and drops the scatter chart by name; round trips of the 3 picture anchors, and "add = append / remove = rebuild /
remove all = the element goes too"; what was read and what was noted in an Excel-made specimen (a chart plus a picture in an unreadable format). In ImageTests / ChartTests,
theSecondSaveChangesNothing flips its one line "reading back gives nothing" to "reading back gives one", and keeps its byte-identity claim. We added the specimen
Tests/SwiftSheetsTests/Fixtures/drawings/openpyxl-four-charts.xlsx (made by openpyxl 3.1.5) and libreoffice-four-charts.ods, which LibreOffice 26.2.3 made from it
(used in B.73).
Decided 2026-09-11. B.43 made it possible to write pictures to ODS, but reading skipped draw:frame, and writing rebuilds content.xml, so it could not re-link the original pictures and
object references and only said "not re-linked". Once they can be read, what was read can be written again as new parts, and this warning remains only for embeddings that are not charts
(math formula objects and the like). Along with this, the charts of addChart are written as ODF chart documents — the same 4 kinds as XLSX (B.34).
Reading. ContentParser no longer skips draw:frame (whether inside a cell or inside table:shapes); it notes the dimensions
(svg:x / svg:y / svg:width / svg:height, in cm), table:end-cell-address (plus end-x / end-y) and the
draw:image@xlink:href / draw:object@xlink:href inside (ODSFrame). After the document has been read, ODSDrawing.resolve matches them
against the package: if there is a draw:object, that is the body, and the draw:image beside it is LibreOffice's preview rendering (ObjectReplacements), so it is ignored.
Object N/content.xml is read by ODFChartParser — chart:class (chart:bar is split into column / bar by the plot-area style's
chart:vertical; chart:line; chart:circle → pie; any other class goes into Chart.Kind as a raw value, B.69),
the text of chart:title, whether there is a chart:legend, the chart:categories of the x axis, and, for each chart:series, its
values-cell-range-address and label-cell-address (Series.nameReference). ODF addresses (Data.B2:Data.B4) are converted to Excel's notation by
ContentParser.excelAddress. For pictures, the part under Pictures/ is passed through SheetImage(data:) (a format it cannot hold is
reported as degraded and stays a part). The parts that were read (pictures, the whole Object N/ set, ObjectReplacements/Object N) are taken out of the preservation sweep.
Anchors. A frame inside a cell with an end-cell-address becomes .span (if end-x / end-y are 0 the end is exclusive — the form this writer produces;
if not 0, it reaches into that cell); without one it becomes .cell (cm converted to pixels at 96 dpi; if they equal the picture's pixels, .original, otherwise .scaled).
A frame inside table:shapes becomes .absolute (cm → pt). A chart's range is its end cell or, if there is none, the range reached by dividing its dimensions by the sheet's column widths and row heights
(LibreOffice does not put end-cell-address on charts).
Writing. A chart becomes an Object N/content.xml (inside office:document-content, an office:chart: the class, title, legend, x-axis
categories, y axis, and the values / label addresses of the series; a horizontal bar chart puts chart:vertical="true" in the plot-area style); Object N/
(media-type application/vnd.oasis.opendocument.chart) and its content.xml are registered in the manifest; and
draw:frame + draw:object xlink:href="./Object N" (dimensions from the range's column widths and row heights, the end as end-cell-address) is placed in the anchor cell. The numbering skips the
Object N/ folders the source ODS brought along (as with B.43's pictures). An absolutely positioned picture is written into table:shapes, not into cell A1 (ODF 1.3 §9.1.2: before the columns).
A kind that cannot be drawn (anything other than the 4 kinds and classes with the chart: prefix) is dropped by name. The Numbers writer drops charts and says so, as before.
Checks. ODSDrawingTests, 4 tests: the 4 kinds of chart LibreOffice 26.2.3 made (specimen libreoffice-four-charts.ods; in document order
column / pie / line / chart:scatter; the series references; the parts leave preservation and "not re-linked" disappears); addChart becomes a chart document and round-trips;
round trips of the 3 picture anchors and which parts belong to what; carrying a chart LibreOffice wrote to XLSX (gated). The Object 1 assertion in ODSCodecTests,
ODSImageTests.namesStepPastThePartsASourceODSBroughtAlong and ChartTests.emptyChartsAndOtherFormatsSpeakUp were rewritten to the new contract
(pictures and charts belong to the model; ODS writes charts).
Decided 2026-09-11. The ODS writer used to drop sheet.tabColor with "ODF 1.3 has no tab colour (LibreOffice drops it on the same conversion)".
That was an old measurement: ODF 1.3 §20.400 has table:tab-color (an attribute of style:table-properties), LibreOffice 26.2.3
converts Excel's <tabColor rgb="FFFF0000"/> to table:tab-color="#ff0000", and turning that ODS back into XLSX yields the same
<tabColor> (measured 2026-09-11, with the same procedure as B.17's specimens). There is no reason to drop it, so we read and write it.
Reading. ODSStyleCatalog.tableTabColor takes the table style's table:tab-color (or, if it is absent, the
tableooo:tab-color LibreOffice wrote before that name was standardised), turns it from #rrggbb into a Color and puts it in Sheet.tabColor. A value that does not start with # is not read.
Writing. The colour is added to the key of the table style (ta1…), and table:tab-color="#rrggbb" is written. In write(_ source:),
resolvingColors() (B.71) resolves sheet.tabColor as well, so theme and indexed colours land as RGB. Only a colour that cannot be resolved (such as an out-of-range
indexed colour with no theme) is reported as degraded, and black is written. The dropped warning is retired, so the ODS count in FormatSupportTests goes from 9 to 8, and "tab colour" leaves
expectedLosses[.ods]. Numbers drops it and says so, as before (its tabs have no colour).
Checks. ODSCodecTests.theTabColourIsWrittenAndReadBack (writes 2 sheets, one with an RGB colour and one with a theme colour, checks the attribute and reads it back) and
libreOfficeCarriesTheTabColourToXLSX (gated: LibreOffice turns the ODS we wrote into XLSX, and <tabColor> appears).
Decided 2026-09-11. B.72 / B.73 read pictures and charts, but shapes (rectangles, arrows, lines) and text boxes were only noted as "present" in XLSX, and skipped in ODS.
Both formats can express the same things, so they enter the model. SmartArt does not — it is a set of 4 dgm: parts (data, layout, style, colours) plus a graphic frame, and all the model could hold is
that it "was there". Shape groups (xdr:grpSp / draw:g) are treated the same way.
The model. Shape: geometry: Shape.Geometry (the raw value is the OOXML preset name rect / roundRect /
ellipse / rightArrow / line …; a struct like the types of B.69, where textBox is the name of "a rectangle whose body is its text"),
text (paragraphs separated by \n), font (one font for the whole text), textAlignment, fill: Color? (nil = no fill),
outline: Outline? (a colour and a width in pt; nil = no line), name, anchor: SheetImage.Anchor (the same 3 kinds as pictures).
Geometry.presets holds the 187 names of ECMA-376 §20.1.10.56, and isPreset says whether the XLSX writer can name a geometry.
sheet.shapes, addShape(_:over:), addTextBox(_:over:font:). Not held: rotation, shadows, gradients, per-paragraph formatting —
an untouched shape stays as bytes, so these are not lost; they are dropped only when the drawing is rebuilt (the rule below).
Reading XLSX. DrawingParser hands the sp / cxnSp inside an anchor to ShapeCollector:
cNvPr@name, cNvSpPr@txBox (a rect with txBox is a text box), prstGeom@prst, from spPr the
solidFill (srgbClr / schemeClr, with lumMod / lumOff / shade turned into a tint) and noFill,
a:ln@w and the fill inside it, and the paragraphs of txBody (pPr@algn; from the first rPr, sz / b / i / u, the colour and latin@typeface;
br). A shape whose spPr states neither a fill nor a line (one Excel has newly placed) takes its colour from xdr:style: the fillRef / lnRef
(with an idx other than 0) and their schemeClr — accent1 is the default blue. grpSp is noted as "a group of shapes", and a graphicFrame whose
graphicData@uri is a diagram as "SmartArt" (drawingUnmodelled). Anchors work as for pictures (a one-cell anchor is .scaled to the size in ext).
Writing XLSX. DrawingParts.shapeAnchorXML: xdr:sp (txBox; xfrm at the size of the anchor; prstGeom;
solidFill or noFill; a:ln; if there is text, txBody with one paragraph and one run). Colours are resolved with wb.rgb(of:) and written as
srgbClr. A name that is not a preset is written as rect and reported as degraded. The copy rule (B.72) is extended to shapes: the
shapes that were read are copied into SheetPreservation.shapes, and if the 3 lists images / charts / shapes all begin with their copies,
the original drawing stays as bytes and what was added is appended (shapes need no relationships, so the rels do not grow; cNvPr ids are in the 5000s). If any of them changed or disappeared, the drawing is rebuilt,
and the noted things (groups, SmartArt, pic without a blip …) are dropped by name.
Reading ODS. The frame capture of ContentParser is widened to draw:custom-shape, draw:line, draw:connector and
draw:frame with a draw:text-box (ODSFrame.kind): draw:name, draw:style-name (the graphic style),
draw:text-style-name, draw:enhanced-geometry@draw:type, the text of the paragraphs (text:p / span / line-break /
s / tab), the style names of the first paragraph and span, and the 4 coordinates of a line. ODSStyleCatalog holds graphic-properties and answers
graphicStyle(named:) (draw:fill / draw:fill-color / draw:stroke / svg:stroke-color /
svg:stroke-width, following parents), paragraphAlignment(named:) and font(named:). Geometry names come from
ODSFrame.geometry: ooxml-X gives X, LibreOffice's own names (rectangle / round-rectangle / ellipse /
diamond / right-arrow …) go through a mapping table, and anything else keeps its ODF name.
Writing ODS. ODSDrawing.shapeXML: draw:custom-shape + draw:enhanced-geometry@draw:type (the LibreOffice name if the mapping table
has one, otherwise ooxml-<preset> — LibreOffice 26.2.3 draws an ooxml-rightArrow with no path, and takes it back to XLSX as
prst="rightArrow"; measured); a line is draw:line, a text box draw:frame + draw:text-box.
style:family="graphic" (gr N: fill and line) and style:family="paragraph" (P N: alignment) are added to the automatic styles, and
the font goes on a span through the existing text style (T N). A cell anchor goes inside that cell (with end-cell-address for a range), an absolute position into
table:shapes (after the pictures). ODS is rebuilt every time, so shapes that were read are written anew too. Order is document order: things in table:shapes are read before
things in cells (ODF 1.3 §9.1.2). Numbers drops them and says so.
Checks. ShapeTests, 13 tests: reading the XLSX and ODS with 7 shapes that LibreOffice wrote (specimens libreoffice-shapes.*: rectangle, arrow, ellipse,
rounded rectangle, text box, line, a diamond inside a cell); untouched stays as bytes; added is appended; changed is rebuilt; SmartArt and a group (specimen
smartart-and-group.xlsx, hand-made) are noted by name and reported as dropped on a rebuild; round trips of 5 kinds (XLSX / ODS); unknown geometry; the Numbers warning; carrying between formats;
LibreOffice reads the shapes of both writers (gated, 2 tests). An Excel-made file with SmartArt is added to the manual checklist (MAINTENANCE).
Decided 2026-09-11. SheetView held only gridlines, zoom and the selection, and the README said the remaining attributes of <sheetView> were "not preserved"
(sheetViews is a child the reader knows, so it does not survive as a fragment either). We add the 5 that users ask for by name: showsRowColumnHeaders (showRowColHeaders),
showsZeros (showZeros), rightToLeft, topLeftCell (nil for A1) and kind (view: normal /
pageLayout / pageBreakPreview — an enum, because the spec fixes it at 3 values). The workbook gets CalculationSettings.calcMode (<calcPr calcMode>: auto /
autoNoTable / manual; an enum for the same reason; nil is the default, automatic). A default value leaves no trace in the XML.
ODS. LibreOffice keeps view settings in settings.xml. A document saved from the GUI has per-table items under Views (ShowGrid,
ZoomValue, PositionLeft/Top, and, when panes are frozen, PositionRight/Bottom as the top-left of the scrolling side); a headless conversion has only the document-wide items
(ooo:configuration-settings: ShowGrid, ShowZeroValues, HasColumnRowHeaders, AutoCalculate —
measured 2026-09-11). Reading applies the document-wide values to every sheet and then overrides them with the per-table items; writing emits both. Right-to-left is the table style's
style:writing-mode="rl-tb" (read and written). AutoCalculate=false ⇔ calcMode == .manual (autoNoTable is treated like auto).
ODF has no view kind, so anything other than normal is reported as degraded. Numbers has no view settings and, as before, says nothing (the same goes for the calculation mode).
Checks. SheetViewTests, 4 tests: the XLSX round trip (defaults leave no trace); the ODS round trip (including the scroll position beyond the frozen panes; the kind is degraded);
reading libreoffice-view.ods, written by headless LibreOffice (document-wide items only); LibreOffice turns the ODS we wrote into XLSX and the attributes appear (gated).
Decided 2026-09-11. preservationSummary answered only the source format, the number of parts and whether there is VBA; to learn "what is inside, and what a conversion drops" you had to write the file and read the
warnings. We add parts: [PreservedPartKind: Int]. PreservedPartKind is a struct like the types of B.69 (chart / chartSheet / drawing /
shapeGroup / drawingObject / image / smartArt / vbaProject / script / theme / slicer / dataConnection / customXML / embeddedObject /
externalLink / pivot / table / printerSettings / other). The fourth argument of init defaults to [:], so existing calls do not change.
How it counts (PreservationStore.inventory). What the model has read is not counted: a sheet's drawing part and the parts it refers to (drawingPath /
drawingParts — pictures, charts and shapes belong to the model, and their bytes only travel as B.72's copy), a theme that could be read, and the comments part and VML of notes.
Parts that come along with something counted are not counted either: _rels, a chart's style / colors, SmartArt's layout / quickStyle / colors (one data part is one SmartArt),
threadedComments and persons (the model's since B.80), ctrlProps, ActiveX .bin files, pivotCache records, and in ODS ObjectReplacements/, Configurations2/ and
the Basic catalogue files. Things in a drawing that did not enter the model (B.75's drawingUnmodelled) are counted as shapeGroup / drawingObject, and a non-grid sheet
(foreignSheet) as chartSheet. In ODS, Pictures/ counts as image, Object N/content.xml as embeddedObject
(one that was read as a chart has left preservation, so it does not appear), and the scripts in Basic/ as script. The rest is other.
Checks. PreservationInventoryTests, 4 tests: a specimen read in full (charts-and-friends) gives an empty inventory; SmartArt and shape groups, VBA, a chart sheet
(its chart and drawing do not belong to a grid sheet, so they remain as chart / drawing) and external links; ODS charts do not appear; the type's names and order.
Decided 2026-09-11. The [1]Data!B2 in a formula travelled as text, but we could not say which file [1] is.
We add ExternalLink { index, target, sheetNames } and Workbook.externalLinks (read-only; package(set)).
Values are not resolved — what a cell holds is the cached value from the file.
XLSX. <externalReferences> is a child the reader does not know, so it stays as a fragment. We take the r:ids of that XML in order
(the order is the number used in formulas), follow the workbook relationships to xl/externalLinks/externalLinkN.xml, and read the sheetName@val of the part's externalBook and
the Target of externalLinkPath in the part's relationships (kept as a relative path, absolute path or URL; percent-encoding is decoded) (ExternalLinkParser).
Nothing is consumed: both the part and the fragment stay as bytes when written back to the same format.
ODS. ODF has no list; a formula names the document directly, as in ['file:///…/Budget.ods'#$Data.B2]. From the formulas of all cells, once reading is done,
a regular expression picks up 'document'#$sheet.; numbers are given in order of first appearance, and the sheet names are the ones the formulas used. The externalLink kind of the inventory (B.77) counts XLSX parts, so
it does not appear for ODS. We do not read them from Numbers (the model has no Numbers external references).
Checks. ExternalLinkTests, 3 tests: reading the hand-made external-links.xlsx (Excel's shape: sheetNames and cached values);
the part and the fragment stay as bytes when written back to the same format; deriving 2 documents and 3 sheet names from the hand-made external-links.ods.
Decided 2026-09-11. Proposal 8 of September 10 treated sparklines as Excel-only and suggested sheet.excel.sparklines (the core-plus-appendix style), but LibreOffice 26.2.3 writes
calcext:sparkline-groups to ODS and converts them to and from XLSX's x14:sparklineGroups (measured 2026-09-11: 2 groups made the round trip hand-made ODS → XLSX → ODS).
Both formats have them, so they sit flat on the sheet: sheet.sparklines: [SparklineGroup].
The model. SparklineGroup: kind: Kind (a struct; the raw value is OOXML's type: line / column / stacked),
sparklines: [Sparkline(dataRange, location)] (several in one group — the shape of an Excel group), 8 colours (series, negative, axis, markers, first, last, high, low),
6 point-display switches and the axis, lineWidth (pt), emptyCells: EmptyCells (gap / zero / span; an enum, because the spec fixes it at 3 values).
addSparkline(_:data:at:) adds a group of one. The data range is text ('Trend'!A2:H2 or A2:H2 — the sheet name is
filled in when writing, as for chart series).
XLSX. A worksheet's extLst stays in a fragment, as a child the reader does not know. Reading: if the fragment contains sparklineGroups, it is wrapped in a root that declares the
prefixes the worksheet root declares (x14 / xm / mc / xr2), passed through SparklineParser, and put into sheet.sparklines and the copy
SheetPreservation.sparklines. Writing (the copy rule, B.72): if the model is the same as the copy, the fragment goes out as it is. If not, only the ext whose uri is
{05C60535-1F16-4fd2-B633-F4F36F0B64E0} is cut out of the fragment (other extensions — such as x14 conditional formatting — stay), and an
ext built from the model (declaring its own namespaces) is appended before </extLst>. An extLst left empty by the cut is removed. Colours go through
StyleRegistry.colorXML (theme colours can be written as theme colours).
ODS. Reading: calcext:sparkline-group inside the table (type, color-*, high / low / first / last / negative / markers /
display-x-axis, line-width, display-empty-cells-as) and calcext:sparkline (cell-address, and data-range turned into Excel's notation).
Writing: calcext:sparkline-groups after the conditional formatting (ids are sequence numbers in GUID form; min/max-axis-type is individual). Colours are resolved by
resolvingColors(), so they land as RGB. A data range that cannot be read as a range is reported as degraded and not written. Numbers drops them and says so.
Checks. SparklineTests, 5 tests: reading the XLSX and ODS with 2 groups that LibreOffice wrote (specimens libreoffice-sparklines.*);
untouched, the extLst stays as bytes; changed, it is rebuilt (the other things stay); round trips in the 2 formats and the Numbers warning; LibreOffice reads the sparklines of both writers
(gated).
Decided 2026-09-11. Since Excel 2019 a comment is a conversation (one opening comment, its replies, and a resolved flag). It lives in xl/threadedComments/threadedCommentN.xml
(one per sheet; ref / dT / personId / id / parentId / done) and
xl/persons/person.xml (one per workbook; id → display name), and for older readers a note beginning with [Threaded comment] is placed in the comments part
as a mirror (its author is tc={id}). Until now this mirror was read into cell.note, and the parts were one kind in the inventory.
The model. CommentThread { author, text, created: CivilDateTime?, resolved, replies: [Reply(author, text, created)] },
Cell.thread (beside note, in CellExtras), Sheet.threads. noteText has the shape "body, blank line, author: reply",
and becomes the text of the note in formats that cannot hold threads.
Reading XLSX. The part behind the workbook relationship …/2017/10/relationships/person is read by PersonParser into id → name
(preserved.persons, SheetReadContext.persons); the part behind the sheet relationship …/threadedComment is read by
ThreadedCommentParser, and entries without a parentId become openings and those with one become replies, bundled in document order (ThreadedCommentParts.parse).
On a cell with a thread, a note beginning with [Threaded comment] is hidden (it is a mirror, not the user's note). All 4 parts stay in preservation
(the same rule as notes). The copy: preserved.threads. The threadedComments kind was taken out of the inventory (B.77) — they now belong to the model.
Writing XLSX. The rule for notes is widened: if the sheet's notes (compared with the as-read notes minus the mirrors) and its threads are both the same as the copies, the comments / VML /
threadedComments parts stay as bytes. If either differs, the 3 parts are set aside and rebuilt: the threadedComments part (ids are new GUIDs; Excel, too, reassigns them on every save),
the comments part with the sheet's notes plus one mirror per thread (on a cell with its own note, its own note wins), and VML for all of them. The persons part belongs to the workbook, so
when any sheet is rebuilt it is rebuilt for the whole workbook — a person the original part named keeps the original id (preserved.persons is looked up by name), and a new person gets
an id derived from the name (FNV-1a). The relationships and Content_Types (application/vnd.ms-excel.threadedcomments+xml / …person+xml) are added.
ODS and Numbers. A thread is written as a note with noteText (its author is the person who opened the thread), and the count is reported as substituted. On a cell that has both its own note and a thread,
the note wins and the thread is reported as dropped. LibreOffice reads Excel's mirror notes as they are (measured: converting the hand-made specimen to ODS gives 3 annotations).
Checks. ThreadedCommentTests, 6 tests: reading a hand-made specimen in Excel's shape (threaded-comments.xlsx: 2 threads, 1 reply,
1 resolved, 1 plain note) and hiding the mirrors; untouched, the 4 parts stay as bytes; changed, they are rebuilt and the person ids are kept; a new workbook writes the 3 parts and their content types;
the substitution in ODS and Numbers and the warning for a cell that has both; LibreOffice reads the mirrors we wrote (gated). An Excel-made specimen is added to the manual checklist (MAINTENANCE).
Decided 2026-09-11. ODF (text:a) and Numbers (smart fields) attach a link to a stretch of text, so one cell can have several, but the model had
a single Cell.hyperlink, and from the second link on it dropped them, saying "the first was taken". We add TextRun.hyperlink: Hyperlink? (default nil),
and several links are carried by the runs of .richText. Cell.hyperlink is the first link, as before (a cell with only one link reads in the same shape as until now — plain text and
a cell link).
ODS. Reading: ODSCellText treats the start and end of a text:a as run boundaries (currentLink), and a run holds
(text, style, link). If there are 2 or more links, hasRunLinks makes the value richText, and richText(font:) attaches the links to the
runs. Only a value that is not text (several links on a number cell) still gets the old warning. Writing: a run's link wraps its span in text:a. A cell whose runs carry links
does not get a whole-cell wrapper on top.
Numbers. Reading: from the table_smartfield entries' character_index, the range of each link (up to the next entry) is taken
(LinkRun), and if there are several, applyingLinks re-cuts the formatting runs at the boundaries and attaches the links. Writing: for each run, a smart field is placed at the run's
start and closed at the run's end with an entry that has no object (the shape of Numbers' attribute table). A link run with no font wears the link character style.
XLSX. One link per cell: the cell's own link, or if it has none, the first run's link is written, and the rest are reported as degraded with "N link(s) on parts of a cell's text dropped". Reading does not change (Excel cannot hold links on stretches of text in the first place).
Checks. RunHyperlinkTests, 4 tests: a cell with 2 links, hand-made in ODF's shape (specimen two-links.ods; one link is
a bold span inside text:a — when LibreOffice 26.2.3 re-saved the file, the text of that span vanished, so it is kept hand-made), read into runs; written to ODS run by run and read back; XLSX takes the first and says so (the cell's own link wins if it has one); a Numbers round trip.
Whether Numbers itself reads it goes on the manual checklist.
Decided 2026-09-11. Excel 2010 puts a data bar's negative colour, axis, direction, solid fill and border, and custom icon sets whose icons are chosen one by one, outside the 2007-format rule:
the <cfRule>'s extLst gives an x14:id, and in the worksheet's extLst,
x14:conditionalFormattings gives the content in an x14:cfRule with that id. Until now, on seeing this extLst we marked the block of rules
as "not fully read" and wrote the original XML back — and since LibreOffice adds this shape every time it writes a data bar, data bars were in practice never editable.
The model. DataBar: negativeColor, axisColor, axisPosition (automatic / middle / none;
an enum, because the spec fixes it at 3 values), direction (context / leftToRight / rightToLeft; likewise), isGradient (default true),
borderColor, usesExtension. IconSet: customIcons: [Icon(set, index)]?. Existing calls do not change.
Reading XLSX. A rule's extLst, if it holds only an x14:id, no longer marks the rule as not fully read; the id is noted in rule.extensionID
(package access; provenance, so it takes no part in ==). An extension that says anything else marks it as not fully read, as before. If the worksheet's extLst fragment has
{78C0D931-…}, X14ConditionalParser reads it into id → extension (the dataBar's attributes and colours, the iconSet's cfIcons), and
X14ConditionalParts.apply folds them into the rules. An x14:cfRule that is not dataBar / iconSet (the new rule types of 2010), and an id no rule names, are
counted in unmatchedConditionalExtensions.
Writing XLSX. Rules are rebuilt every time (as they always were). A rule that needs the extension (needsExtension) is given a new GUID, with
inside <cfRule> an extLst carrying the id, and in the worksheet's extLst an x14:conditionalFormattings
(an x14:cfRule and xm:sqref per block; the bounds are autoMin / autoMax and xm:f).
The extension list now works the same way as B.79's sparklines (ExtensionList: set aside by uri, appended together): when the rules are rebuilt,
the {78C0D931-…} extension is always set aside and rebuilt, and the unreadable extensions counted earlier are reported as dropped. A sheet whose original rules are written back verbatim (because it had a rule that could not be
fully read) keeps its extensions as they are too.
ODS. LibreOffice's calcext:data-bar has the negative colour, the axis colour, the axis position and gradient: we read and write them (writing only what the model states;
we no longer write the defaults negative-color="#ff0000" and axis-position="none" as we used to, and leave them to LibreOffice's defaults — so that on reading back a nil
comes back as nil). Custom icons cannot be held, so they are written with the icons of the named set and reported as degraded.
LibreOffice's limits (measured 2026-09-11). LibreOffice 26.2.3 cannot open an XLSX that contains x14:iconSet (even in exactly Excel's shape it
fails with "Unspecified Application Error", whether or not there are cfIcons and whether the sets are the same or not). It does read the data bar extension. The gated test puts only data bars
before the judge. A user who wants a workbook with custom icons to open in LibreOffice sets customIcons to nil before writing.
Checks. ConditionalExtensionTests, 5 tests: reading the x14 data bar that LibreOffice wrote (specimen libreoffice-databar-x14.xlsx)
as a rule; on writing back, the rule and the extension carry the same id; a round trip of every data bar field and of custom icons, and a second save; what ODS can and cannot express;
LibreOffice reads the extension we wrote (gated).
Decided 2026-09-11. A Numbers sheet is a canvas, and pictures, shapes, text boxes and charts stand beside the tables. Until now reading named them as dropped ("an image", "a shape"),
and writing dropped them with "not implemented yet". The model already has B.72's SheetImage and B.75's Shape, so Numbers reads and writes the same things.
Charts (TSCH.ChartDrawableArchive) are still left out this time — series, axes and styles form a separate, large structure, outside the current way of writing, which appends tables. Movies, groups and connection lines are also dropped by name, as before.
Measured (the document canvas-15.numbers, made by Numbers 15.3.1 through AppleScript). A picture is TSD.ImageArchive:
super.geometry (position and size in pt, flags 3), parent is the sheet, style is the template's
image-0-imageStyle (TSD.MediaStyleArchive), originalSize / naturalSize are the pixel counts taken as pt, and data is a
TSP.DataReference. The file itself goes in Data/<name>-<number>.<extension>, and Index/Metadata.iwa's TSP.PackageMetadata.datas gets
one more TSP.DataInfo (identifier = the largest existing data number + 1, a sequence separate from object numbers; digest = the SHA-1 of the file; preferred_file_name;
file_name; attributes.pixel_size; materialized_length). Numbers itself adds nothing to data_metadata_map, so we do not touch it.
The title and caption (StandinCaptionArchive) and traced_path are optional and are not written.
Shapes and text boxes are both TSWP.ShapeInfoArchive (not TSD.ShapeArchive — the reader's name table is corrected accordingly):
super.super.geometry and parent; style is the template's textbox-0-shapestyle or shape-0-shapestyle;
pathsource.bezier_path_source is the closed square 0,0→100,0→100,100→0,100 with naturalSize = the dimensions; owned_storage and deprecated_storage are the same
TSWP.StorageArchive (kind is not written — the default is BODY. Whether we write CELL, the kind for cells, or state BODY = 0 explicitly, Numbers aborts in TSText when saving.
Measured 2026-09-11: shapes without text and pictures could be saved, and only shapes with text crashed, so we tracked it down by reverting the differences in the storage one at a time. For table_drop_cap_style we write one empty row, as Numbers does.
The paragraph style is taken from shape_properties.paragraph_style of the chosen shape style),
is_text_box. Numbers registers each reference to these styles, one by one, as an external reference of the Document part, so we register them the same way (Numbers refuses a document with a reference that is not registered).
Writing. Pictures as above. For shapes, only rectangles and text boxes are written as measured; any other geometry is written as a rectangle and named as degraded (Numbers' ellipses and arrows cannot be made through AppleScript,
and we have not measured what the numbers in scalar_path_source / point_path_source mean). For fill and line, we copy the template's style, rewrite fill.color and stroke.color / width, and add the resulting
TSD.ShapeStyleArchive to the stylesheet. For the text's font, one TSWP.CharacterStyleArchive, the same as for cell text in B.18, is made and applied to the whole text. textAlignment is not written and is reported as degraded.
Anchors: Numbers has only points on the canvas, so .absolute stays as it is, and .cell / .span are turned into a point by adding the position of the first table (the y at which the writer placed it) and the column widths and row heights (default 98 pt / 20 pt; a width is the character count × 5.7 pt).
.fitCell takes that cell's dimensions, and .original / .scaled take the pixel counts as pt.
Reading. Among the sheet's drawable_infos, a TSD.ImageArchive follows data → datas' file_name → the file in Data/, which goes through
SheetImage(data:); anything that is not PNG / JPEG / GIF (PDF, HEIC) is dropped and reported as "an image", as before. A TSWP.ShapeInfoArchive gives the text (the storage's text),
is_text_box → .textBox, a four-corner bezier → .rectangle, and anything else → Geometry(rawValue: "numbers-path") (not a preset name, so writing it to XLSX / ODS gives a rectangle plus degraded).
Fill and line come from the style's shape_properties. The font is not read (nil). Every anchor is .absolute. Pictures and shapes that were read are built anew on the template when written back to Numbers too (as with ODS in B.73; the copy rule is not used).
The judge. A gated test (NumbersCanvasTests) has Numbers itself open the document we wrote and save it again, then reads back the picture's SHA-1 and the text box's text from the returned document.
On a machine without Numbers it is skipped with a reason. The manual checklist in MAINTENANCE gets a line too.
Decided 2026-09-11. Until now, for a sheet with even one print-related setting, we said in one line "the print setup … is dropped: Numbers prints a canvas, not a page grid" and dropped all of it.
In fact TN.SheetArchive holds its own print settings: in_portrait_page_orientation, content_scale (0.72 in the template), print_margins (a
TSD.EdgeInsetsArchive in pt), page_header_inset / page_footer_inset, using_start_page_number / start_page_number, show_page_numbers,
is_autofit_on, and in headers and footers 3 TSWP.StorageArchives each, in the order left, centre, right (kind HEADER; the centre of the template's footer holds
Numbers' own page number as a U+FFFC in table_attachment). What it can hold we carry; only what it cannot hold is dropped by name.
Writing. pageSetup.orientation → the orientation, scale (%) → content_scale, firstPageNumber → the start page number,
pageMargins (inches) → the margins and the header and footer insets (× 72). oddHeader / oddFooter are split at &L / &C / &R by the same rule as for ODS,
and each region's text is written to the text of its storage (the centre footer's attachment is removed). &P, wherever it is, sets show_page_numbers, and anywhere other than the centre it is reported as degraded.
Other codes (&N &D &T &F &A, the font &"…", the size &12 …) are dropped and named all at once.
Separate text for even pages and the first page is dropped. fitToWidth / fitToHeight become Numbers' auto-fit (one page wide) and are reported as degraded. Paper size, print area, title rows/columns and page breaks are
named in one dropped line (listing what was dropped).
Reading. The orientation is .landscape only when it is not portrait; the scale is content_scale × 100 (72 if it is as in the template); the start page number only when the flag to use it is set;
the margins are pt ÷ 72. For headers and footers the text of the 3 regions is turned back into Excel's codes (leaving out U+FFFC), and if show_page_numbers is set, &P is added to the centre of the footer —
this states what Numbers prints, so even for a Numbers document nobody has touched, oddFooter is &C&P.
The judge. A gated test (NumbersPrintTests) has Numbers open a document with landscape orientation and header text, save it again, and reads it back. In the everything-in workbook the Numbers column
now shows ○ for "Print: orientation" and "Print: header/footer", and the counts on the public pages were updated to follow.
Table.position (Rev 4.76)Decided 2026-09-11. The list in the spec said Table.anchor was "read and written", but the writer ignored it and stacked tables vertically at x=0, and the reader rounded positions to a 98 pt × 20 pt grid.
A Numbers table stands at a point on the canvas, so we hold the point as it is.
The model. CanvasPoint { x, y } (pt) and Table.position: CanvasPoint?. anchor stays, as the rounded view of that point.
sheet.addTable(named:at:) adds a table at a given point. Table == compares position too.
Reading. The position in the geometry of TST.TableInfoArchive goes into position, and, rounded as before, into anchor.
Writing. For each table: the point in position if there is one; otherwise, when anchor is not (1,1), its grid point ((column−1) × 98, (row−1) × 20);
if neither, below the previous table (the old stacking; the stacking y also moves past the bottom edge of tables that were placed). The first table goes at the origin if its anchor is (1,1).
Decided 2026-09-11. Of the things B.84 dropped by name, we carry the 2 that have a place to go.
Paper. Numbers has one paper per document (TN.DocumentArchive's paper_id (iso-a4 / na-letter …) and page_size (pt)).
We write the pageSetup.paperSize of the first sheet that names a paper (Excel's number; the 7 kinds in PageSetup.paperSizesInCentimetres, shared with ODS), and a later sheet that names a different paper is reported as degraded.
A number not in that table is dropped by name, as before. Reading cross-checks paper_id, or failing that page_size within ±2 pt, and puts the result into every sheet's paperSize.
Title rows/columns. printTitleRows starting at row 1 become the first table's number_of_header_rows (columns likewise) and set the sheet's show_repeating_headers
(Numbers' "Repeat headers on each page"). If the count differs from freezePanes, the title rows win and this is reported as degraded. A range that does not start at row 1 has nowhere to go, so it is dropped by name.
Reading: when show_repeating_headers is set, the table's header row count becomes 1...n.
What has nowhere to go. Print areas and page breaks. Numbers flows the canvas onto paper as it is, so neither a range nor page breaks exist in the archive. They are named as dropped, as before.
Decided 2026-09-11. B.83 wrote only rectangles and text boxes as measured, and made every other geometry a rectangle. Numbers cannot make ellipses or arrows through AppleScript (its shape has only text and fill),
and what the numbers in scalar_path_source / point_path_source mean cannot be measured. Instead — since we confirmed with the rectangle that Numbers keeps a bezier path it is given as it is —
we spell every shape out as points.
Writing. In a 100 × 100 unit space (scaled up by naturalSize), the rectangle, the rounded rectangle (radius 16, cubic curves with κ = 0.5523), the ellipse (4 cubic curves), the diamond, the triangle, the right, left, up and down arrows (7-point polygons) and
the line (an open path 0,0 → 100,100; its style is the template's line-0-shapestyle) are written with TSP.Path's moveTo / lineTo / curveTo (2 control points + end point) / closeSubpath.
Any other geometry is written as a rectangle and reported as degraded, with the list of shapes that can be written.
Reading. The path's elements are brought into the unit space (divided by naturalSize; Numbers' own rectangle is in 100 units from the start); if they match one of the paths above to within half a unit, the shape gets that name;
with is_text_box it is textBox; if none matches, numbers-path (not a preset).
The judge. A gated test writes the 10 shapes into one document, has Numbers save it again, and confirms by reading back that the paths return unchanged. How they look (whether an ellipse looks like an ellipse) is on the manual checklist in MAINTENANCE.
Decided 2026-09-11. A Numbers chart is one TSCH.ChartDrawableArchive, which holds, as TSCH.ChartArchive.unity, the kind (TSCH.ChartType); a style preset of the document
(TSCH.ChartStylePreset; every document has 6, and a preset names a chart style, a legend style, 2 value-axis styles, 1 category-axis style, 6 series styles and 2 paragraph styles); the styles and the "non-styles" (the settings of the title, legend, axes and series;
TSCH.ChartNonStyleArchive and others); a cache of the numbers it draws (grid: row names, column names, values); and the mediator (TN.ChartMediatorArchive). The mediator's formulas name
the table the data comes from, through formulas for the data, the row labels and the column labels (an AST that wraps a reference to 1 cell or 1 range in function 175, an internal function not in functions.json), and the mediator itself is registered in the calculation engine as a formula
owner (TSCE.FormulaOwnerDependenciesArchive, owner_kind 2, formula_owner = the chart). Measured on chart-and-control-15.numbers, written by Numbers 15.3.1.
The model. CanvasRect { origin: CanvasPoint, width, height } and Chart.frame: CanvasRect? (the frame on the canvas, in pt). anchor (a cell range) stays as it is.
When a chart that has only a frame is written to XLSX / ODS, anchorOrFrameCells (the cells the frame covers on the default 98 pt × 20 pt grid) becomes its anchor.
Reading. For the kind, the 2D column / bar / line / pie become the model's kinds, and anything else becomes a Kind whose raw value is the enumeration name (a kind that cannot be drawn). The title and legend come from the non-styles. For the series, each of the mediator's
data_formulae is turned back into a string, one at a time, with NumbersFormulaDecoder (after removing function 175 at the end; the result has the form 'Data::表1'!$B$2:$B$4; the decoder puts the table name on both ends of a range, so we fold the two into one).
Of the 2 lists of labels, the one as long as the number of series gives the series names (nameReference), and the other gives the categories, with its first and last cells joined into one range. A chart whose mediator has no formulas (only pasted numbers) is not put into the model, and is reported as "a chart", as before.
Writing. Only the 4 kinds that can be drawn. A series' values is accepted in any of the forms Sheet!$B$2:$B$4 (the sheet means its first table) / 'Sheet::Table'!B2:B4 / a bare range (on that sheet) and resolved to a table and cells, and
the mediator's formulas are always written with 'Sheet::Table'! (the chart has no table of its own, so every reference carries the table's UUID — hostTable is set to a name that does not exist). The data formulas are 1 range per series,
the row labels 1 reference per category cell, and the column labels the nameReference if there is one, otherwise the name as a string. The grid holds the numbers in the table's cells (0 where there is none) and the category text. Non-styles — 1 for the chart, 1 for the legend, 2 for value axes, 1 for the category axis and 1 per series — are made
in the same part as the sheet, and every style points to the one in the template's first preset (no private variants are made). The mediator gets a random entity_id, −1 once per series in local_series_indexes,
1…N in remote_series_indexes, and direction 2. The owner is registered with B.19's registerOwner, with kind 2 and formula_owner set to the chart. The references from the chart to its styles and from the owner to the chart are
registered as external references between parts. The frame comes from anchor (turned into a point on the first table's grid), else from frame, else it is 400 × 250 below the tables. Series that cannot be resolved are dropped and counted; if not one is left, the whole chart is dropped and reported.
The judge. A gated test writes a column chart with 2 series, has Numbers save it again and confirms that the kind and the ranges of the 2 series come back, then has Numbers export it to .xlsx and confirms that one column chart arrives on the Excel side
(NumbersChartTests). Green on this machine (2026-09-11).
Decided 2026-09-11. Every public declaration added since 0.24.0 (B.69–B.88) was read against the naming rules of B.62–B.67 before the surface is frozen. Five places broke a rule, and a handful were smaller inconsistencies. All are fixed here, and none keeps a compatibility alias (B.56): the compiler names every call that has to change.
One rectangle. SheetImage.Anchor.absolute(x:y:width:height:) said what
CanvasRect (B.88) already says, and the Numbers reader and writer converted between the two by hand. The
case is now absolute(CanvasRect), and a pattern reads case .absolute(let frame).
One label per role. A sparkline's data range was data: in
addSparkline(_:data:at:) and dataRange: in both initialisers, and its cell was
location: in one of them. All three now say dataRange: and at:, and
addSparkline gains the typed twin every other add-method has (at: CellRef beside
at: String, B.62). A table placed by cell and a table placed by point were
addTable(named:anchor:) and addTable(named:at:); both are now at:, told apart by the
argument's type, the way addImage(_:at:) reads. IconSet.Icon.set held the same kind of value
IconSet.name holds; it is setName.
Swift types. A reading's span in PhoneticText.Run was two integers, start and
end (Excel's sb / eb). It is range: Range<Int> in UTF-16 code units,
made with Run(_:over:). A range cannot run backwards, so the reader clamps a file whose eb comes
before its sb instead of trapping on it. DataBar.isGradient read as "is a gradient", which a bar is
not; it is gradient, the attribute's own word, like percent and reverse beside it (B.64).
Two Bools B.64 missed. Alignment.wrapText and Alignment.shrinkToFit are
imperatives of the same kind as fitToPage, which B.64 renamed fitsToPage; they are
wrapsText and shrinksToFit, and the attribute names in the files are unchanged. A review reads the
names it is shown, so the rule is now a scan: APIContractTests reads every public declaration for an
imperative Bool, an abbreviated "column" and "A1" in a name, and it is what found these two.
Codec tools out of the model. Five members answered a codec's question, not a caller's, and become
package (B.67): CommentThread.mirrorPrefix (the text Excel writes into a thread's mirror note),
Chart.anchorOrFrameCells (a writer's step for a Numbers frame), Chart.Kind.drawable (the list behind
isDrawable), Shape.Geometry.presets (the 187 names behind isPreset) and
DataBar.usesExtension (the XLSX writer's question). The public predicates isDrawable and
isPreset stay.
Small repairs. A shape made with Shape(_:) and appended by hand now covers one cell
(.span(A1:A1)), so the XLSX and Numbers writers draw it at the same size; it used to borrow a picture's
.original, which a shape has no pixels for. Cell.init takes thread: like every other
extra. Assigning Theme.colors normalises to AARRGGBB, as the initialiser does.
CanvasPoint and CanvasRect are Codable like CellRef, and
PreservationSummary is Hashable. The warning a conversion returns for preserved parts it cannot
carry now names what they hold from the inventory of B.77 (…; what they hold: pivot 2, slicer 1) instead of
guessing "charts, drawings, VBA…"; the VBA project keeps its own .macros warning. The members added since
0.24.0 that had no documentation comment have one.
Considered and left. Placing a chart with the same Anchor enum as pictures and shapes would
remove Chart.frame and unify three types, but it changes the type of chart.anchor for every
caller that reads it as a range; B.88's two properties stay. Moving SheetImage.Anchor to a top-level name
buys a rename only, and the obvious name is already an internal type of the XLSX codec. ExternalLink keeps a
public initialiser although the workbook's list is read-only, so a caller can build one to compare against.
Verification. The whole suite; APIContractTests checks every old → new pair the
CHANGELOG announces against the code; the external-boundary scripts compile an outside client against the checkout.
Decided 2026-09-11. ReadOptions.cellLimit is documented as the most cells one document may expand to
before reading stops, and the README, SECURITY.md and the guides tell a reader of untrusted files to set it. Only
the ODS reader counted. B.41 recorded "the XLSX reader does not count (only ODS does) — not relevant now". That was
true of the reason the option exists (ODF's repeats describe billions of cells in a kilobyte), but not for anyone
who follows the documents: an XLSX or Numbers package inside the default package limits can still hold hundreds of
millions of cells, and the limit they set did nothing.
What counts. Every cell a whole-workbook read stores: each <c> of an XLSX sheet,
each Numbers cell with a value, a style, a note or a control, and each non-empty field of delimited text. ODS keeps
its own count of the cells its repeats materialise (B.9), unchanged. The row-by-row readers hold no cells, and the
limit still does not apply to them (B.40).
How. One budget per read, shared by the sheets parsed side by side — the "atomic counter" B.41
named. A sheet takes an allowance at a time under a lock — 4,096 cells, or a sixty-fourth of a smaller limit — so the
lock is taken once per allowance rather than once per cell, and a sheet gives back what it did not use when it
ends; with no limit set there is no budget at all and nothing is counted. When a
sheet can get no more, its reading stops where it is: the cells read so far stay, what follows them in the part is
not read, and a degraded warning names the sheet (for Numbers, the table). While sheets run side by
side one may still hold an allowance another could have used, so a read holds at most the limit, never more; one
sheet at a time, exactly the limit. Delimited text reads one sheet in one thread and counts without a lock.
Verification. A 600-cell sheet read with a limit of 100 holds 100 cells and says so, in XLSX,
Numbers and delimited text; a four-sheet workbook read side by side holds no more than the limit in total and names
every sheet it stopped; a limit equal to the count Workbook.inspect declares reads everything without a
warning.
Decided 2026-09-11. A review of the per-cell work left after B.39–B.42 found costs paid once per cell that answer the same question every time. Four readers of the code proposed candidates, one per hot path, and an adversarial reader checked each against the code before any was written; the ones below were confirmed, and every one leaves the bytes a writer produces as they were.
Cell.style says so), whose xf index exists from the start; the writer answered it by
hashing a 384-byte style per cell. The reader copied and compared a style per <c> before looking at
its cache; the cache now remembers the default indices too.Int before
Double, which accepts only what Double accepts and never a . or an exponent.<c>, read it back at
</c> and stored it again. It now keeps the open cell and stores it when it ends; a cell a
malformed file never closes is still stored as it was opened.Measured. 100 columns × 10,000 rows, the old build and the new build of the bench run alternately five times each on the same machine at the same time (medians; other work kept the load average near 8, which the alternation shares between the two):
| Operation | Time before → after | Peak memory before → after |
|---|---|---|
| write | 1.54 → 1.09 s (−29%) | 254 → 214 MB (−16%) |
| read | 2.61 → 1.85 s (−29%) | 216 → 216 MB |
| streaming read | 1.85 → 1.35 s (−27%) | 13.4 → 13.3 MB |
| streaming write | 1.84 → 1.51 s (−18%) | 10.8 → 10.7 MB |
| open, edit one cell, save | 3.87 → 2.59 s (−33%) | 255 → 211 MB (−17%) |
| build the model | 0.58 → 0.58 s | 203 → 203 MB |
Both builds computed the same sums. The published performance record still shows the 0.26.0 run; it is measured again, both tiers, on a quiet machine before the release.
ODS, CSV and Numbers. The same review of the other codecs, checked the same way:
| Operation | Time before → after | Peak memory before → after |
|---|---|---|
| write ODS | 3.39 → 2.25 s (−34%) | 216 → 216 MB |
| read ODS | 3.59 → 3.16 s (−12%) | 219 → 219 MB |
| streaming read ODS | 3.24 → 2.78 s (−14%) | 14.5 → 14.4 MB |
| streaming write ODS | 3.52 → 2.50 s (−29%) | 21.5 → 21.4 MB |
| write CSV | 0.88 → 0.65 s (−26%) | 238 → 238 MB |
| read CSV | 1.76 → 1.32 s (−25%) | 238 → 238 MB |
| streaming read CSV | 1.31 → 0.87 s (−33%) | 10.7 → 10.8 MB |
| streaming write CSV | 0.91 → 0.86 s (−6%) | 7.8 → 7.9 MB |
| write Numbers | 3.90 → 2.20 s (−44%) | 374 → 374 MB |
| read Numbers | 1.41 → 1.42 s (no change made) | 279 → 279 MB |
| streaming write Numbers | 3.57 → 3.07 s (−14%) | 25.3 → 25.1 MB |
Measured the same way as the XLSX table, against the build with the XLSX changes already in; the load average was near 6. Both builds computed the same sums.
The model's own cells. Every cell a reader or an append creates went through the public initialiser,
whose style: argument compared the default style with itself; the package-only Cell() and
Cell(value:) make the same cell without the comparison, and a test holds them equal to the public
initialiser's cells for every kind of value. Delimited text reserves its table for exactly the fields it has
parsed, so the dictionary is not copied at every doubling. Measured the same way, against the build with the other
codecs' changes in:
| Operation | Time before → after | Peak memory before → after |
|---|---|---|
| build the model | 0.57 → 0.47 s (−18%) | 203 → 203 MB |
| read CSV | 1.27 → 1.12 s (−12%) | 238 → 165 MB (−31%) |
| read Numbers | 1.34 → 1.24 s (−7%) | 279 → 279 MB |
| read ODS | 2.95 → 2.81 s (−5%) | 219 → 219 MB |
| read XLSX | 1.85 → 1.75 s (−6%) | 216 → 216 MB |
| streaming write XLSX | 1.44 → 1.35 s (−6%) | 11.0 → 10.9 MB |
| streaming write ODS | 2.31 → 2.33 s (no change) | 21.4 → 21.5 MB |
| streaming write Numbers | 3.24 → 3.13 s (−3%) | 25.2 → 25.2 MB |
| streaming write CSV | 0.88 → 0.82 s (−7%) | 7.8 → 7.8 MB |
Left. Building each cell's XML by appending instead of interpolating (confirmed, but it needs a
fixed test of every value kind first); formatting a Decimal from its mantissa (not shown to cost enough);
one pass over the cells instead of four before a sheet is written (small); reserving an XLSX sheet's cell dictionary from its
declared dimension (a sparse sheet would reserve several times what it needs; a projection from the bytes read so
far is the safe form); keeping the Numbers writer's records in one buffer per row instead
of a Data per cell (about 85 bytes a cell, but it touches every use of the records); and writing a
non-integral decimal128 without the long division.
Decided 2026-09-12. Before the release candidate, the two applications that depend on the library — one reads and writes work-breakdown workbooks, one compares tables — moved from 0.19.1 and 0.18.0 to 0.27.0. It was the first use of the surface 1.0 freezes by code written for its own purposes rather than for this library's tests, and it reported thirteen points of friction. Six are acted on; the rest are kept, with the reasons below.
sheet[0, 1] was nil, rowDimension(0) a
default, and CellRef.columnName(0) an empty string, which put $$2:$$4 into a formula. The
integer subscripts compile unchanged across B.61, so a missed - 1 became an empty value, and a test
comparing two empty values passed. The integer and CellRef subscripts, cell(_:),
rowDimension(_:) and columnDimension(_:) (and so the setters built on them) and
CellRef.columnName(_:) now stop on a number below 1. A cell named by A1 text that does not parse still
answers nil (B.53), and columnName(validating:) stays the form that answers nil. The library formats
addresses through a package function that does not stop, so a malformed file still never stops the process (§12),
and an invalid CellRef built by a caller can still be printed in a message.sheet[row: r, column: c] would make every caller rewrite again, and two spellings of one
subscript would break B.62–B.67. The stop above catches the same mistake the first time the line runs.ConversionWarning.Kind.truncated:
reading stopped at a limit the caller set, and what follows is absent. The four readers that count (B.90) give it
with the subject sheets. Before, XLSX, Numbers and delimited text gave degraded with
sheets — the pair a sheet left unread also gives — and ODS degraded with no subject, so only
the English message told them apart, and a caller refusing oversized files had to match its wording. A new case
breaks an exhaustive switch over Kind; the library has none, and the CHANGELOG says so.SourceInfo.isVerifiedVersion
(Bool?): true or false when the document declares a version the reader can parse, nil otherwise and for
every other format. It is on WorkbookSummary.producer as well as Workbook.sourceInfo, so a
row-by-row reader, which has no warnings, asks inspect first. A caller used to copy the internal range
11…15 and match it by hand at every release. The warning a whole-workbook read gives for a newer version is
unchanged.CodecSet.streamingReader(contentsOf:format:limits:csv:) and
StreamingReader(contentsOf:format:limits:csv:), as the forms over bytes already had: a given format
skips detection, and a compound file (an encrypted package, a legacy .xls) is still refused by name. A Numbers
document saved as a folder accepts only .numbers. The SheetDecrypt form with a password is unchanged.CivilDate(serial:epoch:) is the day
CellValue(serial:epoch:) gives, and nil where that is not a date: a time of day ([0, 1)), NaN or an
infinity. A caller wanting the day took three steps through CellValue.tableNames(inSheet:) answering
[String?] where the Numbers reader answered [String].Found on the way. The ODS writer's warning for a filter it drops named the column from the filter's offset (B.61 made it an offset, not a coordinate): a filter on the first column of its range read "the filter on column " and one on the second column of C1:E9 said "A". It names the sheet's column.
Kept. Renamed declarations are not kept as @available(*, unavailable, renamed:): they
would offer a fix-it, and they would also keep every old name in the surface 1.0 freezes; the migration guide maps
each compile error to its new name. stringValue keeps its name, and its documentation already says it
spells a value as Python's str() does. StreamingWriter.close() still returns a result that
must be used (B.51). SheetFormat.detect(_:filename:) keeps no first label, the shape of
probe.
Verification. Each stop runs in its own process (processExitsWith:): both subscripts,
cell(_:), the dimension lookups, setWidth(_:ofColumn:) and columnName(_:) with 0,
while "A0" read as text still answers nil. The cell-limit test reads XLSX, ODS, Numbers and delimited text and finds
one truncated warning naming the sheet. A Numbers fixture inspects as verified, an XLSX as nil, and the
version parser answers nil for text it cannot read. A CSV file named rows.bin is walked with
format: .csv, and legacy.xls given as XLSX is still refused by name.
CivilDate(serial:epoch:) agrees with CellValue(serial:epoch:) across both epochs, the phantom
1900-02-29 and the time-of-day range. The ODS warning names column C for a filter on the first column of C1:E9.
Decided 2026-09-12. Measured again with Swift 6.4, the row-by-row read of ten million XLSX cells peaked at 35 MB where the record of 2026-09-05 said 19 MB. Old and new builds run alternately on the same machine ruled out the compiler (Swift 6.3.3 and 6.4: 35.0 and 34.8 MB) and the changes after 0.27.0 (34.7 and 34.8 MB). A bisection of the 92 commits between the record and 0.27.0 put the step at the commit that brought furigana (B.70): the commit before it read at 19.3 MB, that commit at 35 MB.
Why. The shared-string parser appended a phonetic collector for every entry, nil
where the string has no furigana. An optional struct is as large as the struct — about 80 bytes here — so a table of
100,052 strings held some 9 MB of empty slots, and the array's growth past 65,536 entries briefly held the old buffer
and the new one together: about 17 MB at the peak. The row-by-row reader never reads the phonetics; it built the
slots and let them go. At a million cells the table holds about ten thousand strings, and the cost stayed inside the
record's noise.
What. The parser keeps a collector only for an entry that carries furigana, by its index, and
resolvedPhonetics(fonts:) answers an empty array when there are none — the form the sheet parser and the
writer already read as "no phonetic guide". A table with furigana resolves to the same array as before, aligned with
the strings. Nothing a reader returns or a writer produces changes.
Verification. A table without furigana resolves to no slots; a table with furigana on its second entry resolves to three, the reading on the second; the furigana suite is unchanged. The same change, measured before it was written — main and main with it, alternately, three times each — read the ten-million-cell file at 34.8 and 20.6 MB with the same checksum. As committed, the old and the new build alternately five times each: 34.7 and 19.6 MB in the same 10.2 s; the whole-model read of the same file stayed within its run-to-run range.
Decided 2026-09-13. Measured alternately on the same machine, the whole-model read of ten million ODS cells peaked at 1,679 MB where the commit of the 2026-09-05 record peaked at 1,419 MB, in less time (26.9 against 35.5 s). The other seven whole-model operations stayed within their run-to-run ranges, so the growth between the two records was the machine, not the code, except here. A bisection put the step at the commit that brought threaded comments (B.80): 1,161 MB before it, 1,643 MB with it. The cell did not grow — it is 24 bytes at every commit measured, the thread living behind the extras reference.
Why. That commit made the external-link scan of B.78 walk each table in document order, so the
numbering of the linked documents is stable, by sorting table.cells: an array of every reference and
cell of the sheet — ten million of them — built to find the few formulas that name another document.
What. The scan first keeps the formula cells whose text names another document
('#), then sorts those by reference. The order, and so the numbering, is the same; ExternalLinkTests holds
it with two linked documents.
Verification. The change measured before it was written, main and main with it alternately three times each: 26.9 s at 1,624 MB and 25.2 s at 1,361 MB, the row-by-row ODS read unchanged at 15 MB. As committed, the old and the new build alternately five times each: 27.5 s at 1,339 MB and 25.3 s at 1,091 MB, the two ranges apart; the row-by-row read 14.6 MB in both.