--- title: "Excel date systems and number formats" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Excel date systems and number formats} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") ``` ```{r setup} library(unexcel) ``` This vignette is the reference behind `unexcel`'s decisions: what a serial is, why there are two of them, and how a workbook records which cells are dates. ## Serials A spreadsheet stores a date as the number of days since an origin. The stored value is a plain number; the calendar appearance comes entirely from the cell's number format. Change the format to `General` and the date shows its serial. That is why date auto-conversion is destructive in a specific way: the typed text `30.3` is gone, replaced by `45746`, and only the formatting records that the number is meant to be read as a date. ## Two origins There are two systems in circulation. The **1900 system** is what Excel writes on every current platform. Serial 1 is 1900-01-01. Excel also carries 1900-02-29 — a date that never existed, retained for compatibility with Lotus 1-2-3, which had the bug first. R has no such day, so an origin of 1899-12-30 absorbs the extra day and reproduces what Excel displays for every serial from 61 onward: ```{r} excel_origin("1900") as.Date(45746, origin = excel_origin("1900")) ``` The **1904 system** came from early Macintosh Excel, which sidestepped the Lotus bug by starting later. Serial 0 is 1904-01-01. Workbooks created in it still circulate, and the option survives in current Excel: ```{r} excel_origin("1904") ``` The two are 1462 days apart — four years and a day: ```{r} as.numeric(excel_origin("1904") - excel_origin("1900")) ``` That gap is the reason to read the date system rather than guess it. Both readings of a serial are plausible dates in living memory, so no test of reasonableness separates them: ```{r} serial_to_day_month(44284, date_system = "1900") serial_to_day_month(44284, date_system = "1904") ``` Both answers look entirely ordinary. Only the workbook knows which is right, and it says so in the `date1904` attribute of `xl/workbook.xml`: ```{r} excel_date_system(system.file("extdata", "legacy-1904.xlsx", package = "unexcel")) ``` ## Number formats `xl/styles.xml` holds a table of cell formats (`cellXfs`); each cell's `s` attribute indexes into it, and each entry names a number format id. Ids 14 to 22 and 45 to 47 are Excel's built-in date and time formats; ids from 164 up are custom, and the workbook stores their format codes alongside. `unexcel` treats a cell as a date when its format is one of the built-in *date* formats — the time-only ones, 18 to 21 and 45 to 47, are excluded, since a duration is not a mistyped `day.month` — or when a custom format code contains date tokens. Quoted literals, escaped characters and bracketed sections (`[Red]`, `[<100]`, `[$-409]`) are stripped first, so a currency format reading `"day "0.0` is not mistaken for a date, and only the positive-number section of a multi-part code is considered. The remaining ambiguity is `m`, which means months in `d/m/yyyy` and minutes in `h:mm`. It is read as a month unless hours or seconds appear alongside. ```{r} path <- system.file("extdata", "typed-numbers.xlsx", package = "unexcel") excel_date_columns(path) ``` The same format code also settles the field order. A `d/m/yyyy` column restores day-first, an `mm-dd-yy` column month-first, and neither has to be assumed: ```{r} unexcel_xlsx(path)[, c("dose", "visit")] ``` ## What is still lost Reading the file recovers the number that was typed, not the keystrokes. A value typed as `3.10` becomes the 3rd of October, which restores to the number `3.1`. The trailing zero is not recoverable numerically — the file records a day and a month, and `10` is the month either way. `output = "character"` keeps the printed form instead: ```{r} unexcel_xlsx(path, output = "character")$dose ``` A value that Excel converted to a date *and* that carried more than two components — `30.3.2025` — leaves the `day.month` idea behind entirely; the restored value is still `30.3`, and the year is discarded by design. Finally, if a column was auto-converted and then re-formatted as `General` by a later editor, the workbook no longer describes it as a date. The serial is still there, but the evidence is not, and `restore_day_month()` with its guardrails is all that remains. ## Further reading - ECMA-376 Part 1, §18.8.30 (`numFmt`) — the number format grammar. - Microsoft, "Differences between the 1900 and the 1904 date system in Excel". - Microsoft, "Excel incorrectly assumes that the year 1900 is a leap year".