Spreadsheets silently turn entries such as '30.3' into dates, so the imported data carry date serials instead of the numbers that were typed. Reading the workbook directly recovers those numbers without guesswork: an 'xlsx' file states its own date system, and records which cells are formatted as dates and in which field order, so the values to repair are identified from the file rather than inferred from their magnitude. Functions are also provided for data already imported, where that evidence is no longer available, using conservative and configurable detection.

Type 30.3 into a spreadsheet in a day-first locale and it is not
stored as the number 30.3. The application decides you meant the 30th of
March and stores a day count instead. Import the file and the column
arrives as 45746.
unexcel gets the typed numbers back — and gets them back from the
file rather than from a guess. An .xlsx workbook states its own date
system and records which cells are formatted as dates and in which field
order, so the values to repair are read out of the file, not inferred
from their magnitude.
Install the released version from CRAN:
install.packages("unexcel")
Or the development version from GitHub:
# install.packages("remotes")
remotes::install_github("drhrf/unexcel")
The example workbook holds five doses typed as 30.3, 15.6, 3.10,
1.12 and 28.2. This is what the spreadsheet stored:
library(unexcel)
path <- system.file("extdata", "typed-numbers.xlsx", package = "unexcel")
unexcel_xlsx(path, restore = FALSE)
#> sample dose weight reading visit
#> 1 S1 2025-03-30 70.5 45000 2025-01-07
#> 2 S2 2025-06-15 62.1 45120 2025-02-11
#> 3 S3 2025-10-03 80.0 45230 2025-03-04
#> 4 S4 2025-12-01 55.4 45310 2025-04-09
#> 5 S5 2025-02-28 91.2 45400 2025-05-06
And this is what was typed:
unexcel_xlsx(path)
#> sample dose weight reading visit
#> 1 S1 30.30 70.5 45000 1.70
#> 2 S2 15.60 62.1 45120 2.11
#> 3 S3 3.10 80.0 45230 3.40
#> 4 S4 1.12 55.4 45310 4.90
#> 5 S5 28.20 91.2 45400 5.60
Look at reading. Its values sit squarely inside the range of plausible
date serials — closer to a real serial than some of the dose values
are. It was left alone anyway, because the workbook does not format it
as a date. And visit restored month-first, because its format code is
mm-dd-yy.
Neither decision is available from the imported numbers. Both are read from the file:
excel_date_system(path) # from xl/workbook.xml, not inferred from magnitude
#> [1] "1900"
excel_date_columns(path) # from xl/styles.xml, not inferred from range
#> col name n_date n_values prop_date field_order format_code
#> 1 2 dose 5 5 1 dm d/m/yyyy
#> 2 5 visit 5 5 1 md mm-dd-yy
excel_date_cells() is the audit trail — every value that would change,
with the serial it came from and the format code that justifies changing
it:
head(excel_date_cells(path), 4)
#> ref row col serial date num_fmt_id format_code field_order day_month
#> 1 B2 2 2 45746 2025-03-30 164 d/m/yyyy dm 30.30
#> 2 E2 2 5 45664 2025-01-07 14 mm-dd-yy md 1.70
#> 3 B3 3 2 45823 2025-06-15 164 d/m/yyyy dm 15.60
#> 4 E3 3 5 45699 2025-02-11 14 mm-dd-yy md 2.11
Excel has two date origins, 1462 days apart. Both readings of a serial land on an ordinary date in living memory, so no test of reasonableness separates them:
serial_to_day_month(44284, date_system = "1900")
#> [1] 29.3
serial_to_day_month(44284, date_system = "1904")
#> [1] 30.3
Only the workbook knows which is right, and it says so.
CSV exports and inherited data frames carry no formatting.
restore_day_month() works from the values alone, behind three
deliberately conservative guardrails — whole number, plausible serial
range, resolved year inside a window:
restore_day_month(c(45746, 12.5, 45823))
#> [1] 30.3 12.5 15.6
df <- data.frame(dose = c(45746, 45823), weight = c(70.5, 62.1))
fix_serial_columns(df)
#> dose weight
#> 1 30.3 70.5
#> 2 15.6 62.1
Anything you do know should be supplied rather than left to a default:
restore_day_month(44284, date_system = "1904")
#> [1] 30.3
restore_day_month(45746, order = "md")
#> [1] 3.3
fix_serial_columns(df, cols = "dose") # name the columns, skip the scan
#> dose weight
#> 1 30.3 70.5
#> 2 15.6 62.1
excel_date_cells() shows every change with the
format code behind it, before anything is changed.restore_day_month() and
fix_serial_columns() still work when only the imported values
survive, and say plainly what they are assuming.date1904 rather
than guessed from how recent the dates look.xml2) beyond base R.vignette("getting-started") — a tour of the package.vignette("excel-date-systems") — serials, the 1900 leap-year bug,
and how number formats are read.